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 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 {
timestamp: chrono_timestamp(),
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_output_bytes: result.total_output_bytes,
elapsed_ms: result.elapsed_ms,
output_files,
output_files: result.output_files,
}, 50, 30) {
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 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 {
let watcher = pixstrip_core::watcher::FolderWatcher::new();
@@ -784,19 +757,41 @@ fn cmd_watch_start() {
eprintln!("Failed to start watching {}: {}", watch.path.display(), e);
continue;
}
output_dirs.push(watch.path.join("processed"));
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
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)) {
continue;
}
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
let matched = active.iter()
.find(|w| path.starts_with(&w.path))
.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 {
eprintln!(" Preset '{}' not found, skipping", preset_name);
continue;

View File

@@ -86,8 +86,8 @@ impl OutputEncoder {
if self.options.output_dpi > 0 {
comp.set_pixel_density(mozjpeg::PixelDensity {
unit: mozjpeg::PixelDensityUnit::Inches,
x: self.options.output_dpi as u16,
y: self.options.output_dpi as u16,
x: self.options.output_dpi.min(65535) 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 errors: Vec<(String, String)>,
pub elapsed_ms: u64,
/// Actual output file paths written by the executor (only successfully written files).
pub output_files: Vec<String>,
}
pub struct PipelineExecutor {
@@ -98,6 +100,7 @@ impl PipelineExecutor {
total_output_bytes: 0,
errors: Vec::new(),
elapsed_ms: 0,
output_files: Vec::new(),
});
}
@@ -122,6 +125,7 @@ impl PipelineExecutor {
let output_bytes = AtomicU64::new(0);
let cancelled = AtomicBool::new(false);
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
let (tx, rx) = std::sync::mpsc::channel::<ProgressUpdate>();
@@ -141,6 +145,7 @@ impl PipelineExecutor {
let output_bytes_ref = &output_bytes;
let cancelled_ref = &cancelled;
let errors_ref = &errors;
let written_ref = &written_files;
let worker = scope.spawn(move || {
pool.install(|| {
@@ -189,10 +194,15 @@ impl PipelineExecutor {
});
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);
input_bytes_ref.fetch_add(in_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) => {
failed_ref.fetch_add(1, Ordering::Relaxed);
@@ -231,6 +241,7 @@ impl PipelineExecutor {
total_output_bytes: output_bytes.load(Ordering::Relaxed),
errors: errors.into_inner().unwrap_or_default(),
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,
errors: Vec::new(),
elapsed_ms: 0,
output_files: Vec::new(),
};
for (i, source) in job.sources.iter().enumerate() {
@@ -291,10 +303,13 @@ impl PipelineExecutor {
});
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.total_input_bytes += input_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) => {
result.failed += 1;
@@ -316,14 +331,24 @@ impl PipelineExecutor {
loader: &ImageLoader,
encoder: &OutputEncoder,
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)
.map(|m| m.len())
.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),
// 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 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");
@@ -333,8 +358,12 @@ impl PipelineExecutor {
.ok()
.and_then(|r| r.with_guessed_format().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(
template, stem, ext,
template, &working_stem, ext,
rename.counter_start.saturating_add(index as u32),
dims, Some(ext), Some(&source.path), None,
);
@@ -348,18 +377,26 @@ impl PipelineExecutor {
} else {
new_name
};
job.output_dir.join(new_name)
job.output_dir.join(sanitize_filename(&new_name))
} else {
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 {
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 {
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() => {
find_unique_path(&output_path)
@@ -368,10 +405,16 @@ impl PipelineExecutor {
};
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
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);
return Ok((input_size, output_size));
return Ok((input_size, output_size, output_path));
}
// Load image
@@ -494,21 +537,29 @@ impl PipelineExecutor {
} else {
new_name
};
job.output_dir.join(new_name)
job.output_dir.join(sanitize_filename(&new_name))
} else {
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 {
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
let output_path = match job.overwrite_behavior {
crate::operations::OverwriteAction::Skip => {
if output_path.exists() {
// Return 0 bytes written - file was skipped
return Ok((input_size, 0));
return Ok((input_size, 0, std::path::PathBuf::new()));
}
output_path
}
@@ -524,16 +575,25 @@ impl PipelineExecutor {
// Ensure output directory exists
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)
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
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.
// KeepAll: copy everything back from source.
@@ -563,7 +623,7 @@ impl PipelineExecutor {
.map(|m| m.len())
.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
3 => img.rotate180(), // Rotated 180
4 => img.flipv(), // Flipped vertical
5 => img.rotate90().fliph(), // Transposed
5 => img.fliph().rotate270(), // Transposed
6 => img.rotate90(), // Rotated 90 CW
7 => img.rotate270().fliph(), // Transverse
7 => img.fliph().rotate90(), // Transverse
8 => img.rotate270(), // Rotated 270 CW
_ => 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 {
let stem = path
.file_stem()
@@ -633,13 +723,21 @@ fn find_unique_path(path: &std::path::Path) -> std::path::PathBuf {
.unwrap_or("bin");
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 {
let candidate = 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,
}
}
// Extremely unlikely fallback
// Extremely unlikely fallback - use timestamp for uniqueness
parent.join(format!("{}_{}.{}", stem, std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.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 {
// Image is wider than target, crop width
let new_w = (ih as f64 * target_ratio) as u32;
(new_w.min(iw), ih)
let new_w = (ih as f64 * target_ratio).round().max(1.0) as u32;
(new_w.min(iw).max(1), ih)
} else {
// Image is taller than target, crop height
let new_h = (iw as f64 / target_ratio) as u32;
(iw, new_h.min(ih))
let new_h = (iw as f64 / target_ratio).round().max(1.0) as u32;
(iw, new_h.min(ih).max(1))
};
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 {
// Cap padding to prevent unreasonable memory allocation
let padding = padding.min(10_000);
let (w, h) = (img.width(), img.height());
let new_w = w.saturating_add(padding.saturating_mul(2));
let new_h = h.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)).min(65_535);
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('}') {
let spec = &result[start + 9..start + end];
if let Ok(padding) = spec.parse::<usize>() {
let padding = padding.min(10);
let counter_str = format!("{:0>width$}", counter, width = padding);
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),
})?;
let wm_width = (watermark.width() as f32 * scale) as u32;
let wm_height = (watermark.height() as f32 * scale) as u32;
if wm_width == 0 || wm_height == 0 {
return Ok(img);
}
let wm_width = ((watermark.width() as f32 * scale) as u32).clamp(1, 16384);
let wm_height = ((watermark.height() as f32 * scale) as u32).clamp(1, 16384);
let mut watermark = watermark.resize_exact(wm_width, wm_height, image::imageops::FilterType::Lanczos3);
if let Some(rot) = rotation {
@@ -429,13 +425,9 @@ fn apply_image_watermark(
reason: format!("Failed to load watermark image: {}", e),
})?;
// Scale the watermark
let wm_width = (watermark.width() as f32 * scale) as u32;
let wm_height = (watermark.height() as f32 * scale) as u32;
if wm_width == 0 || wm_height == 0 {
return Ok(img);
}
// Scale the watermark (capped to prevent OOM on extreme scale values)
let wm_width = ((watermark.width() as f32 * scale) as u32).clamp(1, 16384);
let wm_height = ((watermark.height() as f32 * scale) as u32).clamp(1, 16384);
let mut watermark = watermark.resize_exact(
wm_width,

View File

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

View File

@@ -211,9 +211,9 @@ pub fn build_app() -> adw::Application {
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join("\n");
action.downcast_ref::<gtk::gio::SimpleAction>()
.unwrap()
.activate(Some(&paths_str.to_variant()));
if let Some(simple) = action.downcast_ref::<gtk::gio::SimpleAction>() {
simple.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();
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
});
}
@@ -2043,7 +2050,7 @@ fn continue_processing(
file,
} => {
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_text(Some(&format!("{}/{} - {}", current, total, file)));
bar.update_property(&[
@@ -2122,17 +2129,8 @@ fn show_results(
.map(|p| p.display().to_string())
.unwrap_or_default();
// Collect actual output files from the output directory
let output_files: Vec<String> = if let Some(ref dir) = *ui.state.output_dir.borrow() {
std::fs::read_dir(dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.map(|e| e.path().display().to_string())
.collect()
} else {
vec![]
};
// Use actual output file paths from the executor (only successfully written files)
let output_files: Vec<String> = result.output_files.clone();
let _ = history.add(pixstrip_core::storage::HistoryEntry {
timestamp: format!(
@@ -2496,9 +2494,12 @@ fn calculate_eta(start: &std::time::Instant, current: usize, total: usize) -> St
if current == 0 {
return "Estimating time remaining...".into();
}
if current >= total {
return "Almost done...".into();
}
let elapsed = start.elapsed().as_secs_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 {
"Almost done...".into()
} else {

View File

@@ -16,7 +16,7 @@ use gtk::prelude::*;
pub fn full_text_list_factory() -> gtk::SignalListItemFactory {
let factory = gtk::SignalListItemFactory::new();
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()
.xalign(0.0)
.margin_start(8)
@@ -27,7 +27,7 @@ pub fn full_text_list_factory() -> gtk::SignalListItemFactory {
item.set_child(Some(&label));
});
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(string_obj) = obj.downcast_ref::<gtk::StringObject>() {
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;
}
let idx = pidx.get().min(loaded.len() - 1);
let idx = pidx.get().min(loaded.len().saturating_sub(1));
pidx.set(idx);
let path = loaded[idx].clone();
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");
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 filename = url_path
let raw_name = url_path
.rsplit('/')
.next()
.unwrap_or("downloaded.jpg")
.to_string();
.unwrap_or("downloaded.jpg");
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)
let gfile = gtk::gio::File::for_uri(url);
let stream = gfile.read(gtk::gio::Cancellable::NONE).ok()?;
const MAX_DOWNLOAD_BYTES: usize = 100 * 1024 * 1024; // 100 MB
let mut buf = Vec::new();
loop {
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;
}
buf.extend_from_slice(&bytes);
if buf.len() > MAX_DOWNLOAD_BYTES {
return None;
}
}
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_position(var_text.chars().count() as i32);
} 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()
.nth(pos)
.map(|(i, _)| i)
@@ -392,7 +393,7 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
let mut new_text = current.clone();
new_text.insert_str(byte_pos, &var_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 || {
let result = (|| -> Option<Vec<u8>> {
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 {
render_th
} else {
} else if img.width() > 0 {
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 {
// Exact: stretch to exact dimensions

View File

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