fix(sync): S-08 — document and refine glob matching in sync engine
This commit is contained in:
+144
-19
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user