fix(security): harden media decoding, file actions, updater and Windows open-in-place - #657
Conversation
PoC suiteThe suite is attached in the PR description: poc-suite.zip Contents: payload generators (GIF/JPEG/MP4 bombs, malicious nupkg), the standalone harness with vendored pre-fix code paths, the Windows VM verification scripts, and |
There was a problem hiding this comment.
Pull request overview
This PR delivers broad security hardening across media decoding, filesystem actions, the updater supply chain, and external-open behavior (notably on Windows), with added regression tests for key exploit classes.
Changes:
- Add decode-time resource limits and header-only parsing paths to prevent allocation/CPU bombs (GIF/JPEG/PNG/MP4, ffmpeg pipe).
- Refuse symbolic-link sources in file actions and surface refusals via a transient in-app status banner (with i18n strings).
- Harden the velopack update flow by downloading + size-checking + PGP-verifying before velopack extraction, and pin the GPG import action by commit SHA.
Reviewed changes
Copilot reviewed 34 out of 36 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| resources/locale/ja/main.ftl | Adds localized status banner strings for new refusal/hardening UX. |
| resources/locale/en/main.ftl | Adds localized status banner strings for new refusal/hardening UX. |
| resources/locale/de/main.ftl | Adds localized status banner strings for new refusal/hardening UX. |
| crates/mpv-utils/src/worker.rs | Hardens RGBA rotation against degenerate/overflow dimensions. |
| crates/mpv-utils/src/rotation.rs | Hardens MP4 box walking and adds overflow regression test. |
| crates/mpv-utils/src/mpv_context.rs | Adjusts render callback drop ordering to reduce UAF risk window. |
| crates/media-sort-gui/src/view/overlay.rs | Adds non-blocking transient status toast UI element. |
| crates/media-sort-gui/src/view/main_layout.rs | Renders status toast in the main view stack. |
| crates/media-sort-gui/src/updater.rs | Reorders update download/verify vs velopack extraction; validates filenames; adds purge helper. |
| crates/media-sort-gui/src/update/tasks.rs | Applies image decode limits for audio cover; replaces Windows cmd-open with open crate; hardens Linux reveal URI encoding. |
| crates/media-sort-gui/src/update/folder.rs | Validates create-folder names with shared rename validation + status banner. |
| crates/media-sort-gui/src/update/drag_drop.rs | Refuses symlinks on drop and reports refusals via status banner. |
| crates/media-sort-gui/src/update.rs | Adds status banner expiry handling on Tick. |
| crates/media-sort-gui/src/subscriptions/prefetch.rs | Bounds thumbnail work queues and adds recv timeouts to prevent hangs/unbounded growth. |
| crates/media-sort-gui/src/state/media_errors.rs | Caps tracked decode errors to bound memory growth. |
| crates/media-sort-gui/src/state.rs | Adds transient status banner state + helper to set it. |
| crates/media-sort-gui/Cargo.toml | Adds open dependency for safer cross-platform “open externally”. |
| crates/media-sort-core/src/settings/store.rs | Makes config save atomic and attempts to preserve symlinked config paths. |
| crates/media-sort-core/src/path_utils.rs | Scopes EXDEV detection to unix platforms. |
| crates/media-sort-core/src/actions/reversible.rs | Introduces symlink-source rejection helper and error variant. |
| crates/media-sort-core/src/actions/rename_action.rs | Validates ./.. and rejects symlink sources. |
| crates/media-sort-core/src/actions/move_action.rs | Rejects symlink sources and refuses overwriting existing destination; adds security tests. |
| crates/media-sort-core/src/actions/copy_action.rs | Rejects symlink sources. |
| crates/media-sort-backend/tests/thumbnail_tests.rs | Adds regression test for turbojpeg SOF dimension bomb. |
| crates/media-sort-backend/tests/metadata_tests.rs | Adds regression test for GIF bomb handling via header scan. |
| crates/media-sort-backend/src/media/thumbnail.rs | Applies decode limits when decoding embedded audio cover art. |
| crates/media-sort-backend/src/media/image_decoder.rs | Adds shared decode limits + header-only GIF animation scan + sub-block skipper. |
| crates/media-sort-backend/src/media/format_pipeline.rs | Applies decode limits across format pipeline + adds JPEG dimension cap and checked allocation math. |
| crates/media-sort-backend/src/media/ffmpeg_pipe.rs | Adds ffmpeg timeout, option-guard for - names, probe bounds, and PNG decode limits. |
| crates/media-sort-backend/src/filesystem/trash.rs | Avoids canonicalizing symlinks on Windows trash to prevent trashing link targets. |
| crates/iced-mpv/src/widget/shader.rs | Adds buffer-size validation before GPU upload to avoid wgpu panics. |
| crates/iced-mpv/src/state.rs | Validates FrameReady buffer length vs dimensions to avoid panics. |
| Cargo.lock | Locks new dependency graph entries (notably open and its deps). |
| AGENTS.md | Documents new security invariants and architectural details. |
| .gitignore | Ignores PoC suite artifacts and harness target directory. |
| .github/workflows/release.yml | Pins GPG import GitHub Action to a specific commit SHA. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 37 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
crates/media-sort-backend/src/media/ffmpeg_pipe.rs:132
- On the ffmpeg timeout path, the function returns without joining
stdout_thread/stderr_thread, detaching threads per timeout. If multiple corrupt inputs time out, this can briefly accumulate many orphan threads and extra resource usage.
None if std::time::Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
return Err(format!("ffmpeg timed out after {FFMPEG_TIMEOUT:?}"));
}
crates/media-sort-backend/src/media/image_decoder.rs:153
skip_sub_blocksusesSeekFrom::Currentfor every sub-block. This runs on the UI thread (viapoll_background_channelscallingis_animated_gif) and can turn a crafted GIF with many tiny sub-blocks into a large number of OS seeks / syscalls and visible UI stalls.
loop {
chunks += 1;
if chunks > 1_000_000 {
return false;
}
crates/media-sort-gui/src/update/drag_drop.rs:80
- Building
namesby collecting and joining all symlink file names is unbounded. Drag-dropping many symlinks can allocate a very large string and render an extremely wide toast, impacting UI responsiveness.
let names = symlink_paths
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect::<Vec<_>>()
.join(", ");
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 37 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/mpv-utils/src/worker.rs:101
rotate_rgbaallocates the destination buffer solely from the provided dimensions. If a caller ever passes inconsistent inputs (e.g. hostile/buggysrc_w/src_hwith a much smallersrcslice), this will allocatedst_w*dst_h*4bytes even though there isn't enough source data to justify that size. Consider validatingsrc.len()againstsrc_w*src_h*4and bailing out before allocating when the buffer is undersized.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 37 changed files in this pull request and generated no new comments.
Suppressed comments (4)
crates/media-sort-gui/src/updater.rs:304
- The fallback path after a failed rename always tries
remove_file(package_path). Iffs::renamefailed for a reason other than “destination exists” (e.g. permission/IO error) and the destination file is not present,remove_filereturns NotFound and masks the original error. It can also turn a transient rename failure into a hard failure even though a second rename attempt might have worked without deleting anything.
match fs::rename(&partial_path, &package_path) {
Ok(()) => Ok(()),
Err(_) => {
fs::remove_file(&package_path).map_err(|e| e.to_string())?;
fs::rename(&partial_path, &package_path).map_err(|e| e.to_string())
}
}
crates/media-sort-gui/src/updater.rs:277
response.bytes().awaitreads the entire.nupkginto memory before writing it to disk. Update packages can be large, so this can cause significant peak RSS (or OOM) during updates. Consider streaming the response body directly into the.partialfile (while enforcing the expected size / a hard cap) instead of buffering the whole package in RAM.
let package_bytes = response.bytes().await.map_err(|e| e.to_string())?;
if package_bytes.len() as u64 != info.TargetFullRelease.Size {
purge_packages_dir(&packages_dir).await;
crates/media-sort-core/src/settings/store.rs:151
- In the symlink-save branch,
read_linkfailures fall back topath.clone(). If that happens (race, permission issue, etc.), the subsequent temp-file rename will replace the symlink with a regular file — the opposite of what the surrounding comment promises. It’s safer to propagate theread_linkerror so we never clobber the user’s symlink.
let link_target = std::fs::read_link(&path).unwrap_or_else(|_| path.clone());
crates/media-sort-gui/src/subscriptions/prefetch.rs:181
- On
response_rx.recv_timeout(FFMPEG_RESPONSE_TIMEOUT)timeout (and also on a disconnected ffmpeg worker pool), the function returns an error immediately and never attempts the mpv fallback. That makes the thumbnail pipeline less resilient: if ffmpeg hangs on a particular file (or the pool is dead), mpv may still be able to extract a frame, but we won’t try it.
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
return Err(format!(
"ffmpeg thumbnail request timed out after {FFMPEG_RESPONSE_TIMEOUT:?}"
));
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 37 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/media-sort-gui/src/updater.rs:304
- In the atomic package write path, if
fs::rename(partial, package)fails for a reason other than an existing destination (e.g. transient Windows error), the fallback unconditionally callsfs::remove_file(package_path)and propagates its error. If the destination file does not exist, this returns NotFound and aborts the update even though a simple retry ofrenamewould be fine.
Ok(()) => Ok(()),
Err(_) => {
fs::remove_file(&package_path).map_err(|e| e.to_string())?;
fs::rename(&partial_path, &package_path).map_err(|e| e.to_string())
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 37 changed files in this pull request and generated no new comments.
Suppressed comments (2)
crates/media-sort-gui/src/update/drag_drop.rs:88
- The toast text is intended to be capped, but this currently collects all symlink file names into a
Vecbefore truncating toMAX_SHOWN_NAMES. A drop containing thousands of symlinks can still allocate an unbounded amount of memory here.
Only collect/join up to MAX_SHOWN_NAMES names and use symlink_paths.len() to decide whether to append an ellipsis.
let names = symlink_paths
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect::<Vec<_>>();
let names = if names.len() > MAX_SHOWN_NAMES {
crates/media-sort-gui/src/updater.rs:279
- The comment says this avoids buffering the whole response, but the implementation still accumulates the entire
.nupkginpackage_bytesbefore writing it to disk. This can mislead future readers about peak RSS behavior for large packages.
Update the comment to reflect the current behavior (incremental read with size caps, but full in-memory accumulation), or stream directly to the temp file if keeping RSS low is a requirement.
// Stream the package into memory with a hard cap instead of buffering
// the whole response: the feed controls content length, so a lying or
// compromised feed must not be able to drive unbounded RSS. The
// expected size is also enforced incrementally (abort as soon as the
// download exceeds it) and once more at the end.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 37 changed files in this pull request and generated no new comments.
Suppressed comments (3)
crates/media-sort-backend/src/media/image_decoder.rs:130
- After the change to early-return on trailer, the function should return
Nonewhen the scan ends for reasons other than a valid GIF trailer (e.g., malformed input, block cap exceeded, unexpected EOF). ReturningNonepreserves the original contract ofOption<bool>(unknown/unparseable vs. known-static).
}
Some(images >= 2)
crates/media-sort-backend/src/media/image_decoder.rs:85
is_animated_gif_readercurrently returnsSome(false)when the scan terminates due to malformed/truncated data (any non-trailerbreak), which conflates “static GIF” with “could not parse GIF”. That can cause callers to treat a corrupt/unknown GIF as a non-animated GIF. Consider returningNoneunless the scan reaches the GIF trailer (0x3B) or positively detects a second image descriptor.
This issue also appears on line 129 of the same file.
match block[0] {
0x3B => break, // trailer: end of image data
0x2C => {
crates/media-sort-core/src/actions/move_action.rs:214
- The temp directory helper for the security tests uses a timestamp-derived
rand()(subsec_nanos) which can collide when called multiple times in quick succession, making these tests potentially flaky due to shared directories/files. Prefer a monotonic per-process counter (ortempfile) to guarantee uniqueness.
fn temp_subdir() -> std::path::PathBuf {
let dir = temp_dir().join(format!("sub-{}", rand()));
std::fs::create_dir_all(&dir).ok();
dir
}
2bc3827 to
ef9e0c2
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 40 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/media-sort-backend/src/media/ffmpeg_pipe.rs:72
- The
--prefixed-path guard only checkspath.to_str(), so on Unix a non-UTF8 path starting with-will bypass the check and still be interpreted by ffmpeg as an option. Since this is a security hardening guard, it should operate on the raw OS string on Unix.
if path.to_str().is_some_and(|s| s.starts_with('-')) {
return Err(format!(
"cannot extract frame: path {:?} starts with '-'",
path
));
The '-' guard checked path.to_str(), which returns None for non-UTF8 paths (and, on Windows, paths with unpaired surrogates). Such a path whose first byte is '-' bypassed the check even though Command::arg passes the exact raw bytes to ffmpeg, which would parse the argument as an option. The check now operates on the raw OS string (as_bytes on unix, encode_wide on Windows), with regression tests.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 40 changed files in this pull request and generated no new comments.
Suppressed comments (4)
crates/mpv-utils/src/worker.rs:509
- The late-rotation recheck block claims it is “property-only” and does “no file I/O”, but the
paths_matchguard callscurrent_p.canonicalize(), which performs filesystem I/O. This reintroduces per-load file access in the tick path and contradicts the comment.
crates/media-sort-gui/src/updater.rs:190 validate_release_file_nameis used to build a GitHub release download URL later, but it doesn’t reject URL-reserved characters like%,#, or?(or whitespace). A feed-controlled filename containing%2Fcould be interpreted as a path separator by the HTTP layer/server. Consider rejecting these characters here (or percent-encoding path segments when constructing URLs).
crates/media-sort-gui/src/updater.rs:212validate_versionblocks path separators and whitespace, but still allows URL-reserved characters like%,#, and?. Sinceversionis interpolated into the download URL, allowing%2F-style sequences can cause the HTTP request path to differ from the intended literal version string.
crates/mpv-utils/src/worker.rs:117rotate_rgbaalways allocatesdst(and computesdst_size) even whenrotationisRotation::R0, but theR0match arm returnssrc.to_vec()and discards the allocation. This doubles the per-frame allocation/copy cost for the common no-rotation case.
- rotation recheck: the same-load guard no longer calls canonicalize() (which is file I/O) — it compares against the mpv-reported path string that passed the initial detection, which is stable within a load; the recheck is now genuinely zero-I/O as its comment claims - rotate_rgba: the R0 arm early-returns before allocating the dst buffer (the common no-rotation case no longer pays for a discarded allocation) - updater: validate_release_file_name/validate_version reject the URL-reserved chars %/#/? (and whitespace in file names) — when interpolated into the release URL, '#' starts a fragment, '?' a query, and '%' enables escape smuggling (e.g. %2F as a separator) - new regression tests for the URL-reserved rejections
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 40 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
crates/mpv-utils/src/worker.rs:113
- In the
Rotation::R0fast-path,src.to_vec()copies the entire slice, not just the validatedsrc_w * src_h * 4frame. If a caller ever passes a buffer larger than the frame (e.g. a pooled buffer with extra capacity/unused tail), this returns an oversized RGBA buffer that no longer matches the returned(src_w, src_h)dimensions, which can break downstream consumers that assumelen == w*h*4. Since you already computedsrc_size, slice to that exact length for the R0 case.
A source buffer larger than the validated w*h*4 frame (e.g. a pooled buffer with an unused tail) previously leaked its tail into the returned Vec, making len() inconsistent with the returned dimensions. The result is now exactly the frame slice; regression test added.
download_and_apply_async renamed the downloaded package onto the final .nupkg path (and the signature onto its final path) BEFORE the PGP check ran. A crash between the write and the verification left a bare .nupkg that pre_startup_verify_packages would purge the whole packages dir for at next boot — deleting previously staged, verified packages — and a transient signature-download failure clobbered the previously verified package before anything was verified. The package and its .sig are now staged at this run's unique temps, PGP-verified against the staged files, and only then promoted onto the final paths by promote_staged_package (package first, then signature). A crash or transient failure leaves nothing but ignored temps; the final paths only ever receive verified content. cleanup_run_artifacts simplified accordingly (no more wrote_package flag); promote helper covered by new unit tests.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 40 changed files in this pull request and generated no new comments.
Suppressed comments (3)
crates/media-sort-core/src/settings/store.rs:166
- The temp path for the atomic config write is always
<config>.toml.tmp. If two Media Sort instances save concurrently (or the config is saved from two threads in the future), they can race on the same temp file and produce lost/partial writes. Using a per-process-unique temp filename (at least including pid) avoids cross-process collisions while keeping the rename atomic within the destination directory.
let tmp = target.with_extension("toml.tmp");
path_utils::atomic_write(&tmp, &target, data.as_bytes()).map_err(SettingsError::Io)?;
crates/media-sort-gui/src/update/folder.rs:143
SubmitCreateusesstd::fs::create_dir_allfor a single-level folder name.create_dir_allsucceeds when the directory already exists, so the UI treats "create" as successful even though nothing changed and no conflict is surfaced to the user. Usingcreate_dirand reportingAlreadyExistsvia the existingstatus-target-existsbanner makes the outcome unambiguous.
let new_dir = parent.join(&folder_name);
if let Err(e) = std::fs::create_dir_all(&new_dir) {
tracing::error!("Failed to create folder: {e}");
} else if state.folder.current_folder.is_some() {
state.build_folder_tree();
}
crates/media-sort-gui/src/updater.rs:471
- The update package is fully buffered into memory (
collect_capped->Vec<u8>) before being written to disk. Even with the 1 GiB hard cap, this can cause large RSS spikes or OOM crashes on lower-memory machines during updates. A safer approach is to stream chunks directly into the staged temp file while enforcing the sameexpected/caplimits (and then verify the signature against the staged file), avoiding the full in-memory buffer.
…lder-create feedback Three suppressed review comments on the updater/store/folder-create paths: - updater.rs: the package was fully buffered in memory (collect_capped, 1 GiB cap) before being written. It now streams chunk-by-chunk into its staged temp file via tokio::fs, enforcing the expected size and hard cap incrementally and fsyncing before verification; the in-memory cost is gone (a near-1 GiB package no longer costs 1 GiB of RAM) and the signature remains the only buffered payload (1 MiB cap). - store.rs: the atomic config write always used <config>.toml.tmp, so two app instances (or a future second thread) could truncate each other's in-flight temp and land a partial/corrupt config. atomic_write now derives its temp name from shared unique_temp_path (pid + counter, moved into media-sort-core path_utils and reused by the updater, which drops its local copy); last-writer-wins replaces the interleaved truncation race. - folder.rs: SubmitCreate used create_dir_all, which silently succeeds when the directory already exists, so 'create' reported success on a no-op. create_dir now fails with AlreadyExists, surfaced via the existing status-target-exists banner; validate_stem guarantees a single path component, so the plain create_dir cannot trip on a missing parent.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 39 out of 41 changed files in this pull request and generated no new comments.
Suppressed comments (2)
crates/media-sort-gui/src/updater.rs:351
promote_staged_packagerenames the package into its final path before renaming the signature. If the signature rename fails, this returnsErrbut can leave the packages dir in an inconsistent state (new.nupkgpromoted while the matching.sigis still missing/at a temp path). That can break later verification/apply logic and makes failure recovery harder. Consider promoting with a rollback strategy (or backing up existing finals) so you either end up with both files in place or neither.
crates/media-sort-core/src/path_utils.rs:81atomic_writeleaves the uniquely-named temp file behind if the finalrenamefails. Over time this can litter the config directory with*.tmp.<pid>.<n>files and may confuse users or tooling. Consider removing the temp file on rename failure.
pub fn atomic_write(dest: &Path, bytes: &[u8]) -> io::Result<()> {
let tmp = unique_temp_path(dest, "tmp");
{
let mut file = std::fs::File::create(&tmp)?;
file.write_all(bytes)?;
file.sync_all()?;
}
std::fs::rename(&tmp, dest)
}
…nfig temp Two suppressed review comments: - updater.rs: promote_staged_package renamed the package onto its final path before the signature. If the signature rename then failed, the error path left a new .nupkg next to a stale/missing .sig — a mismatched pair that startup verification purges the whole packages dir for. The package rename is now rolled back onto the temp on a signature-rename failure, so error paths end with neither file promoted; the crash window between the two renames is documented as inherent to two plain renames. - path_utils.rs: atomic_write left its uniquely-named temp behind when the final rename failed, littering the config dir with *.tmp.<pid>.<n> files. The temp is now removed on rename failure. Both helpers covered by new unit tests (rollback restores the partial path and leaves finals empty; failed atomic_write leaves no temps).
Switching from a folder with thousands of entries to a small one while
a card was selected left the scroll snapshot (offset_x) at the old
folder's position until the next on_scroll event. The first render of
the new folder then sliced the tiny entry list past its end
("range start index 4399 out of range for slice of length 8") and
panicked.
The virtualization math is extracted into viewport_window(), which
clamps both bounds to the list length; a stale snapshot now renders a
safe (possibly empty) window instead of panicking. The same helper
replaces the duplicated formula in thumbnail_tracker::update_viewport,
and open_folder resets scroll.offset_x to 0 so the new folder starts at
its beginning (the iced scrollable clamps its own offset and emits a
fresh GridScrolled event to re-sync the snapshot). Search queries that
shrink the filtered list are covered by the clamp as well. Six unit
tests cover the helper, including the regression case.
Summary
Security hardening from a full audit of malformed-media, filesystem and update-supply-chain attack surfaces, followed by two review rounds (Copilot PR reviews — regular and suppressed comments — plus manual GUI testing). 30 commits, all SSH-signed. The main exploits have regression tests; the accompanying PoC suite (
poc-suite.zip, attached) documents and reproduces each vulnerability against the pre-fix code.Findings fixed
is_animated_gifdecoded GIF frames withLimits::no_limits()on the UI thread — a 15-byte GIF claiming 65535² forced a ~16 GiB allocationimage::Limitsanywhere; crafted PNG/GIF/WebP headers drove GB-scale transient allocations (incl. audio cover art)image_decode_limits()(16384px / 256 MiB) on every decode path, via shareddecode_path_with_limits/decode_bytes_with_limitsentry points; turbojpegmax_allocguard at the allocation sitechecked_add+ EOF bound + 10k box cap; rotation detected once per load + one-shot property-only re-probe (zero file I/O)canonicalize); Move silently overwrote existing destinationsSourceIsSymlink, checked before AND aftercanonicalizeto close the swap window),TargetExistsenforced at construction and insideexecute()/rollback(),CopyActionre-checks the source immediately beforefs::copy, Windows trash keeps the link, status banner + i18n for refusals--prefixed names misparsed optionscmd /C start "" <path>fed unquoted metacharacters to cmd's re-tokenizeropencrate withshellexecute-on-windows(ShellExecuteExW — no cmd layer); verified on a Windows VMSquirrel.exeover liveUpdate.exebefore PGP verification; a failed update left a persistent malicious updaterdownload_updatesthen early-returns (verified content only); re-verify before apply; delta file names validated; CI signing action pinned to commit SHAPost-audit review rounds
Every valid review comment (regular and suppressed) was fixed; claims that were empirically wrong were compile-verified and rejected with rationale.
Updater (supply chain, further hardened)
.sigland at per-run unique temp paths and are PGP-verified against the staged files before being promoted onto the final*.nupkg/*.nupkg.sigpaths. An unverified package can no longer sit at a final path — previously a crash between write and verification (or a transient signature failure) clobbered a previously staged, verified package or triggered a whole-dir purge at next startup..nupkgnext to a stale.sigwould be a mismatched pair that startup verification purges the whole dir for).%,#,?) and whitespace, not just path traversal.Settings persistence
atomic_writenow derives a unique temp name (pid + counter) per write, so two app instances cannot truncate each other's in-flight config temp and rename a partial/corrupt file into place; the temp is removed on a failed rename instead of littering the config dir.Media decoding & thumbnails
decode_image_dimensions) — ~1900× faster, no full pixel decode.ThumbnailError(DecodevsTransient): queue/ffmpeg timeouts no longer permanently badge slow-but-valid videos as failed; real decode failures still get the persistent badge.Video (mpv-utils)
rotate_rgbaR0 fast-path slices its result to the declared frame size (no oversized pooled buffer leaking into the frame) and skips the allocation entirely; frame-size math is checked, undersized sources rejected even for R0.GUI bug fixes (manual testing)
range start index 4399 out of range for slice of length 8) because the first render after the switch sliced the new, smaller list with the old folder's scroll snapshot. The virtualization math is now the shared, unit-testedviewport_windowhelper (used by both the grid view and the thumbnail tracker), which clamps both bounds to the list length;open_folderalso resets the scroll snapshot. Search queries shrinking the list are covered too.create_dirand surfaces an existing folder via thestatus-target-existsbanner instead of silently reporting success on a no-op.Housekeeping
refactor: improve comments).Verification
cargo build --workspace && cargo test --workspace— all green (21 suites, 420 tests; GUI updater suite with thevelopackfeature: 171 tests; benchmarks crate: 21 tests incl. correctness tests for every variant)cargo clippy --workspace --all-targets -- -D warnings,cargo fmt --all --— clean (enforced by pre-commit hooks on every commit)cargo bench -p benchmarksre-run — no regressions (GIF header scan is strictly cheaper than decode)cmd /C startconstruction vs newShellExecuteExW— hostile&-filenames inert after the fixpoc-suite.zipcontains payload generators, the standalone harness, and the Windows verification scripts used during the auditNote
One original audit claim was refined during verification:
cmd /C start "" <path>&cmddoes not execute the trailing command (startswallows the rest of the line — verified empirically), but%VAR%expansion and any non-startfirst command remain dangerous, so the cmd layer was removed entirely.poc-suite.zip