Fix 30 critical and high severity bugs from audit passes 6-8

Critical fixes:
- Prevent path traversal via rename templates (sanitize_filename)
- Prevent input == output data loss (paths_are_same check)
- Undo now uses actual executor output paths instead of scanning directory
- Filter empty paths from output_files (prevents trashing CWD on undo)
- Sanitize URL download filenames to prevent path traversal writes

High severity fixes:
- Fix EXIF orientation 5/7 transforms per spec
- Atomic file creation in find_unique_path (TOCTOU race)
- Clean up 0-byte placeholder files on encoding failure
- Cap canvas padding to 10000px, total dimensions to 65535
- Clamp crop dimensions to minimum 1px
- Clamp DPI to 65535 before u16 cast in JPEG encoder
- Force pixel path for non-JPEG/TIFF metadata stripping
- Fast path now applies regex find/replace on rename stem
- Add output_dpi to needs_pixel_processing check
- Cap watermark image scale dimensions to 16384
- Cap template counter padding to 10
- Cap URL download size to 100MB
- Fix progress bar NaN when total is zero
- Fix calculate_eta underflow when current > total
- Fix loaded.len()-1 underflow in preview callbacks
- Replace ListItem downcast unwrap with if-let
- Fix resize preview division by zero on degenerate images
- Clamp rename cursor position to prevent overflow panic
- Watch mode: skip output dirs to prevent infinite loop
- Watch mode: drop tx sender so channel closes on exit
- Watch mode: add delay for partially-written files
- Watch mode: warn and skip unmatched files instead of wrong preset
- Clean temp download directory on app close
- Replace action downcast unwrap with checked if-let
- Add BatchResult.output_files for accurate undo tracking
This commit is contained in:
2026-03-07 20:49:10 +02:00
parent b432cc7431
commit adef810691
14 changed files with 207 additions and 106 deletions

View File

