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.
50 KiB
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)andformat— 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
pickreturns the FIRST pool entry that opens a sample, so a later, longer pool yields the same first match for the same samples. crate::sector::KeyFetchis 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_segmentsyields the same segments in the same order, so the anchor loop and eachprobe_index_phasevisit the same segments.- every probe read is at the same LBA, so the same ciphertext is fed to a
crate::sector::KeyFetchthat is stateless by contract, and to the sameaacs_unit_encrypted/decrypt_unit/is_cleanarithmetic. - 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
FmtsKeyMissingverdicts (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 toPhase::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 aFileSectorSourceover the ISO image).title: the selected title; itsextentsdrive the read range and itsstreamsbuild the demux/parse tables.keys: decryption keys applied per sector batch. Passcrate::decrypt::DecryptKeys::Nonefor 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. Whentrue, 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 surfacesHalted).Nonedisables cancellation.event_fn: optional progress/event callback invoked by the prefetcher.fetch: optional key source used UP FRONT byresolve_mux_key_mapto 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): themeta_readsassert fails (the walk repeats per title).- removing the
memo.get(&ek)short-circuit: theprobe_readsAND thefmts_indexescall-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.