Skip to content

fix: pre-0.7.4 audit fixes across codecs, I/O adapters and CI - #145

Merged
MagicalTux merged 60 commits into
masterfrom
fix/pre-0.7.4
Oct 22, 2026
Merged

MagicalTux merged 60 commits into
masterfrom
fix/pre-0.7.4

Conversation

@MagicalTux

Copy link
Copy Markdown
Member

Fixes from a full audit of the crate before the 0.7.4 release. It's one commit per fix; each commit message gives the trigger, the effect and the fix, and every fix has a regression test that fails without it. Where a reference tool exists, the fix was checked against it (xz, zstd, bzip2, brotli, gzip, lz4, lzop, cabextract, unrar, lha, 7z, compress).

Wrong output or rejected valid data

  • lznt1: the offset/length split was reversed, so real Windows/NTFS streams failed and our output only decoded with this crate. Now matches the worked example in MS-XCA §3.3.
  • lzx (CAB): the decoder didn't realign at each 32 KiB frame boundary, the E8 filter was off by one, and odd-sized uncompressed blocks were rejected, plus three smaller bitstream issues. Almost every real CAB stream larger than 32 KiB decoded wrongly. It now agrees with cabextract on 3000 random streams.
  • bcj: the x86, ARM64 and RISC-V filters didn't match xz, so data filtered by xz decoded wrongly here, and the other way round, with no error. They are now byte-identical to xz 5.8.
  • lzfse: bvx2 blocks, which Apple's encoder uses for all but tiny inputs, couldn't be decoded. Checked with streams from lzfse_rust, which validates itself against Apple's library.
  • rar2: symbol 256 didn't record its distance, so valid archives decoded to wrong bytes.
  • lha: lh6/lh7 used the wrong dictionary sizes, so lha and 7z rejected inputs of 64 KiB or more, and 7z also rejected blocks with no matches. Every method except lh2 is now checked against the lha CLI in tests; lh2 is in fix(lha): make -lh2- match the LHarc format (needs a licensing decision) #146.
  • brotli: the decoder rejected valid streams (a complex prefix code with a single code-length code).
  • xpress_huffman: matches crossing the 64 KiB block end were cut short, and inputs over 4 GiB had their length silently truncated.
  • lzs: a match exactly 2048 bytes back overflowed the 11-bit offset field, so MAX_DISTANCE is now 2047.
  • bzip2: concatenated streams (pbzip2/lbzip2 output, or cat) now decode in full instead of stopping after the first.
  • quantum and amiga_lzx: a debug-build panic on 19–21-bit windows, a dropped final odd byte, and rejected odd-sized uncompressed blocks.

Memory and CPU on untrusted input

  • lha lh4–7: the decoder built its whole output at finish; 208 bytes of input could allocate 512 MiB. It now streams.
  • rar5: the decoder buffered a whole block, using 1.25 GiB for 440 KiB of input; it now yields output as it goes. Overlapping filters made it stall while memory grew without bound.
  • lzfse: blocks above Apple's per-block limits are now rejected; a 124-byte block used to decode to 15.7 MB before any output came back.
  • huffman_codec, bcj2, rar2: declared lengths from the stream are now checked before anything is allocated. bcj2 and huffman_codec could panic with "capacity overflow".
  • bzip2 and brotli: decoding was quadratic when input arrived in small chunks (bzip2: 155 s → 26 ms) or when one call held a large buffer. Encoding with brotli was also quadratic.
  • .lzma encoder: it kept every compressed byte it produced (537 MiB peak for 512 MiB of input, now about 5 MiB).
  • lzma and lzma2 encoders: the match finder stopped finding matches after 4 GiB of input. Output stayed valid, but the fifth GiB came out 88% larger for xz.

Streaming contract

  • finish drain: finish failed with UnexpectedEnd instead of handing out output it still held, in the deflate, deflate64, zlib, lzss, embed (exact-size output buffer) and arc_squeeze decoders.
  • flush() on zstd, xz and lzma2 encoders: it was a no-op that held back up to 1 MiB of input. These now emit complete blocks or chunks. .lzma can't fully support flush because the format has no sync point; the docs now say so.
  • decode reaching end of stream: it now returns StreamEnd, and stops consuming trailing bytes, for gzip, brotli, xz and lzfse. The default discard_output now stops at the end of the stream too.
  • reset(): deflate and deflate64 kept their preset dictionary only until the first reset. rar5 lost its declared size after reset, which truncated the next stream silently.
  • zstd: skippable frames are recognised again; a byte-order bug made them return BadHeader.

I/O adapters (std and tokio)

  • Readers: a retry after Interrupted or WouldBlock fed the previous chunk to the codec again, duplicating or corrupting output.
  • Writers: if the inner writer failed partway through, output was lost and a retry corrupted the stream. The tokio write_all could fail with WriteZero. The tokio DecoderReader held decoded output back until more input arrived, which can deadlock a request/response protocol.

