Repository navigation
fix: pre-0.7.4 audit fixes across codecs, I/O adapters and CI - #145
Merged
Merged
Conversation
…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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
cabextracton 3000 random streams.bvx2blocks, which Apple's encoder uses for all but tiny inputs, couldn't be decoded. Checked with streams fromlzfse_rust, which validates itself against Apple's library.lhaand 7z rejected inputs of 64 KiB or more, and 7z also rejected blocks with no matches. Every method except lh2 is now checked against thelhaCLI in tests; lh2 is in fix(lha): make -lh2- match the LHarc format (needs a licensing decision) #146.MAX_DISTANCEis now 2047.cat) now decode in full instead of stopping after the first.Memory and CPU on untrusted input
finish; 208 bytes of input could allocate 512 MiB. It now streams.Streaming contract
finishdrain:finishfailed withUnexpectedEndinstead 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..lzmacan't fully support flush because the format has no sync point; the docs now say so.decodereaching end of stream: it now returnsStreamEnd, and stops consuming trailing bytes, for gzip, brotli, xz and lzfse. The defaultdiscard_outputnow stops at the end of the stream too.reset(): deflate and deflate64 kept their preset dictionary only until the firstreset. rar5 lost its declared size afterreset, which truncated the next stream silently.BadHeader.I/O adapters (std and tokio)
InterruptedorWouldBlockfed the previous chunk to the codec again, duplicating or corrupting output.write_allcould fail withWriteZero. The tokioDecoderReaderheld decoded output back until more input arrived, which can deadlock a request/response protocol.Build, CI and docs
std: any build withbrotlibut withoutstdfailed to compile. A new CI step builds every algorithm onallocalone; its feature list is read fromall.embed.sinceon the hpack/qpack deprecations is corrected to 0.7.4.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.decodereportsInputEmptyandfinishreportsStreamEnd. This is because another member could follow. Bytes after the stream makedecodereportStreamEndimmediately.lzs::MAX_DISTANCEis now 2047; its type is unchanged.Known issues not fixed here (each needs a redesign or a policy decision)
LimitedDecodercan't bound its memory.flush()is still a no-op, holding back up to about 64 KiB..lzmadecoder: 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().Verification
On the final tip, in a clean private target directory, all of these pass:
no_stdbuild, including thumbv7em, and the alloc-free embed tests;cargo semver-checksagainst v0.7.3.🤖 Generated with Claude Code