Skip to content

Work - #1

Merged
apostasie merged 19 commits into
mainfrom
work
Aug 3, 2026
Merged

Work#1
apostasie merged 19 commits into
mainfrom
work

Conversation

@apostasie

Copy link
Copy Markdown

No description provided.

  The parallel reader allocates a whole block up front from the
  uncompressed size recorded in the index, before decoding anything.
  That number was only checked for being non-negative, so a 44-byte
  file claiming 2^49 bytes aborted the process with "makeslice: len
  out of range" — and because the allocation happens on a worker
  goroutine, the caller could not recover from it.

  Bound each record's uncompressed size by what a block of its unpadded
  size could actually produce, and reject sizes beyond the address
  space so 32-bit builds do not truncate instead. As defence in depth,
  convert a panic escaping a worker into a decode error rather than
  letting it kill the process.

Signed-off-by: apostasie <spam_blackhole@farcloser.world>
  The parallel reader parses the index before the blocks, so it cannot
  cross-check the declared record count and passes -1 to skip that
  check. That left the count — a raw uvarint from the file — sizing
  make([]record, n) directly, so a 40-byte file reserved 16 TiB and a
  44-byte one panicked in makeslice. TODO.md records this exact bug
  being found by fuzzing in 2021 and fixed by adding the count check;
  disabling the check for the parallel path reopened it.

  Grow the slice with the records that actually arrive. A hostile count
  now runs into the end of the index and returns EOF, and the check
  stays in place for the sequential reader that can still afford it.

  Also make the hostile-file tests assert an allocation budget, since
  returning an error is not enough if the heap is already gone.

Signed-off-by: apostasie <spam_blackhole@farcloser.world>
  parseBlocks summed the padded block sizes from the index with no
  overflow check. Two records of 2^63-1 wrap the sum negative, which
  puts the computed stream header position at or after the footer the
  walk started from, so pos never decreases and a 104-byte file spins
  NewParallelReader at 100% CPU forever. It takes no context, so there
  is no way to cancel it.

  Accumulate against the space actually available between the stream
  header and the index: every partial sum stays in [0, indexStart], so
  neither the comparison nor the addition can overflow. Add an explicit
  check that each stream precedes the index it was found from, so the
  loop's termination is checkable locally.

Signed-off-by: apostasie <spam_blackhole@farcloser.world>
  The dictionary capacity in an xz block header is attacker controlled
  and reaches 4 GiB, and it was allocated in full before decoding
  started. A 104-byte file with the dictionary-size property set to the
  maximum forced a 4 GiB allocation and then decoded 45 bytes.
  ReaderConfig.DictCap could not prevent it: the filter's value replaces
  the caller's whenever it is larger, so it is a floor, not a ceiling.
  The lzma package already treats its own DictCap as an upper limit for
  .lzma files; the .xz path never got the same treatment.

  Start the dictionary at 64 KiB and double towards the declared
  capacity as data arrives, so it costs what the stream produces rather
  than what its header claims. Growth happens before the ring buffer
  would first wrap, so relocating it is a prefix copy and no index
  changes; wrapping only becomes possible once the declared capacity is
  reached, which is where the old behaviour resumes exactly.

  The bomb file now allocates 0.1 MiB instead of 4 GiB. Reader
  throughput is unchanged; doubling costs about twice the final
  dictionary in transient garbage.

Signed-off-by: apostasie <spam_blackhole@farcloser.world>
  A properties byte can ask for lc=8 lp=4, which sizes the literal
  codec at 0x300<<12 probabilities: 6 MiB allocated and refilled by
  every LZMA2 chunk that carries a property reset. 480 KB of such
  chunks drove 120 GiB of allocation and 16 seconds of CPU while
  producing only 40 MB of output, so output-size bomb detection does
  not see it.

  The reference implementation rejects lc+lp > 4 when decoding the
  properties byte of both LZMA and LZMA2 streams, and our own writer
  has always refused to emit it; only the decoder let it through. Apply
  the rule in Properties.verify so both directions share one definition,
  and drop the writer's separate copy.

  Two header test fixtures used lc=4 lp=3, a combination no real file
  can contain; they now use lc=2 lp=2.

