ntu/src/sanitizer.rs

426 lines
12 KiB
Rust
Raw Normal View History

2025-03-18 03:05:18 +01:00
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()),
}
}
2025-03-18 03:05:18 +01:00
/// 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);
2025-03-18 03:05:18 +01:00
// 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);
2025-03-18 03:05:18 +01:00
// 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) {
2025-03-18 03:05:18 +01:00
"_".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)
2025-03-18 03:05:18 +01:00
|| c == '\u{00B9}'
|| ('\u{2070}'..='\u{209F}').contains(&c)
2025-03-18 03:05:18 +01:00
})
}
/// 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())
);
}
}