github hellobertrand/zxc v0.14.1
ZXC v0.14.1

4 hours ago

Release Notes

v0.14.1 is a hardening release with one new API function.

Everything that decodes an archive got stricter this cycle. Three forged-input paths were being refused late, with the wrong error, or not at all — including one that reported a truncated decode as a success. Block decompression also got 10-13 % faster, reusable compression contexts land in all five wrappers, and seekable archives gain per-block checksum verification you can switch on.

No format change, no ABI break. v0.14.1 reads and writes the same format v8 archives as v0.14.0 — byte-for-byte identical output at every level, verified on Silesia. The soname stays libzxc.so.4.

Drop-in for v0.14.0. Nothing to re-link, nothing to transcode. One symbol is added — zxc_seekable_set_checksum — and none removed or changed, so existing binaries keep working and only code calling the new function needs 0.14.1.

Decoder hardening

A forged block size could pass off a truncated decode as success (#428)

A block header carries its payload size as a plain u32, and the header checksum covers it — so a forged size is structurally valid, and nothing bounded it. Set it large enough to span the EOF marker and the frame walk ran off the end of the archive, exited its loop, and returned the bytes it had: a short decode reported as success, with checksums off, which is the default.

Two rules close it. A data block's payload can never exceed the block size in the file header — a block that would grow falls back to RAW, whose payload equals its content, so the bound is reached exactly and never passed. And the block walk has exactly one legal exit, the EOF block; running out of input first is corruption. Seek-table entries get the matching bound: one entry spans one block.

The check lives in the block decoder every reader goes through — both frame decoders, the streaming reader, the MT driver, the seekable reader, the block API — so the paths cannot drift apart. Both rules are now normative in FORMAT.md.

A stack address stored in heap memory (#429)

Multi-threaded seekable reads handed each worker a heap job descriptor holding the caller's output pointer. Every worker is joined and the descriptors freed before the call returns, so the address never outlived its frame — but nothing said so, and a scanner can't tell. Jobs carry offsets now, and the single base pointer lives in one shared descriptor on the caller's stack.

zxc_stream_decompress ignored the dictionary size limit (#430)

Every decompression entry point rejects a dictionary above ZXC_DICT_SIZE_MAX — except the streaming one, which checked it on the compression side only. Against an archive with a dictionary ID it surfaced as a misleading ZXC_ERROR_DICT_MISMATCH; against one without, it was accepted outright. The check moved into the shared stream engine.

Static contexts, empty frames (#417, #422)

Blocks larger than a static context's carving are rejected up front rather than failing deeper in. And an empty input through a context now produces the canonical 36-byte archive, as the one-shot API already did.

API

Reusable compression contexts in every wrapper (#424, #414, #415)

Cctx and Dctx were C-only. Rust, Go, Python, Node.js and WASM now all expose them, so callers compressing many small buffers stop paying context setup per call. A dictionary given to the factory applies to every call that doesn't override it, in every wrapper.

The block API also gained shared literal Huffman tables (#415): a dictionary can carry the table trained with it, so a small block does not have to spend its own bytes describing one.

Seekable checksum verification, on request (#425)

zxc_seekable_decompress_range never verified per-block checksums, even on archives carrying them. It does now, behind zxc_seekable_set_checksumoff by default, because random access is where the extra pass is least welcome and the caller decides best.

Two range fixes ship with it: an offset plus length that overflowed slipped past the bounds check, and a range that couldn't be fully decompressed returned a silent partial read instead of ZXC_ERROR_CORRUPT_DATA. All five wrappers expose the switch.

CLI

-l and -t report the checksum the archive carries (#435)

Both -l and -t now check the HAS_CHECKSUM header flag to report checksum status accurately. -j adds checksum_method, while exit codes remain unchanged. Thanks to @Vladexy88x for the fix.

Performance

Block decompression writes straight to the destination (#418)

zxc_decompress_block always decoded into an internal bounce buffer and copied out. Given the capacity zxc_decompress_block_bound() asks for — uncompressed size plus ZXC_DECOMPRESS_TAIL_PAD — it now writes directly and the copy disappears. Callers passing exactly the uncompressed size fall back to the bounce buffer instead of being refused with ZXC_ERROR_OVERFLOW.

That copy is worth 10-13 % of zxc_decompress_block, steady from 64 KB to 2 MB blocks — measured on Apple M3, level 3, over Silesia.

Build & Packaging

  • Trusted publishing for the release workflows (#421), replacing long-lived tokens.
  • rapidhash resolved at configure time (#420): the vendored copy unless -DZXC_USE_SYSTEM_RAPIDHASH=ON, and a missing vendored copy fails at configure rather than mid-build. Thanks to @jeanga for the issue.
  • Wrapper packaging and release integrity tightened (#409); the Node.js and WASM publish jobs no longer install an unpinned global npm (#426, #427).
  • Checksum helpers consolidated behind one seeded entry point (#423); Huffman nudge tuning macros tidied (#412).
  • Skipped SARIF uploads on fork PRs due to missing security-events: write permissions.

Documentation

FORMAT.md gains the two decoder rules, and spells out how a dictionary ID derives from the content and the shared Huffman table — a 32-bit value that binds a dictionary to an archive, and does not authenticate it (#413). The Rust crate documentation clarifies the zxc-compress / zxc naming (#410); README refinements (#408).

Changelog

  • api: Enforces strict block size and EOF validation (#428)
  • api: Implements reusable compression contexts (#424)
  • api: Enhances seekable API with checksum control and robust range validation (#425)
  • api: Fix empty frame compression in contexts (#422)
  • api: Enforce strict block size bounds for static decompression contexts (#417)
  • api: Adds shared literal table support to block API (#415)
  • api: Extends dictionary support for reusable contexts (#414)
  • api: Refines README documentation (#408)
  • perf: Optimizes block decompression for direct and flexible output (#418)
  • cli: Report checksum presence from the archive header in -l and -t (#435)
  • build: Migrate release workflows to trusted publishing (#421)
  • build: Enforce rapidhash resolution at configure/setup time (#420)
  • build: Resolve dependency resolution in wrapper test (#410) (#411)
  • build: Enhances wrapper packaging and release integrity (#409)
  • doc: Clarify crate branding and improve documentation metadata (#410)
  • misc: Compile library objects separately for fuzzers (#440)
  • misc: Add fuzzer for streaming API (#443)
  • misc: Skip SARIF uploads for fork pull requests
  • misc: Enforce dictionary size limits on decompression path (#430)
  • misc: Prevent stack memory escape in multi-threaded decompression (#429)
  • misc: Drop unpinned global npm install from publish job (#426)
  • misc: Drop unpinned global npm install from publish job (#427)
  • misc: Consolidates checksums (#423)
  • misc: Formalizes dictionary ID calculation and enhances fuzzer (#ossf-557281704) (#413)
  • misc: Tidy the nudge tuning macros (#412)

Full Changelog: v0.14.0...v0.14.1

Don't miss a new zxc release

NewReleases is sending notifications on new releases.