Status: §A1, §A2, §B1, §B2, §C1, §E1, §F1 landed (drafted 2026-05-09, round-one shipped through 2026-05-11); §G1 round-one (STORED) landed 2026-05-13. The
method >= 1dispatch inrar_pipeline::extract_entrynow drivesRarStreamDecoder; the §3 STORED-only rejection has been removed. The mid-entry checkpoint blob (§F1) is wired throughCheckpointformat v11 and the crash-resume integration test covers compressed entries end-to-end including the multi-block lookahead path (seePLAN_rar5_multi_block_decode.md, resolved 2026-05-10).Open: §C2 (custom-filter bytecode) — deferred to
O.RAR.CUSTOMFILTER. §G1 round-two (compressed-method hot path) — not started; round-one shipped the STORED-side optimizations that brought the README bench grid'srar5100 MiB warm cell from 5.66× → 0.86× ofunrar(see the Postmortem note at the bottom of §G1).Sequencing. §1+§2+§3 of
internal/PLAN_rar.mdwere onmainbefore §A1 began; the decoder plugs into the sameRarSink/rar_pipelinesurfaces those phases shipped.
Supersedes: nothing — this is additive to internal/PLAN_rar.md §4.
§0.1 of internal/PLAN_rar.md rejected the unrar C++ FFI on
licensing grounds: the unRAR license is non-OSI and
GPL-incompatible, and peel is licensed MIT OR Apache-2.0.
Mainstream Rust BLAKE2sp / RAR5 ref crates don't help —
blake2 = "0.10" dropped the parallel variants and the
pure-Rust RAR ports examined on crates.io 2026-05 covered RAR5
minimally or not at all.
Hand-rolling is comparable in scope to
internal/PLAN_zstd_block_decoder.md (10 phases, ~12 weeks) but
materially harder: the RAR5 wire format is author-provided by
RARLAB rather than IETF-standardized, the corpus of public test
vectors is smaller, and the algorithm has more moving parts
(LZSS + filter VM + adaptive Huffman re-coding per block).
The cost is the price of staying fully OSI-licensed.
- Std-first; allowlist-only. No new runtime dependencies. The
reference implementation we cross-check against in tests can
shell out to the locally-installed
unrarbinary at[dev-dependencies]-only scope (mirroring the precedent set byxz2for the XZ plan andflate2for DEFLATE), but the runtime binary must not link any RAR-licensed code. - No async runtime.
- Linux-first; macOS works via the existing
MacosPuncher. - Backwards-compatible checkpoints. Each phase that introduces
new resume state bumps
Checkpoint::format_versionperPLAN.md§9.2. - Hand-rolled wire-format parsing. Every layer (bitstream reader, Huffman tables, LZSS dictionary, filter VM) is hand-rolled. No transitive linking against RAR-licensed C code.
- Compression —
peelis decompression-only. Theunrarlicense restriction we sidestepped by hand-rolling forbids using its source to build a compressor; we never wanted to build one anyway. - RAR4 (legacy). Out of scope per
PLAN_rar.md"What this plan deliberately does not include". RAR4 archives surfaceRarError::UnsupportedFormatVersionat parse time and never reach the decoder. - Encryption (
O.RAR.ENCfollow-on). The decoder ships unencrypted-only. - Multi-volume (
O.RAR.MV). - SFX archives (
O.RAR.SFX). - PPMd-II / PPM compression. RAR4 supported a PPM mode; RAR5
dropped it. libarchive's RAR5 decoder
(
archive_read_support_format_rar5.c) lists method values STORE..BEST and never invokes its bundled PPMd7 functions for RAR5 entries; theFILTER_PPMfilter slot is explicitly marked "not used in RARv5". Hypothetical PPMd-encoded entries surface asRarError::UnsupportedMethodat parse time.
What: hand-rolled MSB-first bitstream reader over the entry's
data area. Lives in src/decode/rar_native/bits.rs.
Why first: every layer above (Huffman, LZSS, filter VM) reads bits and groups of bits from the same stream; getting the bitstream wrong corrupts everything else. Validates the foundation cheaply.
Sketch.
BitReader<'a>over&'a [u8]:read_bits(n: u8) -> u32,peek_bits(n: u8) -> u32,consume(n: u8),align_to_byte(). MSB-first (RAR5 convention), fast path over au32accumulator that refills lazily.- Position tracking in bits (not bytes) so the decoder can record exact restart points later.
- Tests: round-trip random bit sequences, alignment-padding semantics, end-of-buffer rejection.
Demo: cargo test decode::rar_native::bits passes including
a property test that round-trips Vec<(u8, u32)> through the
encoder + decoder and recovers the original groups.
What: canonical Huffman code-table reader and decoder, as
RAR5 uses for literals + match lengths + match distances. Lives
in src/decode/rar_native/huffman.rs.
Sketch.
- Code-length table parser per the RAR5 algorithm (the table is itself Huffman-encoded; we need a small inner decoder to bootstrap).
- Lookup-table-backed canonical Huffman decoder
(256-entry root + per-leaf overflow chains). Same shape as
decode/deflate_native/huffman.rs. - Tests: round-trip random byte sequences encoded with
flate2's reference Huffman + verify our decoder recovers the bytes; rejection of malformed code-length tables.
Demo: cargo test decode::rar_native::huffman passes.
What: ring-buffered dictionary the LZSS layer copies matches
from. Lives in src/decode/rar_native/dict.rs.
Sketch.
Dict { buf: Box<[u8]>, head: usize, capacity: usize }— fixed-capacity ring buffer up to 4 GiB (RAR5's max dictionary size).push_literal(b),copy_match(distance, length),recent_window(start, len)for the filter VM.- Resume snapshot: serialize the live tail of the buffer up to
min(head, capacity)plus the metadata. Same shape asxz_native::dict::Dict::serialize. - Tests: round-trip serialization, copy-overlap semantics
(
distance < length, the LZSS-specific RLE pattern), wrap handling atcapacityboundary.
Demo: cargo test decode::rar_native::dict passes.
What: per-block decoder that consumes Huffman codes and
populates the dictionary. Lives in
src/decode/rar_native/lzss.rs.
Sketch.
decode_block(reader, dict, sink)— drives the literal / short-match / long-match dispatch using the per-block Huffman tables from §A2.- Block-end detection (the literal-table's end-of-block marker) so the upper layer can re-read fresh tables.
- Tests: differential against a small corpus of single-block
RAR5 archives produced by
rar a -m1against fixed payloads. Cross-check the dictionary state against theunrarbinary's debug output where feasible.
Demo: cargo test decode::rar_native::lzss passes for at
least one curated single-entry archive < 1 MiB.
What: small bytecode VM for the post-decompression filters
RAR5 supports (e8/e9/itanium/rgb/audio/delta + custom). Lives
in src/decode/rar_native/filter_vm.rs.
Sketch.
- Decode the static filter set RAR5 ships (per the technote). The "custom" filter slot is rare in practice and lands as a §C2 follow-up.
- Apply filters to dictionary windows after the LZSS layer produces a contiguous run.
- Tests: round-trip e8/e9 transformations against a curated corpus of executable fixtures (a tiny ELF/Mach-O blob is enough); RGB transformation against a small bitmap.
Demo: cargo test decode::rar_native::filter_vm passes.
Deferred. RAR5 archives in the wild rarely use the custom
filter slot — rar a does not emit it by default. Filed as a
follow-on in OPTIMIZATIONS.md (O.RAR.CUSTOMFILTER).
What: RarStreamDecoder that owns the bitstream + dict +
filter VM and exposes the [crate::decode::StreamingDecoder]
trait so the §3 pipeline can replace its STORED-only check
with a fully-method-aware dispatcher. Lives in
src/decode/rar_native/mod.rs.
Sketch.
RarStreamDecoder { bits, dict, vm, ... }— per-entry instance.decode_step(sink)— bounded work per call, same contract as every other decoder.- Wire into
rar_pipeline::extract_entrysomethod != 0entries dispatch through the decoder rather than the passthrough copy path. - Tests: differential round-trip 100+ archives across a
curated + random corpus, byte-comparing against
unrar- produced expected outputs (committed as*.expected.binalongside the fixture archives intests/fixtures/rar5/).
Demo: full RAR5 round-trip against a multi-MB archive downloaded over the mock server.
What: serializable decoder snapshot so a kill -9
mid-entry resumes byte-identical. Lives next to §B1's dict
snapshot but takes the bitstream + Huffman + VM state into
account.
Sketch.
RarStreamDecoder::serialize() -> Vec<u8>— wire-format- stable blob covering every field.RarStreamDecoder::deserialize(&[u8])— round-trip.- Bump
Checkpoint::format_version(v11) and add an optionalcurrent_entry_decoder_statefield toSinkState::Rar(PLAN_rar.md§3 had reserved this slot). - Tests: crash-test round-trip — kill mid-entry at random bit offsets and verify the resumed extraction is byte-identical for 100+ trials.
Demo: the tests/test_coordinator_rar.rs crash-test now
also covers compressed entries.
Postmortem note (2026-05-10): landing §F1 surfaced a latent
struct-layout bug in MacosPuncher — small code-gen changes from
F1 left nonzero garbage in the fpunchhole_t struct's
previously-implicit padding bytes, and APFS rejects the call
with EINVAL when reserved != 0. The bug was not in F1; it
had been latent since MacosPuncher shipped. Full investigation
in PLAN_macos_puncher_race.md.
The crash-resume test now passes 100/100 on macOS arm64 in both
debug and release.
Postmortem note (2026-05-11): wiring the
crash_resume_mid_compressed_entry_produces_identical_output
integration test (the multi-block sibling of the original §F1
coverage) surfaced a real bug in the snapshot serializer. After
each non-last block, RarStreamDecoder::read_block pulls
BLOCK_LOOKAHEAD_BYTES = 4 past the block end into prepend_buf
so the LZSS dispatcher's symbol peek can read across the seam.
The snapshot serialized src_consumed verbatim and the resume
factory cleared prepend_buf, so the resumed decoder's source
delivered bytes starting past the next block's prologue —
the next read_block call read garbage. Fix: serialize
src_consumed - prepend_buf.len() (the "logical" source cursor)
so the resumed decoder's fresh src re-delivers those 4
lookahead bytes as the next block's prologue. Byte-equivalent to
the original lookahead-replay state; avoids serializing the
lookahead bytes themselves. No-op on single-block snapshots
(prepend_buf is always empty there), so the existing
snapshot_resume_round_trips_at_every_step test stays green
without changes. The new integration test pins the multi-block
resume path against the curated multi_block_p27.rar fixture
(67.5 MB decoded, 2.8 KB compressed — Goldilocks for the tight
checkpoint cadence).
What: O.7b-style optimisations after correctness lands.
Bit-table lookups, fast-path literal runs, branchless filter
dispatches. Lives entirely under src/decode/rar_native/;
no new files outside that tree.
Why last: round-one of the hand-roll prioritises correctness; throughput is a second pass once the differential corpus is large enough to be a reliable benchmark.
Demo: bench-grid run shows decode throughput within 2× of
the unrar C++ reference on the curated corpus.
Postmortem note (2026-05-13): round-one of §G1 turned out
to live entirely outside src/decode/rar_native/ — the §3
STORED hot path was dominated by sink-side bookkeeping the
decoder never participated in. Three changes shipped together;
the bench-grid rar5 100 MiB · warm cell moved 5.66× → 0.86×
of unrar (README §"Local-file decode" was refreshed in the
same change). Source-of-record commits stay on the
g1-stored-perf topic branch:
RarSinkbuilds its running BLAKE2sp and CRC-32 only when the caller supplied the matchingexpected_*field. The §1 parser does not yet decode the BLAKE2sp extra-record (O.RAR.HASH_EXTRA) so production pre-this-change was paying ~125 ms per 100 MiB for a digest no consumer ever read.EntryFinalize::{crc32,blake2sp}shifted fromu32/[u8; 32]toOption<…>so the slot shape now matches the runtime cost.zip::Crc32::updatenow dispatches to an#[target_feature(enable = "crc")]aarch64 path that folds 8 input bytes per__crc32xinstruction; slice-by-16 stays as the portable fallback. Apple Silicon's CRC unit takes the slice-by-16 path's ~25 ms per 100 MiB to ~3 ms, with a cross-check test (hw_and_portable_paths_agree_on_random_corpus) pinning bit-exact equivalence against the table-based oracle. Thehash::crc32variant — used by gzip / xz / xz-block — stays slice-by-1 and is the obvious next throughput follow-on for the gz-raw row in the same README grid.- The three STORED copy loops in
rar_pipeline(RAR5 plain, RAR5 encrypted, RAR3 plain) read 1 MiB at a time instead of 64 KiB. The macOS pread + write syscall pair was the next bottleneck after the hash work fell away — at 64 KiB a 100 MiB archive paid ~1600 syscall round-trips, at 1 MiB it pays ~100. Constant lives inSTORED_COPY_BUF_LEN.
Round-two of §G1 — the compressed-method hot path, where
internal/PLAN_rar5_decoder.md Phase B's LZSS dispatcher and
Phase C's filter VM live — has not yet been profiled. The
bench grid's rar5 row exercises STORED only; a
compressed-method bench is the gating prerequisite for that
work.
All of the following are true:
- Each phase's demo has been recorded and reviewed.
- The crash-test harness covers compressed entries in both solid and non-solid modes; resumes still produce byte-identical output.
OPTIMIZATIONS.mdfollow-ons have been amended with the leftover items (O.RAR.CUSTOMFILTER, etc.).- The §3 pipeline's "compression method != 0" rejection has been deleted; round-one §4's hand-rolled decoder handles methods 1..5.
- README format-matrix entry for
.rarno longer carries the "STORED only" caveat. - CI gates remain green; coverage thresholds (80 % overall, 95 % on critical paths) hold across the new modules.
Phases are sequenced; do them in order, do each phase completely. Phase A → B → C → E is the critical path for "general-purpose RAR5 extraction". Phase F (resume) can land any time after §E. Phase G (throughput) is optional for the milestone but expected before the matrix loses its "RAR5 only" caveat.
O.RAR.CUSTOMFILTER— RAR-VM custom filter slot (§C2's deferred follow-up).O.RAR.MULTITHREAD— multi-threaded decode for solid archives. Tricky because solid mode shares one decompression context across entries, but per-block parallel decode within a single entry is plausible after §G profiles the hot paths.