ntu/src/sanitizer.rs
jamulix d3c6ae2503 feat: Bessere Erkennung von Doppel-Extensions
- .tar.gz, .tar.bz2, .tar.xz, .tar.zst, .tar.lz, .tar.Z
  werden jetzt korrekt als Einheit behandelt
- "my archive.tar.gz" → "my_archive.tar.gz" (nicht mehr "my_archive.gz")
- Neue Hilfsfunktion split_filename()
- Tests für Doppel-Extensions hinzugefügt

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 10:13:47 +01:00

426 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::config::Config;
use glob::Pattern;
use log::{debug, warn};
use once_cell::sync::Lazy;
use regex::{Captures, Regex};
use std::ffi::OsStr;
use std::path::Path;
use unicode_segmentation::UnicodeSegmentation;
use walkdir::DirEntry;
// Regex-Patterns als statische Variablen für bessere Performance
static RE_INVALID: Lazy<Regex> = Lazy::new(|| Regex::new(r"[^\w.\-]").unwrap());
static RE_ADJACENT: Lazy<Regex> = Lazy::new(|| Regex::new(r"_\.|\._").unwrap());
static RE_MULTI: Lazy<Regex> = Lazy::new(|| Regex::new(r"[_\.]{2,}").unwrap());
// Bekannte Doppel-Extensions (z.B. .tar.gz)
const DOUBLE_EXTENSIONS: &[&str] = &[
".tar.gz",
".tar.bz2",
".tar.xz",
".tar.zst",
".tar.lz",
".tar.Z",
];
/// Trennt Dateiname in Basis und Extension, berücksichtigt Doppel-Extensions
fn split_filename(filename: &str) -> (String, String) {
// Prüfe auf bekannte Doppel-Extensions
for double_ext in DOUBLE_EXTENSIONS {
if filename.ends_with(double_ext) {
let base_len = filename.len() - double_ext.len();
if base_len > 0 {
return (
filename[..base_len].to_string(),
double_ext.to_string(),
);
}
}
}
// Standard-Fall: nur letzte Extension
match filename.rsplit_once('.') {
Some((b, e)) if !b.is_empty() => (b.to_string(), format!(".{e}")),
_ => (filename.to_string(), String::new()),
}
}
/// Bereinigt den übergebenen Dateinamen oder Verzeichnisnamen.
pub fn clean_filename(name: &OsStr, config: &Config, verbose: bool) -> Option<String> {
let original = name.to_string_lossy();
// Versteckte Dateien (mit führendem Punkt) korrekt behandeln
let (hidden_prefix, rest) = match original.strip_prefix('.') {
Some(rest) => (".", rest),
None => ("", original.as_ref()),
};
// Stamm und Extension trennen (nur im Rest, nicht im hidden_prefix)
let (mut base, mut ext) = split_filename(rest);
// Platzhalter (C++, c++, C#, c#) anlegen
base = preserve_special_identifiers(&base);
ext = preserve_special_identifiers(&ext);
// 1) Konfig-Replacements zuerst
for (k, v) in &config.replacements {
base = base.replace(k, v);
}
// 2) Dann erst hart-codierte Ersetzungen anwenden
base = apply_hardcoded_replacements(&base);
// 3) Emojis und hochgestellte Zeichen ersetzen
base = replace_emojis_and_superscript(&base);
// 4) Entfernen/Ersetzen aller übrigen ungültigen Zeichen
base = RE_INVALID.replace_all(&base, "_").to_string();
// Ungültige Kombinationen aus Punkt und Unterstrich
base = RE_ADJACENT.replace_all(&base, ".").to_string();
// Mehrfache Punkte/Unterstriche auf einen reduzieren
base = RE_MULTI
.replace_all(
&base,
|caps: &Captures| {
if caps[0].contains('.') {
"."
} else {
"_"
}
},
)
.to_string();
// Führender Punkt soll bleiben, führende Unterstriche sollen verschwinden
base = trim_leading_underscores_preserve_leading_dot(&base);
// Überflüssige Unterstriche und Punkte am Ende beseitigen
base = base.trim_end_matches('_').to_string();
base = base.trim_end_matches('.').to_string();
// Falls komplett geleert, "unnamed"
if base.is_empty() {
base = "unnamed".to_string();
}
// Endgültigen Dateinamen zusammenbauen (hidden_prefix wieder hinzufügen)
let mut result = format!("{}{}{}", hidden_prefix, base, ext);
// Platzhalter zurückverwandeln
result = restore_special_identifiers(&result);
// Falls --verbose und sich der Name geändert hat
if verbose && result != original {
debug!("Transformiert: '{}' -> '{}'", original, result);
}
// Keine Änderung -> None zurückgeben
if result == *original {
None
} else {
Some(result)
}
}
/// Schützt spezielle Identifikatoren vor der Umwandlung
fn preserve_special_identifiers(input: &str) -> String {
input
.replace("C++", "CPLUSPLUS")
.replace("c++", "cplusplus")
.replace("C#", "CSHARP")
.replace("c#", "csharp")
}
/// Stellt spezielle Identifikatoren wieder her
fn restore_special_identifiers(input: &str) -> String {
input
.replace("CPLUSPLUS", "C++")
.replace("cplusplus", "c++")
.replace("CSHARP", "C#")
.replace("csharp", "c#")
}
/// Fasst alle fest eingebauten Ersetzungen zusammen.
fn apply_hardcoded_replacements(input: &str) -> String {
input
.replace('\'', "") // Apostroph entfernen
.replace("ˆ", "_")
}
/// Entfernt am Anfang nur Unterstriche, einen führenden Punkt (.) bewahrt es.
fn trim_leading_underscores_preserve_leading_dot(s: &str) -> String {
let mut chars = s.chars().peekable();
let mut result = String::new();
if let Some('.') = chars.peek() {
// Nimm den Punkt
result.push('.');
chars.next();
// Entferne anschließend führende Unterstriche hinter dem Punkt
while let Some('_') = chars.peek() {
chars.next();
}
} else {
// Entferne führende Unterstriche
while let Some('_') = chars.peek() {
chars.next();
}
}
// Restliche Zeichen anfügen
result.extend(chars);
result
}
/// Ersetzt Emojis und hochgestellte Zeichen (z. B. ²³⁴) durch '_'.
fn replace_emojis_and_superscript(input: &str) -> String {
input
.graphemes(true)
.map(|g| {
if emojis::get(g).is_some() || is_superscript(g) {
"_".to_string()
} else {
g.to_string()
}
})
.collect()
}
/// Prüft, ob alle Zeichen ein Superscript sind (z. B. ²³⁴).
fn is_superscript(g: &str) -> bool {
g.chars().all(|c| {
c == '\u{00AA}'
|| c == '\u{00BA}'
|| ('\u{00B2}'..='\u{00B3}').contains(&c)
|| c == '\u{00B9}'
|| ('\u{2070}'..='\u{209F}').contains(&c)
})
}
/// Prüft, ob der Pfad aufgrund der Ausschlussmuster ignoriert werden soll.
pub fn is_excluded(entry: &DirEntry, patterns: &[Pattern]) -> bool {
let path = entry.path();
patterns.iter().any(|p| p.matches_path(path))
}
/// Überprüft, ob eine Umbenennungsoperation sicher ist
pub fn is_safe_rename(src: &Path, dst: &Path, force: bool) -> bool {
if src.exists() && dst.exists() && !force {
warn!(
"Ziel existiert bereits und --force nicht gesetzt: {}",
dst.display()
);
return false;
}
// Prüfen auf zusätzliche Sicherheitsrisiken
// z.B. Systemdateien, schreibgeschützte Verzeichnisse, etc.
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
fn make_test_config() -> Config {
let mut replacements = std::collections::HashMap::new();
replacements.insert("ä".to_string(), "ae".to_string());
replacements.insert("ö".to_string(), "oe".to_string());
replacements.insert("ü".to_string(), "ue".to_string());
replacements.insert("ß".to_string(), "ss".to_string());
Config { replacements }
}
#[test]
fn test_clean_filename_basic() {
let config = Config::default();
// Spaces should become underscores
assert_eq!(
clean_filename(OsStr::new("test file.txt"), &config, false),
Some("test_file.txt".to_string())
);
// Parentheses should become underscores
assert_eq!(
clean_filename(OsStr::new("file (1).txt"), &config, false),
Some("file_1.txt".to_string())
);
// Multiple underscores should be collapsed
assert_eq!(
clean_filename(OsStr::new("test__file.txt"), &config, false),
Some("test_file.txt".to_string())
);
}
#[test]
fn test_clean_filename_hidden_files() {
let config = Config::default();
// Hidden files should keep their leading dot
assert_eq!(
clean_filename(OsStr::new(".gitignore"), &config, false),
None // No change needed
);
// Hidden files with spaces
assert_eq!(
clean_filename(OsStr::new(".my config"), &config, false),
Some(".my_config".to_string())
);
// Hidden files with extension
assert_eq!(
clean_filename(OsStr::new(".test file.txt"), &config, false),
Some(".test_file.txt".to_string())
);
// Multiple leading dots
assert_eq!(
clean_filename(OsStr::new("...strange"), &config, false),
Some(".unnamed.strange".to_string())
);
}
#[test]
fn test_clean_filename_umlauts() {
let config = make_test_config();
// German umlauts
assert_eq!(
clean_filename(OsStr::new("Müller.pdf"), &config, false),
Some("Mueller.pdf".to_string())
);
assert_eq!(
clean_filename(OsStr::new("schön.txt"), &config, false),
Some("schoen.txt".to_string())
);
assert_eq!(
clean_filename(OsStr::new("Größe.doc"), &config, false),
Some("Groesse.doc".to_string())
);
}
#[test]
fn test_clean_filename_extensions() {
let config = Config::default();
// Single extension
assert_eq!(
clean_filename(OsStr::new("test file.txt"), &config, false),
Some("test_file.txt".to_string())
);
// Double extension with spaces in base name
assert_eq!(
clean_filename(OsStr::new("my archive.tar.gz"), &config, false),
Some("my_archive.tar.gz".to_string())
);
// Other double extensions
assert_eq!(
clean_filename(OsStr::new("backup file.tar.bz2"), &config, false),
Some("backup_file.tar.bz2".to_string())
);
assert_eq!(
clean_filename(OsStr::new("data set.tar.xz"), &config, false),
Some("data_set.tar.xz".to_string())
);
// Multiple dots (not a double extension)
assert_eq!(
clean_filename(OsStr::new("foo..bar.txt"), &config, false),
Some("foo.bar.txt".to_string())
);
}
#[test]
fn test_split_filename() {
// Double extensions
assert_eq!(
split_filename("archive.tar.gz"),
("archive".to_string(), ".tar.gz".to_string())
);
assert_eq!(
split_filename("backup.tar.bz2"),
("backup".to_string(), ".tar.bz2".to_string())
);
// Single extension
assert_eq!(
split_filename("file.txt"),
("file".to_string(), ".txt".to_string())
);
// No extension
assert_eq!(
split_filename("README"),
("README".to_string(), String::new())
);
}
#[test]
fn test_clean_filename_special_identifiers() {
let config = Config::default();
// C++ should be preserved
assert_eq!(
clean_filename(OsStr::new("test C++.txt"), &config, false),
Some("test_C++.txt".to_string())
);
// C# should be preserved
assert_eq!(
clean_filename(OsStr::new("guide C#.pdf"), &config, false),
Some("guide_C#.pdf".to_string())
);
}
#[test]
fn test_clean_filename_no_change_needed() {
let config = Config::default();
// Already clean filenames should return None
assert_eq!(
clean_filename(OsStr::new("clean_file.txt"), &config, false),
None
);
assert_eq!(
clean_filename(OsStr::new("another-file.pdf"), &config, false),
None
);
}
#[test]
fn test_clean_filename_empty_after_cleaning() {
let config = Config::default();
// File with only special chars should become "unnamed"
assert_eq!(
clean_filename(OsStr::new("###.txt"), &config, false),
Some("unnamed.txt".to_string())
);
}
#[test]
fn test_clean_filename_apostrophe() {
let config = Config::default();
// Apostrophes should be removed (not replaced with underscore)
assert_eq!(
clean_filename(OsStr::new("O'Reilly.pdf"), &config, false),
Some("OReilly.pdf".to_string())
);
}
}