Skip to content

RAR4 (v29) decode: in-band filters, PPMd-II variant H, solid groups - #122

Open
jdlien wants to merge 10 commits into
KarpelesLab:masterfrom
jdlien:rar3-standard-filters
Open

RAR4 (v29) decode: in-band filters, PPMd-II variant H, solid groups#122
jdlien wants to merge 10 commits into
KarpelesLab:masterfrom
jdlien:rar3-standard-filters

Conversation

@jdlien

@jdlien jdlien commented Jul 17, 2026

Copy link
Copy Markdown

Summary

Adds full RAR4 (v29 / unpack29) decode support to the rar3 module —
the format WinRAR 4.x and modern rar (6.x, -ma4) write. The RAR3 LZ77 + Huffman core is extended with in-band standard filters, PPMd-II variant H, solid-archive support, and a shared filter/PPMd layer.

The Delta transform is now shared with rar5. x86 now uses the unmasked 32-bit position base for RAR3 while rar5 keeps its 16 MiB wrap, and solid x86 filters use file-relative address bases.

Clean-room / licensing

Implemented from public specifications and the public-domain LZMA SDK
Ppmd7.{c,h} / Ppmd7Dec.c
(followed closely for the PPMd model, as the
crate's policy allows), plus the algorithm/structure descriptions in BSD
libarchive and the LGPL unarr/The Unarchiver readers — no license-restricted
code was copied
, and RARLAB unRAR was not transliterated. This keeps the
contribution MIT + clean-room, consistent with CONTRIBUTING.md.

Design invariant

Every path is fail-closed: the decoder either produces the
correct bytes or returns an Error. Unsupported sub-features degrade to Error::Unsupported; bad streams surface UnexpectedEnd/Corrupt.

Validation

  • Differential vs UnRAR.exe 7.23 (47-archive corpus, incl. solid m{1,3,5} +
    PPMd groups and 64-bit sizes): 206 member-checks pass, 0 mismatch.
  • cargo test --all-features: 1740 passing; new rar2/3/5 + ppmd libFuzzer
    targets, ASan-clean; fmt and clippy -D warnings clean.

Performance

Output-buffer preallocation plus a check-free bit-consume path take the
match-heavy profile from 1.8× → 1.47× slower than unrar and the x86+filter
profile to parity, byte-identical. See BENCH-RAR4.md.

Known limitation (fail-closed)

A high-order PPMd stream that exhausts a small memory arena can return
Error::Corrupt rather than decoding — never wrong bytes, never OOB. Full
allocator parity is tracked as follow-up.

Note

This branch is stacked on #121 (rar5-x86-filter-fix): two of its commits
(1f28a46, 5c47b0e) belong to that PR. Until #121 merges, a PR from here to
master will include them.

jdlien added 10 commits July 16, 2026 10:28
The decode-direction address fixup flattened unrar's nested condition
into an else-if:

    if (addr < 0)  { if (addr + off >= 0)  addr += FILE_SIZE; }
    else           { if (addr < FILE_SIZE) addr -= off;       }

A negative operand that stays negative after adding the position (a
byte pattern the encoder never rewrote, e.g. a stray 0xE8 inside a
ModRM/displacement sequence followed by high bytes) fell through into
the (addr - FILE_SIZE) check, which any negative value passes, and got
the position wrongly subtracted.

Found by differential testing against WinRAR 7.23 + UnRAR: the first
32 KiB of notepad.exe compressed with -ma5 -m3 (filter type 1, X86Call)
decoded to the right length but 48 wrong bytes across 15 sites, each
the 4-byte operand after a false-positive 0xE8; every wrong value was
exactly (original - position). With the nested checks the whole
corpus decodes byte-identical to unrar. Regression test carries the
verbatim case from that archive plus both boundary companions.
`{:#02x}` sets a width of 2 that the `#` (0x) alternate form makes
inapplicable, so current stable clippy fails the --all-features test
build under -D warnings. Use `{:#04x}` for the intended two-digit form.
RAR3 (v29) in-band filter declarations (main symbol 257) are now decoded
instead of refused. WinRAR's compressor only ever emits a fixed set of
standard RarVM programs, so — like libarchive and unrar in practice — the
decoder recognizes them by bytecode length + CRC-32 and runs native
transforms; any other program still fails with Error::Unsupported, never
wrong bytes:

- Delta (channel de-interleave; channels in VM register 0) — new, shared
  with rar5 via the new crate-internal rar_filters module. This also
  closes the rar5 Delta gap: rar5 FilterKind::Delta now decodes instead
  of returning Unsupported.
- x86 E8 / E8E9 call translation — reuses the rar5 transform (with the
  nested range checks from the recent corruption fix).

The rar5 x86 filters also gain correct solid-archive semantics: unrar and
libarchive compute E8 addresses relative to the *containing file*, not
the solid stream (libarchive tracks this as solid_offset). A new
Decoder::add_file_boundary lets the container register member offsets;
without it single-file streams behave as before.

Wire format and transform semantics were derived from public format
documentation and validated bit-exact against archives produced by
RARLAB rar 6.24, differentially compared byte-for-byte with UnRAR.exe
7.23 across a 100+ archive corpus (RAR4 m1-m5 matrix incl. dict sweeps,
filter isolation archives, and RAR5 solid/nonsolid groups): 188 entries
byte-identical, 0 mismatches. Delta covers more than bitmaps: rar 6.24
emits it for WAV (2ch) and even generic content at -m5 (12ch observed);
filters are auto-applied at m2-m5, so this unblocks most real RAR4
archives, not just executables/images.

Embedded regression fixtures carry real payloads from that corpus with
the archive's own FILE_CRC as ground truth (tests/fixtures/rar3/).

Clean-room: structure cross-checked against libarchive (BSD) only; no
code from RARLAB's unRAR or The Unarchiver. Fingerprint constants were
computed from archives our own tooling generated.
…ta fixtures

Review findings on the in-band filter implementation, all three fixed by
moving to incremental filter application (flush_completed_filters):

- A slot-0 filter-table reset now applies the completed-window prefix and
  cancels everything still pending, mirroring unrar's InitFilters — a
  canceled filter must never rewrite output. Previously pending filters
  survived the reset and ran at finish.
- The pending queue is capped at 8192 concurrent filters (unrar's
  MAX_UNPACK_FILTERS); when the cap is hit after draining completed
  windows the stream is rejected as corrupt.
- A filter window the stream never finishes producing is now Corrupt
  instead of silently returning pre-filter bytes as success. (unrar
  writes the raw bytes and relies on the container CRC to flag the file;
  surfacing the error directly matches this crate's malformed-input
  policy.)

Flushing is prefix-only — a completed window queued behind an incomplete
one waits — so overlapping windows (filter chains) always apply in
declaration order. Output is append-only and LZ back-references read the
unfiltered window, so early application is equivalent to end-of-stream
application.

New coverage:
- Declaration-level unit tests (reset/cancel semantics, the cap,
  slot-reuse remembered lengths, prefix ordering, truncated-window
  integration test) driving the parser with the real 29-byte Delta
  program.
- rar5 end-to-end real-archive fixtures: a Delta-filtered member run and
  a whole six-member solid group exercising add_file_boundary against
  the archives' own data-CRCs, plus a regression guard showing exactly
  the x86 member corrupts when boundaries are not registered.
- Both fuzz targets from the rar-fuzz-targets branch (KarpelesLab#120) merge-tested
  against this branch and run under ASan seeded with corpus payloads:
  decoder_rar3 328k runs / decoder_rar5 341k runs, no findings.

Docs: README capability matrix updated for both formats; rar5 module
docs now describe Delta support and the solid-group calling convention.

Known gap, documented: the 57-byte E8E9 program has no real-archive
fixture because no current archiver emits it (rar 6.24 uses the E8-only
program); its fingerprint cites libarchive and the transform is
unit-tested.
Port the rar2/rar5 segment-copy transformation (KarpelesLab#115) to rar3, which
predated that pass: non-overlapping matches now bulk-copy disjoint
window segments (extend_from_slice + copy_within) and distance-1 runs
bulk-fill, both capped against ring wrap; genuinely overlapping matches
keep the per-byte loop.

Measured against UnRAR.exe 7.23 `t` on 32-61 MB RAR4 archives
(Ryzen 9 9950X, best of 5, see the corpus repo's BENCH-RAR4.md):

  match-heavy text:   790 -> 1480 MB/s (+87%), 4.6x -> 1.9x vs unrar
  x86 + E8 filters:   631 -> 1145 MB/s (+81%), now at parity
  literal-heavy:      unchanged (~122 MB/s, 1.4x) — path untouched

Byte-identical: full differential corpus still 188 pass / 0 mismatch;
decoder_rar3 fuzz re-run clean over the new path (129k runs, ASan).
Implement the full PPMII variant H model (Ppmd7) and wire it into both the
standalone `.ppmd` framing decoder (7z range-coder flavour) and the RAR3/4
PPMd block path (RAR range-coder flavour, with the RAR literal/match/EOD
escape layer). Replaces the earlier order-0 stub (arena.rs/model.rs removed).

Model (src/ppmd/ppmd7.rs): information-inheritance context tree
(CreateSuccessors/UpdateModel), binary-context fast path, masked-escape
suffix walk, SEE, tree-wide Rescale, and the byte-arena suballocator with
GlueFreeBlocks coalescing. OOB-safe arena accessors set an error flag rather
than panicking. Derived from the public-domain LZMA SDK Ppmd7 and the RAR
range-coder description in libarchive; no license-restricted code copied.

Range decoder (src/ppmd/range_dec.rs): dual 7z/RAR carry-less range coder
with a normalisation-step safety cap.

Hardening: reject a declared output length beyond the buffer-then-decode
ceiling up front and bail on range-coder overrun mid-loop — a
high-probability PPMd symbol can decode repeatedly without consuming input,
so `overran()` alone doesn't bound the known-length loop (fuzz-found OOM;
regression test added).

Fuzzing: add dedicated decoder_rar2/rar3/rar5 targets (input-prefix framing
for unpack size / E8 flag / window selector). ASan campaigns over the
standalone PPMd and RAR3 paths are clean after the OOM fix.

Fixtures: real pyppmd Ppmd7 streams (order 6, 16 MiB) plus a RAR3 PPMd block.
The encoder stays permanently Unsupported (RARLAB licence).
…h (codex review)

Address a gpt-5.6-sol review of the PPMd-II var H work. Three silent-wrong-
output paths are closed; a fourth (arena-exhaustion parity) is documented as
a fail-closed limitation.

- Standalone PPMd, unknown declared length: PPMd carries no in-band
  end-of-stream marker, so decoding a u64::MAX-framed stream until the range
  coder exhausts input appended finalisation-byte garbage (reframing the
  1280-byte `repeat` fixture returned 1466 bytes). Refuse unknown length up
  front (Error::Unsupported) instead of guessing.

- RAR3 PPMd block, truncated payload: the decode loop checked rc.err() but
  not rc.overran(), so a truncated payload kept fabricating symbols from
  zero-filled reads until it hit the declared unpacked size — returning
  StreamEnd with a wrong CRC. Fail with UnexpectedEnd once the range coder
  reads past the input.

- RAR3 mixed blocks: a new-table boundary in the LZ path (symbol 256) that
  selects a PPMd block stored the header but let `expand` keep decoding the
  range-coded payload through the previous block's stale Huffman tables
  (garbage). Refuse the mid-stream switch into PPMd (Error::Unsupported),
  matching run_ppmd_block's own start-new-table stance.

- Suballocator exhaustion (documented, not fixed): a high-order model over a
  large high-entropy payload in a small arena can restart the model at a
  different symbol than the encoder and desync, surfacing as Error::Corrupt.
  It fails closed — never wrong bytes, never OOB (verified: no panic under
  instrumented OOB checks). Full GlueFreeBlocks parity is Phase 3 hardening.

Regression tests: unknown-length refusal, absurd-length rejection (existing),
and RAR3 truncation-not-fabrication. Full suite 1733 green; ppmd/rar3 ASan
fuzz campaigns clean.
…arry-over

RAR3 solid groups share one compression history across members while each
member's payload is its own byte-aligned stream. New API:
Decoder::with_solid() + begin_solid_member(n); the LZ window, code tables,
offset history, declared filter programs and any live PPMd model persist,
and per-member state (bit input, output, pending filter windows) resets.

Member-boundary framing was established empirically against the corpus
(probe payload heads + differential validation, no unrar source):

- The LZ end-of-block marker's second bit announces whether the next
  member opens with its own table header or continues symbol decode
  headerless (observed: photo.jpg, ramp.wav); an inline-table marker at
  the exact boundary parses the tables from the current member's tail.
- PPMd members end with an explicit escape+2 marker decoded THROUGH the
  model (the encoder's model saw it too — required for cross-member model
  parity); a continuation header (no 0x20 reset flag) reuses the live
  model and escape char with a freshly initialised range coder.
- Mid-member LZ<->PPMd switches (start-new-table in both domains) and
  PPMd escape-code-3 filter declarations are now decoded; the previous
  Unsupported arms for these are gone.

Fail-closed rules in solid mode: a short member is a hard error (the
shared history would silently desync every later member), and range-coder
state straddling a member boundary (markerless PPMd member end, or a PPMd
block starting exactly at the boundary via an inline announcement) stays
Unsupported — rar 6.24 emits neither.

Also aligns the in-band filter machinery with UnRAR 7.23 semantics per
review: block-length caps at VM_MEMSIZE (0x40000, delta half) and a filter
reset (slot 0) now cancels every scheduled filter without applying
completed-but-unflushed windows (unrar InitFilters30).

Validation: differential harness 206 pass / 0 mismatch (was 188) — all of
rar4_m{1,3,5}_solid + rar4_ppmd_solid byte-identical to UnRAR 7.23, plus
the 64-bit corpus-large case; whole solid groups embedded as fixtures
(m3 6-member group, PPMd pair) asserted against FILE_CRCs; decoder_rar3
fuzz target grew a solid-group mode with real-group seeds, corpus replay +
7-minute ASan campaign clean.

Claude-Session: https://claude.ai/code/session_01DYLfPTghh7DayY4M7YyeKY
Findings from the adversarially-verified multi-agent review of this
branch, all confirmed against reference semantics:

- rar_filters: the shared x86 E8/E8E9 transform gains a wrap_16m mode
  switch. RAR5 keeps the 16 MiB position-base mask (validated against real
  archives); RAR3's VM filter uses the unmasked 32-bit position (per
  libarchive's RAR3 reader) — the shared helper had silently applied RAR5
  masking to RAR3, which would corrupt filter windows past 16 MiB of a
  large executable while reporting success.
- ppmd7: decode_symbol now bounds num_stats (raw arena u16) before its
  `- 1` / `- num_masked` walks — a corrupt context tree could wrap the
  subtraction into a debug panic, a ps[256] out-of-bounds index, or a
  ~2^32-iteration stall. Fail closed with Error::Corrupt. Plus hot-path
  cleanups: char_mask is built only on the escape path, ps is hoisted out
  of the escape loop, and st_copy/st_swap move states with one range check
  instead of 12-24 byte-wise accessor calls.
- rar5: raw_reset() clears file_boundaries — a decoder reused for an
  unrelated stream kept the previous solid group's boundaries and skewed
  every later x86 filter's position base (silent wrong bytes).
- rar3 filters: Delta channel count capped at unrar's
  MAX3_UNPACK_CHANNELS (1024) and no longer rejected merely for exceeding
  the window length (well-defined, and unrar decodes it); out-of-range
  counts fail closed where unrar emits raw bytes with success.
- ppmd decoder: the 11-byte standalone header is validated as soon as it
  arrives (fail-fast from decode(), sticky error) instead of after
  buffering an arbitrarily large payload.

Differential harness unchanged at 206 pass / 0 mismatch; rar3 + ppmd fuzz
corpora replay clean under ASan.

Claude-Session: https://claude.ai/code/session_01DYLfPTghh7DayY4M7YyeKY
… path

Two low-risk decode speedups, both byte-identical (differential harness
206 pass / 0 mismatch):

- Preallocate `out` from the member's `unpack_size` (capped at 64 MiB so a
  hostile size can't drive a huge up-front allocation; the `u64::MAX`
  unknown-length sentinel reserves nothing). The output buffer previously
  grew from empty, reallocating ~log2(size) times mid-decode — ~26
  grow-and-copy passes on a 61 MB member. This is the bulk of the win:
  match-heavy text 1496 -> 1879 MB/s (+26%), x86 1149 -> 1276 (+11%).

- Add a check-free `BitReader::consume` for use right after a successful
  `peek` (the availability re-check in `drop_bits` is redundant there);
  route `read_bits` and the Huffman LUT fast path through it. After a 9-bit
  LUT miss the code is provably >9 bits, so the canonical slow-path scan
  starts at length 10. Small consistent x86 gain; literal/Huffman profile
  unchanged (it is not bit-decode-bound).

Measured note for the record: directly skipping the sliding-window writes
(a throwaway experiment) produced ~no speedup, so the out+window double
write is not the bottleneck and an out-as-window refactor would not pay off.
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