fix(sync): S-08 — document and refine glob matching in sync engine

This commit is contained in:
2026-09-19 09:33:57 +02:00
parent 15adf79f48
commit 92db21d47e
2 changed files with 223 additions and 19 deletions
+144 -19
View File
@@ -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));
}
}
+79
View File
@@ -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::<u64>()));
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);
}