lamco_clipboard_core/
loop_detector.rs

1//! Loop detection for clipboard synchronization.
2//!
3//! Prevents clipboard sync loops by tracking format and content hashes.
4
5use sha2::{Digest, Sha256};
6use std::collections::VecDeque;
7use std::time::{Duration, Instant};
8
9use crate::ClipboardFormat;
10
11/// Configuration for loop detection
12#[derive(Debug, Clone)]
13pub struct LoopDetectionConfig {
14    /// Time window for detecting loops (default: 500ms)
15    pub window_ms: u64,
16
17    /// Maximum number of operations to track
18    pub max_history: usize,
19
20    /// Enable content hashing for deduplication
21    pub enable_content_hashing: bool,
22
23    /// Optional rate limit in milliseconds (default: None)
24    ///
25    /// When set, sync operations are throttled to at most one per `rate_limit_ms`.
26    /// This provides belt-and-suspenders protection against rapid clipboard updates
27    /// even when loop detection passes.
28    pub rate_limit_ms: Option<u64>,
29}
30
31impl Default for LoopDetectionConfig {
32    fn default() -> Self {
33        Self {
34            window_ms: 500,
35            max_history: 10,
36            enable_content_hashing: true,
37            rate_limit_ms: None,
38        }
39    }
40}
41
42impl LoopDetectionConfig {
43    /// Create config with rate limiting enabled
44    ///
45    /// # Example
46    ///
47    /// ```rust
48    /// use lamco_clipboard_core::LoopDetectionConfig;
49    ///
50    /// let config = LoopDetectionConfig::with_rate_limit(200);
51    /// assert_eq!(config.rate_limit_ms, Some(200));
52    /// ```
53    pub fn with_rate_limit(rate_limit_ms: u64) -> Self {
54        Self {
55            rate_limit_ms: Some(rate_limit_ms),
56            ..Default::default()
57        }
58    }
59}
60
61/// Source of a clipboard operation
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ClipboardSource {
64    /// Operation from RDP client
65    Rdp,
66    /// Operation from local clipboard (Portal, X11, etc.)
67    Local,
68}
69
70impl ClipboardSource {
71    /// Get the opposite source
72    pub fn opposite(self) -> Self {
73        match self {
74            Self::Rdp => Self::Local,
75            Self::Local => Self::Rdp,
76        }
77    }
78}
79
80/// A recorded clipboard operation for loop detection
81#[derive(Debug, Clone)]
82struct ClipboardOperation {
83    /// Hash of the operation (formats or content)
84    hash: String,
85    /// Source of the operation
86    source: ClipboardSource,
87    /// When the operation occurred
88    timestamp: Instant,
89}
90
91/// Detects and prevents clipboard synchronization loops.
92///
93/// # How It Works
94///
95/// When clipboard content is copied, the same content often triggers events
96/// on both ends (RDP and local). Without loop detection, this causes:
97///
98/// 1. User copies on Windows → RDP sends to Linux
99/// 2. Linux clipboard updates → Signal sent to sync back
100/// 3. Windows clipboard updates → RDP sends to Linux again
101/// 4. ... infinite loop
102///
103/// The `LoopDetector` prevents this by:
104///
105/// 1. **Format hashing**: Hashes the list of formats/MIME types
106/// 2. **Content hashing**: Hashes actual clipboard content (optional)
107/// 3. **Time windowing**: Only detects loops within a configurable time window
108/// 4. **Source tracking**: Distinguishes RDP vs local operations
109/// 5. **Rate limiting**: Optional throttle to prevent rapid sync storms
110///
111/// # Example
112///
113/// ```rust
114/// use lamco_clipboard_core::{LoopDetector, ClipboardFormat};
115/// use lamco_clipboard_core::loop_detector::ClipboardSource;
116///
117/// let mut detector = LoopDetector::new();
118///
119/// // Record an RDP operation
120/// let formats = vec![ClipboardFormat::unicode_text()];
121/// detector.record_formats(&formats, ClipboardSource::Rdp);
122///
123/// // Check if a local operation would cause a loop
124/// if detector.would_cause_loop(&formats) {
125///     println!("Loop detected, skipping sync");
126/// }
127/// ```
128#[derive(Debug)]
129pub struct LoopDetector {
130    /// Configuration
131    config: LoopDetectionConfig,
132
133    /// Recent format operations
134    format_history: VecDeque<ClipboardOperation>,
135
136    /// Recent content hashes
137    content_history: VecDeque<ClipboardOperation>,
138
139    /// Last sync time for rate limiting (per source)
140    last_sync_rdp: Option<Instant>,
141    last_sync_local: Option<Instant>,
142}
143
144impl Default for LoopDetector {
145    fn default() -> Self {
146        Self::new()
147    }
148}
149
150impl LoopDetector {
151    /// Create a new loop detector with default configuration
152    pub fn new() -> Self {
153        Self::with_config(LoopDetectionConfig::default())
154    }
155
156    /// Create a new loop detector with custom configuration
157    pub fn with_config(config: LoopDetectionConfig) -> Self {
158        Self {
159            config,
160            format_history: VecDeque::new(),
161            content_history: VecDeque::new(),
162            last_sync_rdp: None,
163            last_sync_local: None,
164        }
165    }
166
167    /// Record a format list operation
168    pub fn record_formats(&mut self, formats: &[ClipboardFormat], source: ClipboardSource) {
169        let hash = Self::hash_formats(formats);
170        self.record_operation(&mut self.format_history.clone(), hash, source);
171        // Need to work around borrow checker
172        let hash = Self::hash_formats(formats);
173        self.format_history.push_back(ClipboardOperation {
174            hash,
175            source,
176            timestamp: Instant::now(),
177        });
178        self.cleanup_history();
179    }
180
181    /// Record a MIME type list operation
182    pub fn record_mime_types(&mut self, mime_types: &[String], source: ClipboardSource) {
183        let hash = Self::hash_mime_types(mime_types);
184        self.format_history.push_back(ClipboardOperation {
185            hash,
186            source,
187            timestamp: Instant::now(),
188        });
189        self.cleanup_history();
190    }
191
192    /// Record content data for deduplication
193    pub fn record_content(&mut self, data: &[u8], source: ClipboardSource) {
194        if !self.config.enable_content_hashing {
195            return;
196        }
197
198        let hash = Self::hash_content(data);
199        self.content_history.push_back(ClipboardOperation {
200            hash,
201            source,
202            timestamp: Instant::now(),
203        });
204        self.cleanup_history();
205    }
206
207    /// Check if syncing these formats would cause a loop
208    ///
209    /// Returns true if a recent operation from the opposite source
210    /// had the same format hash.
211    pub fn would_cause_loop(&self, formats: &[ClipboardFormat]) -> bool {
212        let hash = Self::hash_formats(formats);
213        self.check_hash_collision(&self.format_history, &hash, ClipboardSource::Local)
214    }
215
216    /// Check if syncing these MIME types would cause a loop
217    pub fn would_cause_loop_mime(&self, mime_types: &[String]) -> bool {
218        let hash = Self::hash_mime_types(mime_types);
219        self.check_hash_collision(&self.format_history, &hash, ClipboardSource::Rdp)
220    }
221
222    /// Check if this content would cause a loop
223    pub fn would_cause_content_loop(&self, data: &[u8], source: ClipboardSource) -> bool {
224        if !self.config.enable_content_hashing {
225            return false;
226        }
227
228        let hash = Self::hash_content(data);
229        self.check_hash_collision(&self.content_history, &hash, source)
230    }
231
232    /// Compute hash for deduplication of arbitrary data
233    pub fn compute_hash(data: &[u8]) -> String {
234        Self::hash_content(data)
235    }
236
237    /// Clear all history
238    pub fn clear(&mut self) {
239        self.format_history.clear();
240        self.content_history.clear();
241        self.last_sync_rdp = None;
242        self.last_sync_local = None;
243    }
244
245    /// Check if sync is rate limited for the given source
246    ///
247    /// Returns true if a sync was performed too recently and should be skipped.
248    /// This is only active when `rate_limit_ms` is configured.
249    ///
250    /// # Example
251    ///
252    /// ```rust
253    /// use lamco_clipboard_core::{LoopDetector, LoopDetectionConfig};
254    /// use lamco_clipboard_core::loop_detector::ClipboardSource;
255    ///
256    /// let config = LoopDetectionConfig::with_rate_limit(200);
257    /// let mut detector = LoopDetector::with_config(config);
258    ///
259    /// // First sync is not rate limited
260    /// assert!(!detector.is_rate_limited(ClipboardSource::Rdp));
261    ///
262    /// // Record that we synced
263    /// detector.record_sync(ClipboardSource::Rdp);
264    ///
265    /// // Immediate second sync would be rate limited
266    /// assert!(detector.is_rate_limited(ClipboardSource::Rdp));
267    /// ```
268    pub fn is_rate_limited(&self, source: ClipboardSource) -> bool {
269        let Some(rate_limit_ms) = self.config.rate_limit_ms else {
270            return false;
271        };
272
273        let last_sync = match source {
274            ClipboardSource::Rdp => self.last_sync_rdp,
275            ClipboardSource::Local => self.last_sync_local,
276        };
277
278        let Some(last) = last_sync else {
279            return false;
280        };
281
282        let elapsed = last.elapsed();
283        elapsed < Duration::from_millis(rate_limit_ms)
284    }
285
286    /// Record that a sync operation was performed
287    ///
288    /// Call this after successfully syncing clipboard data to update
289    /// the rate limiting timestamp.
290    pub fn record_sync(&mut self, source: ClipboardSource) {
291        let now = Instant::now();
292        match source {
293            ClipboardSource::Rdp => self.last_sync_rdp = Some(now),
294            ClipboardSource::Local => self.last_sync_local = Some(now),
295        }
296    }
297
298    /// Combined check: would cause loop OR is rate limited
299    ///
300    /// Convenience method that checks both conditions. Returns true if
301    /// the sync should be skipped for any reason.
302    pub fn should_skip_sync(&self, formats: &[ClipboardFormat], source: ClipboardSource) -> bool {
303        if self.is_rate_limited(source) {
304            tracing::debug!("Sync skipped: rate limited for {:?}", source);
305            return true;
306        }
307
308        let would_loop = match source {
309            ClipboardSource::Rdp => self.would_cause_loop(formats),
310            ClipboardSource::Local => self.would_cause_loop(formats),
311        };
312
313        if would_loop {
314            tracing::debug!("Sync skipped: would cause loop");
315        }
316
317        would_loop
318    }
319
320    /// Combined check for MIME types: would cause loop OR is rate limited
321    pub fn should_skip_sync_mime(&self, mime_types: &[String], source: ClipboardSource) -> bool {
322        if self.is_rate_limited(source) {
323            tracing::debug!("Sync skipped: rate limited for {:?}", source);
324            return true;
325        }
326
327        let would_loop = self.would_cause_loop_mime(mime_types);
328
329        if would_loop {
330            tracing::debug!("Sync skipped: would cause loop");
331        }
332
333        would_loop
334    }
335
336    // =========================================================================
337    // Private Methods
338    // =========================================================================
339
340    fn check_hash_collision(
341        &self,
342        history: &VecDeque<ClipboardOperation>,
343        hash: &str,
344        current_source: ClipboardSource,
345    ) -> bool {
346        let window = Duration::from_millis(self.config.window_ms);
347        let now = Instant::now();
348
349        for op in history.iter().rev() {
350            // Only check recent operations
351            if now.duration_since(op.timestamp) > window {
352                break;
353            }
354
355            // Only detect loops from the opposite source
356            if op.source == current_source.opposite() && op.hash == hash {
357                return true;
358            }
359        }
360
361        false
362    }
363
364    fn record_operation(&mut self, history: &mut VecDeque<ClipboardOperation>, hash: String, source: ClipboardSource) {
365        history.push_back(ClipboardOperation {
366            hash,
367            source,
368            timestamp: Instant::now(),
369        });
370    }
371
372    fn cleanup_history(&mut self) {
373        let window = Duration::from_millis(self.config.window_ms * 2);
374        let now = Instant::now();
375
376        // Remove old entries
377        while let Some(front) = self.format_history.front() {
378            if now.duration_since(front.timestamp) > window {
379                self.format_history.pop_front();
380            } else {
381                break;
382            }
383        }
384
385        while let Some(front) = self.content_history.front() {
386            if now.duration_since(front.timestamp) > window {
387                self.content_history.pop_front();
388            } else {
389                break;
390            }
391        }
392
393        // Enforce max history size
394        while self.format_history.len() > self.config.max_history {
395            self.format_history.pop_front();
396        }
397
398        while self.content_history.len() > self.config.max_history {
399            self.content_history.pop_front();
400        }
401    }
402
403    fn hash_formats(formats: &[ClipboardFormat]) -> String {
404        let mut hasher = Sha256::new();
405        for format in formats {
406            hasher.update(format.id.to_le_bytes());
407            if let Some(name) = &format.name {
408                hasher.update(name.as_bytes());
409            }
410        }
411        format!("{:x}", hasher.finalize())
412    }
413
414    fn hash_mime_types(mime_types: &[String]) -> String {
415        let mut hasher = Sha256::new();
416        for mime in mime_types {
417            hasher.update(mime.as_bytes());
418            hasher.update(b"\0");
419        }
420        format!("{:x}", hasher.finalize())
421    }
422
423    fn hash_content(data: &[u8]) -> String {
424        let mut hasher = Sha256::new();
425        hasher.update(data);
426        format!("{:x}", hasher.finalize())
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn test_no_loop_different_formats() {
436        let mut detector = LoopDetector::new();
437
438        let formats1 = vec![ClipboardFormat::unicode_text()];
439        let formats2 = vec![ClipboardFormat::html()];
440
441        detector.record_formats(&formats1, ClipboardSource::Rdp);
442        assert!(!detector.would_cause_loop(&formats2));
443    }
444
445    #[test]
446    fn test_loop_same_formats() {
447        let mut detector = LoopDetector::new();
448
449        let formats = vec![ClipboardFormat::unicode_text()];
450
451        detector.record_formats(&formats, ClipboardSource::Rdp);
452        assert!(detector.would_cause_loop(&formats));
453    }
454
455    #[test]
456    fn test_no_loop_same_source() {
457        let mut detector = LoopDetector::new();
458
459        let formats = vec![ClipboardFormat::unicode_text()];
460
461        // Record from Local
462        detector.record_formats(&formats, ClipboardSource::Local);
463
464        // Check would_cause_loop checks against RDP source, so same formats from Local
465        // shouldn't trigger (opposite source check)
466        // Actually would_cause_loop always checks against Local source
467        // So this should NOT trigger because we recorded from Local, checking Local
468        // Hmm, the check is: op.source == current_source.opposite()
469        // would_cause_loop uses ClipboardSource::Local as current_source
470        // So it checks if op.source == Local.opposite() == Rdp
471        // We recorded from Local, so op.source == Local != Rdp
472        // So this should NOT detect a loop - correct!
473        assert!(!detector.would_cause_loop(&formats));
474    }
475
476    #[test]
477    fn test_content_hash() {
478        let mut detector = LoopDetector::new();
479
480        let data = b"Hello, World!";
481        detector.record_content(data, ClipboardSource::Rdp);
482
483        assert!(detector.would_cause_content_loop(data, ClipboardSource::Local));
484        assert!(!detector.would_cause_content_loop(b"Different", ClipboardSource::Local));
485    }
486
487    #[test]
488    fn test_clear_history() {
489        let mut detector = LoopDetector::new();
490
491        let formats = vec![ClipboardFormat::unicode_text()];
492        detector.record_formats(&formats, ClipboardSource::Rdp);
493
494        detector.clear();
495
496        assert!(!detector.would_cause_loop(&formats));
497    }
498
499    #[test]
500    fn test_compute_hash() {
501        let hash1 = LoopDetector::compute_hash(b"test");
502        let hash2 = LoopDetector::compute_hash(b"test");
503        let hash3 = LoopDetector::compute_hash(b"different");
504
505        assert_eq!(hash1, hash2);
506        assert_ne!(hash1, hash3);
507    }
508
509    #[test]
510    fn test_rate_limit_disabled_by_default() {
511        let detector = LoopDetector::new();
512
513        // Without rate limiting, should never be rate limited
514        assert!(!detector.is_rate_limited(ClipboardSource::Rdp));
515        assert!(!detector.is_rate_limited(ClipboardSource::Local));
516    }
517
518    #[test]
519    fn test_rate_limit_config() {
520        let config = LoopDetectionConfig::with_rate_limit(200);
521        assert_eq!(config.rate_limit_ms, Some(200));
522
523        let mut detector = LoopDetector::with_config(config);
524
525        // First check - not rate limited
526        assert!(!detector.is_rate_limited(ClipboardSource::Rdp));
527
528        // Record sync
529        detector.record_sync(ClipboardSource::Rdp);
530
531        // Immediately after - should be rate limited
532        assert!(detector.is_rate_limited(ClipboardSource::Rdp));
533
534        // But Local should not be affected
535        assert!(!detector.is_rate_limited(ClipboardSource::Local));
536    }
537
538    #[test]
539    fn test_rate_limit_clear() {
540        let config = LoopDetectionConfig::with_rate_limit(200);
541        let mut detector = LoopDetector::with_config(config);
542
543        detector.record_sync(ClipboardSource::Rdp);
544        assert!(detector.is_rate_limited(ClipboardSource::Rdp));
545
546        detector.clear();
547        assert!(!detector.is_rate_limited(ClipboardSource::Rdp));
548    }
549
550    #[test]
551    fn test_should_skip_sync_combined() {
552        let config = LoopDetectionConfig::with_rate_limit(200);
553        let mut detector = LoopDetector::with_config(config);
554
555        let formats = vec![ClipboardFormat::unicode_text()];
556
557        // Initially: not rate limited, no loop
558        assert!(!detector.should_skip_sync(&formats, ClipboardSource::Rdp));
559
560        // Record from RDP
561        detector.record_formats(&formats, ClipboardSource::Rdp);
562        detector.record_sync(ClipboardSource::Rdp);
563
564        // Now should skip for Local (loop detection)
565        assert!(detector.should_skip_sync(&formats, ClipboardSource::Local));
566
567        // And skip for RDP (rate limiting)
568        assert!(detector.should_skip_sync(&formats, ClipboardSource::Rdp));
569    }
570}