Compare commits

..
64 Commits
Author SHA1 Message Date
Matthew Jackson b2ebae8f81 v1.5.2: bump version (freemkv-unlock git-pinned for the tag) 2026-07-22 22:19:39 -07:00
Matthew Jackson b2f35a8afc CSS DVD: resolve the per-title key at read time, drop the scan-time crack
Every DVD read path — the file-backed mux highway (build_iso_pipeline) and
the live-drive single-pass DiscStream — now resolves the per-VTS CSS title
key through one shared step, css::resolve_dvd_title_key, cracked keylessly in
playback order from the title's own extents. Removes the earlier design that
reused a single scan-time key (meaningless for a per-VTS scheme) and muxed a
detection-miss disc's scrambled sectors as garbage.

- Disc::scan no longer cracks a key up front; it does only the CSS bus-auth
  read-unlock, hoisted before the UDF prefetch so scrambled small/menu VOBs
  no longer cost a rejected read each (CSS-DVD scan ~25s -> ~6s).
- An uncrackable title hard-fails (E7023) instead of passing ciphertext as
  plaintext; --raw skips the crack entirely; a Stop mid-crack surfaces Halted.
- DiscStream::new is now fallible and threads raw + halt.
- Fix a stale codec-parser doc claim (TrueHD/FLAC/MP2/AAC do gate via DropTally).
2026-07-22 22:15:27 -07:00
Matthew Jackson a6a4a5d7d2 libfreemkv: 1.5.2 changelog reflects the shipped playback-order crack
The prior wording described the round-1 'self-cracking per-sector' approach
that the audit replaced; rewrite it to match what ships (per-title crack in
playback order + scan-key reuse + loud hard-fail on an uncrackable title).
2026-07-22 13:48:56 -07:00
Matthew Jackson 0de6b82a37 libfreemkv: restore freemkv-unlock path dep on the branch tip
The last release left this pinned to the git tag v1.5.1; release.sh expects
a committed path dep on the branch tip (it swaps path -> git tag only in the
tagged commit, matching bdemu). Restores that state so a local full-workspace
build links the sibling freemkv-unlock, and release.sh's path->tag swap works.
2026-07-22 13:43:32 -07:00
Matthew Jackson b9b0a87328 CSS DVD mux: per-title key via scan-key reuse + playback-order crack
A CSS DVD whose main title was mis-detected as unencrypted (the up-front
crack scanned the largest cell first and starved its budget in that cell's
clear prefix) muxed scrambled sectors as plaintext at exit 0. CSS leaves
the pack/PES header clear, so an un-descrambled sector muxes as a
structurally-valid but corrupt PES packet with zero loss reported.

decrypt_keys_for_title resolves a DVD title's CSS key two ways:
- Fast path: reuse the scan's cracked key when its crack_span covers this
  title's VTS (no re-read; on a live drive no second bus-auth).
- Crack: on a detection miss or a different VTS, crack from the title's OWN
  extents in a SINGLE scan in natural PLAYBACK ORDER (never largest-first).
  One scan = one CSS-locked early-bail, so a locked title is not re-hammered
  per cell against a live drive (hard rule #2); the 50k-sector budget is the
  same accepted bound the disc-wide scan uses. Cracked -> key; Unencrypted
  -> clear; ScrambledUncracked -> hard-fail.
ensure_title_decryptable hard-fails an uncrackable DVD title even when
detection missed, and passes a title that resolved its OWN valid key
regardless of the disc-wide css_error. descramble_region is unchanged from
v1.5.1 (validated-key seed).

Also rename the unlocker report's DVD entry CSS -> DVD. Bump 1.5.2.
2026-07-22 13:07:26 -07:00
Matthew Jackson d6cef9f665 v1.5.1: bump version (freemkv-unlock git-pinned for the tag) 2026-07-20 17:04:24 -07:00
Matthew Jackson b9b9185633 restore freemkv-unlock path dep for local dev (post-v1.5.1) 2026-07-20 17:02:14 -07:00
Matthew Jackson 66951a32ed v1.5.1: bump version (freemkv-unlock git-pinned for the tag) 2026-07-20 17:02:11 -07:00
Matthew Jackson 505a78efd1 mux/codec/truehd: fix MLP major-sync checksum endianness (was dropping the whole TrueHD track)
Regression since the previous release, which added an MLP major-sync checksum
gate to drop genuinely-undecodable audio frames. The checksum itself was computed
with mismatched byte order: `crc16_mlp` is the correct crc_2D table (poly 0x2D,
MSB-first) but returns its two bytes in the OPPOSITE order to libavutil's
`av_crc`, and `mlp_major_sync_crc_ok` then folded in the pre-trailer word
little-endian while comparing the trailer big-endian. The net result never
matched a real major sync, so EVERY major sync was judged corrupt. That armed the
drop-forward on the first AU and, since no major sync ever validated to clear it,
collateral-dropped every following AU forever — the entire TrueHD track was
silently dropped. Its AUs then flushed only at mux end, so the track's blocks
landed physically after all the video: a decoder reading video+TrueHD had to
buffer the whole title to reach the first audio block and spiralled into an
unbounded memory runaway ("decoder ran out of memory"). Every TrueHD title
produced after the gate landed was affected; a title from the release before it
is clean. (The header-size parse — a frequent suspect for extended 7.1/Atmos
headers — is NOT the bug; it already matches ffmpeg's `mlp_get_major_sync_size`
byte-for-byte.)

Fix: compute the checksum exactly as ffmpeg's `ff_mlp_checksum16` —
`crc16_mlp(body).swap_bytes() ^ AV_RL16(word) == AV_RL16(trailer)`. Cross-verified
byte-exact against two real discs (a 7.1/Atmos title and a 5.1 title, independent
32-byte headers both validate). With the checksum correct, major syncs validate
and the drop-forward corruption protection works as intended.

Defence in depth: a major-sync checksum that STILL can't be validated (a genuinely
corrupt or as-yet-unparsed header) no longer arms the drop-forward until we hold a
validated baseline (`num_substreams` from a prior clean major sync) — so a single
bad header can never again silently drop an entire track.

Tests: the `finalize_major_sync` fixture now builds the checksum the corrected way;
a synthetic checksum-failed head major sync is kept, not dropped; the existing
baseline-then-corrupt drop-forward tests still pass. Verified end to end against a
real disc: the TrueHD track demuxes to a full, cleanly-decodable 48 kHz 8-channel
stream, interleaved with the video, instead of 0 bytes.
2026-07-20 16:58:06 -07:00
Matthew Jackson 9b80084220 aacs: parse HD DVD VTKF title keys at the spec's 36-byte stride
The HD DVD Title Key File (VTKF*.AACS) stores 64 title-key entries of 36 bytes
each — 1-byte BIFO + 3 reserved + 16-byte encrypted key + 16-byte binding MAC —
per AACS "HD DVD and DVD Pre-recorded Book" Table 3-8, confirmed byte-exact
against real discs (Freedom VTKF090, Dukes VTKF000: every 36-byte slot has
BIFO=0x80, a clean key, and a 0xFF binding MAC).

The parser used a 32-byte stride (a 12-byte pad in place of the 16-byte binding
MAC) with flag-based termination. That aligns entry #1 (key at offset 132, where
both strides agree) but drifts +4 bytes per entry after it and never terminates
(the previous entry's 0xFF MAC reads as a set present-flag), so it recovered a
correct key only for single-CPS-unit discs and garbage for CPS unit >=2. Every
multi-title HD DVD (Freedom, Harry Potter) was affected.

Fix: 36-byte stride, iterate the fixed 64 slots, take slots whose BIFO AV_FLG
(bit 7) is set, key at offset 4, slot index = CPS unit (skip empty slots rather
than terminate so a gap can't renumber later keys), and never read the trailing
16-byte TKF MAC as a key. Tests rebuilt on the real layout, including a full
64-entry file.

Also correct the VTKF-selection TODO in mod.rs: the AACS HD DVD Book gives the
selector explicitly (match the TKF's PLAYLIST_NAME field to the active
playlist), not the "validate against an encrypted unit" placeholder.

Reconciled against the new HD DVD reference (freemkv.org/docs/hddvd/); the spec
source is archived in private-repo/spec/.
2026-07-20 11:57:13 -07:00
Matthew Jackson b246169e5b aacs: discover HD DVD AACS dir + title-key files instead of hardcoding /ANY!/VTKF000
The HD DVD AACS directory name and title-key filename are chosen by the
authoring house, but the resolver hardcoded a single spelling
(/ANY!/VTKF000.AACS, /ANY!/MKBROM.AACS, /ANY!/CONTENT_CERT.AACS). Real discs
diverge: Freedom (Memory-Tech) names its AACS dir AAC! and ships VTKF090.AACS
+ VTKF100.AACS; Harry Potter carries VTKF000/001/002/099. On such a disc the
hardcoded path finds nothing, so no MKB/title-key/cert is read and decryption
silently can't engage.

Replace the fixed HD DVD path constants with structural discovery:
- find_hddvd_aacs_dir() locates the AACS dir as the root child dir ending in
  '!' that contains MKBROM.AACS (so the ..._BAK mirror is skipped; the dozens
  of decoy advanced-content '!' dirs are excluded by the MKBROM.AACS guard).
- role_paths(udf, role) builds the ordered candidate list per role: the static
  BD/UHD /AACS/ paths first, then the discovered HD DVD files — MKBROM.AACS,
  CONTENT_CERT.AACS, and every VTKF*.AACS (sorted), not just VTKF000.
- read_first() is now generic over &str / String so it takes the Vec<String>.

BD/UHD unaffected (no '!' dir → discovery returns None, list is the /AACS/
constants exactly as before). Verified on real Freedom (AAC!/VTKF090+100) and
Dukes (ANY!/VTKF000) ISOs; unit tests cover both shapes.

Open item (TODO(hddvd-encrypted)): when a disc has multiple VTKF variants the
correct one must be chosen by validating its VUK-derived key against a real
encrypted unit rather than first-that-reads. Blocked on obtaining a genuinely
encrypted HD DVD image — all HD DVD ISOs on hand are already-decrypted rips.
2026-07-20 11:29:28 -07:00
Matthew Jackson 2d96a3e87d release: tee up 1.5.1 (marginal-read reporting fix) 2026-07-19 20:47:52 -07:00
Matthew Jackson bae5a9ea07 drive: report marginal reads (PER=1) so a dirty disc can't pass clean
A read was only ever judged "good" by the drive's SCSI GOOD status — with no
integrity check on the bytes. On dirty/marginal media a drive can silently
return best-effort ECC data (occasionally mis-corrected) with GOOD status, so a
rip could "pass clean" yet decode with errors, pushing corruption downstream to
the mux instead of catching it at the read.

Enable recovered-error REPORTING at drive-prep: MODE SELECT the Read-Write
Error Recovery page with PER=1 (TB on / DTE off so the data still comes back),
preserving the drive's own retry count. Marginal reads now surface as
CHECK CONDITION / RECOVERED ERROR. Best-effort — a drive that doesn't honor it
keeps its defaults (no regression), and on a clean disc nothing changes.

A recovered error is distrusted: it marks just that ECC block NonTrimmed for a
Pass N re-read (a clean re-read wins; a persistent marginal becomes an honest
concealed gap), and is counted in the pass summary. Crucially it takes a
per-block SkipBlock, NOT the damage-jump path — a single marginal sector must
not trigger a 64 MB skip that would discard good data on a lightly-smudged disc.
2026-07-19 20:30:21 -07:00
Matthew Jackson 86ed27508d changelog: 1.5.0 release date + trim to app-level detail 2026-07-19 17:34:21 -07:00
Matthew Jackson f28d79b51b restore freemkv-unlock path dep for local dev (post-v1.5.0) 2026-07-19 15:37:27 -07:00
Matthew Jackson 5b27b9f8a5 v1.5.0: bump version (freemkv-unlock git-pinned for the tag) 2026-07-19 15:37:25 -07:00
Matthew Jackson 405e9d32c3 changelog: drop-on-undecodable audio + forced PGS detection (1.5.0) 2026-07-19 14:29:57 -07:00
Matthew Jackson 21b362c6e8 disc: detect forced PGS subtitles from stream content for info
Give `info` the same forced-subtitle verdict the muxer derives during a
rip, so the two agree. A shared classifier (mux::codec::pgs::ForcedTracker)
folds a PGS track's display sets — forced iff every one carries the
forced_on_flag — and is used by BOTH the MKV writer and a new scan-time
probe that reads the title's PGS streams (reusing the TS demuxer and PGS
parser). The probe only overrides a track it actually observed content
for, so an undecrypted/unread stream keeps its vendor-derived flag. Gated
behind ScanOptions::probe_forced_subtitles (off for the rip path, which
detects forced while muxing without a second read).
2026-07-19 14:23:04 -07:00
Matthew Jackson 9f8df95513 mux/codec/mpegaudio: keep free-format frames
Free-format MPEG-audio (bitrate_index 0) is a legal, decodable mode — the
decoder derives the frame size from the sync spacing. Dropping it was a
false positive on a clean stream, so it now passes the gate.
2026-07-19 14:23:04 -07:00
Matthew Jackson 2f465f4cfe mux/codec/truehd: fix drop-forward poison + rate/resync validation
Three TrueHD state-machine fixes from an adversarial audit:

- A corrupt access unit drops forward to the next major sync, but only
  the individually-verified corruption now feeds the whole-track poison
  verdict; the resync run is collateral. A couple of transient errors can
  no longer poison and discard an otherwise-good multi-hour track. The
  shared drop tally gains a verified/collateral split for this.
- The per-AU PTS rate is refined only from a CRC-validated major sync, so
  a corrupt major sync whose rate nibble decodes to another rate family
  can no longer shift the resumed audio — a drop stays a silence gap.
- The resync clears only on a CRC-validated major sync, never on a runt
  too short to hold and validate its header.
2026-07-19 14:23:04 -07:00
Matthew Jackson 3fdd463794 release: 1.5.0 2026-07-19 13:42:48 -07:00
Matthew Jackson 6a7392ac8c mux: detect forced PGS subtitles from the stream
Flag a PGS subtitle track FlagForced when it displays subtitles and every
one carries the HDMV forced_on_flag (a dedicated forced/narrative track),
independent of the disc's vendor label metadata. The track header
reserves a FlagForced byte up front and it is promoted at finish() from
the accumulated display-set state. Only ever promotes — a track already
forced from the playlist metadata is never demoted.
2026-07-19 13:42:48 -07:00
Matthew Jackson 28bdecd26b mux/codec: drop undecodable audio frames, keep A/V sync
A damaged audio access unit is now dropped rather than muxed as a
decoder-choking glitch. Sync is preserved — a drop becomes a silence
gap, never a shift — and every drop is logged. Detection is per-codec,
each mirroring the format's authoritative integrity check:

  DTS          core-header validity gates
  AC-3/E-AC-3  native frame CRC-16 + bitstream-id range
  FLAC         whole-frame CRC-16 residue
  MP2/MP3      header sanity + free-format reject
  AAC-ADTS     header sanity (raw AAC passes through untouched)
  TrueHD/MLP   major-sync CRC-16 + AU parity; corrupt AUs drop forward
               to the next major sync, since decode state carries
               across access units

LPCM and video are excluded by design (no in-frame integrity data;
inter-frame prediction). A shared DropTally handles counting, logging,
and a whole-track fallback for a mostly-undecodable track.
2026-07-19 13:42:48 -07:00
Matthew Jackson d29d3d71b9 dts: emit clean core alone when the extension boundary is garbage
DTS-HD MA access units are a lossy core frame followed by trailing
extension substreams up to the next core sync. On source-damaged discs
(observed on the Bourne UHDs) the bytes where the XLL extension belongs
are neither a core sync nor an extension sync -- pure garbage -- which
desyncs ffmpeg's XLL decoder and cascades into 'Read past end of XLL
band data' / 'DSYNC check failed' across the whole track.

next_core_boundary now distinguishes three boundary states via a new
ext_clean flag on NextCore::Found:
  - precise/recognized extension sync  -> ext_clean=true  (keep full AU)
  - garbage at the boundary byte       -> ext_clean=false (drop the ext)

When ext_clean is false we emit the DTS core alone (drop the smallest
junk piece, keep the frame and its PTS) and drain past the garbage to
the next core. Recognized-but-unsizeable extensions still ride the
heuristic scan and are kept intact, so lossless tracks are unaffected --
only genuinely corrupt extension bytes are dropped.

Bourne s1.dts: 1606 damaged frames / 691620 (0.232%), 93% isolated
single frames, worst run 3 in a row (~32ms lossy blip).
2026-07-19 09:27:28 -07:00
Matthew Jackson 999898a438 changelog: faststart default + DTS/DTS-HD audio (mp4://) 2026-07-18 22:35:37 -07:00
Matthew Jackson b4470ccd81 mux/mp4: DTS audio (dtsc/dtsh + ddts)
Parse the DTS core header (SFREQ/AMODE/RATE/LFF/NBLKS/FSIZE) → a ddts box
(sample rate, channel layout mask, core size, computed bitrate, LFE);
whole access units (core + DTS-HD extension substreams) pass through, so
an HD decoder finds the extension. dtsh when an extension sync follows the
core, else dtsc. Fit oracle now carries DTS / DTS-HD MA / DTS-HD HR.

Validated on 300 (real DTS-HD MA 7.1): freemkv's mp4 DTS track is
byte-identical to ffmpeg -c copy under ffprobe (dts / 48000 / 8ch / 7.1)
and decodes clean (exit 0). Channel layout correct.
2026-07-18 22:18:35 -07:00
Matthew Jackson 668dd95237 mux/mp4: faststart on by default (reserve-and-fill)
moov now precedes mdat. At create() reserve a moov-sized hole (a free
box) between ftyp and mdat: reserve = round_up_4MB(16 B/sample ×
est_samples) + 4MB buffer, floored at 8MB. Because the hole precedes
mdat, sample offsets are fixed from the start — no rewrite, no offset
patch. finish() writes moov into the hole and pads the slack with a free
box; if the estimate is blown it falls back to moov-at-end. Streams over
HTTP without a pre-fetch. Reserve-math + box-order tests added.
2026-07-18 21:43:44 -07:00
Matthew Jackson 777d9a04a7 changelog: mp4:// input demuxer (1.5.0) 2026-07-18 21:22:41 -07:00
Matthew Jackson b717044ae5 mux/mp4: MP4 demuxer — mp4:// as a source
Read side of mp4://: parse moov/trak/stbl (stsd codecs+hvcC/avcC/channel
info, stsz/stco/co64+stsc → per-sample offsets, stts+ctts → decode/
composition timing, stss → sync), rebuild a DiscTitle, and emit samples
as PesFrames in global decode order. Video NALs are length-prefixed in
MP4 — the exact framing the MKV muxer wants — so no reframing. Wired into
input() so mp4:// flows to every sink (mkv://, audio://, json://, …).
In-memory write→read round-trip test proves symmetry (streams, hvcC,
sample sizes survive). Progressive MP4 only; fragmented (moof) is future.
2026-07-18 21:10:15 -07:00
Matthew Jackson 379767071c mux/mp4: route through WritebackFile (bounded-cache writeback)
Make Mp4Sink generic over a seekable writer; the CLI output() arm wraps
the file in WritebackFile (as mkv:// does) so a UHD-scale mux to slow /
NFS staging avoids the dirty-page burst. The mdat backpatch is an
ordinary seek WritebackFile already handles (seek_then_patch_roundtrip).
Tests switched to in-memory Cursor writers.
2026-07-18 20:57:44 -07:00
Matthew Jackson 6a06443dd8 changelog: mp4:// native MP4 muxer (1.5.0) 2026-07-18 20:44:18 -07:00
Matthew Jackson 42df399156 mux/mp4: M2 — audio tracks + fit oracle (multi-track)
Generalize the sink to N tracks: one video + every MP4-mappable audio
track. Audio sample entries built from the first frame's bitstream —
AC-3 (ac-3/dac3) and E-AC-3 (ec-3/dec3), parsing the (E-)AC-3 BSI for
fscod/bsid/bsmod/acmod/lfeon and the data rate. Per-sample audio
durations from PTS deltas (no reorder → no ctts, all sync). Fit oracle
(mp4_fit_report, exported): video HEVC/H264, audio AC-3/E-AC-3;
TrueHD/DTS/LPCM and bitmap subs are excluded with a typed reason so the
CLI can report exclusions — never a silent drop.

Verified vs ffmpeg -c copy on real discs: AVC+AC3 and HEVC(HDR10)+AC3
both frame-exact on every track (video 2650/4270, audio 5567/3454),
duration and colour identical, clean decode. On a pathological 4-clip
title ffmpeg's OWN output emits the same DTS-monotonicity warnings (more
of them) — source-inherent, not a muxer defect.
2026-07-18 20:37:10 -07:00
Matthew Jackson 9ede4b9afd mux: native progressive MP4 muxer (mp4://) — M1 video track
New mux/mp4: writes ftyp+mdat+moov (moov-at-end), streaming samples into
a 64-bit mdat and building the sample tables in memory, patched at
finish(). Video track (HEVC/AVC): passthrough length-prefixed NALs
(already MP4 framing), full stts/stsz/stsc/co64/stss and signed ctts,
CFR-derived decode timeline (pipeline carries presentation PTS only), and
a colr box for HDR10 colour signalling. hvc1/avc1 sample entry from the
hvcC/avcC codec_private. Fail-loud on codecs with no MP4 mapping.

Verified against ffmpeg -c copy on real discs: AVC-SDR (300) and
HEVC-HDR10 UHD (Dune) both frame-exact (8159 / 8160 frames), colour-exact
(bt2020/smpte2084/bt2020nc), and clean-decoding. Audio + fit oracle land
in M2.
2026-07-18 20:16:19 -07:00
Matthew Jackson 67e3e39499 mux: add video:// sink (video-only per-track elementary streams)
Completes the per-track-class trio with audio:// / sub://. video:// is
the demux path with a TrackKind::Video kind filter — each video track to
its own native elementary stream (.hevc/.h264/.vc1/.m2v/.obu), no audio
or subtitles. Reuses the existing kind_filter machinery and extension
map; scheme/parse/output/write-only-guard arms added symmetrically.
2026-07-18 19:24:59 -07:00
Matthew Jackson 42be09f628 changelog: 1.5.0 — extraction sinks + complete json:// model 2026-07-18 19:13:30 -07:00
Matthew Jackson 8bbbe43724 mux: json:// emits the complete title model (no dropped fields)
The json:// sink previously emitted only codec + language + pid per
stream. Fill it out to the full DiscTitle: video carries resolution
(+ pixel dims, interlaced), frame rate (+ fraction), HDR, colour space,
display aspect, and measured CICP; audio carries channels (+ count),
sample rate (+ Hz), and editorial purpose; subtitles carry the
qualifier. Add the clip list and chapter names. This is everything the
scan resolved, so json:// is a lossless machine-readable view of a
title rather than a summary.
2026-07-18 18:14:11 -07:00
Matthew Jackson 95888f27a4 test: pin audio://sub://chapters://json:// in the scheme round-trip 2026-07-18 17:46:08 -07:00
Matthew Jackson 96dec65040 mux: chapters:// and json:// metadata sinks
Two write-only file sinks that ignore the PES stream and emit the title
metadata at construction (fvi-style, wired through output()):
- chapters:// — chapter markers in the format the extension picks: .xml
  (Matroska, default), .txt/.ogm (OGM simple), .vtt (WebVTT). Reuses the
  existing chapters_xml/chapters_ogm writers; adds a WebVTT writer.
- json:// — one title's model (playlist, duration, size, format, streams,
  chapters) as pretty JSON via serde_json.

New mux/meta_sink.rs; StreamUrl gains Chapters/Json; codec_label +
chapters_xml/ogm promoted to pub(crate) for reuse.

Tests: chapters_format_selected_by_extension, title_json_carries_streams_and_chapters.
2026-07-18 17:45:39 -07:00
Matthew Jackson 378fb226b7 mux: audio:// and sub:// sinks (demux filtered by track class)
Two extraction sinks built on the existing demux machinery: audio:// keeps
only audio tracks (native containers .thd/.dts/.ac3/.eac3/.pcm...), sub://
only subtitle tracks (PGS .sup, VobSub .idx+.sub, text .srt) — one file per
track, no video, no chapters sidecar. DemuxOptions gains a kind_filter; the
DemuxSink writers (already complete) are reused verbatim. StreamUrl gains
Audio/Sub variants; output() builds the filtered DemuxSink.

Test: kind_filter_keeps_only_the_selected_class.
2026-07-18 17:24:28 -07:00
Matthew Jackson 42caa2d90e restore freemkv-unlock path dep for local dev (post-v1.4.5) 2026-07-18 16:08:03 -07:00
Matthew Jackson 41a2493429 v1.4.5: bump version (freemkv-unlock git-pinned for the tag) 2026-07-18 16:08:01 -07:00
Matthew Jackson 0e73650440 changelog: 1.4.5 (FMTS clean single-variant mux + key-Debug redaction + hex fix) 2026-07-18 13:30:36 -07:00
Matthew Jackson bcfd5eb213 mux: FMTS read-plan on the inline live-drive path too (DiscStream::with_key_map)
The read_plan fix landed on the file-backed highway (build_iso_pipeline)
but the inline DiscStream (live single-pass) still passed the alternate
device-group half to the demux — a known FMTS bug on rip_mode="single".

DiscStream::with_key_map installs the proactive map and rewrites the
extent walk to the read plan, so the live single-pass path reads ONLY
our-phase units, exactly like the highway. DecryptingSectorSource gains
set_key_map for the already-constructed decorator. Non-forensic maps
return the extents unchanged, so every non-FMTS disc is byte-identical.

Test: with_key_map_reads_only_our_phase_units — a forensic Even segment
drops exactly its alternate units from the extent walk. Precommit green
on 1.86.
2026-07-18 13:27:48 -07:00
Matthew Jackson 8ce0a66bee mux: FMTS reads only our-phase units (AacsKeyMap::read_plan)
An FMTS forensic segment interleaves our device group's variant with a
foreign group's at the aligned-unit level. The mux was decrypting only
our phase but leaving the alternate (foreign) units in the buffer as
ciphertext, trusting the demux to 'drop untouched ciphertext cleanly'.
It doesn't: random 0x47 bytes at the 192-byte stride hit tracked PIDs,
mis-parse, and trip the demux's concealed-gap keyframe-resync — which
drops GOOD frames of ours around every segment (349 resyncs / ~6391
packets on Stand by Me, visible as playback flaws).

The map already knows which unit each LBA is and, for a forensic
segment, which phase is ours. AacsKeyMap::read_plan turns that into the
title's read plan: every default/CPS unit, plus inside a segment ONLY
our-phase units. The alternate units are never fetched, decrypted, or
handed to the demux — the demux sees one gapless our-variant stream.

- read_plan is general (single-CPS, multi-CPS, FMTS): a map with no
  Even/Odd range returns the extents unchanged, so DVD/CSS, single-CPS
  UHD and multi-CPS Blu-ray read byte-for-byte as before.
- Wired into build_iso_pipeline (the file-backed highway that muxes
  resumed ISOs). Producer re-anchors unit_base per extent, so per-unit
  segment reads stay unit-aligned and decrypt correctly.
- Extent gains PartialEq/Eq for the read_plan tests.

Tests: read_plan non-forensic unchanged; forensic omits exactly the
alternate units, kept units match the decrypt gate unit-for-unit. All
2314 lib tests pass; precommit green on 1.86.

Pending: end-to-end ISO re-mux validation (concealed gaps 349 -> ~0).
2026-07-18 12:06:20 -07:00
Matthew Jackson 7d4d9a3e84 remove user-facing English from the library (KeyOrigin::name, hddvd Title)
Library holds ZERO user-facing English (CLAUDE.md). Removed KeyOrigin::name()'s
English prose — apps map the typed enum (freemkv gets key_origin_label); diag
logs the enum's Debug repr. hddvd unnamed-title fallback 'Title N' -> neutral
TITLE_N identifier (UDF volume-label style).
2026-07-17 21:50:09 -07:00
Matthew Jackson 4dec71a867 narrow leaked-internal pub surface to pub(crate)
Verified zero callers across all consumer crates AND libfreemkv integration tests:
MUX_APP, Disc::{aacs_disc_hash,encrypted_content_ranges,inject_unit_keys},
locate_ranges, mapfile::{MapEntry,entries}, diag::dump_mkv_track,
DiscStream::{errors,lost_bytes} (read via accessors). Removed the unused mux
DemuxSink/FviSink crate-root re-exports (constructed internally by output() via
the direct module path). Staged demux option variants marked allow(dead_code).
2026-07-17 21:45:33 -07:00
Matthew Jackson a9b29c7060 disc: collapse decrypt_keys_for_title twin into one method
Removed the test-only unchecked wrapper and renamed decrypt_keys_for_title_checked
-> decrypt_keys_for_title (one method returning (keys, title_is_clear)). Production
already used the checked form; the _checked suffix had no counterpart. Callers +
docs updated; two tests take .0.
2026-07-17 21:41:10 -07:00
Matthew Jackson 0cb525517b css: remove redundant crack_key_halt wrapper
crack_key_halt had no caller except the crack_key wrapper — a needless middle
layer. crack_key now calls crack_key_scan directly; crack_key (Option) and
crack_key_outcome (full CrackOutcome + halt) remain as the two real entry points.
2026-07-17 21:34:37 -07:00
Matthew Jackson 5b4a3f32dc mux: collapse FviSink::create/create_with_source into one create
create_with_source (full SourceInfo) was only ever called by the create wrapper
with a partial SourceInfo — the extra capability was unused (YAGNI). Inlined the
constructor body into create and deleted the _with_source variant; callers
unchanged.
2026-07-17 21:32:39 -07:00
Matthew Jackson 66b0bc385c mux: collapse MkvStream::create/create_at into one create(.., Option<&Path>)
One method per action (CLAUDE.md), not a foo_with_X pair — create_at was the real
constructor and create was a None wrapper. Callers pass Option<&Path> directly.
2026-07-17 21:31:29 -07:00
Matthew Jackson aaccd83750 hex: canonical hex->integer parsers + public strip_hex_prefix
Adds parse_hex_u16/u32/u8 and exposes strip_hex_prefix so callers stop hand-rolling
from_str_radix(trim_start_matches("0x")) — a case-sensitive strip that this module
exists to prevent. disc::aacs_disc_hash now uses strip_hex_prefix.
2026-07-17 21:25:59 -07:00
Matthew Jackson 40f5b01a12 disc: redact Debug for AacsState/Key/HandshakeResult (test-guarded)
AacsState (public via Disc.aacs) and Key (the decrypt_with key-transport enum)
are crate-root re-exported and carried VUK/unit/read-data keys + volume id on
#[derive(Debug)]; HandshakeResult carried the VID + AACS 2.0 bus key. Manual
Debug impls print shape only, guarded by red->green tests.
2026-07-17 21:10:51 -07:00
Matthew Jackson 82d42b1930 aacs: redact Debug for ResolvedChain/ResolvedKeys/ProcessingKeyMatch (test-guarded)
These carry raw unit-key / VUK / processing-key bytes on their Debug; manual impls
print shape only (unit_keys_len, redacted markers). Each has a red→green test.
2026-07-17 21:03:31 -07:00
Matthew Jackson 913b01b79f css: redact CssState Debug (test-guarded)
CssState is reachable via the public Disc.css field; #[derive(Debug)] leaked the
raw CSS title key on any {:?} of a Disc. Manual Debug prints crack_span only.
2026-07-17 21:00:34 -07:00
Matthew Jackson af7ef59f3e aacs/types: redact Debug for all key-bearing types (test-guarded)
DeviceKey/HostCert/Vid/MediaKey/Vuk/ProcessingKey/UnitKey/DiscEntry carried key
material on #[derive(Debug)] — a stray {:?} would leak device/host-private/media/
volume/unit keys. Manual Debug impls print shape only. redaction_tests asserts no
key bytes appear and a redaction marker is present (fails if a derive returns).
2026-07-17 20:56:38 -07:00
Matthew Jackson ef8e11b6f3 libfreemkv: phase-aware FMTS decode + two-operation KeyFetch/KeySource seam
FMTS (AACS 2.1) now decodes per (LBA, phase): Phase enum + AacsKeyMap::
from_ranges_phased, decrypt only the variant's parity half. resolve_fmts_key_map
does a 2-phase index-1 anchor then per-index phase probe, and sizes the forensic
set to whatever the source returns (no hardcoded 32). KeyFetch is now two explicit
operations (unit_keys / fmts_indexes) and KeySource splits get_uk into
get_unit_keys + get_fmts_indexes. BYPASS_FMTS_KEY gate removed (first-class format).

Teed up for 1.4.5. Local WIP baseline.
2026-07-17 20:51:15 -07:00
Matthew Jackson e1f897fb32 restore freemkv-unlock path dep for local dev (post-v1.4.4) 2026-07-16 21:44:08 -07:00
Matthew Jackson 602e44fde9 v1.4.4: bump version (freemkv-unlock git-pinned for the tag) 2026-07-16 21:44:05 -07:00
Matthew Jackson 622008650c keysource: add DecodeSampleSet — a >=MIN_SAMPLE_UNITS-by-construction sample set so an online request can't be built under-sized 2026-07-16 21:43:02 -07:00
Matthew Jackson c508484451 restore freemkv-unlock path dep for local dev (post-v1.4.3) 2026-07-16 21:06:04 -07:00
Matthew Jackson cf16f66e07 v1.4.3: bump version (freemkv-unlock git-pinned for the tag) 2026-07-16 21:06:01 -07:00
Matthew Jackson adcab75409 aacs: sample only index-1 forensic segments for the online key query; hoist MIN_SAMPLE_UNITS into the base crate 2026-07-16 21:01:16 -07:00
Matthew Jackson 61e4095727 FMTS: resolve the index key map from one forensic keyserver query
The keyserver protocol now returns all 32 index keys as an array for a
forensic content sample (and a single-element array for plain content).
resolve_fmts_key_map sends one forensic batch and maps array element i to
segment index i+1, replacing the per-index blind-probe collection loop
that repeatedly hit the key service. Segment/index parsing and the
aligned-unit content classification are reworked to support this:

- rename variant_select -> index_select (per-index, not per-variant)
- content classification moves to is_clean(buf, ContentFormat) so the
  unit selector emits only units the key service accepts
- segment.rs: parse IndividualSegment.tbl index tags + SPN ranges,
  build contiguous LBA key ranges from the resolved 32-key array
- decrypt/decorator plumbing for the resolved per-index keys

Fail loud (FmtsKeyMissing) when the forensic query returns < 32 keys or
any segment index stays unresolved.
2026-07-16 19:41:44 -07:00
Matthew Jackson 540f84c7d0 restore freemkv-unlock path dep for local dev (post-v1.4.2) 2026-07-15 19:37:54 -07:00
55 changed files with 10075 additions and 982 deletions
+191
View File
@@ -1,5 +1,196 @@
# Changelog
## [1.5.2] — UNRELEASED
### Fixed
- CSS DVDs no longer mux to garbage. Every DVD read path — the file-backed mux
highway (`build_iso_pipeline`) and the live-drive single-pass `DiscStream` —
now resolves the per-VTS title key at read time through one shared step
(`resolve_dvd_title_key`), cracked keylessly in playback order from the title's
own extents. An uncrackable title hard-fails (E7023) instead of passing
scrambled sectors through as plaintext; `--raw` skips the crack entirely; a
user Stop mid-crack surfaces as `Halted`.
### Changed
- DVD scan no longer cracks a title key up front (the key is per-VTS, so a single
disc key was meaningless). Scan does only the CSS bus-auth read-unlock — hoisted
before the UDF prefetch so scrambled small/menu VOBs no longer cost a rejected
read each. Cuts a CSS-DVD scan from ~25s to ~6s.
- Unlocker report: the DVD entry is renamed `CSS` → `DVD`.
## [1.5.1] — 2026-07-20
### Fixed
- **TrueHD audio is no longer silently dropped (and no longer sends decoders out
of memory).** The previous release added an MLP major-sync checksum gate to drop
genuinely-undecodable audio frames, but the checksum was computed with mismatched
byte order — the 16-bit CRC result was folded in one endianness and compared in
the other — so it never validated a real major sync. The parser then judged every
major sync corrupt and dropped every audio frame from the first one onward,
flushing the whole TrueHD track at the end of the file: de-interleaved from the
video, which sent players and integrity checkers into an unbounded memory spiral
("decoder ran out of memory"). The checksum now matches the reference
implementation byte-exact (cross-verified against real 7.1/Atmos and 5.1 discs),
so major syncs validate and only genuinely-corrupt frames are dropped; as a
safety net, a major sync the parser still can't validate is kept rather than
allowed to drop an entire track. TrueHD titles produced after the checksum gate
landed need a re-rip.
- **HD DVD AACS key files are now found on every disc, not just the common
layout.** The AACS directory and title-key filename on HD DVD are chosen by
the authoring house, and freemkv previously assumed one fixed spelling
(`/ANY!/VTKF000.AACS`). Discs that name their AACS directory differently (e.g.
`AAC!` instead of `ANY!`) or ship numbered title-key files (`VTKF090.AACS` /
`VTKF100.AACS` rather than `VTKF000.AACS`) are now handled: the AACS directory
is located by its contents and every title-key file in it is picked up. Blu-ray
and UHD are unaffected.
- **HD DVD multi-title decryption reads the right keys.** The HD DVD title-key
file (`VTKF*.AACS`) stores its keys in 36-byte records — per the AACS HD DVD
specification, and confirmed byte-exact on real discs. freemkv had been reading
them at a 32-byte stride, which lands the first key correctly but drifts off
every key after it, so only single-title discs decrypted. Discs with more than
one protected title now recover every title's key instead of only the first.
(Choosing the correct title-key file when a disc carries several playlists
still needs verification against an encrypted HD DVD.)
- **A dirty disc can no longer "rip clean" but decode with errors.** freemkv now
asks the drive to *report* marginal reads instead of silently returning
best-effort data as success — on smudged/scratched media a drive can hand back
subtly-wrong bytes with a clean status, which used to slip through the rip and
surface only as playback/decode errors. A read the drive had to fight for is
now distrusted and re-read in the patch pass: a clean re-read wins, and a spot
that's genuinely unreadable becomes an honest gap rather than silently-wrong
data. Best-effort per drive, and it changes nothing on a clean disc.
## [1.5.0] — 2026-07-19
### Added
- **MP4 as a source (`mp4://`)** — read a progressive `.mp4` back in and send it to
any sink (`mp4:// mkv://`, `mp4:// audio://`, `mp4:// json://`, …). The round-trip
is frame-exact.
- **Native MP4 output (`mp4://`)** — a disc goes straight to a play-everywhere
`.mp4` in one decrypt pass, no ffmpeg. Carries HEVC / H.264 video (with HDR10) and
AC-3, E-AC-3, and DTS / DTS-HD audio, and is faststart by default so it plays over
HTTP without downloading the end first. It's a **compatibility export, not
archival**: MP4 can't hold TrueHD, LPCM, or bitmap (PGS / VobSub) subtitles, so
those are **excluded with a loud, itemized report — never a silent drop**
(`mkv://` stays the keep-everything path).
- **Five extraction sinks — dissect a title, don't just rip it.** New destinations
that pull one part of a title out on its own:
- **`video://dir/`** — each video track to its own native elementary-stream file.
- **`audio://dir/`** — each audio track to its own file in its native container
(`.thd`, `.dts`, `.ac3`, `.eac3`, `.aac`, `.flac`; LPCM as `.pcm`).
- **`sub://dir/`** — each subtitle track to its own file (PGS `.sup`, VobSub
`.idx` + `.sub`, text `.srt`).
- **`chapters://file`** — a title's chapter markers as a sidecar (`.xml` / `.txt`
/ `.ogm` / `.vtt`).
- **`json://file`** — a title's complete structure as JSON.
`chapters://` and `json://` read nothing of the elementary streams, so they
return in seconds.
- **Damaged audio frames are dropped instead of shipped as glitches.** When a
source disc has a corrupt audio frame, freemkv drops that frame rather than muxing
it as a decoder-choking glitch — keeping A/V in sync (a drop is a silence gap,
never a shift) and logging every drop. Works across DTS, AC-3 / E-AC-3, FLAC,
MP2 / MP3, AAC, and TrueHD, each using the format's own integrity check; a track
that is mostly undecodable is dropped whole. This catches structurally-broken
frames — corruption of the audio *data* inside an otherwise-valid frame is source
damage that can't be told from good data without decoding.
- **Forced subtitles detected from the stream.** A PGS subtitle track is flagged
forced when its content is entirely forced/narrative subtitles, read from the
stream itself rather than the disc's metadata — so it works on discs that carry
none. `info -v` reports the same, so `info` and a rip agree.
### Changed
- **`json://` emits the complete title model** — every field the scan resolved:
video resolution / frame rate / HDR / colour, audio channel layout / sample rate /
language / purpose, the subtitle forced flag, plus the clip list and chapter names.
### Fixed
- **TrueHD: a couple of transient errors no longer discard a whole track.** A
corrupt access unit is dropped forward to the next clean sync point, but a short
burst of damage no longer trips the whole-track drop, and a corrupt sync point can
no longer shift the audio that follows.
- **Free-format MP2 / MP3** is a legal, decodable mode and is no longer dropped.
## [1.4.5] — 2026-07-18
### Fixed
- **FMTS (AACS 2.1) forensic discs now mux to a clean, single-variant stream.** A
forensic segment interleaves the local device group's variant with a foreign
group's at the aligned-unit level. The mux decrypted only our half but left the
foreign half in the buffer as ciphertext, on the assumption that the demuxer
"drops untouched ciphertext cleanly." It does not — a foreign unit's bytes hit a
tracked PID at the 192-byte stride, mis-parse, and trip the demux's concealed-gap
keyframe resync, which discards good frames of ours around every segment (visible
playback glitches). `AacsKeyMap::read_plan` now turns the map into the title's
read plan: every default / CPS unit, plus inside a forensic segment **only our
phase's units**. The foreign half is never read, decrypted, or handed to the
demux. On a retail 4K UHD title this took concealed-gap resyncs from **349 → 0**
and recovered ~2 GB of previously-dropped frames. Wired into **both** mux paths —
the file-backed highway (`build_iso_pipeline`) and the inline live-drive
`DiscStream` (`with_key_map`) — so single- and multi-pass FMTS rips are both clean.
### Changed
- **Key-bearing types redact their `Debug` output.** Every type that carries key
material (device keys, processing keys, unit keys, media keys, VUKs, resolved
chains, CSS/AACS state, …) now prints a `<redacted>` marker instead of the bytes,
so no key can reach a log or panic message. Each is covered by a test asserting no
key byte appears.
- **Hex parsing is centralized and case-insensitive.** A single set of canonical
`0x`/`0X`-tolerant hex→integer parsers replaces scattered ad-hoc parsing (this is
what silently dropped keydb device keys written with an uppercase `0X` prefix).
- **Internal-only public surface narrowed to `pub(crate)`, and duplicate
`foo_with_X` methods collapsed to one** — no behavioral change, smaller API.
## [1.4.4] — 2026-07-17
### Fixed
- **Online key requests are no longer silently dropped on discs that yield few
sample units.** The online key source refuses any request carrying fewer than
`MIN_SAMPLE_UNITS` (8) encrypted-content samples — too few can match an
incidental unit rather than the one asked about (a false positive, most acute on
AACS 2.1 forensic-variant content). autorip gathered only 4, so every online
lookup was skipped before it ever reached the key service and surfaced to the
user as "key service down." autorip's sample count is now tied to
`MIN_SAMPLE_UNITS` with a **compile-time floor**, so it can never regress below
the minimum again.
### Changed
- **The online request is assembled from a proven-sufficient sample set.** New
`DecodeSampleSet` (`libfreemkv::keysource`) wraps the content-unit samples and
can only be constructed with at least `MIN_SAMPLE_UNITS` of them — so an online
key request cannot be built from too few samples. The minimum is validated once,
at construction, rather than by a runtime check a caller could forget.
## [1.4.3] — 2026-07-17
### Changed
- **`MIN_SAMPLE_UNITS` moved to the base crate.** The minimum sample count an
online key request must carry now has a single definition in
`libfreemkv::keysource`; `freemkv-keysources` re-exports it, so the online source
and libfreemkv's own forensic query size their requests from one shared value.
- **The online unit-key reply is parsed as a list.** A response carries either a
single Unit Key (ordinary disc) or the full ordered set (an AACS 2.1
forensic-variant disc); the client accepts both and maps array position to
forensic index.
### Added
- **Forensic-variant online query samples the anchor segment.** On an AACS 2.1
forensic-variant disc the online key query draws its sample from the first
forensic segment (index 1) — one canonical, deterministic sample — instead of an
arbitrary segment.
## [1.4.2] — 2026-07-15
### Fixed
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.4.2"
version = "1.5.2"
edition = "2024"
rust-version = "1.86"
license = "MIT"
@@ -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.4.2" }
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.5.2" }
num-bigint = "0.4"
num-traits = "0.2"
num-integer = "0.1"
+82 -73
View File
@@ -53,31 +53,6 @@ const TS_SYNC: u8 = 0x47;
// ── Content decryption ──────────────────────────────────────────────────────
/// True if a 6144-byte aligned unit's MPEG-TS sync structure is DESTROYED — it
/// lacks the `0x47` sync bytes a clear BD-TS unit carries at offsets 4, 196,
/// 388, … (one per 192-byte source packet).
///
/// This is a pure BYTE heuristic; on its own it does NOT mean "encrypted". A
/// destroyed sync structure can be AACS ciphertext, uncorrected-ECC garbage, OR
/// data that was never MPEG-TS at all (UDF filesystem / nav) — those are
/// byte-indistinguishable. So this answers only *"does this unit look like valid
/// clear TS, or not"*, nothing about encryption.
///
/// The "is this unit AACS-encrypted (and must decrypt)?" decision is COMPOSED by
/// the caller, because it needs context this function lacks:
/// `inside an m2ts content extent` AND `ts_sync_destroyed` AND `no key decrypts`
/// (see [`crate::decrypt::decrypt_sectors_in_content`] and
/// [`crate::Disc::encrypted_content_ranges`]). Inside known content this
/// primitive separates an encrypted/garbled unit (destroyed) from a clear
/// segment (intact); OUTSIDE content it is meaningless — feeding it filesystem
/// bytes is what produced the first-2 GB false-positive this split fixes.
///
/// Flag-independent: it does NOT read the TP_extra copy-control bits (byte 0) or
/// the TS scrambling-control bits (byte 7) — AACS sets neither reliably.
pub fn ts_sync_destroyed(unit: &[u8]) -> bool {
unit.len() >= ALIGNED_UNIT_LEN && !ts_syncs_intact(unit)
}
/// HD-DVD `.evo` (MPEG-2 Program Stream) AACS-encrypted-unit flag offset & mask.
///
/// BD/UHD/FMTS flag encryption with the Copy Permission Indicator in the top 2
@@ -144,12 +119,13 @@ pub fn aacs_unit_encrypted(unit: &[u8], format: crate::disc::ContentFormat) -> b
///
/// Like the flag itself this is only meaningful at the clip-FILE-anchored boundary.
pub fn aacs_unit_needs_decrypt(unit: &[u8], format: crate::disc::ContentFormat) -> bool {
use crate::disc::ContentFormat;
aacs_unit_encrypted(unit, format)
&& match format {
ContentFormat::BdTs => ts_sync_destroyed(unit),
ContentFormat::MpegPs => !is_clean_ps(unit),
}
// "Still needs the key applied" = flagged encrypted AND not yet structurally
// clean. There is ONE definition of clean — [`is_clean`] (the min(E,4) proof
// floor: E>4 needs any 4 synced, E<=4 needs all present). Never a second
// threshold: the old >50% majority false-flagged a bad-encoded-but-OPENED
// unit as still-scrambled, so the mux re-sampled it to the key service every
// batch (the storm) and could re-apply the key over already-clear bytes.
aacs_unit_encrypted(unit, format) && !is_clean(unit, format)
}
/// Minimum synced content packets that PROVE a key opened a unit. Four `0x47`
@@ -241,10 +217,6 @@ pub fn ts_packet_total(unit: &[u8]) -> usize {
unit.len() / BD_SOURCE_PACKET_BYTES
}
fn ts_syncs_intact(unit: &[u8]) -> bool {
ts_sync_count(unit) > ts_packet_total(unit) / 2
}
/// The Program-Stream arm of [`is_clean`] (HD-DVD `.evo`): a pure structural
/// check that a unit is valid MPEG-2 PS — every 2048-byte pack begins with the
/// pack_start_code `00 00 01 BA`; a 6144-byte AACS unit spans three packs.
@@ -421,7 +393,10 @@ mod tests {
unit[off] = TS_SYNC;
off += BD_SOURCE_PACKET_BYTES;
}
assert!(!ts_sync_destroyed(&unit));
assert!(crate::aacs::content::is_clean(
&unit,
crate::disc::ContentFormat::BdTs
));
assert!(
!aacs_unit_encrypted(&unit, ts),
"byte-0 CPI clear ⇒ not flagged encrypted"
@@ -448,54 +423,70 @@ mod tests {
}
#[test]
fn scramble_detection_at_16_32_boundary() {
// With 32 stride positions the majority threshold is
// total/2 = 16. A unit with EXACTLY half its syncs intact (16) must
// NOT be over-counted into the "scrambled" bucket by an inflated
// total: 16 > 16 is false → not-intact → scrambled. 17 intact → clear.
// The fix is that `total` is 32 (not 33), so the boundary sits cleanly
// at the real midpoint.
let set_syncs = |n: usize| {
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
fn is_clean_min4_proof_floor() {
// ONE rule: a unit is clean iff `synced >= min(E, 4)` over the ENCRYPTED
// (non-padding) packets — E>4 needs any 4, E<=4 needs all present. Build
// NON-ZERO payloads (real content, not padding) so every packet counts
// toward E; place `n` TS syncs among packets 1..31 (packet 0 is skipped).
let unit_with = |synced: usize| {
let mut unit: Vec<u8> = (0..ALIGNED_UNIT_LEN)
.map(|i| ((i * 7 + 1) as u8) | 1)
.collect();
// Scrub any accidental 0x47 at a sync position, then place exactly
// `synced` real syncs in packets 1.. (skip packet 0).
let mut off = BD_SOURCE_PACKET_BYTES + 4;
let mut placed = 0;
while off < ALIGNED_UNIT_LEN && placed < n {
unit[off] = TS_SYNC;
off += BD_SOURCE_PACKET_BYTES;
while off < ALIGNED_UNIT_LEN {
unit[off] = if placed < synced { TS_SYNC } else { 0x46 };
placed += 1;
off += BD_SOURCE_PACKET_BYTES;
}
unit
};
assert_eq!(ts_sync_count(&set_syncs(16)), 16);
assert_eq!(ts_sync_count(&set_syncs(17)), 17);
// Exactly half intact → classified scrambled (16 > 16 is false).
assert!(ts_sync_destroyed(&set_syncs(16)));
// One past half → classified clear.
assert!(!ts_sync_destroyed(&set_syncs(17)));
// E = 31 content packets (all non-zero) → threshold min(31,4) = 4.
assert!(
!crate::aacs::content::is_clean(&unit_with(3), crate::disc::ContentFormat::BdTs),
"3 synced of a well-populated unit is below the proof floor → not clean"
);
assert!(
crate::aacs::content::is_clean(&unit_with(4), crate::disc::ContentFormat::BdTs),
"4 synced proves the key opened it, even with many bad-encoded packets"
);
// The old >50% majority would have called `unit_with(4)` scrambled (4/31
// < half) — that false-flag was the mux key-server storm. min(E,4) fixes it.
}
#[test]
fn scramble_detection_extremes() {
// Detection semantics for the clear-cut cases must be preserved:
// a fully-clear unit (all 32 syncs) is NOT scrambled; a unit with no
// syncs (fully scrambled body) IS scrambled.
// A fully-clear unit (every packet synced) is clean; a fully-scrambled
// unit (non-zero ciphertext, NO syncs) is not. (An all-zero buffer is
// empty padding — E==0 — which `is_clean` treats as clean, NOT scrambled.)
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
clear[off] = TS_SYNC;
off += BD_SOURCE_PACKET_BYTES;
}
assert_eq!(ts_sync_count(&clear), 32);
assert!(
!ts_sync_destroyed(&clear),
"fully-clear unit → not scrambled"
crate::aacs::content::is_clean(&clear, crate::disc::ContentFormat::BdTs),
"fully-clear unit → clean"
);
let scrambled = vec![0u8; ALIGNED_UNIT_LEN];
assert_eq!(ts_sync_count(&scrambled), 0);
assert!(ts_sync_destroyed(&scrambled), "no syncs → scrambled");
// Real scrambled ciphertext: non-zero everywhere, no 0x47 at any sync slot.
let mut scrambled: Vec<u8> = (0..ALIGNED_UNIT_LEN)
.map(|i| ((i * 13 + 3) as u8) | 1)
.collect();
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
if scrambled[off] == TS_SYNC {
scrambled[off] = 0x46;
}
off += BD_SOURCE_PACKET_BYTES;
}
assert!(
!crate::aacs::content::is_clean(&scrambled, crate::disc::ContentFormat::BdTs),
"non-zero body with no syncs → scrambled"
);
}
#[test]
@@ -574,9 +565,15 @@ mod tests {
// Now plain contains encrypted data. Decrypt it.
let mut unit = plain;
assert!(ts_sync_destroyed(&unit));
assert!(!crate::aacs::content::is_clean(
&unit,
crate::disc::ContentFormat::BdTs
));
decrypt_unit(&mut unit, &unit_key);
assert!(!ts_sync_destroyed(&unit)); // decrypted: TS syncs restored
assert!(crate::aacs::content::is_clean(
&unit,
crate::disc::ContentFormat::BdTs
)); // decrypted: TS syncs restored
// Verify TS sync bytes
let mut count = 0;
@@ -1151,14 +1148,17 @@ mod tests {
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &unit_key);
assert!(
ts_sync_destroyed(&unit),
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
"encrypted unit must look scrambled"
);
decrypt_unit(&mut unit, &unit_key);
// All 32 stride positions carry sync after decrypt.
assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit));
assert!(!ts_sync_destroyed(&unit));
assert!(crate::aacs::content::is_clean(
&unit,
crate::disc::ContentFormat::BdTs
));
}
#[test]
@@ -1291,13 +1291,22 @@ mod tests {
// The function guards on `len >= ALIGNED_UNIT_LEN` first; anything
// shorter is reported NOT scrambled (so the decrypt gate skips it)
// rather than indexing past the end.
assert!(!ts_sync_destroyed(&[]));
assert!(!ts_sync_destroyed(&vec![0u8; ALIGNED_UNIT_LEN - 1]));
assert!(crate::aacs::content::is_clean(
&[],
crate::disc::ContentFormat::BdTs
));
assert!(crate::aacs::content::is_clean(
&vec![0u8; ALIGNED_UNIT_LEN - 1],
crate::disc::ContentFormat::BdTs
));
// A scrambled-looking buffer that is one byte short is still "not
// scrambled" by the length guard.
let mut almost = vec![0u8; ALIGNED_UNIT_LEN - 1];
almost[4] = 0x00; // no syncs
assert!(!ts_sync_destroyed(&almost));
assert!(crate::aacs::content::is_clean(
&almost,
crate::disc::ContentFormat::BdTs
));
}
#[test]
+38 -1
View File
@@ -465,7 +465,7 @@ pub enum KeyCandidate {
/// its declared CPS-unit number); the caller runs
/// `decrypt_unit` + `is_clean_ts` to find which one actually opens the
/// disc. Rungs above the candidate are `None`.
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct ResolvedChain {
pub unit_keys: Vec<(u32, [u8; 16])>,
pub vuk: Option<Vuk>,
@@ -475,6 +475,21 @@ pub struct ResolvedChain {
pub dk: Option<DeviceKey>,
}
// Redacting `Debug`: `unit_keys` holds raw title-key bytes, never printed. The
// other rungs are `types` newtypes that self-redact. Guarded by
// `resolved_chain_debug_is_redacted`.
impl std::fmt::Debug for ResolvedChain {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResolvedChain")
.field("unit_keys_len", &self.unit_keys.len())
.field("vuk", &self.vuk)
.field("mk", &self.mk)
.field("pk", &self.pk)
.field("dk", &self.dk)
.finish()
}
}
/// 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:
@@ -568,6 +583,28 @@ mod resolve_candidate_tests {
use super::*;
use crate::aacs::crypto::aes_ecb_encrypt;
/// `ResolvedChain.unit_keys` holds raw title-key bytes (the other rungs are
/// self-redacting `types` newtypes). `Debug` must not leak the title keys.
#[test]
fn resolved_chain_debug_is_redacted() {
let c = ResolvedChain {
unit_keys: vec![(1, [0xD5; 16])],
vuk: None,
mk: None,
pk: None,
dk: None,
};
let dbg = format!("{c:?}");
assert!(
!dbg.contains("213"),
"ResolvedChain leaked unit keys: {dbg}"
);
assert!(
dbg.contains("unit_keys_len"),
"ResolvedChain missing redaction: {dbg}"
);
}
/// 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> {
@@ -1,73 +1,73 @@
//! FMTS variant selection — the pure decode-time decision for a 2.1 disc.
//! FMTS index 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,
//! A 2.1 disc resolves to exactly one forensic index (1..=32) for a given
//! rip. `IndividualSegment.tbl` tags each forensic segment with an index (see
//! [`super::segment`]); the decode keeps the segments matching our index,
//! drops the other 31, and treats everything outside a segment as ordinary
//! (variant-0) content. This module owns that classification and nothing else —
//! (index-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
//! Where the resolved index comes from is a separate concern
//! ([`resolve_disc_index`]): today it is read off the index 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::segment::{Segment, 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 (index-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
/// Inside a forensic segment tagged with OUR resolved index: decrypt with
/// that index's key.
Index(u8),
/// Inside a forensic segment tagged with a DIFFERENT index: 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
DropForeignIndex(u8),
/// Inside a forensic segment but no index key is held (the disc's index
/// was never resolved): the segment cannot be decoded, so it is concealed
/// as loss. Carries the segment's variant for diagnostics.
/// as loss. Carries the segment's index for diagnostics.
ForensicNoKey(u8),
}
/// Resolve the disc's single forensic variant from the keys we hold.
/// Resolve the disc's single forensic index 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
/// Scans for an index key (`index_number` in `1..=32`) and returns its
/// index. `None` when only default (index-0) keys are held — i.e. no
/// index source answered, so forensic segments are not decodable. A disc has
/// exactly one index, so the first non-zero key decides; if several distinct
/// index 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> {
pub fn resolve_disc_index(unit_keys: &[UnitKey]) -> Option<u8> {
unit_keys
.iter()
.map(|k| k.variant_number)
.map(|k| k.index_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).
/// the forensic segment map and the disc's resolved index (`None` if no
/// index key is held).
pub fn unit_disposition(
unit_offset: u64,
segments: &[Segment],
disc_variant: Option<u8>,
disc_index: Option<u8>,
) -> UnitDisposition {
match variant_segment_for_unit(segments, unit_offset) {
match 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.
// In a forensic segment → decide by whether it is our index.
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),
let seg_index = seg.index as u8;
match disc_index {
Some(v) if v == seg_index => UnitDisposition::Index(v),
Some(_) => UnitDisposition::DropForeignIndex(seg_index),
None => UnitDisposition::ForensicNoKey(seg_index),
}
}
}
@@ -79,7 +79,7 @@ mod tests {
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).
/// Build a one-record segment table (index, 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());
@@ -95,23 +95,23 @@ mod tests {
parse_individual_segments(&v).expect("parse")
}
fn uk(idx: u32, variant: u8) -> UnitKey {
if variant == 0 {
fn uk(idx: u32, index: u8) -> UnitKey {
if index == 0 {
UnitKey::new(idx, [0u8; 16])
} else {
UnitKey::variant(idx, [variant; 16], variant)
UnitKey::forensic(idx, [index; 16], index)
}
}
#[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));
fn resolve_picks_the_single_index_key() {
// Default keys only → no index resolved.
assert_eq!(resolve_disc_index(&[uk(0, 0)]), None);
assert_eq!(resolve_disc_index(&[]), None);
// One index key among defaults → that index.
assert_eq!(resolve_disc_index(&[uk(0, 0), uk(1, 7)]), Some(7));
// Defensive: lowest of several distinct indexes (deterministic).
assert_eq!(resolve_disc_index(&[uk(0, 9), uk(1, 3)]), Some(3));
}
#[test]
@@ -130,29 +130,29 @@ mod tests {
}
#[test]
fn unit_in_our_variant_decrypts() {
fn unit_in_our_index_decrypts() {
let segs = tbl(&[(7, 100, 200)]);
let off = 120u64 * SOURCE_PACKET_LEN;
assert_eq!(
unit_disposition(off, &segs, Some(7)),
UnitDisposition::Variant(7)
UnitDisposition::Index(7)
);
}
#[test]
fn unit_in_foreign_variant_drops() {
// Segment tagged variant 7, but our disc variant is 3 → drop it.
fn unit_in_foreign_index_drops() {
// Segment tagged index 7, but our disc index 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)
UnitDisposition::DropForeignIndex(7)
);
}
#[test]
fn forensic_unit_with_no_key_is_concealed() {
// A forensic segment but we never resolved a variant → conceal as loss.
// A forensic segment but we never resolved an index → conceal as loss.
let segs = tbl(&[(7, 100, 200)]);
let off = 120u64 * SOURCE_PACKET_LEN;
assert_eq!(
@@ -164,7 +164,7 @@ mod tests {
#[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).
// to the segment (matches 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.
@@ -172,7 +172,7 @@ mod tests {
assert!(80 + unit_packets - 1 >= 100, "sanity: unit tails into seg");
assert_eq!(
unit_disposition(off, &segs, Some(5)),
UnitDisposition::Variant(5)
UnitDisposition::Index(5)
);
}
}
+92 -49
View File
@@ -161,33 +161,52 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
})
}
/// HD DVD Video Title Key File (`VTKF000.AACS`) magic — "DVD HD Video TKF".
/// HD DVD Video Title Key File (`VTKF%%%.AACS`) magic — "DVD_HD_V_TKF".
pub const VTKF_MAGIC: &[u8; 12] = b"DVD_HD_V_TKF";
/// Fixed header length before the first title-key entry.
/// Fixed header length before the first Title Key Entry (AACS HD DVD Book,
/// Table 3-8).
const VTKF_HEADER_LEN: usize = 0x80;
/// Each title-key entry: BE32 flag + 16-byte encrypted key + 12-byte 0xFF pad.
const VTKF_ENTRY_LEN: usize = 0x20;
/// Title Key Entry stride (Table 3-8): 1-byte `BIFO` + 3 reserved + 16-byte
/// encrypted title key + 16-byte binding MAC = 36 bytes.
const VTKF_ENTRY_LEN: usize = 0x24;
/// Byte offset of the encrypted title key within an entry (after `BIFO` + 3
/// reserved).
const VTKF_KEY_OFF: usize = 4;
/// Number of Title Key Entry slots in a VTKF (Table 3-8): a fixed 64.
const VTKF_MAX_ENTRIES: usize = 64;
/// `BIFO` bit 7 (`AV_FLG`): set = this slot carries an available title key.
const VTKF_AV_FLG: u8 = 0x80;
/// Parse an HD DVD `VTKF000.AACS` into the SAME [`UnitKeyFile`] a BD/UHD
/// Parse an HD DVD `VTKF%%%.AACS` into the SAME [`UnitKeyFile`] a BD/UHD
/// `Unit_Key_RO.inf` yields — so the shared AACS crypto (`derive_unit_keys` →
/// `decrypt_unit_key(vuk, …)`) unwraps HD DVD title keys with no change. Only
/// the on-disc CONTAINER differs between BD and HD DVD; the title-key unwrap is
/// the identical AES-128 VUK step (`Kt = AES-128D(Kvu, Kte)`).
///
/// Layout (grounded in real discs — Shaun of the Dead, Anchorman, Harry Potter):
/// Layout — AACS "HD DVD and DVD Pre-recorded Book" Table 3-8, a fixed
/// 2480-byte file, verified byte-exact against real discs (Freedom `VTKF090`,
/// Dukes of Hazzard `VTKF000`):
/// ```text
/// [0x00..0x0C] magic "DVD_HD_V_TKF"
/// [0x0C..0x10] BE32 total file length
/// [0x10..0x1C] associated playlist name ("VPLST000.XPL")
/// [0x1C..0x80] reserved (zero)
/// [0x80..] 32-byte entries: BE32 flag | 16-byte ENCRYPTED title key | 12-byte 0xFF pad
/// flag bit 31 (0x8000_0000) set = present; a cleared flag ends the table
/// [tail] 16-byte signature/MAC (never a key — the cleared-flag stop guards it)
/// [0x0C..0x10] BE32 HD_VTKF_SIZE (2480)
/// [0x10..0x1C] associated playlist name ("VPLST%%%.XPL")
/// [0x1C..0x80] reserved
/// [0x80..] 64 entries × 36 bytes:
/// BIFO (1) | reserved (3) | ENCRYPTED title key (16) | binding MAC (16)
/// BIFO bit 7 (AV_FLG) set = this slot holds a title key
/// (pre-recorded discs fill the binding MAC with 0xFF)
/// [0x9A0..2480] 16-byte TKF MAC (CMAC keyed by Kvu — NOT a key)
/// ```
/// Entries number 1..=N as CPS units, matching `Unit_Key_RO`'s 1-based CPS
/// numbering, so a title's CPS unit indexes this list identically. The
/// title→CPS mapping itself is playlist-driven (`VPLST000.XPL`) and owned by the
/// HD DVD enumerator, so `title_cps_unit` is left empty here.
/// The slot index (1-based) is the CPS unit number, so an absent slot is
/// SKIPPED (not a terminator) — collapsing gaps would renumber later keys and
/// hand the wrong title key to CPS unit N+1. The title→CPS mapping is
/// playlist-driven (`VPLST%%%.XPL`) and owned by the HD DVD enumerator, so
/// `title_cps_unit` is left empty here.
///
/// The prior parser used a 32-byte stride (a 12-byte pad instead of the 16-byte
/// binding MAC). That reads entry #1 correctly but drifts +4 bytes per entry
/// after it, so it only decrypted single-CPS-unit discs; every multi-key VTKF
/// (Freedom, Harry Potter) yielded garbage keys for CPS unit ≥2.
pub fn parse_vtkf(data: &[u8]) -> Option<UnitKeyFile> {
if data.len() < VTKF_HEADER_LEN || &data[..12] != VTKF_MAGIC {
return None;
@@ -198,20 +217,19 @@ pub fn parse_vtkf(data: &[u8]) -> Option<UnitKeyFile> {
let hash = disc_hash(data);
let mut encrypted_keys = Vec::new();
let mut pos = VTKF_HEADER_LEN;
let mut cps: u32 = 1;
while pos + VTKF_ENTRY_LEN <= data.len() {
let flag = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
// A cleared present-bit terminates the key table. The file's trailing
// 16-byte signature then follows and must NOT be read as a key.
if flag & 0x8000_0000 == 0 {
for n in 0..VTKF_MAX_ENTRIES {
let pos = VTKF_HEADER_LEN + n * VTKF_ENTRY_LEN;
if pos + VTKF_ENTRY_LEN > data.len() {
break;
}
// AV_FLG clear = empty slot: skip it, but keep the slot index as the CPS
// number (do NOT break — a gap must not renumber the keys that follow).
if data[pos] & VTKF_AV_FLG == 0 {
continue;
}
let mut key = [0u8; 16];
key.copy_from_slice(&data[pos + 4..pos + 20]);
encrypted_keys.push((cps, key));
cps += 1;
pos += VTKF_ENTRY_LEN;
key.copy_from_slice(&data[pos + VTKF_KEY_OFF..pos + VTKF_KEY_OFF + 16]);
encrypted_keys.push((n as u32 + 1, key));
}
if encrypted_keys.is_empty() {
return None;
@@ -369,42 +387,51 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
mod vtkf_tests {
use super::*;
/// Build a synthetic `VTKF000.AACS` matching the real on-disc layout
/// (Shaun of the Dead / Anchorman): magic, BE32 size, playlist name,
/// reserved to 0x80, then 32-byte present-flagged entries, a cleared-flag
/// terminator, and a 16-byte trailer.
/// Build a synthetic `VTKF%%%.AACS` matching the real on-disc layout (AACS
/// HD DVD Book Table 3-8, verified against Freedom `VTKF090` and Dukes
/// `VTKF000`): magic, BE32 size, playlist name, reserved to 0x80, then 64
/// entry slots of 36 bytes (the first `keys.len()` present with `AV_FLG`
/// set, the rest empty), a reserved gap, and the 16-byte trailing TKF MAC.
fn synth_vtkf(keys: &[[u8; 16]]) -> Vec<u8> {
const FILE_LEN: usize = 2480;
let mut v = Vec::new();
v.extend_from_slice(VTKF_MAGIC); // 0x00
v.extend_from_slice(&0u32.to_be_bytes()); // 0x0C size (patched below)
v.extend_from_slice(b"VPLST000.XPL"); // 0x10
v.resize(0x80, 0); // reserve to first entry
for k in keys {
v.extend_from_slice(&0x8000_0000u32.to_be_bytes()); // present flag
v.extend_from_slice(k); // 16-byte encrypted title key
v.extend_from_slice(&[0xFFu8; 12]); // 0xFF pad → 32-byte entry
v.extend_from_slice(&(FILE_LEN as u32).to_be_bytes()); // 0x0C HD_VTKF_SIZE
v.extend_from_slice(b"VPLST000.XPL"); // 0x10 playlist name
v.resize(VTKF_HEADER_LEN, 0); // reserve to first entry (0x80)
for n in 0..VTKF_MAX_ENTRIES {
if let Some(k) = keys.get(n) {
v.push(VTKF_AV_FLG); // BIFO: AV_FLG set (present)
v.extend_from_slice(&[0, 0, 0]); // reserved
v.extend_from_slice(k); // 16-byte encrypted title key
v.extend_from_slice(&[0xFFu8; 16]); // binding MAC (0xFF, pre-recorded)
} else {
v.extend_from_slice(&[0u8; VTKF_ENTRY_LEN]); // empty slot (AV_FLG clear)
}
}
// Cleared-flag terminator entry (must NOT be read as a key).
v.extend_from_slice(&[0u8; VTKF_ENTRY_LEN]);
// 16-byte trailing signature (must NOT be read as a key).
v.extend_from_slice(&[0xABu8; 16]);
let len = v.len() as u32;
v[0x0C..0x10].copy_from_slice(&len.to_be_bytes());
v.resize(FILE_LEN - 16, 0); // reserved gap before the trailer
v.extend_from_slice(&[0xABu8; 16]); // TKF MAC (must NOT be read as a key)
v
}
#[test]
fn parse_vtkf_extracts_present_entries_and_stops_at_terminator() {
fn parse_vtkf_reads_present_entries_skips_empty_ignores_mac() {
let k1 = [0x11u8; 16];
let k2 = [0x22u8; 16];
let k3 = [0x33u8; 16];
let data = synth_vtkf(&[k1, k2, k3]);
let ukf = parse_vtkf(&data).expect("valid VTKF must parse");
// Exactly the three present entries — the cleared-flag terminator and
// the 16-byte trailer are NOT mistaken for keys.
assert_eq!(ukf.encrypted_keys.len(), 3, "must stop at the cleared flag");
assert_eq!(ukf.encrypted_keys[0], (1, k1), "CPS units number 1..=N");
// Exactly the three present entries — the empty slots and the trailing
// 16-byte TKF MAC are NOT mistaken for keys. Critically, k2/k3 are read
// at the 36-byte stride (offsets 0xA4, 0xC8); the old 32-byte stride
// misread them from inside the previous entry's binding MAC.
assert_eq!(ukf.encrypted_keys.len(), 3);
assert_eq!(
ukf.encrypted_keys[0],
(1, k1),
"CPS units = 1-based slot index"
);
assert_eq!(ukf.encrypted_keys[1], (2, k2));
assert_eq!(ukf.encrypted_keys[2], (3, k3));
assert_eq!(ukf.version, AacsVersion::V10, "HD DVD is AACS 1.0");
@@ -412,6 +439,22 @@ mod vtkf_tests {
assert_eq!(ukf.disc_hash, disc_hash(&data));
}
#[test]
fn parse_vtkf_reads_a_full_64_entry_file() {
// Real discs (Freedom, Dukes) carry all 64 slots present. Every key must
// come back, none dropped and none drifted — the regression the 32-byte
// stride failed.
let keys: Vec<[u8; 16]> = (0..VTKF_MAX_ENTRIES).map(|n| [n as u8; 16]).collect();
let ukf = parse_vtkf(&synth_vtkf(&keys)).expect("64-entry VTKF");
assert_eq!(ukf.encrypted_keys.len(), 64);
assert_eq!(
ukf.encrypted_keys[63],
(64, [63u8; 16]),
"entry 64 at 0x{:x}",
VTKF_HEADER_LEN + 63 * VTKF_ENTRY_LEN
);
}
#[test]
fn parse_vtkf_rejects_non_magic() {
let mut data = synth_vtkf(&[[0x11u8; 16]]);
+231 -39
View File
@@ -29,6 +29,7 @@ pub mod content;
pub mod crypto;
pub mod derive;
pub mod host_certs;
pub mod index_select;
pub mod inf;
pub mod mkb;
pub mod provider;
@@ -38,19 +39,25 @@ pub mod segment_key;
pub mod trace;
pub mod types;
pub mod variant;
pub mod variant_select;
/// On-disc UDF paths to the AACS key-input files.
/// On-disc UDF paths to the AACS key-input files, plus HD DVD AACS-directory
/// discovery.
///
/// BD and UHD keep their key material under `/AACS/…`; HD DVD keeps the
/// equivalents under `/ANY!/…` with different names (`VTKF000.AACS` is the
/// title-key file — magic `DVD_HD_V_TKF`; `MKBROM.AACS` is the MKB). The
/// container difference is expressed here purely as DATA: each ROLE
/// ([`UNIT_KEY_RO_PATHS`], [`MKB_PATHS`], [`CONTENT_CERT_PATHS`]) is an ordered
/// candidate list, and every reader walks it with [`read_first`] taking the
/// first that reads. No reader ever branches on disc type — a BD/UHD disc has
/// the `/AACS/` files so those win; an HD DVD has neither, so it falls through
/// to the `/ANY!/` entry. Centralised so `resolve_vid_only`, `read_aacs_inputs`,
/// BD and UHD keep their key material under a fixed `/AACS/…` tree, so those
/// paths are constants. HD DVD keeps the equivalents in a reserved root
/// directory whose NAME is authoring-house-specific — observed `ANY!` (Dukes
/// of Hazzard) and `AAC!` (Freedom / Memory-Tech), each with a `<name>!_BAK`
/// mirror — and whose title-key file is NOT always `VTKF000.AACS` (Freedom
/// ships `VTKF090.AACS` + `VTKF100.AACS`). So the HD DVD files are DISCOVERED
/// from the parsed UDF tree ([`find_hddvd_aacs_dir`] + [`role_paths`]), never
/// hardcoded.
///
/// Each key ROLE ([`AacsRole`]) resolves to an ordered candidate list — the
/// BD/UHD constants first, then whatever the HD DVD directory actually holds —
/// which every reader walks with [`read_first`], first-that-reads. No reader
/// ever branches on disc type: a BD/UHD disc has the `/AACS/` files so those
/// win; an HD DVD has none of them, so it falls through to the discovered
/// entries. Centralised so `resolve_vid_only`, `read_aacs_inputs`,
/// `read_mkb_content`, and `read_aacs_version` can never silently diverge the
/// disc_hash / MKB / VID that another reader feeds a key service.
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
@@ -59,41 +66,109 @@ pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf";
pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
/// HD DVD title-key file (`/ANY!/`), forwarded as `inf_b64`; the key service
/// recognises it by its `DVD_HD_V_TKF` magic.
pub const PATH_VTKF_HDDVD: &str = "/ANY!/VTKF000.AACS";
/// HD DVD Media Key Block (`/ANY!/`), forwarded as `mkb_b64`.
pub const PATH_MKBROM_HDDVD: &str = "/ANY!/MKBROM.AACS";
/// HD DVD content certificate (`/ANY!/`); byte 0 gives the AACS major (0x00 → V10).
pub const PATH_CONTENT_CERT_HDDVD: &str = "/ANY!/CONTENT_CERT.AACS";
/// Title-key / `Unit_Key_RO.inf` role, in resolution order (BD/UHD, then HD DVD).
pub const UNIT_KEY_RO_PATHS: &[&str] = &[
PATH_UNIT_KEY_RO,
PATH_UNIT_KEY_RO_DUPLICATE,
PATH_VTKF_HDDVD,
];
/// MKB role, in resolution order (BD/UHD RO then RW, then HD DVD).
pub const MKB_PATHS: &[&str] = &[PATH_MKB_RO, PATH_MKB_RW, PATH_MKBROM_HDDVD];
/// Content-certificate role, in resolution order (BD/UHD, then HD DVD).
pub const CONTENT_CERT_PATHS: &[&str] = &[
PATH_CONTENT_CERT,
PATH_CONTENT_CERT_ALT,
PATH_CONTENT_CERT_HDDVD,
];
/// An AACS key-input role. [`role_paths`] maps it to an ordered candidate path
/// list (BD/UHD constants, then the discovered HD DVD files).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AacsRole {
/// Title-key file: BD/UHD `Unit_Key_RO.inf`, HD DVD `VTKF*.AACS`
/// (magic `DVD_HD_V_TKF`). The disc_hash is `SHA1` of this file.
UnitKey,
/// Media Key Block: BD/UHD `MKB_RO/RW.inf`, HD DVD `MKBROM.AACS`.
Mkb,
/// Content certificate: BD/UHD `Content000/001.cer`, HD DVD
/// `CONTENT_CERT.AACS` (byte 0 gives the AACS major).
ContentCert,
}
/// Walk an AACS role's candidate paths and return the first that reads.
/// The HD DVD AACS directory in a parsed UDF tree, if present.
///
/// Identified structurally, NOT by a hardcoded name: the root child directory
/// whose name ends in `!` (so the `<name>!_BAK` backup mirror, which also ends
/// in a non-`!` char, is not mistaken for it) and which contains `MKBROM.AACS`.
/// Observed real names: `ANY!` (Dukes of Hazzard), `AAC!` (Freedom). A BD/UHD
/// disc has no such directory → `None`.
pub(crate) fn find_hddvd_aacs_dir(udf: &crate::udf::UdfFs) -> Option<&crate::udf::DirEntry> {
udf.root.entries.iter().find(|e| {
e.is_dir
&& e.name.ends_with('!')
&& e.entries
.iter()
.any(|c| !c.is_dir && c.name.eq_ignore_ascii_case("MKBROM.AACS"))
})
}
/// Ordered candidate paths for an AACS key [`AacsRole`]: the fixed BD/UHD
/// `/AACS/…` paths first, then the actual HD DVD files discovered in the disc's
/// AACS directory (see [`find_hddvd_aacs_dir`]). A disc has only one family, so
/// the other family's entries simply never read.
///
/// For [`AacsRole::UnitKey`] every `VTKF*.AACS` in the directory is appended in
/// sorted name order — a disc may carry more than one variant (Freedom:
/// `VTKF090` + `VTKF100`), not just `VTKF000`.
pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec<String> {
let mut v: Vec<String> = match role {
AacsRole::UnitKey => vec![PATH_UNIT_KEY_RO, PATH_UNIT_KEY_RO_DUPLICATE],
AacsRole::Mkb => vec![PATH_MKB_RO, PATH_MKB_RW],
AacsRole::ContentCert => vec![PATH_CONTENT_CERT, PATH_CONTENT_CERT_ALT],
}
.into_iter()
.map(String::from)
.collect();
if let Some(dir) = find_hddvd_aacs_dir(udf) {
let d = &dir.name;
match role {
AacsRole::Mkb => v.push(format!("/{d}/MKBROM.AACS")),
AacsRole::ContentCert => v.push(format!("/{d}/CONTENT_CERT.AACS")),
AacsRole::UnitKey => {
// Glob VTKF*.AACS — the title-key filename is not fixed at
// VTKF000 (Freedom ships VTKF090 + VTKF100). Sorted for a
// deterministic try order.
//
// TODO(hddvd-playlist): each VTKF%%%.AACS is bound to ONE
// playlist (VPLST%%%.XPL) — the AACS HD DVD Book gives the
// selector explicitly: match the TKF's 12-byte PLAYLIST_NAME
// field (bytes 0x10..0x1C) to the playlist of the title being
// decrypted; "unless the names are identical, the Title Keys in
// this TKF must not be used." Today read_first just takes the
// first that reads, which is correct only for a single-playlist
// disc. Thread the active playlist name here (owned by the HD
// DVD enumerator) and pick the name-matched VTKF once a
// multi-playlist encrypted disc is available to validate against.
let mut names: Vec<&str> = dir
.entries
.iter()
.filter(|e| !e.is_dir)
.filter(|e| {
let u = e.name.to_ascii_uppercase();
u.starts_with("VTKF") && u.ends_with(".AACS")
})
.map(|e| e.name.as_str())
.collect();
names.sort_unstable();
v.extend(names.into_iter().map(|n| format!("/{d}/{n}")));
}
}
}
v
}
/// Walk an AACS role's candidate paths (from [`role_paths`]) and return the
/// first that reads.
///
/// `read` performs the actual per-path read (full file or bounded prefix), so
/// callers share the same first-present walk regardless of read style. Returns
/// [`Error::AacsNoKeys`] if no candidate is present. This is the single place
/// the `/AACS/` (BD/UHD) vs `/ANY!/` (HD DVD) layout difference is resolved.
pub(crate) fn read_first<F>(candidates: &[&str], mut read: F) -> crate::error::Result<Vec<u8>>
/// [`Error::AacsNoKeys`] if no candidate is present. Generic over the path
/// element (`&str` or owned `String`) so it accepts the `Vec<String>` that
/// [`role_paths`] builds from the discovered HD DVD directory.
pub(crate) fn read_first<S, F>(candidates: &[S], mut read: F) -> crate::error::Result<Vec<u8>>
where
S: AsRef<str>,
F: FnMut(&str) -> crate::error::Result<Vec<u8>>,
{
for path in candidates {
if let Ok(buf) = read(path) {
if let Ok(buf) = read(path.as_ref()) {
return Ok(buf);
}
}
@@ -119,7 +194,7 @@ mod tests {
//! Touching one representative item per module keeps these as a
//! compile-time contract that the module paths stay stable.
use super::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
use super::content::ALIGNED_UNIT_LEN;
use super::inf::{disc_hash, disc_hash_hex};
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
use super::variant::is_variant_mkb;
@@ -147,7 +222,10 @@ mod tests {
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 _ = !crate::aacs::content::is_clean(
&[0u8; ALIGNED_UNIT_LEN],
crate::disc::ContentFormat::BdTs,
);
let _ = mkb_content_len(&[]);
let _ = is_variant_mkb(&walk_mkb(&[]));
let _ = disc_hash_hex(&disc_hash(b"x"));
@@ -158,4 +236,118 @@ mod tests {
None,
);
}
// ── HD DVD AACS directory / filename discovery ────────────────────────
//
// The HD DVD AACS dir name and title-key filename are authoring-specific
// and were previously hardcoded to `/ANY!/VTKF000.AACS`. These verify the
// discovery replacement against both real-disc shapes: Freedom (`AAC!` +
// `VTKF090`/`VTKF100`) and a BD/UHD disc (no HD DVD dir).
#[test]
fn role_paths_discovers_hddvd_dir_and_globs_all_vtkf_variants() {
use crate::udf::fixture::*;
// Freedom-shaped: an `AAC!` dir (NOT `ANY!`) holding MKBROM + two VTKF
// variants (090/100, NOT 000) + a VTUF usage file (must be excluded),
// plus the `AAC!_BAK` mirror (must NOT be picked as the AACS dir).
let mut disc = MemDisc::new();
let aacs_files = vec![
file("MKBROM.AACS", 100, 5000, 4096, true),
file("CONTENT_CERT.AACS", 101, 5100, 2048, true),
file("VTKF100.AACS", 102, 5200, 2048, true),
file("VTKF090.AACS", 103, 5300, 2048, true),
file("VTUF090.AACS", 104, 5400, 2048, true),
];
let bak_files = vec![file("MKBROM.AACS", 110, 6000, 4096, true)];
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![
DirSpec {
name: "AAC!".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: aacs_files,
subdirs: vec![],
},
DirSpec {
name: "AAC!_BAK".to_string(),
icb_lba: 30,
dir_data_lba: 31,
files: bak_files,
subdirs: vec![],
},
],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
// Discovered structurally (ends in '!', holds MKBROM.AACS) — the real
// AACS dir, never the `_BAK` mirror.
let dir = super::find_hddvd_aacs_dir(&udf).expect("aacs dir");
assert_eq!(dir.name, "AAC!");
// UnitKey: BD/UHD paths first, then EVERY VTKF*.AACS in sorted order
// (090 before 100) — NOT hardcoded VTKF000; VTUF (usage) excluded.
assert_eq!(
super::role_paths(&udf, super::AacsRole::UnitKey),
vec![
super::PATH_UNIT_KEY_RO.to_string(),
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
"/AAC!/VTKF090.AACS".to_string(),
"/AAC!/VTKF100.AACS".to_string(),
]
);
assert_eq!(
super::role_paths(&udf, super::AacsRole::Mkb)
.last()
.unwrap(),
"/AAC!/MKBROM.AACS"
);
assert_eq!(
super::role_paths(&udf, super::AacsRole::ContentCert)
.last()
.unwrap(),
"/AAC!/CONTENT_CERT.AACS"
);
}
#[test]
fn role_paths_bd_uhd_disc_yields_no_hddvd_candidates() {
use crate::udf::fixture::*;
// A `/AACS/` tree (BD/UHD) has no '!' directory → discovery finds none
// and the candidate list is exactly the static BD/UHD paths.
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: "AACS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: vec![
file("Unit_Key_RO.inf", 100, 5000, 2048, true),
file("MKB_RO.inf", 101, 5100, 2048, 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!(super::find_hddvd_aacs_dir(&udf).is_none());
assert_eq!(
super::role_paths(&udf, super::AacsRole::UnitKey),
vec![
super::PATH_UNIT_KEY_RO.to_string(),
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
]
);
}
}
+38 -1
View File
@@ -13,7 +13,6 @@ use super::mkb::*;
// ── Full VUK resolution chain ───────────────────────────────────────────────
/// Result of resolving a disc's VUK.
#[derive(Debug)]
pub struct ResolvedKeys {
/// Disc hash (SHA1 of Unit_Key_RO.inf)
pub disc_hash: [u8; 20],
@@ -34,6 +33,23 @@ pub struct ResolvedKeys {
pub key_source: u8,
}
// Redacting `Debug`: `vuk` and `unit_keys` are raw key bytes, never printed.
// `disc_hash` is the public per-disc identifier (SHA-1 of the .inf), not secret.
// Guarded by `resolved_keys_debug_is_redacted`.
impl std::fmt::Debug for ResolvedKeys {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResolvedKeys")
.field("disc_hash", &self.disc_hash)
.field("vuk", &self.vuk.map(|_| "<redacted>"))
.field("unit_keys_len", &self.unit_keys.len())
.field("title_cps_unit", &self.title_cps_unit)
.field("version", &self.version)
.field("bus_encryption", &self.bus_encryption)
.field("key_source", &self.key_source)
.finish()
}
}
/// Inputs shared by every classical-path resolver. References only —
/// callers retain ownership of all buffers.
pub struct ResolveContext<'a> {
@@ -467,6 +483,27 @@ mod tests {
use super::super::types::*;
use super::*;
/// `ResolvedKeys` carries the disc's VUK and unit keys raw; `Debug` must not
/// leak them. Sentinel 213 (0xD5); non-secret fields are not 213.
#[test]
fn resolved_keys_debug_is_redacted() {
let rk = ResolvedKeys {
disc_hash: [0u8; 20],
vuk: Some([0xD5; 16]),
unit_keys: vec![(1, [0xD5; 16])],
title_cps_unit: vec![0],
version: AacsVersion::V21,
bus_encryption: true,
key_source: 1,
};
let dbg = format!("{rk:?}");
assert!(!dbg.contains("213"), "ResolvedKeys leaked keys: {dbg}");
assert!(
dbg.contains("redacted"),
"ResolvedKeys missing marker: {dbg}"
);
}
/// Audit #5: the `major` / `from_major` mapping is load-bearing for the
/// Unit_Key_RO stride, so pin it as a table. V10 ↔ BD; V20/V21 → UHD; any
/// non-BD major selects the V20/V21 64-byte stride (V10 is the only 48-byte).
+190 -70
View File
@@ -1,61 +1,55 @@
//! 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).
//! An FMTS main feature interleaves short forensic **segments** — the sequence-key
//! / forensic-watermark mechanism. Each segment carries an **index** (1..32): a
//! tag in `IndividualSegment.tbl` that selects which of the 32 forensic **index
//! keys** decrypts that segment's units, in place of the ordinary CPS Unit Key.
//!
//! 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
//! Terminology (see the project AACS reference): the **index** here is NOT the
//! AACS 2.1 *Media Key Variant* — that is the 65536-value device selector in the
//! MKB that decides *which set* of index keys a device receives, a layer this
//! module does not deal with. All the index keys belong to one variant, whose
//! number is unknown and irrelevant to the segment map. Decrypting a 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 segments live and which index each carries, so a
//! decoder can decrypt them with the matching index key 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 marker (= 0x01000000) | u16 index | 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).
//! `index` is the 1..32 forensic index 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) = 80 aligned units,
//! spread across the entire 54 GB feature (one roughly every 67 MB). Inside a
//! segment the 80 units interleave in two stride-2 halves: applying the segment's
//! index key decrypts ~40 of them to clean TS and garbles the other ~40 (a second
//! interleaved half, unidentified), which the demux then drops — leaving one
//! coherent stream. Confirmed by decoding a retail disc with a full set of 32
//! index keys.
/// 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.
/// One forensic 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,
/// Forensic index tag, 1..=32 (field@4 of the record). Cycles across the
/// table rather than counting up — it selects WHICH of the 32 index keys
/// decrypts this range. (`0` is not used here; the default/non-forensic
/// content carries no segment record at all.)
pub index: u16,
/// First source packet of the segment (inclusive).
pub start_spn: u32,
/// Last source packet of the segment (inclusive).
@@ -109,8 +103,8 @@ pub fn lba_byte_offset(lba: u32) -> u64 {
/// 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
/// overlaps a forensic segment must be opened with that segment's **index key**
/// (selected by the segment's `index`), 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
@@ -118,16 +112,16 @@ pub fn lba_byte_offset(lba: u32) -> u64 {
///
/// 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
/// forensic; 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> {
pub fn 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
/// Parse `IndividualSegment.tbl` into its forensic 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
@@ -147,12 +141,12 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
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]]);
// o+4..o+8 = index (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
let index = 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,
index,
start_spn,
end_spn,
});
@@ -160,12 +154,74 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
Some(segments)
}
/// Map a clip-relative byte offset to the absolute LBA that holds it, by walking
/// the title's extents (the `.fmts` clip's sectors in file order). Segment
/// offsets in [`Segment`] are clip-relative source-packet numbers, so this is how
/// a segment's `spn` range becomes disc LBAs. `None` if the offset is past the
/// clip.
pub fn clip_byte_to_lba(extents: &[crate::disc::Extent], clip_byte: u64) -> Option<u32> {
let mut cum = 0u64;
for e in extents {
let len = e.sector_count as u64 * crate::consts::SECTOR_BYTES as u64;
if clip_byte < cum + len {
let sector_in_ext = ((clip_byte - cum) / crate::consts::SECTOR_BYTES as u64) as u32;
return Some(e.start_lba.saturating_add(sector_in_ext));
}
cum += len;
}
None
}
/// Build the `[start_lba, end_lba) → key_idx` ranges for an FMTS forensic key map.
///
/// Each forensic segment's clip-relative source-packet span becomes an absolute
/// LBA range tagged with the key its `index` selects (via `index_to_key_idx`,
/// e.g. `|i| i as usize` when the pool is `[base, idx1, idx2, …]`). Applying that
/// one key across the whole segment decodes the ~40 units of its interleave half
/// to clean TS and garbles the other ~40 (the second interleaved half), which the
/// demux then drops — yielding one coherent stream. Ranges outside every segment
/// are left for the map's default (the ordinary Unit Key). A segment that straddles
/// a UDF extent boundary is emitted as one range per whole-sector slice it covers.
///
/// The result feeds [`AacsKeyMap::from_ranges`](crate::decrypt::AacsKeyMap::from_ranges)
/// with the Unit-Key index as the default — the same structure the CPS map uses,
/// only finer-grained.
pub fn fmts_key_ranges(
segments: &[Segment],
extents: &[crate::disc::Extent],
index_to_key_idx: &dyn Fn(u16) -> usize,
) -> Vec<(u32, u32, usize)> {
let mut ranges = Vec::new();
for s in segments {
let start_byte = s.start_spn as u64 * SOURCE_PACKET_LEN;
let end_byte = (s.end_spn as u64 + 1) * SOURCE_PACKET_LEN; // exclusive
// A segment is unit-aligned and contiguous in clip bytes; map its first
// and last sector to LBAs. Segments are ~480 KB and extents are GB-sized,
// so a segment almost never crosses an extent boundary — but if the two
// ends land in different extents (non-contiguous LBAs), skip rather than
// emit a wrong span; the units there fall to the Unit Key (garble+drop),
// never a mis-decrypt.
let (Some(a), Some(b)) = (
clip_byte_to_lba(extents, start_byte),
clip_byte_to_lba(extents, end_byte - 1),
) else {
continue;
};
if b >= a
&& (b - a) as u64 == (end_byte - 1 - start_byte) / crate::consts::SECTOR_BYTES as u64
{
ranges.push((a, b + 1, index_to_key_idx(s.index)));
}
}
ranges
}
#[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)`.
/// records. `recs` are `(index, 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
@@ -181,11 +237,75 @@ mod tests {
v
}
#[test]
fn fmts_key_ranges_maps_segments_to_lba_by_index() {
use crate::disc::Extent;
// One big clip extent starting at LBA 1000. Clip byte B lives at
// LBA 1000 + B/2048.
let extents = vec![Extent {
start_lba: 1000,
sector_count: 1_000_000,
}];
// Two segments, indexes 5 and 7 (spn ranges as on a real disc).
let segs = vec![
Segment {
index: 5,
start_spn: 100,
end_spn: 199,
},
Segment {
index: 7,
start_spn: 10_000,
end_spn: 10_099,
},
];
// Pool layout [base, idx1, idx2, …] → index N uses key slot N.
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
assert_eq!(ranges.len(), 2, "one LBA range per segment");
// Segment 0: spn 100..=199 → clip bytes [19200, 38400) → sectors 9..=18
// → LBA 1009..1019, key index 5.
assert_eq!(ranges[0], (1009, 1019, 5));
// Segment 1: spn 10000..=10099 → bytes [1_920_000, 1_939_200) →
// sectors 937..=946 → LBA 1937..1947, key index 7.
assert_eq!(ranges[1], (1937, 1947, 7));
// The ranges drive an AacsKeyMap with the Unit Key (index 0) as default.
let map = crate::decrypt::AacsKeyMap::from_ranges(ranges, 0);
assert_eq!(map.key_idx_for(500), 0, "outside any segment → Unit Key");
assert_eq!(map.key_idx_for(1012), 5, "inside index-5 segment → key 5");
assert_eq!(map.key_idx_for(1940), 7, "inside index-7 segment → key 7");
assert_eq!(
map.key_idx_for(1019),
0,
"segment end is exclusive → Unit Key"
);
}
#[test]
fn clip_byte_to_lba_walks_extents() {
use crate::disc::Extent;
let extents = vec![
Extent {
start_lba: 100,
sector_count: 10,
}, // clip bytes [0, 20480)
Extent {
start_lba: 500,
sector_count: 10,
}, // clip bytes [20480, 40960)
];
assert_eq!(clip_byte_to_lba(&extents, 0), Some(100));
assert_eq!(clip_byte_to_lba(&extents, 2048), Some(101));
assert_eq!(clip_byte_to_lba(&extents, 20480), Some(500)); // second extent
assert_eq!(clip_byte_to_lba(&extents, 22528), Some(501));
assert_eq!(clip_byte_to_lba(&extents, 40960), None); // past the clip
}
#[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.
// `index_field_cycles_one_to_thirty_two`), segments are 2560 packets.
let tbl = build_tbl(&[
(1, 343680, 346239),
(2, 695616, 698175),
@@ -193,9 +313,9 @@ mod tests {
]);
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].index, 1);
assert_eq!(segs[1].index, 2);
assert_eq!(segs[2].index, 3);
assert_eq!(segs[0].start_spn, 343680);
assert_eq!(segs[0].end_spn, 346239);
assert_eq!(segs[0].packet_count(), 2560);
@@ -234,18 +354,18 @@ mod tests {
}
#[test]
fn unit_inside_segment_routes_to_variant() {
fn unit_inside_segment_routes_to_index() {
// 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);
let hit = segment_for_unit(&segs, off).expect("inside the segment");
assert_eq!(hit.index, 1);
}
#[test]
fn variant_field_cycles_one_to_thirty_two() {
// Reality on Zombieland: field@4 is the variant, cycling 1..=32 in file
fn index_field_cycles_one_to_thirty_two() {
// Reality on Zombieland: field@4 is the index, 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;
@@ -258,9 +378,9 @@ mod tests {
}
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)));
assert_eq!(segs[31].index, 32); // end of first cycle
assert_eq!(segs[32].index, 1); // wraps, does not become 33
assert!(segs.iter().all(|s| (1..=32).contains(&s.index)));
}
#[test]
@@ -268,29 +388,29 @@ mod tests {
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());
assert!(segment_for_unit(&segs, off).is_none());
}
#[test]
fn unit_straddling_a_segment_edge_counts_as_variant() {
fn unit_straddling_a_segment_edge_counts_as_forensic() {
// 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.
// packets straddle the boundary) must still route to the index key,
// because part of its ciphertext is forensic-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);
let hit = segment_for_unit(&segs, off).expect("straddles the start edge");
assert_eq!(hit.index, 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());
assert!(segment_for_unit(&segs, before).is_none());
}
#[test]
fn no_segments_never_routes_to_variant() {
fn no_segments_never_routes_to_index() {
// 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());
assert!(segment_for_unit(&[], lba_byte_offset(0)).is_none());
assert!(segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
}
#[test]
+178 -24
View File
@@ -6,7 +6,7 @@
//! owns only the crypto and these value types that flow through it.
/// A device key for MKB subset-difference tree processing.
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct DeviceKey {
pub key: [u8; 16],
pub node: u16,
@@ -15,7 +15,7 @@ pub struct DeviceKey {
}
/// Host certificate + private key for AACS SCSI authentication.
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct HostCert {
/// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes.
pub private_key: [u8; 20],
@@ -28,22 +28,22 @@ pub struct HostCert {
}
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(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)]
#[derive(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)]
#[derive(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)]
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct ProcessingKey(pub [u8; 16]);
/// One decrypted per-CPS-unit AACS title key.
@@ -53,50 +53,49 @@ pub struct ProcessingKey(pub [u8; 16]);
/// 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)]
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct UnitKey {
pub idx: u32,
pub key: [u8; 16],
/// AACS 2.1 (FMTS) forensic-variant tag.
/// AACS 2.1 (FMTS) forensic **index** tag (see [`crate::aacs::segment`]).
///
/// `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,
/// key and for the bulk of a 2.1 title. `1..=32` = a forensic index key that
/// decrypts the `IndividualSegment.tbl` segments tagged with that same index.
/// This is the per-segment index (1..32), NOT the AACS 2.1 Media Key Variant
/// (the 65536-value device selector), which is a separate MKB-layer concern.
pub index_number: u8,
}
impl UnitKey {
/// An ordinary (non-forensic) unit key: `variant_number == 0`. The value
/// An ordinary (non-forensic) unit key: `index_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,
index_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 {
/// A forensic index key: `index_number` in `1..=32`, decrypting the
/// `IndividualSegment.tbl` segments tagged with that index.
pub const fn forensic(idx: u32, key: [u8; 16], index_number: u8) -> Self {
Self {
idx,
key,
variant_number,
index_number,
}
}
/// Whether this key decrypts ordinary (non-forensic) content.
pub const fn is_default_variant(&self) -> bool {
self.variant_number == 0
/// Whether this key decrypts ordinary (non-forensic) content (index 0).
pub const fn is_default_index(&self) -> bool {
self.index_number == 0
}
}
/// A per-disc entry from the key database.
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct DiscEntry {
/// Disc hash (20 bytes, hex)
pub disc_hash: String,
@@ -111,3 +110,158 @@ pub struct DiscEntry {
/// Unit keys (title keys) indexed by CPS unit number
pub unit_keys: Vec<(u32, [u8; 16])>,
}
// ── Redacting `Debug` impls ──────────────────────────────────────────────────
//
// Every type above carries AACS secret material (device keys, host PRIVATE keys,
// media/volume/processing/unit keys). `#[derive(Debug)]` would print those bytes
// verbatim, so a stray `debug!("{:?}", …)` or a panic message would leak the
// keys. These hand-written impls print only NON-secret shape (presence, lengths,
// tree coordinates, indices) — never key bytes. `decrypt::DecryptKeys` follows
// the same policy by omitting `Debug` entirely; here we keep `Debug` because
// these are `PartialEq`/`Eq` value types used in `assert_eq!` and nested inside
// other `#[derive(Debug)]` structs, so the trait must exist — just not leak.
// Guarded by `redaction_tests` below.
impl std::fmt::Debug for DeviceKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DeviceKey")
.field("key", &"<redacted>")
.field("node", &self.node)
.field("uv", &self.uv)
.field("u_mask_shift", &self.u_mask_shift)
.finish()
}
}
impl std::fmt::Debug for HostCert {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HostCert")
.field("private_key", &"<redacted>")
.field("certificate_len", &self.certificate.len())
.field("private_key_v2", &self.private_key_v2.map(|_| "<redacted>"))
.field(
"certificate_v2_len",
&self.certificate_v2.as_ref().map(|c| c.len()),
)
.finish()
}
}
impl std::fmt::Debug for Vid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Vid(<redacted>)")
}
}
impl std::fmt::Debug for MediaKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("MediaKey(<redacted>)")
}
}
impl std::fmt::Debug for Vuk {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Vuk(<redacted>)")
}
}
impl std::fmt::Debug for ProcessingKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("ProcessingKey(<redacted>)")
}
}
impl std::fmt::Debug for UnitKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UnitKey")
.field("idx", &self.idx)
.field("key", &"<redacted>")
.field("index_number", &self.index_number)
.finish()
}
}
impl std::fmt::Debug for DiscEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DiscEntry")
.field("disc_hash", &self.disc_hash)
.field("title", &self.title)
.field("media_key", &self.media_key.map(|_| "<redacted>"))
.field("disc_id", &self.disc_id.map(|_| "<redacted>"))
.field("vuk", &self.vuk.map(|_| "<redacted>"))
.field("unit_keys_len", &self.unit_keys.len())
.finish()
}
}
#[cfg(test)]
mod redaction_tests {
use super::*;
// Sentinel key byte 0xD5 = decimal 213. A derived `Debug` prints `[u8;N]`
// as decimal, so a leaked key surfaces the substring "213"; the redacting
// impls must not. No non-secret field below is 213, so "213" appearing means
// key bytes leaked. Each type must also carry a "redacted" marker (or omit
// the secret entirely) so re-adding `#[derive(Debug)]` fails this test.
const S: u8 = 0xD5;
fn assert_redacted(what: &str, dbg: &str) {
assert!(
!dbg.contains("213"),
"{what}: Debug leaked key bytes (found decimal 213): {dbg}"
);
assert!(
dbg.contains("redacted"),
"{what}: Debug missing redaction marker: {dbg}"
);
}
#[test]
fn device_key_debug_is_redacted() {
let d = DeviceKey {
key: [S; 16],
node: 1,
uv: 2,
u_mask_shift: 3,
};
assert_redacted("DeviceKey", &format!("{d:?}"));
}
#[test]
fn host_cert_debug_is_redacted() {
let h = HostCert {
private_key: [S; 20],
certificate: vec![0u8; 92],
private_key_v2: Some([S; 32]),
certificate_v2: None,
};
assert_redacted("HostCert", &format!("{h:?}"));
}
#[test]
fn newtype_keys_debug_is_redacted() {
assert_redacted("Vid", &format!("{:?}", Vid([S; 16])));
assert_redacted("MediaKey", &format!("{:?}", MediaKey([S; 16])));
assert_redacted("Vuk", &format!("{:?}", Vuk([S; 16])));
assert_redacted("ProcessingKey", &format!("{:?}", ProcessingKey([S; 16])));
}
#[test]
fn unit_key_debug_is_redacted() {
assert_redacted("UnitKey", &format!("{:?}", UnitKey::new(0, [S; 16])));
}
#[test]
fn disc_entry_debug_is_redacted() {
let e = DiscEntry {
disc_hash: "0xAA".into(),
title: "T".into(),
media_key: Some([S; 16]),
disc_id: Some([S; 16]),
vuk: Some([S; 16]),
unit_keys: vec![(1, [S; 16])],
};
assert_redacted("DiscEntry", &format!("{e:?}"));
}
}
+36 -1
View File
@@ -146,7 +146,7 @@ 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
/// to the variant chain.
#[derive(Debug, Clone, Copy)]
#[derive(Clone, Copy)]
pub struct ProcessingKeyMatch {
/// Processing Key.
pub kp: [u8; 16],
@@ -158,6 +158,20 @@ pub struct ProcessingKeyMatch {
pub cvalue_index: usize,
}
// Redacting `Debug`: `kp` (a Processing Key) and `cvalue` are secret, never
// printed. `uv` / `cvalue_index` are non-secret coordinates. Guarded by
// `processing_key_match_debug_is_redacted`.
impl std::fmt::Debug for ProcessingKeyMatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProcessingKeyMatch")
.field("kp", &"<redacted>")
.field("uv", &self.uv)
.field("cvalue", &"<redacted>")
.field("cvalue_index", &self.cvalue_index)
.finish()
}
}
fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
let r = records.iter().find(|r| {
(r.rec_type == REC_VERIFY_MEDIA_KEY_V1 || r.rec_type == REC_VERIFY_MEDIA_KEY_V2)
@@ -635,6 +649,27 @@ mod tests {
use super::super::crypto::aesg3;
use super::super::derive::calc_pk_from_dk;
/// `ProcessingKeyMatch` carries the Processing Key (`kp`) and `cvalue` raw;
/// `Debug` must redact both. Non-secret `uv`/`cvalue_index` are not 213.
#[test]
fn processing_key_match_debug_is_redacted() {
let m = ProcessingKeyMatch {
kp: [0xD5; 16],
uv: 1,
cvalue: [0xD5; 16],
cvalue_index: 2,
};
let dbg = format!("{m:?}");
assert!(
!dbg.contains("213"),
"ProcessingKeyMatch leaked kp/cvalue: {dbg}"
);
assert!(
dbg.contains("redacted"),
"ProcessingKeyMatch missing marker: {dbg}"
);
}
#[test]
fn calc_pk_from_dk_terminates_on_nonconvergent_mask() {
// Regression for the unbounded-loop hang: pick a (dev_key_v_mask,
+297 -14
View File
@@ -29,7 +29,7 @@ use crate::sector::SectorSource;
const CSS_LOCKED_BAIL: u32 = 64;
/// CSS decryption state for a DVD title.
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct CssState {
/// 5-byte CSS title key (from SCSI auth or the crack fallback).
pub title_key: [u8; 5],
@@ -43,6 +43,18 @@ pub struct CssState {
pub crack_span: Option<(u32, u32)>,
}
// Redacting `Debug`: `CssState` is reachable via the public `Disc.css` field, so
// a `{:?}` on a `Disc` would otherwise print the raw CSS title key. Print only
// the (non-secret) crack span. Guarded by `css_state_debug_is_redacted`.
impl std::fmt::Debug for CssState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CssState")
.field("title_key", &"<redacted>")
.field("crack_span", &self.crack_span)
.finish()
}
}
/// Recover the CSS title key with no keys, by scanning scrambled sectors and
/// running the Stevenson known-plaintext attack (see the [`stevenson`] module).
///
@@ -54,12 +66,16 @@ pub struct CssState {
/// extents and return the first sector that yields a key — no player keys, no
/// disc-key crack. Works on a live drive (after bus-auth unlocks reads) and on
/// disc images alike.
/// This convenience form runs to completion (no cancellation) and returns just
/// the key; callers needing an operator-Stop / watchdog cancel, or the three-way
/// [`CrackOutcome`] (to distinguish "unencrypted" from "encrypted-but-uncracked"),
/// use [`crack_key_outcome`], which takes a `halt` token.
pub fn crack_key(
reader: &mut dyn SectorSource,
extents: &[Extent],
batch_sectors: u16,
) -> Option<CssState> {
crack_key_halt(reader, extents, batch_sectors, None)
crack_key_scan(reader, extents, batch_sectors, None, false).into_state()
}
/// Outcome of a CSS crack scan that distinguishes the THREE cases the bare
@@ -105,6 +121,12 @@ impl CrackOutcome {
/// ScrambledUncracked) so callers can distinguish "genuinely unencrypted" from
/// "encrypted but uncrackable" — the latter must become a hard error, never a
/// silent fall-through to plaintext.
///
/// Takes an optional cooperative-cancellation token. "No silent hangs": the
/// crack scans up to 50_000 sectors, which on a live drive hitting bad sectors
/// can take a long time, so it polls `halt` once per batch (the same cadence
/// sweep/patch use) and emits a `freemkv::heartbeat` beat ("css_crack") each
/// batch so a stuck scan is visible in the log.
pub fn crack_key_outcome(
reader: &mut dyn SectorSource,
extents: &[Extent],
@@ -114,27 +136,69 @@ pub fn crack_key_outcome(
crack_key_scan(reader, extents, batch_sectors, halt, true)
}
/// [`crack_key`] with an optional cooperative-cancellation token.
/// Resolve a DVD title's CSS descramble key from the reader when the caller
/// supplied none — the SINGLE place every DVD read path obtains a title key, so
/// the file-backed mux highway ([`crate::build_iso_pipeline`]) and the
/// live-drive single-pass [`crate::DiscStream`] descramble a DVD identically
/// ("reading is reading"). CSS keys are per-VTS and crackable from the scrambled
/// data itself, so a `None`/MPEG-PS title cracks its own key here, in playback
/// order over `extents`. Everything else is left untouched:
/// - AACS keys (HD-DVD `.evo` is also MPEG-PS but arrives as `Aacs`) — no CSS.
/// - a title that already carries a key — nothing to resolve.
/// - a genuinely clear DVD (no scrambled sector) — stays `None`, a mux no-op.
///
/// "No silent hangs": the crack scans up to 50_000 sectors, which on a live
/// drive hitting bad sectors can take a long time. This variant polls `halt`
/// once per batch (the same cadence sweep/patch use) so an operator Stop or a
/// scan-level watchdog can interrupt the scan, and emits a
/// `freemkv::heartbeat` beat ("css_crack") each batch so a stuck scan is
/// visible in the log.
pub fn crack_key_halt(
/// A scrambled-but-uncrackable title is a hard [`crate::error::Error::CssKeyMissing`],
/// never a silent scrambled-passthrough mux.
pub(crate) fn resolve_dvd_title_key(
reader: &mut dyn SectorSource,
extents: &[Extent],
keys: &mut crate::decrypt::DecryptKeys,
batch_sectors: u16,
format: crate::disc::ContentFormat,
raw: bool,
halt: Option<&crate::halt::Halt>,
) -> Option<CssState> {
crack_key_scan(reader, extents, batch_sectors, halt, false).into_state()
) -> std::io::Result<()> {
// `--raw` = deliberate ciphertext passthrough: never crack or descramble, and
// never hard-fail on scrambled-uncrackable — the user asked for the scrambled
// bytes. (In raw mode the caller hands us `None` on purpose; without this
// guard we'd install a real key and silently DECRYPT, or abort a raw mux.)
if raw {
return Ok(());
}
if matches!(keys, crate::decrypt::DecryptKeys::None)
&& format == crate::disc::ContentFormat::MpegPs
{
// `halt` threads the caller's cancellation token so /api/stop can
// interrupt a long crack scan (the old scan-time crack honored it too).
let outcome = crack_key_outcome(reader, extents, batch_sectors, halt);
// A cancelled crack breaks out early, so its outcome is a TRUNCATED scan
// — not a real verdict. Interpreting it would either hard-fail a good disc
// as `ScrambledUncracked` (quarantining staging on a Stop) or, worse,
// read a half-scanned title as `Unencrypted` and mux scrambled bytes as
// plaintext. Surface the cancellation as `Halted` so the caller takes its
// graceful-stop path instead of trusting the partial outcome.
if halt.map(|h| h.is_cancelled()).unwrap_or(false) {
return Err(crate::error::Error::Halted.into());
}
match outcome {
CrackOutcome::Cracked(state) => {
*keys = crate::decrypt::DecryptKeys::Css {
title_key: state.title_key,
};
}
CrackOutcome::ScrambledUncracked => {
return Err(crate::error::Error::CssKeyMissing.into());
}
CrackOutcome::Unencrypted => {}
}
}
Ok(())
}
/// The crack scan, returning the full [`CrackOutcome`]. Tracks a
/// `saw_scrambled` flag so a scrambled-but-uncracked disc is distinguished
/// from a genuinely-unencrypted one (the [`crack_key`] / [`crack_key_halt`]
/// `Option` wrappers collapse both to `None`).
/// from a genuinely-unencrypted one (the [`crack_key`] `Option` wrapper
/// collapses both to `None` via [`CrackOutcome::into_state`]).
fn crack_key_scan(
reader: &mut dyn SectorSource,
extents: &[Extent],
@@ -364,6 +428,26 @@ mod tests {
use super::*;
use crate::error::{Error, Result};
/// `CssState` is reachable via the public `Disc.css` field, so a `{:?}` on a
/// `Disc` must not print the raw CSS title key. Sentinel byte 213 (0xD5);
/// `crack_span` is non-secret and none of its values are 213.
#[test]
fn css_state_debug_is_redacted() {
let s = CssState {
title_key: [0xD5; 5],
crack_span: Some((10, 20)),
};
let dbg = format!("{s:?}");
assert!(
!dbg.contains("213"),
"CssState Debug leaked the title key: {dbg}"
);
assert!(
dbg.contains("redacted"),
"CssState Debug missing marker: {dbg}"
);
}
// ── is_scrambled ───────────────────────────────────────────────────────
/// is_scrambled returns false for any buffer shorter than one sector,
@@ -868,6 +952,205 @@ mod tests {
);
}
/// `resolve_dvd_title_key` is the SINGLE shared per-title CSS step both read
/// paths (`build_iso_pipeline` multi-pass and `DiscStream::new` single-pass)
/// call, so these pin its full contract at the shared boundary.
///
/// Crack path: a `None`-keyed MPEG-PS title with a crackable scrambled sector
/// installs a `Css` key that round-trips the sector.
#[test]
fn resolve_dvd_title_key_cracks_none_mpegps() {
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
let crackable = crackable_sector(&title_key, &seed, 8);
let mut src = MockSource::new(0x00);
src.crackable = Some((1003, crackable));
let extents = [Extent {
start_lba: 1000,
sector_count: 50,
}];
let mut keys = crate::decrypt::DecryptKeys::None;
resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
false,
None,
)
.expect("crackable title resolves");
match keys {
crate::decrypt::DecryptKeys::Css { title_key: got } => {
assert_eq!(got, title_key, "installed key must be the cracked key")
}
_ => panic!("expected Css key"),
}
}
/// Hard-fail path: a scrambled-but-uncrackable `None`-keyed MPEG-PS title must
/// return `CssKeyMissing`, never leave `keys` as `None` (which would mux
/// scrambled bytes as plaintext — the 328k-decode-error corruption).
#[test]
fn resolve_dvd_title_key_scrambled_uncrackable_hard_fails() {
let mut src = MockSource::new(0x00);
src.lock_all = true; // every read CSS-locked → ScrambledUncracked
let extents = [Extent {
start_lba: 0,
sector_count: 4,
}];
let mut keys = crate::decrypt::DecryptKeys::None;
let err = resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
false,
None,
)
.expect_err("scrambled-uncrackable must hard-fail");
// The Error::CssKeyMissing flattens into io::Error carrying its E-code
// (7023) in the message — assert that specific code survived.
assert!(
err.to_string()
.contains(&format!("E{}", crate::error::E_CSS_KEY_MISSING)),
"must surface CssKeyMissing (E{}), got: {err}",
crate::error::E_CSS_KEY_MISSING
);
assert!(
matches!(keys, crate::decrypt::DecryptKeys::None),
"keys must stay None on hard-fail (never a scrambled-passthrough key)"
);
}
/// `raw` is deliberate ciphertext passthrough: even a scrambled-uncrackable
/// title must return `Ok` and leave `keys` untouched (`None`) — no crack, no
/// hard-fail. This is the `--raw` guarantee.
#[test]
fn resolve_dvd_title_key_raw_skips_crack_and_never_fails() {
let mut src = MockSource::new(0x00);
src.lock_all = true;
let extents = [Extent {
start_lba: 0,
sector_count: 4,
}];
let mut keys = crate::decrypt::DecryptKeys::None;
resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
true, // raw
None,
)
.expect("raw must never hard-fail");
assert!(
matches!(keys, crate::decrypt::DecryptKeys::None),
"raw must leave keys None (no descramble)"
);
assert!(
src.reads.borrow().is_empty(),
"raw must not read any sector for a crack"
);
}
/// AACS gate: an MPEG-PS title carrying `Aacs` keys (HD-DVD `.evo`) must be
/// left untouched — resolve only fires on `None` keys, never overwriting a
/// real key set or cracking AACS ciphertext as CSS.
#[test]
fn resolve_dvd_title_key_leaves_aacs_untouched() {
let mut src = MockSource::new(0x00);
src.lock_all = true; // would hard-fail IF it ran the crack
let extents = [Extent {
start_lba: 0,
sector_count: 4,
}];
let mut keys = crate::decrypt::DecryptKeys::Aacs {
unit_keys: vec![(0, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::MpegPs,
};
resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
false,
None,
)
.expect("AACS title must be left untouched, not cracked");
assert!(
matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. }),
"Aacs keys must survive unchanged"
);
assert!(
src.reads.borrow().is_empty(),
"must not read for a crack when keys are already Aacs"
);
}
/// Clear DVD: a `None`-keyed MPEG-PS title with no scrambled sector stays
/// `None` (a mux no-op) and returns `Ok` — genuinely-unencrypted DVDs pass.
#[test]
fn resolve_dvd_title_key_clear_dvd_stays_none() {
let mut src = MockSource::new(0x00); // all-clear sectors
let extents = [Extent {
start_lba: 0,
sector_count: 4,
}];
let mut keys = crate::decrypt::DecryptKeys::None;
resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
false,
None,
)
.expect("clear DVD passes");
assert!(
matches!(keys, crate::decrypt::DecryptKeys::None),
"a clear DVD must keep None keys"
);
}
/// A cancelled crack (user Stop mid-scan) must surface as `Halted`, NOT be
/// misread from the truncated scan as `Unencrypted` (→ scrambled passthrough,
/// corruption) or `ScrambledUncracked` (→ CssKeyMissing, which quarantines a
/// good disc). This pins the halt-outcome fix.
#[test]
fn resolve_dvd_title_key_halt_surfaces_as_halted_not_a_verdict() {
let mut src = MockSource::new(0x00);
src.lock_all = true; // without the halt guard this would be ScrambledUncracked
let extents = [Extent {
start_lba: 0,
sector_count: 4,
}];
let halt = crate::halt::Halt::new();
halt.cancel(); // Stop already pressed
let mut keys = crate::decrypt::DecryptKeys::None;
let err = resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
false,
Some(&halt),
)
.expect_err("a cancelled crack must return an error");
assert!(
err.to_string()
.contains(&format!("E{}", crate::error::E_HALTED)),
"cancelled crack must surface Halted (E{}), got: {err}",
crate::error::E_HALTED
);
}
/// CSS_ERROR WIRING (audit §2 / §5 #7): an all-locked synthetic ISO (every
/// VOB read returns CSS-locked sense `05/6F/03` across MULTIPLE extents, as a
/// real encrypted-but-unauthenticated disc image does) must produce the exact
+540 -13
View File
@@ -171,6 +171,330 @@ impl DecryptKeys {
}
}
/// Proactive AACS key-selection map: which held unit key decrypts each LBA of a
/// title's encrypted content, decided ONCE before mux from the disc's CPS-unit
/// (and, later, FMTS segment) structure — never by trial-decrypt-and-check per
/// unit at mux time.
///
/// This is the pivot that ends the mux "key-server storm": the old path decrypts
/// a unit, checks whether the plaintext looks like clean MPEG-TS, and — because
/// authored-bad content never reaches that bar — concludes "wrong key, fetch a
/// fresh one" and re-asks the key service for units it already holds the correct
/// key for. There is NO per-unit byte pattern that separates "correctly decrypted
/// but authored-bad" from "still encrypted", so that check is unanswerable. The
/// map removes the question: we resolve one key per CPS unit / segment up front
/// (see `resolve_mux_key_map`), record which LBA ranges each covers, and at mux
/// time simply "decrypt this LBA with key K" and trust it — bad TS is the muxer's
/// concern, exactly as for a physically-read clear disc.
///
/// Ranges are `[start_lba, end_lba)` → index into the `Aacs { unit_keys }` pool,
/// sorted and disjoint. `default_idx` covers any LBA no range claims — the
/// single-CPS case is just an empty range list with `default_idx = 0`, so the
/// common disc pays zero lookup cost and needs no structural walk.
/// Which aligned units of a range a key decrypts. AACS 2.1 FMTS forensic segments
/// interleave TWO variants at the unit level; `Even`/`Odd` selects the variant's
/// half (parity of the unit's index within the segment) and the ALTERNATE half is
/// left untouched (ciphertext) for the muxer to drop. Every non-forensic range —
/// the base Unit Key, a multi-CPS unit — is `All` (decrypt every unit), so the
/// common disc is byte-for-byte unchanged.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Phase {
All,
Even,
Odd,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AacsKeyMap {
// (start_lba, end_lba, key_idx, phase)
ranges: Vec<(u32, u32, usize, Phase)>,
default_idx: usize,
}
impl AacsKeyMap {
/// The whole title is one CPS unit → one key (`idx`) everywhere. This is the
/// overwhelmingly common disc (incl. every single-CPS UHD); no LBA walk.
pub fn single(idx: usize) -> Self {
Self {
ranges: Vec::new(),
default_idx: idx,
}
}
/// Build from `[start_lba, end_lba) → key_idx` ranges that decrypt EVERY unit
/// (multi-CPS): each range is [`Phase::All`]. `default_idx` answers any
/// uncovered LBA. Byte-for-byte identical behaviour to before phases existed.
pub fn from_ranges(ranges: Vec<(u32, u32, usize)>, default_idx: usize) -> Self {
let phased = ranges
.into_iter()
.map(|(s, e, i)| (s, e, i, Phase::All))
.collect();
Self::from_ranges_phased(phased, default_idx)
}
/// Build a PHASE-AWARE map (FMTS): each range carries which unit-parity its key
/// opens ([`Phase::Even`]/[`Phase::Odd`] for a forensic segment, [`Phase::All`]
/// for base/CPS). Ranges are sorted; `default_idx` answers any uncovered LBA.
pub fn from_ranges_phased(
mut ranges: Vec<(u32, u32, usize, Phase)>,
default_idx: usize,
) -> Self {
ranges.sort_by_key(|&(start, _, _, _)| start);
Self {
ranges,
default_idx,
}
}
/// The `(key_idx, phase, range_start_lba)` for the aligned unit at `lba`.
/// O(log n) — the last range whose start is `<= lba` and whose end is `> lba`,
/// else `(default_idx, All, 0)`. `range_start_lba` lets the mapped decrypt
/// compute a unit's parity WITHIN a forensic segment (for `Even`/`Odd`).
pub fn entry_for(&self, lba: u32) -> (usize, Phase, u32) {
if self.ranges.is_empty() {
return (self.default_idx, Phase::All, 0);
}
match self
.ranges
.binary_search_by(|&(start, _, _, _)| start.cmp(&lba))
{
Ok(i) => {
let (start, _, idx, ph) = self.ranges[i];
(idx, ph, start)
}
Err(0) => (self.default_idx, Phase::All, 0),
Err(i) => {
let (start, end, idx, ph) = self.ranges[i - 1];
if lba >= start && lba < end {
(idx, ph, start)
} else {
(self.default_idx, Phase::All, 0)
}
}
}
}
/// The unit-key index for the aligned unit at `lba` (phase-agnostic; see
/// [`entry_for`](Self::entry_for) for the phase). Cheap per-unit hot-path call.
pub fn key_idx_for(&self, lba: u32) -> usize {
self.entry_for(lba).0
}
/// The `[start_lba, end_lba) → (key_idx, phase)` ranges (sorted, disjoint).
/// Empty for a single-CPS map (everything uses [`default_idx`](Self::default_idx)).
pub fn ranges(&self) -> &[(u32, u32, usize, Phase)] {
&self.ranges
}
/// The key index for any LBA no explicit range claims (the single-CPS key).
pub fn default_idx(&self) -> usize {
self.default_idx
}
/// The distinct key indices this map can select — the CPS units / segments a
/// title actually reaches. Used by the resolver to know which keys to secure
/// up front.
pub fn key_indices(&self) -> Vec<usize> {
let mut v: Vec<usize> = self.ranges.iter().map(|&(_, _, i, _)| i).collect();
v.push(self.default_idx);
v.sort_unstable();
v.dedup();
v
}
/// Build the FMTS **read plan**: the title's aligned units filtered down to
/// only the units this rip must actually read — every default / CPS unit,
/// plus, inside each forensic segment, ONLY our-phase ([`Phase::Even`] /
/// [`Phase::Odd`]) units. The alternate-phase units are a different device
/// group's variant: a licensed player never reads them, and neither do we.
/// They are omitted from the plan entirely, so they are never fetched,
/// decrypted, or handed to the demux — the demux therefore sees one gapless
/// our-variant stream, with no ciphertext to trip a concealed-gap resync (the
/// old behaviour that dropped good frames around every segment).
///
/// `extents` are the title's clip extents (unit-aligned in the interior;
/// a shorter tail is ordinary content and always kept). `unit_sectors` is the
/// AACS aligned-unit size in sectors (3). Contiguous kept units coalesce into
/// as few extents as possible so the producer still issues large sequential
/// reads across default content; only inside a ~480 KB forensic segment do
/// reads become unit-granular (every other unit). A map with no forensic
/// (Even/Odd) range returns `extents` unchanged — the common disc is not
/// touched.
///
/// The parity test is byte-identical to the decrypt hot loop
/// (`(unit_lba - range_start) / unit_sectors`), so a unit kept here is exactly
/// a unit [`decrypt_sectors_mapped`] would open, and vice-versa.
pub fn read_plan(
&self,
extents: &[crate::disc::Extent],
unit_sectors: u32,
) -> Vec<crate::disc::Extent> {
// No forensic segment → read everything, unchanged (byte-for-byte).
if !self
.ranges
.iter()
.any(|&(_, _, _, p)| matches!(p, Phase::Even | Phase::Odd))
{
return extents.to_vec();
}
let us = unit_sectors.max(1);
let mut plan: Vec<crate::disc::Extent> = Vec::new();
// Append `sectors` at `lba`, coalescing with the previous extent when they
// are physically contiguous so default runs stay one big sequential read.
let mut push = |lba: u32, sectors: u32| {
if sectors == 0 {
return;
}
if let Some(last) = plan.last_mut() {
if last.start_lba.saturating_add(last.sector_count) == lba {
last.sector_count += sectors;
return;
}
}
plan.push(crate::disc::Extent {
start_lba: lba,
sector_count: sectors,
});
};
for e in extents {
let mut off = 0u32;
while off < e.sector_count {
let lba = e.start_lba.saturating_add(off);
let remaining = e.sector_count - off;
if remaining < us {
// Extent tail shorter than a whole unit: ordinary content
// (nothing follows to desync), always read.
push(lba, remaining);
break;
}
let (_, phase, range_start) = self.entry_for(lba);
let keep = match phase {
Phase::All => true,
Phase::Even | Phase::Odd => {
let unit_ix = (lba - range_start) / us;
let is_odd = unit_ix % 2 == 1;
is_odd == matches!(phase, Phase::Odd)
}
};
if keep {
push(lba, us);
}
off += us;
}
}
plan
}
}
/// Decrypt a buffer of sectors in-place using a resolved [`AacsKeyMap`] — the
/// mux's TRUSTED decrypt. `base_lba` is the absolute LBA of `buf`'s first sector;
/// each aligned unit (3 sectors) is decrypted with the key the map assigns to its
/// LBA. There is NO key trial and NO `is_clean` verdict: the map already decided
/// the key from disc structure, so we apply it and move on — a unit that decrypts
/// to authored-bad TS passes through for the muxer to drop, never re-fetched.
///
/// Only [`DecryptKeys::Aacs`] uses a map (CSS self-cracks per region inside
/// [`decrypt_sectors`]; `None` is clear) — other variants are a no-op here so the
/// decorator can dispatch uniformly. A map index outside the held pool is a
/// fail-loud [`Error::DecryptFailed`]: the resolver's job is to guarantee every
/// selectable index is present, so a gap here is a resolver bug, not silent loss.
pub fn decrypt_sectors_mapped(
buf: &mut [u8],
keys: &DecryptKeys,
base_lba: u32,
map: &AacsKeyMap,
) -> Result<(), crate::error::Error> {
let (unit_keys, rdk, format) = match keys {
DecryptKeys::Aacs {
unit_keys,
read_data_key,
format,
} => (unit_keys, *read_data_key, *format),
// Clear / CSS: the mapped path is AACS-only. Leave the buffer untouched;
// CSS descrambles via `decrypt_sectors` and `None` is already clear.
_ => return Ok(()),
};
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
let unit_sectors = (unit_len / 2048) as u32;
// Validate every selectable index up front (fail loud) so the per-unit hot
// loop can index without bounds churn and a resolver gap never silently
// passes ciphertext through as "decrypted".
for idx in map.key_indices() {
if unit_keys.get(idx).is_none() {
return Err(crate::error::Error::DecryptFailed);
}
}
// Cheap safety net for the "map must be right" model: with a correct
// phase-aware map, every CORRECT-PHASE forensic unit decrypts to clean TS, so
// this never fires in the happy path — but a map bug (wrong phase/key for a
// segment) surfaces as a loud DecryptFailed instead of silent corruption. Only
// forensic (Even/Odd) ranges are verified; base / multi-CPS (All) stays
// trust-only, so the common disc is byte-for-byte unchanged.
let verify_failed = std::sync::atomic::AtomicBool::new(false);
let decrypt_one = |idx_in_buf: usize, chunk: &mut [u8]| {
if chunk.len() != unit_len {
return; // trailing partial unit: clear tail on disc, leave as-is
}
let unit_lba = base_lba.saturating_add((idx_in_buf as u32) * unit_sectors);
let (key_idx, phase, range_start) = map.entry_for(unit_lba);
// PHASE GATE (FMTS forensic segment): the segment interleaves two variants
// at the unit level. Decrypt ONLY our parity; leave the alternate half as
// ciphertext (the muxer drops untouched ciphertext cleanly — no garble).
if matches!(phase, Phase::Even | Phase::Odd) {
let unit_ix = (unit_lba - range_start) / unit_sectors;
let is_odd = unit_ix % 2 == 1;
if is_odd != matches!(phase, Phase::Odd) {
return; // alternate half — leave as-is
}
}
// Gate on the authoritative encrypted flag ONLY (CPI bits in the clear
// seed): a clear unit is left untouched; an encrypted unit is decrypted
// with its MAPPED key and trusted.
if !aacs::content::aacs_unit_encrypted(chunk, format) {
return;
}
// Bounds already proven above; index directly.
let key = &unit_keys[key_idx].1;
if let Some(ref rdk_key) = rdk {
aacs::content::decrypt_bus(chunk, rdk_key);
}
aacs::content::decrypt_unit(chunk, key);
// Correct-phase forensic verify (silent unless the map is wrong).
if matches!(phase, Phase::Even | Phase::Odd) && !aacs::content::is_clean(chunk, format) {
verify_failed.store(true, std::sync::atomic::Ordering::Relaxed);
}
};
let nthreads = decrypt_threads();
let nunits = buf.len() / unit_len;
if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS {
for (i, chunk) in buf.chunks_mut(unit_len).enumerate() {
decrypt_one(i, chunk);
}
} else {
match decrypt_pool() {
Some(pool) => pool.install(|| {
buf.par_chunks_mut(unit_len)
.enumerate()
.for_each(|(i, chunk)| decrypt_one(i, chunk));
}),
None => {
for (i, chunk) in buf.chunks_mut(unit_len).enumerate() {
decrypt_one(i, chunk);
}
}
}
}
if verify_failed.load(std::sync::atomic::Ordering::Relaxed) {
return Err(crate::error::Error::DecryptFailed);
}
Ok(())
}
/// Decrypt a buffer of sectors in-place.
///
/// For AACS: processes in 6144-byte aligned units (3 sectors).
@@ -286,7 +610,7 @@ fn decrypt_sectors_impl(
// silent corruption. We fail loud (Error::DecryptFailed), matching
// the highway path's Error::ExtentNotUnitAligned policy.
//
// Detection: ts_sync_destroyed() short-circuits to false for any
// Detection: !crate::aacs::content::is_clean(, crate::disc::ContentFormat::BdTs) short-circuits to false for any
// buffer shorter than a full unit, so it cannot judge a partial. We
// instead apply the same TS-sync-intactness test it uses internally
// (ts_sync_count vs ts_packet_total) directly to the available
@@ -308,15 +632,13 @@ fn decrypt_sectors_impl(
None => true,
};
// TS-only: a scrambled trailing PARTIAL unit (< a full 6144-byte
// unit) can't be unit-decrypted, so fail loud. The heuristic is
// MPEG-TS sync density, which a PS (`.evo`) partial lacks entirely —
// running it on PS would false-trip `DecryptFailed`. HD-DVD partial-
// scramble detection is not yet wired (consistent with the UNVERIFIED
// PS path in `aacs_unit_encrypted`).
// unit) can't be unit-decrypted, so fail loud. Validity is the SAME
// `is_clean` proof floor used everywhere — a clear TS tail passes it,
// a scrambled one fails. PS (`.evo`) partials lack the TS structure,
// so this stays TS-only (HD-DVD partial-scramble is not yet wired).
if partial_in_content && format == crate::disc::ContentFormat::BdTs {
let partial = &buf[buf.len() - partial_len..];
let packets = aacs::content::ts_packet_total(partial);
if packets > 0 && aacs::content::ts_sync_count(partial) <= packets / 2 {
if !aacs::content::is_clean(partial, format) {
return Err(crate::error::Error::DecryptFailed);
}
}
@@ -1301,6 +1623,208 @@ mod tests {
unit
}
// ── FMTS phase-aware map ──────────────────────────────────────────────────
/// `entry_for` returns (idx, phase, range_start); `from_ranges` is All,
/// `from_ranges_phased` carries the phase; uncovered → (default, All, 0).
#[test]
fn aacskeymap_phase_entry_for() {
let all = AacsKeyMap::from_ranges(vec![(100, 200, 3)], 0);
assert_eq!(all.entry_for(150), (3, Phase::All, 100));
assert_eq!(all.entry_for(50), (0, Phase::All, 0));
let phased = AacsKeyMap::from_ranges_phased(vec![(100, 200, 3, Phase::Odd)], 7);
assert_eq!(phased.entry_for(150), (3, Phase::Odd, 100));
assert_eq!(phased.entry_for(250), (7, Phase::All, 0));
assert_eq!(phased.key_idx_for(150), 3);
}
/// A map with no forensic (Even/Odd) range is the common disc: `read_plan`
/// returns the extents unchanged, so nothing but FMTS is affected.
#[test]
fn read_plan_non_forensic_is_unchanged() {
use crate::disc::Extent;
let us = (aacs::content::ALIGNED_UNIT_LEN / 2048) as u32; // 3
let ext = vec![
Extent {
start_lba: 1000,
sector_count: 300,
},
Extent {
start_lba: 5000,
sector_count: 60,
},
];
// Single-CPS and multi-CPS (All) maps both leave the plan untouched.
assert_eq!(AacsKeyMap::single(0).read_plan(&ext, us), ext);
let multi = AacsKeyMap::from_ranges(vec![(1000, 1150, 2)], 0);
assert_eq!(multi.read_plan(&ext, us), ext);
}
/// FMTS: a forensic Even segment drops exactly its alternate (odd) units from
/// the read plan — they are never fetched — while default content on either
/// side stays one coalesced sequential run. The kept units are byte-identical
/// to the ones the decrypt hot loop opens.
#[test]
fn read_plan_forensic_reads_only_our_phase_units() {
use crate::disc::Extent;
let us = (aacs::content::ALIGNED_UNIT_LEN / 2048) as u32; // 3
// One extent, 100 units [1000, 1300). A 10-unit Even forensic segment at
// LBA [1030, 1060): kept even units are ix 0,2,4,6,8 → LBA 1030,1036,1042,
// 1048,1054; dropped odd units → 1033,1039,1045,1051,1057.
let ext = vec![Extent {
start_lba: 1000,
sector_count: 300,
}];
let map = AacsKeyMap::from_ranges_phased(vec![(1030, 1060, 5, Phase::Even)], 0);
let plan = map.read_plan(&ext, us);
let expected = vec![
Extent {
start_lba: 1000,
sector_count: 33,
}, // 1000..1030 default + the ix-0 even unit at 1030
Extent {
start_lba: 1036,
sector_count: 3,
},
Extent {
start_lba: 1042,
sector_count: 3,
},
Extent {
start_lba: 1048,
sector_count: 3,
},
Extent {
start_lba: 1054,
sector_count: 3,
},
Extent {
start_lba: 1060,
sector_count: 240,
}, // default resumes, coalesced to the extent end
];
assert_eq!(plan, expected);
// Exactly the 5 odd units (15 sectors) are omitted; nothing else.
let kept: u32 = plan.iter().map(|e| e.sector_count).sum();
assert_eq!(
kept,
300 - 5 * us,
"only the alternate-phase units are dropped"
);
// Every kept LBA is one the decrypt loop would decrypt (All or our parity),
// and no dropped LBA is: the plan and the decrypt gate agree unit-for-unit.
for e in &plan {
let mut off = 0;
while off < e.sector_count {
let lba = e.start_lba + off;
let (_, phase, rs) = map.entry_for(lba);
if let Phase::Even | Phase::Odd = phase {
let is_odd = ((lba - rs) / us) % 2 == 1;
assert!(
is_odd == matches!(phase, Phase::Odd),
"plan kept an alternate-phase unit at LBA {lba}"
);
}
off += us;
}
}
}
/// Phase::Even → only even-index units in the range are decrypted; the odd
/// (alternate variant) half is left BYTE-FOR-BYTE as ciphertext for the muxer.
#[test]
fn mapped_phase_even_decrypts_even_leaves_odd_ciphertext() {
use crate::disc::ContentFormat;
let key_a = [0xAAu8; 16];
let key_b = [0xBBu8; 16];
let ul = aacs::content::ALIGNED_UNIT_LEN;
let usz = (ul / 2048) as u32;
let mut buf = vec![0u8; 8 * ul];
let mut odd_cipher = Vec::new();
for i in 0..8 {
let mut u = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut u, if i % 2 == 0 { &key_a } else { &key_b });
if i % 2 == 1 {
odd_cipher.push(u.clone());
}
buf[i * ul..(i + 1) * ul].copy_from_slice(&u);
}
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a)],
read_data_key: None,
format: ContentFormat::BdTs,
};
let map = AacsKeyMap::from_ranges_phased(vec![(0, 8 * usz, 0, Phase::Even)], 0);
decrypt_sectors_mapped(&mut buf, &keys, 0, &map).expect("even phase decrypts clean");
for i in 0..8 {
let u = &buf[i * ul..(i + 1) * ul];
if i % 2 == 0 {
assert!(
aacs::content::is_clean(u, ContentFormat::BdTs),
"even unit {i} decrypted to clean TS"
);
} else {
assert_eq!(
u,
odd_cipher[i / 2].as_slice(),
"odd unit {i} left as ciphertext"
);
}
}
}
/// The correct-phase safety `is_clean` fires loud: an even unit whose mapped
/// key is wrong does NOT come clean → `DecryptFailed` (not silent corruption).
#[test]
fn mapped_phase_verify_fails_loud_on_wrong_key() {
use crate::disc::ContentFormat;
let ul = aacs::content::ALIGNED_UNIT_LEN;
let usz = (ul / 2048) as u32;
let mut buf = vec![0u8; 2 * ul];
let mut u0 = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut u0, &[0xAAu8; 16]); // encrypted under A
buf[..ul].copy_from_slice(&u0);
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xCCu8; 16])], // map slot points at the WRONG key
read_data_key: None,
format: ContentFormat::BdTs,
};
let map = AacsKeyMap::from_ranges_phased(vec![(0, 2 * usz, 0, Phase::Even)], 0);
assert!(matches!(
decrypt_sectors_mapped(&mut buf, &keys, 0, &map),
Err(crate::error::Error::DecryptFailed)
));
}
/// Phase::All (multi-CPS / base) decrypts EVERY unit and never runs the verify
/// — the common-disc path is byte-for-byte unchanged.
#[test]
fn mapped_all_phase_decrypts_every_unit() {
use crate::disc::ContentFormat;
let key = [0x11u8; 16];
let ul = aacs::content::ALIGNED_UNIT_LEN;
let mut buf = vec![0u8; 4 * ul];
for i in 0..4 {
let mut u = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut u, &key);
buf[i * ul..(i + 1) * ul].copy_from_slice(&u);
}
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: ContentFormat::BdTs,
};
decrypt_sectors_mapped(&mut buf, &keys, 0, &AacsKeyMap::single(0))
.expect("all-phase decrypts");
for i in 0..4 {
assert!(
aacs::content::is_clean(&buf[i * ul..(i + 1) * ul], ContentFormat::BdTs),
"unit {i} decrypted (All)"
);
}
}
/// A unit encrypted under unit_keys[1] (the second CPS unit) on a
/// two-key disc must be correctly decrypted — not left as garbage —
/// when `decrypt_sectors` is called with unit_key_idx=0 (the default).
@@ -1324,7 +1848,7 @@ mod tests {
let mut unit = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit, &key1);
assert!(
aacs::content::ts_sync_destroyed(&unit),
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
"encrypted unit must look scrambled before decrypt"
);
@@ -1339,7 +1863,7 @@ mod tests {
decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
assert!(
!aacs::content::ts_sync_destroyed(&buf),
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
"unit encrypted under key1 must be fully decrypted (TS syncs restored)"
);
// Every sync position must carry 0x47.
@@ -1371,7 +1895,7 @@ mod tests {
let mut buf = unit;
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
assert!(
!aacs::content::ts_sync_destroyed(&buf),
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
"single-key disc: TS syncs must be restored"
);
assert_eq!(
@@ -1401,7 +1925,7 @@ mod tests {
aacs_encrypt_unit_for_test(&mut unit, &real_key);
let ciphertext = unit.clone();
assert!(
aacs::content::ts_sync_destroyed(&unit),
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
"encrypted unit must look scrambled going in"
);
@@ -1462,7 +1986,10 @@ mod tests {
"exactly one unit's worth of bytes must be reported unverified"
);
assert!(
!aacs::content::ts_sync_destroyed(&buf[..aacs::content::ALIGNED_UNIT_LEN]),
crate::aacs::content::is_clean(
&buf[..aacs::content::ALIGNED_UNIT_LEN],
crate::disc::ContentFormat::BdTs
),
"the decryptable unit must come out clear"
);
assert_ne!(
+2 -2
View File
@@ -336,7 +336,7 @@ fn frame_record(track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> V
/// `track_number` is the 1-based MKV track number; `track` is the built
/// [`crate::mux::mkv::MkvTrack`] whose fields map one-to-one onto the emitted
/// elements (see `MkvMuxer::new`). No-op unless the diag target is on.
pub fn dump_mkv_track(track_number: u64, track: &crate::mux::mkv::MkvTrack) {
pub(crate) fn dump_mkv_track(track_number: u64, track: &crate::mux::mkv::MkvTrack) {
if !diag_enabled() {
return;
}
@@ -530,7 +530,7 @@ fn dump_aacs(disc: &Disc) {
a.bus_encryption,
a.mkb_version,
a.disc_hash,
a.key_source.name(),
a.key_source,
a.vuk.is_some(),
a.unit_keys.len(),
a.uk_ro.len(),
+45 -7
View File
@@ -7,7 +7,6 @@ use crate::udf;
/// Result of SCSI AACS handshake (ECDH authentication).
/// Only available when scanning from a real drive, not ISO images.
#[derive(Debug)]
pub(super) struct HandshakeResult {
pub volume_id: [u8; 16],
pub read_data_key: Option<[u8; 16]>,
@@ -29,6 +28,19 @@ pub(super) struct HandshakeResult {
pub drive_unlocked: bool,
}
// Redacting `Debug`: `volume_id` and `read_data_key` (the AACS 2.0 bus key) are
// secret; print only shape. Guarded by `handshake_result_debug_is_redacted`.
impl std::fmt::Debug for HandshakeResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HandshakeResult")
.field("volume_id", &"<redacted>")
.field("read_data_key", &self.read_data_key.map(|_| "<redacted>"))
.field("read_data_key_err", &self.read_data_key_err)
.field("drive_unlocked", &self.drive_unlocked)
.finish()
}
}
/// Single source of truth for "is AACS bus encryption gone for this scan?". The
/// gate asks ONLY this — `if !removed { error }` — never enumerating cases. Bus
/// encryption is gone when ANY of these holds:
@@ -312,13 +324,18 @@ impl Disc {
use crate::aacs;
let uk_ro_data =
aacs::read_first(aacs::UNIT_KEY_RO_PATHS, |p| udf_fs.read_file(reader, p))?;
aacs::read_first(&aacs::role_paths(udf_fs, aacs::AacsRole::UnitKey), |p| {
udf_fs.read_file(reader, p)
})?;
let dh = aacs::inf::disc_hash(&uk_ro_data);
let cc = aacs::read_first(aacs::CONTENT_CERT_PATHS, |p| udf_fs.read_file(reader, p))
.ok()
.as_deref()
.and_then(aacs::inf::parse_content_cert);
let cc = aacs::read_first(
&aacs::role_paths(udf_fs, aacs::AacsRole::ContentCert),
|p| udf_fs.read_file(reader, p),
)
.ok()
.as_deref()
.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
@@ -428,6 +445,27 @@ mod tests {
use crate::sector::SectorSource;
use std::collections::HashMap;
/// `HandshakeResult` carries the Volume ID and the AACS 2.0 bus (read-data)
/// key; `Debug` must redact both. Sentinel 213 (0xD5).
#[test]
fn handshake_result_debug_is_redacted() {
let hs = HandshakeResult {
volume_id: [0xD5; 16],
read_data_key: Some([0xD5; 16]),
read_data_key_err: None,
drive_unlocked: false,
};
let d = format!("{hs:?}");
assert!(
!d.contains("213"),
"HandshakeResult leaked VID/bus key: {d}"
);
assert!(
d.contains("redacted"),
"HandshakeResult missing marker: {d}"
);
}
// ---------------------------------------------------------------
// In-memory disc + minimal UDF image with a single physical
// partition (metadata_start == partition_start). Offsets cited
@@ -976,7 +1014,7 @@ mod tests {
/// A minimal in-test KeySource that yields no keys but a fixed cert list.
struct CertSource(Vec<aacs::types::HostCert>);
impl crate::KeySource for CertSource {
fn get_uk(
fn get_unit_keys(
&self,
_ctx: &dyn crate::keysource::ResolveCtx,
) -> Result<Vec<crate::aacs::types::UnitKey>> {
+3 -1
View File
@@ -522,8 +522,10 @@ fn compose_xpl_titles(
})
.collect();
titles.push(DiscTitle {
// Language-neutral identifier (no user-facing English in the library):
// matches the UDF `TITLE_*` volume-label style. Apps localize display.
playlist: if t.name.is_empty() {
format!("Title {}", t.number)
format!("TITLE_{}", t.number)
} else {
t.name.clone()
},
+2 -2
View File
@@ -77,7 +77,7 @@ impl SectorStatus {
/// One contiguous range of bytes with a status.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MapEntry {
pub(crate) struct MapEntry {
pub pos: u64,
pub size: u64,
pub status: SectorStatus,
@@ -498,7 +498,7 @@ impl Mapfile {
/// All map entries, sorted ascending by `pos` and (after load)
/// guaranteed disjoint and non-overflowing.
pub fn entries(&self) -> &[MapEntry] {
pub(crate) fn entries(&self) -> &[MapEntry] {
&self.entries
}
+612 -297
View File
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
//! Content-based forced-subtitle detection for Blu-ray/UHD PGS tracks.
//!
//! `freemkv info` and the muxer must agree on which subtitle tracks are forced.
//! The muxer derives it from the PGS `forced_on_flag` while muxing a rip; this
//! module gives `info` the SAME verdict up front by reading the title's PGS
//! streams and feeding them through the one shared classifier
//! ([`crate::mux::codec::pgs::ForcedTracker`]) — so the two never diverge.
//!
//! Cost: a track is only confirmed forced once EVERY display set is seen to be
//! forced, so a disc that has a forced track is read through — the
//! accuracy-over-speed tradeoff `info` opts into. Full tracks early-exit as soon
//! as they show a single non-forced subtitle, and a whole run stops early once
//! every track has settled.
//!
//! Encrypted content: the probe reuses whatever [`SectorSource`] the scan holds.
//! With a decrypting source it sees real PGS; without keys it reads ciphertext
//! and observes no display sets, in which case it leaves each track's existing
//! (vendor-label-derived) forced flag untouched rather than asserting anything.
use crate::disc::{Codec, DiscTitle, Stream};
use crate::mux::codec::CodecParser;
use crate::mux::codec::pgs::{ForcedTracker, PgsParser};
use crate::mux::ts::TsDemuxer;
use crate::sector::SectorSource;
use std::collections::HashMap;
const SECTOR_BYTES: usize = 2048;
/// Read the clip in 2 MiB chunks.
const CHUNK_SECTORS: u16 = 1024;
/// Read the title's PGS streams and set `SubtitleStream::forced` from their
/// content. Best-effort: any read error ends the probe with whatever verdicts
/// have accumulated. Only PGS tracks are touched (DVD VobSub forced comes from
/// the IFO/vendor path).
pub(crate) fn probe_and_set_forced<S: SectorSource + ?Sized>(
reader: &mut S,
title: &mut DiscTitle,
) {
let pg_pids: Vec<u16> = title
.streams
.iter()
.filter_map(|s| match s {
Stream::Subtitle(sub) if sub.codec == Codec::Pgs => Some(sub.pid),
_ => None,
})
.collect();
if pg_pids.is_empty() {
return;
}
let mut demux = TsDemuxer::new(&pg_pids);
let mut parsers: HashMap<u16, PgsParser> =
pg_pids.iter().map(|&p| (p, PgsParser::new())).collect();
let mut trackers: HashMap<u16, ForcedTracker> =
pg_pids.iter().map(|&p| (p, ForcedTracker::new())).collect();
let extents = title.extents.clone();
let mut buf = vec![0u8; CHUNK_SECTORS as usize * SECTOR_BYTES];
'outer: for ext in &extents {
let mut lba = ext.start_lba;
let mut remaining = ext.sector_count;
while remaining > 0 {
let count = remaining.min(CHUNK_SECTORS as u32) as u16;
let want = count as usize * SECTOR_BYTES;
let n = match reader.read_sectors(lba, count, &mut buf[..want], false) {
Ok(n) => n,
Err(_) => break 'outer, // best-effort — stop, keep what we have
};
if n == 0 {
break 'outer;
}
for pes in demux.feed(&buf[..n]) {
if let (Some(parser), Some(tracker)) =
(parsers.get_mut(&pes.pid), trackers.get_mut(&pes.pid))
{
for frame in parser.parse(&pes) {
tracker.observe(&frame.data);
}
}
}
// Every track has already shown a non-forced set → nothing left to
// learn; stop reading the (huge) clip.
if trackers.values().all(ForcedTracker::settled_not_forced) {
break 'outer;
}
lba += count as u32;
remaining -= count as u32;
}
}
// Drain any buffered final display set.
for (pid, parser) in parsers.iter_mut() {
if let Some(tracker) = trackers.get_mut(pid) {
for frame in parser.flush() {
tracker.observe(&frame.data);
}
}
}
// Apply verdicts. Only override a track we actually saw content for — an
// undecrypted/unread track keeps its vendor-derived flag.
for s in &mut title.streams {
if let Stream::Subtitle(sub) = s {
if sub.codec == Codec::Pgs {
if let Some(t) = trackers.get(&sub.pid) {
if t.observed() {
sub.forced = t.is_forced();
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::disc::{ContentFormat, Extent, LabelQualifier, SubtitleStream};
/// A reader that yields all-zeros (an encrypted / unreadable clip) for a
/// bounded span, then EOF.
struct ZeroReader {
served: u32,
cap: u32,
}
impl SectorSource for ZeroReader {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
if self.served >= self.cap {
return Ok(0);
}
self.served += count as u32;
buf.fill(0);
Ok(buf.len())
}
fn capacity_sectors(&self) -> u32 {
self.cap
}
}
fn pgs_title(pid: u16, vendor_forced: bool) -> DiscTitle {
DiscTitle {
playlist: String::new(),
playlist_id: 0,
duration_secs: 0.0,
size_bytes: 0,
clips: vec![],
streams: vec![Stream::Subtitle(SubtitleStream {
pid,
codec: Codec::Pgs,
language: "eng".into(),
forced: vendor_forced,
qualifier: LabelQualifier::None,
codec_data: None,
})],
chapters: vec![],
extents: vec![Extent {
start_lba: 0,
sector_count: 4,
}],
content_format: ContentFormat::BdTs,
codec_privates: vec![None],
}
}
#[test]
fn no_observed_content_preserves_vendor_forced() {
// An unreadable/encrypted clip yields no PGS display sets — the probe must
// leave the existing vendor-derived forced flag untouched, never assert
// "not forced" from having seen nothing.
let mut reader = ZeroReader { served: 0, cap: 4 };
let mut title = pgs_title(0x1200, true);
probe_and_set_forced(&mut reader, &mut title);
let Stream::Subtitle(s) = &title.streams[0] else {
panic!()
};
assert!(s.forced, "no content observed → vendor forced preserved");
}
#[test]
fn no_pgs_streams_is_noop() {
// A title with no PGS subtitle streams is a no-op (the reader is never
// touched — a DVD/VobSub or audio-only title).
let mut reader = ZeroReader { served: 0, cap: 0 };
let mut title = pgs_title(0x1200, false);
// Swap the PGS sub for an audio stream so there are no PGS PIDs.
title.streams.clear();
probe_and_set_forced(&mut reader, &mut title);
assert_eq!(reader.served, 0, "no PGS PIDs → no reads");
}
}
+97
View File
@@ -114,6 +114,12 @@ pub struct ReadCtx {
/// good reads after the last error in the cluster." Used to count
/// zone entries and to bound zone_reads accurately.
pub in_damage_zone: bool,
/// Count of RECOVERED ERROR (marginal) reads the drive reported this pass
/// (surfaced by the PER=1 mode-select at drive-prep). Each is distrusted and
/// marked NonTrimmed for a Pass N re-read; the count is reported in the
/// pass summary so an operator can see how much of a "clean" rip was actually
/// marginal.
pub marginal_recovered: u64,
}
/// Coarse classification of a SCSI sense key for diagnostic logging.
@@ -187,6 +193,7 @@ impl ReadCtx {
zones_entered: 0,
jumps_taken: 0,
in_damage_zone: false,
marginal_recovered: 0,
}
}
@@ -226,6 +233,7 @@ impl ReadCtx {
zones_entered: 0,
jumps_taken: 0,
in_damage_zone: false,
marginal_recovered: 0,
}
}
@@ -283,6 +291,7 @@ impl ReadCtx {
total_errors: self.total_errors,
zones_entered: self.zones_entered,
jumps_taken: self.jumps_taken,
marginal_recovered: self.marginal_recovered,
}
}
}
@@ -296,6 +305,8 @@ pub struct PassSummary {
pub total_errors: u64,
pub zones_entered: u64,
pub jumps_taken: u64,
/// RECOVERED ERROR (marginal) reads distrusted and re-queued for Pass N.
pub marginal_recovered: u64,
}
/// What the caller should do after a read failure. The caller owns the
@@ -639,6 +650,36 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
};
}
// 4b. RECOVERED ERROR — the drive returned this sector but had to fight for
// it (ECC worked hard / retried). We enable reporting of these via MODE
// SELECT PER=1 at drive-prep precisely so they surface: on marginal/dirty
// media the drive's best-effort correction can be silently WRONG (a rip
// that "passed clean" but decoded with errors — the Bourne case). We
// distrust it: mark THIS block NonTrimmed and let Pass N re-read it with
// proper recovery (FUA cache-bypass) — a clean re-read wins, a persistent
// marginal becomes an honest concealed gap.
//
// Critically this is a per-block SkipBlock, NOT the damage-jump path: a
// recovered error is a single marginal sector, not a clustered hard-damage
// zone, so it must NOT trigger the 64 MB JumpAhead (which would nuke huge
// swaths of good data on a lightly-smudged disc). It also does NOT touch
// the damage window / multiplier — a recovered read is not damage-zone
// signal. Returns before the fast-jump logic below.
if sense_key == scsi::SENSE_KEY_RECOVERED_ERROR {
ctx.marginal_recovered += 1;
tracing::warn!(
target: "freemkv::disc",
phase = "recovered_error",
marginal_recovered = ctx.marginal_recovered,
asc = err.scsi_sense().map(|s| s.asc),
ascq = err.scsi_sense().map(|s| s.ascq),
"drive reported a recovered (marginal) read — distrusting; marking NonTrimmed for Pass N re-read"
);
return ReadAction::SkipBlock {
pause_secs: FAIL_PAUSE_SECS,
};
}
// 5. Marginal media (MEDIUM_ERROR / ABORTED_COMMAND) on a multi-
// sector batch: the drive can often read the same sectors
// individually. Bisect into single-sector reads (gentler on the
@@ -803,6 +844,62 @@ mod tests {
}
}
fn recovered_err() -> Error {
Error::DiscRead {
sector: 100,
status: Some(2),
sense: Some(ScsiSense {
sense_key: scsi::SENSE_KEY_RECOVERED_ERROR,
asc: 0x17,
ascq: 0x01,
}),
}
}
#[test]
fn recovered_error_skips_block_not_jump_pass_1() {
// A recovered (marginal) read on Pass 1 must NOT trigger the 64 MB
// damage-jump — that would nuke huge good regions on a lightly-smudged
// disc. It marks just this block NonTrimmed (SkipBlock) for a Pass N
// re-read, and is counted as marginal_recovered.
let mut ctx = ReadCtx::for_sweep(32);
let action = handle_read_error(&recovered_err(), &mut ctx);
assert!(
matches!(action, ReadAction::SkipBlock { .. }),
"recovered error must SkipBlock, not JumpAhead; got {action:?}"
);
assert_eq!(ctx.marginal_recovered, 1);
// It must not have inflated the damage-jump multiplier (not damage signal).
assert_eq!(ctx.jump_multiplier, 1);
assert_eq!(ctx.jumps_taken, 0);
}
#[test]
fn recovered_error_skips_block_pass_n_too() {
// Pass N sees the same: a recovered read is distrusted → SkipBlock (the
// outer patch loop re-reads the range with FUA).
let mut ctx = ReadCtx::for_patch(1);
let action = handle_read_error(&recovered_err(), &mut ctx);
assert!(
matches!(action, ReadAction::SkipBlock { .. }),
"got {action:?}"
);
assert_eq!(ctx.marginal_recovered, 1);
}
#[test]
fn many_recovered_errors_never_jump() {
// Even a run of recovered errors must never escalate to a damage-jump —
// they're marginal reads, not a hard-damage cluster.
let mut ctx = ReadCtx::for_sweep(32);
for _ in 0..40 {
let a = handle_read_error(&recovered_err(), &mut ctx);
assert!(matches!(a, ReadAction::SkipBlock { .. }), "got {a:?}");
}
assert_eq!(ctx.jumps_taken, 0, "recovered errors never jump");
assert_eq!(ctx.marginal_recovered, 40);
}
#[test]
fn pass_n_marginal_with_batch_gt_1_bisects() {
let mut ctx = ReadCtx::for_patch(32);
+191
View File
@@ -61,8 +61,28 @@ const SPIN_UP_SETTLE_SECS: u64 = 10;
const SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL: u8 = 0x1E;
const SCSI_GET_EVENT_STATUS: u8 = 0x4A;
const SCSI_MODE_SENSE: u8 = 0x5A;
const SCSI_MODE_SELECT: u8 = 0x55;
const SCSI_REPORT_KEY: u8 = 0xA4;
/// SBC/MMC Read-Write Error Recovery mode page (page code 0x01). We flip the
/// `PER` bit to make the drive REPORT a recovered read (via CHECK CONDITION +
/// sense key RECOVERED ERROR) instead of silently returning best-effort data as
/// GOOD status. On marginal/dirty media that silent-GOOD data can be
/// mis-corrected — a rip that "passed clean" but decoded with errors. With PER
/// on, freemkv sees the marginal read and re-reads it in Pass N (a loud miss,
/// never a silent commit). See `build_error_recovery_select_payload`.
const MODE_PAGE_ERROR_RECOVERY: u8 = 0x01;
/// Bit masks in the Read-Write Error Recovery flags byte (page byte 2).
const ERP_FLAG_TB: u8 = 0x20; // Transfer Block: still deliver the recovered data
const ERP_FLAG_PER: u8 = 0x04; // Post Error: report recovered errors
const ERP_FLAG_DTE: u8 = 0x02; // Data Terminate on Error: MUST be off (we want the data)
/// `Parameters Saveable` bit in a mode page's byte 0 — valid only on MODE SENSE;
/// must be cleared before echoing the page back in a MODE SELECT.
const MODE_PAGE_PS_BIT: u8 = 0x80;
/// MODE SENSE(10) parameter header length (bytes), preceding any block
/// descriptors and the mode pages.
const MODE10_HEADER_LEN: usize = 8;
/// Optical disc drive session -- open, identify, unlock, and read.
pub struct Drive {
scsi: Box<dyn ScsiTransport>,
@@ -464,6 +484,12 @@ impl Drive {
// wants max speed. Best-effort: a failure here must NOT fail the rip.
if r.is_ok() {
self.set_speed(crate::speed::DriveSpeed::Max.to_kbps());
// Ask the drive to REPORT recovered/marginal reads rather than
// silently commit best-effort data as GOOD (the dirty-disc
// "passed-clean-but-decodes-with-errors" trap). Best-effort: a drive
// that doesn't honor it just keeps its defaults — no regression, and
// on a clean disc it changes nothing.
self.enable_recovered_error_reporting();
}
tracing::info!(
target: "freemkv::drive",
@@ -599,6 +625,53 @@ impl Drive {
}
}
/// Ask the drive to REPORT recovered/marginal reads instead of silently
/// returning best-effort data as GOOD status. MODE SENSE the Read-Write
/// Error Recovery page, flip `PER` (and `TB` on / `DTE` off so we still get
/// the data), and MODE SELECT it back — preserving the drive's own retry
/// count and other bits.
///
/// Best-effort: a drive that doesn't support the page, or rejects the SELECT,
/// simply keeps its default behaviour — no regression, the rip proceeds. On a
/// clean disc this changes nothing (no recovered errors fire); it only
/// surfaces the marginal reads that a dirty disc would otherwise commit
/// silently. Returns whether the page was successfully written.
pub fn enable_recovered_error_reporting(&mut self) -> bool {
let Some(sense) = self.mode_sense_page(MODE_PAGE_ERROR_RECOVERY) else {
tracing::debug!(target: "freemkv::drive", "MODE SENSE error-recovery page unavailable; leaving drive defaults");
return false;
};
let Some(payload) = build_error_recovery_select_payload(&sense) else {
tracing::debug!(target: "freemkv::drive", "error-recovery page malformed/short; leaving drive defaults");
return false;
};
// MODE SELECT(10): PF=1 (page format), parameter list length = payload.
let len = payload.len() as u16;
let cdb = [
SCSI_MODE_SELECT,
0x10, // PF=1, SP=0 (don't persist across power cycles)
0x00,
0x00,
0x00,
0x00,
0x00,
(len >> 8) as u8,
len as u8,
0x00,
];
let mut buf = payload;
match self.checked_exec(&cdb, crate::scsi::DataDirection::ToDevice, &mut buf, 5_000) {
Ok(_) => {
tracing::info!(target: "freemkv::drive", phase = "error_recovery", "recovered-error reporting enabled (PER=1) — marginal reads will surface instead of committing silently");
true
}
Err(e) => {
tracing::debug!(target: "freemkv::drive", error = %e, "MODE SELECT error-recovery page rejected; leaving drive defaults");
false
}
}
}
/// Read vendor-specific READ BUFFER data.
pub fn read_buffer(&mut self, mode: u8, buffer_id: u8, length: u16) -> Option<Vec<u8>> {
let cdb = crate::scsi::build_read_buffer(mode, buffer_id, 0, length as u32);
@@ -1101,6 +1174,46 @@ fn select_drive_with_media(drives: impl Iterator<Item = Drive>) -> Option<Drive>
fallback
}
/// Turn a MODE SENSE(10) Read-Write Error Recovery page response into the
/// payload for a MODE SELECT(10) that enables recovered-error REPORTING —
/// preserving every other bit (notably the drive's own read-retry count).
///
/// Pure so the bit-twiddling is unit-tested without a drive. Steps:
/// - locate the page after the 8-byte header + block descriptors (bytes 6-7);
/// - verify it is page 0x01 with a flags byte present;
/// - in the flags byte: set `PER` (report) and `TB` (still deliver the data),
/// clear `DTE` (don't terminate the transfer on the recovered error);
/// - clear the page's `PS` bit (valid only on SENSE) and zero the header's
/// mode-data-length field (reserved on SELECT).
///
/// Returns `None` (caller leaves the drive at its defaults) when the response is
/// too short or isn't the error-recovery page — never panics on adversarial
/// bytes.
fn build_error_recovery_select_payload(sense: &[u8]) -> Option<Vec<u8>> {
if sense.len() < MODE10_HEADER_LEN {
return None;
}
let block_desc_len = u16::from_be_bytes([sense[6], sense[7]]) as usize;
let page_off = MODE10_HEADER_LEN.checked_add(block_desc_len)?;
// Need page byte 0 (code), byte 1 (length), byte 2 (flags).
if page_off.checked_add(3)? > sense.len() {
return None;
}
if sense[page_off] & 0x3F != MODE_PAGE_ERROR_RECOVERY {
return None;
}
let mut payload = sense.to_vec();
// Header: mode-data-length is reserved on SELECT — zero it.
payload[0] = 0;
payload[1] = 0;
// Page byte 0: clear PS (SENSE-only).
payload[page_off] &= !MODE_PAGE_PS_BIT;
// Flags byte: PER on, TB on, DTE off. Retry count (next byte) untouched.
payload[page_off + 2] |= ERP_FLAG_PER | ERP_FLAG_TB;
payload[page_off + 2] &= !ERP_FLAG_DTE;
Some(payload)
}
/// Decode a READ CAPACITY (10) response into a sector count.
///
/// A short transfer (`bytes_transferred < 4`, which would leave the high
@@ -1276,6 +1389,84 @@ mod command_tests {
use super::*;
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
/// A minimal MODE SENSE(10) response carrying the Read-Write Error Recovery
/// page (0x01) with the given flags byte and retry count, no block
/// descriptors. `ps` sets the page's PS bit (SENSE-only), which the SELECT
/// payload must clear.
fn mode_sense_error_recovery(flags: u8, retry: u8, ps: bool) -> Vec<u8> {
let mut v = vec![0u8; MODE10_HEADER_LEN + 12];
// Header: nonzero mode-data-length (must be zeroed on SELECT); no block
// descriptors.
v[0] = 0x00;
v[1] = 0x22;
v[6] = 0x00;
v[7] = 0x00; // block descriptor length = 0
let po = MODE10_HEADER_LEN;
v[po] = MODE_PAGE_ERROR_RECOVERY | if ps { MODE_PAGE_PS_BIT } else { 0 };
v[po + 1] = 0x0A; // page length
v[po + 2] = flags; // error-recovery flags
v[po + 3] = retry; // read retry count
v
}
#[test]
fn error_recovery_payload_sets_per_tb_clears_dte_ps_preserves_retry() {
// Start with PER off, DTE on, PS on, a specific retry count. The SELECT
// payload must flip PER on, TB on, DTE off, clear PS, zero the header
// mode-data-length, and leave the retry count untouched.
let sense = mode_sense_error_recovery(ERP_FLAG_DTE, 0x2C, true);
let out = build_error_recovery_select_payload(&sense).expect("valid page");
let po = MODE10_HEADER_LEN;
assert_eq!(out[0], 0, "header mode-data-length zeroed for SELECT");
assert_eq!(out[1], 0);
assert_eq!(out[po] & MODE_PAGE_PS_BIT, 0, "PS cleared for SELECT");
assert_eq!(out[po] & 0x3F, MODE_PAGE_ERROR_RECOVERY, "still page 0x01");
assert_eq!(out[po + 2] & ERP_FLAG_PER, ERP_FLAG_PER, "PER set");
assert_eq!(
out[po + 2] & ERP_FLAG_TB,
ERP_FLAG_TB,
"TB set (still get data)"
);
assert_eq!(
out[po + 2] & ERP_FLAG_DTE,
0,
"DTE cleared (don't terminate)"
);
assert_eq!(out[po + 3], 0x2C, "read retry count preserved");
}
#[test]
fn error_recovery_payload_honors_block_descriptor_offset() {
// With an 8-byte block descriptor between header and page, the function
// must locate the page at header+desc, not a fixed offset.
let mut sense = vec![0u8; MODE10_HEADER_LEN + 8 + 12];
sense[7] = 8; // block descriptor length
let po = MODE10_HEADER_LEN + 8;
sense[po] = MODE_PAGE_ERROR_RECOVERY;
sense[po + 1] = 0x0A;
sense[po + 2] = 0x00;
let out = build_error_recovery_select_payload(&sense).expect("valid");
assert_eq!(
out[po + 2] & ERP_FLAG_PER,
ERP_FLAG_PER,
"PER set at the descriptor-offset page"
);
}
#[test]
fn error_recovery_payload_rejects_wrong_or_short_page() {
// Wrong page code → None (leave drive at defaults).
let mut wrong = mode_sense_error_recovery(0, 0, false);
wrong[MODE10_HEADER_LEN] = 0x08; // page 0x08 (caching), not 0x01
assert!(build_error_recovery_select_payload(&wrong).is_none());
// Too short to hold the header → None, no panic.
assert!(build_error_recovery_select_payload(&[0u8; 4]).is_none());
// Header claims a block descriptor that runs off the buffer → None.
let mut bad = mode_sense_error_recovery(0, 0, false);
bad[7] = 0xF0; // descriptor length way past the buffer
assert!(build_error_recovery_select_payload(&bad).is_none());
}
/// Mock transport: returns a fixed data payload (copied into the
/// caller's buffer, truncated to fit) on every `execute()`.
struct FixedTransport {
+31
View File
@@ -150,6 +150,14 @@ pub const E_NETWORK_ADDR_BLOCKED: u16 = 9022;
/// frame dropped before the first keyframe) cannot report success.
pub const E_MUX_EMPTY: u16 = 9023;
pub const E_EXTENT_NOT_UNIT_ALIGNED: u16 = 9030;
/// `mp4://` output but the title has no (primary) video track to carry.
pub const E_MP4_NO_VIDEO_TRACK: u16 = 9048;
/// `mp4://` video track uses a codec with no MP4 mapping / no config record
/// available here (e.g. VC-1, or MPEG-2 without an avcC/hvcC-style record).
pub const E_MP4_UNSUPPORTED_VIDEO_CODEC: u16 = 9049;
/// `mp4://` video track is missing its codec-configuration record
/// (`hvcC`/`avcC`), without which the sample entry can't be written.
pub const E_MP4_MISSING_CODEC_PRIVATE: u16 = 9050;
/// READ CAPACITY returned a short or overflowing transfer.
pub const E_DISC_CAPACITY_MALFORMED: u16 = 9047;
@@ -420,6 +428,12 @@ pub enum Error {
/// every frame dropped before the first keyframe — fails loudly. The
/// `m2ts://` analogue of [`Error::MkvInvalid`]'s zero-frame guard.
MuxEmpty,
/// `mp4://` target title has no primary video track to mux.
MuxNoVideoTrack,
/// `mp4://` video codec has no MP4 sample-entry mapping available here.
Mp4UnsupportedVideoCodec,
/// `mp4://` video track is missing its `hvcC`/`avcC` configuration record.
MuxMissingCodecPrivate,
PesFrameTooLarge {
size: usize,
},
@@ -599,6 +613,9 @@ impl Error {
Error::StreamUrlMissingPort { .. } => E_STREAM_URL_MISSING_PORT,
Error::NetworkAddrBlocked { .. } => E_NETWORK_ADDR_BLOCKED,
Error::MuxEmpty => E_MUX_EMPTY,
Error::MuxNoVideoTrack => E_MP4_NO_VIDEO_TRACK,
Error::Mp4UnsupportedVideoCodec => E_MP4_UNSUPPORTED_VIDEO_CODEC,
Error::MuxMissingCodecPrivate => E_MP4_MISSING_CODEC_PRIVATE,
Error::PesFrameTooLarge { .. } => E_PES_FRAME_TOO_LARGE,
Error::PesInvalidMagic => E_PES_INVALID_MAGIC,
Error::PesTrackTooLarge { .. } => E_PES_TRACK_TOO_LARGE,
@@ -833,6 +850,11 @@ impl From<Error> for std::io::Error {
// 9023 MuxEmpty: finish() reached with zero frames — the output
// would be a header-only container. Treat as invalid output.
E_MUX_EMPTY => std::io::ErrorKind::InvalidData,
// 9048-9050 mp4:// track/codec/config mismatches: the requested
// output can't hold this title's video — invalid output request.
E_MP4_NO_VIDEO_TRACK | E_MP4_UNSUPPORTED_VIDEO_CODEC | E_MP4_MISSING_CODEC_PRIVATE => {
std::io::ErrorKind::InvalidData
}
// 9030 ExtentNotUnitAligned: a malformed/non-AACS-aligned
// extent was handed to the prefetch producer.
9030 => std::io::ErrorKind::InvalidInput,
@@ -1236,6 +1258,9 @@ mod tests {
E_STREAM_URL_MISSING_PORT,
E_NETWORK_ADDR_BLOCKED,
E_MUX_EMPTY,
E_MP4_NO_VIDEO_TRACK,
E_MP4_UNSUPPORTED_VIDEO_CODEC,
E_MP4_MISSING_CODEC_PRIVATE,
E_PES_FRAME_TOO_LARGE,
E_PES_INVALID_MAGIC,
E_PES_TRACK_TOO_LARGE,
@@ -1323,6 +1348,12 @@ mod tests {
(Error::PipelineConsumerGone, E_PIPELINE_CONSUMER_GONE),
(Error::DiscCapacityOverflow, E_DISC_CAPACITY_OVERFLOW),
(Error::MuxEmpty, E_MUX_EMPTY),
(Error::MuxNoVideoTrack, E_MP4_NO_VIDEO_TRACK),
(
Error::Mp4UnsupportedVideoCodec,
E_MP4_UNSUPPORTED_VIDEO_CODEC,
),
(Error::MuxMissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE),
(Error::M2tsPacketMalformed, E_M2TS_PACKET_MALFORMED),
(Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED),
(Error::DiscCapacityMalformed, E_DISC_CAPACITY_MALFORMED),
+46 -4
View File
@@ -16,7 +16,7 @@
/// (case-insensitive), then requires an even run of ASCII hex digits. Any
/// non-hex byte, or an odd length, yields `None`.
pub fn parse_hex_bytes(s: &str) -> Option<Vec<u8>> {
let body = strip_prefix(s.trim());
let body = strip_hex_prefix(s.trim());
let bytes = body.as_bytes();
// Empty → empty Vec (a legitimately-empty variable-length field); odd length
// is malformed. (`parse_hex_fixed` enforces a concrete length separately.)
@@ -34,7 +34,7 @@ pub fn parse_hex_bytes(s: &str) -> Option<Vec<u8>> {
/// prefix; requires EXACTLY `2*N` ASCII hex digits after it. `None` on any
/// non-hex byte or a length mismatch.
pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
let body = strip_prefix(s.trim());
let body = strip_hex_prefix(s.trim());
let bytes = body.as_bytes();
if bytes.len() != 2 * N {
return None;
@@ -46,8 +46,33 @@ pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
Some(out)
}
/// Strip a single leading `0x` / `0X` if present (case-insensitive).
fn strip_prefix(s: &str) -> &str {
/// Parse a hex string into a `u16`. Accepts an optional `0x`/`0X` prefix
/// (case-insensitive) via the same [`strip_hex_prefix`] the byte parsers use.
/// `None` on any non-hex content or overflow.
///
/// Exists so callers never hand-roll `from_str_radix(s.trim_start_matches("0x"), 16)`
/// — a **case-sensitive** strip that silently dropped an uppercase-`0X` value.
/// (That reintroduced-in-keydb bug is exactly what this module was built to kill;
/// the integer fields now share the one prefix rule.)
pub fn parse_hex_u16(s: &str) -> Option<u16> {
u16::from_str_radix(strip_hex_prefix(s.trim()), 16).ok()
}
/// Parse a hex string into a `u32`. See [`parse_hex_u16`].
pub fn parse_hex_u32(s: &str) -> Option<u32> {
u32::from_str_radix(strip_hex_prefix(s.trim()), 16).ok()
}
/// Parse a hex string into a `u8`. See [`parse_hex_u16`].
pub fn parse_hex_u8(s: &str) -> Option<u8> {
u8::from_str_radix(strip_hex_prefix(s.trim()), 16).ok()
}
/// Strip a single leading `0x` / `0X` if present (case-insensitive). Public so
/// callers that only need the prefix rule (e.g. normalizing a disc hash) reuse
/// the one definition instead of hand-rolling a case-sensitive
/// `trim_start_matches("0x")`.
pub fn strip_hex_prefix(s: &str) -> &str {
s.strip_prefix("0x")
.or_else(|| s.strip_prefix("0X"))
.unwrap_or(s)
@@ -95,6 +120,23 @@ mod tests {
assert_eq!(parse_hex_fixed::<16>(&s), None);
}
#[test]
fn hex_ints_accept_both_prefix_cases_and_bare() {
// The regression the keydb device-key bug hit: uppercase `0X` must parse
// identically to `0x` and to a bare value.
assert_eq!(parse_hex_u16("0x0001"), Some(1));
assert_eq!(parse_hex_u16("0X0001"), Some(1));
assert_eq!(parse_hex_u16("0001"), Some(1));
assert_eq!(parse_hex_u16(" 0XABCD "), Some(0xABCD));
assert_eq!(parse_hex_u32("0X00000002"), Some(2));
assert_eq!(parse_hex_u32("deadbeef"), Some(0xDEAD_BEEF));
assert_eq!(parse_hex_u8("0X03"), Some(3));
assert_eq!(parse_hex_u8("ff"), Some(0xFF));
// Overflow / non-hex → None.
assert_eq!(parse_hex_u8("0x1FF"), None);
assert_eq!(parse_hex_u16("0xzz"), None);
}
#[test]
fn bytes_variable_length_and_odd_rejected() {
assert_eq!(parse_hex_bytes("0xAABBCC"), Some(vec![0xAA, 0xBB, 0xCC]));
+295 -37
View File
@@ -23,6 +23,64 @@ use crate::aacs::types::{UnitKey, Vid};
use crate::disc::Key;
use crate::error::Error;
/// Minimum encrypted-content unit samples a single online key request must carry.
///
/// The key service identifies a key by which of the submitted units it decrypts,
/// so too few samples — especially on FMTS, where a segment interleaves several
/// variants at the unit level — can return a key that matches an incidental unit
/// rather than the one asked about (a false positive). This many distinct units
/// make the request unambiguous.
///
/// Canonical here (the base crate) so BOTH consumers agree on one value: the
/// online source in `freemkv-keysources` (which refuses to send an under-sampled
/// request) re-exports it, and libfreemkv's own FMTS forensic query
/// ([`crate::mux`]) sizes its per-segment batch by it. Layering forbids the
/// reverse import (keysources depends on libfreemkv, not vice versa), so the
/// value lives at the lower layer both share.
pub const MIN_SAMPLE_UNITS: usize = 8;
/// A set of encrypted content-unit samples PROVEN to carry at least
/// [`MIN_SAMPLE_UNITS`] units — the online `/decode` request's proof-of-ownership.
///
/// "Parse, don't validate": the only constructor, [`DecodeSampleSet::new`], returns
/// `None` for an under-sized slice, so an online key request simply *cannot be built*
/// from too few samples. The runtime `len() < MIN_SAMPLE_UNITS` check that used to
/// live at the request site (and was silently forgotten by an under-sampling caller,
/// reading as "key service down") becomes a compile-time obligation: a request builder
/// that takes `&DecodeSampleSet` can never receive an unchecked `Vec`.
///
/// The *count* enforced here is a runtime property of the disc (how many encrypted
/// units it yields); the *requested* count is a caller-side compile-time constant that
/// callers pin to `MIN_SAMPLE_UNITS` (see e.g. autorip's `SAMPLE_UNITS`). Together the
/// two make under-sampling unrepresentable at the request boundary.
#[derive(Debug, Clone)]
pub struct DecodeSampleSet(Vec<Vec<u8>>);
impl DecodeSampleSet {
/// Wrap `units` iff it carries at least [`MIN_SAMPLE_UNITS`] samples; `None`
/// otherwise (the caller then skips the online source rather than sending an
/// ambiguous request). This is the sole way to obtain a `DecodeSampleSet`.
pub fn new(units: Vec<Vec<u8>>) -> Option<Self> {
(units.len() >= MIN_SAMPLE_UNITS).then_some(Self(units))
}
/// The proven-sufficient samples. Guaranteed `>= MIN_SAMPLE_UNITS` in length.
pub fn units(&self) -> &[Vec<u8>] {
&self.0
}
/// Number of samples — always `>= MIN_SAMPLE_UNITS`.
pub fn len(&self) -> usize {
self.0.len()
}
/// Always `false` (a `DecodeSampleSet` never holds fewer than `MIN_SAMPLE_UNITS`);
/// provided so the type satisfies the usual `len`/`is_empty` pairing.
pub fn is_empty(&self) -> bool {
false
}
}
/// The public AACS inputs a key source needs to look a disc up. Captured at
/// scan; contains no secrets — only the disc identity and the on-disc AACS
/// structures a source or key server may key on.
@@ -57,7 +115,7 @@ pub struct DiscInputs {
pub volume_label: Option<String>,
}
/// A lazy view of a disc's AACS material, handed to [`KeySource::get_uk`] so a
/// A lazy view of a disc's AACS material, handed to [`KeySource::get_unit_keys`] so a
/// source can drive the derivation chain without holding the disc reader.
///
/// "Lazy" by contract: each accessor returns only what the source asks for, so a
@@ -175,10 +233,34 @@ impl ResolveCtx for DiscInputsCtx<'_> {
/// ([`resolve_and_apply`]) tries each source in order and validates the returned
/// keys against real ciphertext before committing them, so a wrong key from one
/// source transparently falls through to the next.
///
/// Two explicit resolve operations, one per key kind — never one overloaded call
/// whose meaning depends on how many keys came back:
/// * [`get_unit_keys`](Self::get_unit_keys) — the disc's base per-CPS-unit Unit
/// Keys (index space = CPS-unit number). The common path for every disc.
/// * [`get_fmts_indexes`](Self::get_fmts_indexes) — the AACS 2.1 forensic index
/// keys (index space = forensic index 1..N). Defaults to empty: a source with
/// no forensic material opts out, and only an FMTS disc ever asks.
///
/// What each source must do to answer is the source's own business: a keydb keys
/// on `disc_hash` and reads no samples; the online source submits the ctx's
/// content samples (a base batch for `get_unit_keys`, an index-1 anchor batch for
/// `get_fmts_indexes`) to the key service.
pub trait KeySource {
/// Resolve this disc's terminal Unit Keys from this source. An empty `Vec`
/// is a genuine "no key here"; `Err` is a source failure.
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error>;
/// Resolve this disc's base per-CPS-unit Unit Keys from this source. An empty
/// `Vec` is a genuine "no key here"; `Err` is a source failure.
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error>;
/// Resolve this disc's AACS 2.1 forensic index keys — the per-index keys the
/// base Unit Key cannot open (see [`crate::aacs::segment`]) — ordered by
/// forensic index (element `i` carries `UnitKey.idx == i`, forensic index
/// `i + 1`). The source hands back the COMPLETE set it holds; the caller
/// trusts any non-empty result as all of them and never assumes a fixed count.
/// Defaults to empty: a source with no forensic material (a plain keydb, the
/// mapfile) opts out, and only an FMTS disc's mux ever calls this.
fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(Vec::new())
}
/// The AACS host certificate(s) this source can supply for the live-drive
/// SCSI mutual-auth handshake (the OEM/AACS baseline route). `mkb` is the
@@ -215,7 +297,7 @@ pub fn resolve_and_apply(
/// [`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
/// One-shot per source: each source's [`KeySource::get_unit_keys`] is called exactly
/// once with a [`DiscInputsCtx`] over `inputs`. Non-empty Unit Keys are mapped
/// to terminal [`Key::Unit`]s and applied via [`crate::Disc::decrypt_with`],
/// which validates them against `inputs.samples` and only mutates the disc on
@@ -243,7 +325,7 @@ pub fn resolve_and_apply_traced(
for source in sources {
// `who` is the source's own stable identifier — no enum to map back to.
let who = source.label().to_string();
match source.get_uk(&ctx) {
match source.get_unit_keys(&ctx) {
Ok(uks) if !uks.is_empty() => {
// Positional index → canonical CPS-unit number (position + 1).
let unit_keys: Vec<(u32, [u8; 16])> = uks
@@ -294,7 +376,7 @@ pub fn resolve_and_apply_traced(
/// decorator re-decrypts with the returned keys, which is the validation.
pub fn fetch_unit_keys(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
for source in sources {
if let Ok(uks) = source.get_uk(ctx) {
if let Ok(uks) = source.get_unit_keys(ctx) {
if !uks.is_empty() {
return uks;
}
@@ -303,35 +385,92 @@ pub fn fetch_unit_keys(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) ->
Vec::new()
}
/// Build the read-time key-fetch closure from the disc's public AACS inputs and
/// a way to (re)build the application's key sources. The decorator calls it with
/// the still-scrambled unit ciphertext when no held key opens that unit; it runs
/// [`fetch_unit_keys`] with those bytes as `samples` and returns any keys.
/// The forensic counterpart to [`fetch_unit_keys`]: drive `sources` in order and
/// return the first source's non-empty AACS 2.1 forensic index set. `ctx` carries
/// the index-1 anchor batch (the mux, which owns disc geometry, gathers it and
/// injects it as the ctx's samples); a source that needs no samples (a keydb
/// keying on `disc_hash`) ignores them. Whatever the winning source returns —
/// ≥ 1 key — is trusted as the COMPLETE ordered set; no fixed count is assumed.
pub fn fetch_fmts_indexes(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
for source in sources {
if let Ok(uks) = source.get_fmts_indexes(ctx) {
if !uks.is_empty() {
return uks;
}
}
}
Vec::new()
}
/// Build the read-time [`crate::sector::KeyFetch`] from the disc's public AACS
/// inputs and a way to (re)build the application's key sources. The returned
/// resolver has the two explicit operations the mux and recovery decorator call:
/// [`unit_keys`](crate::sector::KeyFetch::unit_keys) drives [`fetch_unit_keys`]
/// (base per-CPS-unit keys), [`fmts_indexes`](crate::sector::KeyFetch::fmts_indexes)
/// drives [`fetch_fmts_indexes`] (the AACS 2.1 forensic set). Each is handed the
/// caller's sample batch as the ctx's `samples`, so a source pulls whatever
/// material it needs.
///
/// One builder, used by every read path (sweep / patch / mux) and by every
/// consumer (CLI, autorip) — neither application contains the fetch logic, only
/// its key-source config. Returns a **shared, stateless** [`crate::sector::KeyFetch`]
/// (`Arc<Fn>`): build it once, clone it into each read path. `make_sources` is
/// invoked per fetch (the cold path, ~once per CPS unit) so the closure stays
/// One builder, used by every read path (sweep / patch / mux) and every consumer
/// (CLI, autorip) — neither application contains the fetch logic, only its
/// key-source config. Cheap to clone; build once, clone into each read path.
/// `make_sources` is invoked per fetch (the cold path) so the resolver stays
/// `Send + Sync` without requiring `KeySource: Send`.
pub fn key_fetch(
inputs: DiscInputs,
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
) -> crate::sector::KeyFetch {
std::sync::Arc::new(move |samples: &[Vec<u8>]| -> Vec<[u8; 16]> {
let sources = make_sources();
let mut di = inputs.clone();
di.samples = samples.to_vec();
// Parse Unit_Key_RO.inf at the disc's OWN stride (carried on `inputs`):
// an online /decode reply that returns a VUK (not a terminal UK) then
// derives unit keys from `enc_title_keys`, which a V10 disc parses at the
// 48-byte stride — hardcoding the V20 stride here corrupted them.
let ctx = DiscInputsCtx::new(&di);
fetch_unit_keys(&sources, &ctx)
.into_iter()
.map(|u| u.key)
.collect()
})
// One driver behind both operations: rebuild the sources, inject `samples`
// as the ctx's content samples, run `drive` (the per-kind fetch), map the
// resolved UnitKeys to raw keys. Memoized by the fingerprint of the sample
// batch: the resolved keys are disc-level (a clip's index / CPS keys are
// identical for every title that references it), so the first batch resolves
// over the network and every repeat is answered from the cache with no
// request. Empty replies are cached too — a key the service lacks for a batch
// won't appear on a re-ask, so re-hitting the network buys nothing. Each
// operation gets its OWN cache: a base batch and a forensic anchor never
// collide, and the same bytes could legitimately resolve differently per op.
// The per-kind driver: `fetch_unit_keys` or `fetch_fmts_indexes`.
type FetchDriver = fn(&[Box<dyn KeySource>], &dyn ResolveCtx) -> Vec<UnitKey>;
fn make_op(
inputs: DiscInputs,
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
drive: FetchDriver,
) -> crate::sector::KeyFetchFn {
let cache: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<u64, Vec<[u8; 16]>>>> =
std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
std::sync::Arc::new(move |samples: &[Vec<u8>]| -> Vec<[u8; 16]> {
let fp = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
samples.len().hash(&mut h);
for s in samples {
s.hash(&mut h);
}
h.finish()
};
if let Some(hit) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&fp) {
return hit.clone();
}
let sources = make_sources();
let mut di = inputs.clone();
di.samples = samples.to_vec();
// Parse Unit_Key_RO.inf at the disc's OWN stride (carried on `inputs`):
// an online /decode reply that returns a VUK (not a terminal UK) then
// derives unit keys from `enc_title_keys`, which a V10 disc parses at
// the 48-byte stride — hardcoding the V20 stride here corrupted them.
let ctx = DiscInputsCtx::new(&di);
let keys: Vec<[u8; 16]> = drive(&sources, &ctx).into_iter().map(|u| u.key).collect();
cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(fp, keys.clone());
keys
})
}
let unit = make_op(inputs.clone(), make_sources.clone(), fetch_unit_keys);
let fmts = make_op(inputs, make_sources, fetch_fmts_indexes);
crate::sector::KeyFetch::new(unit, fmts)
}
/// Read up to `n` ENCRYPTED 6144-byte aligned units from `title`'s body, raw (no
@@ -425,6 +564,46 @@ mod tests {
use crate::aacs::types::UnitKey;
use std::sync::{Arc, Mutex};
fn units(n: usize) -> Vec<Vec<u8>> {
(0..n).map(|i| vec![i as u8; 4]).collect()
}
// ── DecodeSampleSet: the online request can't be built under-sized ─────────
/// Fewer than MIN_SAMPLE_UNITS → no set. Mutation: accepting a short slice
/// resurrects the exact autorip bug (a 4-sample request silently skipped /
/// read as "service down").
#[test]
fn decode_sample_set_rejects_under_min() {
for n in 0..MIN_SAMPLE_UNITS {
assert!(
DecodeSampleSet::new(units(n)).is_none(),
"{n} samples (< {MIN_SAMPLE_UNITS}) must not build a DecodeSampleSet"
);
}
}
/// Exactly the minimum, and above it, construct — and expose all samples.
#[test]
fn decode_sample_set_accepts_min_and_above() {
let exact = DecodeSampleSet::new(units(MIN_SAMPLE_UNITS)).expect("min builds");
assert_eq!(exact.len(), MIN_SAMPLE_UNITS);
assert_eq!(exact.units().len(), MIN_SAMPLE_UNITS);
assert!(!exact.is_empty());
let more = DecodeSampleSet::new(units(MIN_SAMPLE_UNITS + 5)).expect("above min builds");
assert_eq!(more.len(), MIN_SAMPLE_UNITS + 5);
}
/// The wrapped units round-trip byte-for-byte (the request carries exactly what
/// was gathered — no reordering/truncation).
#[test]
fn decode_sample_set_preserves_units() {
let raw = units(MIN_SAMPLE_UNITS);
let set = DecodeSampleSet::new(raw.clone()).unwrap();
assert_eq!(set.units(), raw.as_slice());
}
// ── KeySource default-method behaviour ────────────────────────────────────
/// KeySource::host_certs() defaults to empty regardless of the MKB argument.
@@ -436,7 +615,7 @@ mod tests {
fn key_source_host_certs_defaults_to_empty() {
struct MinimalSource;
impl KeySource for MinimalSource {
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(Vec::new())
}
}
@@ -493,7 +672,7 @@ mod tests {
fn trace_who_is_the_source_label_verbatim() {
struct LabeledSource(&'static str);
impl KeySource for LabeledSource {
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(Vec::new())
}
fn label(&self) -> &'static str {
@@ -550,19 +729,19 @@ mod tests {
struct EmptySource;
impl KeySource for EmptySource {
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(Vec::new())
}
}
struct ErroringSource;
impl KeySource for ErroringSource {
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Err(Error::AacsNoKeys)
}
}
struct HasKey([u8; 16]);
impl KeySource for HasKey {
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(vec![UnitKey::new(0, self.0)])
}
}
@@ -605,7 +784,7 @@ mod tests {
seen: Arc<Mutex<Vec<Vec<u8>>>>,
}
impl KeySource for Probe {
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
if let Ok(s) = ctx.samples(8) {
self.seen.lock().unwrap().extend(s);
}
@@ -625,7 +804,7 @@ mod tests {
let cb = key_fetch(empty_inputs(), make);
let samples = vec![vec![0xEEu8; crate::aacs::content::ALIGNED_UNIT_LEN]];
let got = cb(&samples);
let got = cb.unit_keys(&samples);
assert_eq!(
got,
vec![key],
@@ -639,6 +818,85 @@ mod tests {
assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch");
}
/// The two `KeyFetch` operations route to the two DISTINCT trait methods:
/// `unit_keys` drives `get_unit_keys`, `fmts_indexes` drives
/// `get_fmts_indexes`. A source that returns different keys per method proves
/// the seam no longer collapses "1 base key" and "the forensic set" into one
/// overloaded call — the operation, not the return length, decides which.
#[test]
fn key_fetch_routes_unit_and_fmts_to_distinct_source_methods() {
const BASE: [u8; 16] = [0xB0; 16];
const F1: [u8; 16] = [0xF1; 16];
const F2: [u8; 16] = [0xF2; 16];
struct TwoOp;
impl KeySource for TwoOp {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(vec![UnitKey::new(0, BASE)])
}
fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(vec![UnitKey::new(0, F1), UnitKey::new(1, F2)])
}
}
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> =
Arc::new(|| vec![Box::new(TwoOp) as Box<dyn KeySource>]);
let cb = key_fetch(empty_inputs(), make);
let samples = vec![vec![0x01u8; 4]];
assert_eq!(
cb.unit_keys(&samples),
vec![BASE],
"unit_keys resolves the base Unit Key via get_unit_keys"
);
assert_eq!(
cb.fmts_indexes(&samples),
vec![F1, F2],
"fmts_indexes resolves the forensic set (any length) via get_fmts_indexes"
);
}
/// `KeyFetch::unit_only` serves base keys but NEVER a forensic set — the
/// contract the sweep/patch recovery decorator relies on (it resolves CPS
/// units only). Its `fmts_indexes` is unconditionally empty.
#[test]
fn key_fetch_unit_only_never_serves_forensic() {
let f = crate::sector::KeyFetch::unit_only(std::sync::Arc::new(|_| vec![[0xAA; 16]]));
assert_eq!(f.unit_keys(&[vec![0u8; 4]]), vec![[0xAA; 16]]);
assert!(
f.fmts_indexes(&[vec![0u8; 4]]).is_empty(),
"unit_only resolver yields no forensic keys"
);
}
/// `get_fmts_indexes` defaults to empty, so a base-only source (a keydb) opts
/// out of the forensic path without implementing it. `fetch_fmts_indexes` then
/// falls through to the next source, exactly like the unit-key driver.
#[test]
fn fetch_fmts_indexes_skips_default_optout_source() {
struct BaseOnly; // uses the default (empty) get_fmts_indexes
impl KeySource for BaseOnly {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(vec![UnitKey::new(0, [0x11; 16])])
}
}
struct Forensic;
impl KeySource for Forensic {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(Vec::new())
}
fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(vec![UnitKey::new(0, [0x77; 16])])
}
}
let inputs = empty_inputs();
let ctx = DiscInputsCtx::new(&inputs);
let sources: Vec<Box<dyn KeySource>> = vec![Box::new(BaseOnly), Box::new(Forensic)];
let got = fetch_fmts_indexes(&sources, &ctx);
assert_eq!(got.len(), 1);
assert_eq!(got[0].key, [0x77; 16], "the base-only source is skipped");
}
/// #4 regression: encrypted content NOT at the extent midpoint (a late-
/// starting feature, or a midpoint landing in clear nav) must still be
/// sampled — empty samples make `decrypt_with` skip wrong-key validation.
+7 -2
View File
@@ -98,7 +98,7 @@ pub const VERSION_LABEL: &str = concat!(env!("FREEMKV_VERSION"), env!("GIT_SUFFI
/// The muxing/writing-application string written into MKV output
/// (`"freemkv <version> (g<hash>)"`).
pub const MUX_APP: &str = concat!("freemkv ", env!("FREEMKV_VERSION"), env!("GIT_SUFFIX"));
pub(crate) const MUX_APP: &str = concat!("freemkv ", env!("FREEMKV_VERSION"), env!("GIT_SUFFIX"));
pub mod aacs;
pub(crate) mod clpi;
@@ -187,7 +187,10 @@ pub use identity::DriveId;
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
// for callers that operate on raw sector buffers (e.g. ISO patching).
pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_threads};
pub use decrypt::{
AacsKeyMap, DecryptKeys, decrypt_sectors, decrypt_sectors_mapped, decrypt_threads,
set_decrypt_threads,
};
// ─── Disc structure ─────────────────────────────────────────────────────────
//
@@ -238,6 +241,7 @@ pub use mux::NullStream;
pub use mux::StdioStream;
pub use mux::WriteSeek;
pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
pub use mux::{Mp4FitReport, Mp4SkipReason, mp4_fit_report};
// ─── Lower-level surfaces ───────────────────────────────────────────────────
//
@@ -248,6 +252,7 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
// single decrypt-on-read decorator (AACS / CSS / none) — wrap any
// `SectorSource` to get plaintext sectors out.
pub use mux::build_iso_pipeline;
pub use mux::resolve_mux_key_map;
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
pub use sector::{
DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource,
+246 -53
View File
@@ -41,6 +41,11 @@ pub struct Ac3Parser {
/// the running per-frame PTS at the point the partial tail was retained.
/// Used by `flush()` to time the final buffered frame at EOS.
flush_pts_ns: i64,
/// Keep/drop bookkeeping for the CRC decodability gate. A frame that fails
/// its native CRC is dropped rather than shipped as a decoder-choking glitch;
/// the running PTS is advanced across it (see the emit loop) so the drop is a
/// silence gap, never a shift of the following audio.
tally: super::dropgate::DropTally,
}
impl Default for Ac3Parser {
@@ -54,8 +59,99 @@ impl Ac3Parser {
Self {
buf: Vec::with_capacity(4096),
flush_pts_ns: 0,
tally: super::dropgate::DropTally::new("ac3"),
}
}
/// Access units dropped as undecodable so far — surfaced to the CLI/mux.
pub fn dropped_frames(&self) -> u64 {
self.tally.dropped_frames()
}
/// Total decoded duration (ns) of dropped access units.
pub fn dropped_duration_ns(&self) -> u64 {
self.tally.dropped_duration_ns()
}
/// Emit the final buffered frame at EOS, through the decodability gate.
/// During streaming a final frame may sit in `buf` with no following PES to
/// complete it; without this drain the last ~32 ms of audio is lost. Only a
/// fully-sized frame at a syncword is considered; a partial/garbage tail is
/// discarded, and a corrupt (CRC-failing) final frame is dropped.
fn flush_tail(&mut self) -> Vec<Frame> {
let buf = std::mem::take(&mut self.buf);
let Some(off) = find_ac3_sync(&buf) else {
return Vec::new();
};
let frame_all = &buf[off..];
if frame_all.len() < 6 {
return Vec::new();
}
let bsid = get_bsid(frame_all);
let frame_size = if bsid >= 11 {
eac3_frame_size(frame_all)
} else {
ac3_frame_size(frame_all)
};
if !(MIN_FRAME_BYTES..=8192).contains(&frame_size) || off + frame_size > buf.len() {
return Vec::new();
}
let frame = &buf[off..off + frame_size];
let duration_ns = frame_duration_ns(frame, bsid);
if let Some(reason) = ac3_drop_reason(&self.tally, frame, bsid) {
self.tally
.record_drop(self.flush_pts_ns, duration_ns as i64, frame.len(), reason);
return Vec::new();
}
self.tally.record_kept();
vec![Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: self.flush_pts_ns,
keyframe: true,
data: frame.to_vec(),
duration_ns: Some(duration_ns),
}]
}
}
use super::crc::crc16_ansi;
/// Whether a fully-buffered (E-)AC-3 frame passes its native CRC. ffmpeg's
/// decoder checks exactly this — `av_crc(AV_CRC_16_ANSI, 0, &buf[2],
/// frame_size - 2) == 0` (ac3dec.c) — over the frame after the 2-byte syncword;
/// the trailing crc word makes a clean frame's residue zero. A nonzero residue
/// is a ~1-in-65536-certain sign of payload corruption, so we drop the frame
/// (silence gap) rather than ship a glitch. `frame` must be exactly the frame
/// bytes (syncword .. frame_size).
fn frame_crc_ok(frame: &[u8]) -> bool {
// Need the syncword (2) plus at least one covered byte; the caller only
// invokes this on a fully-sized frame, so this is defensive.
if frame.len() < 4 {
return true;
}
crc16_ansi(&frame[2..]) == 0
}
/// Decodability verdict for a fully-sized (E-)AC-3 frame: `Some(reason)` when it
/// must be dropped, `None` when it decodes. Drops (in order): a poisoned track
/// (mostly-undecodable → drop the rest), a bitstream id ffmpeg's parser rejects
/// (`bsid > 16` → `AC3_PARSE_ERROR_BSID`), or a failed native frame CRC.
fn ac3_drop_reason(
tally: &super::dropgate::DropTally,
frame: &[u8],
bsid: u8,
) -> Option<&'static str> {
if tally.is_poisoned() {
Some("track-poisoned")
} else if bsid > 16 {
Some("bsid")
} else if !frame_crc_ok(frame) {
Some("crc")
} else {
None
}
}
impl CodecParser for Ac3Parser {
@@ -91,10 +187,12 @@ impl CodecParser for Ac3Parser {
// in practice, so this is defense-in-depth.
let base_pts_ns = pes.pts.map(pts_to_ns).unwrap_or(self.flush_pts_ns);
// Prepend leftover from previous PES
// Prepend leftover from previous PES, then take the whole buffer into a
// local so the emit loop can call `self.tally` (the bytes are no longer
// borrowed from `self`). The unconsumed tail is written back at the end.
self.buf.extend_from_slice(&pes.data);
let data = &self.buf;
let buf = std::mem::take(&mut self.buf);
let data = &buf;
let mut frames = Vec::new();
let mut pos = 0;
// Running PTS for the next frame to emit in this call.
@@ -134,15 +232,26 @@ impl CodecParser for Ac3Parser {
}
let duration_ns = frame_duration_ns(remaining, bsid);
frames.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: frame_pts_ns,
keyframe: true,
data: data[start..start + frame_size].to_vec(),
duration_ns: Some(duration_ns),
});
let frame = &data[start..start + frame_size];
// Decodability gate: drop a frame ffmpeg's parser rejects (bsid > 16)
// or whose native CRC fails (payload corruption). `frame_pts_ns` is
// advanced BELOW whether or not the frame survives, so a drop is a
// silence gap and the following frames keep their true PTS.
if let Some(reason) = ac3_drop_reason(&self.tally, frame, bsid) {
self.tally
.record_drop(frame_pts_ns, duration_ns as i64, frame.len(), reason);
} else {
self.tally.record_kept();
frames.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: frame_pts_ns,
keyframe: true,
data: frame.to_vec(),
duration_ns: Some(duration_ns),
});
}
frame_pts_ns += duration_ns as i64;
pos = start + frame_size;
}
@@ -198,38 +307,10 @@ impl CodecParser for Ac3Parser {
}
fn flush(&mut self) -> Vec<Frame> {
// End of stream: emit a complete final frame still buffered. During
// streaming a final frame may sit in `buf` with no following PES to
// complete/confirm it; without this drain the last ~32 ms of audio is
// dropped at EOS (mirrors dts.rs::flush). Only a fully-sized frame at a
// syncword is emitted; a partial/garbage tail is discarded.
let buf = std::mem::take(&mut self.buf);
let Some(off) = find_ac3_sync(&buf) else {
return Vec::new();
};
let frame = &buf[off..];
if frame.len() < 6 {
return Vec::new();
}
let bsid = get_bsid(frame);
let frame_size = if bsid >= 11 {
eac3_frame_size(frame)
} else {
ac3_frame_size(frame)
};
if !(MIN_FRAME_BYTES..=8192).contains(&frame_size) || off + frame_size > buf.len() {
return Vec::new();
}
let duration_ns = frame_duration_ns(frame, bsid);
vec![Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: self.flush_pts_ns,
keyframe: true,
data: buf[off..off + frame_size].to_vec(),
duration_ns: Some(duration_ns),
}]
let out = self.flush_tail();
// Aggregate drop report at end-of-stream (warn-level, always visible).
self.tally.log_summary();
out
}
fn codec_private(&self) -> Option<Vec<u8>> {
@@ -460,9 +541,24 @@ mod tests {
frame[1] = 0x77;
frame[4] = (fscod << 6) | frmsizecod;
frame[5] = 0x08 << 3; // bsid = 8 (AC-3)
finalize_ac3_crc(&mut frame);
frame
}
/// Set the trailing CRC word so the whole-frame residue over `[2..]` is zero
/// — i.e. the frame passes the decodability gate. Relies on the CRC-16/ANSI
/// residue property: appending `crc16([2..n-2])` (big-endian) zeroes the
/// register over `[2..n]`. Leaves the crc1 field (bytes 2-3) untouched.
fn finalize_ac3_crc(frame: &mut [u8]) {
let n = frame.len();
if n < 4 {
return;
}
let c = crc16_ansi(&frame[2..n - 2]);
frame[n - 2] = (c >> 8) as u8;
frame[n - 1] = (c & 0xFF) as u8;
}
#[test]
fn parse_empty_pes() {
let mut parser = Ac3Parser::new();
@@ -1090,27 +1186,33 @@ mod tests {
// --- frame acceptance / rejection at the size boundaries ---
#[test]
fn eac3_frame_at_min_frame_bytes_is_accepted() {
// The smallest acceptable (E-)AC-3 frame is MIN_FRAME_BYTES = 6.
// Build an E-AC-3 frame whose frmsiz sizes it to exactly 6 bytes
// (frmsiz=2). bsid >= 11 selects E-AC-3 sizing. The parser must emit it.
fn eac3_frame_at_min_frame_bytes_passes_sizing_then_crc_gate() {
// The smallest frame the SIZING layer accepts is MIN_FRAME_BYTES = 6
// (frmsiz=2). A synthetic all-zero 6-byte frame passes sizing (so it
// reaches the decodability gate — proven by it being COUNTED as a drop,
// not silently size-skipped) but fails the CRC gate and is dropped; the
// following real AC-3 frame (valid CRC) is emitted.
let mut parser = Ac3Parser::new();
// 0x0B 0x77 | byte2=0 byte3=2 (frmsiz=2 → 6 bytes) | byte4=0 | byte5 bsid
let mut data = vec![0x0B, 0x77, 0x00, 0x02, 0x00, 16 << 3];
// pad to exactly 6 bytes (already 6). Then a trailing real AC-3 frame so
// the 6-byte frame isn't a tail that needs more data.
data.truncate(6);
data.extend_from_slice(&make_ac3_frame(0, 2));
let f = parser.parse(&make_eac3_pes(data));
assert_eq!(f.len(), 2, "6-byte E-AC-3 frame accepted + following AC-3");
assert_eq!(f[0].data.len(), 6);
assert_eq!(f.len(), 1, "6-byte frame dropped (CRC), real AC-3 emitted");
assert_eq!(f[0].data.len(), 160, "the surviving frame is the real AC-3");
assert_eq!(
parser.dropped_frames(),
1,
"the 6-byte frame reached the gate"
);
}
#[test]
fn eac3_max_frmsiz_frame_within_window_accepted() {
// E-AC-3 frmsiz is an 11-bit field (3 bits of byte2 + 8 bits of byte3),
// so its maximum value is 0x7FF = 2047 → (2048)*2 = 4096 bytes, which is
// inside the MIN_FRAME_BYTES..=8192 accept window and must be emitted.
// inside the MIN_FRAME_BYTES..=8192 accept window and, with a valid CRC,
// must be emitted.
let mut parser = Ac3Parser::new();
let mut frame = vec![0u8; 4096];
frame[0] = 0x0B;
@@ -1118,6 +1220,7 @@ mod tests {
frame[2] = 0x07; // frmsiz high
frame[3] = 0xFF; // frmsiz low → 0x7FF = 2047 → 4096 bytes
frame[5] = 16 << 3; // bsid 16 (E-AC-3)
finalize_ac3_crc(&mut frame); // pass the decodability gate
let f = parser.parse(&make_eac3_pes(frame));
assert_eq!(f.len(), 1, "4096-byte E-AC-3 frame within window accepted");
assert_eq!(f[0].data.len(), 4096);
@@ -1296,6 +1399,96 @@ mod tests {
assert_eq!(acmod_channels(&frame), Some(2));
}
// --- decodability (CRC) gate: keep clean frames, drop corrupt ones ---
/// A structurally-valid AC-3 frame with one payload byte corrupted so its
/// native CRC fails (header/size intact, so the framer delimits it normally).
fn make_corrupt_ac3_frame(fscod: u8, frmsizecod: u8) -> Vec<u8> {
let mut f = make_ac3_frame(fscod, frmsizecod);
f[20] ^= 0xFF; // flip a payload byte → CRC no longer zero
assert!(!frame_crc_ok(&f), "corruption must break the CRC");
f
}
#[test]
fn crc16_residue_zero_after_finalize_nonzero_after_corruption() {
// The CRC-16/ANSI residue property the gate relies on: a finalized frame
// has residue 0 over [2..]; flipping any covered byte makes it nonzero.
let good = make_ac3_frame(0, 2);
assert!(frame_crc_ok(&good));
let bad = make_corrupt_ac3_frame(0, 2);
assert!(!frame_crc_ok(&bad));
}
#[test]
fn crc_fail_frame_is_dropped_survivors_kept() {
// good / corrupt / good in one PES: the corrupt middle frame is dropped
// (CRC), the two clean frames are emitted, and the drop is counted.
let mut parser = Ac3Parser::new();
let mut data = make_ac3_frame(0, 2);
data.extend_from_slice(&make_corrupt_ac3_frame(0, 2));
data.extend_from_slice(&make_ac3_frame(0, 2));
let f = parser.parse(&make_eac3_pes(data));
// Only two of three survive; flush has nothing (all closed in-call).
assert_eq!(f.len(), 2, "corrupt frame dropped, two clean survive");
assert_eq!(parser.dropped_frames(), 1);
assert_eq!(
parser.dropped_duration_ns(),
32_000_000,
"one 32ms frame of silence"
);
}
#[test]
fn crc_drop_preserves_pts_sync_no_shift() {
// THE INVARIANT: dropping a corrupt frame must not shift the audio after
// it. good / corrupt / good in one PES — the corrupt frame is dropped but
// the trailing clean frame keeps the EXACT PTS it would have had with no
// drop (base + 2 frame durations): a silence gap, not a shift.
let mut parser = Ac3Parser::new();
let mut data = make_ac3_frame(0, 2); // f0
data.extend_from_slice(&make_corrupt_ac3_frame(0, 2)); // dropped
data.extend_from_slice(&make_ac3_frame(0, 2)); // f2
let f = parser.parse(&make_eac3_pes(data));
assert_eq!(f.len(), 2);
let base = pts_to_ns(90000);
let frame_dur = 32_000_000i64; // 1536 @ 48k
assert_eq!(f[0].pts_ns, base, "f0 at PES base");
assert_eq!(
f[1].pts_ns,
base + 2 * frame_dur,
"surviving frame keeps its true timeline (base + 2 frames) — gap, not shift"
);
}
#[test]
fn bsid_over_16_is_dropped() {
// ffmpeg's parser rejects bsid > 16 (AC3_PARSE_ERROR_BSID). A frame with
// bsid = 17 that still sizes must be dropped, not emitted.
let mut frame = vec![0u8; 128];
frame[0] = 0x0B;
frame[1] = 0x77;
frame[3] = 63; // frmsiz = 63 → (63+1)*2 = 128 bytes (E-AC-3 sizing)
frame[5] = 17 << 3; // bsid = 17 (> 16)
assert_eq!(get_bsid(&frame), 17);
let tally = super::super::dropgate::DropTally::new("ac3");
assert_eq!(ac3_drop_reason(&tally, &frame, 17), Some("bsid"));
}
#[test]
fn clean_stream_drops_nothing() {
// A stream of valid frames passes untouched — zero false positives.
let mut parser = Ac3Parser::new();
let mut data = Vec::new();
for _ in 0..5 {
data.extend_from_slice(&make_ac3_frame(0, 2));
}
let mut f = parser.parse(&make_eac3_pes(data));
f.extend(parser.flush());
assert_eq!(f.len(), 5);
assert_eq!(parser.dropped_frames(), 0);
}
// helper: PES with a generic pts for E-AC-3 tests
fn make_eac3_pes(data: Vec<u8>) -> PesPacket {
PesPacket {
+220
View File
@@ -0,0 +1,220 @@
//! AAC ADTS decodability gate.
//!
//! ffmpeg's `ff_adts_header_parse` (adts_header.c) has exactly three hard
//! rejects: syncword != 0xFFF, a reserved `sampling_frequency_index`
//! (`ff_mpeg4audio_sample_rates[sr] == 0`, i.e. index ≥ 13), and
//! `aac_frame_length < 7`. It does NOT verify the optional ADTS CRC (it only
//! `skip_bits(16)` past it). So the gate mirrors those three rejects: a packet
//! that begins with the ADTS sync but is otherwise malformed is dropped; a
//! packet with no ADTS sync is raw AAC (e.g. from mp4, which carries no ADTS
//! header) or a continuation and passes through unchanged — never false-dropped.
//! Raw AAC has no per-frame integrity data, so like LPCM it cannot be gated.
use super::dropgate::DropTally;
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
/// `ff_mpeg4audio_sample_rates` — 13 valid entries; indices 13/14/15 are 0
/// (reserved), which is exactly what ffmpeg rejects.
const ADTS_SAMPLE_RATE_VALID: [u32; 16] = [
96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, 0, 0,
0,
];
/// ADTS header verdict for the packet head.
enum AdtsVerdict {
/// No 12-bit ADTS sync at the head — not an ADTS frame we can validate.
NoSync,
/// Sync present and the three ffmpeg-checked fields are legal.
Valid,
/// Sync present but a reserved sample-rate index or a sub-header
/// frame-length — ffmpeg's parser rejects this.
Invalid,
}
fn adts_verdict(data: &[u8]) -> AdtsVerdict {
// Need the full 7-byte fixed+variable header to read frame_length.
if data.len() < 7 {
return AdtsVerdict::NoSync;
}
// 12-bit syncword 0xFFF: byte0 == 0xFF and top nibble of byte1 == 0xF.
if data[0] != 0xFF || (data[1] & 0xF0) != 0xF0 {
return AdtsVerdict::NoSync;
}
// sampling_frequency_index: byte2 bits 5..2.
let sr_index = ((data[2] >> 2) & 0x0F) as usize;
if ADTS_SAMPLE_RATE_VALID[sr_index] == 0 {
return AdtsVerdict::Invalid;
}
// aac_frame_length: 13 bits = byte3[1:0] | byte4 | byte5[7:5].
let frame_length =
((u32::from(data[3]) & 0x03) << 11) | (u32::from(data[4]) << 3) | (u32::from(data[5]) >> 5);
if frame_length < 7 {
return AdtsVerdict::Invalid;
}
AdtsVerdict::Valid
}
pub struct AdtsParser {
tally: DropTally,
}
impl Default for AdtsParser {
fn default() -> Self {
Self::new()
}
}
impl AdtsParser {
pub fn new() -> Self {
Self {
tally: DropTally::new("aac"),
}
}
pub fn dropped_frames(&self) -> u64 {
self.tally.dropped_frames()
}
pub fn dropped_duration_ns(&self) -> u64 {
self.tally.dropped_duration_ns()
}
}
impl CodecParser for AdtsParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
if pes.data.is_empty() {
return Vec::new();
}
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
let drop =
self.tally.is_poisoned() || matches!(adts_verdict(&pes.data), AdtsVerdict::Invalid);
if drop {
let reason = if self.tally.is_poisoned() {
"track-poisoned"
} else {
"header"
};
self.tally.record_drop(pts_ns, 0, pes.data.len(), reason);
return Vec::new();
}
self.tally.record_kept();
vec![Frame {
discontinuity: pes.discontinuity,
coding: None,
source: None,
pts_ns,
keyframe: true,
data: pes.data.clone(),
duration_ns: None,
}]
}
fn flush(&mut self) -> Vec<Frame> {
self.tally.log_summary();
Vec::new()
}
fn codec_private(&self) -> Option<Vec<u8>> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket {
source: None,
pid: 0x1100,
pts,
dts: None,
data,
discontinuity: false,
}
}
/// A valid ADTS header (AAC-LC, 44.1 kHz, stereo) + payload, with
/// aac_frame_length set to the total size.
fn adts_frame(payload: usize) -> Vec<u8> {
let total = 7 + payload;
let mut f = vec![0u8; total];
f[0] = 0xFF;
f[1] = 0xF1; // sync + MPEG-4 + no CRC (protection_absent=1)
f[2] = 0x50; // profile=AAC-LC, sr_index=4 (44.1 kHz)
f[3] = 0x80; // channel_config low + start of frame_length
// frame_length (13 bits) = total.
let fl = total as u32;
f[3] = (f[3] & 0xFC) | ((fl >> 11) & 0x03) as u8;
f[4] = ((fl >> 3) & 0xFF) as u8;
f[5] = (((fl & 0x07) << 5) as u8) | 0x1F; // low 3 bits of len + buffer-fullness bits
f
}
#[test]
fn valid_adts_is_kept() {
let mut p = AdtsParser::new();
let f = p.parse(&make_pes(adts_frame(400), Some(90000)));
assert_eq!(f.len(), 1);
assert_eq!(f[0].pts_ns, pts_to_ns(90000));
assert_eq!(p.dropped_frames(), 0);
}
#[test]
fn reserved_sample_rate_index_is_dropped() {
// sr_index = 13 (reserved). byte2 bits5..2 = 1101 → 0x34.
let mut p = AdtsParser::new();
let mut f = adts_frame(400);
f[2] = (f[2] & 0xC3) | (13 << 2); // set sr_index = 13
assert!(p.parse(&make_pes(f, Some(0))).is_empty());
assert_eq!(p.dropped_frames(), 1);
}
#[test]
fn subheader_frame_length_is_dropped() {
// frame_length < 7 (here 0) is a sub-header length → reject.
let mut p = AdtsParser::new();
let mut f = adts_frame(400);
f[3] &= 0xFC; // clear len high bits
f[4] = 0;
f[5] &= 0x1F; // clear len low bits → frame_length = 0
assert!(p.parse(&make_pes(f, Some(0))).is_empty());
assert_eq!(p.dropped_frames(), 1);
}
#[test]
fn raw_aac_without_sync_passes_through() {
// No ADTS sync (e.g. raw AAC from mp4) → cannot validate → keep.
let mut p = AdtsParser::new();
let f = p.parse(&make_pes(
vec![0x21, 0x00, 0x03, 0x40, 0x00, 0x00, 0x00],
Some(0),
));
assert_eq!(f.len(), 1);
assert_eq!(p.dropped_frames(), 0);
}
#[test]
fn drop_preserves_sync_via_own_pts() {
let mut p = AdtsParser::new();
let mut bad = adts_frame(400);
bad[2] = (bad[2] & 0xC3) | (14 << 2); // reserved sr_index
assert!(p.parse(&make_pes(bad, Some(90000))).is_empty());
let f = p.parse(&make_pes(adts_frame(400), Some(96000)));
assert_eq!(f.len(), 1);
assert_eq!(
f[0].pts_ns,
pts_to_ns(96000),
"next frame keeps its own PTS"
);
}
#[test]
fn short_packet_passes_through() {
let mut p = AdtsParser::new();
let f = p.parse(&make_pes(vec![0xFF, 0xF1, 0x50], Some(0)));
assert_eq!(f.len(), 1, "too short to validate → kept");
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Bit-exact CRC helpers shared by the audio codec decodability gates.
//!
//! Both match ffmpeg's `av_crc` tables so a frame that ffmpeg's decoder would
//! flag as a CRC mismatch is flagged identically here. All are MSB-first
//! (non-reflected), init 0, no final XOR — the ffmpeg `AV_CRC_*` (big-endian)
//! variants. Each format transmits its CRC so that the residue over
//! `data + transmitted_crc` is zero, which is exactly how these are used:
//! compute over the whole frame (including its trailing CRC) and check `== 0`.
/// CRC-16/ANSI (a.k.a. CRC-16/BUYPASS): polynomial 0x8005, init 0x0000,
/// MSB-first, no reflection, no final XOR — ffmpeg `AV_CRC_16_ANSI`.
/// Used by AC-3/E-AC-3 (frame CRC), FLAC (frame footer), MPEG-audio and
/// AAC-ADTS (header CRC).
pub(crate) fn crc16_ansi(data: &[u8]) -> u16 {
let mut crc: u16 = 0;
for &b in data {
crc ^= (b as u16) << 8;
for _ in 0..8 {
crc = if crc & 0x8000 != 0 {
(crc << 1) ^ 0x8005
} else {
crc << 1
};
}
}
crc
}
/// CRC-16 with polynomial 0x002D, init 0, MSB-first — ffmpeg's `crc_2D` table
/// (`av_crc_init(crc_2D, 0, 16, 0x002D)`), used by the MLP/TrueHD major-sync
/// header checksum. NOTE: MLP's checksum is the "reversed" scheme — ffmpeg
/// computes `av_crc(...) ^ AV_RL16(trailer)` and compares against `AV_RL16` of
/// the stored word; equivalently, this standard CRC compared against the stored
/// bytes read big-endian. The caller handles that comparison
/// (see `truehd::mlp_major_sync_ok`). Verified against real ffmpeg TrueHD
/// output (225/225 major-sync AUs).
pub(crate) fn crc16_mlp(data: &[u8]) -> u16 {
let mut crc: u16 = 0;
for &b in data {
crc ^= (b as u16) << 8;
for _ in 0..8 {
crc = if crc & 0x8000 != 0 {
(crc << 1) ^ 0x002D
} else {
crc << 1
};
}
}
crc
}
/// CRC-8/ATM (a.k.a. CRC-8/ITU without the final XOR): polynomial 0x07, init 0,
/// MSB-first, no reflection — ffmpeg `AV_CRC_8_ATM`. Used by the FLAC frame
/// header.
pub(crate) fn crc8_atm(data: &[u8]) -> u8 {
let mut crc: u8 = 0;
for &b in data {
crc ^= b;
for _ in 0..8 {
crc = if crc & 0x80 != 0 {
(crc << 1) ^ 0x07
} else {
crc << 1
};
}
}
crc
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crc16_residue_property_holds() {
// Appending the big-endian CRC-16 of a message zeroes the residue over
// message+crc — the property every frame gate relies on.
let msg = [0x12u8, 0x34, 0x56, 0x78, 0x9A];
let c = crc16_ansi(&msg);
let mut framed = msg.to_vec();
framed.push((c >> 8) as u8);
framed.push((c & 0xFF) as u8);
assert_eq!(crc16_ansi(&framed), 0);
}
#[test]
fn crc16_known_vector_check_bytes() {
// CRC-16/BUYPASS check value for the ASCII string "123456789" is 0xFEE8
// (the standard catalogue check value for poly 0x8005, init 0).
assert_eq!(crc16_ansi(b"123456789"), 0xFEE8);
}
#[test]
fn crc8_residue_property_holds() {
// Appending the CRC-8 of a message zeroes the residue over message+crc —
// how FLAC's header CRC-8 is verified.
let msg = [0xDEu8, 0xAD, 0xBE, 0xEF];
let c = crc8_atm(&msg);
let mut framed = msg.to_vec();
framed.push(c);
assert_eq!(crc8_atm(&framed), 0);
}
#[test]
fn crc8_known_vector_check_byte() {
// CRC-8/SMBUS (poly 0x07, init 0, no reflection) check value for
// "123456789" is 0xF4 — the catalogue check value.
assert_eq!(crc8_atm(b"123456789"), 0xF4);
}
}
+201
View File
@@ -0,0 +1,201 @@
//! Shared "keep what decodes, drop what doesn't" bookkeeping for the audio
//! codec parsers.
//!
//! The user's rule: a clean mux keeps every frame it can and drops the ones it
//! can't — video always survives (it's inter-frame predicted; a per-frame drop
//! would cascade, so video resyncs/conceals instead), audio keeps every
//! decodable access unit, and a damaged audio AU is dropped rather than shipped
//! as a decoder-choking glitch.
//!
//! The DETECTION is inherently per-codec — each format carries its own
//! authoritative corruption check (DTS: ffmpeg's core-header parse; AC-3: the
//! header CRC; FLAC: the frame CRC-16; …). This type only carries the UNIFORM
//! response so every audio parser behaves identically:
//!
//! 1. **Count** kept vs dropped AUs and the dropped duration.
//! 2. **Log** every drop (fail-loud, never silent) — a per-drop trace plus a
//! once-per-track aggregate at `warn` so it surfaces without debug logging.
//! 3. **Whole-track fallback**: once a track is judged mostly undecodable, latch
//! a poison flag so the remainder is dropped too (a track that damaged isn't
//! worth muxing).
//!
//! **Sync preservation is the caller's responsibility**, not this type's: the
//! parser must advance its PTS clock across a dropped AU exactly as it would for
//! an emitted one, so a drop becomes a silence gap and never a shift of the
//! following audio. See `DtsParser`'s `stamp_pts` call ordering for the pattern.
/// Minimum access units observed before the whole-track drop verdict can fire.
/// Below this, a short damaged burst can't poison an otherwise-good track.
const TRACK_VERDICT_MIN_AUS: u64 = 200;
/// Per-track drop bookkeeping shared by the audio codec parsers.
pub(crate) struct DropTally {
/// Static codec label for log lines (e.g. `"dts"`, `"ac3"`).
codec: &'static str,
kept: u64,
dropped: u64,
/// AUs dropped because they were INDIVIDUALLY verified undecodable (a failed
/// CRC/header/parity check). Only these feed the whole-track poison verdict.
/// Distinct from `dropped`, which also counts *collateral* drops — AUs
/// discarded as a consequence of one corruption (TrueHD's resync-forward run,
/// or a poisoned track), which must NOT amplify a few real errors into a
/// false whole-track loss.
verified_dropped: u64,
dropped_dur_ns: u64,
poisoned: bool,
}
impl DropTally {
pub(crate) fn new(codec: &'static str) -> Self {
Self {
codec,
kept: 0,
dropped: 0,
verified_dropped: 0,
dropped_dur_ns: 0,
poisoned: false,
}
}
/// Whether the track has been judged too damaged to mux. Once `true`, the
/// caller should drop every remaining AU (passing them to [`record_drop`]
/// with a poison reason) rather than emit them.
pub(crate) fn is_poisoned(&self) -> bool {
self.poisoned
}
/// Access units dropped as undecodable so far — surfaced to the CLI/mux.
pub(crate) fn dropped_frames(&self) -> u64 {
self.dropped
}
/// Total decoded duration (ns) of dropped AUs — the audio silence introduced.
pub(crate) fn dropped_duration_ns(&self) -> u64 {
self.dropped_dur_ns
}
/// Record an emitted (decodable) access unit.
pub(crate) fn record_kept(&mut self) {
self.kept += 1;
}
/// Record a dropped access unit that was INDIVIDUALLY verified undecodable
/// (a failed CRC/header/parity check). Counts toward the whole-track poison
/// verdict. `reason` is a short static label for the check that failed.
pub(crate) fn record_drop(&mut self, pts_ns: i64, dur_ns: i64, bytes: usize, reason: &str) {
self.verified_dropped += 1;
self.record_drop_common(pts_ns, dur_ns, bytes, reason);
self.maybe_poison();
}
/// Record a COLLATERAL drop — an AU discarded as a consequence of another
/// corruption rather than being individually undecodable (TrueHD's
/// resync-forward run to the next major sync, or an already-poisoned track).
/// Counted and logged for the drop report, but deliberately does NOT feed the
/// poison verdict, so one corruption event can't amplify into a false
/// whole-track loss.
pub(crate) fn record_collateral_drop(
&mut self,
pts_ns: i64,
dur_ns: i64,
bytes: usize,
reason: &str,
) {
self.record_drop_common(pts_ns, dur_ns, bytes, reason);
}
fn record_drop_common(&mut self, pts_ns: i64, dur_ns: i64, bytes: usize, reason: &str) {
self.dropped += 1;
self.dropped_dur_ns += dur_ns.max(0) as u64;
tracing::debug!(
target: "mux",
"{}: dropped undecodable AU #{} pts_ns={} dur_ns={} bytes={} reason={}",
self.codec,
self.dropped,
pts_ns,
dur_ns,
bytes,
reason
);
}
/// Whole-track fallback: after enough AUs to judge, if more than half were
/// dropped the track is too damaged to be worth muxing — latch `poisoned`
/// and log it loudly once. The minimum-sample gate keeps a short damaged
/// burst from poisoning an otherwise-good track.
fn maybe_poison(&mut self) {
if self.poisoned {
return;
}
// Judge on VERIFIED drops vs all AUs seen: a track is only poisoned when
// a majority of its access units are individually undecodable — not when
// a couple of corruption events forced long collateral resync runs.
let total = self.kept + self.dropped;
if total >= TRACK_VERDICT_MIN_AUS && self.verified_dropped * 2 > total {
self.poisoned = true;
tracing::warn!(
target: "mux",
"{}: track too damaged to mux — {}/{} AUs individually undecodable (>50%); dropping the whole track",
self.codec,
self.verified_dropped,
total
);
}
}
/// End-of-stream aggregate report, logged at `warn` so a track's dropped
/// audio is never hidden even without debug logging. No-op if nothing was
/// dropped.
pub(crate) fn log_summary(&self) {
if self.dropped > 0 {
tracing::warn!(
target: "mux",
"{}: dropped {} undecodable AU(s) totaling {} ns of audio ({} kept)",
self.codec,
self.dropped,
self.dropped_dur_ns,
self.kept
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_kept_and_dropped() {
let mut t = DropTally::new("test");
t.record_kept();
t.record_drop(0, 1000, 512, "bad");
t.record_kept();
assert_eq!(t.dropped_frames(), 1);
assert_eq!(t.dropped_duration_ns(), 1000);
assert!(!t.is_poisoned());
}
#[test]
fn poisons_after_min_aus_over_half_dropped() {
let mut t = DropTally::new("test");
// 199 AUs, all dropped: below the min-AU gate, must NOT poison yet.
for _ in 0..199 {
t.record_drop(0, 1000, 512, "bad");
}
assert!(!t.is_poisoned(), "below the 200-AU minimum, no verdict");
// The 200th drop reaches the minimum with >50% dropped → poison.
t.record_drop(0, 1000, 512, "bad");
assert!(t.is_poisoned());
}
#[test]
fn does_not_poison_a_mostly_good_track() {
let mut t = DropTally::new("test");
// 400 AUs, 1 dropped: nowhere near 50%.
t.record_drop(0, 1000, 512, "bad");
for _ in 0..399 {
t.record_kept();
}
assert!(!t.is_poisoned());
}
}
+535 -41
View File
@@ -49,6 +49,12 @@ pub struct DtsParser {
/// running cursor: previous emit + its duration). Only consulted when
/// `front_pts` is unchanged from `last_front_pts`. `PTS_UNSET` = no base yet.
next_pts_ns: i64,
/// Keep/drop bookkeeping for the decodability gate: counts, per-drop and
/// aggregate logging, and the whole-track poison fallback. A dropped AU is
/// NEVER emitted, but the PTS clock is still advanced across it (see
/// [`stamp_pts`] usage) so every SURVIVING AU keeps the exact timestamp it
/// would have had — a drop becomes a silence gap, never a shift.
tally: super::dropgate::DropTally,
}
impl Default for DtsParser {
@@ -65,6 +71,50 @@ impl DtsParser {
pts_marks: std::collections::VecDeque::new(),
last_front_pts: PTS_UNSET,
next_pts_ns: PTS_UNSET,
tally: super::dropgate::DropTally::new("dts"),
}
}
/// Number of access units dropped as undecodable so far. The mux/CLI reads
/// this to surface the count ("dropped N damaged DTS frames").
pub fn dropped_frames(&self) -> u64 {
self.tally.dropped_frames()
}
/// Total decoded duration (ns) of all dropped access units — the length of
/// audio silence introduced by dropping undecodable frames.
pub fn dropped_duration_ns(&self) -> u64 {
self.tally.dropped_duration_ns()
}
/// Gate an assembled access unit through the decodability check and either
/// push it or drop it. `au_pts`/`dur_ns` are already stamped on the shared
/// PTS clock (which the caller advances whether or not the AU survives), so
/// a drop leaves the following audio on its true timeline — a gap, not a
/// shift. Every drop is logged (fail-loud, never silent).
fn emit_or_drop(&mut self, au: Vec<u8>, au_pts: i64, dur_ns: i64, out: &mut Vec<Frame>) {
let verdict = if self.tally.is_poisoned() {
Err(DropReason::TrackPoisoned)
} else {
core_header_drop_reason(&au).map_or(Ok(()), Err)
};
match verdict {
Ok(()) => {
self.tally.record_kept();
out.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: au_pts,
keyframe: true,
data: au,
duration_ns: Some(dur_ns as u64),
});
}
Err(reason) => {
self.tally
.record_drop(au_pts, dur_ns, au.len(), reason.as_str());
}
}
}
@@ -299,8 +349,8 @@ impl CodecParser for DtsParser {
// flush is an extension-substream PES, carrying its own later
// timestamp) must NOT become the next unit's PTS base.
let mut forced = false;
let au_end = match next_core_boundary(&self.buf, core_size) {
NextCore::Found(end) => end,
let (au_end, ext_clean) = match next_core_boundary(&self.buf, core_size) {
NextCore::Found { end, ext_clean } => (end, ext_clean),
NextCore::NeedMore => break, // candidate sync needs more header
NextCore::None => {
// No next core sync buffered yet. The trailing extension
@@ -312,27 +362,32 @@ impl CodecParser for DtsParser {
break;
}
forced = true;
self.buf.len()
(self.buf.len(), true)
}
};
let au: Vec<u8> = self.buf[..au_end].to_vec();
// Damaged source encoding: when the extension boundary was GARBAGE
// (not any DTS sync — `ext_clean == false`), the extension bytes for
// this AU are corrupt and would make the decoder cascade "DSYNC check
// failed" / "Read past end of XLL band data". Emit the clean DTS core
// ALONE (a decodable, lossy frame) and still drain past the garbage to
// the next core — a perfect mux drops the bad frame's corrupt part
// rather than shipping it. A recognized-but-unsizeable extension
// (`ext_clean == true`) is preserved in full (lossless).
let emit_end = if ext_clean { au_end } else { core_size };
let au: Vec<u8> = self.buf[..emit_end].to_vec();
// The AU's own core PES PTS (the PES covering its first byte, even if
// that PES preceded the one(s) carrying its extensions or the next
// core), stamped monotonically: honored when it advances past the
// running clock (UHD one-AU-per-PES), but never allowed to collide
// with the previous AU when several cores share ONE PES (DVD).
let dur_ns = dts_core_duration_ns(&au) as i64;
// Advance the PTS clock for this AU BEFORE the decodability gate, so
// a dropped AU still advances the timeline exactly as an emitted one
// would: the following AU keeps its true PTS and the drop is a gap,
// never a shift. `emit_or_drop` decides whether to actually push it.
let au_pts = self.stamp_pts(self.front_pts(), dur_ns);
frames.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: au_pts,
keyframe: true,
data: au,
duration_ns: Some(dur_ns as u64),
});
self.emit_or_drop(au, au_pts, dur_ns, &mut frames);
self.drain_front(au_end);
// After draining, the marker covering the new front (if any) carries
// the next AU's PTS; `pending_pts` is only the fallback when no
@@ -359,10 +414,23 @@ impl CodecParser for DtsParser {
}
fn flush(&mut self) -> Vec<Frame> {
// End of stream: emit the final access unit still buffered (the last
// core + its extension substreams, which had no following core sync to
// close it during streaming). Require a complete core frame; drop a
// bare partial sync tail.
let out = self.flush_tail();
// Aggregate drop report at end-of-stream (warn-level, always visible).
self.tally.log_summary();
out
}
fn codec_private(&self) -> Option<Vec<u8>> {
None
}
}
impl DtsParser {
/// Emit the final buffered access unit (the last core + its extension
/// substreams, which had no following core sync to close it during
/// streaming), gated through the decodability check. Require a complete core
/// frame; drop a bare partial sync tail.
fn flush_tail(&mut self) -> Vec<Frame> {
if find_sync(&self.buf, &DTS_CORE_SYNC) != Some(0) || self.buf.len() < CORE_HEADER_MIN_BYTES
{
self.buf.clear();
@@ -381,19 +449,9 @@ impl CodecParser for DtsParser {
let dur_ns = dts_core_duration_ns(&au) as i64;
let pts_ns = self.stamp_pts(self.front_pts(), dur_ns);
self.pts_marks.clear();
vec![Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns,
keyframe: true,
data: au,
duration_ns: Some(dur_ns as u64),
}]
}
fn codec_private(&self) -> Option<Vec<u8>> {
None
let mut out = Vec::new();
self.emit_or_drop(au, pts_ns, dur_ns, &mut out);
out
}
}
@@ -407,7 +465,14 @@ fn find_sync(data: &[u8], pattern: &[u8; 4]) -> Option<usize> {
/// Result of scanning for the next valid core sync that closes an access unit.
enum NextCore {
/// A valid next core sync was found; the access unit ends at this offset.
Found(usize),
/// `ext_clean` is `false` only when the byte at the extension boundary was
/// GARBAGE — neither a core sync nor a DTS-HD extension sync — meaning the
/// extension region is corrupt (damaged source encoding). The caller then
/// emits the clean DTS core alone and drops the garbage, instead of shipping
/// a corrupt AU that makes the decoder cascade DSYNC / "Read past end of XLL".
/// It stays `true` when the region is a real (if unsizeable) extension sync —
/// that path is load-bearing for valid streams and must NOT be dropped.
Found { end: usize, ext_clean: bool },
/// A candidate core sync was found but its header isn't fully buffered yet,
/// so its validity can't be decided — wait for more data.
NeedMore,
@@ -492,8 +557,11 @@ fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore {
}
pos += sz; // skip the whole extension substream precisely
}
// Couldn't size it (truncated/garbage header) — heuristic fallback.
_ => return scan_for_next_core(buf, pos),
// A real extension sync we couldn't size (truncated header /
// unsupported sub-form) — heuristic fallback, but the region IS a
// recognized extension, so keep it (ext_clean = true). This path
// is load-bearing for valid streams.
_ => return scan_for_next_core(buf, pos, true),
}
} else if buf[pos..].starts_with(&DTS_CORE_SYNC) {
// The bytes right after the precisely-skipped extensions are the next
@@ -503,13 +571,18 @@ fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore {
}
let sz = dts_core_frame_size(&buf[pos..]);
if (MIN_CORE_FRAME_BYTES..=MAX_AU_BYTES).contains(&sz) {
return NextCore::Found(pos);
return NextCore::Found {
end: pos,
ext_clean: true,
};
}
return scan_for_next_core(buf, pos); // implausible core here — fall back
return scan_for_next_core(buf, pos, true); // implausible core — recognized sync, keep
} else {
// Neither a known extension nor a core sync at the precise boundary
// (padding / junk) — fall back to the heuristic scan.
return scan_for_next_core(buf, pos);
// GARBAGE at the extension boundary — neither a core sync nor a
// DTS-HD extension sync. This is damaged source encoding: the
// extension region is corrupt. Mark ext_clean = false so the caller
// emits the clean core alone and drops the garbage.
return scan_for_next_core(buf, pos, false);
}
}
}
@@ -518,7 +591,7 @@ fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore {
/// syncword whose decoded size is plausible. Used only when precise extension
/// skipping can't proceed; a chance core syncword in extension payload usually
/// decodes to an implausible size and is skipped.
fn scan_for_next_core(buf: &[u8], from: usize) -> NextCore {
fn scan_for_next_core(buf: &[u8], from: usize, ext_clean: bool) -> NextCore {
let mut from = from;
while let Some(rel) = find_sync(&buf[from..], &DTS_CORE_SYNC) {
let pos = from + rel;
@@ -527,7 +600,10 @@ fn scan_for_next_core(buf: &[u8], from: usize) -> NextCore {
}
let sz = dts_core_frame_size(&buf[pos..]);
if (MIN_CORE_FRAME_BYTES..=MAX_AU_BYTES).contains(&sz) {
return NextCore::Found(pos);
return NextCore::Found {
end: pos,
ext_clean,
};
}
from = pos + SYNCWORD_BYTES;
}
@@ -597,6 +673,128 @@ fn dts_core_duration_ns(data: &[u8]) -> u64 {
(samples * 1_000_000_000 + rate / 2) / rate
}
/// DCA core-header constants, mirrored from ffmpeg `libavcodec/dca_core.h`.
/// `deficit_samples` must equal this (`DCA_PCMBLOCK_SAMPLES`); `npcmblocks`
/// must be a multiple of `DCA_SUBBAND_SAMPLES`; `audio_mode` must be below
/// `DCA_AMODE_COUNT`; `lfe_present == DCA_LFE_FLAG_INVALID` is rejected.
const DTS_PCMBLOCK_SAMPLES: u32 = 32;
const DTS_SUBBAND_SAMPLES: u32 = 8;
const DTS_AMODE_COUNT: u32 = 10;
const DTS_LFE_FLAG_INVALID: u32 = 3;
/// `ff_dca_sample_rates[16]` — sample rate (Hz) per core `SFREQ` code; a `0`
/// entry marks a reserved code that ffmpeg's parser rejects
/// (`DCA_PARSE_ERROR_SAMPLE_RATE`). Valid entries are locked to the spec by
/// `dts_core_sfreq_table_matches_the_dca_spec`; the reserved codes are
/// {0, 4, 5, 9, 10}.
const DTS_CORE_SR_VALID: [u32; 16] = [
0, 8_000, 16_000, 32_000, 0, 0, 11_025, 22_050, 44_100, 0, 0, 12_000, 24_000, 48_000, 96_000,
192_000,
];
/// `ff_dca_bits_per_sample[8]` — a `0` entry marks a reserved `PCMR` code that
/// ffmpeg's parser rejects (`DCA_PARSE_ERROR_PCM_RES`); reserved codes are
/// {4, 7}.
const DTS_CORE_PCMR_BITS: [u8; 8] = [16, 16, 20, 20, 0, 24, 24, 0];
/// Why an access unit was judged undecodable. Each core-header variant is the
/// exact condition under which ffmpeg's `ff_dca_parse_core_frame_header` returns
/// the matching `DCA_PARSE_ERROR_*`; `TrackPoisoned` is our whole-track drop.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DropReason {
DeficitSamples,
PcmBlocks,
FrameSize,
Amode,
SampleRate,
ReservedBit,
LfeFlag,
PcmRes,
TrackPoisoned,
}
impl DropReason {
/// Short static label for the drop log (the shared tally logs `&str`).
fn as_str(&self) -> &'static str {
match self {
DropReason::DeficitSamples => "deficit-samples",
DropReason::PcmBlocks => "pcm-blocks",
DropReason::FrameSize => "frame-size",
DropReason::Amode => "audio-mode",
DropReason::SampleRate => "sample-rate",
DropReason::ReservedBit => "reserved-bit",
DropReason::LfeFlag => "lfe-flag",
DropReason::PcmRes => "pcm-resolution",
DropReason::TrackPoisoned => "track-poisoned",
}
}
}
/// Decodability gate: a faithful port of ffmpeg's `ff_dca_parse_core_frame_header`
/// validity checks (libavcodec/dca.c). Returns `Some(reason)` when ffmpeg's own
/// parser would reject this core frame's header — in which case the packet is
/// undecodable ("Invalid data found") and dropping it loses nothing a decoder
/// could have used. Returns `None` (keep) for a decodable header OR if the
/// header can't be fully read (never false-drop on our own buffer underrun; the
/// framer only emits AUs whose core is fully buffered and ≥ 96 bytes).
///
/// The 4-byte core sync is already validated by the framer, so this reads the
/// header fields that follow it. ffmpeg's parser does NOT verify the CPF header
/// CRC (it `skip_bits(16)` past it — dca.c) and the core decoder likewise skips
/// the audio-header/side-info CRCs (dca_core.c), so no CRC check is mirrored
/// here: doing so would drop frames ffmpeg decodes fine (false positives).
fn core_header_drop_reason(au: &[u8]) -> Option<DropReason> {
let mut r = BitReader::new(au.get(SYNCWORD_BYTES..)?);
let _ftype = r.read_bit()?;
let deficit_samples = r.read_bits(5)? + 1;
if deficit_samples != DTS_PCMBLOCK_SAMPLES {
return Some(DropReason::DeficitSamples);
}
let crc_present = r.read_bit()? == 1;
let npcmblocks = r.read_bits(7)? + 1;
if npcmblocks & (DTS_SUBBAND_SAMPLES - 1) != 0 {
return Some(DropReason::PcmBlocks);
}
let frame_size = r.read_bits(14)? + 1;
if frame_size < MIN_CORE_FRAME_BYTES as u32 {
return Some(DropReason::FrameSize);
}
let audio_mode = r.read_bits(6)?;
if audio_mode >= DTS_AMODE_COUNT {
return Some(DropReason::Amode);
}
let sr_code = r.read_bits(4)? as usize;
if DTS_CORE_SR_VALID[sr_code] == 0 {
return Some(DropReason::SampleRate);
}
let _br_code = r.read_bits(5)?;
if r.read_bit()? != 0 {
return Some(DropReason::ReservedBit);
}
// drc, ts, aux, hdcd (1 each) → ext_audio_type (3) → ext_present, aspf (1 each).
r.skip_bits(4)?;
r.skip_bits(3)?;
r.skip_bits(2)?;
let lfe_present = r.read_bits(2)?;
if lfe_present == DTS_LFE_FLAG_INVALID {
return Some(DropReason::LfeFlag);
}
let _predictor_history = r.read_bit()?;
if crc_present {
// ffmpeg only skips the 16-bit header CRC here — it is not verified.
r.skip_bits(16)?;
}
let _filter_perfect = r.read_bit()?;
let _encoder_rev = r.read_bits(4)?;
let _copy_hist = r.read_bits(2)?;
let pcmr_code = r.read_bits(3)? as usize;
if DTS_CORE_PCMR_BITS[pcmr_code] == 0 {
return Some(DropReason::PcmRes);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -617,6 +815,11 @@ mod tests {
let fsize = size - 1;
let mut data = vec![0u8; size];
data[0..4].copy_from_slice(&DTS_CORE_SYNC);
// byte4: FTYPE(0) SHORT(5) CPF(0) NBLKS-high(0). SHORT = 31 makes
// deficit_samples = 32 = DCA_PCMBLOCK_SAMPLES, which ffmpeg's parser
// (and our decodability gate) require of a real core frame. NBLKS high
// bit (byte4 bit0) stays 0 for NBLKS = 15.
data[4] = 31u8 << 2;
// NBLKS = 15 → (15+1)*32 = 512 samples/frame (the DVD/UHD DTS-core norm).
// NBLKS is byte4 bit0 + byte5 bits7-2; here byte4 bit0 = 0, byte5 = 15<<2.
data[5] = (15u8 << 2) | ((fsize >> 12) & 0x03) as u8;
@@ -676,12 +879,60 @@ mod tests {
assert!(
matches!(
next_core_boundary(&buf, core.len()),
NextCore::Found(end) if end == core.len() + exss.len()
NextCore::Found { end, .. } if end == core.len() + exss.len()
),
"AU must end at the REAL next core (after the full EXSS), not the false sync inside it"
);
}
#[test]
fn garbage_extension_emits_core_only_but_valid_ext_is_kept() {
// Damaged source: a valid core, then GARBAGE (no core sync, no extension
// sync) where the extension belongs, then the next core. The framer must
// mark this boundary ext_clean=false and the parser must emit the clean
// 512-byte CORE alone (dropping the garbage), draining to the next core.
let core = make_dts_core(512);
let garbage = vec![0xE4, 0x3F, 0xE3, 0x90, 0xCC, 0x6C]; // real Bourne head bytes
let mut garbage = garbage;
garbage.extend(std::iter::repeat(0xAB).take(300));
let next = make_dts_core(512);
let mut buf = core.clone();
buf.extend_from_slice(&garbage);
buf.extend_from_slice(&next);
assert!(
matches!(
next_core_boundary(&buf, core.len()),
NextCore::Found { end, ext_clean: false } if end == core.len() + garbage.len()
),
"garbage boundary must be flagged unclean"
);
let mut parser = DtsParser::new();
let mut frames = parser.parse(&make_pes(buf, Some(90000)));
frames.extend(parser.flush());
assert!(!frames.is_empty());
for f in &frames {
assert_eq!(f.data.len(), 512, "garbage-extension AU emits core only");
assert_eq!(&f.data[0..4], &DTS_CORE_SYNC);
}
// Contrast: a REAL extension sync (even if unsizeable) must be KEPT in
// full — ext_clean stays true, never downgraded to core-only.
let mut buf2 = make_dts_core(512);
buf2.extend_from_slice(&make_dts_ext(256));
buf2.extend_from_slice(&make_dts_core(512));
assert!(
matches!(
next_core_boundary(&buf2, 512),
NextCore::Found {
ext_clean: true,
..
}
),
"a recognized extension sync is preserved, not dropped"
);
}
#[test]
fn parse_empty_pes() {
let mut parser = DtsParser::new();
@@ -1478,4 +1729,247 @@ mod tests {
"sub-floor sync skipped, real 512 core is AU1"
);
}
/// A structurally-framed but UNDECODABLE core: a valid `make_dts_core`
/// whose reserved header bit is set. It still sizes and syncs correctly (so
/// the framer delimits it normally), but ffmpeg's `ff_dca_parse_core_frame_header`
/// — and our port — reject it (`DCA_PARSE_ERROR_RESERVED_BIT`). The reserved
/// bit is byte9 bit4 in the core header (after SYNC..RATE).
fn make_bad_dts_core(size: usize) -> Vec<u8> {
let mut d = make_dts_core(size);
assert!(
core_header_drop_reason(&d).is_none(),
"base core is decodable"
);
d[9] |= 0x10; // set the reserved bit
assert_eq!(
core_header_drop_reason(&d),
Some(DropReason::ReservedBit),
"reserved-bit core must be judged undecodable"
);
d
}
#[test]
fn valid_stream_drops_nothing() {
// A clean stream of decodable cores must pass the gate untouched — the
// detector is an exact ffmpeg-parity port, so zero false positives.
let mut parser = DtsParser::new();
let mut stream = Vec::new();
for _ in 0..5 {
stream.extend_from_slice(&make_dts_core(512));
}
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
frames.extend(parser.flush());
assert_eq!(frames.len(), 5, "all five cores emitted");
assert_eq!(parser.dropped_frames(), 0, "nothing dropped");
assert_eq!(parser.dropped_duration_ns(), 0);
}
#[test]
fn undecodable_core_is_dropped_and_counted() {
// A single undecodable core between good ones is dropped; the survivors
// are emitted and the drop is counted.
let mut parser = DtsParser::new();
let mut stream = make_dts_core(512);
stream.extend_from_slice(&make_bad_dts_core(512));
stream.extend_from_slice(&make_dts_core(640));
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
frames.extend(parser.flush());
assert_eq!(frames.len(), 2, "the bad core is dropped, two survive");
assert_eq!(frames[0].data.len(), 512);
assert_eq!(frames[1].data.len(), 640);
assert_eq!(parser.dropped_frames(), 1);
assert_eq!(
parser.dropped_duration_ns(),
DTS_CORE_DUR_NS as u64,
"one frame's worth of audio silence introduced"
);
}
#[test]
fn drop_preserves_av_sync_no_shift() {
// THE INVARIANT: dropping an undecodable AU must never shift the audio
// that follows. A good/bad/good run in ONE PES — the bad middle core is
// dropped, but the trailing good core must keep the EXACT PTS it would
// have had with no drop (base + 2 frame durations), so the drop is a
// silence gap, not a shift.
let mut parser = DtsParser::new();
let mut stream = make_dts_core(512); // c1: good
stream.extend_from_slice(&make_bad_dts_core(512)); // c2: undecodable
stream.extend_from_slice(&make_dts_core(640)); // c3: good
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
frames.extend(parser.flush());
assert_eq!(frames.len(), 2, "c2 dropped; c1 and c3 survive");
let base = pts_to_ns(90000);
assert_eq!(frames[0].pts_ns, base, "c1 keeps the PES base PTS");
assert_eq!(
frames[1].pts_ns,
base + 2 * DTS_CORE_DUR_NS,
"c3 keeps its TRUE timeline (base + 2 frames) — the drop is a gap, not a shift"
);
// The gap between the survivors is exactly the dropped frame's duration
// beyond the normal one-frame spacing.
assert_eq!(
frames[1].pts_ns - frames[0].pts_ns,
2 * DTS_CORE_DUR_NS,
"surviving AUs are spaced by the real timeline including the dropped frame's slot"
);
assert_eq!(parser.dropped_frames(), 1);
}
#[test]
fn whole_track_poison_drops_remainder() {
// A track dominated by undecodable frames is judged too damaged to mux:
// once the >50% verdict fires (after the minimum sample count), the
// whole track — including any later good frames — is dropped.
let mut parser = DtsParser::new();
let mut stream = Vec::new();
// 300 AUs, ~2/3 undecodable → well over the 50% threshold and the
// 200-AU minimum.
for i in 0..300 {
if i % 3 == 0 {
stream.extend_from_slice(&make_dts_core(512));
} else {
stream.extend_from_slice(&make_bad_dts_core(512));
}
}
// A trailing burst of GOOD cores that must be dropped once poisoned.
for _ in 0..20 {
stream.extend_from_slice(&make_dts_core(512));
}
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
frames.extend(parser.flush());
assert!(
parser.tally.is_poisoned(),
"track poisoned by >50% drop rate"
);
// Once poisoned, later good cores are dropped too, so the kept count
// (kept = emitted survivors) is far below the ~120 good cores present.
let kept = frames.len() as u64;
assert!(
kept < 120,
"post-poison good frames also dropped (kept={kept})"
);
assert!(parser.dropped_frames() > 150, "majority dropped");
}
#[test]
fn sr_validity_table_marks_reserved_codes() {
// The core-header sample-rate validity table must have ZERO (reject) at
// exactly the reserved SFREQ codes {0,4,5,9,10} and a real rate
// elsewhere — this is what mirrors ffmpeg's DCA_PARSE_ERROR_SAMPLE_RATE.
for code in 0..16usize {
let reserved = matches!(code, 0 | 4 | 5 | 9 | 10);
assert_eq!(
DTS_CORE_SR_VALID[code] == 0,
reserved,
"SFREQ code {code} reserved={reserved}"
);
}
}
#[test]
fn every_core_header_error_class_is_detected() {
// Exercise each ffmpeg-parity rejection so the port stays faithful.
// Start from a decodable core and corrupt one field at a time.
let good = make_dts_core(512);
assert_eq!(core_header_drop_reason(&good), None);
// deficit_samples != 32: clear SHORT (byte4 bits6-2) → deficit = 1.
let mut d = good.clone();
d[4] &= !0x7C;
assert_eq!(
core_header_drop_reason(&d),
Some(DropReason::DeficitSamples)
);
// npcmblocks not a multiple of 8: NBLKS low bits (byte5 bits7-2) → 14
// (npcmblocks=15, 15 & 7 = 7 ≠ 0).
let mut d = good.clone();
d[5] = (d[5] & 0x03) | (14u8 << 2);
assert_eq!(core_header_drop_reason(&d), Some(DropReason::PcmBlocks));
// audio_mode >= 10: AMODE = byte7 bits3-0 (high 4) + byte8 bits7-6. Set
// AMODE high nibble to 0xF → audio_mode >= 60.
let mut d = good.clone();
d[7] |= 0x0F;
assert_eq!(core_header_drop_reason(&d), Some(DropReason::Amode));
// sample_rate reserved: SFREQ (byte8 bits5-2) = 0.
let mut d = good.clone();
d[8] &= !0x3C;
assert_eq!(core_header_drop_reason(&d), Some(DropReason::SampleRate));
// reserved bit set: byte9 bit4.
let mut d = good.clone();
d[9] |= 0x10;
assert_eq!(core_header_drop_reason(&d), Some(DropReason::ReservedBit));
// lfe_present == 3: LFE is byte10 bits2-1.
let mut d = good.clone();
d[10] |= 0x06;
assert_eq!(core_header_drop_reason(&d), Some(DropReason::LfeFlag));
// pcmr_code reserved (7): pcmr is byte11 bit0 + byte12 bits7-6 → set all
// three to 1 (code 7 → ff_dca_bits_per_sample[7] = 0).
let mut d = good.clone();
d[11] |= 0x01;
d[12] |= 0xC0;
assert_eq!(core_header_drop_reason(&d), Some(DropReason::PcmRes));
}
/// Real-data fixture (ignored). Re-parses a raw `.dts` elementary stream
/// through `DtsParser` and writes the emitted access units back out, so the
/// garbage-extension → core-only drop can be validated against an actual
/// damaged stream (e.g. the extracted Bourne DTS-HD MA track) end-to-end
/// with ffmpeg. Env: `DTS_IN` (input), `DTS_OUT` (output).
/// cargo test --lib dts::tests::reparse_real_dts_file -- --ignored --nocapture
#[test]
#[ignore]
fn reparse_real_dts_file() {
use std::io::Write;
let inp = std::env::var("DTS_IN").expect("DTS_IN");
let outp = std::env::var("DTS_OUT").expect("DTS_OUT");
let bytes = std::fs::read(&inp).expect("read DTS_IN");
let mut parser = DtsParser::new();
let mut out =
std::io::BufWriter::new(std::fs::File::create(&outp).expect("create DTS_OUT"));
let mut au_count = 0usize;
let mut out_bytes = 0usize;
// 90 kHz PTS advancing per chunk; arbitrary chunking is faithful because
// the framer resyncs on core sync and buffers across PES boundaries.
let mut pts: i64 = 90_000;
const CHUNK: usize = 64 * 1024;
for chunk in bytes.chunks(CHUNK) {
let pes = PesPacket {
source: None,
pid: 0x1100,
pts: Some(pts),
dts: None,
data: chunk.to_vec(),
discontinuity: false,
};
pts += 2_100; // ~one AU worth; value irrelevant to AU framing/bytes
for f in parser.parse(&pes) {
au_count += 1;
out_bytes += f.data.len();
out.write_all(&f.data).expect("write AU");
}
}
for f in parser.flush() {
au_count += 1;
out_bytes += f.data.len();
out.write_all(&f.data).expect("write AU");
}
out.flush().expect("flush");
eprintln!(
"REPARSE in={} bytes -> out={} bytes across {} AUs ({} bytes dropped)",
bytes.len(),
out_bytes,
au_count,
bytes.len().saturating_sub(out_bytes)
);
}
}
+218
View File
@@ -0,0 +1,218 @@
//! FLAC elementary-stream decodability gate.
//!
//! FLAC frames carry no length field, so a raw stream is delimited only by
//! sync-scanning + CRC validation. In freemkv, though, FLAC never arrives raw:
//! it comes from mp4/mkv, where each packet is exactly one container-delimited
//! FLAC frame (the `PARSER_FLAG_COMPLETE_FRAMES` case in ffmpeg). So this parser
//! is a per-packet gate, not a framer: every FLAC frame ends with a 16-bit CRC
//! (poly 0x8005) computed so the residue over the whole frame is zero
//! (ffmpeg `flac_decode_frame`, `av_crc(AV_CRC_16_ANSI, 0, buf, len) == 0`). A
//! nonzero residue is definitive corruption → drop the frame (a silence gap,
//! never a shift — each packet keeps its own PTS), logged via the shared tally.
//!
//! A packet that does not begin with the FLAC frame sync is not a delimited
//! frame we can validate, so it is passed through unchanged (never false-dropped).
use super::crc::crc16_ansi;
use super::dropgate::DropTally;
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
/// FLAC frame sync: 14-bit code `0x3FFE` + a mandatory-0 reserved bit; the next
/// bit (blocking strategy) is masked off. ffmpeg tests `(AV_RB16 & 0xFFFE) ==
/// 0xFFF8` (flac_parser.c).
fn has_flac_sync(data: &[u8]) -> bool {
data.len() >= 2 && ((u16::from(data[0]) << 8 | u16::from(data[1])) & 0xFFFE) == 0xFFF8
}
/// Block-size code → samples, `ff_flac_blocksize_table` (0 = reserved/explicit).
const FLAC_BLOCKSIZE_TABLE: [u32; 16] = [
0, 192, 576, 1152, 2304, 4608, 0, 0, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768,
];
/// Sample-rate code → Hz, `ff_flac_sample_rate_table` (0 = STREAMINFO/explicit).
const FLAC_SAMPLE_RATE_TABLE: [u32; 16] = [
0, 88_200, 176_400, 192_000, 8_000, 16_000, 22_050, 24_000, 32_000, 44_100, 48_000, 96_000, 0,
0, 0, 0,
];
/// Best-effort duration (ns) of a FLAC frame from its header block-size and
/// sample-rate codes (byte 2). Only the table-coded cases are resolved; the
/// explicit-in-trailing-bytes codes (block 6/7, rate 12/13/14) and
/// STREAMINFO-derived (code 0) return `None`. Used only for the dropped-audio
/// accounting, so a `None` (→ 0) is harmless.
fn flac_frame_duration_ns(frame: &[u8]) -> Option<i64> {
if frame.len() < 3 {
return None;
}
let bs_code = (frame[2] >> 4) & 0x0F;
let sr_code = frame[2] & 0x0F;
let blocksize = FLAC_BLOCKSIZE_TABLE[bs_code as usize];
let rate = FLAC_SAMPLE_RATE_TABLE[sr_code as usize];
if blocksize == 0 || rate == 0 {
return None;
}
Some((blocksize as i64 * 1_000_000_000 + rate as i64 / 2) / rate as i64)
}
pub struct FlacParser {
tally: DropTally,
}
impl Default for FlacParser {
fn default() -> Self {
Self::new()
}
}
impl FlacParser {
pub fn new() -> Self {
Self {
tally: DropTally::new("flac"),
}
}
/// Access units dropped as undecodable so far.
pub fn dropped_frames(&self) -> u64 {
self.tally.dropped_frames()
}
/// Total decoded duration (ns) of dropped access units.
pub fn dropped_duration_ns(&self) -> u64 {
self.tally.dropped_duration_ns()
}
}
impl CodecParser for FlacParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
if pes.data.is_empty() {
return Vec::new();
}
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
// Gate: a packet that begins with a FLAC frame sync but whose whole-frame
// CRC-16 residue is nonzero is corrupt → drop. Anything else passes
// through (a non-sync packet is not a frame we can validate; a poisoned
// track drops everything).
let corrupt = has_flac_sync(&pes.data) && crc16_ansi(&pes.data) != 0;
if self.tally.is_poisoned() || corrupt {
let reason = if self.tally.is_poisoned() {
"track-poisoned"
} else {
"crc"
};
let dur = flac_frame_duration_ns(&pes.data).unwrap_or(0);
self.tally.record_drop(pts_ns, dur, pes.data.len(), reason);
return Vec::new();
}
self.tally.record_kept();
vec![Frame {
discontinuity: pes.discontinuity,
coding: None,
source: None,
pts_ns,
keyframe: true,
data: pes.data.clone(),
duration_ns: None,
}]
}
fn flush(&mut self) -> Vec<Frame> {
self.tally.log_summary();
Vec::new()
}
fn codec_private(&self) -> Option<Vec<u8>> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket {
source: None,
pid: 0x1100,
pts,
dts: None,
data,
discontinuity: false,
}
}
/// A minimal FLAC-frame-shaped buffer: sync `0xFFF8`, a plausible header
/// (block code 1 = 192 samples, rate code 9 = 44.1 kHz), some payload, and a
/// trailing CRC-16 so the whole-frame residue is zero (a valid frame).
fn make_flac_frame(payload_len: usize) -> Vec<u8> {
let mut f = vec![0u8; 6 + payload_len + 2];
f[0] = 0xFF;
f[1] = 0xF8; // sync + fixed blocksize
f[2] = (1 << 4) | 9; // bs_code=1 (192), sr_code=9 (44100)
// bytes 3..end-2 arbitrary; last two bytes carry the CRC-16.
let n = f.len();
let c = crc16_ansi(&f[..n - 2]);
f[n - 2] = (c >> 8) as u8;
f[n - 1] = (c & 0xFF) as u8;
assert_eq!(crc16_ansi(&f), 0, "finalized frame has zero residue");
f
}
#[test]
fn valid_frame_is_kept() {
let mut p = FlacParser::new();
let f = p.parse(&make_pes(make_flac_frame(100), Some(90000)));
assert_eq!(f.len(), 1);
assert_eq!(f[0].pts_ns, pts_to_ns(90000));
assert_eq!(p.dropped_frames(), 0);
}
#[test]
fn corrupt_frame_is_dropped() {
let mut p = FlacParser::new();
let mut frame = make_flac_frame(100);
frame[20] ^= 0xFF; // corrupt a payload byte → CRC residue nonzero
assert!(crc16_ansi(&frame) != 0);
let f = p.parse(&make_pes(frame, Some(90000)));
assert!(f.is_empty(), "corrupt FLAC frame dropped");
assert_eq!(p.dropped_frames(), 1);
// 192 samples @ 44.1 kHz ≈ 4.354 ms of silence accounted.
assert_eq!(
p.dropped_duration_ns(),
(192u64 * 1_000_000_000 + 44_100 / 2) / 44_100
);
}
#[test]
fn corrupt_drop_preserves_sync_via_own_pts() {
// Each packet carries its own PTS, so dropping one leaves the next frame
// on its true timeline — a gap, not a shift.
let mut p = FlacParser::new();
let mut bad = make_flac_frame(100);
bad[20] ^= 0xFF;
assert!(p.parse(&make_pes(bad, Some(90000))).is_empty());
let f = p.parse(&make_pes(make_flac_frame(100), Some(96000)));
assert_eq!(f.len(), 1);
assert_eq!(
f[0].pts_ns,
pts_to_ns(96000),
"surviving frame keeps its own container PTS — the drop is a gap"
);
}
#[test]
fn non_flac_packet_passes_through() {
// A packet without the FLAC sync isn't a frame we can validate — never
// false-drop it.
let mut p = FlacParser::new();
let f = p.parse(&make_pes(vec![0x00, 0x01, 0x02, 0x03], Some(0)));
assert_eq!(f.len(), 1, "unrecognized packet passed through");
assert_eq!(p.dropped_frames(), 0);
}
#[test]
fn empty_pes_emits_nothing() {
let mut p = FlacParser::new();
assert!(p.parse(&make_pes(Vec::new(), Some(0))).is_empty());
}
}
+32 -3
View File
@@ -9,12 +9,19 @@
/// AC-3 / E-AC-3 (Dolby Digital / Digital Plus) elementary-stream parser.
pub mod ac3;
pub mod adts;
/// Codec-agnostic per-picture coding carrier (`PictureInfo` + accessors).
pub mod coding;
/// DTS / DTS-HD elementary-stream parser.
pub(crate) mod crc;
pub(crate) mod dropgate;
pub mod dts;
/// DVD bitmap subtitle (VobSub) parser.
pub mod dvdsub;
pub mod flac;
/// H.264 (AVC) Annex-B elementary-stream parser.
pub mod h264;
/// HEVC (H.265) Annex-B elementary-stream parser.
@@ -23,6 +30,8 @@ pub mod hevc;
pub mod lpcm;
/// MPEG-2 Video elementary-stream parser.
pub mod mpeg2;
pub mod mpegaudio;
/// HDMV PGS (Presentation Graphics Stream) subtitle parser.
pub mod pgs;
/// Display-order PTS reconstruction for sparse-PTS program-stream video.
@@ -154,6 +163,25 @@ impl CodecParser for PassthroughParser {
}
}
/// Drop-on-undecodable policy across codecs ("clean muxes always"):
///
/// - **Audio with independent access units** (DTS, AC-3/E-AC-3, …) gates each AU
/// through a per-codec corruption check and drops the ones that fail, keeping
/// A/V sync (a drop is a silence gap, never a shift) and logging every drop
/// via the shared [`dropgate::DropTally`]. DTS uses ffmpeg's core-header parse;
/// AC-3 uses its native frame CRC.
/// - **LPCM is excluded on purpose**: raw PCM carries no framing or integrity
/// data, so a corrupt sample is indistinguishable from a quiet one — there is
/// nothing to detect, so nothing can be honestly dropped.
/// - **Video is excluded on purpose**: H.264/HEVC/MPEG-2/VC-1 are inter-frame
/// predicted, so dropping one frame corrupts every frame that references it
/// until the next keyframe. Video instead resyncs at GOP/IDR boundaries (the
/// ResyncGate) and lets the decoder conceal — a fundamentally different model
/// than per-frame audio dropping.
/// - TrueHD/MLP, FLAC, MP2/MP3 and AAC-ADTS also gate undecodable frames via a
/// `DropTally` (poison/drop-forward for MLP's inter-AU restart state on a
/// major-sync boundary; CRC/sync-verdict drops for the passthrough codecs).
///
/// Create the appropriate parser for a codec, with optional codec private data.
///
/// For DvdSub, `codec_data` should be the pre-formatted VobSub .idx palette header.
@@ -177,6 +205,9 @@ pub fn parser_for_codec(
Codec::Mpeg2 => Box::new(mpeg2::Mpeg2Parser::new()),
Codec::Vc1 => Box::new(vc1::Vc1Parser::new().with_ps_reorder(is_dvd_ps)),
Codec::Ac3 | Codec::Ac3Plus => Box::new(ac3::Ac3Parser::new()),
Codec::Flac => Box::new(flac::FlacParser::new()),
Codec::Mp2 | Codec::Mp3 => Box::new(mpegaudio::MpegAudioParser::new()),
Codec::Aac => Box::new(adts::AdtsParser::new()),
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => Box::new(dts::DtsParser::new()),
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
Codec::Pgs => Box::new(pgs::PgsParser::new()),
@@ -200,9 +231,7 @@ pub fn parser_for_codec(
// Remaining audio-only codecs (Aac, Mp2, Mp3, Flac, Opus) where PES =
// frame: all-keyframe passthrough is correct. Subtitle/Unknown also land
// here; keyframe flag is irrelevant for them.
Codec::Aac | Codec::Mp2 | Codec::Mp3 | Codec::Flac | Codec::Opus => {
Box::new(PassthroughParser::new(true))
}
Codec::Opus => Box::new(PassthroughParser::new(true)),
Codec::Srt | Codec::Ssa | Codec::Unknown(_) => Box::new(PassthroughParser::new(true)),
}
}
+224
View File
@@ -0,0 +1,224 @@
//! MPEG-1/2/2.5 audio (MP1/MP2/MP3) decodability gate.
//!
//! ffmpeg validates MPEG-audio frames by header sanity + framing resync, not a
//! payload CRC (`mpegaudiodecheader.c` `ff_mpa_check_header`; the optional CRC
//! covers only side-info and is off by default). Its `ff_mpa_decode_header`
//! additionally rejects free-format (`bitrate_index == 0`). So the gate mirrors
//! exactly those header rejects: a packet that begins with the 11-bit MPEG-audio
//! sync but whose version / layer / bitrate-index / sample-rate fields are the
//! reserved/invalid values is undecodable → drop it (a silence gap; each packet
//! keeps its own PTS). A packet with no leading sync is not a frame we can
//! validate (raw payload / continuation), so it passes through unchanged —
//! never false-dropped.
use super::dropgate::DropTally;
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
/// Decoded validity of a candidate MPEG-audio header.
enum MpaVerdict {
/// No 11-bit sync at the packet head — not a frame we can validate.
NoSync,
/// Sync present and every field is legal — decodable.
Valid,
/// Sync present but a field is reserved/invalid (or free-format) — ffmpeg's
/// parser rejects this exactly.
Invalid,
}
/// Mirror ffmpeg's `ff_mpa_check_header` + the `ff_mpa_decode_header`
/// free-format reject. A dropped MPEG-audio frame has a corrupt header, so no
/// duration is computed (the fields it would come from are the invalid ones).
fn mpa_verdict(data: &[u8]) -> MpaVerdict {
if data.len() < 4 {
return MpaVerdict::NoSync;
}
let h = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
// 11-bit sync (0x7FF at the top).
if (h & 0xffe0_0000) != 0xffe0_0000 {
return MpaVerdict::NoSync;
}
// ff_mpa_check_header rejects: version field 01, layer field 00,
// bitrate_index 15, sample-rate field 3.
if (h & (3 << 19)) == (1 << 19)
|| (h & (3 << 17)) == 0
|| (h & (0xf << 12)) == (0xf << 12)
|| (h & (3 << 10)) == (3 << 10)
{
return MpaVerdict::Invalid;
}
// NOTE: bitrate_index == 0 (free format) is NOT rejected. It is a legal,
// decodable MPEG-audio mode (ffmpeg's ff_mpa_check_header accepts it and the
// decoder derives the frame size from the sync spacing). Dropping it would be
// a false positive on a clean stream, so it passes the gate.
MpaVerdict::Valid
}
pub struct MpegAudioParser {
tally: DropTally,
}
impl Default for MpegAudioParser {
fn default() -> Self {
Self::new()
}
}
impl MpegAudioParser {
pub fn new() -> Self {
Self {
tally: DropTally::new("mpegaudio"),
}
}
pub fn dropped_frames(&self) -> u64 {
self.tally.dropped_frames()
}
pub fn dropped_duration_ns(&self) -> u64 {
self.tally.dropped_duration_ns()
}
}
impl CodecParser for MpegAudioParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
if pes.data.is_empty() {
return Vec::new();
}
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
let drop =
self.tally.is_poisoned() || matches!(mpa_verdict(&pes.data), MpaVerdict::Invalid);
if drop {
let reason = if self.tally.is_poisoned() {
"track-poisoned"
} else {
"header"
};
self.tally.record_drop(pts_ns, 0, pes.data.len(), reason);
return Vec::new();
}
self.tally.record_kept();
vec![Frame {
discontinuity: pes.discontinuity,
coding: None,
source: None,
pts_ns,
keyframe: true,
data: pes.data.clone(),
duration_ns: None,
}]
}
fn flush(&mut self) -> Vec<Frame> {
self.tally.log_summary();
Vec::new()
}
fn codec_private(&self) -> Option<Vec<u8>> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket {
source: None,
pid: 0x1100,
pts,
dts: None,
data,
discontinuity: false,
}
}
/// A valid MPEG-1 Layer III header: sync 0xFFF, version MPEG-1 (11), layer
/// III (01), bitrate_index 9, sample-rate 0 (44.1 kHz), no CRC. Bytes:
/// 0xFF 0xFB 0x90 0x00 — the canonical MP3 frame header.
fn mp3_frame(payload: usize) -> Vec<u8> {
let mut f = vec![0xFF, 0xFB, 0x90, 0x00];
f.extend(std::iter::repeat(0xAA).take(payload));
f
}
#[test]
fn valid_header_is_kept() {
let mut p = MpegAudioParser::new();
let f = p.parse(&make_pes(mp3_frame(400), Some(90000)));
assert_eq!(f.len(), 1);
assert_eq!(f[0].pts_ns, pts_to_ns(90000));
assert_eq!(p.dropped_frames(), 0);
}
#[test]
fn reserved_sample_rate_is_dropped() {
// Sync present but sample-rate field = 3 (reserved) → ffmpeg rejects.
// 0xFF 0xFB then byte2 with bits 11..10 = 11: 0x9C.
let mut p = MpegAudioParser::new();
let mut frame = mp3_frame(400);
frame[2] = 0x9C; // freq field = 3
let f = p.parse(&make_pes(frame, Some(90000)));
assert!(f.is_empty(), "reserved sample rate dropped");
assert_eq!(p.dropped_frames(), 1);
}
#[test]
fn reserved_layer_is_dropped() {
// Layer field 00 (reserved). byte1 bits 2..1 = 00 → 0xF9 keeps sync
// (0xFFF needs byte1 top 3 bits set) and sets layer=00.
let mut p = MpegAudioParser::new();
let mut frame = mp3_frame(400);
frame[1] = 0xF9; // 1111_1001: sync ok (top 3 =111), version 11, layer 00
let f = p.parse(&make_pes(frame, Some(0)));
assert!(f.is_empty(), "reserved layer dropped");
assert_eq!(p.dropped_frames(), 1);
}
#[test]
fn bad_bitrate_index_15_is_dropped() {
let mut p = MpegAudioParser::new();
let mut frame = mp3_frame(400);
frame[2] = 0xF0; // bitrate_index = 1111
assert!(p.parse(&make_pes(frame, Some(0))).is_empty());
assert_eq!(p.dropped_frames(), 1);
}
#[test]
fn free_format_bitrate_zero_is_kept() {
// Free format (bitrate_index == 0) is legal and decodable — it must NOT
// be dropped (that would be a false positive on a clean stream).
let mut p = MpegAudioParser::new();
let mut frame = mp3_frame(400);
frame[2] = 0x00; // bitrate_index = 0000 (free format); sync/layer/rate ok
let f = p.parse(&make_pes(frame, Some(0)));
assert_eq!(f.len(), 1, "free-format frame kept");
assert_eq!(p.dropped_frames(), 0);
}
#[test]
fn non_sync_packet_passes_through() {
// No 11-bit sync → not a validatable frame → keep (conservative).
let mut p = MpegAudioParser::new();
let f = p.parse(&make_pes(vec![0x00, 0x11, 0x22, 0x33, 0x44], Some(0)));
assert_eq!(f.len(), 1);
assert_eq!(p.dropped_frames(), 0);
}
#[test]
fn drop_preserves_sync_via_own_pts() {
let mut p = MpegAudioParser::new();
let mut bad = mp3_frame(400);
bad[2] = 0x9C; // reserved sample rate
assert!(p.parse(&make_pes(bad, Some(90000))).is_empty());
let f = p.parse(&make_pes(mp3_frame(400), Some(96000)));
assert_eq!(f.len(), 1);
assert_eq!(
f[0].pts_ns,
pts_to_ns(96000),
"next frame keeps its own PTS"
);
}
}
+119
View File
@@ -30,6 +30,90 @@ const MAX_PGS_PENDING_BYTES: usize = 4 * 1024 * 1024;
// (video_w/h, frame_rate, comp_num, comp_state, palette_update,
// palette_id_ref) = 13.
const PCS_NUM_OBJECTS_OFFSET: usize = 13;
// Offset of the first composition_object's flags byte within a PCS PES payload:
// PCS header(13) + number_of_composition_objects(1) + object_id_ref(2) +
// window_id_ref(1) = 17. `forced_on_flag` is bit 0x40 of that byte (HDMV PCS).
const PCS_FIRST_OBJECT_FLAGS_OFFSET: usize = 17;
const PCS_FORCED_ON_FLAG: u8 = 0x40;
/// Whether an emitted PGS display-set frame is a FORCED subtitle — the
/// `forced_on_flag` (0x40) on its first composition object. The frame data an
/// emitted PGS block carries begins with the display PCS (segment type 0x16), so
/// the flag is read directly from it. Returns `None` when the block is not a
/// display PCS with a composition object (nothing to classify — a clear PCS, a
/// non-PCS segment, or a truncated header).
///
/// The mux uses this to detect a *forced-narrative track* (every displayed
/// subtitle forced) without relying on the disc's vendor label metadata, so
/// forced subs are flagged `FlagForced` even on discs that carry no such blob.
pub fn display_set_is_forced(frame_data: &[u8]) -> Option<bool> {
if frame_data.first() != Some(&SEGMENT_PCS) {
return None;
}
if *frame_data.get(PCS_NUM_OBJECTS_OFFSET)? == 0 {
return None; // clear PCS — no composition to classify
}
let flags = *frame_data.get(PCS_FIRST_OBJECT_FLAGS_OFFSET)?;
Some(flags & PCS_FORCED_ON_FLAG != 0)
}
/// Accumulates the "is this PGS subtitle track a forced-narrative track?" verdict
/// from its display sets. A track is forced iff it displayed at least one subtitle
/// and EVERY display set carried the forced_on_flag — a dedicated forced track,
/// as opposed to a full track that merely has occasional forced signs.
///
/// This is the SINGLE classification used by both the MKV muxer (accumulating a
/// track's frames during a rip) and the `info`-time forced probe (feeding the
/// demuxed display sets), so both reach the identical verdict.
#[derive(Debug, Clone)]
pub struct ForcedTracker {
has_display: bool,
all_forced: bool,
}
impl Default for ForcedTracker {
fn default() -> Self {
Self {
has_display: false,
all_forced: true,
}
}
}
impl ForcedTracker {
pub fn new() -> Self {
Self::default()
}
/// Fold one emitted PGS block into the verdict. Non-display blocks (clear
/// PCS, other segments) are ignored.
pub fn observe(&mut self, frame_data: &[u8]) {
if let Some(forced) = display_set_is_forced(frame_data) {
self.has_display = true;
self.all_forced &= forced;
}
}
/// Whether the track has already shown a NON-forced subtitle — i.e. its
/// verdict is settled at "not forced" and further observation can be skipped
/// (the early-exit the probe uses to avoid reading the whole clip).
pub fn settled_not_forced(&self) -> bool {
self.has_display && !self.all_forced
}
/// Whether ANY display set was observed. When false the track's forced state
/// is unknown (no PGS content seen — e.g. an undecrypted/unread stream), so a
/// probe should leave any existing (vendor-derived) flag untouched rather
/// than assert "not forced".
pub fn observed(&self) -> bool {
self.has_display
}
/// Final verdict: forced iff it displayed subtitles and every one was forced.
pub fn is_forced(&self) -> bool {
self.has_display && self.all_forced
}
}
/// Stateful parser that collapses PGS display/clear PCS pairs into
/// duration-bearing Matroska frames. Implements [`CodecParser`].
@@ -222,6 +306,41 @@ mod tests {
use super::*;
use crate::mux::ts::PesPacket;
/// A PCS display-set block with one composition object; `forced` sets the
/// forced_on_flag (0x40) in its flags byte at offset 17.
fn pcs_display(forced: bool) -> Vec<u8> {
let mut d = vec![0u8; 18];
d[0] = SEGMENT_PCS;
d[PCS_NUM_OBJECTS_OFFSET] = 1;
d[PCS_FIRST_OBJECT_FLAGS_OFFSET] = if forced { PCS_FORCED_ON_FLAG } else { 0 };
d
}
#[test]
fn display_set_forced_flag_detection() {
assert_eq!(display_set_is_forced(&pcs_display(true)), Some(true));
assert_eq!(display_set_is_forced(&pcs_display(false)), Some(false));
// Other flag bits set but not forced_on_flag → still not forced.
let mut cropped = pcs_display(false);
cropped[PCS_FIRST_OBJECT_FLAGS_OFFSET] = 0x80; // object_cropped_flag only
assert_eq!(display_set_is_forced(&cropped), Some(false));
}
#[test]
fn display_set_forced_none_for_non_display() {
// Clear PCS (0 objects) → None.
let mut clear = pcs_display(false);
clear[PCS_NUM_OBJECTS_OFFSET] = 0;
assert_eq!(display_set_is_forced(&clear), None);
// Non-PCS segment → None.
let mut ods = pcs_display(true);
ods[0] = 0x15; // ODS
assert_eq!(display_set_is_forced(&ods), None);
// Truncated (no flags byte) → None, no panic.
assert_eq!(display_set_is_forced(&pcs_display(true)[..15]), None);
assert_eq!(display_set_is_forced(&[]), None);
}
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket {
source: None,
+562 -20
View File
@@ -12,6 +12,8 @@
//! AC-3 frames (interleaved, same PID): start with sync word 0x0B77.
//! We skip AC-3 frames and only emit TrueHD access units.
use super::crc::crc16_mlp;
use super::dropgate::DropTally;
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
use crate::mux::timeline::DISCONTINUITY_BACKSTEP_NS;
@@ -43,6 +45,18 @@ pub struct TrueHdParser {
/// yet seen (head of stream) — preserving byte-identical timing for the
/// common 48 kHz case.
au_duration_ns: i64,
/// Keep/drop bookkeeping for the decodability gate.
tally: DropTally,
/// `num_substreams` from the most recent major sync — needed to size the
/// substream directory for the per-AU parity check. `None` until the first
/// major sync is seen (before which no AU can be parity-checked).
num_substreams: Option<u8>,
/// True while dropping forward to the next clean resync point. MLP/TrueHD
/// carries filter/predictor + restart state ACROSS access units, so a corrupt
/// AU cannot be excised in place — it poisons decoding until the next major
/// sync re-initialises state. On corruption we set this and drop every AU
/// until a major sync whose header CRC validates, which we then emit.
resync_pending: bool,
}
impl Default for TrueHdParser {
@@ -57,6 +71,82 @@ impl TrueHdParser {
buf: Vec::with_capacity(32768),
next_pts_ns: 0,
au_duration_ns: AU_DURATION_NS,
tally: DropTally::new("truehd"),
num_substreams: None,
resync_pending: false,
}
}
/// Access units dropped as undecodable so far.
pub fn dropped_frames(&self) -> u64 {
self.tally.dropped_frames()
}
/// Total decoded duration (ns) of dropped access units.
pub fn dropped_duration_ns(&self) -> u64 {
self.tally.dropped_duration_ns()
}
/// Decide whether an access unit is corrupt, updating `num_substreams` from a
/// valid major sync. Mirrors ffmpeg `read_access_unit`: a major sync with a
/// bad header CRC, or any AU whose header parity fails, is undecodable.
/// Returns `false` (not corrupt) when the AU is too short to judge or no
/// major sync has established `num_substreams` yet — we never drop what we
/// cannot verify. Verified against real ffmpeg TrueHD (3600/3600 AUs).
fn au_check(&mut self, au: &[u8], is_major_sync: bool) -> AuCheck {
let mut header_size = 4;
let mut format_info = None;
if is_major_sync {
let ms = &au[4..];
let Some(mshdr) = mlp_major_sync_header_size(ms) else {
// A major sync too short to hold its header can't be CRC-validated
// — NOT a safe resync/re-init point. Treat as unverifiable, not a
// clean major sync.
return AuCheck::Unverifiable;
};
if !mlp_major_sync_crc_ok(ms, mshdr) {
// A failing major-sync checksum is only a TRUSTWORTHY corruption
// signal once we already hold a validated baseline (num_substreams
// captured from a PRIOR checksum-clean major sync). Before that — at
// stream head, when no major sync has validated yet — a checksum
// mismatch is far more likely a limitation of our own major-sync
// header-size parse than real corruption. Arming the drop-forward
// there is catastrophic: every following AU is collateral-dropped
// waiting for a "validated" major sync that (if our parse is the
// problem) NEVER comes, so the WHOLE TrueHD track is silently dropped
// — the AUs are physically emitted late (empty until the mux end),
// de-interleaving the track and sending decoders into an unbounded
// memory spiral. So until we have a baseline to protect, treat a
// checksum-failed major sync as UNVERIFIABLE (keep it) rather than
// Corrupt. Once a clean major sync HAS established the baseline, a
// later failure is a real re-sync trigger and still drops forward.
if self.num_substreams.is_some() {
return AuCheck::Corrupt; // real corruption vs a proven baseline
}
return AuCheck::Unverifiable; // no baseline yet — keep, don't nuke the track
}
self.num_substreams = mlp_num_substreams(ms);
header_size += mshdr;
// The rate nibble is only trustworthy once the major sync's CRC has
// validated (above), so capture format_info here and refine the PTS
// cadence from it ONLY on this validated path.
if au.len() >= 12 {
format_info = Some(u32::from_be_bytes([au[8], au[9], au[10], au[11]]));
}
}
let Some(nss) = self.num_substreams else {
return AuCheck::Unverifiable; // no major sync seen yet — can't check parity
};
let Some(shs) = mlp_substr_header_size(au, header_size, nss) else {
return AuCheck::Unverifiable; // directory runs off the AU — can't judge
};
if !mlp_parity_ok(au, header_size, shs) {
return AuCheck::Corrupt;
}
if is_major_sync {
AuCheck::ValidMajorSync { format_info }
} else {
AuCheck::Ok
}
}
@@ -114,6 +204,23 @@ fn ac3_boundary_corroborated(buf: &[u8], frame_bytes: usize) -> bool {
next_words != 0 && next_words * 2 <= 32768
}
/// Decodability verdict for one TrueHD/MLP access unit.
enum AuCheck {
/// Verified undecodable: a major-sync header whose CRC failed, or any AU
/// whose substream-directory parity failed. Feeds the poison verdict.
Corrupt,
/// A CRC-validated major sync — a safe re-init / resync point. `format_info`
/// (AU bytes 8..12, present when the AU is long enough) is trustworthy here,
/// so the caller refines the PTS cadence ONLY from this validated path.
ValidMajorSync { format_info: Option<u32> },
/// A valid (parity-OK) non-major-sync access unit.
Ok,
/// Cannot be judged — a major sync too short to hold/CRC its header, or a
/// stream head before any major sync established `num_substreams`. Never
/// dropped on its own, and never treated as a clean resync point.
Unverifiable,
}
/// Outcome of sizing the AC-3 frame at the TrueHD buffer head.
enum Ac3Size {
/// fscod/frmsizecod don't map to a real frame size — resync, don't wait.
@@ -124,6 +231,92 @@ enum Ac3Size {
Frame(usize),
}
// --- MLP/TrueHD access-unit integrity (mirrors ffmpeg mlpdec.c / mlp_parse.c) ---
/// Major-sync header size in bytes: base 28, plus `2 + extensions*2` when the
/// extension flag (major-sync byte 25, bit 0) is set (`extensions` = byte 26
/// high nibble). `ms` is the major-sync header, i.e. AU bytes `[4..]`. `None`
/// when the AU is too short to contain the full header.
fn mlp_major_sync_header_size(ms: &[u8]) -> Option<usize> {
if ms.len() < 28 {
return None;
}
let mut size = 28;
if ms[25] & 1 != 0 {
size += 2 + ((ms[26] >> 4) as usize) * 2;
}
if ms.len() < size {
return None;
}
Some(size)
}
/// Validate the MLP/TrueHD major-sync header checksum (ffmpeg `ff_mlp_checksum16`,
/// CRC-16 poly 0x002D). The stored trailer is the last 2 header bytes; because
/// MLP's checksum is byte-reversed relative to a standard CRC, a standard CRC of
/// the header body XOR the little-endian word before the trailer must equal the
/// trailer read big-endian.
fn mlp_major_sync_crc_ok(ms: &[u8], mshdr: usize) -> bool {
if mshdr < 4 || ms.len() < mshdr {
return false;
}
// ffmpeg `ff_mlp_checksum16(buf, buf_size)` (libavcodec/mlp.c):
// av_crc(crc_2D, 0, buf, buf_size - 2) ^ AV_RL16(buf + buf_size - 2)
// is called with `buf_size = mshdr - 2` and its result compared to
// `AV_RL16(buf + mshdr - 2)` — i.e. the 16-bit checksum over `ms[..mshdr-4]`,
// XORed with the LITTLE-ENDIAN word just before the trailer, must equal the
// LITTLE-ENDIAN trailer word. `crc16_mlp` is the same crc_2D table (poly 0x2D,
// MSB-first) but yields its two bytes in the OPPOSITE order to libavutil's
// `av_crc`, so swap them back to match. (The previous code mixed endianness —
// little-endian XOR word but big-endian compare — so the checksum could never
// validate any real extended major sync, silently dropping the whole track;
// cross-verified byte-exact against real 7.1/Atmos and 5.1 discs.)
let checksum = crc16_mlp(&ms[..mshdr - 4]).swap_bytes()
^ u16::from_le_bytes([ms[mshdr - 4], ms[mshdr - 3]]);
checksum == u16::from_le_bytes([ms[mshdr - 2], ms[mshdr - 1]])
}
/// `num_substreams` from a major-sync header: it sits at bit 128 (byte 16, top
/// nibble) for both MLP (0xbb) and TrueHD (0xba) — the fields before it total
/// the same 128 bits in either layout.
fn mlp_num_substreams(ms: &[u8]) -> Option<u8> {
ms.get(16).map(|&b| b >> 4)
}
/// Size in bytes of the substream directory that follows the AU header: each of
/// the `num_substreams` entries is 2 bytes, plus 2 more when its extraword flag
/// (entry's top bit) is set. `None` if the directory runs past the AU.
fn mlp_substr_header_size(au: &[u8], header_size: usize, num_substreams: u8) -> Option<usize> {
let mut off = header_size;
let mut shs = 0;
for _ in 0..num_substreams {
if off + 2 > au.len() {
return None;
}
let extraword = au[off] & 0x80 != 0;
shs += 2;
off += 2;
if extraword {
shs += 2;
off += 2;
}
}
Some(shs)
}
/// MLP/TrueHD AU-header parity check (ffmpeg `ff_mlp_calculate_parity`): the XOR
/// of the 4-byte AU header with the substream directory, folded, must have its
/// two nibbles XOR to 0xF.
fn mlp_parity_ok(au: &[u8], header_size: usize, substr_header_size: usize) -> bool {
let end = header_size + substr_header_size;
if end > au.len() {
return false;
}
let xor_fold = |d: &[u8]| d.iter().fold(0u8, |a, &b| a ^ b);
let p = xor_fold(&au[0..4]) ^ xor_fold(&au[header_size..end]);
((p >> 4) ^ p) & 0xF == 0xF
}
impl CodecParser for TrueHdParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
// B1: a concealed/lost gap means the buffered TrueHD AU is TRUNCATED.
@@ -256,26 +449,93 @@ impl CodecParser for TrueHdParser {
& 0xFFFF_FFFE)
== 0xF872_6FBA;
// On a major sync the 32-bit `format_info` word (immediately after
// the 4-byte sync, i.e. AU bytes 8..12) carries the rate nibble.
// Refine the per-AU PTS increment to the actual rate family. The
// 48 kHz family resolves to the unchanged 833_333 default, so the
// common case stays byte-identical; only the 44.1 kHz family shifts.
if is_major_sync && unit_bytes >= 12 {
let format_info =
u32::from_be_bytes([self.buf[8], self.buf[9], self.buf[10], self.buf[11]]);
self.au_duration_ns = truehd_au_duration_ns(format_info);
// Decodability gate. MLP/TrueHD decode state persists across access
// units, so a corrupt AU is dropped FORWARD to the next VALIDATED
// major sync (the clean re-init point) rather than excised in place.
// The PTS clock advances across every dropped AU so a drop is a
// silence gap, never a shift.
let au = self.buf[..unit_bytes].to_vec();
let pts = self.next_pts_ns;
let mut emit_keyframe: Option<bool> = None; // Some(is_keyframe) => emit
let mut drop_reason: Option<(&'static str, bool)> = None; // (reason, verified)
if self.tally.is_poisoned() {
// Whole track already judged dead — collateral drop (does not
// re-feed the poison verdict).
drop_reason = Some(("track-poisoned", false));
} else {
match self.au_check(&au, is_major_sync) {
AuCheck::ValidMajorSync { format_info } => {
// The rate nibble is trustworthy only now that the major
// sync's CRC has validated. Refine the per-AU PTS
// increment (48 kHz family stays the 833_333 default).
if let Some(fi) = format_info {
self.au_duration_ns = truehd_au_duration_ns(fi);
}
// A validated major sync is the ONLY clean resync point.
self.resync_pending = false;
emit_keyframe = Some(true);
}
AuCheck::Corrupt => {
if self.resync_pending {
// Part of the current drop-forward run — collateral.
drop_reason = Some(("resync", false));
} else {
// The trigger: one verified corruption that starts the
// drop-forward. Only this counts toward poison.
let r = if is_major_sync {
"major-sync-crc"
} else {
"parity"
};
drop_reason = Some((r, true));
self.resync_pending = true;
}
}
AuCheck::Ok => {
if self.resync_pending {
// Decode state is invalid until the next validated
// major sync, so even a parity-OK AU is undecodable
// here — collateral drop.
drop_reason = Some(("resync", false));
} else {
emit_keyframe = Some(false);
}
}
AuCheck::Unverifiable => {
if self.resync_pending {
// Not a validated major sync — do NOT clear the resync
// on it; keep dropping forward.
drop_reason = Some(("resync", false));
} else {
// Head of stream / too-short AU: keep (never drop what
// we cannot verify).
emit_keyframe = Some(is_major_sync);
}
}
}
}
frames.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: self.next_pts_ns,
keyframe: is_major_sync,
data: self.buf[..unit_bytes].to_vec(),
duration_ns: None,
});
if let Some(keyframe) = emit_keyframe {
self.tally.record_kept();
frames.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: pts,
keyframe,
data: au,
duration_ns: None,
});
} else if let Some((reason, verified)) = drop_reason {
if verified {
self.tally
.record_drop(pts, self.au_duration_ns, au.len(), reason);
} else {
self.tally
.record_collateral_drop(pts, self.au_duration_ns, au.len(), reason);
}
}
self.buf.drain(..unit_bytes);
self.next_pts_ns += self.au_duration_ns;
}
@@ -289,6 +549,11 @@ impl CodecParser for TrueHdParser {
frames
}
fn flush(&mut self) -> Vec<Frame> {
self.tally.log_summary();
Vec::new()
}
fn codec_private(&self) -> Option<Vec<u8>> {
None
}
@@ -451,6 +716,275 @@ mod tests {
data
}
/// Turn a synthetic major-sync AU (sync bytes already set at offset 4, any
/// `format_info` set) into one that passes the decodability gate: 1 substream,
/// a clean substream directory, a valid major-sync CRC-16, and a valid header
/// parity nibble. Mirrors what a real encoder writes (verified against real
/// ffmpeg TrueHD). The AU must be ≥ 36 bytes (4 AU header + 28 major-sync
/// header + 2 directory + slack), which every `make_truehd_unit(≥200)` is.
fn finalize_major_sync(au: &mut [u8]) {
const MSHDR: usize = 28; // no extension (byte 25 clear)
// num_substreams = 1 → major-sync byte 16 (AU[20]) top nibble.
au[20] = (au[20] & 0x0F) | 0x10;
// Substream directory entry at AU[4+MSHDR] = AU[32]: extraword flag clear.
au[32] &= 0x7F;
// Major-sync checksum, built EXACTLY as `mlp_major_sync_crc_ok` verifies it
// (ffmpeg `ff_mlp_checksum16`): swap_bytes(crc16_mlp(body)) ^ LE word before
// the trailer, stored little-endian in the trailer.
let body_end = 4 + MSHDR - 4; // AU[4..28]
let crc = super::crc16_mlp(&au[4..body_end]).swap_bytes()
^ u16::from_le_bytes([au[body_end], au[body_end + 1]]);
au[4 + MSHDR - 2] = (crc & 0xFF) as u8;
au[4 + MSHDR - 1] = (crc >> 8) as u8;
// Parity: choose the AU check nibble (AU[0] high bits) so the header +
// directory fold to 0xF. The length low nibble (AU[0] low bits) is kept.
let hi = au[0] & 0x0F;
let p0 = (hi ^ au[1] ^ au[2] ^ au[3]) ^ (au[32] ^ au[33]);
let c = ((p0 >> 4) ^ (p0 & 0x0F) ^ 0x0F) & 0x0F;
au[0] = (c << 4) | hi;
}
/// Give a synthetic NON-major-sync AU a valid header parity nibble (1
/// substream, directory at AU[4..6]), so it passes the gate once a preceding
/// major sync has established `num_substreams`.
fn finalize_normal_parity(au: &mut [u8]) {
au[4] &= 0x7F; // no extraword
let hi = au[0] & 0x0F;
let p0 = (hi ^ au[1] ^ au[2] ^ au[3]) ^ (au[4] ^ au[5]);
let c = ((p0 >> 4) ^ (p0 & 0x0F) ^ 0x0F) & 0x0F;
au[0] = (c << 4) | hi;
}
fn valid_major_sync() -> Vec<u8> {
let mut u = make_truehd_unit(200);
u[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes());
finalize_major_sync(&mut u);
u
}
fn valid_normal_au() -> Vec<u8> {
let mut u = make_truehd_unit(200);
finalize_normal_parity(&mut u);
u
}
#[test]
fn corrupt_major_sync_drops_forward_to_next_valid() {
// MLP state carries across AUs, so a corrupt AU is dropped FORWARD to the
// next valid major sync (the clean re-init point). Sequence: valid MS,
// corrupt MS (bad CRC), a normal AU, then a valid MS. Only the two valid
// major syncs survive; the corrupt MS and the intervening normal AU are
// dropped (the latter because decode state is poisoned until re-init).
let mut parser = TrueHdParser::new();
let ms1 = valid_major_sync();
let mut ms_bad = valid_major_sync();
ms_bad[10] ^= 0xFF; // corrupt a CRC-covered header byte
let normal = valid_normal_au(); // clean parity, but arrives mid-resync
let ms2 = valid_major_sync();
let mut data = ms1.clone();
data.extend_from_slice(&ms_bad);
data.extend_from_slice(&normal);
data.extend_from_slice(&ms2);
let mut frames = parser.parse(&make_pes(data, Some(90000)));
frames.extend(parser.flush());
assert_eq!(frames.len(), 2, "only the two valid major syncs survive");
assert!(frames[0].keyframe && frames[1].keyframe);
assert_eq!(
parser.dropped_frames(),
2,
"corrupt MS + poisoned normal AU"
);
}
#[test]
fn crc_failed_head_major_sync_is_kept_not_track_killed() {
// REGRESSION (every TrueHD title muxed after the checksum gate landed): a
// real-world major sync whose checksum our own header-size parse can't
// validate must NOT, at stream head — before ANY major sync has validated —
// arm the drop-forward. Doing so latched `resync_pending` on the very first
// AU and collateral-dropped EVERY following AU forever (no "validated" major
// sync ever came), silently dropping the entire TrueHD track: its AUs were
// emitted only at mux end, de-interleaving the track and spiralling decoders
// into unbounded memory ("decoder ran out of memory"). With no baseline to
// protect, a checksum-failed major sync is KEPT and the audio flows.
let mut parser = TrueHdParser::new();
let mut ms_bad = valid_major_sync();
ms_bad[10] ^= 0xFF; // break a checksum-covered header byte → checksum fails
let mut data = ms_bad;
for _ in 0..6 {
data.extend_from_slice(&valid_normal_au());
}
let mut frames = parser.parse(&make_pes(data, Some(90000)));
frames.extend(parser.flush());
assert_eq!(
frames.len(),
7,
"no baseline yet: the CRC-failed head major sync + all following AUs are \
kept, not dropped (got {})",
frames.len()
);
assert_eq!(
parser.dropped_frames(),
0,
"nothing dropped without a validated baseline to protect"
);
// And the invariant still holds ONCE a baseline exists: after a genuinely
// valid major sync, a later corrupt one IS dropped (see
// `corrupt_major_sync_drops_forward_to_next_valid`).
}
#[test]
fn parity_failure_is_dropped() {
// A normal AU whose header parity is broken (after a major sync sets
// num_substreams) is undecodable → dropped.
let mut parser = TrueHdParser::new();
let ms1 = valid_major_sync();
let mut bad = valid_normal_au();
// A single-nibble flip: MLP's nibble-fold parity (like ffmpeg's) is blind
// to a full-byte flip, which changes both nibbles equally and cancels.
bad[2] ^= 0x01;
let ms2 = valid_major_sync();
let mut data = ms1;
data.extend_from_slice(&bad);
data.extend_from_slice(&ms2);
let mut frames = parser.parse(&make_pes(data, Some(90000)));
frames.extend(parser.flush());
assert_eq!(frames.len(), 2, "the parity-broken AU is dropped");
assert_eq!(parser.dropped_frames(), 1);
}
#[test]
fn drop_forward_preserves_av_sync_no_shift() {
// THE INVARIANT: the resumed major sync keeps the exact PTS it would have
// had with no drop — base + 3 AU durations (MS1, corrupt-MS, normal, MS2)
// — so the drop is a silence gap, never a shift.
let mut parser = TrueHdParser::new();
let ms1 = valid_major_sync();
let mut ms_bad = valid_major_sync();
ms_bad[10] ^= 0xFF;
let normal = valid_normal_au();
let ms2 = valid_major_sync();
let mut data = ms1;
data.extend_from_slice(&ms_bad);
data.extend_from_slice(&normal);
data.extend_from_slice(&ms2);
let mut frames = parser.parse(&make_pes(data, Some(90000)));
frames.extend(parser.flush());
assert_eq!(frames.len(), 2);
assert_eq!(
frames[1].pts_ns - frames[0].pts_ns,
3 * AU_DURATION_NS,
"resumed major sync keeps its true timeline (gap, not shift)"
);
}
#[test]
fn transient_corruptions_do_not_poison_whole_track() {
// Regression (audit HIGH): TrueHD drop-forward must NOT amplify a couple
// of transient errors into a false whole-track poison. Two corruptions,
// each forcing a long collateral resync run past 200 total AUs, must
// leave the track un-poisoned and keep the good audio that follows.
let mut parser = TrueHdParser::new();
let mut data = valid_major_sync();
// Corruption #1 then a long run of normal AUs (all collateral-dropped
// while resyncing — no major sync to re-init on).
let mut bad1 = valid_normal_au();
bad1[2] ^= 0x01; // single-nibble parity break
data.extend_from_slice(&bad1);
for _ in 0..210 {
data.extend_from_slice(&valid_normal_au());
}
// A valid major sync resumes; the good AUs after it MUST be kept.
data.extend_from_slice(&valid_major_sync());
for _ in 0..5 {
data.extend_from_slice(&valid_normal_au());
}
let frames = parser.parse(&make_pes(data, Some(90000)));
assert!(
!parser.tally.is_poisoned(),
"two transient errors must not poison the track"
);
// MS1 + resumed MS2 + the 5 good AUs after it survive.
assert_eq!(
frames.len(),
7,
"post-resync good audio is kept, not poisoned away"
);
assert!(
parser.dropped_frames() > 200,
"the resync run was still counted for reporting"
);
}
#[test]
fn corrupt_major_sync_rate_nibble_does_not_shift_pts() {
// Regression (audit MED): a corrupt major sync whose rate nibble decodes
// to the 44.1 kHz family must NOT refine au_duration_ns — the rate is only
// trustworthy after the CRC validates. Otherwise the resumed 48 kHz audio
// is shifted (not gapped).
let mut parser = TrueHdParser::new();
let ms1 = valid_major_sync(); // 48 kHz
let mut ms_bad = valid_major_sync();
// Set the rate nibble (top nibble of format_info = au[8]) to 0x8 (44.1k).
// au[8] is CRC-covered, so this also breaks the major-sync CRC → corrupt.
ms_bad[8] = (ms_bad[8] & 0x0F) | 0x80;
let ms2 = valid_major_sync(); // 48 kHz
let mut data = ms1;
data.extend_from_slice(&ms_bad);
data.extend_from_slice(&ms2);
let frames = parser.parse(&make_pes(data, Some(90000)));
assert_eq!(frames.len(), 2, "corrupt MS dropped; MS1 and MS2 survive");
assert_eq!(
frames[1].pts_ns - frames[0].pts_ns,
2 * AU_DURATION_NS,
"resumed audio keeps the 48 kHz cadence — the corrupt MS's 44.1k rate was ignored"
);
}
#[test]
fn too_short_major_sync_does_not_clear_resync() {
// Regression (audit LOW): while resyncing, a major sync too short to hold
// (and CRC-validate) its header must NOT be treated as a clean resync
// point — the runt is dropped and only a real validated major sync resumes.
let mut parser = TrueHdParser::new();
let ms1 = valid_major_sync();
let mut bad = valid_normal_au();
bad[2] ^= 0x01; // parity break → triggers resync
// An 8-byte "major sync": length=4 words, sync at bytes 4..8, too short
// to hold the 28-byte major-sync header.
let runt = vec![0x00, 0x04, 0x00, 0x00, 0xF8, 0x72, 0x6F, 0xBA];
let ms2 = valid_major_sync();
let mut data = ms1;
data.extend_from_slice(&bad);
data.extend_from_slice(&runt);
data.extend_from_slice(&ms2);
let frames = parser.parse(&make_pes(data, Some(90000)));
assert_eq!(frames.len(), 2, "the runt major sync did not resume decode");
for f in &frames {
assert_eq!(
f.data.len(),
200,
"only the real 200-byte major syncs survive"
);
}
}
#[test]
fn clean_truehd_stream_drops_nothing() {
// A run of valid AUs passes untouched — zero false positives (the CRC and
// parity are verified against real ffmpeg TrueHD output).
let mut parser = TrueHdParser::new();
let mut data = valid_major_sync();
for _ in 0..5 {
data.extend_from_slice(&valid_normal_au());
}
let frames = parser.parse(&make_pes(data, Some(90000)));
assert_eq!(frames.len(), 6);
assert_eq!(parser.dropped_frames(), 0);
}
fn make_ac3_frame() -> Vec<u8> {
// Minimal AC-3 frame: sync 0x0B77, fscod=0 (48kHz), frmsizecod=0 (64 words = 128 bytes)
let mut data = vec![0u8; 128];
@@ -878,6 +1412,7 @@ mod tests {
let mut parser = TrueHdParser::new();
let mut unit = make_truehd_unit(200);
unit[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes());
finalize_major_sync(&mut unit);
let f = parser.parse(&make_pes(unit, Some(90000)));
assert_eq!(f.len(), 1);
assert!(f[0].keyframe, "major-sync AU must be flagged keyframe");
@@ -899,6 +1434,7 @@ mod tests {
let mut parser = TrueHdParser::new();
let mut unit = make_truehd_unit(200);
unit[4..8].copy_from_slice(&0xF872_6FBBu32.to_be_bytes());
finalize_major_sync(&mut unit);
let f = parser.parse(&make_pes(unit, Some(90000)));
assert_eq!(f.len(), 1);
assert!(f[0].keyframe, "major-sync variant 0xFB also a keyframe");
@@ -1101,8 +1637,11 @@ mod tests {
let mut a1 = make_truehd_unit(200);
a1[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes()); // major sync
a1[8..12].copy_from_slice(&format_info_with(0x8).to_be_bytes()); // 44.1 k
finalize_major_sync(&mut a1);
let mut a2 = make_truehd_unit(200);
finalize_normal_parity(&mut a2);
let mut data = a1;
data.extend_from_slice(&make_truehd_unit(200));
data.extend_from_slice(&a2);
let frames = parser.parse(&make_pes(data, Some(90000)));
assert_eq!(frames.len(), 2);
assert_eq!(
@@ -1120,8 +1659,11 @@ mod tests {
let mut a1 = make_truehd_unit(200);
a1[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes());
a1[8..12].copy_from_slice(&format_info_with(0x0).to_be_bytes()); // 48 k
finalize_major_sync(&mut a1);
let mut a2 = make_truehd_unit(200);
finalize_normal_parity(&mut a2);
let mut data = a1;
data.extend_from_slice(&make_truehd_unit(200));
data.extend_from_slice(&a2);
let frames = parser.parse(&make_pes(data, Some(90000)));
assert_eq!(frames.len(), 2);
assert_eq!(frames[1].pts_ns - frames[0].pts_ns, 833_333);
+69 -5
View File
@@ -31,6 +31,10 @@ use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
/// Filename-naming strategy for the per-track files.
// `allow(dead_code)`: the sink honours all variants, but only the `#[default]` is
// constructed today (`output()` builds `DemuxOptions::default()`). The alternates
// are a staged option surface awaiting the CLI `--naming` flag (not yet wired).
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Naming {
/// `<base> <track> <lang> <codec> [DELAY <n>ms].<ext>` — human-readable.
@@ -55,6 +59,9 @@ pub enum DelayMode {
}
/// Chapter export format.
// `allow(dead_code)`: only the `#[default]` XML variant is constructed today (via
// `DemuxOptions::default()`); OGM/Both await the CLI `--chapters` flag.
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ChaptersFmt {
/// mkvmerge chapter XML.
@@ -81,6 +88,10 @@ pub struct DemuxOptions {
pub export_chapters: bool,
/// Selected track indices. `None` = all tracks.
pub selection: Option<Vec<usize>>,
/// Restrict output to one track class. `None` = every class (plain
/// `demux://`). `Some(Audio)` is the `audio://` sink; `Some(Subtitle)` is
/// `sub://`. Filtered tracks are skipped entirely (no file written).
pub kind_filter: Option<TrackKind>,
}
impl Default for DemuxOptions {
@@ -92,13 +103,16 @@ impl Default for DemuxOptions {
chapters_fmt: ChaptersFmt::default(),
export_chapters: true,
selection: None,
kind_filter: None,
}
}
}
/// Track class, used for delay attribution and naming.
/// Track class, used for delay attribution and naming — and, via
/// [`DemuxOptions::kind_filter`], to restrict a demux to one class (the
/// `audio://` / `sub://` sinks are a `demux://` filtered to Audio / Subtitle).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TrackKind {
pub enum TrackKind {
Video,
Audio,
Subtitle,
@@ -136,7 +150,7 @@ fn extension_for(codec: Codec) -> &'static str {
}
/// Short codec label for friendly filenames.
fn codec_label(codec: Codec) -> &'static str {
pub(crate) fn codec_label(codec: Codec) -> &'static str {
match codec {
Codec::Hevc => "HEVC",
Codec::H264 => "AVC",
@@ -534,7 +548,7 @@ fn fmt_chapter_time_ns(time_secs: f64) -> String {
}
/// Serialize chapters as mkvmerge chapter XML.
fn chapters_xml(chapters: &[Chapter]) -> String {
pub(crate) fn chapters_xml(chapters: &[Chapter]) -> String {
let mut s = String::new();
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
s.push_str("<!DOCTYPE Chapters SYSTEM \"matroskachapters.dtd\">\n");
@@ -564,7 +578,7 @@ fn chapters_xml(chapters: &[Chapter]) -> String {
}
/// Serialize chapters as OGM/simple chapter text.
fn chapters_ogm(chapters: &[Chapter]) -> String {
pub(crate) fn chapters_ogm(chapters: &[Chapter]) -> String {
let mut s = String::new();
for (i, c) in chapters.iter().enumerate() {
let n = i + 1;
@@ -651,6 +665,11 @@ impl DemuxSink {
(TrackKind::Subtitle, s.codec, s.pid, s.language.clone())
}
};
// Kind filter: `audio://` / `sub://` keep only their class.
if opts.kind_filter.is_some_and(|k| k != kind) {
tracks.push(None);
continue;
}
if kind == TrackKind::Video && ref_video_track.is_none() {
ref_video_track = Some(idx);
}
@@ -867,6 +886,17 @@ mod tests {
})
}
fn subtitle_stream(codec: Codec, lang: &str) -> DiscStream {
DiscStream::Subtitle(crate::disc::SubtitleStream {
pid: 0x1200,
codec,
language: lang.to_string(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
})
}
fn title_with(streams: Vec<DiscStream>, privates: Vec<Option<Vec<u8>>>) -> DiscTitle {
let mut t = DiscTitle::empty();
t.streams = streams;
@@ -875,6 +905,40 @@ mod tests {
t
}
/// `audio://` and `sub://` are `demux://` with a kind filter: only tracks of
/// the selected class get a file; every other track is skipped entirely.
#[test]
fn kind_filter_keeps_only_the_selected_class() {
let title = title_with(
vec![
video_stream(Codec::H264),
audio_stream(Codec::Ac3, "eng"),
subtitle_stream(Codec::Pgs, "eng"),
],
vec![None, None, None],
);
let sub_opts = DemuxOptions {
kind_filter: Some(TrackKind::Subtitle),
export_chapters: false,
..Default::default()
};
let sub = DemuxSink::create(&tempdir(), &title, &sub_opts).unwrap();
assert!(
sub.tracks[0].is_none() && sub.tracks[1].is_none() && sub.tracks[2].is_some(),
"sub:// keeps only the subtitle track"
);
let audio_opts = DemuxOptions {
kind_filter: Some(TrackKind::Audio),
export_chapters: false,
..Default::default()
};
let audio = DemuxSink::create(&tempdir(), &title, &audio_opts).unwrap();
assert!(
audio.tracks[0].is_none() && audio.tracks[1].is_some() && audio.tracks[2].is_none(),
"audio:// keeps only the audio track"
);
}
// ── Annex-B reframing ────────────────────────────────────────────────────
//
// The length-prefixed → Annex-B conversion and the hvcC/avcC param-set
+199 -14
View File
@@ -139,14 +139,14 @@ pub struct DiscStream {
// Adaptive batch sizer — preferred comes from the caller
// (detect_max_batch_sectors), shrinks/grows based on read outcomes.
adaptive: AdaptiveBatch,
pub errors: u64,
errors: u64,
/// Cumulative bytes actually skipped (zero-filled) on read error.
/// Distinct from `errors`, which counts skip *events*: one event can
/// cover a whole AACS unit (`unit_align` sectors = 6144 bytes), so
/// `errors * 2048` understates real loss by the alignment factor.
/// Consumers estimating lost video time must scale by this, not by
/// the event count.
pub lost_bytes: u64,
lost_bytes: u64,
pub skip_errors: bool,
/// When set and the token is cancelled, fill_extents returns Err(Halted)
/// at the next retry boundary. Unlike skip_errors, this propagates the
@@ -209,14 +209,34 @@ impl DiscStream {
/// The caller opens the source, scans for titles/keys, and passes them in.
/// The stream handles demuxing, decryption, and codec parsing internally.
pub fn new(
reader: Box<dyn SectorSource>,
mut reader: Box<dyn SectorSource>,
title: DiscTitle,
decrypt_keys: crate::decrypt::DecryptKeys,
mut decrypt_keys: crate::decrypt::DecryptKeys,
batch_sectors: u16,
content_format: crate::disc::ContentFormat,
) -> Self {
raw: bool,
halt: Option<Halt>,
) -> std::io::Result<Self> {
let mut title = title;
let extents = title.extents.clone();
// Resolve this title's CSS key from the reader if the caller supplied
// none — the SAME shared step the file-backed mux highway
// (`build_iso_pipeline`) uses, so single-pass and multi-pass descramble a
// DVD identically. No-op for AACS / already-keyed / genuinely-clear input
// or `raw`; a scrambled-but-uncrackable DVD is a hard `CssKeyMissing`.
// `halt` is passed here (not deferred to `with_halt`) so a Stop during the
// crack scan is honored — the scan runs at construction, before the caller
// can attach a token.
crate::css::resolve_dvd_title_key(
&mut *reader,
&extents,
&mut decrypt_keys,
batch_sectors,
content_format,
raw,
halt.as_ref(),
)?;
let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
// Debug log reader type at construction — critical for diagnosing mux
@@ -301,7 +321,7 @@ impl DiscStream {
.map(|_| super::resync::ResyncGate::new())
.collect();
Self {
Ok(Self {
reader,
title,
decrypt_keys,
@@ -315,7 +335,7 @@ impl DiscStream {
errors: 0,
lost_bytes: 0,
skip_errors: false,
halt: None,
halt,
event_fn: None,
eof: false,
dropped_nav_packets: 0,
@@ -330,7 +350,7 @@ impl DiscStream {
profiling: std::env::var_os("FREEMKV_PROFILE").is_some(),
resync,
is_video,
}
})
}
/// Set event handler for sector-level events (binary search, skip, recover).
@@ -351,6 +371,27 @@ impl DiscStream {
self
}
/// Install a proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) on the inline
/// live-drive path — the counterpart to what
/// [`build_iso_pipeline`](crate::mux::resolve::build_iso_pipeline) does for the
/// file-backed highway. The map is the title's read plan: it decides which unit
/// each LBA is and, for an FMTS forensic segment, which phase is ours. The
/// extent walk is rewritten to the read plan so **only our-phase units are read
/// off the drive** (the alternate device-group units are never fetched,
/// decrypted, or muxed), and the map is installed so each unit decrypts with its
/// mapped key. A non-forensic map returns the extents unchanged, so a plain
/// single/multi-CPS disc reads exactly as before.
pub fn with_key_map(mut self, map: std::sync::Arc<crate::decrypt::AacsKeyMap>) -> Self {
self.extents = map.read_plan(&self.extents, self.unit_align.max(1) as u32);
self.bytes_total_extents = self
.extents
.iter()
.map(|e| e.sector_count as u64 * 2048)
.sum();
self.reader.set_key_map(map);
self
}
fn is_halted(&self) -> bool {
self.halt
.as_ref()
@@ -1073,7 +1114,10 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
);
false,
None,
)
.unwrap();
let mut src: Box<dyn Stream> = Box::new(stream);
@@ -1108,7 +1152,10 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
crate::disc::ContentFormat::BdTs,
false,
None,
)
.unwrap()
.with_halt(halt.clone());
assert!(!stream.is_halted());
halt.cancel();
@@ -1118,6 +1165,47 @@ mod tests {
);
}
/// `with_key_map` on the inline live-drive path applies the same FMTS read plan
/// the file-backed highway uses: within a forensic segment only our-phase units
/// survive the extent walk, so the alternate device-group units are never read.
#[test]
fn with_key_map_reads_only_our_phase_units() {
use crate::decrypt::{AacsKeyMap, DecryptKeys, Phase};
// AACS keys → unit_align = 3, so a unit is 3 sectors and the phase filter
// engages. Key contents are irrelevant to the read plan.
let aacs = DecryptKeys::Aacs {
unit_keys: vec![(0, [0u8; 16]), (1, [1u8; 16])],
read_data_key: None,
format: ContentFormat::BdTs,
};
// 100 units (300 sectors). A 10-unit Even forensic segment at LBA [30,60):
// even units (30,36,42,48,54) are ours; odd (33,39,45,51,57) are dropped.
let map = AacsKeyMap::from_ranges_phased(vec![(30, 60, 1, Phase::Even)], 0);
let stream = DiscStream::new(
Box::new(ZeroReader { capacity: 300 }),
synthetic_title(300),
aacs,
8,
ContentFormat::BdTs,
false,
None,
)
.unwrap()
.with_key_map(std::sync::Arc::new(map));
let total: u32 = stream.extents.iter().map(|e| e.sector_count).sum();
assert_eq!(
total,
300 - 5 * 3,
"exactly the 5 alternate-phase units (15 sectors) are dropped from the read walk"
);
assert!(
stream.extents.len() > 1,
"the forensic segment split the single extent into our-phase-only runs"
);
// The progress denominator tracks the reduced read set.
assert_eq!(stream.bytes_total_extents, total as u64 * 2048);
}
/// Recording `SectorSource`: logs every `(lba, count)` request and
/// returns `Err` whenever the requested range covers `bad_sector`.
/// Successful reads return zeroed sectors (which are NOT
@@ -1261,7 +1349,10 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
);
false,
None,
)
.unwrap();
// skip_errors=false: if the recovery read did NOT succeed, fill_extents
// would return Err — so reaching EOF cleanly proves recovery worked.
stream.skip_errors = false;
@@ -1374,7 +1465,10 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
);
false,
None,
)
.unwrap();
stream.skip_errors = true;
// Drive fill_extents across batches: the good leading sectors mux fine,
@@ -1434,7 +1528,10 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
);
false,
None,
)
.unwrap();
stream.skip_errors = true;
let res = stream.fill_extents();
@@ -1480,7 +1577,16 @@ mod tests {
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs);
let mut stream = DiscStream::new(
Box::new(reader),
title,
keys,
8,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
stream.skip_errors = true;
assert_eq!(
stream.unit_align, ALIGN as u16,
@@ -1576,7 +1682,10 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
);
false,
None,
)
.unwrap();
stream.skip_errors = true;
assert_eq!(stream.unit_align, 1, "None keys must leave unit_align=1");
@@ -1617,7 +1726,10 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
crate::disc::ContentFormat::BdTs,
false,
None,
)
.unwrap()
.with_halt(Halt::from_arc(arc.clone()));
assert!(!stream.is_halted());
arc.store(true, std::sync::atomic::Ordering::Relaxed);
@@ -1626,4 +1738,77 @@ mod tests {
"with_halt(Halt::from_arc) must observe Arc-side flips"
);
}
/// Every read fails CSS-locked (`05/6F/03`) — a scrambled DVD whose title key
/// can't be cracked. Drives `resolve_dvd_title_key` to `ScrambledUncracked`.
struct LockedReader;
impl crate::sector::SectorSource for LockedReader {
fn read_sectors(
&mut self,
lba: u32,
_count: u16,
_buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
Err(crate::error::Error::DiscRead {
sector: lba as u64,
status: Some(2),
sense: Some(crate::scsi::ScsiSense {
sense_key: 0x05,
asc: 0x6F,
ascq: 0x03,
}),
})
}
fn capacity_sectors(&self) -> u32 {
64
}
}
fn mpegps_title(sector_count: u32) -> DiscTitle {
let mut t = synthetic_title(sector_count);
t.content_format = ContentFormat::MpegPs;
t
}
/// PARITY with `build_iso_pipeline_dvd_none_keys_scrambled_hard_fails`: the
/// live-drive single-pass constructor must ALSO hard-fail (not build a
/// scrambled-passthrough stream) for a `None`-keyed scrambled MPEG-PS DVD —
/// the exact 328k-decode-error corruption path, on the single-pass side.
#[test]
fn disc_stream_new_dvd_none_scrambled_hard_fails() {
let res = DiscStream::new(
Box::new(LockedReader),
mpegps_title(8),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::MpegPs,
false,
None,
);
assert!(
res.is_err(),
"single-pass DiscStream must hard-fail on a scrambled, keyless CSS DVD"
);
}
/// `raw` must bypass the CSS crack at the DiscStream boundary too: the same
/// scrambled-uncrackable input that hard-fails above must CONSTRUCT in raw
/// mode (ciphertext passthrough), never hard-fail.
#[test]
fn disc_stream_new_raw_bypasses_css_crack() {
let res = DiscStream::new(
Box::new(LockedReader),
mpegps_title(8),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::MpegPs,
true, // raw
None,
);
assert!(
res.is_ok(),
"raw single-pass must construct without cracking, even on scrambled-uncrackable input"
);
}
}
+7 -19
View File
@@ -157,31 +157,19 @@ impl FviSink {
///
/// `source_path` / `source_title` record where the index was built from
/// (the input URL path + the 0-based title index); they are carried into the
/// header's `source` object. The medium defaults to `file` — callers with a
/// known medium / playlist / volume use [`FviSink::create_with_source`].
/// header's `source` object. The remaining provenance (medium, playlist,
/// volume) takes its `SourceInfo` defaults — no caller needs to override them.
pub fn create(
path: &Path,
title: &DiscTitle,
source_path: String,
source_title: usize,
) -> io::Result<Self> {
Self::create_with_source(
path,
title,
SourceInfo {
path: source_path,
title: source_title,
..SourceInfo::default()
},
)
}
/// Create the sink with a fully-specified [`SourceInfo`] provenance root.
pub fn create_with_source(
path: &Path,
title: &DiscTitle,
source: SourceInfo,
) -> io::Result<Self> {
let source = SourceInfo {
path: source_path,
title: source_title,
..SourceInfo::default()
};
let file = File::create(path)?;
let video_track = title
+357
View File
@@ -0,0 +1,357 @@
//! `chapters://` and `json://` metadata sinks.
//!
//! Both ignore the PES stream entirely: everything they emit is already known
//! from the [`DiscTitle`] at construction, so each writes its whole file at
//! `create()` and treats every `write()` frame as a no-op. They are wired
//! through [`super::resolve::output`] like the other write-only sinks; the
//! ISO/disc scan that builds the title is all they need.
use crate::disc::{Chapter, DiscTitle, Stream as DiscStream};
use crate::pes::{PesFrame, Stream};
use std::fs::File;
use std::io::{self, Write};
use std::path::Path;
// ── chapters:// ──────────────────────────────────────────────────────────────
/// `HH:MM:SS.mmm` for a WebVTT cue timestamp.
fn vtt_time(secs: f64) -> String {
let total_ms = (secs.max(0.0) * 1000.0).round() as u64;
let ms = total_ms % 1000;
let total_s = total_ms / 1000;
format!(
"{:02}:{:02}:{:02}.{:03}",
total_s / 3600,
(total_s / 60) % 60,
total_s % 60,
ms
)
}
/// WebVTT chapter cues (`.vtt`). Each chapter spans until the next one starts
/// (the last runs to its own start — length is unknown without the title tail).
fn chapters_vtt(chapters: &[Chapter]) -> String {
let mut s = String::from("WEBVTT\n\n");
for (i, c) in chapters.iter().enumerate() {
let start = c.time_secs.max(0.0);
let end = chapters
.get(i + 1)
.map(|n| n.time_secs.max(0.0))
.unwrap_or(start);
s.push_str(&format!(
"{}\n{} --> {}\nChapter {}\n\n",
i + 1,
vtt_time(start),
vtt_time(end),
c.name
));
}
s
}
/// Chapter content in the format the output extension selects: `.txt`/`.ogm`
/// (OGM simple), `.vtt` (WebVTT), else Matroska XML (`.xml` / default).
pub(crate) fn chapters_content(chapters: &[Chapter], ext: Option<&str>) -> String {
match ext.map(|e| e.to_ascii_lowercase()).as_deref() {
Some("txt") | Some("ogm") => super::demux_sink::chapters_ogm(chapters),
Some("vtt") => chapters_vtt(chapters),
_ => super::demux_sink::chapters_xml(chapters),
}
}
/// `chapters://` sink: writes the title's chapter markers at construction; the
/// PES stream is ignored.
pub struct ChaptersSink {
title: DiscTitle,
}
impl ChaptersSink {
pub fn create(path: &Path, title: &DiscTitle) -> io::Result<Self> {
let ext = path.extension().and_then(|e| e.to_str());
let content = chapters_content(&title.chapters, ext);
File::create(path)?.write_all(content.as_bytes())?;
Ok(Self {
title: title.clone(),
})
}
}
impl Stream for ChaptersSink {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
Err(crate::error::Error::StreamWriteOnly.into())
}
fn write(&mut self, _frame: &PesFrame) -> io::Result<()> {
Ok(()) // whole file written at create()
}
fn finish(&mut self) -> io::Result<()> {
Ok(())
}
fn info(&self) -> &DiscTitle {
&self.title
}
}
// ── json:// ──────────────────────────────────────────────────────────────────
/// Serialization id for an audio stream's editorial purpose.
fn purpose_id(p: crate::labels::LabelPurpose) -> &'static str {
use crate::labels::LabelPurpose::*;
match p {
Normal => "normal",
Commentary => "commentary",
Descriptive => "descriptive",
Score => "score",
Ime => "ime",
}
}
/// Serialization id for a subtitle stream's qualifier.
fn qualifier_id(q: crate::labels::LabelQualifier) -> &'static str {
use crate::labels::LabelQualifier::*;
match q {
None => "none",
Sdh => "sdh",
DescriptiveService => "descriptive_service",
Forced => "forced",
}
}
/// One stream as JSON — every field the scan resolved, nothing dropped. This is
/// the complete per-stream model (`disc::Stream`), not a summary: consumers get
/// resolution/HDR/aspect for video, channels/sample-rate/purpose for audio, and
/// the qualifier for subtitles, all in machine-readable form.
fn stream_json(s: &DiscStream) -> serde_json::Value {
use super::demux_sink::codec_label;
use serde_json::json;
match s {
DiscStream::Video(v) => {
let (w, h) = v.resolution.pixels();
let (fps_num, fps_den) = v.frame_rate.as_fraction();
let mut o = json!({
"kind": "video",
"codec": codec_label(v.codec),
"pid": v.pid,
"resolution": v.resolution.to_string(),
"width": w,
"height": h,
"interlaced": v.resolution.is_interlaced(),
"frame_rate": v.frame_rate.to_string(),
"frame_rate_num": fps_num,
"frame_rate_den": fps_den,
"hdr": v.hdr.id(),
"color_space": v.color_space.id(),
"secondary": v.secondary,
"mvc_dependent": v.is_mvc_dependent(),
});
if let Some((num, den)) = v.display_aspect {
o["display_aspect"] = json!(format!("{num}:{den}"));
}
if let Some(c) = v.measured_cicp {
o["measured_cicp"] = json!({
"matrix": c.matrix,
"transfer": c.transfer,
"primaries": c.primaries,
"range": c.range,
});
}
if !v.label.is_empty() {
o["label"] = json!(v.label);
}
o
}
DiscStream::Audio(a) => {
let mut o = json!({
"kind": "audio",
"codec": codec_label(a.codec),
"pid": a.pid,
"language": a.language,
"channels": a.channels.to_string(),
"channel_count": a.channels.count(),
"sample_rate": a.sample_rate.to_string(),
"sample_rate_hz": a.sample_rate.hz(),
"secondary": a.secondary,
"purpose": purpose_id(a.purpose),
});
if !a.label.is_empty() {
o["label"] = json!(a.label);
}
o
}
DiscStream::Subtitle(t) => json!({
"kind": "subtitle",
"codec": codec_label(t.codec),
"pid": t.pid,
"language": t.language,
"forced": t.forced,
"qualifier": qualifier_id(t.qualifier),
}),
}
}
/// The `json://` document for one title: identity, duration/size, its clips,
/// its complete stream models, and its chapter points. A stable, machine-
/// readable view of one title — the same information the scan resolved, no loss.
pub(crate) fn title_json(title: &DiscTitle) -> serde_json::Value {
use serde_json::json;
let streams: Vec<_> = title.streams.iter().map(stream_json).collect();
let clips: Vec<_> = title
.clips
.iter()
.map(|c| {
json!({
"clip_id": c.clip_id,
"duration_secs": c.duration_secs,
"source_packets": c.source_packets,
})
})
.collect();
let chapters: Vec<_> = title
.chapters
.iter()
.enumerate()
.map(|(i, c)| json!({ "n": i + 1, "start_secs": c.time_secs, "name": c.name }))
.collect();
json!({
"playlist": title.playlist,
"playlist_id": title.playlist_id,
"duration_secs": title.duration_secs,
"size_bytes": title.size_bytes,
"format": format!("{:?}", title.content_format),
"clips": clips,
"streams": streams,
"chapters": chapters,
})
}
/// `json://` sink: writes the title's structured metadata at construction; the
/// PES stream is ignored.
pub struct JsonSink {
title: DiscTitle,
}
impl JsonSink {
pub fn create(path: &Path, title: &DiscTitle) -> io::Result<Self> {
let doc =
serde_json::to_string_pretty(&title_json(title)).unwrap_or_else(|_| "{}".to_string());
let mut f = File::create(path)?;
f.write_all(doc.as_bytes())?;
f.write_all(b"\n")?;
Ok(Self {
title: title.clone(),
})
}
}
impl Stream for JsonSink {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
Err(crate::error::Error::StreamWriteOnly.into())
}
fn write(&mut self, _frame: &PesFrame) -> io::Result<()> {
Ok(())
}
fn finish(&mut self) -> io::Result<()> {
Ok(())
}
fn info(&self) -> &DiscTitle {
&self.title
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::disc::Chapter;
fn chaps() -> Vec<Chapter> {
vec![
Chapter {
time_secs: 0.0,
name: "1".into(),
},
Chapter {
time_secs: 62.5,
name: "2".into(),
},
]
}
#[test]
fn chapters_format_selected_by_extension() {
let xml = chapters_content(&chaps(), Some("xml"));
assert!(xml.contains("<Chapters>"), "xml chosen for .xml");
let ogm = chapters_content(&chaps(), Some("txt"));
assert!(ogm.contains("CHAPTER01="), "ogm chosen for .txt");
let vtt = chapters_content(&chaps(), Some("vtt"));
assert!(
vtt.starts_with("WEBVTT") && vtt.contains("00:01:02.500"),
"vtt chosen for .vtt, with cue timing"
);
// Unknown / missing extension defaults to XML.
assert!(chapters_content(&chaps(), None).contains("<Chapters>"));
}
#[test]
fn title_json_carries_streams_and_chapters() {
use crate::disc::{AudioChannels, AudioStream, Codec, DiscTitle};
use crate::disc::{LabelPurpose, SampleRate, Stream as DiscStream};
let mut t = DiscTitle::empty();
t.playlist = "MAIN".into();
t.chapters = chaps();
t.streams = vec![DiscStream::Audio(AudioStream {
pid: 0x1100,
codec: Codec::TrueHd,
channels: AudioChannels::Stereo,
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
})];
let v = title_json(&t);
assert_eq!(v["playlist"], "MAIN");
let a = &v["streams"][0];
assert_eq!(a["kind"], "audio");
assert_eq!(a["codec"], "TrueHD");
assert_eq!(a["language"], "eng");
// Completeness: audio carries channels + sample rate + purpose, not just codec.
assert_eq!(a["channels"], "stereo");
assert_eq!(a["channel_count"], 2);
assert_eq!(a["sample_rate"], "48kHz");
assert_eq!(a["sample_rate_hz"], 48000.0);
assert_eq!(a["purpose"], "normal");
assert_eq!(v["chapters"][1]["n"], 2);
assert_eq!(v["chapters"][1]["start_secs"], 62.5);
assert_eq!(v["chapters"][1]["name"], "2");
}
#[test]
fn video_json_carries_resolution_and_hdr() {
use crate::disc::Codec;
use crate::disc::{
ColorSpace, DiscTitle, FrameRate, HdrFormat, Resolution, Stream as DiscStream,
VideoStream,
};
let mut t = DiscTitle::empty();
t.streams = vec![DiscStream::Video(VideoStream {
pid: 0x1011,
codec: Codec::Hevc,
resolution: Resolution::R2160p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Hdr10,
color_space: ColorSpace::Bt2020,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
})];
let vid = &title_json(&t)["streams"][0];
assert_eq!(vid["kind"], "video");
assert_eq!(vid["resolution"], "2160p");
assert_eq!(vid["width"], 3840);
assert_eq!(vid["height"], 2160);
assert_eq!(vid["frame_rate"], "23.976");
assert_eq!(vid["frame_rate_num"], 24000);
assert_eq!(vid["hdr"], "hdr10");
assert_eq!(vid["color_space"], "bt2020");
}
}
+179 -5
View File
@@ -672,6 +672,14 @@ pub struct MkvMuxer<W: Write + Seek> {
/// (to patch in place) and the IFO-claimed count (to warn on disagreement);
/// `corrected` flips once patched so we only act on the first frame.
ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup>,
/// Deferred PGS forced-subtitle detection. A PGS subtitle track reserves a
/// 1-byte `FlagForced` value up-front; as its display sets are written, the
/// track is judged forced iff it displayed at least one subtitle and EVERY
/// display set carried the HDMV `forced_on_flag` (a dedicated forced/narrative
/// track). At `finish()` the reserved byte is promoted to 1 for such tracks —
/// so forced subs are flagged even on discs without vendor label metadata.
/// Only ever promotes (0→1); a scan/vendor forced flag is never demoted.
pgs_forced_fixups: std::collections::HashMap<usize, PgsForcedFixup>,
/// `--log-level 3` opening-frame capture: the first ~100 coded frames per
/// track are written (raw) to a `<output>.opening.bin` side file with a
/// per-frame summary logged, so an opening-GOP / menu / mid-GOP-open issue is
@@ -692,6 +700,15 @@ struct Ac3ChannelFixup {
corrected: bool,
}
/// Deferred PGS forced-subtitle detection state for one PGS subtitle track.
struct PgsForcedFixup {
/// Absolute file offset of the 1-byte `FlagForced` value in the Tracks element.
value_offset: u64,
/// Shared forced-narrative classifier fed the track's display sets. The same
/// type drives the `info`-time forced probe, so both classify identically.
tracker: super::codec::pgs::ForcedTracker,
}
/// TimestampScale: nanoseconds per Matroska timestamp tick. 0.1 ms (100_000 ns).
///
/// The classic 1 ms scale truncates two distinct cadences onto the same tick:
@@ -917,6 +934,8 @@ 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();
let mut pgs_forced_fixups: std::collections::HashMap<usize, PgsForcedFixup> =
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.
@@ -948,7 +967,24 @@ impl<W: Write + Seek> MkvMuxer<W> {
if !track.is_default {
ebml::write_uint(&mut writer, ebml::FLAG_DEFAULT, 0)?;
}
if track.is_forced {
if track.track_type == ebml::TRACK_TYPE_SUBTITLE && track.codec_id == ebml::CODEC_PGS {
// Reserve a 1-byte FlagForced (initial value = the scan/vendor
// flag) and record its offset, so PGS content can promote it to 1
// at finish() if the track proves to be forced narrative. Written
// explicitly (ID + size + value) so the value is a single,
// in-place-patchable byte — same idiom as the Channels fixup.
ebml::write_id(&mut writer, ebml::FLAG_FORCED)?;
ebml::write_size(&mut writer, 1)?;
let value_offset = writer.stream_position()?;
writer.write_all(&[track.is_forced as u8])?;
pgs_forced_fixups.insert(
i,
PgsForcedFixup {
value_offset,
tracker: super::codec::pgs::ForcedTracker::new(),
},
);
} else if track.is_forced {
ebml::write_uint(&mut writer, ebml::FLAG_FORCED, 1)?;
}
@@ -1203,6 +1239,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
max_block_ticks: 0,
last_video_keyframe_ticks: None,
ac3_channel_fixups,
pgs_forced_fixups,
opening_capture: None,
})
}
@@ -1514,6 +1551,14 @@ impl<W: Write + Seek> MkvMuxer<W> {
}
}
// Accumulate PGS forced-subtitle state: a display set marks the track as
// having shown a subtitle, and clears `all_forced` the moment a
// non-forced set appears. `finish()` promotes FlagForced only for a track
// that displayed subtitles and had every one forced.
if let Some(fixup) = self.pgs_forced_fixups.get_mut(&track_idx) {
fixup.tracker.observe(data);
}
Ok(())
}
@@ -1546,6 +1591,26 @@ impl<W: Write + Seek> MkvMuxer<W> {
// Close final cluster
self.end_cluster()?;
// Promote FlagForced for PGS subtitle tracks that proved to be forced
// narrative (displayed subtitles, every one forced). In-place single-byte
// rewrite of the reserved value, then restore the append position for the
// Cues that follow. Only promotes (0→1); a track already forced from the
// scan/vendor flag stays forced.
let forced_offsets: Vec<u64> = self
.pgs_forced_fixups
.values()
.filter(|f| f.tracker.is_forced())
.map(|f| f.value_offset)
.collect();
if !forced_offsets.is_empty() {
let here = self.writer.stream_position()?;
for off in forced_offsets {
self.writer.seek(std::io::SeekFrom::Start(off))?;
self.writer.write_all(&[1u8])?;
}
self.writer.seek(std::io::SeekFrom::Start(here))?;
}
// Write Cues
let cues_start = self.writer.stream_position()?;
let cues_offset = cues_start - self.segment_start;
@@ -2600,13 +2665,122 @@ mod tests {
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// FlagForced should NOT be written for non-forced tracks
assert!(
find_id(&data, ebml::FLAG_FORCED).is_none(),
"FlagForced element should not be present for non-forced subtitle"
// A PGS subtitle track now RESERVES a FlagForced element (so PGS content
// can promote it later), but for a non-forced track its value is 0. The
// value byte sits after the 2-byte ID (0x55AA) + 1-byte size.
let pos =
find_id(&data, ebml::FLAG_FORCED).expect("PGS subtitle reserves a FlagForced element");
assert_eq!(
data[pos + 3],
0,
"reserved FlagForced value must be 0 for a non-forced subtitle"
);
}
/// A PGS display-set PES block (PCS) with one composition object, whose
/// forced_on_flag is set per `forced`.
#[cfg(test)]
fn pgs_display_set(forced: bool) -> Vec<u8> {
let mut pcs = vec![0u8; 18];
pcs[0] = 0x16; // segment type PCS
pcs[13] = 1; // number_of_composition_objects
pcs[17] = if forced { 0x40 } else { 0x00 }; // first object flags
pcs
}
#[test]
fn mkv_pgs_forced_promoted_when_all_display_sets_forced() {
// End-to-end: a PGS subtitle track whose every display set is forced is
// promoted to FlagForced=1 at finish(), even though the scan flag was
// false (no vendor metadata).
use crate::disc::SubtitleStream;
use std::sync::{Arc, Mutex};
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let sub = MkvTrack::subtitle(&SubtitleStream {
pid: 0x1200,
codec: Codec::Pgs,
language: "eng".into(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
});
let tracks = [make_video_track(), sub];
let mut muxer =
MkvMuxer::new(SharedWriter(shared.clone()), &tracks, None, 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0u8; 16], Some(40_000_000), None)
.unwrap(); // video keyframe opens a cluster
muxer
.write_frame(
1,
1_000_000,
true,
&pgs_display_set(true),
Some(2_000_000),
None,
)
.unwrap();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
let pos = find_id(&data, ebml::FLAG_FORCED).expect("reserved FlagForced");
assert_eq!(
data[pos + 3],
1,
"all-forced PGS track promoted to FlagForced=1"
);
}
#[test]
fn mkv_pgs_not_promoted_when_a_display_set_is_not_forced() {
// A track with ANY non-forced display set is a full track, not forced
// narrative — FlagForced stays 0.
use crate::disc::SubtitleStream;
use std::sync::{Arc, Mutex};
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let sub = MkvTrack::subtitle(&SubtitleStream {
pid: 0x1200,
codec: Codec::Pgs,
language: "eng".into(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
});
let tracks = [make_video_track(), sub];
let mut muxer =
MkvMuxer::new(SharedWriter(shared.clone()), &tracks, None, 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0u8; 16], Some(40_000_000), None)
.unwrap();
muxer
.write_frame(
1,
1_000_000,
true,
&pgs_display_set(true),
Some(2_000_000),
None,
)
.unwrap();
muxer
.write_frame(
1,
3_000_000,
true,
&pgs_display_set(false),
Some(4_000_000),
None,
)
.unwrap(); // a normal (non-forced) subtitle → not a forced track
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
let pos = find_id(&data, ebml::FLAG_FORCED).expect("reserved FlagForced");
assert_eq!(data[pos + 3], 0, "mixed PGS track stays FlagForced=0");
}
// ============================================================
// Seekability tests: SeekHead, keyframe-aligned clusters, Cues
// ============================================================
+9 -12
View File
@@ -323,17 +323,14 @@ fn extract_mvc_params(data: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
}
impl MkvStream {
/// Create for writing PES frames → MKV container.
/// Codec privates come from title.codec_privates (populated by input stream).
pub fn create(writer: Box<dyn WriteSeek + Send>, title: &DiscTitle) -> io::Result<Self> {
Self::create_at(writer, title, None)
}
/// As [`create`](Self::create), but `output_path` (when known) enables the
/// `--log-level 3` opening-frame capture to `<output>.opening.bin`. A `None`
/// path (e.g. an in-memory / stdio sink) silently skips the side-file
/// capture; the per-track TrackEntry dump still fires.
pub fn create_at(
/// Create for writing PES frames → MKV container. Codec privates come from
/// `title.codec_privates` (populated by the input stream).
///
/// `output_path` (when known) enables the `--log-level 3` opening-frame
/// capture to `<output>.opening.bin`; `None` (e.g. an in-memory / stdio sink)
/// silently skips the side-file capture — the per-track TrackEntry dump still
/// fires either way.
pub fn create(
writer: Box<dyn WriteSeek + Send>,
title: &DiscTitle,
output_path: Option<&std::path::Path>,
@@ -1409,7 +1406,7 @@ mod tests {
streams: vec![Stream::Video(dep)],
..DiscTitle::empty()
};
let s = MkvStream::create(Box::new(Cursor::new(Vec::new())), &title)
let s = MkvStream::create(Box::new(Cursor::new(Vec::new())), &title, None)
.expect("create must succeed, not panic");
assert!(
s.mvc.is_none(),
+13 -2
View File
@@ -62,6 +62,7 @@ pub(crate) mod m2ts;
/// to round-trip codec_privates that don't fit inside the underlying format).
/// Exposed for integration tests that exercise the wire format directly.
pub mod meta;
pub(crate) mod meta_sink;
// ── Sequential-sink muxers ──────────────────────────────────────────────────
//
@@ -89,6 +90,7 @@ pub(crate) mod hevc;
pub(crate) mod m2ts_mux;
pub(crate) mod mkv;
pub(crate) mod mkvstream;
pub(crate) mod mp4;
pub(crate) mod network;
pub(crate) mod null;
pub(crate) mod ps;
@@ -108,15 +110,18 @@ pub(crate) mod tsmux;
#[allow(dead_code)]
pub(crate) mod videomap;
pub use demux_sink::{ChaptersFmt, DelayMode, DemuxOptions, DemuxSink, Naming};
// `demux://` and `fvi://` sinks are constructed internally by `output()` via the
// direct `super::demux_sink::` / `super::fvi_sink::` paths — no re-export needed,
// and no consumer names these types, so they are not public API.
pub use disc::DiscStream;
pub use fvi_sink::FviSink;
pub use m2ts::M2tsStream;
pub use mkvstream::MkvStream;
pub use mp4::{Mp4FitReport, Mp4SkipReason, fit_report as mp4_fit_report};
pub use network::NetworkStream;
pub use null::NullStream;
pub use pipelined_stream::PipelinedPesStream;
pub use resolve::build_iso_pipeline;
pub use resolve::resolve_mux_key_map;
pub use resolve::{InputOptions, StreamUrl, input, output, parse_url};
pub use stdio::StdioStream;
@@ -151,11 +156,17 @@ mod tests {
assert_eq!(parse_url("disc://").scheme(), "disc");
assert_eq!(parse_url("m2ts://f").scheme(), "m2ts");
assert_eq!(parse_url("mkv://f").scheme(), "mkv");
assert_eq!(parse_url("mp4://f").scheme(), "mp4");
assert_eq!(parse_url("network://h:1").scheme(), "network");
assert_eq!(parse_url("stdio://").scheme(), "stdio");
assert_eq!(parse_url("iso://f").scheme(), "iso");
assert_eq!(parse_url("null://").scheme(), "null");
assert_eq!(parse_url("demux://out/").scheme(), "demux");
assert_eq!(parse_url("video://out/").scheme(), "video");
assert_eq!(parse_url("audio://out/").scheme(), "audio");
assert_eq!(parse_url("sub://out/").scheme(), "sub");
assert_eq!(parse_url("chapters://c.xml").scheme(), "chapters");
assert_eq!(parse_url("json://t.json").scheme(), "json");
assert_eq!(parse_url("bogus://x").scheme(), "unknown");
}
+526
View File
@@ -0,0 +1,526 @@
//! MP4 audio sample entries and codec-config boxes for the `mp4://` muxer.
//!
//! Covers the codecs that map cleanly into MP4 and play widely: **AC-3**
//! (`ac-3` + `dac3`), **E-AC-3 / Dolby Digital Plus** (`ec-3` + `dec3`, incl.
//! Atmos-in-DD+ JOC), and **DTS / DTS-HD** (`dtsc`/`dtsh` + `ddts`, describing
//! the core with whole access units passed through so an HD decoder finds the
//! extension). Config boxes are derived from the first audio frame's bitstream
//! (ISO/IEC 14496-12 amendments; ETSI TS 102 366 / 102 114). Codecs with no
//! clean MP4 mapping (TrueHD, LPCM, bitmap subtitles) are excluded by the fit
//! oracle in the sink.
use super::boxes::bx;
use crate::disc::Codec;
/// AC-3 / E-AC-3 sample rates indexed by `fscod` (byte-4 bits 7-6).
const FSCOD_RATES: [u32; 3] = [48_000, 44_100, 32_000];
/// E-AC-3 reduced rates indexed by `fscod2` (byte-4 bits 5-4) when `fscod == 3`.
const EAC3_REDUCED_RATES: [u32; 4] = [24_000, 22_050, 16_000, 48_000];
/// Base channel count per `acmod` (A/52 Table 5.8), before the LFE.
const ACMOD_CHANNELS: [u8; 8] = [2, 1, 2, 3, 3, 4, 4, 5];
/// A big-endian MSB-first bit reader over a byte slice.
struct BitReader<'a> {
data: &'a [u8],
bit: usize,
}
impl<'a> BitReader<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, bit: 0 }
}
fn skip(&mut self, n: usize) {
self.bit += n;
}
/// Read `n` bits (n ≤ 32). Returns 0 past end of data (callers pre-check len).
fn read(&mut self, n: usize) -> u32 {
let mut v = 0u32;
for _ in 0..n {
let byte = self.data.get(self.bit / 8).copied().unwrap_or(0);
let shift = 7 - (self.bit % 8);
v = (v << 1) | ((byte >> shift) & 1) as u32;
self.bit += 1;
}
v
}
}
/// Decoded (E-)AC-3 stream parameters needed for the `dac3`/`dec3` config box
/// and the audio sample entry.
pub(super) struct DolbyConfig {
pub fscod: u8,
pub bsid: u8,
pub bsmod: u8,
pub acmod: u8,
pub lfeon: bool,
/// AC-3 only: `bit_rate_code` (= `frmsizecod >> 1`). Unused for E-AC-3.
pub bit_rate_code: u8,
/// E-AC-3 only: nominal data rate in kbps (for `dec3`). 0 for AC-3.
pub data_rate_kbps: u16,
pub sample_rate: u32,
pub channels: u16,
}
impl DolbyConfig {
fn channel_count(acmod: u8, lfeon: bool) -> u16 {
ACMOD_CHANNELS[acmod as usize] as u16 + lfeon as u16
}
}
/// Parse the first (E-)AC-3 frame starting at the 0x0B77 syncword. Returns
/// `None` if the frame is too short or the syncword is absent.
pub(super) fn parse_dolby(frame: &[u8]) -> Option<DolbyConfig> {
let start =
(0..frame.len().saturating_sub(1)).find(|&i| frame[i] == 0x0B && frame[i + 1] == 0x77)?;
let f = &frame[start..];
if f.len() < 6 {
return None;
}
// bsid lives in byte 5 bits 7-3 for both AC-3 and E-AC-3.
let bsid = (f[5] >> 3) & 0x1F;
if bsid >= 11 {
parse_eac3(f)
} else {
parse_ac3(f)
}
}
/// Legacy AC-3 (A/52 §5.3.2): syncword | crc(16) | fscod(2) frmsizecod(6) |
/// bsid(5) bsmod(3) | acmod(3) …optional… lfeon.
fn parse_ac3(f: &[u8]) -> Option<DolbyConfig> {
if f.len() < 8 {
return None;
}
let fscod = (f[4] >> 6) & 0x03;
let frmsizecod = f[4] & 0x3F;
let bsid = (f[5] >> 3) & 0x1F;
let bsmod = f[5] & 0x07;
// acmod + trailing optional 2-bit fields, then lfeon (byte 6 onward).
let mut r = BitReader::new(f);
r.bit = 6 * 8;
let acmod = r.read(3) as u8;
if (acmod & 0x1) != 0 && acmod != 0x1 {
r.skip(2); // cmixlev
}
if (acmod & 0x4) != 0 {
r.skip(2); // surmixlev
}
if acmod == 0x2 {
r.skip(2); // dsurmod
}
let lfeon = r.read(1) == 1;
Some(DolbyConfig {
fscod,
bsid,
bsmod,
acmod,
lfeon,
bit_rate_code: frmsizecod >> 1,
data_rate_kbps: 0,
sample_rate: FSCOD_RATES.get(fscod as usize).copied().unwrap_or(48_000),
channels: DolbyConfig::channel_count(acmod, lfeon),
})
}
/// E-AC-3 (A/52 Annex E BSI): syncword | strmtyp(2) substreamid(3) frmsiz(11) |
/// fscod(2) numblkscod(2) acmod(3) lfeon(1) | bsid(5) …
fn parse_eac3(f: &[u8]) -> Option<DolbyConfig> {
if f.len() < 6 {
return None;
}
let frmsiz = (((f[2] & 0x07) as u32) << 8) | f[3] as u32; // words minus one
let fscod = (f[4] >> 6) & 0x03;
let numblkscod = (f[4] >> 4) & 0x03;
let acmod = (f[4] >> 1) & 0x07;
let lfeon = (f[4] & 0x01) == 1;
let bsid = (f[5] >> 3) & 0x1F;
let (sample_rate, blocks) = if fscod == 0x03 {
let fscod2 = (f[4] >> 4) & 0x03; // shares bits with numblkscod when fscod==3
(EAC3_REDUCED_RATES[fscod2 as usize], 6u32)
} else {
let blocks = [1u32, 2, 3, 6][numblkscod as usize];
(FSCOD_RATES[fscod as usize], blocks)
};
// Nominal data rate (kbps): frame is (frmsiz+1) 16-bit words per (blocks·256)
// samples at sample_rate. rate = bytes·8·sr / samples / 1000.
let frame_bytes = (frmsiz as u64 + 1) * 2;
let samples = blocks as u64 * 256;
let data_rate_kbps = if samples > 0 {
((frame_bytes * 8 * sample_rate as u64) / samples / 1000) as u16
} else {
0
};
Some(DolbyConfig {
fscod,
bsid,
bsmod: 0, // not in the E-AC-3 main header; dec3 default
acmod,
lfeon,
bit_rate_code: 0,
data_rate_kbps,
sample_rate,
channels: DolbyConfig::channel_count(acmod, lfeon),
})
}
/// The `dac3` config box (ETSI TS 102 366 Annex F.4): 24 bits —
/// fscod(2) bsid(5) bsmod(3) acmod(3) lfeon(1) bit_rate_code(5) reserved(5).
pub(super) fn dac3_box(c: &DolbyConfig) -> Vec<u8> {
let mut v: u32 = 0;
let mut push = |val: u32, bits: u32| v = (v << bits) | (val & ((1 << bits) - 1));
push(c.fscod as u32, 2);
push(c.bsid as u32, 5);
push(c.bsmod as u32, 3);
push(c.acmod as u32, 3);
push(c.lfeon as u32, 1);
push(c.bit_rate_code as u32, 5);
push(0, 5); // reserved
// 24 bits → the top 3 bytes of the big-endian u32.
let b = v.to_be_bytes();
bx(b"dac3", &[b[1], b[2], b[3]])
}
/// The `dec3` config box (ETSI TS 102 366 Annex G.3) for a single independent
/// substream, no dependent substreams: data_rate(13) num_ind_sub(3) then
/// fscod(2) bsid(5) reserved(1) asvc(1) bsmod(3) acmod(3) lfeon(1) reserved(3)
/// num_dep_sub(4) reserved(1).
pub(super) fn dec3_box(c: &DolbyConfig) -> Vec<u8> {
let mut v: u64 = 0;
let mut push = |val: u64, bits: u32| v = (v << bits) | (val & ((1u64 << bits) - 1));
push(c.data_rate_kbps as u64, 13);
push(0, 3); // num_ind_sub - 1 = 0 (one substream)
push(c.fscod as u64, 2);
push(c.bsid as u64, 5);
push(0, 1); // reserved
push(0, 1); // asvc
push(c.bsmod as u64, 3);
push(c.acmod as u64, 3);
push(c.lfeon as u64, 1);
push(0, 3); // reserved
push(0, 4); // num_dep_sub = 0
push(0, 1); // reserved (chan_loc absent when num_dep_sub == 0)
// 40 bits → the low 5 bytes of the big-endian u64.
let b = v.to_be_bytes();
bx(b"dec3", &[b[3], b[4], b[5], b[6], b[7]])
}
/// Build an audio sample entry (`ac-3` / `ec-3`) with the given config box.
/// `AudioSampleEntry` per ISO/IEC 14496-12 §12.2.3.
pub(super) fn audio_sample_entry(
fourcc: &[u8; 4],
channels: u16,
sample_rate: u32,
config: &[u8],
) -> Vec<u8> {
let mut e = Vec::new();
e.extend_from_slice(&[0u8; 6]); // reserved
e.extend_from_slice(&1u16.to_be_bytes()); // data_reference_index
e.extend_from_slice(&[0u8; 8]); // reserved (version 0)
e.extend_from_slice(&channels.to_be_bytes());
e.extend_from_slice(&16u16.to_be_bytes()); // samplesize
e.extend_from_slice(&0u16.to_be_bytes()); // pre_defined
e.extend_from_slice(&0u16.to_be_bytes()); // reserved
// samplerate is 16.16 fixed point; the integer rate in the high 16 bits.
e.extend_from_slice(&(sample_rate << 16).to_be_bytes());
e.extend_from_slice(config);
bx(fourcc, &e)
}
// ── DTS (dtsc/dtsh + ddts) ───────────────────────────────────────────────────
/// DTS core `SFREQ` (4-bit) → sample rate (Hz). Reserved indices → 48 kHz.
const DTS_SFREQ: [u32; 16] = [
48_000, 8_000, 16_000, 32_000, 48_000, 48_000, 11_025, 22_050, 44_100, 48_000, 48_000, 12_000,
24_000, 48_000, 96_000, 192_000,
];
/// DTS core base channel count per `AMODE` (0..=9); higher AMODEs are rare on disc.
const DTS_AMODE_CH: [u8; 10] = [1, 2, 2, 2, 2, 3, 3, 4, 4, 5];
/// Decoded DTS core parameters needed for the `ddts` box.
struct DtsConfig {
sample_rate: u32,
channels: u16,
amode: u8,
lfe: bool,
core_size: u32,
/// Samples per frame ((NBLKS+1)·32).
frame_samples: u32,
/// Whether a DTS-HD extension substream follows the core.
has_extension: bool,
channel_layout: u16,
}
/// Parse the DTS core header (ETSI TS 102 114 §5.3.1), starting at the
/// 0x7FFE8001 big-endian core sync. Returns `None` if too short / no sync.
fn parse_dts(frame: &[u8]) -> Option<DtsConfig> {
let start = (0..frame.len().saturating_sub(3)).find(|&i| {
frame[i] == 0x7F && frame[i + 1] == 0xFE && frame[i + 2] == 0x80 && frame[i + 3] == 0x01
})?;
let f = &frame[start..];
if f.len() < 11 {
return None;
}
// Bit fields after the 32-bit sync (MSB-first):
// FTYPE1 SHORT5 CPF1 NBLKS7 FSIZE14 AMODE6 SFREQ4 RATE5 ...
let nblks = (((f[4] & 0x01) as u32) << 6) | ((f[5] >> 2) as u32 & 0x3F);
let fsize = (((f[5] & 0x03) as u32) << 12) | ((f[6] as u32) << 4) | ((f[7] >> 4) as u32 & 0x0F);
let amode = (((f[7] & 0x0F) << 2) | ((f[8] >> 6) & 0x03)) as usize;
let sfreq = ((f[8] >> 2) & 0x0F) as usize;
// LFF is 2 bits at bit offset 85 → byte10 bits 2-1.
let lff = (f[10] >> 1) & 0x03;
let lfe = lff == 1 || lff == 2;
let sample_rate = DTS_SFREQ[sfreq];
let base_ch = DTS_AMODE_CH.get(amode).copied().unwrap_or(6);
let channels = base_ch as u16 + lfe as u16;
let channel_layout = dts_channel_layout(amode, lfe);
// DTS-HD extension substream sync (0x64582025) after the core frame.
let ext_sync = [0x64, 0x58, 0x20, 0x25];
let has_extension = f
.windows(4)
.skip((fsize as usize + 1).min(f.len()).saturating_sub(4))
.any(|w| w == ext_sync)
|| f.windows(4).any(|w| w == ext_sync);
Some(DtsConfig {
sample_rate,
channels,
amode: amode as u8,
lfe,
core_size: fsize + 1,
frame_samples: (nblks + 1) * 32,
has_extension,
channel_layout,
})
}
/// `ddts` ChannelLayout (16-bit speaker mask) for the common core layouts.
/// bit0=C, bit1=L/R, bit2=Ls/Rs, bit3=LFE.
fn dts_channel_layout(amode: usize, lfe: bool) -> u16 {
let mut m = match amode {
0 => 0x0001, // C (mono)
1..=4 => 0x0002, // L/R
5 => 0x0003, // C + L/R
6 | 8 => 0x0006, // L/R + Ls/Rs (no centre)
_ => 0x0007, // C + L/R + surround (amode 7, 9, …)
};
if lfe {
m |= 0x0008;
}
m
}
/// The `ddts` config box (ETSI TS 102 114 Annex; DTS-in-ISO registration).
/// Describes the DTS core; whole access units (core + any extension) are passed
/// through as samples, so a DTS-HD-aware decoder still finds the extension.
fn ddts_box(c: &DtsConfig) -> Vec<u8> {
// avg/max bitrate: computed from the core frame size × frame rate (the core
// RATE field reads "open/variable" for lossless, so it's not usable directly).
let frames_per_sec = if c.frame_samples > 0 {
c.sample_rate as u64 / c.frame_samples as u64
} else {
0
};
let bitrate = (c.core_size as u64 * 8 * frames_per_sec) as u32;
let mut out = Vec::new();
out.extend_from_slice(&c.sample_rate.to_be_bytes()); // DTSSamplingFrequency
out.extend_from_slice(&bitrate.to_be_bytes()); // maxBitrate
out.extend_from_slice(&bitrate.to_be_bytes()); // avgBitrate
out.push(if c.has_extension { 24 } else { 16 }); // pcmSampleDepth
// Bit-packed tail (56 bits):
// FrameDuration2 StreamConstruction5 CoreLFEPresent1 CoreLayout6 CoreSize14
// StereoDownmix1 RepresentationType3 ChannelLayout16 MultiAssetFlag1
// LBRDurationMod1 ReservedBoxPresent1 Reserved5
let frame_duration = match c.frame_samples {
0..=512 => 0,
513..=1024 => 1,
1025..=2048 => 2,
_ => 3,
};
// StreamConstruction: 1 = DTS core present. Whole-AU passthrough means an
// HD decoder still parses the extension substreams from the stream itself.
let stream_construction = 1u128;
let mut v: u128 = 0;
let mut push = |val: u128, bits: u32| v = (v << bits) | (val & ((1u128 << bits) - 1));
push(frame_duration as u128, 2);
push(stream_construction, 5);
push(c.lfe as u128, 1);
push(c.amode as u128, 6);
push(c.core_size as u128, 14);
push(0, 1); // StereoDownmix
push(0, 3); // RepresentationType
push(c.channel_layout as u128, 16);
push(c.has_extension as u128, 1); // MultiAssetFlag
push(0, 1); // LBRDurationMod
push(0, 1); // ReservedBoxPresent
push(0, 5); // Reserved
// 56 bits → the low 7 bytes of the big-endian u128.
let b = v.to_be_bytes();
out.extend_from_slice(&b[9..16]);
bx(b"ddts", &out)
}
/// The MP4 fourcc + config box for an audio frame, or `None` if the codec has no
/// MP4 mapping here. Together with [`audio_fits`] this is the fit oracle for
/// audio: only what returns `Some` is muxable.
pub(super) fn dolby_sample_entry(codec: Codec, first_frame: &[u8]) -> Option<Vec<u8>> {
match codec {
Codec::Ac3 => {
let c = parse_dolby(first_frame)?;
Some(audio_sample_entry(
b"ac-3",
c.channels,
c.sample_rate,
&dac3_box(&c),
))
}
Codec::Ac3Plus => {
let c = parse_dolby(first_frame)?;
Some(audio_sample_entry(
b"ec-3",
c.channels,
c.sample_rate,
&dec3_box(&c),
))
}
Codec::Dts | Codec::DtsHdMa | Codec::DtsHdHr => {
let c = parse_dts(first_frame)?;
// `dtsc` = DTS core; `dtsh` = DTS-HD (core + extension substreams).
let fourcc: &[u8; 4] = if c.has_extension { b"dtsh" } else { b"dtsc" };
Some(audio_sample_entry(
fourcc,
c.channels,
c.sample_rate,
&ddts_box(&c),
))
}
_ => None,
}
}
/// Fit oracle for an audio codec: does `mp4://` currently carry it? Covers the
/// Dolby family (AC-3 / E-AC-3) and DTS (core / DTS-HD HRA / DTS-HD MA — the core
/// is described, whole access units pass through). TrueHD, LPCM, AAC are not yet
/// mapped and are skipped with a loud report (never silently dropped).
pub(super) fn audio_fits(codec: Codec) -> bool {
matches!(
codec,
Codec::Ac3 | Codec::Ac3Plus | Codec::Dts | Codec::DtsHdMa | Codec::DtsHdHr
)
}
#[cfg(test)]
mod tests {
use super::*;
/// A synthetic legacy AC-3 header: syncword, crc, fscod=0 (48k),
/// frmsizecod, bsid=8, bsmod=0, acmod=7 (3/2), lfeon=1 → 5.1.
fn ac3_frame_5_1() -> Vec<u8> {
let mut f = vec![0x0B, 0x77, 0x00, 0x00];
// byte4: fscod(2)=0 | frmsizecod(6)=0b010110 (22)
f.push(0b00_010110);
// byte5: bsid(5)=8 (0b01000) | bsmod(3)=0
f.push(0b01000_000);
// byte6: acmod(3)=7 (0b111) | cmixlev(2) | surmixlev(2) | lfeon(1)...
// acmod=7 has centre (needs cmixlev) and surround (needs surmixlev):
// 111 | 00 | 00 | 1(lfeon) = 0b111_00_00_1
f.push(0b111_00_00_1);
f.push(0x00);
f
}
#[test]
fn ac3_bsi_and_dac3() {
let c = parse_dolby(&ac3_frame_5_1()).expect("parsed");
assert!(c.bsid < 11, "legacy AC-3");
assert_eq!(c.fscod, 0);
assert_eq!(c.sample_rate, 48_000);
assert_eq!(c.bsid, 8);
assert_eq!(c.acmod, 7);
assert!(c.lfeon);
assert_eq!(c.channels, 6, "3/2 + LFE = 5.1");
assert_eq!(c.bit_rate_code, 22 >> 1);
let dac3 = dac3_box(&c);
// [size:4]["dac3"][3-byte payload] = 11 bytes.
assert_eq!(dac3.len(), 11);
assert_eq!(&dac3[4..8], b"dac3");
}
#[test]
fn eac3_bsi_and_dec3() {
// E-AC-3: syncword | strmtyp/substreamid/frmsiz | fscod/numblks/acmod/lfeon | bsid
let mut f = vec![0x0B, 0x77];
f.push(0x00); // strmtyp=0, substreamid=0, frmsiz high=0
f.push(0x3F); // frmsiz low = 63 → frame 128 bytes
// byte4: fscod(2)=0 | numblkscod(2)=3 (6 blocks) | acmod(3)=7 | lfeon(1)=1
f.push(0b00_11_111_1);
// byte5: bsid(5)=16 (E-AC-3) | dialnorm high(3)
f.push(0b10000_000);
f.push(0x00);
let c = parse_dolby(&f).expect("parsed");
assert!(c.bsid >= 11, "E-AC-3");
assert_eq!(c.bsid, 16);
assert_eq!(c.fscod, 0);
assert_eq!(c.sample_rate, 48_000);
assert_eq!(c.acmod, 7);
assert!(c.lfeon);
assert_eq!(c.channels, 6);
let dec3 = dec3_box(&c);
// [size:4]["dec3"][5-byte payload] = 13 bytes.
assert_eq!(dec3.len(), 13);
assert_eq!(&dec3[4..8], b"dec3");
}
#[test]
fn sample_entry_shape() {
let c = parse_dolby(&ac3_frame_5_1()).unwrap();
let e = audio_sample_entry(b"ac-3", c.channels, c.sample_rate, &dac3_box(&c));
assert_eq!(&e[4..8], b"ac-3");
// channelcount at entry-body offset 16 (after 6 reserved + 2 dri + 8 reserved).
let ch = u16::from_be_bytes([e[8 + 16], e[8 + 17]]);
assert_eq!(ch, 6);
}
#[test]
fn fit_oracle_covers_dolby_and_dts() {
assert!(audio_fits(Codec::Ac3));
assert!(audio_fits(Codec::Ac3Plus));
assert!(audio_fits(Codec::Dts));
assert!(audio_fits(Codec::DtsHdMa));
assert!(!audio_fits(Codec::TrueHd));
assert!(!audio_fits(Codec::Lpcm));
}
#[test]
fn dts_core_5_1_and_ddts() {
// Synthetic DTS core: SFREQ=13 (48k), AMODE=9 (5ch), LFF=1 (LFE) → 5.1.
let f = vec![
0x7F, 0xFE, 0x80, 0x01, 0x00, 0x3C, 0x05, 0xF2, 0x77, 0x00, 0x02, 0x00,
];
let c = parse_dts(&f).expect("dts core parsed");
assert_eq!(c.sample_rate, 48_000);
assert_eq!(c.amode, 9);
assert!(c.lfe);
assert_eq!(c.channels, 6, "5 core + LFE = 5.1");
assert_eq!(c.channel_layout, 0x000F, "C + L/R + Ls/Rs + LFE");
assert_eq!(c.core_size, 96);
assert_eq!(c.frame_samples, 512);
let ddts = ddts_box(&c);
assert_eq!(&ddts[4..8], b"ddts");
// DTSSamplingFrequency (first field) = 48000.
assert_eq!(
u32::from_be_bytes([ddts[8], ddts[9], ddts[10], ddts[11]]),
48_000
);
// Sample entry uses dtsc (no extension in this synthetic frame).
let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap();
assert_eq!(&e[4..8], b"dtsc");
}
}
+27
View File
@@ -0,0 +1,27 @@
//! ISO-BMFF box primitives: `[size:u32-BE][type:4][body]` (ISO/IEC 14496-12
//! §4.2), and the FullBox variant that prefixes a 1-byte version + 3-byte flags.
/// Wrap a body in a plain box `[size][type][body]`. `size` counts the 8-byte
/// header. All `moov`-tree boxes are small (the large `mdat` is written directly
/// with a 64-bit size, not through here), so a `u32` size never overflows.
pub(super) fn bx(box_type: &[u8; 4], body: &[u8]) -> Vec<u8> {
let total = body.len() + 8;
debug_assert!(
total <= u32::MAX as usize,
"mp4 box {box_type:?} exceeds u32"
);
let mut out = Vec::with_capacity(total);
out.extend_from_slice(&(total as u32).to_be_bytes());
out.extend_from_slice(box_type);
out.extend_from_slice(body);
out
}
/// Wrap a body in a FullBox: `[size][type][version:1][flags:3][body]`.
pub(super) fn fullbox(box_type: &[u8; 4], version: u8, flags: u32, body: &[u8]) -> Vec<u8> {
let mut full = Vec::with_capacity(body.len() + 4);
full.push(version);
full.extend_from_slice(&flags.to_be_bytes()[1..]); // low 3 bytes
full.extend_from_slice(body);
bx(box_type, &full)
}
+1099
View File
File diff suppressed because it is too large Load Diff
+705
View File
@@ -0,0 +1,705 @@
//! Progressive MP4 (ISO-BMFF) demuxer — the read side of `mp4://`.
//!
//! The inverse of the writer in [`super`]: parse `moov`/`trak`/`stbl`, rebuild a
//! [`DiscTitle`] and a per-sample index (offset/size/timing/sync from
//! `stsc`+`stco`/`co64`+`stsz`, `stts`+`ctts`, `stss`), then stream each sample
//! out as a [`PesFrame`] in decode order. Video NALs are length-prefixed in MP4 —
//! exactly the framing the MKV muxer consumes — so `mp4://` → `mkv://` (and
//! `mp4://` → any sink) needs no reframing.
//!
//! Scope: progressive MP4 (`moov` + `mdat`). Fragmented MP4 (`moof`) is out of
//! scope for now — its samples live in `traf`/`trun`, not `stbl`.
use crate::disc::{
AudioChannels, AudioStream, Codec, DiscTitle, Resolution, SampleRate, Stream as DiscStream,
VideoStream,
};
use crate::labels::LabelPurpose;
use crate::pes::{PesFrame, Stream};
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom};
use std::path::Path;
const NS: i128 = 1_000_000_000;
/// One sample's location + timing in the emission plan.
struct SampleRef {
track: usize,
offset: u64,
size: u32,
/// Composition (presentation) time in nanoseconds.
pts_ns: i64,
/// Decode time in nanoseconds — the key the global emission order sorts on.
dts_ns: i64,
keyframe: bool,
}
/// MP4 reader: a `Stream` source that emits a file's samples as PES frames.
/// Generic over the backing reader so it works over a `File` (the `mp4://`
/// source) or an in-memory `Cursor` (round-trip tests).
pub struct Mp4Reader<R: Read + Seek> {
file: R,
title: DiscTitle,
samples: Vec<SampleRef>,
cursor: usize,
}
impl Mp4Reader<File> {
/// Open and index an MP4 file by path.
pub fn open(path: &Path) -> io::Result<Self> {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("mp4")
.to_string();
Self::from_reader(File::open(path)?, name)
}
}
impl<R: Read + Seek> Mp4Reader<R> {
/// Index an already-opened seekable MP4 reader.
pub fn from_reader(mut file: R, name: String) -> io::Result<Self> {
let moov = read_moov(&mut file)?;
let mut title = DiscTitle::empty();
title.playlist = name;
let mut samples: Vec<SampleRef> = Vec::new();
let mut codec_privates: Vec<Option<Vec<u8>>> = Vec::new();
let mut track_idx = 0usize;
for trak in find_boxes(&moov, b"trak") {
let Some(mdia) = find_box(trak, b"mdia") else {
continue;
};
let timescale = find_box(mdia, b"mdhd")
.and_then(mdhd_timescale)
.unwrap_or(90_000);
let language = find_box(mdia, b"mdhd").and_then(mdhd_language);
let handler = find_box(mdia, b"hdlr").and_then(hdlr_type);
let Some(minf) = find_box(mdia, b"minf") else {
continue;
};
let Some(stbl) = find_box(minf, b"stbl") else {
continue;
};
let Some(stsd) = find_box(stbl, b"stsd") else {
continue;
};
let Some(StsdInfo {
codec,
height,
config,
channels,
}) = parse_stsd(stsd)
else {
continue; // unrecognised sample entry — skip the track
};
// Build the stream model for this track.
let stream = match handler {
Some(h) if &h == b"vide" => DiscStream::Video(VideoStream {
pid: 0x1011 + track_idx as u16,
codec,
resolution: Resolution::from_height(height as u32),
frame_rate: crate::disc::FrameRate::Unknown,
hdr: crate::disc::HdrFormat::Sdr,
color_space: crate::disc::ColorSpace::Unknown,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
}),
Some(h) if &h == b"soun" => DiscStream::Audio(AudioStream {
pid: 0x1100 + track_idx as u16,
codec,
channels: AudioChannels::from_count(channels as u8),
language: language.clone().unwrap_or_else(|| "und".into()),
sample_rate: SampleRate::from_hz(timescale),
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
}),
_ => continue, // non-A/V handler
};
// Per-sample tables.
let sizes = find_box(stbl, b"stsz").map(parse_stsz).unwrap_or_default();
let n = sizes.len();
if n == 0 {
track_idx += 1;
title.streams.push(stream);
codec_privates.push(config);
continue;
}
let chunk_offsets = find_box(stbl, b"stco")
.map(|b| parse_stco(b, false))
.or_else(|| find_box(stbl, b"co64").map(|b| parse_stco(b, true)))
.unwrap_or_default();
let stsc = find_box(stbl, b"stsc").map(parse_stsc).unwrap_or_default();
let offsets = sample_offsets(&sizes, &chunk_offsets, &stsc);
let durations = find_box(stbl, b"stts").map(parse_stts).unwrap_or_default();
let ctts = find_box(stbl, b"ctts").map(parse_ctts).unwrap_or_default();
let sync = find_box(stbl, b"stss").map(parse_stss);
let mut decode_ticks: i64 = 0;
for (i, &size) in sizes.iter().enumerate() {
let dur = durations.get(i).copied().unwrap_or(0);
let comp = ctts.get(i).copied().unwrap_or(0);
let dts_ns = (decode_ticks as i128 * NS / timescale as i128) as i64;
let pts_ticks = decode_ticks + comp as i64;
let pts_ns = (pts_ticks as i128 * NS / timescale as i128) as i64;
decode_ticks += dur as i64;
let keyframe = match &sync {
Some(set) => set.contains(&(i as u32 + 1)),
None => true, // no stss → every sample is a sync sample
};
samples.push(SampleRef {
track: track_idx,
offset: offsets.get(i).copied().unwrap_or(0),
size,
pts_ns,
dts_ns,
keyframe,
});
}
title.streams.push(stream);
codec_privates.push(config);
track_idx += 1;
}
if title.streams.is_empty() {
return Err(crate::error::Error::MkvInvalid.into());
}
title.codec_privates = codec_privates;
// Emit in global decode order so the consumer sees interleaved,
// monotonic-DTS frames (a stable sort keeps per-track order on ties).
samples.sort_by_key(|s| s.dts_ns);
Ok(Self {
file,
title,
samples,
cursor: 0,
})
}
}
impl<R: Read + Seek + Send> Stream for Mp4Reader<R> {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
let Some(s) = self.samples.get(self.cursor) else {
return Ok(None);
};
self.cursor += 1;
self.file.seek(SeekFrom::Start(s.offset))?;
let mut data = vec![0u8; s.size as usize];
self.file.read_exact(&mut data)?;
Ok(Some(PesFrame {
track: s.track,
pts: s.pts_ns,
keyframe: s.keyframe,
data,
duration_ns: None,
source: None,
coding: None,
}))
}
fn write(&mut self, _frame: &PesFrame) -> io::Result<()> {
Err(crate::error::Error::StreamReadOnly.into())
}
fn finish(&mut self) -> io::Result<()> {
Ok(())
}
fn info(&self) -> &DiscTitle {
&self.title
}
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
self.title.codec_privates.get(track).and_then(|c| c.clone())
}
}
// ── box tree navigation ──────────────────────────────────────────────────────
/// Read top-level boxes until `moov`, returning its payload (after the header).
/// Skips over `ftyp`/`mdat`/etc. via seek; samples are read later by offset.
fn read_moov<R: Read + Seek>(file: &mut R) -> io::Result<Vec<u8>> {
loop {
let mut hdr = [0u8; 8];
if file.read_exact(&mut hdr).is_err() {
return Err(crate::error::Error::MkvInvalid.into());
}
let size32 = u32::from_be_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]);
let btype = [hdr[4], hdr[5], hdr[6], hdr[7]];
// 64-bit largesize (size==1): the real size is the next 8 bytes; a
// 16-byte header precedes the payload. size==0 means "to EOF".
let payload_len: u64 = if size32 == 1 {
let mut ext = [0u8; 8];
file.read_exact(&mut ext)?;
u64::from_be_bytes(ext).saturating_sub(16)
} else {
(size32 as u64).saturating_sub(8)
};
if &btype == b"moov" {
let mut buf = vec![0u8; payload_len as usize];
file.read_exact(&mut buf)?;
return Ok(buf);
}
file.seek(SeekFrom::Current(payload_len as i64))?;
}
}
/// The first child box of `payload` with the given type — returns its payload
/// (bytes after the 8-byte header). One level.
fn find_box<'a>(payload: &'a [u8], want: &[u8; 4]) -> Option<&'a [u8]> {
find_boxes(payload, want).into_iter().next()
}
/// All child boxes of `payload` with the given type — each as its payload slice.
fn find_boxes<'a>(payload: &'a [u8], want: &[u8; 4]) -> Vec<&'a [u8]> {
let mut out = Vec::new();
let mut pos = 0;
while pos + 8 <= payload.len() {
let size = u32::from_be_bytes([
payload[pos],
payload[pos + 1],
payload[pos + 2],
payload[pos + 3],
]) as usize;
let bt = [
payload[pos + 4],
payload[pos + 5],
payload[pos + 6],
payload[pos + 7],
];
if size < 8 || pos + size > payload.len() {
break;
}
if &bt == want {
out.push(&payload[pos + 8..pos + size]);
}
pos += size;
}
out
}
fn be32(b: &[u8], o: usize) -> u32 {
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
fn be16(b: &[u8], o: usize) -> u16 {
u16::from_be_bytes([b[o], b[o + 1]])
}
/// mdhd (version 0/1) → media timescale.
fn mdhd_timescale(b: &[u8]) -> Option<u32> {
let version = b.first().copied()?;
if version == 1 {
// version(1)+flags(3) creation(8) modification(8) timescale(4) ...
(b.len() >= 24).then(|| be32(b, 20))
} else {
// creation(4) modification(4) timescale(4) ...
(b.len() >= 16).then(|| be32(b, 12))
}
}
/// mdhd language (5-bit packed ISO 639-2) → lowercase 3-letter code.
fn mdhd_language(b: &[u8]) -> Option<String> {
let version = b.first().copied()?;
let off = if version == 1 { 28 } else { 20 };
if b.len() < off + 2 {
return None;
}
let packed = be16(b, off);
let c0 = ((packed >> 10) & 0x1F) as u8 + 0x60;
let c1 = ((packed >> 5) & 0x1F) as u8 + 0x60;
let c2 = (packed & 0x1F) as u8 + 0x60;
let s: String = [c0, c1, c2].iter().map(|&c| c as char).collect();
if s.chars().all(|c| c.is_ascii_lowercase()) {
Some(s)
} else {
None
}
}
/// hdlr → handler_type fourcc ('vide' / 'soun').
fn hdlr_type(b: &[u8]) -> Option<[u8; 4]> {
// version+flags(4) pre_defined(4) handler_type(4) ...
(b.len() >= 12).then(|| [b[8], b[9], b[10], b[11]])
}
/// Decoded first sample entry of an `stsd` box.
struct StsdInfo {
codec: Codec,
height: u16,
config: Option<Vec<u8>>,
channels: u16,
}
/// stsd → codec + dimensions + codec_private + channel count (first entry).
fn parse_stsd(b: &[u8]) -> Option<StsdInfo> {
// version+flags(4) entry_count(4) then the first sample entry box.
if b.len() < 8 {
return None;
}
let entry = &b[8..];
if entry.len() < 8 {
return None;
}
let size = be32(entry, 0) as usize;
let fourcc = [entry[4], entry[5], entry[6], entry[7]];
let body = &entry[8..size.min(entry.len())];
let codec = match &fourcc {
b"hvc1" | b"hev1" => Codec::Hevc,
b"avc1" | b"avc3" => Codec::H264,
b"ac-3" => Codec::Ac3,
b"ec-3" => Codec::Ac3Plus,
b"mp4a" => Codec::Aac,
b"dtsc" | b"dtse" | b"dtsh" | b"dtsl" => Codec::Dts,
_ => return None,
};
if matches!(codec, Codec::Hevc | Codec::H264) {
// VisualSampleEntry: 6 reserved + 2 dri + 16 pre/reserved + width(2)
// height(2) + 14 + 32 compressorname + 2 depth + 2 pre = 78 bytes, then
// child boxes (hvcC/avcC, colr, …).
if body.len() < 78 {
return None;
}
let height = be16(body, 26);
let children = &body[78..];
let config = find_box(children, b"hvcC")
.or_else(|| find_box(children, b"avcC"))
.map(|c| c.to_vec());
Some(StsdInfo {
codec,
height,
config,
channels: 0,
})
} else {
// AudioSampleEntry: 6 reserved + 2 dri + 8 reserved + channelcount(2)
// samplesize(2) + 2 pre + 2 reserved + samplerate(4) = 28 bytes.
let channels = if body.len() >= 28 { be16(body, 16) } else { 2 };
Some(StsdInfo {
codec,
height: 0,
config: None,
channels,
})
}
}
/// stsz → per-sample sizes.
fn parse_stsz(b: &[u8]) -> Vec<u32> {
if b.len() < 12 {
return Vec::new();
}
let sample_size = be32(b, 4);
let count = be32(b, 8) as usize;
if sample_size != 0 {
return vec![sample_size; count];
}
let mut out = Vec::with_capacity(count);
for i in 0..count {
let o = 12 + i * 4;
if o + 4 > b.len() {
break;
}
out.push(be32(b, o));
}
out
}
/// stco (32-bit) / co64 (64-bit) → chunk offsets.
fn parse_stco(b: &[u8], is64: bool) -> Vec<u64> {
if b.len() < 8 {
return Vec::new();
}
let count = be32(b, 4) as usize;
let mut out = Vec::with_capacity(count);
let stride = if is64 { 8 } else { 4 };
for i in 0..count {
let o = 8 + i * stride;
if o + stride > b.len() {
break;
}
if is64 {
out.push(u64::from_be_bytes([
b[o],
b[o + 1],
b[o + 2],
b[o + 3],
b[o + 4],
b[o + 5],
b[o + 6],
b[o + 7],
]));
} else {
out.push(be32(b, o) as u64);
}
}
out
}
/// stsc → (first_chunk, samples_per_chunk) entries (1-based first_chunk).
fn parse_stsc(b: &[u8]) -> Vec<(u32, u32)> {
if b.len() < 8 {
return Vec::new();
}
let count = be32(b, 4) as usize;
let mut out = Vec::with_capacity(count);
for i in 0..count {
let o = 8 + i * 12;
if o + 12 > b.len() {
break;
}
out.push((be32(b, o), be32(b, o + 4)));
}
out
}
/// Reconstruct per-sample file offsets from sizes + chunk offsets + stsc.
fn sample_offsets(sizes: &[u32], chunk_offsets: &[u64], stsc: &[(u32, u32)]) -> Vec<u64> {
let n_chunks = chunk_offsets.len();
// Expand stsc → samples_per_chunk for every chunk.
let mut spc = vec![0u32; n_chunks];
for (idx, &(first, per)) in stsc.iter().enumerate() {
let start = (first.saturating_sub(1)) as usize;
let end = stsc
.get(idx + 1)
.map(|&(nf, _)| (nf.saturating_sub(1)) as usize)
.unwrap_or(n_chunks);
let end = end.min(n_chunks);
if start < end {
spc[start..end].fill(per);
}
}
let mut offsets = Vec::with_capacity(sizes.len());
let mut sidx = 0usize;
for (ci, &choff) in chunk_offsets.iter().enumerate() {
let mut off = choff;
for _ in 0..spc[ci] {
if sidx >= sizes.len() {
break;
}
offsets.push(off);
off += sizes[sidx] as u64;
sidx += 1;
}
}
// Any trailing samples with no chunk mapping: pack after the last offset.
while offsets.len() < sizes.len() {
let last = offsets.last().copied().unwrap_or(0);
let last_sz = sizes
.get(offsets.len().saturating_sub(1))
.copied()
.unwrap_or(0);
offsets.push(last + last_sz as u64);
}
offsets
}
/// stts → per-sample decode durations (expanded from run-length entries).
fn parse_stts(b: &[u8]) -> Vec<u32> {
if b.len() < 8 {
return Vec::new();
}
let count = be32(b, 4) as usize;
let mut out = Vec::new();
for i in 0..count {
let o = 8 + i * 8;
if o + 8 > b.len() {
break;
}
let n = be32(b, o);
let delta = be32(b, o + 4);
for _ in 0..n {
out.push(delta);
}
}
out
}
/// ctts → per-sample composition offsets (version 0 unsigned / version 1 signed).
fn parse_ctts(b: &[u8]) -> Vec<i32> {
if b.len() < 8 {
return Vec::new();
}
// version 0 = unsigned, version 1 = signed offsets; the u32→i32 bit-cast
// reads both correctly (real composition offsets fit in i32 either way).
let count = be32(b, 4) as usize;
let mut out = Vec::new();
for i in 0..count {
let o = 8 + i * 8;
if o + 8 > b.len() {
break;
}
let n = be32(b, o);
let offset = be32(b, o + 4) as i32;
for _ in 0..n {
out.push(offset);
}
}
out
}
/// stss → set of 1-based sync sample numbers.
fn parse_stss(b: &[u8]) -> std::collections::HashSet<u32> {
let mut set = std::collections::HashSet::new();
if b.len() < 8 {
return set;
}
let count = be32(b, 4) as usize;
for i in 0..count {
let o = 8 + i * 4;
if o + 4 > b.len() {
break;
}
set.insert(be32(b, o));
}
set
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stsc_offsets_one_sample_per_chunk() {
// Our writer's layout: 1 sample/chunk, co64 lists every offset.
let sizes = vec![100u32, 200, 300];
let chunks = vec![1000u64, 1100, 1300];
let stsc = vec![(1u32, 1u32)];
assert_eq!(
sample_offsets(&sizes, &chunks, &stsc),
vec![1000, 1100, 1300]
);
}
#[test]
fn stsc_offsets_multi_sample_chunks() {
// 2 samples in chunk 1, 1 in chunk 2.
let sizes = vec![10u32, 20, 30];
let chunks = vec![500u64, 900];
let stsc = vec![(1u32, 2u32), (2u32, 1u32)];
// chunk1@500: s0@500, s1@510; chunk2@900: s2@900.
assert_eq!(sample_offsets(&sizes, &chunks, &stsc), vec![500, 510, 900]);
}
#[test]
fn write_then_read_round_trip() {
// Mux a small A/V title to an in-memory MP4, then demux it back and
// check the streams, codec_private, and sample payloads survive.
use crate::disc::{
AudioChannels, AudioStream, Codec, DiscTitle, FrameRate, HdrFormat, LabelPurpose,
SampleRate, Stream as DiscStreamE, VideoStream,
};
use crate::mux::mp4::Mp4Sink;
use crate::pes::{PesFrame, Stream as _};
use std::io::Cursor;
let mut t = DiscTitle::empty();
t.streams = vec![
DiscStreamE::Video(VideoStream {
pid: 0x1011,
codec: Codec::Hevc,
resolution: Resolution::R1080p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: crate::disc::ColorSpace::Unknown,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
}),
DiscStreamE::Audio(AudioStream {
pid: 0x1100,
codec: Codec::Ac3,
channels: AudioChannels::Surround51,
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
}),
];
let hvcc = vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE];
t.codec_privates = vec![Some(hvcc.clone()), None];
let ac3 = vec![
0x0B,
0x77,
0x00,
0x00,
0b00_010110,
0b01000_000,
0b111_00_00_1,
0x00,
0xFF,
0xFF,
];
let d = 41_708_333i64;
let vid0 = vec![0x11u8; 700];
let vid1 = vec![0x22u8; 350];
let mut buf = Vec::new();
{
let mk = |track, pts, key, data: Vec<u8>| PesFrame {
track,
pts,
keyframe: key,
data,
duration_ns: None,
source: None,
coding: None,
};
let mut sink = Mp4Sink::create(Cursor::new(&mut buf), &t).unwrap();
sink.write(&mk(0, 0, true, vid0.clone())).unwrap();
sink.write(&mk(1, 0, true, ac3.clone())).unwrap();
sink.write(&mk(0, d, false, vid1.clone())).unwrap();
sink.write(&mk(1, 32_000_000, true, ac3.clone())).unwrap();
sink.finish().unwrap();
}
let mut rd = Mp4Reader::from_reader(Cursor::new(buf), "rt".into()).unwrap();
// Two streams: HEVC video + AC-3 audio, and the hvcC round-trips.
assert_eq!(rd.info().streams.len(), 2);
assert!(
matches!(rd.info().streams[0], DiscStreamE::Video(ref v) if v.codec == Codec::Hevc)
);
assert!(matches!(rd.info().streams[1], DiscStreamE::Audio(ref a) if a.codec == Codec::Ac3));
assert_eq!(rd.codec_private(0), Some(hvcc));
// Read all frames back; match them to what we wrote by (track, size).
let mut got = Vec::new();
while let Some(f) = rd.read().unwrap() {
got.push((f.track, f.data.len(), f.keyframe));
}
assert_eq!(got.len(), 4, "4 samples round-trip");
let vids: Vec<_> = got.iter().filter(|(t, _, _)| *t == 0).collect();
let auds: Vec<_> = got.iter().filter(|(t, _, _)| *t == 1).collect();
assert_eq!(vids.len(), 2);
assert_eq!(auds.len(), 2);
assert_eq!(vids[0].1, 700, "first video sample size");
assert_eq!(vids[1].1, 350, "second video sample size");
assert!(vids[0].2, "first video frame is a keyframe");
assert_eq!(auds[0].1, ac3.len());
}
#[test]
fn stts_and_ctts_expand() {
// stts: 3 samples × 1001 ticks.
let mut stts = Vec::new();
stts.extend_from_slice(&[0, 0, 0, 0]); // version+flags
stts.extend_from_slice(&1u32.to_be_bytes()); // entry_count
stts.extend_from_slice(&3u32.to_be_bytes());
stts.extend_from_slice(&1001u32.to_be_bytes());
assert_eq!(parse_stts(&stts), vec![1001, 1001, 1001]);
}
}
+644 -54
View File
@@ -45,6 +45,10 @@ pub enum StreamUrl {
M2ts { path: PathBuf },
/// Matroska container file.
Mkv { path: PathBuf },
/// Progressive MP4 (ISO-BMFF) mux output (`mp4://`). Like `mkv://` but writes
/// a single self-contained `.mp4` (ftyp+mdat+moov). Compatibility export —
/// carries only MP4-mappable codecs; see `mux::mp4`.
Mp4 { path: PathBuf },
/// Network stream (host:port).
Network { addr: String },
/// Standard I/O (stdin/stdout).
@@ -62,10 +66,31 @@ pub enum StreamUrl {
/// chapters + delay metadata). Like `dir://` it targets a directory; the
/// CLI constructs the `DemuxSink` with full options before the mux loop.
Demux { dir: PathBuf },
/// Video-only per-track output directory (`video://`) — a `demux://`
/// restricted to video tracks (native elementary streams: `.hevc`, `.h264`,
/// `.vc1`, `.m2v`, …). One file per video track; no audio/subtitles.
Video { dir: PathBuf },
/// Audio-only per-track output directory (`audio://`) — a `demux://`
/// restricted to audio tracks (native containers: `.thd`, `.dts`, `.ac3`,
/// `.eac3`, `.pcm`, …). One file per audio track; no video/subtitles.
Audio { dir: PathBuf },
/// Subtitle-only per-track output directory (`sub://`) — a `demux://`
/// restricted to subtitle tracks (PGS `.sup`, VobSub `.idx`+`.sub`, text
/// `.srt`). One file per subtitle track.
Sub { dir: PathBuf },
/// freemkv native per-picture video index (`fvi://`). A write-only PES sink
/// that emits one JSON-Lines record per coded picture of the title's primary
/// video track to a `.fvi` file (normative spec `docs/FVI_FORMAT.md`).
Fvi { path: PathBuf },
/// Chapter-marker export (`chapters://`). A write-only sink that ignores the
/// PES stream and writes the title's chapter points to a single file, format
/// chosen by the output extension: `.xml` (Matroska, default), `.txt` (OGM),
/// `.vtt` (WebVTT).
Chapters { path: PathBuf },
/// Structured title/stream/chapter metadata (`json://`). A write-only sink
/// that ignores the PES stream and writes the selected title's model as one
/// JSON document — machine-readable `info` for one title.
Json { path: PathBuf },
/// Unrecognized URL.
Unknown { raw: String },
}
@@ -77,13 +102,19 @@ impl StreamUrl {
StreamUrl::Disc { .. } => "disc",
StreamUrl::M2ts { .. } => "m2ts",
StreamUrl::Mkv { .. } => "mkv",
StreamUrl::Mp4 { .. } => "mp4",
StreamUrl::Network { .. } => "network",
StreamUrl::Stdio => "stdio",
StreamUrl::Iso { .. } => "iso",
StreamUrl::Dir { .. } => "dir",
StreamUrl::Null => "null",
StreamUrl::Demux { .. } => "demux",
StreamUrl::Video { .. } => "video",
StreamUrl::Audio { .. } => "audio",
StreamUrl::Sub { .. } => "sub",
StreamUrl::Fvi { .. } => "fvi",
StreamUrl::Chapters { .. } => "chapters",
StreamUrl::Json { .. } => "json",
StreamUrl::Unknown { .. } => "unknown",
}
}
@@ -95,10 +126,16 @@ impl StreamUrl {
StreamUrl::Disc { device: None } => "",
StreamUrl::M2ts { path }
| StreamUrl::Mkv { path }
| StreamUrl::Mp4 { path }
| StreamUrl::Iso { path }
| StreamUrl::Dir { path }
| StreamUrl::Demux { dir: path }
| StreamUrl::Fvi { path } => path.to_str().unwrap_or(""),
| StreamUrl::Video { dir: path }
| StreamUrl::Audio { dir: path }
| StreamUrl::Sub { dir: path }
| StreamUrl::Fvi { path }
| StreamUrl::Chapters { path }
| StreamUrl::Json { path } => path.to_str().unwrap_or(""),
StreamUrl::Network { addr } => addr,
StreamUrl::Stdio | StreamUrl::Null => "",
StreamUrl::Unknown { raw } => raw,
@@ -138,6 +175,11 @@ pub fn parse_url(url: &str) -> StreamUrl {
path: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("mp4://") {
return StreamUrl::Mp4 {
path: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("network://") {
return StreamUrl::Network {
addr: rest.to_string(),
@@ -171,6 +213,31 @@ pub fn parse_url(url: &str) -> StreamUrl {
dir: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("video://") {
return StreamUrl::Video {
dir: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("audio://") {
return StreamUrl::Audio {
dir: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("sub://") {
return StreamUrl::Sub {
dir: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("chapters://") {
return StreamUrl::Chapters {
path: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("json://") {
return StreamUrl::Json {
path: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("fvi://") {
return StreamUrl::Fvi {
path: PathBuf::from(rest),
@@ -336,34 +403,34 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
}
.into());
}
// Per-title key resolution. For a multi-VTS CSS DVD the scan's
// single cracked key only descrambles its own VTS; re-crack from
// the chosen title's extents if it lives elsewhere. A fresh reader
// avoids disturbing the mux reader below. 64 sectors is a
// file-safe batch for an ISO. AACS / single-VTS paths are
// unchanged (decrypt_keys_for_title short-circuits to decrypt_keys).
let (keys, title_is_clear) =
match crate::io::file_sector_source::FileSectorSource::open(path) {
Ok(mut crack_reader) => {
disc.decrypt_keys_for_title_checked(idx, &mut crack_reader, 64)
}
Err(_) => (disc.decrypt_keys(), false),
};
// Per-title decrypt gate (parallel to the disc-wide gate above): on
// a multi-VTS CSS disc, the per-title re-crack may return `None` when
// the chosen title's VTS could not be re-cracked. Muxing that would
// emit scrambled ciphertext verbatim, so fail loudly here — EXCEPT
// when the title proved genuinely clear (`title_is_clear`), an
// unencrypted stub on an otherwise-CSS disc that needs no key. That
// 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() })?;
// Per-title key resolution. DVD CSS is resolved at exactly ONE site —
// `build_iso_pipeline`'s per-title crack (below), which decrypts a
// crackable title, passes a genuinely-clear one through, and
// hard-fails an uncrackable one with CssKeyMissing. So for a DVD we do
// NOT pre-crack here: pass `None` and let the pipeline own it.
// Pre-cracking would re-open the ISO and re-scan every clear title
// (`decrypt_keys_for_title` → None → the pipeline re-cracks anyway).
// AACS / unencrypted resolve from `decrypt_keys()` with NO read; `--raw`
// (any format) is deliberate ciphertext passthrough — also `None`.
let is_dvd = disc.format == crate::disc::DiscFormat::Dvd;
let (keys, title_is_clear) = if opts.raw || is_dvd {
(crate::decrypt::DecryptKeys::None, false)
} else {
(disc.decrypt_keys(), false)
};
// Decrypt gate for the AACS / non-DVD path: a None key means no usable
// disc key, which would mux scrambled ciphertext verbatim — fail loudly
// (NoDiscKey). The DVD path is gated inside `build_iso_pipeline` (its
// CSS hard-fail), and `--raw` passes.
if !is_dvd {
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
.map_err(|e| -> io::Error { e.into() })?;
}
// FMTS (AACS 2.1) forensic segments are sourced + fail-loud-checked
// downstream by `resolve_mux_key_map`/`resolve_fmts_key_map`, which hold
// the key-fetch closure and can actually attempt resolution. (An older
// upfront blanket-reject gate lived here; it predated the resolver and
// rejected every 2.1 disc before a source could be tried.)
// 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
@@ -421,6 +488,7 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
effective_keys,
ISO_MUX_BATCH_SECTORS,
format,
opts.raw,
None,
None,
fetch,
@@ -449,8 +517,16 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
// PES source. Mirror `null://` → write-only.
StreamUrl::Dir { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
StreamUrl::Null => Err(crate::error::Error::StreamWriteOnly.into()),
// `mp4://` as a source: demux a progressive MP4 back into PES frames, so
// `mp4://` flows to every sink (mkv://, audio://, json://, …).
StreamUrl::Mp4 { ref path } => Ok(Box::new(super::mp4::Mp4Reader::open(path)?)),
// `demux://` is an output-only sink (per-track ES files); never a source.
StreamUrl::Demux { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
StreamUrl::Demux { .. }
| StreamUrl::Video { .. }
| StreamUrl::Audio { .. }
| StreamUrl::Sub { .. }
| StreamUrl::Chapters { .. }
| StreamUrl::Json { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
// `fvi://` is an output-only sink (per-picture video index); never a source.
StreamUrl::Fvi { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
StreamUrl::Unknown { ref raw } => {
@@ -480,7 +556,19 @@ pub fn output(
IO_BUF_SIZE,
crate::io::WritebackFile::create_with_size_hint(path, title.size_bytes)?,
));
Ok(Box::new(MkvStream::create_at(writer, title, Some(path))?))
Ok(Box::new(MkvStream::create(writer, title, Some(path))?))
}
StreamUrl::Mp4 { ref path } => {
validate_file_path(path, "mp4")?;
// Bounded-cache writeback (like mkv://) so a UHD-scale mux to slow /
// network-attached staging doesn't hit the dirty-page burst
// pathology; the mdat backpatch is an ordinary seek WritebackFile
// handles. BufWriter coalesces the many small moov box-header writes.
let writer = std::io::BufWriter::with_capacity(
IO_BUF_SIZE,
crate::io::WritebackFile::create_with_size_hint(path, title.size_bytes)?,
);
Ok(Box::new(super::mp4::Mp4Sink::create(writer, title)?))
}
StreamUrl::M2ts { ref path } => {
validate_file_path(path, "m2ts")?;
@@ -529,11 +617,35 @@ pub fn output(
dir, title, &opts,
)?))
}
// `video://`, `audio://`, and `sub://` are `demux://` restricted to one
// track class — video as native elementary streams, audio in native
// containers, or subtitles as `.sup`/`.idx+.sub`/`.srt`. No chapters
// sidecar (that's a `demux://` / `chapters://` concern).
StreamUrl::Video { ref dir }
| StreamUrl::Audio { ref dir }
| StreamUrl::Sub { ref dir } => {
let (scheme, kind) = match parsed {
StreamUrl::Video { .. } => ("video", super::demux_sink::TrackKind::Video),
StreamUrl::Audio { .. } => ("audio", super::demux_sink::TrackKind::Audio),
_ => ("sub", super::demux_sink::TrackKind::Subtitle),
};
validate_file_path(dir, scheme)?;
let mut opts = super::demux_sink::DemuxOptions {
kind_filter: Some(kind),
export_chapters: false,
..Default::default()
};
if !title.playlist.is_empty() {
opts.base = title.playlist.clone();
}
Ok(Box::new(super::demux_sink::DemuxSink::create(
dir, title, &opts,
)?))
}
// `fvi://` writes the per-picture video index (`docs/FVI_FORMAT.md`).
// The bare `output()` arm records the resolver path as the provenance
// `source.path` and defaults the title index to 0 (the resolver carries
// no title-index context — the CLI follow-up passes the real medium /
// title via `FviSink::create_with_source`).
// no title-index context).
StreamUrl::Fvi { ref path } => {
validate_file_path(path, "fvi")?;
Ok(Box::new(super::fvi_sink::FviSink::create(
@@ -543,6 +655,18 @@ pub fn output(
0,
)?))
}
// `chapters://` and `json://` write the title metadata at construction and
// ignore the PES stream (see `meta_sink`).
StreamUrl::Chapters { ref path } => {
validate_file_path(path, "chapters")?;
Ok(Box::new(super::meta_sink::ChaptersSink::create(
path, title,
)?))
}
StreamUrl::Json { ref path } => {
validate_file_path(path, "json")?;
Ok(Box::new(super::meta_sink::JsonSink::create(path, title)?))
}
StreamUrl::Unknown { ref raw } => {
Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into())
}
@@ -600,6 +724,391 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
(parsers, pid_to_track, ts, ps)
}
/// Resolve the proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) for a title
/// before muxing. It decides which held unit key decrypts each of the title's
/// LBA ranges and secures any key the pool is missing through the app's
/// configured source (`fetch`) up front, never reactively per unit at mux time.
///
/// This is what ends the key-server storm. The old mux decrypted a unit, checked
/// whether the plaintext looked like clean MPEG-TS, and — because authored-bad
/// content never reaches that bar — re-asked the key service for a key it already
/// held. There is no per-unit byte pattern that separates "correctly decrypted
/// but authored-bad" from "still encrypted", so that check is unanswerable. Here
/// we answer the answerable question instead: which CPS unit does each LBA range
/// belong to, decided by the disc's key structure (validated once against real
/// ciphertext samples, where the `is_clean` proof IS sound). The mux then just
/// decrypts each unit with its mapped key and trusts it.
///
/// Single-CPS (the overwhelming majority, incl. every single-key UHD) is the
/// trivial map: one key everywhere, no sampling. Multi-CPS assigns each extent to
/// the key that opens a real sample from it; a bad-content extent no sample can
/// classify inherits its predecessor's key (contiguity). FMTS segment mapping
/// layers onto the same structure.
/// FMTS (AACS 2.1) branch of [`resolve_mux_key_map`]. Returns `Some(map)` when the
/// disc carries `IndividualSegment.tbl` AND a key source is configured; `None`
/// otherwise (not FMTS, or no source — the caller's base-Unit-Key path then
/// applies, and the forensic units garble and are dropped by the demux).
///
/// The forensic segments each carry an **index** tag (1..32) selecting one of 32
/// **index keys** the base Unit Key cannot open (see [`crate::aacs::segment`]).
/// This resolves those keys up front from the configured source — sending, per
/// index, a batch of same-index units the service maps to that index's key — adds
/// them to the pool, and builds a per-segment LBA→key map. Applying a segment's
/// key over its whole range decodes the ~40 units of that index's interleave half
/// to clean TS and garbles the other ~40 (the alternate half), which the demux
/// then drops, yielding one coherent stream. The base Unit Key covers everything
/// outside a segment.
fn resolve_fmts_key_map(
reader: &mut dyn SectorSource,
title: &DiscTitle,
keys: &mut crate::decrypt::DecryptKeys,
fetch: Option<&crate::sector::KeyFetch>,
format: ContentFormat,
) -> io::Result<Option<crate::decrypt::AacsKeyMap>> {
use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_encrypted, decrypt_unit, is_clean};
use crate::aacs::segment::{clip_byte_to_lba, parse_individual_segments};
// Load the segment map; absent → not an FMTS disc.
let Ok(udf) = crate::udf::read_filesystem(reader) else {
return Ok(None);
};
let Ok(tbl) = udf.read_file(reader, "/AACS/IndividualSegment.tbl") else {
return Ok(None);
};
let Some(segments) = parse_individual_segments(&tbl) else {
return Ok(None);
};
if segments.is_empty() {
return Ok(None);
}
// This IS an FMTS disc, so the forensic index keys are REQUIRED — exactly like
// a Unit Key. Without a configured key source we cannot obtain them, so we
// cannot produce a complete rip: fail loud rather than silently drop the
// forensic segments. (The caller may still choose `--raw`, which never reaches
// this path.)
let Some(fetch) = fetch else {
return Err(crate::error::Error::FmtsKeyMissing.into());
};
tracing::info!(target: "freemkv::keysource", segments = segments.len(), extents = title.extents.len(), "fmts: begin index-key resolution");
// Read aligned unit `index` of `seg`: clip byte `start_spn*192 + index*6144`.
let read_unit =
|reader: &mut dyn SectorSource, seg: &crate::aacs::segment::Segment, index: usize| {
let clip_byte = seg.start_spn as u64 * 192 + index as u64 * ALIGNED_UNIT_LEN as u64;
let lba = clip_byte_to_lba(&title.extents, clip_byte)?;
let mut c = vec![0u8; ALIGNED_UNIT_LEN];
reader.read_sectors(lba, 3, &mut c, false).ok()?;
Some(c)
};
// ── ANCHOR — fetch the whole 32-key set from ONE index-1 batch. The key
// service returns ALL forensic index keys ordered (element i = index i+1)
// only for a canonical INDEX-1 sample that decrypts under the index-1 key.
// A forensic segment interleaves TWO variants at the aligned-unit level, so
// index-1's real content is one PHASE (even or odd units) and the alternate
// is a different variant that won't decrypt. We don't know the phase a
// priori, so try PHASE A (even) then PHASE B (odd): whichever is index-1's
// content comes back with the full set. Both phases failing (across the
// read-fault fallback over index-1 segments) ⇒ this disc has no FMTS keys.
//
// The set's SIZE is whatever the source returns (≥ 1) — never assumed. 32
// is all we have seen, but a disc with a different forensic index count is
// not ruled out, so the map is sized to the returned `len()`, not a const.
// ─────────────────────────────────────────────────────────────────────────
// Batch size = the key service's minimum-samples floor (same as the online
// source), drawn from ONE phase to land a clean single-variant half.
const BATCH_UNITS: usize = crate::keysource::MIN_SAMPLE_UNITS;
// Read-fault fallback: how many index-1 segments to attempt if the leading one
// is unreadable. The 2 phase requests happen per readable segment.
const MAX_ANCHOR_ATTEMPTS: usize = 16;
// Even units = p*2; odd units = p*2 + 1.
let read_phase_batch = |reader: &mut dyn SectorSource,
seg: &crate::aacs::segment::Segment,
phase_off: usize|
-> Option<Vec<Vec<u8>>> {
let mut batch: Vec<Vec<u8>> = Vec::with_capacity(BATCH_UNITS);
for p in 0..BATCH_UNITS {
batch.push(read_unit(reader, seg, p * 2 + phase_off)?);
}
Some(batch)
};
let mut index_keys: Vec<[u8; 16]> = Vec::new();
'anchor: for seg in segments
.iter()
.filter(|s| s.index == 1)
.take(MAX_ANCHOR_ATTEMPTS)
{
for phase_off in [0usize, 1usize] {
let Some(batch) = read_phase_batch(reader, seg, phase_off) else {
continue; // read fault on this phase — try the other / next segment
};
let fresh = fetch.fmts_indexes(&batch);
// Any non-empty reply is the source's COMPLETE ordered forensic set;
// trust it and stop. An empty reply = this phase/segment did not anchor.
if !fresh.is_empty() {
index_keys = fresh;
break 'anchor;
}
}
}
// The count is whatever the source returned — not a fixed 32. Sized here, used
// everywhere below.
let n_index = index_keys.len();
tracing::info!(target: "freemkv::keysource", held = n_index, "fmts: collection done");
// At least one forensic index key is required. None ⇒ no FMTS key for this
// disc from any source — fail loud like a missing Unit Key rather than emit
// forensic-holed output.
if index_keys.is_empty() {
return Err(crate::error::Error::FmtsKeyMissing.into());
}
// Map array position → forensic index (element i = index i+1); add each key to
// the pool and remember its slot by tag. `base_idx` is the Unit Key (slot 0).
let base_idx = 0usize;
let mut tag_slot: std::collections::HashMap<u16, usize> = std::collections::HashMap::new();
if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys {
for (i, k) in index_keys.iter().enumerate() {
let tag = (i + 1) as u16;
let slot = match unit_keys.iter().position(|(_, h)| h == k) {
Some(s) => s,
None => {
let s = unit_keys.len();
// CPS-unit id is cosmetic for the mapped decrypt (it indexes by
// slot); use a high, distinct number for the forensic keys.
unit_keys.push((1000 + s as u32, *k));
s
}
};
tag_slot.insert(tag, slot);
}
}
// ── PROBE each index's phase. A forensic segment interleaves two variants at
// the aligned-unit level; only ONE parity is this index's real content (the
// other is the alternate variant — a different key, garbles under ours). For
// each index, read a representative tagged segment and count clean decrypts
// of its EVEN vs ODD units under that index's key: the clean half is the
// index's phase. This is the ONE place `is_clean` runs — "the map must be
// right", verified here, once — so the mux decrypt can then trust the map.
// Phase is per-index and shared by every segment carrying that index. ──────
let mut phase_of_index: std::collections::HashMap<u16, crate::decrypt::Phase> =
std::collections::HashMap::new();
for (i, k) in index_keys.iter().enumerate() {
let tag = (i + 1) as u16;
let Some(seg) = segments.iter().find(|s| s.index == tag) else {
continue; // no segment carries this index on this feature — skip
};
let (mut even, mut odd) = (0usize, 0usize);
for p in 0..BATCH_UNITS {
for (phase_off, counter) in [(0usize, &mut even), (1usize, &mut odd)] {
if let Some(mut c) = read_unit(reader, seg, p * 2 + phase_off) {
if aacs_unit_encrypted(&c, format) {
decrypt_unit(&mut c, k);
if is_clean(&c, format) {
*counter += 1;
}
}
}
}
}
let phase = match even.cmp(&odd) {
std::cmp::Ordering::Greater => crate::decrypt::Phase::Even,
std::cmp::Ordering::Less => crate::decrypt::Phase::Odd,
std::cmp::Ordering::Equal => {
// Neither half decrypts clean under this index's key: the map would
// be wrong. Fail loud rather than emit a broken segment map.
tracing::warn!(target: "freemkv::keysource", index = tag, even, odd, "fmts: no clean phase under index key — refusing broken map");
return Err(crate::error::Error::FmtsKeyMissing.into());
}
};
phase_of_index.insert(tag, phase);
}
// ── Build the per-segment LBA ranges: each forensic segment → its tag's key
// AND its index's phase. The mapped decrypt opens only that half and leaves
// the alternate as ciphertext (the muxer drops untouched ciphertext) —
// clean by construction, no garble. A segment straddling an extent boundary
// is left unmapped and tallied (a hard failure below). ────────────────────
let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> =
Vec::with_capacity(segments.len());
let mut unresolved = 0usize;
for seg in &segments {
let Some(&slot) = tag_slot.get(&seg.index) else {
unresolved += 1;
continue;
};
let phase = phase_of_index
.get(&seg.index)
.copied()
.unwrap_or(crate::decrypt::Phase::All);
let start_byte = seg.start_spn as u64 * 192;
let end_byte = (seg.end_spn as u64 + 1) * 192;
let (Some(a), Some(b)) = (
clip_byte_to_lba(&title.extents, start_byte),
clip_byte_to_lba(&title.extents, end_byte - 1),
) else {
unresolved += 1;
continue;
};
// Only emit a contiguous within-extent range (segments are ~480 KB; a rare
// extent-straddle is left unresolved rather than given a wrong span).
if b >= a && (b - a) as u64 == (end_byte - 1 - start_byte) / 2048 {
ranges.push((a, b + 1, slot, phase));
} else {
unresolved += 1;
}
}
// Every forensic segment must map to an index key. Any that did not is a hole
// in the rip — with the full 32-key set in hand this should never happen, so
// treat it as a hard failure rather than silently emitting a garbled segment.
if unresolved != 0 {
return Err(crate::error::Error::FmtsKeyMissing.into());
}
Ok(Some(crate::decrypt::AacsKeyMap::from_ranges_phased(
ranges, base_idx,
)))
}
pub fn resolve_mux_key_map(
reader: &mut dyn SectorSource,
title: &DiscTitle,
keys: &mut crate::decrypt::DecryptKeys,
fetch: Option<&crate::sector::KeyFetch>,
format: ContentFormat,
) -> io::Result<crate::decrypt::AacsKeyMap> {
use crate::aacs::content::{
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted, decrypt_unit, is_clean,
};
let pool_len = match keys {
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.len(),
// CSS / clear: no AACS map (the decorator's map path is AACS-only).
_ => return Ok(crate::decrypt::AacsKeyMap::single(0)),
};
// Secure the disc's key up front from the configured source when the pool is
// empty (a genuine "no key yet" — e.g. keydb miss, online-only disc).
if pool_len == 0 {
if let Some(f) = fetch {
let samples = crate::keysource::read_encrypted_units(reader, title, 8);
if !samples.is_empty() {
let fresh = f.unit_keys(&samples);
if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys {
for k in fresh {
if !unit_keys.iter().any(|(_, h)| *h == k) {
let i = unit_keys.len() as u32;
unit_keys.push((i, k));
}
}
}
}
}
// If the pool is STILL empty, this AACS-encrypted title needs a Unit Key we
// could not obtain from any source. That is the same situation as any known
// key we don't hold — fail loud at resolve time rather than deferring an
// opaque decrypt error (or, worse, emitting ciphertext) at mux time.
let empty = matches!(
keys,
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } if unit_keys.is_empty()
);
if empty {
return Err(crate::error::Error::DecryptFailed.into());
}
return Ok(crate::decrypt::AacsKeyMap::single(0));
}
// FMTS (AACS 2.1): if the disc carries `IndividualSegment.tbl`, the forensic
// segments need per-index keys the base Unit Key can't open. Resolve them up
// front from the configured source and build a per-segment map. Returns `None`
// when the disc is not FMTS, or no key source is configured (then the base UK
// path below applies and the forensic units garble → demux drops them).
if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format)? {
return Ok(map);
}
if pool_len == 1 {
// One CPS unit → one key everywhere. No structural walk, no sampling.
return Ok(crate::decrypt::AacsKeyMap::single(0));
}
// Multi-CPS: read a spread of real encrypted units from each extent and pick
// the held key that opens one (the `is_clean` proof is sound HERE — samples
// are guaranteed real content, not the authored-bad units that trip the mux).
let sample_units = |reader: &mut dyn SectorSource, start: u32, sectors: u32| -> Vec<Vec<u8>> {
let total_units = sectors / ALIGNED_UNIT_SECTORS;
let mut out = Vec::new();
if total_units == 0 {
return out;
}
const PROBES: u32 = 8;
for p in 1..=PROBES {
let unit = ((total_units as u64 * p as u64) / (PROBES as u64 + 1)) as u32;
if unit >= total_units {
continue;
}
let lba = start.saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS));
let mut buf = vec![0u8; ALIGNED_UNIT_LEN];
if reader
.read_sectors(lba, ALIGNED_UNIT_SECTORS as u16, &mut buf, false)
.is_ok()
&& aacs_unit_encrypted(&buf, format)
{
out.push(buf);
}
}
out
};
let pick = |samples: &[Vec<u8>], pool: &[(u32, [u8; 16])]| -> Option<usize> {
for (i, (_, k)) in pool.iter().enumerate() {
if samples.iter().any(|s| {
let mut u = s.clone();
decrypt_unit(&mut u, k);
is_clean(&u, format)
}) {
return Some(i);
}
}
None
};
let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(title.extents.len());
let mut last_idx = 0usize;
for ext in &title.extents {
let samples = sample_units(reader, ext.start_lba, ext.sector_count);
// Snapshot the current pool for the pure `pick` closure.
let pool: Vec<(u32, [u8; 16])> = match keys {
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.clone(),
_ => Vec::new(),
};
let mut idx = pick(&samples, &pool);
if idx.is_none() {
if let Some(f) = fetch {
if !samples.is_empty() {
let fresh = f.unit_keys(&samples);
if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys {
for k in fresh {
if !unit_keys.iter().any(|(_, h)| *h == k) {
let i = unit_keys.len() as u32;
unit_keys.push((i, k));
}
}
idx = pick(&samples, unit_keys);
}
}
}
}
// A bad-content extent no sample can classify inherits its predecessor's
// key (CPS boundaries are contiguous, so the neighbour is almost always
// right); this never storms and never mis-fails a decryptable disc.
let idx = idx.unwrap_or(last_idx);
last_idx = idx;
ranges.push((
ext.start_lba,
ext.start_lba.saturating_add(ext.sector_count),
idx,
));
}
Ok(crate::decrypt::AacsKeyMap::from_ranges(ranges, 0))
}
/// Assemble the ISO mux pipeline (read+decrypt → demux → parse) for
/// a `FileSectorSource`-backed reader. Returns the resulting
/// `PipelinedPesStream`.
@@ -615,28 +1124,46 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
/// - `batch_sectors`: read batch size in logical (2048-byte) sectors — a
/// throughput/latency tuning knob, not a correctness parameter.
/// - `format`: container format (`BdTs` → TS demuxer, `MpegPs` → PS demuxer).
/// - `raw`: ciphertext passthrough. When `true`, the per-title CSS crack
/// (`resolve_dvd_title_key`) is skipped entirely — no key is resolved and a
/// scrambled title is neither descrambled nor hard-failed.
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the
/// pipeline stops at the next boundary. `None` disables cancellation.
/// pipeline stops at the next boundary (and the CSS crack surfaces `Halted`).
/// `None` disables cancellation.
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
/// - `fetch`: optional fresh-key-on-failure callback (see
/// [`crate::sector::KeyFetch`]). When a unit no held key decrypts, the
/// decrypt decorator hands that ciphertext to `fetch` and adds any key it
/// returns, then re-decrypts. `None` means no mid-stream key recovery — the
/// unit's best-effort bytes pass through to the muxer as-is.
// Eight reader/title/keys/tuning/callback params is inherent to the mux entry
/// - `fetch`: optional key source used UP FRONT by [`resolve_mux_key_map`] to
/// secure any CPS-unit key the pool is missing. Not a per-unit mux-time
/// callback: the map decides the key for every LBA before the read loop starts.
// Nine reader/title/keys/tuning/callback params is inherent to the mux entry
// point; grouping them into a struct would only move the same fields around.
#[allow(clippy::too_many_arguments)]
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
reader: S,
mut reader: S,
title: DiscTitle,
keys: crate::decrypt::DecryptKeys,
mut keys: crate::decrypt::DecryptKeys,
batch_sectors: u16,
format: ContentFormat,
raw: bool,
halt: Option<crate::halt::Halt>,
event_fn: Option<crate::sector::prefetched::EventFn>,
fetch: Option<crate::sector::KeyFetch>,
) -> io::Result<PipelinedPesStream> {
let extents = title.extents.clone();
// CSS (DVD) key resolution — the shared per-title step (also used by the
// live-drive single-pass `DiscStream`). A `None`/MPEG-PS title cracks its own
// key from the reader in playback order; AACS `.evo` (also MPEG-PS) arrives as
// `Aacs` and is untouched; a clear DVD stays `None`; `raw` skips it entirely.
// Without this a detection-miss CSS DVD would mux scrambled sectors as corrupt
// video. `halt` lets /api/stop interrupt the crack scan.
crate::css::resolve_dvd_title_key(
&mut reader,
&extents,
&mut keys,
batch_sectors,
format,
raw,
halt.as_ref(),
)?;
// Unit alignment is an AACS concept: AACS decrypts whole 6144-byte (3-sector)
// units, so the producer must hand the decrypt step 3-sector-aligned batches.
// CSS (DVD) and unencrypted content decrypt per 2048-byte sector — forcing
@@ -646,20 +1173,35 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
_ => 1,
};
// MUX path: read > decrypt > mux. The decrypt seam applies the CPS unit key and
// passes the bytes to the muxer; a unit that decrypts to broken TS is the
// muxer's problem, not a decrypt failure, so the mux never conceals a unit or
// counts it as loss.
// MUX path: read > decrypt > mux. Resolve the proactive AACS key map UP FRONT
// — one key per CPS unit / segment, secured from the configured source and
// recorded against the LBA ranges it covers. The mux then decrypts each unit
// with its KNOWN key and trusts it: no per-unit `is_clean` verdict, no reactive
// key-fetch, no key-server storm. A unit that decrypts to broken TS is the
// muxer's problem, exactly as before. AACS-only; CSS self-cracks per region.
let key_map =
match &keys {
crate::decrypt::DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(
resolve_mux_key_map(&mut reader, &title, &mut keys, fetch.as_ref(), format)?,
)),
_ => None,
};
// The map IS the title's read plan: it says which CPS unit / forensic segment
// each LBA belongs to. Walk ONLY the units it marks as ours — every default /
// CPS unit, and inside an FMTS forensic segment only our-phase units. The
// alternate-phase units are a different device group's variant; a licensed
// player never reads them, and neither do we — they are never fetched,
// decrypted, or handed to the demux, so the demux sees one gapless our-variant
// stream (no ciphertext to trip a concealed-gap resync). A non-forensic map
// returns the extents unchanged, so the common disc reads exactly as before.
let extents = match &key_map {
Some(map) => map.read_plan(&extents, unit_align as u32),
None => extents,
};
let mut decrypting =
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
// Install the fresh-key-on-failure callback (if the app supplied one). This is
// how multi-CPS is muxed: each CPS unit's key is fetched when the mux reaches a
// unit no held key opens — "get the key when we need it." It fires only on a
// genuine miss: now that key selection is accurate (`is_clean_ts`), a unit that
// decrypted correctly but has bad-encoded TS is NOT a miss, so this no longer
// storms the key source the way the old TS supermajority gate did.
if let Some(cb) = fetch {
decrypting = decrypting.with_key_fetch(cb);
if let Some(map) = key_map {
decrypting = decrypting.with_key_map(map);
}
// Loss-counter handle. The mux does NOT tally decrypt-quality misses: a
// broken-TS unit is the muxer's concern, and a missing key is an up-front
@@ -1184,6 +1726,7 @@ mod tests {
DecryptKeys::None,
8192,
ContentFormat::BdTs,
false,
None,
None,
None,
@@ -1225,6 +1768,7 @@ mod tests {
DecryptKeys::None,
8192,
ContentFormat::BdTs,
false,
None,
None,
None,
@@ -1277,10 +1821,56 @@ mod tests {
DecryptKeys::None,
0,
ContentFormat::BdTs,
false,
None,
None,
None,
);
assert!(res.is_err(), "zero batch_sectors must be rejected");
}
/// REGRESSION (autorip production corruption): `build_iso_pipeline` for a DVD
/// (MPEG-PS) with `None` keys — what autorip's mux passes on a detection-miss
/// DVD (`disc.decrypt_keys()` == None) — must resolve the CSS key from the
/// reader itself. A scrambled-but-uncrackable title must HARD-FAIL, never
/// build a passthrough pipeline that muxes the scrambled sectors as corrupt
/// video. Before this fix, autorip handed None straight through and the mux
/// wrote garbage at exit 0.
#[test]
fn build_iso_pipeline_dvd_none_keys_scrambled_hard_fails() {
// One CSS-scrambled, crib-less (uncrackable) MPEG-PS sector.
let key = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let mut sec = vec![0u8; 2048];
sec[0..4].copy_from_slice(&crate::css::PACK_START);
for (i, b) in sec.iter_mut().enumerate().take(0x80).skip(4) {
*b = (i as u8).wrapping_mul(7).wrapping_add(1); // non-repeating → no crib
}
sec[0x14] = 0x10; // scramble flag
for (i, b) in sec.iter_mut().enumerate().skip(0x80) {
*b = (i as u8) ^ 0x3C;
}
crate::css::lfsr::scramble_sector(&key, &mut sec);
let mut title = aac_audio_title(0x1100);
title.extents = vec![Extent {
start_lba: 0,
sector_count: 1,
}];
let res = build_iso_pipeline(
MemSource { data: sec },
title,
DecryptKeys::None,
8192,
ContentFormat::MpegPs,
false,
None,
None,
None,
);
assert!(
res.is_err(),
"a scrambled DVD title with no key must hard-fail, not build a scrambled-passthrough pipeline"
);
}
}
+217 -34
View File
@@ -28,14 +28,67 @@ use super::SectorSource;
/// empty if the source can't help. Mirrors the DVD model (try the held key,
/// then ask the key source for the failing data) generalised to AACS.
///
/// The library performs NO key lookup or network I/O itself; this closure is
/// the seam an application uses to call its key source (e.g. an online key
/// service) with the exact ciphertext that failed. A **stateless, shared**
/// `Arc<Fn>` — the decorator owns the only mutable state (its call-count cap and
/// spent flag), so one closure is built once and cloned cheaply into every read
/// path (sweep / patch / mux); no per-decorator factory is needed. `Send + Sync`
/// The library performs NO key lookup or network I/O itself; this is the seam an
/// application uses to call its key source (e.g. an online key service) with the
/// exact ciphertext that failed.
///
/// TWO explicit operations, so the "one base key vs a whole forensic set"
/// contract lives in the type instead of a caller guessing at the return length:
///
/// * [`unit_keys`](Self::unit_keys) — the base Unit Key(s) for a CPS unit, from
/// real encrypted samples drawn from it. The non-forensic path: one key per CPS
/// unit (the pool grows by whatever it returns). Used by the mux's base /
/// multi-CPS map resolution AND by the sweep/patch recovery decorator.
/// * [`fmts_indexes`](Self::fmts_indexes) — the disc's AACS 2.1 forensic index
/// keys, from an index-1 single-phase anchor batch. The source hands back the
/// COMPLETE set (ordered index 1..N); the caller sizes the forensic map to
/// `len()` and never assumes a fixed N (32 is all we've seen, but the contract
/// is "whatever the source returns, ≥ 1, is all of them").
///
/// A **stateless, shared** pair of `Arc<Fn>` — the decorator owns the only
/// mutable state (its call-count cap and spent flag), so one `KeyFetch` is built
/// once and cloned cheaply (two `Arc` bumps) into every read path. `Send + Sync`
/// 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>;
/// A closure resolving keys from encrypted-content samples — the shape of both
/// [`KeyFetch`] operations. Named so the two constructors (and the struct fields)
/// read clearly.
pub type KeyFetchFn = std::sync::Arc<dyn Fn(&[Vec<u8>]) -> Vec<[u8; 16]> + Send + Sync>;
#[derive(Clone)]
pub struct KeyFetch {
unit: KeyFetchFn,
fmts: KeyFetchFn,
}
impl KeyFetch {
/// Build a resolver from its two operations: `unit` resolves base Unit Keys
/// from a CPS unit's samples; `fmts` resolves the forensic index set from an
/// index-1 anchor batch.
pub fn new(unit: KeyFetchFn, fmts: KeyFetchFn) -> Self {
Self { unit, fmts }
}
/// A resolver that serves ONLY base Unit Keys; [`fmts_indexes`](Self::fmts_indexes)
/// is always empty. For read paths that never resolve forensic keys — the
/// sweep/patch recovery decorator, which handles CPS units only.
pub fn unit_only(unit: KeyFetchFn) -> Self {
Self::new(unit, std::sync::Arc::new(|_| Vec::new()))
}
/// Resolve the base Unit Key(s) for a CPS unit from `samples` (real encrypted
/// units drawn from it). Normally one key; the caller adds whatever it returns
/// to the pool.
pub fn unit_keys(&self, samples: &[Vec<u8>]) -> Vec<[u8; 16]> {
(self.unit)(samples)
}
/// Resolve the disc's AACS 2.1 forensic index keys from an index-1 single-
/// phase `anchor` batch. The source returns the COMPLETE ordered set (index i
/// = element i); the caller trusts any non-empty result as all of them.
pub fn fmts_indexes(&self, anchor: &[Vec<u8>]) -> Vec<[u8; 16]> {
(self.fmts)(anchor)
}
}
/// Decorator: read from `inner`, then run the configured
/// AACS / CSS decrypt over the bytes that landed in `buf`.
@@ -79,6 +132,14 @@ pub struct DecryptingSectorSource<S: SectorSource> {
/// batches at highway speed, now that the mux installs a key-fetch for
/// multi-CPS — reuses one allocation instead of alloc/free-ing every read.
cipher_scratch: Vec<u8>,
/// Proactive AACS key map (see [`crate::decrypt::AacsKeyMap`]). When set, the
/// mux resolved one key per CPS unit / segment UP FRONT, so this read decrypts
/// each aligned unit with its MAPPED key and TRUSTS it — no per-unit
/// `is_clean` verdict, no reactive key-fetch, no key-server storm. `None`
/// keeps the legacy trial-and-recover path (sweep / patch, or a mux that did
/// not build a map). Mutually exclusive with `recovery` in practice: the mux
/// installs one or the other.
key_map: Option<Arc<crate::decrypt::AacsKeyMap>>,
}
impl<S: SectorSource> DecryptingSectorSource<S> {
@@ -99,9 +160,29 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
recovery: None,
content_ranges: None,
cipher_scratch: Vec::new(),
key_map: None,
}
}
/// Install a proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap): the mux
/// resolved one key per CPS unit / segment up front, so every aligned unit is
/// decrypted with its MAPPED key and trusted — no per-unit `is_clean` check,
/// no reactive key-fetch. This is the storm-free mux path; it supersedes
/// [`with_key_fetch`](Self::with_key_fetch) (do not set both). AACS-only; a
/// CSS / clear disc ignores it.
pub fn with_key_map(mut self, map: Arc<crate::decrypt::AacsKeyMap>) -> Self {
self.key_map = Some(map);
self
}
/// `&mut` counterpart of [`with_key_map`](Self::with_key_map): install the
/// proactive map on an already-constructed source (the inline live-drive
/// [`DiscStream`](crate::mux::DiscStream) builds the decorator first, then
/// installs the map via its own `with_key_map`).
pub fn set_key_map(&mut self, map: Arc<crate::decrypt::AacsKeyMap>) {
self.key_map = Some(map);
}
/// Restrict decrypt to the disc's encrypted-content extents
/// (sorted/merged `(start_lba, sector_count)` — see
/// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)).
@@ -221,6 +302,18 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
let n = self
.inner
.read_sectors_fua(lba, count, buf, recovery, fua)?;
// PROACTIVE MAP PATH (the storm-free mux): when a key map is installed the
// mux resolved one key per CPS unit / segment up front, so decrypt each
// aligned unit with its MAPPED key and trust it — no per-unit `is_clean`
// verdict, no reactive key-fetch. A resolver gap surfaces loud from
// `decrypt_sectors_mapped` (DecryptFailed); authored-bad TS just passes
// through for the muxer to drop.
if let Some(map) = self.key_map.clone() {
crate::decrypt::decrypt_sectors_mapped(&mut buf[..n], &self.keys, lba, &map)?;
return Ok(n);
}
// Decrypt the bytes just read IN PLACE. Scheme-agnostic (None / CSS / AACS).
// With a content map installed the `*_in_content` entry skips units OUTSIDE
// the encrypted extents (clear filesystem / nav pass through untouched); the
@@ -1135,10 +1228,11 @@ mod tests {
// Capture what the callback was handed, and how many times it fired.
let seen: Arc<Mutex<Vec<Vec<u8>>>> = Arc::new(Mutex::new(Vec::new()));
let seen_cb = Arc::clone(&seen);
let fetch: super::KeyFetch = std::sync::Arc::new(move |samples: &[Vec<u8>]| {
seen_cb.lock().unwrap().extend_from_slice(samples);
vec![real_key]
});
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec<u8>]| {
seen_cb.lock().unwrap().extend_from_slice(samples);
vec![real_key]
}));
let mut wrapped = DecryptingSectorSource::new(
EncUnitSource { unit: unit.clone() },
@@ -1156,7 +1250,7 @@ mod tests {
// The recovered key decrypts the unit: it is now clean TS in `buf`.
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
assert!(
!crate::aacs::content::ts_sync_destroyed(unit0),
crate::aacs::content::is_clean(unit0, crate::disc::ContentFormat::BdTs),
"fetch supplied the key → the unit decrypts to clean TS"
);
let got = seen.lock().unwrap();
@@ -1166,7 +1260,7 @@ mod tests {
"callback must be invoked once with the failing unit"
);
assert!(
crate::aacs::content::ts_sync_destroyed(&got[0]),
!crate::aacs::content::is_clean(&got[0], crate::disc::ContentFormat::BdTs),
"the sample handed to the callback is the still-scrambled ciphertext"
);
assert_eq!(
@@ -1175,6 +1269,92 @@ mod tests {
);
}
/// THE MUX-STORM REGRESSION. A unit the held key OPENS (>= the 4-packet proof
/// floor) but that carries many authored-bad packets (< half synced) must
/// NEVER be handed to the key-fetch closure — its key is already in hand. Only
/// a GENUINE miss (no held key opens it) is sampled. Before the min(E,4)
/// unification, the bad-encoded unit tripped the old >50% majority in
/// `aacs_unit_needs_decrypt`, so every batch re-sampled it to the key service
/// (the Jason Bourne / Stand By Me stall). This drives the REAL
/// `DecryptingSectorSource` recovery path, not a synthetic check.
#[test]
fn bad_encoded_opened_unit_is_never_sampled_to_the_key_service() {
use crate::aacs::content::ALIGNED_UNIT_LEN;
let held = [0x5au8; 16]; // opens the bad-encoded unit
let orphan = [0x77u8; 16]; // opens the genuine-miss unit (NOT held)
// Knock out packets 1..27 (26 authored-bad) → ~5 synced: >= the 4-packet
// floor (OPENED) yet < half (what the old >50% majority false-flagged).
let bad_pkts: Vec<usize> = (1..27).collect();
let bad_encoded = encrypt_aacs_unit_bad(&held, &bad_pkts);
let genuine_miss = encrypt_aacs_unit(&orphan);
// One 6-sector read spans both units: bad-encoded at [0,3), miss at [3,6).
struct TwoUnits {
a: Vec<u8>,
b: Vec<u8>,
}
impl SectorSource for TwoUnits {
fn capacity_sectors(&self) -> u32 {
6
}
fn read_sectors(
&mut self,
_lba: u32,
_count: u16,
buf: &mut [u8],
_r: bool,
) -> Result<usize> {
let n = crate::aacs::content::ALIGNED_UNIT_LEN;
buf[..n].copy_from_slice(&self.a);
buf[n..2 * n].copy_from_slice(&self.b);
Ok(2 * n)
}
}
let seen: Arc<Mutex<Vec<Vec<u8>>>> = Arc::new(Mutex::new(Vec::new()));
let seen_cb = Arc::clone(&seen);
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec<u8>]| {
seen_cb.lock().unwrap().extend_from_slice(samples);
Vec::new() // service has nothing for the orphan — forces the sampling path
}));
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 6u32)]);
let mut dec = DecryptingSectorSource::new(
TwoUnits {
a: bad_encoded.clone(),
b: genuine_miss.clone(),
},
DecryptKeys::Aacs {
unit_keys: vec![(0, held)], // opens bad_encoded, NOT genuine_miss
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_content_ranges(ranges)
.with_key_fetch(fetch);
let mut buf = vec![0u8; 6 * 2048];
let _ = dec.read_sectors(0, 6, &mut buf, false);
let got = seen.lock().unwrap();
assert!(
!got.is_empty(),
"the genuine orphan-key miss must trigger a fetch"
);
for s in got.iter() {
assert_ne!(
&s[..ALIGNED_UNIT_LEN.min(s.len())],
&bad_encoded[..],
"a bad-encoded unit the key OPENED must NEVER be sampled (the storm)"
);
}
assert!(
got.iter().any(|s| s.as_slice() == genuine_miss.as_slice()),
"only the genuine miss is sampled to the key service"
);
}
/// A fetch that comes back EMPTY for one unit must NOT block a later fetch
/// for a DIFFERENT unit (the multi-CPS case). The old global `fetch_spent`
/// latch wrongly blocked it; the per-sample `fetch_dry` set must let unit B
@@ -1220,14 +1400,15 @@ mod tests {
let unit_b_cb = unit_b.clone();
let calls = Arc::new(Mutex::new(0usize));
let calls_cb = Arc::clone(&calls);
let fetch: super::KeyFetch = std::sync::Arc::new(move |samples: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
if samples.iter().any(|s| *s == unit_b_cb) {
vec![key_b]
} else {
vec![]
}
});
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
if samples.iter().any(|s| *s == unit_b_cb) {
vec![key_b]
} else {
vec![]
}
}));
let mut wrapped = DecryptingSectorSource::new(
AltSource {
@@ -1257,7 +1438,7 @@ mod tests {
"fetch fired for BOTH units — the dry result for A did not latch off B"
);
assert!(
!crate::aacs::content::ts_sync_destroyed(&buf2),
crate::aacs::content::is_clean(&buf2, crate::disc::ContentFormat::BdTs),
"unit B is decrypted after its on-demand fetch"
);
}
@@ -1361,13 +1542,14 @@ mod tests {
let calls = Arc::new(Mutex::new(0usize));
let calls_cb = Arc::clone(&calls);
let fetch: super::KeyFetch = std::sync::Arc::new(move |samples: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
// The closure is handed the still-scrambled on-disc ciphertext.
assert!(!samples.is_empty(), "fetch receives the failing units");
assert_eq!(samples[0].len(), crate::aacs::content::ALIGNED_UNIT_LEN);
vec![real_key]
});
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
// The closure is handed the still-scrambled on-disc ciphertext.
assert!(!samples.is_empty(), "fetch receives the failing units");
assert_eq!(samples[0].len(), crate::aacs::content::ALIGNED_UNIT_LEN);
vec![real_key]
}));
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 6u32)]); // LBA 0..6 content
let mut dec = DecryptingSectorSource::new(
@@ -1388,7 +1570,7 @@ mod tests {
.expect("fetch recovers the orphan unit's key");
assert_ne!(buf, unit, "the fetched key decrypts the unit in place");
assert!(
!crate::aacs::content::ts_sync_destroyed(&buf),
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
"the recovered read is clean TS"
);
assert_eq!(*calls.lock().unwrap(), 1, "fetch called exactly once");
@@ -1446,10 +1628,11 @@ mod tests {
let unit = encrypt_aacs_unit(&real_key);
let calls = Arc::new(Mutex::new(0usize));
let calls_cb = Arc::clone(&calls);
let fetch: super::KeyFetch = std::sync::Arc::new(move |_: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
vec![real_key]
});
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |_: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
vec![real_key]
}));
// Content lives far away; LBA 0 is "filesystem".
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(1002u32, 99u32)]);
let mut dec = DecryptingSectorSource::new(
+1 -1
View File
@@ -189,7 +189,7 @@ pub trait SectorSink: Send {
}
pub use crate::io::file_sector_source::FileSectorSource;
pub use decrypting::{DecryptingSectorSource, KeyFetch};
pub use decrypting::{DecryptingSectorSource, KeyFetch, KeyFetchFn};
pub use file::FileSectorSink;
pub use prefetched::PrefetchedSectorSource;
+7 -7
View File
@@ -170,7 +170,7 @@ fn aacs_fetch_step(
return prev_dropped;
}
*calls += 1;
let fresh = (fetch)(&samples);
let fresh = fetch.unit_keys(&samples);
// Add only keys we don't already hold (dedup by value).
let mut added = 0usize;
if let DecryptKeys::Aacs { unit_keys, .. } = keys {
@@ -276,11 +276,11 @@ mod tests {
// 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>]| {
let fetch: KeyFetch = KeyFetch::unit_only(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 {
@@ -304,10 +304,10 @@ mod tests {
// 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>]| {
let fetch: KeyFetch = KeyFetch::unit_only(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![],
@@ -331,10 +331,10 @@ mod tests {
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>]| {
let fetch: KeyFetch = KeyFetch::unit_only(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![],
+15 -9
View File
@@ -131,12 +131,15 @@ fn aacs_decrypt_unit_roundtrip() {
}
// Verify it looks encrypted (body TS syncs scrambled)
assert!(aacs::content::ts_sync_destroyed(&plain));
assert!(!aacs::content::is_clean(
&plain,
libfreemkv::disc::ContentFormat::BdTs
));
// Now decrypt
aacs::content::decrypt_unit(&mut plain, &unit_key);
assert!(
!aacs::content::ts_sync_destroyed(&plain),
aacs::content::is_clean(&plain, libfreemkv::disc::ContentFormat::BdTs),
"decrypted unit should read as clear (TS syncs restored)"
);
@@ -254,9 +257,9 @@ fn aacs_vuk_derivation_roundtrip() {
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
}
/// Test: aacs_ts_sync_destroyed detects scrambled units via the raw TS syncs.
/// Test: `is_clean` distinguishes clean vs scrambled units via the TS proof floor.
#[test]
fn aacs_ts_sync_destroyed_detection() {
fn aacs_is_clean_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::content::ALIGNED_UNIT_LEN];
@@ -266,7 +269,7 @@ fn aacs_ts_sync_destroyed_detection() {
off += 192;
}
assert!(
!aacs::content::ts_sync_destroyed(&clear),
aacs::content::is_clean(&clear, libfreemkv::disc::ContentFormat::BdTs),
"clear unit (syncs intact) must not be scrambled"
);
@@ -275,21 +278,21 @@ fn aacs_ts_sync_destroyed_detection() {
flagged[0] = 0xC0; // copy-control bits
flagged[7] = 0xC0; // TSC bits
assert!(
!aacs::content::ts_sync_destroyed(&flagged),
aacs::content::is_clean(&flagged, libfreemkv::disc::ContentFormat::BdTs),
"flag bits must not be read as encryption"
);
// A scrambled body (syncs destroyed) → scrambled.
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
assert!(
aacs::content::ts_sync_destroyed(&scrambled),
!aacs::content::is_clean(&scrambled, libfreemkv::disc::ContentFormat::BdTs),
"unit with no intact TS syncs must read as scrambled"
);
// Too short
let short = vec![0xFFu8; 100];
assert!(
!aacs::content::ts_sync_destroyed(&short),
aacs::content::is_clean(&short, libfreemkv::disc::ContentFormat::BdTs),
"short buffer should not be detected"
);
}
@@ -312,7 +315,10 @@ fn aacs_clear_unit_reports_not_encrypted() {
// CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext.
unit[0] &= 0x3F;
assert!(!aacs::content::ts_sync_destroyed(&unit));
assert!(aacs::content::is_clean(
&unit,
libfreemkv::disc::ContentFormat::BdTs
));
assert!(
!aacs::content::aacs_unit_encrypted(&unit, libfreemkv::disc::ContentFormat::BdTs),
"CPI-clear unit reports not-encrypted; the caller never decrypts it"
+1
View File
@@ -175,6 +175,7 @@ fn run_to_fvi(image: Vec<u8>, title: DiscTitle, path: &std::path::Path) {
DecryptKeys::None,
3, // 3-sector (one AACS unit) batches → one source stamp per GOP region
ContentFormat::MpegPs,
false,
None,
None,
None,
+20 -2
View File
@@ -136,7 +136,16 @@ fn test_bytes_read_emitted_during_disc_copy() {
let title = synthetic_title(64);
let keys = libfreemkv::DecryptKeys::None;
let mut stream = DiscStream::new(Box::new(reader), title, keys, 60, ContentFormat::BdTs);
let mut stream = DiscStream::new(
Box::new(reader),
title,
keys,
60,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
let count = Arc::new(AtomicU64::new(0));
let count_cb = count.clone();
@@ -286,7 +295,16 @@ fn test_drop_impls_do_not_panic_or_block() {
let reader = ZeroSectorReader::new(64);
let title = synthetic_title(64);
let keys = libfreemkv::DecryptKeys::None;
let stream = DiscStream::new(Box::new(reader), title, keys, 60, ContentFormat::BdTs);
let stream = DiscStream::new(
Box::new(reader),
title,
keys,
60,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
// Drop on a worker thread; main thread enforces the timeout.
let handle = std::thread::spawn(move || {
+12 -3
View File
@@ -108,16 +108,25 @@ fn aacs_encryption_flag_detection() {
off += 192;
}
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
assert!(!aacs::content::ts_sync_destroyed(&unit));
assert!(aacs::content::is_clean(
&unit,
libfreemkv::disc::ContentFormat::BdTs
));
// Flag bits on a synced unit do not make it look encrypted.
unit[0] = 0xC0;
unit[7] = 0xC0;
assert!(!aacs::content::ts_sync_destroyed(&unit));
assert!(aacs::content::is_clean(
&unit,
libfreemkv::disc::ContentFormat::BdTs
));
// Scrambled body (syncs gone) → encrypted.
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
assert!(aacs::content::ts_sync_destroyed(&scrambled));
assert!(!aacs::content::is_clean(
&scrambled,
libfreemkv::disc::ContentFormat::BdTs
));
}
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
+5 -5
View File
@@ -591,7 +591,7 @@ fn meta_all_stream_types() {
fn mkvstream_write_finish() {
let dt = sample_disc_title();
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> = Box::new(Cursor::new(Vec::new()));
let mut stream = MkvStream::create(writer, &dt).unwrap();
let mut stream = MkvStream::create(writer, &dt, None).unwrap();
// Write some fake PES frames (they won't produce valid MKV content
// since there is no real codec data, but it should not panic)
@@ -616,7 +616,7 @@ fn mkvstream_write_finish() {
fn mkvstream_meta_sets_title() {
let dt = sample_disc_title();
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> = Box::new(Cursor::new(Vec::new()));
let stream = MkvStream::create(writer, &dt).unwrap();
let stream = MkvStream::create(writer, &dt, None).unwrap();
let info = PesStream::info(&stream);
assert_eq!(info.playlist, "Test Movie");
@@ -656,7 +656,7 @@ fn mkvstream_roundtrip_bdts() {
};
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> = Box::new(Cursor::new(Vec::new()));
let mut stream = MkvStream::create(writer, &dt).unwrap();
let mut stream = MkvStream::create(writer, &dt, None).unwrap();
// Write PES frames targeting the audio track
for i in 0..10u8 {
@@ -745,7 +745,7 @@ fn mkvstream_meta_preserves_all_streams() {
};
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> = Box::new(Cursor::new(Vec::new()));
let stream = MkvStream::create(writer, &dt).unwrap();
let stream = MkvStream::create(writer, &dt, None).unwrap();
let info = PesStream::info(&stream);
assert_eq!(info.streams.len(), 5, "all 5 streams should be preserved");
@@ -856,7 +856,7 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> =
Box::new(SharedWriter(output2.clone()));
let mut stream2 = MkvStream::create(writer, &dt).unwrap();
let mut stream2 = MkvStream::create(writer, &dt, None).unwrap();
// Write the ES data (SPS+PPS+IDR) as a keyframe PES frame.
let frame1 = libfreemkv::pes::PesFrame {