Signed-off-by: apostasie <spam_blackhole@farcloser.world>
  Close is documented as the way to abandon a ParallelReader, but a
  Read waiting on a block never noticed it. The cancelled dispatcher
  closed jobs and not queue, and could leave a block queued that no
  worker would ever report on, so both of nextBlock's receives could
  park forever. Close from another goroutine also raced with start over
  done, and with Read over err; -race reported both.

  Close both channels on every dispatcher exit path, watch done in
  nextBlock, create done with the reader so Close cannot race a first
  Read, and put err behind a mutex. err now keeps the first failure and
  returns it consistently, so a reader that reached io.EOF still reports
  io.EOF after a later Close.

  Read and WriteTo remain single-goroutine; Close is now genuinely safe
  from another one, and the type documentation says so instead of
  leaving it to "not safe for concurrent use".

Signed-off-by: apostasie <spam_blackhole@farcloser.world>
  Correctness: checked accumulation for the index size total so Size
  cannot wrap; a finalizer so an abandoned ParallelReader does not leak
  its workers for the life of the process; io.ErrShortWrite instead of
  an infinite retry when a writer accepts nothing and reports no error;
  re-apply the Workers floor in start, since it is a promoted field and
  zero workers left every read waiting; a length guard on the block
  header.

  API: WriteTo on a drained reader returns success rather than io.EOF,
  which io.Copy would surface as a failure. ReaderConfig.Verify and
  ParallelReaderConfig.Verify now write back the defaults they always
  claimed to apply, and DictCap is documented as the floor it is. New
  ErrCorrupt, ErrClosed and ErrUnsupported let callers separate a bad
  file from a bad transport with errors.Is instead of matching message
  text; the messages themselves are unchanged, and an I/O error from the
  underlying reader still passes through unwrapped. Added interface
  assertions.

  Security: scan stream padding in blocks rather than one four-byte read
  per group, so a zero-padded file is not millions of syscalls. Reject
  non-canonical variable-length integers and cap them at the nine bytes
  the format allows, matching liblzma — accepting more meant reading
  files the reference decoder refuses.

  Docs: ignore .idea/, go install instead of the long-dead go get form,
  drop upstream text that predates this fork's parallel reader, correct
  the speedup claim, and fix typos.

  One test fixture round-tripped 1<<64-1 through the uvarint encoder,
  which needs ten bytes; the format's maximum is 1<<63-1 in nine.

Signed-off-by: apostasie <spam_blackhole@farcloser.world>
  An LZMA2 chunk can reset the coder state, and state.Reset assigned a
  fresh struct value, dropping every codec's probability array. Each of
  the ~145 trees in the length, repeat length and distance codecs plus
  the literal codec then reallocated and refilled, and a chunk carrying
  new properties built a whole new state on top of that. A stream of
  small resetting chunks cost far more in allocator traffic than in
  decoding.

  Reuse the backing arrays when they are large enough, assign Reset's
  scalar fields individually instead of overwriting the struct, and
  reset in place in the reader. On 20000 resetting chunks: 385.3 MiB of
  allocation drops to 18.6 MiB and 95 ms to 67 ms. Decoded output is
  byte-identical and enwik7 is unaffected, having almost no resets.

  Reset no longer zeroes the struct, so a field added later without a
  matching reset line would survive one. TestStateResetReusesWithoutDrift
  dirties every probability and requires a reset state to deep-equal a
  fresh one.

  Also drop the per-byte modulo in both rolling hashes for an equivalent
  increment-and-compare. That one measures flat (p=0.74) because the
  writer is memory-stalled; it is kept as a simplification, not a win.

  PERF.md records both results, and corrects the long-standing claim
  that the writer's remaining cost is structural: profiling puts 75% in
  the match finder and 15% in the range encoder, and throughput tracks
  the match finder's working set rather than its call structure.

