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 },
|
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 {
|
pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool {
|
||||||
// 1. Anti-Leak Shield: Typische Explorer- und OS-Metadaten immer ausschließen
|
// 1. Anti-Leak Shield: Typische Explorer- und OS-Metadaten immer ausschließen
|
||||||
if is_leak_file(name) {
|
if is_leak_file(name) {
|
||||||
return true;
|
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();
|
let norm_name = name.to_ascii_lowercase();
|
||||||
|
|
||||||
for pat in patterns {
|
for pat in patterns {
|
||||||
@@ -138,32 +215,44 @@ pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wildcard-Muster: *.ext
|
let p_norm = p.replace('\\', "/").to_ascii_lowercase();
|
||||||
if p.starts_with("*.") {
|
|
||||||
let ext = &p[1..].to_ascii_lowercase();
|
// Verzeichnis-Ausschluss mit nachgestelltem Slash (z. B. "logs/" oder "build/temp/")
|
||||||
if norm_name.ends_with(ext) || norm_rel.to_ascii_lowercase().ends_with(ext) {
|
if p_norm.ends_with('/') {
|
||||||
return true;
|
let dir_prefix = p_norm.trim_matches('/');
|
||||||
}
|
if norm_rel == dir_prefix
|
||||||
}
|
|| norm_rel.starts_with(&format!("{}/", dir_prefix))
|
||||||
// Wildcard-Muster: prefix*
|
|| norm_rel.contains(&format!("/{}/", dir_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)
|
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Exakter Name oder Teilpfad
|
|
||||||
else {
|
// Muster mit Pfadtrennzeichen (relativ verankert oder spezifischer Unterpfad, z. B. "/logs", "sub/*.txt")
|
||||||
let p_lower = p.to_ascii_lowercase();
|
if p_norm.contains('/') {
|
||||||
if norm_name == p_lower {
|
let p_clean = p_norm.trim_start_matches('/');
|
||||||
|
if glob_match(p_clean, &norm_rel) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if norm_rel.trim_start_matches('/').to_ascii_lowercase()
|
// Wenn p_clean ein Verzeichnis ohne Wildcards ist, auch alle Unterpfade erfassen
|
||||||
== p_lower.trim_start_matches('/')
|
if !p_clean.contains('*') && !p_clean.contains('?')
|
||||||
|
&& (norm_rel == p_clean || norm_rel.starts_with(&format!("{}/", p_clean)))
|
||||||
{
|
{
|
||||||
return true;
|
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 {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn test_is_excluded_patterns() {
|
fn test_is_excluded_patterns() {
|
||||||
let patterns = vec![
|
let patterns = vec![
|
||||||
@@ -1238,6 +1341,10 @@ mod tests {
|
|||||||
"Thumbs.db".to_string(),
|
"Thumbs.db".to_string(),
|
||||||
"backup_*".to_string(),
|
"backup_*".to_string(),
|
||||||
"/logs".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));
|
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("Thumbs.db", "Thumbs.db", &patterns));
|
||||||
assert!(is_excluded("backup_2026.tar", "backup_2026.tar", &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(
|
assert!(!is_excluded(
|
||||||
"important.doc",
|
"important.doc",
|
||||||
"sub/important.doc",
|
"sub/important.doc",
|
||||||
&patterns
|
&patterns
|
||||||
));
|
));
|
||||||
assert!(!is_excluded("video.mp4", "video.mp4", &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);
|
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