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:
@@ -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 {
|
||||
|
||||
@@ -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>() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user