Fix 16 medium-severity bugs from audit

CLI: add UTC suffix to timestamps, validate image extensions on
single-file input, canonicalize watch paths for reliable matching,
derive counter_enabled from template presence, warn when undo count
exceeds available batches.

Core: apply space/special-char transforms in template rename path,
warn on metadata preservation for unsupported formats, derive AVIF
speed from compress preset quality level.

GTK: use buffer size for apples-to-apples compress preview comparison,
shorten approximate format labels, cache file sizes to avoid repeated
syscalls on checkbox toggle, add batch-update guard to prevent O(n^2)
in select/deselect all, use widget names for reliable progress/log
lookup, add unique suffix for duplicate download filenames.
This commit is contained in:
2026-03-07 23:02:57 +02:00
parent 9ef33fa90f
commit 9fcbe237bd
7 changed files with 136 additions and 50 deletions

View File

@@ -36,6 +36,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
{
let loaded_files = state.loaded_files.clone();
let excluded = state.excluded_files.clone();
let sizes = state.file_sizes.clone();
let stack_ref = stack.clone();
let subfolder_choice = subfolder_choice.clone();
drop_target.connect_drop(move |target, value, _x, _y| {
@@ -49,7 +50,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
add_images_flat(&path, &mut files);
let count = files.len();
drop(files);
refresh_grid(&stack_ref, &loaded_files, &excluded, count);
refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count);
} else {
let choice = *subfolder_choice.borrow();
match choice {
@@ -58,24 +59,25 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
add_images_from_dir(&path, &mut files);
let count = files.len();
drop(files);
refresh_grid(&stack_ref, &loaded_files, &excluded, count);
refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count);
}
Some(false) => {
let mut files = loaded_files.borrow_mut();
add_images_flat(&path, &mut files);
let count = files.len();
drop(files);
refresh_grid(&stack_ref, &loaded_files, &excluded, count);
refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count);
}
None => {
let mut files = loaded_files.borrow_mut();
add_images_flat(&path, &mut files);
let count = files.len();
drop(files);
refresh_grid(&stack_ref, &loaded_files, &excluded, count);
refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count);
let loaded_files = loaded_files.clone();
let excluded = excluded.clone();
let sz = sizes.clone();
let stack_ref = stack_ref.clone();
let subfolder_choice = subfolder_choice.clone();
let dir_path = path.clone();
@@ -88,6 +90,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
&dir_path,
&loaded_files,
&excluded,
&sz,
&stack_ref,
&subfolder_choice,
);
@@ -103,7 +106,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
}
let count = files.len();
drop(files);
refresh_grid(&stack_ref, &loaded_files, &excluded, count);
refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count);
return true;
}
}
@@ -118,6 +121,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
{
let loaded_files = state.loaded_files.clone();
let excluded = state.excluded_files.clone();
let sizes = state.file_sizes.clone();
let stack_ref = stack.clone();
uri_drop.connect_drop(move |_target, value, _x, _y| {
if let Ok(text) = value.get::<glib::GString>() {
@@ -141,6 +145,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
if is_image_url {
let loaded = loaded_files.clone();
let excl = excluded.clone();
let sz = sizes.clone();
let sr = stack_ref.clone();
// Download in background thread
let (tx, rx) = std::sync::mpsc::channel::<Option<std::path::PathBuf>>();
@@ -159,7 +164,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
}
let count = files.len();
drop(files);
refresh_grid(&sr, &loaded, &excl, count);
refresh_grid(&sr, &loaded, &excl, &sz, count);
glib::ControlFlow::Break
}
Ok(None) => glib::ControlFlow::Break,
@@ -259,6 +264,7 @@ fn show_subfolder_prompt(
dir: &std::path::Path,
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
file_sizes: &Rc<RefCell<std::collections::HashMap<PathBuf, u64>>>,
stack: &gtk::Stack,
subfolder_choice: &Rc<RefCell<Option<bool>>>,
) {
@@ -273,6 +279,7 @@ fn show_subfolder_prompt(
let loaded_files = loaded_files.clone();
let excluded = excluded.clone();
let file_sizes = file_sizes.clone();
let stack = stack.clone();
let subfolder_choice = subfolder_choice.clone();
let dir = dir.to_path_buf();
@@ -284,7 +291,7 @@ fn show_subfolder_prompt(
add_images_from_subdirs(&dir, &mut files);
let count = files.len();
drop(files);
refresh_grid(&stack, &loaded_files, &excluded, count);
refresh_grid(&stack, &loaded_files, &excluded, &file_sizes, count);
}
});
@@ -302,6 +309,7 @@ fn refresh_grid(
stack: &gtk::Stack,
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
file_sizes: &Rc<RefCell<std::collections::HashMap<PathBuf, u64>>>,
count: usize,
) {
if count > 0 {
@@ -310,7 +318,7 @@ fn refresh_grid(
stack.set_visible_child_name("empty");
}
if let Some(loaded_widget) = stack.child_by_name("loaded") {
rebuild_grid_model(&loaded_widget, loaded_files, excluded);
rebuild_grid_model(&loaded_widget, loaded_files, excluded, file_sizes);
}
}
@@ -319,15 +327,25 @@ pub fn rebuild_grid_model(
widget: &gtk::Widget,
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
file_sizes: &Rc<RefCell<std::collections::HashMap<PathBuf, u64>>>,
) {
let files = loaded_files.borrow();
let excluded_set = excluded.borrow();
let count = files.len();
let included_count = files.iter().filter(|p| !excluded_set.contains(*p)).count();
// Populate size cache for any new files
{
let mut sizes = file_sizes.borrow_mut();
for p in files.iter() {
sizes.entry(p.clone()).or_insert_with(|| {
std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)
});
}
}
let sizes = file_sizes.borrow();
let total_size: u64 = files.iter()
.filter(|p| !excluded_set.contains(*p))
.filter_map(|p| std::fs::metadata(p).ok())
.map(|m| m.len())
.map(|p| sizes.get(p).copied().unwrap_or(0))
.sum();
let size_str = format_size(total_size);
@@ -386,15 +404,16 @@ fn update_count_label(
widget: &gtk::Widget,
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
file_sizes: &Rc<RefCell<std::collections::HashMap<PathBuf, u64>>>,
) {
let files = loaded_files.borrow();
let excluded_set = excluded.borrow();
let sizes = file_sizes.borrow();
let count = files.len();
let included_count = files.iter().filter(|p| !excluded_set.contains(*p)).count();
let total_size: u64 = files.iter()
.filter(|p| !excluded_set.contains(*p))
.filter_map(|p| std::fs::metadata(p).ok())
.map(|m| m.len())
.map(|p| sizes.get(p).copied().unwrap_or(0))
.sum();
let size_str = format_size(total_size);
update_heading_label(widget, count, included_count, &size_str);
@@ -531,6 +550,9 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
.spacing(0)
.build();
// Guard flag to prevent O(n^2) update cascade during batch checkbox operations
let batch_updating: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
// Toolbar
let toolbar = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
@@ -675,6 +697,8 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
{
let excluded = state.excluded_files.clone();
let loaded = state.loaded_files.clone();
let cached_sizes = state.file_sizes.clone();
let batch_flag = batch_updating.clone();
factory.connect_bind(move |_factory, list_item| {
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else { return };
let Some(item) = list_item.item().and_downcast::<ImageItem>() else { return };
@@ -733,6 +757,8 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
// Wire checkbox toggle
let excl = excluded.clone();
let loaded_ref = loaded.clone();
let sizes_ref = cached_sizes.clone();
let batch_guard = batch_flag.clone();
let file_path = path.clone();
let handler_id = check.connect_toggled(move |btn| {
{
@@ -743,12 +769,16 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
excl.insert(file_path.clone());
}
}
// Skip per-toggle label update during batch select/deselect
if batch_guard.get() {
return;
}
// Update count label
if let Some(parent) = btn.ancestor(gtk::Stack::static_type())
&& let Some(stack) = parent.downcast_ref::<gtk::Stack>()
&& let Some(loaded_widget) = stack.child_by_name("loaded")
{
update_count_label(&loaded_widget, &loaded_ref, &excl);
update_count_label(&loaded_widget, &loaded_ref, &excl, &sizes_ref);
}
});
// Store handler id so we can disconnect in unbind
@@ -817,14 +847,18 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
{
let excl = state.excluded_files.clone();
let loaded = state.loaded_files.clone();
let sizes = state.file_sizes.clone();
let batch_flag = batch_updating.clone();
select_all_button.connect_clicked(move |btn| {
excl.borrow_mut().clear();
if let Some(parent) = btn.ancestor(gtk::Stack::static_type())
&& let Some(stack) = parent.downcast_ref::<gtk::Stack>()
&& let Some(loaded_widget) = stack.child_by_name("loaded")
{
batch_flag.set(true);
set_all_checkboxes_in(&loaded_widget, true);
update_count_label(&loaded_widget, &loaded, &excl);
batch_flag.set(false);
update_count_label(&loaded_widget, &loaded, &excl, &sizes);
}
});
}
@@ -833,6 +867,8 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
{
let excl = state.excluded_files.clone();
let loaded = state.loaded_files.clone();
let sizes = state.file_sizes.clone();
let batch_flag = batch_updating.clone();
deselect_all_button.connect_clicked(move |btn| {
{
let files = loaded.borrow();
@@ -845,8 +881,10 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
&& let Some(stack) = parent.downcast_ref::<gtk::Stack>()
&& let Some(loaded_widget) = stack.child_by_name("loaded")
{
batch_flag.set(true);
set_all_checkboxes_in(&loaded_widget, false);
update_count_label(&loaded_widget, &loaded, &excl);
batch_flag.set(false);
update_count_label(&loaded_widget, &loaded, &excl, &sizes);
}
});
}
@@ -930,7 +968,22 @@ fn download_image_url(url: &str) -> Option<std::path::PathBuf> {
.unwrap_or("downloaded.jpg");
let filename = if sanitized.is_empty() { "downloaded.jpg" } else { sanitized };
let dest = temp_dir.join(filename);
// Avoid overwriting previous downloads with the same filename
let dest = {
let base = temp_dir.join(filename);
if base.exists() {
let stem = std::path::Path::new(filename).file_stem()
.and_then(|s| s.to_str()).unwrap_or("downloaded");
let ext = std::path::Path::new(filename).extension()
.and_then(|e| e.to_str()).unwrap_or("jpg");
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis()).unwrap_or(0);
temp_dir.join(format!("{}_{}.{}", stem, ts, ext))
} else {
base
}
};
// Use GIO for the download (synchronous, runs in background thread)
let gfile = gtk::gio::File::for_uri(url);