Compare commits

...
132 Commits
Author SHA1 Message Date
Matthew Jackson 21f3b6adbd v1.3.0: bump version (freemkv-unlock git-pinned for the tag) 2026-07-10 08:43:42 -07:00
Matthew Jackson b32d636c64 audit: void empty-timeline duration, cover sniff overlap
Round-11 findings from the 10-phase release audit (no real HIGH):

- When a no-declared-duration source (HD-DVD) muxes a degenerate single
  frame at tick 0 with no per-frame duration, max_block_ticks stays 0 and
  the reserved DURATION placeholder was left as a literal 0.0 (players read
  that as a zero-length file). Void the element instead, so the Segment
  omits DURATION as an unknown-duration source did before the back-patch.
- Add a regression test for the sniff_video_codec overlap fix (a
  picture_start_code whose payload begins 00 00 followed by a real start
  code) so the i+=4 marker skip can't silently regress to i+=3.
2026-07-09 20:40:54 -07:00
Matthew Jackson d877630c45 audit: lock DTS rate table, fix sniff overflow-scan, cover decrypt loss
Round-10 findings from the 10-phase release audit:

- A finder claimed the DTS SFREQ→rate table was wrong at 11/12; verified
  it against ffmpeg's avpriv_dca_sample_rates (12k/24k/48k/96k/192k at
  11-15) — the table is CORRECT. Added a test that locks the full table so
  it can't be mis-"fixed".
- sniff_video_codec advanced 3 bytes after a matched start code, re-reading
  the code byte as an overlapping start code; skip the full 4-byte marker.
- Guard the HD-DVD next_id title counter with saturating_add so a crafted
  disc with >65536 clips can't overflow (panic in debug).
- Add a test that an undecryptable unit (DecryptFailed) is zero-filled and
  counted as loss through ExtractResult (complete=false, bytes_lost>0) —
  the recovery-seam consolidation folded that bucket into bytes_unreadable.
2026-07-09 20:17:12 -07:00
Matthew Jackson 1895095f49 audit: drop dead DTS marks cap, lazy passthrough buf, doc corrections
Round-9 findings from the 10-phase release audit (no HIGH):

- Remove the MAX_PTS_MARKS backstop and its tautological test: an empty
  DTS PES returns before recording a mark, and a non-empty run is already
  bounded by the MAX_AU_BYTES buffer clear (which clears pts_marks) — so
  the deque cannot grow unbounded and the cap was dead code.
- AuAssembler::for_codec no longer reserves 256 KiB for a Passthrough
  stream (audio/subtitle, and every TS/BD stream) whose buf is never
  written; only the reassembling modes reserve.
- Correct the scan comment that claimed region is computed (it is a
  Region-free stub until region detection lands) and drop a public-repo
  reference to internal "private refactor notes" in the mkb module doc.
2026-07-09 19:50:54 -07:00
Matthew Jackson 5f8c049444 audit: clamp BD format fallback, running GOP byte counter, O(1) DTS marks
Round-8 findings from the 10-phase release audit:

- detect_disc_format's BDMV fallback passed detect_format's result
  through unchanged, so an SD bonus/menu title could tag a BD-tree disc as
  DVD (mis-sizing the ECC sweep) — violating its own "never below Blu-ray"
  invariant. Clamp anything but UHD up to Blu-ray.
- Track the MPEG-2 GOP byte total incrementally instead of re-summing the
  whole gop_buf on every pushed picture (was O(pictures²) on any MPEG-2
  disc, not just adversarial input).
- Back the DTS pts_marks deque with a VecDeque so the over-cap prune is an
  O(1) pop_front, not an O(n) Vec::remove(0).
- Add a test exercising parse_stream_id_extension's PTS/DTS skip branches
  (the real AU-opening 0xFD video PES path) — previously untested.
2026-07-09 19:27:01 -07:00
Matthew Jackson 7d427d0b17 audit: bound DTS marks, align disc-format tree order, doc/test cleanups
Round-7 findings from the 10-phase release audit (no HIGH; convergence):

- Cap DtsParser.pts_marks (MAX_PTS_MARKS): a run of zero-length timed PES
  packets grew no buffer bytes, so the drain_front mark-prune never ran —
  the deque could accumulate without bound on hostile PS input.
- detect_disc_format tested HVDVD_TS before BDMV while the title-scan
  dispatch tests BDMV first, so a disc with both trees would be classified
  HD-DVD but enumerated as Blu-ray. Align both to BDMV → HVDVD_TS →
  VIDEO_TS.
- Document why the DTS new-PES re-base can emit a locally-decreasing PTS
  (the muxer's block_ts applies the strictly-monotonic audio nudge, tested
  in mkv.rs) — this is by design, not a mux defect.
- Fix stale aacs/keys.rs comment references (functions moved to
  aacs/inf.rs / aacs::resolve/derive in the module split).
2026-07-09 19:01:56 -07:00
Matthew Jackson 0a47b22db5 audit: byte caps on GOP buffers, opener-scan resume, honest video codec
Round-6 findings from the 10-phase release audit:

- Wire the documented MAX_PENDING_BYTES byte cap into the MPEG-2 GOP
  buffer (it was dead code) and add an equivalent MAX_GOP_BYTES cap to the
  sparse-PTS reorder, so a crafted stream of few-but-huge access units
  cannot over-allocate — both were bounded only by frame count before.
- probe_evo_streams defaulted an unsniffable HD-DVD video stream to H.264,
  which mis-parses a VC-1 (or still-encrypted) clip into a corrupt track.
  Emit the video stream only when the codec is actually identified — the
  honest outcome, matching the audio path (a real clear clip always carries
  its sequence header at the head).
- Resume the AU-opener search from a cursor (like the boundary search), so
  a long unsynced junk run is O(bytes), not O(buffer) per push.
- Mark mpeg2's now-dead MAX_AU_BUFFER test-only; restore #[doc(hidden)] on
  the aacs probe harness module.
- Add regression tests: the 0xFD video-routing guard, the FMTS-is-UHD key
  state, and the GOP byte caps.
2026-07-09 18:31:47 -07:00
Matthew Jackson 2ebcb60059 audit: guard 0xFD video routing, carry frame duration, add cap tests
Round-5 findings from the 10-phase release audit:

- collect_es routed EVERY extended-stream-id (0xFD) PES into the video ES
  buffer, so a 0xFD HD-audio sub-stream (MLP/TrueHD) could pollute the
  video sample and — if it preceded the video PES — stamp the video track
  with the audio PID, losing the video. Only the VC-1 extension (0x55) is
  now treated as video; routing 0xFD audio to its own track is deferred to
  the HD-DVD program-chain follow-up.
- The sparse-PTS reorder now carries its calibrated per-frame duration onto
  each frame, so the muxer emits a BlockDuration and the back-patched
  Segment Duration covers the final frame instead of understating it.
- Add regression tests for the MAX_MARKS and MAX_VTI_HITS caps (promote
  MAX_VTI_HITS to module scope); make the differential-test factory array a
  named type; drop an identity-op in a reorder test.
2026-07-09 17:59:04 -07:00
Matthew Jackson f46d99a348 audit: fix AU mark-field loss, VTI tie determinism, and mark/perf issues
Round-4 findings from the 10-phase release audit (the first fully clean
round; it dug into the new #22/#18 refactor code):

- AuAssembler closed each AU from only the FRONT mark's fields, so when
  one PES fragment carried the source and a later fragment of the same AU
  carried the PTS, the second field was dropped — a regression vs the old
  separate pts/source mark deques. Now merge the first Some of each field
  across all in-range marks.
- parse_vti_clip_order picked the largest residue bucket with
  HashMap::into_values().max_by_key(), nondeterministic on a size tie
  (randomized HashMap iteration) — could select a different clip table
  run-to-run. Break ties by smallest offset.
- Bound the marks/disc_marks deques (MAX_MARKS): the buf-size cap prunes
  marks only when bytes accumulate, so a run of zero-length timed
  fragments could grow them without bound on hostile input.
- Add push_owned so the PS path moves the PES payload into a passthrough
  AU with no copy (MPEG-2 video + all audio), removing a per-PES
  malloc+memcpy the refactor had introduced on the DVD path.
- Back-patch the MKV duration from the block END (start + its own
  duration) so it covers the final frame instead of understating by one.
- Add direct tests for the MKB record-framing walker; drop a stale
  drain_complete_aus doc comment left on process_au.
2026-07-09 17:30:41 -07:00
Matthew Jackson 4d93d90561 docs: drop Renesas from changelog (detection-only stub) 2026-07-09 16:36:47 -07:00
Matthew Jackson 4eeafd3378 docs: soften HD-DVD changelog to partial support (PGC parse deferred) 2026-07-09 16:33:40 -07:00
Matthew Jackson a73ba98f82 mux: resume AU-boundary scans from a cursor (O(n) not O(n²))
AuAssembler::drain rescanned the whole buffered access unit from a fixed
offset on every push, so reassembling one AU split across N program-stream
PES fragments cost O(bytes²/fragment) — amplified on the HD-DVD PS path
where H.264/HEVC/VC-1 frames are large and now flow through this shared
assembler (unlike the TS path, which delivers one AU per PES).

Carry a scan_pos cursor (and, for the stateful VC-1/MPEG-2 rules, a
seen_unit flag) so each push resumes the boundary search where the last
one stopped instead of restarting. Total scan work for one AU is now
O(AU bytes). The from-scratch scanners are retained as a #[cfg(test)]
oracle; a new differential test asserts the resumable path yields
byte-identical AUs at every fragment granularity for all three modes.
2026-07-09 16:22:54 -07:00
Matthew Jackson 7676f3f2f6 audit: bound the VTI clip-table scan; fix stale aacs doc links
Round-2 findings from the 10-phase release audit:

- parse_vti_clip_order bucketed hits by residue with an O(stride*hits)
  rescan and no hit cap, so a crafted HD-DVD VTI packed with millions of
  `.EVO` tokens (up to the 64 MiB UDF read cap) could burn seconds of CPU
  on a routine scan. Bucket in a single O(hits) pass and cap collected
  hits at MAX_VTI_HITS (a real table holds a few dozen).
- Fix the stale `super::keys::…` intra-doc links left by the aacs module
  rename: the referenced fns live in `super::derive`.
2026-07-09 14:41:49 -07:00
Matthew Jackson e1cfe59dc9 audit: cap the sparse-PTS reorder buffer, FMTS key state, zero KCD
Round-1 findings from the 10-phase release audit:

- SparsePtsReorder buffered its current GOP with no bound, draining only on
  a keyframe — an open-GOP or crafted program stream that never signals one
  could hold the whole title in RAM. Force-complete the GOP at
  MAX_GOP_FRAMES, matching the MPEG-2 parser's backstop.
- inject_unit_keys labelled a 2.1 FMTS disc as AACS 1.0 / bus-encryption
  off; FMTS is UHD-family, so synthesize the UHD version + bus encryption.
- The compiled Key Correction Data was a non-zero 16-byte constant fed into
  the Media Key derivation. Per the no-compiled-keys rule it is now all-zero;
  the chain still cannot complete on a real disc (documented), so this is
  behaviour-neutral — all variant tests pass unchanged.
- Fix stale doc references (broken `super::variants` intra-doc links, and
  `aacs::keys` comments) left by the module rename.
2026-07-09 14:14:20 -07:00
Matthew Jackson c43f23a759 mux: back-patch the MKV duration from the timeline when the source has none
A title whose scan yields no duration (HD-DVD — its `.MAP` timemaps are
not parsed, so DiscTitle.duration_secs is 0) produced an MKV with no
Segment Duration element, so players/MediaInfo reported an unknown
runtime.

Reserve a DURATION placeholder when the source declares none, track the
highest block timestamp written, and back-patch the placeholder at
finish() with the real muxed runtime (also enabling the per-track BPS
tags for these titles). Gated on duration_secs == 0, so BD/UHD/DVD —
which carry a real mpls/IFO duration — write it up-front exactly as
before, unchanged.
2026-07-09 12:52:48 -07:00
Matthew Jackson 770e0ab936 docs: changelog for HD-DVD feature composition + MPEG-2 AuAssembler 2026-07-09 09:12:56 -07:00
Matthew Jackson 5557765a86 disc: compose the HD-DVD feature from its VTI clip table
HD-DVD Standard Content splits the main feature across clips at the
layer break (FEATURE_1/FEATURE_2, or feature/feature_Divide). The scanner
enumerated one title per .evo, so main-title selection picked only part 1
(e.g. Shaun's 11 GB FEATURE_1, missing the 6.8 GB FEATURE_2).

Parse the HVA*.VTI navigation file's clip table — a fixed-stride record
list naming every clip in authored order, isolated by residue-mod-stride
rather than the imprecise header pointer — and concatenate the feature
clips (matched by the feature* naming convention) into one title whose
extents run in authored order. Every other clip stays its own title.
Falls back to one-title-per-clip when the VTI is absent or unparseable,
so nothing regresses on a disc with no readable navigation.

Validated on real discs: Shaun 17.8 GB / Anchorman 20.1 GB / Harry Potter
24.4 GB now enumerate as one 2-clip FEATURE title (largest = the movie).
2026-07-09 09:12:13 -07:00
Matthew Jackson 458b04ae63 mux: reassemble MPEG-2 access units via the shared AuAssembler
The MPEG-2 parser hand-rolled its own PES reassembly — a byte buffer plus
parallel PTS / source / discontinuity mark queues keyed by absolute
offset — duplicating what AuAssembler already does for H.264/HEVC/VC-1.

Add a Mode::Mpeg2 to AuAssembler (picture 0x00 with preceding sequence
0xB3 / GOP 0xB8 headers — the same headers-precede-picture shape as the
VC-1 mode) and have the MPEG-2 parser own one via AuAssembler::mpeg2().
parse() now feeds fragments to the assembler and processes each complete
access unit; the buffer, base offset, and three mark queues are gone. The
GOP-buffered temporal_reference reorder and PTS origin-locking are
unchanged. The parser's external contract is unchanged, so all existing
MPEG-2 parser tests pass as-is; new AuAssembler tests cover the MPEG-2
boundary rule directly.
2026-07-09 08:44:01 -07:00
Matthew Jackson c31ac6b66b docs: changelog for HD-DVD VC-1, sparse-PTS, variant layout (1.3.0) 2026-07-09 08:21:09 -07:00
Matthew Jackson 3e84ecfe42 mux: reconstruct display-order PTS for sparse-PTS program streams
HD-DVD EVO (and DVD VOB) program streams timestamp video at GOP
granularity: only one access unit per GOP carries a PES PTS. The H.264 /
HEVC / VC-1 parsers collapsed a missing PTS to 0, so on such a source
every non-anchor frame landed on the same block timestamp and a decoder
reported "non monotonically increasing dts".

Add a shared SparsePtsReorder that rebuilds a display-order PTS per frame
from the coded picture type (I/P/B) plus the sparse anchor PTS, with a
per-frame duration self-calibrated from the spacing between consecutive
GOP anchors (no external frame-rate needed). Display order is derived via
the classic single-anchor-delay rule (an anchor displays only after the
previously-held anchor; a B displays immediately), exact for the
non-hierarchical GOP structures HD-DVD H.264/VC-1 use. It mirrors the
MPEG-2 parser's GOP-buffered origin-locking.

Gated to the program-stream path only: the three parsers enable it via
with_ps_reorder(is_dvd_ps), so the BD/UHD transport path (per-frame PTS)
is byte-identical and untouched.
2026-07-08 21:54:09 -07:00
Matthew Jackson 998f82dc07 aacs(2.1): pin variant record layout against reference MKBs
Correct the variant Media Key chain against two real variant MKBs
(Zombieland v70, Stand By Me v70): C for Kmp is the per-slot block of
the 0x0c cvalue table indexed by the matched subset-difference, not the
0x2d head. The 0x2d record is the VARIANTS table (leading body-16 bytes,
one big-endian u16 per subset-difference) followed by a trailing 16-byte
Nonce, with no leading header. VKD stays at 0x2f.

Route record-type selection through the named REC_* consts in mkb rather
than bare hex, and document that Key Correction Data is per-licensee: no
universal constant exists and none is compiled in, so on a real disc the
chain yields a wrong Media Key that the Verify-Media-Key gate rejects
rather than emitting a bad key. This is a key-acquisition gap, not a
code gap.
2026-07-08 21:01:57 -07:00
Matthew Jackson b80af33775 mux: HD-DVD VC-1 demux via extended stream id 0xFD
VC-1 HD-DVDs (e.g. Shaun of the Dead) carry video on MPEG-PS extended
stream id 0xFD, with the real stream selector in stream_id_extension
inside the PES extension. Parse that field so the video routes to a
distinct track (pid 0xFD00|ext) instead of being dropped.

Reframe VC-1 access units in AuAssembler with a dedicated Mode::Vc1:
an AU is delimited by the next frame BDU (0x0D) once a frame has already
been seen, so the sequence (0x0F) and entry-point (0x0E) headers that
precede an I-frame stay attached to the frame they describe. The old
single-start-code split stranded those headers on the prior AU, which
the decoder reported as bits-overconsumption and hard decode failures.

hddvd probe now tracks the video pid it detects and emits VC-1 on 0xFD.
2026-07-08 21:01:48 -07:00
Matthew Jackson 054902cefe release: prepare 1.3.0
Bump to 1.3.0 and add the 1.3.0 changelog entry (FMTS/HD-DVD formats, AACS 2.1 variant chain, recovery seam, aacs module split, main-title-by-size, and the fixes since v1.2.2). Not tagged or pushed.
2026-07-08 14:48:50 -07:00
Matthew Jackson aa277224a9 fix: assorted correctness fixes and dead-code cleanup
- aacs/resolve: a media-keys-only provider missing the VID classifies as
  VidUnavailable, not NoMaterial (an MK derives the VUK once the VID
  arrives).
- disc/bluray: mark a clip seen only after its .clpi parses, so a
  transient parse failure on the first PlayItem cannot suppress the
  clip's extents for a later PlayItem referencing it that succeeds.
- disc/patch: log rather than swallow mapfile record/flush failures on a
  reverify downgrade, so a failed persist cannot silently mismark a bad
  unit good on resume.
- mux/ts: flag a discontinuity when a partial PES is dropped, matching
  the other partial-drop paths.
- mux/demux_thread: the no-demuxer branch forwards an empty batch for
  early consumer-disconnect detection instead of reading the whole disc.
- io/pipeline: correct the send-timing log (as_secs_f64, not as_micros
  printed as ms).
- aacs/derive, aacs/variant, disc/read_error, keysource: comment/doc
  accuracy. sector/prefetched, udf: remove dead fields/functions.
- mux/disc: assert unit-aligned read counts in the test.
2026-07-08 14:44:15 -07:00
Matthew Jackson 26154b6977 sector: generic recovery seam; FMTS forensic segments as decrypt loss
Replace the AACS-specific inline key-fetch in the decrypt decorator with
a scheme-neutral recovery seam: the input stream (L3) installs a Recover
closure (none / AACS key-fetch) and the decorator (L2) runs it at the
single decrypt-miss point. FMTS (AACS 2.1) forensic-segment units that no
key opens are just undecryptable units, concealed and counted as ordinary
decrypt loss with no FMTS-specific branch ("a loss is a loss"), so the
separate bytes_undecryptable bucket collapses into one loss count.

- sector/recovery.rs: the seam (MissOutcome, none/key_fetch factories),
  naming no encryption scheme in its type.
- FMTS: segment routing primitives + BYPASS_FMTS_KEY, and an upfront
  ensure_forensic_segments_decryptable gate (Error::FmtsKeyMissing) in
  the mux input path, parallel to the unit-key gate.
- CSS descramble/rekey moves from decrypt_sectors into
  css::descramble_region: CSS self-recovers from the data itself, so it
  stays OFF the seam (which is only for external inputs).
- disc/mod.rs also: main-title selection aligned to largest physical
  size; is_regular read from the open file handle, not metadata(path),
  fixing a swallowed sync_all on a fresh-rip ISO. decrypt_threads()
  resolved once via OnceLock off the per-buffer hot path.
2026-07-08 14:44:03 -07:00
Matthew Jackson bb90991e05 labels: reader-backed detection, Criterion fix, menu-language fallback
- DetectFn now takes a SectorSource so a parser can inspect a jar's
  central directory in detect() instead of firing on "any BD-J jar".
  dbp/deluxe do the real com/<vendor>/ prefix check up front, so each
  claims only its own discs (foundational for scaling the registry).
- criterion: treat a stream-map value of 0 as unmapped and synthesize a
  real 1-based number, so a 0 can't shadow or collide with a genuine
  stream 1 (with regression tests).
- png_filenames: new Low-confidence, last-resort parser reading menu
  language from {title}_UHD01_{LANG}_Composite artwork; sits below the
  MPLS floor so a real framework parser always wins.
- vocab: add menu_lang() for 639-2/B to 639-2/T menu-token normalization.
2026-07-08 14:43:52 -07:00
Matthew Jackson 175f131008 aacs: parse FMTS SegmentKey.tbl container (16-bit variant-indexed)
New aacs::segment_key: parses the AACS 2.1 SegmentKeyNNNNN.tbl container, confirmed against a retail disc as an 8-byte header + 65536 records of 536 bytes, indexed by the 16-bit variant selector the Media Key Variant chain produces. This is the confirmed link between the two 2.1 variant layers (selector picks the device's per-segment variant). The per-record 528-byte payload layout is not yet reversed.
2026-07-07 19:18:33 -07:00
Matthew Jackson af2f750629 aacs: parse FMTS forensic segment map (IndividualSegment.tbl)
New aacs::segment: parses the AACS 2.1 IndividualSegment.tbl into the source-packet ranges of the forensic variant segments (validated against a retail disc: 792 segments, 2560 packets each). First piece of the FMTS variant decoder — the segments' variants are encrypted under segment keys, not the unit key, so a unit-key rip corrupts them (broken HEVC refs).
2026-07-07 18:59:09 -07:00
Matthew Jackson f1517f7313 disc: select main title by largest size, not clip count
canonical_title_order keyed on clip-count ascending before duration, so a short 1-clip bonus reel outranked the real feature — which is often chaptered into many clips (one per chapter). Make physical size the primary key: the main feature is the largest non-oversize title; decoy 'play-all' playlists run long but tiny (reused clips) and self-eliminate.

Validated across 23 UHD/BD discs: fixes t1 on F9, Fast Five, Fast & Furious 6, and Furious 7 (feature was ranked #13-36); no regressions on the 19 already correct.
2026-07-07 18:36:36 -07:00
Matthew Jackson 984774b068 disc: first-class FMTS + HD-DVD formats; CPI sample selection
Add DiscFormat::Fmts (AACS 2.1) and DiscFormat::HdDvd as first-class peers. Format derives from the AACS MKB generation (mkb_type().generation(): V10=BD, V20=UHD, V21=FMTS), reusing existing AACS code, and from the on-disc tree for HD-DVD/DVD. One detector (detect_disc_format) shared by the coarse DiscId probe and the full scan — no more 'default BluRay, defer to full scan'.

FMTS is a BD-tree stream variant: parse_playlist resolves the clip stream via CLIP_STREAM_EXTS (.m2ts -> .fmts -> .ssif), so the .fmts main feature yields real extents (previously silently empty). HD-DVD is a tree-level peer with its own enumerator (disc/hddvd.rs): HVDVD_TS/*.evo -> MpegPs titles with real extents (playlist/stream parsing honestly stubbed).

Sample selection for key resolution now uses the authoritative AACS CPI flag (aacs_unit_encrypted, byte-0 & 0xC0) not the ts_sync_destroyed heuristic — container-agnostic (M2TS/FMTS/EVO; TS-sync is meaningless on HD-DVD program streams) and stops the key-service '0 encrypted units' rejection.

Tests live with each format (bluray/hddvd/mod); generic UDF fixture builders extracted to a shared udf::fixture module.
2026-07-07 18:16:04 -07:00
Matthew Jackson ddc44e1105 unlock_bridge: run_features/run_bus dispatch; report LibreDrive vs Renesas
Adopt freemkv-unlock's split Unlocker trait: run_features drives the drive-prep capability, run_bus the content bus removal, each iterating unlockers until one doesn't decline (NotApplicable = try next; Ok or a real error stops).

unlocker_matrix now reports which drive-prep unlocker actually ran — LibreDrive removes the bus at the drive; Renesas unlocks features but leaves the bus to the cert. Wire product_id through to fu::DriveId.

Bump to 1.2.3.
2026-07-07 16:36:05 -07:00
Matthew Jackson b8a51ef1c7 aacs: remove boil veneer; move key newtypes to aacs::types
aacs::boil was a thin newtype veneer over aacs::derive — a duplicate layer.
Delete it: move the shared key newtypes (Vid, MediaKey, Vuk, ProcessingKey,
UnitKey) into aacs::types, and expose resolve_candidate + KeyCandidate from
aacs::derive directly. Downstream (keysource, disc::encrypt) now import from
aacs::{derive,types}. Pure API consolidation, no behaviour change; full test
suite green on Rust 1.86.
2026-07-05 12:09:39 -07:00
Matthew Jackson 08cb80aac1 aacs: keep flat re-exports for the typed key primitives
The module structure is the public API, but the typed key primitives
(MediaKey/UnitKey/Vid/Vuk, mk_from_dk/mk_from_pk/uk_from_vuk/vuk_from_mk),
derive_vuk, the aligned-unit decrypt entry points, and DeviceKey/HostCert
are load-bearing names that downstream key-source crates import through the
flat `aacs::` path. Re-export them here so those crates can track the
module refactor without a lockstep re-pin.
2026-07-04 22:19:39 -07:00
Matthew Jackson 60d0ebc8a1 aacs(2.1): variant Media Key as a clean PK -> Km primitive
Redesign derive_media_key_variant to the minimal derivation surface:

  derive_media_key_variant(mkb_records, pk) -> Km

- PK-input only. Deriving Kp from device keys (DK -> PK) is a separate
  concern (walk_processing_key); a leaked 2.1 key is a PK, and the chain
  starts at Kp. A bare PK arrives without its subset-difference slot, so
  the primitive tries it against every slot and returns the Km for the
  slot that passes the MKB's Verify-Media-Key record -- mirroring the
  classical bare-PK derive_media_key_from_pk, gated by the chain's own
  verify so an unverified key is never returned.
- VID-free: the Media Key is MKB-scoped. VUK stays the separate
  derive_vuk(Km, VID) step.
- KCD is a fixed algorithm constant compiled in, not a caller parameter
  (removes the kcd argument, the placeholder const, and KcdNotProvided).
- Soft-correction / online-challenge slots are treated as non-covering;
  surfaced over the generic miss so a disc needing those modes is
  distinguishable from a non-covering key.

resolve_keys_v21 updated to walk DK -> PK first, then call the primitive
and derive the VUK from Km + VID. Module + helper docs refreshed to the
pinned record layout; tests reworked for the PK-only signature.
2026-07-04 19:16:40 -07:00
Matthew Jackson 5dee947007 aacs(2.1): wire variants_for_uv from the 0x2d record; chain runs end-to-end
variants_for_uv previously returned None (placeholder), dead-stopping the
Media Key Variant chain at VariantsTableUnavailable on any real disc. Layout
now pinned against two real 2.1 variant MKBs (Zombieland v70, Stand By Me v70):
the 0x2d Encrypted-Media-Key-Variant-Data body is sd_count u16 VARIANTS entries
(1:1 with 0x0c cvalues / 0x04 subset-diffs) followed by the 16-byte per-disc
Nonce at the tail. variants_for_uv reads the sd_slot_index-th u16.

With this the chain runs fully: Kmp -> Kpnew(=Kmp^KCD, extracted CyberLink
constant) -> VKD(0x2f) -> Km -> Kvu. The only remaining input is a covering
2.1 Processing Key to validate against a known answer; until then the final
Verify-Media-Key (0x86) gate rejects any wrong layout pick, so a bad key can
never be emitted. Tests updated to reflect the wired lookup; 1.86 precommit
green (fmt+clippy+tests).
2026-07-04 18:26:09 -07:00
Matthew Jackson a1796e8345 aacs: point integration tests at real module paths
The facade removal (5cf4252) left tests/ calling aacs::disc_hash,
aacs::decrypt_unit, aacs::AacsVersion, etc. at the old flat paths,
so the branch did not compile its integration tests. Repoint each
to its real module (inf/derive/content/mkb). No logic change.
2026-07-04 18:25:59 -07:00
Matthew Jackson 5cf4252e44 aacs: remove the mod.rs facade — module paths are the public API
Delete the pub-use re-export facade; consumers now import from the owning
module (aacs::content::decrypt_unit, aacs::mkb::MkbType, aacs::derive::derive_vuk,
aacs::boil::mk_from_dk, aacs::resolve::resolve_keys_v2, ...). Internal callers
repointed accordingly. Path-only change; logic hash identical (95fb9924); 2210
tests green.
2026-07-04 14:19:40 -07:00
Matthew Jackson b5f9c4e426 aacs: merge media_key + volume_key into derive.rs
The volume_key module was only 34 lines and is just the tail of the same
DK/PK -> MK -> VUK -> UK derivation ladder as media_key. Fold both into one
derive module so every aacs module is a substantial, distinct responsibility
(crypto/mkb/derive/inf/content/variant/resolve). Relocation only; logic hash
identical (95fb9924); 2210 tests green.
2026-07-04 14:14:56 -07:00
Matthew Jackson 82dca307ad aacs: split keys.rs god-module into media_key/volume_key/inf/resolve
Break the 2800-line keys.rs into four responsibility-scoped modules:
- media_key.rs: DK/PK -> Media Key subset-difference walk (+ probe harness)
- volume_key.rs: VUK derivation, unit-key unwrap
- inf.rs: Unit_Key_RO.inf parsing, disc_hash, content cert, in-drive MKB read
- resolve.rs: the resolve_keys_* orchestration (keys.rs renamed)

Relocation only; the (white-box) test suite stays in resolve.rs and pulls
the moved items via glob imports. Proven byte-identical to the pre-refactor
state via the logic hash (95fb9924); 2210 tests green.
2026-07-04 14:06:57 -07:00
Matthew Jackson a9a21d8139 aacs: extract mkb.rs (MKB record format, MkbType, finders)
Relocate the shared MKB machinery into a single mkb module: the record
framing walker + MkbRecord view (from variant), the MkbType/AacsVersion
classification, the MKB-file utilities, and the record-body finders (from
keys). Fixes the inversion where the MKB parser lived in the 2.1-only
variant module. variant.rs keeps its local MkbRecord-based mkb_find_mk_dv
(name collision with the raw one; unified in the dedup follow-up).

Relocation only. Proven byte-identical to the pre-refactor state via the
function-body logic hash (imports normalized out); 2210 tests green.
2026-07-04 13:55:06 -07:00
Matthew Jackson 8dc7075356 aacs: extract crypto.rs (shared AES primitives + constants)
Relocate the shared low-level primitives into a single crypto module:
aes_ecb_encrypt/decrypt, aes_cbc_decrypt, aes_g (from content/variant) and
aesg3 + AESG3_SEED (from keys), plus AACS_IV. Fixes the scatter where AES-G
lived in the 2.1 file and AES-G3 in keys. Relocation only — no rename, no
logic change (logic-hash identical to baseline; 277 items; 2210 tests green).
2026-07-04 13:37:53 -07:00
Matthew Jackson 02218a6511 aacs: rename decrypt.rs->content.rs, variants.rs->variant.rs (no logic change)
Pure file+module-path rename. 'content' names the AACS unit-decrypt layer
(distinct from the top-level sector-decrypt driver crate::decrypt), and
'variant' (singular, spec term 'Media Key Variant') names the 2.1 chain.
Logic-hash identical to baseline; 277 items intact; tests green.
2026-07-04 13:24:26 -07:00
Matthew Jackson e32a3af898 aacs: annotate crypto with AACS spec section citations
Add [C]/[PR]/[BD]/[libaacs] §x.y provenance markers across the AACS
crypto so each primitive links to the spec section it implements, with a
source-tag legend in mod.rs. Doc-comments only — no logic, constant, or
signature changes.

Also: correct two stale record-type comments in variants.rs (0x82/0x83 →
the real 0x2d/0x2f) and document the Variant Number width (spec lsb_10 vs
the 2.1 chain's lsb_16, driven by the 65,535-entry VKD table).
2026-07-04 13:15:53 -07:00
Matthew Jackson 1253db037f restore freemkv-unlock path dep for local dev (post-v1.2.2) 2026-07-03 20:50:17 -07:00
Matthew Jackson 4b878dcc06 v1.2.2: bump version (freemkv-unlock git-pinned for the tag) 2026-07-03 20:50:14 -07:00
Matthew Jackson 7511b0b808 changelog: 1.2.2 (AACS 2.1 variant MKB, mk_from_dk fix, PK-path perf, autorip online-outage + move-error clear) 2026-07-03 20:48:04 -07:00
Matthew Jackson 707989bd68 aacs: target real AACS 2.1 Variant MKB records (0x2d/0x2f/0x0c)
The Media Key Variant scheme is detected and parsed via the actual MKB
record types found on a real variant disc:
  - 0x2d  Encrypted Media Key Variant Data (C)
  - 0x2f  Variant Key Data table (65,535 x 16)
  - 0x0c  variant cvalues (one per 0x04 subset-difference slot)

Replaces the earlier placeholder 0x82/0x83 record types, which were a
guess and appear on no real MKB. is_variant_mkb, the record finders, and
the subset-difference cvalue source (now 0x0c, falling back to 0x07/0x05)
are updated accordingly, along with the V20->V21 upgrade detection in
resolve_keys_v2 and its fixtures.

The variant chain still halts at variants_for_uv (the VARIANTS[uv] /
Nonce sub-field offsets need a covering key to confirm end-to-end
against the 0x86 verify), so a best-effort offset is never silently
trusted. All 27 variant tests pass on Rust 1.86.
2026-07-03 13:25:01 -07:00
Matthew Jackson 57b2f0c4f9 aacs: make the PK path terminal (no SD-tree walk)
A Processing Key is the key at its Subset-Difference node — one AES-G from
the Media Key — so it is tried directly against the MKB cvalue tables,
matching libaacs _calc_mk_pks (iterate PKs × cvalues). The prior code
treated every PK entry as a device-node label at unknown depth and BFS-walked
the SD tree (depth 3, capped 5), which was both wrong for terminal PKs and
~15x slower on a large UHD MKB (~181k cvalues): PK derivation on UHD dropped
from ~37s to ~2.4s.

The Subset-Difference tree walk now lives solely in the device-key path
(derive_media_key_from_dk), which owns per-node path bits; the PK path never
descends. Removed PK_WALK_MAX_DEPTH / _CAP / _walked / walk_pk_against_tables_impl;
renamed the core scan try_pk_against_tables and its probe test.

Precommit (Rust 1.86): fmt + clippy + tests green.
2026-07-02 23:17:20 -07:00
Matthew Jackson 47406a7e32 aacs: full-ladder resolve_candidate + correct mk_from_dk
resolve_candidate(candidate, mkb, unit_key_ro, vid) -> ResolvedChain: one
composed, PURE-DERIVATION boil-down for a candidate key at any ladder rung
(KeyCandidate::{Uk,Vuk,Mk,Pk,Dk}, each carrying the module's newtype). Walks
DK/PK/MK/VUK -> terminal unit keys, parsing Unit_Key_RO.inf at the version
the disc's MKB declares, and returns every CPS unit key as (cps_unit, key)
(matching ResolvedKeys) plus the intermediate chain. No sampling/validation
(that's the caller's unit_key_validates) and no position recovery (Dk is a
positioned DeviceKey; recover_dk_position first). Adds ProcessingKey newtype.
Consumers stop re-composing the ladder; every client hardens one impl.

Correct mk_from_dk: real Subset-Difference walk (derive_media_key_and_pk_from_dk)
instead of the Media-Key-Variant path, which needed integrator KCD absent
in-tree and Err'd for every real disc -- dead for both consumers. Drops the
now-unused vid arg.
2026-07-01 22:34:52 -07:00
Matthew Jackson 03cbda741a restore freemkv-unlock path dep for local dev (post-v1.2.1) 2026-07-01 19:08:56 -07:00
Matthew Jackson ec59ffe105 v1.2.1: bump version (freemkv-unlock git-pinned for the tag) 2026-07-01 19:08:53 -07:00
Matthew Jackson 02132f52b5 changelog: describe the shipped re-base DTS fix + faithful-passthrough note 2026-07-01 19:02:04 -07:00
Matthew Jackson ca8d046f2a mux/dts: re-base PTS per PES instead of a running clock (fix long-title drift)
The first cut used a global running clock (max(next, own-PES PTS) + advance),
which fixed the same-PES collision but DRIFTED: once accumulated frame
durations exceeded the PES-timestamp spacing, it never re-based, so a
feature-long DVD DTS track ran minutes past its real length (2h44 for a 2h03
film) while AC-3 from the same source stayed exact.

Match the AC-3 path: re-base to each PES's own container timestamp, and advance
by one frame duration ONLY within a run of AUs sharing one PES. Fixes the DVD
multi-frame-per-PES collision without drift; the UHD DTS-HD MA per-PES
attribution (da85f56) is preserved (each AU still takes its own core PES's PTS).
Adds new_pes_rebases_to_its_own_pts_no_drift; full mux suite green (905).
2026-07-01 18:21:25 -07:00
Matthew Jackson f33a9f6c22 v1.2.1: bump version + changelog (DVD DTS timestamp fix) 2026-07-01 17:30:25 -07:00
Matthew Jackson a9aee0d5a4 mux/dts: monotonic per-frame PTS + real frame duration (fixes DVD DTS)
DVD packs several DTS core frames into one PES; the parser stamped every
access unit with that single PES PTS and duration_ns=None, so consecutive
frames collided on one timestamp — ffmpeg rejected the output as 'non
monotonically increasing dts to muxer: X >= X' (deep-decode = corrupt,
e.g. The Punisher). The UHD DTS-HD MA path (one AU per PES, distinct PTS)
was unaffected, which is why this only surfaced on DVD.

Parse the DTS core header for samples ((NBLKS+1)*32) and sample rate
(SFREQ, 48kHz fallback) to derive each AU's duration, and stamp a running
monotonic PTS: max(next_clock, own-core-PES PTS), then advance by the
frame duration. A later PES whose PTS is ahead of the clock still wins
(preserves the UHD per-PES attribution from da85f56/571c613); frames
sharing one PES advance frame-by-frame instead of colliding.

Tests: the 3 that encoded 'same PES -> same PTS' now assert monotonic
advance; new dvd_many_cores_one_pes_are_strictly_monotonic reproduces the
Punisher bug; duration/SFREQ-fallback unit tests added.
2026-07-01 17:17:35 -07:00
Matthew Jackson e16f207efa restore freemkv-unlock path dep for local dev (post-v1.2.0) 2026-07-01 16:53:06 -07:00
Matthew Jackson e5540da6d4 v1.2.0: bump version (freemkv-unlock git-pinned for the tag) 2026-07-01 16:53:03 -07:00
Matthew Jackson 9b49beabee changelog: DVD DTS/LPCM silent-audio fix + Pass-N recovery specialists + flat scheduler 2026-07-01 16:50:08 -07:00
Matthew Jackson b0037f2db9 recovery: flat-pool bandit scheduler (FREEMKV_PATCH_FLAT, opt-in)
The tier ladder (bucket->mug->thimble) forces tier 0 to attempt ALL
ranges before tier 1, and tier 1 all before tier 2 — so on a hardened
residual (late resume, flood already gone) the marginal specialists are
unreachable for hours: you bail near-empty buckets over hundreds of dead
ranges before a thimble ever touches water.

FREEMKV_PATCH_FLAT collapses the 3 tiers into ONE flat pool of all 16
handler configs. run_handlers already sorts best-first by the live
decayed-yield scorecard, so this is a data-driven bandit: the first
ranges try them all (explore), the ranking floats whatever is actually
landing sectors to the front (exploit), re-measured per range; a handler
that doesn't fit stays last but is never dropped (floor -> can revive).
The new handlers self-limit (yield after 4 dead reads), so the flat
scheduler does NOT reintroduce the old depth-first per-range starvation.

Opt-in; unset keeps the proven tier ladder as the default for 1.2.0.
2026-07-01 16:44:29 -07:00
Matthew Jackson 90070c81bd test: widen profile_03 efficiency guard for tier-2 roster
The tier-2 marginal specialists now additionally probe permanently-bad
residual sectors before leaving them NonTrimmed, so the alternating-bad
profile's finite read count rose past the old tier-0/1 bound. Guard
still catches runaway; behavior asserts unchanged.
2026-07-01 13:55:20 -07:00
Matthew Jackson b8c2054249 recovery: wire tier 2 marginal specialists into the Pass-N chain
Add a third breadth-first tier (PATCH_TIERS 2->3) that runs the marginal
specialists on the hardened residual tiers 0-1 leave: SlowSpin (Linear
fwd+rev @ min), FuaRetry (Linear fwd+rev+Bisect @ FUA), SlowFua (Linear @
min+FUA), CachePrime, Oscillate (@ max and @ min), SpeedSweep. Every read is
a wedge-safe read_span, so they inherit wedge-abort / unproductive-yield /
deadline for free. All are new configs, so the EWMA scorecard calibrates each
once then ranks by decayed rate — a specialist that doesn't fit self-
deprioritises. Tiers 0-1 (fast scouts, slow-deep) are unchanged; this is
purely additive. Also switch the scorecard log sort to sort_by_key.

cargo test -p libfreemkv green (2200 passed).
2026-07-01 13:50:35 -07:00
Matthew Jackson c4357d9d87 recovery: CachePrime + Oscillate handlers + fixtures
CachePrime reads the good run immediately preceding a residual island to lock
the servo/PLL, then reads the island warm (boundary sectors the drive can't
cold-seek). Oscillate reads each residual sector by alternating approach —
forward-into then reverse-into — for direction-dependent tracking. Both go
through the wedge-safe read_span (primes included). FakeDisc models a
direction-dependent sector and a servo-primed boundary sector; fixtures prove
a forward/cold Linear misses each while Oscillate/CachePrime recover them.
2026-07-01 13:50:35 -07:00
Matthew Jackson 63c1d5db63 recovery: FuaRetry + SlowFua fixtures (FUA / min+FUA levers)
FuaRetry (Linear fwd+rev+Bisect at {max,fua,deep}) and SlowFua (Linear at
{min,fua,deep}) are Linear/Bisect at FUA params, no new struct. FakeDisc now
models a stochastic sector that lands only on the Nth physical (FUA) read (a
cached re-read keeps missing) and a hardest sector needing BOTH min speed and
FUA. Fixtures prove cached reads keep missing while the FUA group lands the
stochastic sector, and that neither lever alone recovers the hardest sector —
only the min+FUA combination does.
2026-07-01 13:50:35 -07:00
Matthew Jackson e91ce25db3 recovery: SpeedSweep handler + slow-spindle fixtures (SlowSpin/SpeedSweep)
SlowSpin is Linear pinned to min speed (no new struct). SpeedSweep is a new
per-sector handler that tries Max->Min until one reads (speed resonance).
FakeDisc now models a sector that reads ONLY at min speed; fixtures prove a
max-speed Linear misses it while SlowSpin and SpeedSweep recover it (and that
SpeedSweep tries fast-then-slow).
2026-07-01 13:50:35 -07:00
Matthew Jackson 2c94ce97c6 recovery: decayed (EWMA) handler scorecard so the ranking flips
Grade handlers by an exponentially-decayed recovery rate (bytes/second,
alpha=0.5) instead of a cumulative rate. The residual hardens mid-pass, so
the best technique changes: cumulative froze the early winner in the lead
forever; the EWMA re-prices continuously — a handler that stops earning
decays down, a late-starting specialist climbs. Keeps rank()'s untried->top
one-shot calibration and attempted-but-zero-time->bottom.

New flip fixture proves a handler that recovers a lot early then nothing
loses its lead to one that starts recovering later.
2026-07-01 13:50:35 -07:00
Matthew Jackson e7998a1376 recovery: parameterize the read primitive with ReadParams (speed/FUA/timeout)
Add ReadParams { speed: SpeedPref, fua: bool, timeout: TimeoutPref } and
thread it through read_span so every wedge-safe handler read can request a
spindle speed (SET CD SPEED issued only on change, restored to max when the
handler exits), set the READ(10) FUA bit, and pick the 10s vs 60s timeout.

- SectorSource gains read_sectors_fua (default ignores fua); Drive sets the
  CDB bit, DecryptingSectorSource threads fua to its inner read.
- recovery_read gains a fua param.
- Linear becomes { direction, params }; Bisect/Jump take params. Existing
  tier-0/1 instances keep identical behavior (max speed, no FUA, fast/deep).
- Scoreboard keys on the full-config String name (linear:fwd:max:fast, ...).
- FakeDisc observes speed + FUA + approach so specialist techniques are
  provably exercised in later commits.

cargo test -p libfreemkv green (2193 passed).
2026-07-01 13:50:35 -07:00
Matthew Jackson ba6ac3ccdb dvd: route private_stream_1 audio sub-ids by positional stream number
The private_stream_1 sub-stream id low nibble is the DVD audio-stream
NUMBER (0-7), shared across codecs, not a per-codec ordinal. A DTS (or
LPCM) track that isn't the disc's first audio stream got a sub-id one
too low, so the demux routing key (0xBD00 | sub_id) never matched and
every packet was dropped -> present-but-silent track. AC-3 at position
0 coincidentally worked (ordinal==position). Route by positional index.

Fixes silent DTS 5.0 track on The Punisher (AC-3 5.1 @ pos0 plays,
DTS 5.0 @ pos1 was 0xBD88, real wire id 0xBD89).
2026-07-01 13:49:55 -07:00
Matthew Jackson 1d1fe984d7 lib: remove standalone sector-health verify feature
Delete src/verify.rs (standalone 'read every sector, classify health'
module) + its lib.rs pub mod/pub use. It was old, unused code. The
decrypt-verify gate (disc::verify / UnitVerifier) is a DIFFERENT module and
stays. Relocated the pure chapter_at_offset helper (used by locate_ranges +
autorip's done-card) into disc/mod.rs as a pub fn.
2026-07-01 13:09:21 -07:00
Matthew Jackson 9298534b4b recovery: audit fixes — live at-risk excludes NonTried, latency-gated wedge, drop dead block counters
- Live located drilldown (sweep + patch progress snapshots) now excludes
  NonTried, matching the one-shot path. Including the unread remainder made
  main_at_risk_ms show ~full-movie at sweep start and melt to 0 as it
  progressed — unread is unknown, not damage.
- Wedge abort now requires the failure to be FAST (< WEDGE_FASTFAIL_MS,
  500ms) as well as wedge-family sense: a real uncorrectable sector on
  Hardware-error media spends ECC-recovery time before failing, so it no
  longer false-trips the fast-fail wedge abort. New regression test.
- Removed the always-zero blocks_attempted/read_ok/read_failed/
  unreadable_count from PatchOutcome + PatchLoopState (dead residue from
  the old grind loop; the HandlerScoreboard supersedes them) so the
  patch.done log no longer emits misleading zeros.
2026-07-01 12:07:08 -07:00
Matthew Jackson 6799890379 docs: 1.2.0 changelog — Pass-N handler chain, DVD drive-prep unlock fix, unlocker matrix 2026-07-01 11:05:31 -07:00
Matthew Jackson f36163ad02 disc: unlocker_matrix reports 'did work this rip', not 'matched'
yes now means the unlocker actually ran and did its job: LibreDrive from
the runtime firmware-unlock success, AACS host-cert only when LibreDrive
didn't do the bus (stock-drive fallback), CSS from the crack succeeding.
On a LibreDrive UHD that correctly reads LibreDrive: yes, AACS: no (LD
removed the bus, the cert route never ran) instead of the misleading
AACS: yes. Names stay registry-driven; runtime logic lives here.
2026-07-01 10:44:39 -07:00
Matthew Jackson 73ae12ed1f drive: disable FUA on READ(10) — the ~10x sequential-read regression
Forcing FUA (Force Unit Access) on EVERY READ(10) bypassed the drive's
readahead/streaming cache on the bulk Pass-1 sweep, collapsing sequential
throughput ~10x (UHD 15-25 → ~2 MB/s, DVD → ~0.5 MB/s), disc-type-
agnostic — the cache IS the streaming throughput. This was the real speed
regression (not unlock/riplock, which the earlier chase suspected). Clear
byte-1 bit 0x08. FUA will return as a dedicated Pass-N recovery handler
that sets/clears it per marginal-sector re-read, where cache-masking of a
stochastic sector actually matters — never blanket on the bulk path (#55).
2026-07-01 10:26:56 -07:00
Matthew Jackson 2f47b552e8 disc: Disc::unlocker_matrix() — registry-driven unlocker report
Returns each registered unlocker's name + whether it applies to this
drive+disc (via the unlock bridge over all_unlockers()). Disc crypto kind
derived in the library so the CLI and autorip render an identical, always-
current report with no hardcoded names. Also adds a Drive::unlocker_name()
runtime getter.
2026-07-01 10:00:59 -07:00
Matthew Jackson 89c4acdf8f drive: run drive-prep unlock for EVERY disc, incl. DVD (fix riplock regression)
The v1.0.0-rc.1 unlocker refactor added an `if disc_is_dvd() { return }`
early-return in Drive::init() that skipped the whole drive-prep unlock for
DVDs. That firmware unlock is what lifts riplock and readies max read
speed — a DRIVE-level, disc-independent feature — so skipping it left
every DVD stuck at stock/riplock speed (~0.4x, 3h ETA). UHD was unaffected
because it flows through the unlock.

Remove the skip: init() now runs the identity-keyed drive unlocker for all
discs (disc kind is Unknown at init, so only the drive unlocker matches;
the AACS host-cert handshake and CSS bus-auth still run later, gated on the
real disc kind, on top of the unlocked drive). Speed stays where it
belongs — SET CD SPEED(0xFFFF) at pass-1 start (disc/mod.rs) — not in the
unlocker. Also drop the matching probe_disc DVD skip.

Reverts the SET STREAMING stopgap (separate freemkv-unlock revert): that
treated the symptom; the real bug was the skipped unlock. Drive features
come from the firmware unlock, not a stock speed CDB.
2026-07-01 09:38:51 -07:00
Matthew Jackson 1d5a730853 recovery: fresh-eyes audit fixes (handlers + Pass-N engine + sweep)
Handlers (section_recover.rs):
- Bisect expand loops now honor ctx.halted() (were deadline-only, so a
  Stop could hang up to 60s vacuuming a readable island).
- read_span: explicit Transport arm so a bus-abort read isn't counted as
  unproductive grinding; debug_assert the sector-aligned span invariant.
- Scoreboard rank: an attempted-but-zero-time handler (e.g. returned Halted
  on its first check) now ranks BOTTOM, not top — it no longer crowds out
  proven performers.
- Document the wedge tier-size coupling + new regression test that a
  2-handler (tier-1) chain still catches a wedge via cross-section streak.

Pass-N engine (patch.rs):
- Rebuild PatchOutcome stats AFTER the post-read re-verify downgrade flush
  (was snapshotting before it, over-reporting bytes_good / recovered and
  risking a 'perfect rip' verdict on an imperfect one).
- Progress 'recovered' composes the still-bad set to MATCH work_total
  (subtract NonTried, add Unreadable) so the bar can't pin at 0 on a
  partially-swept disc or run backward on the Unreadable→NonTrimmed relabel.
- Remove dead work_done field; rewrite the stale 'adaptive batching' comment
  to describe the handler chain and mark block_sectors/full_recovery as
  informational-only.

Sweep (disc/mod.rs):
- Saturating arithmetic at the damage-jump position math (honor the
  read_error side's documented defence-in-depth guarantee).

Deferred (noted, need focused passes): fast_capture re-introduction,
Pass-1 halt-misclassified-as-jump, bytes_good display inflation, the
always-zero blocks_* telemetry, Pass-1 jump-on-first-error policy.
2026-07-01 09:14:32 -07:00
Matthew Jackson 0a0a8f1327 drive: apply drive-features for every disc, incl. DVD (fix riplock)
DVD ran the drive at ~0.4x stock speed: init() early-returned for DVD
before any speed was set, and its only speed command was the sweep's lone
legacy SET CD SPEED, which this BU40N ignores for DVD. BD/UHD got up to
speed only via the firmware unlocker's calibration — a path a DVD can't
take (it breaks stock CSS).

Call the new unlock-crate drive-features capability at init() for ALL disc
kinds, before the DVD stock-mode early-return, via a bridge shim. Stock
MMC only (SET STREAMING + SET CD SPEED), no bus unlock, so CSS is
undisturbed.
2026-07-01 08:56:19 -07:00
Matthew Jackson e396dcaab7 Pass-N: detect drive fast-fail wedge and abort instead of grinding
The 2026-07-01 overnight rip ground a wedged BU40N for 28 minutes at
0 B/s. Root cause: the handler chain's read_span split errors only into
Transport (bus gone) vs Bad (everything else), so the drive's fast-fail
wedge sense (ILLEGAL REQUEST 0x05/0x24 — it rejects every CDB in <100ms
without attempting recovery) was treated as an ordinary bad sector. The
chain hopped to the next section forever, and the existing wedge detector
in read_error.rs never saw it because the chain reads through
recovery_read directly.

Add a pass-level wedge streak: read_span counts consecutive wedge-family
(Hardware/IllegalRequest) senses; at WEDGE_ABORT_STREAK (16) it escalates
the read to Transport, which every handler already propagates as
TransportFault — aborting the whole pass and setting wedged_exit so the
caller spin-cycles. The streak is carried across sections via PatchCtx
(seeded into and read back from each per-section HandlerCtx) so a wedge is
caught even when every bad sub-range is smaller than the streak. Any Good
or non-wedge (medium-error) read resets it, so scattered bad sectors on
real media never trip it. New fixture test: a wholly-wedged 1000-sector
section aborts in <100 reads, not 1000.
2026-07-01 08:02:52 -07:00
Matthew Jackson f6f68cb9dc Bisect: halve at dead boundary + exempt boundary probes from stall
The expand loops broke out of the batch sweep on the first failing read,
overshooting the good/dead boundary by up to a batch and leaving the
readable sectors adjacent to a dead edge for the re-bisect to re-pin. On
top of that, early-yield counted those boundary-probe failures as a
stall, so the forward expand quit early AND the backward expand inherited
a poisoned unproductive counter and yielded instantly.

Now on a failed expand batch we halve the step (down to a single sector)
to recover right up to the dead edge in ~log2(batch) reads, drive the
expand loops off the deadline only (they self-terminate via halving), and
clear the unproductive streak once an island is located. patch_recovers_
good_middle_of_a_bad_range now recovers 50/50 good-middle sectors.
2026-06-30 22:06:04 -07:00
Matthew Jackson bdafec8081 section_recover: per-rip handler scorecard + Bisect-leads-scouts + proportional Jump
Scorecard: grade each handler by recovery rate (MB/s) per rip, order
best-first on later sections, log the ranking at pass end. Untried
handlers rank top so each is calibrated once before ranking narrows to
the winners. Ephemeral (reset per pass), no persistence.

Tier 0 scouts are now [Bisect, Jump, Linear-fast x2], scorecard-ordered.
Bisect leads: probing the MIDDLE of a range lands on a readable island in
one read where a linear scan grinds the dead front to reach it. Jump now
jumps to the middle of the REMAINING span (proportional) instead of a
fixed 8 MiB that leapt clean over small ranges and missed their readable
middles. Tier 1 is slow deep-recovery on the residue.
2026-06-30 21:26:35 -07:00
Matthew Jackson 71f6cb17a8 section_recover: per-rip handler scorecard + Bisect leads the scouts
Scorecard (ephemeral, reset each pass, no persistence): grades every
handler by recovery rate (bytes/sec). run_handlers orders handlers
best-first by that rate; an untried handler ranks top so each is
calibrated once, then the ranking narrows to the winners. Logged at pass
end (phase=scorecard) so the operator sees which handler is pulling the
weight on this drive/disc and which is a dud.

Tier 0 scouts are now [Bisect, Jump, Linear-fast x2], scorecard-ordered.
Bisect leads because probing a range's MIDDLE lands on a readable island
in one read, where Jump (linear from the front, big skip) can grind the
dead front or overshoot a small range entirely. The scorecard confirms
or overturns that order with real per-disc data.
2026-06-30 21:25:39 -07:00
Matthew Jackson 326868ec64 section_recover: Jump starts at 8 MiB base to clear big dead spots faster
Each dead probe read costs the drive's full ~10s timeout, so a large dead
region took a dozen escalating probes. Starting the jump at 8 MiB (vs
1 MiB) clears it in a handful; a skipped span stays bad for Bisect to
reclaim readable islands, so an over-jump loses nothing.
2026-06-30 21:12:12 -07:00
Matthew Jackson ebb3227294 patch: single Jump-scout tier 0, expand-Bisect, live in-handler progress
Tier 0 is now a single fast Jump scout: it streams the big readable
ranges back and skips dead runs in seconds, so the pass reaches every
section fast and converges to the small genuine-dead residue instead of
grinding three handlers x 60s on each dead fragment. Tier 1 (fast
mop-up + slow deep reads + Bisect) works only that residue.

Bisect now expands: on a good probe it reads outward forward and
backward in full batches until a read fails, recovering the whole
readable island in large reads; the two failing ends become smaller bad
sub-ranges it bisects again. One huge bad range becomes many precisely
located small dead clusters.

Progress heartbeat: HandlerCtx gains a throttled tick (250ms) called
from every read, pushing a fresh snapshot to the reporter DURING a
handler. The bar and speed now move continuously as recovery happens
instead of jumping once per section (the reason speed read 0 B/s and the
% looked frozen between range boundaries).
2026-06-30 20:57:36 -07:00
Matthew Jackson e8b3c719e6 patch: add Jump handler (lead fast tier) + recovery-based progress %
Jump: on sustained batch failures skip ahead an escalating distance
(1 MiB doubling to 256 MiB) to find where readable data resumes, leaving
the skipped span for Bisect to pin — mirrors the Pass-1 damage-jump. It
leads the fast tier so a large dead run is skipped in seconds instead of
the linear sweeps grinding every dead batch (10 s each) first; on a
readable range it just streams it back. Recovers readable data buried
behind a big dead front (the 192 MB Dune range).

Progress %: report bytes RECOVERED (initial-bad minus still-pending)
instead of a per-range counter that only advanced on the final tier — so
the bar reflects the readable bulk recovered during tier 0 the instant it
lands, matching the 'MB remaining' number.
2026-06-30 20:40:22 -07:00
Matthew Jackson 4e65f87b71 patch: breadth-first two-tier recovery, largest ranges first
The per-range walk was depth-first: each bad range ran the full handler
chain (fast + slow deep-recovery + bisect) before the next range was
touched. So a handful of tiny dead fragments at one end of the disc
burned the whole pass and the big NonTrimmed ranges elsewhere — usually
sweep-jump over-marks that read straight back — were never attempted.

Now recovery runs in two breadth-first tiers over ALL sections:
- Tier 0 gives every section one fast full-batch attempt (fast reads
  only), largest ranges first, so the recoverable bulk of the disc comes
  back in the first minutes.
- Tier 1 deep-recovers only the residue tier 0 could not pull.
Per-section still-bad sets persist across tiers. Largest-first ordering
means a big readable region is reached before time is spent on tiny dead
fragments.

Linear no longer collapses a failed batch to count=1 single-sector reads
(live probing: a marginal sector recovers in a large read, not a lone
one) — a failed 32-batch stays 32 and is re-attempted at full size by the
next handler/pass; Bisect salvages readable islands.

Adds a handler-start trace line so the debug log shows which handler is
running and the hand-off to the next.
2026-06-30 20:25:48 -07:00
Matthew Jackson c5e4fa7109 patch: replace grind-until-wedge loop with bounded handler chain
Pass-N recovery is now a chain of time-bounded recovery handlers instead
of one monolithic per-range loop that could grind the front of a bad
range for up to 30 min, wedge the drive, and abort the whole pass.

A bad range is a SubRanges set; recovery is an ordered list of
SectionHandlers (Linear{reverse,fast} covering back/forward x fast/slow,
and Bisect). A coordinator runs each handler with a hard per-handler
deadline: a handler recovers what it can (removing it from the still-bad
set) and hands the rest to the next handler; whatever is still bad after
the chain becomes NonTrimmed residue and we move on to the next range.

Guarantees, now structural rather than bolted-on:
- never hangs: every handler is deadline-bounded; the loop always drains
  to recovered-or-residue.
- always moves on: a range that cannot be finished leaves residue and
  advances; only a genuine transport fault or user halt ends the pass.
- extensible: a new recovery idea is one SectionHandler impl added to the
  chain; a proven-ineffective one is removed. The engine never changes.

Removes ~1.9k lines of the old inner loop (watchdogs, skip escalation,
NOT_READY grind, wedge counters) and their tests. fast_capture is now
inert (the chain supersedes it); breadth-first ordering becomes a future
scheduler concern. New module: disc/section_recover.rs (8 fixture tests,
injectable clock — bounded/never-hang proven without touching a drive).
Two A/B tests updated to the chain's strictly-better recovery counts.
2026-06-30 19:55:37 -07:00
Matthew Jackson b67eaab3a8 drive: always set FUA on READ(10) to bypass the drive cache
Every Drive::read now forces the read from physical media instead of
letting the drive satisfy it from cache. A recovery tool must not trust
the cache: on a re-read of a marginal sector the BU40N can return a
cached miss (~4 ms) instead of giving the surface a fresh physical
attempt (~40-250 ms), masking a sector that would otherwise read on a
real media hit. FUA (READ(10) byte 1 bit 3) is validated on the
BU40N/Initio bridge.
2026-06-30 18:49:14 -07:00
Matthew Jackson dfc54891c5 drive: Drive::spin_cycle() — soft un-wedge via START STOP UNIT (no eject)
Spin the disc down (START=0, LOEJ=0) then back up (START=1) to clear the
BU40N/Initio fast-fail wedge state a run of HARDWARE_ERROR reads leaves the drive
in — the non-eject power-cycle our notes say the wedge needs. The disc stays
loaded; we never eject (slot-loading — a human eject is a product failure for an
unattended service). Validated live 2026-07-01: took the drive from
failing-every-read back to reading at MB/s.
2026-06-30 17:54:51 -07:00
Matthew Jackson 4169cf7f4f disc/patch: fast-capture reads fail-fast (no deep recovery)
A fast_capture pass defers every failed block to a granular pass anyway, so
spending the drive's 60s deep-recovery timeout on it here only freezes the
breadth-first sweep on a pure-bad cluster (~25s per 32-block, incl. NOT_READY
grind). Read with recovery=false in fast_capture so a bad block fails on the
short timeout (~10s) and the sweep steps on; the granular passes
(fast_capture=false) do the deep recovery on what's left. No recovery lost —
the block stays NonTrimmed for the granular retry.
2026-06-30 17:08:26 -07:00
Matthew Jackson edd316b8a8 docs: 1.2.0 changelog — fast_capture breadth-first patch recovery 2026-06-30 16:38:56 -07:00
Matthew Jackson c57ac14ad1 disc/patch: fast_capture mode — breadth-first recovery (#50)
A PatchOptions.fast_capture pass reads each bad range ONCE at the full batch and
leaves every FAILED block NonTrimmed for a later pass — no bisect, no per-sector
grind, no retry. This lets a first retry pass grab the readable blocks (the
sweep's good skip-ahead overshoot) of EVERY section quickly, before any single
section's slow per-sector recovery — instead of grinding section 1 to exhaustion
before even touching section 2. A later pass (fast_capture = false) does the
granular bisect/retry on what's left.

Load-bearing invariant (fixture test): NO data is dropped. A failed block becomes
NonTrimmed (pending, retried by a granular pass), NEVER Unreadable. The test pins
that the readable half of a range recovers, the bad half stays NonTrimmed (not
Unreadable), and the bad block is marked in ONE batch read with zero bisection.
Disc::copy's internal patch keeps fast_capture=false (single-call full recovery).
2026-06-30 16:37:59 -07:00
Matthew Jackson 9154ac12d6 docs: 1.2.0 changelog — PassProgress mapfile-free contract + located drilldown 2026-06-30 16:13:05 -07:00
Matthew Jackson ec3d072ec7 disc: add progress_snapshot_from_mapfile one-shot + locate_ranges test
The library now offers a one-shot PassProgress snapshot built from a mapfile on
disk + title, so a client gets the fully-rendered drilldown without parsing the
mapfile itself — used for autorip's pass-boundary and mux-entry paints (no live
callback yet at those points). Adds a locate_ranges test covering in-feature vs
out-of-feature at-risk time (ported from autorip's removed from_map tests).
2026-06-30 16:09:28 -07:00
Matthew Jackson bbf04c3d51 progress: PassProgress carries the located drilldown (emit side)
Add LocatedRange + LocatedProgress to the progress contract and a 'located'
field on PassProgress, populated by the sweep + patch emitters from the
in-memory bad-range set + title. Move the range->chapter/time annotation
(locate_ranges, range_chapter, byte_offset_in_title) into the library so a
client renders the disc map + at-risk movie time straight from PassProgress
and never reads the mapfile itself — if the mapfile becomes a mapdb, this type
and its producer change, clients don't.

PassProgress is no longer Copy (located carries a Vec); it's built once per
throttled emission and passed by reference. Non-locating phases (verify,
extract) emit LocatedProgress::default(). Adds consts::MILLIS_PER_SEC.
Consumer-side wiring (autorip drops Mapfile::load) follows.
2026-06-30 15:26:49 -07:00
Matthew Jackson d4f8c86cbc disc/patch: remove scatter-recovery (ruled out by live probing); add SubRanges
Live drive probing (cold-single vs in-stream batch vs centered window,
one clean uncached read per sector) showed recovery RATE is identical
across approach: 3/5 every mode. The drive's per-sector ECC is
media-bound and partly stochastic, NOT approach-bound. So scatter
(seek-away recalibration + far-anchor re-read) does not improve recovery
and is dead weight — removed (function, read_good_sectors, SCATTER_*
consts, and its tests).

Add SubRanges: the still-bad (pos,len) sub-ranges of a section with
remove()/split, the work-list foundation for the upcoming per-section
recovery decomposition (recover_section -> stream/bisect/retry phase
helpers). Unit-tested; unused until the decomposition lands.

25 patch + 8 AB profile tests green; precommit clean on Rust 1.86.
2026-06-30 13:56:49 -07:00
Matthew Jackson a4da4dbe24 disc/patch: keep MAX speed on recovery reads + geometric batch re-grow
Live probing on the BU40N/UHD testbed (rprobe/dprobe) settled the speed
question empirically: a marginal sector reads ~12x FASTER at MAX speed
than at slow, and slow NEVER recovered one MAX didn't. Cleanly-tested
(one uncached read per sector) the recovery RATE is identical across
speed/direction/window/batch — the drive's per-sector ECC is media-bound,
not approach-bound. The only host lever that matters is read SPEED.

So the old 'drop to 0x0000 on first failure and hold it for the whole
range' only slowed the GOOD sectors of a bad range — measured ~3x slower
overall. Now the range stays at MAX; the first failure just re-attempts
once (stochastic media) and falls through. Live A/B: 11 KB/s -> 33 KB/s,
bytes_lost unchanged.

Also: batch adaptation is now halve-on-failure / double-on-success
(geometric), so a bisected batch climbs back through clean runs from any
size (the old count==1-only upscale left it stuck at mid sizes). Removed
the dead consecutive_singles_ok counter and the inert batch-retry probe.

25 patch tests green; precommit clean on Rust 1.86.
2026-06-30 13:25:07 -07:00
Matthew Jackson e6dfe54e10 disc/patch: drop cache-priming, bisect batch failures instead of count=1
Two patch-recovery speed fixes, grounded in live rip1 telemetry on the
Dune UHD bad regions.

1. Pull prime_cache. In the reverse walk it issued throwaway reads of
   lba-3..lba-1 — i.e. straight into the not-yet-visited dead L-EC core —
   grinding each ~7-9s at the fast timeout, then discarding the result.
   The live trace showed a good target read (read_duration_ms=1) taking
   9s wall-clock purely from the prime grind ahead of it. The dead-zone
   skip (Tier 1) + scatter's recalibration read (read_good_sectors)
   cover the cache-warm intent without grinding the dead zone, so prime
   is redundant harm — removed (function + CACHE_PRIME_SECTORS + call).

2. Bisect on batch failure. handle_read_failure used to collapse a
   failed count>1 batch straight to count=1 and single-walk the entire
   batch. Now it halves (count/2) and retries the same start: a good
   half recovers in BULK, the bad sector is isolated in O(log n) reads
   instead of O(n). Invariant preserved — only a count==1 failure marks
   NonTrimmed, so no good sector is lumped into a bad mark.

These are Tier 2 (bisect) of the progressive recovery model
(fast-mass-skip -> bisect -> scatter); composable so order can be tuned.
25 lib + 8 AB fixture tests green; precommit clean on Rust 1.86.
2026-06-30 11:20:44 -07:00
Matthew Jackson c68d40887a disc/patch: add scatter-recovery for stuck bad sectors
The 'reset, read good data, come back for one sector' technique. A
genuinely-damaged sector makes the drive grind its full recovery timeout
and still fail; re-reading consecutive bad LBAs at identical conditions
re-fails AND is the rapid-failure cadence that drops the BU40N into a
firmware fast-fail wedge (CLAUDE.md hard-rule #2).

scatter_recover: on a stuck single sector, make up to SCATTER_MAX_ATTEMPTS
fresh tries, each preceded by read_good_sectors() — a seek to a far
known-good anchor + a sustained read that re-seats the head/servo and
breaks the failure cadence. The fresh re-read uses the FAST timeout
(recovery=false) so a recalibrated marginal sector reads quick and a
truly-dead one fails fast instead of burning 60s per attempt. The seek +
good read IS the settle (matches ddrescue/MakeMKV) — no idle sleep.

Gated to genuine single-sector MEDIUM_ERROR (0x03): transport faults
still abort the pass, NOT_READY keeps its own retry path, wedge-family
senses are left for cooldown/eject. A scatter recovery is recorded
exactly like a normal read success (write-then-mark-Finished); nothing
marks a range good without a successful read + write.

Logs anchor_ms/reread_ms per attempt for live tuning. 5 fixture tests
(recover-marginal, give-up-dead, skip-non-medium, skip-batch, AACS
widen); 2 AB read-count guards raised for scatter's bounded extra reads.
Precommit green on Rust 1.86.
2026-06-30 10:29:55 -07:00
Matthew Jackson ad9ea4c199 disc/patch: extract PatchCtx/patch_region orchestration + unify log taxonomy
Pure structural refactor of the Pass-N recovery path, no behavior change.

- PatchCtx<R> holds the one-pass context (reader, pipe, shared, opts,
  reused read buffer, cross-region state) and owns orchestration.
- PatchCtx::run() walks the ordered bad ranges, applies the inter-range
  cooldown (armed on a grind, consumed at the next range's entry), and
  stops the pass the moment a range reports halt/wedge/transport-fault.
- PatchCtx::patch_region() is the focused single-range loop: read ->
  success/failure -> damage-skip -> per-range watchdog. Every former
  break / break 'outer is now a typed RegionOutcome (Completed,
  SkipLimit, BudgetExceeded, Wedged, Halted, TransportFault) that run()
  maps back to next-range vs stop-pass exactly as before.
- recovery_read() extracts the AACS mid-unit-window widening + plain
  read into one reusable helper (the building block the upcoming
  scatter re-read will share).
- Logging unified onto a single dotted patch.* taxonomy (patch.region.*,
  patch.read.*, patch.probe.*, patch.wedge.*, patch.batch.*,
  patch.mapfile.*, patch.skip.*, patch.stall, patch.speed); the
  freemkv::scan pass-boundary markers are left intact.

20/20 disc::patch tests green; precommit (fmt + clippy -D warnings +
tests) clean on Rust 1.86.
2026-06-30 10:04:51 -07:00
Matthew Jackson 9422909773 error: treat IoError/DeviceNotFound as SCSI transport failures (dead bus)
is_scsi_transport_failure recognized only the 0xFF SCSI sentinel, so the two
NON-SCSI dead-bus faults the Linux SG_IO transport returns — Error::IoError
(ioctl(SG_IO) == -1: ENODEV/EIO on an unplugged bridge) and Error::DeviceNotFound
(fd gone) — were classified as recoverable bad sectors. Every caller
(sweep read_error, patch, single-pass fill_extents) would then zero-fill /
skip-ahead across a wedged bus instead of aborting the pass and re-enumerating
the bridge — potentially zero-filling a huge span. Add both to the classifier so
a dead bus aborts the pass everywhere. (Companion to the unlock_bridge fix, which
maps the same two faults to 0xFF across the freemkv-unlock seam.)
2026-06-30 07:19:03 -07:00
Matthew Jackson 53e2e9a6ce unlock_bridge: map non-SCSI transport faults (IoError/DeviceNotFound) to 0xFF
Round-2 audit gap in the prior bridge fix: extract_scsi_context only recognizes
Error::ScsiError / DiscRead and collapses every other variant to (0, None). But
the Linux SG_IO transport returns genuine dead-bus faults as non-SCSI variants —
Error::IoError (ioctl(SG_IO) == -1: ENODEV/EIO on an unplugged bridge) and
Error::DeviceNotFound (fd gone) — which were crossing the seam as status 0x00, so
the unlock crate classified neither as a transport fault (needs 0xFF) nor as a
sense-bearing rejection, and kept hammering a dead bus.

Match the error variant in the adapter: a SCSI status (CHECK CONDITION or a
drive-tagged 0xFF) keeps its real status + sense; any other variant is a
non-SCSI transport/IO fault → SCSI_STATUS_TRANSPORT_FAILURE / None. Keying off
`sense.is_none()` would be wrong (a senseless CHECK CONDITION is a rejection).
Add bridge tests covering CHECK-CONDITION sense preservation, 0xFF passthrough,
and IoError/DeviceNotFound → 0xFF.
2026-06-29 22:57:09 -07:00
Matthew Jackson ab289fb5c4 mux: add the B1 drop-to-keyframe resync gate to DiscStream
DiscStream (the live-drive single-pass path) enables AACS decrypt-loss
concealment (NULL-TS fill on an undecryptable unit) but, unlike the file-backed
PipelinedPesStream, had no B1 resync gate — so after a concealed gap it forwarded
inter-coded video frames referencing the now-missing data, producing
decode-broken MKV output (dangling-reference frames).

Mirror PipelinedPesStream: add per-stream ResyncGate + is_video, and route every
codec-frame emit on the TS path (in-stream parse, demuxer EOF flush, parser EOF
flush) through gate.admit(is_video, frame.discontinuity, frame.keyframe) so a
video track drops forward to the next keyframe after a concealment event. Warn
once if a gate is still armed at EOF (a concealed gap in the final GOP with no
trailing keyframe). Audio/subtitle always admit.
2026-06-29 22:27:52 -07:00
Matthew Jackson dc3b7970e7 unlock/patch: preserve SCSI sense across the bridge; init aborts on dead bus; slow-retry first patch failure
Audit fixes (v1.1.0..HEAD regressions in the unlock migration + adaptive patch
speed):

- unlock_bridge ScsiAdapter: libfreemkv's transport returns Err on ANY non-zero
  SCSI status (a normal CHECK CONDITION), not only transport faults. The adapter
  was collapsing every such Err to { status: 0xFF, sense: None }, which discarded
  the parsed sense and defeated the AACS handshake's ILLEGAL_REQUEST wedge guard
  (so it kept hammering the drive — hard-rule #2) and inverted its
  transport-vs-rejection diagnosis. Now reconstruct status + the 32-byte sense
  buffer (sense_key@2, asc@12, ascq@13) and only emit 0xFF/None for a genuine
  transport fault.

- Drive::init: a genuine transport fault during the drive-prep unlock means the
  bus is dead — propagate it (the v1.1.0 invariant) instead of silently
  swallowing it via `if let Ok`. Other errors (no matching unlocker) still fall
  through to stock mode. SET CD SPEED max now runs only when the bus is alive.

- disc::patch: on the first read failure in a range, drop to slow recovery speed
  and RE-ATTEMPT the same position at slow speed before marking it. A
  single-sector range's first failing sector was being marked from a MAX-speed
  read it never got to recover.

- docs: lib.rs architecture diagram (handshake → host_certs) and README (stale
  pluggable-unlock-seam / register-unlocker / crates.io / docs.rs references).
2026-06-29 22:16:28 -07:00
Matthew Jackson 7cac0a1b15 disc: log adaptive patch speed transitions; issue SET CD SPEED max unconditionally
Two recovery-path fixes:
- patch: log the per-range speed transitions (INFO, phase="patch_speed") — each
  range enters at 0xFFFF (max) and drops to 0x0000 (slow recovery) on its first
  read failure. Previously the adaptive-speed behavior was invisible in the logs.
- drive init: issue the generic SET CD SPEED max UNCONDITIONALLY at drive-open,
  not only when a firmware unlocker matched. A stock-mode BD/UHD drive (no
  firmware unlock) was left riplocked because the call sat inside the
  unlocker-matched branch.
2026-06-29 21:47:50 -07:00
Matthew Jackson 5bd7da0f6f README: drop crates.io/docs.rs badges + stale plugin-model line (off crates.io now) 2026-06-29 21:17:06 -07:00
Matthew Jackson dd23f3f3c9 Cargo: publish = false — off crates.io (git-deps the firmware crate)
libfreemkv now git-deps freemkv-unlock (which carries drive firmware and can
never be on crates.io), so libfreemkv itself can only be consumed by git tag.
The freemkv-unlock dep stays an interim path dep for local cross-repo dev; the
release script swaps it to a git tag in the tagged commit.
2026-06-29 21:04:05 -07:00
Matthew Jackson c7925a293d unlock: dispatch via freemkv-unlock; delete in-tree handshake/css-auth/registry
Rewire the three unlock dispatch points through the freemkv-unlock crate via a
private `unlock_bridge`: drive-prep (kind=Unknown) at `Drive::init`, AACS cert
(kind=Aacs) at `do_handshake_cert`, CSS bus-auth (kind=Css) at scan. The bridge
news up `all_unlockers()` and runs the first matching one, mapping its
`Unlocked` result to the bus-key gate. After a successful drive unlock,
libfreemkv issues a generic SET CD SPEED (max) itself — the old per-unlocker
trait method is gone.

Delete the in-tree unlock code now owned by freemkv-unlock: the AACS cert
handshake (`aacs/handshake.rs`), the CSS bus-auth (`css/auth.rs`), and the
unlock registry (`unlock.rs`). Host-cert collection (a keysource concern) stays
in a small `aacs/host_certs.rs`. No public unlock surface remains — clients
touch libfreemkv only, oblivious to unlockers (as they are to SCSI). 2277 tests
pass.
2026-06-29 20:45:00 -07:00
Matthew Jackson 37a8d8f710 stage 4a: bridge libfreemkv to the freemkv-unlock crate
Add the freemkv-unlock dependency (path dep for local dev; re-pinned to a git
tag at release) and the consumption bridge: one generic ScsiAdapter mapping
libfreemkv's transport to the unlock contract, host-cert + DriveId mapping, and
run_unlockers() which news up all_unlockers() and runs the first matching one.
Additive — validates the dep resolves and the adapter compiles; the in-tree
registry/handshake/css path is still active and gets replaced in 4b.
2026-06-29 19:48:50 -07:00
Matthew Jackson 48bea05472 aacs: extract run_cert_handshake + collect_host_certs as shared primitives
Pull the wedge-guarded cert loop and host-cert collection out of the in-tree
AacsCertUnlocker into public aacs::handshake primitives (run_cert_handshake +
CertHandshake, collect_host_certs). The in-tree path now delegates to them, so
the external freemkv-unlock-aacs plugin runs the IDENTICAL cert handshake — one
implementation, two callers. Pure refactor of the live AACS path; the existing
handshake + collect_host_certs unit tests validate it unchanged.
2026-06-29 17:33:26 -07:00
Matthew Jackson 04ae2704f1 unlock: route_unlock returns a structured UnlockRoute; ctx carries opts
Replace route_unlock's Option<(name, Vid)> with a structured UnlockRoute
{ Unlocked(name, Unlocked) | Failed(UnlockError) | NoMatch } so a single
dispatch serves every caller: drive-prep wants "did anything unlock", and the
AACS cert route (next) needs the FAILURE REASON to render "missing keys" vs
"host cert rejected" instead of collapsing it to a bare None. Only a genuine
SCSI transport fault still returns Err (abort). UnlockCtx gains an optional
ScanOptions (the cert route's host-cert source), and read_mkb_from_drive now
takes &mut dyn ScsiTransport — both prerequisites for the cert handshake to
become an external freemkv-unlock-aacs unlocker. Drive-prep + CSS callers fold
the new outcome; no behavior change.
2026-06-29 17:18:11 -07:00
Matthew Jackson 26fc70b91b unlock: Unlocked carries the bus-key gate nuance; route_unlock returns it
Extend Unlocked with drive_unlocked + read_data_key_err so the registry result
carries everything the downstream bus-key gate needs — a firmware unlocker sets
drive_unlocked=true (bus encryption removed at the drive, no bus key), the cert
route will set read_data_key (+ read_data_key_err on a failed bus-key read).
route_unlock now returns the full Unlocked rather than just a Vid, so a single
dispatch can fold the firmware, cert, and CSS results uniformly. Prep for the
AACS cert handshake becoming a registry unlocker.
2026-06-29 16:59:04 -07:00
Matthew Jackson a061694ba0 css: CSS bus-auth becomes a uniform registry Unlocker
Convert the CSS read-unlock into a first-class registry Unlocker (CssUnlocker)
dispatched through route_unlock like every other barrier removal, instead of
a direct call in scan. libfreemkv appends the built-in CSS unlocker (and, next,
the AACS cert handshake) exactly once via ensure_builtins(), AFTER any
client-registered firmware unlocker — so the registry order is firmware → cert
→ css, owned by the lib, not the client.

Defense in depth: the unlocker does NOT trust the caller-declared DiscKind.
matches() filters on the declared kind (Css), but unlock() self-verifies
against the drive's GET CONFIGURATION profile and refuses (UnlockError::
NotApplicable, a new shared "this unlocker doesn't apply" variant) WITHOUT
issuing a single CSS CDB if the drive reports a non-DVD profile — so a
mis-routed Blu-ray is never sent CSS bus-auth. Guard the firmware unlocker the
same structural way (it matches only the drive-prep phase, kind == Unknown).

Tests: CssUnlocker matches only DiscKind::Css; a BD-profile drive yields
NotApplicable with zero CSS CDBs issued.
2026-06-29 16:49:52 -07:00
Matthew Jackson 45c0bc943c unlock: introduce UnlockCtx + DiscKind; trait keys off context
Reshape the Unlocker seam so every unlocker is dispatched at ONE place from
ONE ordered registry — the firmware, cert, and CSS routes are all "remove the
bus-encryption barrier", differing only in what they key off. matches() and
unlock() now take an UnlockCtx { drive_id, kind: DiscKind } instead of a bare
DriveId: a firmware unlocker keys off drive_id (kind irrelevant), the cert
unlocker will match DiscKind::Aacs, the CSS unlocker DiscKind::Css. UnlockCtx
is #[non_exhaustive] so a host-cert source can be added without breaking
external unlockers. Drive-prep dispatch passes DiscKind::Unknown (no disc
probed yet); the cert/CSS registry impls + the single post-probe dispatch
point follow in subsequent commits.
2026-06-29 16:27:30 -07:00
Matthew Jackson c9c64e2cbf css: auth primitives operate on &mut dyn ScsiTransport; clean up bus_auth
Thread &mut dyn ScsiTransport through unlock_css_reads and its bus-auth /
disc-key primitives instead of &mut Drive — they only ever issued SCSI via
drive.scsi_mut(), so this is mechanical and lossless, and it makes the CSS
unlock speak the same transport interface as the registry Unlocker trait
(prerequisite for CSS becoming a uniform unlocker).

Also clean up the badly-named, half-dead bus_auth:
- rename bus_auth -> establish_authenticated_session: it is run for its
  side effect (sets the drive's ASF=1, unlocking scrambled-sector reads),
  which the name now states.
- drop the derived CSS bus key from the return + computation: it had no
  consumer (descrambling is keyless via the Stevenson attack), so it was
  dead crypto computed on every DVD unlock. Return just the negotiated AGID,
  which the caller genuinely needs for the best-effort disc-key REPORT KEY.
2026-06-29 16:01:07 -07:00
Matthew Jackson 8ac18d857d aacs: handshake primitives operate on &mut dyn ScsiTransport
The AACS cert-auth primitives (aacs_authenticate, the AACS 2.0 P-256
variants, read_volume_id, read_data_keys) and their scsi_read/scsi_write
helpers touched the drive ONLY through Drive::scsi_execute — a pure
pass-through to the transport. Thread &mut dyn ScsiTransport instead of
&mut Drive so these primitives are transport-level, matching the firmware
Unlocker seam (which hands out &mut dyn ScsiTransport for testability).
Pure mechanical signature change, no logic change; the cert orchestrator
(do_handshake_cert) keeps &mut Drive for the OEM-VID shortcut and passes
session.scsi_mut() into the primitives. Step toward making the cert
handshake a uniform registry unlocker.
2026-06-29 15:47:30 -07:00
Matthew Jackson 0a006bbfa6 unlock: Unlocker::unlock returns Unlocked { vid, read_data_key }
Generalize the unlock seam toward uniform unlockers (firmware / cert / CSS).
`unlock` now reports what it LEARNED — a Volume ID and/or an AACS bus key —
instead of just a Vid; libfreemkv files those onto the disc/drive in one
place, so a plugin never touches Disc. The hardware side-effect (extended
mode) still happens inside unlock. route_unlock walks the registry in
registration order (the single ordered place that decides run order); no
priority field. Firmware route reports {vid: Some, read_data_key: None}.
2026-06-29 15:09:17 -07:00
Matthew Jackson 11554c1722 disc: credit firmware unlock in the bus-encryption gate
The bus-key gate only credited the cert handshake's read_data_key as proof
bus encryption was removed. A firmware unlocker removes it AT THE DRIVE
(serves clear content) and yields no read_data_key — so a SUCCESSFUL
firmware unlock (VID present, read_data_key None) tripped the gate and
blocked ALL key resolution, including the online source. That was the
root cause of live UHD discs reporting "missing keys" after an unlock.

Now a single predicate answers "is bus encryption gone?": never-had-it ||
file/ISO || firmware-unlocked || cert-bus-key. The gate is just
`if !bus_encryption_removed { error }` — no enumerated cases. HandshakeResult
gains `drive_unlocked`, and the read_data_key failure reason is captured so
the warn says WHY the bus key is missing.

Also: reword the first hardware-sense escalation as "fast-fail escalation"
(it is often transient — the drive recovers), reserving "wedge" for a
persistent run; and scrub the product name from core comments (it belongs
only in the unlocker crate).
2026-06-29 15:09:16 -07:00
Matthew Jackson 321442632a disc: make bus-key unavailability self-explaining in the log
When a bus-encrypted disc's handshake yields no read_data_key, the gate
logged a bare "bus_key_unavailable" with no indication of WHY — turning
every occurrence into archaeology (is the bus key not-attempted, or did
the read fail?).

Now:
- read_data_keys failure is captured (error code) instead of swallowed by
  `.ok()`, and logged at the handshake with its consequence.
- HandshakeResult carries `read_data_key_err: Option<u16>` so the gate
  distinguishes "never attempted" (None — VID-only/OEM path) from "read
  FAILED" (a code), and the bus_key_unavailable warn now reports the code
  plus whether a Volume ID was present.

No behavior change — purely diagnostic. The handshake_ok debug also now
reports has_volume_id.
2026-06-29 13:33:09 -07:00
Matthew Jackson 46797c01a3 mux: harden audio discontinuity handling (audit follow-up)
Two defensive hardenings from the post-fix audit (vs FFmpeg/GStreamer):

1. Move the `pes.discontinuity` partial-drop ABOVE the empty-data guard in
   all three audio parsers (ac3/dts/truehd), so a discontinuity signal can
   never be stranded by an empty post-gap PES. The demuxer only emits
   non-empty PES today; this is defense-in-depth for any future caller.

2. A PES with no PTS must not reset the timeline to 0. ac3 now carries
   `flush_pts_ns`, dts continues from the most recent known base; truehd
   already kept its running cadence on a None PTS. Matches OSS behavior
   (PTS rebases off the next PES that actually carries a PTS).

Adds an ac3 regression test (empty-payload discontinuity PES still drops
the stranded partial). Loss accounting was reviewed: TS-demux CC-gaps are
NOT counted toward lost_video_secs / abort (that is sector-based via
DiscStream::errors / mapfile bytes_unreadable), so a source splice never
inflates loss — no gating needed there.
2026-06-29 12:50:52 -07:00
Matthew Jackson 790a40138b mux: drop truncated partial audio frame on concealed gap
The AC-3, DTS and TrueHD parsers buffer access units across PES
boundaries. At a concealed-loss gap the buffered unit is truncated:
splicing post-gap bytes onto it manufactures a corrupt frame on top of
the real loss (FFmpeg "Failed to decode block code(s)" / "Invalid data
found" at the gap) and, for TrueHD, strands the PTS cadence into the
non-monotonic audio-DTS band seen on multi-clip titles.

The video parsers already handle this via the ResyncGate, but the
discontinuity signal was only wired into video — audio parsers ignored
pes.discontinuity and spliced across the gap.

Now, when pes.discontinuity is set, each audio parser drops the partial
(clears buf, and for DTS its PTS marks / pending base) so the post-gap
PES re-bases a fresh unit. A lost gap degrades to a clean single-frame
drop instead of a corrupt spliced frame. No effect on perfect rips: the
branch only runs when concealment inserted a discontinuity marker.

Adds a per-parser test feeding a partial frame then a discontinuity PES,
asserting the truncated partial is dropped (not spliced) and the post-gap
PTS is adopted.
2026-06-29 11:23:08 -07:00
Matthew Jackson 4e545e6e46 disc: adaptive patch speed — enter each range fast, slow on first failure
Pass N (patch) pinned the slow recovery speed (0x0000) for the whole pass.
But Pass 1's damage-jump overshoots, so most of a jumped range is clean data
the reverse-walk reads first — grinding it at slow speed wastes minutes per
gap. Now each range mirrors Pass 1's model:

- Enter at max speed (0xFFFF) + reset to the initial batch (current_batch
  carries across ranges, so a prior single-sector grind would otherwise start
  the next range slow). Reads the clean overshoot fast.
- First read failure in the range → drop to 0x0000 (once; idempotent SET CD
  SPEED) for the rest of that range — grind only the genuine damage.
- 10 s halt-responsive cooldown between ranges, gated on "this range actually
  grinded" so a many-small-range pass (100+ ranges) doesn't stall ~20 min on
  unconditional pauses.

No change to good/bad classification, the mapfile, or the abort math — purely
read speed + an inter-range cooldown. Synthetic speed-transition test to follow.
2026-06-29 10:34:18 -07:00
Matthew Jackson afe55c7871 docs: 1.2.0 changelog date → 2026-06-29 (release prep); fix neither/nor grammar in 1.1.0 entry 2026-06-29 09:57:29 -07:00
Matthew Jackson 7114e46018 mux/decrypt: audit-nit cleanup + defensive conceal fallback
Non-blocking follow-ups from the 1.2.0 audit:
- conceal loop: if decrypt reported loss but the padding-aware predicate
  matched nothing to conceal (a ~256^-31 contradiction), fall back to the
  strict predicate and conceal whatever it flags, loudly — belt-and-
  suspenders so ciphertext can never reach the mux.
- decrypt_dropped doc: reflect 1.2.0 (mux-path loss is concealed + tallied,
  not silently dropped).
- direct unit test for aacs_unit_still_ciphertext (the padding-aware
  conceal predicate): clear/all-zero/full-decrypted/full-ciphertext/
  decrypted-short-tail.
- fix three stale "v1.1.1" comment refs (the fragment-tail fix ships in
  1.2.0; there is no v1.1.1 release).
2026-06-29 09:51:28 -07:00
Matthew Jackson ccb1b16ffb mux: make B1 concealment decode-clean on every gap shape
Closes the three residual holes where a concealed/lost gap could still let
a dangling-reference frame reach the muxer (degraded/undecryptable-disc
path only; clean rips are byte-identical and untouched). Root cause: the
discontinuity signal was reconstructed from the 4-bit continuity counter
and applied per-PES, both of which are lossy.

Three coordinated changes:

1. CC-INDEPENDENT marker. fill_null_ts_unit now tags its NULL packets with
   an adaptation-field discontinuity_indicator; the demuxer recognises a
   0x1FFF packet carrying it as a concealed gap and forces a discontinuity
   on every tracked PID (the lost unit's PID is unknowable). This survives
   a loss that is an exact multiple of 16 packets (CC aliases to in-sequence
   — hole 3) and a loss at a PID's very start (no prior CC — hole 4); it
   also drops any open, potentially-truncated partial PES.

2. PUSI ATTRIBUTION. A gap landing on a PES boundary now flags the PES
   STARTING after it, not the one flushed at the boundary (hole 1) —
   stamping the pre-gap frame could arm-then-disarm the gate on a keyframe
   and admit the real post-gap inter frame.

3. PER-FRAME signal. codec::Frame gains `discontinuity`; each parser
   propagates it onto the first post-gap frame. MPEG-2 buffers whole GOPs
   asynchronously, so it associates the gap by ES OFFSET (like PTS/source),
   landing it on the exact post-gap picture mid-GOP (hole 2) — a per-PES
   flag stamped the previous picture. consume_ts (and the EOF flush drain)
   gate on frame.discontinuity.

Tests: CC-independent marker with in-sequence CC + leading-loss; PUSI
attribution flags the post-gap PES; MPEG-2 offset-mark stamps the post-gap
picture through GOP reorder, not the previous one. Existing B1 gate + EOF
tests still green (2270 lib tests).
2026-06-29 09:39:03 -07:00
Matthew Jackson a5268d15ca docs: 1.2.0 CHANGELOG — document the breaking public-API changes
Per the release decision: ship the AACS-version-stride API changes under
the 1.2.0 minor bump, with an explicit Breaking section (DiscInputs new
field, DiscInputsCtx::new arity, read_aacs_inputs* 3-tuple return).
2026-06-29 08:41:26 -07:00
Matthew Jackson accf21b03d mux: conceal only genuinely-undecryptable units (padding-aware)
The P3 concealment loop in DecryptingSectorSource::read_sectors keyed on
aacs_unit_needs_decrypt, whose sync check is the majority-vote
ts_sync_destroyed (<=16 of 32 syncs). A successfully padding-aware-
decrypted content-fragment TAIL unit (e.g. 11 content packets + 21 zero
padding) has only 11 syncs, so the majority vote called it "still
encrypted" — and when such a good unit shared a read buffer with a
genuinely-undecryptable one (dropped>0), the loop overwrote the GOOD
decrypted tail with NULL-TS, silently discarding correct video and
over-counting concealed units vs the tallied dropped bytes.

Add aacs_unit_still_ciphertext (padding-aware): encrypted AND at least
one non-zero (non-padding) 192-byte packet missing its 0x47 sync — the
same discriminator decrypt_unit uses to accept a fragment tail. The
conceal loop now uses it, so only genuinely-unrestored ciphertext is
concealed. Full and fully-ciphertext units are unchanged. Regression
test: a decrypted short-padding-tail co-resident with a failed unit is
left byte-for-byte intact while the failed unit is concealed.
2026-06-29 00:29:20 -07:00
Matthew Jackson 07396ec19d v1.2.0: bump version + reconcile CHANGELOG to [1.2.0] 2026-06-28 23:34:19 -07:00
Matthew Jackson da3859d0c5 mux: B1 drop-to-keyframe resync after a concealed gap
Pairs with A2 (read-path NULL-TS concealment). When the demux assembler
sees a TS continuity gap it now stamps `discontinuity` on the next
completed PES; the codec-parse stage carries that onto a per-track
ResyncGate. After a gap on an inter-coded video track the gate drops
forward to the next IRAP/IDR keyframe so no frame with a dangling
reference reaches the muxer (an ffmpeg deep scan would otherwise report
a missing-reference / non-existing-PPS error). Audio and subtitle tracks
have no cross-frame references, so the gate is a no-op there.

- ts.rs: PesPacket gains `discontinuity`; PesAssembler tracks a sticky
  pending_discontinuity flag set on CC gap / discontinuity_indicator and
  carried to the next completed/flushed PES.
- resync.rs (new): ResyncGate — per-track arm-on-gap, drop non-keyframes
  until the next keyframe disarms and resumes. Logs the resync + drop
  count once at the keyframe.
- pipelined_stream.rs: precompute per-track is_video, apply the gate in
  consume_ts. Out-of-range track index emits as-is (defensive).

Tests: ResyncGate unit tests; ts.rs gap-stamps-discontinuity; end-to-end
B1 video-drops-to-keyframe and audio-never-drops through PipelinedPesStream.
2026-06-28 23:01:47 -07:00
Matthew Jackson d334ab5749 1.2.0: mux loss-concealment read path (P3/Edit-2, A2 NULL-TS fill)
Decrypt-verify is a RIP gate, not a MUX gate. On the mux read path an
undecryptable content unit must never abort the mux:

- DecryptingSectorSource gains tolerate_decrypt_loss(): when set, an
  undecryptable in-content unit is tallied, overwritten with valid NULL
  TS packets (PID 0x1FFF) via aacs::fill_null_ts_unit, logged loud with
  its LBA, and the read returns Ok — the stream keeps flowing. The rip
  paths keep the fail-loud DECRYPT_VERIFY_READ decorator (re-read off the
  disc); only the mux opts in.
- Wire it into both mux read paths: the file-backed highway
  (build_iso_pipeline) and the inline DiscStream.
- NULL-TS fill keeps the demuxer byte-synced on the 192-byte stride; the
  lost video/audio PID packets surface as a CC gap the TS assembler
  already drops a partial PES on (the B1 foundation). Ciphertext is never
  passed downstream either way.
- Fix stale resolve_vid_only no-cert test: default is UHD (audit #4).

Tests: conceal-as-NULL-TS, fill well-formedness, fail-loud still holds.
2026-06-28 22:44:19 -07:00
Matthew Jackson 0b2e83e523 1.2.0: single MKB framing walker + AACS resolve hardening
- mkb_records() as the one record-framing iterator; rebuild walk_mkb,
  find_record_body, mkb_find_subdiff_records, mkb_content_len, mkb_version,
  mkb_type_raw, mkb_find_mk_dv on it (D4).
- resolve_vid_only / read_aacs_version default to UHD (+warn) on a missing
  content cert instead of BD; route MKB through bounded read_mkb_content.
- AacsVersion major()/from_major() + AACS_MAJOR_BD/UHD as the stride source;
  table + stride-discriminating regression tests.
- read_encrypted_units probes 8 evenly-spaced points per extent (off-midpoint
  scrambled content now sampled); decrypt source-zero mask uses PKT.
2026-06-28 22:28:09 -07:00
Matthew Jackson abd7af3907 1.2.0: unify hex parsing (one parser) + robust encrypted-unit sampling
- One workspace hex parser (libfreemkv::hex): the keydb / online / mapfile
  parsers had divergent prefix handling (0x vs 0X vs none) — a key written with
  a prefix one parser didn't expect was silently dropped. All three now call it.
- read_encrypted_units probes several points spread across each extent instead
  of only the midpoint-forward window, so a late-starting / sparse encrypted
  body still yields samples (empty samples make decrypt_with skip wrong-key
  validation). A read error at one probe no longer abandons the extent.
2026-06-28 22:12:06 -07:00
Matthew Jackson 7716a2cb17 1.1.1: unify AACS key-input path on Disc::inputs() + named constants
- read_aacs_inputs* now returns the AACS major version; DiscInputs carries it,
  and DiscInputsCtx parses Unit_Key_RO.inf at the disc's own stride (fixes the
  hardcoded-V20 read-time fetch for V10 discs). One source of truth, no version
  argument to drift.
- Disc::inputs() is the single complete AACS-input source (inf/MKB/VID/hash/
  version); the out-of-band duplicate readers go away.
- Named constants for AACS file paths (aacs::PATH_*) and the AACS majors
  (aacs::AACS_MAJOR_*, AacsVersion::major/from_major) replace magic strings/ints.
- push_ranges saturating (corrupt-disc panic guard).
2026-06-28 21:45:58 -07:00
Matthew Jackson 391e1ae39c 1.1.1: AACS decrypt + key-resolution hardening
- decrypt_unit: padding-aware acceptance — recover real video at content-
  fragment tails (the phantom mux-loss class) without weakening wrong-key
  rejection (a full content unit still needs all 32 TS syncs).
- scan: read the MKB via the bounded read_mkb_content so Disc::inputs()
  carries it. Online key resolution was shipping mkb=0 (a full read of the
  ~128 MiB MKB_RO allocation fails) → the decode service 404'd.
- resolve_vid_only: surface an MKB read error instead of silently emptying.
- fetch: a per-sample dry-set replaces the global fetch_spent latch, so a
  second CPS unit's key can still be fetched after the first came back empty.
- verify::push_ranges: saturating arithmetic (corrupt-disc panic guard).
- Tests for all of the above.
2026-06-28 21:12:57 -07:00
91 changed files with 14671 additions and 10025 deletions
+351 -2
View File
@@ -1,5 +1,354 @@
# Changelog
## [1.3.0] — 2026-07-08
### Added
- **AACS 2.1 (FMTS) is a first-class disc format.** FMTS discs are detected,
labeled, and scanned as their own format rather than misread as plain UHD. The
forensic variant segments are located from `IndividualSegment.tbl` and the
`SegmentKey.tbl` container is parsed; the bulk of the title decrypts with the
unit key as usual, and the forensic segments (for which no segment-key source
exists yet) are skipped as expected loss, so a 2.1 disc rips mostly-complete
instead of failing outright.
- **AACS 2.1 variant Media Key chain runs end to end.** The variant media key is
derived as a clean Processing-Key to media-key primitive, with the record
layout pinned against reference variant MKBs — the per-slot `C` block from the
`0x0c` cvalue table, the `VARIANTS` table plus trailing nonce at `0x2d`, and
`VKD` at `0x2f` — so a genuine variant MKB resolves through the ladder.
- **Partial HD-DVD support.** HD-DVD is detected as its own format and its
`HVDVD_TS` `.evo` clips mux through the pipeline: EVO video is demuxed from the
MPEG program stream, including VC-1 titles carried on extended stream id `0xFD`
(real selector in the PES `stream_id_extension`), with the VC-1 access units
reframed so each I-frame keeps its preceding sequence and entry-point headers.
Title composition is heuristic for now (authoritative program-chain parsing is
planned), so a disc that authors two distinct features under the layer-break
naming may present them as one title.
- **Display-order timestamps for program-stream H.264 / VC-1 / HEVC.** A program
stream stamps a PES PTS only once per GOP; the parsers now reconstruct a
display-order PTS per frame from the coded picture type and the sparse anchor
(duration self-calibrated from anchor spacing), so a decoder no longer sees
colliding DTS. Gated to the program-stream path — the BD/UHD transport path
(per-frame PTS) is unchanged.
- **Stream-label parsers: reader-backed detection and a menu-language fallback.**
Label detection can inspect a jar's contents, so vendor parsers claim only
their own discs; a new last-resort parser reads menu-artwork languages.
- **keydb round-trips AACS 2.0 host certs** (the `HC2` line) so a load/save cycle
no longer drops v2 host credentials.
### Changed
- **MPEG-2 reassembles through the shared `AuAssembler`.** The MPEG-2 parser's
hand-rolled PES buffer and offset-keyed mark queues are replaced by the same
access-unit assembler the H.264/HEVC/VC-1 parsers use (in a new MPEG-2 mode);
the GOP-buffered `temporal_reference` reorder and PTS origin-locking are
unchanged, so DVD output is identical.
- **Generic per-scheme recovery seam.** Decrypt-miss handling is now a
scheme-neutral seam the input stream installs (no recovery, or an AACS
fresh-key fetch), with CSS self-recovering separately from the data itself. An
undecryptable unit is counted the same whatever the scheme, so the separate
"undecryptable" loss bucket folds into one loss count.
- **`aacs` module reorganized.** The former god-module is split into
`media_key` / `volume_key` / `inf` / `resolve` / `mkb` / `crypto` / `content`,
the `boil` veneer is removed, and module paths (not a `mod.rs` facade) are the
public API.
### Fixed
- **Main title is chosen by largest physical size, not clip count**, so a
chapter-per-clip disc (e.g. Fast & Furious) is no longer mis-ranked behind a
virtual composite.
- **A fresh-rip ISO `sync_all` failure is no longer swallowed**: `is_regular` is
read from the open file handle instead of a pre-create `metadata(path)` that
always failed on a path that does not exist yet.
- **A transient CLIP-info parse failure no longer suppresses a clip's extents**
for a later playlist item that references the same clip.
- **Reverify downgrades that fail to persist are logged, not swallowed**, so a
bad unit cannot be silently mismarked good on resume.
- **The CLI sanitizes on-disc metadata** (title, volume label, playlist, stream
labels) before printing, so a crafted disc cannot inject terminal escape
sequences.
- **keydb entry validation matches the parser exactly** — a `0x` line counts only
with a ` = ` — so content that parses to zero usable entries can no longer be
saved as valid.
- **autorip** recovers a poisoned config lock in the rip thread instead of
panicking it, and corrects the resume pass count.
- Criterion stream numbering (a map value of 0 no longer shadows stream 1); AACS
resolve classifies a media-keys-only source missing the VID as "VID
unavailable"; a dropped partial PES flags a discontinuity; the no-demuxer path
detects an early consumer disconnect.
### Performance
- **Decrypt thread count is resolved once** and cached off the per-buffer hot
path (the env var and `available_parallelism` are no longer probed per call).
## [1.2.2] — 2026-07-04
### Added
- **AACS 2.1 Media Key Variant support.** The Media Key Variant scheme is now
detected and parsed from the real MKB record types found on variant discs —
`0x2d` (Encrypted Media Key Variant Data), `0x2f` (Variant Key Data table,
65,535 × 16), and `0x0c` (variant cvalues, one per subset-difference slot) —
replacing the earlier placeholder `0x82`/`0x83` types, which were a guess and
appear on no real MKB. The V2.0→V2.1 upgrade detection and fixtures are updated
accordingly, so a genuine AACS 2.1 variant disc now resolves.
- **`resolve_candidate`** — one composed, pure-derivation boil-down for a
candidate key at any ladder rung (DK/PK/MK/VUK → terminal unit keys), parsing
`Unit_Key_RO.inf` at the disc's declared AACS version and returning every CPS
unit key. Consumers stop re-composing the ladder; every client hardens a single
implementation.
### Fixed
- **`mk_from_dk` does the real Subset-Difference walk again.** It previously ran
the Media-Key-Variant path, which needed an integrator KCD absent in-tree and
errored for every real disc — effectively dead for both consumers. It now
performs the genuine device-key SD walk; the Volume ID enters at the VUK step
(where it belongs), not the MK step. This revives the DK→MK fallback across the
toolchain (`freemkv-keysources` adopts the corrected two-argument call).
- **autorip: a down online key service is no longer reported as a missing key.**
When the online key source resolves no key for an encrypted disc, autorip now
runs one bounded reachability probe (SSRF-pinned, ~8 s, no redirects) and
distinguishes a transient outage (transport error / 502·503·504 → down; 429 →
rate-limited) from a genuine no-key (any real HTTP answer → up). A transient
verdict triggers a bounded key-resolution retry (3 attempts, 8/16/32 s backoff)
and, if the service stays down, parks the disc in a distinct retryable state
("Key service unavailable — temporary outage, not a missing key; will retry.")
instead of the permanent "no keys found". Never hammers the drive or service.
### Performance
- **Processing-Key resolution is ~15× faster on UHD.** A Processing Key is the
key at its subset-difference node (one AES-G from the Media Key), so it is now
tried directly against the MKB cvalue tables (matching libaacs `_calc_mk_pks`)
instead of BFS-walking the SD tree at unknown depth — which was both wrong for
terminal PKs and slow on a large UHD MKB (~181k cvalues). PK derivation on UHD
drops from ~37 s to ~2.4 s; the SD tree walk now lives solely in the device-key
path.
### autorip
- **Clear stuck move errors from the System tab.** Each move-queue error now has
a ✕ to dismiss it, plus Clear all and Refresh — so a resolved or stale error
can be cleared without restarting the container (the mover re-records any that
are still genuinely failing on its next tick).
## [1.2.1] — 2026-07-02
### Fixed
- **DVD DTS audio no longer muxes with non-monotonic timestamps.** A DVD
Program Stream packs several DTS core frames into one PES packet; the parser
stamped every access unit with that single PES timestamp and no per-frame
duration, so consecutive frames collided on one PTS and a strict decode/remux
(ffmpeg) rejected the track — `non monotonically increasing dts to muxer`.
The DTS parser now derives each core frame's duration from its header
(`(NBLKS+1)*32` samples ÷ the `SFREQ` sample rate) and re-bases to each PES's
own container timestamp, advancing by a frame duration only *within* a single
PES — so the track stays monotonic and does not drift past its real length on
a feature-long title. The UHD DTS-HD MA path (one access unit per PES) is
unaffected: each unit keeps its own PES timestamp, preserving the 1.2.0 per-PES
attribution. Completes the DVD DTS fix begun in 1.2.0 (which corrected the
silent-track routing, exposing this timing bug). Note: genuinely corrupt
source DTS frames — valid framing, bad audio blocks — are passed through
faithfully; freemkv never fabricates or drops audio it can't prove is bad.
## [1.2.0] — 2026-07-01
### Breaking
The disc's AACS version is now carried through the key-resolution path as the
single source of truth for the `Unit_Key_RO` stride (AACS-1.0 = 48-byte,
AACS-2.x = 64-byte), so keys are always read at the disc's own layout. That
threaded one new value through three public signatures. In-tree consumers
(`freemkv`, `autorip`, `freemkv-keysources`) are updated; external callers must
adjust:
- **`DiscInputs` gains a `version: u8` field** (between `volume_id` and `mkb`).
Code constructing it with a struct literal must add the field. It is normally
obtained from `Disc::inputs()`, not constructed by hand.
- **`keysource::DiscInputsCtx::new` takes one argument, not two** — the version
is now read from `inputs.version` (`new(inputs)` instead of
`new(inputs, version)`).
- **`disc::read_aacs_inputs` / `read_aacs_inputs_from_drive` return a 3-tuple**
`(inf, mkb, version)` instead of `(inf, mkb)`.
- **`PassProgress` is no longer `Copy` and gains a `located: LocatedProgress`
field.** It now carries a `Vec` (the rendered bad-range drilldown), so it's
`Clone` only — still built once per throttled emission and passed by reference
to `Progress::report`. Struct-literal constructors must add the field (empty:
`located: Default::default()`). New public types `LocatedRange` /
`LocatedProgress`.
These are source-breaking for external crates.io consumers. Shipped under a
minor bump (1.2.0): libfreemkv's surface is not yet frozen and the only known
consumers are the in-tree toolchain crates.
### Added
- **Pass-N marginal-sector recovery specialists.** The patch pass gained a
roster of parameterized recovery techniques — read speed (max/min), cache
bypass (FUA), and traversal (linear fwd/rev, bisect, cache-prime, oscillate,
per-sector speed-sweep) — each targeting a distinct physical failure mode of
marginal media. A per-rip **decayed (EWMA) scorecard** grades every technique
by its recent recovery rate and re-orders them best-first, so the engine
hardcodes no conclusion: a technique that fits *this* disc floats to the front
and one that doesn't self-deprioritises (but is never dropped). Every read is
wedge-safe and deadline-bounded; the existing fast/deep recovery behavior is
unchanged (the specialists are additive, tried only on the hardened residue).
- **Opt-in flat-pool recovery scheduler (`FREEMKV_PATCH_FLAT`).** Collapses the
breadth-first recovery tiers into one flat pool so every technique gets a shot
at each bad range immediately, scorecard-ordered — a data-driven bandit for a
hardened residual (e.g. a late resume) where the tiered ladder would spend a
long time on cheap techniques before reaching the specialists. Unset keeps the
proven tier ladder as the default.
- **`PassProgress` is the complete, mapfile-free progress contract.** Every
emission now carries the fully-rendered "where is the damage" drilldown
(`located`): the bad ranges annotated with chapter + movie-time offset, the
main-feature at-risk time, the section count and the largest gap — computed by
the library from its in-memory mapfile + title. A client (autorip, a future
GUI/CLI) renders the disc map + at-risk time straight from it and never parses
the mapfile, so a mapfile→mapdb change is invisible to clients. Adds
`disc::locate_ranges`, the one-shot `disc::progress_snapshot_from_mapfile`
(builds a snapshot from a mapfile on disk so a boundary/verdict paint stays
mapfile-free client-side), and `consts::MILLIS_PER_SEC`.
- **`PatchOptions::fast_capture` — breadth-first patch recovery.** A fast-capture
pass reads each bad range once at the full batch and leaves every failed block
`NonTrimmed` for a later pass — no bisect, no re-read, no per-sector grind — so
a first retry pass grabs the readable blocks (a sweep's good skip-ahead
overshoot) of EVERY range before any single range's slow per-sector recovery.
No data is dropped: a failed block stays `NonTrimmed` (retried by a granular
pass), never `Unreadable`. A transport fault still aborts. `Disc::copy`'s
internal patch leaves it `false` (single-call full recovery).
- **Mux loss concealment — a logged gap still produces a decode-clean file.**
When a unit genuinely cannot be decrypted on the mux read path (a key the disc
never yielded, after the rip's own decrypt-verify already failed loud and
re-read), the mux no longer passes ciphertext downstream or emits a broken
frame. The undecryptable aligned unit is concealed as NULL transport-stream
packets (PID 0x1FFF, invisible to every real stream), and the codec layer
**drops forward to the next keyframe** so no frame with a dangling reference
reaches the muxer. An ffmpeg deep scan of the result is clean — no missing
references, no partial frames. The loss is tallied and logged, never silently
dropped, and the mux always completes. Audio and subtitle tracks have no
cross-frame references, so only the directly-affected frames are dropped there.
Decrypt-verify remains a **rip** gate (fail loud → re-read), never a mux gate.
- **`Disc::unlocker_matrix()` — registry-driven unlocker did-work report.** Returns
each registered unlocker's name alongside a `did_work` flag recording whether it
performed authentication steps during the current rip. Callers (autorip, the CLI)
surface this so an operator can confirm at a glance which unlock paths —
LibreDrive firmware, AACS, CSS — actually ran, with no hardcoded names on the
caller side.
### Changed
- **One hex parser.** All hex parsing (keys, IDs, key-source inputs) routes
through a single `libfreemkv::hex` parser instead of several ad-hoc decoders,
so length/odd-nibble/invalid-digit handling is identical everywhere.
- **Robust encrypted-unit sampling + a single MKB framing walker.** Up-front
AACS sampling tolerates content layouts that previously yielded too few
encrypted units to resolve a key, and the Media Key Block is now walked by one
framing routine shared across the in-band and out-of-band readers (no
divergent record-stride logic). AACS resolution hardened around these paths.
- **One reader, one `DiscInputs`.** `Disc::inputs()` is now the single, complete
source of a disc's AACS inputs (inf, MKB, VID, disc_hash, version), and
`read_aacs_inputs*` returns the version alongside inf+MKB. Both the CLI and
autorip resolve through `Disc::inputs()`; the duplicate out-of-band readers
(autorip's `key_files()`/`volume_id()`) and the stale mapfile-VID read are
removed. AACS file paths and the AACS major versions are now named constants
(`aacs::PATH_*`, `aacs::AACS_MAJOR_*`, `AacsVersion::major`/`from_major`) so a
fallback or stride change lives in exactly one place.
- **Pass-N recovery rebuilt as a bounded, never-hang handler chain.** The 1.1.0
patch loop retried each bad range sector-by-sector until a per-range budget was
exhausted, with no escape from a wedged drive short of the watchdog firing after
tens of minutes. 1.2.0 replaces that with a two-tier handler chain dispatched
breadth-first, largest bad range first:
- **Jump** (lead tier): reads each range in large forward-skipping batches to
quickly locate readable islands — clearing a multi-gigabyte dead spot in
seconds rather than sector-by-sector.
- **Bisect** (trailing tier): binary-searches the boundaries of each remaining
bad block, converging to within a single sector of the last-readable LBA.
Boundary-probe reads are exempt from the early-yield stall so the boundary
walk always completes.
- **Handler scorecard**: handlers that make progress stay at the front of the
rotation per rip; an idle handler is ranked last so proven performers lead.
- **Wedge detection**: a pass-level streak counter tracks consecutive
wedge-family senses (HARDWARE ERROR / ILLEGAL REQUEST) across section
boundaries. At the threshold the pass aborts and a soft un-wedge
(`Drive::spin_cycle()` — START STOP UNIT, no eject) runs before the next retry
pass, instead of grinding at near-zero throughput until the pass watchdog
fires.
No data is dropped: a block that neither handler recovers in a pass stays
`NonTrimmed` for the next pass.
### Fixed
- **DVD DTS/LPCM audio tracks no longer mux silent.** On DVD-Video the
`private_stream_1` sub-stream id's low nibble is the audio-stream *number*
(shared across codecs), not a per-codec ordinal. A DTS or LPCM track that
wasn't the disc's first audio stream got a sub-id one too low, so the demux
routing key (`0xBD00 | sub_id`) never matched and every packet was dropped —
the track appeared in the container but played silent (AC-3 at position 0
worked by coincidence). Audio sub-stream ids are now assigned by positional
stream number, so a DTS 5.0 track after an AC-3 5.1 track routes correctly.
- **ISO mux no longer drops real video at content-fragment tails.** A title's
encrypted content can end mid-AACS-unit, with the disc zero-padding the rest
of the 6144-byte aligned unit to the next fragment. The decrypt-verify
demanded the TS sync byte on *all 32* source packets, so it rejected such a
tail unit over its legitimate padding — discarding the real video packets it
contained. On a flawless rip this surfaced as a small phantom "loss" at mux
(and, once retries were exhausted, a truncated MKV). Unit acceptance is now
**padding-aware**: only packets whose *source* (pre-decrypt) bytes are
non-zero must restore their TS sync; the zero padding is excluded from the
check and emitted as clean zeros. A full content unit still requires all 32
(unchanged — no wrong-key relaxation), and a unit whose *non-zero* tail fails
to decrypt is still rejected as a genuine bad read.
- **ISO online key resolution now sends the Media Key Block.** Capturing a
disc's AACS inputs at scan read the MKB with a full `read_file` of the
~128 MiB `MKB_RO`/`MKB_RW` allocation, which fails on file-backed readers —
leaving the MKB empty, so `Disc::inputs()` shipped `mkb=0` to an online key
service and the request was rejected (no key → no decrypt). Scan now reads the
MKB through the same bounded prefix-grow + trim reader as the out-of-band
path, so `Disc::inputs()` is the single complete source of AACS inputs — one
reader for every caller.
- **Read-time key-fetch parses `Unit_Key_RO.inf` at the disc's own AACS stride.**
The on-demand fetch (for a CPS unit not sampled up front) hardcoded the V20
64-byte stride, so an AACS-1.0 (V10) disc whose key arrived as a VUK derived
the wrong unit keys. `DiscInputs` now carries the disc's `version`, and the
context parses at the matching stride — the disc is the single source of truth
for its own stride (no separate version argument to drift).
- **A dry key-fetch for one unit no longer blocks fetching a different unit.**
A global "fetch spent" latch meant that once the key service returned nothing
for one CPS unit's ciphertext, no further unit was ever asked — so a multi-CPS
disc could strand a unit whose key the service *would* have served. Replaced
with a per-unit "already-asked-dry" set (still bounded by the fetch budget).
- **`verify::push_ranges` uses saturating arithmetic** so a corrupt-disc LBA near
`u32::MAX` can't panic (matches `udf::merge_ranges`).
- **Audio no longer corrupts at a stream discontinuity.** At a transport-stream
discontinuity — a continuity-counter break, an adaptation-field
discontinuity_indicator, or a concealed-loss gap — the AC-3 / DTS / TrueHD
parsers held a *truncated* partial access unit and spliced the post-gap bytes
onto it, manufacturing a corrupt frame (ffmpeg "exponent out of range" /
"Failed to decode block code(s)" / "Invalid data found") and, for TrueHD, a
non-monotonic timestamp band on multi-segment titles. The video path already
resynced via the keyframe gate; the audio parsers now do too — on a
discontinuity they drop the un-completable partial and resync on the next
syncword, rebasing the timestamp from the post-gap PES. A discontinuity becomes
a clean single-frame gap instead of a corrupt splice. Audio has no inter-frame
references, so dropping the truncated partial is the complete fix; the approach
matches FFmpeg's parser layer and GStreamer's `tsdemux`.
- **Drive-prep firmware unlock skipped for DVD discs.** An
`if disc_is_dvd() { return }` guard in `Drive::init()` (present since
1.0.0-rc.1) bypassed the entire drive-prep unlock step for DVDs. That unlock is
what removes riplock and raises the drive to maximum read speed — a drive-level,
disc-independent feature — so every DVD rip ran at riplock speed (~0.4× rated,
multi-hour ETA). The guard is removed; all disc types now go through the full
drive-prep sequence. UHD and Blu-ray were unaffected (they already ran through
the unlock path).
## [1.1.0]
### Added
@@ -7,8 +356,8 @@
- **Post-read decrypt-verify gate.** Every AACS unit read off the disc is now
buffered, re-aligned to its clip-file 6144-byte unit grid, and verified
(CPI flag → decrypt → strict all-32 TS-sync, matching libaacs `_verify_ts`)
before it is signed off as good. A unit that neither a held nor a
freshly-fetched key decrypts is treated exactly like a bad read — re-read by
before it is signed off as good. A unit that no held or freshly-fetched key
decrypts is treated exactly like a bad read — re-read by
the patch pass, terminal loss only if truly unrecoverable — closing the
"silent bad read" class where a sector reads OK but its ciphertext is subtly
wrong. **Fail-safe:** it only ever downgrades a unit it is *confident* is bad;
+8 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.1.0"
version = "1.3.0"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
@@ -10,6 +10,9 @@ keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
categories = ["hardware-support", "multimedia"]
# Keep internal AI-instruction / private notes out of the published crate.
exclude = ["CLAUDE.md"]
# OFF crates.io: libfreemkv git-deps freemkv-unlock (firmware, never published),
# so libfreemkv itself can only be consumed by git tag. Clients git-tag-pin it.
publish = false
[profile.release]
lto = "thin"
@@ -22,6 +25,10 @@ sha1 = "0.10"
sha2 = "0.10"
aes = "0.8"
cbc = "0.1"
# Interim path dep for local cross-repo dev; the release script re-pins this to
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
# the released tag resolves freemkv-unlock from git, not a sibling path).
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.3.0" }
num-bigint = "0.4"
num-traits = "0.2"
num-integer = "0.1"
+10 -10
View File
@@ -1,26 +1,26 @@
[![Crates.io](https://img.shields.io/crates/v/libfreemkv)](https://crates.io/crates/libfreemkv)
[![docs.rs](https://img.shields.io/docsrs/libfreemkv)](https://docs.rs/libfreemkv)
[![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue)](LICENSE)
# libfreemkv
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Firmware-clean core: drive-unlock support is plugged in via the `Unlocker` trait, with concrete unlockers shipped as separate crates.
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Drive-level unlocking is handled internally; consumers work with disc access and decryption only.
DVDs (CSS) decrypt out of the box. Blu-ray and UHD (AACS) require a `keydb.cfg` (default `~/.config/freemkv/keydb.cfg`) supplying disc-specific volume unique keys; no AACS key material is compiled in.
**12+ MB/s** sustained read speeds on BD. Drive prep routes through the pluggable unlock seam — register an unlocker and `init()` drives it; with none registered the library rips via the host-certificate AACS handshake.
**12+ MB/s** sustained read speeds on BD. Drive prep (`init()`) handles unlocking internally via the `freemkv-unlock` crate — clients never see it; when no drive unlock applies, the library rips via the host-certificate AACS handshake.
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
**[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)**
**[Source & API](https://github.com/freemkv/libfreemkv)** · **[Technical Docs](docs/)**
Part of the [freemkv](https://github.com/freemkv) project.
## Install
Consumed by git tag (not published to crates.io):
```toml
[dependencies]
libfreemkv = "1.0.0-rc.1"
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "vX.Y.Z" }
```
## Quick Start
@@ -32,7 +32,7 @@ use std::path::Path;
// Open drive — identified via INQUIRY
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
drive.wait_ready()?; // wait for disc
drive.init()?; // route through the unlock seam (if an unlocker is registered)
drive.init()?; // unlock + prep (handled internally)
drive.probe_disc()?; // probe disc surface for optimal speeds
// Scan disc — UDF, playlists, streams, AACS (all automatic)
@@ -100,7 +100,7 @@ loop {
## What It Does
- **Drive access** — open, identify, pluggable unlock seam, speed control, eject
- **Drive access** — open, identify, internal unlock + prep, speed control, eject
- **12+ MB/s reads** — auto-detects kernel transfer limits, sustained full speed
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
@@ -134,8 +134,8 @@ Blu-rays and UHD (AACS) require a `keydb.cfg` at `~/.config/freemkv/keydb.cfg` (
```text
Drive — open, identify, init, single-shot read
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
└── Unlocker seam — pluggable trait + registry; concrete unlockers
live in the separate freemkv-unlock repo
└── unlock_bridge — private seam to the freemkv-unlock crate
(firmware / AACS cert / CSS bus-auth unlockers)
Disc — scan titles, streams, AACS/CSS state
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
-308
View File
@@ -1,308 +0,0 @@
//! AACS derivation "boil-down" — one public home for the key chain.
//!
//! Thin newtypes at the API boundary and three wrapper functions over the
//! existing crypto. Nothing here re-implements a primitive: every function
//! delegates to the already-audited code in [`super::keys`] and
//! [`super::variants`], so the boil-down cannot drift from production math.
//!
//! The newtypes wrap bare `[u8; 16]` ONLY at this boundary — the crypto
//! internals continue to operate on raw arrays. They exist so a caller threads
//! the chain `DK → MK → VUK → UK` without confusing one 16-byte secret for
//! another, not to refactor the resolver.
//!
//! Chain (matches `aacs::keys::resolve_keys_classical` path 1 and
//! `aacs::keys::resolve_keys_v21` path 1 byte-for-byte):
//!
//! ```text
//! mk_from_dk(device_keys, mkb, vid) → MediaKey (Km)
//! mk_from_pk(processing_keys, mkb) → MediaKey (Km)
//! vuk_from_mk(MediaKey, Vid) → Vuk (= AES-G(Km, VID))
//! uk_from_vuk(Vuk, enc_title_keys) → [UnitKey] (decrypt_unit_key each)
//! ```
//!
//! `mk_from_dk` and `mk_from_pk` are two entry points to the SAME Media Key:
//! the device-key path walks the MKB's Media-Key-Variant chain, the
//! processing-key path walks the MKB's Subset-Difference cvalue tables. Neither
//! needs a VID (the VID enters at `vuk_from_mk`).
use super::keys::{decrypt_unit_key, derive_media_key_from_pk, derive_vuk};
use super::types::DeviceKey;
use super::variants::{KEY_CORRECTION_DATA_PLACEHOLDER, derive_media_key_variant, walk_mkb};
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Vid(pub [u8; 16]);
/// Media Key (Km, 16 bytes) — the MKB-scoped key derived from device keys.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MediaKey(pub [u8; 16]);
/// Volume Unique Key (VUK / Kvu, 16 bytes) — derived from `MediaKey` + `Vid`,
/// decrypts the per-disc encrypted title keys in `Unit_Key_RO.inf`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Vuk(pub [u8; 16]);
/// One decrypted per-CPS-unit AACS title key.
///
/// `idx` is the POSITIONAL index of the encrypted title key within the slice
/// handed to [`uk_from_vuk`] (i.e. its order in `Unit_Key_RO.inf`'s key-storage
/// area). The CPS-unit *number* association (the `u32` in
/// `ResolvedKeys::unit_keys`) is a higher-level concern owned by
/// [`super::keys::parse_unit_key_ro`], which pairs each positional key with its
/// declared CPS unit; this primitive only does the AES, so it surfaces position.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnitKey {
pub idx: u32,
pub key: [u8; 16],
}
/// Derive the Volume Unique Key from a Media Key and Volume ID.
///
/// Wraps [`derive_vuk`] verbatim: `VUK = AES-128-ECB-DECRYPT(MK, VID) XOR VID`.
/// This is byte-identical to the inline `derive_vuk(&mk, ctx.volume_id)` call in
/// every classical resolver path AND to the `Kvu = AES-G(Km, VID)` step inside
/// [`derive_media_key_variant`] (AES-G and `derive_vuk` are the same math), so
/// `vuk_from_mk(mk_from_dk(..)?, vid)` reproduces the V21 variant VUK exactly.
pub fn vuk_from_mk(mk: MediaKey, vid: Vid) -> Vuk {
Vuk(derive_vuk(&mk.0, &vid.0))
}
/// Decrypt the disc's encrypted title keys with a VUK.
///
/// Wraps [`decrypt_unit_key`] (AES-128-ECB-DECRYPT) per entry, mirroring the
/// `derive_uks` closure in `resolve_keys_classical` / `resolve_keys_v21`. The
/// returned `UnitKey::idx` is the slice position; pair with CPS-unit numbers via
/// [`super::keys::parse_unit_key_ro`] when the numbering matters.
pub fn uk_from_vuk(vuk: Vuk, enc_title_keys: &[[u8; 16]]) -> Vec<UnitKey> {
enc_title_keys
.iter()
.enumerate()
.map(|(i, enc)| UnitKey {
idx: i as u32,
key: decrypt_unit_key(&vuk.0, enc),
})
.collect()
}
/// Derive the Media Key (Km) from device keys via the Media Key Variant chain.
///
/// Wraps [`walk_mkb`] + [`derive_media_key_variant`] with exactly the arguments
/// `resolve_keys_v21` path 1 passes: the placeholder Key Correction Data and the
/// disc Volume ID. Returns the FIRST tuple element `Km` (the Media Key) — the
/// resolver treats `Km` as the media key and derives the VUK from it as
/// `Kvu = AES-G(Km, VID)`, which equals [`vuk_from_mk`]`(MediaKey(km), vid)`. The
/// variant fn's second element is that already-derived `Kvu`; returning `Km`
/// keeps this primitive at the "media key" level so the chain composes.
///
/// Because the integrator KCD is unavailable in-tree (the placeholder is
/// rejected by the variant chain), this returns `Err` for every real disc today
/// — byte-for-byte identical to `resolve_keys_v21` path 1, which the resolver
/// also leaves unreachable in production. All variant-chain failures collapse to
/// [`Error::AacsMkUnavailable`] (E7018): no numeric distinction is load-bearing
/// at this boundary, and the variant error carries no English to preserve.
pub fn mk_from_dk(
device_keys: &[DeviceKey],
mkb: &[u8],
vid: Vid,
) -> Result<MediaKey, crate::error::Error> {
let records = walk_mkb(mkb);
match derive_media_key_variant(
&records,
device_keys,
&KEY_CORRECTION_DATA_PLACEHOLDER,
&vid.0,
) {
Ok((km, _kvu)) => Ok(MediaKey(km)),
Err(_) => Err(crate::error::Error::AacsMkUnavailable),
}
}
/// Derive the Media Key (Km) from one or more Processing Keys and the disc MKB.
///
/// Wraps [`derive_media_key_from_pk`] — the Subset-Difference PK→MK walk: each
/// processing key is validated (and tree-walked) against the MKB's cvalue tables
/// (records `0x04`/`0x05`) until one yields the Media Key whose verify record
/// (`0x81`/`0x86`) matches. Unlike [`mk_from_dk`] this path is reachable for
/// real discs — a leaked/precomputed AACS Processing Key in the keydb resolves
/// the Media Key directly. No VID is involved at this step; the VID enters at
/// [`vuk_from_mk`].
///
/// Returns [`Error::AacsMkUnavailable`] (E7018) when no processing key resolves
/// the MKB — the same terminal error as [`mk_from_dk`]; no numeric distinction
/// is load-bearing at this boundary.
///
/// [`Error::AacsMkUnavailable`]: crate::error::Error::AacsMkUnavailable
pub fn mk_from_pk(
processing_keys: &[[u8; 16]],
mkb: &[u8],
) -> Result<MediaKey, crate::error::Error> {
match derive_media_key_from_pk(mkb, processing_keys) {
Some(km) => Ok(MediaKey(km)),
None => Err(crate::error::Error::AacsMkUnavailable),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aacs::decrypt::aes_ecb_encrypt;
use crate::aacs::keys::{decrypt_unit_key, derive_vuk};
/// `vuk_from_mk` must equal the inline `derive_vuk` path bit-for-bit, for
/// several known (MK, VID) vectors.
#[test]
fn vuk_from_mk_matches_inline_derive_vuk() {
let cases: [([u8; 16], [u8; 16]); 3] = [
([0x5A; 16], [0xA5; 16]),
([0x11; 16], [0x22; 16]),
(
[
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
0x0D, 0x0E, 0x0F,
],
[
0xF0, 0xE1, 0xD2, 0xC3, 0xB4, 0xA5, 0x96, 0x87, 0x78, 0x69, 0x5A, 0x4B, 0x3C,
0x2D, 0x1E, 0x0F,
],
),
];
for (mk, vid) in cases {
let inline = derive_vuk(&mk, &vid);
let boiled = vuk_from_mk(MediaKey(mk), Vid(vid));
assert_eq!(boiled.0, inline, "vuk_from_mk must equal derive_vuk");
}
}
/// `uk_from_vuk` must equal the inline `decrypt_unit_key` path bit-for-bit
/// and carry positional indices 0..n. Built by encrypting known plaintext
/// title keys under the VUK (the same primitive the resolver inverts).
#[test]
fn uk_from_vuk_matches_inline_decrypt_unit_key() {
let vuk = [0x5Au8; 16];
let plain_keys = [[0x11u8; 16], [0x22u8; 16], [0xCDu8; 16]];
let enc: Vec<[u8; 16]> = plain_keys
.iter()
.map(|k| aes_ecb_encrypt(&vuk, k))
.collect();
let boiled = uk_from_vuk(Vuk(vuk), &enc);
assert_eq!(boiled.len(), enc.len());
for (i, uk) in boiled.iter().enumerate() {
assert_eq!(uk.idx, i as u32, "idx must be the positional index");
// Matches the inline derive_uks closure: decrypt_unit_key(vuk, enc).
assert_eq!(uk.key, decrypt_unit_key(&vuk, &enc[i]));
// And recovers the original plaintext title key.
assert_eq!(
uk.key, plain_keys[i],
"VUK roundtrip recovers the title key"
);
}
}
/// `uk_from_vuk` on an empty slice yields no keys (no panic, no phantom idx).
#[test]
fn uk_from_vuk_empty_is_empty() {
assert!(uk_from_vuk(Vuk([0u8; 16]), &[]).is_empty());
}
/// `mk_from_dk` returns `Err(AacsMkUnavailable)` for the placeholder-KCD
/// path that production also leaves unreachable — never a wrong key, never a
/// panic — on both an empty MKB and a non-variant MKB.
#[test]
fn mk_from_dk_errors_without_integrator_kcd() {
let dk = DeviceKey {
key: [0x11; 16],
node: 1,
uv: 1,
u_mask_shift: 0,
};
// Empty MKB → not a variant MKB → Err.
let e = mk_from_dk(std::slice::from_ref(&dk), &[], Vid([0x09; 16]));
assert!(matches!(e, Err(crate::error::Error::AacsMkUnavailable)));
// A variant-looking MKB (0x82 record) still cannot complete without the
// integrator KCD, so it also errors — never silently yields a key.
let mut mkb: Vec<u8> = Vec::new();
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]); // variant data record
mkb.extend_from_slice(&[0xAB; 16]);
let e2 = mk_from_dk(&[dk], &mkb, Vid([0x09; 16]));
assert!(matches!(e2, Err(crate::error::Error::AacsMkUnavailable)));
}
/// Build a 4-byte MKB record header (type + 3-byte big-endian total length,
/// header included) and append `body`. Mirrors the MKB record framing the
/// parser expects; no crypto.
fn mkb_record(rec_type: u8, body: &[u8]) -> Vec<u8> {
let total = 4 + body.len();
let mut rec = Vec::with_capacity(total);
rec.push(rec_type);
rec.push(((total >> 16) & 0xFF) as u8);
rec.push(((total >> 8) & 0xFF) as u8);
rec.push((total & 0xFF) as u8);
rec.extend_from_slice(body);
rec
}
/// `mk_from_pk` resolves a planted Processing Key against a synthetic MKB and
/// drives the FULL boil chain PK → MK → VUK → UK. The MKB is built with the
/// same (pk, cv, mk_dv, uv) construction the production SD walk validates, so
/// this proves a PK entry yields real Unit Keys — not just an `Ok`.
#[test]
fn mk_from_pk_drives_full_chain_to_uks() {
let pk: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
let mk: [u8; 16] = [
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
0xAE, 0xAF,
];
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
// cv = AES-E(pk, mk_raw), where mk_raw is mk with the last-4-bytes-uv XOR
// pre-undone, so the validate step XORs uv back in and recovers mk.
let mut mk_raw = mk;
for a in 0..4 {
mk_raw[12 + a] ^= uv[a];
}
let cv = aes_ecb_encrypt(&pk, &mk_raw);
// mk_dv = AES-E(mk, magic||pad): AES-D(mk, mk_dv) starts with the AACS
// verify sentinel.
let mut vd = [0x11u8; 16];
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
let mk_dv = aes_ecb_encrypt(&mk, &vd);
// Synthetic MKB: type/version (0x10), verify record (0x86 = mk_dv),
// one-entry SD index (0x04 = [u_mask_shift=0][uv]), one-entry cvalue
// table (0x05 = cv).
let mut sd = vec![0u8];
sd.extend_from_slice(&uv);
let mut mkb = Vec::new();
mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
mkb.extend_from_slice(&mkb_record(0x86, &mk_dv));
mkb.extend_from_slice(&mkb_record(0x04, &sd));
mkb.extend_from_slice(&mkb_record(0x05, &cv));
// PK → MK.
let got_mk = mk_from_pk(std::slice::from_ref(&pk), &mkb).expect("planted PK resolves MK");
assert_eq!(got_mk, MediaKey(mk), "mk_from_pk recovers the planted MK");
// MK → VUK → UK over an encrypted title key.
let vid = Vid([0x42u8; 16]);
let plain_uk = [0x7Eu8; 16];
let vuk = vuk_from_mk(got_mk, vid);
let enc = aes_ecb_encrypt(&vuk.0, &plain_uk);
let uks = uk_from_vuk(vuk, std::slice::from_ref(&enc));
assert_eq!(uks.len(), 1);
assert_eq!(uks[0].key, plain_uk, "PK chain recovers the title key");
// A corrupt PK resolves nothing.
let mut bad = pk;
bad[0] ^= 0xFF;
assert!(matches!(
mk_from_pk(std::slice::from_ref(&bad), &mkb),
Err(crate::error::Error::AacsMkUnavailable)
));
}
}
+319 -62
View File
@@ -1,16 +1,18 @@
//! AACS content decryption — AES primitives, unit decryption, bus encryption.
//! AACS content decryption — aligned-unit / bus decryption and TS verification.
//! The low-level AES primitives it uses live in [`super::crypto`].
use aes::Aes128;
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray};
use super::crypto::{aes_cbc_decrypt, aes_ecb_encrypt};
// Available at module scope for this module's test fixtures (they reference
// `super::AACS_IV` when building CBC ciphertext directly); test-only.
#[cfg(test)]
use super::crypto::AACS_IV;
// ── AACS constants ──────────────────────────────────────────────────────────
/// Fixed IV used by AACS for all AES-CBC operations.
pub(crate) const AACS_IV: [u8; 16] = [
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
];
/// Size of an AACS aligned unit (3 × 2048-byte sectors).
/// Size of an AACS aligned unit (3 × 2048-byte sectors). [BD] §3.10.1.
pub const ALIGNED_UNIT_LEN: usize = 6144;
/// An AACS aligned unit spans this many 2048-byte sectors (3).
@@ -47,58 +49,6 @@ use crate::consts::BD_SOURCE_PACKET_BYTES;
/// TS sync byte.
const TS_SYNC: u8 = 0x47;
// ── AES primitives ──────────────────────────────────────────────────────────
/// AES-128-ECB encrypt a single 16-byte block.
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut block = GenericArray::clone_from_slice(data);
cipher.encrypt_block(&mut block);
let mut out = [0u8; 16];
out.copy_from_slice(&block);
out
}
/// AES-128-ECB decrypt a single 16-byte block.
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut block = GenericArray::clone_from_slice(data);
cipher.decrypt_block(&mut block);
let mut out = [0u8; 16];
out.copy_from_slice(&block);
out
}
/// AES-128-CBC decrypt in-place with the fixed AACS IV.
///
/// Precondition: `data.len()` is a multiple of 16. Any trailing partial
/// block is silently ignored; all callers pass aligned regions (6128 and
/// 2032 bytes), and the assert documents/enforces that contract.
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
debug_assert!(
data.len() % 16 == 0,
"aes_cbc_decrypt requires a block-aligned slice"
);
let cipher = Aes128::new(GenericArray::from_slice(key));
let num_blocks = data.len() / 16;
// Process blocks in reverse to avoid clobbering ciphertext needed for XOR
for i in (0..num_blocks).rev() {
let offset = i * 16;
let prev = if i == 0 {
AACS_IV
} else {
let mut p = [0u8; 16];
p.copy_from_slice(&data[(i - 1) * 16..i * 16]);
p
};
let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
cipher.decrypt_block(&mut block);
for j in 0..16 {
data[offset + j] = block[j] ^ prev[j];
}
}
}
// ── Content decryption ──────────────────────────────────────────────────────
/// True if a 6144-byte aligned unit's MPEG-TS sync structure is DESTROYED — it
@@ -127,7 +77,7 @@ pub fn ts_sync_destroyed(unit: &[u8]) -> bool {
}
/// The AUTHORITATIVE AACS "is this aligned unit encrypted?" signal — the Copy
/// Permission Indicator (CPI) in the top 2 bits of byte 0. Byte 0 is the first
/// Permission Indicator (CPI) in the top 2 bits of byte 0. [BD] §3.10.2. Byte 0 is the first
/// byte of the first source packet's `TP_extra_header`, which AACS always leaves
/// in the clear (the first 16 bytes of every unit are the unencrypted SEED). So
/// this is readable WITHOUT a key:
@@ -163,6 +113,101 @@ pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool {
aacs_unit_encrypted(unit) && ts_sync_destroyed(unit)
}
/// PADDING-AWARE "is this aligned unit STILL genuine ciphertext?" — the conceal-
/// path twin of [`decrypt_unit`]'s acceptance criterion, run on the POST-decrypt
/// bytes.
///
/// [`aacs_unit_needs_decrypt`] cannot answer this: it composes CPI with the
/// MAJORITY-VOTE [`ts_sync_destroyed`] (`ts_sync_count <= total/2`). A
/// successfully padding-aware-decrypted content-fragment TAIL unit (e.g. 11 of 32
/// packets are real content, the other 21 source-zero padding) carries only 11 TS
/// syncs after decrypt, so the majority vote calls it "destroyed" and
/// `aacs_unit_needs_decrypt` returns true — even though the unit decrypted
/// PERFECTLY. Concealing on that predicate overwrites the good decrypted tail with
/// NULL-TS, silently discarding correct video (the bug this fixes; the 1.2.0
/// fragment-tail fix in [`decrypt_unit`] must not be undone by the conceal loop).
///
/// The correct, padding-aware notion of "still ciphertext", checkable on the
/// post-decrypt bytes, uses the SAME discriminator [`decrypt_unit`] uses:
/// * A genuinely-FAILED unit was restored to on-disc ciphertext by
/// `decrypt_unit_try_keys`/`decrypt_buf` → its packets are scrambled: a
/// non-padding (non-zero payload) packet LACKS the `0x47` sync at offset 4.
/// * A SUCCESSFULLY-decrypted unit (full OR padding-tail) → every non-zero
/// (content) packet carries `0x47`; padding packets are all-zero.
///
/// So this is true iff `aacs_unit_encrypted` AND at least one 192-byte packet
/// whose 188-byte payload (`[off+4..off+192]`) is NOT all-zero is missing its
/// `0x47` sync at `off+4`. A full content unit (no zero-payload packets) reduces
/// to the strict all-32 check, so the common cases are unchanged: a fully-clear /
/// fully-decrypted unit is never flagged; a fully-ciphertext unit always is.
pub fn aacs_unit_still_ciphertext(unit: &[u8]) -> bool {
if !aacs_unit_encrypted(unit) {
return false;
}
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
let limit = ALIGNED_UNIT_LEN.min(unit.len());
let mut off = 0;
while off + PKT <= limit {
// A packet whose 188-byte payload is all-zero is padding (source zeros) —
// excluded from the verdict, exactly as `decrypt_unit` excludes it. Any
// other (content) packet that lacks its TS sync is un-restored ciphertext.
let payload = &unit[off + 4..off + PKT];
if !payload.iter().all(|&b| b == 0) && unit[off + 4] != TS_SYNC {
return true;
}
off += PKT;
}
false
}
/// Overwrite an aligned unit (6144 bytes) IN PLACE with valid NULL MPEG-TS
/// source packets — the [A2] mux loss-concealment fill for a content unit that
/// genuinely would not decrypt.
///
/// Zero-filling such a unit is wrong at the TS layer: a run of `0x00` bytes
/// carries no `0x47` sync, so the demuxer loses packet framing and can mis-parse
/// the *next* unit if a stray `0x47` appears mid-zero. Instead we lay down 32
/// well-formed BD source packets, each a TS null packet (PID `0x1FFF`) carrying
/// an adaptation-field **discontinuity_indicator**:
///
/// ```text
/// [4-byte TP_extra_header = 0][47 1F FF 20 B7 80 + 182 bytes 0xFF stuffing]
/// ^sync ^PID ^AF-only ^af_len=183 ^disc_indicator
/// ```
///
/// The demuxer stays byte-synced on the 192-byte stride, and because PID
/// `0x1FFF` matches no elementary stream every null packet is silently dropped.
/// The discontinuity_indicator is the B1 loss SIGNAL: `mux::ts` recognises a
/// `0x1FFF` packet with that bit set as a concealed gap and forces a discontinuity
/// on every tracked PID's next PES (the codec consumer then drops forward to the
/// next keyframe). This is CC-INDEPENDENT — unlike the real PID's continuity
/// counter it survives a loss that is an exact multiple of 16 packets, or a loss
/// at a PID's very start. NEVER emits ciphertext; lossless framing, not fabricated
/// content.
pub fn fill_null_ts_unit(unit: &mut [u8]) {
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
let mut off = 0;
while off + PKT <= unit.len() {
// TP_extra_header (arrival timestamp / copy-control) — zero is fine; the
// demuxer never reads it for a PID it does not track.
unit[off..off + 4].fill(0);
// 188-byte TS null packet: sync, PID 0x1FFF (no PUSI/TEI).
unit[off + 4] = TS_SYNC; // 0x47
unit[off + 5] = 0x1F; // PID high (top 5 bits of 0x1FFF, flags clear)
unit[off + 6] = 0xFF; // PID low
// adaptation_field_control = 0b10 (AF only, no payload), CC = 0.
unit[off + 7] = 0x20;
// adaptation_field_length = 183: the AF (its flags byte + 182 stuffing)
// fills the rest of the 188-byte packet.
unit[off + 8] = 0xB7;
// AF flags: discontinuity_indicator (0x80) — the concealed-gap signal.
unit[off + 9] = 0x80;
// Stuffing: 0xFF is the conventional adaptation-field fill.
unit[off + 10..off + PKT].fill(0xFF);
off += PKT;
}
}
/// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride
/// (offset 4 and every 192 bytes after — 4-byte TP_extra_header + 188-byte
/// TS packet). A clear or correctly-decrypted m2ts unit shows ~one per
@@ -258,7 +303,66 @@ pub fn unit_is_clean_ps(unit: &[u8]) -> bool {
/// Decryption restores the TS sync bytes, so the unit reads as clear afterward;
/// there is no flag to clear.
pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
decrypt_unit_checked(unit, unit_key, unit_is_clean_ts)
if unit.len() < ALIGNED_UNIT_LEN {
return false;
}
if !aacs_unit_encrypted(unit) {
return true; // CPI flag clear → plaintext, pass through untouched
}
// PADDING-AWARE acceptance. The question this answers is "did we read good,
// decryptable data?" — NOT "are all 32 packets present". A content fragment
// can END mid-unit, with the disc zero-padding the rest of the aligned unit
// to the next fragment. Such a tail unit is `[real encrypted packets][source
// zeros]`: the real packets decrypt perfectly, but the strict all-32
// `unit_is_clean_ts` would reject the whole unit over the padding tail and
// discard real video. AES ciphertext is high-entropy, so a SOURCE-zero packet
// (all 192 bytes zero before decrypt) can only be padding, never content.
//
// So: a packet whose SOURCE bytes are all zero is padding — excluded from the
// verify and emitted as clean zeros. Every other (content) packet must
// restore its TS sync. A full content unit has no zero-source packets, so this
// is byte-identical to the old all-32 check (no regression, no wrong-key
// hole). The discriminator between a legitimate short tail and a genuine
// misread is exactly this: a misread leaves the failing packets' SOURCE
// non-zero (real ciphertext that won't decrypt) → still rejected.
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
let npkt = ALIGNED_UNIT_LEN / PKT;
let mut pad = [false; ALIGNED_UNIT_LEN / PKT];
for (p, slot) in pad.iter_mut().enumerate().take(npkt) {
let off = p * PKT;
*slot = unit[off..off + PKT].iter().all(|&b| b == 0);
}
// Save original first 16 bytes (the plaintext seed / header) and derive the
// per-unit Block Key (identical to `decrypt_unit_checked`).
// Block Key = AES-128E(Kcu, seed) ⊕ seed ([BD] §3.10.1 Fig 3-8, two-node
// construction: encrypt the clear seed under the CPS Unit Key, then XOR the
// seed back in — the trailing ⊕seed is load-bearing).
let mut header = [0u8; 16];
header.copy_from_slice(&unit[..16]);
let derived = aes_ecb_encrypt(unit_key, &header);
let mut decrypt_key = [0u8; 16];
for i in 0..16 {
decrypt_key[i] = derived[i] ^ header[i];
}
// Final 6128 bytes of the aligned unit under the Block Key; first 16 = clear seed. [BD] §3.10.1.
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
// Verify content packets; zero out padding packets (their decrypted bytes are
// garbage from AES-decrypting zeros, but the source was zero so a clean zero
// fill is lossless and gives the demux a tidy gap instead of garbage).
for (p, &is_pad) in pad.iter().enumerate().take(npkt) {
let off = p * PKT;
if is_pad {
for b in unit[off..off + PKT].iter_mut() {
*b = 0;
}
} else if unit[off + 4] != TS_SYNC {
return false; // a real content packet failed → genuinely undecryptable
}
}
true
}
/// Decrypt an AACS aligned unit in place, accepting the key only when `accept`
@@ -433,7 +537,9 @@ pub fn decrypt_unit_full(
#[cfg(test)]
mod tests {
use super::super::crypto::aes_ecb_decrypt;
use super::*;
use aes::cipher::BlockEncrypt; // test fixtures build ciphertext directly
#[test]
fn test_aes_ecb_roundtrip() {
@@ -703,6 +809,101 @@ mod tests {
unit
}
// ── Padding-aware IsDecryptable (fragment-tail recovery) ───────────────
//
// A content fragment can end mid-unit, with the disc zero-padding the rest
// of the aligned unit to the next fragment (proven on Dunkirk: ~11 real
// video packets + source-zero pad). `decrypt_unit` must accept such a unit
// — its real packets decrypt; the source-zero tail is padding, not content
// — while still REJECTING a unit whose undecryptable tail is non-zero (a
// genuine misread / wrong key). The discriminator is the SOURCE bytes of the
// failing packets: zero ⇒ padding (still decryptable), non-zero ⇒ bad data.
/// Encrypt a full clear unit under `unit_key`, then overwrite the tail (from
/// packet `keep` onward) with `fill`. `0x00` models disc fragment padding; a
/// non-zero `fill` models a corrupt/misread tail.
fn tail_filled_unit(unit_key: &[u8; 16], keep_pkts: usize, fill: u8) -> Vec<u8> {
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, unit_key);
for b in unit[keep_pkts * BD_SOURCE_PACKET_BYTES..].iter_mut() {
*b = fill;
}
unit
}
#[test]
fn decryptable_full_content_unit_under_correct_key() {
let key = [0x5Au8; 16];
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &key);
assert!(
decrypt_unit(&mut unit, &key),
"full clean unit is decryptable"
);
assert_eq!(ts_sync_count(&unit), 32, "all 32 syncs restored");
}
#[test]
fn fragment_tail_with_source_zero_pad_is_decryptable() {
// 11 real content packets, then source-zero padding (the Dunkirk shape).
let key = [0x5Au8; 16];
let mut unit = tail_filled_unit(&key, 11, 0x00);
assert!(
decrypt_unit(&mut unit, &key),
"real prefix + source-zero pad IS decryptable"
);
for p in 0..11 {
assert_eq!(
unit[p * BD_SOURCE_PACKET_BYTES + 4],
TS_SYNC,
"content pkt {p} restored its sync"
);
}
// Padding emitted as clean zeros, not decrypted garbage.
for p in 11..32 {
let off = p * BD_SOURCE_PACKET_BYTES;
assert!(
unit[off..off + BD_SOURCE_PACKET_BYTES]
.iter()
.all(|&b| b == 0),
"padding pkt {p} zeroed"
);
}
}
#[test]
fn fragment_tail_with_nonzero_garbage_is_not_decryptable() {
// Same shape, but the tail is NON-zero — a genuine misread, not padding.
let key = [0x5Au8; 16];
let mut unit = tail_filled_unit(&key, 11, 0xC3);
assert!(
!decrypt_unit(&mut unit, &key),
"real prefix + non-zero garbage tail is NOT decryptable (misread)"
);
}
#[test]
fn wrong_key_full_unit_is_not_decryptable() {
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &[0x11u8; 16]);
assert!(
!decrypt_unit(&mut unit, &[0x22u8; 16]),
"wrong key on a full content unit is rejected"
);
}
#[test]
fn cpi_clear_unit_passes_through_decryptable() {
// CPI-clear (plaintext) unit: decryptable by definition, untouched.
let mut unit = clear_unit(); // byte 0 high bits clear
let before = unit.clone();
assert!(
decrypt_unit(&mut unit, &[0u8; 16]),
"clear unit passes through as decryptable"
);
assert_eq!(unit, before, "clear unit left untouched");
}
// ── AES-ECB KAT (FIPS-197 Appendix C.1) ────────────────────────────────
#[test]
@@ -1197,4 +1398,60 @@ mod tests {
// 6144 = 32 packets.
assert_eq!(ts_packet_total(&[0u8; ALIGNED_UNIT_LEN]), 32);
}
/// Direct coverage for the padding-aware conceal predicate. It must conceal
/// ONLY genuinely-undecryptable ciphertext — never a decrypted unit (full or
/// short padding-tail), a clear/non-encrypted unit, or an all-zero unit.
#[test]
fn aacs_unit_still_ciphertext_is_padding_aware() {
let pkt = BD_SOURCE_PACKET_BYTES;
// Build a 32-packet aligned unit. `cpi` sets the AACS CPI bits (byte 0).
// Per packet: b'S' = decrypted TS (0x47 sync + non-zero payload),
// b'C' = ciphertext (non-zero payload, no sync), b'P' = zero padding.
let build = |cpi: bool, kinds: &[u8]| {
let mut u = vec![0u8; ALIGNED_UNIT_LEN];
if cpi {
u[0] = 0xC0; // CPI bits in the packet-0 header (not the payload)
}
for (i, &k) in kinds.iter().enumerate() {
let off = i * pkt;
match k {
b'S' => {
u[off + 4] = TS_SYNC;
for b in &mut u[off + 5..off + pkt] {
*b = 0x10;
}
}
b'C' => {
// Scrambled: non-zero payload, no 0x47 at the sync position.
for b in &mut u[off + 4..off + pkt] {
*b = 0x5A;
}
}
_ => {} // b'P' → leave zero
}
}
u
};
// Not encrypted (CPI clear) → never concealed, even if it looks scrambled.
assert!(!aacs_unit_still_ciphertext(&build(false, &[b'C'; 32])));
// All-zero unit (CPI clear) → not encrypted → false.
assert!(!aacs_unit_still_ciphertext(&build(false, &[b'P'; 32])));
// Fully decrypted (all packets carry their sync) → false.
assert!(!aacs_unit_still_ciphertext(&build(true, &[b'S'; 32])));
// Fully ciphertext (no packet carries its sync) → true.
assert!(aacs_unit_still_ciphertext(&build(true, &[b'C'; 32])));
// Decrypted SHORT padding-tail: 11 content packets + 21 zero padding. The
// majority vote would mis-flag it (<16 syncs); the padding-aware predicate
// skips the zero padding and sees every non-zero packet has its sync.
let mut tail = [b'P'; 32];
for k in tail.iter_mut().take(11) {
*k = b'S';
}
assert!(
!aacs_unit_still_ciphertext(&build(true, &tail)),
"a decrypted short padding-tail must NOT be flagged as ciphertext"
);
}
}
+102
View File
@@ -0,0 +1,102 @@
//! AACS common cryptographic primitives — [C] Chapter 2 / §3.2.2.
//!
//! Source: `[C]` = AACS Introduction and Common Cryptographic Elements Book,
//! Rev 0.953. The shared low-level building blocks — AES-128 ECB E/D, AES-G,
//! the AES-G3 Triple Generator, AES-CBC decrypt — and their fixed constants
//! (`iv0`, `s0`). Used by every AACS generation; relocated here so the
//! primitives live in one place instead of being scattered across the
//! content / keys / variant modules.
use aes::Aes128;
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
/// Fixed IV used by AACS for all AES-CBC operations. [C] §2.1.2 (default CBC IV, `iv0`).
pub(crate) const AACS_IV: [u8; 16] = [
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
];
/// AES-128-ECB encrypt a single 16-byte block. [C] §2.1.1 (`AES-128E`).
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut block = GenericArray::clone_from_slice(data);
cipher.encrypt_block(&mut block);
let mut out = [0u8; 16];
out.copy_from_slice(&block);
out
}
/// AES-128-ECB decrypt a single 16-byte block. [C] §2.1.1 (`AES-128D`).
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut block = GenericArray::clone_from_slice(data);
cipher.decrypt_block(&mut block);
let mut out = [0u8; 16];
out.copy_from_slice(&block);
out
}
/// AES-128-CBC decrypt in-place with the fixed AACS IV. [C] §2.1.2 (`AES-128CBCD`).
///
/// Precondition: `data.len()` is a multiple of 16. Any trailing partial
/// block is silently ignored; all callers pass aligned regions (6128 and
/// 2032 bytes), and the assert documents/enforces that contract.
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
debug_assert!(
data.len() % 16 == 0,
"aes_cbc_decrypt requires a block-aligned slice"
);
let cipher = Aes128::new(GenericArray::from_slice(key));
let num_blocks = data.len() / 16;
// Process blocks in reverse to avoid clobbering ciphertext needed for XOR
for i in (0..num_blocks).rev() {
let offset = i * 16;
let prev = if i == 0 {
AACS_IV
} else {
let mut p = [0u8; 16];
p.copy_from_slice(&data[(i - 1) * 16..i * 16]);
p
};
let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
cipher.decrypt_block(&mut block);
for j in 0..16 {
data[offset + j] = block[j] ^ prev[j];
}
}
}
/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2. [C] §2.1.3 (note: uses AES-128**D**).
///
/// The Media Key Variant chain uses AES-G to derive both the variant
/// number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key
/// (`Kvu = AES-G(Km, VID)`). See [`super::derive::derive_vuk`] for the
/// classical VUK form — the math is identical, this exposes it as a
/// neutral primitive for the variant chain.
pub(crate) fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
let mut out = aes_ecb_decrypt(x1, x2);
for i in 0..16 {
out[i] ^= x2[i];
}
out
}
/// AACS-G3 seed constant (`s0`). [C] §3.2.2.
pub(crate) const AESG3_SEED: [u8; 16] = [
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
];
/// AACS-G3: derive a subkey from a parent key. [C] §3.2.2 (Triple AES Generator:
/// left=`D(k,s0)⊕s0` inc 0, pk=`D(k,s0+1)⊕(s0+1)` inc 1, right=`D(k,s0+2)⊕(s0+2)` inc 2).
/// seed[15] += inc, then AES-DEC(key, seed) XOR seed.
///
/// Shared with [`super::variant`] (its variant chain runs the same SD
/// tree); a single definition keeps the two walks byte-identical.
pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
let mut seed = AESG3_SEED;
seed[15] = seed[15].wrapping_add(inc);
let mut out = aes_ecb_decrypt(key, &seed);
for i in 0..16 {
out[i] ^= seed[i];
}
out
}
+690
View File
@@ -0,0 +1,690 @@
//! Media-key derivation: DK/PK → Media Key via the subset-difference tree.
//! [C] §3.2.2–§3.2.5.
use super::crypto::*;
use super::inf::*;
use super::mkb::*;
use super::types::*;
/// Derive Media Key from MKB data using processing keys.
///
/// A Processing Key is **terminal**: it is the key at its Subset-Difference
/// node, one `AES-G` from the Media Key. So this is the fast path — each PK is
/// tried *directly* against the MKB cvalue tables (no tree descent), matching
/// libaacs `_calc_mk_pks` (iterate PKs × cvalues). On a large AACS 2.x UHD MKB
/// (~181k cvalues) this is ~15x faster than treating a PK as a device-node
/// label and walking the tree.
///
/// If you hold a **device-node label** at unknown tree depth (not a terminal
/// PK), derive its Media Key through the device-key path
/// ([`derive_media_key_from_dk`]) — that path owns the Subset-Difference tree
/// walk; the PK path never descends.
///
/// MKB format:
/// Record type 0x10 = Type and Version Record (has MKB version)
/// Record type 0x81 = Verify Media Key Record, AACS 1.0 (has mk_dv)
/// Record type 0x86 = Verify Media Key Record, AACS 2.0/2.1 (has mk_dv)
/// Record type 0x04 = Subset-Difference Index (has UVS entries)
/// Record type 0x05 = Media Key Data Record (cvalues, 1:1 with 0x04)
/// Record type 0x07 = Explicit Subset-Difference Record (NOT cvalues)
pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> {
let mk_dv = mkb_find_mk_dv(mkb)?;
let uvs = mkb_find_subdiff_records(mkb)?;
let cvalues = mkb_find_cvalues(mkb)?;
try_pk_against_tables(processing_keys, &uvs, &cvalues, &mk_dv)
}
/// Core terminal-PK table scan over explicit record bodies. Each processing
/// key is tried **directly** against every `(uv, cvalue)` pair — no tree
/// descent. Reached in production via [`derive_media_key_from_pk`]; factored
/// out so reproduction harnesses can drive it with explicit tables.
pub(crate) fn try_pk_against_tables(
processing_keys: &[[u8; 16]],
uvs: &[u8],
cvalues: &[u8],
mk_dv: &[u8; 16],
) -> Option<[u8; 16]> {
let num_uvs = uvs
.chunks(5)
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
.count();
for pk in processing_keys {
for i in 0..num_uvs {
if (i + 1) * 16 > cvalues.len() {
continue;
}
let record_start = i * 5;
if record_start + 5 > uvs.len() {
continue;
}
let uv = &uvs[record_start + 1..record_start + 5];
let cv = &cvalues[i * 16..(i + 1) * 16];
if let Some(mk) = validate_processing_key(pk, cv, uv, mk_dv) {
return Some(mk);
}
}
}
None
}
/// Validate a processing key against a cvalue/UV pair.
/// Returns the Media Key if valid.
///
/// Steps (media key: [C] §3.2.4; verify relation: [C] §3.2.5.1.4):
/// 1. `mk = AES-128D(pk, cvalue)` [C] §3.2.4
/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the last 4 bytes only) [C] §3.2.4
/// 3. `dec_vd = AES-128D(mk, mk_dv)` [C] §3.2.5.1.4
/// 4. If `dec_vd[0..8] == 01 23 45 67 89 AB CD EF` → valid. [C] §3.2.5.1.4
pub(crate) fn validate_processing_key(
pk: &[u8; 16],
cvalue: &[u8],
uv: &[u8],
mk_dv: &[u8; 16],
) -> Option<[u8; 16]> {
if cvalue.len() < 16 || uv.len() < 4 {
return None;
}
// Step 1: mk = AES-128D(pk, cvalue)
let mut cv = [0u8; 16];
cv.copy_from_slice(&cvalue[..16]);
let mut mk = aes_ecb_decrypt(pk, &cv);
// Step 2: XOR uv into the last 4 bytes of mk (mk[12..16]).
for a in 0..4 {
mk[12 + a] ^= uv[a];
}
// Step 3 + 4: dec_vd = AES-128D(mk, mk_dv); verify magic.
let dec_vd = aes_ecb_decrypt(&mk, mk_dv);
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
if dec_vd[..8] == VERIFY_MAGIC {
return Some(mk);
}
None
}
/// Compute v_mask from a UV value. [C] §3.2.3. Shared with [`super::variant`].
pub(super) fn calc_v_mask(uv: u32) -> u32 {
let mut v_mask: u32 = 0xFFFF_FFFF;
while (uv & !v_mask) == 0 && v_mask != 0 {
v_mask <<= 1;
}
v_mask
}
/// Derive processing key from device key using subset-difference tree traversal.
/// [C] §3.2.4 (device-tree descent, MSB-branch, terminal PK). Shared with [`super::variant`].
pub(super) fn calc_pk_from_dk(
dk: &[u8; 16],
uv: u32,
v_mask: u32,
dev_key_v_mask: u32,
) -> [u8; 16] {
// Descend from the device node to the record node, following the record's
// `uv` bits. At each level only the child we descend INTO is needed (the
// sibling is computed but never used), and the Processing Key is the
// `aesg3(.,1)` of the FINAL node — so we derive ONE child per level and the
// PK once at the end, instead of left/pk/right at every level. Identical
// result, ~3x fewer block ops. (left child = `aesg3(node,0)`, right = `,2`.)
let mut node = *dk;
let mut current_v_mask = dev_key_v_mask;
// The subset-difference tree is at most 32 levels deep (u32 mask), so the
// walk must converge in <= 32 steps. The arithmetic `>> 1` sign-extends
// current_v_mask, so a v_mask coarser than dev_key_v_mask (reachable from
// a crafted/corrupt MKB) would otherwise saturate at 0xFFFF_FFFF and spin
// forever — bound the loop to keep a bad disc from hanging the rip thread.
let mut steps = 0u32;
while current_v_mask != v_mask {
if steps >= 32 {
break;
}
steps += 1;
// Find the highest unset bit in current_v_mask
let mut bit_pos: i32 = -1;
for i in (0..32).rev() {
if (current_v_mask & (1u32 << i)) == 0 {
bit_pos = i;
break;
}
}
let inc = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
0 // left child
} else {
2 // right child
};
node = aesg3(&node, inc);
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
}
aesg3(&node, 1)
}
/// Derive Media Key from MKB using device keys (subset-difference tree).
///
/// Thin wrapper over [`derive_media_key_and_pk_from_dk`] that drops the
/// intermediate Processing Key. Callers that need the PK lineage (e.g.
/// the key service banking DK·PK·MK) should call the `_and_pk_` form.
pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option<[u8; 16]> {
derive_media_key_and_pk_from_dk(mkb, device_keys).map(|(mk, _pk)| mk)
}
/// Derive both the Media Key and the intermediate Processing Key from an
/// MKB using device keys (subset-difference tree).
///
/// Identical walk to [`derive_media_key_from_dk`]; this form additionally
/// returns the Processing Key `Kp` derived at the matching subset-difference
/// node — the value `calc_pk_from_dk` produces immediately before it
/// validates into the Media Key. Returns `Some((mk, pk))` for the first DK
/// that walks a uv slot whose Processing Key validates against the MKB.
pub fn derive_media_key_and_pk_from_dk(
mkb: &[u8],
device_keys: &[DeviceKey],
) -> Option<([u8; 16], [u8; 16])> {
let mk_dv = mkb_find_mk_dv(mkb)?;
let uvs = mkb_find_subdiff_records(mkb)?;
let cvalues = mkb_find_cvalues(mkb)?;
// Count UV entries
let num_uvs = uvs
.chunks(5)
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
.count();
for dk in device_keys {
let device_number = dk.node as u32;
// Find applying subset-difference for this device
for uvs_idx in 0..num_uvs {
let p_uv = &uvs[1 + 5 * uvs_idx..];
let u_mask_shift = uvs[5 * uvs_idx]; // byte before the UV value
// `num_uvs` was computed via `take_while(.. c[0] & 0xC0 == 0)`, so
// every iterated slot already has its revoked-marker bits clear — no
// inner `& 0xC0` re-check is needed (it would be unreachable).
//
// Shifts of 32..=63 (0x20..=0x3F) have those bits clear but would
// panic in debug / wrap to a wrong mask in release. The MKB byte is
// disc-controlled, so a crafted/corrupt MKB must not crash the ripper:
// skip an out-of-range slot rather than `<<` it.
if u_mask_shift >= 32 {
continue;
}
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
if uv == 0 {
continue;
}
// u-mask = shift count of low-order 0 bits ([C] §3.2.5.1.5); v-mask [C] §3.2.3.
let u_mask: u32 = 0xFFFF_FFFF << u_mask_shift;
let v_mask = calc_v_mask(uv);
// Subset-difference applies iff (d&mu)==(uv&mu) && (d&mv)!=(uv&mv). [C] §3.2.4.
if ((device_number & u_mask) == (uv & u_mask))
&& ((device_number & v_mask) != (uv & v_mask))
{
// Found matching subset-difference — find the right device key.
// dk.u_mask_shift is a u8 from keydb with no range check;
// guard the shift the same way as the MKB byte above.
if dk.u_mask_shift >= 32 {
continue;
}
let dev_key_v_mask = calc_v_mask(dk.uv);
let dev_key_u_mask: u32 = 0xFFFF_FFFF << dk.u_mask_shift;
if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) {
// Derive processing key via tree traversal
let pk = calc_pk_from_dk(&dk.key, uv, v_mask, dev_key_v_mask);
// Validate and derive media key
if uvs_idx < cvalues.len() / 16 {
let cv = &cvalues[uvs_idx * 16..(uvs_idx + 1) * 16];
if let Some(mk) =
validate_processing_key(&pk, cv, &uvs[1 + uvs_idx * 5..], &mk_dv)
{
return Some((mk, pk));
}
}
}
}
}
}
None
}
/// Recover the subset-difference position (`node`, `uv`, `u_mask_shift`) of an
/// UNPOSITIONED device key by scanning a disc MKB. A device key alone (just the
/// 16 bytes) cannot be walked — the walk needs its tree node. This finds that
/// node empirically: for each MKB subset-difference record, it tries the device
/// at the record's node AND at every ancestor v-position (the device may sit one
/// or more levels ABOVE the record, descending via AES-G to reach it), deriving
/// the candidate Processing Key DIRECTLY (one [`calc_pk_from_dk`] per candidate,
/// no full re-walk) and checking it validates against that record's cvalue.
///
/// On the first verifying candidate it pins `(uv, u_mask_shift)` — invariant for
/// the key across all discs — and resolves a gate-passing `node` (a one-time
/// ≤32-try search at the single hit). Returns a [`DeviceKey`] ready to bank and
/// reuse on every future disc via [`derive_media_key_from_dk`]. `None` if the
/// key does not apply to this MKB.
///
/// Cost is `O(slots × tree_depth)` — linear in the MKB's subset-difference
/// index, not the quartic cost of re-deriving per candidate.
pub fn recover_dk_position(mkb: &[u8], key: &[u8; 16]) -> Option<DeviceKey> {
let mk_dv = mkb_find_mk_dv(mkb)?;
let uvs = mkb_find_subdiff_records(mkb)?;
let cvalues = mkb_find_cvalues(mkb)?;
let num_uvs = uvs
.chunks(5)
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
.count();
let n_cv = cvalues.len() / 16;
// Hoisted ONCE for the whole scan: the Processing Key the device produces if
// it sits EXACTLY at a record (zero descent) is `AES-G3(key, 1)` — it does
// not depend on the record, so the zero-descent probe of every slot reuses
// this single value instead of re-deriving it per slot.
let pk_zero_descent = aesg3(key, 1);
// The slots are independent, so the scan parallelises — a UHD MKB has ~181k
// slots (~26s single-threaded). `find_map_any` returns the first matching
// node found by any thread and cancels the rest; a valid MKB has exactly one
// matching subset-difference, so which thread finds it is immaterial.
use rayon::prelude::*;
let found = (0..num_uvs.min(n_cv)).into_par_iter().find_map_any(|i| {
let u_mask_shift = uvs[5 * i];
if u_mask_shift >= 32 {
return None;
}
let p_uv = &uvs[1 + 5 * i..];
let uv_r = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
if uv_r == 0 {
return None;
}
let v_mask = calc_v_mask(uv_r);
let cv = &cvalues[i * 16..(i + 1) * 16];
let uv_bytes = &uvs[1 + i * 5..];
// Zero descent (device sits at this slot's node): cheapest, most common.
if validate_processing_key(&pk_zero_descent, cv, uv_bytes, &mk_dv).is_some() {
return Some((uv_r, u_mask_shift));
}
// Descent: device is an ANCESTOR of the slot. Walk the depth bit up from
// the slot's lowest set bit; each level descends to the slot's node.
let p = uv_r.trailing_zeros();
for k in (p + 1)..32 {
let uv_d = if k + 1 >= 32 {
1u32 << k
} else {
(uv_r & (0xFFFF_FFFFu32 << (k + 1))) | (1u32 << k)
};
let pk = calc_pk_from_dk(key, uv_r, v_mask, calc_v_mask(uv_d));
if validate_processing_key(&pk, cv, uv_bytes, &mk_dv).is_some() {
return Some((uv_d, u_mask_shift));
}
}
None
});
found.and_then(|(uv, mask)| resolve_dk_node(mkb, key, uv, mask))
}
/// Resolve a positioned [`DeviceKey`] for an orphan `key` known to sit at
/// `(uv, u_mask_shift)`: find a `device_number` (node) that passes the walk's
/// subset-difference gate on `mkb`. The derived key is independent of the exact
/// node (it only gates), so any gating node yields the same Media Key — a
/// one-time ≤32-try search, run only once at the recovered position.
pub(crate) fn resolve_dk_node(
mkb: &[u8],
key: &[u8; 16],
uv: u32,
u_mask_shift: u8,
) -> Option<DeviceKey> {
for b in 0..u_mask_shift {
let dk = DeviceKey {
key: *key,
node: ((uv ^ (1u32 << b)) & 0xFFFF) as u16,
uv,
u_mask_shift,
};
if derive_media_key_from_dk(mkb, std::slice::from_ref(&dk)).is_some() {
return Some(dk);
}
}
// Degenerate MKB (no gating bit): fall back to the node itself.
Some(DeviceKey {
key: *key,
node: (uv & 0xFFFF) as u16,
uv,
u_mask_shift,
})
}
/// Public, side-effect-free accessors over the MKB record helpers, exposed so
/// independent reproduction harnesses (e.g. `examples/prove_hkd_aacs.rs`) can
/// exercise the exact same parser + verify primitives the production walk uses.
/// These are thin wrappers — no new logic.
#[doc(hidden)]
pub mod probe {
use super::super::crypto::aes_ecb_decrypt;
/// `mk_dv` from the MKB's Verify-Media-Key record (type 0x81 / 0x86).
pub fn mkb_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
super::mkb_find_mk_dv(mkb)
}
/// Body of the MKB's Subset-Difference Index record (type 0x04).
pub fn mkb_subdiff(mkb: &[u8]) -> Option<Vec<u8>> {
super::mkb_find_subdiff_records(mkb)
}
/// Body of the MKB's Media-Key-Data (cvalues) record. Selects record
/// `0x05` (the large cvalue table, 1:1 with the `0x04` Subset-Difference
/// index on AACS 2.x UHD MKBs), falling back to `0x07` only when `0x05`
/// is absent.
pub fn mkb_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
super::mkb_find_cvalues(mkb)
}
/// Body (header stripped) of the first MKB record of `rec_type`. Lets a
/// harness pin an exact record type for cross-checking the production
/// cvalue selection (e.g. compare record `0x05` vs `0x07` sizes).
pub fn mkb_record_body(mkb: &[u8], rec_type: u8) -> Option<Vec<u8>> {
super::find_record_body(mkb, rec_type)
}
/// AES-128-ECB single-block decrypt (the AACS verify primitive).
pub fn aes_dec(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] {
aes_ecb_decrypt(key, block)
}
/// Does `km` satisfy the MKB's Verify-Media-Key relation?
/// `AES-D(km, mk_dv)[0..8] == 01 23 45 67 89 AB CD EF`.
pub fn km_verifies(mkb: &[u8], km: &[u8; 16]) -> bool {
match super::mkb_find_mk_dv(mkb) {
Some(mk_dv) => {
aes_ecb_decrypt(km, &mk_dv)[..8] == [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]
}
None => false,
}
}
}
// ── Volume key: Media Key + Volume ID → VUK → unit keys ──────────────────────
/// Derive VUK from Media Key and Volume ID. [PR] §3.3 / [BD] §3.3
/// (`Kvu = AES-G(Km, IDv)`; AES-G uses AES-128D):
/// VUK = AES-128-ECB-DECRYPT(media_key, volume_id) XOR volume_id
pub fn derive_vuk(media_key: &[u8; 16], volume_id: &[u8; 16]) -> [u8; 16] {
let mut vuk = aes_ecb_decrypt(media_key, volume_id);
for i in 0..16 {
vuk[i] ^= volume_id[i];
}
vuk
}
/// Decrypt an encrypted unit key using the VUK (AES-128-ECB). [PR] §3.5
/// (Title Key unwrap `Kt = AES-128D(Ku, Kte)`); the BD "CPS Unit Key" synonym is [BD] §3.9.3.
pub fn decrypt_unit_key(vuk: &[u8; 16], encrypted_uk: &[u8; 16]) -> [u8; 16] {
aes_ecb_decrypt(vuk, encrypted_uk)
}
/// Decrypt every encrypted unit key in a parsed `Unit_Key_RO.inf` with a VUK,
/// paired with its declared CPS-unit number. THE single VUK→unit-keys step:
/// both classical/v21 resolvers and [`resolve_candidate`] call this, so the
/// map cannot drift between the player and harvest paths.
pub(crate) fn derive_unit_keys(uk_file: &UnitKeyFile, vuk: &[u8; 16]) -> Vec<(u32, [u8; 16])> {
uk_file
.encrypted_keys
.iter()
.map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key)))
.collect()
}
/// A candidate key at any rung of the AACS ladder, handed to [`resolve_candidate`].
///
/// Each variant carries the [`super::types`] newtype for that rung (a `Dk` is a
/// POSITIONED [`DeviceKey`] — recover an unpositioned one with
/// [`recover_dk_position`] first).
#[derive(Debug, Clone)]
pub enum KeyCandidate {
Uk(UnitKey),
Vuk(Vuk),
Mk(MediaKey),
Pk(ProcessingKey),
Dk(DeviceKey),
}
/// The AACS key chain derived from a candidate, from [`resolve_candidate`].
///
/// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every
/// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (paired with
/// its declared CPS-unit number); the caller runs
/// [`super::content::unit_key_validates`] to find which one actually opens the
/// disc. Rungs above the candidate are `None`.
#[derive(Debug, Clone)]
pub struct ResolvedChain {
pub unit_keys: Vec<(u32, [u8; 16])>,
pub vuk: Option<Vuk>,
pub mk: Option<MediaKey>,
pub pk: Option<ProcessingKey>,
/// The positioned device key (for a `Dk` candidate).
pub dk: Option<DeviceKey>,
}
/// Derive the full AACS key chain from a candidate key of ANY ladder rung.
///
/// Runs the deterministic derivation DOWNWARD to the disc's terminal unit keys:
/// `DK → MK → VUK → UKs`, `PK → MK → VUK → UKs`, `MK → VUK → UKs`,
/// `VUK → UKs`, or `UK → itself`. Composes the raw derivation primitives
/// ([`derive_media_key_from_pk`], [`derive_media_key_and_pk_from_dk`],
/// [`derive_vuk`], [`derive_unit_keys`]) and parses `Unit_Key_RO.inf` at the
/// version the disc's MKB declares, so a multi-CPS disc yields all its unit
/// keys from the one candidate.
///
/// PURE DERIVATION: no sampling, no validation, no position recovery. Validate
/// `unit_keys` against a real encrypted unit with
/// [`super::content::unit_key_validates`] to prove the candidate opens the disc.
///
/// Returns `None` only when derivation itself cannot proceed: a PK its MKB
/// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs
/// one, or an unparseable/empty `Unit_Key_RO.inf`.
pub fn resolve_candidate(
candidate: &KeyCandidate,
mkb: &[u8],
unit_key_ro: &[u8],
vid: Option<Vid>,
) -> Option<ResolvedChain> {
// Boil a VUK → all unit keys, each paired with its declared CPS-unit number.
// Derive the stride version from the disc's own MKB, then defer to the shared
// `derive_unit_keys` (the one place both resolvers and this path decrypt).
let boil = |vuk: Vuk| -> Option<Vec<(u32, [u8; 16])>> {
let version = mkb_type(mkb)
.map(|t| t.generation())
.unwrap_or(AacsVersion::V10);
let ukf = parse_unit_key_ro(unit_key_ro, version)?;
if ukf.encrypted_keys.is_empty() {
return None;
}
Some(derive_unit_keys(&ukf, &vuk.0))
};
match candidate {
KeyCandidate::Uk(uk) => Some(ResolvedChain {
unit_keys: vec![(uk.idx, uk.key)],
vuk: None,
mk: None,
pk: None,
dk: None,
}),
KeyCandidate::Vuk(v) => Some(ResolvedChain {
unit_keys: boil(*v)?,
vuk: Some(*v),
mk: None,
pk: None,
dk: None,
}),
KeyCandidate::Mk(mk) => {
let vuk = Vuk(derive_vuk(&mk.0, &vid?.0));
Some(ResolvedChain {
unit_keys: boil(vuk)?,
vuk: Some(vuk),
mk: Some(*mk),
pk: None,
dk: None,
})
}
KeyCandidate::Pk(pk) => {
let km = derive_media_key_from_pk(mkb, std::slice::from_ref(&pk.0))?;
let vuk = Vuk(derive_vuk(&km, &vid?.0));
Some(ResolvedChain {
unit_keys: boil(vuk)?,
vuk: Some(vuk),
mk: Some(MediaKey(km)),
pk: Some(*pk),
dk: None,
})
}
KeyCandidate::Dk(dk) => {
let (km, pk) = derive_media_key_and_pk_from_dk(mkb, std::slice::from_ref(dk))?;
let vuk = Vuk(derive_vuk(&km, &vid?.0));
Some(ResolvedChain {
unit_keys: boil(vuk)?,
vuk: Some(vuk),
mk: Some(MediaKey(km)),
pk: Some(ProcessingKey(pk)),
dk: Some(dk.clone()),
})
}
}
}
#[cfg(test)]
mod resolve_candidate_tests {
use super::*;
use crate::aacs::crypto::aes_ecb_encrypt;
/// Minimal AACS-1.0 (48-byte stride) `Unit_Key_RO.inf` with `n` encrypted
/// unit keys — `parse_unit_key_ro` numbers CPS units 1..=n.
fn synth_inf(encs: &[[u8; 16]]) -> Vec<u8> {
let uk_pos = 32usize;
let stride = 48usize;
let n = encs.len();
let total = uk_pos + 48 + n.saturating_sub(1) * stride + 16;
let mut inf = vec![0u8; total.max(20)];
inf[..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
inf[uk_pos..uk_pos + 2].copy_from_slice(&(n as u16).to_be_bytes());
for (i, k) in encs.iter().enumerate() {
let o = uk_pos + 48 + i * stride;
inf[o..o + 16].copy_from_slice(k);
}
inf
}
/// A VUK candidate boils to ALL the disc's unit keys, each paired with its
/// declared CPS-unit number, and each key equals the VUK-decrypt of its slot.
#[test]
fn resolve_candidate_vuk_returns_all_cps_units() {
let vuk = Vuk([0x33u8; 16]);
let encs = [[0x11u8; 16], [0x22u8; 16], [0x44u8; 16]];
let inf = synth_inf(&encs);
let r = resolve_candidate(&KeyCandidate::Vuk(vuk), &[], &inf, None).expect("vuk derives");
let cps: Vec<u32> = r.unit_keys.iter().map(|(c, _)| *c).collect();
assert_eq!(
cps,
vec![1, 2, 3],
"every CPS unit surfaced, numbered from the inf"
);
for ((_, key), enc) in r.unit_keys.iter().zip(encs.iter()) {
assert_eq!(
*key,
decrypt_unit_key(&vuk.0, enc),
"key = VUK-decrypt of its slot"
);
}
assert_eq!(r.vuk, Some(vuk));
assert!(r.mk.is_none() && r.pk.is_none() && r.dk.is_none());
}
/// A bare UK candidate is terminal — it returns itself keyed by its own idx.
#[test]
fn resolve_candidate_uk_is_itself() {
let uk = UnitKey {
idx: 2,
key: [0x9u8; 16],
};
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None).expect("uk is terminal");
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
assert!(r.vuk.is_none() && r.mk.is_none());
}
/// MK/PK/DK paths derive the VUK from a VID; without one, derivation stops.
#[test]
fn resolve_candidate_mk_requires_vid() {
let r = resolve_candidate(&KeyCandidate::Mk(MediaKey([1u8; 16])), &[], &[], None);
assert!(r.is_none(), "MK path returns None without a VID");
}
/// A planted Processing Key resolves against a synthetic MKB and drives the
/// FULL chain PK → MK → VUK → UK — proving a PK candidate yields real keys.
#[test]
fn resolve_candidate_pk_drives_full_chain() {
let pk: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
let mk: [u8; 16] = [
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
0xAE, 0xAF,
];
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
let mut mk_raw = mk;
for a in 0..4 {
mk_raw[12 + a] ^= uv[a];
}
let cv = aes_ecb_encrypt(&pk, &mk_raw);
let mut vd = [0x11u8; 16];
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
let mk_dv = aes_ecb_encrypt(&mk, &vd);
// 4-byte record header (type + BE24 total length) + body.
let rec = |t: u8, body: &[u8]| -> Vec<u8> {
let total = 4 + body.len();
let mut r = vec![
t,
((total >> 16) & 0xFF) as u8,
((total >> 8) & 0xFF) as u8,
(total & 0xFF) as u8,
];
r.extend_from_slice(body);
r
};
let mut sd = vec![0u8];
sd.extend_from_slice(&uv);
let mut mkb = Vec::new();
mkb.extend_from_slice(&rec(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
mkb.extend_from_slice(&rec(0x86, &mk_dv));
mkb.extend_from_slice(&rec(0x04, &sd));
mkb.extend_from_slice(&rec(0x05, &cv));
let vid = Vid([0x42u8; 16]);
let plain_uk = [0x7Eu8; 16];
let vuk = derive_vuk(&mk, &vid.0);
let enc = aes_ecb_encrypt(&vuk, &plain_uk);
let inf = synth_inf(std::slice::from_ref(&enc));
let r = resolve_candidate(&KeyCandidate::Pk(ProcessingKey(pk)), &mkb, &inf, Some(vid))
.expect("planted PK resolves the full chain");
assert_eq!(r.mk, Some(MediaKey(mk)), "PK recovers the planted MK");
assert_eq!(r.unit_keys.len(), 1);
assert_eq!(
r.unit_keys[0].1, plain_uk,
"PK chain recovers the title key"
);
}
}
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
//! Host-certificate collection — the one libfreemkv-side concern left from the
//! old in-tree AACS handshake. The cert mutual-auth itself now lives in the
//! `freemkv-unlock` AACS unlocker; libfreemkv only gathers the certs (a
//! keysource concern) and hands them across the seam.
/// Union the host certificates a scan can offer the drive: the explicit
/// `DriveCredentials`, then each key source's `host_certs(mkb)`. Host certs are
/// keysource-served, never compiled in. `mkb` lets a source pick a
/// generation-appropriate cert (the default impl ignores it).
pub fn collect_host_certs(
opts: &crate::disc::ScanOptions,
mkb: Option<u32>,
) -> Vec<crate::aacs::types::HostCert> {
let mut host_certs: Vec<crate::aacs::types::HostCert> = Vec::new();
if let Some(c) = &opts.credentials {
host_certs.extend(c.host_certs.iter().cloned());
}
for src in &opts.key_sources {
host_certs.extend(src.host_certs(mkb));
}
host_certs
}
+285
View File
@@ -0,0 +1,285 @@
//! AACS on-disc key-input files: `Unit_Key_RO.inf` parsing, the disc-hash
//! keydb lookup key, the Content Certificate, and the in-drive MKB read.
//! These turn raw disc files into the structures the key paths consume.
use super::mkb::*;
/// Parsed Unit_Key_RO.inf file.
#[derive(Debug)]
pub struct UnitKeyFile {
/// Disc hash (SHA1 of the entire file) — used as KEYDB lookup key
pub disc_hash: [u8; 20],
/// Application type (1 = BD-ROM)
pub app_type: u8,
/// Number of BDMV directories
pub num_bdmv_dir: u8,
/// Whether SKB MKB is used
pub use_skb_mkb: bool,
/// AACS generation this file's stride matches
pub version: AacsVersion,
/// Encrypted unit keys (CPS unit number, encrypted key)
pub encrypted_keys: Vec<(u32, [u8; 16])>,
/// Title → CPS unit index mapping (title_idx → unit_key_idx)
pub title_cps_unit: Vec<u16>,
}
/// Compute disc hash (SHA1 of Unit_Key_RO.inf content).
pub fn disc_hash(data: &[u8]) -> [u8; 20] {
use sha1::{Digest, Sha1};
let hash = Sha1::digest(data);
let mut out = [0u8; 20];
out.copy_from_slice(&hash);
out
}
/// Format disc hash as hex string with 0x prefix (for KEYDB lookup).
pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
let mut s = String::with_capacity(42);
s.push_str("0x");
for b in hash {
s.push_str(&format!("{b:02X}"));
}
s
}
/// Parse Unit_Key_RO.inf from raw bytes.
///
/// Format (from AACS spec):
/// [0..4] BE32: offset to key storage area (uk_pos)
/// [16] app_type (1 = BD-ROM)
/// [17] num_bdmv_dir
/// [18] bit 7: use_skb_mkb
/// [20..22] BE16: first_play CPS unit
/// [22..24] BE16: top_menu CPS unit
/// [24..26] BE16: num_titles
/// [26..] title entries: 2 bytes padding + 2 bytes CPS unit, × num_titles
///
/// Key storage at uk_pos:
/// [uk_pos..uk_pos+2] BE16: num_unit_keys
/// [uk_pos+48..] encrypted keys, 16 bytes each
/// AACS 1.0: 48-byte stride
/// AACS 2.0 / 2.1: 64-byte stride (48 + 16 extra)
pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
if data.len() < 20 {
return None;
}
let hash = disc_hash(data);
// Header
let app_type = data[16];
let num_bdmv_dir = data[17];
let use_skb_mkb = (data[18] >> 7) & 1 == 1;
// Key storage offset
let uk_pos = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
if uk_pos + 2 > data.len() {
return None;
}
// Number of unit keys
let num_uk = u16::from_be_bytes([data[uk_pos], data[uk_pos + 1]]) as usize;
if num_uk == 0 {
return Some(UnitKeyFile {
disc_hash: hash,
app_type,
num_bdmv_dir,
use_skb_mkb,
version,
encrypted_keys: Vec::new(),
title_cps_unit: Vec::new(),
});
}
// Stride between keys
let stride = version.unit_key_stride();
// Validate size
let keys_start = uk_pos + 48; // first key at uk_pos + 48
if keys_start + 16 > data.len() {
return None;
}
// Extract encrypted keys
let mut encrypted_keys = Vec::with_capacity(num_uk);
let mut pos = keys_start;
for i in 0..num_uk {
if pos + 16 > data.len() {
break;
}
let mut key = [0u8; 16];
key.copy_from_slice(&data[pos..pos + 16]);
encrypted_keys.push(((i + 1) as u32, key));
pos += stride;
}
// The loop above `break`s if the buffer runs out mid-key. A short list
// means the .inf is malformed/truncated — reject it rather than silently
// accepting fewer keys than the header declared, which would later map
// title CPS units to nonexistent keys.
if encrypted_keys.len() != num_uk {
return None;
}
// Title → CPS unit mapping. libaacs (unit_key.c) validates each on-disc CPS
// value is in `1..=num_uk` (else zeroes it) and converts the 1-based on-disc
// index to a 0-based key index. We mirror that so the stored value is a safe,
// ready-to-use key index rather than a raw 1-based number.
let to_key_idx = |cps: u16| -> u16 {
if cps >= 1 && cps as usize <= num_uk {
cps - 1
} else {
0
}
};
let mut title_cps_unit = Vec::new();
if data.len() >= 26 {
let first_play = u16::from_be_bytes([data[20], data[21]]);
let top_menu = u16::from_be_bytes([data[22], data[23]]);
let num_titles = u16::from_be_bytes([data[24], data[25]]) as usize;
title_cps_unit.push(to_key_idx(first_play));
title_cps_unit.push(to_key_idx(top_menu));
for i in 0..num_titles {
let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit
if off + 2 <= data.len() {
let cps = u16::from_be_bytes([data[off], data[off + 1]]);
title_cps_unit.push(to_key_idx(cps));
}
}
}
Some(UnitKeyFile {
disc_hash: hash,
app_type,
num_bdmv_dir,
use_skb_mkb,
version,
encrypted_keys,
title_cps_unit,
})
}
/// MKB disc structure format code.
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
/// MKB pack buffer size.
const MKB_PACK_SIZE: usize = 32772;
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
/// Returns the concatenated MKB data from all packs.
pub fn read_mkb_from_drive(
session: &mut dyn crate::scsi::ScsiTransport,
) -> crate::error::Result<Vec<u8>> {
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
let cdb = [
SCSI_READ_DISC_STRUCTURE,
0x01,
0x00,
0x00,
0x00,
0x00,
0x00,
MKB_DISC_STRUCTURE_FORMAT,
(MKB_PACK_SIZE >> 8) as u8,
(MKB_PACK_SIZE & 0xFF) as u8,
0x00,
0x00,
];
let mut buf = vec![0u8; 32772];
session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
if data_len < 2 {
return Ok(Vec::new());
}
let len = data_len - 2;
let num_packs = buf[3] as usize;
let mut mkb = Vec::with_capacity(32768 * num_packs.max(1));
if len > 0 && len <= 32768 {
mkb.extend_from_slice(&buf[4..4 + len]);
}
// Read remaining packs
for pack in 1..num_packs {
let mut cdb = [
SCSI_READ_DISC_STRUCTURE,
0x01,
0x00,
0x00,
0x00,
0x00,
0x00,
MKB_DISC_STRUCTURE_FORMAT,
(MKB_PACK_SIZE >> 8) as u8,
(MKB_PACK_SIZE & 0xFF) as u8,
0x00,
0x00,
];
// Pack number goes in address field
cdb[2] = ((pack >> 24) & 0xFF) as u8;
cdb[3] = ((pack >> 16) & 0xFF) as u8;
cdb[4] = ((pack >> 8) & 0xFF) as u8;
cdb[5] = (pack & 0xFF) as u8;
let mut buf = vec![0u8; 32772];
if session
.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
.is_ok()
{
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
if len > 2 && len - 2 <= 32768 {
mkb.extend_from_slice(&buf[4..4 + len - 2]);
}
}
}
Ok(mkb)
}
/// AACS Content Certificate — identifies disc AACS version and features.
#[derive(Debug)]
pub struct ContentCert {
/// Bus encryption enabled flag
pub bus_encryption: bool,
/// Content Certificate ID (6 bytes)
pub cc_id: [u8; 6],
/// AACS generation indicated by the certificate type byte.
///
/// Cert type `0x00` → [`AacsVersion::V10`]; any other value →
/// [`AacsVersion::V20`]. The certificate alone cannot distinguish
/// V20 from V21 — Variant detection happens after the MKB walk.
pub version: AacsVersion,
}
/// Parse a Content Certificate (ContentXXX.cer) file.
pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
if data.len() < 20 {
return None;
}
// Content Certificate layout (matches libaacs content_cert.c):
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
// [1] bit7 bus_encryption_enabled_flag (libaacs: `p[1] >> 7`)
// [14..20] cc_id (6 bytes) (libaacs: `p + 14`)
let version = if data[0] == 0x00 {
AacsVersion::V10
} else {
AacsVersion::V20
};
// The flag is bit 7 of byte 1, NOT bit 0. Reading bit 0 (the prior bug) made
// a bus-encrypted cert (byte1=0x80) read as `false`, defeating the
// AacsBusKeyUnavailable fail-loud gate in disc/encrypt.rs.
let bus_encryption = (data[1] >> 7) & 1 == 1;
let mut cc_id = [0u8; 6];
cc_id.copy_from_slice(&data[14..20]);
Some(ContentCert {
bus_encryption,
cc_id,
version,
})
}
+436
View File
@@ -0,0 +1,436 @@
//! AACS Media Key Block — [C] Chapter 3.
//!
//! The MKB record format (framing walker, the `MkbRecord` view, record-body
//! finders), the MKBType / AACS-generation classification, and MKB-file
//! utilities (content length, trimming, version). Consolidated here so the one
//! place that understands MKB bytes is `mkb`. Some duplicate record finders
//! still live side by side pending a follow-up that collapses them.
// ── MKB record types ([C] Chapter 3) ──────────────────────────────────────
// The ONE canonical set. Every record-type comparison in the `aacs` module
// references these, so a type byte is never a bare literal scattered across
// files (the `0x0c` variant-data record in particular used to appear in several
// hand-rolled forms).
/// Type-and-Version — carries the 32-bit MKBType / AACS generation.
pub(crate) const REC_TYPE_AND_VERSION: u8 = 0x10;
/// Subset-Difference index — the per-slot `(u_mask_shift, uv)` table.
pub(crate) const REC_SUBSET_DIFFERENCE: u8 = 0x04;
/// Media Key Data — the classical (1.0 / 2.0) per-subset cvalue table.
pub(crate) const REC_MEDIA_KEY_DATA: u8 = 0x05;
/// Explicit Subset-Difference — the smaller cvalue table some MKBs use.
pub(crate) const REC_EXPLICIT_SUBSET_DIFF: u8 = 0x07;
/// Media Key Variant Data (AACS 2.1) — the per-subset-difference `C` table
/// (one 16-byte C per slot); the `Kmp` step reads C from HERE, not `0x2d`.
pub(crate) const REC_MEDIA_KEY_VARIANT_DATA: u8 = 0x0c;
/// Variant Data + Nonce (AACS 2.1) — the `VARIANTS[uv]` table (leading bytes)
/// with the 16-byte `Kvn` Nonce at the tail.
pub(crate) const REC_VARIANT_DATA_AND_NONCE: u8 = 0x2d;
/// Variant Key Data table (AACS 2.1) — 65,535×16, indexed by the resolved VKD index.
pub(crate) const REC_VKD_TABLE: u8 = 0x2f;
/// Verify-Media-Key — AACS 1.0.
pub(crate) const REC_VERIFY_MEDIA_KEY_V1: u8 = 0x81;
/// Verify-Media-Key — AACS 2.x.
pub(crate) const REC_VERIFY_MEDIA_KEY_V2: u8 = 0x86;
/// A single MKB record produced by [`walk_mkb`].
#[derive(Debug, Clone)]
pub struct MkbRecord {
/// Byte offset of the record within the MKB.
pub offset: usize,
/// Record type byte.
pub rec_type: u8,
/// Record length in bytes (includes the 4-byte header).
pub rec_len: usize,
/// Record body (the bytes after the 4-byte header).
pub body: Vec<u8>,
}
/// Walk an MKB into a flat list of records.
///
/// MKB record framing per AACS: 1 byte type, 3 bytes BE length
/// INCLUDING the 4-byte header, followed by payload. The walker stops
/// at the first `(type=0, len=0)` end marker or at end of buffer.
pub fn walk_mkb(mkb: &[u8]) -> Vec<MkbRecord> {
mkb_records(mkb)
.map(|(offset, rec_type, rec_len)| MkbRecord {
offset,
rec_type,
rec_len,
body: mkb[offset + 4..offset + rec_len].to_vec(),
})
.collect()
}
/// THE single MKB record-framing walker: yields `(offset, rec_type, rec_len)`
/// for each record — a 4-byte header (type byte + big-endian 24-bit length)
/// then the body — stopping at the `00 000000` end marker or a
/// malformed/out-of-bounds length. Lazy (no body clone), so a find-one-record
/// caller never materialises the multi-MB cvalue table. [`walk_mkb`] and every
/// MKB record walk in `aacs::resolve`/`aacs::derive` are built on this, so the framing rules — and
/// any future fix to them — live in exactly one place (they had drifted across
/// six hand-rolled copies).
pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)> + '_ {
let mut pos = 0usize;
std::iter::from_fn(move || {
if pos + 4 > mkb.len() {
return None;
}
let rec_type = mkb[pos];
let rec_len = ((mkb[pos + 1] as usize) << 16)
| ((mkb[pos + 2] as usize) << 8)
| (mkb[pos + 3] as usize);
if rec_type == 0 && rec_len == 0 {
return None;
}
if rec_len < 4 || pos + rec_len > mkb.len() {
return None;
}
let here = pos;
pos += rec_len;
Some((here, rec_type, rec_len))
})
}
pub(crate) fn mkb_find_body(records: &[MkbRecord], rec_type: u8) -> Option<&[u8]> {
records
.iter()
.find(|r| r.rec_type == rec_type && !r.body.is_empty())
.map(|r| r.body.as_slice())
}
/// AACS protection generation a disc carries.
///
/// The content cert byte distinguishes V10 (`0x00`) from V20 (`0x01`). V21
/// cannot be detected from the cert alone — a V21 disc carries a V20 cert
/// and is upgraded to `V21` only after the MKB walk turns up the real Variant
/// records `0x2d` / `0x2f` (Encrypted Media Key Variant Data and the Variant
/// Key Data table).
///
/// Key-storage stride in `Unit_Key_RO.inf` is 48 bytes for V10 and 64
/// bytes for V20 / V21.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AacsVersion {
/// AACS 1.0 — original BD-ROM.
V10,
/// AACS 2.0 — UHD-BD, classical Media Key derivation.
V20,
/// AACS 2.1 — UHD-BD with Media Key Variant chain on top of V20.
V21,
}
/// AACS major version as the small integer threaded through the scan / key
/// paths (`AacsState.version`, `DiscInputs.version`, `DiscInputsCtx::new`):
/// 1 = AACS 1.0 (BD), 2 = AACS 2.x (UHD). Centralised so the bare `1`/`2` — and
/// the V10-vs-else stride choice it drives — lives in exactly one place.
pub const AACS_MAJOR_BD: u8 = 1;
pub const AACS_MAJOR_UHD: u8 = 2;
impl AacsVersion {
/// Stride (in bytes) between successive encrypted unit keys in
/// `Unit_Key_RO.inf`.
pub(crate) fn unit_key_stride(self) -> usize {
match self {
AacsVersion::V10 => 48,
AacsVersion::V20 | AacsVersion::V21 => 64,
}
}
/// This version as the major integer ([`AACS_MAJOR_BD`] / [`AACS_MAJOR_UHD`]).
pub fn major(self) -> u8 {
match self {
AacsVersion::V10 => AACS_MAJOR_BD,
AacsVersion::V20 | AacsVersion::V21 => AACS_MAJOR_UHD,
}
}
/// The version a bare major integer selects for stride purposes: only the
/// BD major is V10; every other value takes the V20/V21 64-byte stride.
pub fn from_major(major: u8) -> Self {
if major == AACS_MAJOR_BD {
AacsVersion::V10
} else {
AacsVersion::V20
}
}
}
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
/// 0x81: [C] §3.2.5.1.4. 0x86 (AACS 2.x): [libaacs] `mkb.c` — not in the public spec.
pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
// Verify-Media-Key record (0x81 for AACS 1.0, 0x86 for AACS 2.x): mk_dv is
// the 16 bytes at record offset 4 (body offset 0). Needs rec_len >= 20.
let found = mkb_records(mkb).find(|&(_, rt, len)| {
(rt == REC_VERIFY_MEDIA_KEY_V1 || rt == REC_VERIFY_MEDIA_KEY_V2) && len >= 20
});
match found {
Some((o, rec_type, rec_len)) => {
let mut dv = [0u8; 16];
dv.copy_from_slice(&mkb[o + 4..o + 20]);
tracing::debug!(
target: "freemkv::disc",
phase = "mkb_mk_dv_found",
rec_type,
pos = o,
rec_len,
"mk_dv extracted from MKB"
);
Some(dv)
}
None => {
tracing::warn!(
target: "freemkv::disc",
phase = "mkb_mk_dv_not_found",
"no 0x81/0x86 record with rec_len>=20 found"
);
None
}
}
}
/// Find Subset-Difference records (type 0x04) in MKB. [C] §3.2.5.1.5.
pub(crate) fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
find_record_body(mkb, 0x04)
}
/// Find the Media Key Data Record (cvalues table) in an MKB. [C] §3.2.4 / §3.2.5.1.7.
///
/// The cvalue table is record type `0x05` (Media Key Data) on BOTH AACS
/// 1.0 and AACS 2.x MKBs — its 16-byte cvalue entries are 1:1 with the
/// 5-byte Subset-Difference index entries in record `0x04`. This matches
/// libaacs, whose `mkb_cvalues()` reads `0x05` and `mkb_subdiff_records()`
/// reads `0x04`.
///
/// On AACS 2.x in-drive UHD MKBs the `0x05` table is large (the full
/// subset-difference cvalue set: ~181k entries on a retail MKB, 1:1 with
/// the giant `0x04` index), while record `0x07` (Explicit
/// Subset-Difference Record) is a much smaller structure (~96 entries) and
/// is NOT the cvalue table. An earlier version of this function preferred
/// `0x07`, which under-tested the Subset-Difference walk on UHD discs and
/// prevented the DK→walk path from ever finding the matching uv. The
/// selection MUST therefore be `0x05`-first; `0x07` is only a fallback for
/// malformed/legacy MKBs that somehow lack a `0x05` record.
pub(crate) fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
if let Some(body) = find_record_body(mkb, 0x05) {
return Some(body);
}
find_record_body(mkb, 0x07)
}
/// Walk an MKB and return the payload (header stripped) of the first
/// record matching `rec_type`. Returns `None` if no such record exists or
/// the record is empty.
pub(crate) fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
mkb_records(mkb)
.find(|&(_, rt, len)| rt == rec_type_wanted && len > 4)
.map(|(o, _, len)| mkb[o + 4..o + len].to_vec())
}
/// Real content length of an MKB: the byte offset where the record stream
/// ends. MKB files (especially `MKB_RW.inf`, but `MKB_RO.inf` too on some
/// discs) are allocated to a fixed size — often ~128 MiB — with the records at
/// the front and the rest zero padding. Walking records (type+len) and stopping
/// at the first padding byte (`type == 0` / zero-length / overrun) gives the
/// actual size so callers can trim off megabytes of zeros before sending or
/// archiving. Returns `mkb.len()` only if the whole buffer parsed as records.
pub fn mkb_content_len(mkb: &[u8]) -> usize {
// End of the last framed record = where the fixed-region zero padding begins.
// (The `00 000000` terminator / overrun stops the walk; real MKBs pad with
// zeros, so this matches the prior "stop at the first padding byte".)
mkb_records(mkb)
.last()
.map(|(o, _, len)| o + len)
.unwrap_or(0)
}
/// Trim an MKB's trailing fixed-region padding to its real content length —
/// but ONLY when [`mkb_content_len`] actually found one. It returns 0 for an
/// MKB whose first record cannot be parsed; truncating to 0 in that case would
/// hand downstream consumers (and the online key service) an EMPTY MKB that can
/// never resolve. So a 0 (or a length that isn't strictly inside the buffer)
/// leaves the MKB untouched. A 0.31.0 regression dropped this guard and
/// `truncate`-d unconditionally, zeroing unrecognised MKBs.
pub fn trim_mkb(mut mkb: Vec<u8>) -> Vec<u8> {
let n = mkb_content_len(&mkb);
if n > 0 && n < mkb.len() {
mkb.truncate(n);
}
mkb
}
/// Get MKB version from Type and Version Record (type 0x10).
/// Layout: 4-byte record header at `pos` (type + BE24 length), then the
/// record body starts at `pos + 4`. The body holds the BE u32 Type field at
/// body offset 0 (`pos + 4`), then the BE u32 version at body offset 4
/// (`pos + 8`).
pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
// Type-and-Version record (0x10): version is the BE u32 at body offset 4
// (record offset 8). Needs rec_len >= 12 (4 header + 4 type + 4 version).
mkb_records(mkb)
.find(|&(_, rt, len)| rt == REC_TYPE_AND_VERSION && len >= 12)
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 8], mkb[o + 9], mkb[o + 10], mkb[o + 11]]))
}
/// `0x00031003` — recordable media MKB (Class I & II compute Km directly).
pub const MKB_TYPE_3_RECORDABLE: u32 = 0x0003_1003;
/// `0x00041003` — AACS 1.0 pre-recorded content MKB (KCD-based). Standard BD.
pub const MKB_TYPE_4_PRERECORDED: u32 = 0x0004_1003;
/// `0x000A1003` — Class II / Unified MKB (Sequence-Key-Block functionality).
pub const MKB_TYPE_10_CLASS_II: u32 = 0x000A_1003;
/// `0x48141003` — AACS 2.0 Category C (UHD content). libaacs `MKB_20_CATEGORY_C`.
pub const MKB_20_CATEGORY_C: u32 = 0x4814_1003;
/// `0x48151003` — AACS 2.1 Category C (UHD content). libaacs `MKB_21_CATEGORY_C`.
pub const MKB_21_CATEGORY_C: u32 = 0x4815_1003;
/// The AACS MKB Type field, decoded.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MkbType {
/// Type 3 — recordable media.
Recordable,
/// Type 4 — AACS 1.0 pre-recorded content (KCD). Standard Blu-ray.
Prerecorded,
/// Type 10 — Class II / Unified (SKB).
ClassII,
/// AACS 2.0 Category C — UHD content.
CategoryC20,
/// AACS 2.1 Category C — UHD content.
CategoryC21,
/// Unrecognized MKBType value (raw field preserved).
Other(u32),
}
impl MkbType {
pub(crate) fn from_raw(raw: u32) -> Self {
match raw {
MKB_TYPE_3_RECORDABLE => MkbType::Recordable,
MKB_TYPE_4_PRERECORDED => MkbType::Prerecorded,
MKB_TYPE_10_CLASS_II => MkbType::ClassII,
MKB_20_CATEGORY_C => MkbType::CategoryC20,
MKB_21_CATEGORY_C => MkbType::CategoryC21,
other => MkbType::Other(other),
}
}
/// AACS generation this MKB belongs to (Category C → 2.0/2.1, else 1.0).
pub fn generation(self) -> AacsVersion {
match self {
MkbType::CategoryC21 => AacsVersion::V21,
MkbType::CategoryC20 => AacsVersion::V20,
_ => AacsVersion::V10,
}
}
/// `true` for UHD (AACS 2.x Category C); `false` for Blu-ray (AACS 1.x).
pub fn is_uhd(self) -> bool {
matches!(self, MkbType::CategoryC20 | MkbType::CategoryC21)
}
}
/// The raw 32-bit MKBType field from the Type-and-Version record (0x10), bytes
/// 4-7. `None` if no 0x10 record is present. [C] §3.2.5.1.1 Table 3-2.
pub fn mkb_type_raw(mkb: &[u8]) -> Option<u32> {
// Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body
// offset 0). Needs rec_len >= 8 (4 header + 4 type).
mkb_records(mkb)
.find(|&(_, rt, len)| rt == REC_TYPE_AND_VERSION && len >= 8)
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 4], mkb[o + 5], mkb[o + 6], mkb[o + 7]]))
}
/// Decode an MKB's Type field. `None` if no Type-and-Version record is present.
pub fn mkb_type(mkb: &[u8]) -> Option<MkbType> {
mkb_type_raw(mkb).map(MkbType::from_raw)
}
/// `Some(true)` if this MKB is a UHD (AACS 2.x Category C) block, `Some(false)`
/// for Blu-ray (AACS 1.x), `None` if the Type record is absent.
pub fn mkb_is_uhd(mkb: &[u8]) -> Option<bool> {
mkb_type(mkb).map(MkbType::is_uhd)
}
#[cfg(test)]
mod tests {
use super::*;
/// One MKB record: 1 type byte + big-endian 24-bit total length + body.
fn rec(rec_type: u8, body: &[u8]) -> Vec<u8> {
let len = 4 + body.len();
let mut v = vec![rec_type, (len >> 16) as u8, (len >> 8) as u8, len as u8];
v.extend_from_slice(body);
v
}
/// Type-and-Version record (0x10): body = 4-byte MKBType + 4-byte version.
fn type_and_version(mkb_type: u32, version: u32) -> Vec<u8> {
let mut body = mkb_type.to_be_bytes().to_vec();
body.extend_from_slice(&version.to_be_bytes());
rec(REC_TYPE_AND_VERSION, &body)
}
#[test]
fn walker_frames_records_and_stops_at_end_marker() {
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 77);
mkb.extend(rec(REC_VKD_TABLE, &[0xAA; 16]));
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
mkb.extend(rec(0x99, &[0xFF; 8])); // must NOT be walked (past the marker)
let recs = walk_mkb(&mkb);
assert_eq!(recs.len(), 2, "walk stops at the 00 000000 end marker");
assert_eq!(recs[0].rec_type, REC_TYPE_AND_VERSION);
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
assert_eq!(recs[1].body, vec![0xAA; 16]);
}
#[test]
fn walker_stops_on_malformed_or_out_of_bounds_length() {
// A record whose declared length runs past the buffer end must terminate
// the walk rather than panic or read OOB.
let mkb = vec![REC_VKD_TABLE, 0x00, 0xFF, 0xFF, 0x01, 0x02]; // len=0xFFFF, only 6 bytes
assert!(
walk_mkb(&mkb).is_empty(),
"over-long record yields no records"
);
// A sub-4 length (shorter than the header itself) is also rejected.
let short = vec![REC_VKD_TABLE, 0x00, 0x00, 0x02];
assert!(walk_mkb(&short).is_empty(), "sub-4 length is rejected");
// A truncated header (< 4 bytes) yields nothing.
assert!(walk_mkb(&[0x10, 0x00]).is_empty());
}
#[test]
fn mkb_type_and_version_decode_from_the_type_record() {
let mut mkb = type_and_version(MKB_21_CATEGORY_C, 100);
mkb.extend([0x00, 0x00, 0x00, 0x00]);
assert_eq!(mkb_type_raw(&mkb), Some(MKB_21_CATEGORY_C));
assert_eq!(mkb_version(&mkb), Some(100));
assert_eq!(mkb_is_uhd(&mkb), Some(true), "2.1 Category C is UHD");
let bd = type_and_version(MKB_TYPE_4_PRERECORDED, 68);
assert_eq!(
mkb_is_uhd(&bd),
Some(false),
"AACS 1.0 prerecorded is not UHD"
);
// No Type record → None (not a panic, not a fabricated value).
assert_eq!(mkb_version(&rec(REC_VKD_TABLE, &[0; 16])), None);
assert_eq!(mkb_type_raw(&[]), None);
}
#[test]
fn trim_mkb_keeps_only_the_framed_records() {
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 1);
let content_len = mkb.len(); // the single framed record, no end marker
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
mkb.extend([0xDE; 4096]); // trailing padding past the end marker
let trimmed = trim_mkb(mkb);
assert_eq!(
trimmed.len(),
content_len,
"trim keeps the framed records, dropping the end marker and padding"
);
}
}
+63 -55
View File
@@ -13,59 +13,67 @@
//!
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
//! Title keys decrypt m2ts stream content (AES-128-CBC).
//!
//! ## Spec provenance
//!
//! The crypto below carries `[TAG] §x.y` citations back to the published AACS
//! specification (Final Rev 0.953), so each primitive links to the section it
//! implements:
//! - `[C]` — AACS Introduction and Common Cryptographic Elements Book (primitives, MKB/key-management).
//! - `[PR]` — AACS Pre-recorded Video Book (Volume/Title Key layer).
//! - `[BD]` — AACS Blu-ray Disc Pre-recorded Book (CPS Unit Key, Aligned Unit, Block Key).
//! - `[libaacs]` — the libaacs reference implementation, cited only where the spec
//! is silent (the `0x86` verify record and the Category-C MKBType names).
pub mod boil;
pub mod decrypt;
pub mod handshake;
pub mod keys;
pub mod content;
pub mod crypto;
pub mod derive;
pub mod host_certs;
pub mod inf;
pub mod mkb;
pub mod provider;
pub mod resolve;
pub mod segment;
pub mod segment_key;
pub mod trace;
pub mod types;
pub mod variants;
pub mod variant;
// Boil-down derivation primitives (thin newtypes + wrappers over the crypto).
pub use boil::{MediaKey, UnitKey, Vid, Vuk, mk_from_dk, mk_from_pk, uk_from_vuk, vuk_from_mk};
// Structured, English-free resolution trace.
pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, UnlockStep};
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
/// `read_mkb_content`, `read_aacs_version`) walks the exact same files — adding
/// or changing a fallback in one place can then never silently diverge the
/// disc_hash / MKB / VID that another reader feeds a key service.
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf";
pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
pub use decrypt::{
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted,
aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, decrypt_unit_checked, decrypt_unit_full,
decrypt_unit_try_keys, is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed,
unit_is_clean_ps, unit_is_clean_ts, unit_key_validates,
};
// `probe` is a reproduction-harness helper (see keys.rs), not part of the
// documented 1.0 surface; keep it reachable but off the rendered docs so we
// don't commit semver stability to test primitives.
#[doc(hidden)]
pub use keys::probe;
pub use keys::{
AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C, MKB_TYPE_3_RECORDABLE,
MKB_TYPE_4_PRERECORDED, MKB_TYPE_10_CLASS_II, MkbType, ResolveContext, ResolveFailure,
ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_and_pk_from_dk,
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
mkb_content_len, mkb_is_uhd, mkb_type, mkb_type_raw, mkb_version, parse_content_cert,
parse_unit_key_ro, read_mkb_from_drive, recover_dk_position, resolve_keys_v1, resolve_keys_v2,
resolve_keys_v21, resolve_keys_with_reason, trim_mkb,
};
pub use provider::KeyProvider;
pub use types::{DeviceKey, DiscEntry, HostCert};
pub use variants::{
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
derive_media_key_variant, is_variant_mkb, variant_nonce, walk_mkb, walk_processing_key,
};
// The module structure IS the public API — consumers import from the owning
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
// `aacs::derive::{derive_vuk, resolve_candidate}`, `aacs::resolve::resolve_keys_v2`).
// The `derive::probe` reproduction harness stays reachable via its module path.
//
// A small set of flat re-exports is kept for the typed key primitives and the
// content-decrypt entry points that downstream key-source crates import through
// the `aacs::` path. These are the stable, load-bearing names; keeping them here
// lets those crates track the module refactor without a lockstep re-pin.
pub use content::{ALIGNED_UNIT_LEN, decrypt_unit_try_keys};
pub use derive::derive_vuk;
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
#[cfg(test)]
mod tests {
//! Re-export surface guards. The module's public API is the set of
//! `pub use` items above. A regression that drops or renames an export
//! (the class of bug that shipped in 0.31.0 by silently changing a
//! surface) breaks compilation of these references, so they act as a
//! compile-time contract for the crate's AACS surface.
//! Surface guards. The public API is the module tree itself (no facade).
//! Touching one representative item per module keeps these as a
//! compile-time contract that the module paths stay stable.
use super::*;
use super::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
use super::inf::{disc_hash, disc_hash_hex};
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
use super::variant::is_variant_mkb;
#[test]
fn aligned_unit_len_is_three_2048_byte_sectors() {
@@ -87,21 +95,21 @@ mod tests {
}
#[test]
fn key_correction_data_placeholder_is_all_zero() {
// The variant chain refuses to run against this all-zero placeholder
// KCD; the public constant must therefore be exactly 16 zero bytes.
assert_eq!(KEY_CORRECTION_DATA_PLACEHOLDER, [0u8; 16]);
}
#[test]
fn public_helpers_are_callable_through_the_facade() {
// Touch a representative function from each re-export group so a
// dropped/renamed export fails to compile. These are smoke calls, not
// behavioural assertions (behaviour is covered in each module).
fn public_helpers_are_callable_by_module_path() {
// Touch a representative function from each module so a dropped/renamed
// item fails to compile. Smoke calls, not behavioural assertions.
let _ = ts_sync_destroyed(&[0u8; ALIGNED_UNIT_LEN]);
let _ = mkb_content_len(&[]);
let _ = is_variant_mkb(&walk_mkb(&[]));
let _ = disc_hash_hex(&disc_hash(b"x"));
let _ = mk_from_pk(&[[0u8; 16]], &[]);
let _ = super::derive::resolve_candidate(
&super::derive::KeyCandidate::Uk(super::types::UnitKey {
idx: 0,
key: [0u8; 16],
}),
&[],
&[],
None,
);
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ use super::types::{DeviceKey, DiscEntry, HostCert};
/// Source of AACS key material.
///
/// Implementors return raw material only — the resolver in
/// `aacs::keys` owns all the crypto (DK→PK walking, PK validation,
/// `aacs::resolve` and `aacs::derive` own the crypto (DK→PK walking, PK validation,
/// MK→VUK→TK derivation). See module docs for method semantics.
pub trait KeyProvider: Send + Sync {
/// Device keys (top-of-tree, walked by the resolver).
File diff suppressed because it is too large Load Diff
+273
View File
@@ -0,0 +1,273 @@
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
//!
//! An FMTS main feature interleaves N "variant" segments — the sequence-key /
//! forensic-watermark mechanism. The same frames are authored as several
//! slightly different variants; each variant is encrypted under its own SEGMENT
//! key (from `SegmentKeyNNNNN.tbl`), NOT the CPS Unit Key. A player with the
//! right device keys can decrypt exactly one variant per segment, and which one
//! silently identifies the player (traitor tracing). Decrypting a variant
//! segment with the Unit Key yields garbage — broken HEVC reference frames
//! (empirically: `Could not find ref with POC …` on a plain unit-key rip).
//!
//! This table says WHERE the variant segments live so a decoder can decrypt
//! them with segment keys and select one coherent variant instead of muxing
//! unit-key garbage.
//!
//! Format (validated against a retail AACS 2.1 disc):
//! ```text
//! header (8 bytes): u32 type | u16 count | u16 record_size (= 16)
//! record[count] (16 bytes each):
//! u32 marker (= 0x01000000) | u16 segment_number | u16 flag (= 1)
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
//! ```
//! Source-packet numbers are the 192-byte BDAV packet index: byte offset =
//! `spn * 192`. Observed on one disc: 792 segments, each ~2560 packets
//! (~480 KB), spread across the entire 54 GB feature (one roughly every 67 MB).
/// Fixed size of one `IndividualSegment.tbl` record.
pub const SEGMENT_RECORD_LEN: usize = 16;
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
pub const SOURCE_PACKET_LEN: u64 = 192;
/// Whether a 2.1 (FMTS) disc may rip WITHOUT segment (variant) keys.
///
/// `true` (today): the forensic variant segments are skipped as expected loss
/// and the bulk of the title decodes with the unit key, so a 2.1 disc rips
/// mostly-complete. A unit key (VUK) is still required, exactly as for any AACS
/// disc. `false`: the absence of a segment-key source is a hard, UPFRONT failure
/// ([`Error::FmtsKeyMissing`]) — the same policy as a missing unit key, so a
/// forensic-holed rip is refused rather than produced. No segment-key source
/// exists yet, so `true` is the only value under which a 2.1 disc rips at all;
/// flip to `false` once segment keys can be sourced and a partial rip should be
/// refused. Hardcoded on purpose — not a user setting.
///
/// [`Error::FmtsKeyMissing`]: crate::error::Error::FmtsKeyMissing
pub const BYPASS_FMTS_KEY: bool = true;
/// One forensic variant segment: the inclusive source-packet range it occupies
/// in the FMTS clip.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Segment {
/// 1-based segment number (table order).
pub number: u16,
/// First source packet of the segment (inclusive).
pub start_spn: u32,
/// Last source packet of the segment (inclusive).
pub end_spn: u32,
}
impl Segment {
/// Source-packet count in this (inclusive) segment.
pub fn packet_count(&self) -> u32 {
self.end_spn
.saturating_sub(self.start_spn)
.saturating_add(1)
}
/// Byte offset of the segment start within the clip (`start_spn * 192`).
pub fn start_byte(&self) -> u64 {
self.start_spn as u64 * SOURCE_PACKET_LEN
}
/// Byte length of the segment (`packet_count * 192`).
pub fn byte_len(&self) -> u64 {
self.packet_count() as u64 * SOURCE_PACKET_LEN
}
/// True when source packet `spn` falls inside this segment.
pub fn contains_spn(&self, spn: u32) -> bool {
spn >= self.start_spn && spn <= self.end_spn
}
/// True when the inclusive source-packet span `[first, last]` overlaps this
/// segment. Used to decide whether an aligned unit (which spans several
/// packets) touches the segment at all, not just whether one packet does.
pub fn overlaps_spn(&self, first: u32, last: u32) -> bool {
first <= self.end_spn && last >= self.start_spn
}
}
/// Source packets spanned by one AACS aligned unit: `6144 / 192 = 32`.
pub const PACKETS_PER_UNIT: u32 =
(crate::aacs::content::ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32;
/// Byte offset within the clip of a clip-relative 2048-byte sector `lba`. The
/// FMTS decode reads the clip file directly, so `lba` 0 is the clip's first
/// byte and this offset lines up with the source-packet grid the segment map
/// uses.
pub fn lba_byte_offset(lba: u32) -> u64 {
lba as u64 * 2048
}
/// The forensic segment an AACS aligned unit belongs to, if any, given the
/// unit's clip-relative byte offset.
///
/// This is the routing decision behind a 2.1 decrypt-miss: a unit that
/// overlaps a forensic segment must be opened with that segment's **variant
/// key** (from `SegmentKeyNNNNN.tbl`), not the CPS Unit Key. Opening it with
/// the Unit Key is exactly what yields the broken-reference-frame garbage a
/// plain unit-key rip produces. A unit outside every segment is ordinary
/// content and a miss on it is a Unit-Key miss, so this returns `None` and the
/// caller falls back to the normal unit-key fetch.
///
/// The unit is tested as a packet *span* (`[off/192, (off+6144-1)/192]`) so a
/// unit that only partly overlaps a segment edge is still classified as
/// variant; on the observed disc segments are unit-aligned, but the span test
/// does not rely on that.
pub fn variant_segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> {
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
let first = (unit_offset / SOURCE_PACKET_LEN) as u32;
let last = ((unit_offset + unit_len - 1) / SOURCE_PACKET_LEN) as u32;
segments.iter().find(|s| s.overlaps_spn(first, last))
}
/// Parse `IndividualSegment.tbl` into its forensic variant segments, in table
/// order. Returns `None` when the header is malformed, the record size is not
/// [`SEGMENT_RECORD_LEN`], or the declared record count overruns the buffer —
/// so a truncated / foreign table degrades to "no segment map" rather than
/// yielding bogus ranges.
pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
if tbl.len() < 8 {
return None;
}
let count = u16::from_be_bytes([tbl[4], tbl[5]]) as usize;
let record_size = u16::from_be_bytes([tbl[6], tbl[7]]) as usize;
if record_size != SEGMENT_RECORD_LEN {
return None;
}
if 8usize.checked_add(count.checked_mul(record_size)?)? > tbl.len() {
return None;
}
let mut segments = Vec::with_capacity(count);
for i in 0..count {
let o = 8 + i * record_size;
// o+4..o+8 = segment_number (u16) + flag (u16); o+8..o+16 = start/end SPN.
let number = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
let start_spn = u32::from_be_bytes([tbl[o + 8], tbl[o + 9], tbl[o + 10], tbl[o + 11]]);
let end_spn = u32::from_be_bytes([tbl[o + 12], tbl[o + 13], tbl[o + 14], tbl[o + 15]]);
segments.push(Segment {
number,
start_spn,
end_spn,
});
}
Some(segments)
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
/// records. `recs` are `(segment_number, start_spn, end_spn)`.
fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
v.extend_from_slice(&(recs.len() as u16).to_be_bytes()); // count
v.extend_from_slice(&(SEGMENT_RECORD_LEN as u16).to_be_bytes()); // record_size
for &(n, s, e) in recs {
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // marker
v.extend_from_slice(&n.to_be_bytes());
v.extend_from_slice(&1u16.to_be_bytes()); // flag
v.extend_from_slice(&s.to_be_bytes());
v.extend_from_slice(&e.to_be_bytes());
}
v
}
#[test]
fn parses_real_disc_layout() {
// First three records observed on a retail 2.1 disc: 2560-packet segments.
let tbl = build_tbl(&[
(1, 343680, 346239),
(2, 695616, 698175),
(3, 1051840, 1054399),
]);
let segs = parse_individual_segments(&tbl).expect("parse");
assert_eq!(segs.len(), 3);
assert_eq!(segs[0].number, 1);
assert_eq!(segs[0].start_spn, 343680);
assert_eq!(segs[0].end_spn, 346239);
assert_eq!(segs[0].packet_count(), 2560);
assert_eq!(segs[0].byte_len(), 2560 * 192);
assert_eq!(segs[0].start_byte(), 343680 * 192);
assert!(segs[0].contains_spn(345000));
assert!(!segs[0].contains_spn(343679));
assert!(!segs[0].contains_spn(346240));
}
#[test]
fn rejects_wrong_record_size() {
let mut tbl = build_tbl(&[(1, 0, 10)]);
tbl[6..8].copy_from_slice(&20u16.to_be_bytes()); // record_size != 16
assert!(parse_individual_segments(&tbl).is_none());
}
#[test]
fn rejects_truncated_and_overrun() {
assert!(parse_individual_segments(&[0u8; 4]).is_none()); // < header
let mut tbl = build_tbl(&[(1, 0, 10)]);
tbl[4..6].copy_from_slice(&99u16.to_be_bytes()); // claims 99 recs, has 1
assert!(parse_individual_segments(&tbl).is_none());
}
#[test]
fn empty_table_is_empty_not_none() {
let tbl = build_tbl(&[]);
assert_eq!(parse_individual_segments(&tbl), Some(Vec::new()));
}
#[test]
fn packets_per_unit_is_thirty_two() {
// 6144-byte aligned unit / 192-byte source packet.
assert_eq!(PACKETS_PER_UNIT, 32);
}
#[test]
fn unit_inside_segment_routes_to_variant() {
// A real first-record segment: packets [343680, 346239].
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
let off = 344000u64 * SOURCE_PACKET_LEN;
let hit = variant_segment_for_unit(&segs, off).expect("inside the segment");
assert_eq!(hit.number, 1);
}
#[test]
fn unit_outside_every_segment_is_unit_key_miss() {
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
// A unit well before the segment is ordinary content → None (unit-key path).
let off = 1000u64 * SOURCE_PACKET_LEN;
assert!(variant_segment_for_unit(&segs, off).is_none());
}
#[test]
fn unit_straddling_a_segment_edge_counts_as_variant() {
// Segment starts at packet 100. A unit that ENDS just inside it (its 32
// packets straddle the boundary) must still route to the variant key,
// because part of its ciphertext is variant-encrypted.
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
let off = 80u64 * SOURCE_PACKET_LEN;
let hit = variant_segment_for_unit(&segs, off).expect("straddles the start edge");
assert_eq!(hit.number, 7);
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99]
assert!(variant_segment_for_unit(&segs, before).is_none());
}
#[test]
fn no_segments_never_routes_to_variant() {
// The 1.0 / 2.0 case: no forensic map, so every miss is a unit-key miss.
assert!(variant_segment_for_unit(&[], lba_byte_offset(0)).is_none());
assert!(variant_segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
}
#[test]
fn lba_maps_to_the_packet_grid() {
// A unit is 3 sectors (6144 bytes) = 32 packets. Clip-relative LBA 3 is
// the second aligned unit, which starts at packet 32.
let off = lba_byte_offset(3);
assert_eq!(off / SOURCE_PACKET_LEN, 32);
}
}
+165
View File
@@ -0,0 +1,165 @@
//! AACS 2.1 FMTS forensic segment keys, `AACS/SegmentKeyNNNNN.tbl`.
//!
//! One file per CPS unit (`SegmentKey00001.tbl`, ...). It is the on-disc key
//! store for the forensic variant segments mapped by [`super::segment`]. A
//! device does not read a segment key directly. It derives a **16-bit variant
//! selector** from the Media Key Variant chain (see [`super::variant`]) and uses
//! that selector to index this table, which is how the device's position in the
//! key tree decides which variant it can decrypt (the traitor-tracing link).
//!
//! Container format (confirmed against a retail AACS 2.1 disc):
//! ```text
//! header (8 bytes): u32 tag | u16 index_space | u16 record_size
//! record[index_space] (record_size bytes each)
//! ```
//! On the reference disc: `index_space` = `0xffff` (the full 16-bit selector
//! space, 65536 records), `record_size` = `0x0218` = 536. Total
//! `8 + 65536 * 536 = 35,127,304` bytes, which matches the file exactly. Each
//! record begins with an 8-byte sub-header, then 528 bytes of encrypted key
//! material.
//!
//! **Not yet reversed:** the internal layout of a record's 528-byte payload, and
//! how it maps onto the segments of [`super::segment`]. One numeric coincidence
//! worth noting for whoever cracks it: the reference disc has 792 segments and
//! `528 = 33 * 16`, with `792 = 24 * 33`, so `33` appears on both sides. Until
//! the mapping and the key derivation are pinned, this module exposes only the
//! confirmed container: locate the record for a given 16-bit selector.
/// Bytes of the fixed file header.
pub const HEADER_LEN: usize = 8;
/// The on-disc segment-key table container. Borrows the file bytes; a record is
/// looked up by the 16-bit variant selector.
#[derive(Debug, Clone, Copy)]
pub struct SegmentKeyTable<'a> {
data: &'a [u8],
/// Number of records (the selector index space, e.g. 65536).
count: usize,
/// Bytes per record (e.g. 536).
record_size: usize,
}
impl<'a> SegmentKeyTable<'a> {
/// Parse and validate the container header against the buffer length.
///
/// Returns `None` when the buffer is too small, or the declared
/// `count * record_size` (plus header) does not match the buffer, so a
/// truncated or foreign table degrades to "no segment keys" rather than
/// handing back bogus records. `index_space` of `0xffff` is read as the full
/// 65536-entry space (a device selector is a full 16-bit value).
pub fn parse(data: &'a [u8]) -> Option<Self> {
if data.len() < HEADER_LEN {
return None;
}
let index_space = u16::from_be_bytes([data[4], data[5]]);
let record_size = u16::from_be_bytes([data[6], data[7]]) as usize;
// 0xffff means the full 16-bit selector space (65536 records).
let count = if index_space == 0xffff {
0x1_0000
} else {
index_space as usize
};
if record_size == 0 {
return None;
}
let body = count.checked_mul(record_size)?;
if HEADER_LEN.checked_add(body)? != data.len() {
return None;
}
Some(Self {
data,
count,
record_size,
})
}
/// Number of records (the selector index space).
pub fn record_count(&self) -> usize {
self.count
}
/// Bytes per record.
pub fn record_size(&self) -> usize {
self.record_size
}
/// The raw record for a 16-bit variant `selector`, including its 8-byte
/// sub-header. `None` if the selector is past the table (only possible when
/// `index_space` was not the full 16-bit space).
pub fn record(&self, selector: u16) -> Option<&'a [u8]> {
let idx = selector as usize;
if idx >= self.count {
return None;
}
let start = HEADER_LEN + idx * self.record_size;
self.data.get(start..start + self.record_size)
}
/// The encrypted key payload for a selector: the record with its 8-byte
/// sub-header stripped. The internal layout of these bytes is not yet
/// reversed (see module docs).
pub fn record_payload(&self, selector: u16) -> Option<&'a [u8]> {
self.record(selector).and_then(|r| r.get(HEADER_LEN..))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a container with `record_size` and the given `index_space`, filling
/// each record with a distinguishable byte so lookups can be checked.
fn build(index_space: u16, record_size: u16) -> Vec<u8> {
let count = if index_space == 0xffff {
0x1_0000
} else {
index_space as usize
};
let mut v = Vec::with_capacity(HEADER_LEN + count * record_size as usize);
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // tag
v.extend_from_slice(&index_space.to_be_bytes());
v.extend_from_slice(&record_size.to_be_bytes());
for i in 0..count {
let mut rec = vec![(i & 0xff) as u8; record_size as usize];
// sub-header, as seen on disc
rec[..8].copy_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
v.extend_from_slice(&rec);
}
v
}
#[test]
fn parses_retail_container_geometry() {
// The real disc: 0xffff index space, 536-byte records, 35,127,304 total.
let data = build(0xffff, 536);
assert_eq!(
data.len(),
35_127_304,
"matches the retail file size exactly"
);
let t = SegmentKeyTable::parse(&data).expect("parse");
assert_eq!(t.record_count(), 65_536);
assert_eq!(t.record_size(), 536);
let rec = t.record(0x1234).expect("record");
assert_eq!(rec.len(), 536);
assert_eq!(&rec[..8], &[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
assert_eq!(t.record_payload(0x1234).unwrap().len(), 528);
}
#[test]
fn small_index_space_bounds_lookups() {
let data = build(4, 32);
let t = SegmentKeyTable::parse(&data).expect("parse");
assert_eq!(t.record_count(), 4);
assert!(t.record(3).is_some());
assert!(t.record(4).is_none(), "selector past the table is None");
}
#[test]
fn rejects_size_mismatch_and_truncation() {
assert!(SegmentKeyTable::parse(&[0u8; 4]).is_none());
let mut data = build(4, 32);
data.truncate(data.len() - 1); // body no longer matches header
assert!(SegmentKeyTable::parse(&data).is_none());
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ impl ResolutionTrace {
// ── Unlock phase ────────────────────────────────────────────────────────────
/// One unlocker's contribution to the unlock phase. `who` is the unlocker's
/// `name()` (a stable identifier, e.g. `"LibreDrive"`), carried verbatim.
/// `name()` (a stable, product-neutral identifier), carried verbatim.
#[derive(Debug, Clone, PartialEq)]
pub struct UnlockStep {
pub who: String,
+32
View File
@@ -27,6 +27,38 @@ pub struct HostCert {
pub certificate_v2: Option<Vec<u8>>,
}
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Vid(pub [u8; 16]);
/// Media Key (Km, 16 bytes) — the MKB-scoped key derived from device keys.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MediaKey(pub [u8; 16]);
/// Volume Unique Key (VUK / Kvu, 16 bytes) — derived from `MediaKey` + `Vid`,
/// decrypts the per-disc encrypted title keys in `Unit_Key_RO.inf`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Vuk(pub [u8; 16]);
/// Processing Key (Kp, 16 bytes) — an MKB Subset-Difference key that yields the
/// Media Key. A leaked/precomputed PK in the keydb, or the intermediate PK a
/// device-key walk derives at its matching SD node.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProcessingKey(pub [u8; 16]);
/// One decrypted per-CPS-unit AACS title key.
///
/// `idx` is the POSITIONAL index of the encrypted title key within the slice
/// handed to the VUK→UK step (i.e. its order in `Unit_Key_RO.inf`'s key-storage
/// area). The CPS-unit *number* association is a higher-level concern owned by
/// [`super::inf::parse_unit_key_ro`], which pairs each positional key with its
/// declared CPS unit; this primitive only does the AES, so it surfaces position.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnitKey {
pub idx: u32,
pub key: [u8; 16],
}
/// A per-disc entry from the key database.
#[derive(Debug, Clone)]
pub struct DiscEntry {
File diff suppressed because it is too large Load Diff
+4
View File
@@ -20,6 +20,10 @@ pub const SECTOR_BYTES: usize = 2048;
/// the workspace reads as `sectors * SECTOR_BYTES_U64` with no per-site cast.
pub const SECTOR_BYTES_U64: u64 = SECTOR_BYTES as u64;
/// Milliseconds per second. For turning a byte count ÷ bytes-per-second into a
/// movie-time figure (`bytes / bps * MILLIS_PER_SEC`) without a bare `1000.0`.
pub const MILLIS_PER_SEC: f64 = 1_000.0;
/// Bytes per MPEG-2 transport-stream packet. Common to all MPEG-TS, not just
/// Blu-ray — prefixed by the format, not a disc type.
pub const TS_PACKET_BYTES: usize = 188;
-838
View File
@@ -1,838 +0,0 @@
//! CSS drive bus-authentication — read-unlock primitive.
//!
//! A CSS-enforcing DVD drive refuses to return scrambled sectors until a
//! CSS bus-auth handshake has set its Authentication Success Flag (ASF=1).
//! [`unlock_css_reads`] runs that bus-auth challenge-response (which is what
//! actually opens scrambled-sector reads), then a best-effort, non-fatal
//! disc-key REPORT KEY. The bytes are NOT used as keys: the descramble title
//! key is recovered keylessly by the Stevenson known-plaintext attack (see
//! [`super::crack_key`]).
use crate::drive::Drive;
use crate::error::{Error, Result};
// ── CryptKey tables ───────────────────────────────────────────────────────
const CRYPT_TAB0: [u8; 256] = [
0xB7, 0xF4, 0x82, 0x57, 0xDA, 0x4D, 0xDB, 0xE2, 0x2F, 0x52, 0x1A, 0xA8, 0x68, 0x5A, 0x8A, 0xFF,
0xFB, 0x0E, 0x6D, 0x35, 0xF7, 0x5C, 0x76, 0x12, 0xCE, 0x25, 0x79, 0x29, 0x39, 0x62, 0x08, 0x24,
0xA5, 0x85, 0x7B, 0x56, 0x01, 0x23, 0x68, 0xCF, 0x0A, 0xE2, 0x5A, 0xED, 0x3D, 0x59, 0xB0, 0xA9,
0xB0, 0x2C, 0xF2, 0xB8, 0xEF, 0x32, 0xA9, 0x40, 0x80, 0x71, 0xAF, 0x1E, 0xDE, 0x8F, 0x58, 0x88,
0xB8, 0x3A, 0xD0, 0xFC, 0xC4, 0x1E, 0xB5, 0xA0, 0xBB, 0x3B, 0x0F, 0x01, 0x7E, 0x1F, 0x9F, 0xD9,
0xAA, 0xB8, 0x3D, 0x9D, 0x74, 0x1E, 0x25, 0xDB, 0x37, 0x56, 0x8F, 0x16, 0xBA, 0x49, 0x2B, 0xAC,
0xD0, 0xBD, 0x95, 0x20, 0xBE, 0x7A, 0x28, 0xD0, 0x51, 0x64, 0x63, 0x1C, 0x7F, 0x66, 0x10, 0xBB,
0xC4, 0x56, 0x1A, 0x04, 0x6E, 0x0A, 0xEC, 0x9C, 0xD6, 0xE8, 0x9A, 0x7A, 0xCF, 0x8C, 0xDB, 0xB1,
0xEF, 0x71, 0xDE, 0x31, 0xFF, 0x54, 0x3E, 0x5E, 0x07, 0x69, 0x96, 0xB0, 0xCF, 0xDD, 0x9E, 0x47,
0xC7, 0x96, 0x8F, 0xE4, 0x2B, 0x59, 0xC6, 0xEE, 0xB9, 0x86, 0x9A, 0x64, 0x84, 0x72, 0xE2, 0x5B,
0xA2, 0x96, 0x58, 0x99, 0x50, 0x03, 0xF5, 0x38, 0x4D, 0x02, 0x7D, 0xE7, 0x7D, 0x75, 0xA7, 0xB8,
0x67, 0x87, 0x84, 0x3F, 0x1D, 0x11, 0xE5, 0xFC, 0x1E, 0xD3, 0x83, 0x16, 0xA5, 0x29, 0xF6, 0xC7,
0x15, 0x61, 0x29, 0x1A, 0x43, 0x4F, 0x9B, 0xAF, 0xC5, 0x87, 0x34, 0x6C, 0x0F, 0x3B, 0xA8, 0x1D,
0x45, 0x58, 0x25, 0xDC, 0xA8, 0xA3, 0x3B, 0xD1, 0x79, 0x1B, 0x48, 0xF2, 0xE9, 0x93, 0x1F, 0xFC,
0xDB, 0x2A, 0x90, 0xA9, 0x8A, 0x3D, 0x39, 0x18, 0xA3, 0x8E, 0x58, 0x6C, 0xE0, 0x12, 0xBB, 0x25,
0xCD, 0x71, 0x22, 0xA2, 0x64, 0xC6, 0xE7, 0xFB, 0xAD, 0x94, 0x77, 0x04, 0x9A, 0x39, 0xCF, 0x7C,
];
const CRYPT_TAB1: [u8; 256] = [
0x8C, 0x47, 0xB0, 0xE1, 0xEB, 0xFC, 0xEB, 0x56, 0x10, 0xE5, 0x2C, 0x1A, 0x5D, 0xEF, 0xBE, 0x4F,
0x08, 0x75, 0x97, 0x4B, 0x0E, 0x25, 0x8E, 0x6E, 0x39, 0x5A, 0x87, 0x53, 0xC4, 0x1F, 0xF4, 0x5C,
0x4E, 0xE6, 0x99, 0x30, 0xE0, 0x42, 0x88, 0xAB, 0xE5, 0x85, 0xBC, 0x8F, 0xD8, 0x3C, 0x54, 0xC9,
0x53, 0x47, 0x18, 0xD6, 0x06, 0x5B, 0x41, 0x2C, 0x67, 0x1E, 0x41, 0x74, 0x33, 0xE2, 0xB4, 0xE0,
0x23, 0x29, 0x42, 0xEA, 0x55, 0x0F, 0x25, 0xB4, 0x24, 0x2C, 0x99, 0x13, 0xEB, 0x0A, 0x0B, 0xC9,
0xF9, 0x63, 0x67, 0x43, 0x2D, 0xC7, 0x7D, 0x07, 0x60, 0x89, 0xD1, 0xCC, 0xE7, 0x94, 0x77, 0x74,
0x9B, 0x7E, 0xD7, 0xE6, 0xFF, 0xBB, 0x68, 0x14, 0x1E, 0xA3, 0x25, 0xDE, 0x3A, 0xA3, 0x54, 0x7B,
0x87, 0x9D, 0x50, 0xCA, 0x27, 0xC3, 0xA4, 0x50, 0x91, 0x27, 0xD4, 0xB0, 0x82, 0x41, 0x97, 0x79,
0x94, 0x82, 0xAC, 0xC7, 0x8E, 0xA5, 0x4E, 0xAA, 0x78, 0x9E, 0xE0, 0x42, 0xBA, 0x28, 0xEA, 0xB7,
0x74, 0xAD, 0x35, 0xDA, 0x92, 0x60, 0x7E, 0xD2, 0x0E, 0xB9, 0x24, 0x5E, 0x39, 0x4F, 0x5E, 0x63,
0x09, 0xB5, 0xFA, 0xBF, 0xF1, 0x22, 0x55, 0x1C, 0xE2, 0x25, 0xDB, 0xC5, 0xD8, 0x50, 0x03, 0x98,
0xC4, 0xAC, 0x2E, 0x11, 0xB4, 0x38, 0x4D, 0xD0, 0xB9, 0xFC, 0x2D, 0x3C, 0x08, 0x04, 0x5A, 0xEF,
0xCE, 0x32, 0xFB, 0x4C, 0x92, 0x1E, 0x4B, 0xFB, 0x1A, 0xD0, 0xE2, 0x3E, 0xDA, 0x6E, 0x7C, 0x4D,
0x56, 0xC3, 0x3F, 0x42, 0xB1, 0x3A, 0x23, 0x4D, 0x6E, 0x84, 0x56, 0x68, 0xF4, 0x0E, 0x03, 0x64,
0xD0, 0xA9, 0x92, 0x2F, 0x8B, 0xBC, 0x39, 0x9C, 0xAC, 0x09, 0x5E, 0xEE, 0xE5, 0x97, 0xBF, 0xA5,
0xCE, 0xFA, 0x28, 0x2C, 0x6D, 0x4F, 0xEF, 0x77, 0xAA, 0x1B, 0x79, 0x8E, 0x97, 0xB4, 0xC3, 0xF4,
];
const CRYPT_TAB2: [u8; 256] = [
0xB7, 0x75, 0x81, 0xD5, 0xDC, 0xCA, 0xDE, 0x66, 0x23, 0xDF, 0x15, 0x26, 0x62, 0xD1, 0x83, 0x77,
0xE3, 0x97, 0x76, 0xAF, 0xE9, 0xC3, 0x6B, 0x8E, 0xDA, 0xB0, 0x6E, 0xBF, 0x2B, 0xF1, 0x19, 0xB4,
0x95, 0x34, 0x48, 0xE4, 0x37, 0x94, 0x5D, 0x7B, 0x36, 0x5F, 0x65, 0x53, 0x07, 0xE2, 0x89, 0x11,
0x98, 0x85, 0xD9, 0x12, 0xC1, 0x9D, 0x84, 0xEC, 0xA4, 0xD4, 0x88, 0xB8, 0xFC, 0x2C, 0x79, 0x28,
0xD8, 0xDB, 0xB3, 0x1E, 0xA2, 0xF9, 0xD0, 0x44, 0xD7, 0xD6, 0x60, 0xEF, 0x14, 0xF4, 0xF6, 0x31,
0xD2, 0x41, 0x46, 0x67, 0x0A, 0xE1, 0x58, 0x27, 0x43, 0xA3, 0xF8, 0xE0, 0xC8, 0xBA, 0x5A, 0x5C,
0x80, 0x6C, 0xC6, 0xF2, 0xE8, 0xAD, 0x7D, 0x04, 0x0D, 0xB9, 0x3C, 0xC2, 0x25, 0xBD, 0x49, 0x63,
0x8C, 0x9F, 0x51, 0xCE, 0x20, 0xC5, 0xA1, 0x50, 0x92, 0x2D, 0xDD, 0xBC, 0x8D, 0x4F, 0x9A, 0x71,
0x2F, 0x30, 0x1D, 0x73, 0x39, 0x13, 0xFB, 0x1A, 0xCB, 0x24, 0x59, 0xFE, 0x05, 0x96, 0x57, 0x0F,
0x1F, 0xCF, 0x54, 0xBE, 0xF5, 0x06, 0x1B, 0xB2, 0x6D, 0xD3, 0x4D, 0x32, 0x56, 0x21, 0x33, 0x0B,
0x52, 0xE7, 0xAB, 0xEB, 0xA6, 0x74, 0x00, 0x4C, 0xB1, 0x7F, 0x82, 0x99, 0x87, 0x0E, 0x5E, 0xC0,
0x8F, 0xEE, 0x6F, 0x55, 0xF3, 0x7E, 0x08, 0x90, 0xFA, 0xB6, 0x64, 0x70, 0x47, 0x4A, 0x17, 0xA7,
0xB5, 0x40, 0x8A, 0x38, 0xE5, 0x68, 0x3E, 0x8B, 0x69, 0xAA, 0x9B, 0x42, 0xA5, 0x10, 0x01, 0x35,
0xFD, 0x61, 0x9E, 0xE6, 0x16, 0x9C, 0x86, 0xED, 0xCD, 0x2E, 0xFF, 0xC4, 0x5B, 0xA0, 0xAE, 0xCC,
0x4B, 0x3B, 0x03, 0xBB, 0x1C, 0x2A, 0xAC, 0x0C, 0x3F, 0x93, 0xC7, 0x72, 0x7A, 0x09, 0x22, 0x3D,
0x45, 0x78, 0xA9, 0xA8, 0xEA, 0xC9, 0x6A, 0xF7, 0x29, 0x91, 0xF0, 0x02, 0x18, 0x3A, 0x4E, 0x7C,
];
const CRYPT_TAB3: [u8; 256] = [
0x73, 0x51, 0x95, 0xE1, 0x12, 0xE4, 0xC0, 0x58, 0xEE, 0xF2, 0x08, 0x1B, 0xA9, 0xFA, 0x98, 0x4C,
0xA7, 0x33, 0xE2, 0x1B, 0xA7, 0x6D, 0xF5, 0x30, 0x97, 0x1D, 0xF3, 0x02, 0x60, 0x5A, 0x82, 0x0F,
0x91, 0xD0, 0x9C, 0x10, 0x39, 0x7A, 0x83, 0x85, 0x3B, 0xB2, 0xB8, 0xAE, 0x0C, 0x09, 0x52, 0xEA,
0x1C, 0xE1, 0x8D, 0x66, 0x4F, 0xF3, 0xDA, 0x92, 0x29, 0xB9, 0xD5, 0xC5, 0x77, 0x47, 0x22, 0x53,
0x14, 0xF7, 0xAF, 0x22, 0x64, 0xDF, 0xC6, 0x72, 0x12, 0xF3, 0x75, 0xDA, 0xD7, 0xD7, 0xE5, 0x02,
0x9E, 0xED, 0xDA, 0xDB, 0x4C, 0x47, 0xCE, 0x91, 0x06, 0x06, 0x6D, 0x55, 0x8B, 0x19, 0xC9, 0xEF,
0x8C, 0x80, 0x1A, 0x0E, 0xEE, 0x4B, 0xAB, 0xF2, 0x08, 0x5C, 0xE9, 0x37, 0x26, 0x5E, 0x9A, 0x90,
0x00, 0xF3, 0x0D, 0xB2, 0xA6, 0xA3, 0xF7, 0x26, 0x17, 0x48, 0x88, 0xC9, 0x0E, 0x2C, 0xC9, 0x02,
0xE7, 0x18, 0x05, 0x4B, 0xF3, 0x39, 0xE1, 0x20, 0x02, 0x0D, 0x40, 0xC7, 0xCA, 0xB9, 0x48, 0x30,
0x57, 0x67, 0xCC, 0x06, 0xBF, 0xAC, 0x81, 0x08, 0x24, 0x7A, 0xD4, 0x8B, 0x19, 0x8E, 0xAC, 0xB4,
0x5A, 0x0F, 0x73, 0x13, 0xAC, 0x9E, 0xDA, 0xB6, 0xB8, 0x96, 0x5B, 0x60, 0x88, 0xE1, 0x81, 0x3F,
0x07, 0x86, 0x37, 0x2D, 0x79, 0x14, 0x52, 0xEA, 0x73, 0xDF, 0x3D, 0x09, 0xC8, 0x25, 0x48, 0xD8,
0x75, 0x60, 0x9A, 0x08, 0x27, 0x4A, 0x2C, 0xB9, 0xA8, 0x8B, 0x8A, 0x73, 0x62, 0x37, 0x16, 0x02,
0xBD, 0xC1, 0x0E, 0x56, 0x54, 0x3E, 0x14, 0x5F, 0x8C, 0x8F, 0x6E, 0x75, 0x1C, 0x07, 0x39, 0x7B,
0x4B, 0xDB, 0xD3, 0x4B, 0x1E, 0xC8, 0x7E, 0xFE, 0x3E, 0x72, 0x16, 0x83, 0x7D, 0xEE, 0xF5, 0xCA,
0xC5, 0x18, 0xF9, 0xD8, 0x68, 0xAB, 0x38, 0x85, 0xA8, 0xF0, 0xA1, 0x73, 0x9F, 0x5D, 0x19, 0x0B,
];
const VARIANTS: [u8; 32] = [
0xB7, 0x74, 0x85, 0xD0, 0xCC, 0xDB, 0xCA, 0x73, 0x03, 0xFE, 0x31, 0x03, 0x52, 0xE0, 0xB7, 0x42,
0x63, 0x16, 0xF2, 0x2A, 0x79, 0x52, 0xFF, 0x1B, 0x7A, 0x11, 0xCA, 0x1A, 0x9B, 0x40, 0xAD, 0x01,
];
const SECRET: [u8; 5] = [0x55, 0xD6, 0xC4, 0xC5, 0x28];
const PERM_CHALLENGE: [[usize; 10]; 3] = [
[1, 3, 0, 7, 5, 2, 9, 6, 4, 8],
[6, 1, 9, 3, 8, 5, 7, 4, 0, 2],
[4, 0, 3, 5, 7, 2, 8, 6, 1, 9],
];
const PERM_VARIANT: [[u8; 32]; 2] = [
[
0x0A, 0x08, 0x0E, 0x0C, 0x0B, 0x09, 0x0F, 0x0D, 0x1A, 0x18, 0x1E, 0x1C, 0x1B, 0x19, 0x1F,
0x1D, 0x02, 0x00, 0x06, 0x04, 0x03, 0x01, 0x07, 0x05, 0x12, 0x10, 0x16, 0x14, 0x13, 0x11,
0x17, 0x15,
],
[
0x12, 0x1A, 0x16, 0x1E, 0x02, 0x0A, 0x06, 0x0E, 0x10, 0x18, 0x14, 0x1C, 0x00, 0x08, 0x04,
0x0C, 0x13, 0x1B, 0x17, 0x1F, 0x03, 0x0B, 0x07, 0x0F, 0x11, 0x19, 0x15, 0x1D, 0x01, 0x09,
0x05, 0x0D,
],
];
// ── Public API ────────────────────────────────────────────────────────────
/// CSS bus-auth **unlock** primitive.
///
/// Runs the bus-auth challenge-response (which sets the drive's ASF=1 and is
/// what actually unlocks scrambled-sector reads), then a best-effort,
/// non-fatal disc-key REPORT KEY. The title-key REPORT KEY is NOT issued: it
/// is unnecessary (the descramble key is recovered keylessly by the Stevenson
/// attack in [`super::crack_key`]) and its hard failure on some USB bridges
/// used to abort the whole unlock (the 7014 bug). The bytes are discarded.
pub fn unlock_css_reads(drive: &mut Drive, lba: u32) -> Result<()> {
let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::css", phase = "unlock_css_reads", lba, "begin");
let r = unlock_css_reads_inner(drive, lba);
tracing::info!(
target: "freemkv::css",
phase = "unlock_css_reads",
lba,
ok = r.is_ok(),
elapsed_ms = t0.elapsed().as_millis() as u64,
"end"
);
r
}
fn unlock_css_reads_inner(drive: &mut Drive, _lba: u32) -> Result<()> {
tracing::debug!(target: "freemkv::css", "css unlock: begin");
// The bus-auth challenge-response sets the drive's Authentication Success
// Flag (ASF=1), which is what opens scrambled-sector reads. This is the
// ONLY step required to unlock reads; a failure here is fatal — we
// genuinely cannot read scrambled sectors.
let (agid, _bus_key) = bus_auth(drive).inspect_err(|e| {
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus_auth failed");
})?;
tracing::debug!(target: "freemkv::css", agid, "css unlock: bus_auth ok");
// Disc-key REPORT KEY: issued BEST-EFFORT for any firmware that ties part
// of its read-unlock to it. The bytes are unused (the descramble key is
// recovered keylessly) and a failure is NON-FATAL — the gate is already
// open from bus-auth. This replaces the title-key REPORT KEY, whose hard
// failure used to abort the whole unlock (the 7014 bug on USB bridges).
if let Err(e) = read_disc_key(drive, agid) {
tracing::debug!(target: "freemkv::css", error_code = e.code(), "css unlock: disc-key REPORT KEY skipped (non-fatal)");
}
tracing::debug!(target: "freemkv::css", "css unlock: ok");
Ok(())
}
// ── Step 1: Bus Authentication ────────────────────────────────────────────
fn bus_auth(drive: &mut Drive) -> Result<(u8, [u8; 5])> {
let scsi = drive.scsi_mut();
// Invalidate all AGIDs via REPORT KEY format 0x3F
for agid in 0..4u8 {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
// alloc_len = 0 (no data transfer)
cdb[10] = (agid << 6) | 0x3F;
let mut buf = [0u8; 8];
let _ = scsi.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
);
}
// Allocate AGID
let mut buf = [0u8; 8];
scsi.execute(
&report_key_cdb(0, 0x00, 8),
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
let agid = (buf[7] >> 6) & 0x03;
// Host sends challenge. The spec wants a fresh per-session random nonce,
// not a fixed constant — a predictable challenge weakens the bus-auth
// handshake.
let mut host_challenge = [0u8; 10];
{
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut host_challenge);
}
let mut hc_buf = [0u8; 16];
hc_buf[0] = 0x00;
hc_buf[1] = 0x0E;
for i in 0..10 {
hc_buf[4 + i] = host_challenge[9 - i];
}
scsi.execute(
&send_key_cdb(agid, 0x01, 16),
crate::scsi::DataDirection::ToDevice,
&mut hc_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
// Get Key1 from drive
let mut dk_buf = [0u8; 12];
scsi.execute(
&report_key_cdb(agid, 0x02, 12),
crate::scsi::DataDirection::FromDevice,
&mut dk_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
let mut key1 = [0u8; 5];
for i in 0..5 {
key1[i] = dk_buf[4 + (4 - i)];
}
// Brute-force variant (0-31)
let mut variant: Option<u8> = None;
for v in 0..32u8 {
if crypt_key(0, v, &host_challenge) == key1 {
variant = Some(v);
break;
}
}
let variant = variant.ok_or(Error::CssAuthFailed)?;
// Get drive challenge
let mut dc_buf = [0u8; 16];
scsi.execute(
&report_key_cdb(agid, 0x01, 16),
crate::scsi::DataDirection::FromDevice,
&mut dc_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
let mut drive_challenge = [0u8; 10];
for i in 0..10 {
drive_challenge[i] = dc_buf[4 + (9 - i)];
}
// Compute Key2 and send it
let key2 = crypt_key(1, variant, &drive_challenge);
let mut hk_buf = [0u8; 12];
hk_buf[0] = 0x00;
hk_buf[1] = 0x0A;
for i in 0..5 {
hk_buf[4 + i] = key2[4 - i];
}
scsi.execute(
&send_key_cdb(agid, 0x03, 12),
crate::scsi::DataDirection::ToDevice,
&mut hk_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
// Bus key = CryptKey(2, variant, key1 || key2)
let mut combined = [0u8; 10];
combined[..5].copy_from_slice(&key1);
combined[5..].copy_from_slice(&key2);
let bus_key = crypt_key(2, variant, &combined);
Ok((agid, bus_key))
}
// ── Step 2: Disc Key ──────────────────────────────────────────────────────
/// Issue READ DVD STRUCTURE format 0x02 (Copyright Information — opcode 0xAD,
/// NOT the REPORT KEY 0xA4 disc-key block) purely for the bus-auth unlock side
/// effect. The returned block contents are not used — the descramble title key
/// is recovered keylessly elsewhere, so the genuine disc-key REPORT KEY is
/// intentionally skipped. (If a drive is ever found where bus-auth alone does
/// not open scrambled reads, a real REPORT KEY format 0x02 belongs here.)
fn read_disc_key(drive: &mut Drive, agid: u8) -> Result<()> {
let scsi = drive.scsi_mut();
// READ DVD STRUCTURE, format 0x02 (disc key), 2048+4 bytes
let alloc_len: u16 = 2048 + 4;
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE;
// bytes 2-5: address = 0
cdb[6] = 0; // layer
cdb[7] = 0x02; // format = disc key
cdb[8] = (alloc_len >> 8) as u8;
cdb[9] = alloc_len as u8;
cdb[10] = agid << 6;
let mut buf = vec![0u8; alloc_len as usize];
let dvd_result = scsi.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
);
dvd_result.map_err(|_| Error::CssAuthFailed)?;
Ok(())
}
// ── CSSCryptKey ───────────────────────────────────────────────────────────
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
// key_type indexes PERM_CHALLENGE ([_;3]); variant indexes
// VARIANTS/PERM_VARIANT ([_;32]). All internal callers pass key_type in
// 0..3 and variant in 0..32; the asserts document the contract for the
// pub(crate) test entry point test_crypt_key and turn a would-be
// out-of-bounds panic into an explicit precondition violation.
debug_assert!(key_type < 3, "crypt_key: key_type out of range");
debug_assert!((variant as usize) < 32, "crypt_key: variant out of range");
let perm = &PERM_CHALLENGE[key_type];
let mut scratch = [0u8; 10];
for i in 0..10 {
scratch[i] = challenge[perm[i]];
}
let css_variant = match key_type {
0 => variant as usize,
1 => PERM_VARIANT[0][variant as usize] as usize,
_ => PERM_VARIANT[1][variant as usize] as usize,
};
let cse = VARIANTS[css_variant] ^ CRYPT_TAB2[css_variant];
let mut tmp1 = [0u8; 5];
for i in 0..5 {
tmp1[i] = scratch[5 + i] ^ SECRET[i] ^ CRYPT_TAB2[i];
}
let mut lfsr0: u32 = ((tmp1[0] as u32) << 17)
| ((tmp1[1] as u32) << 9)
| (((tmp1[2] as u32) & !7) << 1)
| 8
| (tmp1[2] as u32 & 7);
let mut lfsr1: u32 = ((tmp1[3] as u32) << 9) | 0x100 | (tmp1[4] as u32);
let mut bits = [0u8; 30];
let mut carry: u32 = 0;
for idx in (0..30).rev() {
let mut val: u8 = 0;
for bit in 0..8u8 {
let lfsr0_out = ((lfsr0 >> 24) ^ (lfsr0 >> 21) ^ (lfsr0 >> 20) ^ (lfsr0 >> 12)) & 1;
lfsr0 = ((lfsr0 << 1) | lfsr0_out) & 0x1FFFFFF;
let lfsr1_out = ((lfsr1 >> 16) ^ (lfsr1 >> 2)) & 1;
lfsr1 = ((lfsr1 << 1) | lfsr1_out) & 0x1FFFF;
let combined = ((!lfsr1_out) & 1) + carry + ((!lfsr0_out) & 1);
carry = (combined >> 1) & 1;
val |= ((combined & 1) as u8) << bit;
}
bits[idx] = val;
}
let mut tmp1 = [scratch[0], scratch[1], scratch[2], scratch[3], scratch[4]];
let mut tmp2 = [0u8; 5];
// Round 1: bits[25..29] ^ scratch -> tmp1 (term from original scratch)
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[25 + i] ^ tmp1[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
term = scratch[i]; // original challenge, NOT modified tmp1
}
tmp1[4] ^= tmp1[0];
}
// Round 2
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[20 + i] ^ tmp1[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
tmp2[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
term = tmp1[i];
}
tmp2[4] ^= tmp2[0];
}
// Round 3 (uses CRYPT_TAB0)
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[15 + i] ^ tmp2[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
let idx3 = (CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term) as usize;
tmp1[i] = CRYPT_TAB0[idx3] ^ CRYPT_TAB2[idx3];
term = tmp2[i];
}
tmp1[4] ^= tmp1[0];
}
// Round 4 (uses CRYPT_TAB0)
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[10 + i] ^ tmp1[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
let idx3 = (CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term) as usize;
tmp2[i] = CRYPT_TAB0[idx3] ^ CRYPT_TAB2[idx3];
term = tmp1[i];
}
tmp2[4] ^= tmp2[0];
}
// Round 5
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[5 + i] ^ tmp2[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
term = tmp2[i];
}
tmp1[4] ^= tmp1[0];
}
// Round 6
let mut key = [0u8; 5];
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[i] ^ tmp1[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
key[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
term = tmp1[i];
}
}
key
}
// ── SCSI CDB builders ────────────────────────────────────────────────────
fn report_key_cdb(agid: u8, format: u8, alloc_len: u16) -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
cdb[8] = (alloc_len >> 8) as u8;
cdb[9] = alloc_len as u8;
cdb[10] = (agid << 6) | (format & 0x3F);
cdb
}
fn send_key_cdb(agid: u8, format: u8, param_len: u16) -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_SEND_KEY;
cdb[8] = (param_len >> 8) as u8;
cdb[9] = param_len as u8;
cdb[10] = (agid << 6) | (format & 0x3F);
cdb
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// SECURITY REGRESSION GUARD: no instrumentation in libfreemkv may emit
/// raw key material. Scan every source file for a `tracing` field that
/// binds a forbidden key name to a value-producing expression (`= expr`
/// or `%expr` / `?expr`). The only allowed forms are a string literal
/// (e.g. `disc_key = "<redacted>"`) or a `_fp` fingerprint field.
///
/// This is a source-scan test (not a runtime capture) so it stays cheap
/// and catches re-introductions at compile/CI time.
#[test]
fn no_key_bytes_in_instrumentation() {
use std::path::Path;
// Forbidden field names whose VALUES must never be logged.
const FORBIDDEN: &[&str] = &[
"title_key",
"disc_key",
"unit_key",
"vuk",
"player_key",
"bus_key",
];
fn scan_dir(dir: &Path, forbidden: &[&str], violations: &mut Vec<String>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_dir(&path, forbidden, violations);
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let src = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => continue,
};
for (lineno, line) in src.lines().enumerate() {
let trimmed = line.trim_start();
// Only inspect tracing instrumentation lines.
if !(trimmed.contains("tracing::")
|| trimmed.starts_with("debug!")
|| trimmed.starts_with("info!")
|| trimmed.starts_with("warn!")
|| trimmed.starts_with("trace!")
|| trimmed.starts_with("error!"))
{
continue;
}
// This guard test itself contains the forbidden names.
if path.file_name().and_then(|n| n.to_str()) == Some("auth.rs")
&& line.contains("FORBIDDEN")
{
continue;
}
for &name in forbidden {
// A fingerprint field (`<name>_fp = ...`) is allowed.
// Match `<name>` followed by optional fingerprint
// suffix then `=` and a value that is NOT a string
// literal redaction marker.
if let Some(idx) = line.find(name) {
let after = &line[idx + name.len()..];
let after = after.trim_start();
// `<name>_fp` / `<name>_id` etc. are safe.
if after.starts_with('_') {
continue;
}
// Must be a field binding `name = ...`.
let Some(rest) = after.strip_prefix('=') else {
continue;
};
let rest = rest.trim_start();
// Redaction string literal is the only allowed value.
if rest.starts_with('"') {
continue;
}
// Anything else (`%expr`, `?expr`, bare expr) leaks bytes.
violations.push(format!(
"{}:{}: forbidden key field `{}` logged with a value: {}",
path.display(),
lineno + 1,
name,
line.trim()
));
}
}
}
}
}
// Scan this crate's `src` plus the sibling workspace crates so the
// key-material logging guard covers every crate that can reach the
// CSS/AACS internals, not just libfreemkv. Missing sibling dirs (e.g.
// when building the crate standalone) are simply skipped.
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace = manifest.parent().unwrap_or(manifest);
let mut violations = Vec::new();
scan_dir(&manifest.join("src"), FORBIDDEN, &mut violations);
for sibling in ["autorip", "freemkv", "freemkv-keysources"] {
let dir = workspace.join(sibling).join("src");
if dir.is_dir() {
scan_dir(&dir, FORBIDDEN, &mut violations);
}
}
assert!(
violations.is_empty(),
"key material logged in instrumentation:\n{}",
violations.join("\n")
);
}
#[test]
fn crypt_key_is_deterministic() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for v in 0..32u8 {
let r1 = crypt_key(0, v, &challenge);
let r2 = crypt_key(0, v, &challenge);
assert_eq!(r1, r2);
}
}
#[test]
fn crypt_key_varies_by_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
}
#[test]
fn crypt_key_varies_by_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge));
}
#[test]
fn crypt_key_nonzero() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for v in 0..32u8 {
assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]);
}
}
// ── CSS constant-table integrity ───────────────────────────────────────
/// Each PERM_CHALLENGE row is a permutation of indices 0..10 (it reorders
/// the 10 challenge bytes). A non-permutation would drop/duplicate
/// challenge bytes, weakening or corrupting the bus key derivation.
///
/// Grounding: crypt_key does `scratch[i] = challenge[perm[i]]` for i in
/// 0..10 — perm must be a bijection on 0..10 to use every challenge byte
/// exactly once.
/// Mutation: change PERM_CHALLENGE[0] entry `9` to `8` (duplicate) -> the
/// "covers 0..10" assert fires.
#[test]
fn perm_challenge_rows_are_permutations() {
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
let mut seen = [false; 10];
for &idx in perm.iter() {
assert!(idx < 10, "PERM_CHALLENGE[{row}] index {idx} out of range");
assert!(!seen[idx], "PERM_CHALLENGE[{row}] duplicates index {idx}");
seen[idx] = true;
}
assert!(
seen.iter().all(|&b| b),
"PERM_CHALLENGE[{row}] misses an index"
);
}
}
/// Each PERM_VARIANT row maps the 32 variants to 32 distinct 5-bit values
/// (it is a permutation of 0..32). key_type 1 uses PERM_VARIANT[0],
/// key_type 2 uses PERM_VARIANT[1] to pick the css_variant; a collision
/// would make two variants indistinguishable.
///
/// Grounding: `css_variant = PERM_VARIANT[k][variant]` then indexes
/// VARIANTS[css_variant] (0..32).
/// Mutation: set PERM_VARIANT[0][1] = PERM_VARIANT[0][0] -> duplicate
/// assert fires; also any value >= 32 would later index VARIANTS OOB.
#[test]
fn perm_variant_rows_are_permutations_of_0_31() {
for (row, perm) in PERM_VARIANT.iter().enumerate() {
let mut seen = [false; 32];
for &v in perm.iter() {
let v = v as usize;
assert!(v < 32, "PERM_VARIANT[{row}] value {v} out of 0..32");
assert!(!seen[v], "PERM_VARIANT[{row}] duplicates {v}");
seen[v] = true;
}
assert!(
seen.iter().all(|&b| b),
"PERM_VARIANT[{row}] misses a value"
);
}
}
// ── crypt_key behaviour ────────────────────────────────────────────────
/// crypt_key result depends on every challenge byte. The challenge is
/// permuted into `scratch` and folded through the LFSR seeding and the 6
/// XOR rounds. Flipping any single challenge byte must change the output.
///
/// Grounding: scratch[i]=challenge[perm[i]] for all 10 i, and scratch
/// seeds both LFSRs (bytes 5..10 via tmp1) and the round terms (bytes
/// 0..5).
/// Mutation: in `scratch[i] = challenge[perm[i]]` replace with
/// `challenge[i]` for a perm that drops a byte — or hardcode one scratch
/// entry — and some challenge byte stops mattering; this fails.
#[test]
fn crypt_key_depends_on_every_challenge_byte() {
let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let base_out = crypt_key(0, 5, &base);
for i in 0..10 {
let mut c = base;
c[i] ^= 0x55;
assert_ne!(
crypt_key(0, 5, &c),
base_out,
"flipping challenge byte {i} did not change the bus-key derivation"
);
}
}
/// crypt_key(0, v, ..) must produce a DISTINCT result for each of the 32
/// variants on a fixed challenge. bus_auth brute-forces the variant by
/// matching crypt_key(0, v, host_challenge) == key1; if two variants
/// collided, the wrong variant could be selected and the whole auth
/// derail.
///
/// Grounding: variant selects css_variant -> VARIANTS[css_variant] -> cse,
/// which feeds every round; distinct variants give distinct cse-driven
/// keys in practice.
/// Mutation: make `cse` ignore the variant (e.g. `let cse = 0`) -> all 32
/// outputs collapse to one value; the distinctness assert fires.
#[test]
fn crypt_key_type0_distinct_per_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let mut outs = Vec::new();
for v in 0..32u8 {
let k = crypt_key(0, v, &challenge);
assert!(
!outs.contains(&k),
"variant {v} collides with an earlier variant"
);
outs.push(k);
}
}
/// crypt_key enforces its documented precondition `key_type < 3` via
/// debug_assert (active in test builds). A key_type of 3 would index
/// PERM_CHALLENGE (len 3) out of bounds; the assert turns that into an
/// explicit precondition panic.
///
/// Grounding: `debug_assert!(key_type < 3, ...)`; PERM_CHALLENGE has 3
/// rows (indices 0,1,2).
/// Mutation: delete the debug_assert AND the match-arm guard — but the
/// match `_ =>` arm would then index PERM_CHALLENGE[3] OOB and panic
/// differently; with the assert in place this test pins the contract.
#[test]
#[should_panic]
fn crypt_key_rejects_out_of_range_key_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let _ = crypt_key(3, 0, &challenge);
}
/// crypt_key enforces `variant < 32` via debug_assert. A variant of 32
/// would index VARIANTS / PERM_VARIANT (len 32) out of bounds.
///
/// Grounding: `debug_assert!((variant as usize) < 32, ...)`.
/// Mutation: removing the assert makes this index VARIANTS[32] (still a
/// panic, but unguarded); the assert documents/enforces the contract.
#[test]
#[should_panic]
fn crypt_key_rejects_out_of_range_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let _ = crypt_key(0, 32, &challenge);
}
// ── SCSI CDB builders (MMC REPORT KEY / SEND KEY layout) ───────────────
/// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB:
/// byte 0 = operation code 0xA4
/// bytes 8-9 = allocation length, big-endian
/// byte 10 = (AGID << 6) | (key_format & 0x3F)
/// All other bytes are zero.
///
/// Grounding: MMC REPORT KEY CDB; the AGID is the top 2 bits of byte 10,
/// key format the low 6 bits.
/// Mutation: change `(alloc_len >> 8)` to `alloc_len` for byte 8 (lose the
/// big-endian split) -> byte 8/9 assert fails. Change `agid << 6` to
/// `agid << 5` -> the AGID-position assert fails.
#[test]
fn report_key_cdb_matches_mmc_layout() {
let cdb = report_key_cdb(0b10, 0x04, 0x010C); // AGID=2, format=0x04, len=268
assert_eq!(cdb[0], 0xA4, "REPORT KEY opcode");
assert_eq!(cdb[8], 0x01, "alloc_len high byte (big-endian)");
assert_eq!(cdb[9], 0x0C, "alloc_len low byte");
assert_eq!(
cdb[10],
(0b10 << 6) | 0x04,
"AGID in bits 6-7, format in bits 0-5"
);
// Every other byte must be zero.
for (i, &b) in cdb.iter().enumerate() {
if ![0, 8, 9, 10].contains(&i) {
assert_eq!(b, 0, "CDB byte {i} must be zero");
}
}
assert_eq!(cdb.len(), 12, "REPORT KEY is a 12-byte CDB");
}
/// The key format field is masked to 6 bits: a format with high bits set
/// must not corrupt the AGID. report_key_cdb(0, 0xFF, _) -> byte 10 low 6
/// bits = 0x3F, AGID = 0.
///
/// Grounding: `(agid << 6) | (format & 0x3F)`.
/// Mutation: drop the `& 0x3F` mask -> 0xFF would overwrite the AGID bits;
/// byte 10 would be 0xFF not 0x3F, this fails.
#[test]
fn report_key_cdb_masks_format_to_6_bits() {
let cdb = report_key_cdb(0, 0xFF, 8);
assert_eq!(cdb[10], 0x3F, "format masked to 6 bits, AGID stays 0");
}
/// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the
/// parameter-list length at bytes 8-9 (big-endian) and AGID/format at byte
/// 10.
///
/// Grounding: MMC SEND KEY CDB layout.
/// Mutation: change opcode to SCSI_REPORT_KEY -> opcode assert fails;
/// swap bytes 8/9 -> length assert fails.
#[test]
fn send_key_cdb_matches_mmc_layout() {
let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12
assert_eq!(cdb[0], 0xA3, "SEND KEY opcode");
assert_eq!(cdb[8], 0x00, "param_len high byte");
assert_eq!(cdb[9], 0x0C, "param_len low byte");
assert_eq!(
cdb[10],
(0b11 << 6) | 0x03,
"AGID bits 6-7, format bits 0-5"
);
assert_eq!(cdb.len(), 12);
}
/// Allocation length larger than 255 must split across bytes 8 (high) and
/// 9 (low) — a 16-bit big-endian field. report_key_cdb with alloc_len
/// 0x0804 (2052, the disc-key block size used in read_disc_key) -> byte 8
/// = 0x08, byte 9 = 0x04.
///
/// Grounding: read_disc_key uses `alloc_len = 2048 + 4 = 2052 = 0x0804`
/// and writes `cdb[8] = (alloc_len >> 8); cdb[9] = alloc_len`.
/// Mutation: write only byte 9 (`cdb[9] = alloc_len as u8`) without byte 8
/// -> the drive sees a 4-byte transfer, truncating the disc-key block;
/// this asserts the high byte is present.
#[test]
fn report_key_cdb_alloc_len_is_16bit_big_endian() {
let cdb = report_key_cdb(0, 0x00, 0x0804);
assert_eq!(cdb[8], 0x08, "high byte of 2052-byte transfer");
assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer");
}
}
+6 -7
View File
@@ -24,13 +24,12 @@ use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
/// hierarchy, not the content cipher). Bytes 0x80..0x800 are recovered with
/// `*p = TAB1[*p] ^ (i_t5 & 0xff)`.
///
/// The scramble flag at byte 0x14 (bits 4-5) indicates encryption. Like
/// libdvdcss, the flag byte is NOT modified here — the caller treats a
/// nonzero `sector[0x14] & 0x30` as "needs unscrambling" and the descramble
/// is its own inverse, so re-running it on plaintext would re-scramble.
/// (freemkv historically cleared the flag; we keep clearing it so callers
/// and the existing tests can distinguish a descrambled sector. This does
/// not affect the recovered body.)
/// The scramble flag at byte 0x14 (bits 4-5) indicates encryption. This
/// descrambler CLEARS that flag after unscrambling, so a descrambled sector
/// reads as `sector[0x14] & 0x30 == 0`; callers and the tests use that to tell
/// it from ciphertext, and re-running descramble on an already-cleared sector
/// is a no-op (the flag guard below skips it). Clearing does not affect the
/// recovered body.
///
/// No-op (returns without modifying `sector`) in two cases:
/// - `sector.len() < 2048`: the encrypted region (0x80..0x800) is not
+46 -1
View File
@@ -14,7 +14,6 @@
//! }
//! ```
pub mod auth;
pub mod lfsr;
pub mod stevenson;
pub(crate) mod tables;
@@ -272,6 +271,52 @@ pub fn descramble_sector(state: &CssState, sector: &mut [u8]) {
lfsr::descramble_sector(&state.title_key, sector);
}
/// Descramble a whole CSS buffer in place, re-cracking the title key on a VOB
/// region boundary. `title_key` is a CACHE of the last crack, not a fixed disc
/// key: it changes per VTS/VOB region, so it is validated on every scrambled
/// sector and re-cracked on a miss (libdvdcss's on-demand per-region rekey).
///
/// This CSS key acquisition is intrinsic to the cipher — CSS has no external key
/// source, the ONLY way to a title key is cracking the data — so it lives with
/// the CSS primitives and runs inside `decrypt::decrypt_sectors` (a public,
/// self-contained CSS decrypt), NOT at the post-decrypt recovery seam that AACS
/// key-fetch and FMTS segment-skip use (those consume external inputs).
///
/// The clear header (`<0x80`) is never scrambled, so its periodic crib predicts
/// the plaintext at `0x80`. Descramble with the cached key; if the crib fails to
/// reappear the key region changed (or the primed key was wrong) — restore the
/// ciphertext, re-crack from this very sector, and descramble again. A crib-less
/// sector (no periodic run) can be neither validated nor cracked, so it rides the
/// cached key — correct, because it lives in the same region as the nearby crib
/// sector that set the cache.
pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) {
for chunk in buf.chunks_mut(2048) {
if chunk.len() < 2048 || !is_scrambled(chunk) {
continue;
}
let crib = stevenson::attack_crib(chunk);
// Snapshot the ciphertext (chunk is exactly 2048 here) only when there is
// a crib to validate against, so the common cache-hit path costs no
// per-sector heap allocation.
let mut original = [0u8; 2048];
if crib.is_some() {
original.copy_from_slice(chunk);
}
lfsr::descramble_sector(title_key, chunk);
if let Some(crib) = crib {
if chunk[0x80..0x80 + 10] != crib[..] {
// Cached key is stale for this region — restore the ciphertext and
// crack this sector's own key.
chunk.copy_from_slice(&original);
if let Some(fresh) = stevenson::crack_title_key(chunk) {
*title_key = fresh;
}
lfsr::descramble_sector(title_key, chunk);
}
}
}
}
/// Check if a sector has the CSS scramble flag set.
///
/// This is the RAW flag test — bits 4-5 of the sub-header byte 0x14 — used by
-3
View File
@@ -260,9 +260,6 @@ pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
result
}
/// Inner body of [`crack_title_key`] — the actual AttackPattern search. Split
/// out so the public entry point can wall-clock the whole attempt for the
/// runaway guard without threading a timer through every return path.
/// AttackPattern crib: the predicted 10-byte plaintext at byte 0x80.
///
/// Scans the clear header `sec[0x00..0x80]` (never scrambled) for the longest
+165 -115
View File
@@ -123,17 +123,24 @@ pub fn decrypt_threads() -> usize {
if explicit > 0 {
return explicit;
}
let env = std::env::var("FREEMKV_THREADS")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(0);
if env > 0 {
return env.min(MAX_THREADS);
}
let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(2);
cores.clamp(1, MAX_THREADS)
// Resolve the `FREEMKV_THREADS` env var + `available_parallelism()` ONCE and
// cache it — this runs on the per-buffer decrypt hot path, and a getenv +
// String alloc + parallelism syscall per call is pure overhead. The explicit
// `set_decrypt_threads` override above still takes effect dynamically.
static DEFAULT_THREADS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
*DEFAULT_THREADS.get_or_init(|| {
let env = std::env::var("FREEMKV_THREADS")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(0);
if env > 0 {
return env.min(MAX_THREADS);
}
let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(2);
cores.clamp(1, MAX_THREADS)
})
}
/// Resolved decryption state from disc scanning.
@@ -244,7 +251,7 @@ fn decrypt_sectors_impl(
// Strip CPS-unit IDs — the decrypt primitives only want the raw key bytes.
let raw_keys: Vec<[u8; 16]> = unit_keys.iter().map(|(_, k)| *k).collect();
let rdk: Option<[u8; 16]> = *read_data_key;
let unit_len = aacs::ALIGNED_UNIT_LEN;
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
// AACS decrypts whole 6144-byte aligned units. The live mux path
// (mux/disc.rs::fill_extents) issues 1- or 2-sector reads at every
// extent tail, so a buffer is commonly NOT a multiple of the unit
@@ -288,8 +295,8 @@ fn decrypt_sectors_impl(
};
if partial_in_content {
let partial = &buf[buf.len() - partial_len..];
let packets = aacs::ts_packet_total(partial);
if packets > 0 && aacs::ts_sync_count(partial) <= packets / 2 {
let packets = aacs::content::ts_packet_total(partial);
if packets > 0 && aacs::content::ts_sync_count(partial) <= packets / 2 {
return Err(crate::error::Error::DecryptFailed);
}
}
@@ -326,7 +333,7 @@ fn decrypt_sectors_impl(
// must happen first — it's a shared layer on top that is key-independent
// across all CPS units on the disc.
let decrypt_one = |chunk: &mut [u8]| {
if chunk.len() != unit_len || !aacs::aacs_unit_needs_decrypt(chunk) {
if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk) {
return;
}
// Save original bytes so we can restore if no key validates.
@@ -335,7 +342,7 @@ fn decrypt_sectors_impl(
// Build a bus-decrypted copy to try unit keys against, or work
// in-place when there is no bus layer.
if let Some(ref rdk_key) = rdk {
aacs::decrypt_bus(chunk, rdk_key);
aacs::content::decrypt_bus(chunk, rdk_key);
}
// Reorder the key iterator: try the cached hint first, then fall
@@ -349,7 +356,7 @@ fn decrypt_sectors_impl(
// Work on a per-key copy so a failing attempt doesn't
// clobber the bus-decrypted base we'll retry on.
let mut attempt: Vec<u8> = chunk.to_vec();
if aacs::decrypt_unit(&mut attempt, key) {
if aacs::content::decrypt_unit(&mut attempt, key) {
chunk.copy_from_slice(&attempt);
last_key_idx.store(idx, Ordering::Relaxed);
return;
@@ -418,49 +425,13 @@ fn decrypt_sectors_impl(
dropped_bytes.into_inner()
}
DecryptKeys::Css { title_key } => {
// CSS has no supplied key list: the ONLY source of a title key is
// cracking the data, and the key changes per VTS/VOB region. So
// `title_key` is a CACHE of the last crack, not a fixed disc key —
// applying it blindly across a region boundary descrambles with the
// wrong key (valid headers, garbage payload). Validate it on every
// scrambled sector and re-crack on a miss (libdvdcss's on-demand
// per-region rekey; the same validate-then-rekey shape the AACS arm
// above uses, but re-cracking instead of picking from a list).
//
// The clear header (<0x80) is never scrambled, so its periodic crib
// predicts the plaintext at 0x80. Descramble with the cached key; if
// the crib fails to reappear the key region changed (or the primed
// key was wrong) — restore the ciphertext, re-crack from this very
// sector, and descramble again. A crib-less sector (no periodic run)
// can be neither validated nor cracked, so it rides the cached key —
// correct, because it lives in the same region as the nearby crib
// sector that set the cache.
for chunk in buf.chunks_mut(2048) {
if chunk.len() < 2048 || !css::is_scrambled(chunk) {
continue;
}
let crib = css::stevenson::attack_crib(chunk);
// Snapshot the ciphertext into a stack buffer (chunk is exactly
// 2048 here — guaranteed by the `< 2048` continue above) only
// when there's a crib to validate against, so the common
// cache-hit path costs no per-sector heap allocation.
let mut original = [0u8; 2048];
if crib.is_some() {
original.copy_from_slice(chunk);
}
css::lfsr::descramble_sector(title_key, chunk);
if let Some(crib) = crib {
if chunk[0x80..0x80 + 10] != crib[..] {
// Cached key is stale for this region — restore the
// ciphertext and crack this sector's own key.
chunk.copy_from_slice(&original);
if let Some(fresh) = css::stevenson::crack_title_key(chunk) {
*title_key = fresh;
}
css::lfsr::descramble_sector(title_key, chunk);
}
}
}
// CSS SELF-recovers: the title key changes per VOB region and is
// re-cracked constantly, but always FROM THE DATA ITSELF — no external
// input. So the whole descramble-and-rekey is self-contained here (see
// `css::descramble_region`), and CSS does not need the post-decrypt
// recovery seam that AACS key-fetch / FMTS segment-skip use (those DO
// consume external inputs a `decrypt_sectors` caller cannot supply).
css::descramble_region(buf, title_key);
0
}
};
@@ -478,7 +449,7 @@ mod tests {
/// not left scrambled.
#[test]
fn nav_file_unit_survives_decrypt_attempt() {
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
unit[0] = b'M';
unit[1] = b'P';
unit[2] = b'L';
@@ -528,7 +499,7 @@ mod tests {
let mut u = 0;
while u < len {
v[u] |= 0xC0;
u += aacs::ALIGNED_UNIT_LEN;
u += aacs::content::ALIGNED_UNIT_LEN;
}
v
}
@@ -561,7 +532,7 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
let original = scrambled_region(aacs::ALIGNED_UNIT_LEN);
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
// base_lba 0, content = [(100,10)] ⇒ the unit at LBA 0 is OUTSIDE content.
let mut buf = original.clone();
@@ -581,7 +552,7 @@ mod tests {
decrypt_sectors_in_content(&mut buf2, &mut keys, 0, 100, &[(100, 10)]).unwrap();
assert_eq!(
dropped2,
aacs::ALIGNED_UNIT_LEN,
aacs::content::ALIGNED_UNIT_LEN,
"an undecryptable CONTENT unit IS counted as loss"
);
}
@@ -594,12 +565,12 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
let mut buf = scrambled_region(2 * aacs::ALIGNED_UNIT_LEN);
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
// unit0 @ LBA 0 (clear/skip), unit1 @ LBA 3 (content). Content = [(3,3)].
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(3, 3)]).unwrap();
assert_eq!(
dropped,
aacs::ALIGNED_UNIT_LEN,
aacs::content::ALIGNED_UNIT_LEN,
"only the in-content unit (unit1) is checked; clear unit0 is skipped"
);
}
@@ -613,7 +584,7 @@ mod tests {
read_data_key: None,
};
let mut keys_u = keys_g.clone();
let original = scrambled_region(aacs::ALIGNED_UNIT_LEN);
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
let mut g = original.clone();
let mut u = original.clone();
let gated = decrypt_sectors_in_content(&mut g, &mut keys_g, 0, 0, &[(0, 3)]).unwrap();
@@ -662,7 +633,7 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
let original = scrambled_region(aacs::ALIGNED_UNIT_LEN);
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
let mut buf = original.clone();
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[]).unwrap();
assert_eq!(
@@ -680,7 +651,7 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
let original = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
let original = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let mut buf = original.clone();
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
assert_eq!(dropped, 0, "a clear in-content unit is not ciphertext");
@@ -691,7 +662,7 @@ mod tests {
#[test]
fn content_gate_none_keys_is_noop() {
let mut keys = DecryptKeys::None;
let original = scrambled_region(aacs::ALIGNED_UNIT_LEN);
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
let mut buf = original.clone();
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
assert_eq!(dropped, 0);
@@ -713,6 +684,83 @@ mod tests {
);
}
/// Build a Stevenson-crackable scrambled CSS sector for `title_key` (mirrors
/// `crackable_sector` in the css::mod tests): a periodic run in the clear
/// header continues past 0x80 into the encrypted region, so
/// `stevenson::crack_title_key` recovers the key. Distinct `seed` values give
/// two sectors different cribs, standing in for two VOB regions.
fn crackable_css_sector(title_key: &[u8; 5], seed: &[u8; 5]) -> Vec<u8> {
const RUN_START: usize = 0x59;
const SEED_OFFSET: usize = 0x54;
const PERIOD: usize = 8;
let mut plaintext = vec![0u8; 2048];
plaintext[0x00..0x04].copy_from_slice(&css::PACK_START);
plaintext[0x14] = 0x10; // scramble flag
let pat: Vec<u8> = (0..PERIOD)
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
.collect();
for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) {
*b = pat[i % PERIOD];
}
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
css::lfsr::scramble_sector(title_key, &mut plaintext);
plaintext
}
/// CHARACTERIZATION (recovery refactor safety net): the CSS arm's per-region
/// re-crack (the `title_key` cache is stale for a new VOB region → restore
/// ciphertext, `crack_title_key` this sector, re-descramble). Two crackable
/// sectors scrambled under DIFFERENT keys sit back-to-back; the cache is
/// primed to the FIRST key. Sector 0 rides the cache (crib matches); sector 1
/// must trip the crib mismatch and re-crack to its own key. Both must land
/// correct plaintext, and the cache must end on region 1's key.
///
/// This behaviour currently lives inline in `decrypt_sectors` (the `Css`
/// arm). It is the delicate logic the recovery refactor will move to the
/// input-stream seam, so it must stay green byte-for-byte across that move.
#[test]
fn css_region_change_recracks_the_title_key() {
let key_a = [0x11, 0x22, 0x33, 0x44, 0x55];
let key_b = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE];
let sector_a = crackable_css_sector(&key_a, &[0x01, 0x02, 0x03, 0x04, 0x05]);
let sector_b = crackable_css_sector(&key_b, &[0x09, 0x08, 0x07, 0x06, 0x05]);
// Expected plaintext bodies: each sector descrambled under its true key.
let mut plain_a = sector_a.clone();
css::lfsr::descramble_sector(&key_a, &mut plain_a);
let mut plain_b = sector_b.clone();
css::lfsr::descramble_sector(&key_b, &mut plain_b);
let mut buf = Vec::with_capacity(4096);
buf.extend_from_slice(&sector_a);
buf.extend_from_slice(&sector_b);
// Cache primed to region A's key (as if A was the last crack). CSS
// descramble-and-rekey lives in `css::descramble_region` (the recovery
// seam calls it); the region change must re-crack region B's key.
let mut ended = key_a;
css::descramble_region(&mut buf, &mut ended);
assert_eq!(
&buf[0x80..2048],
&plain_a[0x80..2048],
"sector 0 rides the cached key (crib matches, no re-crack)"
);
assert_eq!(
&buf[2048 + 0x80..4096],
&plain_b[0x80..2048],
"sector 1 re-cracks its own region key and descrambles correctly"
);
// The cache must have advanced to a key that descrambles region B.
let mut check_b = sector_b.clone();
css::lfsr::descramble_sector(&ended, &mut check_b);
assert_eq!(
&check_b[0x80..2048],
&plain_b[0x80..2048],
"the ended cache key must round-trip region B's body"
);
}
/// Mixed 3-unit buffer: only the in-content SCRAMBLED unit is counted; an
/// in-content CLEAR unit and an out-of-content SCRAMBLED unit are both skipped.
#[test]
@@ -721,7 +769,7 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
let u = aacs::ALIGNED_UNIT_LEN;
let u = aacs::content::ALIGNED_UNIT_LEN;
let mut buf = vec![0u8; 3 * u];
buf[..u].copy_from_slice(&scrambled_region(u)); // unit0 @ LBA0 scrambled
buf[u..2 * u].copy_from_slice(&clear_ts_region(u)); // unit1 @ LBA3 clear
@@ -738,10 +786,14 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
let mut buf = scrambled_region(2 * aacs::ALIGNED_UNIT_LEN);
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
// unit0 @ LBA0 content, unit1 @ LBA3 out. Content = [(0,3)].
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
assert_eq!(dropped, aacs::ALIGNED_UNIT_LEN, "only unit0 counts");
assert_eq!(
dropped,
aacs::content::ALIGNED_UNIT_LEN,
"only unit0 counts"
);
}
/// The trailing-partial reject is ALSO content-gated: a scrambled partial
@@ -755,7 +807,7 @@ mod tests {
};
// One full clear unit + a scrambled single-sector partial, all OUTSIDE
// content → the partial must be tolerated (Ok), not DecryptFailed.
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
buf.extend_from_slice(&scrambled_region(2048));
// content far away → both the full unit and the partial are non-content.
let res = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(1000, 3)]);
@@ -776,7 +828,7 @@ mod tests {
read_data_key: None,
};
// One full scrambled unit + a 2048-byte (single-sector) CLEAR tail.
let unit = scrambled_region(aacs::ALIGNED_UNIT_LEN);
let unit = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
let tail = clear_ts_region(2048);
let mut buf = unit;
buf.extend_from_slice(&tail);
@@ -784,7 +836,7 @@ mod tests {
decrypt_sectors(&mut buf, &mut keys, 0).expect("clear trailing partial is Ok");
assert_eq!(
&buf[aacs::ALIGNED_UNIT_LEN..],
&buf[aacs::content::ALIGNED_UNIT_LEN..],
&tail[..],
"clear trailing partial unit must be left unchanged"
);
@@ -801,7 +853,7 @@ mod tests {
read_data_key: None,
};
// One full unit + a 4096-byte (two-sector) SCRAMBLED tail.
let unit = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
let unit = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let tail = scrambled_region(4096);
let mut buf = unit;
buf.extend_from_slice(&tail);
@@ -835,7 +887,7 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN * 2);
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN * 2);
let snapshot = buf.clone();
decrypt_sectors(&mut buf, &mut keys, 0).expect("exact-multiple buffer is Ok");
@@ -908,11 +960,12 @@ mod tests {
/// fixed wrong key -> the body no longer matches the plaintext.
#[test]
fn css_descrambles_with_title_key() {
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let mut title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
let (mut sector, plaintext) = make_css_sector(&title_key, &seed, 0xA5);
let mut keys = DecryptKeys::Css { title_key };
decrypt_sectors(&mut sector, &mut keys, 0).expect("CSS decrypt is Ok");
// CSS descramble lives in `css::descramble_region` (the recovery seam
// calls it); `decrypt_sectors` only flags CSS sectors for recovery.
css::descramble_region(&mut sector, &mut title_key);
assert_eq!(
&sector[0x80..2048],
&plaintext[0x80..2048],
@@ -941,8 +994,8 @@ mod tests {
let (s1, p1) = make_css_sector(&title_key, &[0x66, 0x77, 0x88, 0x99, 0xAA], 0xC3);
let mut buf = s0;
buf.extend_from_slice(&s1);
let mut keys = DecryptKeys::Css { title_key };
decrypt_sectors(&mut buf, &mut keys, 0).expect("CSS multi-sector decrypt is Ok");
let mut title_key = title_key;
css::descramble_region(&mut buf, &mut title_key);
assert_eq!(
&buf[0x80..2048],
&p0[0x80..2048],
@@ -1020,8 +1073,8 @@ mod tests {
buf.extend_from_slice(&s1);
// Cache primed to key_a only — exactly what the one-shot scan crack yields.
let mut keys = DecryptKeys::Css { title_key: key_a };
decrypt_sectors(&mut buf, &mut keys, 0).expect("CSS multi-region decrypt is Ok");
let mut title_key = key_a;
css::descramble_region(&mut buf, &mut title_key);
assert_eq!(
&buf[0x80..2048],
@@ -1034,13 +1087,10 @@ mod tests {
"region B sector must descramble after the path re-cracks its own key"
);
// The cache must have advanced to region B's key.
match keys {
DecryptKeys::Css { title_key } => assert_eq!(
title_key, key_b,
"cache must hold region B's key after the rekey"
),
_ => unreachable!(),
}
assert_eq!(
title_key, key_b,
"cache must hold region B's key after the rekey"
);
}
/// The CSS path leaves UNSCRAMBLED sectors (flag clear) byte-for-byte
@@ -1092,7 +1142,7 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let err = decrypt_sectors(&mut buf, &mut keys, 5)
.expect_err("unit_key_idx 5 is out of range for a 1-key list");
assert_eq!(
@@ -1112,7 +1162,7 @@ mod tests {
unit_keys: vec![],
read_data_key: None,
};
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let err = decrypt_sectors(&mut buf, &mut keys, 0).expect_err("empty unit_keys must error");
assert_eq!(err.code(), crate::error::Error::DecryptFailed.code());
}
@@ -1120,8 +1170,8 @@ mod tests {
// ── Multi-CPS-unit key selection ──────────────────────────────────────
/// Encrypt an aligned unit with the AACS algorithm run in reverse so that
/// `aacs::decrypt_unit` with the same key recovers the plaintext. Mirrors
/// the `aacs_encrypt_unit` helper in `aacs::decrypt::tests`.
/// `aacs::content::decrypt_unit` with the same key recovers the plaintext. Mirrors
/// the `aacs_encrypt_unit` helper in `aacs::content::tests`.
fn aacs_encrypt_unit_for_test(unit: &mut [u8], unit_key: &[u8; 16]) {
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
@@ -1129,14 +1179,14 @@ mod tests {
// the per-unit key so the recovered plaintext header matches.
unit[0] |= 0xC0;
let header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16];
for i in 0..16 {
k[i] = derived[i] ^ header[i];
}
let cipher = Aes128::new(GenericArray::from_slice(&k));
let mut prev = crate::aacs::decrypt::AACS_IV;
let num_blocks = (aacs::ALIGNED_UNIT_LEN - 16) / 16;
let mut prev = crate::aacs::crypto::AACS_IV;
let num_blocks = (aacs::content::ALIGNED_UNIT_LEN - 16) / 16;
for i in 0..num_blocks {
let off = 16 + i * 16;
for j in 0..16 {
@@ -1153,9 +1203,9 @@ mod tests {
/// (offset 4 + k*192) so `ts_sync_destroyed` reports false and
/// `decrypt_unit` verifies it as clear after decryption.
fn clear_ts_unit() -> Vec<u8> {
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
while off < aacs::content::ALIGNED_UNIT_LEN {
unit[off] = 0x47;
off += 192;
}
@@ -1171,7 +1221,7 @@ mod tests {
/// garbage for content under key ≥ 1. The fix tries every key and
/// accepts the one whose output passes the TS-sync verify.
///
/// Grounding: `for idx in try_order { … if aacs::decrypt_unit(&mut attempt, key) { … } }`
/// Grounding: `for idx in try_order { … if aacs::content::decrypt_unit(&mut attempt, key) { … } }`
/// Mutation: revert to the pre-fix `decrypt_unit_full(chunk, &uk, …)` where
/// `uk = raw_keys[unit_key_idx]` (always key 0) → the unit comes out as
/// garbled bytes that still look scrambled, failing the `!ts_sync_destroyed`
@@ -1185,7 +1235,7 @@ mod tests {
let mut unit = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit, &key1);
assert!(
aacs::ts_sync_destroyed(&unit),
aacs::content::ts_sync_destroyed(&unit),
"encrypted unit must look scrambled before decrypt"
);
@@ -1199,13 +1249,13 @@ mod tests {
decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
assert!(
!aacs::ts_sync_destroyed(&buf),
!aacs::content::ts_sync_destroyed(&buf),
"unit encrypted under key1 must be fully decrypted (TS syncs restored)"
);
// Every sync position must carry 0x47.
assert_eq!(
aacs::ts_sync_count(&buf),
aacs::ts_packet_total(&buf),
aacs::content::ts_sync_count(&buf),
aacs::content::ts_packet_total(&buf),
"all TS sync bytes must be restored after decrypting under key1"
);
}
@@ -1230,12 +1280,12 @@ mod tests {
let mut buf = unit;
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
assert!(
!aacs::ts_sync_destroyed(&buf),
!aacs::content::ts_sync_destroyed(&buf),
"single-key disc: TS syncs must be restored"
);
assert_eq!(
aacs::ts_sync_count(&buf),
aacs::ts_packet_total(&buf),
aacs::content::ts_sync_count(&buf),
aacs::content::ts_packet_total(&buf),
"all TS sync bytes must be restored for single-key disc"
);
}
@@ -1263,7 +1313,7 @@ mod tests {
aacs_encrypt_unit_for_test(&mut unit, &real_key);
let ciphertext = unit.clone();
assert!(
aacs::ts_sync_destroyed(&unit),
aacs::content::ts_sync_destroyed(&unit),
"encrypted unit must look scrambled going in"
);
@@ -1277,7 +1327,7 @@ mod tests {
assert_eq!(
dropped,
aacs::ALIGNED_UNIT_LEN,
aacs::content::ALIGNED_UNIT_LEN,
"the whole scrambled unit must be reported as dropped when no key validates"
);
assert_eq!(
@@ -1306,7 +1356,7 @@ mod tests {
aacs_encrypt_unit_for_test(&mut unit_b, &wrong);
let unit_b_ciphertext = unit_b.clone();
let mut buf = Vec::with_capacity(2 * aacs::ALIGNED_UNIT_LEN);
let mut buf = Vec::with_capacity(2 * aacs::content::ALIGNED_UNIT_LEN);
buf.extend_from_slice(&unit_a);
buf.extend_from_slice(&unit_b);
@@ -1318,15 +1368,15 @@ mod tests {
assert_eq!(
dropped,
aacs::ALIGNED_UNIT_LEN,
aacs::content::ALIGNED_UNIT_LEN,
"exactly one unit's worth of bytes must be reported dropped"
);
assert!(
!aacs::ts_sync_destroyed(&buf[..aacs::ALIGNED_UNIT_LEN]),
!aacs::content::ts_sync_destroyed(&buf[..aacs::content::ALIGNED_UNIT_LEN]),
"the decryptable unit must come out clear"
);
assert_eq!(
&buf[aacs::ALIGNED_UNIT_LEN..],
&buf[aacs::content::ALIGNED_UNIT_LEN..],
&unit_b_ciphertext[..],
"the undecryptable unit must be restored to ciphertext"
);
+79 -255
View File
@@ -6,6 +6,18 @@ use crate::mpls;
use crate::sector::SectorSource;
use crate::udf;
/// Stream-file extensions probed for a BD-family playlist clip, in priority
/// order. A clip is normally `.m2ts`; AACS 2.1 (FMTS) discs name the main feature
/// `.fmts` (an M2TS transport stream plus forensic variant segments) and 3D discs
/// use `.ssif`. `.m2ts` is tried first, so a normal clip is unaffected — the
/// fallback only runs when `.m2ts` is absent (exactly when `file_extents` errors).
///
/// Scope: these are all variants that live in `BDMV/STREAM/` and are reached
/// through an MPLS playlist. HD-DVD's `.evo` does NOT belong here — HD-DVD is a
/// different tree (`HVDVD_TS/`) with `.XPL` playlists and needs its own
/// enumerator (a peer to `parse_playlist`), not another extension in this list.
const CLIP_STREAM_EXTS: [&str; 3] = ["m2ts", "fmts", "ssif"];
impl Disc {
/// Scan Blu-ray titles from MPLS playlists.
pub(super) fn scan_bluray_titles(
@@ -73,22 +85,36 @@ impl Disc {
for play_item in &parsed.play_items {
let clip_dur = play_item.out_time.saturating_sub(play_item.in_time) as f64 / 45000.0;
let mut pkt_count: u32 = 0;
let first_ref = seen_clips.insert(play_item.clip_id.clone());
let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id);
if let Ok(clpi_data) = udf_fs.read_file(reader, &clpi_path) {
if let Ok(clip_info) = clpi::parse(&clpi_data) {
pkt_count = clip_info.source_packet_count;
// Mark the clip seen ONLY after its .clpi parses — a transient
// read/parse failure on the first PlayItem referencing a clip
// must not permanently suppress its extents/size for a later
// PlayItem referencing the same clip that succeeds.
let first_ref = seen_clips.insert(play_item.clip_id.clone());
// Only fetch/push the physical extents and add to the
// total size the first time this clip_id is seen.
if first_ref {
total_size += pkt_count as u64 * 192;
// Get m2ts file extents from UDF allocation descriptors.
// Get stream file extents from UDF allocation descriptors.
// Dual-layer discs split files across layers — UDF knows the real layout.
let m2ts_path = format!("/BDMV/STREAM/{}.m2ts", play_item.clip_id);
if let Ok(file_exts) = udf_fs.file_extents(reader, &m2ts_path) {
//
// The clip's stream file is normally `.m2ts`, but AACS 2.1
// (FMTS) discs name the main feature `.fmts` and 3D discs
// use `.ssif` (see [`CLIP_STREAM_EXTS`]). A normal `.m2ts`
// clip is unchanged — the fallback only runs when `.m2ts`
// is absent, which is exactly when `file_extents` errors.
let file_exts = CLIP_STREAM_EXTS.iter().find_map(|ext| {
let path = format!("/BDMV/STREAM/{}.{}", play_item.clip_id, ext);
udf_fs.file_extents(reader, &path).ok()
});
if let Some(file_exts) = file_exts {
for (lba, sectors) in file_exts {
if sectors > 0 && lba > 0 {
extents.push(Extent {
@@ -314,256 +340,7 @@ impl Disc {
#[cfg(test)]
mod tests {
use super::*;
use crate::sector::SectorSource;
use std::collections::HashMap;
// ---------------------------------------------------------------
// In-memory disc backing store
// ---------------------------------------------------------------
/// In-memory SectorSource backed by an absolute-LBA → 2048-byte
/// sector map. Unmapped sectors read as zeroes (matches a freshly
/// formatted region). Mirrors the `MapReader` used in `udf.rs`
/// tests so fixtures are byte-for-byte interoperable.
struct MemDisc {
sectors: HashMap<u32, [u8; 2048]>,
}
impl MemDisc {
fn new() -> Self {
Self {
sectors: HashMap::new(),
}
}
fn put(&mut self, lba: u32, data: [u8; 2048]) {
self.sectors.insert(lba, data);
}
/// Write arbitrary-length bytes starting at `lba`, splitting across
/// consecutive 2048-byte sectors (zero-padded last sector).
fn put_bytes(&mut self, lba: u32, bytes: &[u8]) {
for (i, chunk) in bytes.chunks(2048).enumerate() {
let mut s = [0u8; 2048];
s[..chunk.len()].copy_from_slice(chunk);
self.put(lba + i as u32, s);
}
}
}
impl SectorSource for MemDisc {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let need = count as usize * 2048;
for i in 0..count as u32 {
let off = i as usize * 2048;
let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]);
buf[off..off + 2048].copy_from_slice(&s);
}
Ok(need)
}
}
// ---------------------------------------------------------------
// UDF image builder — produces a disc image `udf::read_filesystem`
// can navigate. All field offsets are cited from ECMA-167 / the
// exact bytes `udf.rs::read_filesystem` reads.
// ---------------------------------------------------------------
/// Fixed layout. PART_START == META_START so file LBAs (physical
/// partition relative) and ICB/dir LBAs (metadata relative) share
/// one address space — both resolve to abs = PART_START + lba. This
/// keeps fixtures small; `read_filesystem` takes the single-partition
/// path (num_partition_maps == 1) so no metadata-partition file is
/// needed.
const PART_START: u32 = 2000;
/// One file's on-disc placement: metadata LBA of its ICB, the LBA of
/// its (single contiguous) data extent, byte length, and whether the
/// ICB encodes its allocation descriptor as a Long AD (16-byte, the
/// real BD-ROM .m2ts layout) vs Short AD (8-byte).
struct FileSpec {
name: String,
icb_lba: u32,
data_lba: u32,
size: u32,
long_ad: bool,
/// Optional explicit file contents written at `data_lba`.
contents: Vec<u8>,
}
/// A directory node for the builder: its ICB LBA, the LBA where its
/// FID list lives, child files, and child subdirectories.
struct DirSpec {
name: String,
icb_lba: u32,
dir_data_lba: u32,
files: Vec<FileSpec>,
subdirs: Vec<DirSpec>,
}
/// Build an Extended File Entry ICB (tag 266) with one allocation
/// descriptor. Offsets per `udf.rs`: tag@0, ICB-tag flags@34,
/// info_length(u64)@56, l_ea@208, l_ad@212, ADs@216.
fn build_file_icb(size: u32, data_lba: u32, long_ad: bool) -> [u8; 2048] {
let mut s = [0u8; 2048];
s[0..2].copy_from_slice(&266u16.to_le_bytes()); // Extended File Entry
if long_ad {
// ICB Tag flags low 3 bits = 1 → Long AD (16-byte stride).
s[34..36].copy_from_slice(&1u16.to_le_bytes());
}
s[56..64].copy_from_slice(&(size as u64).to_le_bytes()); // info_length
s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea
let ad_size: u32 = if long_ad { 16 } else { 8 };
s[212..216].copy_from_slice(&ad_size.to_le_bytes()); // l_ad
// Short/Long AD share length(4)@216 | lba(4)@220. extent_type 0
// (recorded) is top 2 bits = 0, so raw == len.
s[216..220].copy_from_slice(&(size & 0x3FFF_FFFF).to_le_bytes());
s[220..224].copy_from_slice(&data_lba.to_le_bytes());
// Long AD's part_ref(2)@224 + impl_use(6)@226 stay zero.
s
}
/// Build a directory ICB (tag 266) whose single short AD points at the
/// directory's FID data.
fn build_dir_icb(dir_data_lba: u32, dir_data_len: u32) -> [u8; 2048] {
build_file_icb(dir_data_len, dir_data_lba, false)
}
/// Append one File Identifier Descriptor (tag 257) to `buf`.
/// Layout per `read_directory`: tag@0, file_chars@18, l_fi@19,
/// ICB long_ad extent_location(LBA)@24, l_iu(u16)@36, name@(38+l_iu).
/// Name uses UDF compression-id 8 (8-bit ASCII), so the on-disc name
/// field is `[0x08, ascii_bytes...]` and l_fi = 1 + ascii.len().
fn push_fid(buf: &mut Vec<u8>, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) {
let start = buf.len();
let name_field: Vec<u8> = if is_parent {
Vec::new()
} else {
let mut v = vec![0x08u8];
v.extend_from_slice(name.as_bytes());
v
};
let l_fi = name_field.len();
let mut fid = vec![0u8; 38];
fid[0..2].copy_from_slice(&257u16.to_le_bytes()); // FID tag
let mut file_chars = 0u8;
if is_dir {
file_chars |= 0x02;
}
if is_parent {
file_chars |= 0x08;
}
fid[18] = file_chars;
fid[19] = l_fi as u8;
// ICB long_ad: extent_location LBA at offset 24.
fid[24..28].copy_from_slice(&icb_lba.to_le_bytes());
// l_iu (u16) at offset 36 = 0.
fid[36..38].copy_from_slice(&0u16.to_le_bytes());
buf.extend_from_slice(&fid);
buf.extend_from_slice(&name_field);
// Pad to 4-byte alignment (FID stride = (38 + l_iu + l_fi + 3) & !3).
let used = buf.len() - start;
let pad = (used + 3) & !3;
buf.resize(start + pad, 0);
}
/// Recursively lay a DirSpec (and children) into the MemDisc, writing
/// directory ICBs, FID lists, file ICBs, and file data.
fn lay_dir(disc: &mut MemDisc, dir: &DirSpec) {
let mut fids = Vec::new();
// Parent entry first (file_chars bit 0x08) — skipped by the parser
// but present on real discs.
push_fid(&mut fids, "", dir.icb_lba, true, true);
for f in &dir.files {
push_fid(&mut fids, &f.name, f.icb_lba, false, false);
disc.put(
PART_START + f.icb_lba,
build_file_icb(f.size, f.data_lba, f.long_ad),
);
if !f.contents.is_empty() {
disc.put_bytes(PART_START + f.data_lba, &f.contents);
}
}
for sub in &dir.subdirs {
push_fid(&mut fids, &sub.name, sub.icb_lba, true, false);
}
disc.put(
PART_START + dir.icb_lba,
build_dir_icb(dir.dir_data_lba, fids.len() as u32),
);
disc.put_bytes(PART_START + dir.dir_data_lba, &fids);
for sub in &dir.subdirs {
lay_dir(disc, sub);
}
}
/// Build the static UDF anchor/VDS/FSD structure so `read_filesystem`
/// reaches `root_icb_lba`. Single partition map → metadata_start ==
/// partition_start == PART_START.
fn build_udf_skeleton(disc: &mut MemDisc, root_icb_lba: u32) {
// AVDP at sector 256, tag 2 (ECMA-167 §10.2).
let mut avdp = [0u8; 2048];
avdp[0..2].copy_from_slice(&2u16.to_le_bytes());
disc.put(256, avdp);
// Partition Descriptor (tag 5) at sector 32: partition_start@188.
let mut pd = [0u8; 2048];
pd[0..2].copy_from_slice(&5u16.to_le_bytes());
pd[188..192].copy_from_slice(&PART_START.to_le_bytes());
disc.put(32, pd);
// Logical Volume Descriptor (tag 6) at sector 33:
// num_partition_maps(u32)@268 = 1 (single map → no metadata part).
let mut lvd = [0u8; 2048];
lvd[0..2].copy_from_slice(&6u16.to_le_bytes());
lvd[268..272].copy_from_slice(&1u32.to_le_bytes());
disc.put(33, lvd);
// Terminating Descriptor (tag 8) at sector 34 → ends VDS scan.
let mut td = [0u8; 2048];
td[0..2].copy_from_slice(&8u16.to_le_bytes());
disc.put(34, td);
// File Set Descriptor (tag 256) at metadata_start (== PART_START):
// root-dir ICB LBA at offset 404 (long_ad extent_location).
let mut fsd = [0u8; 2048];
fsd[0..2].copy_from_slice(&256u16.to_le_bytes());
fsd[404..408].copy_from_slice(&root_icb_lba.to_le_bytes());
disc.put(PART_START, fsd);
}
fn file(name: &str, icb_lba: u32, data_lba: u32, size: u32, long_ad: bool) -> FileSpec {
FileSpec {
name: name.to_string(),
icb_lba,
data_lba,
size,
long_ad,
contents: Vec::new(),
}
}
fn file_with(
name: &str,
icb_lba: u32,
data_lba: u32,
contents: Vec<u8>,
long_ad: bool,
) -> FileSpec {
FileSpec {
name: name.to_string(),
icb_lba,
data_lba,
size: contents.len() as u32,
long_ad,
contents,
}
}
use crate::udf::fixture::*;
// ---------------------------------------------------------------
// MPLS builder (BD-ROM PlayList spec). Mirrors the layout the
// `mpls::parse` consumer reads (header@0, PlayList@playlist_start,
@@ -797,13 +574,29 @@ mod tests {
u32, /*packets*/
u32, /*data_lba*/
)],
) -> udf::UdfFs {
make_bdmv_fs_ext(disc, clips, "m2ts")
}
/// As [`make_bdmv_fs`] but the STREAM file carries `stream_ext` instead of
/// `.m2ts` (e.g. "fmts" for an AACS 2.1 feature clip, "ssif" for 3D) — drives
/// the [`CLIP_STREAM_EXTS`] fallback in `parse_playlist`.
fn make_bdmv_fs_ext(
disc: &mut MemDisc,
clips: &[(
&str,
u32, /*sectors*/
u32, /*packets*/
u32, /*data_lba*/
)],
stream_ext: &str,
) -> udf::UdfFs {
// Layout LBAs: pick widely separated values to avoid collisions.
let mut stream_files = Vec::new();
let mut clipinf_files = Vec::new();
let mut icb = 100u32;
for (name, sectors, packets, data_lba) in clips {
let m2ts = format!("{name}.m2ts");
let m2ts = format!("{name}.{stream_ext}");
// Size in bytes — file_extents derives sectors via div_ceil(2048).
let size = sectors * 2048;
stream_files.push(file(&m2ts, icb, *data_lba, size, true));
@@ -881,6 +674,37 @@ mod tests {
assert_eq!(t.clips[0].source_packets, 4000);
}
/// AACS 2.1: the feature clip is `00001.fmts`, NOT `.m2ts`. The
/// [`CLIP_STREAM_EXTS`] fallback in `parse_playlist` must still resolve the
/// physical extent — before the fix the hard-coded `.m2ts` path errored,
/// yielding empty extents (a silent empty rip and 0 encrypted samples for key
/// resolution). Size still comes from the `.clpi`, which parses regardless.
#[test]
fn parse_playlist_fmts_clip_resolves_extent() {
let mut disc = MemDisc::new();
// Only a .fmts stream exists for clip 00001 (no .m2ts on disc).
let udf = make_bdmv_fs_ext(&mut disc, &[("00001", 1000, 4000, 5000)], "fmts");
let mpls = build_mpls(
&[PiSpec {
clip_id: *b"00001",
in_time: 0,
out_time: 60 * 45000,
}],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
&[],
);
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
assert_eq!(t.size_bytes, 4000 * 192, "size from .clpi source packets");
assert_eq!(
t.extents.len(),
1,
"the .fmts extent must be resolved via fallback"
);
assert_eq!(t.extents[0].start_lba, PART_START + 5000);
assert_eq!(t.extents[0].sector_count, 1000);
}
/// THE 0.31.0 DEDUP PATH. A playlist that references the SAME clip_id
/// from multiple PlayItems (seamless split / looped segment) must count
/// the physical extents and packet bytes EXACTLY ONCE — mux reads
+12 -11
View File
@@ -922,18 +922,19 @@ mod tests {
2,
"DTS 2.0 nibble must decode to 2 channels"
);
// PIDs route via the per-codec sub-id table: AC-3 #0 → 0x80 → 0xBD80,
// DTS #0 → 0x88 → 0xBD88. Distinct (no 0xBD00 collision) AND the exact
// canonical PIDs.
assert_eq!(audios[0].pid, 0xBD80, "AC-3 #0 → 0xBD80");
assert_eq!(audios[1].pid, 0xBD88, "DTS #0 → 0xBD88");
// PIDs route via the positional sub-id table: AC-3 @ pos 0 → 0x80 →
// 0xBD80, DTS @ pos 1 → 0x89 → 0xBD89 (the shared audio-stream number
// in the low nibble, NOT a per-codec ordinal). Distinct AND the exact
// canonical wire PIDs the demux routes on.
assert_eq!(audios[0].pid, 0xBD80, "AC-3 @ pos 0 → 0xBD80");
assert_eq!(audios[1].pid, 0xBD89, "DTS @ pos 1 → 0xBD89");
assert_ne!(audios[0].pid, audios[1].pid);
}
/// LPCM SCAN ROUTING (audit §2 / §5 #6): the 0xA0..=0xA7 PID range was never
/// exercised in the dvd.rs scan. An LPCM stream (coding_mode 4) must get
/// sub_stream_id 0xA0 → PID 0xBDA0 via `dvd_audio_pid`, distinct from the
/// AC-3 0xBD80 space, with its real channel count preserved.
/// exercised in the dvd.rs scan. An LPCM stream (coding_mode 4) at audio
/// position 1 must get sub_stream_id 0xA1 → PID 0xBDA1 via `dvd_audio_pid`,
/// distinct from the AC-3 0xBD80 space, with its real channel count preserved.
#[test]
fn scan_dvd_titles_lpcm_routes_to_a0_pid_range() {
let mut disc = MemDisc::new();
@@ -978,10 +979,10 @@ mod tests {
assert_eq!(audios.len(), 2);
assert_eq!(audios[0].codec, Codec::Ac3);
assert_eq!(audios[1].codec, Codec::Lpcm, "coding_mode 4 → LPCM");
assert_eq!(audios[0].pid, 0xBD80, "AC-3 #0 → 0xBD80");
assert_eq!(audios[0].pid, 0xBD80, "AC-3 @ pos 0 → 0xBD80");
assert_eq!(
audios[1].pid, 0xBDA0,
"LPCM #0 → 0xBDA0 (the 0xA0 sub-id range), NOT the AC-3 space"
audios[1].pid, 0xBDA1,
"LPCM @ pos 1 → 0xBDA1 (the 0xA0 sub-id range | position), NOT the AC-3 space"
);
assert_eq!(
audios[1].channels.count(),
+265 -219
View File
@@ -11,47 +11,90 @@ use crate::udf;
pub(super) struct HandshakeResult {
pub volume_id: [u8; 16],
pub read_data_key: Option<[u8; 16]>,
/// When `read_data_key` is `None` because the bus-key read FAILED (as opposed
/// to a path that never attempts it), the error code from `read_data_keys`.
/// Carried so the downstream bus-key gate can log WHY the bus key is missing
/// instead of a bare "unavailable" — the difference between a diagnosable log
/// and archaeology.
pub read_data_key_err: Option<u16>,
/// True when the VID came from an unlocker (in `freemkv-unlock`) that
/// unlocked the drive. Such a drive serves CLEAR
/// content, so AACS bus encryption is already removed AT THE DRIVE — the same
/// end state a successful cert handshake's `read_data_key` provides, just via
/// firmware instead of the AKE. The bus-key gate MUST credit this as a valid
/// bus-removal: bus encryption is unremovable only when NEITHER the firmware
/// unlocked the drive NOR the cert handshake yielded a bus key. Without this,
/// a SUCCESSFUL unlock (VID present, `read_data_key: None`) paradoxically trips
/// the gate and blocks ALL key resolution (incl. the online source).
pub drive_unlocked: bool,
}
/// In-tree AACS host-certificate cert-auth "unlocker" — the Drive-level peer of
/// the external firmware [`crate::unlock::Unlocker`]s.
/// Single source of truth for "is AACS bus encryption gone for this scan?". The
/// gate asks ONLY this — `if !removed { error }` — never enumerating cases. Bus
/// encryption is gone when ANY of these holds:
/// - the disc never had it (`!bus_encryption`): nothing to remove;
/// - file/ISO reads (`handshake == None`): content is already clear at read time;
/// - an unlocker unlocked the drive (`drive_unlocked`): it serves clear
/// content;
/// - the cert handshake produced the bus key (`read_data_key`).
///
/// It is NOT a registry `dyn Unlocker`: the cert handshake helpers
/// ([`crate::aacs::handshake::aacs_authenticate`] et al.) operate on a concrete
/// `&mut Drive`, whereas the registry trait hands out a `&mut dyn ScsiTransport`
/// for external firmware unlockers (and keeps their unit tests trivially
/// fakeable). So the firmware path stays transport-level and registry-routed,
/// while this cert path is an in-tree Drive-level peer invoked directly by
/// [`Disc::do_handshake`]. Both produce a Volume ID under the shared
/// [`crate::unlock::UnlockError`] taxonomy.
/// Add a NEW removal mechanism HERE, never in the gate.
fn bus_encryption_removed(bus_encryption: bool, handshake: Option<&HandshakeResult>) -> bool {
if !bus_encryption {
return true; // never had it → nothing to remove
}
match handshake {
None => true, // file/ISO: clear at read time
Some(h) => h.drive_unlocked || h.read_data_key.is_some(),
}
}
/// libfreemkv-side driver for the AACS cert route. It owns the host-cert
/// collection (a keysource concern that stays in libfreemkv) and then dispatches
/// the actual mutual-auth to the `freemkv-unlock` AACS unlocker via the
/// [`crate::unlock_bridge`]. The firmware (drive-prep) and CSS routes dispatch
/// the same way at their own call sites; this one carries the host certs.
struct AacsCertUnlocker<'a> {
opts: &'a ScanOptions,
}
/// Why the AACS cert path produced no Volume ID. Distinguishes the libfreemkv-
/// side "no host cert at all" case (which carries the disc MKB generation for
/// the outcome trace) from the unlocker-reported [`freemkv_unlock::UnlockError`].
enum CertUnlockFailure {
/// No host cert was available from any source — detected in libfreemkv
/// before the unlocker runs, so the MKB generation is still known.
NoHostCert { mkb: Option<u32> },
/// The AACS unlocker ran and reported a specific failure.
Unlock(freemkv_unlock::UnlockError),
}
impl AacsCertUnlocker<'_> {
/// Run the host-certificate mutual-auth handshake: collect non-compiled-in
/// host certs from the key sources + credentials, try each (wedge-guarded),
/// and on success read the Volume ID + `read_data_key` (the AACS 2.0 bus
/// key). Returns a structured [`crate::unlock::UnlockError`] on every
/// no-VID outcome.
/// host certs from the key sources + credentials, then hand them to the AACS
/// unlocker (via the `freemkv-unlock` dispatch), which tries each cert
/// (wedge-guarded) and on success yields the Volume ID + `read_data_key`
/// (the AACS 2.0 bus key). Returns a [`CertUnlockFailure`] on every no-VID
/// outcome.
fn authenticate(
&self,
session: &mut crate::drive::Drive,
) -> std::result::Result<HandshakeResult, crate::unlock::UnlockError> {
) -> std::result::Result<HandshakeResult, CertUnlockFailure> {
use crate::aacs;
use crate::unlock::UnlockError;
use freemkv_unlock::UnlockError;
// MKB generation (best-effort) — forwarded to each source's
// `host_certs(mkb)` so a source MAY select a generation-appropriate cert
// (the default impl ignores it). A read failure leaves it `None`.
let mkb_gen = aacs::read_mkb_from_drive(session)
let mkb_gen = aacs::inf::read_mkb_from_drive(session.scsi_mut())
.ok()
.and_then(|m| aacs::mkb_version(&m));
.and_then(|m| aacs::mkb::mkb_version(&m));
// Host certs are keysource-served, never compiled in — unioned from the
// explicit `DriveCredentials` and the key-source layer. With ZERO certs
// the cert route cannot run: NoUsableHostCert (folded to AacsNoHostCert
// by the caller, preserving the graceful path-1 disc-hash → VUK fallback).
// the cert route cannot run: NoHostCert (folded to AacsNoHostCert by the
// caller, preserving the graceful path-1 disc-hash → VUK fallback). This
// is detected here, where the MKB generation is still in hand.
let host_certs = Disc::collect_host_certs(self.opts, mkb_gen);
if host_certs.is_empty() {
tracing::warn!(
@@ -59,122 +102,70 @@ impl AacsCertUnlocker<'_> {
phase = "handshake_no_host_cert",
"No AACS host certificate available from any key source, so the host-certificate handshake can't run."
);
return Err(UnlockError::NoUsableHostCert { mkb: mkb_gen });
return Err(CertUnlockFailure::NoHostCert { mkb: mkb_gen });
}
let host_cert_count = host_certs.len();
tracing::debug!(
target: "freemkv::disc",
phase = "handshake_start",
host_cert_count,
"handshake starting"
);
// Cert-attempt wedge guard. An earlier version fired up to 16 AACS
// authenticate attempts back-to-back with no pause — 80-160 SCSI
// REPORT_KEY/SEND_KEY commands in a few hundred ms, which can drive
// consumer optical drives into a fast-fail firmware wedge (every CDB
// returns ILLEGAL_REQUEST until power-cycled). Defense-in-depth: cap
// attempts, sleep between, bail early on the drive's wedge sense.
const MAX_CERT_ATTEMPTS: usize = 3;
const PER_CERT_BACKOFF_MS: u64 = 1000;
let mut last_err_code: Option<u16> = None;
for (idx, hc) in host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
if idx > 0 {
std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS));
}
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
Ok(mut auth) => {
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
Ok(vid) => vid,
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_vid_read_failed",
cert_index = idx,
error_code = e.code(),
"auth ok but volume ID read failed"
);
return Err(UnlockError::VidUnavailable);
}
};
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
.ok()
.map(|(rdk, _)| rdk);
tracing::debug!(
target: "freemkv::disc",
phase = "handshake_ok",
cert_index = idx,
has_read_data_key = read_data_key.is_some(),
);
return Ok(HandshakeResult {
volume_id,
read_data_key,
});
}
Err(e) => {
last_err_code = Some(e.code());
// Read the wedge sense off the structured ScsiSense, NOT
// `e.code()` (a flat constant for every ScsiError). On
// ILLEGAL_REQUEST the drive is signalling it won't talk to us
// — trying more certs worsens the wedge, so bail immediately.
let sense = e.scsi_sense();
if sense.map(|s| s.is_illegal_request()).unwrap_or(false) {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_wedge_detected",
cert_index = idx,
sense_key = sense.map(|s| s.sense_key),
asc = sense.map(|s| s.asc),
ascq = sense.map(|s| s.ascq),
"drive returned ILLEGAL_REQUEST during auth; bailing out to avoid wedge"
);
return Err(UnlockError::HandshakeRejected);
}
continue;
}
}
}
tracing::info!(
target: "freemkv::disc",
phase = "vid_cert_rejected",
host_cert_count,
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
last_error_code = last_err_code,
"The drive rejected the AACS host certificate, so no Volume ID was obtained."
// Hand the collected certs to the AACS unlocker. The cert-route bus
// removal depends on the read_data_key, NOT a drive unlock. The
// borrow checker can't split `session` across `scsi_mut()` + `&drive_id`
// through method calls, so clone the (cheap) identity first.
let drive_id = session.drive_id.clone();
let fu_certs = crate::unlock_bridge::map_host_certs(&host_certs);
let (_, unlock_res) = crate::unlock_bridge::run_bus(
session.scsi_mut(),
&drive_id,
freemkv_unlock::DiscKind::Aacs,
&fu_certs,
);
Err(UnlockError::HandshakeRejected)
let unlocked = unlock_res.map_err(CertUnlockFailure::Unlock)?;
// The cert handshake yields a VID on success; its absence is VidUnavailable.
let Some(volume_id) = unlocked.vid else {
return Err(CertUnlockFailure::Unlock(UnlockError::VidUnavailable));
};
Ok(HandshakeResult {
volume_id,
read_data_key: unlocked.bus_key,
// The generic `Unlocked` contract carries no bus-key error code; the
// AACS-specific "why the read_data_key read failed" diagnostic does
// not cross the seam. The bus-key gate keys off presence, not cause.
read_data_key_err: None,
drive_unlocked: unlocked.drive_unlocked,
})
}
}
/// Map an [`crate::unlock::UnlockError`] from the cert path back to the
/// `Error` variant `do_handshake_cert` has always surfaced, so `scan_with`'s
/// rendering and the path-1 disc-hash → VUK fallback are byte-for-byte
/// unchanged. (`NoUsableHostCert` keeps the `<no host cert>` sentinel.)
fn unlock_error_to_error(e: crate::unlock::UnlockError) -> Error {
use crate::unlock::UnlockError;
/// Map a [`CertUnlockFailure`] back to the `Error` variant `do_handshake_cert`
/// has always surfaced, so `scan_with`'s rendering and the path-1 disc-hash →
/// VUK fallback are byte-for-byte unchanged. (`NoHostCert` keeps the
/// `<no host cert>` sentinel.)
fn unlock_error_to_error(e: &CertUnlockFailure) -> Error {
use freemkv_unlock::UnlockError;
match e {
UnlockError::NoUsableHostCert { .. } => Error::AacsNoHostCert {
CertUnlockFailure::NoHostCert { .. }
| CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => Error::AacsNoHostCert {
path: "<no host cert>".into(),
},
UnlockError::VidUnavailable => Error::AacsVidUnavailable,
UnlockError::HandshakeRejected
| UnlockError::CertRevoked { .. }
| UnlockError::FirmwareNotUnlockable
| UnlockError::Scsi(_) => Error::AacsHostCertRejected,
CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => Error::AacsVidUnavailable,
CertUnlockFailure::Unlock(
UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport,
) => Error::AacsHostCertRejected,
}
}
/// Map a cert-path [`crate::unlock::UnlockError`] to a structured
/// [`crate::aacs::UnlockOutcome`] for the resolution trace (English-free).
fn cert_unlock_outcome(e: &crate::unlock::UnlockError) -> crate::aacs::UnlockOutcome {
use crate::aacs::UnlockOutcome;
use crate::unlock::UnlockError;
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::trace::UnlockOutcome`]
/// for the resolution trace (English-free).
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::trace::UnlockOutcome {
use crate::aacs::trace::UnlockOutcome;
use freemkv_unlock::UnlockError;
match e {
UnlockError::FirmwareNotUnlockable => UnlockOutcome::FirmwareNotUnlockable,
UnlockError::NoUsableHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
UnlockError::CertRevoked { mkb } => UnlockOutcome::CertRevoked { mkb: *mkb },
UnlockError::VidUnavailable => UnlockOutcome::VidUnavailable,
UnlockError::HandshakeRejected | UnlockError::Scsi(_) => UnlockOutcome::HandshakeRejected,
CertUnlockFailure::NoHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => {
UnlockOutcome::NoUsableHostCert { mkb: None }
}
CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => UnlockOutcome::VidUnavailable,
CertUnlockFailure::Unlock(
UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport,
) => UnlockOutcome::HandshakeRejected,
}
}
@@ -183,11 +174,11 @@ impl Disc {
/// a structured `HandshakeResult` for downstream key resolution.
///
/// VID acquisition runs through [`Self::do_handshake_cert`], which first
/// asks the pluggable [`crate::unlock::Unlocker`] seam for the OEM VID
/// uses the OEM VID a firmware unlocker may have stashed at drive `init()`
/// (a drive-functionality capability decoupled from the host cert + HRL)
/// and falls back to the cert-based mutual-auth handshake when no
/// unlocker serves one. The cert path also yields `read_data_key`,
/// required for AACS 2.0 bus decryption.
/// and falls back to the cert-based mutual-auth handshake (dispatched to the
/// `freemkv-unlock` AACS unlocker) when none is present. The cert path also
/// yields `read_data_key`, required for AACS 2.0 bus decryption.
///
/// Returns `(handshake, error)`:
/// * `(Some(_), None)` — VID acquired
@@ -219,11 +210,11 @@ impl Disc {
/// Cert-based AACS handshake — the cert route for VID acquisition.
///
/// Before running the cert mutual-auth, this asks the pluggable
/// [`crate::unlock::Unlocker`] seam for the OEM Volume ID. An unlocker
/// unlocks *drive functionality*, not just the disc: VID retrieval via
/// the drive's OEM CDB is a capability separate from `unlock`. When the
/// matching unlocker serves a VID, we use it and SKIP the cert handshake
/// Before running the cert mutual-auth, this checks for an OEM Volume ID a
/// firmware unlocker stashed at drive `init()`. Such an unlocker unlocks
/// *drive functionality*, not just the disc: VID retrieval via the drive's
/// OEM CDB is a capability separate from `unlock`. When one served a VID,
/// we use it and SKIP the cert handshake
/// entirely — the OEM path gets the VID *without* the host certificate +
/// HRL, decoupling VID from the cert chain. The OEM path yields no
/// `read_data_key` (no bus-key is derived); AACS 2.0 content needing
@@ -239,24 +230,23 @@ impl Disc {
/// `mkb` is the disc's MKB generation when known, forwarded to each source's
/// [`crate::KeySource::host_certs`] so a source MAY return only
/// generation-appropriate certs (the default ignores it).
fn collect_host_certs(opts: &ScanOptions, mkb: Option<u32>) -> Vec<crate::aacs::HostCert> {
let mut host_certs: Vec<crate::aacs::HostCert> = Vec::new();
if let Some(c) = &opts.credentials {
host_certs.extend(c.host_certs.iter().cloned());
}
for src in &opts.key_sources {
host_certs.extend(src.host_certs(mkb));
}
host_certs
fn collect_host_certs(
opts: &ScanOptions,
mkb: Option<u32>,
) -> Vec<crate::aacs::types::HostCert> {
// Delegates to the shared cert primitive (the external freemkv-unlock-aacs
// plugin uses the same one). Kept as a thin Disc method so the existing
// collect_host_certs_* unit tests and call sites are unchanged.
crate::aacs::host_certs::collect_host_certs(opts, mkb)
}
fn do_handshake_cert(
session: &mut crate::drive::Drive,
opts: &ScanOptions,
) -> (Option<HandshakeResult>, Option<Error>) {
// OEM VID shortcut: a matching firmware unlocker stashed the disc's
// Volume ID at drive `init()` (the new `unlock()` folds in the old
// `read_volume_id`). Use it and SKIP the cert handshake — the OEM path
// OEM VID shortcut: a matching unlocker stashed the disc's Volume ID at
// drive `init()` (the new `unlock()` folds in the old `read_volume_id`).
// Use it and SKIP the cert handshake — the OEM path
// decouples the VID from the host cert + HRL. It yields no
// `read_data_key`; a bus-encrypted disc that needs the bus key is caught
// by the bus-key gate in `resolve_vid_only`.
@@ -270,6 +260,13 @@ impl Disc {
Some(HandshakeResult {
volume_id,
read_data_key: None,
// OEM/VID-only path never attempts the bus-key read — None here
// is "not attempted", not "failed".
read_data_key_err: None,
// The unlocker stashed this VID at init, which means it
// matched and unlocked the drive — it now serves clear content,
// so bus encryption is removed at the drive. Credit it.
drive_unlocked: true,
}),
None,
);
@@ -296,7 +293,7 @@ impl Disc {
outcome = ?cert_unlock_outcome(&e),
"AACS cert handshake produced no VID; a key source may still supply this disc's key."
);
(None, Some(unlock_error_to_error(e)))
(None, Some(unlock_error_to_error(&e)))
}
}
}
@@ -315,69 +312,97 @@ impl Disc {
use crate::aacs;
let uk_ro_data = udf_fs
.read_file(reader, "/AACS/Unit_Key_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
.map_err(|_| Error::AacsNoKeys)?;
let dh = aacs::disc_hash(&uk_ro_data);
let dh = aacs::inf::disc_hash(&uk_ro_data);
let cc = udf_fs
.read_file(reader, "/AACS/Content000.cer")
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
.ok()
.as_deref()
.and_then(aacs::parse_content_cert);
.and_then(aacs::inf::parse_content_cert);
let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false);
let version = match cc.as_ref().map(|c| c.version) {
Some(aacs::AacsVersion::V10) => 1,
Some(_) => 2,
None if bus_encryption => 2,
None => 1,
};
// No-cert default = UHD (V20 stride), matching `read_aacs_version` so the
// scanned `AacsState.version` and the out-of-band fetch agree. A wrong
// stride on the main resolve path fails loudly (sample validation) rather
// than silently, so the conservative V20 default is safe here too.
let version = cc
.as_ref()
.map(|c| c.version.major())
.unwrap_or(aacs::mkb::AACS_MAJOR_UHD);
// OEM bus-key gate (wrong-keys guard). A bus-encrypted disc (Content
// Certificate bus-encryption bit set) still carries bus encryption on
// its sectors; descrambling needs the `read_data_key` (bus key), which
// ONLY the AACS host-certificate cert-auth handshake produces. A
// VID-only OEM unlock path returns `read_data_key: None`, and a VID
// alone does NOT remove bus encryption — so if a handshake ran (live
// drive) and yielded a VID but no bus key on a bus-encrypted disc, the
// bytes would decrypt to garbage. Fail loudly here instead.
// Bus-encryption gate (wrong-keys guard). A bus-encrypted disc (Content
// Certificate bus-encryption bit set) carries bus encryption on its
// sectors, which MUST be removed before any AACS key can decrypt them.
// There are TWO ways it gets removed, and bus encryption is unremovable
// only when NEITHER succeeded:
// 1. An unlocker unlocked the drive → it serves CLEAR content
// (`drive_unlocked`). This is the common live-drive case and yields
// no `read_data_key` — it doesn't need one.
// 2. The AACS host-certificate cert-auth handshake produced the bus key
// (`read_data_key`).
// The old gate credited ONLY (2), so a SUCCESSFUL drive unlock (VID
// present, `read_data_key: None`, `drive_unlocked: true`) tripped it and
// blocked ALL key resolution — including the online source — even though
// the drive was serving clear content. That was the bug.
//
// Gated on `handshake.is_some()` so the two preserved cases never
// regress: (1) file-backed/ISO scans reach here with `handshake = None`
// and have already had bus encryption removed at read time; (2) AACS 1.0
// BD is not bus-encrypted, so `bus_encryption` is false and the gate is
// skipped (its `read_data_key` is legitimately absent).
if bus_encryption && handshake.is_some_and(|h| h.read_data_key.is_none()) {
// Also skipped when `handshake = None` (file-backed/ISO scans — bus
// encryption already removed at read time) and when `bus_encryption` is
// false (AACS 1.0 BD is not bus-encrypted).
// ONE question — "is AACS bus encryption gone?" — asked of the single
// `bus_encryption_removed` predicate, which OWNS every case (never had it,
// file/ISO, drive unlock, cert bus key). The gate enumerates nothing.
if !bus_encryption_removed(bus_encryption, handshake) {
let (rdk_err, has_vid) = handshake
.map(|h| (h.read_data_key_err, h.volume_id != [0u8; 16]))
.unwrap_or((None, false));
tracing::warn!(
target: "freemkv::disc",
phase = "bus_key_unavailable",
"Disc declares bus encryption but the handshake produced no read_data_key; a VID-only/OEM unlock cannot remove bus encryption. Refusing to proceed with a key that would decrypt to garbage."
read_data_key_err = ?rdk_err,
has_volume_id = has_vid,
"Disc declares bus encryption but it could not be removed: no unlocker \
unlocked the drive AND the cert handshake produced no read_data_key. Refusing to \
emit a key that would decrypt to garbage."
);
return Err(Error::AacsBusKeyUnavailable);
}
// MKB_RO/RW are allocated to a fixed ~128 MiB and zero-padded; trim to
// the real record length (same as `read_aacs_inputs`). Without this the
// MKB stashed on `AacsState` — which `Disc::inputs()` and the device/
// processing-key `decrypt_with` derivation consume, and which a key
// source ships to an online service — is the full 128 MiB pad, not the
// ~few-MB record stream.
let mkb_bytes = udf_fs
.read_file(reader, "/AACS/MKB_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf"))
.ok()
.unwrap_or_default();
// Trim to the real record length. Use trim_mkb rather than a raw
// truncate: trim_mkb only truncates when content_len > 0 and strictly
// inside the buffer, so a malformed/unrecognised MKB is preserved
// intact instead of being zeroed by truncate(0).
let mkb_bytes = aacs::trim_mkb(mkb_bytes);
let mkb_ver = aacs::mkb_version(&mkb_bytes);
// Read the MKB record stream via the SAME bounded reader the
// out-of-band `read_aacs_inputs` uses (`read_mkb_content`: a prefix-grow
// read + trim), NOT a full `read_file`. MKB_RO/RW is allocated to a
// fixed ~128 MiB of zero padding, and a full `read_file` of it FAILS on
// file-backed / large readers — which left `a.mkb` empty here, silently
// breaking online key resolution: `Disc::inputs()` shipped `mkb=0` to
// the decode service and it 404'd, while autorip's separate
// `read_aacs_inputs` path (this same helper) worked. One reader now, so
// `Disc::inputs()` is the single complete source of AACS inputs.
// A read ERROR is surfaced (logged), not silently emptied: an empty MKB
// here is invisible until an online key service rejects the request, so
// a transient I/O hiccup must not masquerade as "no MKB". We still
// continue with an empty MKB (disc-hash-keyed keydb lookups don't need
// it), but the cause is now on the log.
let mkb_bytes = match Self::read_mkb_content(reader, udf_fs) {
Ok(m) => m,
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_mkb",
error = %e,
"MKB read failed at scan; AACS inputs will carry an empty MKB \
(online key resolution cannot proceed without it). Continuing \
— disc-hash-keyed lookups are unaffected."
);
Vec::new()
}
};
let mkb_ver = aacs::mkb::mkb_version(&mkb_bytes);
tracing::debug!(
target: "freemkv::disc",
phase = "scan_aacs_vid_only",
disc_hash = %aacs::disc_hash_hex(&dh),
disc_hash = %aacs::inf::disc_hash_hex(&dh),
version,
bus_encryption,
has_vid = handshake.is_some(),
@@ -388,7 +413,7 @@ impl Disc {
version,
bus_encryption,
mkb_version: mkb_ver,
disc_hash: aacs::disc_hash_hex(&dh),
disc_hash: aacs::inf::disc_hash_hex(&dh),
key_source: KeyOrigin::ExternalUk,
vuk: None,
unit_keys: vec![],
@@ -547,7 +572,7 @@ mod tests {
}
/// A content certificate: type byte@0 (0x00 = V10, else V20),
/// bus_encryption bit7@1, cc_id@14..20 (aacs/keys.rs parse_content_cert,
/// bus_encryption bit7@1, cc_id@14..20 (aacs/inf.rs parse_content_cert,
/// which requires ≥20 bytes and reads the bus flag from `data[1] >> 7`).
fn build_content_cert(cert_type: u8, bus_encryption: bool) -> Vec<u8> {
let mut v = vec![0u8; 20];
@@ -559,7 +584,7 @@ mod tests {
/// An MKB with one Type-and-Version record (type 0x10) carrying the
/// version as BE u32 at record offset 8, followed by a recorded EOF
/// record then trailing zero padding. mkb_content_len walks records
/// and stops at the first padding (type 0) byte (aacs/keys.rs).
/// and stops at the first padding (type 0) byte (aacs/inf.rs).
fn build_mkb(version: u32, pad_to: usize) -> Vec<u8> {
let mut v = Vec::new();
// Type 0x10 record, length 16 (>= 12 so version is read).
@@ -646,10 +671,12 @@ mod tests {
assert!(st.bus_encryption, "cert bus_encryption bit must propagate");
}
/// No content cert at all but bus_encryption can't be read → version
/// defaults to 1 (encrypt.rs: `None => 1`). bus_encryption false.
/// No content cert at all → version defaults to UHD (major 2), matching
/// `read_aacs_version` so the scanned `AacsState.version` and the out-of-band
/// fetch agree on the Unit_Key_RO stride (audit #4: a wrong BD-vs-UHD guess
/// mis-parses unit keys). bus_encryption false (unreadable → off).
#[test]
fn resolve_vid_only_no_cert_defaults_version_1() {
fn resolve_vid_only_no_cert_defaults_version_uhd() {
let mut disc = MemDisc::new();
let udf = build_aacs_fs(
&mut disc,
@@ -661,12 +688,16 @@ mod tests {
}],
);
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
assert_eq!(st.version, 1, "no cert → default version 1");
assert_eq!(
st.version,
aacs::mkb::AACS_MAJOR_UHD,
"no cert → default UHD (major 2)"
);
assert!(!st.bus_encryption);
}
/// disc_hash is SHA1 of the Unit_Key_RO.inf bytes, hex with 0x prefix
/// and uppercase (aacs::disc_hash + disc_hash_hex). The state's
/// and uppercase (aacs::inf::disc_hash + disc_hash_hex). The state's
/// disc_hash must match independently computing it over the same bytes.
#[test]
fn resolve_vid_only_disc_hash_is_sha1_of_unit_key_ro() {
@@ -682,7 +713,7 @@ mod tests {
}],
);
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
let expected = aacs::disc_hash_hex(&aacs::disc_hash(&uk));
let expected = aacs::inf::disc_hash_hex(&aacs::inf::disc_hash(&uk));
assert_eq!(st.disc_hash, expected);
assert!(st.disc_hash.starts_with("0x"));
// uk_ro must be stashed verbatim for the external resolver.
@@ -718,7 +749,7 @@ mod tests {
// Real record stream is the single 16-byte type-0x10 record.
assert_eq!(
st.mkb.len(),
aacs::mkb_content_len(&mkb),
aacs::mkb::mkb_content_len(&mkb),
"MKB must be trimmed to record-stream length, not the zero-pad"
);
assert_eq!(st.mkb.len(), 16);
@@ -765,6 +796,8 @@ mod tests {
let hs = HandshakeResult {
volume_id: vid,
read_data_key: Some(rdk),
read_data_key_err: None,
drive_unlocked: false,
};
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("state");
assert_eq!(st.volume_id, vid);
@@ -809,6 +842,8 @@ mod tests {
let hs = HandshakeResult {
volume_id: [0x11u8; 16],
read_data_key: None,
read_data_key_err: None,
drive_unlocked: false,
};
let err = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs))
.expect_err("bus-encrypted disc with no bus key must hard-error");
@@ -823,6 +858,8 @@ mod tests {
let hs = HandshakeResult {
volume_id: [0x11u8; 16],
read_data_key: Some([0x22u8; 16]),
read_data_key_err: None,
drive_unlocked: false,
};
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("bus key present → ok");
assert!(st.bus_encryption);
@@ -849,6 +886,8 @@ mod tests {
let hs = HandshakeResult {
volume_id: [0x11u8; 16],
read_data_key: None,
read_data_key_err: None,
drive_unlocked: false,
};
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("AACS 1.0 → ok");
assert!(!st.bus_encryption);
@@ -907,7 +946,7 @@ mod tests {
// disc_hash must be computed over the DUPLICATE bytes.
assert_eq!(
st.disc_hash,
aacs::disc_hash_hex(&aacs::disc_hash(&uk)),
aacs::inf::disc_hash_hex(&aacs::inf::disc_hash(&uk)),
"fallback must hash the DUPLICATE Unit_Key_RO.inf"
);
assert_eq!(st.uk_ro, uk);
@@ -929,8 +968,8 @@ mod tests {
// the route fails gracefully (AacsNoHostCert), never panics.
// ---------------------------------------------------------------
fn fake_cert(tag: u8) -> aacs::HostCert {
aacs::HostCert {
fn fake_cert(tag: u8) -> aacs::types::HostCert {
aacs::types::HostCert {
private_key: [tag; 20],
certificate: vec![tag; 92],
private_key_v2: None,
@@ -939,15 +978,15 @@ mod tests {
}
/// A minimal in-test KeySource that yields no keys but a fixed cert list.
struct CertSource(Vec<aacs::HostCert>);
struct CertSource(Vec<aacs::types::HostCert>);
impl crate::KeySource for CertSource {
fn get_uk(
&self,
_ctx: &dyn crate::keysource::ResolveCtx,
) -> Result<Vec<crate::aacs::UnitKey>> {
) -> Result<Vec<crate::aacs::types::UnitKey>> {
Ok(Vec::new())
}
fn host_certs(&self, _mkb: Option<u32>) -> Vec<aacs::HostCert> {
fn host_certs(&self, _mkb: Option<u32>) -> Vec<aacs::types::HostCert> {
self.0.clone()
}
}
@@ -1013,45 +1052,52 @@ mod tests {
#[test]
fn unlock_error_maps_to_legacy_error_variants() {
use crate::unlock::UnlockError;
// No host cert keeps the AacsNoHostCert sentinel path.
match unlock_error_to_error(UnlockError::NoUsableHostCert { mkb: Some(68) }) {
use freemkv_unlock::UnlockError;
// No host cert (libfreemkv-side, carries mkb) keeps the AacsNoHostCert
// sentinel path — as does the unlocker's own NoUsableHostCert.
match unlock_error_to_error(&CertUnlockFailure::NoHostCert { mkb: Some(68) }) {
Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"),
other => panic!("expected AacsNoHostCert, got {other:?}"),
}
match unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert)) {
Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"),
other => panic!("expected AacsNoHostCert, got {other:?}"),
}
assert!(matches!(
unlock_error_to_error(UnlockError::VidUnavailable),
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)),
Error::AacsVidUnavailable
));
assert!(matches!(
unlock_error_to_error(UnlockError::HandshakeRejected),
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)),
Error::AacsHostCertRejected
));
// A transport fault folds to the rejected surface too.
assert!(matches!(
unlock_error_to_error(UnlockError::CertRevoked { mkb: None }),
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::Transport)),
Error::AacsHostCertRejected
));
}
#[test]
fn cert_unlock_outcome_maps_to_structured_trace_step() {
use crate::aacs::UnlockOutcome;
use crate::unlock::UnlockError;
use crate::aacs::trace::UnlockOutcome;
use freemkv_unlock::UnlockError;
// The libfreemkv-side no-cert case carries the MKB generation.
assert_eq!(
cert_unlock_outcome(&UnlockError::NoUsableHostCert { mkb: Some(77) }),
cert_unlock_outcome(&CertUnlockFailure::NoHostCert { mkb: Some(77) }),
UnlockOutcome::NoUsableHostCert { mkb: Some(77) }
);
assert_eq!(
cert_unlock_outcome(&UnlockError::VidUnavailable),
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)),
UnlockOutcome::VidUnavailable
);
assert_eq!(
cert_unlock_outcome(&UnlockError::HandshakeRejected),
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)),
UnlockOutcome::HandshakeRejected
);
// A SCSI/transport error folds to HandshakeRejected at the trace layer.
// A transport fault folds to HandshakeRejected at the trace layer.
assert_eq!(
cert_unlock_outcome(&UnlockError::Scsi(4000)),
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::Transport)),
UnlockOutcome::HandshakeRejected
);
}
+86 -43
View File
@@ -22,7 +22,6 @@ use crate::sector::{DecryptingSectorSource, SectorSource};
use crate::udf::{self, DirEntry, UdfFs};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
/// AACS aligned unit = 3 sectors / 6144 bytes. Content reads are issued in
@@ -66,10 +65,9 @@ pub struct FileResult {
pub path: PathBuf,
/// Bytes written that decrypted cleanly.
pub bytes_good: u64,
/// Bytes lost to unreadable sectors (zero-filled holes).
/// Bytes lost — unreadable sectors AND undecryptable units both land here
/// (extract fails a bad decrypt loud, so it is zero-filled like a bad sector).
pub bytes_unreadable: u64,
/// Bytes lost to undecryptable AACS/CSS units (still ciphertext / dropped).
pub bytes_undecryptable: u64,
/// True when the file was fully written (renamed from `.partial`).
pub complete: bool,
}
@@ -81,10 +79,8 @@ pub struct ExtractResult {
pub files: Vec<FileResult>,
/// Aggregate good bytes across all files.
pub bytes_good: u64,
/// Aggregate unreadable (bad-sector) bytes.
/// Aggregate lost bytes — bad sectors AND undecryptable units (one bucket).
pub bytes_unreadable: u64,
/// Aggregate undecryptable (decrypt-loss) bytes.
pub bytes_undecryptable: u64,
/// True when every file completed and no loss was recorded.
pub complete: bool,
/// True when the run stopped early on an interrupt / progress halt.
@@ -92,11 +88,10 @@ pub struct ExtractResult {
}
impl ExtractResult {
/// Total bytes lost (unreadable + undecryptable). A non-zero value means
/// the extraction is holed; the CLI exits non-zero so a script can re-run
/// through the `iso://` multipass path.
/// Total bytes lost. A non-zero value means the extraction is holed; the CLI
/// exits non-zero so a script can re-run through the `iso://` multipass path.
pub fn bytes_lost(&self) -> u64 {
self.bytes_unreadable + self.bytes_undecryptable
self.bytes_unreadable
}
}
@@ -197,7 +192,6 @@ impl Disc {
// borrowing wrapper (so the caller keeps `reader`), swap keys per CSS
// VTS group via `set_keys`; AACS/None keep `base_keys` throughout.
let mut dec = DecryptingSectorSource::new(Borrowed(reader), base_keys.clone());
let decrypt_loss = dec.decrypt_loss();
let mut result = ExtractResult::default();
let total_bytes = required;
@@ -232,26 +226,15 @@ impl Disc {
}
}
// Acquire (rather than Relaxed) on these per-file delta loads:
// `extract_tree` drives `dec` single-threaded so there is no race
// today, and Acquire costs nothing on x86. Note this is only half
// the synchronisation: the paired counter store
// (sector/decrypting.rs `fetch_add`) is Relaxed, so an Acquire
// load alone does NOT yet establish a happens-before edge. Before
// file extraction is parallelised, upgrade that store to Release
// (or stronger) so the delta cannot read a stale counter.
let before_loss = decrypt_loss.load(Ordering::Acquire);
let (mut fr, halted) =
// A unit that fails to decrypt fails the read loud (extract runs
// non-tolerate), so extract_one_file already zero-filled it and
// counted it in bytes_unreadable — one 'lost' bucket covers both
// media damage and decrypt failure.
let (fr, halted) =
extract_one_file(&mut dec, dest, pf, total_bytes, &mut done_bytes, opts)?;
let after_loss = decrypt_loss.load(Ordering::Acquire);
fr.bytes_undecryptable = after_loss.saturating_sub(before_loss);
fr.bytes_good = fr.bytes_good.saturating_sub(fr.bytes_undecryptable);
result.bytes_good = result.bytes_good.saturating_add(fr.bytes_good);
result.bytes_unreadable = result.bytes_unreadable.saturating_add(fr.bytes_unreadable);
result.bytes_undecryptable = result
.bytes_undecryptable
.saturating_add(fr.bytes_undecryptable);
result.files.push(fr);
if halted {
result.halted = true;
@@ -261,7 +244,6 @@ impl Disc {
result.complete = !result.halted
&& result.bytes_unreadable == 0
&& result.bytes_undecryptable == 0
&& result.files.iter().all(|f| f.complete);
Ok(result)
}
@@ -492,7 +474,6 @@ fn extract_one_file<S: SectorSource>(
path: pf.host_rel.clone(),
bytes_good: 0,
bytes_unreadable: 0,
bytes_undecryptable: 0,
complete: false,
};
@@ -686,6 +667,7 @@ fn report(opts: &ExtractOptions, done: u64, total: u64) -> bool {
bytes_bad_in_main_title: 0,
main_title_duration_secs: None,
main_title_size_bytes: None,
located: Default::default(),
};
p.report(&pp)
}
@@ -850,8 +832,11 @@ mod tests {
struct MemDisc {
sectors: HashMap<u32, [u8; 2048]>,
/// Absolute LBAs that fail to read (bad-sector fixture).
/// Absolute LBAs that fail to read (bad-sector fixture → DiscRead).
bad: std::collections::HashSet<u32>,
/// Absolute LBAs whose read fails to DECRYPT (no/wrong key fixture →
/// DecryptFailed), exercising the undecryptable-unit loss path.
decrypt_fail: std::collections::HashSet<u32>,
}
impl MemDisc {
@@ -859,6 +844,7 @@ mod tests {
Self {
sectors: HashMap::new(),
bad: std::collections::HashSet::new(),
decrypt_fail: std::collections::HashSet::new(),
}
}
fn put(&mut self, lba: u32, data: [u8; 2048]) {
@@ -890,6 +876,9 @@ mod tests {
sense: None,
});
}
if self.decrypt_fail.contains(&(lba + i)) {
return Err(Error::DecryptFailed);
}
}
for i in 0..count as u32 {
let off = i as usize * 2048;
@@ -1082,13 +1071,13 @@ mod tests {
}
/// Build a clear 6144-byte AACS unit (TS syncs at the 192-byte BD-TS
/// stride) then encrypt it under `unit_key` so `aacs::decrypt_unit`
/// stride) then encrypt it under `unit_key` so `aacs::content::decrypt_unit`
/// recovers it cleanly (zero decrypt loss). Mirrors the encrypt helper in
/// `sector/decrypting.rs` tests. `tag` distinguishes two units' payloads.
fn encrypt_aacs_unit(unit_key: &[u8; 16], tag: u8) -> Vec<u8> {
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < unit.len() {
unit[off] = 0x47; // TS sync
@@ -1100,14 +1089,14 @@ mod tests {
// Flag encrypted via CPI bits (byte 0) before key derivation.
unit[0] |= 0xC0;
let header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16];
for i in 0..16 {
k[i] = derived[i] ^ header[i];
}
let cipher = Aes128::new(GenericArray::from_slice(&k));
let mut prev = crate::aacs::decrypt::AACS_IV;
let blocks = (crate::aacs::ALIGNED_UNIT_LEN - 16) / 16;
let mut prev = crate::aacs::crypto::AACS_IV;
let blocks = (crate::aacs::content::ALIGNED_UNIT_LEN - 16) / 16;
for i in 0..blocks {
let o = 16 + i * 16;
for j in 0..16 {
@@ -1123,7 +1112,7 @@ mod tests {
/// The plaintext that `encrypt_aacs_unit(_, tag)` decrypts back to.
fn clear_aacs_unit(tag: u8) -> Vec<u8> {
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < unit.len() {
unit[off] = 0x47;
@@ -1411,6 +1400,64 @@ mod tests {
assert_eq!(res.files[0].bytes_unreadable, good.len() as u64);
}
/// An UNDECRYPTABLE unit (DecryptFailed — wrong/missing key) is zero-filled
/// and counted as loss through the public API exactly like a bad sector:
/// the recovery-seam consolidation folded the old bytes_undecryptable bucket
/// into bytes_unreadable, and the run must still report complete == false and
/// bytes_lost() > 0 (this gates the CLI exit code / multipass re-run).
#[test]
fn undecryptable_unit_holes_file_and_accounts_loss() {
let good = vec![0x55u8; 4 * 2048];
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "BDMV".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "STREAM".to_string(),
icb_lba: 22,
dir_data_lba: 23,
files: vec![file("00001.m2ts", 24, 5000, good.clone(), true)],
subdirs: vec![],
}],
}],
};
let mut disc = build_disc(root);
// The whole extent fails to decrypt (no/wrong key) rather than to read.
for i in 0..4u32 {
disc.decrypt_fail.insert(PART_START + 5000 + i);
}
let out = TmpDir::new("decryptfail");
let res = clear_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract does not abort on an undecryptable unit");
let got = read_out(out.path(), "BDMV/STREAM/00001.m2ts").expect("file written");
assert_eq!(
got.len(),
good.len(),
"holed file still sized to declared size"
);
assert!(
got.iter().all(|&b| b == 0),
"undecryptable range zero-filled"
);
assert!(
!res.complete,
"an undecryptable unit makes the rip incomplete"
);
assert!(
res.bytes_lost() > 0,
"decrypt loss counted, not reported clean"
);
assert_eq!(res.bytes_unreadable, good.len() as u64);
assert_eq!(res.files[0].bytes_unreadable, good.len() as u64);
}
/// Path sanitization rejects a host-illegal component in a disc file name.
#[test]
fn sanitize_rejects_illegal_component() {
@@ -1584,10 +1631,6 @@ mod tests {
res.bytes_unreadable, 0,
"per-extent unit base must keep the second extent off the hole path"
);
assert_eq!(
res.bytes_undecryptable, 0,
"clear units decrypt-restore clean"
);
assert!(
res.complete,
"a clean multi-extent AACS file extracts complete"
@@ -1599,10 +1642,10 @@ mod tests {
/// own batch starts are always unit-aligned; anchoring a later extent
/// against the FIRST extent's base mis-aligns whenever the extents' starts
/// differ by a non-multiple of 3 sectors. This is the exact arithmetic the
/// decrypt-on-read gate (`aacs::is_unit_aligned`) performs.
/// decrypt-on-read gate (`aacs::content::is_unit_aligned`) performs.
#[test]
fn per_extent_base_is_aligned_first_extent_base_is_not() {
use crate::aacs::is_unit_aligned;
use crate::aacs::content::is_unit_aligned;
let ext_a_start = 7000u32; // first extent abs LBA
let ext_b_start = 7004u32; // second extent abs LBA (Δ4 — not mult of 3)
+969
View File
@@ -0,0 +1,969 @@
//! HD-DVD title scanning — `HVDVD_TS/` Enhanced-VOB (`.evo`) enumeration.
//!
//! HD-DVD is a **tree-level peer** of DVD and Blu-ray (not a stream variant like
//! FMTS): its content lives in `HVDVD_TS/` as `.evo` clips — Enhanced VOB, an
//! MPEG **program** stream — each with a small `.map` timemap sidecar, navigated
//! by `.xpl`/`.ifo` playlists in `HVDVD_TS/` and `ADV_OBJ/`. Because it is a
//! different tree with a different playlist format, it gets its OWN scanner
//! (this file), a peer to [`Disc::scan_bluray_titles`] — the two-format design
//! rule: a genuinely different format is a new enumerator, not an extension
//! bolted into the BD path.
//!
//! Title composition: the `HVA*.VTI` navigation file (the DVD-IFO analogue) names
//! every clip in authored order. Standard Content splits the main feature across
//! clips at the layer break (`FEATURE_1`/`FEATURE_2`, or `feature`/`feature_Divide`);
//! the scanner parses the VTI clip table and concatenates those parts into ONE
//! [`DiscTitle`] (so the largest-title pick is the whole movie, not just part 1),
//! emitting every other clip as its own title. Container is
//! [`ContentFormat::MpegPs`], so the existing PS mux path handles it. Per-clip
//! streams ARE enumerated: the clip head is demuxed through the PS demuxer and one
//! [`Stream`] is built per distinct elementary stream (video + DD+ audio
//! sub-streams), with the codec sniffed from the ES bytes.
//!
//! What is NOT parsed yet — and is honestly stubbed, not faked:
//! * full VTI program-chain parsing (chapters, non-feature title grouping) —
//! the feature join uses the clip table + the `feature*` naming convention,
//! not the authoritative PGC,
//! * `.map` timemap → real durations,
//! * subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`).
//!
//! Extents and size ARE real (the ripper needs those to image a clip); durations
//! and chapters are left empty rather than guessed.
use super::*;
use crate::mux::ps::{PsDemuxer, dvd_audio_pid};
use crate::sector::SectorSource;
use crate::udf;
use std::collections::BTreeMap;
/// Clip stream-file extension in the HD-DVD `HVDVD_TS/` tree. HD-DVD is a
/// separate tree from BD, so this is a separate constant — deliberately NOT an
/// entry in [`super::bluray`]'s BD-tree `CLIP_STREAM_EXTS`.
const HDDVD_CLIP_EXT: &str = ".evo";
/// Sectors of an `.evo` clip head to demux when probing its elementary streams
/// (~16 MiB). Enough to see the opening video access unit (SPS) plus every
/// interleaved audio sub-stream, without imaging the whole multi-GiB clip.
const EVO_PROBE_SECTORS: u32 = 8192;
/// Cap on the elementary-stream sample retained per stream while probing — a
/// video SPS / audio syncword lands well inside the first few KiB, so 128 KiB
/// is generous while bounding probe memory.
const EVO_ES_SAMPLE_CAP: usize = 128 * 1024;
/// HD-DVD Standard Content navigation file magic (`HVDVD_TS/HVA*.VTI`). The VTI
/// is the DVD-IFO analogue: it holds a fixed-stride clip table naming every
/// `.evo` in authored order.
const HDDVD_VTI_MAGIC: &[u8] = b"ADVANCED-VTS";
/// Byte stride between clip-table entries in the VTI. Each entry holds a
/// NUL-terminated `<name>.EVO` at a constant sub-offset, so every clip name
/// shares one residue modulo this stride — the signal used to isolate the table.
const VTI_CLIP_ENTRY_STRIDE: usize = 0x140;
/// Cap on clip-name hits collected from a VTI. A real clip table holds a few
/// dozen entries; this bounds the scan so a crafted VTI packed with millions of
/// `.EVO` tokens (up to the 64 MiB UDF read cap) can't burn CPU/memory.
const MAX_VTI_HITS: usize = 8192;
/// Parse the clip-name table from an `ADVANCED-VTS` VTI, returning clip
/// filenames in authored (table) order.
///
/// The table is a run of `VTI_CLIP_ENTRY_STRIDE`-spaced records, each carrying a
/// NUL-terminated `<name>.EVO`. Rather than trust the (imprecise) header pointer,
/// this collects every NUL-terminated `*.EVO` name and keeps the largest group
/// sharing one residue modulo the stride — the clip table — in offset order.
/// Returns empty for a non-VTI blob or one with no recognizable table.
fn parse_vti_clip_order(vti: &[u8]) -> Vec<String> {
if !vti.starts_with(HDDVD_VTI_MAGIC) {
return Vec::new();
}
let is_name_byte = |b: u8| b.is_ascii_graphic();
// Bucket hits by residue-mod-stride in a SINGLE pass — the clip table shares
// one residue, so the largest bucket is it (avoids an O(stride*hits) rescan).
let mut buckets: std::collections::HashMap<usize, Vec<(usize, String)>> =
std::collections::HashMap::new();
let mut count = 0usize;
let mut i = 0usize;
while i < vti.len() && count < MAX_VTI_HITS {
if !is_name_byte(vti[i]) {
i += 1;
continue;
}
let start = i;
while i < vti.len() && is_name_byte(vti[i]) {
i += 1;
}
let name = &vti[start..i];
let nul_terminated = i < vti.len() && vti[i] == 0;
if nul_terminated && name.len() >= 5 && name[name.len() - 4..].eq_ignore_ascii_case(b".EVO")
{
buckets
.entry(start % VTI_CLIP_ENTRY_STRIDE)
.or_default()
.push((start, String::from_utf8_lossy(name).into_owned()));
count += 1;
}
}
// Pick the largest residue bucket (the clip table). On a size tie, break
// deterministically by the bucket's smallest offset — `HashMap` iteration
// order is randomized, so `max_by_key` alone could pick a different bucket
// run-to-run on identical bytes.
let Some(mut best) = buckets
.into_values()
.max_by_key(|g| (g.len(), std::cmp::Reverse(g.iter().map(|(o, _)| *o).min())))
else {
return Vec::new();
};
best.sort_by_key(|(o, _)| *o);
best.into_iter().map(|(_, n)| n).collect()
}
/// Whether a clip belongs to the main feature. HD-DVD Standard Content authors
/// the feature as one or more clips whose name begins `feature` (case-insensitive)
/// — `FEATURE_1`/`FEATURE_2` (a layer-break split) or `feature`/`feature_Divide`.
/// The feature is imaged as ONE title by concatenating these in authored order.
fn is_feature_clip(name: &str) -> bool {
let base = name.rsplit_once('.').map(|(b, _)| b).unwrap_or(name);
base.to_ascii_lowercase().starts_with("feature")
}
/// Sniff a video codec from a program-stream video elementary-stream sample by
/// its MPEG / Annex-B start codes:
/// * `00 00 01 B3` → MPEG-2 (sequence_header)
/// * `00 00 01 0F` → VC-1 (BD/HD-DVD sequence-header BDU)
/// * `00 00 01 [x7]` H.264 SPS NAL (type 7, forbidden_zero_bit clear) → H.264
///
/// Returns `None` when no recognizable start code is present. The scan prefers
/// the unambiguous MPEG-2 / VC-1 sequence headers; H.264 is inferred from an SPS
/// NAL so a stray slice/picture code can't be mistaken for a different codec.
fn sniff_video_codec(es: &[u8]) -> Option<Codec> {
let mut saw_h264_sps = false;
let mut i = 0usize;
while i + 4 <= es.len() {
if es[i] == 0x00 && es[i + 1] == 0x00 && es[i + 2] == 0x01 {
let code = es[i + 3];
match code {
0xB3 => return Some(Codec::Mpeg2),
0x0F => return Some(Codec::Vc1),
// H.264 SPS: mask off nal_ref_idc (bits 6-5); keep the
// forbidden_zero_bit (must be 0) + nal_unit_type (low 5 bits).
// 0x07/0x27/0x47/0x67 all decode to a type-7 SPS.
_ if (code & 0x9F) == 0x07 => saw_h264_sps = true,
_ => {}
}
// Skip the whole consumed `00 00 01 <code>` marker (4 bytes) so the
// code byte isn't re-read as the start of an overlapping start code.
i += 4;
} else {
i += 1;
}
}
saw_h264_sps.then_some(Codec::H264)
}
/// Sniff an audio codec from a `private_stream_1` sub-stream sample. Today only
/// Dolby Digital Plus (E-AC-3) is recognized — its `0x0B77` syncword — which is
/// what ANCHORMAN / SHAUN carry on sub-ids `0xC0..=0xC7`. Returns `None` for an
/// unrecognized sample so the caller drops the stream rather than mislabeling it.
fn sniff_audio_codec(es: &[u8]) -> Option<Codec> {
let has_sync = es.windows(2).any(|w| w[0] == 0x0B && w[1] == 0x77);
has_sync.then_some(Codec::Ac3Plus)
}
/// Demux the head of an `.evo` clip (through the disc's [`SectorSource`]) and
/// build one [`Stream`] per distinct elementary stream found: the video track
/// (mapped to the canonical [`DVD_VIDEO_PID`]) and every DD+ audio sub-stream
/// (mapped via [`dvd_audio_pid`]). Codec is sniffed from the demuxed ES bytes.
///
/// Mirrors the stream construction in `Disc::scan_dvd_titles`; resolution /
/// language / channels use sane HD-DVD defaults (the muxer reads the true pixel
/// dimensions from the H.264 SPS, and E-AC-3 channel counts are not decoded
/// here). Returns an empty vec when the clip cannot be read or carries no
/// recognizable stream (e.g. an AACS-encrypted clip probed as ciphertext).
fn probe_evo_streams(reader: &mut dyn SectorSource, extents: &[Extent]) -> Vec<Stream> {
let mut demux = PsDemuxer::new();
let mut video: Vec<u8> = Vec::new();
// Routing PID of the video track, captured from the first video PES seen:
// `DVD_VIDEO_PID` for a plain 0xE0-0xEF stream (Anchorman's H.264 on 0xE2),
// or `0xFD00 | stream_id_extension` for an HD-DVD extended-stream-id video
// (Shaun's VC-1 on 0xFD ext 0x55). Kept in lockstep with `PsPacket::dvd_pid`
// so the emitted `Stream` PID matches what the demuxer routes at mux time.
let mut video_pid: Option<u16> = None;
// sub_id -> ES sample, ordered so audio tracks surface in sub-id order.
let mut audio: BTreeMap<u8, Vec<u8>> = BTreeMap::new();
let mut remaining = EVO_PROBE_SECTORS;
'outer: for ext in extents {
let mut lba = ext.start_lba;
let mut left = ext.sector_count;
while left > 0 && remaining > 0 {
// 1 MiB read chunks (512 sectors) keep buffers small.
let n = left.min(remaining).min(512) as u16;
let mut buf = vec![0u8; n as usize * crate::consts::SECTOR_BYTES];
if reader.read_sectors(lba, n, &mut buf, false).is_err() {
break 'outer;
}
for pkt in demux.feed(&buf) {
collect_es(&pkt, &mut video, &mut video_pid, &mut audio);
}
lba += n as u32;
left -= n as u32;
remaining -= n as u32;
}
}
for pkt in demux.flush() {
collect_es(&pkt, &mut video, &mut video_pid, &mut audio);
}
let mut streams = Vec::new();
// Emit the video stream only when the codec was actually identified from the
// sampled head. Guessing (e.g. defaulting to H.264) would tag a VC-1 — or a
// still-encrypted — clip with the wrong codec, so the mux applies the wrong
// parser and produces a corrupt track; dropping it is the honest outcome
// (matches the audio path below), and a real clear clip always carries its
// sequence header / SPS at the head, so this never fires on a normal disc.
if let (Some(pid), Some(codec)) = (video_pid, sniff_video_codec(&video)) {
streams.push(Stream::Video(VideoStream {
pid,
codec,
// HD-DVD is HD (1080). The muxer reads the true coded dimensions
// from the H.264/VC-1 bitstream; this is a coarse default only.
resolution: Resolution::R1080p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
}));
}
for (sub, sample) in &audio {
let Some(codec) = sniff_audio_codec(sample) else {
continue;
};
let Some(pid) = dvd_audio_pid(*sub) else {
continue;
};
streams.push(Stream::Audio(AudioStream {
pid,
codec,
// DD+ main tracks are 5.1; E-AC-3 channel counts are not decoded at
// scan time, so this is a default (a 2.0 track is over-stated as
// 5.1 in the header — the compressed audio itself is unaffected).
channels: AudioChannels::Surround51,
language: String::new(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
}));
}
streams
}
/// Accumulate a demuxed PES packet's elementary-stream bytes into the video /
/// per-audio-sub-id sample buffers (bounded by [`EVO_ES_SAMPLE_CAP`]).
fn collect_es(
pkt: &crate::mux::ps::PsPacket,
video: &mut Vec<u8>,
video_pid: &mut Option<u16>,
audio: &mut BTreeMap<u8, Vec<u8>>,
) {
use crate::consts::pes_stream_id::{PRIVATE_STREAM_1, VIDEO, VIDEO_MAX};
const EXTENDED_STREAM_ID: u8 = 0xFD;
/// VC-1 video rides extended-stream-id `0xFD` with `stream_id_extension`
/// `0x55`. HD audio (MLP/TrueHD) can also use `0xFD` with other extensions —
/// routing those to their own audio tracks is deferred (see the HD-DVD
/// program-chain follow-up); until then only the VC-1 extension is treated as
/// video, so an audio `0xFD` sub-stream can never mis-stamp the video PID.
const VC1_STREAM_ID_EXT: u8 = 0x55;
// Whether this packet is the VC-1 video sub-stream of the 0xFD extended id.
let is_vc1_ext =
pkt.stream_id == EXTENDED_STREAM_ID && pkt.sub_stream_id == Some(VC1_STREAM_ID_EXT);
match pkt.stream_id {
// Plain MPEG video (0xE0-0xEF), or the VC-1 sub-stream of the HD-DVD
// extended-stream-id (0xFD). Both feed the single video ES sample; the
// routing PID comes from `PsPacket::dvd_pid` so it matches the demuxer.
VIDEO..=VIDEO_MAX => {
if video_pid.is_none() {
*video_pid = pkt.dvd_pid();
}
if video.len() < EVO_ES_SAMPLE_CAP {
video.extend_from_slice(&pkt.data);
}
}
EXTENDED_STREAM_ID if is_vc1_ext => {
if video_pid.is_none() {
*video_pid = pkt.dvd_pid();
}
if video.len() < EVO_ES_SAMPLE_CAP {
video.extend_from_slice(&pkt.data);
}
}
PRIVATE_STREAM_1 => {
if let Some(sub) = pkt.sub_stream_id {
if (0xC0..=0xC7).contains(&sub) {
let slot = audio.entry(sub).or_default();
if slot.len() < EVO_ES_SAMPLE_CAP {
slot.extend_from_slice(&pkt.data);
}
}
}
}
_ => {}
}
}
impl Disc {
/// Scan HD-DVD titles from the `HVDVD_TS/` `.evo` clips.
///
/// The main feature is authored as one or more `.evo` clips (a layer-break
/// split — `FEATURE_1`/`FEATURE_2` or `feature`/`feature_Divide`). The
/// `HVA*.VTI` navigation file names every clip in authored order; this parses
/// it to concatenate the feature clips into ONE title (so the largest-title
/// pick gets the whole movie, not just part 1), emitting every other clip as
/// its own title. Falls back to one title per clip when the VTI is absent or
/// unparseable, so a disc with no readable navigation still enumerates.
/// `chapters`/duration are left empty pending deeper VTI parsing.
pub(super) fn scan_hddvd_titles(
reader: &mut dyn SectorSource,
udf_fs: &udf::UdfFs,
) -> Vec<DiscTitle> {
let Some(ts_dir) = udf_fs.find_dir("/HVDVD_TS") else {
return Vec::new();
};
// Snapshot clips (name, size) and the VTI navigation file. The `ts_dir`
// borrow must end before the `udf_fs` reads below re-borrow it.
let mut clips: Vec<(String, u64)> = Vec::new();
let mut vti_name: Option<String> = None;
for e in &ts_dir.entries {
if e.is_dir {
continue;
}
let lower = e.name.to_ascii_lowercase();
if lower.ends_with(HDDVD_CLIP_EXT) {
clips.push((e.name.clone(), e.size));
} else if lower.ends_with(".vti") && vti_name.is_none() {
vti_name = Some(e.name.clone());
}
}
// Authored clip order from the VTI clip table (empty if no VTI).
let order: Vec<String> = vti_name
.and_then(|n| udf_fs.read_file(reader, &format!("/HVDVD_TS/{n}")).ok())
.map(|b| parse_vti_clip_order(&b))
.unwrap_or_default();
// Resolve each clip's physical extents once, keyed by lower-case name.
let mut clip_extents: BTreeMap<String, (String, u64, Vec<Extent>)> = BTreeMap::new();
for (name, size) in &clips {
let mut extents = Vec::new();
if let Ok(file_exts) = udf_fs.file_extents(reader, &format!("/HVDVD_TS/{name}")) {
for (lba, sectors) in file_exts {
if sectors > 0 && lba > 0 {
extents.push(Extent {
start_lba: lba,
sector_count: sectors,
});
}
}
}
if !extents.is_empty() {
clip_extents.insert(name.to_ascii_lowercase(), (name.clone(), *size, extents));
}
}
// Feature clips, in authored order, that actually resolved to extents.
let feature: Vec<String> = order
.iter()
.filter(|n| is_feature_clip(n))
.filter(|n| clip_extents.contains_key(&n.to_ascii_lowercase()))
.cloned()
.collect();
let feature_set: std::collections::HashSet<String> =
feature.iter().map(|n| n.to_ascii_lowercase()).collect();
let mut titles = Vec::new();
let mut next_id = 0u16;
// The composed feature title: concatenate its parts' extents in authored
// order. Streams are probed from the head (the first part). One `Clip` per
// part records the composition.
if !feature.is_empty() {
let mut extents = Vec::new();
let mut size_bytes = 0u64;
let mut parts = Vec::new();
for n in &feature {
if let Some((orig, size, exts)) = clip_extents.get(&n.to_ascii_lowercase()) {
extents.extend_from_slice(exts);
size_bytes += *size;
parts.push(Clip {
clip_id: orig
.rsplit_once('.')
.map(|(b, _)| b)
.unwrap_or(orig)
.to_string(),
in_time: 0,
out_time: 0,
duration_secs: 0.0,
source_packets: 0,
});
}
}
let streams = probe_evo_streams(reader, &extents);
titles.push(DiscTitle {
playlist: "FEATURE".to_string(),
playlist_id: next_id,
duration_secs: 0.0,
size_bytes,
clips: parts,
streams,
chapters: Vec::new(),
extents,
content_format: ContentFormat::MpegPs,
codec_privates: Vec::new(),
});
next_id = next_id.saturating_add(1);
}
// Every remaining clip is its own title (unchanged behaviour). Iterated in
// directory order; when there is no VTI/feature this emits ALL clips.
for (name, _size) in &clips {
let key = name.to_ascii_lowercase();
if feature_set.contains(&key) {
continue;
}
let Some((orig, size, extents)) = clip_extents.get(&key) else {
continue;
};
// Probe the clip head for its elementary streams so the mux path
// builds a non-empty `pid_to_track` and actually routes packets.
let streams = probe_evo_streams(reader, extents);
let clip_id = orig
.rsplit_once('.')
.map(|(base, _)| base.to_string())
.unwrap_or_else(|| orig.clone());
titles.push(DiscTitle {
playlist: orig.clone(),
playlist_id: next_id,
duration_secs: 0.0,
size_bytes: *size,
clips: vec![Clip {
clip_id,
in_time: 0,
out_time: 0,
duration_secs: 0.0,
source_packets: 0,
}],
streams,
chapters: Vec::new(),
extents: extents.clone(),
content_format: ContentFormat::MpegPs,
codec_privates: Vec::new(),
});
next_id = next_id.saturating_add(1);
}
titles
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::udf::fixture::*;
/// Build a UDF with an `HVDVD_TS/` tree holding the listed `.evo` clips
/// (name, sector count, data LBA).
fn make_hddvd_fs(disc: &mut MemDisc, evos: &[(&str, u32, u32)]) -> crate::udf::UdfFs {
let mut files = Vec::new();
let mut icb = 100u32;
for (name, sectors, data_lba) in evos {
files.push(file(name, icb, *data_lba, sectors * 2048, true));
icb += 1;
}
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files,
subdirs: vec![],
}],
};
build_udf_skeleton(disc, 10);
lay_dir(disc, &root);
crate::udf::read_filesystem(disc).expect("fs")
}
/// HD-DVD's own enumerator yields one title per `.evo`, MpegPs container,
/// with real physical extents (mirrors the BD `.m2ts` extent path).
#[test]
fn scan_hddvd_titles_enumerates_evo_extents() {
let mut disc = MemDisc::new();
let udf = make_hddvd_fs(
&mut disc,
&[("FEATURE.EVO", 2000, 5000), ("BLOOP.EVO", 300, 9000)],
);
let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
assert_eq!(titles.len(), 2, "one title per .evo clip");
for t in &titles {
assert_eq!(
t.content_format,
ContentFormat::MpegPs,
"EVO is a program stream"
);
assert_eq!(t.extents.len(), 1);
}
let feature = titles.iter().find(|t| t.playlist == "FEATURE.EVO").unwrap();
assert_eq!(feature.extents[0].start_lba, PART_START + 5000);
assert_eq!(feature.extents[0].sector_count, 2000);
assert_eq!(
feature.clips[0].clip_id, "FEATURE",
"clip_id drops the extension"
);
}
// ── VTI playlist parsing + feature composition ────────────────────────
/// Build a synthetic `ADVANCED-VTS` VTI whose clip table lists `clips` in
/// order — one fixed-stride entry each, NUL-terminated name at `entry+0x42`.
fn synthetic_vti(clips: &[&str]) -> Vec<u8> {
let table_start = 0x200usize;
let mut v = vec![0u8; table_start + clips.len() * VTI_CLIP_ENTRY_STRIDE];
v[..HDDVD_VTI_MAGIC.len()].copy_from_slice(HDDVD_VTI_MAGIC);
for (i, name) in clips.iter().enumerate() {
let off = table_start + i * VTI_CLIP_ENTRY_STRIDE + 0x42;
v[off..off + name.len()].copy_from_slice(name.as_bytes());
// The byte after the name stays 0 (NUL terminator).
}
v
}
#[test]
fn parse_vti_clip_order_reads_table_in_authored_order() {
let vti = synthetic_vti(&[
"DELOGO.EVO",
"FEATURE_1.EVO",
"FEATURE_2.EVO",
"TRAILER.EVO",
]);
let order = parse_vti_clip_order(&vti);
assert_eq!(
order,
vec![
"DELOGO.EVO".to_string(),
"FEATURE_1.EVO".to_string(),
"FEATURE_2.EVO".to_string(),
"TRAILER.EVO".to_string(),
]
);
// A non-VTI blob yields nothing.
assert!(parse_vti_clip_order(b"not a vti").is_empty());
}
#[test]
fn parse_vti_clip_order_caps_hits_on_a_crafted_vti() {
// A crafted VTI packed with far more than MAX_VTI_HITS `.EVO` tokens must
// not scan/collect them all (a CPU/memory amplification on a routine
// scan). The result is capped, and parsing stays fast.
let mut vti = Vec::with_capacity(1_000_000);
vti.extend_from_slice(HDDVD_VTI_MAGIC);
// ~160k tokens of the form "X.EVO\0" — well over the 8192 cap.
for _ in 0..(MAX_VTI_HITS * 20) {
vti.extend_from_slice(b"X.EVO\0");
}
let out = parse_vti_clip_order(&vti);
assert!(
out.len() <= MAX_VTI_HITS,
"collected hits capped at MAX_VTI_HITS, got {}",
out.len()
);
}
#[test]
fn parse_vti_clip_order_is_deterministic_on_a_bucket_size_tie() {
// Two residue buckets of EQUAL size must resolve to the SAME winner every
// call — `HashMap` iteration is randomized, so a `max_by_key` without a
// deterministic tie-break could pick a different bucket run-to-run on
// identical bytes. Build a VTI whose stray `.EVO` names tie the real
// table's bucket count, then assert the result is stable across calls.
let mut vti = vec![0u8; 0x600];
vti[..HDDVD_VTI_MAGIC.len()].copy_from_slice(HDDVD_VTI_MAGIC);
let put = |v: &mut Vec<u8>, off: usize, name: &str| {
v[off..off + name.len()].copy_from_slice(name.as_bytes());
};
// Bucket A (residue 0x42): two names at stride 0x140.
put(&mut vti, 0x142, "A1.EVO");
put(&mut vti, 0x282, "A2.EVO");
// Bucket B (residue 0x50): two names — same count, different residue.
put(&mut vti, 0x150, "B1.EVO");
put(&mut vti, 0x290, "B2.EVO");
let first = parse_vti_clip_order(&vti);
for _ in 0..20 {
assert_eq!(
parse_vti_clip_order(&vti),
first,
"tie-break must be deterministic across repeated calls"
);
}
assert!(!first.is_empty());
}
#[test]
fn is_feature_clip_matches_the_feature_naming_variants() {
// Layer-break split (Shaun / Anchorman) and the divide form (Harry Potter).
assert!(is_feature_clip("FEATURE_1.EVO"));
assert!(is_feature_clip("FEATURE_2.EVO"));
assert!(is_feature_clip("feature.EVO"));
assert!(is_feature_clip("feature_Divide.EVO"));
// Extras are not the feature.
assert!(!is_feature_clip("TRAILER.EVO"));
assert!(!is_feature_clip("DLS_01.EVO"));
assert!(!is_feature_clip("EPK.EVO"));
}
#[test]
fn scan_hddvd_composes_split_feature_into_one_title() {
// A disc whose feature is FEATURE_1 + FEATURE_2 (a layer-break split), plus
// a TRAILER extra. The VTI names them in authored order; the scan must
// JOIN the two feature parts into one title (so the largest-title pick is
// the whole movie) and keep the trailer as its own title.
let mut disc = MemDisc::new();
let vti = synthetic_vti(&["FEATURE_1.EVO", "FEATURE_2.EVO", "TRAILER.EVO"]);
let files = vec![
file("FEATURE_1.EVO", 100, 5000, 10 * 2048, true),
file("FEATURE_2.EVO", 101, 8000, 6 * 2048, true),
file("TRAILER.EVO", 102, 12000, 2 * 2048, true),
file_with("HVA00001.VTI", 103, 15000, vti, true),
];
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files,
subdirs: vec![],
}],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
// One composed FEATURE title + the trailer.
assert_eq!(titles.len(), 2, "feature parts merged, trailer separate");
let feat = titles
.iter()
.find(|t| t.playlist == "FEATURE")
.expect("composed feature title");
assert_eq!(feat.clips.len(), 2, "both feature parts recorded");
assert_eq!(feat.size_bytes, (10 + 6) * 2048, "part sizes summed");
// Extents concatenated in authored order: FEATURE_1 (lba 5000) then
// FEATURE_2 (lba 8000) — the movie plays through in order.
assert_eq!(feat.extents.len(), 2);
assert_eq!(feat.extents[0].start_lba, PART_START + 5000);
assert_eq!(feat.extents[0].sector_count, 10);
assert_eq!(feat.extents[1].start_lba, PART_START + 8000);
assert_eq!(feat.extents[1].sector_count, 6);
// The largest title is the whole feature, not just part 1.
let largest = titles.iter().max_by_key(|t| t.size_bytes).unwrap();
assert_eq!(largest.playlist, "FEATURE");
assert!(titles.iter().any(|t| t.playlist == "TRAILER.EVO"));
}
// ── codec sniffing ────────────────────────────────────────────────────
#[test]
fn sniff_video_codec_recognizes_h264_vc1_mpeg2() {
// H.264 SPS NAL (type 7). 0x67/0x27/0x47 all decode to type 7.
assert_eq!(
sniff_video_codec(&[0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1E]),
Some(Codec::H264)
);
assert_eq!(
sniff_video_codec(&[0x11, 0x00, 0x00, 0x01, 0x27, 0x64]),
Some(Codec::H264)
);
// VC-1 sequence-header BDU (0x0F).
assert_eq!(
sniff_video_codec(&[0x00, 0x00, 0x01, 0x0F, 0xC0]),
Some(Codec::Vc1)
);
// MPEG-2 sequence_header (0xB3).
assert_eq!(
sniff_video_codec(&[0x00, 0x00, 0x01, 0xB3, 0x2D]),
Some(Codec::Mpeg2)
);
// A slice/picture-only sample (no SPS/sequence) is indeterminate.
assert_eq!(sniff_video_codec(&[0x00, 0x00, 0x01, 0x61, 0x9A]), None);
assert_eq!(sniff_video_codec(&[0xDE, 0xAD, 0xBE, 0xEF]), None);
// Overlap regression: a picture_start_code (0x00) whose payload begins
// with 00 00 must advance a full 4 bytes so the code byte isn't re-read
// as the start of a new marker. Here the picture is followed by a real
// MPEG-2 sequence header — the scan must reach it cleanly and return
// Mpeg2 (and, critically, not be confused by the 1-byte overlap).
assert_eq!(
sniff_video_codec(&[0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0xB3, 0x2D]),
Some(Codec::Mpeg2)
);
// A lone picture_start_code with a 00-heavy payload and no following real
// start code stays indeterminate (the overlap must not fabricate one).
assert_eq!(
sniff_video_codec(&[0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]),
None
);
}
#[test]
fn sniff_audio_codec_recognizes_eac3_syncword() {
assert_eq!(
sniff_audio_codec(&[0x00, 0x0B, 0x77, 0x12, 0x34]),
Some(Codec::Ac3Plus)
);
assert_eq!(sniff_audio_codec(&[0x00, 0x01, 0x02, 0x03]), None);
}
// ── EVO head probe → streams ──────────────────────────────────────────
/// A minimal bounded PES: `00 00 01 [id] [len:2] 80 00 00 [payload]`.
fn pes(stream_id: u8, payload: &[u8]) -> Vec<u8> {
let mut v = vec![0x00, 0x00, 0x01, stream_id];
let len = (3 + payload.len()) as u16; // flags1+flags2+hdl + payload
v.extend_from_slice(&len.to_be_bytes());
v.extend_from_slice(&[0x80, 0x00, 0x00]);
v.extend_from_slice(payload);
v
}
/// Synthetic EVO program-stream: pack header, a video PES (H.264 SPS+IDR on
/// stream_id 0xE2, exactly as ANCHORMAN carries it), two DD+ audio PES
/// (sub-ids 0xC0/0xC1, each with the 4-byte sub-header + E-AC-3 syncword),
/// then program-end.
fn synthetic_evo() -> Vec<u8> {
let mut d = Vec::new();
// MPEG-2 pack header (14 bytes, stuffing 0).
d.extend_from_slice(&[
0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8,
]);
// Video PES on stream_id 0xE2 (Anchorman's H.264 sub-id in the 0xE0-0xEF
// range): SPS (type 7) + IDR (type 5) Annex-B.
let video_es = [
0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1E, 0xAB, 0xCD, // SPS
0x00, 0x00, 0x01, 0x65, 0x88, 0x00, // IDR slice
];
d.extend_from_slice(&pes(0xE2, &video_es));
// DD+ audio PES: sub-id + 4-byte sub-header (num_frames + ptr) folded in
// — the demuxer strips 4 bytes, leaving the E-AC-3 syncword.
for sub in [0xC0u8, 0xC1] {
let audio_payload = [
sub, 0x01, 0x00, 0x00, // sub-id + num_frames(1) + ptr(2)
0x0B, 0x77, 0xDE, 0xAD, // E-AC-3 syncword + body
];
d.extend_from_slice(&pes(0xBD, &audio_payload));
}
d.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]); // program end
d
}
/// Build a UDF whose `HVDVD_TS/FEATURE.EVO` holds the given raw bytes.
fn make_hddvd_fs_with_evo(disc: &mut MemDisc, evo: &[u8]) -> crate::udf::UdfFs {
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: vec![file_with("FEATURE.EVO", 100, 5000, evo.to_vec(), true)],
subdirs: vec![],
}],
};
build_udf_skeleton(disc, 10);
lay_dir(disc, &root);
crate::udf::read_filesystem(disc).expect("fs")
}
/// End-to-end: scanning an `.evo` whose head carries an H.264 video PES and
/// two DD+ audio PES yields a title with the video track (canonical
/// DVD_VIDEO_PID) and both DD+ tracks (0xBDC0 / 0xBDC1) — the non-empty
/// `streams` the mux path needs to route packets (the historical blocker).
#[test]
fn scan_hddvd_titles_probes_streams_from_evo_head() {
let mut disc = MemDisc::new();
let udf = make_hddvd_fs_with_evo(&mut disc, &synthetic_evo());
let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
assert_eq!(titles.len(), 1);
let t = &titles[0];
let video: Vec<_> = t
.streams
.iter()
.filter_map(|s| match s {
Stream::Video(v) => Some(v),
_ => None,
})
.collect();
assert_eq!(video.len(), 1, "one video track probed");
assert_eq!(video[0].codec, Codec::H264, "SPS sniffed as H.264");
assert_eq!(
video[0].pid,
crate::mux::ps::DVD_VIDEO_PID,
"video routes to canonical PID"
);
let audio: Vec<_> = t
.streams
.iter()
.filter_map(|s| match s {
Stream::Audio(a) => Some(a),
_ => None,
})
.collect();
assert_eq!(audio.len(), 2, "both DD+ sub-streams probed");
assert!(audio.iter().all(|a| a.codec == Codec::Ac3Plus));
let pids: Vec<u16> = audio.iter().map(|a| a.pid).collect();
assert_eq!(pids, vec![0xBDC0, 0xBDC1], "DD+ PIDs 0xBDC0/0xBDC1");
}
/// A clip whose head carries no recognizable stream (unreadable /
/// ciphertext) leaves `streams` empty rather than fabricating one — the
/// title still enumerates (extents are real).
#[test]
fn scan_hddvd_titles_empty_streams_when_head_unrecognized() {
let mut disc = MemDisc::new();
// 4 KiB of junk with no PS start codes.
let junk = vec![0x55u8; 4096];
let udf = make_hddvd_fs_with_evo(&mut disc, &junk);
let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
assert_eq!(titles.len(), 1);
assert!(
titles[0].streams.is_empty(),
"no recognizable stream → empty, not fabricated"
);
}
/// A PES on the HD-DVD extended-stream-id (0xFD) carrying the given
/// `stream_id_extension` in a minimal PES extension: flags1=0x80, flags2=0x01
/// (PES_extension only), header_data_length=3, optional bytes
/// `[ext_flags=0x01][field_len=0x81][ext]` — exactly the shape SHAUN's VC-1
/// video PES uses (ext 0x55).
fn pes_extended(stream_id_extension: u8, payload: &[u8]) -> Vec<u8> {
let mut v = vec![0x00, 0x00, 0x01, 0xFD];
let opt = [0x01u8, 0x81, stream_id_extension];
let len = (3 + opt.len() + payload.len()) as u16; // flags1+flags2+hdl + opt + payload
v.extend_from_slice(&len.to_be_bytes());
v.extend_from_slice(&[0x80, 0x01, opt.len() as u8]);
v.extend_from_slice(&opt);
v.extend_from_slice(payload);
v
}
/// Synthetic EVO carrying VC-1 video on the extended-stream-id 0xFD (ext
/// 0x55), as SHAUN OF THE DEAD does, plus one DD+ audio PES.
fn synthetic_evo_vc1() -> Vec<u8> {
let mut d = Vec::new();
d.extend_from_slice(&[
0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8,
]);
// VC-1 sequence header (00 00 01 0F) + a frame BDU (00 00 01 0D).
let video_es = [
0x00, 0x00, 0x01, 0x0F, 0xC5, 0x00, 0x00, // sequence header BDU
0x00, 0x00, 0x01, 0x0D, 0x12, 0x34, // frame BDU
];
d.extend_from_slice(&pes_extended(0x55, &video_es));
let audio_payload = [0xC0u8, 0x01, 0x00, 0x00, 0x0B, 0x77, 0xDE, 0xAD];
d.extend_from_slice(&pes(0xBD, &audio_payload));
d.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
d
}
/// Build a bare PsPacket for the collect_es routing test.
fn ps_pkt(stream_id: u8, sub: Option<u8>, data: Vec<u8>) -> crate::mux::ps::PsPacket {
crate::mux::ps::PsPacket {
stream_id,
sub_stream_id: sub,
pts: None,
dts: None,
data,
source: None,
}
}
#[test]
fn collect_es_routes_only_vc1_0xfd_to_video() {
use crate::mux::ps::hddvd_extended_pid;
// The 0xFD guard: only the VC-1 extension (0x55) is video. An HD-audio
// 0xFD sub-stream (e.g. 0x72) that arrives FIRST must NOT stamp video_pid
// with its PID or pollute the video sample — else the real video track is
// lost. (Routing 0xFD audio to its own track is deferred.)
let mut video = Vec::new();
let mut video_pid: Option<u16> = None;
let mut audio = BTreeMap::new();
// Audio-on-0xFD (ext 0x72) first — must be ignored by the video path.
collect_es(
&ps_pkt(0xFD, Some(0x72), vec![0xAA; 32]),
&mut video,
&mut video_pid,
&mut audio,
);
assert!(
video.is_empty(),
"0xFD audio sub-stream not routed to video"
);
assert_eq!(video_pid, None, "0xFD audio did not stamp the video PID");
// Then the real VC-1 video (ext 0x55).
collect_es(
&ps_pkt(0xFD, Some(0x55), vec![0xBB; 32]),
&mut video,
&mut video_pid,
&mut audio,
);
assert_eq!(
video_pid,
Some(hddvd_extended_pid(0x55)),
"video PID stamped from the VC-1 0xFD sub-stream (0xFD55)"
);
assert_eq!(video.len(), 32, "VC-1 0xFD payload accumulated as video");
}
/// End-to-end: an `.evo` whose video rides the extended-stream-id 0xFD yields
/// a VC-1 video track routed to `0xFD00 | ext` (0xFD55) — the PID the demuxer
/// derives from the same stream_id_extension, so mux-time routing lines up.
#[test]
fn scan_hddvd_titles_probes_vc1_on_extended_stream_id() {
let mut disc = MemDisc::new();
let udf = make_hddvd_fs_with_evo(&mut disc, &synthetic_evo_vc1());
let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
assert_eq!(titles.len(), 1);
let video: Vec<_> = titles[0]
.streams
.iter()
.filter_map(|s| match s {
Stream::Video(v) => Some(v),
_ => None,
})
.collect();
assert_eq!(video.len(), 1, "one video track probed");
assert_eq!(video[0].codec, Codec::Vc1, "VC-1 sequence header sniffed");
assert_eq!(
video[0].pid,
crate::mux::ps::hddvd_extended_pid(0x55),
"VC-1 routes to the extended-stream-id PID 0xFD55"
);
}
}
+3 -29
View File
@@ -651,35 +651,9 @@ impl Drop for Mapfile {
/// caller treats a bad VID comment as simply absent rather than an
/// error, so a corrupt header never fails a mapfile load.
fn parse_vid_hex(s: &str) -> Option<[u8; 16]> {
let s = s.strip_prefix("0x").unwrap_or(s);
// Parse on bytes, not on the &str: slicing a &str by byte index
// (`&s[i*2..i*2+2]`) panics when the cut lands inside a multi-byte
// UTF-8 char. A hand-edited/corrupt `# freemkv-vid:` comment of
// exactly 32 bytes containing a multi-byte char would otherwise
// kill the whole load. ASCII hex is one byte per char, so anything
// non-ASCII is simply rejected here as malformed.
let bytes = s.as_bytes();
if bytes.len() != 32 {
return None;
}
let mut out = [0u8; 16];
for (i, b) in out.iter_mut().enumerate() {
let hi = hex_nibble(bytes[i * 2])?;
let lo = hex_nibble(bytes[i * 2 + 1])?;
*b = (hi << 4) | lo;
}
Some(out)
}
/// Map a single ASCII hex digit byte to its 0-15 value. Returns `None`
/// for any non-hex byte (including any non-ASCII / multi-byte lead byte).
fn hex_nibble(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
// The one workspace hex parser (accepts an optional `0x`/`0X` prefix,
// byte-based so a multi-byte `# freemkv-vid:` comment rejects, never panics).
crate::hex::parse_hex_fixed::<16>(s)
}
/// Parse a `# freemkv-uk:` value `<cps>:<32hex>` into `(cps_unit, key)`. Returns
+665 -108
View File
File diff suppressed because it is too large Load Diff
+831 -2262
View File
File diff suppressed because it is too large Load Diff
+15 -20
View File
@@ -196,22 +196,10 @@ impl ReadCtx {
/// threshold is loose so we don't bail too early on a range that
/// has scattered good sectors mixed in.
///
/// `damage_threshold_pct = 6` mirrors `disc/patch.rs`'s
/// `PASSN_DAMAGE_THRESHOLD_PCT`. Pass N triggers the damage-skip
/// at half the density Pass 1 uses (Pass 1 = 12%) because the
/// patch loop's whole job is to chip away at bad ranges — being
/// more eager to skip clustered bad sectors converges faster on
/// the recoverable good sectors inside a range. The patch-side
/// `compute_damage_skip` reads its threshold directly from
/// `PASSN_DAMAGE_THRESHOLD_PCT`, which is an alias for this crate's
/// `PATCH_DAMAGE_THRESHOLD_PCT`, so the two are always in sync.
/// The patch loop's damage-skip is not yet unified with `handle_read_error`'s
/// jump path. (v0.20.8 unification attempt found the unification
/// itself blocked on the size-aware `range_remaining/4` cap that
/// lives in `compute_damage_skip` but not in
/// `handle_read_error::JumpAhead` — see
/// `tests/passn_handler_ab.rs` for the A/B fixture that pins
/// the divergence point.)
/// `damage_threshold_pct = 6` is looser than Pass 1 (12%): Pass N triggers
/// the damage-skip at half Pass 1 density because the patch loop exists to chip
/// away at bad ranges, so being more eager to skip clustered bad sectors
/// converges faster on the recoverable good sectors inside a range.
pub fn for_patch(batch: u16) -> Self {
Self {
batch,
@@ -443,8 +431,8 @@ const WEDGE_ABORT_THRESHOLD: u64 = 16;
const WEDGE_PASS_N_SKIP_SECTORS: u64 = 64;
/// Single source of truth for the Pass-N damage-window threshold.
/// Both [`ReadCtx::for_patch`] and `disc::patch::compute_damage_skip`
/// reference this constant so the two damage-skip paths cannot drift.
/// [`ReadCtx::for_patch`] reads this constant for the Pass-N damage-skip
/// threshold.
///
/// 6% means: with a 16-entry sliding window, the damage-skip fires
/// once 1 out of 16 recent reads has failed. Pass 1 uses a 12%
@@ -531,13 +519,20 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
);
if is_wedge_transition {
// NOTE: this is the FIRST escalation into the hardware/illegal-request
// sense family — NOT a confirmed wedge. Drives frequently recover and keep
// reading after one such error (a single bad spot), so calling it a "wedge"
// here over-claims (it sent past investigations chasing a drive ghost). A
// genuine wedge is PERSISTENT — see the `wedge_skip` / WEDGE_ABORT_THRESHOLD
// path below, which only fires after repeated fast-fails with no recovery.
tracing::warn!(
target: "freemkv::disc",
phase = "wedge_transition",
phase = "fastfail_escalation",
errors_in_zone = ctx.total_errors,
ms_since_last_success,
new_family = ?current_family,
"drive entered wedge / fast-fail family (was returning recoverable medium errors before this)"
"drive escalated into the fast-fail sense family (was returning recoverable medium \
errors before this) — often transient; only a PERSISTENT run is a real wedge"
);
}
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -190,11 +190,17 @@ impl Sink<WorkItem> for SweepSink {
}
WorkItem::StatsRequest => {
let stats = self.map.stats();
// DAMAGE only — NOT NonTried. NonTried is the unread remainder
// ahead of the sweep head, not damage; including it made the live
// located drilldown (at-risk movie time + range count) treat the
// whole unread disc as confirmed damage, so at sweep start it
// showed ~full-movie at-risk and melted to 0 as the sweep
// progressed. Matches the one-shot progress path, which already
// excludes NonTried.
let bad_ranges = self.map.ranges_with(&[
SectorStatus::NonTrimmed,
SectorStatus::Unreadable,
SectorStatus::NonScraped,
SectorStatus::NonTried,
]);
// Best-effort: drop on backpressure; producer's cache
// stays current enough.
+15 -12
View File
@@ -7,7 +7,7 @@
//! grid (clips can start off the 6144 grid and fragment across UDF extents). So
//! this gate BUFFERS the disc-absolute read stream and re-ALIGNS it into
//! clip-file units, then applies the standards-correct
//! [`crate::aacs::unit_is_clean_ts`] gate (libaacs `_verify_ts`, all-32 syncs).
//! [`crate::aacs::content::unit_is_clean_ts`] gate (libaacs `_verify_ts`, all-32 syncs).
//!
//! FAIL-SAFE CONTRACT (this sits in the middle of every read, so it must never
//! break a good read): the gate can ONLY downgrade a unit it is *confident* is
@@ -25,7 +25,8 @@
use std::collections::{HashMap, VecDeque};
use crate::aacs::{self, ALIGNED_UNIT_LEN};
use crate::aacs::content::ALIGNED_UNIT_LEN;
use crate::aacs::{self};
use crate::consts::SECTOR_BYTES_U64;
use crate::decrypt::DecryptKeys;
use crate::sector::KeyFetch;
@@ -57,7 +58,7 @@ pub enum ContainerKind {
Ts,
/// HD-DVD `.evo` — MPEG-2 program stream (pack-start `00 00 01 BA`).
/// NOT yet enabled by enumeration; present so adding HD-DVD is a one-mapping
/// change. See [`crate::aacs::unit_is_clean_ps`] for the (unvalidated) check.
/// change. See [`crate::aacs::content::unit_is_clean_ps`] for the (unvalidated) check.
Ps,
}
@@ -187,8 +188,8 @@ impl UnitVerifier {
/// The post-decrypt structural check for a clip's container.
fn accept_for(&self, clip: u32) -> fn(&[u8]) -> bool {
match self.containers[clip as usize] {
ContainerKind::Ts => aacs::unit_is_clean_ts,
ContainerKind::Ps => aacs::unit_is_clean_ps,
ContainerKind::Ts => aacs::content::unit_is_clean_ts,
ContainerKind::Ps => aacs::content::unit_is_clean_ps,
}
}
@@ -301,8 +302,8 @@ impl UnitVerifier {
}
/// Can this fully-assembled unit be decrypted + verified? `accept` is the
/// container's strict structural check ([`aacs::unit_is_clean_ts`] for TS,
/// [`aacs::unit_is_clean_ps`] for PS) — the only format-specific part; the
/// container's strict structural check ([`aacs::content::unit_is_clean_ts`] for TS,
/// [`aacs::content::unit_is_clean_ps`] for PS) — the only format-specific part; the
/// AACS crypto is container-agnostic. Returns the 3-state [`Decryptability`].
fn decryptability(
&mut self,
@@ -318,7 +319,7 @@ impl UnitVerifier {
// ENCRYPTED units that no key opens are ever flagged. (A real bad READ of
// clear content is still caught by the normal SCSI read-error path; this
// gate just won't false-flag it.)
if !aacs::aacs_unit_encrypted(raw) {
if !aacs::content::aacs_unit_encrypted(raw) {
return if accept(raw) {
Decryptability::Decryptable
} else {
@@ -363,7 +364,7 @@ impl UnitVerifier {
fn try_keys(&self, raw: &[u8; ALIGNED_UNIT_LEN], accept: fn(&[u8]) -> bool) -> bool {
for k in &self.keys {
let mut scratch = *raw;
if aacs::decrypt_unit_checked(&mut scratch, k, accept) {
if aacs::content::decrypt_unit_checked(&mut scratch, k, accept) {
return true;
}
}
@@ -477,7 +478,9 @@ fn push_ranges(out: &mut Vec<(u32, u32)>, lbas: &[u32; 3]) {
present.sort_unstable();
for lba in present {
if let Some(last) = out.last_mut() {
if last.0 + last.1 == lba {
// Saturating: LBAs come from disc-controlled ICB extents, so a
// corrupt disc must not panic here (matches `udf::merge_ranges`).
if last.0.saturating_add(last.1) == lba {
last.1 += 1;
continue;
}
@@ -521,13 +524,13 @@ mod tests {
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
unit[0] |= 0xC0; // CPI flag => reads as encrypted
let header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16];
for i in 0..16 {
k[i] = derived[i] ^ header[i];
}
let cipher = Aes128::new(GenericArray::from_slice(&k));
let mut prev = crate::aacs::decrypt::AACS_IV;
let mut prev = crate::aacs::crypto::AACS_IV;
for i in 0..(ALIGNED_UNIT_LEN - 16) / 16 {
let off = 16 + i * 16;
for j in 0..16 {
+183 -79
View File
@@ -1,8 +1,8 @@
//! Drive session — open, identify, and read from optical drives.
//!
//! A `Drive` is opened from a device path, identifies itself via INQUIRY,
//! optionally unlocks/initializes via a registered [`crate::unlock::Unlocker`],
//! and reads sectors.
//! optionally unlocks/initializes via the `freemkv-unlock` dispatch
//! (through [`crate::unlock_bridge`]), and reads sectors.
pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
match e {
@@ -51,6 +51,13 @@ pub enum DriveStatus {
// SCSI opcodes used in drive control
const SCSI_TEST_UNIT_READY: u8 = 0x00;
const SCSI_START_STOP_UNIT: u8 = 0x1B;
/// Idle time the disc sits spun-down during [`Drive::spin_cycle`] before it's
/// spun back up — long enough for the mechanism's fast-fail wedge state to
/// clear. Validated at 5–6 s live.
const SPIN_DOWN_IDLE_SECS: u64 = 5;
/// Settle time after spin-up in [`Drive::spin_cycle`] before the caller reads
/// again, so the first post-cycle read doesn't hit a transient NOT_READY.
const SPIN_UP_SETTLE_SECS: u64 = 10;
const SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL: u8 = 0x1E;
const SCSI_GET_EVENT_STATUS: u8 = 0x4A;
const SCSI_MODE_SENSE: u8 = 0x5A;
@@ -59,9 +66,9 @@ const SCSI_REPORT_KEY: u8 = 0xA4;
/// Optical disc drive session -- open, identify, unlock, and read.
pub struct Drive {
scsi: Box<dyn ScsiTransport>,
/// Name of the [`crate::unlock::Unlocker`] that handled this drive at
/// `init()`, if any matched. `None` means no unlocker matched and the
/// drive runs in stock mode (host-cert AACS handshake carries discs).
/// Name of the unlocker that handled this drive at `init()`, if any matched.
/// `None` means no unlocker matched and the drive runs in stock mode
/// (host-cert AACS handshake carries discs).
unlocker_name: Option<String>,
/// The OEM Volume ID the matching unlocker returned from `unlock()` at
/// `init()`, stashed for the AACS handshake phase (which reads it via
@@ -212,11 +219,20 @@ impl Drive {
self.unlock_tray();
}
/// Whether a registered unlocker matches this drive (i.e. it can be
/// firmware-unlocked). Queried against the unlock registry by identity;
/// does not require `init()` to have run.
/// Whether an unlocker claims this drive by identity (i.e. it can be
/// unlocked at drive-prep). Queried via `freemkv-unlock`; does not require
/// `init()` to have run.
pub fn has_profile(&self) -> bool {
crate::unlock::matching_name(&self.drive_id).is_some()
crate::unlock_bridge::unlocker_name(&self.drive_id).is_some()
}
/// The name of the drive unlocker that ACTUALLY unlocked this drive at
/// `init()`, or `None` if none applied (unsupported drive, or the unlock
/// failed). Distinct from [`has_profile`](Self::has_profile), which reports
/// only an identity match: this is the runtime outcome. Apps render it in the
/// user-facing unlocker report.
pub fn unlocker_name(&self) -> Option<&str> {
self.unlocker_name.as_deref()
}
/// Access the SCSI transport for direct commands (used by CSS/AACS auth).
@@ -224,10 +240,10 @@ impl Drive {
self.scsi.as_mut()
}
/// The OEM Volume ID a matching [`crate::unlock::Unlocker`] returned at
/// [`Drive::init`], if any. The AACS handshake uses this to skip the cert
/// handshake when an unlocker already supplied the VID. `None` when no
/// unlocker matched or it produced no VID.
/// The OEM Volume ID a matching unlocker returned at [`Drive::init`], if any.
/// The AACS handshake uses this to skip the cert handshake when an unlocker
/// already supplied the VID. `None` when no unlocker matched or it produced
/// no VID.
pub(crate) fn oem_vid(&self) -> Option<[u8; 16]> {
self.oem_vid
}
@@ -334,15 +350,17 @@ impl Drive {
}
/// Name of the unlocker handling this drive. After `init()` this is the
/// unlocker that ran; before `init()` it reflects the registry match by
/// unlocker that ran; before `init()` it reflects the unlocker match by
/// identity. `"Unknown"` when no unlocker matches.
pub fn platform_name(&self) -> &str {
if let Some(ref n) = self.unlocker_name {
return n;
}
// Cache the registry match so we can hand out a `&str` borrow.
// Cache the unlocker match so we can hand out a `&str` borrow.
self.matched_name_cache.get_or_init(|| {
crate::unlock::matching_name(&self.drive_id).unwrap_or_else(|| "Unknown".to_string())
crate::unlock_bridge::unlocker_name(&self.drive_id)
.map(str::to_string)
.unwrap_or_else(|| "Unknown".to_string())
})
}
@@ -353,7 +371,7 @@ impl Drive {
/// Current mounted-disc profile from the GET CONFIGURATION header
/// (Current Profile, bytes 6-7). DVD family is `0x0010..=0x001F`, BD
/// family `0x0040..=0x0043`. This is a stock MMC command — it works
/// before (and without) any firmware unlock. `None` if unreadable.
/// before (and without) any drive unlock. `None` if unreadable.
fn current_profile(&mut self) -> Option<u16> {
let cdb = [
crate::scsi::SCSI_GET_CONFIGURATION,
@@ -390,10 +408,10 @@ impl Drive {
matches!(self.current_profile(), Some(p) if (0x0010..=0x001F).contains(&p))
}
/// Initialize drive — unlock + firmware upload.
/// Initialize drive — drive-prep unlock + init.
/// Optional. Adds features: removes riplock, enables UHD reads, speed control.
///
/// The LibreDrive/OEM firmware unlock is required for BD/UHD (AACS) reads,
/// The drive-prep (OEM) unlock is required for BD/UHD (AACS) reads,
/// but it puts the drive in an extended-access state where stock CSS
/// authentication no longer works — so a CSS-protected DVD can't be read.
/// For a DVD we therefore SKIP the unlock and run the drive in its normal
@@ -402,45 +420,51 @@ impl Drive {
pub fn init(&mut self) -> Result<()> {
let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::drive", phase = "init", "begin");
if self.disc_is_dvd() {
tracing::info!(target: "freemkv::drive", phase = "init", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no unlock)");
self.init_ran = true;
return Ok(());
}
// Walk the unlock registry: the first unlocker whose identity
// matches runs; none matching leaves the drive in stock mode so the
// host-cert AACS handshake (the OEM route) carries the disc.
let r = crate::unlock::route_unlock(self.scsi.as_mut(), &self.drive_id);
// Drive-prep runs for EVERY disc, DVD INCLUDED. The drive-level firmware
// unlock lifts riplock and readies max read speed regardless of disc type
// — drive features are disc-independent. At init the disc kind is not yet
// probed (Unknown), so only the identity-keyed DRIVE unlocker matches
// here; the AACS host-cert handshake and the CSS bus-auth handshake run
// LATER, each gated on the actual disc kind, on TOP of the already-
// unlocked drive. A genuine transport fault means the bus is dead — abort
// init (the v1.1.0 invariant; `if let Ok` was silently swallowing it).
// Every other error (NotApplicable / no match) is "nothing applied" —
// fall through to stock mode. NOTE: the `if disc_is_dvd() { return }` skip
// that used to sit here was the v1.0.0-rc.1 regression — it skipped the
// drive-prep for DVD, leaving DVDs riplocked at stock speed.
self.init_ran = true;
let r = match r {
Ok(Some((name, vid))) => {
self.unlocker_name = Some(name);
let (matched, unlock_res) =
crate::unlock_bridge::run_features(self.scsi.as_mut(), &self.drive_id);
let r: Result<()> = match unlock_res {
Ok(unlocked) => {
// Record WHICH drive-prep unlocker actually ran — "LibreDrive"
// (MediaTek) or "Renesas" — not the ld-only identity lookup, so a
// Renesas drive reports itself honestly rather than as nothing.
self.unlocker_name = Some(matched.to_string());
// Stash the OEM Volume ID the unlocker returned for the AACS
// handshake phase (do_handshake reads it via `oem_vid()`).
self.oem_vid = Some(vid.0);
// The matched unlocker may also be able to raise the drive to
// its maximum read speed. Best-effort: a failure here must NOT
// fail the rip — a slow drive still rips. Log and continue.
if let Err(e) =
crate::unlock::unlocker_set_max_read_speed(self.scsi.as_mut(), &self.drive_id)
{
tracing::warn!(
target: "freemkv::drive",
phase = "init",
error = ?e,
"unlocker set_max_read_speed failed; continuing at current speed"
);
// handshake phase (do_handshake reads it via `oem_vid()`). A
// drive-prep unlocker always carries a VID; guard anyway.
if let Some(vid) = unlocked.vid {
self.oem_vid = Some(vid);
}
Ok(())
}
// No unlocker matched, or one matched but only hit a capability
// failure (not firmware-unlockable / no OEM VID): not an error —
// fall through to the OEM host-cert route.
Ok(None) => Ok(()),
// A genuine transport fault during unlock (UnlockError::Scsi)
// propagates here and aborts init — the bus is dead.
Err(e) => Err(e),
Err(freemkv_unlock::UnlockError::Transport) => Err(Error::ScsiError {
opcode: 0,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
}),
Err(_) => Ok(()),
};
// Raise the drive to its maximum read speed — UNCONDITIONALLY whenever
// the bus is alive (init didn't transport-fault), whether or not an
// unlocker matched, and for ANY disc type (DVD now flows through here too,
// so it gets max speed on the freshly firmware-unlocked drive instead of
// the stock riplock). A stock-mode drive with no firmware unlocker still
// wants max speed. Best-effort: a failure here must NOT fail the rip.
if r.is_ok() {
self.set_speed(crate::speed::DriveSpeed::Max.to_kbps());
}
tracing::info!(
target: "freemkv::drive",
phase = "init",
@@ -458,14 +482,9 @@ impl Drive {
pub fn probe_disc(&mut self) -> Result<()> {
let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::drive", phase = "probe_disc", "begin");
// A DVD runs in stock mode (see `init`); skip the OEM/firmware-path
// disc calibration, which only applies to the unlocked BD/UHD drive.
if self.disc_is_dvd() {
tracing::info!(target: "freemkv::drive", phase = "probe_disc", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no calibration)");
return Ok(());
}
// Disc-speed calibration is firmware-specific and now lives inside
// the unlocker's `unlock()` (run at `init()`). Nothing to do here.
// Disc-speed calibration is unlocker-specific and now lives inside
// the unlocker's `unlock()` (run at `init()`, for every disc including
// DVD). Nothing to do here — no disc-type branch.
tracing::info!(
target: "freemkv::drive",
phase = "probe_disc",
@@ -609,16 +628,14 @@ impl Drive {
/// Whether libfreemkv should take the OEM extended-access read path.
///
/// Whether a registered [`crate::unlock::Unlocker`] matches this drive.
///
/// An unlocker unlocks *drive functionality* — firmware unlock, OEM VID
/// retrieval, and other vendor capabilities. When one matches, libfreemkv
/// routes both `unlock` and OEM VID through it (VID via the OEM path is
/// decoupled from the host cert + HRL). This mirrors [`Self::has_profile`]
/// — the honest signal is "a registered unlocker claims this drive" —
/// rather than the old const `false`.
/// True when an unlocker claims this drive by identity. Such an unlocker
/// unlocks *drive functionality* — drive unlock, OEM VID retrieval, and other
/// vendor capabilities. When one matches, libfreemkv routes both `unlock` and
/// OEM VID through it (VID via the OEM path is decoupled from the host cert +
/// HRL). This mirrors [`Self::has_profile`] — the honest signal is "an
/// unlocker claims this drive" — rather than the old const `false`.
pub fn is_unlocked(&self) -> bool {
crate::unlock::matching_name(&self.drive_id).is_some()
crate::unlock_bridge::unlocker_name(&self.drive_id).is_some()
}
/// Read sectors from the disc. Single-shot — no inline retries, no
@@ -640,6 +657,25 @@ impl Drive {
/// recovery layers (Disc::patch multi-pass, DiscStream batch halving)
/// do not touch the wedge-prone reset path.
pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8], recovery: bool) -> Result<usize> {
// Bulk path: FUA off (the drive cache IS the streaming throughput).
self.read_fua(lba, count, buf, recovery, false)
}
/// [`read`], but with an explicit Force Unit Access request: `fua = true`
/// sets the READ(10) FUA bit so the drive re-fetches the medium instead of
/// returning a cached copy — the Pass-N marginal-sector lever (see
/// [`crate::sector::SectorSource::read_sectors_fua`]). The bulk sweep always
/// passes `false`; only a per-sector recovery handler asks for FUA.
///
/// [`read`]: Drive::read
pub fn read_fua(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
fua: bool,
) -> Result<usize> {
let timeout_ms = if recovery {
crate::scsi::READ_RECOVERY_TIMEOUT_MS
} else {
@@ -665,7 +701,7 @@ impl Drive {
// read_one call with no behavior change.
let max_sectors = (self.scsi.max_transfer_bytes() / 2048).max(1) as u32;
if count as u32 <= max_sectors {
return self.read_one(lba, count, buf, timeout_ms, recovery);
return self.read_one(lba, count, buf, timeout_ms, recovery, fua);
}
// Large read: split into chunks of at most `max_sectors` sectors,
@@ -681,7 +717,7 @@ impl Drive {
let byte_off = done as usize * 2048;
let byte_len = chunk as usize * 2048;
let slice = &mut buf[byte_off..byte_off + byte_len];
let n = self.read_one(cur_lba, chunk as u16, slice, timeout_ms, recovery)?;
let n = self.read_one(cur_lba, chunk as u16, slice, timeout_ms, recovery, fua)?;
total += n;
done += chunk;
}
@@ -701,13 +737,23 @@ impl Drive {
count: u16,
buf: &mut [u8],
timeout_ms: u32,
// `recovery` only gates the Linux /dev/sr0 pread fallback below; on
// `recovery` gates only the Linux /dev/sr0 pread fallback below; on
// other platforms it is intentionally unused.
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))] recovery: bool,
// FUA (Force Unit Access): when set, byte-1 bit 0x08 forces the drive to
// re-fetch the medium past its cache.
fua: bool,
) -> Result<usize> {
// FUA is OFF on the bulk path — unconditionally forcing every READ(10)
// past the cache disabled the drive's readahead/streaming cache on the
// sequential sweep and collapsed throughput ~10x (UHD 15-25 → ~2 MB/s,
// DVD → ~0.5 MB/s), disc-type-agnostic — the cache IS the streaming
// throughput. It is set ONLY when a Pass-N recovery handler (FuaRetry)
// asks for it per marginal-sector re-read, where cache-masking of a
// stochastic sector actually matters (#55).
let cdb = [
crate::scsi::SCSI_READ_10,
0x00,
if fua { 0x08 } else { 0x00 },
(lba >> 24) as u8,
(lba >> 16) as u8,
(lba >> 8) as u8,
@@ -886,6 +932,29 @@ impl Drive {
Ok(())
}
/// Soft power-cycle the drive mechanism WITHOUT ejecting: spin the disc
/// down (`START STOP UNIT`, START=0, **LOEJ=0**) then back up (START=1).
/// This clears the BU40N/Initio fast-fail *wedge* state that a run of
/// `HARDWARE_ERROR` reads leaves the drive in — the non-eject equivalent of
/// the power-cycle our notes say the wedge needs. The disc stays loaded (the
/// BU40N is slot-loading; we NEVER eject to recover — a hands-on eject is a
/// failure for an unattended service). Validated live 2026-07-01: took the
/// drive from failing-every-read back to reading at MB/s.
pub fn spin_cycle(&mut self) -> Result<()> {
let stop = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x00, 0]; // START=0, LOEJ=0 → spin down
let start = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x01, 0]; // START=1, LOEJ=0 → spin up
let mut buf = [0u8; 0];
self.scsi
.as_mut()
.execute(&stop, crate::scsi::DataDirection::None, &mut buf, 30_000)?;
std::thread::sleep(std::time::Duration::from_secs(SPIN_DOWN_IDLE_SECS));
self.scsi
.as_mut()
.execute(&start, crate::scsi::DataDirection::None, &mut buf, 30_000)?;
std::thread::sleep(std::time::Duration::from_secs(SPIN_UP_SETTLE_SECS));
Ok(())
}
pub fn scsi_execute(
&mut self,
cdb: &[u8],
@@ -970,6 +1039,17 @@ impl SectorSource for Drive {
self.read(lba, count, buf, recovery)
}
fn read_sectors_fua(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
fua: bool,
) -> Result<usize> {
self.read_fua(lba, count, buf, recovery, fua)
}
fn set_speed(&mut self, kbs: u16) {
Drive::set_speed(self, kbs);
}
@@ -1245,7 +1325,7 @@ mod command_tests {
/// `disc_is_dvd()` must match the DVD profile family (0x0010..=0x001F)
/// and ONLY that family. A false positive on a BD/UHD profile (0x0040+)
/// would skip the LibreDrive firmware unlock that UHD reads require; a
/// would skip the drive unlock that UHD reads require; a
/// false negative on a DVD would re-introduce the CSS read failure. The
/// Current Profile is bytes 6-7 of the GET CONFIGURATION header.
/// Mutation: widening the range to `..=0x0040` makes the BD-ROM assert
@@ -1259,7 +1339,7 @@ mod command_tests {
hdr[7] = profile as u8;
drive_with(hdr).disc_is_dvd()
};
// DVD family → DVD (skip firmware unlock, run stock for CSS).
// DVD family → DVD (skip drive unlock, run stock for CSS).
assert!(probe(0x0010), "DVD-ROM");
assert!(probe(0x0011), "DVD-R");
assert!(probe(0x001B), "DVD+R DL");
@@ -1269,7 +1349,7 @@ mod command_tests {
assert!(!probe(0x0008), "CD-ROM");
assert!(!probe(0x0000), "no/unknown profile");
// Short / failed GET CONFIGURATION → no Current Profile → NOT DVD,
// so the firmware unlock still runs (safe default).
// so the drive unlock still runs (safe default).
assert!(
!drive_with(vec![0u8; 4]).disc_is_dvd(),
"short GET CONFIGURATION must default to not-DVD (unlock still runs)"
@@ -1364,8 +1444,10 @@ mod command_tests {
#[test]
fn read_builds_read10_cdb_with_be_lba_and_count() {
// Drive::read issues READ(10) (0x28). LBA bytes 2..5 big-endian,
// transfer length bytes 7..8 big-endian (MMC-6). No FUA on this
// path (byte 1 == 0). Distinct nibbles catch a swapped shift.
// transfer length bytes 7..8 big-endian (MMC-6). FUA is DISABLED (byte 1
// == 0x00) so the drive cache/readahead is allowed — forcing FUA on the
// bulk sweep collapsed throughput ~10x. Distinct nibbles catch a swapped
// shift.
let RecordingHarness {
drive: mut d,
cdb,
@@ -1376,11 +1458,33 @@ mod command_tests {
assert_eq!(n, 4096, "returns transport bytes_transferred");
let c = cdb.lock().unwrap();
assert_eq!(c[0], crate::scsi::SCSI_READ_10);
assert_eq!(c[1], 0x00, "Drive::read path sets no FUA");
assert_eq!(
c[1], 0x00,
"FUA disabled — cache/readahead allowed on the bulk read path"
);
assert_eq!(&c[2..6], &[0x00, 0xAB, 0xCD, 0xEF], "LBA big-endian");
assert_eq!(&c[7..9], &[0x00, 0x02], "transfer length big-endian");
}
#[test]
fn read_fua_sets_the_force_unit_access_bit() {
// The Pass-N FuaRetry lever: read_fua(.., fua=true) sets READ(10) byte-1
// bit 0x08 so the drive re-fetches the medium past its cache; fua=false
// leaves it clear (the bulk path).
let RecordingHarness {
drive: mut d,
cdb,
timeouts: _to,
} = recording(TransportOutcome::Ok(2048));
let mut buf = vec![0u8; 2048];
d.read_fua(0, 1, &mut buf, false, true).unwrap();
assert_eq!(
cdb.lock().unwrap()[1],
0x08,
"FUA requested — byte-1 bit 0x08 set so the drive bypasses its cache"
);
}
#[test]
fn read_recovery_flag_selects_60s_timeout() {
// recovery=true must use READ_RECOVERY_TIMEOUT_MS (60 s); false
+43 -5
View File
@@ -88,6 +88,7 @@ pub const E_NO_DISC_KEY: u16 = 7022;
pub const E_CSS_KEY_MISSING: u16 = 7023;
pub const E_AACS_NO_HOST_CERT: u16 = 7024;
pub const E_AACS_BUS_KEY_UNAVAILABLE: u16 = 7025;
pub const E_FMTS_KEY_MISSING: u16 = 7026;
// Keydb (8xxx)
pub const E_KEYDB_CONNECT: u16 = 8000;
@@ -360,6 +361,15 @@ pub enum Error {
/// time and no handshake runs.
AacsBusKeyUnavailable,
/// AACS 2.1 (FMTS) disc carries forensic variant segments, but no segment
/// (variant) key is available to open them, and `BYPASS_FMTS_KEY` is `false`
/// (strict mode). Raised UPFRONT — before the mux — exactly like a missing
/// unit key, so a 2.1 disc that would rip with holes is refused rather than
/// silently producing a forensic-holed output. When `BYPASS_FMTS_KEY` is
/// `true` (the default today) this is never raised: the bulk decodes with the
/// unit key and the forensic segments are skipped as expected loss.
FmtsKeyMissing,
// Keydb (8xxx)
KeydbConnect {
host: String,
@@ -573,6 +583,7 @@ impl Error {
Error::CssKeyMissing => E_CSS_KEY_MISSING,
Error::AacsNoHostCert { .. } => E_AACS_NO_HOST_CERT,
Error::AacsBusKeyUnavailable => E_AACS_BUS_KEY_UNAVAILABLE,
Error::FmtsKeyMissing => E_FMTS_KEY_MISSING,
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
Error::KeydbInvalid => E_KEYDB_INVALID,
@@ -879,6 +890,15 @@ impl Error {
status: Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE),
..
}
) || matches!(
// A failed `ioctl(SG_IO)` (Error::IoError, e.g. ENODEV/EIO on an
// unplugged USB bridge) and a vanished device (Error::DeviceNotFound,
// fd gone) are dead-bus / transport-layer faults too — NOT recoverable
// bad sectors. Treat them as transport failures so sweep / patch /
// fill_extents abort the pass and re-enumerate the bridge instead of
// zero-filling every read against a wedged device.
self,
Error::IoError { .. } | Error::DeviceNotFound { .. }
)
}
@@ -1200,6 +1220,7 @@ mod tests {
E_CSS_KEY_MISSING,
E_AACS_NO_HOST_CERT,
E_AACS_BUS_KEY_UNAVAILABLE,
E_FMTS_KEY_MISSING,
E_KEYDB_CONNECT,
E_KEYDB_HTTP,
E_KEYDB_INVALID,
@@ -1375,11 +1396,13 @@ mod tests {
}
}
/// is_scsi_transport_failure returns true only for SCSI_STATUS_TRANSPORT_FAILURE (0xFF).
/// Spec: comment on SCSI_STATUS_TRANSPORT_FAILURE says "synthesised sentinel: the
/// transport never delivered a SCSI status byte".
/// is_scsi_transport_failure is true for the 0xFF SCSI sentinel AND for the
/// non-SCSI dead-bus faults (Error::IoError from a failed ioctl(SG_IO),
/// Error::DeviceNotFound from a vanished fd) — but NEVER for a real SCSI
/// reply (CHECK CONDITION) or unrelated errors.
/// Mutation: testing against 0x02 (CHECK CONDITION) would wrongly mark CHECK
/// CONDITION replies as transport failures.
/// CONDITION replies as transport failures; dropping the IoError/
/// DeviceNotFound arm would let a dead bus zero-fill the disc.
#[test]
fn is_scsi_transport_failure_only_for_0xff() {
use crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE;
@@ -1403,7 +1426,22 @@ mod tests {
};
assert!(!cc.is_scsi_transport_failure());
// False for non-SCSI errors.
// True: non-SCSI dead-bus faults — a failed ioctl(SG_IO) and a vanished
// device are transport-layer failures, not recoverable bad sectors.
assert!(
Error::IoError {
source: std::io::Error::from(std::io::ErrorKind::NotConnected)
}
.is_scsi_transport_failure()
);
assert!(
Error::DeviceNotFound {
path: "/dev/sg9".into()
}
.is_scsi_transport_failure()
);
// False for unrelated errors.
assert!(!Error::Halted.is_scsi_transport_failure());
}
+106
View File
@@ -0,0 +1,106 @@
//! The single hex → bytes parser for the whole workspace.
//!
//! Key material arrives as hex from three third-party sources — the keydb, an
//! online key service, and the mapfile's `# freemkv-vid:` comment — and each
//! used to parse it slightly differently (one stripped `0x`/`0X`, one stripped
//! nothing, one stripped `0x` only). A key written with a prefix one parser
//! didn't expect was silently dropped → "can't decrypt" with no error. This is
//! the one parser they all call, so the prefix/case/validation rules live in
//! exactly one place.
//!
//! Operates on BYTES, not `&str` char indices: the inputs are untrusted, so a
//! multi-byte UTF-8 scalar must reject as malformed, never panic on a
//! mid-codepoint slice.
/// Parse a hex string into bytes. Accepts an optional `0x`/`0X` prefix
/// (case-insensitive), then requires an even run of ASCII hex digits. Any
/// non-hex byte, or an odd length, yields `None`.
pub fn parse_hex_bytes(s: &str) -> Option<Vec<u8>> {
let body = strip_prefix(s.trim());
let bytes = body.as_bytes();
// Empty → empty Vec (a legitimately-empty variable-length field); odd length
// is malformed. (`parse_hex_fixed` enforces a concrete length separately.)
if bytes.len() % 2 != 0 {
return None;
}
let mut out = Vec::with_capacity(bytes.len() / 2);
for pair in bytes.chunks_exact(2) {
out.push(byte(pair[0], pair[1])?);
}
Some(out)
}
/// Parse a hex string into a fixed `[u8; N]`. Accepts an optional `0x`/`0X`
/// prefix; requires EXACTLY `2*N` ASCII hex digits after it. `None` on any
/// non-hex byte or a length mismatch.
pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
let body = strip_prefix(s.trim());
let bytes = body.as_bytes();
if bytes.len() != 2 * N {
return None;
}
let mut out = [0u8; N];
for (i, slot) in out.iter_mut().enumerate() {
*slot = byte(bytes[2 * i], bytes[2 * i + 1])?;
}
Some(out)
}
/// Strip a single leading `0x` / `0X` if present (case-insensitive).
fn strip_prefix(s: &str) -> &str {
s.strip_prefix("0x")
.or_else(|| s.strip_prefix("0X"))
.unwrap_or(s)
}
/// Combine two ASCII hex-digit bytes into one byte. `as char` is intentional:
/// for a non-ASCII byte it produces a Latin-1 scalar that `to_digit(16)` then
/// rejects — so non-hex (incl. `+`/`-` sign chars) and multi-byte input fail
/// cleanly rather than slipping through `from_str_radix`'s sign handling.
fn byte(hi: u8, lo: u8) -> Option<u8> {
let hi = (hi as char).to_digit(16)?;
let lo = (lo as char).to_digit(16)?;
Some((hi * 16 + lo) as u8)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixed_accepts_0x_0x_and_bare_same_result() {
let want = [0x00, 0x11, 0xab, 0xCD, 0xef, 0x42, 0x99, 0x00];
let bare = "0011abcdef429900";
assert_eq!(parse_hex_fixed::<8>(bare), Some(want));
assert_eq!(parse_hex_fixed::<8>(&format!("0x{bare}")), Some(want));
// The case that used to be dropped by one parser but not another.
assert_eq!(parse_hex_fixed::<8>(&format!("0X{bare}")), Some(want));
assert_eq!(parse_hex_fixed::<8>(&format!(" 0X{bare} ")), Some(want));
}
#[test]
fn fixed_rejects_wrong_length_and_non_hex_and_signs() {
assert_eq!(parse_hex_fixed::<16>("00"), None); // too short
assert_eq!(parse_hex_fixed::<2>("00112233"), None); // too long
assert_eq!(parse_hex_fixed::<2>("zz11"), None); // non-hex
assert_eq!(parse_hex_fixed::<2>("+5-A"), None); // sign chars
}
#[test]
fn does_not_panic_on_multibyte_of_exact_byte_length() {
// "中" is 3 bytes; + 29 'a' = 32 bytes → would mis-slice a &str-indexed
// parser. Must reject, not panic.
let s = "中".to_string() + &"a".repeat(29);
assert_eq!(s.len(), 32);
assert_eq!(parse_hex_fixed::<16>(&s), None);
}
#[test]
fn bytes_variable_length_and_odd_rejected() {
assert_eq!(parse_hex_bytes("0xAABBCC"), Some(vec![0xAA, 0xBB, 0xCC]));
assert_eq!(parse_hex_bytes("AABBC"), None); // odd
// Empty (or prefix-only) → empty Vec: a legitimately-empty field.
assert_eq!(parse_hex_bytes(""), Some(vec![]));
assert_eq!(parse_hex_bytes("0x"), Some(vec![]));
}
}
+69 -35
View File
@@ -621,37 +621,34 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
}
/// Assign the on-wire `private_stream_1` sub-stream id to each audio
/// stream by per-codec ordinal, matching DVD authoring convention and the
/// muxer's `dvd_pid()` routing:
/// - AC-3 → `0x80 + n` (n = 0-based index among AC-3 streams)
/// - DTS → `0x88 + n`
/// - LPCM → `0xA0 + n`
/// stream. On DVD-Video the sub-id's **low nibble is the audio-stream
/// *number* (0-7), shared across all codecs** — the single stream index the
/// PGC `audio_control` table / navigation registers select — and the high
/// nibble is the codec base. So the sub-id is `codec_base | position`, where
/// `position` is the stream's index in the IFO audio-attribute table (NOT a
/// per-codec running count):
/// - AC-3 → `0x80 | i`
/// - DTS → `0x88 | i`
/// - LPCM → `0xA0 | i`
/// - MP1/MP2 and anything else → `None` (regular MPEG-audio PES, not a
/// private-stream-1 sub-id).
///
/// Indices saturate at the codec range ceiling (8 AC-3/DTS, 8 LPCM) so a
/// malformed over-count never produces an out-of-range sub-id.
/// A per-codec ordinal was wrong: it only coincides with the wire id when a
/// codec's first stream is also the disc's audio stream #0. Any codec that is
/// not the first audio stream (e.g. a DTS track after an AC-3 track) then got
/// a sub-id one-too-low, so the demux routing key (`0xBD00 | sub_id`) never
/// matched and the track muxed silent. The positional index is the real wire
/// number, so distinct positions still give distinct sub-ids (no collision).
///
/// Position saturates at 7 so a malformed over-count never produces an
/// out-of-range sub-id.
fn assign_audio_sub_stream_ids(streams: &mut [DvdAudioAttr]) {
let mut n_ac3 = 0u8;
let mut n_dts = 0u8;
let mut n_lpcm = 0u8;
for s in streams.iter_mut() {
for (i, s) in streams.iter_mut().enumerate() {
let n = (i as u8).min(7);
s.sub_stream_id = match s.codec {
Codec::Ac3 => {
let id = 0x80 + n_ac3.min(7);
n_ac3 = n_ac3.saturating_add(1);
Some(id)
}
Codec::Dts => {
let id = 0x88 + n_dts.min(7);
n_dts = n_dts.saturating_add(1);
Some(id)
}
Codec::Lpcm => {
let id = 0xA0 + n_lpcm.min(7);
n_lpcm = n_lpcm.saturating_add(1);
Some(id)
}
Codec::Ac3 => Some(0x80 | n),
Codec::Dts => Some(0x88 | n),
Codec::Lpcm => Some(0xA0 | n),
_ => None,
};
}
@@ -1138,9 +1135,10 @@ mod tests {
#[test]
fn mixed_codec_sub_stream_ids_are_distinct() {
// A title mixing AC-3, DTS and LPCM must get per-codec ordinal
// sub-ids (0x80, 0x88, 0xA0...), all distinct — this is the
// routing key that keeps mixed-codec audio from colliding.
// A title mixing AC-3, DTS and LPCM: the sub-id low nibble is the
// POSITIONAL audio-stream number (shared across codecs), OR'd with the
// codec base. So idx 1 (DTS) → 0x89, idx 3 (AC-3) → 0x83 — the real
// wire ids the demux routes on. All distinct (positions are unique).
let mut streams = vec![
DvdAudioAttr {
codec: Codec::Ac3,
@@ -1172,10 +1170,10 @@ mod tests {
},
];
assign_audio_sub_stream_ids(&mut streams);
assert_eq!(streams[0].sub_stream_id, Some(0x80)); // AC-3 #0
assert_eq!(streams[1].sub_stream_id, Some(0x88)); // DTS #0
assert_eq!(streams[2].sub_stream_id, Some(0xA0)); // LPCM #0
assert_eq!(streams[3].sub_stream_id, Some(0x81)); // AC-3 #1
assert_eq!(streams[0].sub_stream_id, Some(0x80)); // AC-3 @ pos 0
assert_eq!(streams[1].sub_stream_id, Some(0x89)); // DTS @ pos 1
assert_eq!(streams[2].sub_stream_id, Some(0xA2)); // LPCM @ pos 2
assert_eq!(streams[3].sub_stream_id, Some(0x83)); // AC-3 @ pos 3
// All sub-ids unique.
let ids: Vec<u8> = streams.iter().filter_map(|s| s.sub_stream_id).collect();
let mut sorted = ids.clone();
@@ -1184,6 +1182,42 @@ mod tests {
assert_eq!(ids.len(), sorted.len(), "sub-stream ids must be unique");
}
/// Regression (The Punisher 2004): audio[0]=AC-3 5.1, audio[1]=DTS 5.0.
/// The DTS track sits at audio position 1, so its wire sub-id is 0x89
/// (0x88 | 1), NOT the per-codec 0x88. With the old per-codec ordinal it
/// got 0x88 → demux routing key 0xBD88 had no match → every DTS packet
/// (which carries 0x89) was dropped → the track muxed present-but-silent
/// while the AC-3 (at position 0, where ordinal and position coincide)
/// played fine. Positional numbering fixes it end-to-end.
#[test]
fn dts_after_ac3_uses_positional_substream_id() {
let mut streams = vec![
DvdAudioAttr {
codec: Codec::Ac3,
channels: 6,
sample_rate: 48000,
language: "en".into(),
sub_stream_id: None,
},
DvdAudioAttr {
codec: Codec::Dts,
channels: 5,
sample_rate: 48000,
language: "en".into(),
sub_stream_id: None,
},
];
assign_audio_sub_stream_ids(&mut streams);
assert_eq!(streams[0].sub_stream_id, Some(0x80));
assert_eq!(
streams[1].sub_stream_id,
Some(0x89),
"DTS at audio position 1 routes to 0x89 on the wire, not 0x88"
);
// The routing key the muxer actually uses must resolve for 0x89.
assert_eq!(crate::mux::ps::dvd_audio_pid(0x89), Some(0xBD89));
}
#[test]
fn audio_attr_dts() {
let mut data = vec![0u8; 16];
@@ -1371,10 +1405,10 @@ mod tests {
];
assign_audio_sub_stream_ids(&mut streams);
assert_eq!(streams[0].sub_stream_id, None); // MP2 → no sub-id
assert_eq!(streams[1].sub_stream_id, Some(0x80)); // AC3 #0
assert_eq!(streams[1].sub_stream_id, Some(0x81)); // AC3 @ pos 1
}
/// assign_audio_sub_stream_ids saturates the per-codec ordinal at the
/// assign_audio_sub_stream_ids saturates the positional index at the
/// range ceiling (min(7)) so a malformed over-count never produces an
/// out-of-range sub-id. 9 AC-3 streams: the 9th still ≤ 0x87.
#[test]
+8 -2
View File
@@ -449,7 +449,10 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
} else {
// Benign per-item OK: trace-level (L4) only; the
// apply-side rolling summary carries throughput.
tracing::trace!("Pipeline send: OK in {:.3}ms", elapsed.as_micros());
tracing::trace!(
"Pipeline send: OK in {:.3}ms",
elapsed.as_secs_f64() * 1000.0
);
}
}
Ok(())
@@ -464,7 +467,10 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
std::any::type_name::<I>()
);
} else {
tracing::debug!("Pipeline send: failed after {:.3}ms", elapsed.as_micros());
tracing::debug!(
"Pipeline send: failed after {:.3}ms",
elapsed.as_secs_f64() * 1000.0
);
}
}
Err(e.0)
+294 -51
View File
@@ -3,12 +3,13 @@
//! libfreemkv performs NO key lookup. An application resolves a disc's keys
//! through one or more [`KeySource`]s, each an adapter over a backing store (a
//! keydb file, a key server, the mapfile cache). A source's job is to return the
//! disc's terminal **Unit Keys** ([`crate::aacs::UnitKey`]). It knows what
//! disc's terminal **Unit Keys** ([`crate::aacs::types::UnitKey`]). It knows what
//! material it holds (a DK / MK / VUK / pre-decrypted UK) and what it must fetch
//! from the disc (VID, MKB, encrypted title keys, content samples) to get there;
//! it orchestrates the derivation by calling libfreemkv's own boil-down crypto
//! primitives ([`crate::aacs::mk_from_dk`] / [`crate::aacs::vuk_from_mk`] /
//! [`crate::aacs::uk_from_vuk`]) through the [`ResolveCtx`] handed to it.
//! it orchestrates the derivation by calling libfreemkv's own derivation
//! primitives ([`crate::aacs::derive::derive_media_key_from_dk`] /
//! [`crate::aacs::derive::derive_vuk`] / [`crate::aacs::derive::decrypt_unit_key`])
//! through the [`ResolveCtx`] handed to it.
//!
//! libfreemkv still OWNS the crypto: the boil-down primitives and the AES live
//! here. A source owns only PATH ORCHESTRATION — deciding which primitive to
@@ -17,7 +18,8 @@
//! keeping key *policy* (which store, which order, online vs local) out of the
//! library.
use crate::aacs::{HostCert, UnitKey, Vid};
use crate::aacs::types::HostCert;
use crate::aacs::types::{UnitKey, Vid};
use crate::disc::Key;
use crate::error::Error;
@@ -32,6 +34,10 @@ pub struct DiscInputs {
/// Volume ID (16 bytes). `[0u8; 16]` when no authenticated handshake ran
/// (e.g. an ISO/mapfile flow), which disables VID-keyed lookups.
pub volume_id: [u8; 16],
/// AACS major version (1 = V10 / BD AACS 1.0, 2 = V20+ / UHD). Drives the
/// `Unit_Key_RO.inf` parse stride (48-byte V10 vs 64-byte V20/V21) when a
/// source returns a VUK to derive unit keys from. Defaults to 2.
pub version: u8,
/// Raw MKB bytes. Empty when not captured.
pub mkb: Vec<u8>,
/// Raw `Unit_Key_RO.inf` bytes. Empty when not captured.
@@ -70,8 +76,8 @@ pub trait ResolveCtx {
/// Raw MKB bytes (may be empty when not captured).
fn mkb(&self) -> Result<&[u8], Error>;
/// The disc's encrypted title keys, parsed from `Unit_Key_RO.inf` the same
/// way the library's resolver parses them ([`crate::aacs::parse_unit_key_ro`]),
/// in on-disc order. Feed straight into [`crate::aacs::uk_from_vuk`].
/// way the library's resolver parses them ([`crate::aacs::inf::parse_unit_key_ro`]),
/// in on-disc order. Feed straight into [`crate::aacs::derive::decrypt_unit_key`].
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error>;
/// Up to `n` encrypted on-disc content sample units, for a source that
/// validates a candidate server-side against real ciphertext.
@@ -99,24 +105,22 @@ pub struct DiscInputsCtx<'a> {
impl<'a> DiscInputsCtx<'a> {
/// Build a context over `inputs`, parsing the encrypted title keys at the
/// stride for AACS major `version_u8` (1 = V10, else V20/V21).
/// stride for the disc's own AACS major (`inputs.version`: 1 → 48-byte V10
/// stride, else 64-byte V20/V21) — the single source of truth, no separate
/// version argument to drift from it.
///
/// A present-but-malformed `unit_key_ro` (truncated / wrong magic / wrong
/// stride) parses to an empty key set, so a later [`Self::enc_title_keys`]
/// returns `Ok(&[])` indistinguishably from a disc that legitimately has no
/// title keys — the parse failure is swallowed here, not surfaced as an
/// error.
pub fn new(inputs: &'a DiscInputs, version_u8: u8) -> Self {
use crate::aacs::{AacsVersion, parse_unit_key_ro};
pub fn new(inputs: &'a DiscInputs) -> Self {
use crate::aacs::inf::parse_unit_key_ro;
use crate::aacs::mkb::AacsVersion;
let enc_keys = if inputs.unit_key_ro.is_empty() {
Vec::new()
} else {
let version = if version_u8 == 1 {
AacsVersion::V10
} else {
AacsVersion::V20
};
parse_unit_key_ro(&inputs.unit_key_ro, version)
parse_unit_key_ro(&inputs.unit_key_ro, AacsVersion::from_major(inputs.version))
.map(|f| f.encrypted_keys.into_iter().map(|(_, k)| k).collect())
.unwrap_or_default()
};
@@ -163,8 +167,8 @@ impl ResolveCtx for DiscInputsCtx<'_> {
/// holds, orchestrates the derivation down to Unit Keys using the library's
/// boil-down crypto primitives — never re-implementing AES. A source that holds
/// pre-decrypted Unit Keys returns them directly; one that holds a VUK calls
/// [`crate::aacs::uk_from_vuk`]; one that holds device keys calls
/// [`crate::aacs::mk_from_dk`] → [`crate::aacs::vuk_from_mk`] → `uk_from_vuk`.
/// [`crate::aacs::derive::decrypt_unit_key`]; one that holds device keys calls
/// [`crate::aacs::derive::derive_media_key_from_dk`] → [`crate::aacs::derive::derive_vuk`] → `decrypt_unit_key`.
///
/// Returning an empty `Vec` means "no key for this disc from this source"; an
/// `Err` means the source itself failed (I/O, parse, network). The caller
@@ -208,7 +212,7 @@ pub fn resolve_and_apply(
}
/// Like [`resolve_and_apply`] but also returns a structured
/// [`crate::aacs::ResolutionTrace`] recording, per source, what happened — for
/// [`crate::aacs::trace::ResolutionTrace`] recording, per source, what happened — for
/// applications to render. ZERO English; the trace is typed enums only.
///
/// One-shot per source: each source's [`KeySource::get_uk`] is called exactly
@@ -218,8 +222,8 @@ pub fn resolve_and_apply(
/// success — so a wrong/partial key set is rejected and the loop continues.
///
/// CPS-unit numbering: a source returns Unit Keys carrying the POSITIONAL index
/// from [`crate::aacs::uk_from_vuk`]; the library's canonical CPS-unit number is
/// `position + 1` (matching [`crate::aacs::parse_unit_key_ro`]'s `(i + 1)`), so
/// from [`crate::aacs::derive::decrypt_unit_key`]; the library's canonical CPS-unit number is
/// `position + 1` (matching [`crate::aacs::inf::parse_unit_key_ro`]'s `(i + 1)`), so
/// the committed `AacsState.unit_keys` is byte-identical to the library-resolved
/// path. The number is cosmetic for descramble (the decrypt path strips it and
/// tries every key) but is kept faithful to the resolver's convention.
@@ -227,16 +231,14 @@ pub fn resolve_and_apply_traced(
sources: &[Box<dyn KeySource>],
inputs: &DiscInputs,
disc: &mut crate::Disc,
) -> (bool, crate::aacs::ResolutionTrace) {
) -> (bool, crate::aacs::trace::ResolutionTrace) {
use crate::aacs::trace::{KeyNode, KeyOutcome, KeyStep};
let mut trace = crate::aacs::ResolutionTrace::new();
let mut trace = crate::aacs::trace::ResolutionTrace::new();
// AACS major drives the Unit_Key_RO.inf stride the ctx parses at. Default to
// the V20/V21 stride when there is no AACS state (it is the common live case;
// a non-AACS disc has nothing to resolve and the loop simply finds nothing).
let version_u8 = disc.aacs.as_ref().map(|a| a.version).unwrap_or(2);
let ctx = DiscInputsCtx::new(inputs, version_u8);
// The ctx parses Unit_Key_RO.inf at the stride for `inputs.version` (the
// disc's own AACS major), so the stride is the disc's single source of truth.
let ctx = DiscInputsCtx::new(inputs);
for source in sources {
// `who` is the source's own stable identifier — no enum to map back to.
@@ -320,9 +322,11 @@ pub fn key_fetch(
let sources = make_sources();
let mut di = inputs.clone();
di.samples = samples.to_vec();
// V20/V21 stride (BD/UHD AACS 2.x); the online /decode UK path forwards
// raw inf + samples and doesn't depend on the parsed title-key stride.
let ctx = DiscInputsCtx::new(&di, 2);
// Parse Unit_Key_RO.inf at the disc's OWN stride (carried on `inputs`):
// an online /decode reply that returns a VUK (not a terminal UK) then
// derives unit keys from `enc_title_keys`, which a V10 disc parses at the
// 48-byte stride — hardcoding the V20 stride here corrupted them.
let ctx = DiscInputsCtx::new(&di);
fetch_unit_keys(&sources, &ctx)
.into_iter()
.map(|u| u.key)
@@ -340,19 +344,31 @@ pub fn key_fetch(
/// `start_lba`), which the library owns. A key source is *handed* these bytes
/// via `DiscInputs.samples`; it never reads the disc itself.
///
/// "Encrypted" is decided by [`crate::aacs::ts_sync_destroyed`] — the SAME
/// predicate the decrypt gate uses — so all sides agree. A clip opens with clear
/// navigation units (PAT/PMT, menus); only the feature body is scrambled, and a
/// clear unit proves nothing, so this collects only scrambled ones, sampling the
/// largest extent at its midpoint forward.
/// "Encrypted" is decided by [`crate::aacs::content::aacs_unit_encrypted`] — the
/// AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the
/// spec-correct signal (libaacs' `buf[0] & 0xc0`). NOT the `ts_sync_destroyed`
/// sync heuristic: destroyed TS syncs do not imply encryption (an FMTS variant
/// frame or an odd clear unit can lack syncs yet be unencrypted), and a clear
/// unit sent to a key server yields nothing to validate against — the "0
/// encrypted units" rejection. A clip opens with clear navigation units (PAT/PMT,
/// menus) whose CPI is clear; only CPI-flagged content units are collected —
/// probing several points spread across EACH extent so a title whose encrypted
/// body starts late (or whose midpoint lands in clear nav) still yields samples.
/// CPI is read at each extent's `start_lba` (clip-file-anchored), so byte 0 is a
/// real unit start and the flag is meaningful.
pub fn read_encrypted_units(
reader: &mut dyn crate::sector::SectorSource,
title: &crate::disc::DiscTitle,
n: usize,
) -> Vec<Vec<u8>> {
use crate::aacs::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed};
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap
const MAX_CHUNKS_PER_EXTENT: u32 = 4; // ~60 units scanned at each extent's midpoint
// Probe several evenly-spaced points across EACH extent rather than only the
// midpoint-and-forward: a title whose encrypted feature starts late, or whose
// midpoint lands in a clear nav stretch, must STILL yield scrambled samples.
// Empty samples make `Disc::decrypt_with` skip wrong-key validation, so a
// real encrypted title returning nothing here is a silent wrong-key hazard.
const PROBES_PER_EXTENT: u32 = 8;
let mut out: Vec<Vec<u8>> = Vec::new();
for ext in &title.extents {
@@ -360,25 +376,30 @@ pub fn read_encrypted_units(
if total_units == 0 {
continue;
}
let mut unit = total_units / 2; // midpoint (past the clear nav at the head)
for _ in 0..MAX_CHUNKS_PER_EXTENT {
for p in 1..=PROBES_PER_EXTENT {
// Probe at p/(P+1) of the extent — spreads P points across it while
// skipping the clear nav at the very head.
let unit = ((total_units as u64 * p as u64) / (PROBES_PER_EXTENT as u64 + 1)) as u32;
if unit >= total_units {
break;
continue;
}
let units_this = CHUNK_UNITS.min(total_units - unit);
// Saturate: start_lba comes from attacker-controlled UDF/MPLS
// extents; a malformed extent near u32::MAX would otherwise panic
// (debug) or wrap to a wrong LBA (release). An over-capacity LBA then
// fails cleanly via the read_sectors().is_err() break below.
// fails cleanly via the read_sectors().is_err() skip below.
let lba = ext
.start_lba
.saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS));
let count = (units_this * ALIGNED_UNIT_SECTORS) as u16;
let mut buf = vec![0u8; count as usize * 2048];
// `false` = no recovery retries; the reader is the raw drive/file
// (no decrypt decorator), so these are the on-disc encrypted bytes.
// (no decrypt decorator), so these are the on-disc encrypted bytes. A
// read error at one probe skips THAT probe only — it must not abandon
// the rest of the extent (the old `break` blinded the sampler on a
// single transient miss).
if reader.read_sectors(lba, count, &mut buf, false).is_err() {
break;
continue;
}
for i in 0..units_this as usize {
let o = i * ALIGNED_UNIT_LEN;
@@ -386,14 +407,13 @@ pub fn read_encrypted_units(
break;
}
let u = &buf[o..o + ALIGNED_UNIT_LEN];
if ts_sync_destroyed(u) {
if aacs_unit_encrypted(u) {
out.push(u.to_vec());
if out.len() >= n {
return out;
}
}
}
unit += units_this;
}
}
out
@@ -402,7 +422,7 @@ pub fn read_encrypted_units(
#[cfg(test)]
mod tests {
use super::*;
use crate::aacs::UnitKey;
use crate::aacs::types::UnitKey;
use std::sync::{Arc, Mutex};
// ── KeySource default-method behaviour ────────────────────────────────────
@@ -443,6 +463,7 @@ mod tests {
let inputs = DiscInputs {
disc_hash: "0xABC".into(),
volume_id: [0u8; 16],
version: crate::aacs::mkb::AACS_MAJOR_BD,
mkb: vec![1, 2, 3],
unit_key_ro: uk_ro,
samples: vec![vec![9u8; 4], vec![8u8; 4], vec![7u8; 4]],
@@ -450,7 +471,7 @@ mod tests {
};
// Zero VID → None.
let ctx = DiscInputsCtx::new(&inputs, 1);
let ctx = DiscInputsCtx::new(&inputs);
assert_eq!(ctx.disc_hash(), "0xABC");
assert_eq!(ctx.title(), Some("TITLE_X"));
assert!(ctx.vid().is_none(), "all-zero VID is the no-VID sentinel");
@@ -461,7 +482,7 @@ mod tests {
// Non-zero VID → Some(vid).
let mut inputs2 = inputs.clone();
inputs2.volume_id = [0x42u8; 16];
let ctx2 = DiscInputsCtx::new(&inputs2, 1);
let ctx2 = DiscInputsCtx::new(&inputs2);
assert_eq!(ctx2.vid(), Some(Vid([0x42u8; 16])));
}
@@ -498,6 +519,7 @@ mod tests {
let inputs = DiscInputs {
disc_hash: "0x00".into(),
volume_id: [0u8; 16],
version: crate::aacs::mkb::AACS_MAJOR_UHD,
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
@@ -518,6 +540,7 @@ mod tests {
DiscInputs {
disc_hash: String::new(),
volume_id: [0u8; 16],
version: crate::aacs::mkb::AACS_MAJOR_UHD,
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
@@ -552,7 +575,7 @@ mod tests {
#[test]
fn fetch_unit_keys_first_nonempty_skips_empty_and_errors() {
let inputs = empty_inputs();
let ctx = DiscInputsCtx::new(&inputs, 2);
let ctx = DiscInputsCtx::new(&inputs);
let key = [0xABu8; 16];
let sources: Vec<Box<dyn KeySource>> = vec![
@@ -607,7 +630,7 @@ mod tests {
});
let cb = key_fetch(empty_inputs(), make);
let samples = vec![vec![0xEEu8; crate::aacs::ALIGNED_UNIT_LEN]];
let samples = vec![vec![0xEEu8; crate::aacs::content::ALIGNED_UNIT_LEN]];
let got = cb(&samples);
assert_eq!(
got,
@@ -621,4 +644,224 @@ mod tests {
);
assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch");
}
/// #4 regression: encrypted content NOT at the extent midpoint (a late-
/// starting feature, or a midpoint landing in clear nav) must still be
/// sampled — empty samples make `decrypt_with` skip wrong-key validation.
/// The old midpoint-and-forward sampler returned empty; the probe-spread
/// finds the early scrambled band.
#[test]
fn read_encrypted_units_finds_scrambled_content_off_the_midpoint() {
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
use crate::error::Result;
use crate::sector::SectorSource;
// Units in the FIRST SIXTH of the extent are scrambled (0xFF → no TS
// sync); everything else (incl. the midpoint) is clear (0x47 syncs).
struct BandSource {
ext_start: u32,
total_units: u32,
}
impl SectorSource for BandSource {
fn capacity_sectors(&self) -> u32 {
self.ext_start + self.total_units * ALIGNED_UNIT_SECTORS + 64
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_r: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
for (i, chunk) in buf[..bytes].chunks_mut(ALIGNED_UNIT_LEN).enumerate() {
if chunk.len() < ALIGNED_UNIT_LEN {
break;
}
let abs_unit = (lba - self.ext_start) / ALIGNED_UNIT_SECTORS + i as u32;
if abs_unit < self.total_units / 6 {
chunk.fill(0xFF); // scrambled: no TS sync
} else {
chunk.fill(0);
let mut o = 4;
while o < ALIGNED_UNIT_LEN {
chunk[o] = 0x47; // clear TS syncs
o += 192;
}
}
}
Ok(bytes)
}
}
let total_units = 600u32;
let ext_start = 1000u32;
let mut src = BandSource {
ext_start,
total_units,
};
let title = crate::disc::DiscTitle {
playlist: String::new(),
playlist_id: 0,
duration_secs: 0.0,
size_bytes: 0,
clips: Vec::new(),
streams: Vec::new(),
chapters: Vec::new(),
extents: vec![crate::disc::Extent {
start_lba: ext_start,
sector_count: total_units * ALIGNED_UNIT_SECTORS,
}],
content_format: crate::disc::ContentFormat::BdTs,
codec_privates: Vec::new(),
};
let samples = read_encrypted_units(&mut src, &title, 4);
assert!(
!samples.is_empty(),
"the probe-spread must sample the early scrambled band the midpoint misses"
);
for s in &samples {
assert!(
aacs_unit_encrypted(s),
"every sample is a CPI-flagged encrypted unit (byte0 & 0xC0 != 0)"
);
}
}
/// DISCRIMINATING: selection is by the AACS CPI (byte 0), NOT the
/// `ts_sync_destroyed` heuristic. Half the units are sync-destroyed but
/// CPI-CLEAR (`byte0 & 0xC0 == 0`) — genuinely UNencrypted units that merely
/// lack TS syncs; the old sampler collected these and the key server rejected
/// the POST as "0 encrypted units". `read_encrypted_units` must skip them and
/// return ONLY CPI-flagged units. A regression to `ts_sync_destroyed` would
/// collect the CPI-clear units too and fail the `& 0xC0` assertion.
#[test]
fn read_encrypted_units_selects_by_cpi_not_ts_sync() {
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
use crate::error::Result;
use crate::sector::SectorSource;
// Even units: CPI-clear (byte0 & 0xC0 == 0) AND sync-destroyed (no 0x47).
// Odd units: CPI-set (byte0 = 0xC0) with a scrambled body.
// `ts_sync_destroyed` is TRUE for BOTH; `aacs_unit_encrypted` only odd.
struct MixSource {
ext_start: u32,
total_units: u32,
}
impl SectorSource for MixSource {
fn capacity_sectors(&self) -> u32 {
self.ext_start + self.total_units * ALIGNED_UNIT_SECTORS + 64
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_r: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
for (i, chunk) in buf[..bytes].chunks_mut(ALIGNED_UNIT_LEN).enumerate() {
if chunk.len() < ALIGNED_UNIT_LEN {
break;
}
let abs = (lba - self.ext_start) / ALIGNED_UNIT_SECTORS + i as u32;
if abs % 2 == 0 {
chunk.fill(0x11); // CPI-clear (0x11 & 0xC0 == 0), no TS sync
} else {
chunk.fill(0xAB); // scrambled body (no TS sync)
chunk[0] = 0xC0; // CPI set -> encrypted
}
}
Ok(bytes)
}
}
let total_units = 400u32;
let ext_start = 500u32;
let mut src = MixSource {
ext_start,
total_units,
};
let title = crate::disc::DiscTitle {
playlist: String::new(),
playlist_id: 0,
duration_secs: 0.0,
size_bytes: 0,
clips: Vec::new(),
streams: Vec::new(),
chapters: Vec::new(),
extents: vec![crate::disc::Extent {
start_lba: ext_start,
sector_count: total_units * ALIGNED_UNIT_SECTORS,
}],
content_format: crate::disc::ContentFormat::BdTs,
codec_privates: Vec::new(),
};
let samples = read_encrypted_units(&mut src, &title, 8);
assert!(
!samples.is_empty(),
"the CPI-flagged (odd) units must still be collected"
);
for s in &samples {
assert!(
aacs_unit_encrypted(s),
"only CPI-flagged units are selected"
);
assert_eq!(
s[0] & 0xC0,
0xC0,
"a CPI-clear sync-destroyed unit must never be sampled"
);
}
}
/// Audit #5 — a DISCRIMINATING test for the version→stride fix. A 2-key
/// `Unit_Key_RO.inf` whose SECOND key sits at the V20 (64-byte) offset; a V10
/// (48-byte) parse reads a DIFFERENT region. Confirms `DiscInputsCtx` parses
/// at the stride for `inputs.version` — a swapped `from_major` branch or a
/// hardcoded stride (the exact bug 1.2.0 fixes) would fail this, where the
/// prior single-key fixtures passed regardless of stride.
#[test]
fn disc_inputs_ctx_parses_unit_keys_at_the_version_stride() {
use crate::aacs::mkb::{AACS_MAJOR_BD, AACS_MAJOR_UHD};
const UK_POS: usize = 64;
let mut inf = vec![0u8; 200];
inf[0..4].copy_from_slice(&(UK_POS as u32).to_be_bytes()); // uk_pos
inf[UK_POS..UK_POS + 2].copy_from_slice(&2u16.to_be_bytes()); // num_uk = 2
let key0_at = UK_POS + 48; // first key — same for both strides
let key1_v10_at = key0_at + 48; // second key if parsed at V10 stride
let key1_v20_at = key0_at + 64; // second key if parsed at V20 stride
inf[key0_at..key0_at + 16].fill(0xA0);
inf[key1_v10_at..key1_v10_at + 16].fill(0x10);
inf[key1_v20_at..key1_v20_at + 16].fill(0x20);
let base = DiscInputs {
disc_hash: String::new(),
volume_id: [0u8; 16],
version: AACS_MAJOR_UHD,
mkb: Vec::new(),
unit_key_ro: inf,
samples: Vec::new(),
volume_label: None,
};
let k20 = DiscInputsCtx::new(&base).enc_title_keys().unwrap().to_vec();
let v10_inputs = DiscInputs {
version: AACS_MAJOR_BD,
..base.clone()
};
let k10 = DiscInputsCtx::new(&v10_inputs)
.enc_title_keys()
.unwrap()
.to_vec();
assert_eq!(k20.len(), 2);
assert_eq!(k10.len(), 2);
assert_eq!(k20[0], [0xA0; 16], "first key is at +48 for both strides");
assert_eq!(k10[0], [0xA0; 16]);
assert_eq!(k20[1], [0x20; 16], "V20 reads the 2nd key at +64");
assert_eq!(k10[1], [0x10; 16], "V10 reads the 2nd key at +48");
assert_ne!(k20[1], k10[1], "the parse stride follows inputs.version");
}
}
+35 -21
View File
@@ -16,7 +16,7 @@ use std::collections::HashMap;
/// Cheap signature check: a Criterion disc ships `streamproperties.xml`
/// inside a `/BDMV/JAR/*` archive.
pub fn detect(udf: &UdfFs) -> bool {
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "streamproperties.xml")
}
@@ -77,11 +77,18 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
/// `apply_labels` matches on `(type, stream_number)`, so a collision
/// would mislabel tracks.)
fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap<String, u16>) -> Vec<u16> {
// Numbers already claimed by the map, per type.
// Numbers already claimed by the map, per type. A map value of 0 is NOT a
// claim: apply_labels binds on 1-based stream numbers, so 0 is unmatchable.
// Treat 0 as "unmapped" here (defense in depth — parse_playback_config also
// filters it) so such a stream gets a real synthesized number instead of an
// orphan 0 that collides with / shadows a genuine stream 1.
let mut taken_audio: Vec<u16> = Vec::new();
let mut taken_sub: Vec<u16> = Vec::new();
for info in infos {
if let Some(&n) = stream_map.get(&info.id) {
if n == 0 {
continue;
}
match info.stream_type {
StreamLabelType::Audio => taken_audio.push(n),
StreamLabelType::Subtitle => taken_sub.push(n),
@@ -94,8 +101,8 @@ fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap<String, u16>
let mut out = Vec::with_capacity(infos.len());
for info in infos {
let n = match stream_map.get(&info.id).copied() {
Some(n) => n,
None => {
Some(n) if n != 0 => n,
_ => {
let (idx, taken) = match info.stream_type {
StreamLabelType::Audio => (&mut audio_idx, &taken_audio),
StreamLabelType::Subtitle => (&mut sub_idx, &taken_sub),
@@ -277,27 +284,34 @@ mod tests {
assert_eq!(nums[3], 2); // subtitle 2
}
/// Spec: map stream_num=0 is explicitly rejected (apply_labels uses 1-based).
/// This is documented in parse_playback_config: `if stream_num != 0`.
/// Mutation: remove the `!= 0` guard → zero is stored in map.
/// Spec: a map value of 0 is unmatchable (apply_labels is 1-based), so
/// assign_stream_numbers must treat it as unmapped and synthesize a real
/// 1-based number rather than emit an orphan 0.
/// Mutation: read the map value verbatim → stream_number 0 leaks out.
#[test]
fn map_zero_stream_num_is_skipped() {
// parse_playback_config skips zero; simulate that: the zero shouldn't
// end up in the map. We test assign_stream_numbers with a zero-containing
// map to verify it won't freeze the fallback counter at 1 forever.
fn map_zero_stream_num_is_synthesized_not_emitted() {
let mut map = HashMap::new();
map.insert("a0".to_string(), 0u16); // zero — per spec, was filtered by parse_playback_config
map.insert("a0".to_string(), 0u16); // 0 must not be treated as a claim
let infos = vec![info("a0", StreamLabelType::Audio)];
// If 0 IS in the map and assign_stream_numbers uses it, stream_number=0
// is not matchable (apply_labels is 1-based). The fallback counter
// would assign 1 instead. Test both paths:
let nums = assign_stream_numbers(&infos, &map);
// If the map has 0 for a0, assign_stream_numbers returns 0 (map wins).
// This is a known limitation — the guard lives in parse_playback_config.
// The test documents the ACTUAL behavior so a code change that introduces
// the guard in assign_stream_numbers would be caught.
// Current behavior: map wins → 0.
assert_eq!(nums[0], 0);
// 0 is treated as unmapped → the fallback counter assigns 1.
assert_eq!(nums[0], 1);
}
/// A stream genuinely mapped to 1 plus another stream whose map value is 0
/// must NOT both land on 1: the 0-stream is synthesized past the claimed 1.
#[test]
fn map_zero_does_not_collide_with_a_real_stream_one() {
let mut map = HashMap::new();
map.insert("real".to_string(), 1u16);
map.insert("bad".to_string(), 0u16);
let infos = vec![
info("real", StreamLabelType::Audio),
info("bad", StreamLabelType::Audio),
];
let nums = assign_stream_numbers(&infos, &map);
assert_eq!(nums[0], 1); // the genuinely-mapped stream keeps 1
assert_eq!(nums[1], 2); // the 0-stream is synthesized to the next free slot
}
/// Spec: collision-avoidance works across audio AND subtitle independently.
+1 -1
View File
@@ -11,7 +11,7 @@ use std::collections::HashMap;
/// Cheap signature check: a CTRM disc ships `menu_base.prop` and/or
/// `language_streams.txt` inside a `/BDMV/JAR/*` archive.
pub fn detect(udf: &UdfFs) -> bool {
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "menu_base.prop")
|| super::jar_file_exists(udf, "language_streams.txt")
}
+10 -8
View File
@@ -35,14 +35,16 @@ use crate::sector::SectorSource;
use crate::udf::UdfFs;
use std::collections::BTreeMap;
/// dbp detect can't peek inside a jar without a SectorSource (the
/// trait function only takes `&UdfFs`), so we trigger on the cheap
/// signal "any top-level .jar in /BDMV/JAR/." That fires on every
/// BD-J disc, but parse() does the real `com/dbp/` check and
/// returns None on a mismatch — so this parser only ever consumes
/// time on discs that fell through every earlier parser.
pub fn detect(udf: &UdfFs) -> bool {
jar::has_any_top_level_jar(udf)
/// The real dbp signal is the `com/dbp/` package prefix inside a top-level
/// jar's central directory. With a reader in `detect`, we check that directly
/// (a cheap central-directory scan, no class decode) so this parser claims
/// only dbp discs instead of firing on every BD-J disc. `parse()` repeats the
/// check as belt-and-suspenders.
pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
jar::for_each_jar(reader, udf, |_entry, archive| {
jar::has_path_prefix(archive, "com/dbp/").then_some(())
})
.is_some()
}
/// Scan every top-level `/BDMV/JAR/*.jar` for the dbp framework and
+9 -5
View File
@@ -64,11 +64,15 @@ use crate::sector::SectorSource;
use crate::udf::UdfFs;
use std::collections::{HashMap, HashSet};
pub fn detect(udf: &UdfFs) -> bool {
// Cheap pre-check at the dir level; the real signal is
// `com/bydeluxe/` inside any top-level jar's central directory,
// which `parse()` confirms when given a `SectorSource`.
jar::has_any_top_level_jar(udf)
pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
// The real signal is `com/bydeluxe/` inside a top-level jar's central
// directory. With a reader in detect we check it directly (cheap
// central-directory scan, no bytecode walk) so this parser claims only
// Deluxe discs; `parse()` repeats the check.
jar::for_each_jar(reader, udf, |_entry, archive| {
jar::has_path_prefix(archive, "com/bydeluxe/").then_some(())
})
.is_some()
}
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
-15
View File
@@ -26,21 +26,6 @@ const MAX_CLASS_BYTES: u64 = 64 * 1024 * 1024;
/// etc.
pub type Jar = ZipArchive<Cursor<Vec<u8>>>;
/// True if `/BDMV/JAR/` contains at least one top-level `.jar` file
/// (not under a subdir). Used by `detect()` in parsers whose real
/// signal lives inside a jar — they can't open the jar without a
/// `SectorSource`, so they use this cheap pre-check and do the real
/// `com/<vendor>/` discriminator in `parse()`.
pub fn has_any_top_level_jar(udf: &UdfFs) -> bool {
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
return false;
};
jar_dir
.entries
.iter()
.any(|e| !e.is_dir && e.name.to_lowercase().ends_with(".jar"))
}
/// Open every top-level `*.jar` entry in `/BDMV/JAR/` and yield each
/// `(entry_name, Jar)` to `f`. Returns the first `Some(R)` the callback
/// produces, or `None` if every jar was visited without a hit.
+28 -15
View File
@@ -20,6 +20,7 @@ pub(crate) mod jar;
mod mpls_universal;
mod paramount;
mod pixelogic;
mod png_filenames;
pub(crate) mod text;
pub mod vocab;
pub(crate) mod xml;
@@ -91,7 +92,11 @@ pub enum LabelQualifier {
// the registry picks the highest-confidence parse result, falling back
// to array order on confidence ties.
type DetectFn = fn(&UdfFs) -> bool;
// `detect` takes the reader too, so a parser can look INSIDE a jar's central
// directory (real vendor-prefix / project-file check) rather than firing on
// "any jar present". Precise detection is what lets the registry scale to many
// parsers without cross-parser collisions.
type DetectFn = fn(&mut dyn SectorSource, &UdfFs) -> bool;
type ParseFn = fn(&mut dyn SectorSource, &UdfFs) -> Option<ParseResult>;
/// Per-parser claim of how reliable its output is. Used by the
@@ -158,11 +163,11 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
("criterion", criterion::detect, criterion::parse),
("pixelogic", pixelogic::detect, pixelogic::parse),
("ctrm", ctrm::detect, ctrm::parse),
// dbp and deluxe both detect on "any top-level .jar in /BDMV/JAR/"
// (every BD-J disc trips that) and do the real vendor-prefix check
// in parse(). Order between them is the tiebreaker on equal
// confidence; dbp goes first because its parse path is cheaper
// (constant-pool iteration vs. deluxe's bytecode walking).
// dbp and deluxe now detect via the real `com/<vendor>/` central-directory
// prefix (reader-backed), so they claim only their own discs. Order between
// them is the tiebreaker on equal confidence; dbp goes first because its
// parse path is cheaper (constant-pool iteration vs. deluxe's bytecode
// walking).
("dbp", dbp::detect, dbp::parse),
("deluxe", deluxe::detect, deluxe::parse),
// Universal MPLS fallback. Returns Confidence::Low so framework
@@ -176,6 +181,11 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
mpls_universal::detect,
mpls_universal::parse,
),
// Menu-graphic filename language hints (Low). AFTER mpls_universal so the
// richer spec-derived floor wins the Low tie whenever it produces anything;
// this only becomes the chosen parser when even MPLS yields nothing but the
// menu artwork still names its languages. A last-resort language source.
("png_filenames", png_filenames::detect, png_filenames::parse),
];
/// Search disc for config files, extract labels, apply to streams.
@@ -521,7 +531,7 @@ fn generate_audio_label_inner(
fn extract(reader: &mut dyn SectorSource, udf: &UdfFs) -> Vec<StreamLabel> {
let mut best: Option<(&'static str, ParseResult)> = None;
for (name, detect, parse) in PARSERS {
if !detect(udf) {
if !detect(reader, udf) {
continue;
}
tracing::info!(parser = name, "label parser detected");
@@ -788,7 +798,7 @@ pub fn analyze(reader: &mut dyn SectorSource, udf: &UdfFs) -> LabelAnalysis {
let mut all_results: Vec<(&'static str, ParseResult)> = Vec::new();
for (name, detect, parse) in PARSERS {
if !detect(udf) {
if !detect(reader, udf) {
continue;
}
tracing::info!(parser = name, "label parser detected");
@@ -961,8 +971,9 @@ pub struct ChapterSummary {
/// List filenames found under any `/BDMV/JAR/<x>/` subdirectory of
/// the disc. Deduped, sorted. Returns an empty vec if no JAR dir is
/// present.
fn jar_inventory(udf: &UdfFs) -> Vec<String> {
/// present. `pub(crate)` so filename-based parsers (e.g. `png_filenames`)
/// can scan menu-asset names without a reader.
pub(crate) fn jar_inventory(udf: &UdfFs) -> Vec<String> {
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
return Vec::new();
};
@@ -1042,12 +1053,14 @@ mod registry_tests {
"dbp",
"deluxe",
"mpls_universal",
"png_filenames",
],
"PARSERS array order changed — confirm dbp + deluxe stay just \
before mpls_universal (loose detect, real check in parse), \
stricter parsers (paramount/criterion/pixelogic/ctrm — all \
file-presence gated detect) stay first, and mpls_universal \
stays LAST as the universal Low-confidence fallback."
"PARSERS array order changed — file-presence/reader-gated High \
parsers (paramount/criterion/pixelogic/ctrm) stay first; dbp + \
deluxe (now real com/<vendor>/ prefix detect) stay before \
mpls_universal; mpls_universal stays the universal Low fallback; \
png_filenames (Low, language-only hint) stays LAST so MPLS wins \
the Low tie whenever it produces anything."
);
}
+1 -1
View File
@@ -29,7 +29,7 @@ use crate::udf::UdfFs;
/// True iff `/BDMV/PLAYLIST/` exists and contains at least one
/// `.mpls` file. Cheap directory walk only — no sector reads.
pub fn detect(udf: &UdfFs) -> bool {
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") else {
return false;
};
+1 -1
View File
@@ -16,7 +16,7 @@ use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelT
use crate::sector::SectorSource;
use crate::udf::UdfFs;
pub fn detect(udf: &UdfFs) -> bool {
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "playlists.xml")
}
+1 -1
View File
@@ -25,7 +25,7 @@ const REGIONS: &[&str] = &[
"US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE",
];
pub fn detect(udf: &UdfFs) -> bool {
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "bluray_project.bin")
}
+149
View File
@@ -0,0 +1,149 @@
//! Menu-graphic filename language hints.
//!
//! Some BD-J discs encode per-language menu artwork with the language in the
//! filename, e.g. `Dune_UHD01_Eng_Composite1.png`,
//! `VForVendetta_UHD01_FRE_Composite2.png`. The `_UHD01_{LANG}_Composite`
//! marker is authored deliberately, so the set of `{LANG}` tokens is the set
//! of menu languages the disc ships.
//!
//! This is a language-only hint (no per-stream purpose/codec), so it runs at
//! [`Confidence::Low`] — it never displaces a real framework parser, and it
//! sits at the same tier as the MPLS floor. It is here so the pattern is a
//! first-class, testable parser that keeps picking up discs as the corpus
//! grows, rather than lost logic. Detection is precise: it fires only on the
//! `_UHD01_{LANG}_Composite` grammar with a `{LANG}` the vocab recognizes.
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, vocab};
use crate::sector::SectorSource;
use crate::udf::UdfFs;
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
super::jar_inventory(udf)
.iter()
.any(|f| filename_lang(f).is_some())
}
pub fn parse(_reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
let names = super::jar_inventory(udf);
let labels = labels_from_filenames(&names);
if labels.is_empty() {
return None;
}
// Low: language-only, derived from menu-asset filenames. A real framework
// parser (and even the MPLS floor's per-stream data) is preferred; this is
// a hint of which languages the disc menus offer.
Some(ParseResult::low(labels))
}
/// One audio [`StreamLabel`] per distinct menu language found, in first-seen
/// order, numbered 1-based. Split out from `parse` so it is unit-testable
/// without a `UdfFs`.
fn labels_from_filenames(names: &[String]) -> Vec<StreamLabel> {
let mut seen: Vec<&'static str> = Vec::new();
for name in names {
if let Some(code) = filename_lang(name) {
if !seen.contains(&code) {
seen.push(code);
}
}
}
seen.into_iter()
.enumerate()
.map(|(i, code)| StreamLabel {
stream_number: (i as u16).saturating_add(1),
stream_type: StreamLabelType::Audio,
language: code.to_string(),
name: String::new(),
purpose: LabelPurpose::Normal,
qualifier: LabelQualifier::None,
codec_hint: String::new(),
variant: String::new(),
})
.collect()
}
/// Extract the ISO-639-2 language code from a `{title}_UHD01_{LANG}_Composite`
/// menu-graphic filename, or `None` if the name does not match the grammar or
/// carries a `{LANG}` the vocab does not recognize.
///
/// The `_UHD01_` marker plus the `_Composite` suffix keep this from firing on
/// unrelated PNGs (`KeyComposite4.png`, `LoadingComposite1.png` have no
/// `_UHD01_{LANG}_` segment).
fn filename_lang(name: &str) -> Option<&'static str> {
// Case-fold once; the marker/suffix are matched case-insensitively.
let lower = name.to_ascii_lowercase();
let marker = "_uhd01_";
let m = lower.find(marker)?;
let after = m + marker.len();
// The language token runs from `after` up to the next `_`.
let rest = &lower[after..];
let end = rest.find('_')?;
if !rest[end..].starts_with("_composite") {
return None;
}
let token = &name[after..after + end];
vocab::menu_lang(token)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_confirmed_samples() {
assert_eq!(filename_lang("Dune_UHD01_Eng_Composite1.png"), Some("eng"));
assert_eq!(filename_lang("Dune_UHD01_Ger_Composite2.png"), Some("deu"));
assert_eq!(
filename_lang("VForVendetta_UHD01_FRE_Composite2.png"),
Some("fra")
);
}
#[test]
fn ignores_non_language_composites() {
assert_eq!(filename_lang("KeyComposite4.png"), None);
assert_eq!(filename_lang("LoadingComposite1.png"), None);
assert_eq!(
filename_lang("FourKWarningsComposite1_bt2020_HDR.png"),
None
);
assert_eq!(filename_lang("Fast9_UPK75_Composite1.png"), None);
}
#[test]
fn unknown_language_token_is_none() {
// A UHD01 marker but a token the vocab does not recognize must not
// produce a bogus language.
assert_eq!(filename_lang("Movie_UHD01_Zzz_Composite1.png"), None);
}
#[test]
fn dedups_and_numbers_distinct_languages() {
let names = vec![
"Dune_UHD01_Eng_Composite1.png".to_string(),
"Dune_UHD01_Eng_Composite2.png".to_string(),
"Dune_UHD01_Ger_Composite1.png".to_string(),
"LoadingComposite1.png".to_string(),
];
let labels = labels_from_filenames(&names);
assert_eq!(labels.len(), 2);
assert_eq!(labels[0].language, "eng");
assert_eq!(labels[0].stream_number, 1);
assert_eq!(labels[1].language, "deu");
assert_eq!(labels[1].stream_number, 2);
assert!(
labels
.iter()
.all(|l| l.stream_type == StreamLabelType::Audio)
);
}
#[test]
fn no_matching_names_yields_empty() {
let names = vec![
"KeyComposite4.png".to_string(),
"disc.properties".to_string(),
];
assert!(labels_from_filenames(&names).is_empty());
}
}
+43
View File
@@ -176,6 +176,49 @@ const BARE_LANGS: &[(&str, &str)] = &[
("galician", "glg"),
];
/// Map a short menu-graphic language token (as embedded in authoring
/// filenames like `Dune_UHD01_Eng_Composite1.png`) to an ISO-639-2/T code.
///
/// These filename tokens are compact 2/3-letter abbreviations, NOT the full
/// language names [`lang`] handles, so they get their own certain table.
/// Accepts the ISO-639-2/B spellings some tools emit (`ger`, `fre`, `chi`)
/// and normalizes them to the /T code the rest of the pipeline uses (`deu`,
/// `fra`, `zho`). Case-insensitive. Returns `None` for anything not in the
/// table — never guesses, so an unrecognized token drops rather than
/// mislabels.
pub fn menu_lang(token: &str) -> Option<&'static str> {
let t = token.trim().to_ascii_lowercase();
let code = match t.as_str() {
"eng" | "en" => "eng",
"ger" | "deu" | "de" => "deu",
"fre" | "fra" | "fr" => "fra",
"spa" | "es" => "spa",
"ita" | "it" => "ita",
"por" | "pt" => "por",
"jpn" | "jap" | "ja" => "jpn",
"kor" | "ko" => "kor",
"chi" | "zho" | "zh" => "zho",
"rus" | "ru" => "rus",
"dut" | "nld" | "nl" => "nld",
"pol" | "pl" => "pol",
"cze" | "ces" | "cs" => "ces",
"dan" | "da" => "dan",
"fin" | "fi" => "fin",
"nor" | "no" => "nor",
"swe" | "sv" => "swe",
"hun" | "hu" => "hun",
"gre" | "ell" | "el" => "ell",
"tur" | "tr" => "tur",
"ara" | "ar" => "ara",
"hin" | "hi" => "hin",
"tha" | "th" => "tha",
"ukr" | "uk" => "ukr",
"cat" | "ca" => "cat",
_ => return None,
};
Some(code)
}
// ── Purpose ──────────────────────────────────────────────────────────────────
/// Classify a free-form English label string into a [`LabelPurpose`].
+15 -18
View File
@@ -1,9 +1,10 @@
//! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD.
//!
//! Handles drive access, disc structure parsing, AACS decryption, and raw
//! sector reading. Drive unlocking is pluggable: libfreemkv owns only the
//! [`Unlocker`] seam and registry — firmware blobs and unlock CDBs live in
//! an external crate (e.g. `freemkv-unlock-ld`).
//! sector reading. Unlocking — removing bus encryption (firmware unlock, AACS
//! cert handshake, CSS bus-auth) — lives entirely in the `freemkv-unlock`
//! crate; libfreemkv consumes it privately and exposes none of it, so clients
//! are oblivious to unlockers (just as they are to the SCSI layer).
//!
//! # Quick Start
//!
@@ -47,8 +48,8 @@
//! Drive -- open, identify, unlock, read sectors
//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS)
//! ├── DriveId -- INQUIRY + GET_CONFIG identification
//! └── Unlocker -- pluggable, external (e.g. freemkv-unlock-ld);
//! libfreemkv owns only the trait + registry
//! └── unlock_bridge -- private seam to the `freemkv-unlock` crate
//! (firmware / AACS cert / CSS bus-auth unlockers)
//!
//! Disc -- scan titles, streams, AACS state
//! ├── UDF reader -- Blu-ray UDF 2.50 with metadata partitions
@@ -57,7 +58,7 @@
//! ├── JAR parser -- BD-J audio track labels
//! └── AACS -- encryption: key resolution + content decrypt
//! ├── aacs -- KEYDB, VUK, MKB, unit decrypt
//! └── handshake -- SCSI auth, ECDH, bus key
//! └── host_certs -- collect host certs (cert handshake lives in freemkv-unlock)
//! ```
//!
//! # AACS Encryption
@@ -111,6 +112,7 @@ pub mod dvdnav;
pub mod error;
pub mod event;
pub mod halt;
pub mod hex;
pub(crate) mod identity;
pub(crate) mod ifo;
pub mod io;
@@ -125,11 +127,7 @@ pub mod scsi;
pub mod sector;
pub(crate) mod speed;
pub(crate) mod udf;
pub mod unlock;
pub mod verify;
// Re-export verify types at the crate root for ergonomic imports.
pub use verify::{SectorRange, SectorStatus, VerifyResult, verify_title};
pub(crate) mod unlock_bridge;
// ─── Drive lifecycle ────────────────────────────────────────────────────────
//
@@ -175,14 +173,13 @@ pub use io::pipeline::{
pub use event::{BatchSizeReason, Event, EventKind};
pub use identity::DriveId;
// ─── Pluggable unlock seam ──────────────────────────────────────────────────
// ─── Unlock seam ────────────────────────────────────────────────────────────
//
// libfreemkv carries no firmware blobs / unlock CDBs / drive profiles. An
// external unlocker crate (e.g. `freemkv-unlock-ld`) implements `Unlocker`
// and registers it once at process start via `register_unlocker`. At
// drive-prep the registry is walked in order; the first matching unlocker
// runs, else the drive falls through to the host-cert AACS handshake.
pub use unlock::{UnlockError, Unlocker, register_unlocker};
// Drive/disc unlocking (removing bus encryption — firmware, AACS cert, CSS
// bus-auth) lives entirely in the `freemkv-unlock` crate. libfreemkv consumes
// it through the private `unlock_bridge` and exposes nothing of it: clients are
// oblivious to unlockers, exactly as they are to the SCSI layer. There is no
// public unlock surface to import.
// ─── Decryption (AACS / CSS) ────────────────────────────────────────────────
//
+844
View File
@@ -0,0 +1,844 @@
//! Access-unit assembly — a codec-parser helper.
//!
//! The contract a codec parser converts is `PES → access units (Frames)`. A
//! *transport* stream hands the parser one AU per PES for free (BD aligns one
//! access unit per PES; the TS demuxer reassembles to the
//! `payload_unit_start_indicator`). A *program* stream does not — the PS muxer
//! chops the elementary stream into fixed-size PES fragments with no AU
//! alignment, and only the first fragment of an AU carries a PTS. So a parser
//! that assumes one-AU-per-PES (h264/hevc/vc1, written against TS) mis-frames a
//! program stream, while `mpeg2` — the DVD/PS codec — must reassemble across PES.
//!
//! [`AuAssembler`] is that reassembly, factored out so EVERY program-stream video
//! parser shares one implementation instead of hand-rolling the buffer. The
//! h264/hevc/vc1 parsers ([`Mode::StartCode`] / [`Mode::Vc1`]) and the MPEG-2
//! parser ([`Mode::Mpeg2`], via [`AuAssembler::mpeg2`]) all drive it. It buffers
//! PES-fragment bytes and emits one AU per codec AU boundary, carrying the
//! AU-start timing/source forward. Since the boundary is a codec start code, it
//! lives with the codec parser (which picks the marker); only the generic
//! buffering + timing-carry is shared here.
//!
//! This is *inside* the parser, not a pipeline stage: the pipeline stays
//! `Demuxer → PES → Parser → Frames`, and the demuxer stays codec-agnostic. Every
//! stream a parser sees runs through one of these — self-framing codecs (MPEG-2,
//! audio) use [`Mode::Passthrough`] so the parser code path is uniform.
use crate::disc::Codec;
use crate::pes::SourcePos;
use std::collections::VecDeque;
/// Safety cap on a single in-progress access unit. A real coded picture is far
/// below this; a stream that never yields a second AU boundary is force-flushed
/// at the cap rather than buffering without bound on hostile/corrupt input.
const MAX_AU_BUFFER: usize = 8 * 1024 * 1024;
/// Cap on buffered timing/discontinuity marks. A real access unit spans a few
/// hundred PES fragments at most; this bounds the mark deques so a run of
/// zero-length (or start-code-free) timed fragments — which grow no buffer bytes
/// and so never trip the `MAX_AU_BUFFER` mark-prune — cannot accumulate marks
/// without bound on hostile/corrupt disc input.
const MAX_MARKS: usize = 64 * 1024;
/// One AU-complete unit drained from the buffer: its elementary-stream bytes plus
/// the timing/source/discontinuity of the fragment that opened the AU.
pub(crate) struct AssembledAu {
pub data: Vec<u8>,
pub pts: Option<i64>,
pub dts: Option<i64>,
pub source: Option<SourcePos>,
pub discontinuity: bool,
}
/// VC-1 (SMPTE 421M Annex E) BDU start-code suffixes, `00 00 01 <type>`.
const VC1_FRAME: u8 = 0x0D; // coded picture
const VC1_ENTRY: u8 = 0x0E; // entry-point header
const VC1_SEQ: u8 = 0x0F; // sequence header
/// MPEG-2 (ISO/IEC 13818-2) start-code suffixes, `00 00 01 <type>`.
const MP2_PICTURE: u8 = 0x00; // picture_start_code
const MP2_SEQ: u8 = 0xB3; // sequence_header_code
const MP2_GOP: u8 = 0xB8; // group_start_code
/// How a stream's fragments become AU-complete units.
#[derive(Clone, Copy)]
enum Mode {
/// Split the elementary stream on the codec's single AU-delimiter start code
/// `00 00 01 <marker>` (H.264 AUD `0x09`, HEVC AUD `0x46`). Every AU opens with
/// exactly that code, so a plain split is correct.
StartCode(u8),
/// VC-1 has no single AU delimiter: an access unit is a `[sequence header?]
/// [entry point?][frame][slices…]` group. The sequence-header (`0x0F`) and
/// entry-point (`0x0E`) BDUs precede the frame (`0x0D`) they belong to, so a
/// plain `0x0D` split would glue them onto the *previous* AU and strip every
/// I-frame of its headers. The boundary is instead the next `0x0F`/`0x0E`/`0x0D`
/// start code that follows a frame already seen in the current AU.
Vc1,
/// MPEG-2 access unit: `[sequence header?][GOP header?][picture][slices…]`.
/// Structurally identical to [`Mode::Vc1`] — the sequence (`0xB3`) and GOP
/// (`0xB8`) headers precede the picture (`0x00`) they introduce, so the
/// boundary is the next picture / sequence / GOP start code that follows a
/// picture already seen. Slice (`0x01..=0xAF`), extension (`0xB5`),
/// user-data (`0xB2`) and sequence-end (`0xB7`) codes are NOT boundaries.
Mpeg2,
/// The codec self-frames (MPEG-2 reassembles in its own parser; audio resyncs
/// on syncwords), so each fragment passes straight through as one unit. Lets
/// the caller run EVERY stream through an assembler with no per-codec branch.
Passthrough,
}
/// A timing/source mark taken at the absolute stream offset of a fragment that
/// carried it, so it survives `buf.drain(..)` and can be attributed to the AU
/// whose byte range contains it.
struct Mark {
off: u64,
pts: Option<i64>,
dts: Option<i64>,
source: Option<SourcePos>,
}
/// Reassembles PES fragments into AU-complete units. One per stream; stateful
/// across `push` calls.
pub(crate) struct AuAssembler {
mode: Mode,
/// Buffered elementary-stream bytes not yet emitted as a complete AU.
buf: Vec<u8>,
/// Absolute stream offset of `buf[0]`, so marks (taken at absolute offsets)
/// survive `buf.drain(..)`.
base: u64,
/// Timing/source marks, in fragment order.
marks: VecDeque<Mark>,
/// Absolute offsets of fragments flagged with an upstream discontinuity.
disc_marks: VecDeque<u64>,
/// Incremental boundary-scan cursor: the offset into `buf` up to which the
/// current AU has already been searched for its end without finding one. Each
/// `push` resumes the boundary search from here instead of rescanning the
/// whole buffer, so reassembling one AU split across N PES fragments costs
/// O(AU bytes) total, not O(AU bytes²/fragment). Reset to 0 whenever `buf[0]`
/// moves (an AU drained, or leading bytes dropped).
scan_pos: usize,
/// Whether the current AU has already contained a coded frame/picture — the
/// state the VC-1/MPEG-2 boundary rule carries across a resumed scan (their
/// boundary is "the next opener after a frame is already seen"). Meaningless
/// for `Mode::StartCode`. Reset with `scan_pos`.
seen_unit: bool,
/// Pre-sync opener-search cursor: the offset up to which the buffer has been
/// searched for the FIRST AU opener with none found. Resumes the opener scan
/// so a long run of junk with no start code (hostile/corrupt input) costs
/// O(bytes) total, not O(buffer) per push. Reset when `buf[0]` moves.
opener_pos: usize,
}
impl AuAssembler {
/// An assembler for `codec`. Video codecs whose parsers assume AU-complete PES
/// (H.264 / HEVC / VC-1) get a [`Mode::StartCode`] assembler; MPEG-2 (self-
/// reassembles) and audio/subtitle codecs (self-framing) get [`Mode::Passthrough`]
/// so callers can run every stream through this uniformly.
pub(crate) fn for_codec(codec: Codec) -> Self {
let mode = match codec {
Codec::H264 => Mode::StartCode(0x09), // access_unit_delimiter NAL (type 9)
Codec::Hevc => Mode::StartCode(0x46), // AUD NAL (type 35 → (35 << 1) = 0x46)
Codec::Vc1 => Mode::Vc1, // frame + preceding seq/entry headers
_ => Mode::Passthrough,
};
Self {
mode,
// Passthrough never writes `buf` (one fragment → one unit); only the
// reassembling modes need reserve. Avoids ~256 KiB per audio/subtitle
// stream (and every TS/BD stream, which never feeds the assembler).
buf: match mode {
Mode::Passthrough => Vec::new(),
_ => Vec::with_capacity(256 * 1024),
},
base: 0,
marks: VecDeque::new(),
disc_marks: VecDeque::new(),
scan_pos: 0,
seen_unit: false,
opener_pos: 0,
}
}
/// An assembler that reassembles MPEG-2 access units. The MPEG-2 parser owns
/// one of these directly (rather than hand-rolling the buffer): the demux
/// layer runs MPEG-2 through [`Mode::Passthrough`] and hands each fragment to
/// the parser, which feeds them here to be reframed on picture boundaries.
pub(crate) fn mpeg2() -> Self {
Self {
mode: Mode::Mpeg2,
buf: Vec::with_capacity(128 * 1024),
base: 0,
marks: VecDeque::new(),
disc_marks: VecDeque::new(),
scan_pos: 0,
seen_unit: false,
opener_pos: 0,
}
}
/// Feed one PES fragment the caller OWNS; return every AU now complete. For
/// a self-framing (`Passthrough`) stream the payload is MOVED straight into
/// the emitted unit with no copy — the common DVD/HD-DVD case (MPEG-2 video,
/// all audio). A buffering mode copies into `buf` exactly as [`Self::push`].
pub(crate) fn push_owned(
&mut self,
data: Vec<u8>,
pts: Option<i64>,
dts: Option<i64>,
source: Option<SourcePos>,
discontinuity: bool,
) -> Vec<AssembledAu> {
if matches!(self.mode, Mode::Passthrough) {
return vec![AssembledAu {
data,
pts,
dts,
source,
discontinuity,
}];
}
self.push(&data, pts, dts, source, discontinuity)
}
/// Feed one PES fragment (borrowed); return every AU that is now complete.
pub(crate) fn push(
&mut self,
data: &[u8],
pts: Option<i64>,
dts: Option<i64>,
source: Option<SourcePos>,
discontinuity: bool,
) -> Vec<AssembledAu> {
// Self-framing codecs pass through unchanged — one fragment, one unit,
// its own timing. (This is exactly today's behaviour for mpeg2/audio.)
if matches!(self.mode, Mode::Passthrough) {
return vec![AssembledAu {
data: data.to_vec(),
pts,
dts,
source,
discontinuity,
}];
}
let off = self.base + self.buf.len() as u64;
if pts.is_some() || dts.is_some() || source.is_some() {
self.marks.push_back(Mark {
off,
pts,
dts,
source,
});
// Backstop: the `buf`-size cap prunes marks only when bytes accumulate.
// A run of zero-length (or start-code-free) timed fragments grows no
// bytes, so bound the deque directly — drop the oldest (stalest) mark,
// which belongs to an already-emitted or lost AU. A real AU spans far
// fewer fragments than this cap.
if self.marks.len() > MAX_MARKS {
self.marks.pop_front();
}
}
if discontinuity {
self.disc_marks.push_back(off);
if self.disc_marks.len() > MAX_MARKS {
self.disc_marks.pop_front();
}
}
self.buf.extend_from_slice(data);
self.drain(false)
}
/// Emit the trailing in-progress AU at end of stream (no following boundary).
pub(crate) fn flush(&mut self) -> Vec<AssembledAu> {
if matches!(self.mode, Mode::Passthrough) {
return Vec::new();
}
self.drain(true)
}
fn drain(&mut self, force: bool) -> Vec<AssembledAu> {
if matches!(self.mode, Mode::Passthrough) {
return Vec::new();
}
let mut out = Vec::new();
loop {
// Locate the AU start code that opens the buffered run (resumes from
// opener_pos so an unsynced junk run is scanned once, not per push).
let Some(a0) = self.au_opener_resumable() else {
// No AU boundary buffered. Bound memory: drop all but a 3-byte
// tail (enough to catch a start-code prefix straddling the cut)
// once over the cap; otherwise wait for more data.
if self.buf.len() > MAX_AU_BUFFER {
let drop = self.buf.len() - 3;
self.buf.drain(..drop);
self.base += drop as u64;
self.reset_scan();
self.drop_marks_before(self.base);
}
break;
};
if a0 > 0 {
// Leading bytes before the first AU boundary are a partial AU from
// before we synced (or junk) — discard them and any stale marks.
self.buf.drain(..a0);
self.base += a0 as u64;
self.reset_scan();
self.drop_marks_before(self.base);
continue;
}
// The AU runs from here (buf[0]) to the NEXT AU boundary. The search
// resumes from `scan_pos` (bytes already searched with no boundary),
// so one AU spread across many fragments is scanned once, not per push.
let end = match self.au_boundary_resumable() {
Some(next) => next,
// No next boundary yet: on EOF (or over-cap backstop) the rest of
// the buffer is this AU; otherwise wait for more data.
None if force => self.buf.len(),
None if self.buf.len() > MAX_AU_BUFFER => self.buf.len(),
None => break,
};
if end == 0 {
break;
}
let end_abs = self.base + end as u64;
// The AU's own timing/source: take the FIRST Some of each field
// across every mark in this AU's range [base, end_abs), independently
// — one PES fragment may carry the source while a later fragment of
// the same AU carries the PTS (and vice versa), so reading only the
// front mark would drop the other field. This restores the semantics
// of the pre-consolidation separate pts/source mark deques.
let (mut pts, mut dts, mut source) = (None, None, None);
while self.marks.front().is_some_and(|m| m.off < end_abs) {
let m = self.marks.pop_front().unwrap();
pts = pts.or(m.pts);
dts = dts.or(m.dts);
source = source.or(m.source);
}
let mut discontinuity = false;
if self.disc_marks.front().is_some_and(|&o| o < end_abs) {
discontinuity = true;
}
while self.disc_marks.front().is_some_and(|&o| o < end_abs) {
self.disc_marks.pop_front();
}
let data = self.buf[..end].to_vec();
self.buf.drain(..end);
self.base += end as u64;
self.reset_scan();
out.push(AssembledAu {
data,
pts,
dts,
source,
discontinuity,
});
}
out
}
/// Reset the incremental boundary-scan cursor. Called whenever `buf[0]` moves
/// (an AU drained, or leading bytes discarded) so the next scan starts fresh
/// from the new AU opener.
fn reset_scan(&mut self) {
self.scan_pos = 0;
self.seen_unit = false;
self.opener_pos = 0;
}
/// Locate the first AU opener in `buf`, resuming the search from `opener_pos`
/// (bytes already searched with no opener) so a long unsynced run costs
/// O(bytes) total, not O(buffer) per push. Advances `opener_pos` on a miss.
fn au_opener_resumable(&mut self) -> Option<usize> {
match au_opener_from(self.mode, &self.buf, self.opener_pos) {
Some(o) => Some(o),
None => {
// Nothing yet; next call resumes here (back up 3 for a straddling
// start-code prefix). Never advance past what is searchable.
self.opener_pos = self.buf.len().saturating_sub(3).max(self.opener_pos);
None
}
}
}
/// Find the end of the AU that opens at `buf[0]`, resuming from `scan_pos`
/// (and, for VC-1/MPEG-2, the carried `seen_unit`) instead of rescanning the
/// whole buffer. On no boundary yet, advances `scan_pos`/`seen_unit` so the
/// next call continues where this one stopped. Equivalent result to a
/// from-scratch whole-buffer scan, but O(total AU bytes) across all pushes.
fn au_boundary_resumable(&mut self) -> Option<usize> {
match self.mode {
Mode::StartCode(marker) => {
// Stateless: the AU ends at the next delimiter after the opener at
// buf[0]. Resume from the furthest searched offset (never before 4,
// to skip the opening delimiter). find_start_code needs 4 bytes, so
// back up 3 to catch a code straddling the previous buffer end.
let from = self.scan_pos.max(4);
match find_start_code(&self.buf, from, marker) {
Some(e) => Some(e),
None => {
self.scan_pos = self.buf.len().saturating_sub(3).max(from);
None
}
}
}
Mode::Vc1 => self.scan_unit_boundary(VC1_FRAME, &[VC1_ENTRY, VC1_SEQ]),
Mode::Mpeg2 => self.scan_unit_boundary(MP2_PICTURE, &[MP2_SEQ, MP2_GOP]),
Mode::Passthrough => None,
}
}
/// Resumable form of the VC-1/MPEG-2 boundary rule: scan from `scan_pos`,
/// carrying `seen_unit`; the AU ends at the next `frame` / `header` start code
/// once a frame is already seen. Advances `scan_pos`/`seen_unit` when no
/// boundary is found so the next push continues, not restarts.
fn scan_unit_boundary(&mut self, frame: u8, headers: &[u8]) -> Option<usize> {
let buf = &self.buf;
let mut i = self.scan_pos;
let mut seen = self.seen_unit;
while i + 4 <= buf.len() {
if buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1 {
let c = buf[i + 3];
let is_frame = c == frame;
if (is_frame || headers.contains(&c)) && i > 0 && seen {
// The AU ends at the next frame/header once a frame is seen.
return Some(i);
}
if is_frame {
seen = true;
}
i += 4;
} else {
i += 1;
}
}
// No boundary yet. Persist the scan state so the next append resumes here
// rather than rescanning from 0 (the i+=4 stride is preserved exactly).
self.scan_pos = i;
self.seen_unit = seen;
None
}
fn drop_marks_before(&mut self, off: u64) {
while self.marks.front().is_some_and(|m| m.off < off) {
self.marks.pop_front();
}
while self.disc_marks.front().is_some_and(|&o| o < off) {
self.disc_marks.pop_front();
}
}
}
/// Offset of the start code that opens the next AU in `buf` (at or after 0), or
/// `None` if no AU-opening start code is buffered yet.
fn au_opener_from(mode: Mode, buf: &[u8], from: usize) -> Option<usize> {
match mode {
Mode::StartCode(marker) => find_start_code(buf, from, marker),
// Any of the three AU-opening BDU types opens a VC-1 access unit.
Mode::Vc1 => find_vc1_start(buf, from),
// A sequence header, GOP header, or picture opens an MPEG-2 access unit.
Mode::Mpeg2 => find_mpeg2_start(buf, from),
Mode::Passthrough => None,
}
}
/// Find the next `00 00 01 <marker>` start code at or after `from`.
fn find_start_code(buf: &[u8], from: usize, marker: u8) -> Option<usize> {
let mut i = from;
while i + 4 <= buf.len() {
if buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1 && buf[i + 3] == marker {
return Some(i);
}
i += 1;
}
None
}
/// Find the next VC-1 AU-opening BDU start code (`00 00 01` followed by a
/// sequence header, entry point, or frame) at or after `from`.
fn find_vc1_start(buf: &[u8], from: usize) -> Option<usize> {
let mut i = from;
while i + 4 <= buf.len() {
if buf[i] == 0
&& buf[i + 1] == 0
&& buf[i + 2] == 1
&& matches!(buf[i + 3], VC1_FRAME | VC1_ENTRY | VC1_SEQ)
{
return Some(i);
}
i += 1;
}
None
}
/// Find the next MPEG-2 AU-opening start code (`00 00 01` followed by a picture,
/// sequence header, or GOP header) at or after `from`.
fn find_mpeg2_start(buf: &[u8], from: usize) -> Option<usize> {
let mut i = from;
while i + 4 <= buf.len() {
if buf[i] == 0
&& buf[i + 1] == 0
&& buf[i + 2] == 1
&& matches!(buf[i + 3], MP2_PICTURE | MP2_SEQ | MP2_GOP)
{
return Some(i);
}
i += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
const AUD: &[u8] = &[0x00, 0x00, 0x01, 0x09]; // H.264 access-unit delimiter
fn au(payload: u8, len: usize) -> Vec<u8> {
let mut v = AUD.to_vec();
v.extend(std::iter::repeat_n(payload, len));
v
}
#[test]
fn self_framing_codecs_pass_through_each_fragment_unchanged() {
// MPEG-2 (self-reassembles in its parser) and audio (syncword resync) run
// through a Passthrough assembler: every fragment emerges immediately as
// one unit with its own timing — byte-identical to today's path.
for codec in [Codec::Mpeg2, Codec::Ac3Plus, Codec::Dts, Codec::Lpcm] {
let mut a = AuAssembler::for_codec(codec);
let out = a.push(&[1, 2, 3, 4], Some(42), None, None, false);
assert_eq!(
out.len(),
1,
"{codec:?} passes each fragment straight through"
);
assert_eq!(out[0].data, vec![1, 2, 3, 4]);
assert_eq!(out[0].pts, Some(42));
assert!(a.flush().is_empty(), "passthrough buffers nothing");
}
}
#[test]
fn video_codecs_reassemble_across_fragments() {
// H.264 buffers: one fragment is NOT a complete AU on its own.
let mut a = AuAssembler::for_codec(Codec::H264);
assert!(
a.push(&[0, 0, 1, 0x09, 0xAB], Some(1), None, None, false)
.is_empty(),
"holds an AU until the next boundary"
);
}
#[test]
fn one_au_split_across_fragments_reassembles_with_start_pts() {
// A single AU (AUD + 100 bytes) arrives as three fragments; only the
// first carries a PTS. It must emit exactly ONE AU with that PTS.
let mut a = AuAssembler::for_codec(Codec::H264);
let full = au(0xAB, 100);
assert!(
a.push(&full[..40], Some(9000), None, None, false)
.is_empty()
);
assert!(a.push(&full[40..80], None, None, None, false).is_empty());
assert!(a.push(&full[80..], None, None, None, false).is_empty());
let out = a.flush();
assert_eq!(out.len(), 1);
assert_eq!(
out[0].pts,
Some(9000),
"AU carries its START pts, not 0/None"
);
assert_eq!(out[0].data, full);
}
#[test]
fn two_aus_emit_when_the_second_boundary_arrives() {
let mut a = AuAssembler::for_codec(Codec::H264);
let au1 = au(0x11, 50);
let au2 = au(0x22, 60);
let mut buf = au1.clone();
buf.extend_from_slice(&au2);
// AU1 + AU2's opening AUD → AU1 completes, tagged pts1.
let out = a.push(&buf[..au1.len() + 4], Some(1000), None, None, false);
assert_eq!(out.len(), 1);
assert_eq!(out[0].data, au1);
assert_eq!(out[0].pts, Some(1000));
a.push(&buf[au1.len() + 4..], None, None, None, false);
let out2 = a.flush();
assert_eq!(out2.len(), 1);
assert_eq!(out2[0].data, au2);
}
#[test]
fn au_merges_pts_and_source_from_different_fragments() {
// One fragment of an AU may carry the source stamp while a later fragment
// of the SAME AU carries the PTS (each PES gets a source; only the anchor
// gets a PTS). The AU must keep BOTH — reading only the front mark would
// drop whichever field the first fragment lacked.
let src = crate::pes::SourcePos::at_byte(4242);
let mut a = AuAssembler::for_codec(Codec::H264);
let full = au(0xAB, 80);
// Fragment 1: source only, no PTS.
assert!(a.push(&full[..30], None, None, Some(src), false).is_empty());
// Fragment 2 (same AU): PTS only, no source.
assert!(
a.push(&full[30..], Some(9000), None, None, false)
.is_empty()
);
let out = a.flush();
assert_eq!(out.len(), 1);
assert_eq!(out[0].pts, Some(9000), "PTS from the 2nd fragment retained");
assert_eq!(
out[0].source.map(|s| s.byte),
Some(4242),
"source from the 1st fragment retained"
);
}
#[test]
fn discontinuity_flag_attaches_to_the_au_it_opens() {
// A discontinuity-flagged fragment opens AU2; that flag must land on AU2,
// not AU1 (the B1 resync gate keys off it).
let mut a = AuAssembler::for_codec(Codec::H264);
let au1 = au(0x11, 30);
let au2 = au(0x22, 30);
a.push(&au1, Some(1), None, None, false);
// AU2 arrives flagged; its opening AUD completes AU1 first.
let out = a.push(&au2, Some(2), None, None, true);
assert_eq!(out.len(), 1, "AU1 completes when AU2's boundary arrives");
assert!(!out[0].discontinuity, "AU1 is NOT the discontinuity");
let out2 = a.flush();
assert_eq!(out2.len(), 1);
assert!(out2[0].discontinuity, "AU2 carries the discontinuity");
}
#[test]
fn leading_bytes_before_first_au_are_discarded() {
let mut a = AuAssembler::for_codec(Codec::H264);
let mut buf = vec![0xFF, 0xFF, 0xFF, 0xFF];
buf.extend_from_slice(&au(0x33, 20));
a.push(&buf, Some(500), None, None, false);
let out = a.flush();
assert_eq!(out.len(), 1);
assert_eq!(out[0].data, au(0x33, 20), "leading junk dropped, AU intact");
}
// ── VC-1 AU grouping ──────────────────────────────────────────────────
fn bdu(ty: u8, payload: u8, len: usize) -> Vec<u8> {
let mut v = vec![0x00, 0x00, 0x01, ty];
v.extend(std::iter::repeat_n(payload, len));
v
}
#[test]
fn vc1_i_frame_keeps_its_preceding_seq_and_entry_headers() {
// An I-frame AU is [seq 0x0F][entry 0x0E][frame 0x0D][slices]; a following
// P-frame is just [frame 0x0D][slices]. A plain 0x0D split would strand the
// seq/entry headers on the P-frame's AU — the decode bug. The VC-1 mode must
// group them with the I-frame that follows them.
let mut a = AuAssembler::for_codec(Codec::Vc1);
let mut iframe = bdu(VC1_SEQ, 0xAA, 8);
iframe.extend(bdu(VC1_ENTRY, 0xBB, 6));
iframe.extend(bdu(VC1_FRAME, 0xCC, 20)); // frame + slice bytes
let pframe = bdu(VC1_FRAME, 0xDD, 15);
// Feed the I-frame; it stays open until the P-frame's boundary arrives.
assert!(a.push(&iframe, Some(9000), None, None, false).is_empty());
let out = a.push(&pframe, Some(9376), None, None, false);
assert_eq!(out.len(), 1, "I-frame AU completes at the P-frame boundary");
assert_eq!(out[0].data, iframe, "I-frame AU retains seq+entry+frame");
assert_eq!(out[0].pts, Some(9000));
let tail = a.flush();
assert_eq!(tail.len(), 1);
assert_eq!(tail[0].data, pframe, "P-frame is its own AU");
assert_eq!(tail[0].pts, Some(9376));
}
#[test]
fn vc1_consecutive_frames_split_one_per_au() {
// Back-to-back frames with no headers between them each form their own AU.
let mut a = AuAssembler::for_codec(Codec::Vc1);
let f1 = bdu(VC1_FRAME, 0x11, 30);
let f2 = bdu(VC1_FRAME, 0x22, 40);
let mut both = f1.clone();
both.extend_from_slice(&f2);
both.extend(bdu(VC1_FRAME, 0x33, 4)); // opening boundary of a 3rd frame
let out = a.push(&both, Some(1), None, None, false);
assert_eq!(out.len(), 2, "two complete frames emit");
assert_eq!(out[0].data, f1);
assert_eq!(out[1].data, f2);
}
#[test]
fn vc1_entry_point_without_seq_header_still_groups_with_frame() {
// Mid-GOP open points can carry an entry-point header with no sequence
// header; it must still attach to the frame that follows it.
let mut a = AuAssembler::for_codec(Codec::Vc1);
let mut au = bdu(VC1_ENTRY, 0xEE, 5);
au.extend(bdu(VC1_FRAME, 0xFF, 12));
let mut done = a.push(&au, Some(500), None, None, false);
// Next frame's opening boundary closes the entry+frame AU.
done.extend(a.push(&bdu(VC1_FRAME, 0x00, 4), None, None, None, false));
done.extend(a.flush());
assert_eq!(done.len(), 2);
assert_eq!(done[0].data, au, "entry+frame grouped");
assert_eq!(done[0].pts, Some(500));
}
// ── MPEG-2 AU grouping ────────────────────────────────────────────────
#[test]
fn mpeg2_keeps_seq_and_gop_headers_with_their_picture() {
// A GOP-opening AU is [seq 0xB3][gop 0xB8][picture 0x00][slices]; the next
// picture (no headers) is its own AU. The seq/GOP headers must stay with
// the picture they introduce, not glue onto the previous AU.
let mut a = AuAssembler::mpeg2();
let mut gop = bdu(MP2_SEQ, 0xAA, 10);
gop.extend(bdu(MP2_GOP, 0xBB, 8));
gop.extend(bdu(MP2_PICTURE, 0xCC, 20)); // picture + slice bytes
let pic2 = bdu(MP2_PICTURE, 0xDD, 15);
assert!(a.push(&gop, Some(9000), None, None, false).is_empty());
let out = a.push(&pic2, Some(9376), None, None, false);
assert_eq!(
out.len(),
1,
"first AU completes at the next picture boundary"
);
assert_eq!(out[0].data, gop, "AU retains seq + GOP + picture");
assert_eq!(out[0].pts, Some(9000));
let tail = a.flush();
assert_eq!(tail.len(), 1);
assert_eq!(tail[0].data, pic2, "second picture is its own AU");
assert_eq!(tail[0].pts, Some(9376));
}
#[test]
fn mpeg2_slice_codes_are_not_au_boundaries() {
// Slice start codes (0x01..=0xAF) inside a picture must not split the AU.
let mut a = AuAssembler::mpeg2();
let mut pic = bdu(MP2_PICTURE, 0x11, 4);
pic.extend(bdu(0x01, 0x22, 10)); // slice 1
pic.extend(bdu(0xAF, 0x33, 10)); // slice 175 (max slice code)
let next = bdu(MP2_PICTURE, 0x44, 4); // opening boundary of the next AU
let out = a.push(&[pic.clone(), next].concat(), Some(1), None, None, false);
assert_eq!(out.len(), 1, "slices stay inside the one picture AU");
assert_eq!(out[0].data, pic, "AU spans the picture and all its slices");
}
#[test]
fn mpeg2_reassembles_one_picture_split_across_fragments() {
// A picture split across three PES fragments; only the first carries a PTS.
let mut a = AuAssembler::mpeg2();
let full = bdu(MP2_PICTURE, 0xEE, 100);
assert!(a.push(&full[..40], Some(500), None, None, false).is_empty());
assert!(a.push(&full[40..80], None, None, None, false).is_empty());
assert!(a.push(&full[80..], None, None, None, false).is_empty());
let out = a.flush();
assert_eq!(out.len(), 1);
assert_eq!(out[0].pts, Some(500), "AU carries its START pts");
assert_eq!(out[0].data, full);
}
/// Split `stream` into fragments of `frag` bytes, push them through the given
/// assembler mode, and return the reassembled AU byte-payloads.
fn reassemble_with(mut a: AuAssembler, stream: &[u8], frag: usize) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let mut i = 0;
while i < stream.len() {
let end = (i + frag).min(stream.len());
for au in a.push(&stream[i..end], None, None, None, false) {
out.push(au.data);
}
i = end;
}
for au in a.flush() {
out.push(au.data);
}
out
}
#[test]
fn resumable_boundary_matches_from_scratch_across_all_fragmentations() {
// The incremental scan_pos cursor must produce byte-identical AUs to a
// whole-buffer rescan, at EVERY fragment granularity (this is what makes
// the O(n) resume equivalent to the old O(n^2) from-scratch scan). Build a
// multi-AU stream per codec, reassemble it fed 1 byte at a time up to
// whole, and require one canonical result.
let h264 = {
let mut s = au(0x11, 40); // AU1 (AUD + payload)
s.extend(au(0x22, 70)); // AU2
s.extend(au(0x33, 25)); // AU3
s
};
let vc1 = {
let mut s = bdu(VC1_SEQ, 0xAA, 8);
s.extend(bdu(VC1_ENTRY, 0xBB, 6));
s.extend(bdu(VC1_FRAME, 0xCC, 50)); // I-frame AU
s.extend(bdu(VC1_FRAME, 0xDD, 30)); // P-frame AU
s.extend(bdu(VC1_FRAME, 0xEE, 20)); // P-frame AU
s
};
let mpeg2 = {
let mut s = bdu(MP2_SEQ, 0xAA, 10);
s.extend(bdu(MP2_GOP, 0xBB, 8));
s.extend(bdu(MP2_PICTURE, 0xCC, 60)); // GOP-opening picture AU
s.extend(bdu(MP2_PICTURE, 0xDD, 40)); // picture AU
s
};
// (label, stream, assembler factory). MPEG-2 uses the dedicated mpeg2()
// assembler (Mode::Mpeg2); the AUD/VC-1 codecs use for_codec().
type MakeAsm = fn() -> AuAssembler;
let cases: [(&str, &[u8], MakeAsm); 3] = [
("h264", &h264, || AuAssembler::for_codec(Codec::H264)),
("vc1", &vc1, || AuAssembler::for_codec(Codec::Vc1)),
("mpeg2", &mpeg2, AuAssembler::mpeg2),
];
for (label, stream, make) in cases {
let whole = reassemble_with(make(), stream, stream.len());
assert!(!whole.is_empty(), "{label}: baseline produced AUs");
for frag in 1..=stream.len() {
let got = reassemble_with(make(), stream, frag);
assert_eq!(
got, whole,
"{label}: fragmented at {frag} differs from whole-buffer reassembly"
);
}
}
}
#[test]
fn marks_deques_stay_bounded_on_zero_length_timed_fragments() {
// A run of zero-length fragments that each carry a PTS (or a
// discontinuity) grows no buffer bytes, so the buf-size cap never prunes
// the mark deques. The MAX_MARKS backstop must bound them regardless.
let mut a = AuAssembler::for_codec(Codec::H264);
for i in 0..(MAX_MARKS * 2) {
a.push(&[], Some(i as i64), None, None, true);
}
assert!(
a.marks.len() <= MAX_MARKS,
"marks bounded at MAX_MARKS, got {}",
a.marks.len()
);
assert!(
a.disc_marks.len() <= MAX_MARKS,
"disc_marks bounded at MAX_MARKS, got {}",
a.disc_marks.len()
);
}
#[test]
fn over_cap_without_boundary_force_flushes() {
let mut a = AuAssembler::for_codec(Codec::H264);
let big = au(0x44, MAX_AU_BUFFER + 16);
let emitted = a.push(&big, Some(1), None, None, false);
assert!(
!emitted.is_empty(),
"over-cap AU is force-flushed, not buffered forever"
);
}
}
+127 -1
View File
@@ -60,6 +60,20 @@ impl Ac3Parser {
impl CodecParser for Ac3Parser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
// B1: a concealed/lost gap means the bytes held in `buf` are a TRUNCATED
// frame. Appending the post-gap bytes would splice them into one corrupt
// frame (wrong frame_size, bad CRC → "exponent out of range" / garbage).
// Drop the partial and resync on the next syncword — a clean single-frame
// gap instead of a frankenstein frame. (The video parsers carry this via
// the ResyncGate; audio has no inter-frame refs, so dropping the spliced
// partial is the whole fix.)
//
// Handle the discontinuity BEFORE the empty-data guard so the signal can
// never be stranded by an empty post-gap PES (the demuxer only emits
// non-empty PES today; this is defensive for any future caller).
if pes.discontinuity {
self.buf.clear();
}
if pes.data.is_empty() {
return Vec::new();
}
@@ -69,7 +83,13 @@ impl CodecParser for Ac3Parser {
// PES that carries several AC-3 frames stamps a monotonically increasing
// PTS per frame instead of the same PES timestamp on all of them (which
// collapses their timecodes and drifts A/V).
let base_pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
//
// A PES with no PTS (rare for audio, but legal — and the case OSS demuxers
// guard at a post-gap continuation) must NOT reset the timeline to 0;
// carry the running cadence (`flush_pts_ns` tracks the retained partial /
// last frame's PTS). The discontinuity-carrying PES is a PUSI with a PTS
// in practice, so this is defense-in-depth.
let base_pts_ns = pes.pts.map(pts_to_ns).unwrap_or(self.flush_pts_ns);
// Prepend leftover from previous PES
self.buf.extend_from_slice(&pes.data);
@@ -115,6 +135,7 @@ impl CodecParser for Ac3Parser {
let duration_ns = frame_duration_ns(remaining, bsid);
frames.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: frame_pts_ns,
@@ -201,6 +222,7 @@ impl CodecParser for Ac3Parser {
}
let duration_ns = frame_duration_ns(frame, bsid);
vec![Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: self.flush_pts_ns,
@@ -450,6 +472,7 @@ mod tests {
pts: None,
dts: None,
data: vec![],
discontinuity: false,
};
assert!(parser.parse(&pes).is_empty());
}
@@ -464,6 +487,7 @@ mod tests {
pts: Some(90000),
dts: None,
data: frame_data.clone(),
discontinuity: false,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
@@ -483,6 +507,7 @@ mod tests {
pts: Some(90000),
dts: None,
data: frame_data[..mid].to_vec(),
discontinuity: false,
};
let frames1 = parser.parse(&pes1);
assert!(frames1.is_empty(), "partial frame should not emit");
@@ -494,12 +519,104 @@ mod tests {
pts: Some(93000),
dts: None,
data: frame_data[mid..].to_vec(),
discontinuity: false,
};
let frames2 = parser.parse(&pes2);
assert_eq!(frames2.len(), 1);
assert_eq!(frames2[0].data.len(), 160);
}
#[test]
fn discontinuity_drops_truncated_partial() {
// B1: a partial AC-3 frame is buffered, then a concealed gap arrives
// (PES marked discontinuity) carrying a fresh complete frame. The
// truncated partial must be DROPPED, not spliced — otherwise the parser
// emits one corrupt frame built from [stale partial | head of fresh] and
// strands the tail (FFmpeg: "incomplete frame" / wrong sync).
let mut parser = Ac3Parser::new();
let frame_data = make_ac3_frame(0, 2); // 160 bytes, starts with 0x0B77
// First PES: only the first half of a frame (no boundary marker).
let pes1 = PesPacket {
source: None,
pid: 0,
pts: Some(90000),
dts: None,
data: frame_data[..80].to_vec(),
discontinuity: false,
};
assert!(
parser.parse(&pes1).is_empty(),
"partial frame should not emit"
);
// Concealed gap: a fresh whole frame, marked discontinuity.
let fresh = make_ac3_frame(0, 2);
let pes2 = PesPacket {
source: None,
pid: 0,
pts: Some(99000),
dts: None,
data: fresh.clone(),
discontinuity: true,
};
let frames = parser.parse(&pes2);
assert_eq!(frames.len(), 1, "exactly one clean frame across the gap");
assert_eq!(
frames[0].data, fresh,
"emitted frame is the fresh post-gap frame, not a spliced partial"
);
}
#[test]
fn empty_discontinuity_pes_still_drops_partial() {
// Defensive ordering: the discontinuity clear runs BEFORE the empty-data
// guard, so even an empty-payload discontinuity PES drops the stranded
// partial instead of leaking the signal and splicing on the next PES.
let mut parser = Ac3Parser::new();
let frame_data = make_ac3_frame(0, 2); // 160 bytes
// Partial first half buffered.
let pes1 = PesPacket {
source: None,
pid: 0,
pts: Some(90000),
dts: None,
data: frame_data[..80].to_vec(),
discontinuity: false,
};
assert!(parser.parse(&pes1).is_empty());
// Empty-payload discontinuity PES: must still clear the partial.
let gap = PesPacket {
source: None,
pid: 0,
pts: None,
dts: None,
data: vec![],
discontinuity: true,
};
assert!(parser.parse(&gap).is_empty(), "empty PES emits nothing");
// A fresh whole frame (no discontinuity now): if the partial had leaked,
// this would splice into a frankenstein; instead it emits cleanly.
let fresh = make_ac3_frame(0, 2);
let pes2 = PesPacket {
source: None,
pid: 0,
pts: Some(99000),
dts: None,
data: fresh.clone(),
discontinuity: false,
};
let frames = parser.parse(&pes2);
assert_eq!(frames.len(), 1, "one clean frame, partial was dropped");
assert_eq!(
frames[0].data, fresh,
"no splice — partial did not leak past the empty gap PES"
);
}
#[test]
fn skip_garbage_before_sync() {
let mut parser = Ac3Parser::new();
@@ -512,6 +629,7 @@ mod tests {
pts: None,
dts: None,
data,
discontinuity: false,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
@@ -535,6 +653,7 @@ mod tests {
pts: Some(90000),
dts: None,
data: pes1_data,
discontinuity: false,
};
let frames1 = parser.parse(&pes1);
assert_eq!(frames1.len(), 1, "first complete frame emitted");
@@ -548,6 +667,7 @@ mod tests {
pts: Some(93000),
dts: None,
data: pes2_data,
discontinuity: false,
};
let frames2 = parser.parse(&pes2);
assert_eq!(frames2.len(), 1, "split-sync frame must be recovered");
@@ -574,6 +694,7 @@ mod tests {
pts: None,
dts: None,
data,
discontinuity: false,
};
let frames = parser.parse(&pes);
assert!(frames.is_empty());
@@ -602,6 +723,7 @@ mod tests {
pts: None,
dts: None,
data,
discontinuity: false,
};
assert!(parser.parse(&pes).is_empty());
assert_eq!(parser.buf, vec![0x0B], "lone trailing 0x0B retained");
@@ -639,6 +761,7 @@ mod tests {
pts: Some(90000),
dts: None,
data,
discontinuity: false,
};
let f = parser.parse(&pes);
assert_eq!(f.len(), 1, "frame 1 emitted in parse");
@@ -671,6 +794,7 @@ mod tests {
pts: Some(90000),
dts: None,
data,
discontinuity: false,
};
let f = parser.parse(&pes);
assert_eq!(f.len(), 2);
@@ -714,6 +838,7 @@ mod tests {
pts: Some(90000),
dts: None,
data,
discontinuity: false,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1, "only the real AC-3 frame is emitted");
@@ -1179,6 +1304,7 @@ mod tests {
pts: Some(90000),
dts: None,
data,
discontinuity: false,
}
}
}
+357 -34
View File
@@ -37,7 +37,18 @@ pub struct DtsParser {
/// timestamp instead of the later PES's. Offsets are kept relative to the
/// current `buf` start and rebased whenever bytes are drained from the
/// front.
pts_marks: Vec<(usize, i64)>,
pts_marks: std::collections::VecDeque<(usize, i64)>,
/// The `front_pts` of the PREVIOUS emitted access unit. When the current
/// AU's `front_pts` differs, it began a new PES → re-base to it. When it is
/// unchanged, this AU shares the previous AU's PES → advance one frame
/// duration. This per-PES re-base (rather than a global running clock) is
/// what keeps a feature-long DVD DTS track from drifting past its real
/// length. `PTS_UNSET` = no AU emitted yet.
last_front_pts: i64,
/// The PTS for the NEXT AU *if it shares the current PES* (the within-PES
/// running cursor: previous emit + its duration). Only consulted when
/// `front_pts` is unchanged from `last_front_pts`. `PTS_UNSET` = no base yet.
next_pts_ns: i64,
}
impl Default for DtsParser {
@@ -51,10 +62,42 @@ impl DtsParser {
Self {
buf: Vec::with_capacity(32768),
pending_pts: 0,
pts_marks: Vec::new(),
pts_marks: std::collections::VecDeque::new(),
last_front_pts: PTS_UNSET,
next_pts_ns: PTS_UNSET,
}
}
/// Stamp an access unit's PTS. `front` is the AU's own core-PES PTS (from
/// [`front_pts`]); `dur_ns` its decoded duration.
///
/// The model matches the (correct) AC-3 path: **re-base to each PES's own
/// container timestamp, and advance by one frame duration ONLY within a run
/// of AUs that share the same PES.** A new PES (its `front` differs from the
/// previous AU's) trusts its own timestamp — so the emitted timeline tracks
/// the container and never drifts. Advancing within a PES is what fixes the
/// DVD case (several DTS core frames packed in one PES, which otherwise all
/// collided on that single PES timestamp → "non monotonically increasing
/// dts"). A global running clock was WRONG here: once accumulated frame
/// durations exceeded the PES spacing it never re-based, drifting the track
/// minutes past the real length over a feature-long title.
fn stamp_pts(&mut self, front: i64, dur_ns: i64) -> i64 {
let base = if front != PTS_UNSET && front != self.last_front_pts {
// New PES (or the first AU): trust its own timestamp — no drift.
front
} else if self.next_pts_ns != PTS_UNSET {
// Same PES as the previous AU (front unchanged) → advance one frame.
self.next_pts_ns
} else if front != PTS_UNSET {
front
} else {
0
};
self.last_front_pts = front;
self.next_pts_ns = base + dur_ns;
base
}
/// Drop `n` bytes from the front of `buf` and rebase the PTS markers so
/// their offsets stay relative to the new buffer start. A marker that now
/// sits at or before offset 0 is clamped to 0 (it still covers the front).
@@ -123,10 +166,43 @@ const PTS_UNSET: i64 = -1;
impl CodecParser for DtsParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
// B1: a concealed/lost gap means the buffered DTS access unit is
// TRUNCATED. Splicing post-gap bytes onto it corrupts the core/extension
// framing (→ "Failed to decode block code(s)" / "Invalid data found").
// Drop the partial AU and its PTS marks; the next PES re-bases a fresh
// unit. (Audio has no inter-frame refs — dropping the spliced partial is
// the whole fix; the video ResyncGate handles video.)
//
// Handle the discontinuity BEFORE the empty-data guard so the signal can
// never be stranded by an empty post-gap PES (defensive; the demuxer only
// emits non-empty PES today).
if pes.discontinuity {
self.buf.clear();
self.pts_marks.clear();
self.pending_pts = PTS_UNSET;
// A concealed gap is a timeline discontinuity: let the post-gap AU
// re-base to its own PES PTS rather than the pre-gap cursor.
self.next_pts_ns = PTS_UNSET;
self.last_front_pts = PTS_UNSET;
}
if pes.data.is_empty() {
return Vec::new();
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
// A PES with no PTS (rare for audio, but legal — the case OSS demuxers
// guard at a post-gap continuation) must NOT reset the timeline to 0;
// continue from the most recent known base. Defense-in-depth: the
// discontinuity-carrying PES is a PUSI with a PTS in practice.
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or_else(|| {
self.pts_marks
.back()
.map(|&(_, p)| p)
.filter(|&p| p >= 0)
.unwrap_or(if self.pending_pts >= 0 {
self.pending_pts
} else {
0
})
});
// On Blu-ray, a DTS-HD MA/HRA access unit is a DTS core frame
// (sync 0x7FFE8001) followed by one or more DTS extension substreams
@@ -154,7 +230,10 @@ impl CodecParser for DtsParser {
// (see `front_pts`), so an AU whose core arrived in an earlier PES keeps
// that core's timestamp even when its extensions / the following core
// arrive (with a later PTS) in this same parse() call.
self.pts_marks.push((self.buf.len(), pts_ns));
// (pts_marks is bounded implicitly: an empty PES returns above without
// pushing a mark, and a non-empty run grows `buf`, which is cleared —
// along with pts_marks — once it exceeds MAX_AU_BYTES.)
self.pts_marks.push_back((self.buf.len(), pts_ns));
self.buf.extend_from_slice(&pes.data);
let mut frames = Vec::new();
@@ -238,17 +317,21 @@ impl CodecParser for DtsParser {
};
let au: Vec<u8> = self.buf[..au_end].to_vec();
// The AU takes the PTS of the PES covering its first byte — its own
// core's PES, even if that PES preceded the one(s) carrying its
// extensions or the next core.
let au_pts = self.front_pts();
// The AU's own core PES PTS (the PES covering its first byte, even if
// that PES preceded the one(s) carrying its extensions or the next
// core), stamped monotonically: honored when it advances past the
// running clock (UHD one-AU-per-PES), but never allowed to collide
// with the previous AU when several cores share ONE PES (DVD).
let dur_ns = dts_core_duration_ns(&au) as i64;
let au_pts = self.stamp_pts(self.front_pts(), dur_ns);
frames.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: au_pts,
keyframe: true,
data: au,
duration_ns: None,
duration_ns: Some(dur_ns as u64),
});
self.drain_front(au_end);
// After draining, the marker covering the new front (if any) carries
@@ -294,17 +377,18 @@ impl CodecParser for DtsParser {
}
// The final AU's PTS is the PES covering the buffer front (its core's
// PES). Fall back to pending_pts, clamping the sentinel to 0.
let front = self.front_pts();
let pts_ns = if front == PTS_UNSET { 0 } else { front };
let au = std::mem::take(&mut self.buf);
let dur_ns = dts_core_duration_ns(&au) as i64;
let pts_ns = self.stamp_pts(self.front_pts(), dur_ns);
self.pts_marks.clear();
vec![Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns,
keyframe: true,
data: au,
duration_ns: None,
duration_ns: Some(dur_ns as u64),
}]
}
@@ -470,6 +554,49 @@ fn dts_core_frame_size(data: &[u8]) -> usize {
fsize + 1
}
/// DTS core `SFREQ` → sample rate (Hz). 4-bit index; reserved/invalid entries
/// fall back to 48 kHz (the DVD/UHD norm) so a bogus value never yields a zero
/// rate (division) or a wildly wrong frame duration.
const DTS_CORE_SAMPLE_RATES: [u32; 16] = [
48_000, // 0: invalid → fallback
8_000, 16_000, 32_000, 48_000, // 4: invalid → fallback
48_000, // 5: invalid → fallback
11_025, 22_050, 44_100, 48_000, // 9: invalid → fallback
48_000, // 10: invalid → fallback
12_000, 24_000, 48_000, 96_000, 192_000,
];
/// Samples in one DTS core frame: `(NBLKS + 1) * 32`. `NBLKS` (7 bits) is the
/// core-header PCM-sample-block count — the same field ffmpeg's `dca` decoder
/// uses to timestamp frames. Bit layout after the 32-bit sync: FTYPE(1) SHORT(5)
/// CPF(1) **NBLKS(7)** FSIZE(14) …, so NBLKS = byte4 bit0 + byte5 bits7-2.
fn dts_core_samples(data: &[u8]) -> u32 {
if data.len() < CORE_HEADER_MIN_BYTES {
return 512; // typical; only reached on a truncated header
}
let nblks = ((data[4] as u32 & 0x01) << 6) | (data[5] as u32 >> 2);
(nblks + 1) * 32
}
/// DTS core sample rate (Hz) from `SFREQ` (4 bits: byte8 bits5-2), with a 48 kHz
/// fallback for reserved indices.
fn dts_core_sample_rate(data: &[u8]) -> u32 {
if data.len() < CORE_HEADER_MIN_BYTES {
return 48_000;
}
let sfreq = (data[8] as usize >> 2) & 0x0F;
DTS_CORE_SAMPLE_RATES[sfreq]
}
/// Duration of one DTS core access unit in nanoseconds: `samples / rate`,
/// rounded to nearest. This is what lets consecutive core frames packed in a
/// single DVD PES advance monotonically instead of colliding on one PES PTS.
fn dts_core_duration_ns(data: &[u8]) -> u64 {
let samples = dts_core_samples(data) as u64;
let rate = dts_core_sample_rate(data) as u64;
(samples * 1_000_000_000 + rate / 2) / rate
}
#[cfg(test)]
mod tests {
use super::*;
@@ -482,6 +609,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
@@ -489,12 +617,22 @@ mod tests {
let fsize = size - 1;
let mut data = vec![0u8; size];
data[0..4].copy_from_slice(&DTS_CORE_SYNC);
data[5] = (data[5] & 0xFC) | ((fsize >> 12) & 0x03) as u8;
// NBLKS = 15 → (15+1)*32 = 512 samples/frame (the DVD/UHD DTS-core norm).
// NBLKS is byte4 bit0 + byte5 bits7-2; here byte4 bit0 = 0, byte5 = 15<<2.
data[5] = (15u8 << 2) | ((fsize >> 12) & 0x03) as u8;
data[6] = ((fsize >> 4) & 0xFF) as u8;
data[7] = (data[7] & 0x0F) | (((fsize & 0x0F) << 4) as u8);
// SFREQ = 13 → 48 kHz (byte8 bits5-2). Only when the header byte exists.
if size > 8 {
data[8] = 13u8 << 2;
}
data
}
// 512 samples @ 48 kHz, rounded to nearest ns — the duration make_dts_core
// frames advance by. (512 * 1e9 + 24000) / 48000 = 10_666_667 ns.
const DTS_CORE_DUR_NS: i64 = (512 * 1_000_000_000 + 48_000 / 2) / 48_000;
/// A real DTS-HD EXSS substream of `total` bytes (short header form), with an
/// optional false DTS core syncword embedded in its payload (decoding to a
/// plausible core size) — to prove precise sizing, not a payload scan, bounds
@@ -582,10 +720,54 @@ mod tests {
}
#[test]
fn two_cores_back_to_back_emit_first_on_boundary() {
// The first complete unit is emitted as soon as the next core sync is
// seen; the second is held until flush. Both cores arrived in ONE PES,
// so both legitimately carry that PES's PTS.
fn discontinuity_drops_truncated_partial() {
// B1: a partial DTS core is buffered, then a concealed gap (PES marked
// discontinuity) carries a fresh core. The truncated partial must be
// DROPPED — splicing it makes the framer emit a corrupt sub-core-length
// AU (the Dunkirk `dca` "Failed to decode block code(s)" class) and
// strands the rest. With the fix the post-gap core is the only AU, and it
// carries the post-gap PTS (not the stale pre-gap one).
let mut parser = DtsParser::new();
// PES 1: first half of a 512-byte core (no boundary marker).
let core = make_dts_core(512);
let pes1 = make_pes(core[..256].to_vec(), Some(90000));
assert!(parser.parse(&pes1).is_empty(), "partial core held");
// Concealed gap: a fresh whole core, marked discontinuity.
let fresh = make_dts_core(512);
let pes2 = PesPacket {
source: None,
pid: 0x1100,
pts: Some(99000),
dts: None,
data: fresh.clone(),
discontinuity: true,
};
assert!(
parser.parse(&pes2).is_empty(),
"post-gap core held awaiting next core — NO corrupt partial emitted"
);
let tail = parser.flush();
assert_eq!(tail.len(), 1, "exactly one clean AU across the gap");
assert_eq!(
tail[0].data, fresh,
"AU is the fresh post-gap core, not a splice"
);
assert_eq!(
tail[0].pts_ns,
pts_to_ns(99000),
"post-gap AU re-bases to the post-gap PTS, not the stranded pre-gap one"
);
}
#[test]
fn two_cores_back_to_back_advance_within_one_pes() {
// Both cores arrive in ONE PES — the DVD layout. AU1 keeps the PES PTS;
// AU2 must ADVANCE by one frame duration to stay monotonic. Reusing the
// single PES PTS for both was the "non monotonically increasing dts to
// muxer" bug (fixed for 1.2.1).
let mut parser = DtsParser::new();
let mut stream = make_dts_core(512);
stream.extend_from_slice(&make_dts_core(640));
@@ -593,13 +775,18 @@ mod tests {
assert_eq!(f.len(), 1);
assert_eq!(f[0].data.len(), 512);
assert_eq!(f[0].pts_ns, pts_to_ns(90000), "AU1 keeps its PES PTS");
assert_eq!(
f[0].duration_ns,
Some(DTS_CORE_DUR_NS as u64),
"AU1 carries a real frame duration (was None)"
);
let tail = parser.flush();
assert_eq!(tail.len(), 1);
assert_eq!(tail[0].data.len(), 640);
assert_eq!(
tail[0].pts_ns,
pts_to_ns(90000),
"AU2 came in the same PES → same PTS"
pts_to_ns(90000) + DTS_CORE_DUR_NS,
"AU2 in the same PES advances one frame duration (monotonic)"
);
}
@@ -614,31 +801,33 @@ mod tests {
let mut parser = DtsParser::new();
// PES A: just core1 (held — no following core yet).
let f0 = parser.parse(&make_pes(make_dts_core(512), Some(100)));
let f0 = parser.parse(&make_pes(make_dts_core(512), Some(90000)));
assert!(f0.is_empty(), "core1 held awaiting next core");
// PES B: core2 + core3. Closes AU1 (core1) and AU2 (core2).
// PES B (realistically LATER — far past one frame): core2 + core3.
// Closes AU1 (core1) and AU2 (core2).
let mut pes_b = make_dts_core(600);
pes_b.extend_from_slice(&make_dts_core(640));
let f = parser.parse(&make_pes(pes_b, Some(200)));
let f = parser.parse(&make_pes(pes_b, Some(190000)));
assert_eq!(f.len(), 2, "AU1 and AU2 both close in this call");
assert_eq!(f[0].data.len(), 512, "AU1 = core1");
assert_eq!(
f[0].pts_ns,
pts_to_ns(100),
pts_to_ns(90000),
"AU1 keeps PES A's PTS, not the later PES B PTS"
);
assert_eq!(f[1].data.len(), 600, "AU2 = core2");
assert_eq!(
f[1].pts_ns,
pts_to_ns(200),
"AU2's core arrived in PES B → PES B PTS"
pts_to_ns(190000),
"AU2's core is in PES B → attributes to PES B PTS (ahead of the clock, so it wins)"
);
// AU3 (core3) drains on flush, also PES B PTS.
// AU3 (core3) drains on flush — 2nd core in PES B, so it advances one
// frame duration from AU2 to stay monotonic.
let tail = parser.flush();
assert_eq!(tail.len(), 1);
assert_eq!(tail[0].pts_ns, pts_to_ns(200));
assert_eq!(tail[0].pts_ns, pts_to_ns(190000) + DTS_CORE_DUR_NS);
}
#[test]
@@ -653,23 +842,157 @@ mod tests {
// AU2 (core2) held awaiting a third core.
let mut pes_a = make_dts_core(512);
pes_a.extend_from_slice(&make_dts_core(600));
let f = parser.parse(&make_pes(pes_a, Some(100)));
let f = parser.parse(&make_pes(pes_a, Some(90000)));
assert_eq!(f.len(), 1);
assert_eq!(f[0].pts_ns, pts_to_ns(100), "AU1 PES A PTS");
assert_eq!(f[0].pts_ns, pts_to_ns(90000), "AU1 PES A PTS");
// PES B: core3 — closes AU2 (core2). AU2's core was in PES A.
let f2 = parser.parse(&make_pes(make_dts_core(640), Some(200)));
// PES B (realistically later): core3 — closes AU2 (core2). AU2's core
// was in PES A, so it is PES A's 2nd frame: it advances one frame
// duration from AU1 (still on PES A's timeline, and monotonic) — it does
// NOT inherit the closing PES B PTS.
let f2 = parser.parse(&make_pes(make_dts_core(640), Some(190000)));
assert_eq!(f2.len(), 1);
assert_eq!(f2[0].data.len(), 600, "AU2 = core2");
assert_eq!(
f2[0].pts_ns,
pts_to_ns(100),
"AU2's core arrived in PES A → must keep PES A's PTS, not PES B's"
pts_to_ns(90000) + DTS_CORE_DUR_NS,
"AU2 = 2nd frame of PES A → PES A base + one frame, not the closing PES B PTS"
);
// AU3 = core3, whose own core is in PES B → jumps to PES B's PTS.
let tail = parser.flush();
assert_eq!(tail.len(), 1);
assert_eq!(tail[0].pts_ns, pts_to_ns(200), "AU3 = core3, PES B PTS");
assert_eq!(
tail[0].pts_ns,
pts_to_ns(190000),
"AU3 = core3 in PES B → PES B PTS"
);
}
#[test]
fn dvd_many_cores_one_pes_are_strictly_monotonic() {
// Punisher-DVD reproduction: a single PES carrying SEVERAL DTS core
// frames (the DVD packing) must emit STRICTLY-increasing PTSs. The old
// code stamped every AU with the one PES PTS, which ffmpeg rejected as
// "non monotonically increasing dts to muxer: X >= X".
let mut parser = DtsParser::new();
let mut stream = Vec::new();
for _ in 0..6 {
stream.extend_from_slice(&make_dts_core(512));
}
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
frames.extend(parser.flush());
assert_eq!(frames.len(), 6, "all six cores emitted");
for w in frames.windows(2) {
assert!(
w[1].pts_ns > w[0].pts_ns,
"consecutive DTS AUs must STRICTLY increase: {} !> {}",
w[1].pts_ns,
w[0].pts_ns
);
}
// Each advances by exactly one frame duration, and carries that duration.
assert_eq!(frames[0].pts_ns, pts_to_ns(90000));
assert_eq!(frames[1].pts_ns, pts_to_ns(90000) + DTS_CORE_DUR_NS);
assert_eq!(frames[5].pts_ns, pts_to_ns(90000) + 5 * DTS_CORE_DUR_NS);
for f in &frames {
assert_eq!(f.duration_ns, Some(DTS_CORE_DUR_NS as u64));
}
}
#[test]
fn dts_core_duration_512_samples_48khz() {
// NBLKS=15 → (15+1)*32 = 512 samples; SFREQ=13 → 48 kHz.
let core = make_dts_core(512);
assert_eq!(dts_core_samples(&core), 512);
assert_eq!(dts_core_sample_rate(&core), 48_000);
assert_eq!(dts_core_duration_ns(&core), DTS_CORE_DUR_NS as u64);
}
#[test]
fn dts_core_sfreq_reserved_falls_back_to_48k() {
// A bogus SFREQ index must never yield a zero rate (division) — fall
// back to 48 kHz.
let mut core = make_dts_core(512);
core[8] = 0; // SFREQ = 0 (reserved)
assert_eq!(dts_core_sample_rate(&core), 48_000);
}
#[test]
fn dts_core_sfreq_table_matches_the_dca_spec() {
// Lock the SFREQ → sample-rate table to ffmpeg's authoritative
// `avpriv_dca_sample_rates` (ETSI TS 102 114 Table 6-4). The high-rate
// triad in particular — 48 k / 96 k / 192 k at indices 13/14/15 — must not
// be shifted; a wrong entry would compute an N× frame duration and
// reintroduce PTS drift on a 96/192 kHz DTS stream.
let mut core = make_dts_core(512);
let set_sfreq = |c: &mut [u8], idx: u8| c[8] = (c[8] & !0x3C) | ((idx & 0x0F) << 2);
for (idx, want) in [
(1u8, 8_000u32),
(2, 16_000),
(3, 32_000),
(6, 11_025),
(7, 22_050),
(8, 44_100),
(11, 12_000),
(12, 24_000),
(13, 48_000),
(14, 96_000),
(15, 192_000),
] {
set_sfreq(&mut core, idx);
assert_eq!(
dts_core_sample_rate(&core),
want,
"SFREQ {idx} must be {want} Hz"
);
}
}
#[test]
fn new_pes_rebases_to_its_own_pts_no_drift() {
// Regression for the drift bug: a global running clock overshot a
// feature-long DTS track by minutes (2h44 for a 2h03 film). When a NEW
// PES arrives whose PTS is BEHIND where accumulated frame durations
// would put a running clock, the AU must re-base to that PES's OWN
// timestamp — tracking the container, not drifting ahead of it.
//
// The re-base can make one emitted PTS sit just below the previous AU's
// (a fresh PES whose PTS lands under the within-PES cursor). That is
// CORRECT here and is NOT a muxer defect: the parser reports the true
// container timestamps, and the mkv muxer applies the strictly-monotonic
// per-track nudge to AUDIO at emit time (`mkv::block_ts` / `monotonic_ts`,
// tested in `mkv.rs`), so the written block DTS is always monotonic. The
// alternative — clamping in the parser — is what reintroduced the drift.
let mut parser = DtsParser::new();
// PES A: core1 + core2 (2 frames), pts 90000.
let mut pes_a = make_dts_core(512);
pes_a.extend_from_slice(&make_dts_core(600));
let f = parser.parse(&make_pes(pes_a, Some(90000)));
assert_eq!(f.len(), 1, "AU1 (core1) emits on the core2 boundary");
assert_eq!(f[0].pts_ns, pts_to_ns(90000), "AU1 = PES A base");
// PES B: core3, pts only 500 ticks after PES A — LESS than one frame
// (960 ticks @ 48 kHz). AU2 (core2, still PES A) advances within PES A;
// AU3 (core3, PES B) must RE-BASE to PES B's own PTS.
let f2 = parser.parse(&make_pes(make_dts_core(640), Some(90500)));
assert_eq!(f2.len(), 1, "AU2 (core2) closes on core3");
assert_eq!(
f2[0].pts_ns,
pts_to_ns(90000) + DTS_CORE_DUR_NS,
"AU2 = 2nd frame of PES A → advances one frame within PES A"
);
let tail = parser.flush();
assert_eq!(tail.len(), 1);
assert_eq!(
tail[0].pts_ns,
pts_to_ns(90500),
"AU3 = core3 in PES B → re-bases to PES B's PTS"
);
assert_ne!(
tail[0].pts_ns,
pts_to_ns(90000) + 2 * DTS_CORE_DUR_NS,
"must NOT carry the accumulated running clock across a PES (drift)"
);
}
/// Build a minimal DTS-HD extension substream of `size` bytes (just the
+4
View File
@@ -43,6 +43,7 @@ impl DvdSubParser {
if force || buf.len() >= *size {
let (pts_ns, _, data) = self.pending.take().unwrap();
return Some(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns,
@@ -103,6 +104,7 @@ impl CodecParser for DvdSubParser {
let d = ((pes.data[0] as usize) << 8) | pes.data[1] as usize;
if d < 2 {
out.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns,
@@ -116,6 +118,7 @@ impl CodecParser for DvdSubParser {
} else {
// Too short to carry SPU_size — pass through as a lone frame.
out.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns,
@@ -233,6 +236,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
+107 -3
View File
@@ -52,6 +52,10 @@ pub struct H264Parser {
// the stale avcC copy after a mid-title redefinition.
cur_sps: Option<Vec<u8>>,
cur_pps: Option<Vec<u8>>,
/// Display-order PTS reconstruction, enabled only on the program-stream
/// (HD-DVD EVO) path where the source stamps a PTS once per GOP. `None` on
/// the BD/UHD transport path, which carries a per-frame PTS.
reorder: Option<super::reorder::SparsePtsReorder>,
}
impl Default for H264Parser {
@@ -68,6 +72,25 @@ impl H264Parser {
pps: None,
cur_sps: None,
cur_pps: None,
reorder: None,
}
}
/// Enable display-order PTS reconstruction for a program-stream source.
/// No-op (leaves timestamps as parsed) for a transport-stream source.
pub(crate) fn with_ps_reorder(mut self, enabled: bool) -> Self {
if enabled {
self.reorder = Some(super::reorder::SparsePtsReorder::new());
}
self
}
/// Route a finished frame through the PTS reorderer when enabled, else emit
/// it directly (unchanged transport-stream behaviour).
fn finish(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
match self.reorder.as_mut() {
Some(r) => r.push(explicit, frame),
None => vec![frame],
}
}
}
@@ -152,7 +175,8 @@ impl CodecParser for H264Parser {
// decode order and the player reorders by timecode. Use PTS, not DTS —
// DTS presents B-frames in decode order (visible judder) and breaks
// PTS-based seeking. Fall back to DTS only if PTS is absent.
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
let explicit_pts = pes.pts.or(pes.dts).map(pts_to_ns);
let pts_ns = explicit_pts.unwrap_or(0);
// Single pass: detect IDR keyframes, seed/strip param sets, and convert
// Annex B (start-code prefixed) NALUs to length-prefixed NALUs (MKV with
@@ -236,16 +260,27 @@ impl CodecParser for H264Parser {
}
}
vec![Frame {
let frame = Frame {
// Coding-type only: H.264 field order is not decoded here, so
// `field_order()` stays `None` — honestly absent, never guessed.
coding: coding_type.map(PictureInfo::coding_type_only),
source: pes.source,
pts_ns,
keyframe,
// One access unit per PES (BD-TS aligns AUs to PES), so the gap
// signal maps straight onto this frame.
discontinuity: pes.discontinuity,
data: frame_data,
duration_ns: None,
}]
};
self.finish(explicit_pts, frame)
}
fn flush(&mut self) -> Vec<Frame> {
match self.reorder.as_mut() {
Some(r) => r.flush(),
None => Vec::new(),
}
}
fn codec_private(&self) -> Option<Vec<u8>> {
@@ -537,6 +572,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
@@ -663,6 +699,71 @@ mod tests {
);
}
/// End-to-end sparse-PTS reconstruction through the REAL parser + reorder:
/// a program-stream source (`with_ps_reorder(true)`) that stamps a PTS only
/// on each GOP's I-frame must yield distinct, display-ordered PTS for every
/// frame — the property the mkv muxer needs so a decoder derives monotonic
/// DTS. Without the reorder the non-anchor frames all collapse to one PTS.
#[test]
fn h264_ps_reorder_reconstructs_distinct_display_pts() {
use super::super::coding::CodingType;
// slice bodies: 0x88 → I (IDR), 0x98 → P, 0x9C → B (non-IDR).
// Decode order of a classic single-B GOP: I P B P B.
let gop = |anchor_pts: Option<i64>| {
vec![
(NAL_SLICE_IDR, 0x88u8, anchor_pts),
(NAL_SLICE_NON_IDR, 0x98, None),
(NAL_SLICE_NON_IDR, 0x9C, None),
(NAL_SLICE_NON_IDR, 0x98, None),
(NAL_SLICE_NON_IDR, 0x9C, None),
]
};
let feed = |reorder: bool| -> Vec<super::super::Frame> {
let mut p = H264Parser::new().with_ps_reorder(reorder);
let mut out = Vec::new();
// Two GOPs; the second I carries an anchor 5 frames later (90 kHz:
// 5 * 3750 = 18750 ticks) so the reorder can calibrate a duration.
for (nal, body, pts) in gop(Some(0)).into_iter().chain(gop(Some(18750))) {
out.extend(p.parse(&make_pes(h264_nal(nal, &[body]), pts)));
}
out.extend(p.flush());
out
};
// With reorder ON: all 10 frames emitted, every PTS distinct.
let recon = feed(true);
assert_eq!(recon.len(), 10, "no frame dropped");
let mut pts: Vec<i64> = recon.iter().map(|f| f.pts_ns).collect();
let n = pts.len();
pts.sort_unstable();
pts.dedup();
assert_eq!(
pts.len(),
n,
"reconstructed PTS are all distinct (no DTS collision)"
);
// The GOP's first-displayed frame is the I; the B in decode position 2
// must display BEFORE the P in decode position 1 (classic reorder).
let g1 = &recon[0..5];
assert_eq!(g1[0].coding.unwrap().coding_type(), CodingType::I);
assert!(
g1[2].pts_ns < g1[1].pts_ns,
"B (decode idx 2) displays before its forward-anchor P (decode idx 1)"
);
assert_eq!(g1[0].pts_ns, 0, "GOP anchor locks the I to its true PTS");
// With reorder OFF (transport-stream behaviour): the non-anchor frames
// collapse to a single colliding PTS — the bug this fix removes.
let raw = feed(false);
let collisions = raw.iter().filter(|f| f.pts_ns == 0).count();
assert!(
collisions >= 8,
"without reorder the sparse-PTS frames collide on 0 (got {collisions})"
);
}
/// Regression (Fight Club bug, H.264 variant): PPS id 0 = body A (→ avcC),
/// redefined to B, then switched BACK to A. A streaming decoder is on B; the
/// revert to A == avcC must still be emitted in-band or the A-segment
@@ -923,6 +1024,7 @@ mod tests {
pts: Some(180000), // 2 seconds (presentation)
dts: Some(90000), // 1 second (decode)
data,
discontinuity: false,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
@@ -1232,6 +1334,7 @@ mod tests {
pts: None,
dts: Some(90000),
data: vec![0x00, 0x00, 0x01, 0x41, 0x10],
discontinuity: false,
};
let f = parser.parse(&pes);
assert_eq!(f.len(), 1);
@@ -1247,6 +1350,7 @@ mod tests {
pts: None,
dts: None,
data: vec![0x00, 0x00, 0x01, 0x41, 0x10],
discontinuity: false,
};
let f = parser.parse(&pes);
assert_eq!(f.len(), 1);
+41 -3
View File
@@ -166,6 +166,10 @@ pub struct HevcParser {
// colour-volume metadata is ever fabricated.
sei_mastering: Option<MasteringDisplay>,
sei_content_light: Option<ContentLightLevel>,
/// Display-order PTS reconstruction, enabled only on the program-stream
/// path where the source stamps a PTS once per GOP. `None` on the BD/UHD
/// transport path (the common HEVC case), which carries a per-frame PTS.
reorder: Option<super::reorder::SparsePtsReorder>,
}
/// Mastering Display Colour Volume payload (Rec. ITU-T H.265 D.2.28),
@@ -234,6 +238,25 @@ impl HevcParser {
pts_wrap_offset: 0,
sei_mastering: None,
sei_content_light: None,
reorder: None,
}
}
/// Enable display-order PTS reconstruction for a program-stream source.
/// No-op (leaves timestamps as parsed) for a transport-stream source.
pub(crate) fn with_ps_reorder(mut self, enabled: bool) -> Self {
if enabled {
self.reorder = Some(super::reorder::SparsePtsReorder::new());
}
self
}
/// Route a finished frame through the PTS reorderer when enabled, else emit
/// it directly (unchanged transport-stream behaviour).
fn finish(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
match self.reorder.as_mut() {
Some(r) => r.push(explicit, frame),
None => vec![frame],
}
}
@@ -443,7 +466,8 @@ impl CodecParser for HevcParser {
// block timecode monotonic in storage order, which presents B-frames in
// decode order (visible judder / wrong frames) and breaks PTS-based
// seeking. Fall back to DTS only if PTS is somehow absent.
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
let explicit_pts = pes.pts.or(pes.dts).map(pts_to_ns);
let pts_ns = explicit_pts.unwrap_or(0);
// Auto-detect a non-seamless clip boundary from the bitstream. freemkv
// reads a BD title's clips as ONE concatenated sector stream and the
@@ -651,7 +675,7 @@ impl CodecParser for HevcParser {
// from the first coded picture before writing the track header). `None`
// until both SEI present → SDR / no-SEI tracks carry nothing.
let hdr10 = self.hdr10();
vec![Frame {
let frame = Frame {
// Coding-type only: HEVC field order (pic_struct, from a pic_timing
// SEI) is not decoded here, so field_order() stays None — honestly
// absent, never guessed. HDR10 metadata is attached when measured.
@@ -661,9 +685,20 @@ impl CodecParser for HevcParser {
source: pes.source,
pts_ns,
keyframe,
// One access unit per PES (BD-TS aligns AUs to PES), so the gap
// signal maps straight onto this frame.
discontinuity: pes.discontinuity,
data: frame_data,
duration_ns: None,
}]
};
self.finish(explicit_pts, frame)
}
fn flush(&mut self) -> Vec<Frame> {
match self.reorder.as_mut() {
Some(r) => r.flush(),
None => Vec::new(),
}
}
fn codec_private(&self) -> Option<Vec<u8>> {
@@ -973,6 +1008,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
@@ -2243,6 +2279,7 @@ mod tests {
pts: Some(180000), // 2 s (presentation)
dts: Some(90000), // 1 s (decode)
data,
discontinuity: false,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
@@ -2850,6 +2887,7 @@ mod tests {
d.extend_from_slice(&[0x10, 0x20]);
d
},
discontinuity: false,
};
let f = parser.parse(&pes);
assert_eq!(f.len(), 1);
+2
View File
@@ -72,6 +72,7 @@ impl CodecParser for LpcmParser {
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
vec![Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns,
@@ -98,6 +99,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
+24 -3
View File
@@ -25,6 +25,8 @@ pub mod lpcm;
pub mod mpeg2;
/// HDMV PGS (Presentation Graphics Stream) subtitle parser.
pub mod pgs;
/// Display-order PTS reconstruction for sparse-PTS program-stream video.
pub(crate) mod reorder;
/// Shared MPEG/Annex-B start-code scanning helpers.
pub(crate) mod startcode;
/// Dolby TrueHD / Atmos elementary-stream parser.
@@ -44,6 +46,16 @@ pub struct Frame {
pub pts_ns: i64,
/// Whether this is a keyframe (used for cue points).
pub keyframe: bool,
/// This frame is the FIRST coded picture after a concealed/lost gap (P3/B1):
/// its data begins after packets the demuxer never received (an undecryptable
/// unit concealed as NULL-TS upstream, or a continuity break in a damaged
/// source). Inter-coded video frames carrying this flag reference data that is
/// gone, so the consumer's `ResyncGate` arms here and drops forward to the next
/// keyframe. Carried per-FRAME (not per-PES) because buffering parsers — MPEG-2
/// emits whole GOPs, H.264/HEVC lag one access unit — decouple the frame from
/// the PES that carried the gap signal. Default `false`; only ever set on the
/// degraded/conceal path, so a clean rip leaves every frame `false`.
pub discontinuity: bool,
/// Frame data (elementary stream bytes).
pub data: Vec<u8>,
/// Optional duration in nanoseconds — only set by parsers that
@@ -123,11 +135,15 @@ impl PassthroughParser {
impl CodecParser for PassthroughParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
// Passthrough emits exactly one frame per PES with no cross-PES buffering,
// so the PES's discontinuity maps directly onto this frame. (Buffering
// parsers must instead defer the flag to the next emitted frame.)
vec![Frame {
coding: None,
source: None,
pts_ns,
keyframe: self.keyframe,
discontinuity: pes.discontinuity,
data: pes.data.clone(),
duration_ns: None,
}]
@@ -152,10 +168,14 @@ pub fn parser_for_codec(
is_dvd_ps: bool,
) -> Box<dyn CodecParser> {
match codec {
Codec::H264 => Box::new(h264::H264Parser::new()),
Codec::Hevc => Box::new(hevc::HevcParser::new()),
// `is_dvd_ps` marks a program-stream source (DVD VOB / HD-DVD EVO), whose
// video is timestamped only at GOP granularity. On that path the H.264 /
// HEVC / VC-1 parsers reconstruct a display-order PTS per frame; on the
// BD/UHD transport path (per-frame PTS) they leave timestamps untouched.
Codec::H264 => Box::new(h264::H264Parser::new().with_ps_reorder(is_dvd_ps)),
Codec::Hevc => Box::new(hevc::HevcParser::new().with_ps_reorder(is_dvd_ps)),
Codec::Mpeg2 => Box::new(mpeg2::Mpeg2Parser::new()),
Codec::Vc1 => Box::new(vc1::Vc1Parser::new()),
Codec::Vc1 => Box::new(vc1::Vc1Parser::new().with_ps_reorder(is_dvd_ps)),
Codec::Ac3 | Codec::Ac3Plus => Box::new(ac3::Ac3Parser::new()),
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => Box::new(dts::DtsParser::new()),
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
@@ -198,6 +218,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
+178 -231
View File
@@ -26,13 +26,10 @@
//! - Extension (seq/pic):00 00 01 B5
//! - GOP header: 00 00 01 B8
use std::collections::VecDeque;
use super::coding::{CodingType, Mpeg2Coding, PictureInfo};
use super::startcode::find_start_code;
use super::{CodecParser, Frame, pts_to_ns};
use crate::mux::ts::PesPacket;
use crate::pes::SourcePos;
/// Sequence header start code suffix.
const SEQ_HEADER_CODE: u8 = 0xB3;
@@ -49,10 +46,10 @@ const PICTURE_CODE: u8 = 0x00;
/// Picture coding type: I-frame.
const PICTURE_TYPE_I: u8 = 1;
/// Hard cap on the access-unit reassembly buffer. A real MPEG-2 frame is well
/// under 1 MiB (DVD I-frames ~100 KB); past this cap a corrupt stream that
/// never produces a second access-unit boundary is force-flushed as a single
/// frame rather than driving unbounded allocation.
/// The access-unit reassembly cap now lives in [`crate::mux::au_assembly`] (the
/// `AuAssembler` owns cross-PES buffering); this mirror exists only so the
/// force-flush test below can size an over-cap fixture against the same bound.
#[cfg(test)]
const MAX_AU_BUFFER: usize = 8 * 1024 * 1024;
/// Cap on frames held awaiting the first PES PTS anchor. A DVD stamps a PTS in
@@ -94,20 +91,11 @@ pub struct Mpeg2Parser {
/// Raw bytes of the last seen sequence header (+ sequence extension if
/// present), captured for MKV codecPrivate.
seq_header: Option<Vec<u8>>,
/// Unemitted elementary-stream bytes: the in-progress access unit plus any
/// lookahead needed to detect the next AU boundary.
buf: Vec<u8>,
/// Absolute ES byte offset of `buf[0]`. Used to associate PES PTS marks
/// (recorded by absolute offset) with the access units they belong to.
base_offset: u64,
/// `(absolute ES offset of a PES's first byte, PTS in ns)` for every PES
/// that carried a timestamp, in ascending offset order.
pts_marks: VecDeque<(u64, i64)>,
/// `(absolute ES offset of a PES's first byte, SourcePos)` for every PES
/// that carried byte-exact provenance, parallel to `pts_marks` and drained
/// by the SAME mark-drain invariant. Attaches the source position to each
/// access unit so the index carries it — never reconstructed.
source_marks: VecDeque<(u64, SourcePos)>,
/// Reassembles PES fragments into complete access units (one coded picture
/// with its leading sequence/GOP headers) and carries each AU's start
/// timing / source / discontinuity forward — the shared machinery the
/// H.264/HEVC/VC-1 parsers also use, in its MPEG-2 mode.
au_asm: crate::mux::au_assembly::AuAssembler,
/// Full-frame presentation interval (ns) at the sequence-header display rate
/// (`1/frame_rate`). The field period is half this. Per-frame durations are
/// `nb_fields × field_period`, so 2:3-telecined frames alternate 2- and
@@ -122,6 +110,10 @@ pub struct Mpeg2Parser {
/// without ever reordering emitted blocks (B-frames keep decode order; only
/// their PTS is lower).
gop_buf: Vec<BufferedPicture>,
/// Running total of `data` bytes buffered in `gop_buf` — the byte-cap counter,
/// incremented on each push and reset when the GOP flushes. Avoids re-summing
/// the whole buffer per picture (which would be O(pictures²)).
gop_bytes: usize,
/// Total field-display periods of all frames already emitted, in display
/// order — the running base for each new frame's display time.
emitted_fields: u64,
@@ -156,13 +148,11 @@ impl Mpeg2Parser {
pub fn new() -> Self {
Self {
seq_header: None,
buf: Vec::with_capacity(128 * 1024),
base_offset: 0,
pts_marks: VecDeque::new(),
source_marks: VecDeque::new(),
au_asm: crate::mux::au_assembly::AuAssembler::mpeg2(),
frame_duration_ns: 0,
progressive_sequence: false,
gop_buf: Vec::new(),
gop_bytes: 0,
emitted_fields: 0,
origin_pts_ns: None,
}
@@ -189,175 +179,98 @@ impl Mpeg2Parser {
parse_aspect_ratio(hdr)
}
/// Drain every complete access unit from `buf`, returning one Frame each.
/// When `force` is true (EOF flush, or buffer-cap backstop) the trailing
/// in-progress access unit is emitted even without a following boundary.
fn drain_complete_aus(&mut self, force: bool) -> Vec<Frame> {
let mut out = Vec::new();
loop {
// An access unit must contain a coded picture; without one there is
// nothing to emit yet (leading sequence/GOP headers wait for it).
let Some(pic) = find_code(&self.buf, 0, PICTURE_CODE) else {
// No coded picture in an over-cap buffer means we are
// accumulating unparseable data (a stream with no picture
// start codes). Drop all but a 3-byte tail — enough to catch a
// start-code prefix straddling the boundary — and advance the
// absolute offset so the PES-mark invariant holds. Mirrors the
// post-picture buffer backstop in the AU-boundary search below.
if self.buf.len() > MAX_AU_BUFFER {
let drop = self.buf.len() - 3;
self.base_offset += drop as u64;
self.buf.drain(..drop);
let cutoff = self.base_offset;
while let Some(&(off, _)) = self.pts_marks.front() {
if off < cutoff {
self.pts_marks.pop_front();
} else {
break;
}
}
while let Some(&(off, _)) = self.source_marks.front() {
if off < cutoff {
self.source_marks.pop_front();
} else {
break;
}
}
}
break;
};
// The current AU ends where the next one begins: the first
// picture / sequence / GOP start code after this picture.
let end = match find_au_start(&self.buf, pic + 4) {
Some(b) => b,
None if force => self.buf.len(),
None if self.buf.len() > MAX_AU_BUFFER => self.buf.len(),
None => break, // AU not yet complete — await the next boundary
};
if end == 0 {
break;
}
// Phase 1 — read everything from `buf` before any mutation of self
// (the slice borrow must end before we touch self fields).
let hdr = extract_seq_header(&self.buf[..end]);
// A GOP header (0xB8) or a fresh sequence header (0xB3) starts a new
// GOP, resetting temporal_reference to 0.
let gop_boundary = find_code(&self.buf[..end], 0, GOP_CODE).is_some()
|| find_code(&self.buf[..end], 0, SEQ_HEADER_CODE).is_some();
// picture_coding_type: the full 3-bit value (bits 5-3 of buf[pic+5]).
// 0 when the picture header is truncated (no coding type available).
let raw_coding_type = if pic + 5 < end {
(self.buf[pic + 5] >> 3) & 0x07
} else {
0
};
// temporal_reference: the 10 bits immediately after the picture
// start code = display order within the GOP.
let tr = if pic + 5 < end {
(((self.buf[pic + 4] as u64) << 2) | ((self.buf[pic + 5] as u64) >> 6)) & 0x3FF
} else {
0
};
let end_abs = self.base_offset + end as u64;
let data = self.buf[..end].to_vec();
// Phase 2 — mutate self.
if let Some(h) = hdr {
self.progressive_sequence = parse_progressive_sequence(&h);
self.seq_header = Some(h);
if let Some((num, den)) = self.frame_rate() {
if num > 0 {
self.frame_duration_ns = 1_000_000_000i64 * den as i64 / num as i64;
}
}
}
// Decode the picture coding extension ONCE here and fold every
// per-picture datum (coding type + tff/rff/progressive_frame/
// frame_picture, plus the sequence's progressive flag) into one
// codec-agnostic `PictureInfo`. `nb_fields()`, `keyframe()`, and
// `field_order()` all derive from it; nothing downstream re-parses
// the elementary stream.
let (tff, rff, progressive_frame, frame_picture) = picture_coding_flags(&data);
let info = PictureInfo::mpeg2(
coding_type_from_raw(raw_coding_type),
Mpeg2Coding {
top_field_first: tff,
repeat_first_field: rff,
progressive_frame,
progressive_sequence: self.progressive_sequence,
frame_picture,
},
);
let keyframe = info.keyframe();
// An explicit PES PTS for this access unit, if any. By the mark-drain
// invariant the front mark's offset is >= this AU's start, so a front
// mark inside [start, end) is this AU's own timestamp.
let explicit = self
.pts_marks
.front()
.filter(|&&(off, _)| off < end_abs)
.map(|&(_, p)| p);
// Byte-exact source provenance for this AU, by the same mark-drain
// invariant as the PTS: the front source mark inside [start, end)
// belongs to this access unit.
let src = self
.source_marks
.front()
.filter(|&&(off, _)| off < end_abs)
.map(|&(_, s)| s);
// A GOP boundary means the buffered run is a COMPLETE GOP (all its
// pictures display before the next GOP's), so flush it before
// starting the new one. `temporal_reference` resets to 0 at the
// boundary, keeping each GOP's display order self-contained.
if gop_boundary && !self.gop_buf.is_empty() {
self.flush_gop(&mut out);
}
self.gop_buf.push(BufferedPicture {
tr,
info,
explicit_pts: explicit,
frame: Frame {
pts_ns: 0,
keyframe,
data,
duration_ns: None,
coding: Some(info),
source: src,
},
});
// Safety cap: a stream with no GOP/sequence boundaries would buffer
// unbounded. Force-flush a pathologically long run as its own GOP.
if self.gop_buf.len() >= MAX_PENDING_FRAMES {
self.flush_gop(&mut out);
}
self.buf.drain(..end);
self.base_offset = end_abs;
// Drop PTS marks fully consumed by the emitted AU; keep the mark at
// the boundary (it belongs to the next AU).
while let Some(&(off, _)) = self.pts_marks.front() {
if off < end_abs {
self.pts_marks.pop_front();
} else {
break;
}
}
while let Some(&(off, _)) = self.source_marks.front() {
if off < end_abs {
self.source_marks.pop_front();
} else {
break;
/// Process one reassembled access unit (from [`AuAssembler`]): decode its
/// per-picture coding info, capture a new sequence header, and buffer the
/// picture into the current GOP for display-order timestamping. The AU's
/// timing / source / discontinuity were already attributed by the assembler.
fn process_au(&mut self, au: crate::mux::au_assembly::AssembledAu, out: &mut Vec<Frame>) {
let data = au.data;
// An access unit must contain a coded picture; a fragment that assembled
// without one (only headers, or truncated at EOF) yields nothing.
let Some(pic) = find_code(&data, 0, PICTURE_CODE) else {
return;
};
let end = data.len();
// Capture a sequence header for codecPrivate; a new one replaces the
// stored value and re-locks the frame duration.
if let Some(h) = extract_seq_header(&data) {
self.progressive_sequence = parse_progressive_sequence(&h);
self.seq_header = Some(h);
if let Some((num, den)) = self.frame_rate() {
if num > 0 {
self.frame_duration_ns = 1_000_000_000i64 * den as i64 / num as i64;
}
}
}
// EOF: emit the final (possibly incomplete) GOP so nothing is dropped.
if force {
self.flush_gop(&mut out);
// A GOP header (0xB8) or a fresh sequence header (0xB3) starts a new GOP,
// resetting temporal_reference to 0.
let gop_boundary = find_code(&data, 0, GOP_CODE).is_some()
|| find_code(&data, 0, SEQ_HEADER_CODE).is_some();
// picture_coding_type: the full 3-bit value (bits 5-3 of data[pic+5]).
// 0 when the picture header is truncated (no coding type available).
let raw_coding_type = if pic + 5 < end {
(data[pic + 5] >> 3) & 0x07
} else {
0
};
// temporal_reference: the 10 bits immediately after the picture start
// code = display order within the GOP.
let tr = if pic + 5 < end {
(((data[pic + 4] as u64) << 2) | ((data[pic + 5] as u64) >> 6)) & 0x3FF
} else {
0
};
// Decode the picture coding extension ONCE here and fold every
// per-picture datum (coding type + tff/rff/progressive_frame/
// frame_picture, plus the sequence's progressive flag) into one
// codec-agnostic `PictureInfo`. `nb_fields()`, `keyframe()`, and
// `field_order()` all derive from it; nothing downstream re-parses the
// elementary stream.
let (tff, rff, progressive_frame, frame_picture) = picture_coding_flags(&data);
let info = PictureInfo::mpeg2(
coding_type_from_raw(raw_coding_type),
Mpeg2Coding {
top_field_first: tff,
repeat_first_field: rff,
progressive_frame,
progressive_sequence: self.progressive_sequence,
frame_picture,
},
);
let keyframe = info.keyframe();
// A GOP boundary means the buffered run is a COMPLETE GOP (all its
// pictures display before the next GOP's), so flush it before starting
// the new one. `temporal_reference` resets to 0 at the boundary, keeping
// each GOP's display order self-contained.
if gop_boundary && !self.gop_buf.is_empty() {
self.flush_gop(out);
}
self.gop_bytes += data.len();
self.gop_buf.push(BufferedPicture {
tr,
info,
explicit_pts: au.pts,
frame: Frame {
pts_ns: 0,
keyframe,
// The assembler attributes the concealed-gap flag to the AU whose
// own bytes begin after the gap — the first post-gap picture — so
// it rides through GOP buffering/reorder to the ResyncGate.
discontinuity: au.discontinuity,
data,
duration_ns: None,
coding: Some(info),
source: au.source,
},
});
// Safety cap: a stream with no GOP/sequence boundaries would buffer
// unbounded. Force-flush a pathologically long run as its own GOP —
// bounded by BOTH the frame count and the total buffered bytes, so a
// crafted stream of few-but-huge pictures cannot over-allocate either.
if self.gop_buf.len() >= MAX_PENDING_FRAMES || self.gop_bytes >= MAX_PENDING_BYTES {
self.flush_gop(out);
}
out
}
/// Emit the buffered GOP. Each frame's PTS is the display-order prefix-sum of
@@ -373,6 +286,8 @@ impl Mpeg2Parser {
if n == 0 {
return;
}
// The GOP is fully drained below; reset the running byte counter.
self.gop_bytes = 0;
let field_period = self.frame_duration_ns / 2;
if field_period <= 0 {
// No sequence header / frame rate yet (malformed lead-in): emit in
@@ -418,25 +333,31 @@ impl CodecParser for Mpeg2Parser {
if pes.data.is_empty() {
return Vec::new();
}
// Record this PES's timestamp against the absolute offset of its first
// ES byte, BEFORE appending. MKV block timecodes are presentation
// timestamps; prefer PTS (DTS shows B-frames in decode order — judder
// and broken seeking), falling back to DTS only when PTS is absent.
let off = self.base_offset + self.buf.len() as u64;
if let Some(ts) = pes.pts.or(pes.dts) {
self.pts_marks.push_back((off, pts_to_ns(ts)));
// Feed the fragment to the assembler, which reframes the elementary
// stream on picture boundaries and hands back each complete access unit
// with its start timing. MKV block timecodes are presentation timestamps;
// prefer PTS (DTS shows B-frames in decode order — judder and broken
// seeking), falling back to DTS only when PTS is absent.
let pts = pes.pts.or(pes.dts).map(pts_to_ns);
let aus = self
.au_asm
.push(&pes.data, pts, None, pes.source, pes.discontinuity);
let mut out = Vec::new();
for au in aus {
self.process_au(au, &mut out);
}
if let Some(src) = pes.source {
self.source_marks.push_back((off, src));
}
self.buf.extend_from_slice(&pes.data);
self.drain_complete_aus(false)
out
}
fn flush(&mut self) -> Vec<Frame> {
// drain_complete_aus(true) force-completes the trailing access unit and
// flushes the final GOP, so nothing is left buffered at EOF.
self.drain_complete_aus(true)
// Force-complete the trailing access unit, then flush the final GOP so
// nothing is left buffered at EOF.
let mut out = Vec::new();
for au in self.au_asm.flush() {
self.process_au(au, &mut out);
}
self.flush_gop(&mut out);
out
}
fn codec_private(&self) -> Option<Vec<u8>> {
@@ -482,25 +403,6 @@ fn find_code(data: &[u8], from: usize, want: u8) -> Option<usize> {
None
}
/// Find the next access-unit boundary at or after `from`: the position of a
/// picture (0x00), sequence header (0xB3), or GOP (0xB8) start code. Extension
/// (0xB5), slice (0x01..=0xAF), user-data (0xB2) and sequence-end (0xB7) codes
/// belong to the current access unit and are NOT boundaries.
fn find_au_start(data: &[u8], from: usize) -> Option<usize> {
let mut pos = from;
while let Some(sc) = find_start_code(data, pos) {
if sc + 3 >= data.len() {
return None;
}
let code = data[sc + 3];
if code == PICTURE_CODE || code == SEQ_HEADER_CODE || code == GOP_CODE {
return Some(sc);
}
pos = sc + 4;
}
None
}
/// Parse horizontal and vertical resolution from sequence header bytes.
/// The sequence header must start with 00 00 01 B3.
fn parse_resolution(hdr: &[u8]) -> Option<(u16, u16)> {
@@ -707,6 +609,7 @@ mod tests {
pts: None,
dts: None,
data,
discontinuity: false,
};
let mut p = Mpeg2Parser::new();
let mut frames = Vec::new();
@@ -822,6 +725,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
@@ -995,6 +899,48 @@ mod tests {
assert!(!frames[1].keyframe);
}
/// B1 hole-2 regression: MPEG-2 buffers a GOP and emits asynchronously, so a
/// concealed gap must be associated by OFFSET (like PTS), landing on the
/// picture whose own bytes begin after the gap — NOT the previous picture
/// that completes when the discontinuity PES arrives. pic1 (I) is pre-gap;
/// pic2 (P), carried by a `discontinuity` PES, is the first post-gap AU.
#[test]
fn discontinuity_offset_mark_stamps_post_gap_picture_not_previous() {
let mut parser = Mpeg2Parser::new();
let mut pic1 = make_picture_header(PICTURE_TYPE_I);
pic1.extend_from_slice(&[0x11; 100]);
let mut pic2 = make_picture_header(2); // P
pic2.extend_from_slice(&[0x22; 100]);
// pic1 on a clean PES; nothing emits (same GOP, buffered).
assert!(parser.parse(&make_pes(pic1.clone(), Some(0))).is_empty());
// pic2 on a PES flagged discontinuity (a concealed gap preceded it).
// parse() of this PES completes pic1's AU (the PREVIOUS picture) — which
// must stay clean — while pic2 keeps buffering.
let pes2 = PesPacket {
source: None,
pid: 0x1011,
pts: Some(90000),
dts: None,
data: pic2.clone(),
discontinuity: true,
};
assert!(parser.parse(&pes2).is_empty(), "same GOP — still buffered");
let frames = parser.flush();
assert_eq!(frames.len(), 2);
assert_eq!(frames[0].data, pic1);
assert!(
!frames[0].discontinuity,
"the previous (pre-gap) I picture must NOT be flagged"
);
assert_eq!(frames[1].data, pic2);
assert!(
frames[1].discontinuity,
"the post-gap P picture (the discontinuity PES's own AU) IS flagged"
);
}
#[test]
fn picture_coding_extension_stays_with_its_picture() {
// Regression for `ignoring pic cod ext after 0`: the picture coding
@@ -1415,6 +1361,7 @@ mod tests {
pts: None,
dts: Some(90000),
data,
discontinuity: false,
};
let f = parse_then_flush(&mut parser, &pes);
assert_eq!(f[0].pts_ns, 1_000_000_000, "DTS fallback");
@@ -1428,6 +1375,7 @@ mod tests {
pts: None,
dts: None,
data: data2,
discontinuity: false,
};
let f2 = parse_then_flush(&mut parser2, &pes2);
assert_eq!(f2[0].pts_ns, 0, "no PTS/DTS → 0");
@@ -1464,13 +1412,12 @@ mod tests {
let mut data = make_picture_header(PICTURE_TYPE_I);
// > MAX_AU_BUFFER of slice bytes with no following picture/seq/GOP.
data.extend(std::iter::repeat_n(0xAA, MAX_AU_BUFFER + 1024));
let frames = parser.parse(&make_pes(data, Some(0)));
assert!(
frames.is_empty(),
"over-cap AU is force-COMPLETED (bounded) but buffered in its GOP"
);
let frames = parser.flush();
assert_eq!(frames.len(), 1, "force-flushed at EOF, not dropped");
// The AU assembler force-completes the ~8 MiB AU (no boundary), and the
// GOP byte cap (MAX_PENDING_BYTES) then force-flushes that oversized GOP
// during parse rather than buffering it unbounded.
let mut frames = parser.parse(&make_pes(data, Some(0)));
frames.extend(parser.flush());
assert_eq!(frames.len(), 1, "over-cap AU force-flushed, not dropped");
assert!(frames[0].keyframe);
}
+7
View File
@@ -57,6 +57,7 @@ impl PgsParser {
let (start_pts, data) = self.pending.take()?;
let duration = end_pts_ns.saturating_sub(start_pts).max(0) as u64;
Some(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: start_pts,
@@ -92,6 +93,7 @@ impl CodecParser for PgsParser {
.take()
.map(|(start_pts, data)| {
vec![Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: start_pts,
@@ -119,6 +121,7 @@ impl CodecParser for PgsParser {
let frame = match pts {
Some(end) => self.emit_pending(end),
None => self.pending.take().map(|(start_pts, data)| Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: start_pts,
@@ -142,6 +145,7 @@ impl CodecParser for PgsParser {
// Flush any prior pending undurated and skip storing this one.
None => {
out.extend(self.pending.take().map(|(start_pts, data)| Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: start_pts,
@@ -167,6 +171,7 @@ impl CodecParser for PgsParser {
// (A missing PTS falls through to the drop path below: a
// bitmap with no timing reference would land at 00:00:00.)
out.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: pts.unwrap_or(0),
@@ -195,6 +200,7 @@ impl CodecParser for PgsParser {
// the final on-screen subtitle (see the module doc).
match self.pending.take() {
Some((start_pts, data)) => vec![Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: start_pts,
@@ -223,6 +229,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
+389
View File
@@ -0,0 +1,389 @@
//! Display-order PTS reconstruction for sparse-PTS program-stream video.
//!
//! MPEG program streams (DVD VOB, HD-DVD EVO) timestamp video at GOP
//! granularity: only one access unit per GOP carries a PES PTS, and the rest
//! arrive with none. The H.264 / HEVC / VC-1 parsers collapse a missing PTS to
//! `0` (`pes.pts.or(dts).unwrap_or(0)`), so on such a source every non-anchor
//! frame lands on the same block timestamp. A decoder then cannot order them and
//! reports "non monotonically increasing dts". (The MPEG-2 parser already avoids
//! this by reconstructing per-picture PTS from `temporal_reference`; these three
//! codecs carry no such field.)
//!
//! [`SparsePtsReorder`] reconstructs a display-order PTS for every frame from two
//! signals the parsers already provide — the coded picture type (I/P/B) and the
//! sparse anchor PTS — plus a per-frame duration self-calibrated from the spacing
//! between consecutive GOP anchors (no external frame-rate needed). It mirrors
//! the MPEG-2 parser's GOP-buffered origin-locking, but derives display order via
//! the classic single-anchor-delay rule instead of `temporal_reference`:
//!
//! - In DECODE order an anchor (I/P) is stored before the B-frames that
//! reference it forward, so decode `I P B P B` displays as `I B P B P`.
//! - The rule that produces that mapping: an anchor is displayed only after the
//! previously-held anchor; a B-frame displays immediately. This is exact for
//! the classic (non-hierarchical) GOP structures HD-DVD H.264/VC-1 use.
//!
//! This reconstruction is applied ONLY on the program-stream path
//! (`ContentFormat::MpegPs`). BD/UHD transport streams carry a per-frame PTS and
//! are never routed through it, so the primary decode path is untouched.
use super::Frame;
use super::coding::CodingType;
/// Fallback per-frame duration (ns) when the anchor spacing cannot calibrate one
/// (a stream with a single GOP, or no anchor PTS at all): 24000/1001 fps film,
/// the dominant HD-DVD cadence. Only affects intra-GOP spacing — each GOP's
/// origin is re-locked to its own anchor PTS, so a wrong fallback cannot drift
/// the timeline across GOPs.
const FALLBACK_FRAME_DUR_NS: i64 = 1_001_000_000 / 24;
/// Force-complete the current GOP once it reaches this many buffered pictures
/// even without a keyframe. A GOP is normally a few dozen frames; a stream that
/// never signals a keyframe (open-GOP recovery-point coding, or crafted/corrupt
/// disc bytes) would otherwise buffer every access unit — the whole title — in
/// RAM. Mirrors the MPEG-2 parser's `MAX_PENDING_FRAMES` backstop so no
/// reassembly buffer grows unbounded on disc-controlled input.
const MAX_GOP_FRAMES: usize = 600;
/// Byte cap on the buffered GOP, complementing [`MAX_GOP_FRAMES`]. A GOP holds a
/// couple hundred MB at most in practice; this force-completes a run of
/// few-but-huge access units so a crafted/corrupt stream cannot over-allocate
/// (the AU assembler caps each frame at 8 MiB, so 600 frames alone could reach
/// ~5 GiB without this).
const MAX_GOP_BYTES: usize = 64 * 1024 * 1024;
/// One buffered coded picture awaiting its GOP's completion.
struct Pending {
/// Explicit PES PTS (ns) for this AU, or `None` when the source omitted it.
explicit: Option<i64>,
/// Coded picture type; `P` (anchor) when the parser could not determine it,
/// so an unknown frame is never mis-placed as a bi-predicted B.
ctype: CodingType,
frame: Frame,
}
/// A completed GOP, buffered until the NEXT GOP's anchor is known so a per-frame
/// duration can be calibrated from the two anchors before its frames are emitted.
struct Gop {
pend: Vec<Pending>,
/// Display index (0-based) of each `pend` entry, in `pend` (decode) order.
dispidx: Vec<i64>,
/// Display-frame count (== `pend.len()`).
count: i64,
/// The anchor: `(explicit_pts, dispidx)` of the first buffered frame that
/// carried an explicit PTS, used to lock the display origin. `None` when the
/// GOP carried no PTS at all (origin then continues from the running base).
anchor: Option<(i64, i64)>,
}
/// Reconstructs display-order PTS for a sparse-PTS video elementary stream.
pub(crate) struct SparsePtsReorder {
/// Frames of the GOP currently accumulating, in decode order.
cur: Vec<Pending>,
/// Total `data` bytes buffered in `cur` — the byte-cap counter, reset each
/// time `cur` is drained into a completed GOP.
cur_bytes: usize,
/// The previously-completed GOP, held one step so its duration can be
/// calibrated from the next GOP's anchor before it is emitted.
held: Option<Gop>,
/// Self-calibrated per-frame display duration (ns); 0 until two anchors seen.
dur_ns: i64,
/// Display time (ns) at which the next emitted GOP should begin, when its own
/// anchor is absent. Advanced by each emitted GOP.
next_start_ns: i64,
}
impl SparsePtsReorder {
pub(crate) fn new() -> Self {
Self {
cur: Vec::new(),
cur_bytes: 0,
held: None,
dur_ns: 0,
next_start_ns: 0,
}
}
/// Feed one parsed frame with its explicit PES PTS (or `None`). Returns any
/// frames whose display PTS is now finalized (emitted in decode order).
pub(crate) fn push(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
let ctype = frame
.coding
.map(|c| c.coding_type())
.unwrap_or(CodingType::P);
// A keyframe opens a new GOP: the picture already accumulated in `cur` is
// a complete GOP. Complete it (this frame belongs to the NEW GOP). Also
// force-complete a pathologically long run that never signalled a
// keyframe — bounded by BOTH frame count and total buffered bytes, so a
// crafted/corrupt stream of few-but-huge access units cannot buffer
// without bound.
let mut out = Vec::new();
let over_cap = self.cur.len() >= MAX_GOP_FRAMES || self.cur_bytes >= MAX_GOP_BYTES;
if (frame.keyframe || over_cap) && !self.cur.is_empty() {
out = self.complete_current_gop();
}
self.cur_bytes += frame.data.len();
self.cur.push(Pending {
explicit,
ctype,
frame,
});
out
}
/// Flush all buffered frames at end of stream.
pub(crate) fn flush(&mut self) -> Vec<Frame> {
let mut out = self.complete_current_gop();
if let Some(gop) = self.held.take() {
out.extend(self.emit_gop(gop));
}
out
}
/// Move `cur` into a completed [`Gop`]; if a GOP was already held, calibrate
/// the duration from the two anchors and emit the held one.
fn complete_current_gop(&mut self) -> Vec<Frame> {
if self.cur.is_empty() {
return Vec::new();
}
let pend = std::mem::take(&mut self.cur);
self.cur_bytes = 0;
let dispidx = display_indices(pend.iter().map(|p| p.ctype));
let count = pend.len() as i64;
let anchor = pend
.iter()
.zip(&dispidx)
.find_map(|(p, &d)| p.explicit.map(|pts| (pts, d)));
let gop = Gop {
pend,
dispidx,
count,
anchor,
};
let mut out = Vec::new();
match self.held.take() {
Some(held) => {
// Calibrate a per-frame duration from the two anchors' spacing,
// spread across the held GOP's display-frame count. Approximate
// (assumes both anchors sit at a similar relative display slot),
// but each GOP re-locks its own origin, so the estimate only sets
// intra-GOP spacing.
if self.dur_ns == 0 {
if let (Some((p_held, _)), Some((p_next, _))) = (held.anchor, gop.anchor) {
let span = p_next - p_held;
if span > 0 && held.count > 0 {
self.dur_ns = (span / held.count).max(1);
}
}
}
out = self.emit_gop(held);
self.held = Some(gop);
}
None => self.held = Some(gop),
}
out
}
/// Assign each frame in `gop` its display PTS and return them in decode order.
fn emit_gop(&mut self, gop: Gop) -> Vec<Frame> {
let dur = if self.dur_ns > 0 {
self.dur_ns
} else {
FALLBACK_FRAME_DUR_NS
};
// Lock the display origin: prefer the GOP's own anchor PTS (back out its
// display offset); otherwise continue from the running base.
let origin = match gop.anchor {
Some((pts, didx)) => pts - didx * dur,
None => self.next_start_ns,
};
let Gop {
pend,
dispidx,
count,
..
} = gop;
let mut out = Vec::with_capacity(pend.len());
for (mut p, didx) in pend.into_iter().zip(dispidx) {
p.frame.pts_ns = origin + didx * dur;
// Carry the calibrated per-frame duration so the muxer emits a
// BlockDuration and the back-patched Segment Duration covers the
// final frame (the source gives no duration on this path).
p.frame.duration_ns = Some(dur as u64);
out.push(p.frame);
}
// Next GOP with no anchor continues after this one's last display slot.
self.next_start_ns = origin + count * dur;
out
}
}
/// Display index (0-based, decode order in → decode order out) for a GOP's coded
/// picture types via the classic single-anchor-delay reorder: an anchor (I/P) is
/// displayed only after the previously-held anchor; a B displays immediately.
/// Decode `I P B P B` → display indices `[0, 2, 1, 4, 3]` (display `I B P B P`).
fn display_indices(types: impl Iterator<Item = CodingType>) -> Vec<i64> {
let types: Vec<CodingType> = types.collect();
let mut disp = vec![0i64; types.len()];
let mut held: Option<usize> = None;
let mut cursor = 0i64;
for (i, &c) in types.iter().enumerate() {
match c {
CodingType::I | CodingType::P => {
if let Some(h) = held {
disp[h] = cursor;
cursor += 1;
}
held = Some(i);
}
CodingType::B => {
disp[i] = cursor;
cursor += 1;
}
}
}
if let Some(h) = held {
disp[h] = cursor;
}
disp
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::codec::coding::PictureInfo;
fn frame(ctype: CodingType, keyframe: bool) -> Frame {
Frame {
keyframe,
coding: Some(PictureInfo::coding_type_only(ctype)),
..Default::default()
}
}
#[test]
fn display_indices_map_classic_gop() {
use CodingType::*;
// decode I P B P B -> display I B P B P
let d = display_indices([I, P, B, P, B].into_iter());
assert_eq!(d, vec![0, 2, 1, 4, 3]);
}
#[test]
fn display_indices_all_anchors_are_identity() {
use CodingType::*;
let d = display_indices([I, P, P, P].into_iter());
assert_eq!(d, vec![0, 1, 2, 3]);
}
#[test]
fn reconstructs_monotonic_display_pts_from_one_anchor_per_gop() {
use CodingType::*;
// Two GOPs of 5 frames, decode order I P B P B, anchor PTS only on the
// GOP's I (0 ns, then ~5-frames-later). Frame duration should calibrate
// to the spacing/5 and every frame get a distinct increasing display PTS.
let dur = 41_708_333i64;
let mut r = SparsePtsReorder::new();
let mut got: Vec<i64> = Vec::new();
// GOP 1: anchor on the I at t=0.
for (k, (ct, pts)) in [(I, Some(0i64)), (P, None), (B, None), (P, None), (B, None)]
.into_iter()
.enumerate()
{
let out = r.push(pts, frame(ct, k == 0));
got.extend(out.iter().map(|f| f.pts_ns));
}
// GOP 2: anchor on the I at t = 5*dur (its true display time).
for (k, (ct, pts)) in [
(I, Some(5 * dur)),
(P, None),
(B, None),
(P, None),
(B, None),
]
.into_iter()
.enumerate()
{
let out = r.push(pts, frame(ct, k == 0));
got.extend(out.iter().map(|f| f.pts_ns));
}
got.extend(r.flush().iter().map(|f| f.pts_ns));
// Ten frames out, none dropped.
assert_eq!(got.len(), 10, "all frames emitted");
// The calibrated duration is (5*dur)/5 = dur.
// GOP 1 decode order I P B P B -> display indices 0 2 1 4 3 -> PTS:
assert_eq!(
&got[0..5],
&[0, 2 * dur, dur, 4 * dur, 3 * dur],
"GOP1 display PTS in decode order"
);
// GOP 2 re-locks origin to 5*dur.
assert_eq!(
&got[5..10],
&[5 * dur, 7 * dur, 6 * dur, 9 * dur, 8 * dur],
"GOP2 display PTS continue monotonically per display order"
);
}
#[test]
fn force_flushes_a_gop_that_exceeds_the_byte_cap() {
use CodingType::*;
// Few-but-huge access units with no keyframe must not accumulate past the
// byte cap: a handful of ~MAX_GOP_BYTES/4-sized frames force-completes the
// GOP well before the frame-count cap, bounding memory.
let big = MAX_GOP_BYTES / 4 + 1;
let mut r = SparsePtsReorder::new();
let mut emitted = 0usize;
// Enough huge frames to trigger several byte-cap completions (a GOP is
// held one step for duration calibration, so the first emit lands after
// the second cap fires) — well under the 600-frame count cap.
for i in 0..16 {
let mut f = frame(P, false);
f.data = vec![0u8; big];
emitted += r.push((i == 0).then_some(0), f).len();
}
assert!(
emitted >= 1,
"byte cap force-flushed (emitted {emitted}) before the frame-count cap"
);
}
#[test]
fn force_flushes_a_gop_that_never_signals_a_keyframe() {
use CodingType::*;
// A stream that never flags a keyframe (open-GOP recovery points, or a
// crafted/corrupt disc) must not buffer the whole title: the cap
// force-completes GOPs so frames are emitted well before flush().
let mut r = SparsePtsReorder::new();
let mut emitted = 0usize;
for i in 0..(MAX_GOP_FRAMES * 3) {
let pts = (i == 0).then_some(0);
emitted += r.push(pts, frame(P, false)).len();
}
assert!(
emitted >= MAX_GOP_FRAMES,
"cap force-flushed GOPs before EOF (emitted {emitted})"
);
}
#[test]
fn no_pts_collisions_within_a_gop() {
use CodingType::*;
// Every frame distinct in DISPLAY order — the property the mkv muxer
// needs so a decoder can derive monotonic DTS.
let mut r = SparsePtsReorder::new();
let mut all: Vec<i64> = Vec::new();
for gop in 0..3 {
for (k, ct) in [I, P, B, P, B].into_iter().enumerate() {
let pts = (k == 0).then_some(gop as i64 * 5 * 41_708_333);
all.extend(r.push(pts, frame(ct, k == 0)).iter().map(|f| f.pts_ns));
}
}
all.extend(r.flush().iter().map(|f| f.pts_ns));
let mut sorted = all.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), all.len(), "no two frames share a display PTS");
}
}
+59
View File
@@ -126,6 +126,19 @@ enum Ac3Size {
impl CodecParser for TrueHdParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
// B1: a concealed/lost gap means the buffered TrueHD AU is TRUNCATED.
// Splicing post-gap bytes onto it corrupts the AU framing (→ "Invalid
// data found") and strands the PTS cadence (the non-monotonic audio-DTS
// band at gaps). Drop the partial; with `buf` now empty the PTS-base block
// below re-seeds the cadence from the post-gap PES, monotonic across the
// gap. (Audio has no inter-frame refs — this is the whole audio fix.)
//
// Handle the discontinuity BEFORE the empty-data guard so the signal can
// never be stranded by an empty post-gap PES (defensive; the demuxer only
// emits non-empty PES today).
if pes.discontinuity {
self.buf.clear();
}
if pes.data.is_empty() {
return Vec::new();
}
@@ -255,6 +268,7 @@ impl CodecParser for TrueHdParser {
}
frames.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: self.next_pts_ns,
@@ -425,6 +439,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
@@ -477,6 +492,50 @@ mod tests {
assert_eq!(frames[0].data.len(), 200);
}
#[test]
fn discontinuity_drops_truncated_partial() {
// B1: a partial TrueHD unit is buffered, then a concealed gap (PES marked
// discontinuity) carries a fresh unit. The truncated partial must be
// DROPPED — splicing it makes the length-prefixed framer emit a
// wrong-size unit (corrupt AU framing) and re-seeds the PTS cadence from
// the post-gap PES rather than stranding it (the non-monotonic audio-DTS
// band at gaps).
let mut parser = TrueHdParser::new();
// PES 1: first 150 bytes of a 300-byte unit (length prefix says 300, only
// 150 present) → held, nothing emitted.
let partial = make_truehd_unit(300);
let pes1 = make_pes(partial[..150].to_vec(), Some(90000));
assert!(parser.parse(&pes1).is_empty(), "partial unit held");
// Concealed gap: a fresh 200-byte unit at a forward PTS jump.
let fresh = make_truehd_unit(200);
let pes2 = PesPacket {
source: None,
pid: 0x1100,
pts: Some(180000),
dts: None,
data: fresh.clone(),
discontinuity: true,
};
let frames = parser.parse(&pes2);
assert_eq!(frames.len(), 1, "exactly one clean unit across the gap");
assert_eq!(
frames[0].data.len(),
200,
"emitted unit is the fresh 200-byte one, not a 300-byte splice"
);
assert_eq!(
frames[0].data, fresh,
"unit bytes are the fresh post-gap unit"
);
assert_eq!(
frames[0].pts_ns,
pts_to_ns(180000),
"cadence re-bases to the post-gap PTS across the cleared buffer"
);
}
#[test]
fn parse_multiple_units_incrementing_pts() {
let mut parser = TrueHdParser::new();
+42 -3
View File
@@ -103,6 +103,10 @@ pub struct Vc1Parser {
cur_entry_point: Option<Vec<u8>>,
width: u32,
height: u32,
/// Display-order PTS reconstruction, enabled only on the program-stream
/// (HD-DVD EVO) path where the source stamps a PTS once per GOP. `None` on
/// the BD/UHD transport path, which carries a per-frame PTS.
reorder: Option<super::reorder::SparsePtsReorder>,
}
impl Default for Vc1Parser {
@@ -120,6 +124,25 @@ impl Vc1Parser {
cur_entry_point: None,
width: 1920,
height: 1080,
reorder: None,
}
}
/// Enable display-order PTS reconstruction for a program-stream source.
/// No-op (leaves timestamps as parsed) for a transport-stream source.
pub(crate) fn with_ps_reorder(mut self, enabled: bool) -> Self {
if enabled {
self.reorder = Some(super::reorder::SparsePtsReorder::new());
}
self
}
/// Route a finished frame through the PTS reorderer when enabled, else emit
/// it directly (unchanged transport-stream behaviour).
fn finish(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
match self.reorder.as_mut() {
Some(r) => r.push(explicit, frame),
None => vec![frame],
}
}
}
@@ -169,7 +192,8 @@ impl CodecParser for Vc1Parser {
// decode order and the player reorders by timecode. Use PTS, not DTS —
// DTS presents B-frames in decode order (visible judder) and breaks
// PTS-based seeking. Fall back to DTS only if PTS is absent.
let ts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
let explicit_pts = pes.pts.or(pes.dts).map(pts_to_ns);
let ts_ns = explicit_pts.unwrap_or(0);
let mut has_seq_header = false;
let mut has_entry_point = false;
let mut frame_start: Option<usize> = None;
@@ -321,16 +345,27 @@ impl CodecParser for Vc1Parser {
vc1_frame_coding_type(data.get(fs + 4..)?, self.cur_seq_header.as_deref())
});
vec![Frame {
let frame = Frame {
// Coding-type only: VC-1 field order is not decoded here, so
// field_order() stays None — honestly absent, never guessed.
coding: coding_type.map(PictureInfo::coding_type_only),
source: pes.source,
pts_ns: ts_ns,
keyframe,
// One frame per PES (BD-TS aligns frames to PES), so the gap signal
// maps straight onto this frame.
discontinuity: pes.discontinuity,
data: frame_data,
duration_ns: None,
}]
};
self.finish(explicit_pts, frame)
}
fn flush(&mut self) -> Vec<Frame> {
match self.reorder.as_mut() {
Some(r) => r.flush(),
None => Vec::new(),
}
}
fn codec_private(&self) -> Option<Vec<u8>> {
@@ -451,6 +486,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
@@ -757,6 +793,7 @@ mod tests {
pts: Some(180000), // presentation
dts: Some(90000), // decode
data,
discontinuity: false,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
@@ -1007,6 +1044,7 @@ mod tests {
pts: None,
dts: Some(90000),
data: vec![0x00, 0x00, 0x01, SC_FRAME, 0x55],
discontinuity: false,
};
let f = parser.parse(&pes);
assert_eq!(f[0].pts_ns, 1_000_000_000, "DTS fallback");
@@ -1018,6 +1056,7 @@ mod tests {
pts: None,
dts: None,
data: vec![0x00, 0x00, 0x01, SC_FRAME, 0x55],
discontinuity: false,
};
let f2 = parser2.parse(&pes2);
assert_eq!(f2[0].pts_ns, 0, "no PTS/DTS → 0");
+19 -3
View File
@@ -204,6 +204,14 @@ impl DemuxThread {
}
} else {
let _ = recycle_tx.send(buf);
// No demuxer (a BdTs title with zero streams): still send
// an empty batch so an early consumer disconnect is
// detected here too, exactly like the ts/ps branches above.
// Without it this worker reads the whole disc even after
// the consumer has dropped.
if tx.send(DemuxBatch::Ts(Vec::new())).is_err() {
return;
}
}
}
// Flush tail packets at EOF.
@@ -479,7 +487,7 @@ mod tests {
#[test]
fn no_demuxer_configured_still_recycles_and_eofs() {
// With neither ts nor ps set, the worker must still recycle buffers
// and terminate with Eof — never emit a spurious Ts/Ps batch.
// and terminate with Eof — also forward an empty batch per buffer for disconnect detection.
let (pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(4);
let (rc_tx, rc_rx) = bounded::<Vec<u8>>(4);
let (_dt, rx) = DemuxThread::spawn_zero_copy(pf_rx, rc_tx, (), None, None, None).unwrap();
@@ -492,8 +500,16 @@ mod tests {
drop(pf_tx);
let batches = collect_batches(&rx, Duration::from_secs(5));
assert_eq!(batches.len(), 1, "only the Eof sentinel");
assert!(matches!(batches[0], DemuxBatch::Eof));
// The no-demuxer branch now forwards an empty Ts batch per buffer for
// early consumer-disconnect detection (same rationale as the ts/ps
// branches), then the Eof sentinel.
assert_eq!(
batches.len(),
2,
"empty Ts disconnect-probe batch, then Eof"
);
assert!(matches!(batches[0], DemuxBatch::Ts(ref v) if v.is_empty()));
assert!(matches!(batches[1], DemuxBatch::Eof));
}
#[test]
+113 -13
View File
@@ -195,6 +195,16 @@ pub struct DiscStream {
/// timestamp cost or `prof_active()` env-var lookup (which takes a
/// process-wide lock).
profiling: bool,
/// B1 drop-to-keyframe resync gate, one per stream — mirrors
/// `PipelinedPesStream`. After an AACS-concealed (undecryptable) gap stamps
/// a frame `discontinuity`, the gate drops forward inter-coded video frames
/// until the next keyframe so no dangling-reference frame reaches the muxer.
/// Without this the live-drive single-pass path emitted decode-broken output
/// on an undecryptable unit (the file-backed highway path is already gated).
resync: Vec<super::resync::ResyncGate>,
/// Whether each stream (by track index) is video — audio/subtitle frames are
/// independent and always admit.
is_video: Vec<bool>,
}
impl DiscStream {
@@ -227,7 +237,12 @@ impl DiscStream {
// CSS/unencrypted content needs a decrypting wrapper to yield plaintext
// VOB bytes before the AC-3 sub-stream probe can read real `acmod`s.
let mut reader = DecryptingSectorSource::new(reader, decrypt_keys.clone());
// MUX path: tolerate decrypt loss — conceal an undecryptable unit (NULL TS
// fill) + tally + log rather than abort the stream (P3). DiscStream is a
// decode/mux stream (live-drive single-pass / direct), never the
// ciphertext-preserving sweep, so concealment is always correct here.
let mut reader =
DecryptingSectorSource::new(reader, decrypt_keys.clone()).tolerate_decrypt_loss();
// Wrong-substream fix (Silence-of-the-Lambs): re-route the title's
// declared AC-3 audio onto the physically-correct `0x8x` sub-streams by
@@ -282,6 +297,18 @@ impl DiscStream {
// clones an Arc per frame on the mux hot path.
let decrypt_loss = reader.decrypt_loss();
// B1 resync gates: one per stream, video flagged so the gate only
// drop-to-keyframes video (audio/subtitle always admit). Computed before
// `title` is moved into the struct below.
let is_video: Vec<bool> = title
.streams
.iter()
.map(|s| matches!(s, crate::disc::Stream::Video(_)))
.collect();
let resync = (0..title.streams.len())
.map(|_| super::resync::ResyncGate::new())
.collect();
Self {
reader,
decrypt_loss,
@@ -310,6 +337,8 @@ impl DiscStream {
pid_to_track,
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
profiling: std::env::var_os("FREEMKV_PROFILE").is_some(),
resync,
is_video,
}
}
@@ -668,10 +697,25 @@ impl crate::pes::Stream for DiscStream {
if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
let resync = &mut self.resync;
let is_video = &self.is_video;
let pending = &mut self.pending_frames;
for frame in parser.parse(pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(*track, frame),
);
// Same B1 gate — a concealed gap can leave a
// post-gap frame in the demuxer's final flush.
let emit = match resync.get_mut(*track) {
Some(gate) => gate.admit(
is_video.get(*track).copied().unwrap_or(false),
frame.discontinuity,
frame.keyframe,
),
None => true,
};
if emit {
pending.push_back(crate::pes::PesFrame::from_codec_frame(
*track, frame,
));
}
}
}
}
@@ -718,6 +762,8 @@ impl crate::pes::Stream for DiscStream {
pts: ps.pts.map(|p| p as i64),
dts: ps.dts.map(|d| d as i64),
data: ps.data.clone(),
// PS (DVD/CSS) path: no AACS conceal, no gap flag.
discontinuity: false,
};
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid)
{
@@ -734,12 +780,42 @@ impl crate::pes::Stream for DiscStream {
// PES boundaries).
let pid_to_track = &self.pid_to_track;
let pending = &mut self.pending_frames;
let resync = &mut self.resync;
let is_video = &self.is_video;
for (pid, parser) in self.parsers.iter_mut() {
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
continue;
};
// Flush frames carry their own per-frame `discontinuity` (a
// post-gap picture buffered at EOF was stamped by the parser),
// so route them through the SAME B1 gate the in-stream path
// uses — otherwise a trailing dangling-reference frame would
// bypass the resync.
for frame in parser.flush() {
pending.push_back(crate::pes::PesFrame::from_codec_frame(track, frame));
let emit = match resync.get_mut(track) {
Some(gate) => gate.admit(
is_video.get(track).copied().unwrap_or(false),
frame.discontinuity,
frame.keyframe,
),
None => true,
};
if emit {
pending.push_back(crate::pes::PesFrame::from_codec_frame(track, frame));
}
}
}
// A gate still armed at EOF dropped post-gap frames that never
// reached a keyframe (a concealed gap in the final GOP). Surface
// it once so the loss is visible, not silent.
for (track, gate) in self.resync.iter().enumerate() {
if gate.is_armed() {
tracing::warn!(
target: "mux",
track,
dropped = gate.dropped_in_run(),
"B1: stream ended while dropping to a keyframe after a concealed gap (no trailing keyframe)"
);
}
}
return Ok(self.pending_frames.pop_front());
@@ -787,10 +863,28 @@ impl crate::pes::Stream for DiscStream {
} else if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
// Disjoint field borrows: the resync gate + is_video
// reads coexist with the mutable `self.parsers` drain.
let resync = &mut self.resync;
let is_video = &self.is_video;
let pending = &mut self.pending_frames;
for frame in parser.parse(&pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(track, frame),
);
// B1: after a concealed/lost gap, drop forward to
// the next keyframe on a video track so no frame
// with a dangling reference is emitted.
let emit = match resync.get_mut(track) {
Some(gate) => gate.admit(
is_video.get(track).copied().unwrap_or(false),
frame.discontinuity,
frame.keyframe,
),
None => true,
};
if emit {
pending.push_back(crate::pes::PesFrame::from_codec_frame(
track, frame,
));
}
}
}
}
@@ -841,6 +935,8 @@ impl crate::pes::Stream for DiscStream {
pts: ps.pts.map(|p| p as i64),
dts: ps.dts.map(|d| d as i64),
data: ps.data.clone(),
// PS (DVD/CSS) path: no AACS conceal, no gap flag.
discontinuity: false,
};
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
@@ -1424,11 +1520,15 @@ mod tests {
"read at lba {lba} is not unit-aligned (offset {} % {ALIGN} != 0)",
lba - ext_start
);
// Non-tail reads must be a whole number of units; the only
// permitted short read is the final partial unit (here COUNT is a
// multiple of ALIGN, so every read should be unit-multiple unless
// it shrank below one unit — which is itself a single unit).
let _ = count;
// Non-tail reads must be a whole number of units; the only permitted
// short read is a final partial unit (below one unit). Assert it
// rather than documenting it — a mid-stream non-unit-multiple read
// would straddle AACS unit boundaries and decrypt under the wrong
// alignment.
assert!(
count as u32 % ALIGN == 0 || (count as u32) < ALIGN,
"read count {count} is neither a whole number of units nor a sub-unit tail"
);
}
// At least one error was skipped (the bad unit) and a SectorSkipped
+105 -2
View File
@@ -557,6 +557,14 @@ pub struct MkvMuxer<W: Write + Seek> {
track_uids: Vec<u64>,
/// Segment duration in seconds (from `Info`), for the BPS denominator.
duration_secs: f64,
/// Byte offset of the DURATION element's 8-byte payload when it was written
/// as a patch-later placeholder — the source supplied no duration (e.g.
/// HD-DVD, whose `.MAP` timemaps are not parsed). `None` when a real duration
/// was written up-front. Back-patched at `finish()` from the muxed timeline.
duration_patch_pos: Option<u64>,
/// Highest block timestamp (TimestampScale ticks) written across all tracks —
/// the muxed runtime, used to back-patch the DURATION placeholder.
max_block_ticks: i64,
/// Per-AC-3-audio-track channel-correction state. The DVD IFO audio nibble
/// is unreliable, so the channel count written in the track header is
/// corrected from the AC-3 bitstream `acmod` of the first frame on the
@@ -766,11 +774,21 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::TIMESTAMP_SCALE,
TIMESTAMP_SCALE_NS as u64,
)?;
if duration_secs > 0.0 {
let duration_patch_pos = if duration_secs > 0.0 {
// Duration is expressed in TimestampScale ticks (not ms).
let duration_ticks = duration_secs * 1_000_000_000.0 / TIMESTAMP_SCALE_NS as f64;
ebml::write_float(&mut writer, ebml::DURATION, duration_ticks)?;
}
None
} else {
// The source declared no duration (e.g. HD-DVD — its `.MAP` timemaps
// are not parsed). Reserve a DURATION placeholder now and back-patch
// it at finish() from the muxed timeline, so the file still declares a
// runtime instead of showing "unknown". The 8-byte float payload sits
// 3 bytes in (2-byte ID `0x4489` + 1-byte size `0x88`).
let pos = writer.stream_position()?;
ebml::write_float(&mut writer, ebml::DURATION, 0.0)?;
Some(pos + 3)
};
// Stamp the freemkv version so any muxed file is traceable to the build
// that produced it (MediaInfo "Writing application"/"library").
ebml::write_string(&mut writer, ebml::MUXING_APP, crate::MUX_APP)?;
@@ -1007,6 +1025,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
track_bytes: vec![0u64; tracks.len()],
track_uids,
duration_secs,
duration_patch_pos,
max_block_ticks: 0,
ac3_channel_fixups,
opening_capture: None,
})
@@ -1196,6 +1216,14 @@ impl<W: Write + Seek> MkvMuxer<W> {
// Committed to writing this frame — record its (monotonic) timestamp so
// the next block on this track is forced strictly later.
self.last_pts_ticks.insert(track_idx, pts_ticks);
// Track the highest block timestamp so a missing source duration can be
// back-patched from the real muxed runtime at finish().
// Track the block END (start + its own duration when known), not just
// the start, so a back-patched Segment Duration covers the final frame's
// full presentation instead of understating the runtime by one frame.
let block_end_ticks =
pts_ticks + duration_ns.map_or(0, |d| (d as i64 / TIMESTAMP_SCALE_NS).max(1));
self.max_block_ticks = self.max_block_ticks.max(block_end_ticks);
let relative_ts = (pts_ticks - self.cluster_ts_ticks) as i16;
match duration_ns {
@@ -1268,6 +1296,13 @@ impl<W: Write + Seek> MkvMuxer<W> {
if self.frame_count == 0 {
return Err(crate::error::Error::MkvInvalid.into());
}
// The source declared no duration up-front (DURATION was reserved as a
// placeholder). Derive the real runtime from the muxed timeline so the
// Segment declares it — and so the BPS tags below can be computed.
if self.duration_patch_pos.is_some() && self.max_block_ticks > 0 {
self.duration_secs =
self.max_block_ticks as f64 * TIMESTAMP_SCALE_NS as f64 / 1_000_000_000.0;
}
// Close final cluster
self.end_cluster()?;
@@ -1322,6 +1357,30 @@ impl<W: Write + Seek> MkvMuxer<W> {
.seek(std::io::SeekFrom::Start(fixup.value_offset))?;
self.writer.write_all(&offset.to_be_bytes())?;
}
// Back-patch the DURATION placeholder (source supplied no duration) with
// the real runtime from the muxed timeline. The CUES-void and seek-to-end
// below re-seek absolutely, so no position restore is needed here.
if let Some(pos) = self.duration_patch_pos {
if self.max_block_ticks > 0 {
self.writer.seek(std::io::SeekFrom::Start(pos))?;
self.writer
.write_all(&(self.max_block_ticks as f64).to_be_bytes())?;
} else {
// The timeline never advanced past tick 0 (a degenerate
// single-frame recovery at t=0 with no per-frame duration): we
// can't derive a runtime, so DON'T leave a literal DURATION=0.0
// (players read that as a zero-length/corrupt file). Void the
// whole 11-byte DURATION element (ID 2 + size 1 + 8-byte payload)
// so the Segment simply omits it, as an unknown-duration source
// did before the back-patch. `pos` is the payload start (+3 from
// the element start), so back up 3.
self.writer.seek(std::io::SeekFrom::Start(pos - 3))?;
ebml::write_id(&mut self.writer, ebml::VOID)?;
ebml::write_size(&mut self.writer, 9)?; // 11 - 1 (Void id) - 1 (size)
self.writer.write_all(&[0u8; 9])?;
}
}
// Neutralise the unused CUES Seek entry. The entry is a fixed 21-byte
// Seek master: SEEK(2 ID + 1 size) + SEEK_ID(2+1) + 4-byte target id +
// SEEK_POSITION(2+1) + 8-byte value = 21 bytes. A Void (0xEC, 1-byte ID)
@@ -2665,6 +2724,7 @@ mod tests {
pts: Some(gop_pts),
dts: None,
data: es,
discontinuity: false,
}));
}
frames.extend(parser.flush());
@@ -2851,6 +2911,49 @@ mod tests {
);
}
/// Scan for the first DURATION element (`0x4489`, 8-byte float payload) and
/// return its value in TimestampScale ticks.
fn find_duration_ticks(data: &[u8]) -> Option<f64> {
data.windows(3)
.position(|w| w == [0x44, 0x89, 0x88])
.and_then(|i| data.get(i + 3..i + 11))
.map(|b| f64::from_be_bytes(b.try_into().unwrap()))
}
#[test]
fn duration_placeholder_voided_when_timeline_never_advances() {
// Degenerate recovery: a source with no declared duration muxes exactly
// one keyframe at tick 0 with no per-frame duration, so max_block_ticks
// stays 0. The reserved DURATION placeholder must be VOIDED (element
// omitted) rather than left as a bogus 0.0 that players read as a
// zero-length file.
let tracks = [make_video_track()];
let one_frame = vec![(0usize, 0i64, true, vec![0xAAu8; 16])];
let (data, _) = mux_to_bytes(&tracks, &[], &one_frame);
assert_eq!(
find_duration_ticks(&data),
None,
"no DURATION element (placeholder voided), not a 0.0 duration"
);
}
#[test]
fn duration_backpatched_from_timeline_when_source_gives_none() {
// `mux_to_bytes` muxes with `duration_secs = 0.0` (as an HD-DVD title
// does), so DURATION is reserved as a placeholder and must be
// back-patched from the muxed timeline at finish() — not left 0/absent.
let tracks = [make_video_track()];
let frames = frames_for(5.0, 1.0); // ~5 s of 24 fps video
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
let dur = find_duration_ticks(&data).expect("DURATION element present");
let expect = 5.0 * 1_000_000_000.0 / TIMESTAMP_SCALE_NS as f64; // ~50000 ticks
assert!(dur > 0.0, "duration back-patched from timeline, not 0");
assert!(
(dur - expect).abs() < 60.0,
"duration ~ real runtime: got {dur} ticks, expected ~{expect}"
);
}
#[test]
fn seekhead_points_to_real_elements() {
let tracks = [make_video_track(), make_audio_track()];
+2
View File
@@ -39,6 +39,7 @@ pub mod resolve;
// accessors and an alternate `DemuxThread` spawn path. They are kept as
// part of the parser/demux surface and covered by unit tests; allow the
// dead-code lint rather than delete still-relevant scaffolding.
pub(crate) mod au_assembly;
#[allow(dead_code)]
pub(crate) mod codec;
pub(crate) mod demux_sink;
@@ -91,6 +92,7 @@ pub(crate) mod mkvstream;
pub(crate) mod network;
pub(crate) mod null;
pub(crate) mod ps;
pub(crate) mod resync;
pub(crate) mod stdio;
/// Shared clip-boundary timeline-continuity corrector (used by the MKV muxer
/// and the `demux://` sink).
+397 -19
View File
@@ -68,6 +68,31 @@ pub struct PipelinedPesStream {
/// are expected on every disc; instead of a per-packet WARN they're tallied
/// and summarised once at EOF.
dropped_nav_packets: u64,
/// Per-track (by stream index) B1 drop-to-keyframe gate. After a TS gap on a
/// video track, drop inter-coded frames until the next IRAP so the muxed
/// stream stays decode-clean across an upstream concealed loss (P3/B1).
resync: Vec<super::resync::ResyncGate>,
/// Per-track "is inter-coded video" flag (only video has cross-frame
/// references the gate must protect). Indexed by stream index.
is_video: Vec<bool>,
/// Per-track access-unit assembler. On the PS path a program-stream video AU
/// is split across many fixed-size PES fragments; this reassembles them to the
/// codec's AU boundary so the parser sees AU-complete PES — the same shape the
/// TS demuxer already delivers via PUSI. Self-framing codecs (MPEG-2, audio)
/// use passthrough, so every track runs through it uniformly. Indexed by
/// stream index. (TS titles are AU-complete already, so this is a passthrough
/// there too — `consume_ts` does not use it.)
au_asm: Vec<super::au_assembly::AuAssembler>,
}
/// The `Codec` of a stream, for configuring its [`AuAssembler`].
fn stream_codec(s: &crate::disc::Stream) -> crate::disc::Codec {
use crate::disc::Stream;
match s {
Stream::Video(v) => v.codec,
Stream::Audio(a) => a.codec,
Stream::Subtitle(sub) => sub.codec,
}
}
impl PipelinedPesStream {
@@ -87,6 +112,19 @@ impl PipelinedPesStream {
parsers: Vec<(u16, Box<dyn CodecParser>)>,
pid_to_track: Vec<(u16, usize)>,
) -> Self {
let is_video: Vec<bool> = title
.streams
.iter()
.map(|s| matches!(s, crate::disc::Stream::Video(_)))
.collect();
let resync = (0..title.streams.len())
.map(|_| super::resync::ResyncGate::new())
.collect();
let au_asm = title
.streams
.iter()
.map(|s| super::au_assembly::AuAssembler::for_codec(stream_codec(s)))
.collect();
Self {
title,
parsers,
@@ -98,6 +136,9 @@ impl PipelinedPesStream {
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
decrypt_loss: None,
dropped_nav_packets: 0,
resync,
is_video,
au_asm,
}
}
@@ -164,9 +205,39 @@ impl PipelinedPesStream {
} else if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
let is_video = self.is_video.get(track).copied().unwrap_or(false);
for frame in parser.parse(&pes) {
self.pending_frames
.push_back(PesFrame::from_codec_frame(track, frame));
// B1: after a concealed/lost gap, drop forward to the next
// keyframe on a video track so no frame with a dangling
// reference is emitted. The signal is read PER-FRAME
// (`frame.discontinuity`), not per-PES: buffering parsers
// (MPEG-2 GOPs, H.264/HEVC AU lag) stamp the exact post-gap
// picture, so only it arms the gate — not a whole PES of
// frames. Audio/subtitle always admit (independent frames);
// a track with no gate (out-of-range index) emits as-is.
let emit = match self.resync.get_mut(track) {
Some(gate) => {
let was_armed = gate.is_armed();
let dropped = gate.dropped_in_run();
let admit =
gate.admit(is_video, frame.discontinuity, frame.keyframe);
if admit && was_armed {
tracing::warn!(
target: "mux",
track,
pid = pes.pid,
dropped,
"B1: resynced at keyframe after concealed gap"
);
}
admit
}
None => true,
};
if emit {
self.pending_frames
.push_back(PesFrame::from_codec_frame(track, frame));
}
}
}
}
@@ -207,21 +278,50 @@ impl PipelinedPesStream {
);
continue;
};
let pes = PesPacket {
// Carry the PS demuxer's byte-exact source stamp through to the
// codec parser, exactly as the TS path does — provenance must
// survive the PsPacket → PesPacket seam so the frame's `source`
// reaches the mux/index (FVI `src`), never reconstructed.
source: ps.source,
pid,
pts: ps.pts.map(|p| p as i64),
dts: ps.dts.map(|d| d as i64),
data: ps.data,
// Carry the PS demuxer's byte-exact source stamp through to the codec
// parser, exactly as the TS path does — provenance must survive the
// PsPacket → PesPacket seam so the frame's `source` reaches the
// mux/index (FVI `src`), never reconstructed.
let (pts_i64, dts_i64, src) = (
ps.pts.map(|p| p as i64),
ps.dts.map(|d| d as i64),
ps.source,
);
// Reassemble the PS fragments into AU-complete PES for this track
// (passthrough for self-framing codecs — MPEG-2/audio), so the parser
// sees exactly the AU-complete shape a transport stream delivers. The
// AU-start PTS/source survive the reassembly. A track with no assembler
// (only reachable via a hand-built `pid_to_track` outrunning the stream
// list) passes the fragment straight through. (PS path: no AACS conceal
// → no continuity-gap flag.)
let pkts: Vec<PesPacket> = match self.au_asm.get_mut(track) {
Some(asm) => asm
.push_owned(ps.data, pts_i64, dts_i64, src, false)
.into_iter()
.map(|au| PesPacket {
source: au.source,
pid,
pts: au.pts,
dts: au.dts,
data: au.data,
discontinuity: au.discontinuity,
})
.collect(),
None => vec![PesPacket {
source: src,
pid,
pts: pts_i64,
dts: dts_i64,
data: ps.data,
discontinuity: false,
}],
};
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
for frame in parser.parse(&pes) {
self.pending_frames
.push_back(PesFrame::from_codec_frame(track, frame));
for pes in &pkts {
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
for frame in parser.parse(pes) {
self.pending_frames
.push_back(PesFrame::from_codec_frame(track, frame));
}
}
}
}
@@ -254,15 +354,66 @@ impl Stream for PipelinedPesStream {
);
}
// Drain any access unit a parser buffered past the last
// PES (e.g. DTS-HD's final core+extension unit).
// PES (e.g. DTS-HD's final core+extension unit, or MPEG-2's
// final GOP). These flush frames carry their own per-frame
// `discontinuity` (a post-gap picture buffered at EOF was
// stamped by the parser), so route them through the SAME B1
// gate the in-stream path uses — otherwise a trailing
// dangling-reference frame (MPEG-2 final-GOP corner) would
// bypass the resync. Disjoint field borrows so the gate +
// is_video reads coexist with the mutable parser drain.
let pid_to_track = &self.pid_to_track;
let pending = &mut self.pending_frames;
let resync = &mut self.resync;
let is_video = &self.is_video;
let au_asm = &mut self.au_asm;
for (pid, parser) in self.parsers.iter_mut() {
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
continue;
};
for frame in parser.flush() {
pending.push_back(PesFrame::from_codec_frame(track, frame));
// First: the trailing access unit(s) the PS assembler
// buffered past the final fragment (the last AU has no
// following boundary). Parse them, THEN drain the parser's
// own internal buffer (MPEG-2 final GOP, DTS-HD tail).
let mut frames = Vec::new();
let tail = au_asm.get_mut(track).map(|a| a.flush()).unwrap_or_default();
for au in tail {
let pes = PesPacket {
source: au.source,
pid: *pid,
pts: au.pts,
dts: au.dts,
data: au.data,
discontinuity: au.discontinuity,
};
frames.extend(parser.parse(&pes));
}
frames.extend(parser.flush());
for frame in frames {
let emit = match resync.get_mut(track) {
Some(gate) => gate.admit(
is_video.get(track).copied().unwrap_or(false),
frame.discontinuity,
frame.keyframe,
),
None => true,
};
if emit {
pending.push_back(PesFrame::from_codec_frame(track, frame));
}
}
}
// A gate still armed at EOF dropped post-gap frames that never
// reached a keyframe (e.g. a concealed gap in the final GOP).
// Surface it once so the loss is visible, not silent.
for (track, gate) in self.resync.iter().enumerate() {
if gate.is_armed() {
tracing::warn!(
target: "mux",
track,
dropped = gate.dropped_in_run(),
"B1: stream ended while dropping to a keyframe after a concealed gap (no trailing keyframe)"
);
}
}
return Ok(self.pending_frames.pop_front());
@@ -382,6 +533,7 @@ mod tests {
source: None,
pts_ns: pes.pts.unwrap_or(0) + i as i64,
keyframe: i == 0,
discontinuity: false,
data: pes.data.clone(),
duration_ns: None,
})
@@ -394,6 +546,7 @@ mod tests {
source: None,
pts_ns: 0,
keyframe: false,
discontinuity: false,
data: vec![0xEE],
duration_ns: None,
})
@@ -411,6 +564,7 @@ mod tests {
pts: Some(90_000),
dts: None,
data,
discontinuity: false,
}
}
@@ -498,6 +652,127 @@ mod tests {
);
}
/// A parser that emits exactly one frame per PES, marking it a keyframe iff
/// the PES payload's first byte is `b'K'`. Lets a test script a precise
/// keyframe/inter-frame sequence to exercise the B1 resync gate.
struct KeyframeParser;
impl CodecParser for KeyframeParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<super::super::codec::Frame> {
vec![super::super::codec::Frame {
coding: None,
source: None,
pts_ns: pes.pts.unwrap_or(0),
keyframe: pes.data.first() == Some(&b'K'),
// Propagate so the B1 gate can be driven end-to-end in tests.
discontinuity: pes.discontinuity,
data: pes.data.clone(),
duration_ns: None,
}]
}
fn flush(&mut self) -> Vec<super::super::codec::Frame> {
vec![]
}
fn codec_private(&self) -> Option<Vec<u8>> {
None
}
}
fn ts_pes_disc(pid: u16, data: Vec<u8>, discontinuity: bool) -> PesPacket {
PesPacket {
source: None,
pid,
pts: Some(90_000),
dts: None,
data,
discontinuity,
}
}
/// B1 end-to-end: after a TS discontinuity on a VIDEO track the consumer
/// must DROP every inter-coded frame until the next keyframe, so no frame
/// with a dangling reference reaches the muxer (an ffmpeg deep-scan would
/// otherwise report a missing reference). The frame carrying the
/// discontinuity and the inter frames behind it are dropped; the stream
/// resumes cleanly at the next keyframe.
#[test]
fn b1_video_drops_to_keyframe_after_discontinuity() {
let title = video_title(false); // one HEVC video stream, PID 0x1011
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(0x1011, Box::new(KeyframeParser))];
let pid_to_track = vec![(0x1011u16, 0usize)];
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
// K0,P1 clean → emit. P2 carries the gap (inter frame referencing the
// lost data) → arms the gate; P2,P3 drop. K4 is the next keyframe →
// resync + emit. P5 then admits cleanly.
tx.send(DemuxBatch::Ts(vec![
ts_pes_disc(0x1011, b"K0".to_vec(), false),
ts_pes_disc(0x1011, b"P1".to_vec(), false),
ts_pes_disc(0x1011, b"P2".to_vec(), true),
ts_pes_disc(0x1011, b"P3".to_vec(), false),
ts_pes_disc(0x1011, b"K4".to_vec(), false),
ts_pes_disc(0x1011, b"P5".to_vec(), false),
]))
.unwrap();
tx.send(DemuxBatch::Eof).unwrap();
let mut emitted = Vec::new();
while let Some(f) = stream.read().unwrap() {
emitted.push(f.data);
}
// P2 (gap) and P3 (still no keyframe) are dropped; the rest survive in
// order. Crucially the FIRST frame after the gap that we emit is the
// keyframe K4 — never a dangling-reference inter frame.
assert_eq!(
emitted,
vec![
b"K0".to_vec(),
b"P1".to_vec(),
b"K4".to_vec(),
b"P5".to_vec()
],
"post-gap inter frames dropped, stream resumes at the keyframe"
);
}
/// Counterpart to B1: a discontinuity on a NON-video track must NOT drop
/// frames — audio/subtitle access units are independent, so the gate admits
/// every frame the parser still produces.
#[test]
fn b1_audio_does_not_drop_on_discontinuity() {
let mut title = DiscTitle::empty();
title.streams.push(crate::disc::Stream::Audio(AudioStream {
pid: 0x1100,
codec: Codec::Ac3,
channels: AudioChannels::Surround51,
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
}));
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(0x1100, Box::new(KeyframeParser))];
let pid_to_track = vec![(0x1100u16, 0usize)];
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
tx.send(DemuxBatch::Ts(vec![
ts_pes_disc(0x1100, b"a0".to_vec(), false),
ts_pes_disc(0x1100, b"a1".to_vec(), true), // gap — but audio is independent
ts_pes_disc(0x1100, b"a2".to_vec(), false),
]))
.unwrap();
tx.send(DemuxBatch::Eof).unwrap();
let mut emitted = Vec::new();
while let Some(f) = stream.read().unwrap() {
emitted.push(f.data);
}
assert_eq!(
emitted,
vec![b"a0".to_vec(), b"a1".to_vec(), b"a2".to_vec()],
"audio frames are never dropped on a discontinuity"
);
}
/// At EOF the consumer must call `flush()` on every parser and emit the
/// buffered tail frames — a parser that holds the final access unit (e.g.
/// DTS-HD) must NOT have it dropped. Without the flush the last frame is
@@ -596,6 +871,109 @@ mod tests {
assert!(stream.read().unwrap().is_none(), "unmappable PS dropped");
}
/// Build a single-video-stream title on `codec`, a [`CountingParser`] (1 frame
/// per PES it is handed), and feed three 0xE0 program-stream fragments that
/// together form TWO H.264 access units (AUD-delimited); only AU-start
/// fragments carry a PTS. Returns every emitted frame.
fn run_ps_fragments(codec: Codec) -> Vec<crate::pes::PesFrame> {
let mut title = DiscTitle::empty();
title.streams.push(crate::disc::Stream::Video(VideoStream {
pid: crate::mux::ps::DVD_VIDEO_PID,
codec,
resolution: Resolution::R1080p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
}));
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(
crate::mux::ps::DVD_VIDEO_PID,
Box::new(CountingParser {
per_pes: 1,
flush_n: 0,
cp: None,
}),
)];
let pid_to_track = vec![(crate::mux::ps::DVD_VIDEO_PID, 0usize)];
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
let frag = |pts, data: &[u8]| PsPacket {
source: None,
stream_id: 0xE0,
sub_stream_id: None,
pts,
dts: None,
data: data.to_vec(),
};
tx.send(DemuxBatch::Ps(vec![
frag(Some(9_000), &[0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xAA]), // AU1: AUD + slice head
frag(None, &[0xBB, 0xCC]), // AU1: slice tail (no PTS)
frag(Some(18_000), &[0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xDD]), // AU2 opener (AUD closes AU1)
]))
.unwrap();
tx.send(DemuxBatch::Eof).unwrap();
let mut out = Vec::new();
while let Some(f) = stream.read().unwrap() {
out.push(f);
}
out
}
/// PS-path integration: an H.264 access unit split across several fixed-size
/// PES fragments (only the first with a PTS) must be REJOINED so the parser
/// sees one AU-complete PES with the AU-START pts — not one bogus per-fragment
/// frame each with pts 0 (the HD-DVD truncation/corruption bug). The
/// `CountingParser` makes it observable: 3 fragments forming 2 AUs → 2 frames.
#[test]
fn ps_h264_au_split_across_fragments_reassembles_to_one_frame() {
let frames = run_ps_fragments(Codec::H264);
assert_eq!(
frames.len(),
2,
"3 fragments → 2 access units, not 3 frames"
);
assert_eq!(frames[0].track, 0);
assert_eq!(
frames[0].data,
vec![0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xAA, 0xBB, 0xCC],
"AU1 = fragment1 + fragment2 rejoined"
);
assert_eq!(
frames[0].pts, 9_000,
"AU carries its START pts, not the mid-fragment None→0"
);
assert_eq!(
frames[1].data,
vec![0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xDD],
"AU2 flushed at EOF (no following boundary)"
);
assert_eq!(frames[1].pts, 18_000);
}
/// Contrast: a self-framing codec (MPEG-2 reassembles in its own parser) uses
/// a Passthrough assembler — the SAME three fragments pass straight through as
/// three frames, byte-identical to the pre-assembler behaviour. This proves the
/// reassembly is gated by codec and does not disturb the DVD/MPEG-2 path.
#[test]
fn ps_self_framing_codec_is_not_reassembled() {
let frames = run_ps_fragments(Codec::Mpeg2);
assert_eq!(
frames.len(),
3,
"MPEG-2 passthrough: one frame per fragment"
);
assert_eq!(frames[0].pts, 9_000);
assert_eq!(
frames[1].pts, 0,
"mid-fragment has no PTS under passthrough"
);
assert_eq!(frames[2].pts, 18_000);
}
/// A batch with no trackable packets must NOT terminate the stream early:
/// pump_one_batch loops to the next batch. Here an empty-but-untracked
/// batch is followed by a real frame batch — the consumer must skip the
+249 -5
View File
@@ -27,6 +27,12 @@ const PRIVATE_STREAM_1: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_1;
/// Private stream 2 (0xBF) — DVD navigation (PCI/DSI). Carries no muxable
/// elementary stream; expected to be dropped on every disc.
const PRIVATE_STREAM_2: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_2;
/// Extended stream id (0xFD) — the H.222.0 escape whereby the real stream id is
/// the `stream_id_extension` carried in the PES extension. HD-DVD `.evo` puts its
/// VC-1 video (and HD audio) here (Shaun of the Dead: VC-1 on `0xFD` ext `0x55`);
/// a transport stream never uses it. The elementary-stream bytes follow the PES
/// header exactly like any other PES — only the routing key differs.
const EXTENDED_STREAM_ID: u8 = 0xFD;
/// Hard cap on the demuxer's reassembly buffer. A length-0 (unbounded) video
/// PES is delimited by the next PS-layer boundary; if a corrupt stream declares
@@ -73,9 +79,16 @@ pub const DVD_VIDEO_PID: u16 = 0xE0;
/// source of truth shared with `Disc::scan_dvd_titles`
/// (`src/disc/dvd.rs`), which sets each `AudioStream.pid` from the same
/// function so demuxer output routes through the title's `pid_to_track`.
///
/// HD-DVD (`.evo` Enhanced VOB) carries Dolby Digital Plus (E-AC-3) on
/// `private_stream_1` sub-stream ids `0xC0..=0xC7` — a range DVD never uses
/// (DVD audio is `0x80..=0x8F` / `0xA0..=0xA7`), so admitting it here is purely
/// additive and cannot change any DVD mapping. The PID is `0xBD00 | sub` just
/// like the DVD audio ranges, so a mixed HD-DVD title (four DD+ tracks
/// `0xC0..0xC3`) routes each track to its own distinct PID.
pub fn dvd_audio_pid(sub_stream_id: u8) -> Option<u16> {
match sub_stream_id {
0x80..=0x8F | 0xA0..=0xA7 => Some(0xBD00 | sub_stream_id as u16),
0x80..=0x8F | 0xA0..=0xA7 | 0xC0..=0xC7 => Some(0xBD00 | sub_stream_id as u16),
_ => None,
}
}
@@ -90,6 +103,17 @@ pub fn dvd_subtitle_pid(sub_stream_id: u8) -> Option<u16> {
}
}
/// Canonical PID for an HD-DVD extended-stream-id (`0xFD`) stream, keyed by its
/// `stream_id_extension`: `0xFD00 | ext`. Disjoint from the DVD video (`0xE0`) and
/// `private_stream_1` (`0xBD00..`) PID spaces, so several elementary streams
/// multiplexed on `0xFD` (VC-1 video, MLP/TrueHD audio) never collide. The
/// scanner's head probe and `PsPacket::dvd_pid` derive the same PID from the same
/// `stream_id_extension`, so demux output routes through the title's
/// `pid_to_track`.
pub fn hddvd_extended_pid(stream_id_extension: u8) -> u16 {
0xFD00 | stream_id_extension as u16
}
impl PsPacket {
/// Map this packet to the canonical DVD PID assigned by
/// `Disc::scan_dvd_titles` (`src/disc/dvd.rs`), so demux output can
@@ -112,6 +136,12 @@ impl PsPacket {
let sub = self.sub_stream_id?;
dvd_audio_pid(sub).or_else(|| dvd_subtitle_pid(sub))
}
// HD-DVD extended-stream-id (0xFD): route by the stream_id_extension
// (carried in `sub_stream_id`) to a distinct `0xFD00 | ext` PID, so a
// disc that puts several elementary streams on 0xFD keeps them apart.
// The codec (VC-1 etc.) is decided by the scanner's head probe, not
// here — this only assigns a stable routing key.
EXTENDED_STREAM_ID => self.sub_stream_id.map(hddvd_extended_pid),
_ => None,
}
}
@@ -347,9 +377,74 @@ fn find_ps_boundary(data: &[u8], from: usize) -> Option<usize> {
/// Check whether a start code byte is a valid PES stream ID that carries payload.
fn is_pes_stream_id(id: u8) -> bool {
// Video: 0xE0-0xEF, MPEG audio: 0xC0-0xDF, private stream 1: 0xBD,
// private stream 2: 0xBF, padding: 0xBE, ECM/EMM etc.
// We parse anything in the payload-bearing PES range.
crate::consts::pes_stream_id::PAYLOAD_RANGE.contains(&id)
// private stream 2: 0xBF, padding: 0xBE, ECM/EMM etc. — plus the HD-DVD
// extended-stream-id (0xFD), which carries VC-1 video / HD audio.
crate::consts::pes_stream_id::PAYLOAD_RANGE.contains(&id) || id == EXTENDED_STREAM_ID
}
/// For an extended-stream-id (`0xFD`) PES, walk the optional PES-header fields to
/// the PES extension and read the 7-bit `stream_id_extension` — the real stream
/// id. `data` starts at the PES start code; the optional fields live in
/// `data[9..header_end]` (all bounds-checked against `header_end`). Returns `None`
/// if the extension is absent or malformed.
fn parse_stream_id_extension(data: &[u8], flags2: u8, header_end: usize) -> Option<u8> {
let get = |p: usize| -> Option<u8> {
if p < header_end {
data.get(p).copied()
} else {
None
}
};
let mut pos = 9usize;
let pts_dts = (flags2 >> 6) & 0x03;
if pts_dts & 0x02 != 0 {
pos += 5; // PTS
}
if pts_dts == 0x03 {
pos += 5; // DTS
}
if flags2 & 0x20 != 0 {
pos += 6; // ESCR
}
if flags2 & 0x10 != 0 {
pos += 3; // ES_rate
}
if flags2 & 0x08 != 0 {
pos += 1; // DSM_trick_mode
}
if flags2 & 0x04 != 0 {
pos += 1; // additional_copy_info
}
if flags2 & 0x02 != 0 {
pos += 2; // PES_CRC
}
if flags2 & 0x01 == 0 {
return None; // no PES_extension
}
let ext_flags = get(pos)?;
pos += 1;
if ext_flags & 0x80 != 0 {
pos += 16; // PES_private_data
}
if ext_flags & 0x40 != 0 {
// pack_header_field: 1-byte length + that many bytes.
pos += 1 + get(pos)? as usize;
}
if ext_flags & 0x20 != 0 {
pos += 2; // program_packet_sequence_counter
}
if ext_flags & 0x10 != 0 {
pos += 2; // P-STD_buffer
}
if ext_flags & 0x01 == 0 {
return None; // no PES_extension_flag_2
}
// PES_extension_field_length (7 bits, marker in the top bit), then the
// stream_id_extension byte: present when its top bit (the extension flag) is 0.
let _field_len = get(pos)? & 0x7F;
pos += 1;
let b = get(pos)?;
(b & 0x80 == 0).then_some(b & 0x7F)
}
/// Parse a single PES packet from a byte slice that starts at the start code.
@@ -416,10 +511,30 @@ fn parse_pes_packet(data: &[u8]) -> Option<PsPacket> {
// For private stream 1, the first payload byte is the sub-stream ID,
// followed by a sub-header whose length depends on the sub-stream type.
let (sub_stream_id, es_data) = if stream_id == PRIVATE_STREAM_1 && !payload.is_empty() {
let (sub_stream_id, es_data) = if stream_id == EXTENDED_STREAM_ID {
// HD-DVD extended-stream-id: the real stream id lives in the
// stream_id_extension inside the PES extension. There is no leading
// sub-header byte on the payload (unlike private_stream_1), so the ES
// is the payload verbatim.
(
parse_stream_id_extension(data, data[7], header_end),
payload.to_vec(),
)
} else if stream_id == PRIVATE_STREAM_1 && !payload.is_empty() {
let sub_id = payload[0];
let skip = match sub_id {
0x80..=0x8F => 4, // AC3/DTS: sub_id + frame_count + access_unit_ptr(2)
// HD-DVD Dolby Digital Plus (E-AC-3): the sub-header is the same
// 4-byte shape as DVD AC-3 — sub_id + number_of_frames(1) +
// first_access_unit_pointer(2). Verified empirically on ANCHORMAN
// EVO: across every 0xC0..=0xC7 packet the 0x0B77 E-AC-3 syncword
// sits `first_access_unit_pointer` bytes past this 4-byte header
// (the leading bytes are the tail of the previous frame). Stripping
// exactly these 4 bytes on EVERY packet yields a clean, continuous
// E-AC-3 elementary stream that the ac3 parser reassembles across
// PES boundaries; a shorter skip would splice the sub-header bytes
// into a straddling frame and corrupt it.
0xC0..=0xC7 => 4,
0xA0..=0xA7 => 7, // LPCM: sub_id + frames + ptr(2) + emphasis + quant_freq + channels
_ => 1,
};
@@ -836,6 +951,90 @@ mod tests {
}
}
#[test]
fn parse_extended_stream_id_extracts_stream_id_extension() {
// SHAUN's VC-1 video PES: stream_id 0xFD, flags2=0x01 (PES_extension
// only), header_data_length=3, optional bytes 0x0F/... — build the
// minimal well-formed variant: ext_flags=0x01 (PES_extension_flag_2),
// field_len=0x81, stream_id_extension=0x55. Payload is the ES.
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
let opt = [0x01u8, 0x81, 0x55];
let es = [0xDEu8, 0xAD, 0xBE, 0xEF];
let len = (3 + opt.len() + es.len()) as u16;
pkt.extend_from_slice(&len.to_be_bytes());
pkt.extend_from_slice(&[0x80, 0x01, opt.len() as u8]);
pkt.extend_from_slice(&opt);
pkt.extend_from_slice(&es);
let parsed = parse_pes_packet(&pkt).expect("parses");
assert_eq!(parsed.stream_id, EXTENDED_STREAM_ID);
assert_eq!(
parsed.sub_stream_id,
Some(0x55),
"stream_id_extension extracted from PES extension"
);
// ES is the payload verbatim — no leading sub-header byte stripped.
assert_eq!(parsed.data, es);
// Routes to the extended-stream-id PID space.
assert_eq!(parsed.dvd_pid(), Some(hddvd_extended_pid(0x55)));
assert_eq!(parsed.dvd_pid(), Some(0xFD55));
}
#[test]
fn parse_extended_stream_id_skips_pts_and_dts_before_the_extension() {
// The common real case: an AU-opening 0xFD VC-1 video PES carries a PTS
// (and often DTS) in the optional-header region, which the parser must
// SKIP (PTS +5, DTS +5) to reach the PES_extension → stream_id_extension.
// Both branches were previously untested (flags2 there was 0x01, skipping
// everything), so an off-by-one in the skip would silently misroute video.
let build = |flags2: u8, skip: usize| {
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
// optional region: `skip` bytes (PTS/DTS placeholders) then
// ext_flags=0x01, field_len=0x81, stream_id_extension=0x55.
let mut opt = vec![0xFFu8; skip];
opt.extend_from_slice(&[0x01, 0x81, 0x55]);
let es = [0xDEu8, 0xAD];
let len = (3 + opt.len() + es.len()) as u16;
pkt.extend_from_slice(&len.to_be_bytes());
// flags1=0x80, flags2, header_data_length = optional region length.
pkt.extend_from_slice(&[0x80, flags2, opt.len() as u8]);
pkt.extend_from_slice(&opt);
pkt.extend_from_slice(&es);
pkt
};
// PTS present (pts_dts bits = 10 → flags2 0x80) + PES_extension (0x01).
let pts_only = parse_pes_packet(&build(0x81, 5)).expect("parses");
assert_eq!(
pts_only.sub_stream_id,
Some(0x55),
"extension found after skipping a 5-byte PTS"
);
// PTS+DTS present (pts_dts bits = 11 → flags2 0xC0) + PES_extension.
let pts_dts = parse_pes_packet(&build(0xC1, 10)).expect("parses");
assert_eq!(
pts_dts.sub_stream_id,
Some(0x55),
"extension found after skipping a 10-byte PTS+DTS"
);
}
#[test]
fn parse_extended_stream_id_without_extension_yields_no_sub_id() {
// A 0xFD PES that declares no PES_extension (flags2=0x00) can't carry a
// stream_id_extension → sub_stream_id None, and dvd_pid falls through.
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
let es = [0x11u8, 0x22];
let len = (3 + es.len()) as u16;
pkt.extend_from_slice(&len.to_be_bytes());
pkt.extend_from_slice(&[0x80, 0x00, 0x00]);
pkt.extend_from_slice(&es);
let parsed = parse_pes_packet(&pkt).expect("parses");
assert_eq!(parsed.sub_stream_id, None);
assert_eq!(parsed.dvd_pid(), None);
assert_eq!(parsed.data, es);
}
#[test]
fn dvd_pid_matches_scanner_assignment() {
// Video → 0xE0 (matches dvd.rs VideoStream pid).
@@ -1175,6 +1374,51 @@ mod tests {
assert_eq!(dvd_audio_pid(0xA8), None);
}
#[test]
fn hddvd_ddplus_substream_maps_to_bd_pid() {
// HD-DVD Dolby Digital Plus sub-ids 0xC0..=0xC7 map to 0xBD00|sub,
// distinct per track and disjoint from the DVD audio space. A DVD never
// emits these, so the range is purely additive.
assert_eq!(dvd_audio_pid(0xC0), Some(0xBDC0));
assert_eq!(dvd_audio_pid(0xC3), Some(0xBDC3));
assert_eq!(dvd_audio_pid(0xC7), Some(0xBDC7));
// Just outside the range.
assert_eq!(dvd_audio_pid(0xBF), None);
assert_eq!(dvd_audio_pid(0xC8), None);
// Four DD+ tracks (ANCHORMAN) get four distinct PIDs.
let pids: Vec<u16> = (0xC0u8..=0xC3).map(|s| dvd_audio_pid(s).unwrap()).collect();
assert_eq!(pids, vec![0xBDC0, 0xBDC1, 0xBDC2, 0xBDC3]);
// And route through dvd_pid on a private_stream_1 packet.
assert_eq!(mk(0xBD, Some(0xC0)).dvd_pid(), Some(0xBDC0));
assert_eq!(mk(0xBD, Some(0xC3)).dvd_pid(), Some(0xBDC3));
}
#[test]
fn hddvd_ddplus_pes_strips_4byte_subheader_to_syncword() {
// A private_stream_1 PES carrying DD+ (sub-id 0xC0) has a 4-byte
// sub-header (sub_id + num_frames(1) + access_unit_ptr(2)); the demuxer
// must strip exactly those 4 bytes so es_data begins at the E-AC-3
// payload — here the 0x0B77 syncword sits right after the sub-header.
let mut demuxer = PsDemuxer::new();
let mut data = vec![
0x00, 0x00, 0x01, 0xBD, // private stream 1
0x00, 0x0B, // PES_packet_length = 11 (flags2 + hdl1 + 8 payload)
0x80, 0x00, 0x00, // no PTS, header_data_len = 0
0xC0, // sub-stream id: DD+ track 0
0x01, 0x00, 0x00, // num_frames(1) + access_unit_ptr(2)
0x0B, 0x77, 0xDE, 0xAD, // E-AC-3 syncword + payload
];
data.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
let p = demuxer.feed(&data);
assert_eq!(p.len(), 1);
assert_eq!(p[0].sub_stream_id, Some(0xC0));
assert_eq!(
p[0].data,
vec![0x0B, 0x77, 0xDE, 0xAD],
"4-byte DD+ sub-header stripped; es_data starts at the syncword"
);
}
#[test]
fn dvd_subtitle_pid_range_boundaries() {
// VobSub subtitle sub-ids 0x20..=0x3F map to the identity PID.
+20 -7
View File
@@ -358,6 +358,12 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
// case must NOT raise a false E7023.
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
.map_err(|e| -> io::Error { e.into() })?;
// Upfront FMTS gate, parallel to the unit-key gate above. With
// BYPASS_FMTS_KEY this is a no-op and a 2.1 disc's forensic units are
// concealed as ordinary decrypt loss below; without it, a 2.1 disc
// lacking segment keys fails here rather than emitting a holed mux.
disc.ensure_forensic_segments_decryptable(opts.raw)
.map_err(|e| -> io::Error { e.into() })?;
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
// by probing the first DECRYPTED access units of the chosen title.
// A fresh reader avoids disturbing the mux reader below. Skipped in
@@ -629,19 +635,26 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
_ => 1,
};
// MUX path: tolerate decrypt loss. An undecryptable content unit is concealed
// (NULL TS fill) + tallied + logged, never an abort — decrypt-verify is a RIP
// gate, not a mux gate (P3). The rip's own read paths keep their fail-loud
// decorator; only this mux pipeline opts in.
let mut decrypting =
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys)
.tolerate_decrypt_loss();
// Install the fresh-key-on-failure callback (if any) so a unit no held key
// decrypts is re-tried via the application's key source before being counted
// as loss.
// as loss. An AACS 2.1 forensic-segment unit that no key opens is just an
// undecryptable unit like any other: concealed and counted as decrypt loss —
// a loss is a loss, no FMTS special casing.
if let Some(cb) = fetch {
decrypting = decrypting.with_key_fetch(cb);
}
// Grab the decrypt-loss counter before the decorator is moved into the
// producer thread. It tracks bytes of scrambled AACS units no key could
// decrypt — silent loss the demux drops; the consuming stream surfaces it
// through `lost_bytes()` so the mux abort gate sees a partial decrypt
// failure rather than a clean rip.
// Grab the loss counters before the decorator is moved into the producer
// thread. It tracks bytes of scrambled AACS units no key could decrypt —
// silent loss the demux drops; the consuming stream surfaces it through
// `lost_bytes()` so the mux abort gate sees a partial decrypt failure rather
// than a clean rip. Forensic (2.1) undecryptable units land here too.
let decrypt_loss = decrypting.decrypt_loss();
// Wrong-substream fix (Silence-of-the-Lambs): before the prefetcher takes
+123
View File
@@ -0,0 +1,123 @@
//! B1 drop-to-IRAP gate — keep a muxed elementary stream decode-clean across a
//! mid-stream gap (e.g. an undecryptable unit the mux concealed as NULL TS,
//! P3/A2).
//!
//! When packets are lost, the affected access unit is already dropped at the TS
//! layer (the assembler drops the partial PES on the continuity gap). But for
//! INTER-CODED video the frames that follow reference the lost frame (and each
//! other) until the next IRAP/IDR keyframe — emitting them yields an ffmpeg
//! "missing reference / non-existing PPS" deep-scan error and visibly broken
//! decode. So after a gap on a video track we DROP FORWARD to the next keyframe
//! and resume cleanly there. The gap rounds up to (at most) one GOP — the price
//! of never emitting a dangling reference; it is logged.
//!
//! Audio and subtitle frames are independent (no inter-frame references), so a
//! gap there costs only the single already-dropped frame; the gate is a no-op
//! for non-video tracks (it always admits).
/// Per-track keyframe-resync state. One gate per elementary stream; a video
/// track's gate stays "armed" from a discontinuity until the next keyframe.
#[derive(Debug, Default)]
pub(crate) struct ResyncGate {
/// True while dropping post-gap inter-coded frames until the next keyframe.
armed: bool,
/// Count of frames dropped while armed (for a single summary log on resync).
dropped: u64,
}
impl ResyncGate {
pub(crate) fn new() -> Self {
Self {
armed: false,
dropped: 0,
}
}
/// Decide whether a parsed frame should be EMITTED (`true`) or DROPPED
/// (`false`).
///
/// * `is_video` — inter-coded video track (the only kind with cross-frame
/// references); `false` for audio/subtitle, which always admit.
/// * `discontinuity` — this frame's source PES followed a TS continuity gap.
/// * `keyframe` — this frame is a self-contained IRAP/IDR.
///
/// A non-video track always admits. A video track arms on a discontinuity
/// and then drops every non-keyframe until (and excluding the drop of) the
/// next keyframe, which disarms and is emitted.
pub(crate) fn admit(&mut self, is_video: bool, discontinuity: bool, keyframe: bool) -> bool {
if !is_video {
return true;
}
if discontinuity {
self.armed = true;
}
if self.armed {
if keyframe {
self.armed = false;
self.dropped = 0;
true
} else {
self.dropped += 1;
false
}
} else {
true
}
}
/// Frames dropped so far in the CURRENT armed run (0 when not armed / just
/// resynced). Lets the consumer log the resync cost once at the keyframe.
pub(crate) fn dropped_in_run(&self) -> u64 {
self.dropped
}
/// Whether the gate is currently dropping frames (armed, awaiting keyframe).
pub(crate) fn is_armed(&self) -> bool {
self.armed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_video_always_admits_even_on_discontinuity() {
let mut g = ResyncGate::new();
// Audio/subtitle: a gap drops only the (already TS-dropped) frame; every
// frame the parser still emits is independent and must pass.
assert!(g.admit(false, true, false));
assert!(g.admit(false, true, false));
assert!(!g.is_armed(), "non-video never arms");
}
#[test]
fn video_drops_inter_frames_until_next_keyframe() {
let mut g = ResyncGate::new();
// Clean run: everything admits.
assert!(g.admit(true, false, true)); // IDR
assert!(g.admit(true, false, false)); // P
// Gap arrives on the next frame (a P referencing lost data) → drop it
// and every inter frame until the next keyframe.
assert!(!g.admit(true, true, false), "post-gap P dropped");
assert!(!g.admit(true, false, false), "still dropping (no key yet)");
assert!(!g.admit(true, false, false));
assert_eq!(g.dropped_in_run(), 3);
// Next keyframe resyncs and is emitted.
assert!(g.admit(true, false, true), "keyframe resumes the stream");
assert!(!g.is_armed());
assert_eq!(g.dropped_in_run(), 0);
// Back to a clean run.
assert!(g.admit(true, false, false), "post-resync P admits");
}
#[test]
fn discontinuity_landing_on_a_keyframe_emits_immediately() {
let mut g = ResyncGate::new();
// If the first surviving frame after the gap is itself an IRAP, there is
// nothing to drop — it is self-contained.
assert!(g.admit(true, true, true), "gap+keyframe emits, no drop");
assert!(!g.is_armed());
assert_eq!(g.dropped_in_run(), 0);
}
}
+237 -22
View File
@@ -12,6 +12,10 @@ use crate::consts::TS_PACKET_BYTES;
/// TS sync byte.
const SYNC_BYTE: u8 = 0x47;
/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream; the P3
/// concealment fill emits null packets on this PID, tagged with an
/// adaptation-field discontinuity_indicator to signal a concealed gap.
const NULL_PID: u16 = 0x1FFF;
/// A reassembled PES packet with timestamp info.
#[derive(Debug)]
@@ -28,6 +32,18 @@ pub struct PesPacket {
/// from the producer's known stream offset. `None` when the demuxer was fed
/// without a base offset (callers that don't need provenance).
pub source: Option<crate::pes::SourcePos>,
/// True when one or more packets for this stream were lost before this PES —
/// a continuity break (CC gap or adaptation-field discontinuity_indicator) on
/// a tracked PID, or the CC-independent concealment marker the mux emits when
/// it replaces an undecryptable unit with NULL-TS packets (P3/A2). This PES is
/// the FIRST whose data is entirely after the gap: a mid-frame loss drops the
/// truncated partial and flags the next complete PES; a loss landing on a PES
/// boundary flags the PES STARTING after it (never the one just flushed). So
/// for inter-coded video this PES — and every later frame up to the next
/// IRAP/IDR — may reference data that is now gone. The codec-parse consumer
/// (via the per-frame `Frame::discontinuity` its parser propagates) drops
/// forward to the next keyframe (B1) instead of emitting dangling references.
pub discontinuity: bool,
}
/// Per-PID PES reassembly state.
@@ -58,6 +74,12 @@ struct PesAssembler {
/// Stamped at PES start, emitted on the completed packet — provenance is
/// carried, never reconstructed downstream.
pes_source: Option<crate::pes::SourcePos>,
/// Sticky "a gap occurred on this PID" flag. Set by a CC gap, an explicit
/// discontinuity_indicator, or the concealment marker; consumed by the NEXT
/// PES this assembler completes — the first whose data is entirely post-gap —
/// then cleared. A gap detected on a PUSI sets it AFTER `start()` so it rides
/// the new PES, not the one just flushed. Drives B1 drop-to-keyframe.
pending_discontinuity: bool,
}
/// Initial capacity for a fresh PES buffer. Sized to cover the
@@ -92,6 +114,7 @@ impl PesAssembler {
header_remaining: 0,
last_cc: None,
pes_source: None,
pending_discontinuity: false,
}
}
@@ -105,12 +128,15 @@ impl PesAssembler {
source: Option<crate::pes::SourcePos>,
) -> Option<PesPacket> {
let completed = if self.active && !self.buffer.is_empty() {
let discontinuity = self.pending_discontinuity;
self.pending_discontinuity = false;
Some(PesPacket {
pid: self.pid,
pts: self.pts,
dts: self.dts,
data: std::mem::replace(&mut self.buffer, Vec::with_capacity(PES_BUFFER_INIT_CAP)),
source: self.pes_source,
discontinuity,
})
} else {
self.buffer.clear();
@@ -141,6 +167,10 @@ impl PesAssembler {
self.buffer.clear();
self.active = false;
self.header_remaining = 0;
// A dropped partial PES is a gap in the elementary stream — flag
// it so the NEXT completed PES carries a discontinuity, matching
// every other partial-drop path in this file (lines 395/479/504).
self.pending_discontinuity = true;
return;
}
self.buffer.extend_from_slice(data);
@@ -151,12 +181,15 @@ impl PesAssembler {
fn flush(&mut self) -> Option<PesPacket> {
if self.active && !self.buffer.is_empty() {
self.active = false;
let discontinuity = self.pending_discontinuity;
self.pending_discontinuity = false;
Some(PesPacket {
pid: self.pid,
pts: self.pts,
dts: self.dts,
data: std::mem::take(&mut self.buffer),
source: self.pes_source,
discontinuity,
})
} else {
None
@@ -335,6 +368,39 @@ impl TsDemuxer {
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
let adaptation = (ts[3] >> 4) & 0x03;
// P3/B1 CONCEALMENT MARKER. The decrypt layer fills an undecryptable
// aligned unit with NULL-TS packets (PID 0x1FFF) that carry an
// adaptation-field discontinuity_indicator (see `aacs::content::fill_null_ts_unit`).
// This is the authoritative loss signal — unlike a tracked PID's 4-bit
// continuity_counter it is CC-INDEPENDENT, so it survives a loss that is
// an exact multiple of 16 packets and a loss at the very start of a PID
// (no prior CC to diff against). The decrypt layer cannot know which
// elementary PID(s) the lost unit carried (the data was undecryptable),
// so force a pending discontinuity on EVERY tracked assembler: the next
// completed PES of each resyncs. Harmless for audio/subtitle (the codec
// gate is a no-op there) and at most one extra GOP on a video track that
// did not actually lose packets — bounded, and only on a degraded disc.
if pid == NULL_PID
&& (adaptation == 0x02 || adaptation == 0x03)
&& (ts[4] as usize) > 0
&& (ts[5] & 0x80) != 0
{
for a in &mut self.assemblers {
// A concealed unit may have dropped packets belonging to a PES
// currently open on any PID — so that partial is potentially
// TRUNCATED (a hole in the middle of its access unit). Drop it
// like a mid-PES continuity break, and flag pending so the NEXT
// completed PES (the first frame whose data is entirely post-gap)
// resyncs. Mirrors the non-PUSI cc_gap path, applied to every PID
// because the lost unit's PID(s) are unknowable (undecryptable).
a.buffer.clear();
a.active = false;
a.header_remaining = 0;
a.pending_discontinuity = true;
}
return;
}
let idx = if (pid as usize) < self.pid_index.len() {
self.pid_index[pid as usize]
} else {
@@ -380,8 +446,9 @@ impl TsDemuxer {
// — splicing the new payload would corrupt the elementary stream — so
// drop the partial and resync on the next PUSI.
let cc = ts[3] & 0x0f;
let discontinuity_flag =
(adaptation == 0x03 || adaptation == 0x02) && ts[4] > 0 && (ts[5] & 0x80) != 0;
// adaptation == 0x02 (AF only) already returned above, so only 0x03
// (AF + payload) can carry an adaptation field here.
let discontinuity_flag = adaptation == 0x03 && ts[4] > 0 && (ts[5] & 0x80) != 0;
// A gap is a CC that is neither the expected `(prev + 1) & 0xf` nor a
// duplicate `prev` (ISO 13818-1 permits a packet to repeat its CC; a
// duplicate is not a loss). Anything else means one or more packets for
@@ -391,25 +458,30 @@ impl TsDemuxer {
None => false,
};
asm.last_cc = Some(cc);
if !pusi && (discontinuity_flag || cc_gap) && asm.active {
tracing::trace!(
target: "mux",
pid = asm.pid,
"TS continuity break on non-PUSI continuation; dropping partial PES",
);
asm.buffer.clear();
asm.active = false;
asm.header_remaining = 0;
return;
}
// A continuity gap means packets for THIS PID were lost (a damaged source,
// or — for the conceal path — a loss that the CC-independent NULL-TS marker
// above did not already flag). The flag is sticky and rides to the FIRST
// post-gap PES so the codec consumer drops forward to the next keyframe
// (B1). Attribution differs by where the gap lands (see below).
let gap = discontinuity_flag || cc_gap;
if pusi {
// `header_len` is the FULL (uncapped) PES-header length:
// 0 = malformed (payload is not a PES start), else 6/9+N.
let (pts, dts, header_len) = parse_pes_header(payload);
// Flush the previous PES FIRST — a gap detected on this PUSI packet
// belongs to the PES STARTING now (its data begins after the lost
// packets), NOT the one just completing. So set `pending_discontinuity`
// AFTER start(): it rides the new PES to its own completion. (Setting
// it before would stamp the pre-gap frame; if that frame were a
// keyframe the gate would arm-then-disarm on it and admit the real
// post-gap inter frame with a dangling reference.)
if let Some(prev) = asm.start(pts, dts, source) {
completed.push(prev);
}
if gap {
asm.pending_discontinuity = true;
}
if header_len == 0 {
// PUSI packet whose payload is not a valid PES start. Do
// NOT push it — those bytes are not elementary-stream data
@@ -426,16 +498,37 @@ impl TsDemuxer {
// the following continuation packet(s).
asm.header_remaining = header_len - payload.len();
}
} else if asm.header_remaining > 0 {
// Continuation packet still inside a PES header that spanned
// the boundary — consume header bytes before any ES data.
let skip = asm.header_remaining.min(payload.len());
asm.header_remaining -= skip;
if skip < payload.len() {
asm.push(&payload[skip..]);
}
} else {
asm.push(payload);
// Non-PUSI continuation.
if gap {
// Mid-PES hole: the open partial has a gap, so splicing this
// payload would corrupt the ES. Flag pending (consumed at the
// NEXT completed PES — the first post-gap frame) and drop the
// open partial; resync on the next PUSI.
asm.pending_discontinuity = true;
if asm.active {
tracing::trace!(
target: "mux",
pid = asm.pid,
"TS continuity break on non-PUSI continuation; dropping partial PES",
);
asm.buffer.clear();
asm.active = false;
asm.header_remaining = 0;
return;
}
}
if asm.header_remaining > 0 {
// Continuation packet still inside a PES header that spanned
// the boundary — consume header bytes before any ES data.
let skip = asm.header_remaining.min(payload.len());
asm.header_remaining -= skip;
if skip < payload.len() {
asm.push(&payload[skip..]);
}
} else {
asm.push(payload);
}
}
}
@@ -916,6 +1009,128 @@ mod tests {
);
}
/// B1 plumbing: a continuity gap detected on a PUSI must stamp
/// `discontinuity = true` on the PES STARTING after the gap, NOT the one
/// flushed at the boundary — the post-gap PES is the one whose data begins
/// after the lost packets and references them. (Attribution fix: stamping the
/// pre-gap PES would, if it were a keyframe, arm-then-disarm the gate and let
/// the real post-gap inter frame through with a dangling reference.) A clean
/// in-sequence PES carries `discontinuity = false`.
#[test]
fn continuity_gap_stamps_discontinuity_on_next_pes() {
let pid = 0x1011;
let mut demux = TsDemuxer::new(&[pid]);
// Open A (cc=0): nothing completed yet.
let out = demux.feed(&ts_payload_packet(pid, true, 0, &pes_start(b"AAAA")));
assert!(out.is_empty());
// B's PUSI (cc=1, in sequence) flushes A. A saw no gap → clean.
let out = demux.feed(&ts_payload_packet(pid, true, 1, &pes_start(b"BBBB")));
assert_eq!(out.len(), 1, "A completes");
assert_eq!(&out[0].data[..4], b"AAAA");
assert!(
!out[0].discontinuity,
"in-sequence PES is not a discontinuity"
);
// C's PUSI jumps cc 1 -> 5: packets were lost between B and C. B (flushed
// here) is PRE-gap and stays clean — the gap belongs to C, which starts
// after the lost packets.
let out = demux.feed(&ts_payload_packet(pid, true, 5, &pes_start(b"CCCC")));
assert_eq!(out.len(), 1, "B completes");
assert_eq!(&out[0].data[..4], b"BBBB");
assert!(
!out[0].discontinuity,
"the pre-gap PES flushed at the boundary must NOT be flagged"
);
// C carries the discontinuity — it is the first post-gap PES.
let out = demux.flush();
assert_eq!(out.len(), 1);
assert_eq!(&out[0].data[..4], b"CCCC");
assert!(
out[0].discontinuity,
"the post-gap PES must be flagged so B1 resyncs at/after it"
);
}
/// One 192-byte BD source packet that is a B1 concealment marker: a PID-0x1FFF
/// null packet carrying the adaptation-field discontinuity_indicator (the byte
/// shape `fill_null_ts_unit` writes for every packet of a concealed unit).
fn null_marker_packet() -> Vec<u8> {
let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES];
pkt[4] = SYNC_BYTE; // 0x47
pkt[5] = 0x1F; // PID 0x1FFF
pkt[6] = 0xFF;
pkt[7] = 0x20; // adaptation-field only
pkt[8] = 0xB7; // af_len 183
pkt[9] = 0x80; // discontinuity_indicator
for b in &mut pkt[10..] {
*b = 0xFF;
}
pkt
}
/// HOLE 3 (16-multiple CC blind spot) + the truncated-partial drop. A concealed
/// unit can drop an exact multiple of 16 packets on a PID, leaving its 4-bit
/// continuity_counter looking IN-SEQUENCE — so CC-based detection is blind. The
/// CC-INDEPENDENT marker flags the loss anyway, drops the (potentially
/// truncated) open PES, and stamps the first post-gap PES.
#[test]
fn conceal_marker_forces_discontinuity_with_in_sequence_cc() {
let pid = 0x1011;
let mut demux = TsDemuxer::new(&[pid]);
// A (cc=0) opens; B's PUSI (cc=1) flushes A clean.
demux.feed(&ts_payload_packet(pid, true, 0, &pes_start(b"AAAA")));
let out = demux.feed(&ts_payload_packet(pid, true, 1, &pes_start(b"BBBB")));
assert_eq!(&out[0].data[..4], b"AAAA");
assert!(!out[0].discontinuity);
// Concealment marker: a unit was dropped. B is open → potentially truncated
// → dropped. CC is NOT consulted.
assert!(
demux.feed(&null_marker_packet()).is_empty(),
"marker emits nothing itself"
);
// C (cc=2) is EXACTLY in-sequence after B's cc=1 — as if a multiple of 16
// packets were lost, so `cc_gap` is false. The marker is the only signal.
let out = demux.feed(&ts_payload_packet(pid, true, 2, &pes_start(b"CCCC")));
assert!(
out.is_empty(),
"the truncated open PES (B) is dropped, not emitted"
);
let out = demux.flush();
assert_eq!(out.len(), 1);
assert_eq!(&out[0].data[..4], b"CCCC");
assert!(
out[0].discontinuity,
"marker flags the post-gap PES despite in-sequence CC (16-aligned blind spot)"
);
}
/// HOLE 4 (leading loss). If the disc's very first unit is undecryptable the
/// first surviving packet has no predecessor CC (`last_cc == None`), so CC
/// detection is blind. The marker still flags the first PES.
#[test]
fn conceal_marker_at_stream_start_flags_first_pes() {
let pid = 0x1011;
let mut demux = TsDemuxer::new(&[pid]);
// Marker FIRST — no prior CC exists for this PID.
assert!(demux.feed(&null_marker_packet()).is_empty());
// The first real PES of the PID.
let out = demux.feed(&ts_payload_packet(pid, true, 0, &pes_start(b"AAAA")));
assert!(out.is_empty());
let out = demux.flush();
assert_eq!(out.len(), 1);
assert_eq!(&out[0].data[..4], b"AAAA");
assert!(
out[0].discontinuity,
"leading concealed loss flags the first PES (last_cc == None case)"
);
}
/// In-sequence continuation (cc 0 -> 1) must still splice normally — the
/// continuity check must not break the happy path.
#[test]
+3 -3
View File
@@ -1,7 +1,7 @@
//! Platform-specific filesystem / IO helpers.
//!
//! Drive unlock no longer lives here — it moved out behind the pluggable
//! [`crate::unlock::Unlocker`] seam. This module now carries only the
//! filesystem-type detection used by the writeback / sink paths.
//! Drive unlock no longer lives here — it moved out to the `freemkv-unlock`
//! crate (consumed via [`crate::unlock_bridge`]). This module now carries only
//! the filesystem-type detection used by the writeback / sink paths.
pub mod fs_type;
+56 -1
View File
@@ -35,6 +35,51 @@ pub enum PassKind {
Verify,
}
/// One located bad/not-yet-good range, annotated with the chapter and movie
/// time it falls in. This is the *rendered* drilldown a client draws: the LBA
/// and sector count place it on the disc map, while `chapter` and
/// `time_offset_secs` tell the user *what* is affected. Computed by the library
/// (which owns the mapfile and title) so no client ever re-derives it. If the
/// mapfile becomes a mapdb, this type and its producer change; clients don't.
#[derive(Debug, Clone)]
pub struct LocatedRange {
/// First sector (LBA) of the range.
pub lba: u64,
/// Length of the range in sectors.
pub count: u32,
/// Movie time this range spans, in milliseconds (range bytes ÷ title
/// bytes/sec). Used to sort the drilldown and size the "largest gap".
pub duration_ms: f64,
/// 1-based chapter the range falls in, if it lands inside the title.
pub chapter: Option<u32>,
/// Movie time offset (seconds) where the range begins, if in-title.
pub time_offset_secs: Option<f64>,
}
/// The fully-rendered "where is the damage" view for one progress sample: the
/// located drilldown plus the derived movie-time figures. A client maps this
/// straight to its UI — it never touches the mapfile. `Default` is the empty
/// (no-damage / not-applicable) view, used by phases that don't locate ranges
/// (verify, extract, mux-label).
#[derive(Debug, Clone, Default)]
pub struct LocatedProgress {
/// Located not-yet-good ranges, largest-movie-time first, capped (see
/// `truncated`).
pub ranges: Vec<LocatedRange>,
/// Total number of located ranges before the cap (so a client can say
/// "N sections").
pub num_ranges: u32,
/// How many ranges were dropped by the display cap (`ranges.len()` is the
/// kept count; this is the "+X more").
pub truncated: u32,
/// Main-feature movie time still at risk: duration of the not-yet-good
/// ranges that intersect the title extents, in milliseconds. `0` when all
/// damage is out-of-feature (menus/extras).
pub main_at_risk_ms: f64,
/// Movie time of the single largest range, in milliseconds.
pub largest_gap_ms: f64,
}
/// One progress sample from a pipeline phase.
///
/// `work_done / work_total` is the per-pass percentage — always 0..=100%
@@ -48,7 +93,12 @@ pub enum PassKind {
/// - `bytes_good_total` = good + slow + recovered sectors × 2048
/// - `bytes_unreadable_total` = bad sectors × 2048
/// - `bytes_pending_total` = 0 (verify processes sequentially, nothing pending)
#[derive(Debug, Clone, Copy)]
///
/// NOT `Copy`: `located` carries a `Vec`. Constructed once per (throttled)
/// emission and passed by reference to `Progress::report`, so this costs one
/// small heap alloc per UI tick — cheap, and it makes `PassProgress` the single
/// complete contract a client renders from.
#[derive(Debug, Clone)]
pub struct PassProgress {
pub kind: PassKind,
pub work_done: u64,
@@ -72,6 +122,11 @@ pub struct PassProgress {
pub main_title_duration_secs: Option<f64>,
/// Main title size in bytes (sum of extent sizes).
pub main_title_size_bytes: Option<u64>,
/// The fully-rendered "where is the damage" drilldown for this sample:
/// located ranges + at-risk movie time. Empty (`Default`) for phases that
/// don't locate ranges. A client renders the disc map + section list from
/// this and NEVER reads the mapfile itself.
pub located: LocatedProgress,
}
impl PassProgress {
+511 -124
View File
@@ -38,16 +38,6 @@ use super::SectorSource;
/// so it can ride the mux highway's producer thread.
pub type KeyFetch = std::sync::Arc<dyn Fn(&[Vec<u8>]) -> Vec<[u8; 16]> + Send + Sync>;
/// Cap on how many times one decorator will call the fetch closure over its
/// lifetime — bounds key-server traffic to roughly O(distinct CPS units) even
/// if scrambled units keep arriving. A disc has only a handful of unit keys.
const MAX_FETCH_CALLS: usize = 16;
/// Cap on how many still-scrambled sample units are handed to the fetch
/// closure per call — a few samples are plenty for a key service to identify
/// and validate the key, and it bounds the request size.
const MAX_FETCH_SAMPLES: usize = 8;
/// Cap on how many per-unit decrypt-verify-failure diagnostics one read emits.
/// The diagnostic runs only on the failure (cold) path and bounds log volume so
/// a large undecryptable range can't flood the device log; the first few units
@@ -99,27 +89,22 @@ pub struct DecryptingSectorSource<S: SectorSource> {
unit_base: u32,
/// Cumulative bytes of scrambled AACS units that no key could decrypt.
/// `decrypt_sectors` restores those bytes to their original ciphertext (so a
/// clear nav-file is never corrupted), but for genuine encrypted content the
/// still-encrypted bytes are silently dropped by the downstream TS assembler
/// — real, unaccounted loss. Mux read paths share this counter into their
/// loss accounting (via [`decrypt_loss`]) so a partial AACS/CSS decrypt
/// clear nav-file is never corrupted). On the mux read path
/// (`tolerate_decrypt_loss`) such content is concealed as NULL-TS and tallied
/// here (not silently dropped); on the rip path the read fails loud for
/// re-read. Either way this counter is the loss signal: mux read paths fold it
/// into their accounting (via [`decrypt_loss`]) so a partial AACS/CSS decrypt
/// failure can't be reported as a perfect rip. Shared `Arc` so the highway's
/// producer thread and the consuming `Stream` see the same tally.
///
/// [`decrypt_loss`]: Self::decrypt_loss
decrypt_dropped: Arc<AtomicU64>,
/// Optional "fetch a fresh key for THIS data" callback (see [`KeyFetch`]).
/// `None` for the common case (keys fully resolved up front); set via
/// [`with_key_fetch`](Self::with_key_fetch) by an application that wants
/// to ask its key source for a key when a unit fails to decrypt.
fetch: Option<KeyFetch>,
/// Latched once a fetch call returns no NEW key — further failures on this
/// decorator then skip the callback (the source has nothing more to offer, so
/// re-asking would only burn key-server requests).
fetch_spent: bool,
/// How many times the fetch closure has been invoked, capped at
/// [`MAX_FETCH_CALLS`].
fetch_calls: usize,
/// The miss policy (see [`crate::sector::recovery::Recover`]) — a generic,
/// scheme-neutral recovery the input stream (L3) installs and this decorator
/// (L2) executes at the one seam when a content unit will not decrypt. `None`
/// = no recovery (a miss is loss). Installed via
/// [`with_key_fetch`](Self::with_key_fetch).
recovery: Option<crate::sector::recovery::Recover>,
/// Verify-only mode: a read decrypt-CHECKS a scratch copy of the bytes (to
/// detect undecryptable units) but NEVER mutates `buf` — the inner
/// ciphertext is returned unchanged. This is what makes a multipass sweep
@@ -140,6 +125,20 @@ pub struct DecryptingSectorSource<S: SectorSource> {
/// Reused scratch buffer for verify-only decrypt checks — avoids a per-read
/// allocation on the sweep's hot path. Grown on demand, never shrunk.
scratch: Vec<u8>,
/// MUX loss-concealment switch (P3 / Edit-2). When `true`, a content unit
/// that genuinely won't decrypt is NOT a read failure: it is overwritten with
/// valid NULL TS packets ([`crate::aacs::content::fill_null_ts_unit`]), tallied into
/// [`decrypt_dropped`](Self::decrypt_dropped), logged loud with its LBA, and
/// the read returns `Ok` so the mux KEEPS GOING (it can never abort over an
/// undecryptable unit). This is the spec's "decrypt-verify is a RIP gate, not
/// a MUX gate": the rip path leaves this `false` (default) and fails loud via
/// [`DECRYPT_VERIFY_READ`] so its read-error recovery re-reads the disc; only
/// the mux read path opts in. Ciphertext is NEVER passed downstream either
/// way — fail-loud re-reads it, conceal replaces it with null packets.
///
/// Mutually meaningful only with `!verify_only` (the in-place decrypt path
/// the mux uses); a verify-only sweep keeps the rip's fail-loud contract.
tolerate_decrypt_loss: bool,
}
impl<S: SectorSource> DecryptingSectorSource<S> {
@@ -155,20 +154,32 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
unit_key_idx: 0,
unit_base: 0,
decrypt_dropped: Arc::new(AtomicU64::new(0)),
fetch: None,
fetch_spent: false,
fetch_calls: 0,
// No recovery by default. CSS self-decrypts in `decrypt_sectors`
// (needs no external input); AACS installs a key-fetch via
// `with_key_fetch`.
recovery: None,
verify_only: false,
content_ranges: None,
scratch: Vec::new(),
tolerate_decrypt_loss: false,
}
}
/// Opt into MUX loss-concealment: an undecryptable content unit is concealed
/// (filled with NULL TS packets), tallied, logged loud, and the read still
/// succeeds — the mux never aborts over it. See
/// [`tolerate_decrypt_loss`](Self::tolerate_decrypt_loss). The rip path must
/// NOT set this (it relies on fail-loud read-error recovery).
pub fn tolerate_decrypt_loss(mut self) -> Self {
self.tolerate_decrypt_loss = true;
self
}
/// Restrict decrypt/verify to the disc's encrypted-content extents
/// (sorted/merged `(start_lba, sector_count)` — see
/// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)).
/// Units outside content (UDF filesystem / BDMV nav) pass through untouched,
/// so [`ts_sync_destroyed`](crate::aacs::ts_sync_destroyed) is never consulted
/// so [`ts_sync_destroyed`](crate::aacs::content::ts_sync_destroyed) is never consulted
/// about non-content bytes. Whole-disc readers (sweep / patch) set this; the
/// mux leaves it unset because it only ever reads title extents.
pub fn with_content_ranges(mut self, ranges: Arc<[(u32, u32)]>) -> Self {
@@ -209,7 +220,7 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
/// for [`DecryptKeys::Aacs`]; ignored otherwise. The library makes no network
/// call — `cb` is the application's seam to its key source.
pub fn with_key_fetch(mut self, cb: KeyFetch) -> Self {
self.fetch = Some(cb);
self.recovery = Some(crate::sector::recovery::key_fetch(cb));
self
}
@@ -257,66 +268,6 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
}
}
/// Collect the still-scrambled aligned units in `buf`, hand them to the
/// fetch callback, add any returned keys not already held to the AACS
/// pool (the CACHE — every later unit this pass, and any later read, reuses
/// them), and re-decrypt `buf`. Returns the post-retry dropped-byte count
/// (equal to `prev_dropped` when the callback could not help). The re-decrypt
/// is content-gated identically to the first read so a non-content unit is
/// never re-attempted. Caller guarantees the keys are `DecryptKeys::Aacs`, a
/// callback is installed, and the call budget is not yet spent.
fn fetch_failed_units(
&mut self,
buf: &mut [u8],
lba: u32,
content: Option<&[(u32, u32)]>,
prev_dropped: usize,
) -> usize {
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
// Gather up to MAX_FETCH_SAMPLES still-scrambled aligned units — the
// exact on-disc ciphertext no held key could open. A trailing partial
// unit (chunks_exact remainder) can't be a whole scrambled unit, so
// skipping it is correct.
let mut samples: Vec<Vec<u8>> = Vec::new();
for chunk in buf.chunks_exact(unit_len) {
if crate::aacs::aacs_unit_needs_decrypt(chunk) {
samples.push(chunk.to_vec());
if samples.len() >= MAX_FETCH_SAMPLES {
break;
}
}
}
if samples.is_empty() {
return prev_dropped;
}
// Ask the application's key source for keys that open this ciphertext.
self.fetch_calls += 1;
let fresh = match self.fetch.as_ref() {
Some(cb) => cb(&samples),
None => return prev_dropped,
};
// Add only keys we don't already hold (dedup by value).
let mut added = 0usize;
if let DecryptKeys::Aacs { unit_keys, .. } = &mut self.keys {
for k in fresh {
if !unit_keys.iter().any(|(_, have)| *have == k) {
let idx = unit_keys.len() as u32;
unit_keys.push((idx, k));
added += 1;
}
}
}
if added == 0 {
// Nothing new — stop asking for the rest of this decorator's life.
self.fetch_spent = true;
return prev_dropped;
}
// Retry now that the pool has grown; a unit that still won't decrypt is
// genuine loss. A retry error must not mask the original count.
Self::decrypt_buf(buf, &mut self.keys, self.unit_key_idx, lba, content)
.unwrap_or(prev_dropped)
}
/// Emit a bounded, structured diagnostic for each undecryptable unit in a
/// failed verify read. Called only on the failure (cold) path. On a fresh
/// rip `buf` holds the post-decrypt bytes straight off the drive, so the
@@ -342,7 +293,7 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
content: Option<&[(u32, u32)]>,
keys: &DecryptKeys,
) {
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
let unit_sectors = (unit_len / 2048) as u32;
// Only AACS produces decrypt-verify failures; None / CSS never reach here
// with a non-zero dropped count.
@@ -366,12 +317,12 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
// A unit that decrypted is no longer sync-destroyed; a CPI-clear or
// non-content unit is gated out. Only undecryptable in-content units
// that are flagged encrypted carry signal.
if !in_content || !crate::aacs::aacs_unit_needs_decrypt(chunk) {
if !in_content || !crate::aacs::content::aacs_unit_needs_decrypt(chunk) {
continue;
}
let all_zero = chunk.iter().all(|&b| b == 0);
let ts_sync = crate::aacs::ts_sync_count(chunk);
let ts_total = crate::aacs::ts_packet_total(chunk);
let ts_sync = crate::aacs::content::ts_sync_count(chunk);
let ts_total = crate::aacs::content::ts_packet_total(chunk);
let mut seen = [false; 256];
for &b in chunk {
seen[b as usize] = true;
@@ -382,10 +333,10 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
for (_, k) in unit_keys.iter() {
let mut attempt = chunk.to_vec();
if let Some(ref rdk_key) = rdk {
crate::aacs::decrypt_bus(&mut attempt, rdk_key);
crate::aacs::content::decrypt_bus(&mut attempt, rdk_key);
}
crate::aacs::decrypt_unit(&mut attempt, k);
let s = crate::aacs::ts_sync_count(&attempt);
crate::aacs::content::decrypt_unit(&mut attempt, k);
let s = crate::aacs::content::ts_sync_count(&attempt);
if s > best_sync {
best_sync = s;
}
@@ -421,6 +372,20 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
// Bulk path: no Force Unit Access (the cache IS the streaming
// throughput). FUA is a Pass-N recovery lever threaded through
// `read_sectors_fua`.
self.read_sectors_fua(lba, count, buf, recovery, false)
}
fn read_sectors_fua(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
fua: bool,
) -> Result<usize> {
// Defense-in-depth: AACS aligned units are 3 sectors (6144 bytes) and
// `decrypt_sectors` anchors units at buffer offset 0. A read that does
@@ -434,12 +399,14 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// units (else its readable units are wrongly rejected → "Decryption
// failed" on exactly those titles).
if matches!(self.keys, DecryptKeys::Aacs { .. })
&& !crate::aacs::is_unit_aligned(lba, self.unit_base)
&& !crate::aacs::content::is_unit_aligned(lba, self.unit_base)
{
return Err(crate::error::Error::DecryptFailed);
}
let read_t0 = std::time::Instant::now();
let n = self.inner.read_sectors(lba, count, buf, recovery)?;
let n = self
.inner
.read_sectors_fua(lba, count, buf, recovery, fua)?;
let read_ms = read_t0.elapsed().as_millis() as u64;
// Decrypt the bytes just read. Scheme-agnostic: `decrypt_sectors*`
// dispatches on the keys (None / CSS / AACS) and returns the count of
@@ -456,9 +423,11 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// recover a unit no held key opened.
let content = self.content_ranges.clone(); // cheap Arc bump; frees the &self borrow
let content_ref = content.as_deref();
// Whether a fresh-key fetch is still worth attempting on this decorator.
let fetch_viable =
!self.fetch_spent && self.fetch.is_some() && self.fetch_calls < MAX_FETCH_CALLS;
// Copy out the small Copy fields the seam needs, so the `&mut self.recovery`
// borrow below does not collide with reads of other `self` fields. The
// recovery closure self-limits (its budget lives in its captures), so the
// decorator simply calls it whenever there is a miss.
let unit_key_idx = self.unit_key_idx;
// First decrypt, then the FRESH-KEY-ON-FAILURE retry (read → decrypt → on
// fail fetch a new key → retry → CACHE or fail). This runs in BOTH modes:
// * VERIFY-ONLY (multipass sweep): decrypt a reused SCRATCH copy so `buf`
@@ -473,11 +442,11 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// * NORMAL (mux / --no-raw): decrypt `buf` in place, same retry.
// The fetch re-decrypt targets the post-decrypt buffer (scratch / buf),
// whose still-scrambled units ARE the failures.
let dropped = if self.verify_only {
let outcome = if self.verify_only {
let mut scratch = std::mem::take(&mut self.scratch);
scratch.clear();
scratch.extend_from_slice(&buf[..n]);
let mut d = match Self::decrypt_buf(
let d = match Self::decrypt_buf(
&mut scratch,
&mut self.keys,
self.unit_key_idx,
@@ -490,27 +459,122 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
return Err(e);
}
};
if d > 0 && fetch_viable {
d = self.fetch_failed_units(&mut scratch, lba, content_ref, d);
}
let o = match (d, self.recovery.as_mut()) {
(0, _) | (_, None) => crate::sector::recovery::MissOutcome { dropped: d },
(d, Some(r)) => {
let rctx = crate::sector::recovery::RecoverCtx {
unit_key_idx,
lba,
content: content.clone(),
prev_dropped: d,
};
r(&mut scratch, &mut self.keys, &rctx)
}
};
self.scratch = scratch;
d
o
} else {
let mut d = Self::decrypt_buf(
let d = Self::decrypt_buf(
&mut buf[..n],
&mut self.keys,
self.unit_key_idx,
lba,
content_ref,
)?;
if d > 0 && fetch_viable {
d = self.fetch_failed_units(&mut buf[..n], lba, content_ref, d);
match (d, self.recovery.as_mut()) {
(0, _) | (_, None) => crate::sector::recovery::MissOutcome { dropped: d },
(d, Some(r)) => {
let rctx = crate::sector::recovery::RecoverCtx {
unit_key_idx,
lba,
content: content.clone(),
prev_dropped: d,
};
r(&mut buf[..n], &mut self.keys, &rctx)
}
}
d
};
// A loss is a loss: whatever recovery could not decrypt (a missing unit
// key, or an AACS 2.1 forensic-segment unit with no variant key — same
// thing to the read path) is concealed and counted the same way.
let dropped = outcome.dropped;
if dropped > 0 {
self.decrypt_dropped
.fetch_add(dropped as u64, Ordering::Relaxed);
// MUX CONCEALMENT (P3 / Edit-2): on the mux read path an undecryptable
// content unit is NOT a read failure — never abort the mux over it.
// Overwrite each still-scrambled in-content unit with valid NULL TS
// packets (A2: keeps the demuxer byte-synced; the lost video/audio PID
// packets surface as a CC gap the TS assembler already drops a partial
// PES on), tally it (done above), log it LOUD with the LBA, and return
// Ok so the stream keeps flowing. Verify-only (sweep) is excluded — the
// rip stays fail-loud. Ciphertext is never passed downstream: it is
// replaced by null packets, not emitted.
if self.tolerate_decrypt_loss && !self.verify_only {
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
let mut concealed = 0usize;
let mut first_lba = lba;
for (i, chunk) in buf[..n].chunks_mut(unit_len).enumerate() {
if chunk.len() < unit_len {
continue; // trailing partial can't be a whole scrambled unit
}
// Conceal ONLY a unit that is GENUINELY still ciphertext, using
// the PADDING-AWARE test that matches `decrypt_unit`'s success
// criterion — NOT the majority-vote `aacs_unit_needs_decrypt`.
// A successfully padding-aware-decrypted content-fragment TAIL
// (the 1.2.0 fix: a few real packets + source-zero padding) has
// <16 TS syncs, so the majority vote would mis-flag it as
// "needs decrypt" and overwrite GOOD video with NULL-TS. The
// padding-aware predicate excludes zero-payload (padding) packets
// and flags the unit only when a real content packet is still
// un-restored ciphertext. In-content gating already happened in
// `decrypt_buf`, which restored only failed units to ciphertext;
// clear nav and decrypted tails pass through clean.
if crate::aacs::content::aacs_unit_still_ciphertext(chunk) {
if concealed == 0 {
first_lba =
lba + (i as u32) * crate::aacs::content::ALIGNED_UNIT_SECTORS;
}
crate::aacs::content::fill_null_ts_unit(chunk);
concealed += 1;
}
}
if concealed > 0 {
tracing::warn!(
target: "freemkv::decrypt",
lba = first_lba,
units = concealed,
bytes = dropped,
"mux: undecryptable content concealed as NULL TS (loss tallied)"
);
} else {
// dropped > 0 (decrypt reported undecryptable bytes) yet the
// padding-aware predicate matched NOTHING to conceal — a
// contradiction: the only way a genuinely-ciphertext unit passes
// `aacs_unit_still_ciphertext` is if every one of its non-zero
// packets coincidentally carried a 0x47 (~256^-31). Belt-and-
// suspenders so ciphertext can NEVER reach the mux: fall back to
// the strict majority-vote predicate and conceal whatever it
// flags, loudly. Cryptographically unreachable in practice.
let mut forced = 0usize;
for chunk in buf[..n].chunks_mut(unit_len) {
if chunk.len() == unit_len
&& crate::aacs::content::aacs_unit_needs_decrypt(chunk)
{
crate::aacs::content::fill_null_ts_unit(chunk);
forced += 1;
}
}
tracing::warn!(
target: "freemkv::decrypt",
lba,
bytes = dropped,
forced,
"mux: decrypt reported loss but padding-aware conceal matched nothing; forced strict conceal (unexpected)"
);
}
return Ok(n);
}
// DECRYPT_VERIFY_READ: a unit that SHOULD have decrypted but didn't
// means this read did NOT truly succeed — it returned ciphertext the
// TS assembler would silently drop. Fail the read loud so the caller's
@@ -518,6 +582,11 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// fails (no clean data to mux). Scheme-agnostic (only AACS reaches a
// non-zero count); clear filesystem (gated out) and zero-fill (not
// scrambled) never get here.
//
// An undecryptable unit is an undecryptable unit whatever the scheme —
// a missing unit key or an AACS 2.1 forensic-segment unit with no
// variant key both land here and fail the verify read the same way.
// (`dropped > 0` already holds inside the enclosing block.)
if DECRYPT_VERIFY_READ {
// FACT-FINDING: on a fresh rip these bytes came straight off the
// drive, so each failing unit's signature (all-zero? entropy?
@@ -1118,12 +1187,12 @@ mod tests {
}
/// Build a clear 6144-byte AACS unit (TS syncs at the BD-TS stride) then
/// encrypt it under `unit_key` so `aacs::decrypt_unit` recovers it. Mirrors
/// encrypt it under `unit_key` so `aacs::content::decrypt_unit` recovers it. Mirrors
/// the encrypt helper in `crate::decrypt`'s tests.
fn encrypt_aacs_unit(unit_key: &[u8; 16]) -> Vec<u8> {
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < unit.len() {
unit[off] = 0x47;
@@ -1132,14 +1201,14 @@ mod tests {
// CPI bits on byte 0 so it reads as encrypted; set before key derivation.
unit[0] |= 0xC0;
let header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16];
for i in 0..16 {
k[i] = derived[i] ^ header[i];
}
let cipher = Aes128::new(GenericArray::from_slice(&k));
let mut prev = crate::aacs::decrypt::AACS_IV;
let blocks = (crate::aacs::ALIGNED_UNIT_LEN - 16) / 16;
let mut prev = crate::aacs::crypto::AACS_IV;
let blocks = (crate::aacs::content::ALIGNED_UNIT_LEN - 16) / 16;
for i in 0..blocks {
let o = 16 + i * 16;
for j in 0..16 {
@@ -1212,7 +1281,7 @@ mod tests {
);
assert_eq!(
loss.load(Ordering::Relaxed),
crate::aacs::ALIGNED_UNIT_LEN as u64,
crate::aacs::content::ALIGNED_UNIT_LEN as u64,
"the undecryptable unit is tallied as loss before the read errors"
);
@@ -1223,7 +1292,7 @@ mod tests {
);
assert_eq!(
loss.load(Ordering::Relaxed),
2 * crate::aacs::ALIGNED_UNIT_LEN as u64,
2 * crate::aacs::content::ALIGNED_UNIT_LEN as u64,
"loss must accumulate across reads"
);
@@ -1244,6 +1313,243 @@ mod tests {
);
}
/// MUX CONCEALMENT (P3): with `tolerate_decrypt_loss()` an undecryptable AACS
/// content unit must NOT fail the read. Instead the decorator (a) tallies the
/// loss, (b) overwrites the unit with valid NULL TS packets (PID 0x1FFF, sync
/// 0x47 at the BD-TS stride), and (c) returns `Ok` so the mux keeps going.
/// This is the inverse of the fail-loud rip path proven directly above.
#[test]
fn tolerate_decrypt_loss_conceals_undecryptable_unit_as_null_ts() {
let real_key = [0x33u8; 16];
let wrong_key = [0x44u8; 16];
// One unit encrypted under real_key, plus one trailing CLEAR (TS-sync)
// unit so we can confirm conceal touches ONLY the undecryptable unit.
let enc = encrypt_aacs_unit(&real_key);
let mut clear = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
let mut o = 4;
while o < clear.len() {
clear[o] = 0x47;
o += 192;
}
let mut two_units = enc;
two_units.extend_from_slice(&clear);
struct TwoUnitSource {
data: Vec<u8>,
}
impl SectorSource for TwoUnitSource {
fn capacity_sectors(&self) -> u32 {
(self.data.len() / 2048) as u32
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].copy_from_slice(&self.data[..bytes]);
Ok(bytes)
}
}
let mut wrapped = DecryptingSectorSource::new(
TwoUnitSource { data: two_units },
DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)], // can't open the encrypted unit
read_data_key: None,
},
)
.tolerate_decrypt_loss();
let loss = wrapped.decrypt_loss();
let mut buf = vec![0u8; 6 * 2048];
// Must SUCCEED (no DecryptFailed) — the mux never aborts on bad decrypt.
let n = wrapped
.read_sectors(0, 6, &mut buf, false)
.expect("tolerate_decrypt_loss must conceal, not error");
assert_eq!(n, 6 * 2048);
// The undecryptable unit is tallied as loss.
assert_eq!(
loss.load(Ordering::Relaxed),
crate::aacs::content::ALIGNED_UNIT_LEN as u64,
"the concealed unit is still counted as loss"
);
// Unit 0 is now valid NULL TS packets — sync 0x47 at every 192-byte
// stride (offset 4), PID 0x1FFF — and carries no ciphertext.
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
let mut off = 0;
while off + 192 <= unit0.len() {
assert_eq!(unit0[off + 4], 0x47, "null packet sync at {off}");
assert_eq!(unit0[off + 5] & 0x1F, 0x1F, "PID high bits 0x1FFF");
assert_eq!(unit0[off + 6], 0xFF, "PID low byte 0xFF");
off += 192;
}
assert!(
!crate::aacs::content::ts_sync_destroyed(unit0),
"concealed unit reads as well-formed TS, not scrambled"
);
// Unit 1 (clear) passed through untouched.
let unit1 = &buf
[crate::aacs::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
assert_eq!(unit1, &clear[..], "the clear unit is left exactly as read");
}
/// REGRESSION (silent-data-loss): the conceal loop must NOT overwrite a
/// SUCCESSFULLY-decrypted content-fragment TAIL unit. Such a tail (a few real
/// content packets + source-zero padding — the 1.2.0 shape) carries <16 TS
/// syncs after decrypt, so the old majority-vote `aacs_unit_needs_decrypt`
/// predicate mis-flagged it as "still needs decrypt" and, when it shared a read
/// buffer with a genuinely-undecryptable unit (`dropped > 0`), NULL-TS-filled
/// the GOOD decrypted video. The padding-aware `aacs_unit_still_ciphertext`
/// predicate must conceal ONLY the genuinely-undecryptable unit and leave the
/// good tail byte-for-byte intact.
#[test]
fn conceal_leaves_decrypted_padding_tail_unit_intact() {
let bad_key = [0x77u8; 16]; // encrypts the undecryptable unit (NOT provided)
let good_key = [0x33u8; 16]; // encrypts the padding-tail unit (provided)
// Unit A: a full content unit encrypted under `bad_key` — with only
// `good_key` in the pool it cannot be decrypted → restored to ciphertext.
let bad_unit = encrypt_aacs_unit(&bad_key);
// Unit B: a SHORT-PADDING-TAIL unit — encrypt a full clear unit under
// `good_key`, then zero the trailing source packets (from packet 11 on) so
// they decrypt back to clean zero padding. Only 11 of 32 packets are real
// content → 11 TS syncs after decrypt (well under the majority-vote 16).
const KEEP: usize = 11;
let mut good_tail = encrypt_aacs_unit(&good_key);
for b in good_tail[KEEP * 192..].iter_mut() {
*b = 0;
}
// The byte-exact expected post-decrypt form of unit B (independent decrypt).
let mut expected_tail = good_tail.clone();
assert!(
crate::aacs::content::decrypt_unit(&mut expected_tail, &good_key),
"padding-tail must decrypt under good_key"
);
let mut two_units = bad_unit;
two_units.extend_from_slice(&good_tail);
struct TwoUnitSource {
data: Vec<u8>,
}
impl SectorSource for TwoUnitSource {
fn capacity_sectors(&self) -> u32 {
(self.data.len() / 2048) as u32
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].copy_from_slice(&self.data[..bytes]);
Ok(bytes)
}
}
let mut wrapped = DecryptingSectorSource::new(
TwoUnitSource { data: two_units },
DecryptKeys::Aacs {
unit_keys: vec![(0, good_key)], // opens unit B, NOT unit A
read_data_key: None,
},
)
.tolerate_decrypt_loss();
let loss = wrapped.decrypt_loss();
let mut buf = vec![0u8; 6 * 2048];
let n = wrapped
.read_sectors(0, 6, &mut buf, false)
.expect("tolerate_decrypt_loss must conceal, not error");
assert_eq!(n, 6 * 2048);
// ONLY the genuinely-undecryptable unit A is tallied / concealed.
assert_eq!(
loss.load(Ordering::Relaxed),
crate::aacs::content::ALIGNED_UNIT_LEN as u64,
"exactly one unit (the undecryptable one) is counted as loss"
);
// Unit A → NULL TS (concealed).
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
let mut off = 0;
while off + 192 <= unit0.len() {
assert_eq!(unit0[off + 4], 0x47, "unit A null packet sync at {off}");
assert_eq!(
unit0[off + 6],
0xFF,
"unit A null packet PID low 0xFF at {off}"
);
off += 192;
}
// Unit B → the GOOD decrypted padding tail, byte-for-byte intact (NOT
// overwritten with NULL TS). This is the silent-data-loss the old
// majority-vote predicate caused.
let unit1 = &buf
[crate::aacs::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
assert_eq!(
unit1,
&expected_tail[..],
"the decrypted padding-tail unit must be left byte-for-byte intact"
);
// Sanity: its real content packets carry their TS sync; its padding is zero.
for p in 0..KEEP {
assert_eq!(unit1[p * 192 + 4], 0x47, "content pkt {p} sync preserved");
}
for p in KEEP..32 {
let o = p * 192;
assert!(
unit1[o..o + 192].iter().all(|&b| b == 0),
"padding pkt {p} stayed zero (not NULL-TS-filled)"
);
}
}
/// `fill_null_ts_unit` round-trip: every BD source packet in the unit becomes
/// a well-formed TS null packet (PID 0x1FFF, invisible to any real PID) that
/// carries the B1 adaptation-field discontinuity_indicator — the marker
/// `mux::ts` reads as a concealed gap.
#[test]
fn null_ts_fill_is_well_formed_and_invisible_to_real_pids() {
let mut unit = vec![0xAAu8; crate::aacs::content::ALIGNED_UNIT_LEN];
crate::aacs::content::fill_null_ts_unit(&mut unit);
// 32 packets, each: sync 0x47, PID 0x1FFF, adaptation-only (0b10) with a
// discontinuity_indicator in the adaptation field.
let mut off = 0;
let mut pkts = 0;
while off + 192 <= unit.len() {
assert_eq!(unit[off + 4], 0x47, "sync");
let pid = ((unit[off + 5] as u16 & 0x1F) << 8) | unit[off + 6] as u16;
assert_eq!(pid, 0x1FFF, "null PID");
assert_eq!(
(unit[off + 7] >> 4) & 0x03,
0x02,
"adaptation_field_control = AF only (no payload)"
);
assert!(unit[off + 8] > 0, "adaptation_field_length > 0");
assert_eq!(
unit[off + 9] & 0x80,
0x80,
"adaptation-field discontinuity_indicator set (the B1 marker)"
);
off += 192;
pkts += 1;
}
assert_eq!(pkts, 32, "32 source packets per aligned unit");
}
/// Fresh-key-on-failure: a unit encrypted under a key NOT in the initial set
/// would normally count as decrypt loss. With a [`with_key_fetch`] callback
/// that returns that key, the decorator must hand the still-scrambled unit to
@@ -1309,7 +1615,7 @@ mod tests {
"callback must be invoked once with the failing unit"
);
assert!(
crate::aacs::ts_sync_destroyed(&got[0]),
crate::aacs::content::ts_sync_destroyed(&got[0]),
"the sample handed to the callback is the still-scrambled ciphertext"
);
assert_eq!(
@@ -1333,11 +1639,92 @@ mod tests {
);
assert_eq!(
nocb_loss.load(Ordering::Relaxed),
crate::aacs::ALIGNED_UNIT_LEN as u64,
crate::aacs::content::ALIGNED_UNIT_LEN as u64,
"without a fetch callback the undecryptable unit is loss"
);
}
/// A fetch that comes back EMPTY for one unit must NOT block a later fetch
/// for a DIFFERENT unit (the multi-CPS case). The old global `fetch_spent`
/// latch wrongly blocked it; the per-sample `fetch_dry` set must let unit B
/// be asked for after unit A came back dry.
#[test]
fn fetch_dry_does_not_block_a_distinct_later_unit() {
let key_a = [0x5au8; 16];
let key_b = [0x77u8; 16];
let unit_a = encrypt_aacs_unit(&key_a);
let unit_b = encrypt_aacs_unit(&key_b);
assert_ne!(unit_a, unit_b, "distinct ciphertext under distinct keys");
struct AltSource {
units: Vec<Vec<u8>>,
idx: usize,
}
impl SectorSource for AltSource {
fn capacity_sectors(&self) -> u32 {
6
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_r: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
let u = &self.units[self.idx.min(self.units.len() - 1)];
buf[..bytes].copy_from_slice(u);
self.idx += 1;
Ok(bytes)
}
}
// Callback serves key_b only when asked about unit B; nothing for A.
let unit_b_cb = unit_b.clone();
let calls = Arc::new(Mutex::new(0usize));
let calls_cb = Arc::clone(&calls);
let fetch: super::KeyFetch = std::sync::Arc::new(move |samples: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
if samples.iter().any(|s| *s == unit_b_cb) {
vec![key_b]
} else {
vec![]
}
});
let mut wrapped = DecryptingSectorSource::new(
AltSource {
units: vec![unit_a, unit_b],
idx: 0,
},
DecryptKeys::Aacs {
unit_keys: vec![(0, [0x11u8; 16])], // neither real key held up front
read_data_key: None,
},
)
.with_key_fetch(fetch);
// Read A: fetch fires, returns nothing → A undecryptable (read errors).
let mut buf = vec![0u8; 3 * 2048];
let _ = wrapped.read_sectors(0, 3, &mut buf, false);
// Read B: fetch must STILL fire (B's sample isn't in the dry set) and
// recover key_b → B decrypts cleanly.
let mut buf2 = vec![0u8; 3 * 2048];
wrapped
.read_sectors(3, 3, &mut buf2, false)
.expect("unit B recovers via its own fetch");
assert_eq!(
*calls.lock().unwrap(),
2,
"fetch fired for BOTH units — the dry result for A did not latch off B"
);
assert!(
!crate::aacs::content::ts_sync_destroyed(&buf2),
"unit B is decrypted after its on-demand fetch"
);
}
/// `into_inner` / `inner` / `inner_mut` must hand back the original
/// source unchanged. Grounding: the accessor methods.
#[test]
@@ -1448,7 +1835,7 @@ mod tests {
let mut u = 0;
while u < bytes {
buf[u] |= 0xC0;
u += crate::aacs::ALIGNED_UNIT_LEN;
u += crate::aacs::content::ALIGNED_UNIT_LEN;
}
Ok(bytes)
}
@@ -1547,7 +1934,7 @@ mod tests {
let mut u = 0;
while u < b {
buf[u] |= 0xC0; // CPI bits → reads as encrypted
u += crate::aacs::ALIGNED_UNIT_LEN;
u += crate::aacs::content::ALIGNED_UNIT_LEN;
}
Ok(b)
}
+50
View File
@@ -16,6 +16,7 @@
pub mod decrypting;
pub mod file;
pub mod prefetched;
pub mod recovery;
use crate::error::Result;
@@ -53,6 +54,33 @@ pub trait SectorSource: Send {
recovery: bool,
) -> Result<usize>;
/// Like [`read_sectors`], but with an explicit Force Unit Access request.
///
/// `fua = true` asks the drive to bypass its readahead cache and physically
/// re-fetch the medium — a Pass-N marginal-sector lever: a cached hit can
/// mask a *stochastic* sector that would land differently off the platter on
/// each physical read, so FuaRetry re-reads it FUA. It is never blanket-
/// applied to the bulk path (forcing every sequential read past the cache
/// collapses streaming throughput ~10×).
///
/// The default ignores `fua` and delegates to [`read_sectors`]: only a live
/// [`Drive`] sets the CDB bit; file- / memory-backed sources have no drive
/// cache to bypass, so FUA is meaningless to them.
///
/// [`read_sectors`]: SectorSource::read_sectors
/// [`Drive`]: crate::drive::Drive
fn read_sectors_fua(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
fua: bool,
) -> Result<usize> {
let _ = fua;
self.read_sectors(lba, count, buf, recovery)
}
/// Optional speed control for sources that map to a physical
/// drive. No-op for everything else.
fn set_speed(&mut self, _kbs: u16) {}
@@ -87,6 +115,17 @@ impl SectorSource for Box<dyn SectorSource> {
(**self).read_sectors(lba, count, buf, recovery)
}
fn read_sectors_fua(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
fua: bool,
) -> Result<usize> {
(**self).read_sectors_fua(lba, count, buf, recovery, fua)
}
fn set_speed(&mut self, kbs: u16) {
(**self).set_speed(kbs)
}
@@ -107,6 +146,17 @@ impl SectorSource for &mut (dyn SectorSource + '_) {
(**self).read_sectors(lba, count, buf, recovery)
}
fn read_sectors_fua(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
fua: bool,
) -> Result<usize> {
(**self).read_sectors_fua(lba, count, buf, recovery, fua)
}
fn set_speed(&mut self, kbs: u16) {
(**self).set_speed(kbs)
}
+1 -4
View File
@@ -358,7 +358,6 @@ impl PrefetchedSectorSource {
// and suppresses `self`'s own `Drop` (which would otherwise
// double-`join`), leaving NO extra live endpoint behind. This
// is the panic-free equivalent of the `Option::take` approach.
let total = self.total_sectors;
let me = std::mem::ManuallyDrop::new(self);
// SAFETY: `me` is `ManuallyDrop`, so none of these fields will
// be dropped by `me`. Each `ptr::read` performs exactly one
@@ -367,7 +366,7 @@ impl PrefetchedSectorSource {
let producer = unsafe { std::ptr::read(&me.producer) };
let rx = unsafe { std::ptr::read(&me.rx) };
let recycle = unsafe { std::ptr::read(&me.recycle_tx) };
(rx, recycle, PrefetchShell { producer, total })
(rx, recycle, PrefetchShell { producer })
}
}
@@ -376,8 +375,6 @@ impl PrefetchedSectorSource {
/// producer, even though the channels have been peeled off.
pub struct PrefetchShell {
producer: Option<JoinHandle<()>>,
#[allow(dead_code)]
total: u32,
}
impl Drop for PrefetchShell {
+330
View File
@@ -0,0 +1,330 @@
//! The recovery seam: what a read does when a content unit will not decrypt.
//!
//! Per-format miss policy does NOT belong in the generic decrypt decorator
//! (L2). The input stream (L3, e.g. [`crate::mux::disc::DiscStream`]) knows what
//! it is reading and installs a [`Recover`] at construction; the decorator
//! executes it at the one seam and honours the returned outcome. This keeps
//! "a DVD re-cracks, a BD/UHD fetches a fresh key" out of the decryptor, where
//! it would otherwise smear across `if`-branches.
//!
//! The recovery type ([`Recover`]) names **no encryption scheme**. It is a
//! generic `FnMut(&mut [u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome` that
//! operates on the generic [`DecryptKeys`] the whole decrypt path already uses,
//! so a scheme is never baked into the type — only into the factory that builds
//! a recovery:
//! * [`none`] — no recovery; a miss is loss (raw sweep / clear).
//! * [`key_fetch`] — AACS key-fetch: hand the failing ciphertext to the
//! application's key source and add any returned keys to the pool. An AACS
//! 2.1 forensic-segment unit that no key opens is just an undecryptable unit
//! like any other — a loss is a loss, with no FMTS-specific branch here.
//!
//! CSS is deliberately NOT on this seam — and the reason is precise: this seam is
//! for recovery that needs something `decrypt_sectors` does not have (an EXTERNAL
//! key source for AACS, a segment map for FMTS). CSS's title key changes per VOB
//! region and is re-cracked constantly, but always FROM THE DATA ITSELF — no
//! external input — so CSS SELF-recovers inside `decrypt_sectors` (see
//! [`crate::css::descramble_region`]). The generic type here would accept a CSS
//! recovery, but CSS has no reason to use it.
use crate::decrypt::DecryptKeys;
use crate::sector::KeyFetch;
use std::collections::HashSet;
use std::sync::Arc;
/// The result of running a recovery on a read's still-scrambled units: how many
/// bytes remain loss after recovery ran. A loss is a loss — an undecryptable
/// unit is concealed and counted the same whatever the scheme (an AACS 2.1
/// forensic-segment unit with no variant key is just another undecryptable
/// unit).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct MissOutcome {
/// Bytes that remain loss after recovery.
pub dropped: usize,
}
impl MissOutcome {
/// All `n` bytes are loss.
fn loss(n: usize) -> Self {
Self { dropped: n }
}
}
/// Cap on how many times one recovery will call its fetch closure over its
/// lifetime — bounds key-server traffic to ~O(distinct CPS units) even if
/// scrambled units keep arriving. A disc has only a handful of unit keys.
const MAX_FETCH_CALLS: usize = 16;
/// Cap on how many still-scrambled sample units are handed to the fetch closure
/// per call — a few samples suffice for a key service to identify and validate
/// the key, and it bounds the request size.
const MAX_FETCH_SAMPLES: usize = 8;
/// Stable per-run fingerprint of a failing unit's ciphertext, for the dedup set.
/// `DefaultHasher` is fixed-seed, so equal samples map to equal fingerprints
/// within a process — all the dedup needs.
fn sample_fp(sample: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
sample.hash(&mut h);
h.finish()
}
/// Re-decrypt `buf` after the key pool grew, content-gated identically to the
/// first read so a non-content unit is never re-attempted. Mirrors the
/// decorator's `decrypt_buf` dispatch.
fn redecrypt(
buf: &mut [u8],
keys: &mut DecryptKeys,
unit_key_idx: usize,
lba: u32,
content: Option<&[(u32, u32)]>,
prev_dropped: usize,
) -> usize {
match content {
Some(ranges) => {
crate::decrypt::decrypt_sectors_in_content(buf, keys, unit_key_idx, lba, ranges)
}
None => crate::decrypt::decrypt_sectors(buf, keys, unit_key_idx),
}
.unwrap_or(prev_dropped)
}
/// What a read hands a recovery on a miss: the disc's decrypt parameters and how
/// many bytes the held keys could not decrypt. Scheme-neutral — a recovery reads
/// only the generic [`DecryptKeys`] and these fields.
pub struct RecoverCtx {
/// Which AACS unit-key index the read decrypts with (ignored by non-AACS).
pub unit_key_idx: usize,
/// Base LBA of the read.
pub lba: u32,
/// The encrypted-content extent map, when the read is content-gated.
pub content: Option<Arc<[(u32, u32)]>>,
/// Bytes the held keys could not decrypt before recovery ran.
pub prev_dropped: usize,
}
/// A recovery: given a read's still-scrambled `buf` and the **generic**
/// [`DecryptKeys`], make units decrypt (crack or fetch a key into `keys`) and/or
/// classify the loss (see [`MissOutcome`]). The type names NO encryption scheme
/// — the installed recovery decides what to do with the generic keys, so any
/// scheme (an AACS key-fetch, a future CSS re-crack) is just a different
/// [`Recover`] the input stream installs. `FnMut` so per-recovery
/// state (the AACS dedup set / call budget) lives in the closure's captures with
/// no lock; `Send` so it can ride the mux highway's producer thread.
pub type Recover = Box<dyn FnMut(&mut [u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome + Send>;
/// The AACS key-fetch step used by [`key_fetch`]: gather the
/// still-scrambled units, ask `fetch` for keys, add any new ones to the pool and
/// re-decrypt. `dry` / `calls` are the caller-owned dedup set and call budget.
/// Returns the post-retry dropped-byte count.
fn aacs_fetch_step(
dry: &mut HashSet<u64>,
calls: &mut usize,
fetch: &KeyFetch,
buf: &mut [u8],
keys: &mut DecryptKeys,
ctx: &RecoverCtx,
) -> usize {
let prev_dropped = ctx.prev_dropped;
if *calls >= MAX_FETCH_CALLS {
return prev_dropped;
}
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
// Gather up to MAX_FETCH_SAMPLES still-scrambled aligned units — the exact
// on-disc ciphertext no held key could open. A trailing partial unit
// (chunks_exact remainder) can't be a whole scrambled unit, so skipping it is
// correct.
let mut samples: Vec<Vec<u8>> = Vec::new();
for chunk in buf.chunks_exact(unit_len) {
if crate::aacs::content::aacs_unit_needs_decrypt(chunk) {
samples.push(chunk.to_vec());
if samples.len() >= MAX_FETCH_SAMPLES {
break;
}
}
}
if samples.is_empty() {
return prev_dropped;
}
// Skip the call when EVERY failing unit here is one a prior fetch already
// came back empty for — re-asking identical ciphertext only burns a request.
// A unit not asked about yet (e.g. a second CPS unit) still gets its chance.
let fps: Vec<u64> = samples.iter().map(|s| sample_fp(s)).collect();
if fps.iter().all(|fp| dry.contains(fp)) {
return prev_dropped;
}
*calls += 1;
let fresh = (fetch)(&samples);
// Add only keys we don't already hold (dedup by value).
let mut added = 0usize;
if let DecryptKeys::Aacs { unit_keys, .. } = keys {
for k in fresh {
if !unit_keys.iter().any(|(_, have)| *have == k) {
let idx = unit_keys.len() as u32;
unit_keys.push((idx, k));
added += 1;
}
}
}
if added == 0 {
// Nothing new for THESE units — remember them so we don't re-ask the same
// ciphertext, but leave the door open for other units.
dry.extend(fps);
return prev_dropped;
}
// Retry now that the pool has grown; a unit that still won't decrypt is
// genuine loss. A retry error must not mask the original count.
redecrypt(
buf,
keys,
ctx.unit_key_idx,
ctx.lba,
ctx.content.as_deref(),
prev_dropped,
)
}
/// No recovery: a miss is loss. Equivalent to installing nothing — provided so a
/// caller that wants an explicit "give up" recovery has one.
pub fn none() -> Recover {
Box::new(|_buf, _keys, ctx| MissOutcome::loss(ctx.prev_dropped))
}
/// AACS key-fetch recovery (BD / UHD): on a miss, ask the application's key
/// source for a key that opens the failing ciphertext and add it to the pool.
pub fn key_fetch(fetch: KeyFetch) -> Recover {
let mut dry: HashSet<u64> = HashSet::new();
let mut calls: usize = 0;
Box::new(move |buf, keys, ctx| {
MissOutcome::loss(aacs_fetch_step(
&mut dry, &mut calls, &fetch, buf, keys, ctx,
))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aacs::content::ALIGNED_UNIT_LEN;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
/// A 6144-byte aligned unit that reads as still-scrambled: CPI bits set on
/// byte 0 (so `aacs_unit_encrypted` flags it) and every 192-byte TS-sync
/// probe position forced off 0x47. `tag` varies the whole body so distinct
/// tags produce distinct fingerprints (mirrors decrypt.rs `scrambled_region`).
fn scrambled_unit(tag: u8) -> Vec<u8> {
let len = ALIGNED_UNIT_LEN;
let mut v: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(31) ^ tag).collect();
let mut off = 4;
while off < len {
v[off] = 0xA5; // never a 0x47 sync
off += 192;
}
v[0] |= 0xC0; // CPI: reads as encrypted content
v
}
/// A recovery context reading at clip-relative `lba` with `prev` bytes the
/// held keys could not decrypt.
fn ctx(lba: u32, prev: usize) -> RecoverCtx {
RecoverCtx {
unit_key_idx: 0,
lba,
content: None,
prev_dropped: prev,
}
}
#[test]
fn none_recovers_nothing() {
let mut r = none();
let mut buf = scrambled_unit(0x33);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
};
let out = r(&mut buf, &mut keys, &ctx(0, 6144));
assert_eq!(out.dropped, 6144);
}
#[test]
fn key_fetch_adds_returned_keys_to_the_pool() {
// The fetch returns one key; it must be appended to the (empty) pool. We
// assert the pool grew (the decrypt itself is exercised end-to-end by the
// decorator's integration tests); here we pin the seam's key-plumbing.
let calls = Arc::new(AtomicUsize::new(0));
let c2 = Arc::clone(&calls);
let fetch: KeyFetch = Arc::new(move |samples: &[Vec<u8>]| {
c2.fetch_add(1, Ordering::SeqCst);
assert!(!samples.is_empty(), "failing ciphertext is forwarded");
vec![[0xAB; 16]]
});
let mut r = key_fetch(fetch);
let mut buf = scrambled_unit(0x33);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
};
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
assert_eq!(calls.load(Ordering::SeqCst), 1, "fetch called once");
let DecryptKeys::Aacs { unit_keys, .. } = &keys else {
unreachable!()
};
assert_eq!(unit_keys.len(), 1, "returned key added to the pool");
assert_eq!(unit_keys[0].1, [0xAB; 16]);
}
#[test]
fn key_fetch_does_not_re_ask_dry_ciphertext() {
// A fetch that returns nothing marks the ciphertext dry; a second miss on
// the SAME ciphertext must not call the fetch again.
let calls = Arc::new(AtomicUsize::new(0));
let c2 = Arc::clone(&calls);
let fetch: KeyFetch = Arc::new(move |_: &[Vec<u8>]| {
c2.fetch_add(1, Ordering::SeqCst);
Vec::new() // never helps
});
let mut r = key_fetch(fetch);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
};
let mut buf = scrambled_unit(0x44);
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
let mut buf2 = scrambled_unit(0x44); // identical ciphertext
r(&mut buf2, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"identical dry ciphertext is not re-asked"
);
}
#[test]
fn key_fetch_call_budget_bounds_fetches() {
let calls = Arc::new(AtomicUsize::new(0));
let c2 = Arc::clone(&calls);
let fetch: KeyFetch = Arc::new(move |_: &[Vec<u8>]| {
c2.fetch_add(1, Ordering::SeqCst);
Vec::new()
});
let mut r = key_fetch(fetch);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
};
// Distinct ciphertext each time so the dry-set never short-circuits; only
// the internal call budget should stop the fetch. The closure self-limits,
// so the decorator can call it unconditionally.
for i in 0..(MAX_FETCH_CALLS as u8 + 5) {
let mut buf = scrambled_unit(i);
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
}
assert_eq!(
calls.load(Ordering::SeqCst),
MAX_FETCH_CALLS,
"fetch is capped at MAX_FETCH_CALLS"
);
}
}
+224 -49
View File
@@ -325,53 +325,6 @@ impl UdfFs {
Ok(merged)
}
/// All sector ranges that contain data (metadata + all files including STREAM).
/// For full disc-to-ISO dumps — reads only allocated sectors, skips gaps.
pub fn all_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result<Vec<(u32, u32)>> {
let mut ranges = Vec::new();
// UDF structure sectors
let meta_end = self.metadata_start.saturating_add(self.metadata_sectors);
ranges.push((0, meta_end));
// Walk entire tree including STREAM directories
self.collect_all_file_ranges(reader, &self.root, &mut ranges)?;
// Merge overlapping/adjacent ranges and sort
ranges.sort_by_key(|r| r.0);
let merged = merge_ranges(&ranges);
Ok(merged)
}
fn collect_all_file_ranges(
&self,
reader: &mut dyn SectorSource,
entry: &DirEntry,
ranges: &mut Vec<(u32, u32)>,
) -> Result<()> {
for child in &entry.entries {
if child.is_dir {
self.collect_all_file_ranges(reader, child, ranges)?;
} else {
// Include the ICB sector
ranges.push((self.meta_to_abs(child.meta_lba)?, 1));
// Include ALL file data extents (large m2ts files have many)
if let Ok(extents) = self.read_icb_extents(reader, child.meta_lba) {
for (data_lba, data_len) in extents {
let abs_start = match self.partition_start.checked_add(data_lba) {
Some(v) => v,
None => continue,
};
let sector_count = (data_len as u64).div_ceil(2048) as u32;
ranges.push((abs_start, sector_count));
}
}
}
}
Ok(())
}
fn collect_file_ranges(
&self,
reader: &mut dyn SectorSource,
@@ -1771,8 +1724,9 @@ mod tests {
reader.put(50, [0xCC; 2048]);
let fs = fs_with(0, 0, root);
let (inf, _mkb) = crate::disc::Disc::read_aacs_inputs_from_reader(&mut reader, &fs)
.expect("read_aacs_inputs must succeed for a Long-AD disc");
let (inf, _mkb, _version) =
crate::disc::Disc::read_aacs_inputs_from_reader(&mut reader, &fs)
.expect("read_aacs_inputs must succeed for a Long-AD disc");
assert_eq!(
inf.len(),
4096,
@@ -2390,3 +2344,224 @@ mod tests {
);
}
}
/// Shared UDF image fixtures for tests across the `disc::*` format scanners.
///
/// Builds an in-memory disc image that [`read_filesystem`] can navigate — a
/// [`MemDisc`] SectorSource plus a [`DirSpec`] tree laid out via [`lay_dir`] and
/// [`build_udf_skeleton`]. Format-agnostic: BD (`bluray.rs`), HD-DVD
/// (`hddvd.rs`), and the format detector (`disc/mod.rs`) all build their own
/// trees (`BDMV/`, `HVDVD_TS/`, `AACS/…`) on top of these primitives, so each
/// format's tests live in that format's file, not piled into one.
#[cfg(test)]
pub(crate) mod fixture {
use crate::sector::SectorSource;
use std::collections::HashMap;
/// PART_START == META_START: file LBAs (partition-relative) and ICB/dir LBAs
/// (metadata-relative) share one address space (abs = PART_START + lba), so
/// `read_filesystem` takes the single-partition path.
pub(crate) const PART_START: u32 = 2000;
/// In-memory `SectorSource` (absolute-LBA → 2048-byte sector map); unmapped
/// sectors read as zeroes.
pub(crate) struct MemDisc {
sectors: HashMap<u32, [u8; 2048]>,
}
impl MemDisc {
pub(crate) fn new() -> Self {
Self {
sectors: HashMap::new(),
}
}
fn put(&mut self, lba: u32, data: [u8; 2048]) {
self.sectors.insert(lba, data);
}
/// Write arbitrary-length bytes at `lba`, split across 2048-byte sectors.
pub(crate) fn put_bytes(&mut self, lba: u32, bytes: &[u8]) {
for (i, chunk) in bytes.chunks(2048).enumerate() {
let mut s = [0u8; 2048];
s[..chunk.len()].copy_from_slice(chunk);
self.put(lba + i as u32, s);
}
}
}
impl SectorSource for MemDisc {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let need = count as usize * 2048;
for i in 0..count as u32 {
let off = i as usize * 2048;
let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]);
buf[off..off + 2048].copy_from_slice(&s);
}
Ok(need)
}
}
/// One file's placement: ICB metadata LBA, data-extent LBA, byte length,
/// Long-AD (16-byte, real BD-ROM layout) vs Short-AD, optional contents.
pub(crate) struct FileSpec {
pub(crate) name: String,
pub(crate) icb_lba: u32,
pub(crate) data_lba: u32,
pub(crate) size: u32,
pub(crate) long_ad: bool,
pub(crate) contents: Vec<u8>,
}
/// A directory node: ICB LBA, FID-list LBA, child files and subdirectories.
pub(crate) struct DirSpec {
pub(crate) name: String,
pub(crate) icb_lba: u32,
pub(crate) dir_data_lba: u32,
pub(crate) files: Vec<FileSpec>,
pub(crate) subdirs: Vec<DirSpec>,
}
/// Build an Extended File Entry ICB (tag 266) with one allocation descriptor.
pub(crate) fn build_file_icb(size: u32, data_lba: u32, long_ad: bool) -> [u8; 2048] {
let mut s = [0u8; 2048];
s[0..2].copy_from_slice(&266u16.to_le_bytes()); // Extended File Entry
if long_ad {
s[34..36].copy_from_slice(&1u16.to_le_bytes()); // ICB flags → Long AD
}
s[56..64].copy_from_slice(&(size as u64).to_le_bytes()); // info_length
s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea
let ad_size: u32 = if long_ad { 16 } else { 8 };
s[212..216].copy_from_slice(&ad_size.to_le_bytes()); // l_ad
s[216..220].copy_from_slice(&(size & 0x3FFF_FFFF).to_le_bytes());
s[220..224].copy_from_slice(&data_lba.to_le_bytes());
s
}
fn build_dir_icb(dir_data_lba: u32, dir_data_len: u32) -> [u8; 2048] {
build_file_icb(dir_data_len, dir_data_lba, false)
}
/// Append one File Identifier Descriptor (tag 257) to `buf`.
fn push_fid(buf: &mut Vec<u8>, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) {
let start = buf.len();
let name_field: Vec<u8> = if is_parent {
Vec::new()
} else {
let mut v = vec![0x08u8];
v.extend_from_slice(name.as_bytes());
v
};
let l_fi = name_field.len();
let mut fid = vec![0u8; 38];
fid[0..2].copy_from_slice(&257u16.to_le_bytes()); // FID tag
let mut file_chars = 0u8;
if is_dir {
file_chars |= 0x02;
}
if is_parent {
file_chars |= 0x08;
}
fid[18] = file_chars;
fid[19] = l_fi as u8;
fid[24..28].copy_from_slice(&icb_lba.to_le_bytes()); // ICB long_ad LBA @24
fid[36..38].copy_from_slice(&0u16.to_le_bytes()); // l_iu @36
buf.extend_from_slice(&fid);
buf.extend_from_slice(&name_field);
let used = buf.len() - start;
let pad = (used + 3) & !3;
buf.resize(start + pad, 0);
}
/// Recursively lay a [`DirSpec`] into the [`MemDisc`].
pub(crate) fn lay_dir(disc: &mut MemDisc, dir: &DirSpec) {
let mut fids = Vec::new();
push_fid(&mut fids, "", dir.icb_lba, true, true);
for f in &dir.files {
push_fid(&mut fids, &f.name, f.icb_lba, false, false);
disc.put(
PART_START + f.icb_lba,
build_file_icb(f.size, f.data_lba, f.long_ad),
);
if !f.contents.is_empty() {
disc.put_bytes(PART_START + f.data_lba, &f.contents);
}
}
for sub in &dir.subdirs {
push_fid(&mut fids, &sub.name, sub.icb_lba, true, false);
}
disc.put(
PART_START + dir.icb_lba,
build_dir_icb(dir.dir_data_lba, fids.len() as u32),
);
disc.put_bytes(PART_START + dir.dir_data_lba, &fids);
for sub in &dir.subdirs {
lay_dir(disc, sub);
}
}
/// Build the static UDF anchor/VDS/FSD so `read_filesystem` reaches
/// `root_icb_lba` (single partition map → metadata_start == PART_START).
pub(crate) fn build_udf_skeleton(disc: &mut MemDisc, root_icb_lba: u32) {
let mut avdp = [0u8; 2048];
avdp[0..2].copy_from_slice(&2u16.to_le_bytes());
disc.put(256, avdp);
let mut pd = [0u8; 2048];
pd[0..2].copy_from_slice(&5u16.to_le_bytes());
pd[188..192].copy_from_slice(&PART_START.to_le_bytes());
disc.put(32, pd);
let mut lvd = [0u8; 2048];
lvd[0..2].copy_from_slice(&6u16.to_le_bytes());
lvd[268..272].copy_from_slice(&1u32.to_le_bytes());
disc.put(33, lvd);
let mut td = [0u8; 2048];
td[0..2].copy_from_slice(&8u16.to_le_bytes());
disc.put(34, td);
let mut fsd = [0u8; 2048];
fsd[0..2].copy_from_slice(&256u16.to_le_bytes());
fsd[404..408].copy_from_slice(&root_icb_lba.to_le_bytes());
disc.put(PART_START, fsd);
}
pub(crate) fn file(
name: &str,
icb_lba: u32,
data_lba: u32,
size: u32,
long_ad: bool,
) -> FileSpec {
FileSpec {
name: name.to_string(),
icb_lba,
data_lba,
size,
long_ad,
contents: Vec::new(),
}
}
pub(crate) fn file_with(
name: &str,
icb_lba: u32,
data_lba: u32,
contents: Vec<u8>,
long_ad: bool,
) -> FileSpec {
FileSpec {
name: name.to_string(),
icb_lba,
data_lba,
size: contents.len() as u32,
long_ad,
contents,
}
}
}
-532
View File
@@ -1,532 +0,0 @@
//! Pluggable drive-unlock seam.
//!
//! libfreemkv knows the *seam*, never the *mechanism*. An [`Unlocker`] is
//! supplied by an external crate (e.g. `freemkv-unlock-ld`) and registered
//! once at process start via [`register_unlocker`]. At drive-prep the
//! registry is walked in registration order; the first unlocker whose
//! [`Unlocker::matches`] returns true is asked to [`Unlocker::unlock_drive`]
//! the drive by issuing its own CDBs through the raw [`ScsiTransport`].
//!
//! No firmware blobs, no unlock CDBs, no drive profiles live here — only
//! the trait, the registry, and the routing. If no unlocker matches, the
//! drive is left untouched and the caller falls back to the standard
//! host-certificate AACS handshake (the "OEM route").
use crate::aacs::Vid;
use crate::error::Result;
use crate::identity::DriveId;
use crate::scsi::ScsiTransport;
use std::sync::RwLock;
/// Why an [`Unlocker::unlock`] attempt produced no Volume ID. Structured and
/// English-free — applications render it. `Scsi` wraps the numeric error code
/// from [`crate::error::Error::code`] (the `Error` itself is not `Clone`).
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum UnlockError {
/// This unlocker cannot put this drive's firmware into extended mode.
FirmwareNotUnlockable,
/// No usable (non-revoked) host certificate was available for the auth
/// attempt. `mkb` is the disc MKB generation when known.
NoUsableHostCert { mkb: Option<u32> },
/// Every available host cert was revoked on this drive's HRL. `mkb` is the
/// disc MKB generation when known.
CertRevoked { mkb: Option<u32> },
/// The drive rejected the auth handshake (non-revocation rejection / wedge).
HandshakeRejected,
/// Auth succeeded (or was skipped) but the Volume ID could not be read.
VidUnavailable,
/// A SCSI/transport error; carries the numeric [`crate::error::Error`] code.
Scsi(u16),
}
impl From<crate::error::Error> for UnlockError {
fn from(e: crate::error::Error) -> Self {
UnlockError::Scsi(e.code())
}
}
/// A pluggable drive-capability provider.
///
/// Unlockers are optional drive-capability providers. libfreemkv's AACS
/// layer is the always-present baseline; it uses an unlocker's capabilities
/// when one matches, and does the in-tree cert handshake (the
/// `AacsCertUnlocker` peer) when none do.
///
/// Implementors own everything about *how* a particular drive family is
/// driven: firmware upload, vendor CDBs, variant logic. libfreemkv only
/// hands over the raw SCSI transport and the drive identity.
pub trait Unlocker: Send + Sync {
/// Stable, language-neutral identifier for this unlocker (logged).
fn name(&self) -> &str;
/// True if this unlocker handles the given drive.
fn matches(&self, id: &DriveId) -> bool;
/// Put the drive into extended-access mode (firmware/bootloader/whatever
/// THIS unlocker needs) AND return the disc's Volume ID — folding the old
/// `unlock_drive()` + `read_volume_id()` into one step. A firmware unlocker
/// that cannot unlock returns [`UnlockError::FirmwareNotUnlockable`]; one
/// that unlocks but has no OEM VID path returns [`UnlockError::VidUnavailable`].
/// Either error makes libfreemkv fall through to the in-tree cert handshake.
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
id: &DriveId,
) -> std::result::Result<Vid, UnlockError>;
/// Raise the drive to its maximum read speed. Default: no-op.
fn set_max_read_speed(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId) -> Result<()> {
Ok(())
}
}
/// Process-wide ordered registry of unlockers.
static REGISTRY: RwLock<Vec<Box<dyn Unlocker>>> = RwLock::new(Vec::new());
/// Register an unlocker. Order is preserved; [`route_unlock`] tries each in
/// registration order and stops at the first whose `matches` is true.
///
/// Call once at process start (CLI / service `main`), before any rip. The
/// single `register_unlocker(...)` line is the entire plug — remove it (and
/// the unlocker crate) and libfreemkv still compiles and falls back to the
/// host-cert handshake.
pub fn register_unlocker(u: Box<dyn Unlocker>) {
if let Ok(mut reg) = REGISTRY.write() {
reg.push(u);
}
}
/// Walk the registry in order and run the first matching unlocker, returning
/// its name AND the Volume ID it produced.
///
/// Returns:
/// * `Ok(Some((name, vid)))` — a registered unlocker matched, put the drive
/// into extended mode, and returned the OEM Volume ID. The caller stashes
/// the VID for the handshake phase and need not run the cert handshake.
/// * `Ok(None)` — no unlocker matched, OR the matching unlocker reported a
/// *capability* failure ([`UnlockError::FirmwareNotUnlockable`],
/// [`UnlockError::VidUnavailable`], or a cert-auth outcome — all logged).
/// Either way the drive is usable in stock mode and the caller falls
/// through to the in-tree cert handshake. Folding a capability failure into
/// `Ok(None)` keeps drive `init()` infallible — a drive that simply isn't
/// firmware-unlockable must not fail init.
/// * `Err(_)` — the matching unlocker hit a genuine SCSI/transport fault
/// ([`UnlockError::Scsi`]). The bus is broken, not merely unsupported, so
/// this propagates and aborts init rather than silently falling through to
/// a cert handshake that would also fail.
pub(crate) fn route_unlock(
scsi: &mut dyn ScsiTransport,
id: &DriveId,
) -> Result<Option<(String, Vid)>> {
let reg = match REGISTRY.read() {
Ok(r) => r,
// A poisoned lock means a prior unlocker panicked; treat as
// "no unlocker available" so the cert fallback still runs.
Err(_) => return Ok(None),
};
for u in reg.iter() {
if u.matches(id) {
let name = u.name().to_string();
match u.unlock(scsi, id) {
Ok(vid) => return Ok(Some((name, vid))),
// A genuine SCSI/transport fault is not "this drive can't be
// unlocked" — the bus is broken. Propagate so init() aborts
// instead of falling through to a cert handshake that will
// also fail on the same dead transport. The numeric code from
// the originating error is logged; the returned error is the
// canonical transport-error variant.
Err(UnlockError::Scsi(code)) => {
tracing::error!(
target: "freemkv::unlock",
unlocker = %name,
code,
"unlocker hit a transport fault during unlock; aborting init"
);
return Err(crate::error::Error::ScsiError {
opcode: 0,
status: 0,
sense: None,
});
}
// A firmware unlocker that can't unlock / has no OEM VID:
// fall through to the cert handshake. Debug-only structured
// log (variant identifiers, no English prose).
Err(e) => {
tracing::warn!(
target: "freemkv::unlock",
unlocker = %name,
outcome = ?e,
"unlocker matched but produced no VID; falling through to cert handshake"
);
return Ok(None);
}
}
}
}
Ok(None)
}
/// Walk the registry in order and ask the first matching unlocker to raise
/// the drive to its maximum read speed.
///
/// Mirrors [`route_unlock`]'s resolution so the SAME identified unlocker
/// that unlocks the drive is the one asked to set speed. Returns:
/// * `Ok(())` — the matching unlocker set max speed, or no unlocker
/// matched (no-op), or the matching unlocker has no speed capability
/// (its default no-op).
/// * `Err(_)` — the matching unlocker's `set_max_read_speed` failed. The
/// caller treats this as non-fatal (log and continue): a slow drive
/// still rips.
pub(crate) fn unlocker_set_max_read_speed(
scsi: &mut dyn ScsiTransport,
id: &DriveId,
) -> Result<()> {
let reg = match REGISTRY.read() {
Ok(r) => r,
// Poisoned lock ⇒ treat as "no unlocker available" (no-op).
Err(_) => return Ok(()),
};
for u in reg.iter() {
if u.matches(id) {
return u.set_max_read_speed(scsi, id);
}
}
Ok(())
}
/// Number of registered unlockers — test/introspection helper.
#[doc(hidden)]
pub fn registered_count() -> usize {
REGISTRY.read().map(|r| r.len()).unwrap_or(0)
}
/// Name of the first registered unlocker that matches `id`, without
/// running it. Used for drive-info display ("is this drive supported?")
/// before any unlock has been attempted.
pub(crate) fn matching_name(id: &DriveId) -> Option<String> {
let reg = REGISTRY.read().ok()?;
reg.iter()
.find(|u| u.matches(id))
.map(|u| u.name().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct NoopTransport;
impl ScsiTransport for NoopTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
Ok(ScsiResult {
status: 0,
bytes_transferred: 0,
sense: [0u8; 32],
})
}
}
fn fake_id(vendor: &str) -> DriveId {
let mut inquiry = vec![0u8; 96];
let v = vendor.as_bytes();
inquiry[8..8 + v.len().min(8)].copy_from_slice(&v[..v.len().min(8)]);
DriveId::from_inquiry(&inquiry, "")
}
/// Fake unlocker that records whether its `unlock` ran, matches on vendor
/// id, and serves a Volume ID (`Some` → `Ok(Vid)`; `None` →
/// `Err(VidUnavailable)`, i.e. matched-but-no-OEM-VID → cert fallback) or
/// records a `set_max_read_speed` call.
struct FakeUnlocker {
want_vendor: String,
ran: Arc<AtomicBool>,
/// VID this unlocker returns: `Some(vid)` → `unlock` yields `Ok(Vid)`;
/// `None` → `unlock` yields `Err(UnlockError::VidUnavailable)` so
/// `route_unlock` falls through to the cert handshake.
vid: Option<[u8; 16]>,
/// When `Some(code)`, `unlock` yields `Err(UnlockError::Scsi(code))`
/// (a transport fault) instead of consulting `vid`, so `route_unlock`
/// propagates an error and aborts init.
scsi_err: Option<u16>,
/// Records whether set_max_read_speed was invoked.
speed_ran: Arc<AtomicBool>,
}
impl FakeUnlocker {
fn new(vendor: &str, ran: Arc<AtomicBool>) -> Self {
Self {
want_vendor: vendor.into(),
ran,
// Default: a successful unlock returning an all-zero VID.
vid: Some([0u8; 16]),
scsi_err: None,
speed_ran: Arc::new(AtomicBool::new(false)),
}
}
fn with_vid(mut self, vid: Option<[u8; 16]>) -> Self {
self.vid = vid;
self
}
fn with_scsi_err(mut self, code: u16) -> Self {
self.scsi_err = Some(code);
self
}
fn with_speed(mut self, speed_ran: Arc<AtomicBool>) -> Self {
self.speed_ran = speed_ran;
self
}
}
impl Unlocker for FakeUnlocker {
fn name(&self) -> &str {
"fake"
}
fn matches(&self, id: &DriveId) -> bool {
id.vendor_id.trim() == self.want_vendor
}
fn unlock(
&self,
_scsi: &mut dyn ScsiTransport,
_id: &DriveId,
) -> std::result::Result<Vid, UnlockError> {
self.ran.store(true, Ordering::SeqCst);
if let Some(code) = self.scsi_err {
return Err(UnlockError::Scsi(code));
}
match self.vid {
Some(v) => Ok(Vid(v)),
None => Err(UnlockError::VidUnavailable),
}
}
fn set_max_read_speed(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId) -> Result<()> {
self.speed_ran.store(true, Ordering::SeqCst);
Ok(())
}
}
/// `UnlockError` is `PartialEq` and a crate `Error` folds into
/// `Scsi(code)` — the conversion `?`-callers rely on, English-free.
#[test]
fn unlock_error_from_crate_error_carries_code() {
let e: UnlockError = crate::error::Error::AacsVidUnavailable.into();
assert_eq!(e, UnlockError::Scsi(crate::error::E_AACS_VID_UNAVAILABLE));
assert_ne!(
UnlockError::NoUsableHostCert { mkb: Some(1) },
UnlockError::NoUsableHostCert { mkb: Some(2) }
);
}
/// A registered, matching unlocker runs and returns its name + VID; a
/// non-matching identity leaves the registry untouched and routes to the
/// cert fallback (`None`).
///
/// Both assertions live in one test because the registry is process-wide
/// and tests share it — running them as one case keeps the ordering
/// deterministic regardless of test-harness threading.
#[test]
fn registry_routes_match_else_oem() {
let ran = Arc::new(AtomicBool::new(false));
register_unlocker(Box::new(FakeUnlocker::new("MATCHVND", ran.clone())));
// Matching identity → unlocker runs, returns its name + VID.
let mut scsi = NoopTransport;
let matched = route_unlock(&mut scsi, &fake_id("MATCHVND")).unwrap();
assert_eq!(
matched.as_ref().map(|(n, _)| n.as_str()),
Some("fake"),
"matching unlocker runs"
);
assert!(ran.load(Ordering::SeqCst), "unlock() was invoked");
// Non-matching identity → no unlocker runs, cert path (None).
ran.store(false, Ordering::SeqCst);
let none = route_unlock(&mut scsi, &fake_id("OTHERVND")).unwrap();
assert!(none.is_none(), "no match → cert fallback");
assert!(
!ran.load(Ordering::SeqCst),
"unlock() not invoked on no-match"
);
}
/// `route_unlock` returns the FIRST matching unlocker's VID. A matching
/// unlocker that yields `Ok(Vid)` returns that VID (OEM path — cert
/// handshake skipped). A matching unlocker whose `unlock` errors (no OEM
/// VID), or no match at all, yields `Ok(None)` (cert fallback).
///
/// Distinct vendor ids keep this independent of the other registry test
/// despite the process-wide shared registry.
#[test]
fn route_unlock_returns_vid_else_cert() {
let mut scsi = NoopTransport;
// Unlocker WITH an OEM VID. Vendor ids are exactly 8 chars: INQUIRY
// field [8..16] has no null padding to trim, so `matches` is exact.
let vid = [0x5Au8; 16];
register_unlocker(Box::new(
FakeUnlocker::new("VIDVNDOR", Arc::new(AtomicBool::new(false))).with_vid(Some(vid)),
));
// Matching identity → its VID is returned.
let got = route_unlock(&mut scsi, &fake_id("VIDVNDOR")).unwrap();
assert_eq!(
got.map(|(_, v)| v),
Some(Vid(vid)),
"matching unlocker's OEM VID is used"
);
// Unlocker that MATCHES but has NO OEM VID path (unlock → Err) → cert.
register_unlocker(Box::new(
FakeUnlocker::new("NOVIDVND", Arc::new(AtomicBool::new(false))).with_vid(None),
));
let got = route_unlock(&mut scsi, &fake_id("NOVIDVND")).unwrap();
assert!(
got.is_none(),
"unlocker without OEM VID falls through to cert"
);
// No matching unlocker → Ok(None), cert fallback.
let got = route_unlock(&mut scsi, &fake_id("UNKNWNVD")).unwrap();
assert!(got.is_none(), "no match → cert fallback");
}
/// A matching unlocker that hits a genuine transport fault
/// (`UnlockError::Scsi`) makes `route_unlock` PROPAGATE an `Err` rather
/// than fold to `Ok(None)`: a dead bus must abort init, not silently fall
/// through to a cert handshake that would also fail. Capability failures
/// (`VidUnavailable` etc.) still fold to `Ok(None)` — proven by the sibling
/// routing tests; this one pins the transport-fault exception.
#[test]
fn route_unlock_propagates_scsi_transport_fault() {
let mut scsi = NoopTransport;
register_unlocker(Box::new(
FakeUnlocker::new("SCSIVNDR", Arc::new(AtomicBool::new(false)))
.with_scsi_err(crate::error::E_SCSI_ERROR),
));
let got = route_unlock(&mut scsi, &fake_id("SCSIVNDR"));
assert!(
got.is_err(),
"a transport fault during unlock aborts init (propagates Err)"
);
assert_eq!(
got.unwrap_err().code(),
crate::error::E_SCSI_ERROR,
"propagated error is the canonical transport-error code"
);
}
/// `unlocker_set_max_read_speed` consults the FIRST matching unlocker's
/// `set_max_read_speed`. A matching unlocker is invoked; a non-match is a
/// safe no-op (nothing invoked, `Ok(())`).
///
/// Distinct vendor ids keep this independent of the other registry tests
/// despite the process-wide shared registry.
#[test]
fn unlocker_set_max_read_speed_routes_match_else_noop() {
let mut scsi = NoopTransport;
let speed_ran = Arc::new(AtomicBool::new(false));
register_unlocker(Box::new(
FakeUnlocker::new("SPEEDVND", Arc::new(AtomicBool::new(false)))
.with_speed(speed_ran.clone()),
));
// Matching identity → set_max_read_speed invoked.
unlocker_set_max_read_speed(&mut scsi, &fake_id("SPEEDVND")).unwrap();
assert!(
speed_ran.load(Ordering::SeqCst),
"set_max_read_speed() invoked on match"
);
// No matching unlocker → Ok(()), nothing invoked (safe no-op).
speed_ran.store(false, Ordering::SeqCst);
unlocker_set_max_read_speed(&mut scsi, &fake_id("NOSPEEDV")).unwrap();
assert!(
!speed_ran.load(Ordering::SeqCst),
"no match → safe no-op, nothing invoked"
);
}
/// `matching_name` reports the FIRST matching unlocker's name without
/// running it (drive-info "is this drive supported?" before any unlock),
/// and returns `None` for an unknown drive. `registered_count` counts the
/// registered unlockers — pinning the two introspection helpers the routing
/// tests never touch.
///
/// The registry is process-wide and other unlock tests register into it
/// concurrently, so the count is only asserted to be MONOTONIC across this
/// test's own registration (never an exact delta) — registering an unlocker
/// can only grow the count, never shrink it.
#[test]
fn matching_name_and_registered_count_introspection() {
let before = registered_count();
register_unlocker(Box::new(FakeUnlocker::new(
"NAMEVNDR",
Arc::new(AtomicBool::new(false)),
)));
// Registering an unlocker can only grow the count (other tests may also
// be registering concurrently, so this is a monotonic check, not a
// delta-of-exactly-one).
assert!(
registered_count() > before,
"registered_count grows after register_unlocker"
);
// A matching identity reports the unlocker's name — and `matches`
// is consulted WITHOUT running unlock_drive (introspection only).
assert_eq!(
matching_name(&fake_id("NAMEVNDR")).as_deref(),
Some("fake"),
"matching_name reports the supporting unlocker"
);
// An identity no registered unlocker matches → None (unsupported).
assert!(
matching_name(&fake_id("ZZNOMTCH")).is_none(),
"matching_name is None for an unsupported drive"
);
}
/// Registration order is preserved and the FIRST matching unlocker wins:
/// when two unlockers both match the same identity, `route_unlock` runs the
/// one registered earlier and never consults the later one. The routing
/// docs promise "registration order; stops at the first whose `matches` is
/// true" — this is the only test that registers two overlapping matchers to
/// prove the ordering rather than a single-match no-op.
#[test]
fn route_unlock_first_registered_match_wins() {
let mut scsi = NoopTransport;
// Two unlockers that BOTH match vendor "DUPEVNDR"; the first registered
// must be the one that runs.
let first_ran = Arc::new(AtomicBool::new(false));
let second_ran = Arc::new(AtomicBool::new(false));
register_unlocker(Box::new(FakeUnlocker::new("DUPEVNDR", first_ran.clone())));
register_unlocker(Box::new(FakeUnlocker::new("DUPEVNDR", second_ran.clone())));
let matched = route_unlock(&mut scsi, &fake_id("DUPEVNDR")).unwrap();
assert_eq!(
matched.as_ref().map(|(n, _)| n.as_str()),
Some("fake"),
"a match was routed"
);
assert!(
first_ran.load(Ordering::SeqCst),
"the FIRST-registered matching unlocker ran"
);
assert!(
!second_ran.load(Ordering::SeqCst),
"the later-registered unlocker was never consulted (first-match-wins)"
);
}
}
+259
View File
@@ -0,0 +1,259 @@
//! Bridges libfreemkv's drive layer to the `freemkv-unlock` crate: one generic
//! SCSI-transport adapter, identity/host-cert mapping, and the dispatch that
//! news up `all_unlockers()` and runs the first matching one. libfreemkv names
//! no individual unlocker — it only calls this bridge.
use freemkv_unlock as fu;
/// Map libfreemkv's drive identity to the unlock contract's `DriveId`.
fn to_fu_drive_id(drive_id: &crate::identity::DriveId) -> fu::DriveId {
fu::DriveId {
vendor_id: drive_id.vendor_id.clone(),
product_id: drive_id.product_id.clone(),
product_revision: drive_id.product_revision.clone(),
vendor_specific: drive_id.vendor_specific.clone(),
firmware_date: drive_id.firmware_date.clone(),
}
}
/// Name of the unlocker that claims this drive by identity (drive-info "is this
/// drive supported?" display), or `None`. A pure lookup — does NOT touch the
/// drive or unlock anything.
pub(crate) fn unlocker_name(drive_id: &crate::identity::DriveId) -> Option<&'static str> {
fu::unlocker_name(&to_fu_drive_id(drive_id))
}
/// Adapt libfreemkv's `ScsiTransport` to the unlock crate's transport contract.
struct ScsiAdapter<'a>(&'a mut dyn crate::scsi::ScsiTransport);
impl fu::scsi::ScsiTransport for ScsiAdapter<'_> {
fn execute(
&mut self,
cdb: &[u8],
dir: fu::scsi::DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> fu::scsi::Result<fu::scsi::ScsiResult> {
let d = match dir {
fu::scsi::DataDirection::None => crate::scsi::DataDirection::None,
fu::scsi::DataDirection::FromDevice => crate::scsi::DataDirection::FromDevice,
fu::scsi::DataDirection::ToDevice => crate::scsi::DataDirection::ToDevice,
};
match self.0.execute(cdb, d, data, timeout_ms) {
Ok(r) => Ok(fu::scsi::ScsiResult {
status: r.status,
bytes_transferred: r.bytes_transferred,
sense: r.sense,
}),
// libfreemkv's transport returns Err for ANY non-zero SCSI status —
// i.e. a normal drive CHECK CONDITION (ILLEGAL_REQUEST, etc.), NOT
// only a transport-layer fault. Preserve the real status AND the
// parsed sense across the seam: the AACS handshake's wedge guard
// bails on an ILLEGAL_REQUEST sense (so it stops hammering the drive),
// and its diagnosis distinguishes a cert rejection from a dead bus by
// the same status/sense. Collapsing everything to 0xFF/None defeated
// both. Reconstruct the 32-byte sense buffer at the offsets the
// unlock crate reads (sense_key@2 low-nibble, asc@12, ascq@13); a
// genuine transport fault (status 0xFF, no sense) maps through
// unchanged.
Err(e) => {
// A SCSI status (CHECK CONDITION or a 0xFF transport fault the
// drive layer already tagged) carries its real status + sense via
// extract_scsi_context. Any OTHER error variant is a non-SCSI
// transport/IO-layer fault — ioctl(SG_IO) == -1 (ENODEV/EIO on an
// unplugged bridge) or the fd is gone — i.e. a DEAD BUS, not a
// drive rejection; surface the transport-failure status so the
// unlock crate bails instead of hammering a wedged device.
// (Keying off `sense.is_none()` would be wrong: a CHECK CONDITION
// whose sense didn't parse is a rejection, not a transport fault.)
let (status, sense) = match &e {
crate::error::Error::ScsiError { .. }
| crate::error::Error::DiscRead { .. } => {
crate::drive::extract_scsi_context(&e)
}
_ => (crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, None),
};
let sense_buf = sense.map(|s| {
let mut b = [0u8; 32];
b[2] = s.sense_key & 0x0F;
b[12] = s.asc;
b[13] = s.ascq;
b
});
Err(fu::scsi::ScsiError {
status,
sense: sense_buf,
})
}
}
}
}
/// Map libfreemkv's host certs (keysource-collected) to the unlock contract's.
pub(crate) fn map_host_certs(certs: &[crate::aacs::types::HostCert]) -> Vec<fu::HostCert> {
certs
.iter()
.map(|c| fu::HostCert {
private_key: c.private_key,
certificate: c.certificate.clone(),
private_key_v2: c.private_key_v2,
certificate_v2: c.certificate_v2.clone(),
})
.collect()
}
/// Result of a capability dispatch: `(matched_name, result)`. `matched_name` is
/// the unlocker that handled it (or `""` if none did) — lets the caller record
/// WHICH unlocker ran (e.g. `LibreDrive` vs `Renesas`), distinct from the ld-only
/// identity lookup [`unlocker_name`]. Iterating stops at the first unlocker whose
/// capability method returns anything other than `NotApplicable` — i.e. an actual
/// unlock (`Ok`) OR a real failure such as a dead bus (`Err(Transport)`), which
/// the caller must surface rather than skip.
type Dispatch = (
&'static str,
std::result::Result<fu::Unlocked, fu::UnlockError>,
);
/// Try each unlocker's `capability` (`unlock_features` or `unlock_bus`) in
/// registration order, stopping at the first that doesn't decline. Shared by
/// [`run_features`] and [`run_bus`].
fn dispatch(
scsi: &mut dyn crate::scsi::ScsiTransport,
drive_id: &crate::identity::DriveId,
kind: fu::DiscKind,
host_certs: &[fu::HostCert],
capability: impl Fn(
&dyn fu::Unlocker,
&mut dyn fu::scsi::ScsiTransport,
&fu::UnlockCtx,
) -> std::result::Result<fu::Unlocked, fu::UnlockError>,
) -> Dispatch {
let id = to_fu_drive_id(drive_id);
let ctx = fu::UnlockCtx::new(&id, kind, host_certs);
let mut adapter = ScsiAdapter(scsi);
for u in fu::all_unlockers() {
match capability(u.as_ref(), &mut adapter, &ctx) {
// This unlocker doesn't provide the capability for this drive/disc —
// try the next one.
Err(fu::UnlockError::NotApplicable) => continue,
// An actual unlock, or a real failure (e.g. Transport) — stop here.
other => return (u.name(), other),
}
}
("", Err(fu::UnlockError::NotApplicable))
}
/// Drive-prep: unlock DRIVE FEATURES (riplock/speed, OEM VID). `host_certs` are
/// not needed for features — pass `&[]`; `kind` is `Unknown` at drive-prep.
pub(crate) fn run_features(
scsi: &mut dyn crate::scsi::ScsiTransport,
drive_id: &crate::identity::DriveId,
) -> Dispatch {
dispatch(scsi, drive_id, fu::DiscKind::Unknown, &[], |u, s, c| {
u.unlock_features(s, c)
})
}
/// Content: remove BUS ENCRYPTION for the mounted disc. Called only when the bus
/// isn't already clear (the `oem_vid`/`bus_encryption_removed` gate). `host_certs`
/// are the caller-collected certs for the AACS route; `kind` selects Aacs vs Css.
pub(crate) fn run_bus(
scsi: &mut dyn crate::scsi::ScsiTransport,
drive_id: &crate::identity::DriveId,
kind: fu::DiscKind,
host_certs: &[fu::HostCert],
) -> Dispatch {
dispatch(scsi, drive_id, kind, host_certs, |u, s, c| {
u.unlock_bus(s, c)
})
}
/// The names of every REGISTERED unlocker, in dispatch order. Registry-driven —
/// sourced from `all_unlockers()`, so adding/removing an unlocker updates every
/// report with no other change (no hardcoded names). The per-unlocker "did it
/// run this rip" outcome is computed by the caller, which has the disc + drive
/// runtime state this crate cannot see.
pub(crate) fn unlocker_names() -> Vec<&'static str> {
fu::all_unlockers().iter().map(|u| u.name()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use freemkv_unlock::scsi::ScsiTransport as _; // brings `execute` into scope
/// A fake libfreemkv transport whose `execute` always fails with a
/// freshly-built error (`crate::error::Error` isn't `Clone` — `io::Error`).
struct ErrTransport<F>(F);
impl<F: FnMut() -> crate::error::Error + Send> crate::scsi::ScsiTransport for ErrTransport<F> {
fn execute(
&mut self,
_cdb: &[u8],
_dir: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> std::result::Result<crate::scsi::ScsiResult, crate::error::Error> {
Err((self.0)())
}
}
fn adapt(e: impl FnMut() -> crate::error::Error + Send) -> fu::scsi::ScsiError {
let mut t = ErrTransport(e);
let mut adapter = ScsiAdapter(&mut t);
let mut buf = [0u8; 0];
adapter
.execute(&[0u8; 12], fu::scsi::DataDirection::None, &mut buf, 1_000)
.expect_err("error path")
}
/// A CHECK CONDITION carrying sense crosses the seam with status + parsed
/// sense intact, so the unlock crate's ILLEGAL_REQUEST wedge guard can fire.
#[test]
fn check_condition_preserves_status_and_sense() {
let err = adapt(|| crate::error::Error::ScsiError {
opcode: 0xA3,
status: 0x02,
sense: Some(crate::scsi::ScsiSense {
sense_key: 0x05,
asc: 0x24,
ascq: 0x00,
}),
});
assert_eq!(err.status, 0x02);
let sense = err.sense.expect("sense preserved");
assert!(fu::scsi::ScsiSense::from_buf(&sense).is_illegal_request());
}
/// A drive-tagged transport fault (status 0xFF) crosses unchanged.
#[test]
fn scsi_transport_fault_maps_unchanged() {
let err = adapt(|| crate::error::Error::ScsiError {
opcode: 0,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
assert_eq!(err.status, 0xFF);
assert!(err.sense.is_none());
}
/// A non-SCSI IO fault (ioctl SG_IO == -1: ENODEV/EIO) is a dead bus —
/// surfaced as 0xFF so the unlock crate bails instead of hammering it.
#[test]
fn io_error_maps_to_transport_failure() {
let err = adapt(|| crate::error::Error::IoError {
source: std::io::Error::from(std::io::ErrorKind::NotConnected),
});
assert_eq!(err.status, crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE);
assert!(err.sense.is_none());
}
/// Device-gone (fd closed) likewise maps to the transport-failure status.
#[test]
fn device_not_found_maps_to_transport_failure() {
let err = adapt(|| crate::error::Error::DeviceNotFound {
path: "/dev/sg9".into(),
});
assert_eq!(err.status, 0xFF);
assert!(err.sense.is_none());
}
}
-888
View File
@@ -1,888 +0,0 @@
//! Disc sector verification — read every sector and classify health.
use crate::disc::{Chapter, DiscTitle};
use crate::progress::{PassProgress, Progress};
use crate::sector::SectorSource;
use std::time::{Duration, Instant};
/// Pause before the single retry attempt on a failed sector, letting a
/// drive that briefly went NOT READY spin back up. Slept in
/// [`RETRY_POLL_INTERVAL`] increments so a cancel request (the progress
/// callback returning `false`) is observed within that interval rather
/// than after the full pause.
const RETRY_PAUSE: Duration = Duration::from_secs(2);
/// Polling cadence while sleeping out [`RETRY_PAUSE`].
const RETRY_POLL_INTERVAL: Duration = Duration::from_millis(250);
/// Sleep up to `total`, returning early with `false` if `cancelled`
/// observes a stop request mid-pause. Returns `true` if the full pause
/// elapsed without cancellation.
fn cancellable_pause(total: Duration, cancelled: &mut dyn FnMut() -> bool) -> bool {
let deadline = Instant::now() + total;
loop {
if cancelled() {
return false;
}
let now = Instant::now();
if now >= deadline {
return true;
}
std::thread::sleep(RETRY_POLL_INTERVAL.min(deadline - now));
}
}
/// Health status of a single sector read.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SectorStatus {
/// Read succeeded on first attempt, under 500ms
Good,
/// Read succeeded but took longer than expected (>500ms per sector)
Slow,
/// Read failed initially but succeeded on retry
Recovered,
/// Read failed all attempts — unrecoverable
Bad,
}
/// A contiguous range of bad or slow sectors.
#[derive(Debug, Clone)]
pub struct SectorRange {
pub start_lba: u32,
pub count: u32,
pub status: SectorStatus,
/// Byte offset from start of title
pub byte_offset: u64,
}
/// Result of a disc verification scan.
#[derive(Debug, Clone)]
pub struct VerifyResult {
pub total_sectors: u64,
pub good: u64,
pub slow: u64,
pub recovered: u64,
pub bad: u64,
/// Contiguous ranges of non-good sectors
pub ranges: Vec<SectorRange>,
pub elapsed_secs: f64,
}
impl VerifyResult {
/// Percentage of sectors that are fully readable (good + slow + recovered).
///
/// Returns `100.0` when there are no sectors. The subtraction is
/// saturating so a caller-constructed `VerifyResult` with `bad` greater
/// than `total_sectors` cannot wrap in release builds (it pins at 0%).
pub fn readable_pct(&self) -> f64 {
if self.total_sectors == 0 {
return 100.0;
}
self.total_sectors.saturating_sub(self.bad) as f64 / self.total_sectors as f64 * 100.0
}
/// True if every sector read first-attempt clean — no bad, no
/// recovered-on-retry, and no slow sectors. A `Recovered` or `Slow`
/// sector means data was eventually returned but the surface is
/// degraded, so `is_perfect()` is intentionally stricter than
/// "fully readable" (which [`readable_pct`](Self::readable_pct)
/// reports by counting recovered + slow as readable).
pub fn is_perfect(&self) -> bool {
self.bad == 0 && self.recovered == 0 && self.slow == 0
}
/// Map a bad sector range to a chapter timestamp.
pub fn chapter_at_offset(
chapters: &[Chapter],
byte_offset: u64,
duration_secs: f64,
total_bytes: u64,
) -> Option<(usize, f64)> {
if total_bytes == 0 || chapters.is_empty() {
return None;
}
let time_secs = byte_offset as f64 / total_bytes as f64 * duration_secs;
let mut chapter_idx = 0;
for (i, ch) in chapters.iter().enumerate() {
if ch.time_secs <= time_secs {
chapter_idx = i;
} else {
break;
}
}
Some((chapter_idx + 1, time_secs))
}
}
/// Verify all sectors in a title's extents.
///
/// Reads in batches for speed, falling back to single-sector reads on a
/// batch failure. Each sector that fails its single read gets one retry
/// after a [`RETRY_PAUSE`] cool-down (cancellable — see below).
///
/// `batch_sectors` is the read granularity in 2048-byte sectors; a value
/// of `0` is treated as `1` (a `0` batch would make no forward progress).
///
/// `on_progress` is both the progress sink and the cancellation channel:
/// returning `false` from [`Progress::report`] requests an early stop. The
/// stop is honoured between sectors *and* mid-retry-pause, so a verify of a
/// disc with many bad sectors stays responsive instead of blocking the full
/// pause per sector. Timing classification uses a 500 ms-per-sector
/// threshold to mark a successful-but-slow read as [`SectorStatus::Slow`].
pub fn verify_title(
reader: &mut dyn SectorSource,
title: &DiscTitle,
batch_sectors: u16,
on_progress: Option<&dyn Progress>,
) -> VerifyResult {
let start = Instant::now();
let mut good: u64 = 0;
let mut slow: u64 = 0;
let mut recovered: u64 = 0;
let mut bad: u64 = 0;
let mut ranges: Vec<SectorRange> = Vec::new();
let mut sectors_done: u64 = 0;
let mut byte_offset: u64 = 0;
// A zero batch size would never advance `offset` -> infinite loop.
// Clamp to at least one sector per read.
let batch_sectors = batch_sectors.max(1);
let total_sectors: u64 = title.extents.iter().map(|e| e.sector_count as u64).sum();
let mut buf = vec![0u8; batch_sectors as usize * 2048];
'outer: for ext in &title.extents {
let mut offset: u32 = 0;
while offset < ext.sector_count {
let remaining = ext.sector_count - offset;
let count = remaining.min(batch_sectors as u32) as u16;
// `start_lba + offset` stays within the extent's own LBA span by
// construction, but a crafted/corrupt extent can push the sum past
// u32::MAX; saturate rather than wrap (release) or panic (debug).
let lba = ext.start_lba.saturating_add(offset);
let bytes = count as usize * 2048;
let batch_start = Instant::now();
let batch_ok = reader
.read_sectors(lba, count, &mut buf[..bytes], false)
.is_ok();
let batch_ms = batch_start.elapsed().as_millis();
if batch_ok {
// Batch succeeded — classify by speed
let status = if batch_ms > count as u128 * 500 {
SectorStatus::Slow
} else {
SectorStatus::Good
};
match status {
SectorStatus::Good => good += count as u64,
SectorStatus::Slow => {
slow += count as u64;
// Merge with the previous range when contiguous and the
// same status, mirroring the per-sector path so a run of
// slow batches coalesces into one range instead of one
// entry per batch.
let merged = ranges.last_mut().is_some_and(|last| {
if last.status == SectorStatus::Slow
&& last.start_lba.saturating_add(last.count) == lba
{
last.count = last.count.saturating_add(count as u32);
true
} else {
false
}
});
if !merged {
ranges.push(SectorRange {
start_lba: lba,
count: count as u32,
status: SectorStatus::Slow,
byte_offset,
});
}
}
_ => {}
}
sectors_done += count as u64;
if let Some(cb) = on_progress {
let pp = crate::progress::PassProgress {
kind: crate::progress::PassKind::Verify,
work_done: sectors_done,
work_total: total_sectors,
bytes_good_total: (good + slow + recovered) * 2048,
bytes_unreadable_total: bad * 2048,
bytes_pending_total: 0,
bytes_retryable_total: 0,
bytes_total_disc: total_sectors * 2048,
disc_duration_secs: Some(title.duration_secs),
bytes_bad_in_main_title: 0,
main_title_duration_secs: Some(title.duration_secs),
main_title_size_bytes: Some(total_sectors * 2048),
};
if !cb.report(&pp) {
break 'outer;
}
}
} else {
// Batch failed — test each sector individually
for i in 0..count {
let sector_lba = lba.saturating_add(i as u32);
let sector_offset = i as usize * 2048;
let sector_byte_offset = byte_offset + i as u64 * 2048;
let s1 = Instant::now();
let first_ok = reader
.read_sectors(
sector_lba,
1,
&mut buf[sector_offset..sector_offset + 2048],
false,
)
.is_ok();
let s1_ms = s1.elapsed().as_millis();
let status = if first_ok && s1_ms <= 500 {
good += 1;
SectorStatus::Good
} else if first_ok {
slow += 1;
SectorStatus::Slow
} else {
// Retry once more after a brief cool-down. The pause is
// cancellable via the progress callback so a long run of
// bad sectors doesn't pin the thread for 2s each with no
// way to stop. If cancelled mid-pause, skip the retry and
// count the sector as bad, then bail out of the scan.
let cancelled_during_pause = !cancellable_pause(RETRY_PAUSE, &mut || {
on_progress.is_some_and(|cb| {
!cb.report(&PassProgress {
kind: crate::progress::PassKind::Verify,
work_done: sectors_done,
work_total: total_sectors,
bytes_good_total: (good + slow + recovered) * 2048,
bytes_unreadable_total: bad * 2048,
bytes_pending_total: 0,
bytes_retryable_total: 0,
bytes_total_disc: total_sectors * 2048,
disc_duration_secs: Some(title.duration_secs),
bytes_bad_in_main_title: 0,
main_title_duration_secs: Some(title.duration_secs),
main_title_size_bytes: Some(total_sectors * 2048),
})
})
});
if cancelled_during_pause {
bad += 1;
break 'outer;
}
if reader
.read_sectors(
sector_lba,
1,
&mut buf[sector_offset..sector_offset + 2048],
false,
)
.is_ok()
{
recovered += 1;
SectorStatus::Recovered
} else {
bad += 1;
SectorStatus::Bad
}
};
if status != SectorStatus::Good {
// Merge with previous range if contiguous and same status
if let Some(last) = ranges.last_mut() {
if last.status == status
&& last.start_lba.saturating_add(last.count) == sector_lba
{
last.count += 1;
} else {
ranges.push(SectorRange {
start_lba: sector_lba,
count: 1,
status,
byte_offset: sector_byte_offset,
});
}
} else {
ranges.push(SectorRange {
start_lba: sector_lba,
count: 1,
status,
byte_offset: sector_byte_offset,
});
}
}
sectors_done += 1;
if let Some(cb) = on_progress {
let pp = crate::progress::PassProgress {
kind: crate::progress::PassKind::Verify,
work_done: sectors_done,
work_total: total_sectors,
bytes_good_total: (good + slow + recovered) * 2048,
bytes_unreadable_total: bad * 2048,
bytes_pending_total: 0,
bytes_retryable_total: 0,
bytes_total_disc: total_sectors * 2048,
disc_duration_secs: Some(title.duration_secs),
bytes_bad_in_main_title: 0,
main_title_duration_secs: Some(title.duration_secs),
main_title_size_bytes: Some(total_sectors * 2048),
};
if !cb.report(&pp) {
break 'outer;
}
}
}
}
offset += count as u32;
byte_offset += bytes as u64;
}
}
VerifyResult {
total_sectors,
good,
slow,
recovered,
bad,
ranges,
elapsed_secs: start.elapsed().as_secs_f64(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::disc::{ContentFormat, DiscTitle, Extent};
use crate::error::Error;
/// Synthetic source: every read succeeds, filling the buffer with zeros.
struct AlwaysGood;
impl SectorSource for AlwaysGood {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize, Error> {
let n = count as usize * 2048;
buf[..n].fill(0);
Ok(n)
}
}
/// Synthetic source: every read fails.
struct AlwaysBad;
impl SectorSource for AlwaysBad {
fn read_sectors(
&mut self,
_lba: u32,
_count: u16,
_buf: &mut [u8],
_recovery: bool,
) -> Result<usize, Error> {
Err(Error::ScsiError {
opcode: 0x28,
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: None,
})
}
}
fn title_with_extent(start_lba: u32, sector_count: u32) -> DiscTitle {
DiscTitle {
playlist: String::new(),
playlist_id: 0,
duration_secs: 0.0,
size_bytes: 0,
clips: Vec::new(),
streams: Vec::new(),
chapters: Vec::new(),
extents: vec![Extent {
start_lba,
sector_count,
}],
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
}
}
#[test]
fn batch_zero_does_not_hang() {
// A zero batch size must be clamped to 1 internally; otherwise the
// inner `while offset < sector_count` loop never advances. The
// assertion only matters because the test returns at all.
let title = title_with_extent(0, 4);
let mut src = AlwaysGood;
let r = verify_title(&mut src, &title, 0, None);
assert_eq!(r.total_sectors, 4);
assert_eq!(r.good, 4);
assert!(r.is_perfect());
}
#[test]
fn all_good_is_perfect() {
let title = title_with_extent(100, 10);
let mut src = AlwaysGood;
let r = verify_title(&mut src, &title, 4, None);
assert_eq!(r.good, 10);
assert_eq!(r.bad, 0);
assert!(r.ranges.is_empty());
assert_eq!(r.readable_pct(), 100.0);
}
#[test]
fn cancel_during_retry_pause_stops_promptly() {
// AlwaysBad forces the single-sector retry path, which pauses before
// its retry. The progress callback requests stop on its first call,
// so the pause must return early and the scan must bail out rather
// than sleeping RETRY_PAUSE * sector_count. A generous wall-clock
// bound (well under a single full pause) proves cancellation worked.
let title = title_with_extent(0, 8);
let mut src = AlwaysBad;
let cb = |_p: &PassProgress| false; // always request stop
let started = Instant::now();
let r = verify_title(&mut src, &title, 4, Some(&cb));
assert!(
started.elapsed() < RETRY_PAUSE,
"verify did not honour cancel during retry pause"
);
// Cancelled mid-first-bad-sector: exactly one sector counted bad.
assert_eq!(r.bad, 1);
}
#[test]
fn readable_pct_saturates_on_inconsistent_counts() {
let r = VerifyResult {
total_sectors: 10,
good: 0,
slow: 0,
recovered: 0,
bad: 9999, // impossible in practice; must not wrap
ranges: Vec::new(),
elapsed_secs: 0.0,
};
assert_eq!(r.readable_pct(), 0.0);
}
#[test]
fn high_lba_extent_does_not_overflow() {
// An extent whose start_lba + offset would exceed u32::MAX must
// saturate, not panic in debug builds.
let title = title_with_extent(u32::MAX - 1, 4);
let mut src = AlwaysGood;
let r = verify_title(&mut src, &title, 2, None);
assert_eq!(r.total_sectors, 4);
}
#[test]
fn no_progress_callback_still_completes_on_bad_sectors() {
// Without a callback there is no cancel signal, so each bad sector
// takes the full RETRY_PAUSE. Keep the extent tiny (1 sector) so the
// test stays fast while still exercising the bad+retry path.
let title = title_with_extent(0, 1);
let mut src = AlwaysBad;
let r = verify_title(&mut src, &title, 1, None);
assert_eq!(r.bad, 1);
assert_eq!(r.ranges.len(), 1);
assert_eq!(r.ranges[0].status, SectorStatus::Bad);
}
// ── New comprehensive tests ────────────────────────────────────────────────
/// A SectorSource that fails exactly `fail_count` calls, then succeeds.
struct FailFirst {
remaining_fails: usize,
}
impl SectorSource for FailFirst {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize, Error> {
if self.remaining_fails > 0 {
self.remaining_fails -= 1;
Err(Error::ScsiError {
opcode: 0x28,
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: None,
})
} else {
let n = count as usize * 2048;
buf[..n].fill(0);
Ok(n)
}
}
}
fn title_with_two_extents(s1: u32, c1: u32, s2: u32, c2: u32) -> DiscTitle {
DiscTitle {
playlist: String::new(),
playlist_id: 0,
duration_secs: 0.0,
size_bytes: 0,
clips: Vec::new(),
streams: Vec::new(),
chapters: Vec::new(),
extents: vec![
Extent {
start_lba: s1,
sector_count: c1,
},
Extent {
start_lba: s2,
sector_count: c2,
},
],
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
}
}
/// readable_pct on a fully-good result is 100.0 (not NaN or < 100).
/// Mutation: changing the formula to `(total - bad - 1) / total` makes this fail.
#[test]
fn readable_pct_all_good_is_exactly_100() {
let r = VerifyResult {
total_sectors: 100,
good: 100,
slow: 0,
recovered: 0,
bad: 0,
ranges: Vec::new(),
elapsed_secs: 0.0,
};
assert_eq!(r.readable_pct(), 100.0);
}
/// readable_pct with zero total_sectors must return 100.0, not NaN/inf
/// from a division by zero.
/// Spec: the doc-comment explicitly states "Returns 100.0 when there are no sectors."
/// Mutation: removing the `if self.total_sectors == 0` guard makes this panic or NaN.
#[test]
fn readable_pct_empty_disc_is_100() {
let r = VerifyResult {
total_sectors: 0,
good: 0,
slow: 0,
recovered: 0,
bad: 0,
ranges: Vec::new(),
elapsed_secs: 0.0,
};
assert_eq!(r.readable_pct(), 100.0);
}
/// readable_pct counts slow and recovered as readable (not bad).
/// Spec: doc says "good + slow + recovered" are readable.
/// Mutation: omitting `slow` or `recovered` from the numerator reduces the result.
#[test]
fn readable_pct_counts_slow_and_recovered_as_readable() {
// 6 of 10 sectors are "lost" (bad); 4 are readable (slow + recovered).
let r = VerifyResult {
total_sectors: 10,
good: 0,
slow: 2,
recovered: 2,
bad: 6,
ranges: Vec::new(),
elapsed_secs: 0.0,
};
// saturating_sub(6) = 4; 4/10 = 40.0
let got = r.readable_pct();
assert!((got - 40.0).abs() < 1e-9, "expected 40.0, got {got}");
}
/// is_perfect() is false when there are slow sectors, even if bad == 0.
/// Spec: doc says "no bad, no recovered-on-retry, and no slow sectors".
/// Mutation: removing `self.slow == 0` from is_perfect() makes this pass when it should fail.
#[test]
fn is_perfect_false_when_slow_present() {
let r = VerifyResult {
total_sectors: 10,
good: 9,
slow: 1,
recovered: 0,
bad: 0,
ranges: Vec::new(),
elapsed_secs: 0.0,
};
assert!(!r.is_perfect());
}
/// is_perfect() is false when there are recovered sectors.
/// Mutation: removing `self.recovered == 0` from is_perfect() makes this fail.
#[test]
fn is_perfect_false_when_recovered_present() {
let r = VerifyResult {
total_sectors: 10,
good: 9,
slow: 0,
recovered: 1,
bad: 0,
ranges: Vec::new(),
elapsed_secs: 0.0,
};
assert!(!r.is_perfect());
}
/// is_perfect() is false when there are bad sectors.
/// Mutation: removing `self.bad == 0` from is_perfect() makes this fail.
#[test]
fn is_perfect_false_when_bad_present() {
let r = VerifyResult {
total_sectors: 5,
good: 4,
slow: 0,
recovered: 0,
bad: 1,
ranges: Vec::new(),
elapsed_secs: 0.0,
};
assert!(!r.is_perfect());
}
/// chapter_at_offset returns None when chapters is empty.
/// Mutation: removing the `chapters.is_empty()` check makes this return Some.
#[test]
fn chapter_at_offset_empty_chapters_returns_none() {
let result = VerifyResult::chapter_at_offset(&[], 1024, 120.0, 100_000);
assert!(result.is_none());
}
/// chapter_at_offset returns None when total_bytes == 0 (division-by-zero guard).
/// Mutation: removing the `total_bytes == 0` guard makes this panic or NaN.
#[test]
fn chapter_at_offset_zero_total_bytes_returns_none() {
use crate::disc::Chapter;
let chapters = vec![Chapter {
time_secs: 0.0,
name: String::new(),
}];
let result = VerifyResult::chapter_at_offset(&chapters, 1024, 120.0, 0);
assert!(result.is_none());
}
/// chapter_at_offset maps byte offset to the correct chapter index (1-based).
/// Spec: time_secs = byte_offset / total_bytes * duration; chapter = last chapter
/// whose time_secs <= computed time.
/// Mutation: off-by-one on chapter_idx (using `i` instead of `chapter_idx`) changes result.
#[test]
fn chapter_at_offset_selects_correct_chapter() {
use crate::disc::Chapter;
// 3 chapters at 0s, 30s, 60s; disc is 120s, 120,000 bytes.
let chapters = vec![
Chapter {
time_secs: 0.0,
name: String::new(),
},
Chapter {
time_secs: 30.0,
name: String::new(),
},
Chapter {
time_secs: 60.0,
name: String::new(),
},
];
let total_bytes: u64 = 120_000;
let duration = 120.0_f64;
// Byte 90,000 → time 90.0s → chapter 3 (index 2, 1-based = 3).
let (ch, t) =
VerifyResult::chapter_at_offset(&chapters, 90_000, duration, total_bytes).unwrap();
assert_eq!(ch, 3, "expected chapter 3, got {ch}");
assert!((t - 90.0).abs() < 1e-6, "expected t≈90.0, got {t}");
// Byte 0 → time 0s → chapter 1 (first chapter).
let (ch0, _) =
VerifyResult::chapter_at_offset(&chapters, 0, duration, total_bytes).unwrap();
assert_eq!(ch0, 1);
}
/// A contiguous sequence of bad sectors in one extent must be coalesced
/// into a single SectorRange (not one entry per sector).
/// Mutation: removing the range-merge logic produces many small ranges.
#[test]
fn contiguous_bad_sectors_coalesce_into_one_range() {
// 4-sector extent, all bad. Batch size 1 forces per-sector path.
// With cancel-on-second-bad we'd stop early; we must let all 4 sectors
// run to check coalescing. Use no callback so they all complete.
// But AlwaysBad + no callback is slow (RETRY_PAUSE per sector × 4).
// Limit to 2 sectors to keep wall-clock reasonable.
let title = title_with_extent(100, 2);
let mut src = AlwaysBad;
// no callback → full 2 × RETRY_PAUSE, but only 2 sectors
let r = verify_title(&mut src, &title, 1, None);
// Both sectors are bad and contiguous; they must merge into one range.
assert_eq!(r.bad, 2);
assert_eq!(
r.ranges.len(),
1,
"contiguous bad sectors must coalesce: got {} ranges",
r.ranges.len()
);
assert_eq!(r.ranges[0].start_lba, 100);
assert_eq!(r.ranges[0].count, 2);
assert_eq!(r.ranges[0].status, SectorStatus::Bad);
}
/// A batch that fails, then each individual sector succeeds on the first
/// per-sector read, is counted as Good (not Recovered).
/// Mutation: counting per-sector-first-ok as Recovered instead of Good flips this.
#[test]
fn batch_fail_then_per_sector_ok_counts_as_good() {
// First read fails (batch), then all per-sector reads succeed.
let mut src = FailFirst { remaining_fails: 1 };
let title = title_with_extent(0, 2);
let r = verify_title(&mut src, &title, 2, None);
// Both sectors should be Good (or Slow if the read was slow — but
// FailFirst returns instantly, so they should be Good).
assert_eq!(r.bad, 0);
assert_eq!(r.recovered, 0);
// Good ≥ 1: at least one sector was read clean on the per-sector path.
assert!(r.good >= 1, "expected some good sectors, got {}", r.good);
}
/// A recovered sector (batch fails, per-sector fails, retry succeeds)
/// appears in ranges with SectorStatus::Recovered.
/// This is exercised by FailFirst with remaining_fails=2: batch fails (1),
/// first per-sector attempt fails (2), retry succeeds (0 remaining).
/// Mutation: mapping recovered to Good removes the Recovered range entry.
#[test]
fn recovered_sector_appears_in_ranges_as_recovered() {
// Batch read of 1 sector: fail once (batch), fail once (per-sector),
// then succeed (retry). That yields Recovered status.
let mut src = FailFirst { remaining_fails: 2 };
let title = title_with_extent(0, 1);
// Provide a callback that never cancels so the retry pause can run.
// But we want the test to be fast — FailFirst returns immediately,
// so the cancel-during-pause path won't be hit. However, we still
// sleep RETRY_PAUSE here (2s). Use no-cancel callback.
let noop_cb = |_: &crate::progress::PassProgress| true;
let r = verify_title(&mut src, &title, 1, Some(&noop_cb));
assert_eq!(r.recovered, 1, "sector should be Recovered");
assert_eq!(r.bad, 0);
assert_eq!(r.ranges.len(), 1);
assert_eq!(r.ranges[0].status, SectorStatus::Recovered);
}
/// total_sectors sums across multiple extents correctly.
/// Mutation: only summing the first extent makes total wrong.
#[test]
fn total_sectors_sums_multiple_extents() {
let title = title_with_two_extents(0, 3, 100, 5);
let mut src = AlwaysGood;
let r = verify_title(&mut src, &title, 4, None);
assert_eq!(r.total_sectors, 8, "expected 3+5=8 sectors");
assert_eq!(r.good, 8);
}
/// SectorRange byte_offset is zero for the very first sector of the first extent.
/// Mutation: starting byte_offset at 2048 instead of 0 shifts all offsets.
#[test]
fn sector_range_byte_offset_starts_at_zero_for_first_sector() {
// AlwaysBad with 1 sector: the range for LBA 0 must have byte_offset=0.
let title = title_with_extent(0, 1);
let mut src = AlwaysBad;
let r = verify_title(&mut src, &title, 1, None);
assert_eq!(r.ranges.len(), 1);
assert_eq!(
r.ranges[0].byte_offset, 0,
"first sector byte_offset must be 0"
);
}
/// SectorRange byte_offset for the second sector is 2048.
/// Spec: sector size = 2048 bytes on Blu-ray (ISO 9660 / ECMA-119 §7).
/// Mutation: computing offset as `i * 2049` changes the second sector offset.
#[test]
fn sector_range_byte_offset_increments_by_2048() {
// Force the second sector to be bad: fail twice (first two reads
// attempt LBA 0 in batch, then individually). That is complex.
// Simpler: use a source that only fails on LBA 1.
struct BadOnLba1;
impl SectorSource for BadOnLba1 {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize, Error> {
if lba == 1 && count == 1 {
Err(Error::ScsiError {
opcode: 0x28,
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: None,
})
} else if lba == 0 && count == 2 {
// Batch of 2 fails when it includes LBA 1 — simulate batch
// failure so we fall to per-sector.
Err(Error::ScsiError {
opcode: 0x28,
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: None,
})
} else {
let n = count as usize * 2048;
buf[..n].fill(0);
Ok(n)
}
}
}
// Extent: LBA 0..1 (2 sectors), batch size 2 so we get a batch attempt first.
let title = title_with_extent(0, 2);
let mut src = BadOnLba1;
let noop_cb = |_: &crate::progress::PassProgress| true;
let r = verify_title(&mut src, &title, 2, Some(&noop_cb));
// We expect exactly one bad sector at LBA 1.
assert_eq!(r.bad, 1);
let bad_range = r
.ranges
.iter()
.find(|rng| rng.status == SectorStatus::Bad)
.unwrap();
// LBA 1 is the second sector → byte_offset = 1 * 2048 = 2048.
assert_eq!(
bad_range.byte_offset, 2048,
"second sector byte_offset must be 2048, got {}",
bad_range.byte_offset
);
}
/// cancellable_pause returns true when it completes the full duration
/// without cancellation.
/// Mutation: returning `false` unconditionally makes callers think they were cancelled.
#[test]
fn cancellable_pause_returns_true_when_not_cancelled() {
// Use a very short duration so the test runs fast.
let tiny = Duration::from_millis(10);
let result = cancellable_pause(tiny, &mut || false);
assert!(result, "not-cancelled pause must return true");
}
/// cancellable_pause returns false immediately when the callback requests cancel.
/// Mutation: ignoring the callback return and sleeping the full duration breaks this.
#[test]
fn cancellable_pause_returns_false_when_cancelled() {
let long = Duration::from_secs(60);
let started = Instant::now();
let result = cancellable_pause(long, &mut || true);
assert!(!result, "cancelled pause must return false");
// Must return well within the first poll interval, not after 60s.
assert!(started.elapsed() < Duration::from_secs(1));
}
}
+41 -41
View File
@@ -84,9 +84,9 @@ fn aacs_decrypt_unit_roundtrip() {
];
// Build plaintext unit with TS sync bytes every 192 bytes starting at offset 4
let mut plain = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut plain = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
let mut offset = 4;
while offset < aacs::ALIGNED_UNIT_LEN {
while offset < aacs::content::ALIGNED_UNIT_LEN {
plain[offset] = 0x47; // TS sync byte
offset += 192;
}
@@ -118,7 +118,7 @@ fn aacs_decrypt_unit_roundtrip() {
// Step 3: AES-CBC encrypt bytes 16..6144
let cipher = Aes128::new(GenericArray::from_slice(&encrypt_key));
let mut prev = aacs_iv;
let num_blocks = (aacs::ALIGNED_UNIT_LEN - 16) / 16;
let num_blocks = (aacs::content::ALIGNED_UNIT_LEN - 16) / 16;
for i in 0..num_blocks {
let off = 16 + i * 16;
for j in 0..16 {
@@ -131,29 +131,29 @@ fn aacs_decrypt_unit_roundtrip() {
}
// Verify it looks encrypted (body TS syncs scrambled)
assert!(aacs::ts_sync_destroyed(&plain));
assert!(aacs::content::ts_sync_destroyed(&plain));
// Now decrypt
let result = aacs::decrypt_unit(&mut plain, &unit_key);
let result = aacs::content::decrypt_unit(&mut plain, &unit_key);
assert!(
result,
"decrypt_unit should return true on valid encrypted unit"
);
assert!(
!aacs::ts_sync_destroyed(&plain),
!aacs::content::ts_sync_destroyed(&plain),
"decrypted unit should read as clear (TS syncs restored)"
);
// Verify TS sync bytes at expected positions (flag byte is cleared by decrypt)
let mut sync_count = 0;
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
while off < aacs::content::ALIGNED_UNIT_LEN {
if plain[off] == 0x47 {
sync_count += 1;
}
off += 192;
}
let expected_syncs = (aacs::ALIGNED_UNIT_LEN - 4) / 192 + 1;
let expected_syncs = (aacs::content::ALIGNED_UNIT_LEN - 4) / 192 + 1;
assert_eq!(
sync_count, expected_syncs,
"TS sync bytes not recovered: got {}, expected {}",
@@ -176,11 +176,11 @@ fn aacs_disc_hash_deterministic() {
let data1 = b"Unit_Key_RO.inf test data for deterministic hashing";
let data2 = b"Different data should produce different hash";
let hash1a = aacs::disc_hash(data1);
let hash1b = aacs::disc_hash(data1);
let hash1a = aacs::inf::disc_hash(data1);
let hash1b = aacs::inf::disc_hash(data1);
assert_eq!(hash1a, hash1b, "disc_hash not deterministic on same input");
let hash2 = aacs::disc_hash(data2);
let hash2 = aacs::inf::disc_hash(data2);
assert_ne!(
hash1a, hash2,
"different inputs should produce different hashes"
@@ -190,7 +190,7 @@ fn aacs_disc_hash_deterministic() {
assert_eq!(hash1a.len(), 20);
// Verify disc_hash_hex formatting
let hex = aacs::disc_hash_hex(&hash1a);
let hex = aacs::inf::disc_hash_hex(&hash1a);
assert!(hex.starts_with("0x"), "hex should start with 0x prefix");
assert_eq!(
hex.len(),
@@ -225,7 +225,7 @@ fn aacs_decrypt_unit_key_roundtrip() {
encrypted_uk.copy_from_slice(&block);
// Decrypt with the public API
let decrypted = aacs::decrypt_unit_key(&vuk, &encrypted_uk);
let decrypted = aacs::derive::decrypt_unit_key(&vuk, &encrypted_uk);
assert_eq!(
decrypted, original_unit_key,
"decrypt_unit_key did not recover original unit key"
@@ -246,7 +246,7 @@ fn aacs_vuk_derivation_roundtrip() {
0x30,
];
let vuk = aacs::derive_vuk(&media_key, &volume_id);
let vuk = aacs::derive::derive_vuk(&media_key, &volume_id);
// VUK should be non-zero and different from both inputs
assert_ne!(vuk, [0u8; 16], "VUK should not be all zeros");
@@ -254,7 +254,7 @@ fn aacs_vuk_derivation_roundtrip() {
assert_ne!(vuk, volume_id, "VUK should differ from volume_id");
// Verify determinism
let vuk2 = aacs::derive_vuk(&media_key, &volume_id);
let vuk2 = aacs::derive::derive_vuk(&media_key, &volume_id);
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
}
@@ -263,14 +263,14 @@ fn aacs_vuk_derivation_roundtrip() {
fn aacs_ts_sync_destroyed_detection() {
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
// scrambled. (Flag bits play no role.)
let mut clear = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut clear = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
while off < aacs::content::ALIGNED_UNIT_LEN {
clear[off] = 0x47;
off += 192;
}
assert!(
!aacs::ts_sync_destroyed(&clear),
!aacs::content::ts_sync_destroyed(&clear),
"clear unit (syncs intact) must not be scrambled"
);
@@ -279,21 +279,21 @@ fn aacs_ts_sync_destroyed_detection() {
flagged[0] = 0xC0; // copy-control bits
flagged[7] = 0xC0; // TSC bits
assert!(
!aacs::ts_sync_destroyed(&flagged),
!aacs::content::ts_sync_destroyed(&flagged),
"flag bits must not be read as encryption"
);
// A scrambled body (syncs destroyed) → scrambled.
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
assert!(
aacs::ts_sync_destroyed(&scrambled),
aacs::content::ts_sync_destroyed(&scrambled),
"unit with no intact TS syncs must read as scrambled"
);
// Too short
let short = vec![0xFFu8; 100];
assert!(
!aacs::ts_sync_destroyed(&short),
!aacs::content::ts_sync_destroyed(&short),
"short buffer should not be detected"
);
}
@@ -303,10 +303,10 @@ fn aacs_ts_sync_destroyed_detection() {
/// A clear unit (TS syncs intact) should pass through decrypt_unit unchanged.
#[test]
fn aacs_decrypt_unit_unencrypted_passthrough() {
let mut unit = vec![0x42u8; aacs::ALIGNED_UNIT_LEN];
let mut unit = vec![0x42u8; aacs::content::ALIGNED_UNIT_LEN];
// Intact TS syncs every 192 bytes → not scrambled → passthrough.
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
while off < aacs::content::ALIGNED_UNIT_LEN {
unit[off] = 0x47;
off += 192;
}
@@ -315,8 +315,8 @@ fn aacs_decrypt_unit_unencrypted_passthrough() {
let original = unit.clone();
let key = [0xAA; 16];
assert!(!aacs::ts_sync_destroyed(&unit));
let result = aacs::decrypt_unit(&mut unit, &key);
assert!(!aacs::content::ts_sync_destroyed(&unit));
let result = aacs::content::decrypt_unit(&mut unit, &key);
assert!(result, "clear unit should return true");
assert_eq!(unit, original, "clear unit should be unchanged");
}
@@ -370,17 +370,17 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
0x10,
];
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut plaintext = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
// TS sync bytes every 192 bytes starting at offset 4
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
while off < aacs::content::ALIGNED_UNIT_LEN {
plaintext[off] = 0x47;
off += 192;
}
// Fill the rest with a recognisable pattern (prime modulus avoids artefacts).
// Index-arithmetic — clearer with a counted loop than an enumerate chain.
#[allow(clippy::needless_range_loop)]
for i in 16..aacs::ALIGNED_UNIT_LEN {
for i in 16..aacs::content::ALIGNED_UNIT_LEN {
if plaintext[i] == 0 {
plaintext[i] = (i % 251) as u8;
}
@@ -402,7 +402,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
ref_aes_cbc_encrypt(
&dk,
&CROSS_AACS_IV,
&mut plaintext[16..aacs::ALIGNED_UNIT_LEN],
&mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN],
);
// Sanity: ciphertext should differ
@@ -413,7 +413,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
);
// -- Decrypt with the library --
let ok = aacs::decrypt_unit(&mut plaintext, &unit_key);
let ok = aacs::content::decrypt_unit(&mut plaintext, &unit_key);
assert!(
ok,
"decrypt_unit returned false (TS sync verification failed)"
@@ -436,9 +436,9 @@ fn aacs_cross_validation_alternate_key() {
0x08,
];
let mut plaintext = vec![0xFFu8; aacs::ALIGNED_UNIT_LEN];
let mut plaintext = vec![0xFFu8; aacs::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
while off < aacs::content::ALIGNED_UNIT_LEN {
plaintext[off] = 0x47;
off += 192;
}
@@ -455,10 +455,10 @@ fn aacs_cross_validation_alternate_key() {
ref_aes_cbc_encrypt(
&dk,
&CROSS_AACS_IV,
&mut plaintext[16..aacs::ALIGNED_UNIT_LEN],
&mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN],
);
assert!(aacs::decrypt_unit(&mut plaintext, &unit_key));
assert!(aacs::content::decrypt_unit(&mut plaintext, &unit_key));
// Decryption clears no flag, so the unit round-trips byte-for-byte.
assert_eq!(&plaintext[..], &expected[..]);
@@ -473,15 +473,15 @@ fn aacs_bus_decrypt_cross_validation() {
0x00,
];
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut plaintext = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
#[allow(clippy::needless_range_loop)]
for i in 0..aacs::ALIGNED_UNIT_LEN {
for i in 0..aacs::content::ALIGNED_UNIT_LEN {
plaintext[i] = ((i * 3 + 17) & 0xFF) as u8;
}
let expected = plaintext.clone();
// Encrypt per-sector: AES-CBC encrypt bytes 16..2048 of each 2048-byte sector
for sector_start in (0..aacs::ALIGNED_UNIT_LEN).step_by(2048) {
for sector_start in (0..aacs::content::ALIGNED_UNIT_LEN).step_by(2048) {
ref_aes_cbc_encrypt(
&read_data_key,
&CROSS_AACS_IV,
@@ -490,7 +490,7 @@ fn aacs_bus_decrypt_cross_validation() {
}
assert_ne!(&plaintext[16..32], &expected[16..32]);
aacs::decrypt_bus(&mut plaintext, &read_data_key);
aacs::content::decrypt_bus(&mut plaintext, &read_data_key);
assert_eq!(
plaintext, expected,
"bus decrypt did not recover original plaintext"
@@ -738,7 +738,7 @@ fn aacs_parse_unit_key_ro_minimal() {
data[key_pos + i] = (0xA0 + i) as u8;
}
let result = aacs::parse_unit_key_ro(&data, aacs::AacsVersion::V10);
let result = aacs::inf::parse_unit_key_ro(&data, aacs::mkb::AacsVersion::V10);
assert!(
result.is_some(),
"parse_unit_key_ro should succeed on valid data"
@@ -751,6 +751,6 @@ fn aacs_parse_unit_key_ro_minimal() {
assert_eq!(ukf.disc_hash.len(), 20);
// disc_hash should be deterministic
let hash = aacs::disc_hash(&data);
let hash = aacs::inf::disc_hash(&data);
assert_eq!(ukf.disc_hash, hash);
}
+9 -9
View File
@@ -10,7 +10,7 @@ use libfreemkv::{aacs, decrypt::DecryptKeys};
#[test]
fn decrypt_sectors_with_aacs_keys_works() {
// Build an encrypted aligned unit
let mut unit = vec![0xFFu8; aacs::ALIGNED_UNIT_LEN];
let mut unit = vec![0xFFu8; aacs::content::ALIGNED_UNIT_LEN];
// Set encryption flag (bits 6-7 of byte 0)
unit[0] |= 0xC0;
@@ -19,7 +19,7 @@ fn decrypt_sectors_with_aacs_keys_works() {
for (i, byte) in unit
.iter_mut()
.enumerate()
.take(aacs::ALIGNED_UNIT_LEN)
.take(aacs::content::ALIGNED_UNIT_LEN)
.skip(1)
{
*byte = ((i * 3 + 7) & 0xFF) as u8;
@@ -28,7 +28,7 @@ fn decrypt_sectors_with_aacs_keys_works() {
let unit_key: [u8; 16] = [0xAAu8; 16];
// Encrypt the unit using AACS algorithm
aacs::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data
aacs::content::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data
// Now we have encrypted data - create DecryptKeys with actual keys
let mut keys = DecryptKeys::Aacs {
@@ -83,23 +83,23 @@ fn decrypt_sectors_with_css_keys_works() {
#[test]
fn aacs_encryption_flag_detection() {
// A clear unit: TS syncs (0x47) intact at every 192-byte packet.
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
while off < aacs::content::ALIGNED_UNIT_LEN {
unit[off] = 0x47;
off += 192;
}
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
assert!(!aacs::ts_sync_destroyed(&unit));
assert!(!aacs::content::ts_sync_destroyed(&unit));
// Flag bits on a synced unit do not make it look encrypted.
unit[0] = 0xC0;
unit[7] = 0xC0;
assert!(!aacs::ts_sync_destroyed(&unit));
assert!(!aacs::content::ts_sync_destroyed(&unit));
// Scrambled body (syncs gone) → encrypted.
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
assert!(aacs::ts_sync_destroyed(&scrambled));
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
assert!(aacs::content::ts_sync_destroyed(&scrambled));
}
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
+1
View File
@@ -275,6 +275,7 @@ fn patch_block_sectors_zero_does_not_busy_spin() {
progress: None,
halt: Some(halt.clone()),
key_fetch: None,
fast_capture: false,
};
let outcome = disc.patch(&mut reader, &iso_path, &opts);
+116 -26
View File
@@ -397,10 +397,14 @@ fn profile_02_all_medium_error() {
);
assert!(!pr.halted, "02_all_medium halted");
// Upper bound: every sector probed individually + a few batch-drop
// and skip-escalation attempts. 16 sectors × ~3 visits ≈ 50.
// and skip-escalation attempts, PLUS scatter-recovery on each hard
// single sector (up to SCATTER_MAX_ATTEMPTS fresh tries, each a
// recalibration read + a re-read = +6 reads/sector). Still strictly
// bounded — the guard exists to catch an UNBOUNDED retry loop, which
// would be in the hundreds.
assert!(
trace_len <= 80,
"02_all_medium trace_len={trace_len} exceeds 80"
trace_len <= 200,
"02_all_medium trace_len={trace_len} exceeds 200"
);
}
@@ -463,9 +467,13 @@ fn profile_03_alternating_good_bad() {
"03_alternating expected NonTrimmed remainder, got bytes_pending=0"
);
assert!(!pr.halted, "03_alternating halted");
// Efficiency guard (catches runaway, not the tier-2 roster). The 8
// permanently-bad sectors are now additionally probed by the tier-2
// marginal specialists (SlowSpin/FuaRetry/SlowFua/CachePrime/Oscillate/
// SpeedSweep) before being left NonTrimmed — bounded, finite extra reads.
assert!(
trace_len <= 120,
"03_alternating trace_len={trace_len} exceeds 120"
trace_len <= 220,
"03_alternating trace_len={trace_len} exceeds 220"
);
}
@@ -636,9 +644,13 @@ fn profile_06_deep_pit() {
"06_deep_pit bytes_pending expected > 0"
);
assert!(!pr.halted, "06_deep_pit halted");
// Bounded as in profile 02: the deep pit's hard single sectors each get
// scatter-recovery (up to SCATTER_MAX_ATTEMPTS recalibrate + re-read
// tries) on top of the baseline probe/skip walk. Still bounded — a
// runaway loop would be in the hundreds.
assert!(
trace_len <= 120,
"06_deep_pit trace_len={trace_len} exceeds 120"
trace_len <= 180,
"06_deep_pit trace_len={trace_len} exceeds 180"
);
}
@@ -691,31 +703,28 @@ fn profile_07_medium_then_good() {
trace,
);
// GOLDEN: patch's cache-priming (`prime_cache`) issues 3 throwaway
// single-sector reads at lba-3, lba-2, lba-1 (NOT lba itself) before
// each count==1 recovery read. In the default REVERSE patch pass,
// lba 108's prime reads lba 105 (consuming step 0 = fail) and lba 107's
// prime reads lba 105 again (consuming step 1 = fail), so when the real
// recovery read for lba 105 occurs its script step is 2 (= ok).
// prime_cache(105) itself reads 102, 103, 104 — it does NOT advance
// lba 105's own counter. Net effect: patch fully recovers the range in
// one pass thanks to priming, even though the script said "fails on
// first two attempts."
//
// This is the documented cache-prime behavior (`disc/patch.rs`
// ~line 398, "Proven 2026-05-07 with dd-as-oracle: 8/8 sectors
// recoverable when primed vs 6/8 cold"). The golden pins it.
// GOLDEN (handler-chain engine): with the script "fail, fail, ok" per bad
// sector, each bad sector needs three reads to recover. The chain re-reads a
// sector across successive handlers — linear reverse/forward (each narrows a
// failed batch to per-sector reads) then bisect — so every sector in the
// cluster is read enough times to consume its two failing steps and reach
// the Ok step WITHIN one pass. So all 256 sectors recover here; none defer.
// (The old batch-halving loop reached only 254; the chain is strictly
// better because more handlers re-touch each sector.)
assert_eq!(
stats.bytes_good,
capacity_sectors as u64 * 2048,
"07_medium_then_good bytes_good — cache-prime should consume \
the failing script steps so the real read sees Ok"
256 * 2048,
"07_medium_then_good bytes_good (handler chain re-reads each sector \
across handlers, consuming the fail,fail,ok script for the whole cluster)"
);
assert_eq!(
stats.bytes_unreadable, 0,
"07_medium_then_good bytes_unreadable"
"07_medium_then_good bytes_unreadable (NonTrimmed, never terminal in one pass)"
);
assert_eq!(
stats.bytes_pending, 0,
"07_medium_then_good bytes_pending (whole cluster recovered in one pass)"
);
assert_eq!(stats.bytes_pending, 0, "07_medium_then_good bytes_pending");
assert!(!pr.halted, "07_medium_then_good halted");
assert!(
trace_len <= 100,
@@ -810,3 +819,84 @@ fn profile_08_batch_fail_singles_ok() {
// If the unification ever proceeds, the next step is to add a clock
// injection point in `handle_read_failure` and extend this fixture
// with the wedge/NOT_READY profiles too.
// ──────── Handler chain recovers re-readable sectors inside a bad block ────────
//
// A bad range holds one genuinely-dead sector surrounded by readable ones. The
// handler chain's linear pass narrows a failed batch to per-sector reads, so it
// recovers EVERY re-readable sector and leaves ONLY the dead sector NonTrimmed —
// strictly better than the old fast-capture path, which left the whole failed
// 32-block untouched. (`fast_capture` is now inert: the chain supersedes it. The
// breadth-first "fast on all ranges, then escalate" ORDERING it once provided is
// a scheduling concern for the handler scheduler, tracked separately.)
//
// The load-bearing invariant is unchanged: NO data is dropped. A still-bad
// sector becomes NonTrimmed (pending, retried by a later pass), NEVER Unreadable.
#[test]
fn handler_chain_recovers_readable_sectors_leaving_only_dead_pending() {
let capacity_sectors: u32 = 256;
let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors);
// One bad sector at LBA 130 — inside the LOW 32-sector block of the range.
reader.always(
130,
ScriptStep::Err {
sense_key: 3,
asc: 0x11,
ascq: 0x05,
},
);
let total_bytes = capacity_sectors as u64 * SECTOR_SIZE as u64;
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().unwrap();
let iso_path = tmp.path().to_path_buf();
drop(tmp);
// 64-sector NonTrimmed range [128,192); everything else already Finished.
let nontrimmed = [(128 * 2048, 64 * 2048)];
let finished = [
(0, 128 * 2048),
(192 * 2048, (capacity_sectors as u64 - 192) * 2048),
];
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
let opts = libfreemkv::disc::PatchOptions {
decrypt: false,
block_sectors: Some(32),
full_recovery: true,
reverse: true,
wedged_threshold: 50,
progress: None,
halt: None,
key_fetch: None,
fast_capture: true,
};
disc.patch(&mut reader, &iso_path, &opts)
.expect("fast-capture patch must not error");
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
let stats = Mapfile::load(&map_path).unwrap().stats();
// The clean sectors of [128,192) all recover; only the one always-dead
// sector (LBA 130) stays NonTrimmed — NOT Unreadable. The chain narrows the
// failed batch to per-sector reads, so 63 of the 64 range sectors come back.
// Conservation: 255 good + 1 still-pending = the full 256, nothing lost.
assert_eq!(
stats.bytes_unreadable, 0,
"recovery must never mark Unreadable in a pass"
);
assert_eq!(
stats.bytes_pending, 2048,
"only the single always-dead sector (LBA 130) stays NonTrimmed"
);
assert_eq!(
stats.bytes_good,
255 * 2048,
"every sector except the one dead LBA is recovered"
);
let _ = trace; // read trace retained by the fixture; no ordering assertion here
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(&map_path);
}