Signed-off-by: apostasie <spam_blackhole@farcloser.world>
  Adds five fuzz targets with a seed corpus, end-to-end coverage of all
  four check types, and systematic malformed-input tests. Package
  coverage goes from 77.6% to 87.6%.

  The malformed-input sweep found a real bug. A file truncated at the
  end of its last block, with the index and footer gone but every byte
  of compressed data present, decoded to the full correct payload and
  returned no error; xz -dc rejects the same bytes. streamReader.Read
  read the bare io.EOF where the next block header belongs as "this
  stream ended" rather than "this file was cut". Every xz stream ends
  with an index and a footer, so that EOF is truncation. This is how an
  interrupted download turns into silent data loss.

  Coverage also showed newCRC32 was never called: the CRC-32 path, which
  is what the reference tool emits by default, had no test at all. All
  four checks now round-trip, detect a corrupted check, decode under
  xz -dc, and read back files xz produced.

  WriterConfig{CheckSum: None} silently yields CRC-64, since None is
  zero and fill treats a zero CheckSum as unset. Left as is, because
  changing it would alter existing callers' output; pinned by a test and
  documented on the field.

Signed-off-by: apostasie <spam_blackhole@farcloser.world>
  Applies the modernize analyzer (interface{} to any, range over int,
  min, slices.Backward, obsolete +build lines), replaces the deprecated
  io/ioutil, and moves the benchmarks to b.Loop, which also stops the
  one-time setup in BenchmarkReader from polluting CPU profiles.

  Adds .golangci.yml with 19 linters beyond the defaults, weighted
  towards correctness: a codec's long decode loops and deliberate width
  conversions fight style checks, so gosec's G115 and staticcheck's
  QF1001 are off, each with its reason recorded in the file. All 87
  issues it reported are fixed.

  One of them was a bug. Writer2.Close discarded a failed Flush and
  returned nil, so a write error while flushing the last chunk produced
  a truncated stream and a successful Close. Together with the reader
  accepting a file truncated after its last block, this package could
  write a short archive silently and read it back without complaint.
  Nothing in the suite had closed a writer over a failing sink.

  Output is byte-identical, and an interleaved A/B shows the reader
  slightly faster and the writer unchanged.

Signed-off-by: apostasie <spam_blackhole@farcloser.world>
  Writer2.writeChunk picked the uncompressed chunk form whenever it was
  smaller, but that form is produced by copying the chunk's input back out
  of the encoder dictionary. On incompressible input a chunk grows to the
  64 KiB compressed-data limit, and a dictionary smaller than that has
  already dropped part of the input, so CopyN came up short and Write or
  Close failed with "insufficient space". DictCap values down to
  MinDictCap (4096) are legal, which made those configurations unusable.

  Only choose the uncompressed form when the dictionary still holds the
  whole chunk — the same criterion CopyN truncates on — and otherwise
  write the compressed chunk, which always exists and always fits. Output
  is byte-identical for every configuration that worked before; the
  previously failing ones now pay at most the range-coder overhead per
  chunk instead of erroring. This also stops lzma.ErrNoSpace, which
  matches none of the package's sentinels, from escaping to callers.

Signed-off-by: apostasie <spam_blackhole@farcloser.world>
  Dropping a ParallelReader without Close left the dispatcher and every
  worker running for the life of the process: with more blocks than the
  queue holds, the dispatcher blocks on the full queue, never closes the
  job channel, and the workers never return. The SetFinalizer meant to
  cover this could not fire at all — the goroutines were started as
  methods on the reader, so their stacks kept it reachable, and the
  finalizer could only run once they had already exited on their own.

  Move everything the dispatcher and workers touch onto an internal
  parallelDecoder, so the goroutines no longer root the reader, and
  attach a runtime.AddCleanup to the reader that stops the decoder once
  the reader is collected. Close remains the documented way to release a
  reader early; the cleanup only keeps forgetting it from leaking.

