Compare commits

..
68 Commits
Author SHA1 Message Date
Matthew Jackson eaa7ddc683 v1.4.1: bump version (freemkv-unlock git-pinned for the tag) 2026-07-14 14:45:30 -07:00
Matthew Jackson 723c7edf45 Fix mux concealing decryptable video over a single defective packet
AACS content decryption rejected a whole 6144-byte aligned unit unless
EVERY content packet was conformant MPEG-TS. One authored-bad packet (a
pressing/encoding defect or an AACS 2.1 forensic-variant frame) made the
mux conceal the entire unit as NULL TS — destroying up to 31/32 good
packets and tallying them as loss, surfacing as false "corruption" on
otherwise-clean discs (observed across two UHD titles).

decrypt_unit now asks only "did a key OPEN this unit?" — a padding-aware
>=75% supermajority of content packets restoring their 0x47 sync, a gate
no wrong key can reach (uniform-AES noise floor) yet one that tolerates a
minority of authored-bad packets. Opened units pass through VERBATIM; a
non-conforming packet is left for the demuxer to drop on sync-loss and
resync past. TS-sync conformance is a muxer concern, never a decryption
verdict. The post-read verify/sweep gate now shares the same primitive so
it can never disagree with the mux decrypt.

Also unify the MVC (Blu-ray 3D) track signals: the mvcC CodecPrivate
extension, the BlockAdditionMapping, and each per-frame BlockAdditional
all derive from one MVCDecoderConfigurationRecord built once per track, so
a malformed dependent-view parameter set can no longer orphan a BlockAddID.
2026-07-14 14:43:06 -07:00
Matthew Jackson d9881c57c0 restore freemkv-unlock path dep for local dev (post-v1.4.0) 2026-07-13 19:28:55 -07:00
Matthew Jackson 61b4486ae9 v1.4.0: bump version (freemkv-unlock git-pinned for the tag) 2026-07-13 19:28:52 -07:00
Matthew Jackson 72d39544ac Changelog: 1.4.0 (Blu-ray 3D / MVC) 2026-07-13 12:47:03 -07:00
Matthew Jackson 1e394eaeb8 3D MVC mux: audit round 2 (converged)
Second audit round converged (severity collapsed 6 HIGH -> 1; the one
HIGH was a bounded 32-element scan, not a defect; the sole spec MEDIUM
was the same false-positive re-raised — 0xBF matches ISO/IEC 14496-15
§7.6.2 verbatim). One genuine robustness fix plus coverage:

- extract_mvc_params: skip a zero-length NAL instead of abandoning the
  scan, so a stray length prefix before the subset SPS/PPS no longer
  silently drops 3D signalling. Test proves params after a zero-length
  NAL are still found.
- Tests: parser_for_mvc_dependent routes H.264 to a passthrough parser;
  passthrough with an IDR does not re-assert param sets (the keyframe &&
  !mvc branch).
- Document the per-playlist (not per-clip) is_3d latching as a known
  limitation (real main-feature playlists are uniformly 3D).
2026-07-13 11:39:07 -07:00
Matthew Jackson 4d1aa6bbf7 Harden 3D MVC mux: robustness + tests (audit round 1)
Triage of a 10-lens code audit of the 3D branch. Fixes for real defects;
rejected three spec false-positives that matched the ISO/IEC 14496-15
§7.6.2 record verbatim.

Robustness / correctness:
- Never panic when a title's only video is the MVC dependent view: the
  base is now the first NON-dependent video, so a dependent-only title
  sets up no merge (muxed as an ordinary track) instead of hitting an
  `expect` on the skipped track slot.
- Drop a per-frame BlockAdditional (BlockAddID=2) when the track declared
  no mvcC mapping (dependent params not captured before the header) — a
  plain block keeps the file conforming instead of an orphaned add.
- A non-keyframe MVC base frame always carries a ReferenceBlock (fall back
  to a 0 offset in the pre-first-keyframe corner) so it is never mistaken
  for a seek point.
- Reference the last keyframe on the PRIMARY video track only, so a
  secondary video track's keyframe can't become a cross-track reference.
- dep_by_pts overflow: bound BEFORE inserting so the just-arrived
  dependent survives the drift-clear; count a displaced duplicate-PTS
  dependent as an orphan instead of losing it silently.

API / docs:
- Fold write_frame_with_additional into write_frame(..., Option<&[u8]>)
  per the "no foo_with_X" convention.
- Fix mvc_params doc (StereoMode is intentionally not emitted); remove a
  stale PAT/PMT comment describing an approach that was never taken.

Tests: MVCDecoderConfigurationRecord over-length guards; write_int minimal
two's-complement widths; BlockGroup/BlockAdditions/BlockAdditional +
ReferenceBlock emission; additional dropped without a mapping; h264 MVC
passthrough keeps param sets in-band; extract_mvc_params no-panic on
truncated/empty input; pairing window + dep-overflow edges; no-panic on a
dependent-only title.
2026-07-13 11:24:23 -07:00
Matthew Jackson 9ce03b4d1d Mux Blu-ray 3D (MVC) as a single MVC video track
Fold the MVC dependent (right-eye) view into the base H.264 track as a
per-frame BlockAdditional under an mvcC BlockAdditionMapping, so a 3D
title produces one MVC video track instead of two independent H.264
tracks.

- h264: MVC-passthrough parser mode keeps the dependent view's subset
  SPS/PPS in-band, so each emitted frame is a self-contained dependent
  access unit for a BlockAdditional
- resolve: route the dependent stream through the passthrough parser
- mkvstream: detect the dependent view, pair it to the base frame by
  PTS (bounded FIFO), attach it as a BlockAdditional (BlockAddID=2),
  and skip building its own track; build the mvcC
  MVCDecoderConfigurationRecord from the captured subset SPS/PPS and set
  it on the base track at activation
- mkv: emit the mvcC BlockAdditionMapping and BlockGroup/BlockAdditions,
  with a ReferenceBlock on non-keyframe base frames
- ebml: add BlockAdditions/BlockMore/BlockAdditional/BlockAddID/
  BlockAddIDValue/ReferenceBlock elements and a signed-int writer

Verified against a Blu-ray 3D ISO: ffprobe shows a single MVC track,
the mvcC mapping is present, ~144k BlockAdditionals carry the dependent
view (8.7 GB), and the base view decodes cleanly with no regression.
MVCDecoderConfigurationRecord follows ISO/IEC 14496-15 7.6.2; StereoMode
is intentionally omitted (no enum value describes MVC-in-BlockAdditional;
the mvcC mapping is the primary 3D signal per RFC 9559).
2026-07-13 11:00:04 -07:00
Matthew Jackson f099a4a31a feat(3d): read the SSIF and enumerate the MVC dependent view
Blu-ray 3D as a mux-path variant:
- Detect 3D (a clip has STREAM/SSIF/<clip>.ssif — note the SSIF/ subdir, which
  the old .ssif fallback path got wrong) and use the SSIF extents for the mux:
  one transport stream carrying both the base (left) and MVC dependent (right)
  eyes on distinct PIDs, so muxing it captures the full 3D.
- Add the dependent-view video stream. The base STN table lists only the left
  eye; the on-disc PAT/PMT are AACS-encrypted (unreadable pre-key) and the base
  STN omits the dependent view (it lives in the MPLS STN_table_SS), so use the
  BD-3D PID convention: dependent = base video PID + 1 (0x1011 -> 0x1012),
  coding 0x20 -> H.264. The demux routes its packets from the SSIF.

info now reports two video streams for a 3D title; the mux writes both eyes.
2026-07-12 23:41:47 -07:00
Matthew Jackson e606b5e3a2 feat(3d): enumerate MVC dependent view (stream_type 0x20 -> H.264 video)
from_coding_type maps 0x20 (MVC dependent view) to Codec::H264, so the existing
PAT/PMT scan surfaces the SSIF right-eye substream as a second video stream on
its own PID instead of dropping it as Unknown. No new parser: the dependent eye
rides the same demux path as any other TS video stream. Removes the throwaway
3D-structure probes (findings captured in prior commit messages).
2026-07-12 22:16:13 -07:00
Matthew Jackson de809189ec feat(3d): add MVC dependent-view coding type (0x20)
First step of Blu-ray 3D as a mux-path format variant: name stream_type 0x20
(H.264/MVC dependent view, the SSIF right-eye substream). Recognition in the
scan/demux + dependent-stream enumeration follow.
2026-07-12 22:11:01 -07:00
Matthew Jackson 253b0924e3 wip(3d): prove SSIF de-interleave = ssif−base, dependent decrypts on same UK
Empirical (300, clip 00042): base .m2ts (1567 extents, 25.85 GB) is entirely
inside the SSIF LBA span; dependent = SSIF − base = 1567 ranges, 9.59 GB
(exact vs 35.43−25.85). 200/200 sampled dependent-view units decrypt under the
base UK. => de-interleave needs no SSIF parsing (complement of the base extents
we already resolve), and the dependent eye needs no extra key.
2026-07-12 20:40:58 -07:00
Matthew Jackson 98a7892c34 wip(3d): read-only 3D-structure probe (SSIF/m2ts + MPLS ExtensionData)
Dev tool for the 3D build: uses the library UDF reader (7z cannot parse these
ISOs) to dump base .m2ts vs SSIF sizes and the MPLS ExtensionData entries
(STN_table_SS + SS sub-path). Not product code.
2026-07-12 20:38:37 -07:00
Matthew Jackson b82d21756a fix(udf): follow allocation-descriptor continuation past the AED header
read_icb_extents parsed a continuation block's allocation descriptors from
offset 0, but a continuation block begins with a 24-byte Allocation Extent
Descriptor (ECMA-167 4/14.5): 16-byte descriptor tag + prev-loc(4) +
length_of_allocation_descriptors(4); the real ADs start at offset 24. Reading
from 0 mis-parsed the AED tag as a descriptor -> one garbage extent, then an
unknown-type break, silently truncating any file whose extents spill into a
continuation block.

Few-extent files store their ADs inline and never hit this path, so it stayed
hidden; a heavily fragmented file (~1600 fragments) truncated to 113 extents +
a bogus non-unit-aligned fragment that then aborted the mux
(ExtentNotUnitAligned). Skip the 24-byte AED header, read l_ad bytes of
descriptors from offset 24. Adds a regression test with a non-zero AED tag.
2026-07-12 20:37:55 -07:00
Matthew Jackson 02c584a3d9 restore freemkv-unlock path dep for local dev (post-v1.3.2) 2026-07-10 14:26:11 -07:00
Matthew Jackson 564e66fd24 v1.3.2: bump version (freemkv-unlock git-pinned for the tag) 2026-07-10 14:26:08 -07:00
Matthew Jackson 57d40e81a5 1.3.2: AACS 2.1 FMTS variant-decode foundation
Add UnitKey.variant_number (0 = ordinary, 1..32 = forensic variant) with new/variant constructors, and aacs::variant_select — resolve a disc's single variant and classify each aligned unit (default / variant / drop foreign / conceal keyless). Correct IndividualSegment.tbl: the per-record field is the variant (cycles 1..32 on a retail disc), not a segment number — Segment.number -> Segment.variant.
2026-07-10 14:00:28 -07:00
Matthew Jackson 674819d937 changelog: trim the 1.3.1 relicense note to just the relicense fact 2026-07-10 12:53:17 -07:00
Matthew Jackson c5e8421f9f restore freemkv-unlock path dep for local dev (post-v1.3.1) 2026-07-10 12:35:12 -07:00
Matthew Jackson 7dcf9b5a0d v1.3.1: bump version (freemkv-unlock git-pinned for the tag) 2026-07-10 12:35:09 -07:00
Matthew Jackson f01c097784 1.3.1: relicense to MIT (clean-room CSS + drop copyleft-lib references)
Relicensed from AGPL-3.0 to MIT, effective 1.3.1 (<=1.3.0 remain AGPL). The CSS
content cipher and Stevenson title-key attack are attributed to their published
cryptanalysis (not libdvdcss); all libaacs/libbluray/libdvdread/libdvdnav name
references were dropped from comments while keeping the standard format/spec
descriptions. Also bumps to 1.3.1.
2026-07-10 12:31:19 -07:00
Matthew Jackson c265fa349e hddvd: authoritative title composition from the Advanced-Content playlist
Parse ADV_OBJ/VPLST000.XPL (the real HD-DVD player playlist) with roxmltree
into one DiscTitle per <Title>: its PrimaryAudioVideoClip clips in order (EVO
via the .MAP sidecar), the titleDuration, the displayName, and the ChapterList.
A layer-break split (FEATURE_1+FEATURE_2, feature/feature_Divide) composes into
ONE title with the two parts as clips, each carrying its title-time in/out
points (45kHz ticks) for seamless-join splicing. Falls back to the clip-name
heuristic when no playlist is present.