Build, CI and docs

  • brotli without std: any build with brotli but without std failed to compile. A new CI step builds every algorithm on alloc alone; its feature list is read from all.
  • docs.rs build: it now passes with embed.
  • Deprecation versions: since on the hpack/qpack deprecations is corrected to 0.7.4.
  • CLI test: a test that needed seven codecs is now gated on them.

Behaviour changes worth a release-note line

  • EncoderWriter::flush(), both std and tokio, now does a real sync flush, the same as flate2. For gzip that adds a small marker to the stream on each call.
  • When a bzip2, gzip or brotli stream ends exactly where the input ends, decode reports InputEmpty and finish reports StreamEnd. This is because another member could follow. Bytes after the stream make decode report StreamEnd immediately.
  • The value of lzs::MAX_DISTANCE is now 2047; its type is unchanged.

Known issues not fixed here (each needs a redesign or a policy decision)

  • rar3: it buffers all input and output before returning anything, so LimitedDecoder can't bound its memory.
  • brotli decoder: a meta-block that arrives in pieces is decoded again from its start on every call. It's fine with large chunks but very slow with tiny ones.
  • brotli encoder: flush() is still a no-op, holding back up to about 64 KiB.
  • .lzma decoder: the dictionary is capped at 64 MiB. Raising it needs a growable dictionary or a memory-limit option, and the decoder's config type is currently ().
  • amiga_lzx: never checked against real Amiga archives, because no reference tool or sample files were available.

Verification

On the final tip, in a clean private target directory, all of these pass:

  • fmt, all 5 clippy variants and both full test suites (1946 tests with all features);
  • every no_std build, including thumbv7em, and the alloc-free embed tests;
  • the rustdoc build and the docs.rs-style build;
  • MSRV 1.95, the embed footprint ceilings, the embed cross-check and the fuzz build;
  • cargo semver-checks against v0.7.3.

🤖 Generated with Claude Code

MagicalTux and others added 30 commits October 22, 2026 09:25
…nk targets

The HPACK/QPACK constructor deprecations said `since = "0.6.12"` but first
ship in 0.7.4. The embed::flate format table used explicit link targets
that nightly rustdoc flags as redundant under --cfg docsrs -D warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: build a deflate or deflate64 Decoder with
DecoderConfig::with_dictionary / { dictionary }, decode a stream that
back-references the dictionary, call reset(), decode the same stream.

Effect: the second decode fails with InvalidDistance. raw_reset zeroed
window_size and the dictionary was never stored, so reset silently
dropped configuration, against the Decoder::reset contract
("configuration ... dictionary ... is preserved"); zlib already kept
its FDICT dictionary across reset.

Fix: keep the (window-trimmed) dictionary in the decoder and re-seed
the window from it in raw_reset. reset_keep_window is unchanged. The
old test that asserted reset drops the dictionary is replaced by
regression tests for both codecs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: embed build, decode a deflate/zlib/gzip stream with an output
buffer exactly the size of the data, then finish(&mut []).

Effect: the push decoder refused to decode a literal/length symbol
once the output was full, so it stopped before the last block's
end-of-block code: decode reported OutputFull instead of StreamEnd and
finish(&mut []) failed with UnexpectedEnd on a valid stream, where the
standard build ends the stream.

Fix: decode the symbol even with no room; the window output drops a
literal that does not fit and flags it, and the step is taken back
(bit buffer and input restored) to be redone with room. Footprint
ceilings still hold (the decoders got slightly smaller).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: Decoder::discard_output(stream, n) on a complete deflate,
deflate64 or zlib stream with n larger than its decoded length (with
or without bytes after the stream).

Effect: the default RawDecoder::raw_skip, which these decoders use,
ignored the `done` flag of raw_decode and always returned done=false,
so discard_output never reported StreamEnd (InputEmpty, or OutputFull
with input left over when bytes followed the stream) and kept feeding
the decoder what came after the stream.

