Files
libfreemkv/docs/resolve.md
T
Matthew Jackson 6e14fc0c0c libfreemkv: 1.7.0 groundwork — sector-recycle soundness + CI hardening
Security
- Remove the unsafe Vec::set_len over recycled sector buffers — UB reachable
  from safe code (GHSA-j8ww-f5fg-9pmh, low). Uses Vec::resize; pooling unchanged.

CI / tooling
- clippy on stable with the MSRV single-sourced from Cargo.toml; cargo-deny
  dependency-audit gate with first-party git sources allowed (#15); audience-based
  comment-guard (#17).

Docs
- README (keydb ownership, BD-J parser count), drive-access transport/reset
  notes, mux timeline module-doc links.
2026-09-02 12:40:55 -07:00

50 KiB
Raw Blame History

Stream URL resolver

src/mux/resolve.rs parses URL strings (scheme://path) into PES stream instances.

Scheme table

Scheme Input Output Path
disc:// Yes -- empty (auto-detect) or /dev/sgN
disk:// Yes -- alias for disc:// (identical behavior)
iso:// Yes -- file path (required)
mkv:// Yes Yes file path (required)
m2ts:// Yes Yes file path (required)
network:// Yes (listen) Yes (connect) host:port (required)
stdio:// Yes (stdin) Yes (stdout) empty
null:// -- Yes empty
demux:// -- Yes directory path (required) — per-track ES demux
fvi:// -- Yes file path (required) — per-picture video index

Bare paths without a scheme are rejected.

image_input (shared IMAGE-level source body)

iso:// and dir:// differ only in how the sectors are produced — a file versus a synthesized UDF volume over a folder — and not at all in what is done with them: scan, apply caller-resolved keys, gate on decryptability, select the title, prune streams, correct TrueHD channel counts, mux. One body means a dir:// source cannot drift away from the iso:// behaviour that has years of fixes in it.

reopen yields a SECOND, independent reader for the TrueHD channel probe, which must not disturb the mux reader's position. It returns Option because a failed re-open is non-fatal: the correction is skipped, not the mux.

FMTS_POOL_TAG_BASE

CPS-unit id given to an FMTS forensic index key banked into the caller's key pool by resolve_fmts_key_map (FMTS_POOL_TAG_BASE + slot).

The pool entry's first field is a CPS-unit number that the mapped decrypt never reads — it indexes the pool by SLOT (crate::decrypt::AacsKeyMap ranges carry slots) — so the field doubles as the tag that separates the disc's BASE CPS unit keys from the forensic index keys appended on top of them. Base ids come from a key's position in Unit_Key_RO.inf (+1), whose count is a BE16, so they cannot exceed 65_536; this base is far above that, so the two id spaces can never collide and misclassify a base key as forensic (which would under-count the CPS units and hand the wrong key to a whole extent).

single_base_key_slot

The pool slot of the disc's ONE base CPS Unit Key, or None when the pool holds several (a genuine multi-CPS disc) — the forensic index keys (FMTS_POOL_TAG_BASE) that resolve_fmts_key_map appends to the same pool are excluded, so the answer is a property of the DISC and not of how many titles have already resolved through the shared pool.

base_slot_for_extent

Which CPS unit's BASE Unit Key opens ext, as a pool slot — decided by this extent's own ciphertext, exactly like the multi-CPS path in resolve_mux_key_map_cached, and memoised in the same per-disc CpsUnitCache so a clip several playlists share is sampled once.

Only base keys are considered: the forensic index keys share the pool but belong to segment ranges, which are already mapped by tag before this runs.

last_idx is the slot resolved for the PRECEDING extent of this title, carried into an extent with no sampleable encrypted units (nothing to mis-decrypt). An extent that does have real ciphertext no held or fetched key opens is a fail-loud crate::error::Error::DecryptFailed rather than a silently wrong key over the whole extent.

resolve_fmts_key_map

FMTS (AACS 2.1) branch of resolve_mux_key_map. Returns Some(map) when the disc carries IndividualSegment.tbl AND a key source is configured; None otherwise (not FMTS, or no source — the caller's base-Unit-Key path then applies, and the forensic units garble and are dropped by the demux).

The forensic segments each carry an index tag (1..32) selecting one of 32 index keys the base Unit Key cannot open (see crate::aacs::segment). This resolves those keys up front from the configured source — sending, per index, a batch of same-index units the service maps to that index's key — adds them to the pool, and builds a per-segment LBA→key map. Applying a segment's key over its whole range decodes the ~40 units of that index's interleave half to clean TS and garbles the other ~40 (the alternate half), which the demux then drops, yielding one coherent stream. The base Unit Key covers everything outside a segment.

The segment SPNs are offsets into the FORENSIC FEATURE CLIP, so every clip-byte → LBA mapping here is anchored on that clip's own extents (forensic_clip_extents) — never on title.extents, which on a play-all playlist is the concatenation of several clips and would put the segments in the wrong one.

Everything expensive here is memoised per DISC, not recomputed per title: cache.table holds the UDF walk + IndividualSegment.tbl and cache.clip the forensic clip's extents (both disc-invariant outright), and cache.keys holds the index keys + phases. See resolve_mux_key_map_cached for why a hit is provably the same answer. What remains per title is the pool→slot mapping, the LBA range arithmetic and the base-key gap fill.

The gap fill covers each non-forensic LBA with the base Unit Key of the CPS unit it belongs to. On the single-base-key disc that is every FMTS disc seen so far that costs nothing; a disc with several base CPS Unit Keys resolves each extent from its own ciphertext through cps (CpsUnitCache, shared with the multi-CPS path so a clip is sampled once per disc).

probe_fmts_index_keys

The drive- and key-service-hitting half of resolve_fmts_key_map: anchor the disc's whole forensic index-key set from ONE key-service round trip, then probe each index's interleave phase. Split out so the result can be memoised per disc (FmtsKeyCache) — the arithmetic that turns these keys into a per-title LBA map stays at the call site, where the title belongs.

clip_extents are the FORENSIC FEATURE CLIP's own extents — the byte space the segment SPNs are relative to — and segments those addressable within it; every read is clip_byte_to_lba(clip_extents, …), so a probe never lands in another clip's sectors whatever order a playlist lists its clips in. Neither probe reads the caller's key pool, which is what makes the result independent of resolve order across titles.

forensic_clip_extents

The FMTS forensic feature clip's own extents — the byte space every IndividualSegment.tbl SPN is relative to — or None when the disc does not identify exactly one such clip.

An AACS 2.1 disc names its forensic feature BDMV/STREAM/<clip>.fmts (the extension bluray.rs already resolves clip extents through), and the disc carries ONE segment table, whose SPNs are therefore in ONE clip's byte space. So: exactly one .fmts file → that clip's extents; none, or several (an ambiguous SPN space), → None, which resolve_fmts_key_map turns into a loud Error::FmtsKeyMissing rather than a guess.

This is a DISC fact — no title is consulted — which is exactly why it is a sound anchor: a playlist's extents are the concatenation of ALL its clips in playback order, so their byte space is the playlist's, not the forensic clip's.

A read fault on the clip's ICB propagates (Err), like every other read on this path; a purely structural absence is Ok(None).

filter_addressable_segments

Keep only the forensic segments addressable within the FORENSIC CLIP's extents: a segment whose clip-byte start (start_spn * 192) maps to an LBA inside the clip is real forensic content; one that does not is past the clip's end (a stale or foreign table record) and is dropped. An empty result means there is nothing forensic to resolve, so resolve_fmts_key_map returns Ok(None) and the caller's base Unit-Key path applies. Extracted from resolve_fmts_key_map for direct testing of the inclusion/exclusion decision.

resolve_tie_phase

Decide a forensic index's decrypt phase from the clean-sample counts of its EVEN vs ODD aligned units under that index's key. Extracted from resolve_fmts_key_map so the tie logic is unit-testable; the tracing diagnostics stay at the call site, which holds the segment-index context.

  • even > odd → Phase::Even; odd > even → Phase::Odd — the clean half is this index's real content variant.
  • even == odd == 0 → Error::FmtsKeyMissing: NEITHER half decrypts clean, so the key is wrong (or the sample is not this index's content) — fail loud rather than emit a broken segment.
  • even == odd > 0 → Phase::Even: BOTH halves are clean, i.e. source-zero padding (clean under any key), so the parity is immaterial — default Even.

IndexProbe

Outcome of probing ONE forensic index's decrypt phase (see probe_index_phase). The load-bearing distinction is between the last two: a genuine wrong key and a transient live-drive read fault both leave zero clean decrypts, but only the former is a real FmtsKeyMissing — the latter must NOT abort a rip whose index keys are valid.

probe_index_phase

Probe one forensic index's decrypt phase by reading a representative segment's EVEN vs ODD aligned units and counting clean decrypts under key. Extracted from resolve_fmts_key_map so the read-fault-vs-wrong-key decision is directly testable without a full UDF/segment-table fixture.

Mirrors the anchor loop's read-fault tolerance: try up to max_segments same-index segments (rather than a single .find), skipping any whose reads all fault, and only conclude IndexProbe::WrongKey once a segment actually yielded decrypt attempts. If EVERY read of EVERY same-index segment faults, return IndexProbe::ReadFault — the caller then leaves the phase unresolved (defaults to Phase::All) instead of hard-aborting the rip. read(seg, unit) reads aligned unit unit of seg; None is a read fault.

Masking guard: IndexProbe::ReadFault is returned ONLY when not a single read succeeded, so a genuine wrong key (whose reads DO succeed) can never be masked as a read fault — any successful, non-clean decrypt yields IndexProbe::WrongKey.

fill_base_key_gaps

Back-fill the LBA gaps NOT covered by the forensic segment ranges with the base Unit Key, so the finished map is a COMPLETE positive list over the title's content extents: every content LBA resolves to either a forensic key (inside a segment) or the base key (base_idx). An LBA left in no range would pass ciphertext through as clear — this range arithmetic guarantees there is no such hole inside any extent. Extracted from resolve_fmts_key_map for exhaustive direct testing (gaplessness over every extent).

forensic_ranges are the already-built per-segment ranges; only their [start, end) spans matter here (they carve the holes — the key idx / phase are irrelevant). The return is the base-key fill ranges ONLY; the caller appends them to forensic_ranges to form the full map.

resolve_mux_key_map

This is what ends the key-server storm. The old mux decrypted a unit, checked whether the plaintext looked like clean MPEG-TS, and — because authored-bad content never reaches that bar — re-asked the key service for a key it already held. There is no per-unit byte pattern that separates "correctly decrypted but authored-bad" from "still encrypted", so that check is unanswerable. Here we answer the answerable question instead: which CPS unit does each LBA range belong to, decided by the disc's key structure (validated once against real ciphertext samples, where the is_clean proof IS sound). The mux then just decrypts each unit with its mapped key and trusts it.

Single-CPS (the overwhelming majority, incl. every single-key UHD) keys every content extent with one index; multi-CPS keys each extent with the key that opens a real sample from it; FMTS layers per-segment index keys on top. Any LBA outside the title's content (nav/filesystem) is in no range and passes through.

CpsUnitCache

Memoises the multi-CPS "which held unit key opens this extent" decision across the titles of ONE disc, for resolve_mux_key_map_cached.

Keyed by content format plus the extent's exact (start_lba, sector_count), so a hit returns the index that was resolved from those same physical bytes — see the safety argument on resolve_mux_key_map_cached. Only successfully resolved extents are memoised; a no-samples extent (whose index is inherited from the preceding extent of the SAME title, i.e. not a property of the extent) and the fail-loud "no key opens it" outcome are never cached.

A disc's playlists overwhelmingly reference the same handful of clips (main feature, play-all, per-chapter and seamless-branch variants), so without this the same extents are re-sampled off the drive once per playlist: 8 random 6144-byte reads each, ~200 ms of seek apiece on a stock BD drive.

Scope, stated precisely: this memo covers the extent-sampling reads that decide which CPS unit an extent belongs to, and nothing else. On an FMTS (AACS 2.1) disc resolve_fmts_key_map runs first and returns a finished map before the extent loop below is ever reached, so the loop's reads are removed by FmtsTableCache and FmtsKeyCache instead — but a MULTI-CPS FMTS disc samples through this same memo from the gap fill (base_slot_for_extent), and the cached value means the same thing on both paths: the pool slot whose key opens that extent's own ciphertext.

FmtsTableCache

The disc's forensic segment table (/AACS/IndividualSegment.tbl), resolved at most ONCE per disc: None = not looked for yet; Some(None) = looked for and this disc is not FMTS; Some(Some(v)) = the parsed, non-empty segment list.

Every input is a property of the MEDIA, not of a title: the UDF walk (crate::udf::read_filesystem) reads fixed low LBAs (the anchor at 256, the VDS, the FSD, the root directory), read_file follows that file's own allocation descriptors, and parse_individual_segments is pure. Re-running it per title re-reads ~35 single sectors at low LBAs from a head the previous title's content sampling left deep in the content area — a full-stroke seek out and back per playlist, for a byte-identical answer.

Only the two deterministic negatives are memoised as "not FMTS" (UdfNotFilesystem, and UdfNotFound for the table): a read fault is NEVER cached, so a transient failure still propagates and a later title still retries.

FmtsKeyCache

Memoises the FMTS (AACS 2.1) forensic index-key set and per-index phase — the expensive half of resolve_fmts_key_map — across the titles of ONE disc.

Keyed by (format, the title's exact extent list), mirroring crate::disc::pgs_forced_probe::ForcedProbeCache. Every read the anchor probe, the phase probe and the key-service call make is clip_byte_to_lba(forensic_clip_extents, …) and the probed segments are filter_addressable_segments(_, forensic_clip_extents) — both DISC facts (see forensic_clip_extents), so the answer no longer varies by title at all. The extent list is KEPT in the key deliberately: it is finer than the answer needs, so each distinct extent list still gets its own verdict rather than inheriting another's, and a title can never be served an answer resolved from bytes it does not read. Two titles with the same extent list feed byte-identical samples to a stateless crate::sector::KeyFetch and to the same is_clean arithmetic under the same format — see the safety argument on resolve_mux_key_map_cached.

The value holds the ordered index keys (element i = forensic index i + 1) and the resolved phase per index tag. It is key material: never logged, never rendered, and dropped with the disc's resolve.

FmtsClipCache

The disc's forensic feature clip extents, resolved in the SAME UDF walk as FmtsTableCache and at most once per disc: None = not looked for yet; Some(None) = looked for and not identifiable (or the disc is not FMTS); Some(Some(v)) = the clip's extents, the anchor for every segment SPN.

A disc fact like the table itself — see forensic_clip_extents for why, and for what Some(None) costs on an FMTS disc (a loud FmtsKeyMissing, not a guess).

resolve_mux_key_map_cached

resolve_mux_key_map with a caller-owned per-disc cache (DiscKeyCache) shared across the titles of one disc.

Why a cache cannot change a resolved key

Multi-CPS extents (CpsUnitCache)

The cached value is the pool index pick chose for an extent, and every input to that choice is stable across the titles of one disc:

  • The samples are a deterministic function of the extent's (start_lba, sector_count) and format — both in the key — read from unchanging media.
  • The key pool is APPEND-only here (base-key fetch and the FMTS resolver only push), and pick returns the FIRST pool entry that opens a sample, so a later, longer pool yields the same first match for the same samples.
  • crate::sector::KeyFetch is stateless by contract, and any key a hit's index refers to was already banked into the pool on the miss that filled the entry — so skipping the re-fetch skips no side effect the map depends on.

FMTS segment table (FmtsTableCache)

Disc-invariant outright: no input to the UDF walk, the read_file or the parse mentions the title. See the type's doc for the negatives that are (and are not) memoised.

FMTS forensic clip extents (FmtsClipCache)

Disc-invariant outright, like the table: the clip is found by name in the UDF tree and its extents come from its own allocation descriptors. No title is consulted — which is the point. It is the byte-space anchor for every segment SPN, and anchoring on a TITLE's extent list instead made a playlist that lists a trailer before the feature map every segment into the trailer's sectors.

FMTS index keys and phases (FmtsKeyCache)

Every input to the anchor and phase probes is now a disc fact: the probes read clip_byte_to_lba(forensic_clip_extents, …) and visit filter_addressable_segments(_, forensic_clip_extents). The title's extent list stays IN the key anyway — a strictly finer key than the answer needs, so no title inherits a verdict resolved from bytes it does not read. Given the same key, the same format and unchanging media:

  • filter_addressable_segments yields the same segments in the same order, so the anchor loop and each probe_index_phase visit the same segments.
  • every probe read is at the same LBA, so the same ciphertext is fed to a crate::sector::KeyFetch that is stateless by contract, and to the same aacs_unit_encrypted / decrypt_unit / is_clean arithmetic.
  • NEITHER probe reads the key pool. The anchor takes its keys from fetch; the phase probe takes them from the anchor's reply. So the pool's growth across titles — the one thing that does change between calls — cannot move this result, and the cached value is independent of the order titles are resolved in.

What is deliberately NOT memoised, which is what makes this safe rather than merely faster:

  • the fail-loud FmtsKeyMissing verdicts (no anchor, wrong key), so a retry after a key source is reconfigured re-probes rather than inheriting nothing;
  • a run where any index's phase probe came back IndexProbe::ReadFault — that leaves the phase defaulted to Phase::All (degraded but complete), a property of a transient live-drive fault and NOT of the extents. Caching it would spread one bad read across every remaining title.

Everything downstream of the cache still runs per title: the pool insertion that turns index keys into slots, the per-segment LBA range arithmetic, and the base-key gap fill — all of which genuinely depend on this title's extents.

Halt is polled per extent AND once on entry to the FMTS branch, so a 60-playlist sweep stays cancellable even when every title is served from cache.

build_iso_pipeline parameters

Assembles the ISO mux pipeline (read+decrypt → demux → parse) for a FileSectorSource-backed reader. Returns the resulting PipelinedPesStream.

  • reader: the sector source to read from (typically a FileSectorSource over the ISO image).
  • title: the selected title; its extents drive the read range and its streams build the demux/parse tables.
  • keys: decryption keys applied per sector batch. Pass crate::decrypt::DecryptKeys::None for raw / unencrypted reads (the decrypt decorator then becomes a pass-through).
  • batch_sectors: read batch size in logical (2048-byte) sectors — a throughput/latency tuning knob, not a correctness parameter.
  • format: container format (BdTs → TS demuxer, MpegPs → PS demuxer).
  • raw: ciphertext passthrough. When true, the per-title CSS crack (resolve_dvd_title_key) is skipped entirely — no key is resolved and a scrambled title is neither descrambled nor hard-failed.
  • halt: cooperative cancel token (not a timeout); when cancelled the pipeline stops at the next boundary (and the CSS crack surfaces Halted). None disables cancellation.
  • event_fn: optional progress/event callback invoked by the prefetcher.
  • fetch: optional key source used UP FRONT by resolve_mux_key_map to secure any CPS-unit key the pool is missing. Not a per-unit mux-time callback: the map decides the key for every LBA before the read loop starts.

Nine reader/title/keys/tuning/callback params is inherent to the mux entry point; grouping them into a struct would only move the same fields around.

parse_url_never_panics_on_adversarial_input

parse_url must never panic on ANY input — it is the front door for caller-supplied URL strings, so a panic here would crash the binary on malformed input instead of surfacing a clean error downstream. Feed it a battery of adversarial strings (empty, doubled/garbled schemes, embedded NUL, unicode, a very long path, lone scheme markers) plus an exhaustive sweep of every single byte 0x00..=0xFF as the whole input and as a scheme suffix. Any StreamUrl variant is an acceptable result; the only failure mode under test is a panic.

fvi_output_records_the_source_not_the_destination

fvi:// must record the SOURCE in source.{path,medium,title}. It used to pass the DESTINATION path as FviSink::create's source_path (and default the medium/title), so every index claimed to be its own source — docs/FVI_FORMAT.md §6.2 defines source as describing the input.

fvi_output_is_reproducible_across_destination_paths

The property the destination-as-source defect broke: two runs indexing the SAME source must produce byte-identical output regardless of where they write. Previously the header embedded the destination path, so the two files differed (and differed in length when the paths differed in length) purely from where they landed.

parse_dir_url_is_an_image_source_unlike_the_directory_sinks

dir://PATH/ parses to StreamUrl::Dir with the raw remainder as the path. Unlike the other directory schemes it IS an image-level source (1.6.1): crate::dirimage synthesizes a UDF volume over the folder, so is_disc_source() — "has a filesystem to scan" — is true for it and false for the write-only demux:// / fvi:// directory sinks.

dir_url_is_an_input_but_never_a_pes_sink

dir:// is never a PES SINK — it writes raw decrypted files, not muxed frames, so output() still rejects it (StreamReadOnly → Unsupported) and the CLI routes a dir:// dest to Disc::extract_tree.

As a SOURCE it is no longer rejected out of hand: it is an image source, and a missing folder now fails as a missing folder (NotFound) rather than as "this scheme cannot be read".

build_demux_state_bdts_builds_ts_demuxer_and_pid_table

BdTs format must build a TsDemuxer (Some(ts), None(ps)) when there is at least one PID, and one parser + pid_to_track entry per stream keyed by the stream's own PID. (Mis-keying here is exactly the class of bug that mis-routes PES into the wrong codec parser.)

HaltCountSource

A counting SectorSource over zeros. touched_extent flags whether any read landed in the title's extent region (LBA >= 1000); the UDF probe only reads near LBA 256 (small capacity), so a hit there means the expensive per-extent sample_units loop ran.

resolve_mux_key_map_honors_pre_cancelled_halt

resolve_mux_key_map on the multi-CPS live path must honor a pre-cancelled halt PROMPTLY — Err(Halted) at the first extent boundary, before sampling any extent's ciphertext — rather than reading through every extent. This is the round-2 Fix 1 guard: the resolve chain runs on the LIVE drive (each read_sectors can stall to the SCSI recovery timeout), so an operator Stop during key resolution must interrupt it.

Mutation: dropping the halt.is_some_and(...) → Err(Halted) check in the multi-CPS extent loop makes the resolve run the sampling reads and return Ok(map) (zeros sample to no encrypted units → carry key 0), so expect_err fails AND touched_extent flips true.

bdts_data_packet

Build a 192-byte BD-TS data packet on pid carrying payload as the TS payload (payload-only adaptation). Layout: 4-byte TP_extra_header (zeros) + 188-byte TS packet (sync 0x47, PID, PUSI, AFC=0b01). Mirrors the BD-TS framing in ts.rs.

build_iso_pipeline_empty_extents_clean_eof

Empty extents → the producer thread exits immediately, the demux thread sees a clean channel close and emits the Eof sentinel, and the PipelinedPesStream returns Ok(None) on the first read. The highway must terminate cleanly (no panic, no hang) when there is nothing to read.

build_iso_pipeline_delivers_one_frame_then_eof

End-to-end: one BD-TS packet carrying a complete audio PES flows read → decrypt(passthrough) → TS demux → codec parse → one PesFrame. Proves the full highway wiring delivers the ES payload intact and reaches a clean EOF afterward (never silently truncating the frame).

build_iso_pipeline_pruned_title_drops_unselected_pid_frames

End-to-end proof of stream selection: a title declaring TWO audio PIDs, pruned to one via StreamSelection::apply BEFORE build_iso_pipeline, must never surface a frame from the excluded PID. The demuxer is built from the pruned title.streams, so the excluded PID is untracked and its packets are skipped — track headers and frames both follow the pruned list, which is the whole point of the selection seam.

build_iso_pipeline_dvd_none_keys_scrambled_hard_fails

REGRESSION (autorip production corruption): build_iso_pipeline for a DVD (MPEG-PS) with None keys — what autorip's mux passes on a detection-miss DVD (disc.decrypt_keys() == None) — must resolve the CSS key from the reader itself. A scrambled-but-uncrackable title must HARD-FAIL, never build a passthrough pipeline that muxes the scrambled sectors as corrupt video. Before this fix, autorip handed None straight through and the mux wrote garbage at exit 0.

content_map_builds_exact_ranges_from_extents

content_map(title, idx) keys every single-CPS UHD disc (the common case): each content extent → one [start_lba, start_lba+sector_count) range at idx, phase All. Assert the exact ranges — an off-by-one on the end (or a wrong idx / phase) must flip this test to FAIL.

filter_addressable_segments_keeps_only_in_title_segments

BEHAVIOR 1 — segment filter (resolve_fmts_key_map line ~800). A segment whose clip-byte start (start_spn * 192) maps inside the title's extents is kept; one whose start is past the clip is dropped; all-outside → empty (the resolver then returns Ok(None) and the base-UK path applies).

resolve_tie_phase_covers_all_arms

BEHAVIOR 2 — phase-tie default (resolve_fmts_key_map line ~936). All four arms of the even/odd clean-count decision.

assert_gapless

Assert forensic + fills together cover every LBA of every extent EXACTLY once — no gap (a hole would pass ciphertext through as clear) and no overlap (two keys over one LBA). This is the load-bearing invariant of the gap-fill.

fill_base_key_gaps_is_gapless_over_every_extent

BEHAVIOR 3 — gap-fill range arithmetic (resolve_fmts_key_map line ~1005). Exhaustive: no segments, mid-extent, at-start, at-end, adjacent segments, and multi-extent. Each asserts the EXACT fills AND gaplessness over every extent — an off-by-one that leaves a hole flips this to FAIL.

encrypted_clean_unit

Build a 6144-byte aligned unit of CLEAN MPEG-TS (sync 0x47 + non-zero payload in packets 1.., packet 0 is the clear seed) then AACS-encrypt it under key. Decrypting under the SAME key restores clean TS (is_clean → true); decrypting under any other key yields garbage.

probe_index_phase_all_faults_is_read_fault_not_wrong_key

A probe whose EVERY read faults (read returns None) must classify as IndexProbe::ReadFault, NOT IndexProbe::WrongKey — a transient live-drive read fault while probing must not be read as a missing key (which the caller turns into a rip-aborting FmtsKeyMissing).

Mutation: reverting to the no-fallback single-segment probe (i.e. treating even==odd==0 as unconditional FmtsKeyMissing regardless of whether any read succeeded) makes this return WrongKey → the assert fails.

probe_index_phase_reads_succeed_but_no_clean_phase_is_wrong_key

Reads SUCCEED but decrypt to NEITHER clean parity (ciphertext under a key we do NOT hold) → IndexProbe::WrongKey. This is the genuine-missing-key path the caller MUST keep as a hard FmtsKeyMissing.

probe_index_phase_falls_through_faulting_segment_to_next

Read-fault TOLERANCE across segments: the first same-index segment faults on every read, but a SECOND same-index segment decrypts clean → the probe must fall through to it and resolve a phase (mirrors the anchor loop's multi-segment retry). A single-.find probe would have stopped at the faulting first segment.

CipherSource

A SectorSource that tiles a fixed 6144-byte ciphertext unit across each registered extent range ((start_lba, end_lba, unit)), zeros elsewhere. Every 3-sector aligned-unit read inside a range returns the same ciphertext, so sample_units collects real encrypted content for pick/fetch to run on. Low LBAs are zero, so udf::read_filesystem fails → the FMTS branch returns Ok(None) and the multi-CPS path is exercised.

resolve_mux_key_map_multi_cps_pick_selects_correct_index

pick() must select the pool index of the key that actually opens the extent's real ciphertext — index 2 here, NOT 0. Feeds units encrypted under the third pool key through the live multi-CPS path.

Mutation: pick hard-returning Some(0) keys the extent to 0 → this assert (Some(2)) fails.

resolve_mux_key_map_multi_cps_fail_loud_on_absent_key

Fail-loud: a sample that decrypts clean under NO held key and NO fetched key (fetch = None) must surface Error::DecryptFailed, never silently key the extent to a neighbour's (wrong) index.

Mutation: dropping the None => Err(DecryptFailed) guard (e.g. falling back to last_idx) returns Ok → this expect_err fails.

resolve_mux_key_map_multi_cps_fetch_recovers_missing_key

KeyFetch cold path: the pool is missing the extent's key, but the injected KeyFetch::unit_keys returns it from the failing samples → the extent resolves to the newly-appended pool index (2) and the map succeeds. Proves the on-miss fetch+re-pick branch runs end to end.

CountingCipherSource

CipherSource plus a counter of CONTENT reads (lba >= CONTENT_LBA_FLOOR) — the sample_units probes whose cost this cache exists to remove. Low-LBA UDF metadata probes are excluded so the counts speak only about extent sampling.

multi_cps_shared_extent_is_served_from_cache_not_resampled

A disc's playlists overwhelmingly share clips, and the multi-CPS path issues 8 random 6144-byte reads per extent. The SECOND title over the same extent must cost ZERO further reads and resolve the SAME index; a DIFFERENT extent must still be sampled and get its own index.

Mutation: dropping the cache.get short-circuit re-samples → the zero-further-reads assert fails. Caching the wrong index (e.g. inserting last_idx) breaks the same-index asserts.

multi_cps_cache_hit_matches_a_full_recompute

The cached index must be exactly what a full recompute produces: resolve the same title twice, once through a warm shared cache and once through a cold one (a real re-read), and compare the maps range for range.

Mutation: caching under a key that ignores start_lba/sector_count, or storing anything but pick's index, diverges here.

multi_cps_inherited_index_is_not_cached

An extent with no sampleable encrypted units inherits the PRECEDING extent's index — per-title state, not a property of the extent — so it must never be memoised. Title A reaches the clear extent carrying index 2, title B carries index 1: B's clear extent must key to 1, not to A's cached 2.

Mutation: caching the samples.is_empty() => last_idx arm makes B's clear extent resolve to 2 and this fails.

multi_cps_failed_extent_is_not_cached

A fail-loud extent (real ciphertext no key opens) must not be memoised: a later retry, after a key source banked the missing key, has to re-sample and succeed rather than inherit the earlier failure.

Mutation: caching before the None => Err(DecryptFailed) arm (or caching the error) leaves the retry unable to resolve.

FaultSource

A SectorSource whose every read is a transient I/O fault (DiscRead), modelling a marginal live drive stalling while resolve_fmts_key_map probes the UDF metadata / segment table.

resolve_fmts_key_map_read_fault_propagates

A transient DiscRead fault while reading the UDF metadata for the segment table must PROPAGATE (fail loud / retryable), NOT be swallowed into the not-FMTS Ok(None) fall-through — otherwise a marginal AACS 2.1 disc would silently drop its forensic content under a base-Unit-Key-only map and the mux would report success.

Mutation: revert the read_filesystem arm to let Ok(udf) = ... else { return Ok(None) } → this returns Ok(None) and the assert fails.

resolve_fmts_key_map_not_udf_is_clean_none

A reader whose bytes are structurally NOT a UDF disc (all zeros → no AVDP at sector 256 → UdfNotFilesystem) is genuinely not FMTS: it must map to the clean Ok(None) negative, NOT fail loud. Guards against Fix 1 over-reaching and rejecting benign non-FMTS discs.

fmts_disc_with_extra_records

The same synthetic disc with extra records appended to IndividualSegment.tbl ONLY — the content region is laid out from fmts_segments exactly as before, so the anchor and phase probes still resolve normally and the extra records are seen purely by the range builder. That is what isolates each of its "this record does not map" arms.

FmtsDisc::rebuild_meta / with_forensic_clip

clip = false drops BDMV/STREAM/00001.fmts from the tree: an FMTS disc (the table is there) whose forensic clip cannot be identified, so the segment SPNs have no defensible anchor. rebuild_meta returns just the UDF metadata image, with tbl_segs as the segment table and the forensic clip present — see fmts_disc_with_extra_records.

FmtsDisc::with_second_cps_unit

The same disc, plus a second CPS unit occupying the extent at FMTS_CPS2_LBA — a disc whose Unit_Key_RO.inf carries two base Unit Keys, which is what makes "the base key" ambiguous.

FmtsDisc::unit_at

The 6144-byte ciphertext of the aligned unit starting at clip byte unit_byte: inside a segment, EVEN units carry that index's content and ODD units the alternate variant; outside, ordinary base-Unit-Key content.

counting_fmts_fetch

A KeyFetch whose fmts_indexes counts its calls and behaves like the real service: it replies with the disc's COMPLETE ordered index-key set only for a genuine index-1 anchor batch (one that opens under index key 1), and empty otherwise. unit_keys is never used on this path.

fmts_two_cps_title

A play-all title over BOTH CPS units: the forensic clip (CPS unit 1) then the second unit's extent (CPS unit 2).

fmts_two_cps_keys

The disc's two BASE CPS Unit Keys, in Unit_Key_RO.inf order — the pool an AACS 2.1 disc with two CPS units is resolved with.

fmts_gap_fill_uses_each_lbas_own_cps_unit_key_not_pool_slot_zero

On an FMTS (AACS 2.1) disc the non-forensic gap fill used to hardcode pool slot 0 as "the" base Unit Key. On a disc carrying MORE THAN ONE base CPS Unit Key in Unit_Key_RO.inf that is simply the first CPS unit's key, so every content LBA outside a forensic segment in any OTHER CPS unit was keyed with the wrong key.

It does not fail loudly: the mapped decrypt runs the wrong key over those units and emits garbage plaintext with lost_bytes == 0. And resolve_mux_key_map_cached calls the FMTS resolver BEFORE the single_base_key_slot short-circuit, so the guard that makes slot 0 correct on a single-CPS disc is never consulted on this path.

The title spans both CPS units; the second extent's every unit is encrypted under the SECOND base key (pool slot 1). Mutation: pinning the gap fill back to slot 0 fails the second extent's asserts, and pinning it to slot 1 fails the first extent's.

fmts_index_keys_resolved_once_per_disc_not_once_per_title

The defect this fixes: resolve_content_key_map resolves EVERY title, and the FMTS branch used to re-walk the UDF filesystem, re-run the anchor probe, re-run the 2-index phase probe AND re-ask the key service — once per playlist — for an answer that is a property of the DISC. N titles must cost ONE UDF walk, ONE index probe and ONE fmts_indexes call.

Mutations, and which assert kills each:

  • if table.is_none() → if true (never memoise the segment table): the meta_reads assert fails (the walk repeats per title).
  • removing the memo.get(&ek) short-circuit: the probe_reads AND the fmts_indexes call-count asserts both fail.
  • if probed.all_phases_definite → if false (never insert): same two.

fmts_memoised_map_equals_per_title_recomputation

Result-identity, proven rather than assumed: the same three titles resolved with a SHARED per-disc memo must produce the same maps — and leave the key pool in the same state — as resolving each with a FRESH memo (i.e. the unmemoised per-title recomputation).

Mutation: key the FMTS memo on something NOT title-invariant (e.g. drop the extent list from extent_key and key on format alone) → title 3's differing extents are served the wrong answer and the range comparison fails.

fmts_different_extent_list_is_a_miss_and_reprobes

A DIFFERENT extent list is a genuine miss: the extent list is the only per-title input to the probes, so it is IN the memo key and a title with a different one must re-probe rather than inherit.

Mutation: drop title.extents from extent_key → the second title hits and the probe_reads / call-count asserts fail.

fmts_read_faulted_phase_is_not_memoised

A phase probe that READ-FAULTED on every segment of an index leaves that index defaulted to Phase::All — degraded but complete. That is a property of a transient live-drive fault, NOT of the title's extents, so it must NOT be memoised: caching it would spread one bad read across every remaining playlist. The next title must re-probe.

Mutation: memoise unconditionally (drop the all_phases_definite guard) → the second title is served from cache and both the probe_reads and the key-service call-count asserts fail.

non_fmts_disc_walks_the_filesystem_once_for_every_title

The UDF walk that decides whether a disc is FMTS at all runs on EVERY disc, FMTS or not. On a plain (non-UDF / non-FMTS) BD it must be attempted ONCE for the whole disc, not once per playlist — ~35 low-LBA single-sector reads reached by a full-stroke seek back from wherever the last title's content sampling left the head.

Mutation: if table.is_none() → if true → the second title re-reads the metadata and the meta assert fails.

fmts_memoised_title_still_honors_halt

Halt must stay responsive even when both FMTS memos are warm and a title does no I/O at all: resolve_content_key_map polls nothing itself, so the entry check inside the FMTS branch is the only cancellation point a fully-memoised title reaches.

Mutation: remove the check_halt()? at the top of resolve_fmts_key_map → the cancelled second title returns Ok(map) and expect_err fails.

single_cps_short_circuit_survives_a_forensic_title_resolving_first

The single-CPS short-circuit must depend on the number of BASE CPS Unit Keys, NOT on the length of the whole pool — which the FMTS resolver APPENDS its forensic index keys to. Before the fix, a disc whose first-resolved playlist was forensic left the shared pool at 1 + n_index, so every LATER title (single-CPS, non-forensic) missed the short-circuit and took the multi-CPS sampling path: 8 random 6144-byte reads per extent, and a DecryptFailed abort of the WHOLE-disc key map for any extent no pooled key opened. That made the result depend on the ORDER titles resolve in, contradicting the order-independence resolve_mux_key_map_cached documents.

Mutation: count the whole pool (unit_keys.len() == 1) → the menu title samples its extent and the probe_reads assert fails.

forensic_segments_anchor_to_the_forensic_clip_not_the_titles_first_extent

Forensic segment SPNs live in the FORENSIC FEATURE CLIP's byte space, so mapping them through the TITLE's extent list is wrong for any playlist whose extents do not begin with that clip. On a play-all playlist ordered [trailer, forensic feature] the old code treated clip byte 0 as the trailer's first sector: every segment landed 300 sectors early, the anchor probe sampled the TRAILER (so the key service never anchored → FmtsKeyMissing aborted the whole disc), and had it anchored, the index keys would have been applied to non-forensic sectors — silent garble with no error at all.

Mutation: anchor the byte space on title.extents again → the resolve either errors (no anchor) or maps segment 1 to 10_000 instead of 10_300.

unidentifiable_forensic_clip_fails_loud_rather_than_guessing_an_anchor

The anchor is only sound because the forensic clip is IDENTIFIED. A disc that carries a non-empty IndividualSegment.tbl but no BDMV/STREAM/*.fmts gives the SPNs no defensible byte space, so the resolve must fail LOUD (FmtsKeyMissing, retryable) rather than fall back to a title's extent list and map forensic index keys onto whatever clip happens to be first — which produces silently garbled output with no error at all.

single_base_key_slot_counts_cps_units_not_banked_forensic_keys

The base-CPS count that drives the single-CPS short-circuit, in isolation: the forensic index keys the FMTS resolver appends (tagged FMTS_POOL_TAG_BASE + n) are NOT CPS units, and a genuine second CPS unit is.

Mutation: drop the tag filter → the first case returns None and its assert fails.

fill_base_key_gaps_sorts_cuts_that_arrive_in_table_order_not_lba_order

The forensic ranges reach fill_base_key_gaps in IndividualSegment.tbl RECORD order, which is not LBA order — the table is a list of segments, and nothing in aacs::segment sorts it. The gap walk is a single forward sweep (cur = cur.max(ce)), so it is only correct on cuts in ascending order; that is what c.sort_unstable() is for.

Every existing gap-fill case happens to pass its cuts already sorted, so the sort is unconstrained by them. Here the cuts arrive REVERSED: without the sort the sweep takes the high cut first, jumps cur past it, then discards the low cut as "already behind" — and emits a base-key fill straight over the low forensic segment. That is the silent-wrong-key shape: the forensic LBAs end up in TWO ranges, one of them keyed with the base Unit Key.

RecordingSource

A SectorSource that records every (lba, count) it is asked for and serves a caller-chosen aligned unit, so the probe SPREAD of sample_encrypted_units is observable directly.

sample_encrypted_units_probes_eight_aligned_units_spread_across_the_extent

sample_encrypted_units is the evidence every CPS-unit decision rests on, so WHICH units it reads matters: 8 probes at total * p / 9 for p in 1..=8, each a whole aligned unit (3 sectors) measured from the extent's own start.

Pinned exactly. A probe count, a divisor or a p range that drifts moves the sample set — clustering probes at one end of a 20-minute clip, where an authored-bad or padding region can make an extent look unopenable — and each of those is an independently reachable mutation of this arithmetic.

sample_encrypted_units_drops_clear_units_and_reads_nothing_below_one_unit

Only genuinely AACS-encrypted units come back — that is the whole contract that lets a caller treat a decrypt-to-clean as proof of the key. A clear (unencrypted) extent yields NO samples, which is what makes pick_pool_slot return None and the caller inherit rather than fail loud.

pick_pool_slot_honours_the_caller_s_slot_list_and_its_order

pick_pool_slot answers "which of THESE slots, in THIS order, opens the extent" — and its two callers pass different slot lists for a reason: the multi-CPS path offers the whole pool, the FMTS gap fill only the BASE keys (offering a forensic index key there would key a whole extent with it).

So both the RESTRICTION and the ORDER are load-bearing, and neither is implied by "some slot matched". Two samples open under two different pool slots here, so the answer is decided purely by which slot the caller listed first — a find that ignored slots order, or that scanned the pool instead, would return the same value for both directions.

extents_overlap_is_half_open_at_both_ends

Extents are [start_lba, start_lba + sector_count) — half open. This decides whether a title "reads the forensic clip", i.e. whether resolve_fmts_key_map resolves index keys at all or returns Ok(None) and leaves the title on the base-Unit-Key path.

Both directions are wrong in a way that does not fail loudly: an inclusive end makes a title that merely ABUTS the forensic clip resolve (and pay a key-service round trip) for content it does not read, while a stricter test makes a title that shares exactly one sector fall through to a base-key-only map and silently garble its forensic units.

fmts_resolve_err_with

Resolve the synthetic FMTS disc with extra bogus records appended to its segment table, and return the error text.

fmts_baseline_table_resolves_so_the_unmappable_record_tests_mean_something

Control: the SAME resolve with no extra records succeeds. Without this the four tests below could be passing for any reason at all — a fixture that never reaches the range builder would expect_err just as happily.

fmts_inverted_segment_record_aborts_rather_than_base_keying_the_hole

Arm 1 — an INVERTED record (start_spn > end_spn). end_byte - 1 - start_byte would underflow, so the record is refused; the tally is what turns that refusal into a loud failure instead of a base-keyed hole.

fmts_record_with_no_index_key_aborts_rather_than_base_keying_the_hole

Arm 2 — a record whose forensic INDEX has no key. The synthetic source returns two index keys, so tags 1 and 2 have pool slots and tag 3 has none. On a real disc this is a table that outruns the key set the service returned — precisely the case where guessing a key is worst.

fmts_record_running_past_the_clips_end_aborts_rather_than_base_keying_it

Arm 3 — a record whose START is addressable within the clip (so filter_addressable_segments keeps it) but whose END runs past the clip's last byte. Only the second clip_byte_to_lba fails, which is why the filter upstream cannot stand in for this check.

fmts_record_off_the_aligned_unit_grid_aborts_rather_than_spanning_wrongly

Arm 4 — a record whose LBA span is not one contiguous run: b - a counts SECTOR crossings while (end_byte - 1 - start_byte) / 2048 counts the span's own length in sectors, and the two disagree exactly when the record is not aligned to the aligned-unit grid the forensic interleave is defined on (or when it straddles a clip extent boundary).

A structurally valid forensic segment cannot hit this: the interleave is per 6144-byte aligned unit, so a real record starts and ends on a 32-packet boundary and the two counts agree. start_spn = 10 does not — clip byte 1920 is mid-sector — so the span is refused.

multi_cps_cache_hit_still_feeds_the_next_extents_inheritance

The multi-CPS loop's inheritance chain has to run THROUGH a cache hit. An extent served from the memo never re-samples, so its index reaches the next extent only because the hit arm carries it into last_idx; without that, a following extent with no sampleable ciphertext inherits whatever the loop started at (slot 0) instead of its neighbour's key.

That is silent: an unsampleable extent produces no error either way, so the map simply keys those LBAs to the wrong CPS unit and the mux decrypts them to garbage with lost_bytes == 0. The existing shared-extent test resolves a SINGLE-extent title through the cache, so nothing downstream of the hit is observed; here the clear extent is deliberately placed AFTER the hit.

fmts_multi_cps_gap_fill_carries_the_preceding_extents_cps_unit_to_a_clear_tail

The FMTS gap fill on a MULTI-CPS disc runs its own extent loop, with its own inheritance chain (base_slot_for_extent's last_idx). A title whose last extent has no sampleable ciphertext — a clear/nav tail, which is exactly the case that cannot fail loudly — must take the CPS unit its neighbour is in, not base_slots[0].

Slot 0 here is CPS unit 1's key and the neighbour is CPS unit 2, so losing the carry keys the tail with the wrong unit's key and decrypts it to garbage with no error at all.

fmts_multi_cps_gap_fill_samples_each_extent_once_per_disc

The FMTS gap fill samples extents off the LIVE DRIVE through the same per-disc CpsUnitCache the multi-CPS path uses — 8 random 6144-byte reads per extent, ~200 ms of seek apiece. Every input to that decision is in the cache key, so a second title over the same extents must cost ZERO further content reads.

The index-key memo alone does not deliver that: it short-circuits the anchor and phase probes but the gap-fill loop still runs, and on a multi-CPS disc it re-samples every extent unless base_slot_for_extent banked its verdict.

fmts_stop_during_the_udf_walk_touches_no_content_sector

Stop lands while the UDF walk is still running — before the anchor loop. The anchor loop's own poll must catch it, so the drive is never asked for a single CONTENT sector. Without that poll the entry poll has already passed and the next one is inside the phase loop, so the full anchor batch (two MIN_SAMPLE_UNITS phase reads plus a key-service round trip) is issued to a drive the operator has already stopped.

fmts_stop_during_the_anchor_batch_stops_before_the_phase_probes

Stop lands with the drive already working, during the anchor batch. The PHASE loop's poll must catch it before probing index 1's parity — otherwise every index in the set is probed (MAX_ANCHOR_ATTEMPTS segments × MIN_SAMPLE_UNITS × 2 parities each) after the operator said stop.