github pacnpal/compressatorium v4.2.0
v4.2.0: A seventh tool, the first tool chain, and the hardening release

latest releases: v4.5.0, v4.5.0-beta-3, v4.5.0-beta-2...
3 months ago

Compressatorium 4.2.0

Released 2026-08-09. The seventh tool, the first tool chain, and the release that hardens everything around them.

4.0.0 rebuilt the foundation. 4.1.0 proved it could grow, adding two tools in one release. 4.2.0 pushes the architecture past two boundaries it had never crossed: a tool whose input is a folder instead of a file (PS3 ISO), and a conversion that chains two tools together in one job (CSO → CHD). Both required real new machinery — directory locking, cross-tool orchestration, split-file output handling — and both slot into the same registry every other tool uses, so everything downstream (progress, verify, delete-on-verify, archive handling, the Web UI) just works.

Then the release takes a long, deliberate breath. After the two betas shipped the new tools, the following weeks went into making the whole app harder to break and harder to abuse: opt-in authentication for the Web UI and API, confirmation guards on every destructive file operation, symlink and path-escape defenses, bounded parsing of untrusted files, a locked-down default network posture, and an idempotency/determinism/modularity pass (the #177 tech-debt epic) that closed out a long list of internal debt. It's the least glamorous work in the project's history and some of the most important.

This document rolls up every change since 4.1.0, across two betas (4.2.0-beta-1, 4.2.0-beta-2) and everything after. If you've been running a 4.2.0 beta: the PS3 ISO tool and the CSO → CHD chain are unchanged, but everything from the authentication and hardening sections onward is new in this stable cut. The per-change breakdown lives in docs/RELEASE_NOTES.md.


The short version

  • A seventh tool: PS3 ISO. Pack a decrypted PS3 disc/JB folder into a .iso that RPCS3 mounts directly, using makeps3iso. The first tool that converts a folder.
  • Optional 4 GB split for FAT32 targets, with split sets folded into a single entry in the file browser.
  • The first tool chain: CSO → CHD in one step. maxcso decompresses, chdman packages, one job, one progress bar, delete-on-verify end to end.
  • Opt-in authentication for the Web UI and /api routes, off by default, token in headers only, CSRF-guarded.
  • A security hardening sweep: confirmation headers on rename/delete, volume-root deletion blocked, PS3 folder inputs confined to volumes, bounded CHD header parsing, symlink guards, a loopback-bound default compose file, and hardened CI workflows.
  • Re-queuing an already-done job is now a no-op. The pipeline recognizes a provably verified prior success and completes instantly instead of re-converting.
  • Deterministic output everywhere: sorted extension lists, stable search ordering, chunk-boundary-independent progress parsing.
  • All seven tools now share one subprocess loop. The last three hand-rolled spawn/cancel/stall loops are gone.
  • The frontend derives from the registry. Icons, Help content, and convertibility all read from the single source of truth, with a parity guard test to keep it that way; ~23 legacy per-file API flags were dropped.
  • README screenshots are generated, not hand-captured, by a shot-scraper GitHub Actions workflow.
  • Renovate replaced Dependabot, and dependencies were bumped across the board.

New tool: PS3 ISO (makeps3iso)

Compressatorium can now build a PS3 .iso from a decrypted disc or JB folder — a PS3_GAME/ root, plus PS3_DISC.SFB for disc rips — using makeps3iso. The result mounts directly in RPCS3. Decryption and keys are out of scope: this only repackages folders you already decrypted, and it never deletes the source folder (no delete-on-verify).

This is the first conversion whose input is a folder, not a file. Browse to (or search for) a PS3 folder, switch to the PS3 ISO tool, and the folder becomes selectable like any source row — clicking its name still navigates into it. The output is <folder>.iso next to the source or in your chosen output directory. After a successful build the tool reads the TITLE_ID back out of the produced ISO's PARAM.SFO and flags a mismatch in the job message.

Optional 4 GB split for FAT32 targets. FAT32 can't hold a file larger than 4 GB, and most PS3 discs are bigger than that. A per-job Split into 4 GB parts (FAT32) toggle runs makeps3iso with -s: an image over ~4 GB is written as Game.iso.0, Game.iso.1, … (RPCS3 mounts the .0), while a title under 4 GB still produces a single Game.iso. The file browser folds a split set into one entry with the combined size instead of a row per chunk. Default off; ext4/NTFS/exFAT targets don't need it.

Folder builds are safe under concurrency. While a folder is being packed, its whole subtree is locked. A per-file job that was already queued and whose input or output lands inside that folder is deferred — re-queued and retried once the folder job finishes — instead of failing. A new request submitted mid-build is rejected up front like any other output collision (409 for a single job, skipped in a batch). Rename and delete inside an in-flight folder are rejected too, and every subtree comparison resolves symlinks so a symlinked path into a locked folder can't slip past.

Folder inputs are confined to your volumes. Directory-input planning rejects symlinks and non-regular filesystem entries, and the worker repeats the same safety walk after acquiring the lock, immediately before invoking makeps3iso. A symlinked source root is rejected even when hidden behind a trailing separator, a . component, or a ..-cancelled symlinked ancestor. A crafted or mutated PS3 folder can't cause the native packer to read and embed files from outside the configured volume boundary, and a safety rejection on an overwrite job can't delete your prior output.

Under the hood, this shipped as a directory-input seam on the plugin contract (InputKind, ToolPlugin.accepts_directory, directory-lock APIs on the lock manager) rather than PS3-specific plumbing, so the next folder-input tool inherits it. The makeps3iso binary (GPL-3.0, pinned commit) is built unmodified in the multi-stage Dockerfile for amd64 and arm64. New setting: MAKEPS3ISO_PATH.


The first tool chain: CSO → CHD in one step

A new Convert to CHD mode on the CSO tool converts a .cso/.zso/.dax straight to .chd: maxcso decompresses the compressed image to a temporary .iso, then chdman packages that to a CHD that PPSSPP, PCSX2, and RetroArch read. The intermediate .iso lives in a private temp dir and is cleaned up automatically; only the source and the final .chd remain. Delete-on-verify works end to end — the original .cso is removed only after the final CHD passes chdman verification — and disc-ID GAME/NAME tags are embedded just like a direct Create DVD.

The interesting part is how: a general cross-tool chaining seam sits above the plugin registry. A ChainSpec declares ordered steps, intermediates, and which step owns the final verify, and a synthetic ChainTool orchestrates the component tools through the registry — so every existing consumer (the convert route, the job manager, the file listing) works unchanged. Progress from both steps is aggregated into one bar with per-step weights, and a new shared disk-headroom preflight checks free space for the work dir and output dir independently, since a chain briefly holds source + intermediate + partial output at once. Future chains are a declaration, not a project.


Opt-in authentication for the Web UI and API

The networked UI and /api routes can now require a token, gated behind COMPRESSATORIUM_ENABLE_AUTH (default off — nothing changes unless you opt in). When enabled, set COMPRESSATORIUM_AUTH_TOKEN (or legacy CHD_AUTH_TOKEN), or let the app generate a persistent token in /config/auth_token on first startup. The Web UI prompts via HTTP Basic (username admin, token as password); scripts can send Authorization: Bearer or X-Compressatorium-Token. The token is accepted only in request headers, never the URL query string, state-changing requests are restricted to same-origin callers to prevent CSRF via cached browser credentials, and /health stays unauthenticated for container health checks.

Alongside this, the compose files now publish the port as 127.0.0.1:8080:8080 — loopback-only by default. The API is intended for trusted local access; if you expose it beyond the host, put it behind a reverse proxy, VPN, or the new auth.


The hardening sweep

A focused pass over everything destructive, everything that parses untrusted files, and everything that touches paths:

  • Every destructive file operation now requires explicit confirmation. POST /api/files/rename, DELETE /api/files/delete, and POST /api/files/delete-batch are guarded by the X-CHD-Action-Confirm header (rename-file / delete-file), recursive deletion of a non-empty directory requires recursive-delete, and the batch endpoint carries the same guards so the single-delete gate can't be bypassed by wrapping a path in a one-item batch. The Web UI sends these automatically. (#216, #234)
  • A configured volume root can never be deleted, through the single or the batch endpoint, even when empty. (#216, #219)
  • CHD disc-ID probing is bounded. CHD files live in user volumes, so the header/map parser now treats them as untrusted input: hunk sizes, compressed-block lengths, LZMA dictionary sizes, and dumpmeta output are all capped, and the chdman metadata call is time-bounded — a malformed or hostile CHD can't balloon memory or hang a scan. (#224)
  • Unsupported archive members fail with a controlled 400, not a 500. The per-tool extension gate now runs before any output-path derivation inside the archive branch, so archive.zip::bad.txt gets the intended per-tool skip reason instead of an uncaught exception. (#239)
  • Volume discovery skips symlinks under the data mount root, so a stray link can't pull an unintended directory tree into the configured volumes. (#223)
  • The container entrypoint uses absolute command paths for its root-context UID/GID remapping (/usr/bin/id, /usr/sbin/usermod, …), removing any reliance on PATH lookup while running as root. (#227)
  • CI workflows were hardened too: the release workflow validates tag-derived versions against strict SemVer, routes release-controlled values through env vars instead of shell interpolation, and authenticates its version-sync push with an in-memory masked header so the write-scoped token is never persisted to .git/config (#241); the screenshot workflow's token access got the same treatment (#231).

Idempotency: re-queuing finished work is now free

Re-queuing a job whose verified output already exists is now a no-op. When a job starts and its target artifact is already on disk and provably the exact result of the current request, the pipeline recognizes the prior success and completes the job without re-spawning the converter.

To make that safe rather than surprising, a delete-on-verify conversion now records a produced_meta snapshot alongside its verification (new nullable JSON column, migration 0003): the producing mode/compression/split plus stat fingerprints of the complete source set (a .cue/.gdi and its track files, not just the descriptor) and of the output. The fast path fires only when the current request's settings, source-set fingerprint, and output fingerprint all match the snapshot — a different compression, a changed .bin track, or a replaced output all force a full re-convert. A plain /info verify never qualifies, and delete-on-verify jobs always take the normal path so their guarded source deletion still runs.

Two adjacent races were fixed in the same pass: CHD embedded-hash matching now reads its cached hashes and freshness-checks them as a single observation, so a CHD rewritten mid-match can't yield stale hashes (falling back to a file-level SHA1 on any staleness); and duplicate same-file/same-mode submissions keep an accurate in-progress count, since the optimistic placeholder is now cleared only when its real job first arrives.


Determinism

Byte-stable output, run to run:

  • The registry's extension-union helpers return a sorted tuple instead of a frozenset, so any serialized extension list is stable.
  • Recursive search results sort to match the directory listing, and the file browser keeps a stable order for rows with equal sort keys.
  • Subprocess progress-line parsing no longer depends on read-chunk boundaries.
  • The cross-process FIFO job ticket is hardened against counter fallback/recovery collisions and preserves legacy ticket files across a rolling restart.
  • The app version is read from package.json as the single source of truth, so releases can't drift between two separately-maintained version strings.

No wire-API change in any of it.


Under the hood: the tech-debt epic

The #177 epic closed out the duplication left over from three releases of fast growth. The theme in every item: one source of truth, enforced by tests.

  • All seven tools share one subprocess loop. z3ds, maxcso, and nsz each hand-rolled their own ~150-line spawn / stall-timeout / cancel / progress loop; they now delegate to the shared SubprocessRunner like the other four tools, so a cancel or stall fix lands once instead of drifting between copies. Output-growth progress estimation and per-tool error parity are preserved through shared seams.
  • The last tool/mode-identity if/elif ladders are gone from the dispatch path — behavior is read from each mode's ModeSpec via the registry.
  • Disc-ID embedding is a plugin hook (post_convert), called generically for every mode instead of mode-gated branches in two places.
  • Multi-file outputs are modeled on the contract. A companion_outputs hook replaces the hard-coded .bin (extractcd) and .0/.1 (makeps3iso) branches everywhere output conflict detection, overwrite cleanup, and size accounting reason about a mode's files.
  • Backend request validation, shared constants, and info models were collapsed into single sources of truth, and the archive extension sets are registry-authoritative — they fail loudly rather than falling back to a stale static list.
  • The frontend mirror can't silently drift anymore. A parity guard test evaluates the UI's tool registry with Node and asserts every mode row matches the backend's mode_specs() field by field. File icons and the Help view's mode reference are now generated from the registry (so Switch dumps and loose archives finally get meaningful icons), with their own drift guard.
  • ~23 legacy per-tool FileEntry flags were dropped from the file/search API (has_chd, *_ready, *_convertible, *_path, …). They were pure derivations of the registry-driven fields the frontend already reads. No wire shape the UI relies on changed; the JSON just stopped carrying dead weight.

Automated screenshots

The Web UI screenshots embedded in the README are now generated with shot-scraper instead of captured by hand. A Take screenshots GitHub Actions workflow builds the UI, boots it against a throwaway fixture library, captures every surface in light and dark (definitions in shots.yml), optimizes the PNGs with Oxipng, and commits them back to docs/screenshots/. It can also run locally — see docs/SCREENSHOTS.md. The stale hand-captured set was removed in favour of the regenerated one, and the gallery stayed current through this whole release cycle because a robot was doing it.


Fixes

  • Selecting multiple archives with delete-on-verify no longer crashes the workspace. The confirmation modal keyed its warning lists by message text, and multi-archive selections produce duplicate messages — a hard each_key_duplicate runtime error in Svelte 5 that took down the view before a single job was queued. Repeated messages are now collapsed to one line with a count (… (×12)), which also reads better. Present since 4.0.0. (#245)
  • Verify streams no longer leak their child process on cancellation. When an SSE verification client disconnected mid-stream, chdman and dolphin-tool left the native verify subprocess running, slowly exhausting the process table. Both now terminate and untrack the child on every exit path, matching the other tools. (#237)
  • Split-part discovery probes exact paths instead of scanning the output directory, so detecting a Game.iso.0/.1/… set no longer walks every sibling file. (#230)
  • Rename no longer spin-loops when its output is inside a folder being packed. A rename-duplicate job targeting a locked PS3 subtree used to probe numbered names in a tight, sleepless loop until the folder build finished; it's now rejected up front and deferred like any blocked job.
  • An archive e2e test stub was updated to accept the new split kwarg. (#238)

Dependencies and CI

Renovate replaced Dependabot (#236), with automerge configured, after two rounds of consolidated Dependabot rollups (#220, #222) cleared the backlog.

Python: FastAPI >=0.138.0, uvicorn >=0.49.0, SQLAlchemy >=2.0.51, sse-starlette >=3.4.5, python-multipart >=0.0.32, pydantic-settings >=2.14.2, pytest >=9.1.1.

Frontend: Svelte 5.56.3, Vite 8.1.3, ESLint 10.6.0, @lucide/svelte 1.22.0, plus lint-plugin and globals bumps, and a security bump to the npm dependency group (#246).

Build and CI: the pinned debian / node base-image digests were refreshed, actions/checkout moved to v7, attest-build-provenance to 4.1.1, and the release and screenshot workflows were hardened as described above.


Documentation

  • The Synology DSM cpus: startup failure is documented (#248). DSM's Docker doesn't support CPU CFS bandwidth control, so the compose files' cpus: limit fails with NanoCPUs can not be set…. The compose files, README, and deployment docs now flag this and document the fix: drop cpus: (keep memory:), or pin cores with cpuset instead.
  • The README's duplicate "Environment Variables" headings were disambiguated (#247) — the per-tool sections are now "3DS/Switch/CSO/PS3 ISO Environment Variables", so the plain #environment-variables anchor resolves to the global table instead of silently landing on the 3DS section.
  • All docs were synced for the new tools, and CLAUDE.md gained the project one-liner, build/run commands, and architecture summary it was missing.

Upgrade notes

The database migration is automatic. Migration 0003 adds the nullable verifications.produced_meta column, is idempotent, and runs on startup.

Authentication is opt-in. Nothing changes unless you set COMPRESSATORIUM_ENABLE_AUTH=true. New settings: COMPRESSATORIUM_ENABLE_AUTH, COMPRESSATORIUM_AUTH_TOKEN (legacy alias CHD_AUTH_TOKEN), plus MAKEPS3ISO_PATH and the COMPRESSATORIUM_MAKEPS3ISO_* priority knobs for the new tool.

Behavior changes to know about if you script against the API or use the stock compose file:

  • Destructive file endpoints now require a confirmation header. POST /api/files/rename needs X-CHD-Action-Confirm: rename-file; DELETE /api/files/delete and POST /api/files/delete-batch need delete-file; recursive non-empty-directory deletion still needs recursive-delete. The Web UI sends these automatically — only direct API scripts need updating.
  • Configured volume roots can no longer be deleted through the API at all.
  • The compose files bind the UI to 127.0.0.1. If you access Compressatorium from another machine, change the port mapping back to "8080:8080" — and consider fronting it with the new auth or a reverse proxy.
  • Unsupported archive members now return 400 instead of 500, with a per-tool skip reason. If you scripted around the old 500, update the status check.
  • Volume discovery ignores symlinked directories under the data mount root. If you relied on a symlink to pull a volume in, mount it directly instead.
  • Serialized extension lists are now sorted. Same wire shape and content; the ordering is simply stable now.

Everything else is backward compatible: mode strings, endpoint paths, and JSON shapes are unchanged, the removed FileEntry flags were dead derivations of fields the API still carries, and all 4.1.0 environment variables (including the CHD_* aliases) keep working.


Thanks

Full commit list since v4.1.0 (70 commits, oldest first)

  • 3d9272e chore: sync package.json to v4.1.0 [skip ci]
  • 1fda9dc pip: update python-multipart requirement from >=0.0.30 to >=0.0.32 (#153)
  • a167513 pip: update uvicorn requirement from >=0.48.0 to >=0.49.0 (#154)
  • fed511b npm: bump svelte from 5.56.1 to 5.56.3 (#156)
  • 72991b9 npm: bump svelte-eslint-parser from 1.7.1 to 1.8.0 (#155)
  • 18f4fc3 feat(#98): cso→chd chaining seam (Phase 1) + directory-input scaffolding (#157)
  • 2d9faf6 PS3 decrypted folder → ISO via makeps3iso (#98 Phase 2) (#158)
  • 1a43787 docs: align makeps3iso subtree-lock / wait-requeue / symlink-volume guards with shipped code (#159)
  • a162e14 makeps3iso: optional 4 GB FAT32 split (-s), with split-set folding in the browser (#160)
  • fdf9c3e makeps3iso: harden split-set folding, output detection, and overwrite/collision guards (#161)
  • a923dc5 release: promote 4.2.0 release notes and bump version
  • cc8bfe9 chore: sync package.json to v4.2.0-beta-1 [skip ci]
  • 264ada0 docs: sync all docs with 4.2.0 (PS3 ISO tool + cso→chd chain) (#162)
  • f22d728 chore: sync package.json to v4.2.0-beta-2 [skip ci]
  • 910bc22 Idempotency hardening (#184 sites 2–4): embedded-hash TOCTOU, background-task GC & duplicate optimistic count (#193)
  • db9a878 Finish the SubprocessRunner migration: z3ds / maxcso / nsz (#179) (#194)
  • 6afd86a fix(version): single-source the app version from package.json (#187)
  • 882e3f1 fix(determinism): stabilize ordering across registry, search, runner & FIFO ticket (#188)
  • d820bb1 refactor(dispatch): replace residual tool/mode-identity branches with ModeSpec reads (#189)
  • e3dc6f3 refactor: de-duplicate backend request validation, constants & info models (#192)
  • 2693e64 refactor: wire the post_convert seam; de-duplicate disc-ID embedding (#190)
  • dfc9ba0 refactor: model companion/sidecar outputs on the plugin contract (#191)
  • e9d4b57 Frontend SSOT: parity guard + dedup of constants/dead code (#186 sites 1/2/4/5) (#195)
  • 2006f55 docker: bump debian from b6e2a15 to 4e401d9 (#165)
  • 27d9786 docker: bump node from 242549c to 2c87ef9 (#166)
  • dec7b4e pip: update sqlalchemy requirement from <3.0,>=2.0.50 to >=2.0.51,<3.0 (#168)
  • a6957a5 actions: bump actions/checkout from 6 to 7 (#170)
  • 73fa2e2 pip: update sse-starlette requirement from >=3.4.4 to >=3.4.5 (#171)
  • 0eaf603 pip: update pytest requirement from >=9.0.3 to >=9.1.1 (#172)
  • 230d931 npm: bump globals from 17.6.0 to 17.7.0 (#173)
  • 728a06d pip: update fastapi requirement from >=0.136.3 to >=0.138.0 (#174)
  • d47d522 pip: update pydantic-settings requirement from >=2.14.1 to >=2.14.2 (#176)
  • 3b30ec4 actions: bump actions/attest-build-provenance from 4.1.0 to 4.1.1 (#196)
  • 9cfed89 npm: bump eslint-plugin-svelte from 3.19.0 to 3.20.0 (#197)
  • 26d0f5f npm: bump vite from 8.0.16 to 8.1.3 (#199)
  • 658e74b npm: bump @lucide/svelte from 1.17.0 to 1.22.0 (#200)
  • e1ae1f2 npm: bump eslint from 10.4.1 to 10.6.0 (#198)
  • 6fe7204 docs: fill CLAUDE.md gaps (what-it-is, build/run commands, architecture) (#217)
  • 94ec49a Consolidate Dependabot updates (#201–#214) (#220)
  • 832f9a4 Require confirmation header for recursive delete and block configured-volume-root deletion (#216)
  • 2d1fc99 Add shot-scraper screenshot automation (#218)
  • e6b127f Update Web UI screenshots — Sat Jul 11 20:50:17 UTC 2026
  • aaf2c82 Consolidate remaining Dependabot updates (#201, #206, #208, #210) (#222)
  • 7398ac4 Update Web UI screenshots — Sat Jul 11 21:44:03 UTC 2026
  • ad5b95c Harden PS3 folder input validation (#215)
  • 0e90aac Add Renovate config with automerge and retire Dependabot (#236)
  • 85a0ff0 Fix archive e2e test stub to accept split kwarg (#238)
  • b24ad68 Fix chdman verify stream cancellation cleanup (#237)
  • 4b47735 fix: require confirmation for file rename and delete (#234)
  • 23a6dc1 Bind compose web UI to loopback (#233)
  • e78e1ee Update Web UI screenshots — Thu Jul 23 17:08:09 UTC 2026
  • 22dea73 Add opt-in authentication for Web UI and API (#232)
  • 9c222ce Harden screenshot workflow token access (#231)
  • 854dbce fix: avoid split output directory scans (#230)
  • 749e568 Validate archive member extensions before output-path derivation (#239)
  • cfeb5b8 fix: harden root container command lookup (#227)
  • 2de5ebe fix: bound CHD disc ID probing (#224)
  • bc671fd Fix symlink volume discovery (#223)
  • 786c67f ci: harden release version sync and build summary (#241)
  • fcecbb3 Frontend SSOT: derive icon + Help lists from the registry (#186, site 3) (#242)
  • defcbaa Update Web UI screenshots — Thu Jul 23 18:19:40 UTC 2026
  • cba461e Drop legacy per-tool FileEntry flags (#186, site 6 — Phase 9) (#243)
  • 64a6ddd Update Web UI screenshots — Thu Jul 23 18:34:18 UTC 2026
  • 04b4f5c Recognize prior success on re-queue: job-start no-op fast path (#184, site 1) (#244)
  • f32153d Fix delete-on-verify view crash on multi-archive selections (#245)
  • 9bf0dba Update Web UI screenshots — Sat Jul 25 22:41:51 UTC 2026
  • b2de583 docs: disambiguate duplicate Environment Variables headings (#250)
  • db6e2f9 docs: document Synology DSM cpus:/NanoCPUs startup failure (#248) (#251)
  • e099842 chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#246)
  • 5d720d1 Update Web UI screenshots — Sun Aug 9 01:00:11 UTC 2026

Don't miss a new compressatorium release

NewReleases is sending notifications on new releases.