Signed-off-by: apostasie <spam_blackhole@farcloser.world>
  The parallel reader allocated each block's whole uncompressed size up
  front, straight from the index record. checkUncompressedSize bounds
  that declaration, but the surviving factor is about 350,000x the
  block's input, so a 5.4 KB file whose index declares 300 MiB for one
  8 KiB block allocated 300 MiB on a worker goroutine before anything
  validated the claim — multiplied by every block in flight.

  Start each block at 1 MiB and grow the buffer geometrically with the
  bytes actually decoded, capped by the declared size, so memory tracks
  real output rather than the attacker-controlled declaration. Pooled
  buffers keep their capacity, so a warmed-up reader reslices instead of
  growing. The plausibility check stays: it rejects absurd records before
  a worker spends time on them and keeps Size meaningful.

Signed-off-by: apostasie <spam_blackhole@farcloser.world>
  Non-zero padding in a block header came back as errPadding, the
  sentinel the stream reader uses for stream-padding control flow. It is
  a plain errors.New, so the error did not match ErrCorrupt — breaking
  the contract that every corrupt-input error from this package matches
  it, and steering callers who use the sentinel to separate "reject the
  input" from "retry the transport" into retrying a permanently corrupt
  file — and its message talked about stream padding that is not
  involved. Return a corruptf error instead.

  Also replace the two surviving bare io.EOF comparisons in
  blockReader.Read with errors.Is, and document on Reader.Read that data
  returned alongside a corruption error can include trailing bytes
  decoded from input past the corruption point, an artifact of the range
  decoder's sticky error handling, and should be discarded.

Signed-off-by: apostasie <spam_blackhole@farcloser.world>
  Writer: the per-chunk state snapshot (cloneState) was 91% of the
  writer's allocations because the deepcopy methods always allocated
  fresh probability arrays. Let deepcopy reuse capacity like init does,
  snapshot into one persistent start state, and replace the state
  aliasing in writeUncompressedChunk with a copy. 4,624 -> 306 allocs/op;
  output is byte-identical.

  Reader: nothing was reused across blocks or chunks. Add
  lzma.Reader2.Reset and thread a cached reader through the xz filter
  plumbing — the sequential reader carries it across blocks and streams,
  each parallel worker owns one — so the decoder, its probability models
  and the dictionary survive block boundaries. Reinitialize the range
  decoder in place on Reopen and reuse the chunk header and its scratch
  buffer. 291 -> 132 allocs/op on one block; 16,859 -> 2,576 on a
  153-block file. Parallel workers also keep one bufio.Reader, created on
  first use and sized to the largest block, instead of allocating 256 KiB
  per block: decoding 64 KiB-block files goes from 767 to 976 MB/s.

  Also guard the per-chunk/per-block xlog.Debugf calls behind a new
  xlog.DebugEnabled (the ...any boxing happens at the call site, before
  suppression), use stack arrays in the CRC Sum methods, replace the de
  Bruijn nlz32 with math/bits.LeadingZeros32, and document the intended
  cost of on-demand dictionary growth. LZMA1 input buffering (P9) is left
  alone deliberately: batching reads would consume input past the end of
  the stream and break embedded-stream callers.

Signed-off-by: apostasie <spam_blackhole@farcloser.world>
Signed-off-by: apostasie <spam_blackhole@farcloser.world>
Signed-off-by: apostasie <spam_blackhole@farcloser.world>
Signed-off-by: apostasie <spam_blackhole@farcloser.world>
Signed-off-by: apostasie <spam_blackhole@farcloser.world>
@apostasie
apostasie merged commit a4204e8 into main Aug 3, 2026
9 checks passed
@apostasie
apostasie deleted the work branch August 3, 2026 07:33
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