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
.isothat 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
/apiroutes, 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, andPOST /api/files/delete-batchare guarded by theX-CHD-Action-Confirmheader (rename-file/delete-file), recursive deletion of a non-empty directory requiresrecursive-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
dumpmetaoutput 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.txtgets 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 onPATHlookup 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.jsonas 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, andnszeach hand-rolled their own ~150-line spawn / stall-timeout / cancel / progress loop; they now delegate to the sharedSubprocessRunnerlike 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/elifladders are gone from the dispatch path — behavior is read from each mode'sModeSpecvia 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_outputshook 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
FileEntryflags 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_duplicateruntime 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,
chdmananddolphin-toolleft 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
splitkwarg. (#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 withNanoCPUs can not be set…. The compose files, README, and deployment docs now flag this and document the fix: dropcpus:(keepmemory:), or pin cores withcpusetinstead. - 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-variablesanchor resolves to the global table instead of silently landing on the 3DS section. - All docs were synced for the new tools, and
CLAUDE.mdgained 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/renameneedsX-CHD-Action-Confirm: rename-file;DELETE /api/files/deleteandPOST /api/files/delete-batchneeddelete-file; recursive non-empty-directory deletion still needsrecursive-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)
3d9272echore: sync package.json to v4.1.0 [skip ci]1fda9dcpip: update python-multipart requirement from >=0.0.30 to >=0.0.32 (#153)a167513pip: update uvicorn requirement from >=0.48.0 to >=0.49.0 (#154)fed511bnpm: bump svelte from 5.56.1 to 5.56.3 (#156)72991b9npm: bump svelte-eslint-parser from 1.7.1 to 1.8.0 (#155)18f4fc3feat(#98): cso→chd chaining seam (Phase 1) + directory-input scaffolding (#157)2d9faf6PS3 decrypted folder → ISO via makeps3iso (#98 Phase 2) (#158)1a43787docs: align makeps3iso subtree-lock / wait-requeue / symlink-volume guards with shipped code (#159)a162e14makeps3iso: optional 4 GB FAT32 split (-s), with split-set folding in the browser (#160)fdf9c3emakeps3iso: harden split-set folding, output detection, and overwrite/collision guards (#161)a923dc5release: promote 4.2.0 release notes and bump versioncc8bfe9chore: sync package.json to v4.2.0-beta-1 [skip ci]264ada0docs: sync all docs with 4.2.0 (PS3 ISO tool + cso→chd chain) (#162)f22d728chore: sync package.json to v4.2.0-beta-2 [skip ci]910bc22Idempotency hardening (#184 sites 2–4): embedded-hash TOCTOU, background-task GC & duplicate optimistic count (#193)db9a878Finish the SubprocessRunner migration: z3ds / maxcso / nsz (#179) (#194)6afd86afix(version): single-source the app version from package.json (#187)882e3f1fix(determinism): stabilize ordering across registry, search, runner & FIFO ticket (#188)d820bb1refactor(dispatch): replace residual tool/mode-identity branches with ModeSpec reads (#189)e3dc6f3refactor: de-duplicate backend request validation, constants & info models (#192)2693e64refactor: wire the post_convert seam; de-duplicate disc-ID embedding (#190)dfc9ba0refactor: model companion/sidecar outputs on the plugin contract (#191)e9d4b57Frontend SSOT: parity guard + dedup of constants/dead code (#186 sites 1/2/4/5) (#195)2006f55docker: bump debian fromb6e2a15to4e401d9(#165)27d9786docker: bump node from242549cto2c87ef9(#166)dec7b4epip: update sqlalchemy requirement from <3.0,>=2.0.50 to >=2.0.51,<3.0 (#168)a6957a5actions: bump actions/checkout from 6 to 7 (#170)73fa2e2pip: update sse-starlette requirement from >=3.4.4 to >=3.4.5 (#171)0eaf603pip: update pytest requirement from >=9.0.3 to >=9.1.1 (#172)230d931npm: bump globals from 17.6.0 to 17.7.0 (#173)728a06dpip: update fastapi requirement from >=0.136.3 to >=0.138.0 (#174)d47d522pip: update pydantic-settings requirement from >=2.14.1 to >=2.14.2 (#176)3b30ec4actions: bump actions/attest-build-provenance from 4.1.0 to 4.1.1 (#196)9cfed89npm: bump eslint-plugin-svelte from 3.19.0 to 3.20.0 (#197)26d0f5fnpm: bump vite from 8.0.16 to 8.1.3 (#199)658e74bnpm: bump @lucide/svelte from 1.17.0 to 1.22.0 (#200)e1ae1f2npm: bump eslint from 10.4.1 to 10.6.0 (#198)6fe7204docs: fill CLAUDE.md gaps (what-it-is, build/run commands, architecture) (#217)94ec49aConsolidate Dependabot updates (#201–#214) (#220)832f9a4Require confirmation header for recursive delete and block configured-volume-root deletion (#216)2d1fc99Add shot-scraper screenshot automation (#218)e6b127fUpdate Web UI screenshots — Sat Jul 11 20:50:17 UTC 2026aaf2c82Consolidate remaining Dependabot updates (#201, #206, #208, #210) (#222)7398ac4Update Web UI screenshots — Sat Jul 11 21:44:03 UTC 2026ad5b95cHarden PS3 folder input validation (#215)0e90aacAdd Renovate config with automerge and retire Dependabot (#236)85a0ff0Fix archive e2e test stub to accept split kwarg (#238)b24ad68Fix chdman verify stream cancellation cleanup (#237)4b47735fix: require confirmation for file rename and delete (#234)23a6dc1Bind compose web UI to loopback (#233)e78e1eeUpdate Web UI screenshots — Thu Jul 23 17:08:09 UTC 202622dea73Add opt-in authentication for Web UI and API (#232)9c222ceHarden screenshot workflow token access (#231)854dbcefix: avoid split output directory scans (#230)749e568Validate archive member extensions before output-path derivation (#239)cfeb5b8fix: harden root container command lookup (#227)2de5ebefix: bound CHD disc ID probing (#224)bc671fdFix symlink volume discovery (#223)786c67fci: harden release version sync and build summary (#241)fcecbb3Frontend SSOT: derive icon + Help lists from the registry (#186, site 3) (#242)defcbaaUpdate Web UI screenshots — Thu Jul 23 18:19:40 UTC 2026cba461eDrop legacy per-tool FileEntry flags (#186, site 6 — Phase 9) (#243)64a6dddUpdate Web UI screenshots — Thu Jul 23 18:34:18 UTC 202604b4f5cRecognize prior success on re-queue: job-start no-op fast path (#184, site 1) (#244)f32153dFix delete-on-verify view crash on multi-archive selections (#245)9bf0dbaUpdate Web UI screenshots — Sat Jul 25 22:41:51 UTC 2026b2de583docs: disambiguate duplicate Environment Variables headings (#250)db6e2f9docs: document Synology DSM cpus:/NanoCPUs startup failure (#248) (#251)e099842chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#246)5d720d1Update Web UI screenshots — Sun Aug 9 01:00:11 UTC 2026