Fix: break out of the skip loop when raw_decode reports done and pass
`done` through. Regression tests for deflate and deflate64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: a stream starting with a Skippable_Frame magic
(0x184D2A50..=0x184D2A5F, wire bytes 5? 2A 4D 18).
Effect: the decoder reported Error::BadHeader instead of the documented
Error::Unsupported, because the high-three-byte constant was stored
in the wrong byte order (4D 2A 18).
Fix: compare bytes 1..4 against 2A 4D 18; add a regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: any LZFSE stream containing a bvx2 block (the block type Apple's
encoder uses for all but tiny inputs), e.g. streams from lzfse_rust, which
is validated against Apple's reference library.
Effect: 12 of 13 reference-encoder bvx2 streams tested were rejected with
Error::Corrupt. The decoder had only ever been round-tripped against the
crate's own test encoder, which shared its deviations from Apple's format:
FSE tables were spread zstd-style instead of filled contiguously, payload
bits were read LSB-first and the header's literal_bits/lmd_bits field was
taken as a stub width (Apple: MSB-first, 7 - field unused top bits), L/M/D
fields put the extra bits above the state bits, the literal stream's
multiple-of-four padding was emitted as trailing output, M == 0 commands
were rejected for lacking a distance, and match distances into earlier
blocks were refused.
Fix: follow Apple's decoder on each point; keep a 256 KiB window of
earlier output (trimmed amortised) so bvx2 matches can reach previous
blocks; update the test-only encoder to emit the same format; replace the
hand-made vector (built in the old format) with multi-block fixtures from
lzfse_rust, decoded at several chunkings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: a bvx2 header claiming up to 2^20-1 commands with single-symbol
FSE tables (zero bits per command), e.g. a 124-byte block of
L=0/M=15/D=1 commands.
Effect: the decoder materialises a whole block before draining any of it,
so that block allocated 15.7 MB in one go (with 11-bit M extras ~1.4 MB of
input reaches ~2.4 GB), beyond the reach of LimitedDecoder.
Fix: reject blocks exceeding Apple's per-block limits
(LZFSE_MATCHES_PER_BLOCK = 10000, LZFSE_LITERALS_PER_BLOCK = 40000),
which its encoder never exceeds, bounding a block to ~23.6 MiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: decode() handed a buffer holding an .xz stream followed by more
data (a following file, Stream Padding, a concatenated stream).
Effect: the Index phase slurped all remaining input and reported it as
consumed; bytes past the 12-byte footer were silently discarded, so a
caller could not resume reading after the stream (e.g. 238 consumed for a
224-byte stream).
Fix: once the Index completes, hand the over-read bytes back (they all
arrived in the current call) and let the footer phase read exactly 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: decode() handed a buffer holding an LZFSE stream followed by
more data, e.g. the 34804-byte fixture plus 14 trailing bytes in 7-byte
input chunks.
Effect: every call appended all remaining input to the internal block
buffer and reported it consumed, so bytes after the bvx$ marker were
swallowed (34811 reported consumed) and a caller could not resume after
the stream.
Fix: pull only the bytes the current state needs (magic, block header,
or payload), as the existing comment already intended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: a RAR 2.x stream that uses main symbol 256 (repeat last match)
and later references an old offset via symbols 257..=260.

Effect: unrar's CopyString20 records every match distance, including the
256 repeat, in the 4-entry OldDist ring; we skipped it, so later
old-offset references picked the wrong distance and valid streams
decoded to wrong bytes with no error.

Fix: call commit_match_offset in the 256 branch before copying, and add
a regression test using a hand-built stream checked against unrar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: Decoder::with_unpack_size(1 << 46) (a hostile or corrupt
container header) followed by any decode/finish.

Effect: run_decode called reserve_exact(unpack_size) before reading any
input, so the process aborted with "memory allocation of 70368744177664
bytes failed" instead of returning an error.

Fix: cap the speculative reservation at 64 MiB (same policy as rar3's
out_prealloc); larger members still decode, the Vec just grows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: an unsized decoder (Decoder::new / Rar5::decoder()) decodes one
stream, is reset, then decodes a longer stream.

Effect: the first stream's last block overwrote unpack_total with the
real size, and reset kept it, so the second stream was silently cut to
the first stream's length while reporting StreamEnd.

Fix: remember the constructor's unpack size and restore unpack_total
from it in raw_reset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: a block whose main code gives a long match a 1-bit code
(~3 KiB of output per input byte), decoded through LimitedDecoder or
vec::decompress_to_vec_capped.

Effect: decode_in_block ran the entire block into the internal `ready`
queue before returning, so ~440 KiB of input buffered ~1.3 GiB (a 16 MiB
block reaches tens of GiB) before the output cap saw a single byte.

Fix: pause block decoding once 1 MiB of finished output is waiting (the
bit position already persists in State::InBlock), and let finish resume
paused decoding via raw_decode. Adds a synthetic-stream builder plus
chunking and capped-decode regression tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: two filter descriptors whose regions overlap (e.g. Delta over
[0,100) and another over [10,14)), followed by more data.

Effect: after the first filter ran, the second one's start lay behind
the emit position; flush returned early with it still at the head of
the queue, so no further output was ever produced, out_queue grew
without bound with the remaining decoded data, and finish failed with
UnexpectedEnd.

Fix: discard such a filter, as unrar does for a filter whose start is
behind its written border, and keep flushing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: a caller following add_file_boundary's docs registers solid
member boundaries, calls reset, and decodes the group again.

Effect: the docs promised boundaries survive reset, but raw_reset
deliberately clears them, so the x86 filters of the re-decode silently
used stream-relative positions and corrupted those members.

Fix: make the docs match the (intended) behaviour and tell callers to
re-register after reset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…habets

Trigger: encoding 64 KiB or more of text or random data with lh6/lh7.