@@ -419,37 +419,8 @@ fn cmd_process(args: CmdProcessArgs) {
} }
} }
// Save to history // Save to history - use actual output paths from the executor
let history = HistoryStore::new(); let history = HistoryStore::new();
let output_ext = match job.convert {
Some(ConvertConfig::SingleFormat(fmt)) => fmt.extension(),
_ => "",
};
let output_files: Vec<String> = source_files
.iter()
.enumerate()
.map(|(i, f)| {
let stem = f.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
let ext = if output_ext.is_empty() {
f.extension().and_then(|e| e.to_str()).unwrap_or("jpg")
} else {
output_ext
};
let name = if let Some(ref rename) = job.rename {
if let Some(ref tmpl) = rename.template {
pixstrip_core::operations::rename::apply_template(
tmpl, stem, ext, rename.counter_start.saturating_add(i as u32), None,
)
} else {
rename.apply_simple(stem, ext, (i as u32).saturating_add(1))
}
} else {
format!("{}.{}", stem, ext)
};
output_dir.join(name).to_string_lossy().into()
})
.collect();
if let Err(e) = history.add(pixstrip_core::storage::HistoryEntry { if let Err(e) = history.add(pixstrip_core::storage::HistoryEntry {
timestamp: chrono_timestamp(), timestamp: chrono_timestamp(),
input_dir: input_dir.canonicalize().unwrap_or_else(|_| input_dir.to_path_buf()).to_string_lossy().into(), input_dir: input_dir.canonicalize().unwrap_or_else(|_| input_dir.to_path_buf()).to_string_lossy().into(),
@@ -461,7 +432,7 @@ fn cmd_process(args: CmdProcessArgs) {
total_input_bytes: result.total_input_bytes, total_input_bytes: result.total_input_bytes,
total_output_bytes: result.total_output_bytes, total_output_bytes: result.total_output_bytes,
elapsed_ms: result.elapsed_ms, elapsed_ms: result.elapsed_ms,
output_files, output_files: result.output_files,
}, 50, 30) { }, 50, 30) {
eprintln!("Warning: failed to save history (undo may not work): {}", e); eprintln!("Warning: failed to save history (undo may not work): {}", e);
} }
@@ -777,6 +748,8 @@ fn cmd_watch_start() {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
let mut watchers = Vec::new(); let mut watchers = Vec::new();
// Collect output directories so we can skip files inside them (prevent infinite loop)
let mut output_dirs: Vec<PathBuf> = Vec::new();
for watch in &active { for watch in &active {
let watcher = pixstrip_core::watcher::FolderWatcher::new(); let watcher = pixstrip_core::watcher::FolderWatcher::new();
@@ -784,19 +757,41 @@ fn cmd_watch_start() {
eprintln!("Failed to start watching {}: {}", watch.path.display(), e); eprintln!("Failed to start watching {}: {}", watch.path.display(), e);
continue; continue;
} }
output_dirs.push(watch.path.join("processed"));
watchers.push((watcher, watch.preset_name.clone())); watchers.push((watcher, watch.preset_name.clone()));
} }
// Drop the original sender so the channel closes when all watcher threads exit
drop(tx);
if watchers.is_empty() {
eprintln!("Failed to start any watchers. Check that watch folders exist and are accessible.");
return;
}
// Process incoming files // Process incoming files
for event in &rx { for event in &rx {
match event { match event {
pixstrip_core::watcher::WatchEvent::NewImage(path) => { 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)) {
continue;
}
println!("New image: {}", path.display()); println!("New image: {}", path.display());
// Wait briefly for file to be fully written
std::thread::sleep(std::time::Duration::from_millis(500));
// Find which watcher owns this path and use its preset // Find which watcher owns this path and use its preset
let matched = active.iter() let matched = active.iter()
.find(|w| path.starts_with(&w.path)) .find(|w| path.starts_with(&w.path))
.map(|w| w.preset_name.clone()); .map(|w| w.preset_name.clone());
if let Some(preset_name) = matched.as_deref().or_else(|| watchers.first().map(|(_, n)| n.as_str())) { if matched.is_none() {
eprintln!(" Warning: no matching watch folder for {}, skipping", path.display());
continue;
}
if let Some(preset_name) = matched.as_deref() {
let Some(preset) = find_preset(preset_name) else { let Some(preset) = find_preset(preset_name) else {
eprintln!(" Preset '{}' not found, skipping", preset_name); eprintln!(" Preset '{}' not found, skipping", preset_name);
continue; continue;

View File

@@ -86,8 +86,8 @@ impl OutputEncoder {
if self.options.output_dpi > 0 { if self.options.output_dpi > 0 {
comp.set_pixel_density(mozjpeg::PixelDensity { comp.set_pixel_density(mozjpeg::PixelDensity {
unit: mozjpeg::PixelDensityUnit::Inches, unit: mozjpeg::PixelDensityUnit::Inches,
x: self.options.output_dpi as u16, x: self.options.output_dpi.min(65535) as u16,
y: self.options.output_dpi as u16, y: self.options.output_dpi.min(65535) as u16,
}); });
} }

View File

@@ -33,6 +33,8 @@ pub struct BatchResult {
pub total_output_bytes: u64, pub total_output_bytes: u64,
pub errors: Vec<(String, String)>, pub errors: Vec<(String, String)>,
pub elapsed_ms: u64, pub elapsed_ms: u64,
/// Actual output file paths written by the executor (only successfully written files).
pub output_files: Vec<String>,
} }
pub struct PipelineExecutor { pub struct PipelineExecutor {
@@ -98,6 +100,7 @@ impl PipelineExecutor {
total_output_bytes: 0, total_output_bytes: 0,
errors: Vec::new(), errors: Vec::new(),
elapsed_ms: 0, elapsed_ms: 0,
output_files: Vec::new(),
}); });
} }
@@ -122,6 +125,7 @@ impl PipelineExecutor {
let output_bytes = AtomicU64::new(0); let output_bytes = AtomicU64::new(0);
let cancelled = AtomicBool::new(false); let cancelled = AtomicBool::new(false);
let errors: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new()); let errors: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
let written_files: Mutex<Vec<String>> = Mutex::new(Vec::new());
// Channel for progress updates from worker threads to the callback // Channel for progress updates from worker threads to the callback
let (tx, rx) = std::sync::mpsc::channel::<ProgressUpdate>(); let (tx, rx) = std::sync::mpsc::channel::<ProgressUpdate>();
@@ -141,6 +145,7 @@ impl PipelineExecutor {
let output_bytes_ref = &output_bytes; let output_bytes_ref = &output_bytes;
let cancelled_ref = &cancelled; let cancelled_ref = &cancelled;
let errors_ref = &errors; let errors_ref = &errors;
let written_ref = &written_files;
let worker = scope.spawn(move || { let worker = scope.spawn(move || {
pool.install(|| { pool.install(|| {
@@ -189,10 +194,15 @@ impl PipelineExecutor {
}); });
match Self::process_single_static(job, source, &loader, &encoder, idx) { match Self::process_single_static(job, source, &loader, &encoder, idx) {
Ok((in_size, out_size)) => { Ok((in_size, out_size, out_path)) => {
succeeded_ref.fetch_add(1, Ordering::Relaxed); succeeded_ref.fetch_add(1, Ordering::Relaxed);
input_bytes_ref.fetch_add(in_size, Ordering::Relaxed); input_bytes_ref.fetch_add(in_size, Ordering::Relaxed);
output_bytes_ref.fetch_add(out_size, Ordering::Relaxed); output_bytes_ref.fetch_add(out_size, Ordering::Relaxed);
if !out_path.as_os_str().is_empty() {
if let Ok(mut wf) = written_ref.lock() {
wf.push(out_path.display().to_string());
}
}
} }
Err(e) => { Err(e) => {
failed_ref.fetch_add(1, Ordering::Relaxed); failed_ref.fetch_add(1, Ordering::Relaxed);
@@ -231,6 +241,7 @@ impl PipelineExecutor {
total_output_bytes: output_bytes.load(Ordering::Relaxed), total_output_bytes: output_bytes.load(Ordering::Relaxed),
errors: errors.into_inner().unwrap_or_default(), errors: errors.into_inner().unwrap_or_default(),
elapsed_ms: start.elapsed().as_millis() as u64, elapsed_ms: start.elapsed().as_millis() as u64,
output_files: written_files.into_inner().unwrap_or_default(),
}) })
} }
@@ -256,6 +267,7 @@ impl PipelineExecutor {
total_output_bytes: 0, total_output_bytes: 0,
errors: Vec::new(), errors: Vec::new(),
elapsed_ms: 0, elapsed_ms: 0,
output_files: Vec::new(),
}; };
for (i, source) in job.sources.iter().enumerate() { for (i, source) in job.sources.iter().enumerate() {
@@ -291,10 +303,13 @@ impl PipelineExecutor {
}); });
match Self::process_single_static(job, source, &loader, &encoder, i) { match Self::process_single_static(job, source, &loader, &encoder, i) {
Ok((input_size, output_size)) => { Ok((input_size, output_size, out_path)) => {
result.succeeded += 1; result.succeeded += 1;
result.total_input_bytes += input_size; result.total_input_bytes += input_size;
result.total_output_bytes += output_size; result.total_output_bytes += output_size;
if !out_path.as_os_str().is_empty() {
result.output_files.push(out_path.display().to_string());
}
} }
Err(e) => { Err(e) => {
result.failed += 1; result.failed += 1;
@@ -316,14 +331,24 @@ impl PipelineExecutor {
loader: &ImageLoader, loader: &ImageLoader,
encoder: &OutputEncoder, encoder: &OutputEncoder,
index: usize, index: usize,
) -> std::result::Result<(u64, u64), PixstripError> { ) -> std::result::Result<(u64, u64, std::path::PathBuf), PixstripError> {
let input_size = std::fs::metadata(&source.path) let input_size = std::fs::metadata(&source.path)
.map(|m| m.len()) .map(|m| m.len())
.unwrap_or(0); .unwrap_or(0);
// Metadata stripping via little_exif only works for JPEG/TIFF.
// For other formats, force the pixel path so re-encoding strips metadata.
let metadata_needs_reencode = job.metadata.as_ref().is_some_and(|m| {
!matches!(m, crate::operations::MetadataConfig::KeepAll)
&& !matches!(
source.original_format,
Some(ImageFormat::Jpeg) | Some(ImageFormat::Tiff)
)
});
// Fast path: if no pixel processing needed (rename-only or rename+metadata), // Fast path: if no pixel processing needed (rename-only or rename+metadata),
// just copy the file instead of decoding/re-encoding. // just copy the file instead of decoding/re-encoding.
if !job.needs_pixel_processing() { if !job.needs_pixel_processing() && !metadata_needs_reencode {
let output_path = if let Some(ref rename) = job.rename { let output_path = if let Some(ref rename) = job.rename {
let stem = source.path.file_stem().and_then(|s| s.to_str()).unwrap_or("output"); let stem = source.path.file_stem().and_then(|s| s.to_str()).unwrap_or("output");
let ext = source.path.extension().and_then(|e| e.to_str()).unwrap_or("jpg"); let ext = source.path.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
@@ -333,8 +358,12 @@ impl PipelineExecutor {
.ok() .ok()
.and_then(|r| r.with_guessed_format().ok()) .and_then(|r| r.with_guessed_format().ok())
.and_then(|r| r.into_dimensions().ok()); .and_then(|r| r.into_dimensions().ok());
// Apply regex find/replace on stem (matching the pixel path)
let working_stem = crate::operations::rename::apply_regex_replace(
stem, &rename.regex_find, &rename.regex_replace,
);
let new_name = crate::operations::rename::apply_template_full( let new_name = crate::operations::rename::apply_template_full(
template, stem, ext, template, &working_stem, ext,
rename.counter_start.saturating_add(index as u32), rename.counter_start.saturating_add(index as u32),
dims, Some(ext), Some(&source.path), None, dims, Some(ext), Some(&source.path), None,
); );
@@ -348,18 +377,26 @@ impl PipelineExecutor {
} else { } else {
new_name new_name
}; };
job.output_dir.join(new_name) job.output_dir.join(sanitize_filename(&new_name))
} else { } else {
let new_name = rename.apply_simple(stem, ext, (index as u32).saturating_add(1)); let new_name = rename.apply_simple(stem, ext, (index as u32).saturating_add(1));
job.output_dir.join(new_name) job.output_dir.join(sanitize_filename(&new_name))
} }
} else { } else {
job.output_path_for(source, None) job.output_path_for(source, None)
}; };
// Prevent overwriting the source file
if paths_are_same(&source.path, &output_path) {
return Err(PixstripError::Processing {
operation: "output".into(),
reason: format!("output path is the same as source: {}", source.path.display()),
});
}
let output_path = match job.overwrite_behavior { let output_path = match job.overwrite_behavior {
crate::operations::OverwriteAction::Skip if output_path.exists() => { crate::operations::OverwriteAction::Skip if output_path.exists() => {
return Ok((input_size, 0)); return Ok((input_size, 0, std::path::PathBuf::new()));
} }
crate::operations::OverwriteAction::AutoRename if output_path.exists() => { crate::operations::OverwriteAction::AutoRename if output_path.exists() => {
find_unique_path(&output_path) find_unique_path(&output_path)
@@ -368,10 +405,16 @@ impl PipelineExecutor {
}; };
if let Some(parent) = output_path.parent() { if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?; std::fs::create_dir_all(parent).map_err(|e| {
cleanup_placeholder(&output_path);
PixstripError::Io(e)
})?;
} }
std::fs::copy(&source.path, &output_path).map_err(PixstripError::Io)?; std::fs::copy(&source.path, &output_path).map_err(|e| {
cleanup_placeholder(&output_path);
PixstripError::Io(e)
})?;
// Metadata handling on the copy // Metadata handling on the copy
if let Some(ref meta_config) = job.metadata { if let Some(ref meta_config) = job.metadata {
@@ -391,7 +434,7 @@ impl PipelineExecutor {
} }
let output_size = std::fs::metadata(&output_path).map(|m| m.len()).unwrap_or(0); let output_size = std::fs::metadata(&output_path).map(|m| m.len()).unwrap_or(0);
return Ok((input_size, output_size)); return Ok((input_size, output_size, output_path));
} }
// Load image // Load image
@@ -494,21 +537,29 @@ impl PipelineExecutor {
} else { } else {
new_name new_name
}; };
job.output_dir.join(new_name) job.output_dir.join(sanitize_filename(&new_name))
} else { } else {
let new_name = rename.apply_simple(stem, ext, (index as u32).saturating_add(1)); let new_name = rename.apply_simple(stem, ext, (index as u32).saturating_add(1));
job.output_dir.join(new_name) job.output_dir.join(sanitize_filename(&new_name))
} }
} else { } else {
job.output_path_for(source, Some(output_format)) job.output_path_for(source, Some(output_format))
}; };
// Prevent overwriting the source file
if paths_are_same(&source.path, &output_path) {
return Err(PixstripError::Processing {
operation: "output".into(),
reason: format!("output path is the same as source: {}", source.path.display()),
});
}
// Handle overwrite behavior // Handle overwrite behavior
let output_path = match job.overwrite_behavior { let output_path = match job.overwrite_behavior {
crate::operations::OverwriteAction::Skip => { crate::operations::OverwriteAction::Skip => {
if output_path.exists() { if output_path.exists() {
// Return 0 bytes written - file was skipped // Return 0 bytes written - file was skipped
return Ok((input_size, 0)); return Ok((input_size, 0, std::path::PathBuf::new()));
} }
output_path output_path
} }
@@ -524,16 +575,25 @@ impl PipelineExecutor {
// Ensure output directory exists // Ensure output directory exists
if let Some(parent) = output_path.parent() { if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?; std::fs::create_dir_all(parent).map_err(|e| {
cleanup_placeholder(&output_path);
PixstripError::Io(e)
})?;
} }
// Watermark (after compress settings determined, before encode) // Watermark (after compress settings determined, before encode)
if let Some(ref config) = job.watermark { if let Some(ref config) = job.watermark {
img = apply_watermark(img, config)?; img = apply_watermark(img, config).map_err(|e| {
cleanup_placeholder(&output_path);
e
})?;
} }
// Encode and save // Encode and save
encoder.encode_to_file(&img, &output_path, output_format, quality)?; encoder.encode_to_file(&img, &output_path, output_format, quality).map_err(|e| {
cleanup_placeholder(&output_path);
e
})?;
// Metadata handling: re-encoding strips all EXIF by default. // Metadata handling: re-encoding strips all EXIF by default.
// KeepAll: copy everything back from source. // KeepAll: copy everything back from source.
@@ -563,7 +623,7 @@ impl PipelineExecutor {
.map(|m| m.len()) .map(|m| m.len())
.unwrap_or(0); .unwrap_or(0);
Ok((input_size, output_size)) Ok((input_size, output_size, output_path))
} }
} }
@@ -614,14 +674,44 @@ fn auto_orient_from_exif(
2 => img.fliph(), // Flipped horizontal 2 => img.fliph(), // Flipped horizontal
3 => img.rotate180(), // Rotated 180 3 => img.rotate180(), // Rotated 180
4 => img.flipv(), // Flipped vertical 4 => img.flipv(), // Flipped vertical
5 => img.rotate90().fliph(), // Transposed 5 => img.fliph().rotate270(), // Transposed
6 => img.rotate90(), // Rotated 90 CW 6 => img.rotate90(), // Rotated 90 CW
7 => img.rotate270().fliph(), // Transverse 7 => img.fliph().rotate90(), // Transverse
8 => img.rotate270(), // Rotated 270 CW 8 => img.rotate270(), // Rotated 270 CW
_ => img, _ => img,
} }
} }
/// Strip path separators and parent-directory components from a user-provided filename.
/// Prevents path traversal attacks via rename templates (e.g., "../../etc/passwd").
fn sanitize_filename(name: &str) -> String {
let path = std::path::Path::new(name);
path.file_name()
.and_then(|f| f.to_str())
.unwrap_or("output")
.to_string()
}
/// Check if two paths refer to the same file (resolves symlinks and relative paths).
fn paths_are_same(a: &std::path::Path, b: &std::path::Path) -> bool {
match (a.canonicalize(), b.canonicalize()) {
(Ok(ca), Ok(cb)) => ca == cb,
_ => {
// If canonicalize fails (file doesn't exist yet), compare components directly
a.as_os_str() == b.as_os_str()
}
}
}
/// Remove a 0-byte placeholder file created by find_unique_path on error.
fn cleanup_placeholder(path: &std::path::Path) {
if let Ok(meta) = std::fs::metadata(path) {
if meta.len() == 0 {
let _ = std::fs::remove_file(path);
}
}
}
fn find_unique_path(path: &std::path::Path) -> std::path::PathBuf { fn find_unique_path(path: &std::path::Path) -> std::path::PathBuf {
let stem = path let stem = path
.file_stem() .file_stem()
@@ -633,13 +723,21 @@ fn find_unique_path(path: &std::path::Path) -> std::path::PathBuf {
.unwrap_or("bin"); .unwrap_or("bin");
let parent = path.parent().unwrap_or_else(|| std::path::Path::new(".")); let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
// Use O_CREAT | O_EXCL via create_new() to atomically reserve the path,
// preventing TOCTOU races when multiple threads auto-rename concurrently.
for i in 1u32..10000 { for i in 1u32..10000 {
let candidate = parent.join(format!("{}_{}.{}", stem, i, ext)); let candidate = parent.join(format!("{}_{}.{}", stem, i, ext));
if !candidate.exists() { match std::fs::OpenOptions::new()
return candidate; .write(true)
.create_new(true)
.open(&candidate)
{
Ok(_) => return candidate,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(_) => continue,
} }
} }
// Extremely unlikely fallback // Extremely unlikely fallback - use timestamp for uniqueness
parent.join(format!("{}_{}.{}", stem, std::time::SystemTime::now() parent.join(format!("{}_{}.{}", stem, std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis()) .map(|d| d.as_millis())

View File

@@ -68,12 +68,12 @@ fn crop_to_aspect_ratio(img: DynamicImage, w_ratio: f64, h_ratio: f64) -> Dynami
let (crop_w, crop_h) = if current_ratio > target_ratio { let (crop_w, crop_h) = if current_ratio > target_ratio {
// Image is wider than target, crop width // Image is wider than target, crop width
let new_w = (ih as f64 * target_ratio) as u32; let new_w = (ih as f64 * target_ratio).round().max(1.0) as u32;
(new_w.min(iw), ih) (new_w.min(iw).max(1), ih)
} else { } else {
// Image is taller than target, crop height // Image is taller than target, crop height
let new_h = (iw as f64 / target_ratio) as u32; let new_h = (iw as f64 / target_ratio).round().max(1.0) as u32;
(iw, new_h.min(ih)) (iw, new_h.min(ih).max(1))
}; };
let x = iw.saturating_sub(crop_w) / 2; let x = iw.saturating_sub(crop_w) / 2;
@@ -193,9 +193,11 @@ fn apply_sepia(img: DynamicImage) -> DynamicImage {
} }
fn add_canvas_padding(img: DynamicImage, padding: u32) -> DynamicImage { fn add_canvas_padding(img: DynamicImage, padding: u32) -> DynamicImage {
// Cap padding to prevent unreasonable memory allocation
let padding = padding.min(10_000);
let (w, h) = (img.width(), img.height()); let (w, h) = (img.width(), img.height());
let new_w = w.saturating_add(padding.saturating_mul(2)); let new_w = w.saturating_add(padding.saturating_mul(2)).min(65_535);
let new_h = h.saturating_add(padding.saturating_mul(2)); let new_h = h.saturating_add(padding.saturating_mul(2)).min(65_535);
let mut canvas = RgbaImage::from_pixel(new_w, new_h, Rgba([255, 255, 255, 255])); let mut canvas = RgbaImage::from_pixel(new_w, new_h, Rgba([255, 255, 255, 255]));

View File

@@ -31,6 +31,7 @@ pub fn apply_template_full(
if let Some(end) = result[start..].find('}') { if let Some(end) = result[start..].find('}') {
let spec = &result[start + 9..start + end]; let spec = &result[start + 9..start + end];
if let Ok(padding) = spec.parse::<usize>() { if let Ok(padding) = spec.parse::<usize>() {
let padding = padding.min(10);
let counter_str = format!("{:0>width$}", counter, width = padding); let counter_str = format!("{:0>width$}", counter, width = padding);
result = format!("{}{}{}", &result[..start], counter_str, &result[start + end + 1..]); result = format!("{}{}{}", &result[..start], counter_str, &result[start + end + 1..]);
} }

View File

@@ -363,12 +363,8 @@ fn apply_tiled_image_watermark(
reason: format!("Failed to load watermark image: {}", e), reason: format!("Failed to load watermark image: {}", e),
})?; })?;
let wm_width = (watermark.width() as f32 * scale) as u32; let wm_width = ((watermark.width() as f32 * scale) as u32).clamp(1, 16384);
let wm_height = (watermark.height() as f32 * scale) as u32; let wm_height = ((watermark.height() as f32 * scale) as u32).clamp(1, 16384);
if wm_width == 0 || wm_height == 0 {
return Ok(img);
}
let mut watermark = watermark.resize_exact(wm_width, wm_height, image::imageops::FilterType::Lanczos3); let mut watermark = watermark.resize_exact(wm_width, wm_height, image::imageops::FilterType::Lanczos3);
if let Some(rot) = rotation { if let Some(rot) = rotation {
@@ -429,13 +425,9 @@ fn apply_image_watermark(
reason: format!("Failed to load watermark image: {}", e), reason: format!("Failed to load watermark image: {}", e),
})?; })?;
// Scale the watermark // Scale the watermark (capped to prevent OOM on extreme scale values)
let wm_width = (watermark.width() as f32 * scale) as u32; let wm_width = ((watermark.width() as f32 * scale) as u32).clamp(1, 16384);
let wm_height = (watermark.height() as f32 * scale) as u32; let wm_height = ((watermark.height() as f32 * scale) as u32).clamp(1, 16384);
if wm_width == 0 || wm_height == 0 {
return Ok(img);
}
let mut watermark = watermark.resize_exact( let mut watermark = watermark.resize_exact(
wm_width, wm_width,

View File

@@ -80,6 +80,7 @@ impl ProcessingJob {
|| self.convert.is_some() || self.convert.is_some()
|| self.compress.is_some() || self.compress.is_some()
|| self.watermark.is_some() || self.watermark.is_some()
|| self.output_dpi > 0
} }
pub fn output_path_for( pub fn output_path_for(

View File

@@ -211,9 +211,9 @@ pub fn build_app() -> adw::Application {
.map(|p| p.display().to_string()) .map(|p| p.display().to_string())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
action.downcast_ref::<gtk::gio::SimpleAction>() if let Some(simple) = action.downcast_ref::<gtk::gio::SimpleAction>() {
.unwrap() simple.activate(Some(&paths_str.to_variant()));
.activate(Some(&paths_str.to_variant())); }
} }
} }
}); });
@@ -617,6 +617,13 @@ fn build_ui(app: &adw::Application) {
state.expanded_sections = app_state_for_close.expanded_sections.borrow().clone(); state.expanded_sections = app_state_for_close.expanded_sections.borrow().clone();
let _ = session.save(&state); let _ = session.save(&state);
// Clean up temporary download directory
let temp_downloads = std::env::temp_dir().join("pixstrip-downloads");
if temp_downloads.exists() {
let _ = std::fs::remove_dir_all(&temp_downloads);
}
glib::Propagation::Proceed glib::Propagation::Proceed
}); });
} }
@@ -2043,7 +2050,7 @@ fn continue_processing(
file, file,
} => { } => {
if let Some(ref bar) = progress_bar { if let Some(ref bar) = progress_bar {
let frac = current as f64 / total as f64; let frac = if total > 0 { (current as f64 / total as f64).clamp(0.0, 1.0) } else { 0.0 };
bar.set_fraction(frac); bar.set_fraction(frac);
bar.set_text(Some(&format!("{}/{} - {}", current, total, file))); bar.set_text(Some(&format!("{}/{} - {}", current, total, file)));
bar.update_property(&[ bar.update_property(&[
@@ -2122,17 +2129,8 @@ fn show_results(
.map(|p| p.display().to_string()) .map(|p| p.display().to_string())
.unwrap_or_default(); .unwrap_or_default();
// Collect actual output files from the output directory // Use actual output file paths from the executor (only successfully written files)
let output_files: Vec<String> = if let Some(ref dir) = *ui.state.output_dir.borrow() { let output_files: Vec<String> = result.output_files.clone();
std::fs::read_dir(dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.map(|e| e.path().display().to_string())
.collect()
} else {
vec![]
};
let _ = history.add(pixstrip_core::storage::HistoryEntry { let _ = history.add(pixstrip_core::storage::HistoryEntry {
timestamp: format!( timestamp: format!(
@@ -2496,9 +2494,12 @@ fn calculate_eta(start: &std::time::Instant, current: usize, total: usize) -> St
if current == 0 { if current == 0 {
return "Estimating time remaining...".into(); return "Estimating time remaining...".into();
} }
if current >= total {
return "Almost done...".into();
}
let elapsed = start.elapsed().as_secs_f64(); let elapsed = start.elapsed().as_secs_f64();
let per_image = elapsed / current as f64; let per_image = elapsed / current as f64;
let remaining = (total - current) as f64 * per_image; let remaining = (total.saturating_sub(current)) as f64 * per_image;
if remaining < 1.0 { if remaining < 1.0 {
"Almost done...".into() "Almost done...".into()
} else { } else {

View File

@@ -16,7 +16,7 @@ use gtk::prelude::*;
pub fn full_text_list_factory() -> gtk::SignalListItemFactory { pub fn full_text_list_factory() -> gtk::SignalListItemFactory {
let factory = gtk::SignalListItemFactory::new(); let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(|_, item| { factory.connect_setup(|_, item| {
let item = item.downcast_ref::<gtk::ListItem>().unwrap(); let Some(item) = item.downcast_ref::<gtk::ListItem>() else { return };
let label = gtk::Label::builder() let label = gtk::Label::builder()
.xalign(0.0) .xalign(0.0)
.margin_start(8) .margin_start(8)
@@ -27,7 +27,7 @@ pub fn full_text_list_factory() -> gtk::SignalListItemFactory {
item.set_child(Some(&label)); item.set_child(Some(&label));
}); });
factory.connect_bind(|_, item| { factory.connect_bind(|_, item| {
let item = item.downcast_ref::<gtk::ListItem>().unwrap(); let Some(item) = item.downcast_ref::<gtk::ListItem>() else { return };
if let Some(obj) = item.item() { if let Some(obj) = item.item() {
if let Some(string_obj) = obj.downcast_ref::<gtk::StringObject>() { if let Some(string_obj) = obj.downcast_ref::<gtk::StringObject>() {
if let Some(label) = item.child().and_downcast_ref::<gtk::Label>() { if let Some(label) = item.child().and_downcast_ref::<gtk::Label>() {

View File

@@ -282,7 +282,7 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
return; return;
} }
let idx = pidx.get().min(loaded.len() - 1); let idx = pidx.get().min(loaded.len().saturating_sub(1));
pidx.set(idx); pidx.set(idx);
let path = loaded[idx].clone(); let path = loaded[idx].clone();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("image"); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("image");

View File

@@ -883,20 +883,25 @@ fn download_image_url(url: &str) -> Option<std::path::PathBuf> {
let temp_dir = std::env::temp_dir().join("pixstrip-downloads"); let temp_dir = std::env::temp_dir().join("pixstrip-downloads");
std::fs::create_dir_all(&temp_dir).ok()?; std::fs::create_dir_all(&temp_dir).ok()?;
// Extract filename from URL // Extract and sanitize filename from URL to prevent path traversal
let url_path = url.split('?').next().unwrap_or(url); let url_path = url.split('?').next().unwrap_or(url);
let filename = url_path let raw_name = url_path
.rsplit('/') .rsplit('/')
.next() .next()
.unwrap_or("downloaded.jpg") .unwrap_or("downloaded.jpg");
.to_string(); let sanitized = std::path::Path::new(raw_name)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("downloaded.jpg");
let filename = if sanitized.is_empty() { "downloaded.jpg" } else { sanitized };
let dest = temp_dir.join(&filename); let dest = temp_dir.join(filename);
// Use GIO for the download (synchronous, runs in background thread) // Use GIO for the download (synchronous, runs in background thread)
let gfile = gtk::gio::File::for_uri(url); let gfile = gtk::gio::File::for_uri(url);
let stream = gfile.read(gtk::gio::Cancellable::NONE).ok()?; let stream = gfile.read(gtk::gio::Cancellable::NONE).ok()?;
const MAX_DOWNLOAD_BYTES: usize = 100 * 1024 * 1024; // 100 MB
let mut buf = Vec::new(); let mut buf = Vec::new();
loop { loop {
let bytes = stream.read_bytes(8192, gtk::gio::Cancellable::NONE).ok()?; let bytes = stream.read_bytes(8192, gtk::gio::Cancellable::NONE).ok()?;
@@ -904,6 +909,9 @@ fn download_image_url(url: &str) -> Option<std::path::PathBuf> {
break; break;
} }
buf.extend_from_slice(&bytes); buf.extend_from_slice(&bytes);
if buf.len() > MAX_DOWNLOAD_BYTES {
return None;
}
} }
if buf.is_empty() { if buf.is_empty() {

View File

@@ -384,7 +384,8 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
tr.set_text(&var_text); tr.set_text(&var_text);
tr.set_position(var_text.chars().count() as i32); tr.set_position(var_text.chars().count() as i32);
} else { } else {
let pos = tr.position() as usize; let char_count = current.chars().count();
let pos = (tr.position().max(0) as usize).min(char_count);
let byte_pos = current.char_indices() let byte_pos = current.char_indices()
.nth(pos) .nth(pos)
.map(|(i, _)| i) .map(|(i, _)| i)
@@ -392,7 +393,7 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
let mut new_text = current.clone(); let mut new_text = current.clone();
new_text.insert_str(byte_pos, &var_text); new_text.insert_str(byte_pos, &var_text);
tr.set_text(&new_text); tr.set_text(&new_text);
tr.set_position((pos + var_text.chars().count()) as i32); tr.set_position((pos.saturating_add(var_text.chars().count())) as i32);
} }
}); });

View File

@@ -445,12 +445,14 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
std::thread::spawn(move || { std::thread::spawn(move || {
let result = (|| -> Option<Vec<u8>> { let result = (|| -> Option<Vec<u8>> {
let img = image::open(&path).ok()?; let img = image::open(&path).ok()?;
let target_w = if render_tw > 0 { render_tw } else { img.width() }; let target_w = if render_tw > 0 { render_tw } else { img.width().max(1) };
let target_h = if render_th > 0 { let target_h = if render_th > 0 {
render_th render_th
} else { } else if img.width() > 0 {
let scale = target_w as f64 / img.width() as f64; let scale = target_w as f64 / img.width() as f64;
(img.height() as f64 * scale).round() as u32 (img.height() as f64 * scale).round().max(1.0) as u32
} else {
target_w
}; };
let resized = if mode == 0 && render_th > 0 { let resized = if mode == 0 && render_th > 0 {
// Exact: stretch to exact dimensions // Exact: stretch to exact dimensions

View File

@@ -434,7 +434,7 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
return; return;
} }
let idx = pidx.get().min(loaded.len() - 1); let idx = pidx.get().min(loaded.len().saturating_sub(1));
pidx.set(idx); pidx.set(idx);
let path = loaded[idx].clone(); let path = loaded[idx].clone();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("image"); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("image");