diff --git a/src/sync.rs b/src/sync.rs index 2eaea8a..c0b2414 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -122,14 +122,91 @@ pub enum FileTransferResult { DryRunTransferred { size: u64 }, } -/// Prüft, ob ein Dateiname oder Pfad einem der Ausschlussmuster entspricht. +/// Einfacher, effizienter Glob-Matcher ohne externe Abhängigkeiten (unterstützt `*`, `**` und `?`). +pub fn glob_match(pattern: &str, text: &str) -> bool { + glob_match_slice(pattern.as_bytes(), text.as_bytes()) +} + +fn glob_match_slice(pat: &[u8], text: &[u8]) -> bool { + if pat.is_empty() { + return text.is_empty(); + } + + // Double-Star '**': Entspricht 0 oder mehr beliebigen Zeichen inklusive Verzeichnistrenner + if pat.starts_with(b"**") { + let rest_pat = if pat.len() > 2 && pat[2] == b'/' { + &pat[3..] + } else { + &pat[2..] + }; + if rest_pat.is_empty() { + return true; + } + for i in 0..=text.len() { + if glob_match_slice(rest_pat, &text[i..]) { + return true; + } + } + return false; + } + + // Single-Star '*': Entspricht 0 oder mehr Zeichen innerhalb desselben Pfadsegments (stoppt an '/') + if pat[0] == b'*' { + let rest_pat = &pat[1..]; + for i in 0..=text.len() { + if i > 0 && (text[i - 1] == b'/' || text[i - 1] == b'\\') { + break; + } + if glob_match_slice(rest_pat, &text[i..]) { + return true; + } + } + return false; + } + + if text.is_empty() { + return false; + } + + // Question mark '?': Einzelzeichen-Platzhalter (außer Pfadtrenner) + if pat[0] == b'?' { + if text[0] == b'/' || text[0] == b'\\' { + return false; + } + return glob_match_slice(&pat[1..], &text[1..]); + } + + if pat[0] == text[0] { + return glob_match_slice(&pat[1..], &text[1..]); + } + + false +} + +/// Prüft, ob ein Dateiname oder Pfad einem der Ausschlussmuster entspricht (S-08). +/// +/// Unterstützte Musterformate: +/// 1. Anti-Leak Shield: OS- und Explorer-Metadaten (z. B. Thumbs.db, .DS_Store) werden stets ausgeschlossen. +/// 2. Glob-Muster mit Wildcards: +/// - `*.ext`: Schließt alle Dateien mit dieser Endung im gesamten Baum aus (z. B. `*.tmp`, `*.bak`). +/// - `prefix*`: Schließt alle Dateien aus, deren Name mit dem Präfix beginnt (z. B. `temp_*`, `backup*`). +/// - `*middle*`: Schließt Dateien/Pfade aus, die die Zeichenkette enthalten. +/// - `?`: Einzelzeichen-Platzhalter (z. B. `file?.txt`). +/// 3. Verzeichnis-Ausschlüsse: +/// - `dirname/` oder `dirname`: Schließt das Verzeichnis und alle darin enthaltenen Dateien/Unterordner aus. +/// - `/path/to/dir`: Verankert den Ausschluss relativ zum Synchronisations-Wurzelverzeichnis. +/// 4. Pfadspezifische Muster: +/// - `build/*.bin`: Schließt `.bin`-Dateien im Ordner `build` aus. pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool { // 1. Anti-Leak Shield: Typische Explorer- und OS-Metadaten immer ausschließen if is_leak_file(name) { return true; } - let norm_rel = rel_path.replace('\\', "/"); + let norm_rel = rel_path + .replace('\\', "/") + .trim_start_matches('/') + .to_ascii_lowercase(); let norm_name = name.to_ascii_lowercase(); for pat in patterns { @@ -138,32 +215,44 @@ pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool { continue; } - // Wildcard-Muster: *.ext - if p.starts_with("*.") { - let ext = &p[1..].to_ascii_lowercase(); - if norm_name.ends_with(ext) || norm_rel.to_ascii_lowercase().ends_with(ext) { - return true; - } - } - // Wildcard-Muster: prefix* - else if p.ends_with('*') && !p[..p.len() - 1].contains('*') { - let prefix = p[..p.len() - 1].to_ascii_lowercase(); - if norm_name.starts_with(&prefix) || norm_rel.to_ascii_lowercase().starts_with(&prefix) + let p_norm = p.replace('\\', "/").to_ascii_lowercase(); + + // Verzeichnis-Ausschluss mit nachgestelltem Slash (z. B. "logs/" oder "build/temp/") + if p_norm.ends_with('/') { + let dir_prefix = p_norm.trim_matches('/'); + if norm_rel == dir_prefix + || norm_rel.starts_with(&format!("{}/", dir_prefix)) + || norm_rel.contains(&format!("/{}/", dir_prefix)) { return true; } } - // Exakter Name oder Teilpfad - else { - let p_lower = p.to_ascii_lowercase(); - if norm_name == p_lower { + + // Muster mit Pfadtrennzeichen (relativ verankert oder spezifischer Unterpfad, z. B. "/logs", "sub/*.txt") + if p_norm.contains('/') { + let p_clean = p_norm.trim_start_matches('/'); + if glob_match(p_clean, &norm_rel) { return true; } - if norm_rel.trim_start_matches('/').to_ascii_lowercase() - == p_lower.trim_start_matches('/') + // Wenn p_clean ein Verzeichnis ohne Wildcards ist, auch alle Unterpfade erfassen + if !p_clean.contains('*') && !p_clean.contains('?') + && (norm_rel == p_clean || norm_rel.starts_with(&format!("{}/", p_clean))) { return true; } + } else { + // Reines Dateinamen- oder Segment-Muster ohne '/' (z. B. "*.tmp", "temp_*", "node_modules") + if glob_match(&p_norm, &norm_name) || glob_match(&p_norm, &norm_rel) { + return true; + } + // Wenn p_norm ein exakter Ordnername ist (z. B. "node_modules", ".git"), alle Pfade mit diesem Segment erfassen + if !p_norm.contains('*') && !p_norm.contains('?') { + for seg in norm_rel.split('/') { + if seg == p_norm { + return true; + } + } + } } } @@ -1230,6 +1319,20 @@ fn delete_orphans_on_host( mod tests { use super::*; + #[test] + fn test_glob_match() { + assert!(glob_match("*.txt", "hello.txt")); + assert!(glob_match("*.txt", ".txt")); + assert!(!glob_match("*.txt", "hello.doc")); + assert!(glob_match("temp_*", "temp_file.dat")); + assert!(!glob_match("temp_*", "other_temp_file.dat")); + assert!(glob_match("build/*.bin", "build/app.bin")); + assert!(!glob_match("build/*.bin", "build/debug/app.bin")); + assert!(glob_match("file?.txt", "file1.txt")); + assert!(!glob_match("file?.txt", "file12.txt")); + assert!(glob_match("*test*", "my_test_case")); + } + #[test] fn test_is_excluded_patterns() { let patterns = vec![ @@ -1238,6 +1341,10 @@ mod tests { "Thumbs.db".to_string(), "backup_*".to_string(), "/logs".to_string(), + "target/".to_string(), + "node_modules".to_string(), + "build/*.bin".to_string(), + "file?.doc".to_string(), ]; assert!(is_excluded("file.tmp", "sub/file.tmp", &patterns)); @@ -1248,11 +1355,29 @@ mod tests { )); assert!(is_excluded("Thumbs.db", "Thumbs.db", &patterns)); assert!(is_excluded("backup_2026.tar", "backup_2026.tar", &patterns)); + assert!(is_excluded("app.log", "logs/app.log", &patterns)); + assert!(is_excluded("deep.log", "logs/sub/deep.log", &patterns)); + assert!(is_excluded("cache.bin", "target/cache.bin", &patterns)); + assert!(is_excluded( + "package.json", + "node_modules/pkg/package.json", + &patterns + )); + assert!(is_excluded( + "package.json", + "client/node_modules/pkg/package.json", + &patterns + )); + assert!(is_excluded("app.bin", "build/app.bin", &patterns)); + assert!(is_excluded("file1.doc", "sub/file1.doc", &patterns)); + + assert!(!is_excluded("file12.doc", "sub/file12.doc", &patterns)); assert!(!is_excluded( "important.doc", "sub/important.doc", &patterns )); assert!(!is_excluded("video.mp4", "video.mp4", &patterns)); + assert!(!is_excluded("app.bin", "other/app.bin", &patterns)); } } diff --git a/tests/sync_test.rs b/tests/sync_test.rs index f0a6d81..84b4e31 100644 --- a/tests/sync_test.rs +++ b/tests/sync_test.rs @@ -985,3 +985,82 @@ fn test_s07_skip_invalid_windows_filenames_push_and_pull() { } let _ = fs::remove_dir_all(&temp_root); } + +#[test] +fn test_s08_refined_glob_and_directory_exclusions() { + let temp_root = + std::env::temp_dir().join(format!("sanctum_s08_test_{}", rand::random::())); + let container_path = temp_root.join("s08.sanctum"); + let source_dir = temp_root.join("source"); + let restore_dir = temp_root.join("restore"); + + fs::create_dir_all(&source_dir).unwrap(); + fs::create_dir_all(source_dir.join("build")).unwrap(); + fs::create_dir_all(source_dir.join("logs")).unwrap(); + fs::create_dir_all(source_dir.join("node_modules").join("dep")).unwrap(); + fs::create_dir_all(&restore_dir).unwrap(); + + let (db, dek) = create_test_container(&container_path); + + // Dateien anlegen + fs::write(source_dir.join("main.rs"), b"fn main() {}").unwrap(); + fs::write(source_dir.join("temp_1.tmp"), b"junk 1").unwrap(); + fs::write(source_dir.join("build").join("output.bin"), b"binary").unwrap(); + fs::write(source_dir.join("build").join("notes.txt"), b"keep notes").unwrap(); + fs::write(source_dir.join("logs").join("sync.log"), b"log data").unwrap(); + fs::write( + source_dir.join("node_modules").join("dep").join("lib.js"), + b"module code", + ) + .unwrap(); + + let mut opts = SyncOptions::default(); + opts.direction = SyncDirection::Push; + opts.quiet = true; + opts.exclude_patterns = vec![ + "*.tmp".to_string(), + "build/*.bin".to_string(), + "logs/".to_string(), + "node_modules".to_string(), + ]; + + // 1. Push + let push_stats = run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + source_dir.to_str().unwrap(), + "/", + &opts, + ) + .expect("Push sync with refined glob exclusions"); + + // Übertragen werden dürfen nur: main.rs und build/notes.txt (2 Dateien) + // Ausgeschlossen: temp_1.tmp (*.tmp), build/output.bin (build/*.bin), logs/sync.log (logs/), node_modules/dep/lib.js (node_modules) + assert_eq!(push_stats.files_transferred, 2); + + // 2. Pull + opts.direction = SyncDirection::Pull; + let pull_stats = run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + "/", + restore_dir.to_str().unwrap(), + &opts, + ) + .expect("Pull sync with refined glob exclusions"); + + assert_eq!(pull_stats.files_transferred, 2); + assert!(restore_dir.join("main.rs").exists()); + assert!(restore_dir.join("build").join("notes.txt").exists()); + assert!(!restore_dir.join("temp_1.tmp").exists()); + assert!(!restore_dir.join("build").join("output.bin").exists()); + assert!(!restore_dir.join("logs").join("sync.log").exists()); + assert!(!restore_dir.join("node_modules").exists()); + + let _ = fs::remove_dir_all(&temp_root); +} +