Fixes unknown runtimes (real durations), poor names (FEATURE -> 'Main Movie'),
and gives authoritative composition + chapters. Validated on ANCHORMAN (97m),
SHAUN (99m), HARRY_POTTER (152m).
2026-07-10 10:28:18 -07:00
Matthew Jackson 6a59dcb7ba ci: drop the dead crates.io publish job (libfreemkv is git-tag-only)
libfreemkv is publish=false (it git-deps the firmware crate freemkv-unlock,
never on crates.io), so the Release workflow's 'cargo publish' step failed
hard on every tag. Consumers git-tag-pin libfreemkv; the git tag is the
release artifact. Removed the publish job.
2026-07-10 08:52:57 -07:00
Matthew Jackson 0eb1817ec7 restore freemkv-unlock path dep for local dev (post-v1.3.0) 2026-07-10 08:43:47 -07:00
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
81 changed files with 10452 additions and 3678 deletions
+5 -23
View File
@@ -39,29 +39,11 @@ jobs:
# would always fail (no lockfile to lock against on a fresh runner).
- run: cargo test
# crates.io publish is an INDEPENDENT job: it serves EXTERNAL consumers only.
# The freemkv binaries no longer depend on it (they git-tag-pin libfreemkv via
# a committed [patch.crates-io]), so this publish runs in parallel with their
# release builds rather than gating them. It `needs: [verify, test]` so a
# failing test suite still blocks publication to crates.io — external
# consumers who `cargo add libfreemkv` must never receive a release whose
# tests were failing. (The two upstream jobs run in parallel, so this gate
# does not serialize publish behind test beyond their own completion.)
publish:
needs: [verify, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.86.0
- uses: Swatinem/rust-cache@v2
# --no-verify: CI already compiled this exact commit (in the `test` job
# and on every push via ci.yml). cargo publish's default re-verify does a
# full cold release build of the packaged tarball, which here is pure
# redundant work (~a cold lib build). Skip it.
- name: Publish to crates.io
run: cargo publish --no-verify
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
# NOTE: there is no crates.io publish job. libfreemkv is git-tag-only
# (`package.publish = false` — it git-deps the firmware crate freemkv-unlock,
# which never ships to crates.io). Every consumer git-tag-pins libfreemkv via
# a committed [patch.crates-io]; the git tag itself IS the release artifact.
# A `cargo publish` here fails hard on `publish = false`, so it was removed.
release:
# Only needs `verify`; the GitHub Release can be cut as soon as the version
+173 -5
View File
@@ -1,5 +1,173 @@
# Changelog
## [1.4.1] — 2026-07-14
### Fixed
- **Mux no longer discards good video over a single defective packet.** AACS
content decryption judged a 6144-byte aligned unit "undecryptable" unless
**every** content packet was conformant MPEG-TS. A single authored-bad packet
— a pressing/encoding defect, or an AACS 2.1 forensic-variant frame — made the
mux conceal the **whole** unit as NULL TS, destroying up to 31 of 32 good
packets and tallying them as loss. On discs carrying such packets this
surfaced as false "corruption" over large runs of otherwise-perfect video
(observed across two UHD titles: ~466 MB concealed, every unit decryptable).
The decrypt path now asks only *"did a key OPEN this unit?"* — a padding-aware
**≥75% supermajority** of content packets restoring their `0x47` sync, a gate
no wrong key can reach (uniform-AES noise floor ≈ 256⁻ⁿ) yet one that tolerates
a minority of authored-bad packets. Opened units pass through **verbatim**; a
non-conforming packet is left for the demuxer to drop on sync-loss and resync
past — TS-sync conformance is a muxer concern, never a decryption verdict. The
read/decrypt path no longer rewrites content bytes. The post-read verify/sweep
gate now shares the exact same primitive (`decrypt_unit` for TS), so verify can
never disagree with the mux decrypt and never false-marks a defect unit as a
bad read.
- **MVC (Blu-ray 3D) track signals unified and hardened.** The `mvcC`
`CodecPrivate` extension, the `BlockAdditionMapping`, and each frame's
`BlockAdditional` now all derive from a single `MVCDecoderConfigurationRecord`
built once per track, so they can no longer diverge. A track is flagged 3D
only when that record actually builds — a malformed dependent-view parameter
set no longer emits a mapping with no matching record (previously the flag was
taken from `mvc_params.is_some()`, which could orphan a `BlockAddID`). The base
track's `CodecPrivate` now carries the `mvcC` extension block
(`avcC ‖ u32be(size) ‖ "mvcC" ‖ record`, Matroska-spec size = block − 4) so
players and mediainfo detect MVC at the track level.
## [1.4.0] — 2026-07-13
### Added
- **Blu-ray 3D (MVC) support.** A 3D disc now rips to an MKV that preserves
**both eyes** as a single MVC video track — the AVC base (left) view in each
Block, and the MVC dependent (right-eye) view as a per-frame `BlockAdditional`
under an `mvcC` `BlockAdditionMapping` (`MVCDecoderConfigurationRecord` per
ISO/IEC 14496-15 §7.6.2), paired to the base by PTS. Remux only — no
transcode, no side-by-side conversion. The Blu-ray scan reads the interleaved
`STREAM/SSIF/<clip>.ssif`, enumerates the dependent view (stream_type `0x20`)
by the BD-3D PID convention, and parses it in a parameter-set-passthrough mode
so every dependent frame is a self-contained access unit. Verified on
*300: Rise of an Empire*: one MVC track, ~8.7 GB dependent payload carried in
per-frame BlockAdditionals, base view byte-identical to the 2D rip.
## [1.3.2] — 2026-07-10
### Added
- **AACS 2.1 (FMTS) variant-decode foundation.** `UnitKey` gains a
`variant_number` field (`0` = ordinary content, `1..=32` = a forensic
variant) with `UnitKey::new` / `UnitKey::variant` constructors, and a new
`aacs::variant_select` module resolves a disc's single forensic variant and
classifies each aligned unit — decrypt with the default key, decrypt with the
variant key, drop a foreign variant, or conceal a keyless forensic unit. This
is the groundwork for selecting one variant's segments and dropping the other
31; the decrypt-pipeline wiring lands with the variant key source.
### Fixed
- **`IndividualSegment.tbl`: the per-record field is the variant, not a segment
number.** `Segment.number` → `Segment.variant`. Verified against a retail 2.1
disc, the field cycles `1..=32` across the table (a per-variant tag) rather
than counting up, so variant selection routes on the correct value.
## [1.3.1] — 2026-07-10
### Licensing
- **Relicensed to the MIT License, from 1.3.1 onwards** (releases up to and
including 1.3.0 remain under AGPL-3.0).
### Added
- **Authoritative HD-DVD title composition** from the Advanced-Content playlist
(`ADV_OBJ/VPLST000.XPL`): each title's clips, real duration, display name, and
chapters come from the disc's own playlist instead of a clip-name heuristic. A
layer-break split (`FEATURE_1`+`FEATURE_2`, `feature`/`feature_Divide`) composes
into ONE title with the two parts as clips and their title-time offsets. Falls
back to the clip-name heuristic when no playlist is present.
## [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
@@ -39,7 +207,7 @@
- **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`)
tried directly against the MKB cvalue tables (direct PK × cvalue iteration)
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
@@ -272,7 +440,7 @@ consumers are the in-tree toolchain crates.
- **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`)
(CPI flag → decrypt → strict all-32 TS-sync)
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
@@ -293,7 +461,7 @@ consumers are the in-tree toolchain crates.
- **AACS decrypt acceptance is now standards-strict.** A key is accepted only
when the decrypted unit has the TS sync byte on *all* 32 source packets
(libaacs `_verify_ts`), replacing a majority-vote heuristic where a wrong key
(all-32 TS-sync verify), replacing a majority-vote heuristic where a wrong key
could coincidentally restore enough syncs to pass and silently corrupt a unit.
- keydb download/save moved out of the library into freemkv-keysources;
libfreemkv no longer has any keydb I/O (it already held no keys).
@@ -301,7 +469,7 @@ consumers are the in-tree toolchain crates.
### Fixed
- **AACS content-certificate bus-encryption flag read from the wrong bit.** The
flag is bit 7 of byte 1 (libaacs `p[1] >> 7`) but was read as bit 0, so a
flag is bit 7 of byte 1 (`p[1] >> 7`) but was read as bit 0, so a
bus-encrypted disc parsed as *not* bus-encrypted — defeating the fail-loud
guard that refuses to decrypt bus-wrapped data to garbage when no bus key was
obtained. Also corrected the cc_id offset (byte 14) and the AACS2 type marker
@@ -601,7 +769,7 @@ hardening.
- **Keyless DVD/CSS title-key recovery.** A CSS-protected DVD decrypts with no
key database — the title key is recovered directly from the scrambled disc
data via the Stevenson known-plaintext attack (ported from libdvdcss) and
data via the Stevenson known-plaintext attack and
validated by descrambling a sector and confirming the known plaintext
reappears, so a wrong key fails cleanly instead of producing silent garbage
(`src/css/stevenson.rs`). `Disc::scan_image` recovers the same title key from
+1 -1
View File
@@ -24,4 +24,4 @@ cargo test
## License
By contributing, you agree your code will be licensed under AGPL-3.0.
By contributing, you agree your code will be licensed under MIT.
+9 -3
View File
@@ -1,9 +1,9 @@
[package]
name = "libfreemkv"
version = "1.2.2"
version = "1.4.1"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
license = "MIT"
description = "Open source raw disc access library for optical drives"
repository = "https://github.com/freemkv/libfreemkv"
keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
@@ -28,7 +28,7 @@ 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.2.2" }
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.4.1" }
num-bigint = "0.4"
num-traits = "0.2"
num-integer = "0.1"
@@ -36,6 +36,12 @@ rand = "0.8"
cmac = "0.7"
zip = { version = "2", default-features = false, features = ["deflate"] }
base64 = "0.22.1"
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
# parser, not a hand-rolled scanner: the XPL is genuine XML (comments, varied
# attribute order, self-closing tags).
roxmltree = "0.20"
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
# wire a tracing subscriber and pipe events into the JSONL debug log.
+17 -12
View File
@@ -1,16 +1,21 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
MIT License
Copyright (C) 2026 FreeMKV Contributors
Copyright (c) 2026 Matthew Jackson & Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, version 3 of the License.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2 -2
View File
@@ -1,4 +1,4 @@
[![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue)](LICENSE)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
# libfreemkv
@@ -190,4 +190,4 @@ Run `freemkv info disc:// --share` with the [freemkv CLI](https://github.com/fre
## License
AGPL-3.0-only
MIT
+1 -1
View File
@@ -7,7 +7,7 @@ AACS decryption requires an external `keydb.cfg` (default
material is compiled in; DVD CSS player keys are the only compiled-in keys.
**Repository:** <https://github.com/freemkv/libfreemkv>
**License:** AGPL-3.0-only
**License:** MIT
---
-500
View File
@@ -1,500 +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,
//! both via the MKB's Subset-Difference cvalue tables: the device-key path
//! recovers its Processing Key at the matching SD node and walks on to the MK;
//! the processing-key path starts from a precomputed PK. Neither needs a VID
//! (the VID enters at `vuk_from_mk`).
use super::keys::{
decrypt_unit_key, derive_media_key_and_pk_from_dk, derive_media_key_from_pk, derive_vuk,
};
use super::types::DeviceKey;
/// 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 [`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 positioned device keys via the MKB's
/// Subset-Difference tables.
///
/// Wraps [`derive_media_key_and_pk_from_dk`] — the real SD walk the resolver
/// runs: each positioned device key is placed against the MKB's subset-diff /
/// cvalue records, recovering its Processing Key at the matching node and
/// continuing to the Media Key. Reachable for real discs whenever a device key
/// applies to the MKB. No VID is involved here — it enters at [`vuk_from_mk`].
///
/// Returns [`Error::AacsMkUnavailable`] (E7018) when no supplied device key
/// resolves the MKB — the same terminal error as [`mk_from_pk`]; no numeric
/// distinction is load-bearing at this boundary.
///
/// [`Error::AacsMkUnavailable`]: crate::error::Error::AacsMkUnavailable
pub fn mk_from_dk(device_keys: &[DeviceKey], mkb: &[u8]) -> Result<MediaKey, crate::error::Error> {
// Positioned device keys drive the real Subset-Difference MKB walk
// ([`derive_media_key_and_pk_from_dk`], the same walk the resolver runs). The
// old Media-Key-Variant path needed integrator Key Correction Data absent
// in-tree, so it Err'd for EVERY real disc (dead for both consumers —
// freemkv-keysources' DK fallback and the kdb harvester). No VID is needed
// for the Media Key; it enters only at [`vuk_from_mk`].
match derive_media_key_and_pk_from_dk(mkb, device_keys) {
Some((km, _pk)) => Ok(MediaKey(km)),
None => 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),
}
}
/// A candidate key at any rung of the AACS ladder, handed to [`resolve_candidate`].
///
/// Each variant carries the module's existing newtype for that rung (a `Dk` is a
/// POSITIONED [`DeviceKey`] — recover an unpositioned one with
/// [`super::keys::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 (positional
/// order); the caller runs [`super::decrypt::unit_key_validates`] to find which
/// one actually opens the disc. Rungs above the candidate are `None` (a `Vuk`
/// candidate has no `mk`/`pk`/`dk`; a `Uk` candidate has only `unit_keys`).
#[derive(Debug, Clone)]
pub struct ResolvedChain {
/// Every unit key derived from the VUK, as `(cps_unit_number, key)` — the
/// CPS-unit numbers come from `Unit_Key_RO.inf` (via `parse_unit_key_ro`), so
/// a consumer maps `UK → CPS unit` directly. Same shape as
/// [`super::keys::ResolvedKeys::unit_keys`]. A `Uk` candidate yields exactly
/// itself, keyed by its own `idx`.
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 module's own boil steps
/// ([`mk_from_pk`], [`vuk_from_mk`], [`uk_from_vuk`]) and parses
/// `Unit_Key_RO.inf` at the version the disc's MKB declares (48-byte stride for
/// AACS-1.0, 64 for AACS-2.x), so a multi-CPS disc yields all its unit keys from
/// the one candidate.
///
/// PURE DERIVATION: no sampling, no validation, no position recovery. Every step
/// is deterministic AES, so the returned keys are only as sound as the input
/// candidate — validate `unit_keys` against a real encrypted unit with
/// [`super::decrypt::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> {
use super::keys::{AacsVersion, derive_media_key_and_pk_from_dk, mkb_type, parse_unit_key_ro};
// Boil a VUK → all unit keys, each paired with its declared CPS-unit number.
// `.inf` parsing lives here: derive the stride version from the disc's own
// MKB, then defer the VUK→unit-keys step to the shared `derive_unit_keys`
// (the one place both resolvers and this path decrypt the title keys).
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(super::keys::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_from_mk(*mk, vid?);
Some(ResolvedChain {
unit_keys: boil(vuk)?,
vuk: Some(vuk),
mk: Some(*mk),
pk: None,
dk: None,
})
}
KeyCandidate::Pk(pk) => {
let mk = mk_from_pk(std::slice::from_ref(&pk.0), mkb).ok()?;
let vuk = vuk_from_mk(mk, vid?);
Some(ResolvedChain {
unit_keys: boil(vuk)?,
vuk: Some(vuk),
mk: Some(mk),
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 mk = MediaKey(km);
let vuk = vuk_from_mk(mk, vid?);
Some(ResolvedChain {
unit_keys: boil(vuk)?,
vuk: Some(vuk),
mk: Some(mk),
pk: Some(ProcessingKey(pk)),
dk: Some(dk.clone()),
})
}
}
}
#[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)` when the MKB has no
/// processable Subset-Difference tables (empty MKB, or one with no
/// mk_dv/cvalues/subdiff records) — never a wrong key, never a panic.
#[test]
fn mk_from_dk_errors_on_unprocessable_mkb() {
let dk = DeviceKey {
key: [0x11; 16],
node: 1,
uv: 1,
u_mask_shift: 0,
};
// Empty MKB → no SD records to walk → Err.
let e = mk_from_dk(std::slice::from_ref(&dk), &[]);
assert!(matches!(e, Err(crate::error::Error::AacsMkUnavailable)));
// An MKB with no complete Subset-Difference tables (mk_dv / cvalues /
// subdiff) cannot yield a Media Key, so the real walk also errors —
// never silently yields a key.
let mut mkb: Vec<u8> = Vec::new();
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]); // stray data record only
mkb.extend_from_slice(&[0xAB; 16]);
let e2 = mk_from_dk(&[dk], &mkb);
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)
));
}
/// 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");
}
}
+270 -106
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,14 +77,14 @@ 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:
/// * `(buf[0] & 0xC0) == 0` → CPI clear → the unit is plaintext; pass through.
/// * non-zero → bytes `16..6144` are AES-CBC encrypted; decrypt.
///
/// This is exactly libaacs' test (`if (!(buf[0] & 0xc0)) return; /* clear */`)
/// This is exactly the spec CPI test (`buf[0] & 0xc0 == 0` means clear)
/// and is the spec-correct replacement for the [`ts_sync_destroyed`] byte
/// heuristic. CRITICAL: it is only meaningful when `unit` is read at the correct
/// clip-FILE-anchored boundary — byte 0 must be the real unit start. A
@@ -163,51 +113,68 @@ 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.
/// The one canonical "did a key OPEN this unit?" signal — a padding-aware,
/// defect-TOLERANT structural check 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).
/// ARCHITECTURE (why this is the only TS-sync test the read/decrypt path may
/// use): TS-sync presence is a *muxer* concern, not a decryption verdict. The
/// read/decrypt path is not allowed to reject or conceal a unit just because a
/// packet isn't conformant MPEG-TS — a non-conforming packet inside an otherwise
/// perfectly-decrypted unit is an authoring/pressing defect (or an AACS 2.1
/// forensic-variant frame), which the demuxer drops on sync-loss and resyncs
/// past. The ONLY thing the decrypt path legitimately needs from TS structure is
/// KEY SELECTION: "did this candidate key turn ciphertext back into MPEG-TS at
/// all?" — used to pick among held keys (multi-CPS-unit discs) and to detect a
/// genuinely-missing key. That is a coarse, all-or-nothing question, and this is
/// its answer.
///
/// 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.
/// Verdict: the key opened the unit iff a SUPERMAJORITY (>= 75%) of the
/// non-padding content packets carry their `0x47` TS sync. Rationale:
/// * WRONG key → AES output is uniform random → each content packet carries
/// `0x47` at offset 4 with probability 1/256 → ~0 synced. Reaching 75% by
/// chance is cryptographically impossible (e.g. 17-of-22 ≈ 256^-17). So a
/// wrong key can NEVER pass — no silent-corruption hole.
/// * RIGHT key → every real content packet decrypts to clear TS. A handful of
/// authored-bad packets (pressing defects / variant frames) legitimately
/// lack `0x47`, but they are a small minority and MUST NOT reject the unit —
/// the decrypted bytes (defects and all) pass through verbatim; the muxer
/// handles the bad packets. This is the whole point: correctness of a
/// *packet* is not a decryption verdict.
///
/// 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;
}
/// Padding-aware: a 192-byte packet whose 188-byte payload is all-zero is source
/// padding (or a NULL packet) and is excluded from the count, so a legitimate
/// content-fragment TAIL (a few real packets + source-zero padding) is judged on
/// its real packets only. A full content unit reduces to "nearly all 32 synced".
pub fn unit_content_decrypted(unit: &[u8]) -> bool {
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
let limit = ALIGNED_UNIT_LEN.min(unit.len());
let mut content = 0usize;
let mut synced = 0usize;
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.
// All-zero payload → padding / NULL packet → excluded from the verdict.
let payload = &unit[off + 4..off + PKT];
if !payload.iter().all(|&b| b == 0) && unit[off + 4] != TS_SYNC {
return true;
if !payload.iter().all(|&b| b == 0) {
content += 1;
if unit[off + 4] == TS_SYNC {
synced += 1;
}
}
off += PKT;
}
false
// No content packets (all padding) → trivially "opened". Otherwise require a
// >=75% supermajority of content packets restored — the wrong-key-proof gate.
content == 0 || synced * 4 >= content * 3
}
/// "Is this aligned unit STILL genuine ciphertext (no held key opened it)?" — the
/// conceal-path twin of [`unit_content_decrypted`], run on the POST-decrypt
/// bytes. True iff the unit is flagged encrypted (CPI set) AND no key opened it
/// (below the supermajority-sync gate). A unit the right key opened — even one
/// carrying a few defective packets — is NOT ciphertext and is never concealed;
/// its bytes belong to the muxer.
pub fn aacs_unit_still_ciphertext(unit: &[u8]) -> bool {
aacs_unit_encrypted(unit) && !unit_content_decrypted(unit)
}
/// Overwrite an aligned unit (6144 bytes) IN PLACE with valid NULL MPEG-TS
@@ -288,7 +255,7 @@ fn ts_syncs_intact(unit: &[u8]) -> bool {
}
/// STRICT, standards-correct "is this a clean MPEG-TS aligned unit?" check —
/// byte-for-byte libaacs' `_verify_ts` (`aacs.c`): EVERY one of the 32 BD source
/// the standards-correct all-32-sync verify: EVERY one of the 32 BD source
/// packets (192-byte stride) must carry its TS sync `0x47` at offset 4; the first
/// miss fails. This is the authoritative gate for the POST-READ verify stage,
/// independent of (and not coupled to) `decrypt_unit`.
@@ -385,7 +352,10 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
}
// Save original first 16 bytes (the plaintext seed / header) and derive the
// per-unit decrypt key (identical to `decrypt_unit_checked`).
// 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);
@@ -393,22 +363,28 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
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).
// Restore source-zero padding packets to zero (their decrypted bytes are
// garbage from AES-decrypting zeros, but the source WAS zero, so writing the
// true source back is faithful — not concealment — and gives the demux a tidy
// gap instead of AES noise). Content packets are left EXACTLY as decrypted:
// the read/decrypt path never rewrites content, so an authored-bad packet
// (no `0x47`) passes through verbatim for the muxer to drop.
for (p, &is_pad) in pad.iter().enumerate().take(npkt) {
let off = p * PKT;
if is_pad {
let off = p * PKT;
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
// KEY-SELECTION verdict only: did this key OPEN the unit (restore the TS
// structure of a supermajority of content packets)? A wrong key can't; the
// right key can even when a few content packets are authored-bad. This is NOT
// a per-packet conformance gate — that belongs to the muxer.
unit_content_decrypted(unit)
}
/// Decrypt an AACS aligned unit in place, accepting the key only when `accept`
@@ -583,7 +559,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() {
@@ -926,6 +904,192 @@ mod tests {
);
}
// ── Defect-tolerant "did a key OPEN this unit?" verdict ─────────────────
//
// The Bourne-UHD bug: a commercial disc carries the odd authored-bad TS
// packet (a pressing/encoding defect, or an AACS 2.1 forensic-variant frame)
// — one non-conforming packet inside an otherwise perfectly-decrypted 6144
// unit. The OLD strict per-packet acceptance rejected the WHOLE unit over
// that single packet, so the mux concealed ~all of it as NULL and 21/22 good
// video packets were destroyed and tallied as loss ("false corruption").
// The read/decrypt path must instead ACCEPT the unit (the key opened it) and
// pass the defective packet through VERBATIM for the muxer to drop. TS-sync
// conformance is a muxer concern, never a decryption verdict.
/// Build a unit that decrypts to 32 content packets, with `defect_pkts`
/// marked authored-bad (a non-`0x47` at the sync position + non-zero payload
/// so they count as genuine content, not padding), encrypted under `key`.
fn unit_with_defects(key: &[u8; 16], defect_pkts: &[usize]) -> Vec<u8> {
let mut unit = clear_unit();
for &p in defect_pkts {
let off = p * BD_SOURCE_PACKET_BYTES;
unit[off + 4] = 0x80; // NOT a TS sync
unit[off + 5] = 0xAB; // non-zero payload => real content, not padding
}
aacs_encrypt_unit(&mut unit, key);
unit
}
/// A POST-DECRYPT-looking unit: `content` non-padding packets, `synced` of
/// them carrying `0x47`; the remaining packets are source-zero padding. CPI
/// (encrypted flag) set iff `cpi`. Feeds the key-independent predicates.
fn decrypted_shape(content: usize, synced: usize, cpi: bool) -> Vec<u8> {
let mut u = vec![0u8; ALIGNED_UNIT_LEN];
for p in 0..content {
let off = p * BD_SOURCE_PACKET_BYTES;
u[off + 5] = 0xAB; // non-zero payload => counted as content
u[off + 4] = if p < synced { TS_SYNC } else { 0x80 };
}
if cpi {
u[0] |= 0xC0;
}
u
}
#[test]
fn single_authored_bad_packet_still_decrypts_and_passes_verbatim() {
// 1 defective content packet in an otherwise-perfect unit (the real case).
let key = [0x5Au8; 16];
let mut unit = unit_with_defects(&key, &[17]);
assert!(
decrypt_unit(&mut unit, &key),
"31/32 content packets synced -> the key OPENED the unit"
);
let off = 17 * BD_SOURCE_PACKET_BYTES;
assert_eq!(
unit[off + 4],
0x80,
"defect packet's bytes pass through VERBATIM (no null-fill, no zeroing)"
);
assert_eq!(unit[off + 5], 0xAB, "defect payload untouched");
assert!(
!aacs_unit_still_ciphertext(&unit),
"an opened unit is NOT ciphertext -> the mux never conceals it"
);
for p in 0..32 {
if p == 17 {
continue;
}
assert_eq!(
unit[p * BD_SOURCE_PACKET_BYTES + 4],
TS_SYNC,
"every other packet restored its sync (pkt {p})"
);
}
}
#[test]
fn several_defect_packets_within_tolerance_still_decrypt() {
// Up to 25% authored-bad content packets are tolerated (opened + passed
// through); the muxer drops them.
let key = [0x33u8; 16];
let mut unit = unit_with_defects(&key, &[3, 9, 17, 24, 30]); // 5/32 ≈ 16%
assert!(
decrypt_unit(&mut unit, &key),
"27/32 synced (>=75%) -> opened"
);
}
#[test]
fn wrong_key_never_opens_a_unit() {
// A wrong key restores ~0 syncs -> far below the supermajority gate.
let key = [0x5Au8; 16];
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &key);
assert!(
!decrypt_unit(&mut unit, &[0x22u8; 16]),
"wrong key cannot open the unit"
);
}
#[test]
fn threshold_accepts_at_75pct_and_rejects_just_below() {
// 24/32 = exactly 75% opens; 23/32 does not.
assert!(unit_content_decrypted(&decrypted_shape(32, 24, false)));
assert!(!unit_content_decrypted(&decrypted_shape(32, 23, false)));
}
#[test]
fn still_ciphertext_tracks_the_open_verdict() {
// Fully clean -> opened, not ciphertext.
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 32, true)));
// One defect -> still opened, still not ciphertext.
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 31, true)));
// Wrong-key noise floor -> not opened -> ciphertext (concealable).
assert!(aacs_unit_still_ciphertext(&decrypted_shape(32, 3, true)));
// CPI-clear bytes are never "ciphertext" regardless of sync count.
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 0, false)));
}
#[test]
fn all_padding_unit_is_trivially_opened() {
// CPI set but every packet is source-zero padding: nothing to decrypt.
assert!(unit_content_decrypted(&decrypted_shape(0, 0, true)));
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(0, 0, true)));
}
#[test]
fn defect_count_boundary_via_real_decrypt() {
// Pin the 75% gate on the REAL decrypt path (not just the predicate):
// 8/32 defects => 24 synced = exactly 75% => opens; 9/32 => 23 synced =>
// does NOT open. Packets 1.. avoid the clear-seed packet 0.
let key = [0x77u8; 16];
let eight: Vec<usize> = (1..9).collect();
let mut u_ok = unit_with_defects(&key, &eight);
assert!(
decrypt_unit(&mut u_ok, &key),
"8 defects (24/32 = 75%) -> opened"
);
let nine: Vec<usize> = (1..10).collect();
let mut u_no = unit_with_defects(&key, &nine);
assert!(
!decrypt_unit(&mut u_no, &key),
"9 defects (23/32 < 75%) -> NOT opened (too corrupt / wrong key)"
);
}
#[test]
fn small_content_units_keep_the_wrong_key_floor() {
// Tiny content units are where a coincidental wrong-key sync matters most.
// The gate stays strict enough that a single fluke can't "open" them.
assert!(unit_content_decrypted(&decrypted_shape(2, 2, false))); // 2/2 -> open
assert!(!unit_content_decrypted(&decrypted_shape(2, 1, false))); // 1/2 -> not
assert!(unit_content_decrypted(&decrypted_shape(4, 3, false))); // 3/4=75% -> open
assert!(!unit_content_decrypted(&decrypted_shape(4, 2, false))); // 2/4 -> not
}
#[test]
fn fragment_tail_padding_plus_one_defect_still_decrypts() {
// Both tolerances at once: source-zero padding tail EXCLUDED, and the one
// authored-bad packet in the real prefix TOLERATED. 20 real packets
// (pkt 5 defective) => 19/20 synced => opened; padding emitted as zeros.
let key = [0x5Au8; 16];
let mut unit = clear_unit();
let off = 5 * BD_SOURCE_PACKET_BYTES;
unit[off + 4] = 0x80; // defect inside the real content
unit[off + 5] = 0xAB;
for b in unit[20 * BD_SOURCE_PACKET_BYTES..].iter_mut() {
*b = 0; // source-zero padding tail (packets 20..32)
}
aacs_encrypt_unit(&mut unit, &key);
assert!(
decrypt_unit(&mut unit, &key),
"19/20 real packets synced + zero pad -> opened"
);
assert_eq!(
unit[off + 4],
0x80,
"the defect packet passes through verbatim"
);
for p in 20..32 {
let o = p * BD_SOURCE_PACKET_BYTES;
assert!(
unit[o..o + BD_SOURCE_PACKET_BYTES].iter().all(|&b| b == 0),
"padding pkt {p} emitted as clean zeros"
);
}
}
#[test]
fn wrong_key_full_unit_is_not_decryptable() {
let mut unit = clear_unit();
@@ -1255,7 +1419,7 @@ mod tests {
#[test]
fn unit_is_clean_ts_is_strict_all_32_syncs() {
// Standards-correct gate (libaacs `_verify_ts`): EVERY one of the 32
// Standards-correct gate (all-32 TS syncs): EVERY one of the 32
// packet syncs is required. A fully-synced clear unit passes.
let clear = clear_unit();
assert!(unit_is_clean_ts(&clear), "all-32-sync unit is clean");
+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
}
+687
View File
@@ -0,0 +1,687 @@
//! 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) — the
/// direct PK × cvalue iteration. 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::new(2, [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"
);
}
}
+2 -2
View File
@@ -10,8 +10,8 @@
pub fn collect_host_certs(
opts: &crate::disc::ScanOptions,
mkb: Option<u32>,
) -> Vec<crate::aacs::HostCert> {
let mut host_certs: Vec<crate::aacs::HostCert> = Vec::new();
) -> 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());
}
+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 (AACS Unit_Key_RO format): 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 (per the AACS content-cert format):
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
// [1] bit7 bus_encryption_enabled_flag (`p[1] >> 7`)
// [14..20] cc_id (6 bytes) (`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,
})
}
+435
View File
@@ -0,0 +1,435 @@
//! 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): [RE] — not in the public spec (from real 2.x MKBs).
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` — the standard AACS
/// MKB layout (`0x05` cvalues 1:1 with the `0x04` subset-difference index).
///
/// 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) MKB type value.
pub const MKB_20_CATEGORY_C: u32 = 0x4814_1003;
/// `0x48151003` — AACS 2.1 Category C (UHD content) MKB type value.
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"
);
}
}
+49 -59
View File
@@ -13,15 +13,32 @@
//!
//! 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).
//! - `[RE]` — reverse-engineered from real discs, cited only where the public
//! spec is silent (the `0x86` verify record and the Category-C MKB type values).
pub mod boil;
pub mod decrypt;
pub mod content;
pub mod crypto;
pub mod derive;
pub mod host_certs;
pub mod keys;
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;
pub mod variant_select;
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
@@ -35,53 +52,29 @@ 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";
// Boil-down derivation primitives (thin newtypes + wrappers over the crypto).
pub use boil::{
KeyCandidate, MediaKey, ProcessingKey, ResolvedChain, UnitKey, Vid, Vuk, mk_from_dk,
mk_from_pk, resolve_candidate, uk_from_vuk, vuk_from_mk,
};
// Structured, English-free resolution trace.
pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, UnlockStep};
// 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, aacs_unit_still_ciphertext, decrypt_bus, decrypt_unit,
decrypt_unit_checked, decrypt_unit_full, decrypt_unit_try_keys, fill_null_ts_unit,
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::{
AACS_MAJOR_BD, AACS_MAJOR_UHD, 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() {
@@ -103,21 +96,18 @@ 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::new(0, [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
+303
View File
@@ -0,0 +1,303 @@
//! 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 variant | u16 flag (= 1)
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
//! ```
//! `variant` is the 1..32 forensic-variant tag, NOT a sequential segment id:
//! measured on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across
//! records in file order — 24 full cycles of 32 plus a final partial cycle of
//! 24 = 792 records. Source-packet numbers are the 192-byte BDAV packet index:
//! byte offset = `spn * 192`. Each segment is ~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 {
/// Forensic variant tag, 1..=32 (field@4 of the record). Cycles across the
/// table rather than counting up — it selects WHICH variant this range is,
/// which is what a variant-keyed decode routes on. (`0` is not used here;
/// the default/non-forensic content carries no segment record at all.)
pub variant: 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 = variant (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
let variant = 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 {
variant,
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 `(variant, 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 retail 2.1 (Zombieland): the variant
// field counts 1,2,3,… (it wraps at 32 further into the table — see
// `variant_field_cycles_one_to_thirty_two`), segments are 2560 packets.
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].variant, 1);
assert_eq!(segs[1].variant, 2);
assert_eq!(segs[2].variant, 3);
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.variant, 1);
}
#[test]
fn variant_field_cycles_one_to_thirty_two() {
// Reality on Zombieland: field@4 is the variant, cycling 1..=32 in file
// order (NOT a sequential segment id). Reproduce one-and-a-bit cycles.
let mut recs = Vec::new();
let mut spn = 1000u32;
for row in 0..2 {
for v in 1..=32u16 {
recs.push((v, spn, spn + 2559));
spn += 50_000; // ~one segment every ~67 MB
}
let _ = row;
}
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
assert_eq!(segs.len(), 64);
assert_eq!(segs[31].variant, 32); // end of first cycle
assert_eq!(segs[32].variant, 1); // wraps, does not become 33
assert!(segs.iter().all(|s| (1..=32).contains(&s.variant)));
}
#[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.variant, 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());
}
}
+68
View File
@@ -27,6 +27,74 @@ 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],
/// AACS 2.1 (FMTS) forensic-variant tag.
///
/// `0` = ordinary (non-forensic) content — the value for every 1.0 / 2.0
/// key and for the bulk of a 2.1 title. `1..=32` = a variant key that
/// decrypts the forensic segments tagged with that same variant in
/// `IndividualSegment.tbl`. A disc resolves to exactly one variant, so at
/// most one non-zero value is ever in play for a given rip; the decode
/// selects the segments matching it and drops the other variants.
pub variant_number: u8,
}
impl UnitKey {
/// An ordinary (non-forensic) unit key: `variant_number == 0`. The value
/// for every AACS 1.0 / 2.0 key and the bulk of a 2.1 title.
pub const fn new(idx: u32, key: [u8; 16]) -> Self {
Self {
idx,
key,
variant_number: 0,
}
}
/// A forensic-variant key: `variant_number` in `1..=32`, decrypting the
/// `IndividualSegment.tbl` segments tagged with that variant.
pub const fn variant(idx: u32, key: [u8; 16], variant_number: u8) -> Self {
Self {
idx,
key,
variant_number,
}
}
/// Whether this key decrypts ordinary (non-forensic) content.
pub const fn is_default_variant(&self) -> bool {
self.variant_number == 0
}
}
/// A per-disc entry from the key database.
#[derive(Debug, Clone)]
pub struct DiscEntry {
+395 -295
View File
@@ -3,34 +3,20 @@
//! On AACS 2.1 the Media Key derivation gains a second stage on top of
//! the classical subset-difference walk. The classical walk yields a
//! Media Key Precursor (Kmp) rather than the final Media Key; the
//! Precursor combines with disc-supplied Variant Key Data (VKD) and an
//! integrator-supplied Key Correction Data (KCD) constant to produce
//! the Media Key.
//! Precursor combines with disc-supplied Variant Key Data (VKD) and a
//! per-licensee Key Correction Data (KCD) constant to produce the Media
//! Key.
//!
//! This module is wiring only — `resolve_keys` is not aware of it. The
//! entry point is [`derive_media_key_variant`]. The Variant scheme is
//! detected via the real AACS 2.1 MKB records `0x2d` (Encrypted Media
//! Key Variant Data / C), `0x2f` (Variant Key Data table, 65,535×16),
//! and `0x0c` (variant cvalues, one per `0x04` subset-difference slot).
//! When a disc carries none, callers fall back to the classical
//! single-stage derivation in [`super::keys`]. (The earlier `0x82`/`0x83`
//! record types were a speculative guess that never appeared in any real
//! MKB; they were replaced with the real records once a live variant MKB
//! was obtained.)
//! The entry point is [`derive_media_key_variant`] — a `Kp -> Km`
//! derivation. Deriving `Kp` itself from device keys (DK -> PK) is the
//! separate [`walk_processing_key`] step. The Variant scheme is detected
//! via the AACS 2.1 MKB records `0x2d` (Encrypted Media Key Variant Data
//! / C), `0x2f` (Variant Key Data table, up to 65,535×16), and `0x0c`
//! (variant cvalues, one per `0x04` subset-difference slot). When a disc
//! carries none, callers fall back to the classical single-stage
//! derivation in [`super::derive`].
//!
//! **Status: the chain cannot yet produce a key on a real disc — for two
//! reasons, one external, one internal:**
//! - EXTERNAL: no device key in our pool walks any real variant MKB,
//! so `Kp` (and thus the whole chain) can't be produced live. This is
//! a key-acquisition gap, not a code gap.
//! - INTERNAL: the exact `VARIANTS[uv]` lookup ([`variants_for_uv`])
//! and the Nonce / C sub-field offsets can't be pinned without a real
//! disc + covering key to run the chain end-to-end against the `0x86`
//! verify. Until then [`variants_for_uv`] returns `None` and the
//! chain halts at [`MediaKeyVariantError::VariantsTableUnavailable`],
//! so a wrong best-effort offset is never silently trusted.
//!
//! The chain follows the published spec:
//! The chain:
//!
//! ```text
//! Kmp = AES-128D(Kp, C) XOR uv
@@ -41,96 +27,55 @@
//! Km = AES-128D(Kpnew, VKD) XOR uv
//! ```
//!
//! Two condition bits on `Kmp[15]` route off the hardcoded-KCD path
//! (Soft Correction and Online Challenge). The chain refuses to run in
//! either case — callers must handle those modes out of band.
//! **Status.** The record layout is pinned against real variant MKBs:
//! `C` is the per-slot block of the `0x0c` cvalue table (indexed by the
//! matched subset-difference — NOT the `0x2d` head), `VARIANTS[uv]` is the
//! `0x2d` VARIANTS table (leading `body-16` bytes, Nonce at the `0x2d`
//! tail), and `VKD` is `0x2f`. Two inputs still block an end-to-end run
//! against the `0x86` Verify-Media-Key record: the real per-licensee KCD
//! (see [`KEY_CORRECTION_DATA`] — not coded, per-manufacturer), and a
//! covering 2.1 Processing Key. Either one missing yields a wrong `Km`
//! that the final verify gate rejects, so a bad key is never emitted —
//! only an error. (A covering key would also confirm the last layout
//! picks: the 16-bit `Kvn` width and Nonce head-vs-tail.)
//!
//! # Status: Kp verification
//! Two condition bits on `Kmp[15]` route off the default KCD path (Soft
//! Correction and Online Challenge); the chain does not model those modes
//! and treats such a slot as non-covering.
//!
//! On the classical path [`walk_processing_key`] gates each match on
//! the VERIFY_MAGIC relation, which authenticates the Processing Key.
//! On a variant MKB that magic check does NOT hold (the walk yields a
//! Media Key *Precursor*, not the Media Key), so the walk accepts a
//! variant match without it. The replacement gate lives at the END of
//! [`derive_media_key_variant`]: the derived final `Km` is verified
//! against the MKB's Verify-Media-Key record before any `(Km, Kvu)` is
//! returned. A future implementer wiring [`variants_for_uv`] must keep
//! that final gate — the per-match magic check no longer protects the
//! variant path.
//! **Verify gate.** On the classical path [`walk_processing_key`] gates
//! each match on the VERIFY_MAGIC relation, which authenticates the
//! Processing Key. On a variant MKB that magic does NOT hold (the walk
//! yields a Precursor, not the Media Key), so the authoritative gate is
//! at the END of the chain: the derived `Km` is verified against the
//! MKB's Verify-Media-Key record before it is ever returned.
use super::decrypt::aes_ecb_decrypt;
use super::crypto::{aes_ecb_decrypt, aes_g};
use super::mkb::*;
use super::types::DeviceKey;
// The MKB record types this chain selects — `REC_MEDIA_KEY_VARIANT_DATA`
// (`0x0c`, the per-slot C table), `REC_VARIANT_DATA_AND_NONCE` (`0x2d`, VARIANTS
// + tail Nonce), `REC_VKD_TABLE` (`0x2f`), the subset-difference / cvalue records
// (`0x04` / `0x05` / `0x07`), and the verify records (`0x81` / `0x86`) — are the
// canonical set in [`super::mkb`], in scope here via the `use super::mkb::*` glob.
// ── Public constants ──────────────────────────────────────────────────────
/// Placeholder Key Correction Data. Sixteen zero bytes.
/// AACS 2.1 Key Correction Data — a zero placeholder, NOT real key material.
///
/// Integrators MUST supply a non-placeholder KCD via the `kcd` argument
/// to [`derive_media_key_variant`]; the chain refuses to operate when
/// the supplied KCD compares equal to this placeholder.
pub const KEY_CORRECTION_DATA_PLACEHOLDER: [u8; 16] = [0u8; 16];
/// **KCD is PER-LICENSEE** (per player manufacturer) — there is no single
/// universal value. libfreemkv compiles in no AACS key material (keydb.cfg is
/// the single source of truth), so this stays all-zero: the chain's SHAPE still
/// runs, but on a real variant disc the derivation yields a wrong Media Key that
/// the final Verify-Media-Key gate rejects. The variant chain therefore cannot
/// complete on a real disc today — a key-acquisition gap, not a code gap. If a
/// real per-licensee KCD is ever available it must come from keydb.cfg, never a
/// compiled constant.
const KEY_CORRECTION_DATA: [u8; 16] = [0u8; 16];
// ── MKB record walking ────────────────────────────────────────────────────
/// 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::keys` 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))
})
}
/// True iff `records` contains at least one Media Key Variant record.
///
/// The real AACS 2.1 Variant markers — confirmed against a live variant MKB —
@@ -140,40 +85,41 @@ pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)
/// The earlier `0x82`/`0x83` guess was speculative and never appeared in any
/// real MKB.
pub fn is_variant_mkb(records: &[MkbRecord]) -> bool {
records.iter().any(|r| matches!(r.rec_type, 0x2d | 0x2f))
records
.iter()
.any(|r| matches!(r.rec_type, REC_VARIANT_DATA_AND_NONCE | REC_VKD_TABLE))
}
/// Body of the Encrypted Media Key Variant Data record (type `0x2d`).
///
/// Confirmed against a live variant MKB as the `0x2d` record (92,220 bytes on
/// the reference disc — not a clean multiple of 16, so it is a structured /
/// count-prefixed record, not a flat C-block table). The exact per-uv C
/// selection is the one sub-field still unconfirmed without a real disc+key to
/// test against; the chain currently consumes the leading 16-byte block.
/// `pub(crate)` until that offset is pinned.
/// Body of the `0x2d` record: the `VARIANTS` table followed by the trailing
/// 16-byte `Kvn` Nonce. Measured `46_100*2 + 16 = 92_216` on Zombieland v70 and
/// `92_220` on Stand By Me v70 — in both, the leading `body.len() - 16` bytes are
/// the big-endian `u16` `VARIANTS` table (one per subset-difference) and the last
/// 16 bytes are the Nonce, with NO leading header. This does NOT hold the C used
/// for `Kmp` — that is the per-slot block in `0x0c`
/// ([`REC_MEDIA_KEY_VARIANT_DATA`]). Both [`variant_nonce`] and
/// [`variants_for_uv`] read this body.
pub(crate) fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
records
.iter()
.find(|r| r.rec_type == 0x2d)
.find(|r| r.rec_type == REC_VARIANT_DATA_AND_NONCE)
.map(|r| r.body.as_slice())
}
/// 16-byte Nonce for `Kvn = AES-G(Kp, Nonce)`.
/// 16-byte Nonce for `Kvn = AES-G(Kp, Nonce)` — the trailing 16 bytes of the
/// `0x2d` record ([`variant_data_record`]).
///
/// **UNCONFIRMED source.** The speculative `0x83` "Variant Number" record does
/// not exist on a real variant MKB. The `0x2d` Encrypted-Media-Key-Variant-Data
/// record is the most likely home for a per-disc nonce, so this best-effort
/// reads the trailing 16 bytes of `0x2d`. Confirming this (vs. a field inside
/// `0x21`, or a fixed slice of `0x2d`) needs a real disc+key to test the whole
/// chain against the `0x86` verify — until then the chain halts earlier at
/// [`variants_for_uv`], so a wrong nonce here is never silently trusted.
/// The Nonce-at-tail placement is consistent across both reference MKBs (the
/// leading `body-16` bytes form the `VARIANTS` table exactly), but head-vs-tail
/// is only truly pinned by running the full chain against the `0x86` verify with
/// a covering key. Until then a wrong nonce can only fail that final gate, never
/// emit a bad key.
pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
let r = records.iter().find(|r| r.rec_type == 0x2d)?;
if r.body.len() < 16 {
let body = variant_data_record(records)?;
if body.len() < 16 {
return None;
}
let mut out = [0u8; 16];
out.copy_from_slice(&r.body[r.body.len() - 16..]);
out.copy_from_slice(&body[body.len() - 16..]);
Some(out)
}
@@ -185,34 +131,17 @@ pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
records
.iter()
.find(|r| r.rec_type == 0x2f && !r.body.is_empty() && r.body.len() % 16 == 0)
.find(|r| r.rec_type == REC_VKD_TABLE && !r.body.is_empty() && r.body.len() % 16 == 0)
.map(|r| r.body.as_slice())
}
// ── AES-G ────────────────────────────────────────────────────────────────
/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2.
///
/// 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::keys::derive_vuk`] for the
/// classical VUK form — the math is identical, this exposes it as a
/// neutral primitive for the variant chain.
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
}
// ── Subset-difference walk that exposes (Kp, uv) ──────────────────────────
// `calc_v_mask` and `calc_pk_from_dk` (and the AES-G3 seed step they ride
// on) are shared with the classical walk in [`super::keys`] — a single
// on) are shared with the classical walk in [`super::derive`] — a single
// definition keeps the variant SD tree byte-identical to the classical one.
// (`aesg3` itself is imported separately in the test module.)
use super::keys::{calc_pk_from_dk, calc_v_mask};
use super::derive::{calc_pk_from_dk, calc_v_mask};
/// Outcome of a subset-difference walk against an MKB. Carries the
/// processing key and the matching `uv` slot — both needed as inputs
@@ -229,17 +158,11 @@ pub struct ProcessingKeyMatch {
pub cvalue_index: usize,
}
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())
}
fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
let r = records
.iter()
.find(|r| (r.rec_type == 0x81 || r.rec_type == 0x86) && r.body.len() >= 16)?;
let r = records.iter().find(|r| {
(r.rec_type == REC_VERIFY_MEDIA_KEY_V1 || r.rec_type == REC_VERIFY_MEDIA_KEY_V2)
&& r.body.len() >= 16
})?;
let mut out = [0u8; 16];
out.copy_from_slice(&r.body[..16]);
Some(out)
@@ -249,7 +172,7 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
/// `device_keys` covers. Returns `None` if no DK walks any uv.
///
/// This is the AACS-2.1 **variant** walk; the classical walk lives in
/// [`super::keys::derive_media_key_and_pk_from_dk`]. The two are kept
/// [`super::derive::derive_media_key_and_pk_from_dk`]. The two are kept
/// separate on purpose and select MKB records in DELIBERATELY different
/// order:
///
@@ -258,10 +181,10 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
/// small `0x07` Explicit-Subset-Difference record carries the
/// cvalue the Precursor chain consumes, whereas a classical UHD MKB
/// keeps its 1:1 cvalue table in the large `0x05` record (see the
/// note on [`super::keys::probe::mkb_cvalues`]). They must NOT be
/// note on [`super::derive::probe::mkb_cvalues`]). They must NOT be
/// unified to one order — each is correct for its own MKB shape.
/// - finders: this walk operates on parsed [`MkbRecord`]s (needed
/// because the variant chain also reads `0x82`/`0x83`); the
/// because the variant chain also reads `0x2d`/`0x2f`); the
/// classical walk operates on raw MKB bytes. Same framing, different
/// input type.
///
@@ -273,14 +196,14 @@ pub fn walk_processing_key(
device_keys: &[DeviceKey],
) -> Option<ProcessingKeyMatch> {
let mk_dv = mkb_find_mk_dv(records)?;
let uvs = mkb_find_body(records, 0x04)?;
let uvs = mkb_find_body(records, REC_SUBSET_DIFFERENCE)?;
// Variant cvalue source: a real variant MKB carries its per-uv cvalue table
// in record `0x0c` (confirmed 46,101×16, one per `0x04` subset-difference
// slot). Fall back to `0x07`/`0x05` for the synthetic fixtures and any MKB
// shape that keeps its cvalues there.
let cvalues = mkb_find_body(records, 0x0c)
.or_else(|| mkb_find_body(records, 0x07))
.or_else(|| mkb_find_body(records, 0x05))?;
let cvalues = mkb_find_body(records, REC_MEDIA_KEY_VARIANT_DATA)
.or_else(|| mkb_find_body(records, REC_EXPLICIT_SUBSET_DIFF))
.or_else(|| mkb_find_body(records, REC_MEDIA_KEY_DATA))?;
let num_uvs = uvs
.chunks(5)
@@ -298,14 +221,11 @@ pub fn walk_processing_key(
// parse stops, no inner re-check needed.
let u_mask_shift = uvs[5 * uvs_idx];
if u_mask_shift & 0xC0 != 0 {
break;
}
// 0x20..=0x3F (32..=63) pass the 0xC0 revoked-marker check but are
// out of range for a u32 shift. `wrapping_shl` would silently
// compute shift % 32 (e.g. 32 → no shift → 0xFFFF_FFFF), matching a
// wrong uv slot and deriving a wrong key. Disc-controlled byte:
// skip the slot instead.
// 0x20..=0x3F (32..=63) have their revoked-marker bits clear (so they
// pass the take_while above) but are out of range for a u32 shift.
// `wrapping_shl` would silently compute shift % 32 (e.g. 32 → no shift
// → 0xFFFF_FFFF), matching a wrong uv slot and deriving a wrong key.
// Disc-controlled byte: skip the slot instead.
if u_mask_shift >= 32 {
continue;
}
@@ -393,10 +313,8 @@ pub enum MediaKeyVariantError {
/// `Kmp[15]` carries bit `0x04`: the online-challenge path applies
/// for this Precursor. Out of scope for the hardcoded-KCD chain.
OnlineChallengeRequired,
/// Supplied KCD equals [`KEY_CORRECTION_DATA_PLACEHOLDER`]. The
/// derivation refuses to run with the all-zero placeholder.
KcdNotProvided,
/// `VARIANTS[uv]` lookup for the matched uv is not implemented.
/// `VARIANTS[uv]` could not be read from the `0x2d` record for the
/// matched slot.
VariantsTableUnavailable,
/// VKD index resolved out of the supplied `vkd_table`.
VkdIndexOutOfRange,
@@ -414,7 +332,6 @@ impl std::fmt::Display for MediaKeyVariantError {
MediaKeyVariantError::ProcessingKeyUnavailable => 7102,
MediaKeyVariantError::SoftCorrectionRequired => 7103,
MediaKeyVariantError::OnlineChallengeRequired => 7104,
MediaKeyVariantError::KcdNotProvided => 7105,
MediaKeyVariantError::VariantsTableUnavailable => 7106,
MediaKeyVariantError::VkdIndexOutOfRange => 7107,
MediaKeyVariantError::MediaKeyVerifyFailed => 7108,
@@ -427,99 +344,136 @@ impl std::error::Error for MediaKeyVariantError {}
// ── Chain ─────────────────────────────────────────────────────────────────
/// Look up the per-slot `VARIANTS` value for the matched subset-difference
/// slot. AACS 2.1 keys the VARIANTS table by the matched SD slot (the same
/// index that selected the cvalue), so the caller passes
/// [`ProcessingKeyMatch::cvalue_index`]. The byte layout of the per-slot entry
/// in the Variant Number record is undocumented and disc-specific; this helper
/// returns `None` until a Variant disc is available to fix the layout against.
/// Look up the per-slot `VARIANTS` value for the matched subset-difference slot,
/// keyed by the same index that selected the cvalue ([`ProcessingKeyMatch::cvalue_index`]).
///
/// `sd_slot_index` is the matched subset-difference slot (== cvalue index).
fn variants_for_uv(_records: &[MkbRecord], _sd_slot_index: usize) -> Option<u16> {
None
/// LAYOUT (fixed against a real 2.1 variant MKB — Zombieland v70, `MKB_RO.inf`):
/// the `0x2d` Encrypted-Media-Key-Variant-Data body is exactly
/// `46_100*2 + 16 = 92_216` bytes, i.e. one **big-endian u16 `VARIANTS` entry per
/// subset-difference slot** (1:1 with the `0x0c` variant cvalues and the `0x04`
/// subset-differences), with the 16-byte per-disc Nonce packed at the **tail**
/// (see [`variant_nonce`]). So the VARIANTS table is the leading `sd_count*2`
/// bytes and this reads its `sd_slot_index`-th entry.
///
/// The record/field *sizing* is confirmed; the one bit still to pin against a
/// covering key is Nonce-head-vs-tail (both fit the size) — a wrong pick can only
/// yield a wrong `Km`, which the final Verify-Media-Key gate rejects (never a
/// silent bad key).
fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option<u16> {
let body = variant_data_record(records)?;
// The VARIANTS table is the leading bytes; the 16-byte Kvn Nonce is packed at
// the TAIL (see [`variant_nonce`]). Bound the read to the table region so a
// near-end slot can never read Nonce bytes as a VARIANTS entry. NO leading
// header (measured: Zombieland v70 `0x2d` body = 46_100*2 + 16 = 92_216).
const NONCE: usize = 16;
let table_len = body.len().checked_sub(NONCE)?;
let off = sd_slot_index.checked_mul(2)?;
if off + 2 > table_len {
return None;
}
Some(u16::from_be_bytes([body[off], body[off + 1]]))
}
/// Run the Media Key Variant chain on an MKB.
///
/// Inputs:
///
/// - `mkb_records` : MKB pre-walked via [`walk_mkb`].
/// - `device_keys` : pool of device keys; the chain runs against the
/// first uv slot any DK covers.
/// - `kcd` : integrator-supplied Key Correction Data. Must not
/// equal [`KEY_CORRECTION_DATA_PLACEHOLDER`].
/// - `vid` : 16-byte Volume ID for the disc. Used to derive
/// the final VUK alongside the Media Key.
///
/// Returns `(Km, Kvu)` on success.
///
/// NOTE: the `VARIANTS[uv]` lookup ([`variants_for_uv`]) is not yet
/// implemented, so on a real Variant disc this always returns
/// `Err(`[`MediaKeyVariantError::VariantsTableUnavailable`]`)` before a
/// key is produced. The chain can only succeed against synthetic test
/// fixtures today.
pub fn derive_media_key_variant(
mkb_records: &[MkbRecord],
device_keys: &[DeviceKey],
kcd: &[u8; 16],
vid: &[u8; 16],
) -> Result<([u8; 16], [u8; 16]), MediaKeyVariantError> {
if !is_variant_mkb(mkb_records) {
return Err(MediaKeyVariantError::NotVariantMkb);
/// Enumerate the `(uv, slot_index)` pairs of a variant MKB's subset-difference
/// record (`0x04`), in table order — the same parse [`walk_processing_key`] uses
/// to index cvalues. Factored out so a bare Processing Key (which arrives without
/// its slot) can be tried against each slot.
fn variant_uv_slots(records: &[MkbRecord]) -> Option<Vec<(u32, usize)>> {
let uvs = mkb_find_body(records, REC_SUBSET_DIFFERENCE)?;
let mut out = Vec::new();
let mut idx = 0usize;
while (idx + 1) * 5 <= uvs.len() {
let u_mask_shift = uvs[5 * idx];
// The `0xC0` revoked-marker terminates the table (matches the walk's
// `take_while`). Shifts ≥ 32 are out of range and skipped, never wrapped.
if u_mask_shift & 0xC0 != 0 {
break;
}
let p_uv = &uvs[1 + 5 * idx..];
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
if uv != 0 && u_mask_shift < 32 {
out.push((uv, idx));
}
idx += 1;
}
Some(out)
}
let pkm = walk_processing_key(mkb_records, device_keys)
.ok_or(MediaKeyVariantError::ProcessingKeyUnavailable)?;
/// The MKB-derived inputs the variant chain needs for every slot it tries against
/// a given Processing Key. Fetched once by [`derive_media_key_variant`] so the
/// per-slot body stays a lean `(Kp, uv, slot)` call.
struct VariantMkb<'a> {
records: &'a [MkbRecord],
nonce: [u8; 16],
vkd_table: &'a [u8],
/// The per-subset-difference Encrypted-Media-Key-Variant-Data (C) table from
/// record `0x0c` — one 16-byte C per slot. C for slot `i` is
/// `cvalues[i*16..][..16]`, the SAME source/index [`walk_processing_key`]
/// uses. (NOT `0x2d`, which is VARIANTS + Nonce.)
cvalues: &'a [u8],
mk_dv: [u8; 16],
}
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let c_value = variant_data_record(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
if c_value.len() < 16 {
return Err(MediaKeyVariantError::MkbIncomplete);
}
/// The variant chain body for ONE known `(Kp, uv, slot)`: derive and verify the
/// Media Key against the MKB's Verify-Media-Key record. VID-free — the Km is
/// MKB-scoped; the VUK is a separate [`super::derive::derive_vuk`] step. Returns
/// the verified Km, or a classification of why this slot did not yield one.
fn variant_km_for_slot(
m: &VariantMkb<'_>,
kp: &[u8; 16],
uv: u32,
slot_index: usize,
) -> Result<[u8; 16], MediaKeyVariantError> {
// C for THIS subset-difference: the slot's 16-byte block in the `0x0c`
// Encrypted-Media-Key-Variant-Data table (same index that selected the
// cvalue in `walk_processing_key`). `0x2d` is VARIANTS + Nonce, not C.
let cv_off = slot_index
.checked_mul(16)
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
let c_slice = m
.cvalues
.get(cv_off..cv_off + 16)
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
let mut c_block = [0u8; 16];
c_block.copy_from_slice(&c_value[..16]);
c_block.copy_from_slice(c_slice);
// Step: Kmp = AES-128D(Kp, C) XOR uv (uv into low 4 bytes).
let mut kmp = aes_ecb_decrypt(&pkm.kp, &c_block);
let uv_bytes = pkm.uv.to_be_bytes();
let mut kmp = aes_ecb_decrypt(kp, &c_block);
let uv_bytes = uv.to_be_bytes();
for i in 0..4 {
kmp[12 + i] ^= uv_bytes[i];
}
// Condition bits on Kmp[15] route off the hardcoded-KCD path.
// Condition bits on Kmp[15] select the correction mode. Bit 0x02 (SoftKCD)
// and 0x04 (online challenge) need out-of-band data we don't model; the
// default path (neither bit set) uses the fixed KCD constant.
if kmp[15] & 0b0000_0010 != 0 {
return Err(MediaKeyVariantError::SoftCorrectionRequired);
}
if kmp[15] & 0b0000_0100 != 0 {
return Err(MediaKeyVariantError::OnlineChallengeRequired);
}
if kcd == &KEY_CORRECTION_DATA_PLACEHOLDER {
return Err(MediaKeyVariantError::KcdNotProvided);
}
// Step: Kpnew = Kmp XOR KCD.
let mut kpnew = [0u8; 16];
for i in 0..16 {
kpnew[i] = kmp[i] ^ kcd[i];
kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i];
}
// Step: Kvn = AES-G(Kp, Nonce) & 0xFFFF (low 16 bits, BE).
let kvn_block = aes_g(&pkm.kp, &nonce);
let kvn_block = aes_g(kp, &m.nonce);
let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]);
// Step: VKD_idx = Kvn XOR VARIANTS[uv].
let v_for_uv = variants_for_uv(mkb_records, pkm.cvalue_index)
// Step: VKD_idx = Kvn XOR VARIANTS[uv]; VKD = vkd_table[VKD_idx].
let v_for_uv = variants_for_uv(m.records, slot_index)
.ok_or(MediaKeyVariantError::VariantsTableUnavailable)?;
let vkd_idx = kvn ^ v_for_uv;
// Step: VKD = vkd_table[VKD_idx * 16 .. +16].
let off = (vkd_idx as usize) * 16;
if off + 16 > vkd_table.len() {
if off + 16 > m.vkd_table.len() {
return Err(MediaKeyVariantError::VkdIndexOutOfRange);
}
let mut vkd = [0u8; 16];
vkd.copy_from_slice(&vkd_table[off..off + 16]);
vkd.copy_from_slice(&m.vkd_table[off..off + 16]);
// Step: Km = AES-128D(Kpnew, VKD) XOR uv.
let mut km = aes_ecb_decrypt(&kpnew, &vkd);
@@ -527,30 +481,159 @@ pub fn derive_media_key_variant(
km[12 + i] ^= uv_bytes[i];
}
// Gate: verify the derived Media Key against the MKB's Verify-Media-Key
// record. On the variant path the per-match magic check in
// `walk_processing_key` does NOT hold (it only saw the Precursor), so this
// is the authoritative Kp/Km verification — it MUST run before returning a
// real key.
// Gate: the derived Media Key MUST reproduce the MKB's Verify-Media-Key magic
// (the per-match magic in `walk_processing_key` only saw the Precursor). This
// is the authoritative check — no unverified key is ever returned.
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
if aes_ecb_decrypt(&km, &m.mk_dv)[..8] != VERIFY_MAGIC {
return Err(MediaKeyVariantError::MediaKeyVerifyFailed);
}
Ok(km)
}
/// Derive the AACS 2.1 variant **Media Key** from a Processing Key.
///
/// The one deterministic `Kp → Km` derivation for a variant MKB. A leaked 2.1
/// Processing Key arrives without its subset-difference slot, so this tries `pk`
/// against every slot and returns the Km for the slot whose full chain passes the
/// MKB's Verify-Media-Key record — exactly the shape of the classical bare-PK
/// [`super::derive::derive_media_key_from_pk`], gated by the chain's own verify so
/// an unverified key is never returned.
///
/// VID-free by design: the Media Key is MKB-scoped. Derive the per-disc VUK from
/// the returned Km with [`super::derive::derive_vuk`]. Deriving a Processing Key
/// from device keys (DK → PK) is a separate concern — walk it first via
/// [`walk_processing_key`], then call this.
///
/// Errors: `NotVariantMkb` (caller should use the classical path), `MkbIncomplete`
/// (a required record is missing), or `ProcessingKeyUnavailable` (no slot verified
/// — `pk` does not cover this MKB, or its slot needs the soft-correction / online
/// path, surfaced as `SoftCorrectionRequired` / `OnlineChallengeRequired`).
pub fn derive_media_key_variant(
mkb_records: &[MkbRecord],
pk: &[u8; 16],
) -> Result<[u8; 16], MediaKeyVariantError> {
if !is_variant_mkb(mkb_records) {
return Err(MediaKeyVariantError::NotVariantMkb);
}
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
// C for the Kmp step is the per-subset-difference `0x0c` table (one 16-byte
// C per slot) — the SAME source and index `walk_processing_key` uses. `0x2d`
// holds VARIANTS + Nonce, NOT C. Fall back to `0x07`/`0x05` for the synthetic
// fixtures that keep a single cvalue there.
let cvalues = mkb_find_body(mkb_records, REC_MEDIA_KEY_VARIANT_DATA)
.or_else(|| mkb_find_body(mkb_records, REC_EXPLICIT_SUBSET_DIFF))
.or_else(|| mkb_find_body(mkb_records, REC_MEDIA_KEY_DATA))
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let slots = variant_uv_slots(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let m = VariantMkb {
records: mkb_records,
nonce,
vkd_table,
cvalues,
mk_dv,
};
// Try `pk` against each slot; return the first verified Km. If none verify,
// surface a correction-mode error over the generic miss so a disc that needs
// the soft/online path is distinguishable from a non-covering key.
let mut correction: Option<MediaKeyVariantError> = None;
for (uv, slot_index) in slots {
match variant_km_for_slot(&m, pk, uv, slot_index) {
Ok(km) => return Ok(km),
Err(e @ MediaKeyVariantError::SoftCorrectionRequired)
| Err(e @ MediaKeyVariantError::OnlineChallengeRequired) => {
correction.get_or_insert(e);
}
Err(_) => {}
}
}
Err(correction.unwrap_or(MediaKeyVariantError::ProcessingKeyUnavailable))
}
/// Run the variant chain from a caller-supplied Processing Key and EXPLICIT
/// per-slot inputs — the harness entry that tries a captured `Kp` against known
/// slot material, bypassing both the device-key walk and the on-MKB
/// `VARIANTS[uv]` lookup. The caller supplies the `0x0c` C block, the slot's
/// subset-difference number `uv`, and its `VARIANTS[uv]`; the MKB supplies the
/// Nonce, the VKD table, and the Verify-Media-Key value.
///
/// Returns `(Km, Kvu)`. The terminal Verify-Media-Key gate is identical to
/// [`derive_media_key_variant`], so a wrong `c_block` / `uv` / `variants_uv`
/// returns [`MediaKeyVariantError::MediaKeyVerifyFailed`] rather than a bogus
/// key. The soft-correction / online-challenge bits on `Kmp[15]` are classified
/// the same way, so a slot needing an out-of-band correction path is
/// distinguishable from a non-matching input.
///
/// (Note the KCD caveat on [`KEY_CORRECTION_DATA`]: without the real per-licensee
/// KCD this fails the verify gate on a real disc — a key-acquisition gap.)
pub fn media_key_variant_from_kp(
kp: &[u8; 16],
c_block: &[u8; 16],
uv: u32,
variants_uv: u16,
mkb_records: &[MkbRecord],
vid: &[u8; 16],
) -> Result<([u8; 16], [u8; 16]), MediaKeyVariantError> {
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
// Kmp = AES-128D(Kp, C) XOR uv.
let mut kmp = aes_ecb_decrypt(kp, c_block);
let uv_bytes = uv.to_be_bytes();
for i in 0..4 {
kmp[12 + i] ^= uv_bytes[i];
}
if kmp[15] & 0b0000_0010 != 0 {
return Err(MediaKeyVariantError::SoftCorrectionRequired);
}
if kmp[15] & 0b0000_0100 != 0 {
return Err(MediaKeyVariantError::OnlineChallengeRequired);
}
// Kpnew = Kmp XOR KCD.
let mut kpnew = [0u8; 16];
for i in 0..16 {
kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i];
}
// Kvn = AES-G(Kp, Nonce) & 0xFFFF; VKD_idx = Kvn XOR VARIANTS[uv].
let kvn_block = aes_g(kp, &nonce);
let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]);
let vkd_idx = kvn ^ variants_uv;
let off = (vkd_idx as usize) * 16;
if off + 16 > vkd_table.len() {
return Err(MediaKeyVariantError::VkdIndexOutOfRange);
}
let mut vkd = [0u8; 16];
vkd.copy_from_slice(&vkd_table[off..off + 16]);
// Km = AES-128D(Kpnew, VKD) XOR uv, then the authoritative Verify-Media-Key gate.
let mut km = aes_ecb_decrypt(&kpnew, &vkd);
for i in 0..4 {
km[12 + i] ^= uv_bytes[i];
}
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
if aes_ecb_decrypt(&km, &mk_dv)[..8] != VERIFY_MAGIC {
return Err(MediaKeyVariantError::MediaKeyVerifyFailed);
}
// Step: Kvu = AES-G(Km, VID).
// Kvu = AES-G(Km, VID).
let kvu = aes_g(&km, vid);
Ok((km, kvu))
}
#[cfg(test)]
mod tests {
use super::*;
// These three live in `super::keys` now (consolidated SD-walk helpers);
// These three live in `super::derive` now (consolidated SD-walk helpers);
// `use super::*` does not re-export the parent module's private `use`
// imports, so pull them in directly for the tests below.
use super::super::keys::{aesg3, calc_pk_from_dk};
use super::super::crypto::aesg3;
use super::super::derive::calc_pk_from_dk;
#[test]
fn calc_pk_from_dk_terminates_on_nonconvergent_mask() {
@@ -631,49 +714,36 @@ mod tests {
#[test]
fn chain_rejects_non_variant_mkb() {
let recs = walk_mkb(&synthetic_mkb_classical());
let err = derive_media_key_variant(&recs, &[], &[0xAA; 16], &[0u8; 16])
let err = derive_media_key_variant(&recs, &[0xAA; 16])
.expect_err("classical MKB must be rejected");
assert_eq!(err, MediaKeyVariantError::NotVariantMkb);
}
#[test]
fn chain_rejects_placeholder_kcd() {
// To reach the KCD check we need a complete variant MKB AND a
// DK that walks it. We construct both via the synthetic
// fixture below.
let (recs, dk, _kp, _expected_kmp) = synthetic_variant_setup(/*kmp15*/ 0x00);
let err =
derive_media_key_variant(&recs, &[dk], &KEY_CORRECTION_DATA_PLACEHOLDER, &[0u8; 16])
.expect_err("placeholder KCD must be rejected");
assert_eq!(err, MediaKeyVariantError::KcdNotProvided);
}
#[test]
fn chain_detects_soft_correction_bit() {
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x02);
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
// Kmp[15] bit 0x02 on the covering PK's slot surfaces the soft-correction
// classification over the generic no-slot miss.
let (recs, _dk, kp, _) = synthetic_variant_setup(/*kmp15*/ 0x02);
let err = derive_media_key_variant(&recs, &kp)
.expect_err("bit 0x02 must surface SoftCorrectionRequired");
assert_eq!(err, MediaKeyVariantError::SoftCorrectionRequired);
}
#[test]
fn chain_detects_online_challenge_bit() {
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x04);
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
let (recs, _dk, kp, _) = synthetic_variant_setup(/*kmp15*/ 0x04);
let err = derive_media_key_variant(&recs, &kp)
.expect_err("bit 0x04 must surface OnlineChallengeRequired");
assert_eq!(err, MediaKeyVariantError::OnlineChallengeRequired);
}
#[test]
fn chain_surfaces_variants_table_gap_on_clean_kmp() {
// With both condition bits clear and a non-placeholder KCD, the
// chain advances to the per-uv VARIANTS[uv] lookup, which is
// not yet wired. That returns VariantsTableUnavailable —
// proving the bit checks and KCD check all passed.
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x00);
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
.expect_err("expected VariantsTableUnavailable at the per-uv lookup");
assert_eq!(err, MediaKeyVariantError::VariantsTableUnavailable);
fn variants_for_uv_reads_the_table_from_0x2d() {
// variants_for_uv reads the VARIANTS u16 from the 0x2d record, so on a
// variant MKB that carries 0x2d it yields Some (never dead-stops the chain
// at VariantsTableUnavailable).
let (recs, _dk, _kp, _) = synthetic_variant_setup(/*kmp15*/ 0x00);
assert!(variants_for_uv(&recs, 0).is_some());
}
#[test]
@@ -686,7 +756,6 @@ mod tests {
MediaKeyVariantError::ProcessingKeyUnavailable,
MediaKeyVariantError::SoftCorrectionRequired,
MediaKeyVariantError::OnlineChallengeRequired,
MediaKeyVariantError::KcdNotProvided,
MediaKeyVariantError::VariantsTableUnavailable,
MediaKeyVariantError::VkdIndexOutOfRange,
MediaKeyVariantError::MediaKeyVerifyFailed,
@@ -723,13 +792,13 @@ mod tests {
/// times — Kp = aesg3(dk, 1).
/// - one cvalue in record 0x07 chosen so AES-D(Kp, C) ⊕ uv produces a
/// Kmp whose byte-15 is exactly `kmp15`.
/// - record 0x82 with a 16-byte body (acts as both Variant Data
/// and Variant Key Data; satisfies the parser heuristics).
/// - record 0x83 with a 16-byte Nonce.
/// - record 0x2d (Encrypted Media Key Variant Data): a 32-byte body
/// carrying C in the head 16 bytes and a 16-byte Nonce in the tail.
/// - record 0x2f (Variant Key Data): one 16-byte entry.
///
/// Returns (records, dk, planted_kp, planted_kmp).
fn synthetic_variant_setup(kmp15: u8) -> (Vec<MkbRecord>, DeviceKey, [u8; 16], [u8; 16]) {
use crate::aacs::decrypt::aes_ecb_encrypt;
use crate::aacs::crypto::aes_ecb_encrypt;
// Build header.
let mut mkb = vec![
@@ -758,21 +827,24 @@ mod tests {
aes_d_result[15] ^= 0x02;
let c_block = aes_ecb_encrypt(&kp, &aes_d_result);
// cvalues record (0x07): one 16-byte cvalue. The walker
// indexes it for the magic-check step; on a variant MKB the
// magic check fails but `variant_present` is true so the
// walker still returns the match. Content is don't-care.
// cvalues record (0x07): the per-SD C the chain reads for `Kmp`. This
// fixture has no `0x0c`, so both the walk and the chain fall back to
// `0x07` — plant the computed `c_block` HERE so `AES-D(Kp, C) XOR uv ==
// Kmp` and the chosen `kmp15` bit lands. On a variant MKB the per-match
// magic check fails, but `variant_present` is true, so the walk still
// returns the match.
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xAB; 16]);
mkb.extend_from_slice(&c_block);
// Verify Media Key (0x86): body content is don't-care.
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xCD; 16]);
// 0x2d record: Encrypted Media Key Variant Data — C (head 16) then a
// trailing 16-byte Nonce (variant_nonce reads the tail), 32-byte body.
// 0x2d record: VARIANTS table (head, don't-care for these tests) then the
// trailing 16-byte Nonce (`variant_nonce` reads the tail), 32-byte body.
// (C is NOT here — it is the `0x07`/`0x0c` cvalue above.)
mkb.extend_from_slice(&[0x2d, 0x00, 0x00, 0x24]);
mkb.extend_from_slice(&c_block);
mkb.extend_from_slice(&[0x11; 16]);
mkb.extend_from_slice(&[0x77; 16]);
// 0x2f record: Variant Key Data table — one 16-byte entry.
@@ -817,7 +889,7 @@ mod tests {
fn mkb_records_matches_walk_mkb_framing() {
// The lazy `mkb_records` iterator and the owning `walk_mkb` must agree on
// (offset, type, len) for every record — they share the one framing
// walker, and every keys.rs MKB walk now relies on this equivalence.
// walker, and every aacs::resolve/derive MKB walk now relies on this equivalence.
let mut mkb = vec![0x10, 0x00, 0x00, 0x06, 0xAA, 0xBB];
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 1, 2, 3, 4]);
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0xFF]); // terminator + trailing
@@ -936,13 +1008,16 @@ mod tests {
// ── derive_media_key_variant: missing-record classification ────────────
#[test]
fn chain_reports_processing_key_unavailable_with_no_dks() {
// A complete variant MKB but an empty device-key pool → no uv covered
// → ProcessingKeyUnavailable (the walk_processing_key None branch).
fn chain_yields_no_key_for_non_covering_pk() {
// A complete variant MKB but a Processing Key that covers no slot → no
// Km verifies → an error (never a key). A non-covering key resolves to
// ProcessingKeyUnavailable, or to a correction-mode classification if its
// Kmp happens to set the soft/online bit — either way, no key is emitted.
let (recs, _dk, _, _) = synthetic_variant_setup(0x00);
let err = derive_media_key_variant(&recs, &[], &[0xAA; 16], &[0u8; 16])
.expect_err("no DK → ProcessingKeyUnavailable");
assert_eq!(err, MediaKeyVariantError::ProcessingKeyUnavailable);
let out = derive_media_key_variant(&recs, &[0x11; 16]);
assert!(out.is_err(), "non-covering PK must not yield a Media Key");
assert_ne!(out, Err(MediaKeyVariantError::NotVariantMkb));
assert_ne!(out, Err(MediaKeyVariantError::MkbIncomplete));
}
#[test]
@@ -950,7 +1025,7 @@ mod tests {
// Build a variant MKB (still variant via 0x2f, and a DK can walk it)
// but WITHOUT the 0x2d record that carries C + the trailing Nonce →
// MkbIncomplete at the variant_nonce `?`.
let (recs, dk, _, _) = synthetic_variant_setup(0x00);
let (recs, _dk, kp, _) = synthetic_variant_setup(0x00);
// Reconstruct bytes without the 0x2d record.
let mut mkb = Vec::new();
for r in &recs {
@@ -965,8 +1040,7 @@ mod tests {
}
let recs2 = walk_mkb(&mkb);
assert!(is_variant_mkb(&recs2), "still variant via 0x2f");
let err = derive_media_key_variant(&recs2, &[dk], &[0xAA; 16], &[0u8; 16])
.expect_err("missing nonce → MkbIncomplete");
let err = derive_media_key_variant(&recs2, &kp).expect_err("missing nonce → MkbIncomplete");
assert_eq!(err, MediaKeyVariantError::MkbIncomplete);
}
@@ -1054,7 +1128,7 @@ mod tests {
fn error_codes_are_unique_and_in_7100_range() {
// Each MediaKeyVariantError maps to a distinct E71xx code. A
// copy-paste collision (two variants sharing a code) would break
// operator triage; assert all nine are distinct.
// operator triage; assert all are distinct.
use std::collections::HashSet;
let cases = [
MediaKeyVariantError::NotVariantMkb,
@@ -1062,7 +1136,6 @@ mod tests {
MediaKeyVariantError::ProcessingKeyUnavailable,
MediaKeyVariantError::SoftCorrectionRequired,
MediaKeyVariantError::OnlineChallengeRequired,
MediaKeyVariantError::KcdNotProvided,
MediaKeyVariantError::VariantsTableUnavailable,
MediaKeyVariantError::VkdIndexOutOfRange,
MediaKeyVariantError::MediaKeyVerifyFailed,
@@ -1070,4 +1143,31 @@ mod tests {
let codes: HashSet<String> = cases.iter().map(|e| e.to_string()).collect();
assert_eq!(codes.len(), cases.len(), "all error codes must be unique");
}
/// `media_key_variant_from_kp` runs the full chain from explicit inputs and
/// classifies the `Kmp[15]` soft-correction bit. A `c_block` chosen so
/// `AES-D(Kp, C) == Kmp` with bit `0x02` set (uv=0) must surface
/// `SoftCorrectionRequired` before it touches the VKD / verify steps —
/// proving the explicit-input entry runs the same chain and gates.
#[test]
fn media_key_variant_from_kp_classifies_soft_correction() {
use crate::aacs::crypto::aes_ecb_encrypt;
let kp = [0x11u8; 16];
// Plant Kmp[15]=0x02 (soft-correction) with uv=0 so Kmp == AES-D(kp, C).
let mut target_kmp = [0x00u8; 16];
target_kmp[15] = 0x02;
let c_block = aes_ecb_encrypt(&kp, &target_kmp);
// Minimal variant MKB: 0x2d (16-byte body = tail Nonce), 0x2f (one VKD
// entry), 0x86 (Verify-Media-Key).
let mut mkb = vec![0x2d, 0x00, 0x00, 0x14];
mkb.extend_from_slice(&[0x99; 16]);
mkb.extend_from_slice(&[0x2f, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xBB; 16]);
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xCD; 16]);
let recs = walk_mkb(&mkb);
let err = media_key_variant_from_kp(&kp, &c_block, 0, 0, &recs, &[0u8; 16])
.expect_err("soft-correction bit → classified, not a key");
assert_eq!(err, MediaKeyVariantError::SoftCorrectionRequired);
}
}
+178
View File
@@ -0,0 +1,178 @@
//! FMTS variant selection — the pure decode-time decision for a 2.1 disc.
//!
//! A 2.1 disc resolves to exactly one forensic variant (1..=32) for a given
//! rip. `IndividualSegment.tbl` tags each forensic segment with a variant (see
//! [`super::segment`]); the decode keeps the segments matching our variant,
//! drops the other 31, and treats everything outside a segment as ordinary
//! (variant-0) content. This module owns that classification and nothing else —
//! no I/O, no keys, no cipher — so it is fully testable in isolation. The
//! decrypt pipeline consumes the [`UnitDisposition`] it returns.
//!
//! Where the resolved variant comes from is a separate concern
//! ([`resolve_disc_variant`]): today it is read off the variant keys the key
//! source handed us; when Processing Keys are available it will come from the
//! VK derivation instead. Either way the disposition logic below is identical.
use super::segment::{Segment, variant_segment_for_unit};
use super::types::UnitKey;
/// What the decode should do with one AACS aligned unit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnitDisposition {
/// Outside every forensic segment: ordinary content, decrypt with the
/// default (variant-0) unit key.
Default,
/// Inside a forensic segment tagged with OUR resolved variant: decrypt with
/// that variant's key.
Variant(u8),
/// Inside a forensic segment tagged with a DIFFERENT variant: not our
/// watermark, so it is not part of our output — drop it.
DropForeignVariant(u8),
/// Inside a forensic segment but no variant key is held (the disc's variant
/// was never resolved): the segment cannot be decoded, so it is concealed
/// as loss. Carries the segment's variant for diagnostics.
ForensicNoKey(u8),
}
/// Resolve the disc's single forensic variant from the keys we hold.
///
/// Scans for a variant key (`variant_number` in `1..=32`) and returns its
/// variant. `None` when only default (variant-0) keys are held — i.e. no
/// variant source answered, so forensic segments are not decodable. A disc has
/// exactly one variant, so the first non-zero key decides; if several distinct
/// variant keys were somehow supplied the lowest wins (deterministic), which is
/// only a defensive tiebreak — the probe/derivation yields one.
pub fn resolve_disc_variant(unit_keys: &[UnitKey]) -> Option<u8> {
unit_keys
.iter()
.map(|k| k.variant_number)
.filter(|&v| v != 0)
.min()
}
/// Classify the AACS aligned unit at `unit_offset` (clip-relative bytes) given
/// the forensic segment map and the disc's resolved variant (`None` if no
/// variant key is held).
pub fn unit_disposition(
unit_offset: u64,
segments: &[Segment],
disc_variant: Option<u8>,
) -> UnitDisposition {
match variant_segment_for_unit(segments, unit_offset) {
// Not in any forensic segment → ordinary content.
None => UnitDisposition::Default,
// In a forensic segment → decide by whether it is our variant.
Some(seg) => {
let seg_variant = seg.variant as u8;
match disc_variant {
Some(v) if v == seg_variant => UnitDisposition::Variant(v),
Some(_) => UnitDisposition::DropForeignVariant(seg_variant),
None => UnitDisposition::ForensicNoKey(seg_variant),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aacs::content::ALIGNED_UNIT_LEN;
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
/// Build a one-record segment table (variant, start_spn, end_spn).
fn tbl(recs: &[(u16, u32, u32)]) -> Vec<Segment> {
let mut v = Vec::new();
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
v.extend_from_slice(&(recs.len() as u16).to_be_bytes());
v.extend_from_slice(&16u16.to_be_bytes());
for &(n, s, e) in recs {
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
v.extend_from_slice(&n.to_be_bytes());
v.extend_from_slice(&1u16.to_be_bytes());
v.extend_from_slice(&s.to_be_bytes());
v.extend_from_slice(&e.to_be_bytes());
}
parse_individual_segments(&v).expect("parse")
}
fn uk(idx: u32, variant: u8) -> UnitKey {
if variant == 0 {
UnitKey::new(idx, [0u8; 16])
} else {
UnitKey::variant(idx, [variant; 16], variant)
}
}
#[test]
fn resolve_picks_the_single_variant_key() {
// Default keys only → no variant resolved.
assert_eq!(resolve_disc_variant(&[uk(0, 0)]), None);
assert_eq!(resolve_disc_variant(&[]), None);
// One variant key among defaults → that variant.
assert_eq!(resolve_disc_variant(&[uk(0, 0), uk(1, 7)]), Some(7));
// Defensive: lowest of several distinct variants (deterministic).
assert_eq!(resolve_disc_variant(&[uk(0, 9), uk(1, 3)]), Some(3));
}
#[test]
fn unit_outside_segments_is_default() {
let segs = tbl(&[(1, 343680, 346239)]);
let off = 1000u64 * SOURCE_PACKET_LEN; // well before the segment
assert_eq!(
unit_disposition(off, &segs, Some(1)),
UnitDisposition::Default
);
// With no segments at all (1.0 / 2.0), everything is Default.
assert_eq!(
unit_disposition(off, &[], Some(1)),
UnitDisposition::Default
);
}
#[test]
fn unit_in_our_variant_decrypts() {
let segs = tbl(&[(7, 100, 200)]);
let off = 120u64 * SOURCE_PACKET_LEN;
assert_eq!(
unit_disposition(off, &segs, Some(7)),
UnitDisposition::Variant(7)
);
}
#[test]
fn unit_in_foreign_variant_drops() {
// Segment tagged variant 7, but our disc variant is 3 → drop it.
let segs = tbl(&[(7, 100, 200)]);
let off = 120u64 * SOURCE_PACKET_LEN;
assert_eq!(
unit_disposition(off, &segs, Some(3)),
UnitDisposition::DropForeignVariant(7)
);
}
#[test]
fn forensic_unit_with_no_key_is_concealed() {
// A forensic segment but we never resolved a variant → conceal as loss.
let segs = tbl(&[(7, 100, 200)]);
let off = 120u64 * SOURCE_PACKET_LEN;
assert_eq!(
unit_disposition(off, &segs, None),
UnitDisposition::ForensicNoKey(7)
);
}
#[test]
fn straddling_unit_still_classified_as_its_segment() {
// A unit whose 32-packet span only tails into the segment still routes
// to the segment (matches variant_segment_for_unit's span test).
let segs = tbl(&[(5, 100, 200)]);
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
// Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100.
let off = 80u64 * SOURCE_PACKET_LEN;
assert!(80 + unit_packets - 1 >= 100, "sanity: unit tails into seg");
assert_eq!(
unit_disposition(off, &segs, Some(5)),
UnitDisposition::Variant(5)
);
}
}
+3 -3
View File
@@ -236,7 +236,7 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
}
/// Parse the ProgramInfo section: per-stream (pid, coding_type,
/// language, codec sub-fields). Layout per BD spec / libbluray
/// language, codec sub-fields). Layout per the BD CLPI spec
/// clpi_parse.c:
///
/// ```text
@@ -394,7 +394,7 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
return Ok((Vec::new(), Vec::new()));
}
// Stream PID entry — bit-packed per BD spec (libbluray clpi_parse.c):
// Stream PID entry — bit-packed per the BD CLPI spec:
// stream_PID: 16 bits → ep_map[2..4]
// reserved: 10 bits ┐
// EP_stream_type: 4 bits │ ep_map[4..14] = 80 bits
@@ -821,7 +821,7 @@ mod tests {
// ─────────────────────────────────────────────────────────────────────
// Added hardening tests. Grounded in the BD-ROM CLPI spec
// (https://github.com/lw/BluRay/wiki/CLPI) and libbluray clpi_parse.c.
// (https://github.com/lw/BluRay/wiki/CLPI).
// ─────────────────────────────────────────────────────────────────────
/// Build a ProgramInfo section. `streams` = Vec<(pid, sci_bytes)>.
+4
View File
@@ -66,6 +66,10 @@ pub mod coding_type {
pub const MPEG2_VIDEO: u8 = 0x02;
/// H.264 / AVC video (ISO/IEC 13818-1 Table 2-34).
pub const H264: u8 = 0x1B;
/// H.264 / MVC dependent view (Blu-ray 3D right-eye substream). Carried in
/// the SSIF interleaved stream under its own PID; the base view is [`H264`].
/// ISO/IEC 13818-1 stream_type 0x20 (MVC video sub-bitstream).
pub const H264_MVC: u8 = 0x20;
/// HEVC / H.265 video (ISO/IEC 13818-1 Table 2-34, 2015 amendment).
pub const HEVC: u8 = 0x24;
/// SMPTE VC-1 video (BD-ROM convention, ISO user-private range).
+120 -134
View File
@@ -1,58 +1,46 @@
//! CSS cipher implementation based on the Stevenson 1999 analysis.
//! CSS content cipher — an independent implementation of the publicly
//! documented Content Scramble System stream cipher.
//!
//! The CSS cipher uses two table-driven feedback circuits:
//! - LFSR1: 17-bit state (9-bit lo + 8-bit hi register, seeded from
//! key[0..2]), driven by TAB2/TAB3
//! - LFSR0: 24-bit feedback register (seeded from key[2..5] XOR seed[2..5],
//! masked to 0xFFFFFF), driven by a feedback polynomial through TAB4
//! The algorithm is the one recovered and published in Frank A. Stevenson's
//! 1999 cryptanalysis ("Cryptanalysis of Contents Scrambling System") and
//! described in the open CSS literature. It is implemented here from that public
//! description; its constants (see [`super::tables`]) are the cipher's own
//! defined values. Nothing in this file is copied or translated from any
//! particular CSS software.
//!
//! The keystream is the bytewise sum (with carry) of both LFSR outputs.
//! Content descrambling computes plain = TAB1[cipher] ^ keystream — a TAB1
//! substitution of each ciphertext byte followed by an XOR with the keystream
//! (NOT a plain XOR; the cipher is not its own inverse).
//! The cipher uses two table-driven linear-feedback circuits:
//! - **LFSR1** — a 17-bit register (a 9-bit and an 8-bit half seeded from
//! `key[0..2] XOR seed[0..2]`), stepped through `TAB2`/`TAB3`/`TAB5`.
//! - **LFSR0** — a 24-bit feedback register (seeded from `key[2..5] XOR
//! seed[2..5]`), stepped through a feedback polynomial and `TAB4`.
//!
//! Algorithm: Frank A. Stevenson's divide-and-conquer attack (1999).
//! Tables: CSS specification constants.
//! Each output byte is the sum-with-carry of the two register outputs. A body
//! byte is recovered as `plain = TAB1[cipher] ^ keystream` — a `TAB1`
//! substitution of the ciphertext byte followed by an XOR with the keystream
//! (so the cipher is deliberately not its own inverse).
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
/// Descramble a CSS-encrypted DVD sector in place.
///
/// Exact port of libdvdcss `dvdcss_unscramble` (css.c). The two content
/// LFSRs are seeded **directly** from `title_key XOR sector_seed` — there is
/// no `decrypt_key` mangling on this path (that is the disc/title-key
/// hierarchy, not the content cipher). Bytes 0x80..0x800 are recovered with
/// `*p = TAB1[*p] ^ (i_t5 & 0xff)`.
/// The two feedback registers are seeded **directly** from
/// `title_key XOR sector_seed` (bytes `0x54..0x59`) — there is no title-key
/// mangling on the content path (that belongs to the disc/title-key hierarchy,
/// not the sector cipher). Only the body, bytes `0x80..0x800`, is transformed:
/// `body[i] = TAB1[body[i]] ^ (keystream & 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) marks an encrypted sector. This
/// routine CLEARS that flag after unscrambling, so a descrambled sector reads as
/// `sector[0x14] & 0x30 == 0`; callers and 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
/// fully present. Callers chunk by 2048, so a trailing partial chunk is
/// left untouched. The `debug_assert!` flags this misuse in debug/test
/// builds; a DVD sector is always exactly 2048 bytes.
/// - `sector.len() < 2048`: the encrypted region (`0x80..0x800`) is not fully
/// present. Callers chunk by 2048, so a trailing partial chunk is left
/// untouched. The `debug_assert!` flags this misuse in debug/test builds; a
/// DVD sector is always exactly 2048 bytes.
/// - scramble flags are zero: the sector is not CSS-encrypted.
///
/// Design reference: libdvdcss `dvdcss_unscramble`. The combiner mirrors
/// `css.c` line-for-line:
/// ```text
/// i_t1 = (key[0] ^ sec[0x54]) | 0x100;
/// i_t2 = key[1] ^ sec[0x55];
/// i_t3 = (key[2]|key[3]<<8|key[4]<<16) ^ (sec[0x56]|sec[0x57]<<8|sec[0x58]<<16);
/// i_t4 = i_t3 & 7; i_t3 = i_t3*2 + 8 - i_t4;
/// // per byte over 0x80..0x800:
/// i_t4 = TAB2[i_t2] ^ TAB3[i_t1];
/// i_t2 = i_t1 >> 1; i_t1 = ((i_t1 & 1) << 8) ^ i_t4; i_t4 = TAB5[i_t4];
/// i_t6 = (((((((i_t3>>3)^i_t3)>>1)^i_t3)>>8)^i_t3)>>5) & 0xff;
/// i_t3 = (i_t3 << 8) | i_t6; i_t6 = TAB4[i_t6];
/// i_t5 += i_t6 + i_t4; *p = TAB1[*p] ^ (i_t5 & 0xff); i_t5 >>= 8;
/// ```
pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
debug_assert!(
sector.len() >= 2048,
@@ -62,102 +50,103 @@ pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
return;
}
// libdvdcss: `if( !(p_sec[0x14] & 0x30) ) return;`
// Not scrambled (flag bits 4-5 clear) → nothing to do.
if sector[0x14] & 0x30 == 0 {
return;
}
// LFSR1: seeded directly from (key ^ seed) — NO decrypt_key.
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
// LFSR1 halves, seeded from (key ^ seed) bytes 0-1. The 9-bit half carries a
// set bit 8 (`| 0x100`) as its running marker.
let mut r1a: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
let mut r1b: u32 = (title_key[1] ^ sector[0x55]) as u32;
// LFSR0 (i_t3): 24-bit feedback register seeded from the remaining three
// key/seed bytes, then transformed `i_t3 = i_t3*2 + 8 - (i_t3 & 7)`.
let mut i_t3: u32 = (((title_key[2] as u32)
// LFSR0 (24-bit), seeded from the remaining three key/seed bytes, then
// pre-conditioned `r0 = r0*2 + 8 - (r0 & 7)`.
let mut r0: u32 = (((title_key[2] as u32)
| ((title_key[3] as u32) << 8)
| ((title_key[4] as u32) << 16))
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
& 0xFF_FFFF;
let i_t4_seed = i_t3 & 7;
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
r0 = r0 * 2 + 8 - (r0 & 7);
let mut i_t5: u32 = 0;
// Keystream accumulator; the low byte is the current keystream byte and the
// high bits carry into the next iteration.
let mut acc: u32 = 0;
for byte in sector.iter_mut().take(2048).skip(128) {
// Advance LFSR1.
let mut i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
i_t2 = i_t1 >> 1;
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
i_t4 = TAB5[i_t4 as usize] as u32;
// Step LFSR1: its output byte `o1`.
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
r1b = r1a >> 1;
r1a = ((r1a & 1) << 8) ^ o1;
o1 = TAB5[o1 as usize] as u32;
// Advance LFSR0 (i_t3) and fold both outputs into i_t5.
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xFF;
i_t3 = (i_t3 << 8) | i_t6;
i_t6 = TAB4[i_t6 as usize] as u32;
i_t5 += i_t6 + i_t4;
// Step LFSR0: its output byte `o0`.
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
r0 = (r0 << 8) | o0;
o0 = TAB4[o0 as usize] as u32;
*byte = TAB1[*byte as usize] ^ (i_t5 & 0xFF) as u8;
i_t5 >>= 8;
// Combine (sum with carry) and recover the plaintext byte.
acc += o0 + o1;
*byte = TAB1[*byte as usize] ^ (acc & 0xFF) as u8;
acc >>= 8;
}
// libdvdcss leaves byte 0x14 untouched; freemkv clears the scramble bits
// so downstream code and tests can tell a sector was descrambled.
// Clear the scramble bits so downstream code and tests can tell a sector was
// descrambled; bits 6-7 of byte 0x14 are preserved.
sector[0x14] &= 0xCF;
}
/// Exact inverse of [`descramble_sector`]: turn a plaintext sector body into
/// CSS ciphertext under `title_key`.
///
/// Descramble computes `plain = TAB1[cipher] ^ (i_t5 & 0xff)`, so the
/// inverse is `cipher = TAB1_INV[plain ^ (i_t5 & 0xff)]` with the identical
/// LFSR keystream. The keystream derivation is byte-for-byte the same as
/// `descramble_sector` (libdvdcss `dvdcss_unscramble`); only the final
/// substitution differs. Bytes 0x80..0x800 are rewritten in place; the
/// scramble flag is set to 0x10 so a subsequent descramble runs.
/// Descramble computes `plain = TAB1[cipher] ^ (keystream & 0xff)`, so the
/// inverse is `cipher = TAB1_INV[plain ^ (keystream & 0xff)]` with the identical
/// keystream. The keystream derivation is the same as [`descramble_sector`];
/// only the final substitution differs. Bytes `0x80..0x800` are rewritten in
/// place; the scramble flag is set to `0x10` so a subsequent descramble runs.
///
/// Not on any production read path — it exists so the key-recovery tests
/// (and any caller that needs to produce a known CSS-encrypted sector) can
/// build genuine ciphertext rather than approximating it.
/// Not on any production read path — it exists so the key-recovery tests (and
/// any caller that needs a known CSS-encrypted sector) can build genuine
/// ciphertext rather than approximating it.
#[cfg(test)]
pub(crate) fn scramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
if sector.len() < 2048 {
return;
}
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
let mut i_t3: u32 = (((title_key[2] as u32)
let mut r1a: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
let mut r1b: u32 = (title_key[1] ^ sector[0x55]) as u32;
let mut r0: u32 = (((title_key[2] as u32)
| ((title_key[3] as u32) << 8)
| ((title_key[4] as u32) << 16))
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
& 0xFF_FFFF;
let i_t4_seed = i_t3 & 7;
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
r0 = r0 * 2 + 8 - (r0 & 7);
let mut i_t5: u32 = 0;
let mut acc: u32 = 0;
for byte in sector.iter_mut().take(2048).skip(128) {
let mut i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
i_t2 = i_t1 >> 1;
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
i_t4 = TAB5[i_t4 as usize] as u32;
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
r1b = r1a >> 1;
r1a = ((r1a & 1) << 8) ^ o1;
o1 = TAB5[o1 as usize] as u32;
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xFF;
i_t3 = (i_t3 << 8) | i_t6;
i_t6 = TAB4[i_t6 as usize] as u32;
i_t5 += i_t6 + i_t4;
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
r0 = (r0 << 8) | o0;
o0 = TAB4[o0 as usize] as u32;
acc += o0 + o1;
// Inverse of `*p = TAB1[*p] ^ ks`: apply ks then TAB1's inverse.
*byte = (*TAB1_INV)[(*byte ^ (i_t5 & 0xFF) as u8) as usize];
i_t5 >>= 8;
*byte = (*TAB1_INV)[(*byte ^ (acc & 0xFF) as u8) as usize];
acc >>= 8;
}
// Mark the sector scrambled so the descrambler will process it.
sector[0x14] = (sector[0x14] & 0xCF) | 0x10;
}
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a
/// bijection on 0..256, so `TAB1_INV[TAB1[x]] == x`.
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a bijection on
/// `0..256`, so `TAB1_INV[TAB1[x]] == x`.
#[cfg(test)]
static TAB1_INV: std::sync::LazyLock<[u8; 256]> = std::sync::LazyLock::new(|| {
let mut inv = [0u8; 256];
@@ -181,14 +170,15 @@ mod tests {
assert_eq!(sector, original);
}
/// Cross-check `descramble_sector` against the EXACT output of libdvdcss
/// `dvdcss_unscramble` (css.c) for a fixed sector, computed from the
/// reference C semantics with the reference tables. Pins the content
/// cipher to libdvdcss byte-for-byte.
/// Regression vector: the deterministic output of the CSS content cipher for
/// a fixed key/seed/body. The value is generated by this implementation and
/// is self-consistent with the scramble/descramble round-trip below — any
/// correct CSS descrambler yields the same bytes, since the cipher is
/// deterministic. Pins the implementation against accidental change.
///
/// key = 42 13 37 BE EF, seed (0x54..0x59) = DE AD BE EF 42, body = 0xAA.
#[test]
fn descramble_matches_libdvdcss_unscramble_vector() {
fn descramble_produces_the_reference_css_vector() {
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let mut sector = vec![0xAAu8; 2048];
sector[0x14] = 0x30;
@@ -200,12 +190,12 @@ mod tests {
0x81, 0x92, 0x24, 0xA2, 0x46, 0x70, 0x3C, 0x64, 0xA6, 0x91, 0x84, 0xF5, 0x1F, 0x98,
0xA0, 0x31
],
"descramble body head must match libdvdcss dvdcss_unscramble"
"descramble body head must match the reference CSS vector"
);
assert_eq!(
&sector[0x7F8..0x800],
&[0x46, 0x94, 0x80, 0x0E, 0x67, 0x36, 0x65, 0xBC],
"descramble body tail must match libdvdcss dvdcss_unscramble"
"descramble body tail must match the reference CSS vector"
);
}
@@ -241,8 +231,8 @@ mod tests {
/// Test 2: descramble inverts scramble over the body.
///
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to
/// the ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to the
/// ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
/// [`scramble_sector`]. Scrambling a plaintext body and then descrambling
/// with the same key must reproduce the original body exactly.
#[test]
@@ -279,9 +269,9 @@ mod tests {
/// css_tab1_relationship
///
/// Verify the structure of TAB1: it is a substitution table used in
/// key mangling. Check that no two inputs map to the same output
/// (TAB1 is a permutation of 0..255).
/// Verify the structure of TAB1: it is a substitution table used in key
/// mangling. Check that no two inputs map to the same output (TAB1 is a
/// permutation of 0..255).
#[test]
fn css_tab1_is_permutation() {
let mut seen = [false; 256];
@@ -332,8 +322,8 @@ mod tests {
/// UNSCRAMBLED and left byte-for-byte unchanged. This guards against a
/// too-wide mask silently "descrambling" (and thus corrupting) clear data.
///
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live
/// in bits 4-5; the masked value 0 means not scrambled.
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live in
/// bits 4-5; the masked value 0 means not scrambled.
/// Mutation: widen the mask `0x30` to `0x70`/`0xF0` -> 0x40/0x80 would be
/// seen as scrambled and the body would change.
#[test]
@@ -352,11 +342,10 @@ mod tests {
}
}
/// Each individual scramble bit (4 and 5) independently marks the sector
/// as encrypted: 0x10 and 0x20 must both trigger descrambling.
/// Each individual scramble bit (4 and 5) independently marks the sector as
/// encrypted: 0x10 and 0x20 must both trigger descrambling.
///
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both
/// nonzero.
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both nonzero.
/// Mutation: change `!= 0` early-return condition to `== 3` -> a sector
/// flagged only 0x10 or 0x20 would be skipped and left scrambled.
#[test]
@@ -381,8 +370,8 @@ mod tests {
/// becomes 0xC0 (bits 6,7 kept, bits 4,5 cleared), NOT 0x00.
///
/// Grounding: code does `sector[0x14] &= 0xCF`; 0xF0 & 0xCF == 0xC0.
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved
/// high bits assert fails.
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved high
/// bits assert fails.
#[test]
fn descramble_clear_preserves_high_bits_of_0x14() {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
@@ -398,11 +387,11 @@ mod tests {
// ── header / body boundary (encrypted region is 0x80..0x800) ───────────
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80
/// (the header) must NOT be modified by the keystream — except byte 0x14
/// whose flag is cleared. In particular the sector-seed bytes 0x54..0x59
/// (which live inside the header) must survive untouched, since the
/// descrambler reads them but never writes them.
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80 (the
/// header) must NOT be modified by the keystream — except byte 0x14 whose
/// flag is cleared. In particular the sector-seed bytes 0x54..0x59 (which
/// live inside the header) must survive untouched, since the descrambler
/// reads them but never writes them.
///
/// Grounding: loop is `sector.iter_mut().take(2048).skip(128)` -> indices
/// 128..2048 only.
@@ -429,16 +418,15 @@ mod tests {
assert_eq!(&sector[0x54..0x59], &seed, "sector seed must survive");
}
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a
/// prefix. With a constant body and constant key, the keystream is
/// non-degenerate enough that the very last sector byte (index 2047) is
/// altered. This guards the loop bound `.take(2048)` against an
/// off-by-one that would leave the final byte(s) scrambled.
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a prefix.
/// With a constant body and constant key, the keystream is non-degenerate
/// enough that the very last sector byte (index 2047) is altered. This guards
/// the loop bound `.take(2048)` against an off-by-one that would leave the
/// final byte(s) scrambled.
///
/// Grounding: encrypted region end is 0x800 == 2048 (exclusive).
/// Mutation: change `.take(2048)` to `.take(2047)` -> last byte unchanged,
/// assert fires (keystream byte for the last position is verified nonzero
/// below by the round-trip, and this body is all-zero so any XOR shows).
/// assert fires (this body is all-zero so any keystream XOR shows).
#[test]
fn descramble_covers_final_body_byte() {
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
@@ -447,9 +435,7 @@ mod tests {
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
descramble_sector(&key, &mut sector);
// Body was all zero; any nonzero in [0x80,0x800) is keystream. Confirm
// the keystream reaches the final byte. (If the last keystream byte
// happened to be 0 this could be a flaky test, so assert the run-end
// region as a whole differs from zero.)
// the keystream reaches the final byte.
assert_ne!(
&sector[2040..2048],
&[0u8; 8][..],
@@ -458,13 +444,13 @@ mod tests {
}
/// Descramble is keyed by `title_key XOR seed`: two different title keys
/// produce two different bodies for the same scrambled input. A cipher
/// that ignored the title key (or mixed it in wrongly) would yield
/// identical output — silent wrong-key decryption.
/// produce two different bodies for the same scrambled input. A cipher that
/// ignored the title key (or mixed it in wrongly) would yield identical
/// output — silent wrong-key decryption.
///
/// Grounding: per-sector key = title_key[i] ^ sector[0x54+i].
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both
/// keys give the same body, assert fires.
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both keys
/// give the same body, assert fires.
#[test]
fn descramble_output_depends_on_title_key() {
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
+47 -1
View File
@@ -46,7 +46,7 @@ pub struct CssState {
/// Recover the CSS title key with no keys, by scanning scrambled sectors and
/// running the Stevenson known-plaintext attack (see the [`stevenson`] module).
///
/// The crib comes from `AttackPattern`: a scrambled sector's cleartext region
/// The crib comes from the periodic-run detector: a scrambled sector's cleartext region
/// (bytes 0x00..0x80) often ends in a short-period repeating run (stuffing /
/// constant fill); the attack assumes that run continues across the 0x80
/// boundary into the encrypted region, giving the known plaintext the 2^16
@@ -271,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 (the standard 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
+42 -61
View File
@@ -1,48 +1,37 @@
//! CSS title-key recovery — Frank A. Stevenson's divide-and-conquer attack
//! (1999), ported exactly from libdvdcss `RecoverTitleKey` + `AttackPattern`
//! (css.c).
//!
//! Recovers the 5-byte CSS title key from a single scrambled DVD sector with
//! no player keys and no disc-key crack, using only known plaintext.
//! (1999), implemented from his published cryptanalysis ("Cryptanalysis of
//! Contents Scrambling System"). It recovers the 5-byte CSS title key from a
//! single scrambled DVD sector with no player keys and no disc-key crack, using
//! only known plaintext. Implemented from that public description; nothing here
//! is copied or translated from any particular CSS software.
//!
//! # The cipher this attacks
//!
//! The content descrambler ([`super::lfsr::descramble_sector`], = libdvdcss
//! `dvdcss_unscramble`) seeds its two LFSRs **directly** from
//! `key = title_key XOR sector_seed` (seed = `sector[0x54..0x59]`):
//!
//! ```text
//! i_t1 = (key[0] ^ sec[0x54]) | 0x100; // LFSR1 low (9-bit)
//! i_t2 = key[1] ^ sec[0x55]; // LFSR1 high
//! i_t3 = (key[2]|key[3]<<8|key[4]<<16) ^ seed3; // LFSR0 (24-bit feedback)
//! i_t3 = i_t3*2 + 8 - (i_t3 & 7);
//! // per byte: *p = TAB1[*p] ^ (i_t5 & 0xff)
//! ```
//!
//! There is NO `decrypt_key` mangling on the content path. So the recovery
//! is a single inversion of `dvdcss_unscramble`, not the multi-stage
//! working-key inversion the previous (non-CSS) implementation used.
//! The content descrambler ([`super::lfsr::descramble_sector`]) seeds its two
//! LFSRs **directly** from `key = title_key XOR sector_seed` (seed =
//! `sector[0x54..0x59]`): LFSR1 from key/seed bytes 0-1, LFSR0 (24-bit) from
//! bytes 2-4 with the pre-conditioning `r0 = r0*2 + 8 - (r0 & 7)`, and each body
//! byte recovered as `plain = TAB1[cipher] ^ (keystream & 0xff)`. There is no
//! title-key mangling on the content path, so the recovery is a single inversion
//! of the sector cipher.
//!
//! # The attack
//!
//! 1. **Known plaintext → keystream.** Because the descramble applies TAB1
//! to the ciphertext, the per-byte keystream is
//! `buf[i] = TAB1[cipher[i]] ^ plain[i]` (matching libdvdcss
//! `RecoverTitleKey`'s `p_buffer`).
//! 1. **Known plaintext → keystream.** Because descramble applies TAB1 to the
//! ciphertext, the per-byte keystream is `TAB1[cipher[i]] ^ plain[i]`.
//! 2. **Brute the 16-bit LFSR1 seed.** For each of 2^16 seeds, run LFSR1
//! forward; for the first four steps deduce the LFSR0 output bytes from
//! the keystream (carry-tracked), reconstructing `i_t3`. For the next six
//! steps clock LFSR0 normally and check it reproduces the keystream — a
//! wrong LFSR1 seed fails fast.
//! 3. **Back-clock LFSR0.** Run four backward `i_t3` steps (each a 256-way
//! search for the byte shifted in) to reach the initial state, then undo
//! `i_t3 = i_t3*2 + 8 - (i_t3 & 7)` to recover key[2..5].
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]` (plain XOR —
//! the descramble seeds directly, so there is no inversion).
//! forward; for the first four steps deduce the LFSR0 output bytes from the
//! keystream (carry-tracked), reconstructing LFSR0's state. For the next six
//! steps clock LFSR0 normally and check it reproduces the keystream — a wrong
//! LFSR1 seed fails fast.
//! 3. **Back-clock LFSR0.** Run four backward steps (each a 256-way search for
//! the byte shifted in) to reach the initial state, then undo the
//! `r0*2 + 8 - (r0 & 7)` pre-conditioning to recover key[2..5].
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]`.
//!
//! `AttackPattern` finds known plaintext for step 1: the longest periodic
//! run in the cleartext `sec[0x00..0x80]`, assumed to continue into the
//! encrypted region at 0x80.
//! Known plaintext for step 1 comes from the longest periodic run in the
//! cleartext `sec[0x00..0x80]`, assumed to continue into the encrypted region at
//! 0x80.
use super::lfsr::descramble_sector;
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
@@ -52,13 +41,11 @@ const ENCRYPTED_START: usize = 0x80; // byte 128
const SEED_OFFSET: usize = 0x54; // sector seed at bytes 0x54-0x58
const FLAG_BYTE: usize = 0x14;
/// RecoverTitleKey: recover the title key from cipher + known plaintext.
///
/// Exact port of libdvdcss `RecoverTitleKey` (css.c). `crypted` is the
/// ciphertext starting at sector byte 0x80; `decrypted` is the matching
/// known plaintext; `seed` is `sector[0x54..0x59]`. On success returns the
/// recovered 5-byte title key; `None` if no LFSR seed reproduces the
/// keystream.
/// Recover the title key from cipher + known plaintext (the core of Stevenson's
/// attack). `crypted` is the ciphertext starting at sector byte 0x80;
/// `decrypted` is the matching known plaintext; `seed` is `sector[0x54..0x59]`.
/// On success returns the recovered 5-byte title key; `None` if no LFSR seed
/// reproduces the keystream.
///
/// At least 10 bytes of `crypted`/`decrypted` are required (the cipher is
/// iterated 10 times: 4 to reconstruct LFSR0, 6 to validate).
@@ -222,16 +209,13 @@ fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool {
test[ENCRYPTED_START..ENCRYPTED_START + n] == plain[..n]
}
/// AttackPattern: find a repeating pattern just before the encrypted region
/// and assume the plaintext at 0x80 continues it.
///
/// Functionally-equivalent port of libdvdcss `AttackPattern` (css.c) — finds the
/// same periodic cribs on real DVD data, though its byte-comparison anchor
/// differs from the C on phase-misaligned runs. Scans cleartext
/// `sec[0x00..0x80]` for the longest run that repeats with a cycle length in
/// 2..0x2F. If the run is long enough (`plen > 3` and at least two full
/// cycles), the known plaintext at 0x80 is taken to be the periodic run
/// continuing forward, and [`recover_title_key_from_plain`] is applied.
/// Find a repeating pattern just before the encrypted region and assume the
/// plaintext at 0x80 continues it — the known-plaintext step of Stevenson's
/// attack. Scans cleartext `sec[0x00..0x80]` for the longest run that repeats
/// with a cycle length in 2..0x2F. If the run is long enough (`plen > 3` and at
/// least two full cycles), the known plaintext at 0x80 is taken to be the
/// periodic run continuing forward, and [`recover_title_key_from_plain`] is
/// applied.
pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
if sector.len() < SECTOR_BYTES {
return None;
@@ -260,10 +244,7 @@ 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.
/// Crib: the predicted 10-byte plaintext at byte 0x80.
///
/// Scans the clear header `sec[0x00..0x80]` (never scrambled) for the longest
/// run that repeats with a cycle length in 2..0x2F. If the run is long enough
@@ -369,7 +350,7 @@ mod tests {
/// Build a synthetic scrambled sector whose CLEARTEXT (0x00..0x80) ends
/// in a periodic run that continues into the encrypted region — the case
/// `AttackPattern` (crack_title_key) is designed to crack.
/// `crack_title_key` is designed to crack.
fn synth_periodic_sector(
title_key: &[u8; 5],
seed: &[u8; 5],
@@ -382,7 +363,7 @@ mod tests {
// (RUN_START..0x80) and continuing into the encrypted region. This
// mirrors a real VOB: a periodic data run just before the scrambled
// part. The run must NOT overlap the seed bytes (0x54..0x59), or the
// AttackPattern detector would break mid-run. The phase is anchored to
// the crib detector would break mid-run. The phase is anchored to
// offset 0 so the run is consistent across the 0x80 boundary.
// Just above the seed (0x54..0x59); gives a 39-byte run (0x59..0x80)
// — enough for >=2 cycles of every tested period (<=19).
@@ -469,7 +450,7 @@ mod tests {
}
}
/// MANDATORY (Task C.1): the AttackPattern entry point crack_title_key —
/// MANDATORY (Task C.1): the crib-based entry point crack_title_key —
/// no plaintext supplied — recovers a round-tripping key when the
/// cleartext ends in a periodic run that continues into 0x80.
#[test]
@@ -491,7 +472,7 @@ mod tests {
}
}
/// recover_title_key_from_plain inverts dvdcss_unscramble exactly: scramble
/// recover_title_key_from_plain inverts descramble_sector exactly: scramble
/// a known body, hand back the keystream-derived key, and the recovered
/// key (XOR-back included) reproduces the plaintext.
#[test]
+11 -12
View File
@@ -24,9 +24,8 @@ pub const TAB1: [u8; 256] = [
0xb7, 0xf7, 0xbf, 0xa2, 0xe7, 0xa7, 0xef, 0xf2, 0xba, 0xfa, 0xb2, 0xaf, 0xea, 0xaa, 0xe2, 0xff,
];
/// Table 2: LFSR1 high-byte feedback permutation.
///
/// Byte-identical to libdvdcss `p_css_tab2` (csstables.h).
/// Table 2: LFSR1 high-byte feedback permutation — a fixed constant of the CSS
/// cipher (per the published algorithm).
pub const TAB2: [u8; 256] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x09, 0x08, 0x0b, 0x0a, 0x0d, 0x0c, 0x0f, 0x0e,
0x12, 0x13, 0x10, 0x11, 0x16, 0x17, 0x14, 0x15, 0x1b, 0x1a, 0x19, 0x18, 0x1f, 0x1e, 0x1d, 0x1c,
@@ -46,12 +45,12 @@ pub const TAB2: [u8; 256] = [
0xed, 0xec, 0xef, 0xee, 0xe9, 0xe8, 0xeb, 0xea, 0xe4, 0xe5, 0xe6, 0xe7, 0xe0, 0xe1, 0xe2, 0xe3,
];
/// Table 3: LFSR1 9-bit low-word feedback table (512 entries).
/// Table 3: LFSR1 9-bit low-word feedback table (512 entries) — a fixed constant
/// of the CSS cipher (per the published algorithm).
///
/// Byte-identical to libdvdcss `p_css_tab3` (csstables.h): the 8-value
/// block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step indexes this
/// table with the 9-bit low register (0x100..=0x1FF), but only the low 3
/// bits select the output — the high bits are ignored, hence the constant
/// It is the 8-value block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step
/// indexes this table with the 9-bit low register (0x100..=0x1FF), but only the
/// low 3 bits select the output — the high bits are ignored, hence the constant
/// blocks. The 512-entry width simply lets the 9-bit index be used without
/// masking.
pub const TAB3: [u8; 512] = [
@@ -197,12 +196,12 @@ mod tests {
}
}
/// TAB3 is the libdvdcss `p_css_tab3`: the 8-value feedback block
/// TAB3 is the CSS LFSR1 low-word table: the 8-value feedback block
/// BASE = [0x00,0x24,0x49,0x6d,0x92,0xb6,0xdb,0xff]
/// repeated 64 times — `TAB3[i] == BASE[i & 7]`. The high bits of the
/// 9-bit index do not affect the output (libdvdcss's LFSR1 step indexes
/// with the full 9-bit low register but only `& 7` matters). This pins
/// all 512 entries to the published table.
/// 9-bit index do not affect the output (the LFSR1 step indexes with the
/// full 9-bit low register but only `& 7` matters). This pins all 512
/// entries to the published cipher's table.
///
/// Mutation: flip any single byte in the TAB3 literal -> the formula
/// check fails at that index.
+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"
);
+134 -256
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(
@@ -60,6 +72,11 @@ impl Disc {
// Parse each clip for size, duration, and sector extents
let mut extents = Vec::new();
let mut total_size: u64 = 0;
// Set when any clip resolves to a STREAM/SSIF/<clip>.ssif — a Blu-ray 3D
// interleaved stream carrying both the base (left) and MVC dependent
// (right) views. Drives reading the SSIF for both eyes and adding the
// dependent-view stream below.
let mut is_3d = false;
let mut clips = Vec::with_capacity(parsed.play_items.len());
// BD playlists legally reference the same .m2ts clip_id from
// multiple PlayItems (multi-angle, seamless splits, looped
@@ -73,22 +90,49 @@ 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.
// 3D discs interleave the left (base) and right (MVC
// dependent) views in STREAM/SSIF/<clip>.ssif — note the
// SSIF/ subdir. Prefer it when present: the SSIF is one
// transport stream carrying BOTH eyes on distinct PIDs,
// so muxing it captures the full 3D. 2D clips fall back to
// the base .m2ts / .fmts as before.
let ssif = format!("/BDMV/STREAM/SSIF/{}.ssif", play_item.clip_id);
let file_exts = match udf_fs.file_extents(reader, &ssif) {
Ok(exts) => {
is_3d = true;
Some(exts)
}
Err(_) => 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 {
@@ -112,7 +156,7 @@ impl Disc {
}
// Build streams from STN table
let streams: Vec<Stream> = parsed
let mut streams: Vec<Stream> = parsed
.streams
.iter()
.filter_map(|s| {
@@ -206,6 +250,42 @@ impl Disc {
})
.collect();
// 3D: add the MVC dependent (right-eye) video stream. The base STN table
// lists only the left-eye video; the dependent view is a second video
// PID (stream_type 0x20) carried in the SSIF. The on-disc PAT/PMT are
// AACS-encrypted (unreadable pre-key) and the base STN omits the
// dependent view (it lives in the MPLS STN_table_SS), so we use the
// BD-3D PID convention: dependent = base-view video PID + 1
// (e.g. 0x1011 -> 0x1012). Reading the SSIF (above) provides its packets.
//
// Limitation: `is_3d` latches per PLAYLIST, not per clip. A playlist that
// mixed a 3D clip (has an SSIF) with a 2D clip (no SSIF) would tag the
// whole title 3D; the 2D clip's frames then mux as plain Blocks (no
// dependent PID → no BlockAdditional) under a track that still advertises
// the mvcC mapping. That output is valid (per-frame BlockAdditional is
// optional) but over-claims 3D for those frames. Real 3D main-feature
// playlists are single-clip or uniformly 3D, so this is not exercised;
// per-clip 3D would need per-clip stream sets (a larger change).
if is_3d {
if let Some(base) = streams.iter().find_map(|s| match s {
Stream::Video(v) => Some(v.clone()),
_ => None,
}) {
let dep_pid = base.pid.wrapping_add(1);
let have_dep = streams
.iter()
.any(|s| matches!(s, Stream::Video(v) if v.pid == dep_pid));
if !have_dep {
streams.push(Stream::Video(VideoStream {
pid: dep_pid,
secondary: true,
label: crate::disc::MVC_DEPENDENT_LABEL.to_string(),
..base
}));
}
}
}
// Convert marks to chapters. mark_type == 1 is an entry-mark
// (chapter); type 2 is a link point and type 0 is reserved, so
// neither is a chapter.
@@ -314,256 +394,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 +628,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 +728,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
+31 -28
View File
@@ -86,9 +86,9 @@ impl AacsCertUnlocker<'_> {
// 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.scsi_mut())
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
@@ -111,13 +111,13 @@ impl AacsCertUnlocker<'_> {
// 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 unlocked = crate::unlock_bridge::run_unlockers(
let (_, unlock_res) = crate::unlock_bridge::run_bus(
session.scsi_mut(),
&drive_id,
freemkv_unlock::DiscKind::Aacs,
&fu_certs,
)
.map_err(CertUnlockFailure::Unlock)?;
);
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));
@@ -152,10 +152,10 @@ fn unlock_error_to_error(e: &CertUnlockFailure) -> Error {
}
}
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::UnlockOutcome`]
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::trace::UnlockOutcome`]
/// for the resolution trace (English-free).
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::UnlockOutcome {
use crate::aacs::UnlockOutcome;
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::trace::UnlockOutcome {
use crate::aacs::trace::UnlockOutcome;
use freemkv_unlock::UnlockError;
match e {
CertUnlockFailure::NoHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
@@ -230,7 +230,10 @@ 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> {
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.
@@ -312,14 +315,14 @@ impl Disc {
.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, 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);
// No-cert default = UHD (V20 stride), matching `read_aacs_version` so the
// scanned `AacsState.version` and the out-of-band fetch agree. A wrong
@@ -328,7 +331,7 @@ impl Disc {
let version = cc
.as_ref()
.map(|c| c.version.major())
.unwrap_or(aacs::AACS_MAJOR_UHD);
.unwrap_or(aacs::mkb::AACS_MAJOR_UHD);
// Bus-encryption gate (wrong-keys guard). A bus-encrypted disc (Content
// Certificate bus-encryption bit set) carries bus encryption on its
@@ -394,12 +397,12 @@ impl Disc {
Vec::new()
}
};
let mkb_ver = aacs::mkb_version(&mkb_bytes);
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(),
@@ -410,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![],
@@ -569,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];
@@ -581,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).
@@ -687,14 +690,14 @@ mod tests {
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
assert_eq!(
st.version,
aacs::AACS_MAJOR_UHD,
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() {
@@ -710,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.
@@ -746,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);
@@ -943,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);
@@ -965,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,
@@ -975,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()
}
}
@@ -1077,7 +1080,7 @@ mod tests {
#[test]
fn cert_unlock_outcome_maps_to_structured_trace_step() {
use crate::aacs::UnlockOutcome;
use crate::aacs::trace::UnlockOutcome;
use freemkv_unlock::UnlockError;
// The libfreemkv-side no-cert case carries the MKB generation.
assert_eq!(
+85 -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,
};
@@ -851,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 {
@@ -860,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]) {
@@ -891,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;
@@ -1083,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
@@ -1101,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 {
@@ -1124,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;
@@ -1412,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() {
@@ -1585,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"
@@ -1600,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)
+1395
View File
File diff suppressed because it is too large Load Diff
+367 -96
View File
@@ -13,6 +13,7 @@ mod dvd;
pub mod dvd_audio_probe;
mod encrypt;
mod extract;
mod hddvd;
pub mod mapfile;
mod patch;
pub mod read_error;
@@ -93,8 +94,16 @@ pub enum ContentFormat {
pub enum DiscFormat {
/// 4K UHD Blu-ray (HEVC 2160p)
Uhd,
/// UHD Blu-ray with AACS 2.1 FMTS content — the main feature is a `.fmts`
/// clip (M2TS transport stream plus interleaved forensic variant segments).
/// A BD-tree disc (enumerated by [`Disc::scan_bluray_titles`]); distinct
/// from [`DiscFormat::Uhd`] only in the container + AACS generation.
Fmts,
/// Standard Blu-ray (1080p/1080i)
BluRay,
/// HD-DVD — `HVDVD_TS/` tree with `.evo` (Enhanced VOB, MPEG program stream)
/// clips. A tree-level peer of DVD/BD, enumerated by its own scanner.
HdDvd,
/// DVD
Dvd,
/// Unknown
@@ -207,6 +216,22 @@ pub struct VideoStream {
pub measured_cicp: Option<MeasuredCicp>,
}
/// Label marking a video stream as the Blu-ray 3D **MVC dependent (right-eye)
/// view** — the paired substream of the AVC base view. Set by the BD scan
/// (`bluray.rs`) and recognised by the mux path (`resolve.rs` builds its parser
/// in param-set-preserving mode; `mkvstream.rs` folds it into the base track as
/// per-frame `BlockAdditional`). Single source of truth for the contract.
pub const MVC_DEPENDENT_LABEL: &str = "MVC dependent view (3D right eye)";
impl VideoStream {
/// Whether this video stream is the MVC dependent (right-eye) view — the
/// 3D substream that the muxer merges into the base track as a per-frame
/// `BlockAdditional` rather than emitting as an independent track.
pub fn is_mvc_dependent(&self) -> bool {
self.label == MVC_DEPENDENT_LABEL
}
}
/// Measured CICP colour signalling read directly from a video elementary stream
/// (ITU-T H.273). Preferred over the coarse [`ColorSpace`] enum when present.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -802,7 +827,11 @@ impl Codec {
use crate::consts::coding_type as c;
match ct {
c::HEVC => Codec::Hevc,
c::H264 => Codec::H264,
// 0x1B base-view AVC and 0x20 MVC dependent-view (Blu-ray 3D right
// eye) are both H.264. Mapping 0x20 to video is what makes the PMT
// scan enumerate the dependent view as a second H.264 stream (its
// own PID in the SSIF) instead of dropping it — the basis of 3D.
c::H264 | c::H264_MVC => Codec::H264,
c::VC1 => Codec::Vc1,
c::MPEG2_VIDEO => Codec::Mpeg2,
c::TRUEHD => Codec::TrueHd,
@@ -1413,7 +1442,7 @@ impl KeyOrigin {
#[derive(Default, Clone)]
pub struct DriveCredentials {
/// Host certificate(s) + private key(s) for the SCSI AACS handshake.
pub host_certs: Vec<crate::aacs::HostCert>,
pub host_certs: Vec<crate::aacs::types::HostCert>,
}
/// Options for disc scanning.
@@ -1481,13 +1510,10 @@ impl Disc {
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
let meta_title = Self::read_meta_title(&mut buffered, &udf_fs);
let format = if udf_fs.find_dir("/BDMV").is_some() {
DiscFormat::BluRay // full scan distinguishes UHD vs BD
} else if udf_fs.find_dir("/VIDEO_TS").is_some() {
DiscFormat::Dvd
} else {
DiscFormat::Unknown
};
// Authoritative here — the same MKB-driven detector the full scan uses
// (no titles needed: BD/UHD/FMTS come from the MKB generation). It no
// longer defaults to BluRay or defers UHD/FMTS to the full scan.
let format = Self::detect_disc_format(&mut buffered, &udf_fs, &[]);
let encrypted =
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
let layers = if capacity > 24_000_000 { 2 } else { 1 };
@@ -1644,12 +1670,13 @@ impl Disc {
// key). Any failure is non-fatal: continue to the crack, which
// simply finds nothing if the drive kept the sectors gated.
let drive_id = session.drive_id.clone();
if let Err(e) = crate::unlock_bridge::run_unlockers(
let (_, css_unlock_res) = crate::unlock_bridge::run_bus(
session.scsi_mut(),
&drive_id,
freemkv_unlock::DiscKind::Css,
&[],
) {
);
if let Err(e) = css_unlock_res {
tracing::warn!(
target: "freemkv::scan",
outcome = ?e,
@@ -1781,8 +1808,8 @@ impl Disc {
Ok((inf, mkb, version))
}
/// AACS major version ([`crate::aacs::AACS_MAJOR_BD`] /
/// [`crate::aacs::AACS_MAJOR_UHD`]) from the content certificate. Drives the
/// AACS major version ([`crate::aacs::mkb::AACS_MAJOR_BD`] /
/// [`crate::aacs::mkb::AACS_MAJOR_UHD`]) from the content certificate. Drives the
/// `Unit_Key_RO.inf` parse stride (48-byte V10 vs 64-byte V20/V21), so the
/// out-of-band key-fetch path parses `enc_title_keys` at the right stride (a
/// server VUK then derives the correct unit keys).
@@ -1798,7 +1825,7 @@ impl Disc {
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
.ok()
.as_deref()
.and_then(crate::aacs::parse_content_cert)
.and_then(crate::aacs::inf::parse_content_cert)
{
Some(c) => c.version.major(),
None => {
@@ -1808,7 +1835,7 @@ impl Disc {
"no readable AACS content certificate; defaulting to the V20/UHD \
Unit_Key_RO stride (a VUK-from-server path would otherwise mis-stride)"
);
crate::aacs::AACS_MAJOR_UHD
crate::aacs::mkb::AACS_MAJOR_UHD
}
}
}
@@ -1817,7 +1844,7 @@ impl Disc {
///
/// `MKB_RO.inf` / `MKB_RW.inf` are allocated to a fixed ~128 MiB and
/// zero-padded; the actual record stream is a few MiB. We read a bounded
/// prefix, find the record-stream length via [`crate::aacs::mkb_content_len`]
/// prefix, find the record-stream length via [`crate::aacs::mkb::mkb_content_len`]
/// and return exactly that, growing the prefix if the records run past it.
/// This avoids reading 100+ MiB of padding on every scan AND avoids the
/// `read_file` `MAX_FILE_BYTES` cap that (since 0.31.0) rejected the padded
@@ -1833,13 +1860,13 @@ impl Disc {
.read_file_prefix(reader, crate::aacs::PATH_MKB_RO, want)
.or_else(|_| udf_fs.read_file_prefix(reader, crate::aacs::PATH_MKB_RW, want))
.map_err(|_| Error::AacsNoKeys)?;
let n = crate::aacs::mkb_content_len(&buf);
let n = crate::aacs::mkb::mkb_content_len(&buf);
// `n` strictly inside `buf` => the record walk reached the padding
// boundary (full content captured). `buf` shorter than `want` =>
// the whole file is already read. Otherwise the records may run
// past the prefix — grow and retry, bounded by MAX_BYTES.
if (n > 0 && n < buf.len()) || buf.len() < want || want >= MAX_BYTES {
return Ok(crate::aacs::trim_mkb(buf));
return Ok(crate::aacs::mkb::trim_mkb(buf));
}
want = (want * 2).min(MAX_BYTES);
}
@@ -1906,12 +1933,20 @@ impl Disc {
}
};
// 3. Titles — BD (MPLS playlists) or DVD (IFO title sets)
// 3. Titles + container — dispatched by on-disc tree. HD-DVD and DVD are
// tree-level peers, each with its own enumerator; FMTS shares the BD
// tree (a `.fmts` stream variant). Disc FORMAT is a separate axis
// derived below from the AACS MKB generation, not the tree.
let (mut titles, content_format) = if udf_fs.find_dir("/BDMV").is_some() {
(
Self::scan_bluray_titles(reader, &udf_fs),
ContentFormat::BdTs,
)
} else if udf_fs.find_dir("/HVDVD_TS").is_some() {
(
Self::scan_hddvd_titles(reader, &udf_fs),
ContentFormat::MpegPs,
)
} else if udf_fs.find_dir("/VIDEO_TS").is_some() {
(
Self::scan_dvd_titles(reader, &udf_fs),
@@ -1935,8 +1970,11 @@ impl Disc {
crate::labels::apply(reader, &udf_fs, &mut titles);
crate::labels::fill_defaults(&mut titles);
// 5. Derive format, layers, region
let format = Self::detect_format(&titles);
// 5. Format (AACS MKB generation → BD/UHD/FMTS; tree → HD-DVD/DVD) and
// layers. Region coding is not yet decoded from the disc — every disc
// reports Region-free for now (correct for all UHD; a stub for
// region-locked BD/DVD until region detection is implemented).
let format = Self::detect_disc_format(reader, &udf_fs, &titles);
let layers = if capacity > 24_000_000 { 2 } else { 1 };
let region = DiscRegion::Free;
@@ -2025,10 +2063,10 @@ impl Disc {
/// 1. Real titles (`size_bytes ≤ capacity_bytes`) before virtual
/// composites. The capacity check is a hard "physically
/// possible data on this disc" gate.
/// 2. Among real titles, fewer clips first. A 1-clip playlist is
/// the canonical main feature; multi-clip playlists are either
/// chapter-stitched (small count) or virtual composites
/// (large count). Fewer wins.
/// 2. Among real titles, LARGEST physical size first — the main
/// feature is the biggest real title on the disc. (This replaced
/// the old clip-count ordering, which mis-ranked chapter-per-clip
/// discs like Fast & Furious.)
/// 3. Tiebreak on longer duration first.
///
/// **Effect on non-branching discs:** unchanged — the main movie
@@ -2040,17 +2078,27 @@ impl Disc {
b: &DiscTitle,
capacity_bytes: u64,
) -> std::cmp::Ordering {
// A title bigger than the whole disc is a "play-all" composite artifact
// (its declared size double-counts clips shared with other playlists) —
// demote it below any real single title.
let a_oversize = a.size_bytes > capacity_bytes;
let b_oversize = b.size_bytes > capacity_bytes;
a_oversize
.cmp(&b_oversize)
.then_with(|| a.clips.len().cmp(&b.clips.len()))
// PRIMARY: largest physical size = the main feature. Robust where
// duration and clip-count are not — a decoy "play-all" playlist runs
// long (e.g. 1h31m) but is tiny (0.4 GB of reused/junk clips), and the
// real feature is often chaptered into MANY clips (one per chapter),
// which the old clip-count-ascending key wrongly demoted below 1-clip
// bonus reels. Validated across 23 UHD/BD discs — fixes F9 / Fast Five
// / Fast & Furious 6 / Furious 7 (feature was ranked ~#13–36), no
// regressions on the 19 already correct.
.then_with(|| b.size_bytes.cmp(&a.size_bytes))
// Tiebreak for equal-size twins: longer duration, then richer audio —
// the same feature authored as sibling playlists (a full-audio main
// vs an audio-reduced twin, e.g. Fight Club's 00800 [DTS-HD MA + 13
// tracks] vs 00004 [stereo AC-3 only]). Prefer lossless-multichannel.
.then_with(|| b.duration_secs.total_cmp(&a.duration_secs))
// Same length + clip count = the same feature authored as multiple
// playlists (a full-audio main vs an audio-reduced twin, e.g. Fight
// Club's 00800 [DTS-HD MA + 13 tracks] vs 00004 [stereo AC-3 only]).
// Prefer the richer audio so we never rip a stereo-only variant over
// the lossless-multichannel main feature.
.then_with(|| Self::audio_richness(b).cmp(&Self::audio_richness(a)))
}
@@ -2086,6 +2134,55 @@ impl Disc {
(lossless, max_ch, count)
}
/// The disc format, from the two on-disc axes:
/// * **tree** → HD-DVD (`HVDVD_TS/`) and DVD (`VIDEO_TS/`) are tree-level
/// peers with their own enumerators;
/// * **AACS MKB generation** → within the BD tree (`BDMV/`), the MKB Type
/// record decides BD (1.0) / UHD (2.0) / FMTS (2.1). This is the
/// authoritative, cheap signal (the Type record is the first bytes of
/// `MKB_RO.inf`) — reusing [`crate::aacs::mkb::mkb_type`] /
/// [`crate::aacs::mkb::MkbType::generation`], not a filesystem heuristic.
///
/// An unencrypted / MKB-less BD tree falls back to video resolution (still a
/// BD-tree disc, so never below [`DiscFormat::BluRay`]).
fn detect_disc_format(
reader: &mut dyn SectorSource,
udf_fs: &crate::udf::UdfFs,
titles: &[DiscTitle],
) -> DiscFormat {
use crate::aacs::mkb::{AacsVersion, mkb_type};
// Tree priority MUST match the title-scan dispatch (BDMV → HVDVD_TS →
// VIDEO_TS): otherwise a disc carrying two trees would be classified as
// one format but enumerated as another (e.g. BD titles tagged HdDvd).
if udf_fs.find_dir("/BDMV").is_some() {
// Only the Type-and-Version record (first record) is needed.
if let Ok(mkb) = udf_fs.read_file_prefix(reader, "/AACS/MKB_RO.inf", 64) {
match mkb_type(&mkb).map(|t| t.generation()) {
Some(AacsVersion::V21) => return DiscFormat::Fmts,
Some(AacsVersion::V20) => return DiscFormat::Uhd,
Some(AacsVersion::V10) => return DiscFormat::BluRay,
None => {}
}
}
// Unencrypted / unreadable MKB: refine by resolution, but a BD-tree
// disc is never below Blu-ray — only UHD can promote it. detect_format
// is a general resolution classifier that can return Dvd for an SD
// bonus/menu title, which must NOT tag a BDMV disc as DVD (that
// mis-sizes the ECC-block sweep). Clamp anything but UHD up to BluRay.
return match Self::detect_format(titles) {
DiscFormat::Uhd => DiscFormat::Uhd,
_ => DiscFormat::BluRay,
};
}
if udf_fs.find_dir("/HVDVD_TS").is_some() {
return DiscFormat::HdDvd;
}
if udf_fs.find_dir("/VIDEO_TS").is_some() {
return DiscFormat::Dvd;
}
DiscFormat::Unknown
}
fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
for title in titles.iter().take(3) {
for stream in &title.streams {
@@ -2201,7 +2298,7 @@ fn aligned_unit_keys_validate(
read_data_key: Option<&[u8; 16]>,
samples: &[Vec<u8>],
) -> bool {
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_unit_full};
use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_unit_full};
let scrambled: Vec<&[u8]> = samples
.iter()
.map(|s| s.as_slice())
@@ -2274,7 +2371,7 @@ impl Disc {
/// else (UDF filesystem, BDMV nav, PLAYLIST/CLIPINF) is always clear.
///
/// The in-read decrypt-verify gate (`DecryptingSectorSource`) uses this so it
/// never consults [`ts_sync_destroyed`](crate::aacs::ts_sync_destroyed) about
/// never consults [`ts_sync_destroyed`](crate::aacs::content::ts_sync_destroyed) about
/// non-content bytes — filesystem data has no TS sync and would otherwise be
/// mistaken for ciphertext (the first-2-GB false-positive this fixes).
///
@@ -2308,18 +2405,23 @@ impl Disc {
/// that honestly (`AACS: no`), and on a *stock* drive that fell back to the
/// cert route it reports `LibreDrive: no, AACS: yes` — the real diagnostic.
pub fn unlocker_matrix(&self, drive: &crate::Drive) -> Vec<(&'static str, bool)> {
// LibreDrive firmware-unlocked the drive iff a drive unlocker actually
// succeeded at init (name recorded only on success).
let ld_worked = drive.unlocker_name().is_some();
// The drive-prep unlocker that actually ran (recorded on init):
// "LibreDrive" (MediaTek) or "Renesas" — mutually exclusive per drive.
let prep = drive.unlocker_name();
// Only LibreDrive (MediaTek) removes AACS bus encryption AT THE DRIVE;
// Renesas unlocks features but leaves the bus to the cert.
let ld_removed_bus = prep == Some("LibreDrive");
crate::unlock_bridge::unlocker_names()
.into_iter()
.map(|name| {
let did_work = match name {
// The drive firmware unlock ran and succeeded.
"LibreDrive" => ld_worked,
// The AACS host-cert route removed the bus ONLY when LibreDrive
// didn't (stock drive) AND AACS state was actually obtained.
"AACS" => self.aacs.is_some() && !ld_worked,
// Each firmware unlocker did work iff it was the one that ran.
"LibreDrive" => ld_removed_bus,
"Renesas" => prep == Some("Renesas"),
// The AACS host-cert route removed the bus ONLY when the
// firmware didn't (stock or Renesas drive) AND AACS state was
// actually obtained.
"AACS" => self.aacs.is_some() && !ld_removed_bus,
// The CSS handshake/crack succeeded → title keys recovered.
"CSS" => self.css.is_some(),
// A newly-registered unlocker with no runtime signal wired
@@ -2543,6 +2645,28 @@ impl Disc {
self.ensure_decryptable_keys(raw, keys)
}
/// Upfront FMTS (AACS 2.1) key gate, parallel to
/// [`ensure_title_decryptable`](Self::ensure_title_decryptable). A 2.1 disc
/// carries forensic variant segments that need segment (variant) keys the
/// unit-key path cannot provide. When
/// [`BYPASS_FMTS_KEY`](crate::aacs::segment::BYPASS_FMTS_KEY) is `false`,
/// their absence is a hard upfront failure ([`Error::FmtsKeyMissing`]) — the
/// same policy as a missing unit key, so a forensic-holed rip is refused, not
/// produced. When `true` (the default today) the segments are skipped as
/// expected loss and this passes. `raw` mode and non-FMTS discs always pass.
pub fn ensure_forensic_segments_decryptable(&self, raw: bool) -> Result<()> {
if raw || crate::aacs::segment::BYPASS_FMTS_KEY {
return Ok(());
}
// A 2.1 (FMTS) disc carries forensic variant segments with no segment-key
// source (none exists yet), so its variant segments cannot be opened.
// Refuse upfront rather than emit a forensic-holed rip.
if self.format == DiscFormat::Fmts {
return Err(Error::FmtsKeyMissing);
}
Ok(())
}
/// Inject pre-resolved AACS unit keys into a scanned disc — the deferred-mux
/// / resume path. The keys come from the mapfile's `# freemkv-uk:` header
/// (persisted at sweep time when the disc was keyed), so the mux decrypts
@@ -2565,13 +2689,15 @@ impl Disc {
aacs.unit_keys = keys;
aacs.key_source = KeyOrigin::ExternalUk;
} else if self.encrypted && self.css.is_none() {
// FMTS is AACS 2.1, a UHD-family (bus-encrypted) format — not BD.
let uhd_family = matches!(self.format, DiscFormat::Uhd | DiscFormat::Fmts);
self.aacs = Some(AacsState {
version: if self.format == DiscFormat::Uhd {
crate::aacs::AACS_MAJOR_UHD
version: if uhd_family {
crate::aacs::mkb::AACS_MAJOR_UHD
} else {
crate::aacs::AACS_MAJOR_BD
crate::aacs::mkb::AACS_MAJOR_BD
},
bus_encryption: self.format == DiscFormat::Uhd,
bus_encryption: uhd_family,
mkb_version: None,
disc_hash: String::new(),
key_source: KeyOrigin::ExternalUk,
@@ -2667,7 +2793,7 @@ impl Disc {
Key::Processing(pks) => supplied.processing_keys = pks,
Key::Media(mks) => supplied.media_keys = mks,
Key::Volume(vuk) => {
supplied.disc_entry = Some(crate::aacs::DiscEntry {
supplied.disc_entry = Some(crate::aacs::types::DiscEntry {
disc_hash: aacs.disc_hash.clone(),
title: String::new(),
media_key: None,
@@ -2685,8 +2811,8 @@ impl Disc {
let uk_ro = aacs.uk_ro.clone();
let version_u8 = aacs.version;
let provider_refs: [&dyn crate::aacs::KeyProvider; 1] = [&supplied];
let ctx = crate::aacs::ResolveContext {
let provider_refs: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied];
let ctx = crate::aacs::resolve::ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &volume_id,
@@ -2700,7 +2826,7 @@ impl Disc {
// reason-preserving wrapper threads the no-key cause out so the
// decrypt gate can report E7017 (had derivation material but no VID)
// vs E7022 (no usable material) instead of a flat AacsKeyRejected.
let resolved = crate::aacs::resolve_keys_with_reason(&ctx, version_u8)
let resolved = crate::aacs::resolve::resolve_keys_with_reason(&ctx, version_u8)
.map_err(|_reason| crate::error::Error::AacsKeyRejected)?;
if resolved.unit_keys.is_empty() {
@@ -3158,25 +3284,37 @@ impl Disc {
// ISO file: if resuming and mapfile has Finished ranges, open existing;
// otherwise create fresh and pre-size to total_bytes (sparse holes for
// non-tried regions).
let is_regular = std::fs::metadata(path)
.map(|m| m.file_type().is_file())
.unwrap_or(false);
let file = if resume
//
// `is_regular` MUST be read from the OPEN file handle, not from
// `metadata(path)` — on a fresh rip the path does not exist yet, so a
// pre-create `metadata(path)` always fails (is_regular=false), which both
// skips the pre-size AND makes `SweepSink::close` swallow a real
// `sync_all()` failure on the just-written ISO as if it were /dev/null.
let (file, is_regular) = if resume
&& std::fs::metadata(path)
.map(|m| m.len() > 0)
.unwrap_or(false)
{
std::fs::OpenOptions::new()
let f = std::fs::OpenOptions::new()
.write(true)
.open(path)
.map_err(|e| Error::IoError { source: e })?
.map_err(|e| Error::IoError { source: e })?;
let reg = f
.metadata()
.map(|m| m.file_type().is_file())
.unwrap_or(false);
(f, reg)
} else {
let f = std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?;
if is_regular {
let reg = f
.metadata()
.map(|m| m.file_type().is_file())
.unwrap_or(false);
if reg {
f.set_len(total_bytes)
.map_err(|e| Error::IoError { source: e })?;
}
f
(f, reg)
};
// Wrap the raw `File` in our bounded-cache `WritebackFile`
@@ -3203,7 +3341,7 @@ impl Disc {
// decrypts and is AACS-keyed. Region read-starts are aligned DOWN to a
// unit boundary in the loop below; a fresh sweep starts at LBA 0 (already
// aligned), so alignment only bites on resume NonTried regions.
const UNIT_SECTORS: u16 = (crate::aacs::ALIGNED_UNIT_LEN / 2048) as u16; // 3
const UNIT_SECTORS: u16 = (crate::aacs::content::ALIGNED_UNIT_LEN / 2048) as u16; // 3
if decrypt_is_aacs && batch % UNIT_SECTORS != 0 {
batch = batch.saturating_add(UNIT_SECTORS - (batch % UNIT_SECTORS));
}
@@ -3285,7 +3423,7 @@ impl Disc {
// sweep's NonTried region starts at 0, already unit-aligned; this only
// shifts resume regions that begin mid-unit.
let mut pos = if decrypt_is_aacs {
let unit_bytes = crate::aacs::ALIGNED_UNIT_LEN as u64;
let unit_bytes = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
region_pos - (region_pos % unit_bytes)
} else {
region_pos
@@ -3971,8 +4109,10 @@ const MIN_BATCH_SECTORS: u16 = 3;
pub(crate) fn ecc_sectors(format: DiscFormat) -> u16 {
match format {
DiscFormat::Uhd | DiscFormat::BluRay => 32,
DiscFormat::Dvd => 16,
// BD-family 64 KiB ECC block (32 × 2048). FMTS is a UHD BD disc.
DiscFormat::Uhd | DiscFormat::Fmts | DiscFormat::BluRay => 32,
// 32 KiB ECC block (16 × 2048) — DVD and HD-DVD.
DiscFormat::Dvd | DiscFormat::HdDvd => 16,
DiscFormat::Unknown => 32,
}
}
@@ -4240,8 +4380,8 @@ mod tests {
/// `sector::decrypting::tests::aacs_unaligned_start_lba_rejected`.
#[test]
fn aacs_sweep_batch_and_region_are_unit_aligned() {
const UNIT_SECTORS: u16 = (crate::aacs::ALIGNED_UNIT_LEN / 2048) as u16; // 3
let unit_bytes = crate::aacs::ALIGNED_UNIT_LEN as u64; // 6144
const UNIT_SECTORS: u16 = (crate::aacs::content::ALIGNED_UNIT_LEN / 2048) as u16; // 3
let unit_bytes = crate::aacs::content::ALIGNED_UNIT_LEN as u64; // 6144
// (a) Batch rounding: ecc_sectors() for UHD/BD is 32, not a multiple of 3.
// The decrypting-AACS path rounds it up to the next multiple of 3 (33).
@@ -4388,14 +4528,14 @@ mod tests {
);
}
/// Non-branching disc: longest 1-clip title is the movie. Sort
/// must not change behaviour — the existing "duration descending"
/// expectation holds when no titles overflow capacity.
/// Non-branching disc: largest title is the movie. With realistic sizes
/// (bytes track duration for same-codec content) size-first yields the same
/// ranking as duration — biggest/longest feature, then extra, then menu.
#[test]
fn canonical_order_preserves_natural_ranking_on_normal_disc() {
const CAPACITY: u64 = 60_000_000_000;
let mut titles = vec![
title_with("00100.mpls", 600.0, 5_000_000_000, 1), // 10 min menu
title_with("00100.mpls", 600.0, 500_000_000, 1), // 10 min menu (small)
title_with("00800.mpls", 7320.0, 55_000_000_000, 1), // 2h02m main feature
title_with("00200.mpls", 1800.0, 2_000_000_000, 1), // 30 min extra
];
@@ -4429,21 +4569,6 @@ mod tests {
);
}
/// Tiebreak: equal duration + equal capacity-validity → fewer
/// clips wins. A chapter-stitched 3-clip movie should beat a
/// 50-clip virtual composite of the same duration.
#[test]
fn canonical_order_fewer_clips_wins_tiebreak() {
const CAPACITY: u64 = 100_000_000_000;
let mut titles = vec![
title_with("00050.mpls", 7200.0, 50_000_000_000, 50),
title_with("00800.mpls", 7200.0, 50_000_000_000, 3),
];
titles.sort_by(|a, b| Disc::canonical_title_order(a, b, CAPACITY));
assert_eq!(titles[0].playlist, "00800.mpls");
assert_eq!(titles[1].playlist, "00050.mpls");
}
#[test]
fn detect_format_uhd() {
let titles = vec![title_with_video(Codec::Hevc, Resolution::R2160p)];
@@ -4468,6 +4593,132 @@ mod tests {
assert_eq!(Disc::detect_format(&titles), DiscFormat::Unknown);
}
/// An AACS MKB Type-and-Version record (0x10) carrying `raw_type` — the only
/// record [`Disc::detect_disc_format`] reads to decide BD/UHD/FMTS.
fn mkb_type_record(raw_type: u32) -> Vec<u8> {
let mut v = vec![0x10, 0x00, 0x00, 0x0c]; // record type 0x10, rec_len 12
v.extend_from_slice(&raw_type.to_be_bytes()); // MKBType @ body offset 0
v.extend_from_slice(&0u32.to_be_bytes()); // version @ body offset 4
v
}
/// FORMAT derives from the AACS MKB generation, not the tree or filesystem:
/// 2.1 → FMTS, 2.0 → UHD, 1.0 → BD — all from the MKB Type record.
#[test]
fn detect_format_from_mkb_generation() {
use crate::udf::fixture::*;
for (raw, expected) in [
(0x4815_1003u32, DiscFormat::Fmts),
(0x4814_1003u32, DiscFormat::Uhd),
(0x0004_1003u32, DiscFormat::BluRay),
] {
let mut disc = MemDisc::new();
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![
DirSpec {
name: "BDMV".into(),
icb_lba: 12,
dir_data_lba: 13,
files: Vec::new(),
subdirs: vec![],
},
DirSpec {
name: "AACS".into(),
icb_lba: 14,
dir_data_lba: 15,
files: vec![file_with(
"MKB_RO.inf",
16,
5000,
mkb_type_record(raw),
true,
)],
subdirs: vec![],
},
],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
assert_eq!(
Disc::detect_disc_format(&mut disc, &udf, &[]),
expected,
"MKB type {raw:#010x}"
);
}
}
/// HD-DVD is a tree-level format — recognized from `HVDVD_TS/`, no MKB.
#[test]
fn detect_format_hddvd_from_tree() {
use crate::udf::fixture::*;
let mut disc = MemDisc::new();
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "HVDVD_TS".into(),
icb_lba: 20,
dir_data_lba: 21,
files: Vec::new(),
subdirs: vec![],
}],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
assert_eq!(
Disc::detect_disc_format(&mut disc, &udf, &[]),
DiscFormat::HdDvd
);
}
/// Title selection is by largest physical size, NOT clip count or duration.
/// Real-disc shape (Fast Five): a 57 GB / 11-clip feature must outrank both a
/// small 1-clip bonus reel and a long-but-tiny decoy "play-all" (91 reused
/// clips, 1h31m, 0.4 GB). The old clip-count-ascending key put the bonus t1.
#[test]
fn canonical_title_order_picks_largest_feature() {
fn title_sized(size_bytes: u64, duration_secs: f64, n_clips: usize) -> DiscTitle {
DiscTitle {
playlist: String::new(),
playlist_id: 0,
duration_secs,
size_bytes,
clips: (0..n_clips)
.map(|i| Clip {
clip_id: format!("{i:05}"),
in_time: 0,
out_time: 0,
duration_secs: 0.0,
source_packets: 0,
})
.collect(),
streams: Vec::new(),
chapters: Vec::new(),
extents: Vec::new(),
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
}
}
let capacity = 66_000_000_000u64;
let feature = title_sized(57_000_000_000, 7860.0, 11); // 2h11m, 11 chapters
let bonus = title_sized(1_200_000_000, 600.0, 1); // 10m, 1 clip
let decoy = title_sized(400_000_000, 5460.0, 91); // 1h31m but tiny (reused)
let mut v = vec![bonus, decoy, feature];
v.sort_by(|a, b| Disc::canonical_title_order(a, b, capacity));
assert_eq!(
v[0].size_bytes, 57_000_000_000,
"the largest real title is the main feature"
);
}
#[test]
fn content_format_default_bdts() {
let t = title_with_video(Codec::H264, Resolution::R1080p);
@@ -4632,6 +4883,25 @@ mod tests {
);
}
#[test]
fn inject_unit_keys_labels_fmts_as_uhd_family() {
// FMTS is AACS 2.1 — a UHD-family, bus-encrypted format. Injecting a UK
// on an FMTS disc must synthesize the UHD version + bus encryption, not
// mislabel it AACS 1.0 / bus-off (which would break FMTS decryption on
// the mapfile-recovered-UK path).
let mut disc = make_test_disc(1000, "FMTS");
disc.format = DiscFormat::Fmts;
disc.encrypted = true;
disc.inject_unit_keys(vec![(0u32, [0x22u8; 16])]);
let aacs = disc.aacs.as_ref().expect("aacs state synthesized");
assert_eq!(
aacs.version,
crate::aacs::mkb::AACS_MAJOR_UHD,
"FMTS is AACS 2.x (UHD major), not BD"
);
assert!(aacs.bus_encryption, "FMTS is bus-encrypted like UHD");
}
/// Build an AacsState carrying the given unit keys (other fields are inert
/// defaults — these tests only exercise the unit-key/decrypt-keys plumbing).
fn aacs_with(unit_keys: Vec<(u32, [u8; 16])>) -> AacsState {
@@ -4700,7 +4970,7 @@ mod tests {
// The resolver classifies a device-keys-but-zero-VID context as
// `VidUnavailable`; that reason rides on `aacs_error`.
let supplied = crate::aacs::provider::SuppliedKey {
device_keys: vec![crate::aacs::DeviceKey {
device_keys: vec![crate::aacs::types::DeviceKey {
key: [0x11; 16],
node: 1,
uv: 1,
@@ -4710,14 +4980,14 @@ mod tests {
media_keys: Vec::new(),
disc_entry: None,
};
let provider_refs: [&dyn crate::aacs::KeyProvider; 1] = [&supplied];
let provider_refs: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied];
// A minimal but parseable Unit_Key_RO.inf (uk_pos=32, zero unit keys)
// so resolution proceeds to the path-try logic and fails for lack of a
// VID — not because the .inf failed to parse.
let mut uk_ro = vec![0u8; 40];
uk_ro[0..4].copy_from_slice(&32u32.to_be_bytes()); // uk_pos = 32
// num_unit_keys = 0 (BE16) at uk_pos -> parses to an empty key file.
let ctx = crate::aacs::ResolveContext {
let ctx = crate::aacs::resolve::ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &[0u8; 16], // the "no VID" sentinel
@@ -4725,8 +4995,8 @@ mod tests {
mkb: None,
};
assert_eq!(
crate::aacs::resolve_keys_with_reason(&ctx, 2).err(),
Some(crate::aacs::ResolveFailure::VidUnavailable),
crate::aacs::resolve::resolve_keys_with_reason(&ctx, 2).err(),
Some(crate::aacs::resolve::ResolveFailure::VidUnavailable),
"device keys + zero VID must classify as VidUnavailable"
);
@@ -4750,8 +5020,8 @@ mod tests {
media_keys: Vec::new(),
disc_entry: None,
};
let provider_refs_none: [&dyn crate::aacs::KeyProvider; 1] = [&supplied_none];
let ctx_none = crate::aacs::ResolveContext {
let provider_refs_none: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied_none];
let ctx_none = crate::aacs::resolve::ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &[0u8; 16],
@@ -4759,8 +5029,8 @@ mod tests {
mkb: None,
};
assert_eq!(
crate::aacs::resolve_keys_with_reason(&ctx_none, 2).err(),
Some(crate::aacs::ResolveFailure::NoMaterial),
crate::aacs::resolve::resolve_keys_with_reason(&ctx_none, 2).err(),
Some(crate::aacs::resolve::ResolveFailure::NoMaterial),
"no key material must classify as NoMaterial"
);
@@ -5030,8 +5300,8 @@ mod tests {
let vuk = [0x5au8; 16];
let enc0 = [0x12u8; 16];
let enc1 = [0x34u8; 16];
let exp0 = crate::aacs::decrypt_unit_key(&vuk, &enc0);
let exp1 = crate::aacs::decrypt_unit_key(&vuk, &enc1);
let exp0 = crate::aacs::derive::decrypt_unit_key(&vuk, &enc0);
let exp1 = crate::aacs::derive::decrypt_unit_key(&vuk, &enc1);
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
@@ -5116,7 +5386,7 @@ mod tests {
#[test]
fn unit_key_validation_gates_on_real_ciphertext() {
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
use crate::aacs::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
// No samples -> nothing to disprove against -> accept (sample-less paths
// like resume / mapfile must be unaffected).
@@ -5174,7 +5444,7 @@ mod tests {
// CPS-unit-1 sectors then passed through as raw encrypted bytes into the
// ISO/MKV with no error surfaced. The gate must now reject a key set
// that leaves any scrambled sample uncovered.
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
use crate::aacs::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
@@ -5220,7 +5490,8 @@ mod tests {
/// unit algorithm — ECB-derive the per-unit key, then AES-CBC encrypt the
/// body with the fixed AACS IV.
fn encrypt_unit_for_test(clear: &[u8], uk: &[u8; 16]) -> Vec<u8> {
use crate::aacs::decrypt::{AACS_IV, ALIGNED_UNIT_LEN};
use crate::aacs::content::ALIGNED_UNIT_LEN;
use crate::aacs::crypto::AACS_IV;
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let mut unit = clear[..ALIGNED_UNIT_LEN].to_vec();
+19 -13
View File
@@ -473,9 +473,7 @@ pub(super) fn recovery_read<R: SectorSource + ?Sized>(
/// gets recorded NonTrimmed. Pure data structure — no I/O — so each phase
/// helper is unit-testable by asserting the residual `SubRanges`.
///
/// Foundation for the phased `recover_section` orchestrator; not yet wired
/// into the live loop (see the deferral note in the #50 work).
#[cfg_attr(not(test), allow(dead_code))]
/// The residue tracker used by the phased `recover_section` orchestrator.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct SubRanges {
/// (pos, len) pairs, sorted by pos, non-overlapping, all non-zero len.
@@ -1461,13 +1459,22 @@ impl Disc {
if !bad.is_empty() {
let n: usize = bad.len();
for (lba, cnt) in bad {
let _ = m.record(
if let Err(e) = m.record(
lba as u64 * 2048,
cnt as u64 * 2048,
mapfile::SectorStatus::NonTrimmed,
) {
tracing::warn!(
lba,
"reverify downgrade: mapfile record failed ({e}) — unit may stay mismarked as good"
);
}
}
if let Err(e) = m.flush() {
tracing::warn!(
"reverify downgrade: mapfile flush failed ({e}) — downgrade not persisted; a resume could mismark it good"
);
}
let _ = m.flush();
// The re-verify ran AFTER `pipe.finish()` snapshotted
// `summary.stats`, so those stats still count the just-
// downgraded units as good. Refresh from the mapfile so
@@ -1547,14 +1554,13 @@ mod tests {
assert!(on("true"));
}
/// Transport failure (status=0xFF, USB-bridge crash) must be recognised by
/// the gate `handle_read_failure` now checks FIRST, so it aborts the pass
/// (wedged_exit + BreakOuter) instead of treating the bridge crash as an
/// ordinary bad sector and hammering the crashed device for up to the
/// per-range watchdog budget. `handle_read_failure` is not unit-testable in
/// isolation, so this guards the classification predicate the production
/// early-return keys off, and the contrast that an ordinary read error is
/// NOT misclassified as a transport failure.
/// Transport failure (status=0xFF, USB-bridge crash) must be recognised and
/// abort the pass, rather than being treated as an ordinary bad sector and
/// hammering the crashed device for up to the per-range watchdog budget. The
/// transport-failure classification predicate is not unit-testable in
/// isolation, so this guards the predicate the production early-return keys
/// off, and the contrast that an ordinary read error is NOT misclassified as
/// a transport failure.
#[test]
fn transport_failure_is_recognised_for_patch_abort() {
use crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE;
+6 -18
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%
+84 -20
View File
@@ -6,8 +6,11 @@
//! decrypt-verify are meaningful is each clip's FILE-anchored 6144-byte unit
//! 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).
//! clip-file units, then decrypts each with the SAME primitive the mux/rip path
//! uses ([`crate::aacs::content::decrypt_unit`] for TS) so verify and the real
//! decrypt can never disagree. That verdict is "did a key OPEN this unit?" —
//! defect-TOLERANT: an opened unit carrying a few authored-bad packets (pressing
//! defects / forensic-variant frames) is NOT bad; only a unit no key opens is.
//!
//! 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 +28,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,10 +61,19 @@ 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,
}
/// PS-container per-unit decrypt for the verify gate — the analogue of
/// [`aacs::content::decrypt_unit`] (TS) for HD-DVD program-stream content. Wraps
/// [`aacs::content::decrypt_unit_checked`] with the pack-start structural check.
/// A free `fn` (not a closure) so it has the same `fn(&mut [u8], &[u8;16]) -> bool`
/// type as `decrypt_unit`, letting [`Verifier::decrypt_for`] return either.
fn decrypt_unit_ps(unit: &mut [u8], key: &[u8; 16]) -> bool {
aacs::content::decrypt_unit_checked(unit, key, aacs::content::unit_is_clean_ps)
}
/// A clip's on-disc layout: declared file size, its absolute disc extents in
/// FILE order, and its stream container. `extents` is `(disc_lba, byte_len)`;
/// the verifier reuses exactly the same `(abs_lba, byte_len)` extents the
@@ -184,11 +197,28 @@ impl UnitVerifier {
})
}
/// The post-decrypt structural check for a clip's container.
/// The post-decrypt structural check for a clip's container — used ONLY on the
/// CPI-clear branch (a plaintext unit that is structurally clean is
/// decryptable-as-is; if not, we return Unknown, never bad).
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,
}
}
/// The per-unit DECRYPT for a clip's container — the key-selection primitive
/// verify uses to prove a candidate key opens an ENCRYPTED unit. It MUST be
/// the exact same primitive the mux/rip decrypt path uses, so verify and the
/// real decrypt never disagree (a unit the mux will happily decrypt must not
/// be marked bad here). For TS that is [`aacs::content::decrypt_unit`], whose
/// verdict is defect-TOLERANT (a supermajority of content packets restored) —
/// an authored-bad packet or forensic-variant frame no longer false-flags the
/// whole unit as undecryptable.
fn decrypt_for(&self, clip: u32) -> fn(&mut [u8], &[u8; 16]) -> bool {
match self.containers[clip as usize] {
ContainerKind::Ts => aacs::content::decrypt_unit,
ContainerKind::Ps => decrypt_unit_ps,
}
}
@@ -214,7 +244,8 @@ impl UnitVerifier {
self.fill(clip, unit, slot, lba, &bytes[off..off + sector]);
if let Some((raw, lbas)) = self.take_if_complete(clip, unit) {
let accept = self.accept_for(clip);
match self.decryptability(&raw, accept) {
let decrypt = self.decrypt_for(clip);
match self.decryptability(&raw, accept, decrypt) {
Decryptability::Undecryptable => push_ranges(&mut bad, &lbas),
Decryptability::Decryptable | Decryptability::Unknown => {}
}
@@ -301,13 +332,14 @@ 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,
raw: &[u8; ALIGNED_UNIT_LEN],
accept: fn(&[u8]) -> bool,
decrypt: fn(&mut [u8], &[u8; 16]) -> bool,
) -> Decryptability {
// CPI clear -> the unit is plaintext by spec (no key needed). If it is
// structurally clean, it is decryptable-as-is. If it ISN'T, we
@@ -318,7 +350,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 {
@@ -326,7 +358,7 @@ impl UnitVerifier {
};
}
// Encrypted: any held key that decrypts to a structurally-clean unit.
if self.try_keys(raw, accept) {
if self.try_keys(raw, decrypt) {
return Decryptability::Decryptable;
}
// No held key works. Ask the application's key source ONCE for this
@@ -349,7 +381,7 @@ impl UnitVerifier {
self.fetch_spent = true; // service has nothing new; stop asking
return Decryptability::Unknown;
}
if self.try_keys(raw, accept) {
if self.try_keys(raw, decrypt) {
return Decryptability::Decryptable;
}
return Decryptability::Undecryptable; // service's keys don't open it -> bad ciphertext
@@ -358,12 +390,18 @@ impl UnitVerifier {
Decryptability::Unknown
}
/// True if any currently-held key decrypts `raw` to a structurally-clean unit
/// under the container's `accept` check.
fn try_keys(&self, raw: &[u8; ALIGNED_UNIT_LEN], accept: fn(&[u8]) -> bool) -> bool {
/// True if any currently-held key OPENS `raw` — i.e. the container's per-unit
/// `decrypt` (the same primitive the mux uses) reports the key restored the
/// unit's TS structure. Defect-tolerant for TS: an opened unit carrying a few
/// authored-bad packets still counts as decryptable (never marked bad).
fn try_keys(
&self,
raw: &[u8; ALIGNED_UNIT_LEN],
decrypt: fn(&mut [u8], &[u8; 16]) -> bool,
) -> bool {
for k in &self.keys {
let mut scratch = *raw;
if aacs::decrypt_unit_checked(&mut scratch, k, accept) {
if decrypt(&mut scratch, k) {
return true;
}
}
@@ -438,9 +476,10 @@ impl UnitVerifier {
}
}
let accept = self.accept_for(clip);
let decrypt = self.decrypt_for(clip);
if readable
&& matches!(
self.decryptability(&raw, accept),
self.decryptability(&raw, accept, decrypt),
Decryptability::Undecryptable
)
{
@@ -523,13 +562,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 {
@@ -897,6 +936,31 @@ mod tests {
);
}
#[test]
fn reverify_iso_defect_packet_unit_is_not_flagged_bad() {
// A unit with ONE authored-bad content packet (a pressing defect). The
// held key OPENS it (31/32 syncs restored), so verify must AGREE with the
// mux decrypt and NOT mark it bad — the bad packet is the muxer's problem,
// not a read/decrypt failure. (Old strict all-32 gate flagged it bad.)
let key = [0x5a; 16];
let mut u = clear_unit();
let off = 17 * 192; // corrupt packet 17's sync in the plaintext
u[off + 4] = 0x80;
u[off + 5] = 0xAB;
encrypt_unit(&mut u, &key);
let mut iso = MockIso {
sectors: Default::default(),
err_lba: None,
};
place_unit(&mut iso, [100, 101, 102], &u);
let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap();
let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)], &|_| true);
assert!(
bad.is_empty(),
"defect-packet unit is opened by the held key -> not flagged bad"
);
}
#[test]
fn reverify_iso_fragmented_unit_reads_distant_sectors() {
let key = [0x5a; 16];
+7 -8
View File
@@ -433,15 +433,14 @@ impl Drive {
// 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: Result<()> = match crate::unlock_bridge::run_unlockers(
self.scsi.as_mut(),
&self.drive_id,
freemkv_unlock::DiscKind::Unknown,
&[],
) {
let (matched, unlock_res) =
crate::unlock_bridge::run_features(self.scsi.as_mut(), &self.drive_id);
let r: Result<()> = match unlock_res {
Ok(unlocked) => {
self.unlocker_name =
crate::unlock_bridge::unlocker_name(&self.drive_id).map(str::to_string);
// 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()`). A
// drive-prep unlocker always carries a VID; guard anyway.
+1 -1
View File
@@ -6,7 +6,7 @@
//!
//! Byte layout follows the DVD-Video specification (VMGI/VTSI headers,
//! PGC/cell tables, PCI/HLI button packets); the VM command decoder is
//! verified against libdvdnav's decoder.
//! verified against real discs.
//!
//! Current contents: [`vmcmd`] — the VM command decoder (proven against the
//! SOTL/Greenland test discs). The IFO/PCI parsing and the navigation executor
+5 -5
View File
@@ -2,7 +2,7 @@
//!
//! An 8-byte navigation command as found in PGC command tables (pre/post/cell)
//! and PCI button info. Decoded per the DVD-Video VM instruction set and
//! verified against libdvdnav's command decoder.
//! verified against real discs.
//!
//! Bit model: the 8 bytes are a big-endian 64-bit word. `byte0` bits 7-5 are the
//! command **type**; for type 1, `byte0` bit 4 selects Link (0) vs Jump (1), and
@@ -133,7 +133,7 @@ const JP_JUMP_SS: u8 = 6;
const JP_CALL_SS: u8 = 8;
// Link (type 1, direct=0) sub-commands. NOTE: sub-op 0 is NOP/no-link and 1 is
// the LinkSub form (libdvdnav `decoder.c` `eval_link_instruction`).
// the LinkSub form (the DVD-Video VM link instruction).
const LK_SUB: u8 = 1;
const LK_PGCN: u8 = 4;
const LK_PTTN: u8 = 5;
@@ -159,7 +159,7 @@ fn be16(b: &[u8; 8], o: usize) -> u16 {
((b[o] as u16) << 8) | b[o + 1] as u16
}
// Compare-operand layouts ("if_version"s) per libdvdnav `decoder.c`. The op
// Compare-operand layouts ("if_version"s) per the DVD-Video VM. The op
// nibble is always `byte1` bits 6-4; the immediate flag is `byte1` bit 7. The
// operand *offsets* differ by command family.
//
@@ -205,7 +205,7 @@ pub fn decode(b: &[u8; 8]) -> Command {
let cmd = b[1] & 0x0F;
// Compare predicate, with the operand layout for this command family
// (libdvdnav `decoder.c` `vm_eval_command` type dispatch).
// (the DVD-Video VM command type dispatch).
let compare = match (typ, direct) {
(TYPE_SPECIAL, _) => if_v1(b),
(TYPE_LINK_JUMP, 1) => if_v2(b), // jump
@@ -372,7 +372,7 @@ mod tests {
}
}
// Regression for the libdvdnav cross-check: link sub-op 0 = NOP, 1 = LinkSub.
// Regression for the link sub-op decode: 0 = NOP, 1 = LinkSub.
#[test]
fn link_subop_zero_is_nop_one_is_linksub() {
assert_eq!(decode(&h("2000000000000000")).instr, Instr::Nop);
+12
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,
@@ -1209,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,
+8 -8
View File
@@ -58,7 +58,7 @@ pub struct DvdTitle {
pub struct DvdCell {
pub first_sector: u32,
pub last_sector: u32,
/// Raw cell-category byte at `cell_playback + 0` (libdvdread layout).
/// Raw cell-category byte at `cell_playback + 0` (DVD-Video IFO layout).
/// Packs block_mode (bits 7-6), block_type (bits 5-4), seamless_play
/// (bit 3), interleaved (bit 2), stc_discontinuity (bit 1),
/// seamless_angle (bit 0). Carried so the extent builder can recognise
@@ -72,7 +72,7 @@ pub struct DvdCell {
}
/// Decoded view of a cell-category byte (`cell_playback + 0`), per the
/// DVD-Video spec / libdvdread `cell_playback_t` layout. Byte-0 bitfields,
/// DVD-Video IFO cell-playback layout. Byte-0 bitfields,
/// MSB-first: `block_mode`(7-6), `block_type`(5-4), `seamless_play`(3),
/// `interleaved`(2), `stc_discontinuity`(1), `seamless_angle`(0). (The real
/// `cell_type` is a karaoke-only field in byte 1, not used here.)
@@ -93,7 +93,7 @@ pub struct CellCategory {
}
impl CellCategory {
/// Decode the raw `cell_playback + 0` byte (libdvdread `read_cell_playback`).
/// Decode the raw `cell_playback + 0` byte (DVD-Video IFO cell playback).
pub fn decode(raw: u8) -> Self {
CellCategory {
block_mode: (raw >> 6) & 0x03,
@@ -423,7 +423,7 @@ fn parse_vts(
}
// VTSI_MAT (VTS_xx_0.IFO header) field offsets — fixed by the DVD-Video
// spec (libdvdread `vtsi_mat_t`). The offsets are constant; the sector
// spec (the VTSI management table). The offsets are constant; the sector
// values they point to are per-disc.
const VTSTT_VOBS_OFFSET: usize = 0xC4; // VTS title VOBS start sector (feature)
const VTS_PGCIT_OFFSET: usize = 0xCC; // VTS_PGCIT sector pointer
@@ -580,7 +580,7 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
_ => Codec::Unknown(coding_mode),
};
let sample_rate_flag = (b1 >> 4) & 0x03; // sample_frequency: byte 1 bits 5-4 (libdvdread audio_attr_t)
let sample_rate_flag = (b1 >> 4) & 0x03; // sample_frequency: byte 1 bits 5-4 (DVD-Video audio attributes)
let sample_rate = match sample_rate_flag {
0 => 48000,
1 => 96000,
@@ -1071,7 +1071,7 @@ mod tests {
/// reads with — a co-edit of constant + helper would silently re-introduce
/// the PAL-as-NTSC bug and every test would still pass. This test feeds
/// `parse_video_attr` HARDCODED bytes captured from real DVD-Video layouts
/// (DVD spec / libdvdread `video_attr_t`: mpeg_version[7-6] video_format[5-4]
/// (DVD-Video video attributes: mpeg_version[7-6] video_format[5-4]
/// display_aspect[3-2] permitted_df[1-0]) — no `v_atr_byte`. If the parser's
/// bit positions drift, these fail.
#[test]
@@ -1238,7 +1238,7 @@ mod tests {
// ─────────────────────────────────────────────────────────────────────
// Added hardening tests. Grounded in the DVD-Video IFO spec
// (dvd_udf / libdvdread ifo_types.h; http://dvd.sourceforge.net).
// (DVD-Video IFO format; http://dvd.sourceforge.net).
// ─────────────────────────────────────────────────────────────────────
/// BCD frame-rate flag: bits 7-6 of byte[3]. 0b01 = 25fps (PAL),
@@ -1593,7 +1593,7 @@ mod tests {
}
}
/// CellCategory decodes the libdvdread byte-0 bitfields: block_mode (7-6),
/// CellCategory decodes the DVD-Video cell-category byte-0 bitfields: block_mode (7-6),
/// block_type (5-4), seamless_play (3), interleaved (2),
/// stc_discontinuity (1), seamless_angle (0).
#[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)
+133 -39
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;
@@ -74,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.
@@ -113,7 +115,8 @@ impl<'a> DiscInputsCtx<'a> {
/// title keys — the parse failure is swallowed here, not surfaced as an
/// error.
pub fn new(inputs: &'a DiscInputs) -> Self {
use crate::aacs::{AacsVersion, parse_unit_key_ro};
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 {
@@ -164,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
@@ -209,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
@@ -219,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.
@@ -228,10 +231,10 @@ 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();
// 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.
@@ -341,18 +344,24 @@ 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 — 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.
/// "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 (`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
// Probe several evenly-spaced points across EACH extent rather than only the
// midpoint-and-forward: a title whose encrypted feature starts late, or whose
@@ -398,7 +407,7 @@ 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;
@@ -413,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 ────────────────────────────────────
@@ -454,7 +463,7 @@ mod tests {
let inputs = DiscInputs {
disc_hash: "0xABC".into(),
volume_id: [0u8; 16],
version: crate::aacs::AACS_MAJOR_BD,
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]],
@@ -510,7 +519,7 @@ mod tests {
let inputs = DiscInputs {
disc_hash: "0x00".into(),
volume_id: [0u8; 16],
version: crate::aacs::AACS_MAJOR_UHD,
version: crate::aacs::mkb::AACS_MAJOR_UHD,
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
@@ -531,7 +540,7 @@ mod tests {
DiscInputs {
disc_hash: String::new(),
volume_id: [0u8; 16],
version: crate::aacs::AACS_MAJOR_UHD,
version: crate::aacs::mkb::AACS_MAJOR_UHD,
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
@@ -554,10 +563,7 @@ mod tests {
struct HasKey([u8; 16]);
impl KeySource for HasKey {
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(vec![UnitKey {
idx: 0,
key: self.0,
}])
Ok(vec![UnitKey::new(0, self.0)])
}
}
@@ -603,10 +609,7 @@ mod tests {
if let Ok(s) = ctx.samples(8) {
self.seen.lock().unwrap().extend(s);
}
Ok(vec![UnitKey {
idx: 0,
key: self.key,
}])
Ok(vec![UnitKey::new(0, self.key)])
}
}
@@ -621,7 +624,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,
@@ -643,7 +646,7 @@ mod tests {
/// finds the early scrambled band.
#[test]
fn read_encrypted_units_finds_scrambled_content_off_the_midpoint() {
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};
use crate::error::Result;
use crate::sector::SectorSource;
@@ -713,7 +716,98 @@ mod tests {
"the probe-spread must sample the early scrambled band the midpoint misses"
);
for s in &samples {
assert!(ts_sync_destroyed(s), "every sample is a scrambled unit");
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"
);
}
}
@@ -725,7 +819,7 @@ mod tests {
/// prior single-key fixtures passed regardless of stride.
#[test]
fn disc_inputs_ctx_parses_unit_keys_at_the_version_stride() {
use crate::aacs::{AACS_MAJOR_BD, AACS_MAJOR_UHD};
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
+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`].
+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"
);
}
}
+46 -4
View File
@@ -37,7 +37,7 @@ 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
@@ -62,7 +62,7 @@ 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,
}
@@ -194,7 +194,7 @@ impl CodecParser for DtsParser {
// 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
.last()
.back()
.map(|&(_, p)| p)
.filter(|&p| p >= 0)
.unwrap_or(if self.pending_pts >= 0 {
@@ -230,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();
@@ -915,6 +918,37 @@ mod tests {
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
@@ -922,6 +956,14 @@ mod tests {
// 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);
+214 -6
View File
@@ -52,6 +52,17 @@ 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>,
/// MVC dependent-view (Blu-ray 3D right-eye) passthrough mode. When set, the
/// parser does NOT strip SPS/PPS (nor re-assert at keyframes): every NAL —
/// subset SPS (type 15), prefix (14), coded-slice-extension (20), PPS (8) —
/// is length-prefixed in-band, so each emitted frame is a self-contained
/// dependent access unit suitable for a Matroska `BlockAdditional`. The base
/// view's avcC/param-set stripping is unchanged (separate parser instance).
mvc_passthrough: bool,
}
impl Default for H264Parser {
@@ -68,6 +79,35 @@ impl H264Parser {
pps: None,
cur_sps: None,
cur_pps: None,
reorder: None,
mvc_passthrough: false,
}
}
/// 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
}
/// Enable MVC dependent-view passthrough (see the `mvc_passthrough` field):
/// keep every parameter set in-band so each frame is a self-contained
/// dependent access unit for a Matroska `BlockAdditional`. Used only for the
/// Blu-ray 3D dependent (right-eye) stream.
pub(crate) fn with_mvc_passthrough(mut self, enabled: bool) -> Self {
self.mvc_passthrough = enabled;
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 +192,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
@@ -168,17 +209,23 @@ impl CodecParser for H264Parser {
// frame in the mux hot path (mirrors the HEVC parser).
let mut frame_data = Vec::with_capacity(pes.data.len() + 64);
// MVC dependent-view passthrough: keep ALL param sets in-band (the frame
// is a self-contained BlockAdditional access unit), never strip/re-assert.
let mvc = self.mvc_passthrough;
for nal in NalIterator::new(&pes.data) {
let nal_type = nal[0] & 0x1F;
match nal_type {
// Param sets: seed avcC, strip if unchanged vs the active set,
// emit in-band on any change (incl. reverting to the avcC copy).
NAL_SPS => {
// In MVC passthrough these fall through to the default arm so the
// subset SPS / PPS stay in-band (self-contained dependent AU).
NAL_SPS if !mvc => {
emitted_sps |=
handle_param_set(&mut self.sps, &mut self.cur_sps, nal, &mut frame_data)
}
NAL_PPS => {
NAL_PPS if !mvc => {
emitted_pps |=
handle_param_set(&mut self.pps, &mut self.cur_pps, nal, &mut frame_data)
}
@@ -226,7 +273,7 @@ impl CodecParser for H264Parser {
// ahead of the slices (even when unchanged vs codecPrivate) so a decoder
// that dropped the set at a reset recovers, and a stale avcC re-apply
// can't revert it. Skipped per-type only when this AU already carried it.
if keyframe {
if keyframe && !mvc {
let mut prefix = Vec::new();
reassert_active(&mut prefix, &self.cur_sps, emitted_sps);
reassert_active(&mut prefix, &self.cur_pps, emitted_pps);
@@ -236,7 +283,7 @@ 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),
@@ -248,7 +295,15 @@ impl CodecParser for H264Parser {
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>> {
@@ -619,6 +674,94 @@ mod tests {
v
}
#[test]
fn mvc_passthrough_keeps_param_sets_inband() {
// A dependent-view access unit: subset SPS (NAL 15) + PPS (NAL 8) +
// coded-slice-extension (NAL 20). No IDR (type 5), so keyframe stays
// false and there is no keyframe re-assertion.
let au = || {
let mut d = Vec::new();
d.extend_from_slice(&h264_nal(0x6F, &[0x80, 0x00, 0x33, 0xAA])); // subset SPS (15)
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
d.extend_from_slice(&h264_nal(0x74, &[0x11, 0x22])); // slice-ext (20)
d
};
let nal_types =
|f: &Frame| -> Vec<u8> { h264_nals_in(&f.data).iter().map(|n| n[0] & 0x1F).collect() };
// Normal parser strips the PPS from a non-keyframe AU (it is captured for
// the avcC and, without an IDR, never re-asserted in-band).
let mut normal = H264Parser::new();
let f = normal.parse(&make_pes(au(), Some(90000)));
assert_eq!(f.len(), 1);
assert!(
!nal_types(&f[0]).contains(&8),
"normal parser strips PPS from a non-keyframe AU: {:?}",
nal_types(&f[0])
);
// Passthrough keeps EVERY parameter set in-band, so each dependent frame
// is a self-contained access unit for a BlockAdditional.
let mut pt = H264Parser::new().with_mvc_passthrough(true);
let f = pt.parse(&make_pes(au(), Some(90000)));
assert_eq!(f.len(), 1);
let types = nal_types(&f[0]);
assert!(types.contains(&15), "subset SPS kept in-band: {types:?}");
assert!(
types.contains(&8),
"PPS kept in-band under passthrough: {types:?}"
);
assert!(types.contains(&20), "slice kept: {types:?}");
}
#[test]
fn parser_for_mvc_dependent_h264_is_passthrough() {
// The dependent-view stream must get a passthrough parser: a PPS in a
// non-keyframe AU is kept in-band, not stripped like the base parser.
let mut p = crate::mux::codec::parser_for_mvc_dependent(crate::disc::Codec::H264, false);
let mut d = Vec::new();
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
d.extend_from_slice(&h264_nal(0x74, &[0x11, 0x22])); // slice-ext (20)
let f = p.parse(&make_pes(d, Some(90000)));
assert_eq!(f.len(), 1);
let types: Vec<u8> = h264_nals_in(&f[0].data)
.iter()
.map(|n| n[0] & 0x1F)
.collect();
assert!(
types.contains(&8),
"dependent parser keeps PPS in-band (passthrough): {types:?}"
);
}
#[test]
fn mvc_passthrough_with_idr_does_not_reassert_param_sets() {
// With an IDR present (keyframe=true), passthrough must NOT re-assert the
// param sets (the `keyframe && !mvc` guard), so SPS/PPS appear exactly
// once — a duplicate would corrupt the dependent BlockAdditional.
let mut p = H264Parser::new().with_mvc_passthrough(true);
let mut d = Vec::new();
d.extend_from_slice(&h264_nal(0x67, &[0x42, 0x00, 0x1E, 0x01])); // SPS (7)
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
d.extend_from_slice(&h264_nal(0x65, &[0x88, 0x00])); // IDR slice (5)
let f = p.parse(&make_pes(d, Some(90000)));
assert_eq!(f.len(), 1);
let types: Vec<u8> = h264_nals_in(&f[0].data)
.iter()
.map(|n| n[0] & 0x1F)
.collect();
assert_eq!(
types.iter().filter(|&&t| t == 7).count(),
1,
"exactly one SPS, no keyframe re-assert under passthrough: {types:?}"
);
assert_eq!(
types.iter().filter(|&&t| t == 8).count(),
1,
"exactly one PPS, no keyframe re-assert under passthrough: {types:?}"
);
}
#[test]
fn h264_populates_measured_coding_type_and_source() {
use super::super::coding::CodingType;
@@ -667,6 +810,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
+35 -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.
@@ -666,7 +690,15 @@ impl CodecParser for HevcParser {
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>> {
+25 -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.
@@ -166,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()),
@@ -201,6 +207,22 @@ pub fn parser_for_codec(
}
}
/// Build the codec parser for a Blu-ray 3D **MVC dependent (right-eye)** video
/// stream. Same codec space as the base view (H.264), but in param-set
/// passthrough mode so each emitted frame is a self-contained dependent access
/// unit for a Matroska `BlockAdditional`. Non-H.264 (unexpected) falls back to
/// the ordinary parser.
pub fn parser_for_mvc_dependent(codec: Codec, is_dvd_ps: bool) -> Box<dyn CodecParser> {
match codec {
Codec::H264 => Box::new(
h264::H264Parser::new()
.with_ps_reorder(is_dvd_ps)
.with_mvc_passthrough(true),
),
_ => parser_for_codec(codec, None, is_dvd_ps),
}
}
#[cfg(test)]
mod tests {
use super::*;
+132 -267
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,
@@ -129,15 +121,6 @@ pub struct Mpeg2Parser {
/// each GOP's first PES PTS so video stays in sync with the PES-timestamped
/// audio. None until the first PES timestamp is seen.
origin_pts_ns: Option<i64>,
/// B1: absolute ES offsets at which a concealed/lost-gap PES began, parallel
/// to `pts_marks`/`source_marks` and drained by the SAME mark-drain invariant.
/// MPEG-2 emits whole GOPs asynchronously, so a per-PES flag can't ride
/// through to the right frame (the PES that carries the gap completes the
/// PREVIOUS picture); associating by OFFSET instead stamps `discontinuity` on
/// the access unit whose own bytes begin after the gap — the first post-gap
/// picture — surviving GOP buffering + temporal reorder. The consumer's
/// ResyncGate then arms at that exact picture, mid-GOP if need be.
disc_marks: VecDeque<u64>,
}
/// One coded picture buffered awaiting its GOP's completion (see `gop_buf`).
@@ -165,16 +148,13 @@ 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,
disc_marks: VecDeque::new(),
}
}
@@ -199,195 +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;
}
}
while let Some(&off) = self.disc_marks.front() {
if off < cutoff {
self.disc_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);
}
// A concealed-gap mark inside this AU's range [start, end_abs) means
// this picture's own bytes begin after the gap — the first post-gap
// AU. Same front-mark invariant as PTS/source. Carries through GOP
// buffering/reorder to the ResyncGate (which arms at this picture).
let discontinuity = self.disc_marks.front().is_some_and(|&off| off < end_abs);
self.gop_buf.push(BufferedPicture {
tr,
info,
explicit_pts: explicit,
frame: Frame {
pts_ns: 0,
keyframe,
discontinuity,
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;
}
}
while let Some(&off) = self.disc_marks.front() {
if off < end_abs {
self.disc_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
@@ -403,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
@@ -448,31 +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));
}
// A concealed/lost gap on this PES marks the access unit its bytes begin —
// associated by offset (like PTS/source) so it lands on the first post-gap
// picture, not the previous one that completes when this PES arrives.
if pes.discontinuity {
self.disc_marks.push_back(off);
}
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>> {
@@ -518,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)> {
@@ -1546,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);
}
+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");
}
}
+35 -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,7 +345,7 @@ 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),
@@ -333,7 +357,15 @@ impl CodecParser for Vc1Parser {
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>> {
+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]
+9 -5
View File
@@ -1520,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
+69
View File
@@ -94,6 +94,31 @@ pub fn write_uint(w: &mut impl Write, id: u32, val: u64) -> io::Result<()> {
}
}
/// Write a complete EBML signed-integer element (two's-complement, big-endian,
/// minimal width). Used for `ReferenceBlock` (0xFB), whose value is a signed
/// tick offset relative to the current block's timestamp.
pub fn write_int(w: &mut impl Write, id: u32, val: i64) -> io::Result<()> {
write_id(w, id)?;
// Minimal two's-complement width: shrink while the top byte is pure sign
// extension of the next byte's MSB.
let be = val.to_be_bytes();
let mut start = 0usize;
while start < 7 {
let sign_ext = if be[start + 1] & 0x80 != 0 {
0xFF
} else {
0x00
};
if be[start] != sign_ext {
break;
}
start += 1;
}
let bytes = &be[start..];
write_size(w, bytes.len() as u64)?;
w.write_all(bytes)
}
/// Write a complete EBML float element (8-byte double).
pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> {
write_id(w, id)?;
@@ -481,6 +506,25 @@ pub const LUMINANCE_MIN: u32 = 0x55DA;
pub const BLOCK_ADDITION_MAPPING: u32 = 0x41E4;
pub const BLOCK_ADD_ID_TYPE: u32 = 0x41E7;
pub const BLOCK_ADD_ID_EXTRA_DATA: u32 = 0x41ED;
/// BlockAddIDValue (RFC 9559) — the value a per-frame `BlockAddID` references
/// to select this BlockAdditionMapping. Values ≥ 2 (1 is the default plain
/// BlockAdditional). Used by the MVC (`mvcC`) mapping for Blu-ray 3D.
pub const BLOCK_ADD_ID_VALUE: u32 = 0x41F0;
// Block additions carried inside a BlockGroup — per-frame side data. For
// Blu-ray 3D (MVC) the dependent (right-eye) view NAL units for an access unit
// ride here as a BlockAdditional under the track's `mvcC` mapping (RFC 9559
// §5.1.4.1.4; Matroska Codec Specifications §4.1.5).
pub const BLOCK_ADDITIONS: u32 = 0x75A1;
pub const BLOCK_MORE: u32 = 0xA6;
pub const BLOCK_ADDITIONAL: u32 = 0xA5;
pub const BLOCK_ADD_ID: u32 = 0xEE;
/// ReferenceBlock (RFC 9559 element 0xFB, child of BlockGroup) — signed
/// timestamp (in TimestampScale ticks) of a block this one references, relative
/// to this block's own timestamp. Its PRESENCE marks the Block as non-keyframe
/// (a keyframe Block in a BlockGroup carries none). Written for non-keyframe
/// video frames that must live in a BlockGroup to carry an MVC BlockAdditional.
pub const REFERENCE_BLOCK: u32 = 0xFB;
// Audio
pub const AUDIO: u32 = 0xE1;
@@ -960,6 +1004,31 @@ mod tests {
assert_eq!(buf, [0x42, 0x86, 0x81, 0x00]);
}
#[test]
fn write_int_minimal_two_complement_width() {
// ReferenceBlock (0xFB) signed offsets, minimal two's-complement width.
let enc = |v: i64| {
let mut b = Vec::new();
write_int(&mut b, REFERENCE_BLOCK, v).unwrap();
b
};
assert_eq!(enc(0), [0xFB, 0x81, 0x00], "0 -> 1 byte 0x00");
assert_eq!(enc(-1), [0xFB, 0x81, 0xFF], "-1 -> 1 byte 0xFF");
assert_eq!(enc(127), [0xFB, 0x81, 0x7F], "127 -> 1 byte");
assert_eq!(
enc(128),
[0xFB, 0x82, 0x00, 0x80],
"128 needs 2 bytes (0x80 alone is -128)"
);
assert_eq!(enc(-128), [0xFB, 0x81, 0x80], "-128 -> 1 byte 0x80");
assert_eq!(enc(-129), [0xFB, 0x82, 0xFF, 0x7F], "-129 needs 2 bytes");
// i64::MIN is the widest: 8 bytes, size 0x88.
let mn = enc(i64::MIN);
assert_eq!(mn[0], 0xFB);
assert_eq!(mn[1], 0x88);
assert_eq!(&mn[2..], &i64::MIN.to_be_bytes());
}
// ============================================================
// write_float — EBML floats here are always 8-byte IEEE-754 doubles,
// big-endian (Matroska SamplingFrequency/Duration). size byte = 0x88.
+609 -30
View File
@@ -68,6 +68,82 @@ const COLOUR_RANGE_LIMITED: u8 = 1;
/// Vision configuration record (RFC 9559 + Dolby Vision-in-Matroska spec).
const BLOCK_ADD_ID_TYPE_DVCC: u64 = 0x6476_6343;
/// BlockAddIDType "mvcC" — the MVCDecoderConfigurationRecord fourcc, big-endian
/// ASCII 'm''v''c''C'. Matroska BlockAdditionMapping/BlockAddIDType for a
/// Blu-ray 3D MVC configuration (RFC 9559 + Matroska Codec Specifications
/// §4.1.5; equals ISO/IEC 14496-15 MVCConfigurationBox('mvcC')). The per-frame
/// dependent (right-eye) view rides as a BlockAdditional under this mapping.
const BLOCK_ADD_ID_TYPE_MVCC: u64 = 0x6D76_6343;
/// BlockAddIDValue for the MVC mapping — the value each per-frame `BlockAddID`
/// references (RFC 9559 requires ≥ 2; 1 is the default plain BlockAdditional).
const BLOCK_ADD_ID_VALUE_MVC: u64 = 2;
/// Build an `MVCDecoderConfigurationRecord` (ISO/IEC 14496-15:2013 §7.6.2) from
/// the dependent view's subset-SPS (NAL type 15) and PPS NAL units. This is the
/// `BlockAddIDExtraData` for the `mvcC` BlockAdditionMapping (and, in the ISO
/// container, the `MVCConfigurationBox('mvcC')` payload).
///
/// Layout mirrors the AVCDecoderConfigurationRecord except byte[4] repurposes
/// the AVC record's `bit(6) reserved` as
/// `complete_representation(1) | explicit_au_track(1) | reserved '1111'(4)`.
/// `profile`/`compat`/`level` describe the WHOLE MVC stream and come from the
/// subset SPS (bytes 1..=3). `length_size_minus_one` MUST match the base avcC
/// (freemkv always emits 4-byte length prefixes → 3). SPSs precede subset SPSs
/// in the array; here the array carries the dependent view's parameter sets.
///
/// Returns `None` if either param set is absent, too short, or exceeds the
/// 16-bit length field (a param set > 65535 bytes is non-conforming and would
/// mis-frame the record).
fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option<Vec<u8>> {
if subset_sps.len() < 4 || subset_sps.len() > 0xFFFF || pps.is_empty() || pps.len() > 0xFFFF {
return None;
}
let mut record = vec![
1, // configurationVersion
subset_sps[1], // AVCProfileIndication (whole MVC stream, from subset SPS)
subset_sps[2], // profile_compatibility
subset_sps[3], // AVCLevelIndication
// complete_representation(1)=1 | explicit_au_track(1)=0 |
// reserved '1111'(4) | lengthSizeMinusOne(2)=3(11) → 1_0_1111_11 = 0xBF.
0xBF,
// reserved '0'(1) | numOfSequenceParameterSets(7)=1 → 0_0000001 = 0x01.
// NB: distinct from the AVC record's byte[5] (reserved(3)|numSPS(5)).
0x01,
(subset_sps.len() >> 8) as u8,
subset_sps.len() as u8,
];
record.extend_from_slice(subset_sps);
record.push(1); // numOfPictureParameterSets
record.push((pps.len() >> 8) as u8);
record.push(pps.len() as u8);
record.extend_from_slice(pps);
Some(record)
}
/// Build the `CodecPrivate` for an MVC (Blu-ray 3D) base track: the base view's
/// `AVCDecoderConfigurationRecord` (`avcc`) followed by an `mvcC` extension
/// block, per the Matroska Codec Specifications §4.3.9:
///
/// ```text
/// avcC ‖ u32be(extension_block_size − 4) ‖ "mvcC" ‖ MVCDecoderConfigurationRecord
/// ```
///
/// The size field is the extension block length **excluding the 4-byte size
/// field itself** — i.e. `4 ("mvcC") + record.len()`. This is the track-level
/// MVC signal that decoders and mediainfo read (the per-frame `BlockAdditional`
/// under the `mvcC` BlockAdditionMapping carries the dependent view's data). A
/// plain (2D) track never calls this — it writes its `avcc` verbatim.
fn mvc_codec_private(avcc: &[u8], record: &[u8]) -> Vec<u8> {
let ext_size = (4 + record.len()) as u32; // "mvcC" (4) + record; = block size − 4
let mut out = Vec::with_capacity(avcc.len() + 8 + record.len());
out.extend_from_slice(avcc);
out.extend_from_slice(&ext_size.to_be_bytes());
out.extend_from_slice(b"mvcC");
out.extend_from_slice(record);
out
}
/// Resolve a video stream's CICP colour code points — `(matrix, transfer,
/// primaries, range)`, ITU-T H.273 — using a single precedence so EVERY sink
/// (the MKV muxer here AND the FVI sidecar in `videomap.rs`) agrees and can
@@ -176,6 +252,16 @@ pub struct MkvTrack {
/// stream is parsed. When `Some`, the serializer emits MasteringMetadata +
/// MaxCLL/MaxFALL inside Colour; when `None` they are omitted entirely.
pub hdr10: Option<crate::mux::codec::Hdr10Metadata>,
/// Blu-ray 3D (MVC): the dependent (right-eye) view's `(subset_sps, pps)`
/// NAL units, from which the serializer builds the `mvcC`
/// MVCDecoderConfigurationRecord (ISO/IEC 14496-15 §7.6.2) for the track's
/// BlockAdditionMapping. `None` for non-3D tracks. Set at muxer activation
/// from the dependent stream's parameter sets — the same deferred path
/// `hdr10`/FieldOrder use — never at construction. When `Some`, the per-frame
/// dependent view rides as a `BlockAdditional`. (No `StereoMode` is written:
/// RFC 9559 assigns no StereoMode value to MVC-in-BlockAdditional; the mvcC
/// mapping is the 3D signal.)
pub mvc_params: Option<(Vec<u8>, Vec<u8>)>,
}
/// Build a DOVIDecoderConfigurationRecord (dvcC) — 24 bytes — for the Matroska
@@ -380,6 +466,7 @@ impl MkvTrack {
// coded picture's PictureInfo before the header is written (the same
// deferred path FieldOrder uses). `None` here → omitted unless seen.
hdr10: None,
mvc_params: None,
}
}
@@ -448,6 +535,7 @@ impl MkvTrack {
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
}
}
@@ -485,6 +573,7 @@ impl MkvTrack {
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
}
}
}
@@ -530,6 +619,12 @@ pub struct MkvMuxer<W: Write + Seek> {
/// the primary video. `None` when the title has no video track (no track
/// drives epochs).
primary_video_track: Option<usize>,
/// Per-track: whether the track declared an `mvcC` BlockAdditionMapping (its
/// `mvc_params` was set at activation). A `BlockAdditional` with BlockAddID=2
/// is only conforming when the track carries the matching mapping, so
/// `write_frame`'s additional is dropped for a track without it (e.g. the
/// dependent view's parameter sets were never captured before activation).
track_has_mvc_mapping: Vec<bool>,
/// Cross-clip timeline-continuity corrector (clip-boundary PTS rebasing).
continuity: TimelineContinuity,
cues: Vec<CuePoint>,
@@ -557,6 +652,19 @@ 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,
/// Timestamp (TimestampScale ticks) of the last video keyframe written on the
/// primary video track. A non-keyframe MVC base frame lives in a BlockGroup
/// (to carry its dependent-view BlockAdditional) and needs a `ReferenceBlock`
/// so players don't mistake it for a keyframe; it references this keyframe.
last_video_keyframe_ticks: Option<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 +874,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)?;
@@ -787,9 +905,23 @@ impl<W: Write + Seek> MkvMuxer<W> {
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
std::collections::HashMap::new();
// Per track: whether it emitted a conforming `mvcC` BlockAdditionMapping.
// Filled below from the SAME built record that drives the CodecPrivate
// mvcC extension, so the three MVC signals never diverge.
let mut track_has_mvc_mapping: Vec<bool> = Vec::with_capacity(tracks.len());
for (i, track) in tracks.iter().enumerate() {
let track_uid = (i + 1) as u64 | 0x100_0000;
track_uids.push(track_uid);
// Build the MVC (Blu-ray 3D) MVCDecoderConfigurationRecord ONCE per
// track from the dependent view's subset-SPS/PPS. `None` for every
// non-3D track (and if the params are malformed) — the single source
// of truth for the CodecPrivate mvcC extension, the
// BlockAdditionMapping, and whether BlockAdditionals are conforming.
let mvc_record = track
.mvc_params
.as_ref()
.and_then(|(sps, pps)| mvc_decoder_config_record(sps, pps));
track_has_mvc_mapping.push(mvc_record.is_some());
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
ebml::write_uint(&mut writer, ebml::TRACK_UID, track_uid)?;
@@ -809,7 +941,20 @@ impl<W: Write + Seek> MkvMuxer<W> {
}
if let Some(ref cp) = track.codec_private {
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?;
match mvc_record.as_ref() {
// MVC (Blu-ray 3D) base track: CodecPrivate = base-view avcC
// followed by the `mvcC` extension block. This is the
// track-level signal decoders/mediainfo read to recognise the
// stereoscopic MVC track (the per-frame dependent view rides
// in BlockAdditional under the mapping below).
Some(record) => {
let cp_mvc = mvc_codec_private(cp, record);
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, &cp_mvc)?;
}
// Non-MVC (2D/UHD/audio/…): write the codec_private verbatim —
// the unchanged path, byte-identical to a 2D mux.
None => ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?,
}
}
// Pre-0.13 a deferred codecPrivate path existed for video tracks
// (placeholder reserve + later seek-back fill via
@@ -905,6 +1050,40 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::end_master(&mut writer, vid_pos)?;
}
// Blu-ray 3D (MVC) signaling — BlockAdditionMapping (sibling of
// Video) carries the mvcC MVCDecoderConfigurationRecord so players /
// mediainfo recognise the dependent (right-eye) view that rides as a
// per-frame BlockAdditional under this mapping (BlockAddIDValue = 2).
match mvc_record.as_ref() {
Some(record) => {
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
ebml::write_uint(
&mut writer,
ebml::BLOCK_ADD_ID_VALUE,
BLOCK_ADD_ID_VALUE_MVC,
)?;
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, BLOCK_ADD_ID_TYPE_MVCC)?;
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, record)?;
ebml::end_master(&mut writer, map_pos)?;
}
// `mvc_params` present but the record failed to build (malformed
// parameter sets): no mapping, and `track_has_mvc_mapping` above
// is already `false`, so BlockAdditionals are dropped — the file
// stays conforming rather than carrying an orphaned BlockAddID.
None if track.mvc_params.is_some() => {
let (s, p) = track.mvc_params.as_ref().unwrap();
tracing::warn!(
target: "mux",
"MVC track: could not build MVCDecoderConfigurationRecord from the \
dependent view's parameter sets (subset_sps={} B, pps={} B); \
emitting no mvcC mapping — the 3D pairing will not be signalled.",
s.len(),
p.len(),
);
}
None => {}
}
// Dolby Vision signaling — BlockAdditionMapping is a child of the
// TrackEntry (sibling of Video). Carries the dvcC so players /
// mediainfo recognise the track as Dolby Vision.
@@ -995,6 +1174,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
primary_video_track: tracks
.iter()
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
track_has_mvc_mapping,
continuity: TimelineContinuity::new(),
cues: Vec::new(),
frame_count: 0,
@@ -1007,6 +1187,9 @@ impl<W: Write + Seek> MkvMuxer<W> {
track_bytes: vec![0u64; tracks.len()],
track_uids,
duration_secs,
duration_patch_pos,
max_block_ticks: 0,
last_video_keyframe_ticks: None,
ac3_channel_fixups,
opening_capture: None,
})
@@ -1038,6 +1221,13 @@ impl<W: Write + Seek> MkvMuxer<W> {
/// (`None`) leaves the written value untouched: an interlaced track keeps
/// its guess rather than being cleared via a multi-element change. The byte
/// width is fixed (FieldOrder is 0..=14), so the in-place rewrite is valid.
///
/// `block_additional`, when `Some`, is attached to the frame as a Matroska
/// `BlockAdditional` (BlockAddID=2) — Blu-ray 3D (MVC): the base view is the
/// Block and the dependent (right-eye) access unit rides as the
/// BlockAdditional under the track's `mvcC` mapping. Such a frame is always a
/// `BlockGroup` (never a SimpleBlock), with a `ReferenceBlock` when it is not
/// a keyframe. `None` for every non-3D frame.
pub fn write_frame(
&mut self,
track_idx: usize,
@@ -1045,6 +1235,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
keyframe: bool,
data: &[u8],
duration_ns: Option<u64>,
block_additional: Option<&[u8]>,
) -> io::Result<()> {
// --log-level 3: capture the first ~100 coded frames per track to the
// side file BEFORE any timeline mangling, with the codec parser's own
@@ -1196,17 +1387,82 @@ 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 {
Some(dur_ns) => {
// BlockDuration is in TimestampScale ticks, floored at 1.
let duration_ticks = (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64;
self.write_block_group(track_idx + 1, relative_ts, keyframe, data, duration_ticks)?;
let duration_ticks =
duration_ns.map(|dur_ns| (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64);
// A BlockAdditional with BlockAddID=2 is only conforming when the track
// declared the matching mvcC BlockAdditionMapping. If it did not (the
// dependent view's parameter sets were never captured before the header
// was written), drop the additional and emit a plain block rather than a
// non-conforming file with an orphaned BlockAddID.
let block_additional = match block_additional {
Some(a)
if self
.track_has_mvc_mapping
.get(track_idx)
.copied()
.unwrap_or(false) =>
{
Some(a)
}
None => {
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
Some(_) => None,
None => None,
};
match block_additional {
// MVC: base view Block + dependent-view BlockAdditional, always a
// BlockGroup. Non-keyframe base frames get a ReferenceBlock to the
// last keyframe (a keyframe carries none), so a player never treats a
// P/B frame as a seek point.
Some(additional) => {
let reference = if keyframe {
None
} else {
// Offset (ticks) of the referenced keyframe relative to this
// block. A non-keyframe MUST carry a ReferenceBlock or a reader
// treats it as a seek point; fall back to 0 (self-relative) in
// the pre-first-keyframe corner (unreachable in practice — such
// frames are dropped before a cluster opens) so the marker is
// never absent.
Some(
self.last_video_keyframe_ticks
.map(|kf| kf - pts_ticks)
.unwrap_or(0),
)
};
self.write_block_group_mvc(
track_idx + 1,
relative_ts,
data,
additional,
reference,
duration_ticks,
)?;
}
None => match duration_ticks {
// BlockDuration present (PGS subtitles) → BlockGroup.
Some(dt) => {
self.write_block_group(track_idx + 1, relative_ts, keyframe, data, dt)?;
}
None => {
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
}
},
}
// Remember the last PRIMARY-video keyframe's tick so a later non-keyframe
// MVC base frame references a keyframe on its OWN track (see the
// block_additional path above). Gating to the primary video track avoids a
// secondary video track's keyframe becoming a cross-track reference target.
if keyframe && Some(track_idx) == self.primary_video_track {
self.last_video_keyframe_ticks = Some(pts_ticks);
}
self.frame_count += 1;
@@ -1268,6 +1524,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 +1585,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)
@@ -1460,6 +1747,52 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::end_master(&mut self.writer, bg_pos)?;
Ok(())
}
/// Write a BlockGroup carrying the base view Block plus the MVC dependent
/// (right-eye) access unit as a `BlockAdditional` (BlockAddID=2), per the
/// track's `mvcC` BlockAdditionMapping. A non-keyframe frame gets a
/// `ReferenceBlock` (`reference` = referenced keyframe offset in ticks) so it
/// is not mistaken for a seek point; `BlockDuration` is written when known.
fn write_block_group_mvc(
&mut self,
track_num: usize,
relative_ts: i16,
data: &[u8],
additional: &[u8],
reference: Option<i64>,
duration_ticks: Option<u64>,
) -> io::Result<()> {
let (tv, tv_len) = track_vint(track_num);
let track_vint = &tv[..tv_len];
// The 0x80 Keyframe flag is SimpleBlock-only; inside a BlockGroup Block
// it is reserved and MUST be 0 — keyframe-ness is signalled by the
// presence/absence of ReferenceBlock.
let flags: u8 = 0x00;
let block_size = track_vint.len() + 2 + 1 + data.len();
let bg_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_GROUP)?;
ebml::write_id(&mut self.writer, ebml::BLOCK)?;
ebml::write_size(&mut self.writer, block_size as u64)?;
self.writer.write_all(track_vint)?;
self.writer.write_all(&relative_ts.to_be_bytes())?;
self.writer.write_all(&[flags])?;
self.writer.write_all(data)?;
if let Some(dt) = duration_ticks {
ebml::write_uint(&mut self.writer, ebml::BLOCK_DURATION, dt)?;
}
if let Some(ref_off) = reference {
ebml::write_int(&mut self.writer, ebml::REFERENCE_BLOCK, ref_off)?;
}
// BlockAdditions → BlockMore { BlockAddID=2, BlockAdditional=dependent AU }.
let adds_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_ADDITIONS)?;
let more_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_MORE)?;
ebml::write_uint(&mut self.writer, ebml::BLOCK_ADD_ID, BLOCK_ADD_ID_VALUE_MVC)?;
ebml::write_binary(&mut self.writer, ebml::BLOCK_ADDITIONAL, additional)?;
ebml::end_master(&mut self.writer, more_pos)?;
ebml::end_master(&mut self.writer, adds_pos)?;
ebml::end_master(&mut self.writer, bg_pos)?;
Ok(())
}
}
// ============================================================
@@ -1643,6 +1976,7 @@ mod tests {
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
}
}
@@ -1672,6 +2006,7 @@ mod tests {
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
}
}
@@ -1755,7 +2090,7 @@ mod tests {
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF], None)
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF], None, None)
.unwrap();
let data = muxer.writer.into_inner();
assert!(
@@ -1775,7 +2110,7 @@ mod tests {
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None)
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None, None)
.unwrap();
muxer.finish().unwrap();
@@ -1801,7 +2136,7 @@ mod tests {
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(writer, &tracks, Some("NoCue"), 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None)
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None, None)
.unwrap();
// Force the zero-cue branch: drop every cue before finalizing.
let cues_entry_pos = muxer.cues_seek_entry_pos.expect("CUES seek entry recorded");
@@ -2052,16 +2387,16 @@ mod tests {
let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0, &[]).unwrap();
// Write frames to both tracks
muxer
.write_frame(0, 0, true, &[0x00, 0x00, 0x01], None)
.write_frame(0, 0, true, &[0x00, 0x00, 0x01], None, None)
.unwrap();
muxer
.write_frame(1, 0, false, &[0x0B, 0x77, 0x00], None)
.write_frame(1, 0, false, &[0x0B, 0x77, 0x00], None, None)
.unwrap();
muxer
.write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01], None)
.write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01], None, None)
.unwrap();
muxer
.write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01], None)
.write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01], None, None)
.unwrap();
// Should not panic
let data = muxer.writer.into_inner();
@@ -2076,11 +2411,11 @@ mod tests {
// Record position before first frame
let pos_before_kf = muxer.writer.position();
muxer.write_frame(0, 0, true, &[0xAA], None).unwrap();
muxer.write_frame(0, 0, true, &[0xAA], None, None).unwrap();
let pos_after_kf = muxer.writer.position();
muxer
.write_frame(0, 1_000_000, false, &[0xBB], None)
.write_frame(0, 1_000_000, false, &[0xBB], None, None)
.unwrap();
let pos_after_nkf = muxer.writer.position();
@@ -2326,7 +2661,7 @@ mod tests {
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, chapters).unwrap();
for (t, pts, kf, data) in frames {
muxer.write_frame(*t, *pts, *kf, data, None).unwrap();
muxer.write_frame(*t, *pts, *kf, data, None, None).unwrap();
}
let frame_count = muxer.frame_count;
muxer.finish().unwrap();
@@ -2688,7 +3023,7 @@ mod tests {
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
for f in &frames {
muxer
.write_frame(0, f.pts_ns, f.keyframe, &f.data, f.duration_ns)
.write_frame(0, f.pts_ns, f.keyframe, &f.data, f.duration_ns, None)
.unwrap();
}
muxer.finish().unwrap();
@@ -2852,6 +3187,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()];
@@ -3034,12 +3412,14 @@ mod tests {
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
// Audio frames (track 1) and non-keyframe video — no track-0 keyframe.
muxer.write_frame(1, 0, true, &[0xAA; 8], None).unwrap();
muxer
.write_frame(0, 10_000_000, false, &[0xBB; 8], None)
.write_frame(1, 0, true, &[0xAA; 8], None, None)
.unwrap();
muxer
.write_frame(1, 20_000_000, true, &[0xCC; 8], None)
.write_frame(0, 10_000_000, false, &[0xBB; 8], None, None)
.unwrap();
muxer
.write_frame(1, 20_000_000, true, &[0xCC; 8], None, None)
.unwrap();
let err = muxer.finish().unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
@@ -3114,7 +3494,7 @@ mod tests {
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
for (t, pts, kf, data) in &frames_in_order {
muxer.write_frame(*t, *pts, *kf, data, None).unwrap();
muxer.write_frame(*t, *pts, *kf, data, None, None).unwrap();
}
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
@@ -3184,7 +3564,7 @@ mod tests {
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, &[]).unwrap();
for (t, pts, kf, data, dur) in frames {
muxer.write_frame(*t, *pts, *kf, data, *dur).unwrap();
muxer.write_frame(*t, *pts, *kf, data, *dur, None).unwrap();
}
muxer.finish().unwrap();
shared.lock().unwrap().clone().into_inner()
@@ -4033,10 +4413,10 @@ mod tests {
let mut muxer = MkvMuxer::new(writer, &tracks, None, 10.0, &[]).unwrap();
// Video keyframe 1000 bytes; audio frame 500 bytes.
muxer
.write_frame(0, 0, true, &vec![0xABu8; 1000], None)
.write_frame(0, 0, true, &vec![0xABu8; 1000], None, None)
.unwrap();
muxer
.write_frame(1, 0, false, &vec![0xCDu8; 500], None)
.write_frame(1, 0, false, &vec![0xCDu8; 500], None, None)
.unwrap();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
@@ -4086,8 +4466,10 @@ mod tests {
// lfeon(0) = 0b0100_0000 = 0x40. acmod_channels only needs >= 8 bytes.
let ac3 = vec![0x0B, 0x77, 0x00, 0x00, 0x00, 8 << 3, 0x40, 0x00];
// Open a cluster with a video keyframe first (cluster invariant).
muxer.write_frame(0, 0, true, &[0x01, 0x02], None).unwrap();
muxer.write_frame(1, 0, false, &ac3, None).unwrap();
muxer
.write_frame(0, 0, true, &[0x01, 0x02], None, None)
.unwrap();
muxer.write_frame(1, 0, false, &ac3, None, None).unwrap();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
@@ -4132,6 +4514,203 @@ mod tests {
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
}
#[test]
fn mvc_decoder_config_record_matches_iso_14496_15_layout() {
// subset SPS (NAL type 15): [nal_hdr, profile, compat, level, ...].
let subset_sps = vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22];
let pps = vec![0x68, 0xEE, 0x3C];
let rec = mvc_decoder_config_record(&subset_sps, &pps).expect("record builds");
// ISO/IEC 14496-15:2013 §7.6.2 byte layout, verbatim:
let expected: Vec<u8> = [
vec![
1, // configurationVersion
0x80, // AVCProfileIndication = subset_sps[1]
0x00, // profile_compatibility = subset_sps[2]
0x33, // AVCLevelIndication = subset_sps[3]
0xBF, // complete_rep(1) explicit_au(0) reserved'1111' lengthSizeMinusOne=3
0x01, // reserved'0'(1) numOfSequenceParameterSets(7)=1
0x00, 0x06, // sequenceParameterSetLength = 6
],
subset_sps.clone(),
vec![
1, // numOfPictureParameterSets
0x00, 0x03, // pictureParameterSetLength = 3
],
pps.clone(),
]
.concat();
assert_eq!(rec, expected, "MVCDecoderConfigurationRecord byte layout");
// Guards: too-short SPS and empty PPS both refuse (no corrupt record).
assert!(mvc_decoder_config_record(&[0x6F, 0x80, 0x00], &pps).is_none());
assert!(mvc_decoder_config_record(&subset_sps, &[]).is_none());
// Over-length param sets (> 65535) would mis-frame the 16-bit length
// field; both must refuse rather than emit a truncated record.
let huge = vec![0u8; 0x1_0000];
assert!(mvc_decoder_config_record(&huge, &pps).is_none());
assert!(mvc_decoder_config_record(&subset_sps, &huge).is_none());
}
#[test]
fn mvc_frame_emits_blockgroup_additional_and_reference() {
// A track declaring an mvcC mapping: a keyframe base frame carrying a
// dependent AU emits BlockGroup > BlockAdditions > BlockMore
// {BlockAddID=2, BlockAdditional=dep}; a following non-keyframe adds a
// ReferenceBlock so it is not mistaken for a seek point.
let mut v = make_video_track();
v.mvc_params = Some((
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
vec![0x68, 0xEE, 0x3C],
));
let mut muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]).unwrap();
let dep_kf = [0xDEu8, 0xAD, 0xBE, 0xEF];
let dep_p = [0xCAu8, 0xFE];
muxer
.write_frame(0, 0, true, &[0x65, 0x01, 0x02], None, Some(&dep_kf))
.unwrap();
muxer
.write_frame(0, 40_000_000, false, &[0x41, 0x03], None, Some(&dep_p))
.unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::BLOCK_ADDITIONS).is_some(),
"BlockAdditions (0x75A1) present"
);
// BlockAddID = 2 (uint): element 0xEE, size 0x81, value 0x02.
assert!(
data.windows(3).any(|w| w == [0xEE, 0x81, 0x02]),
"BlockAddID must be 2"
);
assert!(
data.windows(4).any(|w| w == dep_kf),
"keyframe dependent AU present as BlockAdditional"
);
assert!(
data.windows(2).any(|w| w == dep_p),
"non-keyframe dependent AU present"
);
assert!(
find_id(&data, ebml::REFERENCE_BLOCK).is_some(),
"non-keyframe MVC base frame must carry a ReferenceBlock"
);
}
#[test]
fn additional_dropped_when_track_has_no_mvc_mapping() {
// If a track did NOT declare an mvcC mapping (mvc_params None), a stray
// block_additional must be dropped (no orphaned BlockAddID=2 / no
// BlockAdditions) so the file stays conforming.
let mut muxer = MkvMuxer::new(
Cursor::new(Vec::new()),
&[make_video_track()],
None,
0.0,
&[],
)
.unwrap();
muxer
.write_frame(0, 0, true, &[0x65, 0x01], None, Some(&[0xDE, 0xAD]))
.unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::BLOCK_ADDITIONS).is_none(),
"no BlockAdditions without a declared mvcC mapping"
);
}
#[test]
fn mvc_track_emits_mvcc_block_addition_mapping() {
// A track with mvc_params must emit BlockAdditionMapping (0x41E4) with the
// mvcC BlockAddIDType (0x6D766343) and a BlockAddIDValue (0x41F0), so
// players / mediainfo recognise the Blu-ray 3D dependent view.
let mut v = make_video_track();
v.mvc_params = Some((
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
vec![0x68, 0xEE, 0x3C],
));
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_some(),
"MVC track must emit BlockAdditionMapping"
);
assert!(
find_id(&data, ebml::BLOCK_ADD_ID_VALUE).is_some(),
"MVC mapping must carry BlockAddIDValue"
);
assert!(
data.windows(4).any(|w| w == [0x6D, 0x76, 0x63, 0x43]),
"mvcC fourcc must be present as the BlockAddIDType value"
);
// Without mvc_params, no mapping.
let muxer = MkvMuxer::new(
Cursor::new(Vec::new()),
&[make_video_track()],
None,
0.0,
&[],
)
.unwrap();
let data = muxer.writer.into_inner();
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
}
#[test]
fn mvc_codec_private_appends_mvcc_extension_block() {
// avcC ‖ u32be(4 + record.len()) ‖ "mvcC" ‖ record (Matroska Codec Spec §4.3.9).
let avcc = vec![0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0xAA];
let record = vec![0x01, 0x80, 0x00, 0x33, 0xBF, 0x01, 0xCC]; // 7 bytes
let out = mvc_codec_private(&avcc, &record);
assert_eq!(&out[..avcc.len()], &avcc[..], "avcC preserved verbatim");
// size field = 4 ("mvcC") + 7 (record) = 11 = extension block size minus 4.
assert_eq!(&out[avcc.len()..avcc.len() + 4], &11u32.to_be_bytes());
assert_eq!(&out[avcc.len() + 4..avcc.len() + 8], b"mvcC");
assert_eq!(
&out[avcc.len() + 8..],
&record[..],
"record after the mvcC fourcc"
);
}
#[test]
fn mvc_track_codec_private_carries_avcc_plus_mvcc() {
// An MVC base track's CodecPrivate must be the base avcC followed by the
// mvcC extension — the track-level signal mediainfo/decoders read.
let avcc = vec![
0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0x00, 0x05, 0x67, 0x64, 0x00, 0x33, 0x99,
];
let subset_sps = vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22];
let pps = vec![0x68, 0xEE, 0x3C];
let mut v = make_video_track();
v.codec_private = Some(avcc.clone());
v.mvc_params = Some((subset_sps.clone(), pps.clone()));
let data = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[])
.unwrap()
.writer
.into_inner();
let record = mvc_decoder_config_record(&subset_sps, &pps).unwrap();
let expected = mvc_codec_private(&avcc, &record);
assert!(
data.windows(expected.len())
.any(|w| w == expected.as_slice()),
"emitted CodecPrivate must be avcC + mvcC extension"
);
// A non-MVC (2D) track writes its avcC VERBATIM — no mvcC appended.
let mut v2 = make_video_track();
v2.codec_private = Some(avcc.clone());
let d2 = MkvMuxer::new(Cursor::new(Vec::new()), &[v2], None, 0.0, &[])
.unwrap()
.writer
.into_inner();
assert!(
!d2.windows(4).any(|w| w == b"mvcC"),
"2D track CodecPrivate must not carry an mvcC extension"
);
}
// ---- CodecPrivate emission (avcC / hvcC / VC-1 / MPEG-2) -------------
//
// `MkvTrack::video` always builds with `codec_private: None`; the PES mux
+549 -52
View File
@@ -120,14 +120,206 @@ struct PendingMux {
video_track: Option<usize>,
/// `--log-level 3` opening-capture side-file path (if any).
opening_capture_path: Option<std::path::PathBuf>,
/// Frames received before activation, replayed in order once built.
buffered: Vec<crate::pes::PesFrame>,
/// Frames received before activation, replayed in order once built. Each
/// carries an optional MVC dependent-view `BlockAdditional` (present only
/// for a 3D base-view frame that was already paired before activation).
buffered: Vec<(crate::pes::PesFrame, Option<Vec<u8>>)>,
}
/// Matroska container stream.
pub struct MkvStream {
disc_title: DiscTitle,
mode: Mode,
/// Blu-ray 3D (MVC) merge state — present iff the title carries an MVC
/// dependent (right-eye) view. Folds the dependent stream's frames into the
/// base video track as per-frame `BlockAdditional`, paired by PTS, so the
/// output is a single MVC track instead of two independent H.264 tracks.
mvc: Option<MvcMerge>,
}
/// Largest number of base frames held awaiting their PTS-matching dependent AU
/// before the oldest is flushed unpaired (a plain Block). The SSIF interleaves
/// base and dependent access units per unit, so a base's dependent normally
/// arrives within one or two frames; this window only bounds memory/latency for
/// a stream where the pairing drifts.
const MVC_PAIR_WINDOW: usize = 32;
/// A base-view frame (track already remapped to the muxer's base track index)
/// awaiting — or already carrying — its dependent-view `BlockAdditional`.
struct PendingBase {
frame: crate::pes::PesFrame,
additional: Option<Vec<u8>>,
}
/// State for folding the MVC dependent (right-eye) view into the base track.
struct MvcMerge {
/// `title.streams` index of the base (left-eye) video stream.
base_stream_idx: usize,
/// `title.streams` index of the dependent (right-eye) video stream.
dep_stream_idx: usize,
/// Muxer track index of the base view — where the dependent AU is attached
/// as a `BlockAdditional` and where `mvc_params` (the `mvcC` mapping) lives.
base_track_idx: usize,
/// `title.streams` index → muxer track index. The dependent maps to `None`
/// (it becomes a BlockAdditional, not a track); every other stream shifts
/// down by one if it followed the dependent in stream order.
stream_to_track: Vec<Option<usize>>,
/// Base frames (decode order) awaiting their dependent or a window flush.
pending_base: std::collections::VecDeque<PendingBase>,
/// Dependent AU data keyed by PTS, waiting for the matching base.
dep_by_pts: std::collections::HashMap<i64, Vec<u8>>,
/// `(subset_sps, pps)` from the first dependent AU — builds the `mvcC`
/// MVCDecoderConfigurationRecord for the base track's BlockAdditionMapping.
captured_params: Option<(Vec<u8>, Vec<u8>)>,
/// Count of dependent AUs dropped with no matching base (diagnostic).
orphan_deps: u64,
}
impl MvcMerge {
/// Ingest one incoming frame; returns `(frame, additional)` pairs ready to
/// hand to the muxer, in emit order. Base frames buffer briefly to pair with
/// their dependent by PTS; the dependent stream produces no frames of its own
/// (it becomes `BlockAdditional`); all other streams pass straight through
/// with their track index remapped.
fn ingest(
&mut self,
frame: &crate::pes::PesFrame,
) -> Vec<(crate::pes::PesFrame, Option<Vec<u8>>)> {
let mut out = Vec::new();
if frame.track == self.dep_stream_idx {
if self.captured_params.is_none() {
self.captured_params = extract_mvc_params(&frame.data);
}
// Attach to a waiting base of the same PTS, else stash by PTS.
if let Some(pb) = self
.pending_base
.iter_mut()
.find(|pb| pb.frame.pts == frame.pts && pb.additional.is_none())
{
pb.additional = Some(frame.data.clone());
} else {
// Bound the orphan map BEFORE inserting: if dependents pile up
// unpaired (pairing badly drifted), drop the drifted buffer so it
// stays bounded — but keep THIS just-arrived dependent, whose base
// frame commonly arrives next. Clearing after the insert would
// discard it and overcount orphans by one.
if self.dep_by_pts.len() >= MVC_PAIR_WINDOW * 4 {
self.orphan_deps += self.dep_by_pts.len() as u64;
self.dep_by_pts.clear();
}
// A duplicate-PTS dependent (e.g. a stale repeat after a stream
// discontinuity) displaces the prior one — count it as an orphan
// rather than losing it silently.
if self
.dep_by_pts
.insert(frame.pts, frame.data.clone())
.is_some()
{
self.orphan_deps += 1;
}
}
} else if frame.track == self.base_stream_idx {
let additional = self.dep_by_pts.remove(&frame.pts);
let mut remapped = frame.clone();
remapped.track = self.base_track_idx;
self.pending_base.push_back(PendingBase {
frame: remapped,
additional,
});
} else {
// Audio / subtitle / other video: remap the track index and forward.
let mut remapped = frame.clone();
if let Some(Some(t)) = self.stream_to_track.get(frame.track) {
remapped.track = *t;
out.push((remapped, None));
}
}
self.drain_ready(&mut out);
out
}
/// Emit base frames from the FIFO front once each has its dependent attached,
/// or flush the oldest unpaired base as a plain Block when the window is full.
fn drain_ready(&mut self, out: &mut Vec<(crate::pes::PesFrame, Option<Vec<u8>>)>) {
loop {
let front_ready = self
.pending_base
.front()
.map(|pb| pb.additional.is_some())
.unwrap_or(false);
if front_ready || self.pending_base.len() > MVC_PAIR_WINDOW {
if let Some(pb) = self.pending_base.pop_front() {
out.push((pb.frame, pb.additional));
continue;
}
}
break;
}
}
/// Flush every remaining buffered base frame (unpaired → plain Block) at EOF.
fn flush(&mut self) -> Vec<(crate::pes::PesFrame, Option<Vec<u8>>)> {
let mut out = Vec::new();
for pb in self.pending_base.drain(..) {
out.push((pb.frame, pb.additional));
}
self.orphan_deps += self.dep_by_pts.len() as u64;
self.dep_by_pts.clear();
out
}
}
/// Hand a frame to the muxer, attaching the MVC dependent view as a
/// `BlockAdditional` when `additional` is `Some` (a 3D base frame), else a
/// plain block.
fn emit_to_muxer(
m: &mut MkvMuxer<Box<dyn WriteSeek + Send>>,
frame: &crate::pes::PesFrame,
additional: Option<&[u8]>,
) -> io::Result<()> {
m.write_frame(
frame.track,
frame.pts,
frame.keyframe,
&frame.data,
frame.duration_ns,
additional,
)
}
/// Scan a length-prefixed (4-byte big-endian) H.264 NAL stream for the first
/// subset SPS (NAL type 15) and first PPS (NAL type 8) — the two parameter sets
/// that populate the `mvcC` MVCDecoderConfigurationRecord. Returns
/// `Some((subset_sps, pps))` only when BOTH are found; `None` otherwise (the
/// serializer then emits no mvcC mapping and logs it).
fn extract_mvc_params(data: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
let mut subset_sps: Option<Vec<u8>> = None;
let mut pps: Option<Vec<u8>> = None;
let mut i = 0usize;
while i + 4 <= data.len() {
let len = u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
i += 4;
// A zero-length NAL (a stray length prefix) is skipped, not fatal — the
// subset SPS / PPS may still follow. A length that runs past the buffer
// end IS unrecoverable (the NAL can't be read), so stop there.
if len == 0 {
continue;
}
if i + len > data.len() {
break;
}
let nal = &data[i..i + len];
i += len;
match nal[0] & 0x1F {
15 if subset_sps.is_none() => subset_sps = Some(nal.to_vec()),
8 if pps.is_none() => pps = Some(nal.to_vec()),
_ => {}
}
if subset_sps.is_some() && pps.is_some() {
break;
}
}
Some((subset_sps?, pps?))
}
impl MkvStream {
@@ -146,10 +338,38 @@ impl MkvStream {
title: &DiscTitle,
output_path: Option<&std::path::Path>,
) -> io::Result<Self> {
// Blu-ray 3D (MVC): a dependent (right-eye) view stream is NOT emitted as
// its own track — it is folded into the base track as per-frame
// BlockAdditional. Detect it so we skip building a track for it and set up
// the merge. `base_stream_idx` is the first video stream.
let dep_stream_idx = title
.streams
.iter()
.position(|s| matches!(s, crate::disc::Stream::Video(v) if v.is_mvc_dependent()));
// The base is the first NON-dependent video. Excluding the dependent here
// means a (malformed / hand-built) title whose only video IS the dependent
// yields `base_stream_idx == None` → no merge (the dependent is muxed as an
// ordinary track) instead of `base == dep` and a panic on the skipped slot.
let base_stream_idx = title
.streams
.iter()
.position(|s| matches!(s, crate::disc::Stream::Video(v) if !v.is_mvc_dependent()));
// The merge is only active when BOTH a dependent and a distinct base exist;
// only then is the dependent's track skipped/folded.
let mvc_active = dep_stream_idx.is_some() && base_stream_idx.is_some();
let skip_stream_idx = if mvc_active { dep_stream_idx } else { None };
let mut tracks = Vec::new();
let mut has_default_video = false;
let mut has_default_audio = false;
// `title.streams` index → muxer track index (`None` = the dependent view,
// which has no track). Streams after the dependent shift down by one.
let mut stream_to_track: Vec<Option<usize>> = Vec::with_capacity(title.streams.len());
for (idx, s) in title.streams.iter().enumerate() {
if Some(idx) == skip_stream_idx {
stream_to_track.push(None);
continue;
}
let mut track = match s {
crate::disc::Stream::Video(v) => MkvTrack::video(v),
crate::disc::Stream::Audio(a) => MkvTrack::audio(a),
@@ -166,9 +386,31 @@ impl MkvStream {
if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) {
track.codec_private = Some(cp.clone());
}
stream_to_track.push(Some(tracks.len()));
tracks.push(track);
}
// Assemble the MVC merge only when active — i.e. a dependent AND a
// distinct base video both exist (established above). `base_stream_idx`
// then always has a built track, so its remap is `Some` (no panic path).
let mvc = match (mvc_active, dep_stream_idx, base_stream_idx) {
(true, Some(dep_stream_idx), Some(base_stream_idx)) => stream_to_track
.get(base_stream_idx)
.copied()
.flatten()
.map(|base_track_idx| MvcMerge {
base_stream_idx,
dep_stream_idx,
base_track_idx,
stream_to_track,
pending_base: std::collections::VecDeque::new(),
dep_by_pts: std::collections::HashMap::new(),
captured_params: None,
orphan_deps: 0,
}),
_ => None,
};
// Defer muxer construction (and the TrackEntry dump) until the first
// coded picture arrives, so the primary video track's FieldOrder is set
// from the parser's MEASURED value before the header is written — never
@@ -177,6 +419,7 @@ impl MkvStream {
Ok(Self {
disc_title: title.clone(),
mvc,
mode: Mode::Write(WriteMode::Pending(Box::new(PendingMux {
writer,
tracks,
@@ -209,6 +452,24 @@ impl MkvStream {
if let Some(vt) = pending.video_track {
apply_coding_to_track(&mut pending.tracks[vt], coding, video_picture_seen);
}
// Blu-ray 3D: set the base video track's `mvc_params` from the dependent
// view's captured subset-SPS/PPS BEFORE the header is written, so the
// TrackEntry carries the `mvcC` BlockAdditionMapping. Captured from the
// first dependent AU (which arrives right after the first base AU in the
// SSIF), so it is available by the time the first base frame activates.
if let Some(mvc) = &self.mvc {
if let Some(params) = &mvc.captured_params {
if let Some(t) = pending.tracks.get_mut(mvc.base_track_idx) {
t.mvc_params = Some(params.clone());
}
} else {
tracing::warn!(
target: "mux",
"MVC: no dependent-view subset-SPS/PPS captured before activation; \
the base track will carry no mvcC mapping (3D not signalled)."
);
}
}
// --log-level 3: dump the FINAL TrackEntry metadata (field order set).
for (i, track) in pending.tracks.iter().enumerate() {
crate::diag::dump_mkv_track((i + 1) as u64, track);
@@ -223,18 +484,72 @@ impl MkvStream {
if let Some(path) = &pending.opening_capture_path {
muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len()));
}
for f in pending.buffered.drain(..) {
muxer.write_frame(f.track, f.pts, f.keyframe, &f.data, f.duration_ns)?;
for (f, additional) in pending.buffered.drain(..) {
muxer.write_frame(
f.track,
f.pts,
f.keyframe,
&f.data,
f.duration_ns,
additional.as_deref(),
)?;
}
self.mode = Mode::Write(WriteMode::Active(Box::new(muxer)));
Ok(())
}
/// Emit one frame (track index already muxer-relative) with an optional MVC
/// dependent-view `BlockAdditional`, honouring the deferred-activation
/// machinery: the first video frame triggers muxer construction (its coding
/// sets FieldOrder); earlier frames buffer. `additional` is `None` for every
/// non-3D frame and for the 3D base frames that had no paired dependent.
fn emit(&mut self, frame: &crate::pes::PesFrame, additional: Option<&[u8]>) -> io::Result<()> {
match &mut self.mode {
Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()),
Mode::Write(WriteMode::Active(m)) => {
return emit_to_muxer(m, frame, additional);
}
Mode::Write(WriteMode::Building) => return Ok(()),
Mode::Write(WriteMode::Pending(_)) => {}
}
// Pending: the first video frame (or the safety cap) triggers muxer
// construction; that frame's coding sets the field order. Other frames
// buffer until then.
let (activate_now, use_coding) = match &self.mode {
Mode::Write(WriteMode::Pending(p)) => {
let is_video = match p.video_track {
Some(vt) => frame.track == vt,
// No video track: nothing to wait for — build on frame one.
None => true,
};
(is_video || p.buffered.len() >= MAX_PENDING_FRAMES, is_video)
}
_ => unreachable!("guarded above"),
};
if activate_now {
// Pass the trigger frame's coding only when it IS the video frame; a
// cap-triggered build never saw the video frame, so nothing measured
// is passed (apply_coding_to_track then logs + leaves UNDETERMINED).
self.activate(if use_coding { frame.coding } else { None }, use_coding)?;
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
return emit_to_muxer(m, frame, additional);
}
Ok(())
} else {
if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode {
p.buffered
.push((frame.clone(), additional.map(|a| a.to_vec())));
}
Ok(())
}
}
/// Open an MKV file for reading → PES frames.
pub fn open(mut reader: impl Read + Send + 'static) -> io::Result<Self> {
let (disc_title, codec_privates, ts_scale_ns) = parse_mkv_header(&mut reader)?;
Ok(Self {
disc_title,
mvc: None,
mode: Mode::Read(ReadState {
reader: Box::new(reader),
cluster_ts_ticks: 0,
@@ -425,59 +740,40 @@ impl crate::pes::Stream for MkvStream {
}
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
// Fast paths.
match &mut self.mode {
Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()),
Mode::Write(WriteMode::Active(m)) => {
return m.write_frame(
frame.track,
frame.pts,
frame.keyframe,
&frame.data,
frame.duration_ns,
);
}
Mode::Write(WriteMode::Building) => return Ok(()),
Mode::Write(WriteMode::Pending(_)) => {}
if matches!(self.mode, Mode::Read(_)) {
return Err(crate::error::Error::StreamReadOnly.into());
}
// Pending: the first video frame (or the safety cap) triggers muxer
// construction; that frame's coding sets the field order. Other frames
// buffer until then.
let (activate_now, use_coding) = match &self.mode {
Mode::Write(WriteMode::Pending(p)) => {
let is_video = match p.video_track {
Some(vt) => frame.track == vt,
// No video track: nothing to wait for — build on frame one.
None => true,
};
(is_video || p.buffered.len() >= MAX_PENDING_FRAMES, is_video)
}
_ => unreachable!("guarded above"),
};
if activate_now {
// Pass the trigger frame's coding only when it IS the video frame; a
// cap-triggered build never saw the video frame, so nothing measured
// is passed (apply_coding_to_track then logs + leaves UNDETERMINED).
self.activate(if use_coding { frame.coding } else { None }, use_coding)?;
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
return m.write_frame(
frame.track,
frame.pts,
frame.keyframe,
&frame.data,
frame.duration_ns,
);
}
Ok(())
} else {
if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode {
p.buffered.push(frame.clone());
}
Ok(())
// Non-3D fast path: emit the frame directly, no clone, no buffering.
if self.mvc.is_none() {
return self.emit(frame, None);
}
// Blu-ray 3D: run the frame through the MVC merge, which remaps track
// indices, folds the dependent view into the base as BlockAdditional
// (paired by PTS), and yields 0+ frames ready to emit. `ingest` returns
// owned pairs so the `self.mvc` borrow is released before `emit`.
let emits = self.mvc.as_mut().unwrap().ingest(frame);
for (f, additional) in emits {
self.emit(&f, additional.as_deref())?;
}
Ok(())
}
fn finish(&mut self) -> io::Result<()> {
// Blu-ray 3D: flush any base frames still awaiting a dependent (emitted
// unpaired as plain Blocks) before finalizing.
if let Some(mvc) = self.mvc.as_mut() {
let tail = mvc.flush();
let orphans = mvc.orphan_deps;
if orphans > 0 {
tracing::debug!(
target: "mux",
"MVC: {orphans} dependent-view access units had no matching base frame (dropped)"
);
}
for (f, additional) in tail {
self.emit(&f, additional.as_deref())?;
}
}
// A title that produced no frames (or only buffered ones) is still
// finalized into a valid MKV: activate now with no measured coding.
if matches!(self.mode, Mode::Write(WriteMode::Pending(_))) {
@@ -916,6 +1212,207 @@ mod tests {
use crate::pes::Stream as _;
use std::io::Cursor;
/// Length-prefix (4-byte big-endian) each NAL, as the H.264 parser emits.
fn lp(nals: &[&[u8]]) -> Vec<u8> {
let mut v = Vec::new();
for n in nals {
v.extend_from_slice(&(n.len() as u32).to_be_bytes());
v.extend_from_slice(n);
}
v
}
fn mvc_frame(track: usize, pts: i64, keyframe: bool, data: Vec<u8>) -> crate::pes::PesFrame {
crate::pes::PesFrame {
track,
pts,
keyframe,
data,
duration_ns: None,
source: None,
coding: None,
}
}
// A subset SPS (NAL type 15), a PPS (type 8), and a coded-slice-extension
// (type 20) — the shape of a dependent-view access unit.
const SUBSET_SPS: [u8; 5] = [0x6F, 0x80, 0x00, 0x33, 0xAA]; // 0x6F & 0x1F = 15
const DEP_PPS: [u8; 3] = [0x68, 0xEE, 0x3C]; // 0x68 & 0x1F = 8
const DEP_SLICE: [u8; 3] = [0x74, 0x11, 0x22]; // 0x74 & 0x1F = 20
#[test]
fn extract_mvc_params_finds_subset_sps_and_pps() {
let data = lp(&[&SUBSET_SPS, &DEP_PPS, &DEP_SLICE]);
let (s, p) = extract_mvc_params(&data).expect("both param sets present");
assert_eq!(s, SUBSET_SPS, "subset SPS (NAL 15) captured verbatim");
assert_eq!(p, DEP_PPS, "PPS (NAL 8) captured verbatim");
// Missing PPS → None (the serializer then emits no mvcC mapping).
assert!(extract_mvc_params(&lp(&[&SUBSET_SPS, &DEP_SLICE])).is_none());
// Missing subset SPS → None.
assert!(extract_mvc_params(&lp(&[&DEP_PPS, &DEP_SLICE])).is_none());
}
fn empty_merge() -> MvcMerge {
MvcMerge {
base_stream_idx: 0,
dep_stream_idx: 2,
base_track_idx: 0,
stream_to_track: vec![Some(0), Some(1), None],
pending_base: std::collections::VecDeque::new(),
dep_by_pts: std::collections::HashMap::new(),
captured_params: None,
orphan_deps: 0,
}
}
#[test]
fn mvc_merge_pairs_base_and_dependent_by_pts() {
let mut m = empty_merge();
let dep = lp(&[&SUBSET_SPS, &DEP_PPS, &DEP_SLICE]);
// Base arrives first (SSIF order): buffered, nothing emitted yet.
let e = m.ingest(&mvc_frame(0, 100, true, lp(&[&[0x65, 1, 2]])));
assert!(e.is_empty(), "base held until its dependent arrives");
// Dependent arrives → base is emitted, remapped to the base track, with
// the dependent AU as its BlockAdditional; params are captured.
let e = m.ingest(&mvc_frame(2, 100, false, dep.clone()));
assert_eq!(e.len(), 1, "the paired base frame is emitted");
assert_eq!(e[0].0.track, 0, "remapped to the base muxer track");
assert_eq!(
e[0].1.as_deref(),
Some(dep.as_slice()),
"dependent attached"
);
assert!(m.captured_params.is_some(), "mvcC params captured");
// Audio passes straight through (remapped, no additional).
let e = m.ingest(&mvc_frame(1, 100, true, vec![0xAA]));
assert_eq!(e.len(), 1);
assert_eq!(e[0].0.track, 1);
assert!(e[0].1.is_none());
// Dependent-before-base (reordered) also pairs.
let dep2 = lp(&[&DEP_SLICE]);
assert!(m.ingest(&mvc_frame(2, 200, false, dep2.clone())).is_empty());
let e = m.ingest(&mvc_frame(0, 200, false, lp(&[&[0x61, 3, 4]])));
assert_eq!(e.len(), 1);
assert_eq!(e[0].1.as_deref(), Some(dep2.as_slice()));
}
#[test]
fn mvc_merge_flushes_unpaired_base_at_eof() {
let mut m = empty_merge();
// Base with no dependent ever → held, then flushed unpaired at EOF.
assert!(
m.ingest(&mvc_frame(0, 10, true, vec![0, 0, 0, 1]))
.is_empty()
);
let tail = m.flush();
assert_eq!(tail.len(), 1, "unpaired base still emitted");
assert!(tail[0].1.is_none(), "no BlockAdditional when unpaired");
}
#[test]
fn extract_mvc_params_no_panic_on_truncated_or_empty() {
// Empty, sub-header, zero-length NAL, and a length prefix claiming more
// than is present must all return None without panicking (untrusted AU).
assert!(extract_mvc_params(&[]).is_none());
assert!(extract_mvc_params(&[0, 0, 0]).is_none());
assert!(
extract_mvc_params(&[0, 0, 0, 0]).is_none(),
"lone zero-length NAL yields no params"
);
assert!(
extract_mvc_params(&[0, 0, 0, 10, 0x6F]).is_none(),
"length prefix past end breaks, no slice panic"
);
// A zero-length NAL is SKIPPED, not fatal: valid param sets that follow
// are still found (a stray length prefix must not abandon the whole AU).
let mut d = vec![0, 0, 0, 0];
d.extend_from_slice(&lp(&[&SUBSET_SPS, &DEP_PPS]));
let (s, p) = extract_mvc_params(&d).expect("params found past the zero-length NAL");
assert_eq!(s, SUBSET_SPS);
assert_eq!(p, DEP_PPS);
}
#[test]
fn mvc_merge_flushes_oldest_base_once_past_window() {
let mut m = empty_merge();
// Push more unpaired base frames than the window; the excess flush as
// plain (unpaired) blocks in FIFO order once len exceeds MVC_PAIR_WINDOW.
let n = MVC_PAIR_WINDOW + 8;
let mut emitted = 0usize;
for pts in 0..n {
emitted += m
.ingest(&mvc_frame(0, pts as i64, false, vec![0, 0, 0, 1]))
.len();
}
assert_eq!(emitted, 8, "the {n} bases beyond the window flush unpaired");
assert_eq!(m.pending_base.len(), MVC_PAIR_WINDOW, "window still held");
assert!(m.flush().iter().all(|(_, add)| add.is_none()));
}
#[test]
fn mvc_merge_dep_overflow_drops_old_keeps_newest() {
let mut m = empty_merge();
// Fill dep_by_pts to the bound with unpaired dependents (unique PTS).
for pts in 0..(MVC_PAIR_WINDOW * 4) {
assert!(
m.ingest(&mvc_frame(2, pts as i64, false, lp(&[&DEP_SLICE])))
.is_empty()
);
}
assert_eq!(m.dep_by_pts.len(), MVC_PAIR_WINDOW * 4);
// One more overflows: the drifted buffer is cleared BUT the newest survives
// so its (soon-to-arrive) base can still pair.
let dep_new = lp(&[&DEP_SLICE]);
m.ingest(&mvc_frame(2, 9_999, false, dep_new.clone()));
assert_eq!(m.dep_by_pts.len(), 1, "old cleared, newest kept");
assert!(m.dep_by_pts.contains_key(&9_999));
assert_eq!(
m.orphan_deps,
(MVC_PAIR_WINDOW * 4) as u64,
"old buffer counted once"
);
// The surviving dependent pairs with its base.
let e = m.ingest(&mvc_frame(0, 9_999, false, vec![0x61, 1]));
assert_eq!(e.len(), 1);
assert_eq!(e[0].1.as_deref(), Some(dep_new.as_slice()));
}
#[test]
fn create_does_not_panic_when_only_video_is_mvc_dependent() {
// A (malformed / hand-built) title whose single video IS the dependent
// must NOT panic: base_stream_idx is None, so no merge is set up and the
// dependent is muxed as an ordinary track.
use crate::disc::{
Codec, ColorSpace, DiscTitle, FrameRate, HdrFormat, Resolution, Stream, VideoStream,
};
let dep = VideoStream {
pid: 0x1012,
codec: Codec::H264,
resolution: Resolution::R1080p,
frame_rate: FrameRate::F24,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: true,
label: crate::disc::MVC_DEPENDENT_LABEL.to_string(),
measured_cicp: None,
};
let title = DiscTitle {
streams: vec![Stream::Video(dep)],
..DiscTitle::empty()
};
let s = MkvStream::create(Box::new(Cursor::new(Vec::new())), &title)
.expect("create must succeed, not panic");
assert!(
s.mvc.is_none(),
"no merge when there is no distinct base view"
);
}
#[test]
fn apply_coding_to_track_sets_measured_field_order_never_guesses() {
use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream};
+1
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;
+190 -17
View File
@@ -75,6 +75,24 @@ pub struct PipelinedPesStream {
/// 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 {
@@ -102,6 +120,11 @@ impl PipelinedPesStream {
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,
@@ -115,6 +138,7 @@ impl PipelinedPesStream {
dropped_nav_packets: 0,
resync,
is_video,
au_asm,
}
}
@@ -254,23 +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,
// PS (DVD/CSS) path: no AACS conceal → no continuity-gap flag.
discontinuity: false,
// 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));
}
}
}
}
@@ -315,11 +366,30 @@ impl Stream for PipelinedPesStream {
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() {
// 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),
@@ -801,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.
+25 -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
@@ -569,7 +575,17 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
pids.push(pid);
pid_to_track.push((pid, idx));
let is_dvd_ps = matches!(format, ContentFormat::MpegPs);
parsers.push((pid, super::codec::parser_for_codec(codec, None, is_dvd_ps)));
// The Blu-ray 3D MVC dependent (right-eye) view uses a param-set-
// passthrough H.264 parser so each frame is a self-contained dependent
// access unit for a BlockAdditional; every other stream uses the
// ordinary parser for its codec.
let parser = match s {
crate::disc::Stream::Video(v) if v.is_mvc_dependent() => {
super::codec::parser_for_mvc_dependent(codec, is_dvd_ps)
}
_ => super::codec::parser_for_codec(codec, None, is_dvd_ps),
};
parsers.push((pid, parser));
}
let (ts, ps) = match format {
ContentFormat::MpegPs => (None, Some(super::ps::PsDemuxer::new())),
@@ -638,15 +654,17 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
.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
+5 -1
View File
@@ -167,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);
@@ -366,7 +370,7 @@ impl TsDemuxer {
// 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::fill_null_ts_unit`).
// 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
+96 -160
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
@@ -109,21 +99,12 @@ pub struct DecryptingSectorSource<S: SectorSource> {
///
/// [`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>,
/// Fingerprints (hash over the unit ciphertext) of failing units a fetch
/// already returned NO new key for. A later failure re-asks the source only
/// for units NOT in this set — so on a multi-CPS disc the source is still
/// asked for the *second* CPS unit's key even after the first came back dry
/// (the old global latch blocked that), while the *same* failing unit is
/// never re-fetched (and the total is still bounded by `MAX_FETCH_CALLS`).
fetch_dry: std::collections::HashSet<u64>,
/// 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
@@ -146,7 +127,7 @@ pub struct DecryptingSectorSource<S: SectorSource> {
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::fill_null_ts_unit`]), tallied into
/// 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
@@ -173,9 +154,10 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
unit_key_idx: 0,
unit_base: 0,
decrypt_dropped: Arc::new(AtomicU64::new(0)),
fetch: None,
fetch_dry: std::collections::HashSet::new(),
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(),
@@ -197,7 +179,7 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
/// (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 {
@@ -238,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
}
@@ -286,86 +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;
}
// Skip the call when EVERY failing unit here is one a prior fetch already
// came back empty for — re-asking the identical ciphertext only burns a
// key-server request. A unit we have NOT asked about yet (e.g. a second
// CPS unit on a multi-CPS disc) still gets its one chance, where the old
// global `fetch_spent` latch wrongly blocked it.
let fps: Vec<u64> = samples.iter().map(|s| Self::sample_fp(s)).collect();
if fps.iter().all(|fp| self.fetch_dry.contains(fp)) {
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 for THESE units — remember them so we don't re-ask the
// same ciphertext, but leave the door open for other units.
self.fetch_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.
Self::decrypt_buf(buf, &mut self.keys, self.unit_key_idx, lba, content)
.unwrap_or(prev_dropped)
}
/// Stable per-run fingerprint of a failing unit's ciphertext, for the
/// `fetch_dry` 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()
}
/// 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
@@ -391,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.
@@ -415,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;
@@ -431,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;
}
@@ -497,7 +399,7 @@ 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);
}
@@ -521,8 +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.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`
@@ -537,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,
@@ -554,24 +459,45 @@ 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);
@@ -585,7 +511,7 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// 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::ALIGNED_UNIT_LEN;
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() {
@@ -604,11 +530,12 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// 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::aacs_unit_still_ciphertext(chunk) {
if crate::aacs::content::aacs_unit_still_ciphertext(chunk) {
if concealed == 0 {
first_lba = lba + (i as u32) * crate::aacs::ALIGNED_UNIT_SECTORS;
first_lba =
lba + (i as u32) * crate::aacs::content::ALIGNED_UNIT_SECTORS;
}
crate::aacs::fill_null_ts_unit(chunk);
crate::aacs::content::fill_null_ts_unit(chunk);
concealed += 1;
}
}
@@ -631,8 +558,10 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// 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::aacs_unit_needs_decrypt(chunk) {
crate::aacs::fill_null_ts_unit(chunk);
if chunk.len() == unit_len
&& crate::aacs::content::aacs_unit_needs_decrypt(chunk)
{
crate::aacs::content::fill_null_ts_unit(chunk);
forced += 1;
}
}
@@ -653,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?
@@ -1253,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;
@@ -1267,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 {
@@ -1347,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"
);
@@ -1358,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"
);
@@ -1392,7 +1326,7 @@ mod tests {
// 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::ALIGNED_UNIT_LEN];
let mut clear = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
let mut o = 4;
while o < clear.len() {
clear[o] = 0x47;
@@ -1441,13 +1375,13 @@ mod tests {
// The undecryptable unit is tallied as loss.
assert_eq!(
loss.load(Ordering::Relaxed),
crate::aacs::ALIGNED_UNIT_LEN as u64,
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::ALIGNED_UNIT_LEN];
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}");
@@ -1456,12 +1390,13 @@ mod tests {
off += 192;
}
assert!(
!crate::aacs::ts_sync_destroyed(unit0),
!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::ALIGNED_UNIT_LEN..2 * crate::aacs::ALIGNED_UNIT_LEN];
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");
}
@@ -1496,7 +1431,7 @@ mod tests {
// The byte-exact expected post-decrypt form of unit B (independent decrypt).
let mut expected_tail = good_tail.clone();
assert!(
crate::aacs::decrypt_unit(&mut expected_tail, &good_key),
crate::aacs::content::decrypt_unit(&mut expected_tail, &good_key),
"padding-tail must decrypt under good_key"
);
@@ -1542,12 +1477,12 @@ mod tests {
// ONLY the genuinely-undecryptable unit A is tallied / concealed.
assert_eq!(
loss.load(Ordering::Relaxed),
crate::aacs::ALIGNED_UNIT_LEN as u64,
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::ALIGNED_UNIT_LEN];
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}");
@@ -1562,7 +1497,8 @@ mod tests {
// 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::ALIGNED_UNIT_LEN..2 * crate::aacs::ALIGNED_UNIT_LEN];
let unit1 = &buf
[crate::aacs::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
assert_eq!(
unit1,
&expected_tail[..],
@@ -1587,8 +1523,8 @@ mod tests {
/// `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::ALIGNED_UNIT_LEN];
crate::aacs::fill_null_ts_unit(&mut unit);
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;
@@ -1679,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!(
@@ -1703,7 +1639,7 @@ 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"
);
}
@@ -1784,7 +1720,7 @@ mod tests {
"fetch fired for BOTH units — the dry result for A did not latch off B"
);
assert!(
!crate::aacs::ts_sync_destroyed(&buf2),
!crate::aacs::content::ts_sync_destroyed(&buf2),
"unit B is decrypted after its on-demand fetch"
);
}
@@ -1899,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)
}
@@ -1998,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)
}
+1
View File
@@ -16,6 +16,7 @@
pub mod decrypting;
pub mod file;
pub mod prefetched;
pub mod recovery;
use crate::error::Result;
+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"
);
}
}
+279 -52
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,
@@ -640,10 +593,27 @@ impl UdfFs {
match next_block {
Some(cont_lba) => {
read_sector(reader, self.meta_to_abs(cont_lba)?, &mut block)?;
// A continuation block is a list of Short ADs from byte 0,
// spanning the whole sector.
ad_start = 0;
ad_bytes = block.len();
// A continuation block does NOT begin with allocation
// descriptors — it begins with a 24-byte Allocation Extent
// Descriptor (ECMA-167 4/14.5, tag id 258): the 16-byte
// descriptor tag, then prev_allocation_extent_location
// (Uint32 @16) and length_of_allocation_descriptors
// (Uint32 @20). The real ADs (same Short/Long/Extended type
// as the file) start at offset 24, and their total byte
// length is that @20 field.
//
// Reading from offset 0 parses the AED's own tag header as
// allocation descriptors → one garbage extent, then an
// unknown extent_type break — silently truncating every
// file whose ADs spill into a continuation block (a heavily
// fragmented file, e.g. a Blu-ray 3D interleaved base-view
// .m2ts with ~1600 fragments). A normal few-extent .m2ts
// fits inline and never reaches here, which is why this
// stayed hidden.
let aed_l_ad =
u32::from_le_bytes([block[20], block[21], block[22], block[23]]) as usize;
ad_start = 24;
ad_bytes = aed_l_ad.min(block.len().saturating_sub(24));
}
None => break,
}
@@ -1582,8 +1552,15 @@ mod tests {
/// A continuation block: a bare list of short ADs from byte 0.
fn build_cont_block(ads: &[(u32, u32, u32)]) -> [u8; 2048] {
// A continuation block is an Allocation Extent Descriptor (ECMA-167
// 4/14.5): a 16-byte descriptor tag, then prev_allocation_extent_location
// (Uint32 @16) and length_of_allocation_descriptors (Uint32 @20). The
// actual allocation descriptors begin at offset 24. The parser skips the
// 24-byte header and reads `l_ad` bytes of ADs from there.
let mut s = [0u8; 2048];
let mut off = 0usize;
let l_ad = (ads.len() * 8) as u32;
s[20..24].copy_from_slice(&l_ad.to_le_bytes());
let mut off = 24usize;
for &(etype, dlen, dlba) in ads {
let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF);
s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes());
@@ -1637,6 +1614,35 @@ mod tests {
assert_eq!(extents, vec![(10, 4096), (20, 2048)]);
}
#[test]
fn icb_extents_continuation_skips_aed_header_not_read_as_extent() {
// Regression (Blu-ray 3D): a continuation block begins with a 24-byte
// Allocation Extent Descriptor whose 16-byte descriptor tag holds
// NON-zero bytes (tag id 258, CRC, location, ...). Reading allocation
// descriptors from offset 0 parses that tag header as an AD → a garbage
// extent, then an unknown extent_type break → every fragment past the
// first continuation is lost. On a 3D disc that truncated the 25.8 GB
// interleaved base-view feature to ~1.8 GB (113 of ~1600 fragments) and
// then choked the mux on the bogus, non-unit-aligned extent. The parser
// MUST skip the 24-byte AED header and read the real ADs from offset 24.
let icb = build_efe(6144, &[(0, 4096, 10), (3, 2048, 50)]);
let mut cont = build_cont_block(&[(0, 2048, 20)]);
// Stamp a realistic AED descriptor tag (id 258) into the header so a
// regression that reads from offset 0 mis-parses it as a bogus extent
// instead of finding the real (20, 2048) at offset 24.
cont[0..2].copy_from_slice(&258u16.to_le_bytes());
let mut reader = MapReader::new();
reader.put(5, icb);
reader.put(50, cont);
let fs = fs_with(0, 0, file_entry("3D", 5, 6144));
let extents = fs.read_icb_extents(&mut reader, 5).expect("extents");
assert_eq!(
extents,
vec![(10, 4096), (20, 2048)],
"continuation ADs must be read past the 24-byte AED header, not from offset 0"
);
}
#[test]
fn icb_extents_long_ad_returns_all_extents_not_just_first() {
// Regression: BD-ROM large .m2ts files use Long ADs (16-byte
@@ -2391,3 +2397,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,
}
}
}
+56 -12
View File
@@ -9,6 +9,7 @@ use freemkv_unlock as fu;
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(),
@@ -89,7 +90,7 @@ impl fu::scsi::ScsiTransport for ScsiAdapter<'_> {
}
/// Map libfreemkv's host certs (keysource-collected) to the unlock contract's.
pub(crate) fn map_host_certs(certs: &[crate::aacs::HostCert]) -> Vec<fu::HostCert> {
pub(crate) fn map_host_certs(certs: &[crate::aacs::types::HostCert]) -> Vec<fu::HostCert> {
certs
.iter()
.map(|c| fu::HostCert {
@@ -101,27 +102,70 @@ pub(crate) fn map_host_certs(certs: &[crate::aacs::HostCert]) -> Vec<fu::HostCer
.collect()
}
/// News up the unlockers, build the context for `kind`, and run the FIRST
/// matching one — returning its `Result` so the caller can both consume what it
/// learned (vid / bus_key / drive_unlocked) AND render the specific failure
/// (the AACS cert path maps the `UnlockError` to its outcome trace). `Err(
/// NotApplicable)` when nothing matched. `host_certs` are collected by the
/// caller — lazily, only for AACS; pass `&[]` for the drive-prep / CSS kinds.
pub(crate) fn run_unlockers(
/// 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],
) -> std::result::Result<fu::Unlocked, fu::UnlockError> {
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() {
if u.matches(&ctx) {
return u.unlock(&mut adapter, &ctx);
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)
("", 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 —
+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.