1use sha2::{Digest, Sha256};
6use std::collections::VecDeque;
7use std::time::{Duration, Instant};
8
9use crate::ClipboardFormat;
10
11#[derive(Debug, Clone)]
13pub struct LoopDetectionConfig {
14 pub window_ms: u64,
16
17 pub max_history: usize,
19
20 pub enable_content_hashing: bool,
22
23 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ClipboardSource {
64 Rdp,
66 Local,
68}
69
70impl ClipboardSource {
71 pub fn opposite(self) -> Self {
73 match self {
74 Self::Rdp => Self::Local,
75 Self::Local => Self::Rdp,
76 }
77 }
78}
79
80#[derive(Debug, Clone)]
82struct ClipboardOperation {
83 hash: String,
85 source: ClipboardSource,
87 timestamp: Instant,
89}
90
91#[derive(Debug)]
129pub struct LoopDetector {
130 config: LoopDetectionConfig,
132
133 format_history: VecDeque<ClipboardOperation>,
135
136 content_history: VecDeque<ClipboardOperation>,
138
139 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 pub fn new() -> Self {
153 Self::with_config(LoopDetectionConfig::default())
154 }
155
156 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 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 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 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 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 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 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 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 pub fn compute_hash(data: &[u8]) -> String {
234 Self::hash_content(data)
235 }
236
237 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 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 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 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 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 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 if now.duration_since(op.timestamp) > window {
352 break;
353 }
354
355 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 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 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 detector.record_formats(&formats, ClipboardSource::Local);
463
464 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 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 assert!(!detector.is_rate_limited(ClipboardSource::Rdp));
527
528 detector.record_sync(ClipboardSource::Rdp);
530
531 assert!(detector.is_rate_limited(ClipboardSource::Rdp));
533
534 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 assert!(!detector.should_skip_sync(&formats, ClipboardSource::Rdp));
559
560 detector.record_formats(&formats, ClipboardSource::Rdp);
562 detector.record_sync(ClipboardSource::Rdp);
563
564 assert!(detector.should_skip_sync(&formats, ClipboardSource::Local));
566
567 assert!(detector.should_skip_sync(&formats, ClipboardSource::Rdp));
569 }
570}