use std::time::Duration; use tokio::sync::watch; use tracing::{info, warn}; use csm_protocol::{Frame, MessageType, ClipboardRule, ClipboardViolationPayload}; /// Clipboard control configuration pushed from server #[derive(Debug, Clone, Default)] pub struct ClipboardControlConfig { pub enabled: bool, pub rules: Vec, } /// Start the clipboard control plugin. /// Periodically checks clipboard content against rules and reports violations. pub async fn start( mut config_rx: watch::Receiver, data_tx: tokio::sync::mpsc::Sender, device_uid: String, ) { info!("Clipboard control plugin started"); let mut config = ClipboardControlConfig::default(); let mut check_interval = tokio::time::interval(Duration::from_secs(2)); check_interval.tick().await; loop { tokio::select! { result = config_rx.changed() => { if result.is_err() { break; } let new_config = config_rx.borrow_and_update().clone(); info!( "Clipboard control config updated: enabled={}, rules={}", new_config.enabled, new_config.rules.len() ); config = new_config; } _ = check_interval.tick() => { if !config.enabled || config.rules.is_empty() { continue; } let uid = device_uid.clone(); let rules = config.rules.clone(); let result = tokio::task::spawn_blocking(move || check_clipboard(&uid, &rules)).await; match result { Ok(Some(payload)) => { if let Ok(frame) = Frame::new_json(MessageType::ClipboardViolation, &payload) { if data_tx.send(frame).await.is_err() { warn!("Failed to send clipboard violation: channel closed"); return; } } } Ok(None) => {} Err(e) => warn!("Clipboard check task failed: {}", e), } } } } } /// Check clipboard content against rules. Returns a violation payload if a rule matched. fn check_clipboard(device_uid: &str, rules: &[ClipboardRule]) -> Option { #[cfg(target_os = "windows")] { let clipboard_text = get_clipboard_text(); let foreground_process = get_foreground_process(); for rule in rules { if rule.rule_type != "block" { continue; } // Check direction — only interested in "out" or "both" if !matches!(rule.direction.as_str(), "out" | "both") { continue; } // Check source process filter if let Some(ref src_pattern) = rule.source_process { if let Some(ref fg_proc) = foreground_process { if !pattern_match(src_pattern, fg_proc) { continue; } } else { continue; } } // Check content pattern if let Some(ref content_pattern) = rule.content_pattern { if let Some(ref text) = clipboard_text { if !content_matches(content_pattern, text) { continue; } } else { continue; } } // Rule matched — generate violation (never send raw content) let preview = clipboard_text.as_ref().map(|t| format!("[{} chars]", t.len())); // Clear clipboard to enforce block let _ = std::process::Command::new("powershell") .args(["-NoProfile", "-NonInteractive", "-Command", "Set-Clipboard -Value ''"]) .output(); info!("Clipboard blocked: rule_id={}", rule.id); return Some(ClipboardViolationPayload { device_uid: device_uid.to_string(), source_process: foreground_process, target_process: None, content_preview: preview, action_taken: "blocked".to_string(), timestamp: chrono::Utc::now().to_rfc3339(), }); } None } #[cfg(not(target_os = "windows"))] { let _ = (device_uid, rules); None } } #[cfg(target_os = "windows")] fn get_clipboard_text() -> Option { let output = std::process::Command::new("powershell") .args(["-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"]) .output() .ok()?; if !output.status.success() { return None; } let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); if text.is_empty() { None } else { Some(text) } } #[cfg(target_os = "windows")] fn get_foreground_process() -> Option { let output = std::process::Command::new("powershell") .args([ "-NoProfile", "-NonInteractive", "-Command", r#"Add-Type @" using System; using System.Runtime.InteropServices; public class WinAPI { [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); } "@ $hwnd = [WinAPI]::GetForegroundWindow() $pid = 0 [WinAPI]::GetWindowThreadProcessId($hwnd, [ref]$pid) | Out-Null if ($pid -gt 0) { (Get-Process -Id $pid -ErrorAction SilentlyContinue).ProcessName } else { "" }"#, ]) .output() .ok()?; if !output.status.success() { return None; } let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); if name.is_empty() { None } else { Some(name) } } /// Simple case-insensitive wildcard pattern matching. Supports `*` as wildcard. fn pattern_match(pattern: &str, text: &str) -> bool { let p = pattern.to_lowercase(); let t = text.to_lowercase(); if !p.contains('*') { return t.contains(&p); } let parts: Vec<&str> = p.split('*').collect(); if parts.is_empty() { return true; } let mut pos = 0usize; let mut matched_any = false; for (i, part) in parts.iter().enumerate() { if part.is_empty() { continue; } matched_any = true; if i == 0 && !parts[0].is_empty() { if !t.starts_with(part) { return false; } pos = part.len(); } else { match t[pos..].find(part) { Some(idx) => pos += idx + part.len(), None => return false, } } } if matched_any && !parts.last().map_or(true, |p| p.is_empty()) { return t.ends_with(parts.last().unwrap()); } true } fn content_matches(pattern: &str, text: &str) -> bool { text.to_lowercase().contains(&pattern.to_lowercase()) }