Fix 40+ bugs from audit passes 9-12

- PNG chunk parsing overflow protection with checked arithmetic
- Font directory traversal bounded with global result limit
- find_unique_path TOCTOU race fixed with create_new + marker byte
- Watch mode "processed" dir exclusion narrowed to prevent false skips
- Metadata copy now checks format support before little_exif calls
- Clipboard temp files cleaned up on app exit
- Atomic writes for file manager integration scripts
- BMP format support added to encoder and convert step
- Regex DoS protection with DFA size limit
- Watermark NaN/negative scale guard
- Selective EXIF stripping for privacy/custom metadata modes
- CLI watch mode: file stability checks, per-file history saves
- High contrast toggle preserves and restores original theme
- Image list deduplication uses O(1) HashSet lookups
- Saturation/trim/padding overflow guards in adjustments
This commit is contained in:
2026-03-07 22:14:48 +02:00
parent adef810691
commit d1cab8a691
18 changed files with 600 additions and 113 deletions

View File

@@ -224,6 +224,18 @@ pub fn apply_case_conversion(name: &str, case_mode: u32) -> String {
}
}
/// Pre-compile a regex for batch use. Returns None (with message) if invalid.
pub fn compile_rename_regex(find: &str) -> Option<regex::Regex> {
if find.is_empty() {
return None;
}
regex::RegexBuilder::new(find)
.size_limit(1 << 16)
.dfa_size_limit(1 << 16)
.build()
.ok()
}
/// Apply regex find-and-replace on a filename
pub fn apply_regex_replace(name: &str, find: &str, replace: &str) -> String {
if find.is_empty() {
@@ -231,6 +243,7 @@ pub fn apply_regex_replace(name: &str, find: &str, replace: &str) -> String {
}
match regex::RegexBuilder::new(find)
.size_limit(1 << 16)
.dfa_size_limit(1 << 16)
.build()
{
Ok(re) => re.replace_all(name, replace).into_owned(),
@@ -238,9 +251,17 @@ pub fn apply_regex_replace(name: &str, find: &str, replace: &str) -> String {
}
}
/// Apply a pre-compiled regex find-and-replace on a filename
pub fn apply_regex_replace_compiled(name: &str, re: &regex::Regex, replace: &str) -> String {
re.replace_all(name, replace).into_owned()
}
pub fn resolve_collision(path: &Path) -> PathBuf {
if !path.exists() {
return path.to_path_buf();
// Use create_new (O_CREAT|O_EXCL) for atomic reservation, preventing TOCTOU races
match std::fs::OpenOptions::new().write(true).create_new(true).open(path) {
Ok(_) => return path.to_path_buf(),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(_) => return path.to_path_buf(), // other errors (e.g. permission) - let caller handle
}
let parent = path.parent().unwrap_or(Path::new("."));
@@ -259,15 +280,21 @@ pub fn resolve_collision(path: &Path) -> PathBuf {
} else {
parent.join(format!("{}_{}.{}", stem, i, ext))
};
if !candidate.exists() {
return candidate;
match std::fs::OpenOptions::new().write(true).create_new(true).open(&candidate) {
Ok(_) => return candidate,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(_) => continue,
}
}
// Fallback - should never happen with 1000 attempts
// Fallback with timestamp for uniqueness
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
if ext.is_empty() {
parent.join(format!("{}_overflow", stem))
parent.join(format!("{}_{}", stem, ts))
} else {
parent.join(format!("{}_overflow.{}", stem, ext))
parent.join(format!("{}_{}.{}", stem, ts, ext))
}
}