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

@@ -626,16 +626,19 @@ fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) {
watches.push(watch);
if let Err(e) = std::fs::create_dir_all(&config_dir) {
eprintln!("Warning: failed to create config directory: {}", e);
eprintln!("Failed to create config directory: {}", e);
std::process::exit(1);
}
match serde_json::to_string_pretty(&watches) {
Ok(json) => {
if let Err(e) = std::fs::write(&watches_path, json) {
eprintln!("Warning: failed to write watch config: {}", e);
eprintln!("Failed to write watch config: {}", e);
std::process::exit(1);
}
}
Err(e) => {
eprintln!("Warning: failed to serialize watch config: {}", e);
eprintln!("Failed to serialize watch config: {}", e);
std::process::exit(1);
}
}
@@ -773,15 +776,35 @@ fn cmd_watch_start() {
for event in &rx {
match event {
pixstrip_core::watcher::WatchEvent::NewImage(path) => {
// Skip files inside output directories to prevent infinite processing loop
if output_dirs.iter().any(|d| path.starts_with(d)) {
// Skip files inside output directories to prevent infinite processing loop.
// Check if the file is a direct child of any "processed" subdirectory
// under a watched folder.
let in_output_dir = output_dirs.iter().any(|d| path.starts_with(d))
|| active.iter().any(|w| {
// Skip if the file's immediate parent is "processed" under the watch root
path.parent()
.and_then(|p| p.file_name())
.is_some_and(|name| name == "processed")
&& path.starts_with(&w.path)
});
if in_output_dir {
continue;
}
println!("New image: {}", path.display());
// Wait briefly for file to be fully written
std::thread::sleep(std::time::Duration::from_millis(500));
// Wait for file to be fully written (check size stability)
{
let mut last_size = 0u64;
for _ in 0..10 {
std::thread::sleep(std::time::Duration::from_millis(200));
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
if size > 0 && size == last_size {
break;
}
last_size = size;
}
}
// Find which watcher owns this path and use its preset
let matched = active.iter()
@@ -803,7 +826,23 @@ fn cmd_watch_start() {
let executor = PipelineExecutor::new();
match executor.execute(&job, |_| {}) {
Ok(r) => println!(" Processed: {} -> {}", format_bytes(r.total_input_bytes), format_bytes(r.total_output_bytes)),
Ok(r) => {
println!(" Processed: {} -> {}", format_bytes(r.total_input_bytes), format_bytes(r.total_output_bytes));
let history = HistoryStore::new();
let _ = history.add(pixstrip_core::storage::HistoryEntry {
timestamp: chrono_timestamp(),
input_dir: input_dir.to_string_lossy().into(),
output_dir: output_dir.to_string_lossy().into(),
preset_name: Some(preset_name.to_string()),
total: r.total,
succeeded: r.succeeded,
failed: r.failed,
total_input_bytes: r.total_input_bytes,
total_output_bytes: r.total_output_bytes,
elapsed_ms: r.elapsed_ms,
output_files: r.output_files,
}, 50, 30);
}
Err(e) => eprintln!(" Failed: {}", e),
}
}
@@ -882,8 +921,9 @@ fn parse_format(s: &str) -> Option<ImageFormat> {
"avif" => Some(ImageFormat::Avif),
"gif" => Some(ImageFormat::Gif),
"tiff" | "tif" => Some(ImageFormat::Tiff),
"bmp" => Some(ImageFormat::Bmp),
_ => {
eprintln!("Unknown format: '{}'. Supported: jpeg, png, webp, avif, gif, tiff", s);
eprintln!("Unknown format: '{}'. Supported: jpeg, png, webp, avif, gif, tiff, bmp", s);
None
}
}
@@ -980,12 +1020,42 @@ fn format_duration(ms: u64) -> String {
}
fn chrono_timestamp() -> String {
// Simple timestamp without chrono dependency
// Human-readable timestamp without chrono dependency
let now = std::time::SystemTime::now();
let duration = now
let secs = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
format!("{}", duration.as_secs())
.unwrap_or_default()
.as_secs();
// Convert to date/time components
let days = secs / 86400;
let time_secs = secs % 86400;
let hours = time_secs / 3600;
let minutes = (time_secs % 3600) / 60;
let seconds = time_secs % 60;
let mut d = days;
let mut year = 1970u64;
loop {
let days_in_year = if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 { 366 } else { 365 };
if d < days_in_year { break; }
d -= days_in_year;
year += 1;
}
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
let month_days: [u64; 12] = if leap {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1u64;
for md in &month_days {
if d < *md { break; }
d -= md;
month += 1;
}
format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", year, month, d + 1, hours, minutes, seconds)
}
#[cfg(test)]
@@ -999,11 +1069,11 @@ mod tests {
assert_eq!(parse_format("PNG"), Some(ImageFormat::Png));
assert_eq!(parse_format("webp"), Some(ImageFormat::WebP));
assert_eq!(parse_format("avif"), Some(ImageFormat::Avif));
assert_eq!(parse_format("bmp"), Some(ImageFormat::Bmp));
}
#[test]
fn parse_format_invalid() {
assert_eq!(parse_format("bmp"), None);
assert_eq!(parse_format("xyz"), None);
}