diff --git a/libs/clipboard/src/platform/unix/serv_files.rs b/libs/clipboard/src/platform/unix/serv_files.rs index 6f4fb54a4..6fe66517c 100644 --- a/libs/clipboard/src/platform/unix/serv_files.rs +++ b/libs/clipboard/src/platform/unix/serv_files.rs @@ -5,7 +5,7 @@ use hbb_common::{ log, }; use parking_lot::Mutex; -use std::{path::PathBuf, sync::Arc, usize}; +use std::{path::PathBuf, sync::Arc, time::SystemTime, usize}; lazy_static::lazy_static! { // local files are cached, this value should not be changed when copying files @@ -30,9 +30,35 @@ enum FileContentsRequest { }, } +// Cheap fingerprint of one top-level selected entry. A change in size/mtime -- +// or a directory in the selection -- forces sync_files() to rebuild (see below). +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct FileSig { + size: u64, + mtime: Option, + is_dir: bool, +} + +// Stat the top-level selected paths only (no recursion), same order as `files`. +fn fingerprint(files: &[String]) -> Vec { + files + .iter() + .map(|s| match std::fs::metadata(s) { + Ok(mt) => FileSig { + size: mt.len(), + mtime: mt.modified().ok(), + is_dir: mt.is_dir(), + }, + Err(_) => FileSig::default(), + }) + .collect() +} + #[derive(Default)] struct ClipFiles { files: Vec, + // Fingerprint of `files` (same len/order); detects in-place edits on re-copy. + sigs: Vec, file_list: Vec, first_file_index: usize, files_pdu: Vec, @@ -41,12 +67,17 @@ struct ClipFiles { impl ClipFiles { fn clear(&mut self) { self.files.clear(); + self.sigs.clear(); self.file_list.clear(); self.first_file_index = usize::MAX; self.files_pdu.clear(); } - fn sync_files(&mut self, clipboard_files: &[String]) -> Result<(), CliprdrError> { + fn sync_files( + &mut self, + clipboard_files: &[String], + sigs: Vec, + ) -> Result<(), CliprdrError> { let clipboard_paths = clipboard_files .iter() .map(|s| PathBuf::from(s)) @@ -58,6 +89,7 @@ impl ClipFiles { .position(|f| !f.path.is_dir()) .unwrap_or(usize::MAX); self.files = clipboard_files.to_vec(); + self.sigs = sigs; Ok(()) } @@ -258,14 +290,128 @@ pub fn read_file_contents( } pub fn sync_files(files: &[String]) -> Result<(), CliprdrError> { + // Dedup: skip the rebuild only when paths + sizes + mtimes match and no dir is + // selected (a dir's own mtime doesn't change when a file inside it is edited). + let current = fingerprint(files); let mut files_lock = CLIP_FILES.lock(); - if files_lock.files == files { + if files_lock.files == files + && files_lock.sigs == current + && !current.iter().any(|sig| sig.is_dir) + { return Ok(()); } - files_lock.sync_files(files)?; + files_lock.sync_files(files, current)?; Ok(files_lock.build_file_list_pdu()) } pub fn get_file_list_pdu() -> Vec { CLIP_FILES.lock().files_pdu.clone() } + +#[cfg(test)] +mod sig_test { + use super::*; + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + // Unique temp dir under the system temp dir; removed on drop (no dev-dep). + struct TmpDir(PathBuf); + impl TmpDir { + fn new(tag: &str) -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let mut dir = std::env::temp_dir(); + dir.push(format!("rustdesk_sig_test_{}_{}", tag, nanos)); + fs::create_dir_all(&dir).unwrap(); + TmpDir(dir) + } + fn join(&self, name: &str) -> PathBuf { + self.0.join(name) + } + } + impl Drop for TmpDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn path_str(p: &PathBuf) -> String { + p.to_string_lossy().to_string() + } + + #[test] + fn fingerprint_missing_path_is_default() { + let tmp = TmpDir::new("missing"); + let missing = path_str(&tmp.join("does_not_exist.bin")); + let sigs = fingerprint(&[missing]); + assert_eq!(sigs.len(), 1); + // A path that can't be stat'd -> default sig, which forces a rebuild. + assert_eq!(sigs[0], FileSig::default()); + assert_eq!(sigs[0].mtime, None); + } + + #[test] + fn fingerprint_detects_inplace_edit() { + let tmp = TmpDir::new("edit"); + let file = tmp.join("a.bin"); + fs::write(&file, b"small").unwrap(); + let p = path_str(&file); + + let before = fingerprint(&[p.clone()]); + // Same content, same path: fingerprint must be stable. + let again = fingerprint(&[p.clone()]); + assert_eq!(before, again); + assert_eq!(before[0].size, 5); + assert!(!before[0].is_dir); + + // Edit in place so the file grows. + fs::write(&file, b"much larger contents than before").unwrap(); + let after = fingerprint(&[p]); + assert_ne!(before, after); + assert!(after[0].size > before[0].size); + } + + #[test] + fn fingerprint_flags_directory() { + let tmp = TmpDir::new("dir"); + let sub = tmp.join("subdir"); + fs::create_dir_all(&sub).unwrap(); + let sigs = fingerprint(&[path_str(&sub)]); + assert_eq!(sigs.len(), 1); + assert!(sigs[0].is_dir); + } + + #[test] + fn recopy_after_edit_refreshes_cached_size() { + let tmp = TmpDir::new("recopy"); + let file = tmp.join("doc.bin"); + fs::write(&file, b"v1").unwrap(); // 2 bytes + let files = vec![path_str(&file)]; + + // Drive the public, guarded `sync_files` over the global CLIP_FILES; + // reset first (this is the only test that touches the global). + clear_files(); + + sync_files(&files).unwrap(); + { + let cache = CLIP_FILES.lock(); + let idx = cache.first_file_index; + assert_eq!(cache.file_list[idx].size, 2); + } + + // In-place edit grows the file; the re-copy must rebuild. Pre-fix the + // path-only guard early-returned and left the cached size stale at 2. + fs::write(&file, b"v2 is bigger").unwrap(); // 12 bytes + sync_files(&files).unwrap(); + { + let cache = CLIP_FILES.lock(); + let idx = cache.first_file_index; + assert_eq!(cache.file_list[idx].size, 12); + } + + clear_files(); // leave the global clean for other tests + } +}