Effect: lh6/lh7 used 64/128 KiB dictionaries with 17/18 position
symbols, so matches past 32/64 KiB produced position symbols the real
format does not have; lha failed with "make_table(): Bad table" and
7-Zip/unar reported "Data Error".

Fix: use dictionary bits 15/16 and np 16/17 as LHa, lhasa and 7-Zip do
(and record lh5's real 8 KiB ring, which changes no behaviour). Docs
updated; a module test pins the parameters and checks the transmitted
position-table size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: encoding input whose block has no matches (b"x", b"ab",
b"aaa", or incompressible data) with lh4-lh7.

Effect: the unused position table was written as an explicit list of
all-zero lengths, which 7-Zip rejects with "Data Error" (lha accepts it).

Fix: emit the count-0 single-symbol form (symbol 0) instead, as LHa
itself writes; module test checks the transmitted count for tiny inputs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: a few hundred bytes of lh5 blocks whose single-symbol C table
is a 256-byte match and whose 65535 codes are each zero bits long, fed
through LimitedDecoder (or the default config, which has no size bound).

Effect: finish() decoded the whole stream into one Vec before handing
back any output, so 208 input bytes allocated 512 MiB (~2.5 million to
one) before an output cap saw a byte; without with_len it is unbounded.

Fix: a resumable StaticDecoder (bit position saved/restored via
BitReader::save/resume, partial matches carried across calls) decodes
straight into the caller's buffer; errors are sticky and reset clears
the state. decode_payload keeps its signature as a wrapper. Regression
test: a ~26 KB payload that would expand to ~68 GB must stop at a 1 MiB
cap with OutputLimitExceeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pipe_decode_drains_internal_block_buffer encodes with bzip2, gzip, xz,
zstd, lzma, lz4 and brotli, so it failed under any narrower feature set
(e.g. `cargo test --features rar2`). Compile it only when all are on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: an inner Read that returns Err(Interrupted) or Err(WouldBlock)
(signals, non-blocking sockets) under io::DecoderReader / EncoderReader.

Effect: the refill path reset in_consumed to 0 before calling read();
on error in_filled kept its old value, so the retry re-fed the previous
chunk to the codec. Decoded output was duplicated (or failed as corrupt)
and encoded output contained the plaintext twice.

Fix: read into a local and only update the cursors on success.
DecoderReader also no longer refills over input the decoder has not
consumed yet; it keeps feeding it, or reports a stall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oder output

Trigger: tokio_io::EncoderWriter with a block codec (bzip2, zstd, lzma)
on incompressible input, or tokio_io::DecoderWriter on highly
expanding input (zstd, xz, lzma2, lz5); tokio_io::DecoderReader with
small read buffers or an inner reader that goes idle.

Effect: poll_write made a single codec call; when that call only emitted
buffered output it consumed 0 bytes and returned Ok(0), which write_all
reports as WriteZero. DecoderWriter::poll_shutdown called finish while
the decoder still held output (deflate64 then failed with
UnexpectedEnd). DecoderReader only called decode when it had buffered
input, so decoded bytes stayed stuck until the inner reader produced
more data (a peer waiting for a reply deadlocks), small reads on raw
deflate hit finish early and failed with UnexpectedEnd, and a decode
that wrote nothing while input was pending returned Ready with nothing
filled, i.e. a false EOF.

Fix: poll_write drains staged output and retries until it consumes input
or makes no progress; poll_shutdown drains decode(&[]) before finish;
DecoderReader calls decode every round like the sync adapter and keeps
feeding pending input, or reports a stall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: io::EncoderWriter / io::DecoderWriter over a writer that fails
part-way (WouldBlock from a non-blocking socket, a short write followed
by an error), with the caller retrying the same bytes.

Effect: write() pushed codec output with write_all; on error the bytes
already taken by the codec were not reported and the unwritten part of
the output was dropped. The retry fed the input again and the stream
was corrupt (gzip round-trip failed with InvalidBlockType). A failed
finish() followed by the Drop pass wrote the remaining tail with a
hole in it.

Fix: keep unwritten output in scratch[out_pos..out_len] and send it
before anything else (write, flush, finish). write() returns Ok(consumed)
when the codec already took input, and Err only when nothing of buf was
taken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: Write::flush / AsyncWrite::poll_flush on an EncoderWriter, e.g.
a gzip or zlib stream over a socket flushed after each message.

Effect: flush only flushed the inner writer. Data held in the encoder
stayed there, so the peer could decode none of the flushed message
(gzip: only the 10-byte header had been written). That breaks the
Write::flush contract and stalls request/response protocols.

Fix: flush runs Encoder::flush(Flush::Sync) and writes its output before
flushing the inner writer. Formats without a sync marker keep the no-op
default; the embed encoder's Unsupported is treated as nothing to flush.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: draining a squeezed stream through Decoder::finish with an output
buffer that fills exactly as the decoder's emit buffer runs dry (likely
with small buffers; e.g. the tokio DecoderReader's EOF path).

Effect: the decode loop stopped because output was full, then the EOS
check saw emit_buf empty and eos unset and returned UnexpectedEnd on a
valid stream, with the tail still undecoded.

Fix: decode ahead with pump() before the EOS check; if that yields
output, report not-done so the caller drains it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: any input with a 2-byte match exactly 2048 bytes back (common on
random data >= ~64 KiB, or a 2 KiB block followed by a repeat of its start).
Effect: the encoder wrote distance 2048 into the 11-bit long-offset field,
which wraps to offset 0, so its own decoder (and any RFC 1974 decoder)
rejects the stream with InvalidDistance.
Fix: MAX_DISTANCE is 2047, the largest value the long-offset form can carry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: a valid MS-XCA stream whose last match in a block starts before
the 65,536-byte block end and finishes after it (the spec's decoder only
tests OutputPosition >= BlockEnd before each symbol).
Effect: the decoder cut the match short at the block end, dropping the
spilled bytes and shifting all later output (corrupt data, or an
UnexpectedEnd once the stream ran out early).
Fix: drop the block-end break inside the match copy; the next block's end
is already counted from wherever the match stopped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: encoding more than 4 GiB - 1 bytes (e.g. 4 GiB + 5 zero bytes).
Effect: the 4-byte framing header was written as `total_input as u32`, so
it silently wrapped (to 5 in the example) and the decoder stopped after
that many bytes, reporting success: silent data loss.
Fix: return Error::Unsupported once the input would exceed u32::MAX, the
same guard the xpress and lzs encoders use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: any LZNT1 stream from Windows (RtlCompressBuffer, NTFS), e.g.
the worked example in MS-XCA section 3.3, whose first match 0x2000 sits
at U = 3.
Effect: split_for_pos returned the split reversed (12 offset / 4 length
bits at the start of a chunk, shrinking the offset as U grew) and moved
each boundary one byte early. The decoder rejected real streams with
InvalidDistance or produced garbage, and the encoder wrote streams only
this crate could read. Round-trip tests passed because both sides agreed.
Fix: offset bits = the largest M in 4..=12 with 2^(M-1) < U (else 4),
length bits = 16 - M, per MS-XCA 2.5.1.4. Docs, the split-table unit
test and the hand-built fixtures are corrected; the section 3.3 example
is now a regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: decode a stream with a small output buffer until all input is
consumed while a match copy (or stashed literal) is still pending, then
call finish to drain it.
Effect: finish only accepted the Done/empty-header states and returned
UnexpectedEnd, dropping the last bytes of a valid stream, against the
documented "call finish until StreamEnd" contract.
Fix: in the paused states finish runs the decoder with no input to hand
out the pending bytes; it still reports UnexpectedEnd if the stream turns
out to be truncated once they are out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: hand a whole deflate, deflate64 or zlib stream to decode with
a tiny output buffer, stop once all input is consumed, then call
finish with small buffers until StreamEnd.

Effect: the decoders pull input ahead into their bit reader, so all
input is consumed while decoded output (and the final end-of-block)
is still pending; raw_finish only checked for the Done state and
failed with UnexpectedEnd, breaking the documented "call finish
repeatedly until StreamEnd" drain contract. zlib, which wraps the
deflate decoder, failed the same way.

Fix: raw_finish first runs the decoder on empty input into the
caller's buffer; it reports done once the stream has ended, hands out
what it wrote otherwise, and only fails with UnexpectedEnd when the
stream stops short with nothing left to give. The embed decoders
already did this. Regression tests for all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MagicalTux and others added 27 commits October 22, 2026 09:43
…ting

Trigger: bcj2::decode(main, call, jump, rc, out_len) with a huge out_len,
which in a 7z archive is the attacker-controlled unpack size.

Effect: `vec![0; out_len]` ran before any validation: usize::MAX panicked
with "capacity overflow" (the docs promise "never panics") and values like
1 TiB attempted a giant allocation from a few bytes of input.

Fix: every output byte comes from `main` or a 4-byte call/jump target, so
return UnexpectedEnd when out_len exceeds main+call+jump lengths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: streaming any large input through lzma::Encoder (e.g. 512 MiB of
random data at level 0 with a 64 KiB output buffer).

Effect: take_output copied new bytes out of rc.out but never removed
them, so the range coder's Vec held the entire compressed stream; peak
RSS was ~537 MiB for that run despite the documented O(dict_size)
memory bound.

Fix: move (append) the bytes out of rc.out instead of tracking a
`drained` index. Same output; peak RSS for the run drops to ~5 MiB.
Unit test asserts rc.out stays empty between pushes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: encoding more than 4 GiB with the xz, lzma2 or .lzma encoder.

Effect: the hash chain stores positions as u32 but compared them to the
full usize position. Past 4 GiB every link looked ~4 GiB away, so each
probe broke out as "beyond the dictionary" and no new matches were found.
Measured at level 0 on text-like data: xz output per input GiB went from
345 MB to 648 MB (+88%) for the fifth GiB; .lzma from 583 MB to 648 MB.
Output stayed valid, only the ratio collapsed.

Fix: recover the distance modulo 2^32 ((pos as u32) - link); dictionaries
are < 2^31 so in-range distances are exact, and every candidate is still
byte-verified. After the fix the fifth GiB compresses like the first and
a 5 GiB stream round-trips through `xz -dc`. Unit tests probe the finder
across the 4 GiB boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The .lzma module doc claimed the advertised dictionary is clamped to the
input length; the streaming encoder writes the header before seeing the
input and always advertises the level's window.

lha::static_huff::decode_payload's public doc linked to the crate-private
StaticDecoder, which fails `cargo doc` under -D warnings (CI docs job).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: a stream whose LEB128 length header is huge (e.g. 2^63, or 2^32
with a single-symbol code table) followed by a tiny payload.
Effect: Vec::with_capacity panicked with 'capacity overflow' for 2^63;
for 2^32 the single-symbol fast path materialised 4 GiB from an 8-byte
stream inside finish, before any output, so LimitedDecoder could not
stop it.
Fix: every symbol costs at least one payload bit (the encoder also writes
ceil(len/8) bytes for single-symbol input), so reject a declared length
above payload_bytes * 8 with UnexpectedEnd before allocating.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: a multi-stream file (`cat a.bz2 b.bz2`, pbzip2/lbzip2 output),
which `bzip2 -d` decodes in full.
Effect: the decoder returned StreamEnd after the first stream's trailer
and silently dropped every later stream, while reporting the whole input
as consumed, so a caller could neither notice nor resume.
Fix: after a stream's combined CRC, park in BetweenStreams: another
BZh<1-9> header restarts at Header with a fresh combined CRC; anything
else is trailing garbage and ends the stream (as `bzip2 -d` does). With
no bytes yet, decode waits for more input and finish settles the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecode

Trigger: feeding a large block to the decoder in small input chunks
(1 byte at a time is the extreme).
Effect: every call re-parsed the whole buffered block from its magic and
failed with UnexpectedEnd until the last byte arrived, so decode time was
quadratic in the block size: a 200 KB random block took ~155 s in
release (a 900 KB block would take most of an hour).
Fix: after a failed attempt, retry only once the buffer has grown by 1/8
of what was parsed (at least 1 byte); finish always retries. Total
re-parse work is now a constant multiple of the block size (26 ms for
the same 200 KB block).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: an LZX stream with the Intel E8 header bit set (CAB data from
x86 executables), decoded in one call or in small input chunks.
Effect: every translated call target came out one too small (curpos was
taken as the byte after 0xE8; libmspack, wimlib and cabextract use the
0xE8 byte's own position), and when input arrived in pieces the decoder
drained partial frames before the frame-wide E8 pass ran, emitting
untranslated bytes and shifting later frame offsets.
Fix: use the 0xE8 position as curpos, and hold a frame back (frame_ready)
until it is complete (32 KiB or the final partial frame) and filtered.
Regression test checks values cabextract produced for a hand-built
stream, plus a multi-frame stream under 1-byte and odd chunkings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: a CAB-style LZX stream whose verbatim/aligned data crosses a
32 KiB frame boundary at a bit position that is not word-aligned (i.e.
nearly any real compressed CAB folder larger than one frame).
Effect: MS compressors pad to a 16-bit boundary at every frame end and
libmspack/cabextract skip that padding; this decoder read straight
through it, so everything after the first frame was mis-decoded.
Fix: drop to the next word boundary when a frame completes (a no-op
inside uncompressed blocks, which are word-aligned). Regression test uses
a hand-built multi-frame verbatim stream that cabextract decodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: an uncompressed block whose 27-bit header ends exactly on a
16-bit boundary (e.g. after a verbatim block that ends 5 bits into a
word).
Effect: the format pads 1..=16 bits there, so a whole zero word follows
(libmspack: "read 1-16 (not 0-15) bits to align"); the decoder padded
0..=15, took the pad word as the low half of R0, and decoded the rest of
the block shifted by two bytes.
Fix: when already aligned, drop one full word before the R0..R2 dump.
This encoder's own streams never hit the aligned case, so they decode as
before. Regression test is a hand-built stream that cabextract decodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ends

Trigger: (1) an odd-sized uncompressed block that is not the last block
of the stream (e.g. makecab storing an odd-length incompressible run);
(2) a 32 KiB frame boundary falling on an odd byte inside an
uncompressed block (the block starts at an odd frame offset).
Effect: (1) decoding failed with Unsupported at the pad byte;
(2) both bytes of the straddling word went into the old frame, making
it 32769 bytes, so E8 translation saw the wrong frame tail (an E8 in
the last 10 bytes of the real frame was translated).
Fix: data words are read low byte first and the high byte is carried
in the block state when the frame fills after the low byte; for an odd
block the final word's high byte is the pad and is dropped, so no
separate pad state is needed. Regression tests use hand-built streams
that cabextract decodes to the same bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: a stream whose main tree has long codes (up to 16 bits) and
whose last symbols are short codes sitting in the final few bits.
Effect: a Huffman decode waits until the tree's longest code length is
buffered (or 10 bits for the lookup table); at the end of the stream
those bits never arrive, so the decoder stalled and finish failed with
UnexpectedEnd on streams cabextract decodes fine.
Fix: on finish, append one zero word to the bit reader (as libmspack
does at end of input), run the state machine, then drain. Regression
test uses hand-built streams that cabextract decodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: a match whose offset slot has 17..=19 footer bits (window_bits
19..=21) read while the bit buffer holds 15..=16 bits; found by random
input to Decoder::with_window_bits(21).
Effect: read_many_bits tops the buffer up to 31..=32 bits and takes the
whole footer in one run, but peek_bits debug-asserted 1..=16, so debug
builds panicked (release computed the right value).
Fix: peek_bits accepts 1..=32 (the buffer width; the shift is valid for
all of them). Unit test reads a 19-bit run after a 1-bit read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: a verbatim/aligned block where a match's last byte is the
32768th byte of a frame and the block continues.
Effect: the match-copy loop fell through to decoding the next symbol
instead of returning to step, so the frame-end realignment (and the E8
pass) happened late: the next symbol was read from the padding bits and
the rest of the stream was garbage, or the decoder failed with
InvalidBlockType. Whether it hit depended on input chunking (a literal
path already yielded here). Found by a differential run of random valid
streams against cabextract (3000 streams agree after the fix).
Fix: yield to step after a completed match when the frame is full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… block

Trigger: a stream with an odd-sized uncompressed block that is not the
last block (the format the encoder documents: a 1-byte zero pad iff the
declared block length is odd).
Effect: the decoder returned Unsupported when it reached the pad byte.
Fix: the pad byte is the high half of the block's last 16-bit word,
which the data loop already consumes and drops when the block ends, so
go straight to the next block header and remove the pad state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: a Quantum stream of odd length whose final byte still carries
code bits, decoded through finish.
Effect: the bit reader reads byte pairs; at EOF it substituted 16 zero
bits whenever fewer than two bytes were left, silently dropping an odd
last byte. libmspack's read_input instead appends two zero bytes and
keeps reading pairs, so the last byte is read (paired with a zero).
The arithmetic decoder's final symbols could therefore differ.
Fix: when padding at EOF with one real byte left, read it as the high
byte and a zero as the low byte; the padding is spent either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: calling `Encoder::flush(Flush::Sync | Flush::Full)` on the zstd
encoder with fewer than 128 KiB buffered since the last full block.

Effect: the encoder fell back to the trait's default no-op flush and
reported it complete while still holding up to 128 KiB of input in its
pending block, so a peer decoding the bytes emitted so far got none of
it, breaking the documented flush contract. `Full` did not reset the
match window either.

Fix: implement `raw_flush`. It emits the pending input as a non-last
block (Last_Block = 0) and drains it, returning OutputFull until the
block is fully forwarded so small output buffers just re-call. A frame
flushed before its first full block now gets the windowed header, not
a known-size Single_Segment one. `Full` also drops the cross-block match
window and cached Huffman table, so no later block references pre-flush
bytes. The repeat-offset ring is frame state the decoder keeps, but with
the window empty every repeat distance is bounds-checked against
post-flush data only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: calling `Encoder::flush(Flush::Sync | Flush::Full)` on the xz
encoder with input buffered since the last emitted LZMA2 chunk.

Effect: xz fell back to the trait's default no-op flush and reported it
complete while the LZMA2 stream encoder still held up to ~1 MiB of
input in its chunk lookahead, so a peer decoding the bytes emitted so
far got none of it. `Full` did not reset the dictionary either.

Fix: implement `raw_flush`. It encodes everything the shared
`Lzma2StreamEncoder` buffers into complete chunks inside the still-open
Block and drains them, returning OutputFull until fully forwarded.
Streaming decoders (ours, and `xz -dc`) output that data before the
Block trailer arrives; only the CRC32/Index verification waits for
`finish`, which the codec docs now spell out.

`Full` also starts a new dictionary: `Lzma2StreamEncoder::reset_dict`
empties the window so its base is the reset point, restarts
`output_pos`, and makes the next chunk a dictionary reset (`0xE0` or
`0x01`). Match, rep-distance and literal look-back bounds are now taken
against the window base rather than absolute position zero, so nothing
reaches across the reset; in normal streaming the window always keeps
at least `dict_size` of history, so those bounds change nothing there.
Stale hash-chain entries lie before the base and stop the chain walk,
so the O(dict_size) tables need no clearing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: calling `Encoder::flush(Flush::Sync | Flush::Full)` on the raw
LZMA2 encoder with input buffered since the last emitted chunk.

Effect: like xz, the raw LZMA2 encoder used the trait's default no-op
flush and reported it complete while holding up to ~1 MiB of input in
the stream encoder's chunk lookahead, so a decoder fed the bytes so far
got none of it. `Full` did not reset the dictionary.

Fix: implement `raw_flush` on top of the `Lzma2StreamEncoder::flush` /
`reset_dict` added for xz: encode everything buffered into complete
chunks (no `0x00` end marker) and drain them, returning OutputFull
until fully forwarded; `Full` makes the next chunk a dictionary reset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: calling `Encoder::flush(Flush::Sync | Flush::Full)` on the
`.lzma` (alone) encoder.

Effect: the encoder used the trait's default no-op flush while holding
up to 64 KiB of input (small-input guard buffering) plus its parser
lookahead margin, so a decoder fed the bytes so far was missing all of
that input.

Fix: implement `raw_flush`. It commits to streaming, encodes every
buffered byte and drains every byte the range coder has settled,
returning OutputFull until fully forwarded. `.lzma` has no in-band sync
point: the range coder's last few bytes stay undetermined until the EOS
marker ends the stream, so the trait's guarantee cannot be fully met.
The encoder docs now state exactly what flush guarantees: everything
except what those final range-coder bytes encode (a few bytes on
incompressible data, possibly several KiB on highly repetitive data),
and `Full` behaves like `Sync` since the format has no dictionary reset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: `cargo build --no-default-features --features brotli` (or any
build enabling brotli without `std`, including default features +
brotli). Effect: compile error E0599, `f32::log2` is std-only (libm)
and the q9-q11 optimal-parse cost model in encoder_optimal.rs used it.
CI never caught it because the "no_std every algorithm" step builds
`all`, which pulls in `std`.

Fix: a small core-only log2f (exponent bits + atanh series on the
mantissa, ~1e-5 bits error) and a CI step building every algorithm on
alloc without std.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: a complete gzip member followed by bytes that are not another
member (e.g. tar padding, appended data), decoded with any buffer size.
Effect: the decoder moved to Done but its decode path returned
done=false and swallowed the tail, so `decode` never reported
`StreamEnd` (only `finish` could), contrary to the Decoder contract,
and the caller could not tell where the gzip data ended.

Fix: the Done phase reports done=true and leaves the trailing bytes
unconsumed, as the zlib decoder already does. Also corrects the module
docs, which still claimed multi-member streams were unsupported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…blocks

Trigger: one `encode` call with a large input (e.g. vec::compress_to_vec,
which hands over the whole payload), drained through a normal-size
output buffer. Effect: every 64 KiB meta-block was drained off the front
of the pending input, and every encode/finish call shifted the whole
remaining output queue down after copying out one buffer's worth: both
O(n^2) memmoves. 64 MiB at quality 1 took 3.2 s in one call vs 1.2 s
fed in 64 KiB pieces, growing quadratically with size.

Fix: flush_full_blocks emits blocks by offset and drops them from
`pending` once; compact_out only shifts the queue once at least half
of it has been drained. Output bytes are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…time

Trigger: one `decode` call carrying a multi-meta-block stream (e.g.
vec::decompress_to_vec, or any caller passing a large buffer). Effect:
every meta-block decode cloned all of the not-yet-decoded input, and
every finished meta-block shifted the rest of the input down: both
O(n^2). A 32 MB stream of 64 KiB meta-blocks took 1.39 s in one call
vs 0.36 s fed in 64 KiB pieces, growing quadratically with size.

Fix: move the input buffer out of the decoder for the meta-block decode
instead of cloning it, and drop the consumed prefix only once it is at
least half the buffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: a stream from the reference brotli encoder whose complex prefix
code uses exactly one non-zero code-length code (e.g. `brotli -q 5` on
300 kB of half-random, half-text data). RFC 7932 §3.5 says that symbol
then has a zero-length code, and the reference decoder accepts
`num_codes == 1 || space == 0`. Effect: the decoder demanded space == 0
and rejected the valid stream with InvalidHuffmanTree.

Fix: count the non-zero code-length codes and allow the single-code
case (the zero-length single-symbol decoder was already in place).
Regression test: a hand-built 15-byte stream, checked against
`brotli -d`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trigger: decoding any complete brotli stream with `decode`, with or
without bytes after it. Effect: raw_decode never returned done, so
`decode` never answered StreamEnd (only `finish` did), contrary to the
Decoder contract; bytes after the stream were silently swallowed and
reported consumed.

Fix: once the last meta-block is decoded and its output handed out,
report done; give back the bytes after the stream that arrived in the
call which found the end (earlier calls had already buffered theirs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The step listed 57 features by hand, which drifts as soon as a codec is
added. Read `all` from cargo metadata and drop the std-enabling features
(`std`, `tokio`) instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MagicalTux
MagicalTux merged commit b44654d into master Oct 22, 2026
50 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant