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));
}
}