Compare commits

..
681 Commits
Author SHA1 Message Date
Matthew Jackson de4658dab8 scsi(macos): take the default IOKit port as MACH_PORT_NULL, no availability check for the Rust link to resolve 2026-10-01 17:19:24 -07:00
Matthew Jackson f7ed7278b2 Keep a Stop over the empty-output MkvInvalid it causes 2026-10-01 15:27:23 -07:00
Matthew Jackson ca0f9d5bc8 Keep a strict read error over the empty-output MkvInvalid it causes 2026-10-01 15:23:31 -07:00
Matthew Jackson 134fdd973f Cargo.lock: drop local patch.unused entries 2026-10-01 15:17:42 -07:00
Matthew Jackson 09b9324903 Merge branch 'audit-lib-G6' into audit-fix-lib 2026-10-01 15:17:42 -07:00
Matthew Jackson 6af1dcd912 Merge branch 'audit-lib-G5' into audit-fix-lib 2026-10-01 15:16:36 -07:00
Matthew Jackson 735efac62b mkv tests: drop redundant cast 2026-10-01 15:15:06 -07:00
Matthew Jackson 92b14006a2 mux: EditionFlagDefault uses its registered ID, SeekHead refuses an unreserved target; tests for decode_ts reorder clamp, VobSub stop, field order, late AAC CodecPrivate 2026-10-01 15:14:54 -07:00
Matthew Jackson f49f966c46 video codecs: pin param-set re-assert, SEI, discontinuity, slice types, field pairing, reorder; warn on unparseable HEVC SPS 2026-10-01 15:14:11 -07:00
Matthew Jackson d3534bafdd audio codecs: pin ADTS/PCE, DTS CRC/EXSS, LPCM tables, free-format padding, TrueHD LFE; doc and naming fixes 2026-10-01 15:14:11 -07:00
Matthew Jackson 566c9c8df3 Merge branch 'audit-lib-G8' into audit-fix-lib 2026-10-01 15:12:46 -07:00
Matthew Jackson 92a79afef0 Merge branch 'audit-lib-G7' into audit-fix-lib 2026-10-01 15:12:46 -07:00
Matthew Jackson b14e6f1113 Merge branch 'audit-lib-G4' into audit-fix-lib 2026-10-01 15:12:45 -07:00
Matthew Jackson 8f5b45b907 Merge branch 'audit-lib-G3' into audit-fix-lib 2026-10-01 15:12:45 -07:00
Matthew Jackson 31a0f0d4d2 Merge branch 'audit-lib-G2b' into audit-fix-lib 2026-10-01 15:12:45 -07:00
Matthew Jackson 3b24038700 Merge branch 'audit-lib-G2a' into audit-fix-lib 2026-10-01 15:12:45 -07:00
Matthew Jackson 38b302fc86 lint_stop: drop the ctrm items_after_test_module baseline entry 2026-10-01 15:11:16 -07:00
Matthew Jackson a0a058a012 fmt, clippy 2026-10-01 15:09:36 -07:00
Matthew Jackson d8c6ea7fb1 labels/deluxe: fetch the binding class by name, escape disc text, test confidence and union bounds 2026-10-01 15:09:19 -07:00
Matthew Jackson 10e2d400bd demux_thread: test the PS tail flush 2026-10-01 15:08:28 -07:00
Matthew Jackson 286997119a select: test extension pairing by index 2026-10-01 15:07:17 -07:00
Matthew Jackson c37ef82dbf tests: header gate rebase and subtitle, fvi header provenance, hvcC cut arrays, FMKV stream fields, json mvc and cue times 2026-10-01 15:07:14 -07:00
Matthew Jackson bbeb2d5ead pipelined: test the resync gate on the EOF flush 2026-10-01 15:06:22 -07:00
Matthew Jackson b8f88bfe9a stdio: tests for the zero-frame header and frame padding 2026-10-01 15:05:39 -07:00
Matthew Jackson 6fc8f657ac selected: test for translated per-track answers and passthrough 2026-10-01 15:04:29 -07:00
Matthew Jackson 089ef9edda dirimage: keep PVD interchange level 3 (golden images pin it) 2026-10-01 15:03:28 -07:00
Matthew Jackson 19066aa771 labels: log caught parser panics, pin label tables and selection rules with tests 2026-10-01 15:02:36 -07:00
Matthew Jackson 535d1b8dd9 disc: warn on unreadable cert and MKB prefix, cap null mapfile name, release MKB padding capacity, fix stale docs; table tests 2026-10-01 15:02:03 -07:00
Matthew Jackson 82b2dd6cd2 disc tests: DVD nav join, halt polls, HD DVD Stop handling, PGS LBA bound, forced flag, unreadable MKB and cert 2026-10-01 15:02:03 -07:00
Matthew Jackson 389340bb92 extract: a File Entry that fails after listing loses that file only; loss, progress and blank accounting tests 2026-10-01 15:02:03 -07:00
Matthew Jackson 4876f61d47 bluray: 3D size follows the interleaved extents, meta title drops control characters; scan tests for skipped playlist, late Stop, nibble mapping 2026-10-01 15:02:03 -07:00
Matthew Jackson 927bc1f8ec network: tests for timing range and header guards and the incomplete reset 2026-10-01 15:01:53 -07:00
Matthew Jackson cd208c8c18 demux_sink: authored PGS clear beats a capped duration, hostile chapter names neutralised; driver: stop paths pinned, dead live_keyed param removed 2026-10-01 15:00:26 -07:00
Matthew Jackson 66162585d1 tsmux/ps/videomap: doc and comment fixes; tests for MPEG audio stream ids and display aspect 2026-10-01 14:59:10 -07:00
Matthew Jackson 1355b35026 session: no expect after scan; identity: raw INQUIRY doc; tests for ctx env switches, diag labels, firmware date cap, unlock bridge mapping 2026-10-01 14:58:07 -07:00
Matthew Jackson c9e7f81036 sector: an unreadable AACS unit head is a guess, not a cached CPI; docs and batch/threshold dedupe; tests for blank reporting, byte-view clip, producer latch, BD-TS gates, forwarding 2026-10-01 14:58:06 -07:00
Matthew Jackson 5189ef1f6e drive/scsi: DVD+ DL media is DVD, sg open errno keeps its cause, status-only failures carry no sense; may-block labels, stale comments; tests for the stop, CDB and allow-list guards 2026-10-01 14:58:06 -07:00
Matthew Jackson 858847ffcf ts: cap path reuses drop_partial; resolve: dead raw-key copy removed, AACS unit constant; ts tests 2026-10-01 14:54:45 -07:00
Matthew Jackson 2908d11f09 io: accurate docs for open_block_sink, debug_enabled, drop-chunk comment; SAFETY note on macOS prefetch 2026-10-01 14:52:23 -07:00
Matthew Jackson cb3949f390 io, whole_disc: tests for durable-sync errors and clamps, padded reads, flusher latch, rebind, WAIT_AFTER credit; fix flusher and writeback docs 2026-10-01 14:52:22 -07:00
Matthew Jackson e408f699f4 tree sink: an unlistable target is refused, not treated as empty 2026-10-01 14:52:22 -07:00
Matthew Jackson 5aa492397a dirimage: PVD interchange level 2, warn on a vanished entry, pin extent reads, entry and metadata caps, directory links 2026-10-01 14:52:22 -07:00
Matthew Jackson 8d25ec4890 m2ts: LPCM omission as let-else; tests for AAC omission, PID uniqueness, extensions, stream types 2026-10-01 14:51:12 -07:00
Matthew Jackson 9cb0ad73d3 mpls: warn on dropped play items and STN entries; spec_guards, lint_stop tests 2026-10-01 14:50:27 -07:00
Matthew Jackson 5940b72850 aacs: segment edge, variant shift, declared CPS and read_first tests; doc and comment fixes 2026-10-01 14:49:39 -07:00
Matthew Jackson c08b71263c decrypt: tests for MpegPs blanking, wrong-key and damage verdicts; fix stale doc 2026-10-01 14:49:39 -07:00
Matthew Jackson d3436c0ff0 keys: HD DVD set spans sorted, keyless session set uses the media-id hash; unignore live DVD parity test 2026-10-01 14:49:39 -07:00
Matthew Jackson 1924037d80 keysource: sampling stops on a gone source, unparseable unit key file warns; default and format tests 2026-10-01 14:49:39 -07:00
Matthew Jackson 1ec12d0701 aacs/inf: untransferred MKB header and holed first pack fail, short VTKF is rejected; cc_id test 2026-10-01 14:49:39 -07:00
Matthew Jackson de89fbf27c css: a clear head with a mostly unreadable tail is no clear verdict; pin TAB1/TAB2 contents 2026-10-01 14:49:38 -07:00
Matthew Jackson 3cfb86f7c6 mp4: stz2 sizes, u16-truncated entry rate, edit list covers lost-frame tail, saturating start delays; tests for sample order, duration, mpeg entries 2026-10-01 14:49:24 -07:00
Matthew Jackson 087297c9d5 labels: decode bdmt entities, drop PiP PG labels, cap mpls floor, close parser test gaps 2026-10-01 14:48:55 -07:00
Matthew Jackson 9f913b7db8 ifo: fall back to the BUP when the IFO keeps fewer titles, bound chapter cells, tests 2026-10-01 14:48:16 -07:00
Matthew Jackson b242fe9ed0 mpg pstd: commencement reuses first_byte; tests for the interleave cap and unpacketizable EOF 2026-10-01 14:47:47 -07:00
Matthew Jackson 15bbb52090 mpg scan: tests for probes, track order, extension pairing, map bounds 2026-10-01 14:46:04 -07:00
Matthew Jackson 58c1191dd1 dvdnav: tests for counter-mode, rnd/swap taint and link-code range 2026-10-01 14:44:21 -07:00
Matthew Jackson 18060529a9 error: escape disc-derived names in Display; use code constants in the kind map 2026-10-01 14:42:50 -07:00
Matthew Jackson 2a95f4a41a mkvstream: foreign VFW tracks stay unknown, Channels defaults to 1, bad buffered track no longer fails activation, bounded total CodecPrivate, saturating origin lead 2026-10-01 14:42:13 -07:00
Matthew Jackson 8823e5cf6d mpg: sink warn lists every counter, video_out from the route; tests for bounds, rates, clamps 2026-10-01 14:42:09 -07:00
Matthew Jackson 7faccaab4b pgs: gap before a PCS drops an END-less set, lost PCS skips its orphans, gap resets the clear scan 2026-10-01 14:40:54 -07:00
Matthew Jackson 69329b29ca clpi, bdjo: tests for short sci and NUL-padded app strings 2026-10-01 14:39:04 -07:00
Matthew Jackson 1a3e56f8c7 bdnav: GOTO/BREAK/PLAY/SETSYSTEM tests; doc fixes in pes, consts, golden helper 2026-10-01 14:38:10 -07:00
Matthew Jackson 6ddadda5dd udf: tests for non-stream ranges and FUA bypass 2026-10-01 14:37:08 -07:00
Matthew Jackson 82217737e1 dvdsub: studio-range YCbCr palette conversion, warn on truncated SPU 2026-10-01 14:36:14 -07:00
Matthew Jackson 761e1ac212 udf: combine surrogate pairs, sanitise CS0 names, map directory extents through the metadata partition, fail short file reads 2026-10-01 14:36:10 -07:00
Matthew Jackson 60cb67c096 mpg: sink rides the clip-join timeline 2026-10-01 14:35:37 -07:00
Matthew Jackson b62efb79a8 mux: stdio header read before a selection is resolved, MP4 sink rides the clip-join timeline, laced MKV frames share one decode budget; udf/extract: unread File Entry and dead bus fail instead of staging holes 2026-10-01 14:32:41 -07:00
Matthew Jackson 62d56dff5b bdnav: BC compare per libbluray; labels: camelCase split, deluxe master enums and Rc stack names; ifo: warn on multi-PGC titles; h264/hevc: param sets lead the AU in hierarchy order 2026-10-01 14:27:14 -07:00
Matthew Jackson 08aece29ff lint: tree sink takes extract's lint baselines; recovery slot reservation is a plain CAS loop 2026-10-01 08:34:14 -07:00
Matthew Jackson 00cd0d42cf sink: dir:// is a TreeSink; extract runs the read/decrypt chain into it 2026-10-01 08:04:48 -07:00
Matthew Jackson 389725a7ee read: m2ts:// reads through the one Read stage and prefetcher; the byte prefetcher is removed 2026-10-01 08:02:21 -07:00
Matthew Jackson db69131636 decrypt: one constructor for the decryption stage (keys, a key ring's view, or content-detected) 2026-10-01 07:58:43 -07:00
Matthew Jackson 2f51b1cdcf docs: trim three comment blocks to the comment-guard caps 2026-09-30 22:52:16 -07:00
Matthew Jackson 96e538c559 cleanup: drop dead error variants (E9009, E9063, E9082), the io::sink and fmp4/m2ts_mux/HevcMux scaffolding, stale comments and doc links 2026-09-30 22:50:43 -07:00
Matthew Jackson 3a2c448a74 test: live-mux driver tests hold the drive-holder lock; drop a duplicated cfg 2026-09-30 22:17:03 -07:00
Matthew Jackson 9ab985fbb2 sink: iso:// and null:// block sinks; a live DVD extracts descrambled; an encrypted folder is keyed like its image; platform null device 2026-09-30 22:15:08 -07:00
Matthew Jackson 064679b201 read: one Read stage (live and image policies) behind one prefetcher and demux; DiscStream removed 2026-09-30 21:58:08 -07:00
Matthew Jackson 01507ddb10 pes: PesSource and PesSink replace pes::Stream; sink caps; TrueHD labels completed in-stream on every path; selection on container inputs 2026-09-30 21:44:58 -07:00
Matthew Jackson ed02bc1392 source: open_source opens every input incl. disc://; MuxSource replaced by Source; one image probe; disc-named output titles 2026-09-30 21:28:56 -07:00
Matthew Jackson 1f5f5df8cb keys: acquire from KeyEvidence into a KeyRing, not from a scanned Disc 2026-09-30 21:11:07 -07:00
Matthew Jackson 79b7893f2e ctx: one run context (halt, events, loss counters) for every stage 2026-09-30 20:36:53 -07:00
Matthew Jackson 9f87155c95 docs: name the m2ts private-data audio by its layout, not a reference implementation 2026-09-30 20:11:59 -07:00
Matthew Jackson 8b165585b8 mux: m2ts output paces a PCR and arrival stamps; ADTS keeps every raw_data_block
M2TS output had no PCR and zero TP_extra_header arrival stamps, and an AAC
access unit from a multi-block ADTS frame was re-framed as one block. Write
adaptation-only PCR packets on PID 0x1001 (the PMT's PCR_PID) at most 100 ms
apart, stamp every packet's ATS from the same 27 MHz clock (at most 1 s before
its DTS, 128 Mb/s per packet), restart with the discontinuity_indicator on a
timeline jump past 10 s, and declare the ADTS block count from the duration.

Parity goldens re-blessed: only the m2ts output lines change (the added PCR
packets and non-zero ATS).
2026-09-30 20:09:33 -07:00
Matthew Jackson 9e792fc105 mux: expose is_blocked_ip so callers vet network:// targets with the library's own rule 2026-09-30 19:54:14 -07:00
Matthew Jackson 98b17c3898 decrypt: one CSS pack test for disc and file; stage re-review fixes (blank heads, damaged stale-CPI units, refused-read retry) 2026-09-30 19:41:58 -07:00
Matthew Jackson ff3c18c404 mux: m2ts output declares tracks in an HDMV PMT; carry AAC/MP2/MP3 through m2ts input, m2ts and mp4 output
M2TS output had no PAT/PMT, so FFmpeg could not name BD LPCM (or AAC):
write PAT + HDMV-registered PMT every 100 ms of PTS progress, re-frame AAC
as ADTS from its ASC, and give MPEG audio the 0xC0 stream_id. Loose m2ts
input types FFmpeg's stream_type 0x06 audio from its stream_id and ES sync.
mp4:// carries AAC/MP2/MP3 as mp4a + esds.

Parity goldens re-blessed: only m2ts output bytes change (the added
PAT/PMT packets).
2026-09-30 19:41:46 -07:00
Matthew Jackson 1583c5e139 test: parity goldens pin today's paths (bytes, counters, verdicts) 2026-09-30 19:26:52 -07:00
Matthew Jackson d335fe5f89 mux: network:// allows LAN and loopback targets 2026-09-30 19:22:35 -07:00
Matthew Jackson b67d82a5e1 docs: fix the output_depth intra-doc link 2026-09-30 19:02:37 -07:00
Matthew Jackson 7005cc0551 decrypt: stale CPI on 3/4-synced TS incl. a partial tail; FMKV files skip the pack search
A clear unit with a few damaged packets keeps opening without keys. Also moves
the network accept_staged allow-list entry and trims a comment.
2026-09-30 18:53:31 -07:00
Matthew Jackson 880bbcab4a decrypt: stage review fixes: early stream verdict, strict stale CPI, partial units at EOF only
A byte stream is ruled out from five bytes, so network:// and stdio:// parse
the FMKV header as soon as it arrives. A stale CPI flag is cleared only on
wholly clear TS. A flagged partial unit is blanked only when it ends the
source. A file stage finds a VOB's first aligned pack past damaged sectors,
and one damaged unit at a clip's head no longer hides its source packets.
Raw mpg falls back to the raw head on E7022 too; an uncrackable PS verdict is
kept so a retry does not scan again; SectorBytes reports a short source as
UnexpectedEof; disc_root_of canonicalizes, follows SSIF and drops HD DVD.
2026-09-30 18:53:31 -07:00
Matthew Jackson b6579a85ff decrypt: one content-detected decryption stage for every input
Every input() source now passes the decryption stage. mpg:// and m2ts:// read
through DecryptingSectorSource::detecting (CSS cracked once via the shared
css::crack_title_key; a loose BD-TS keyed on arrival from InputOptions.keys,
E7022 when no key opens it); mkv://, mp4://, network:// and stdio:// through
sector::stage::Stage, which hands a clear container through untouched and
refuses encrypted content unless raw. PsFileCss, resolve_ps_file_title_key and
descramble_ps_region are gone; the disc keeps its libdvdcss pack layout
(css::Packs). A flagged trailing partial AACS unit is blanked and counted on
every reader. Adds disc_root_of for finding a loose clip's disc folder.
2026-09-30 18:53:23 -07:00
Matthew Jackson 10d85b2444 damage: keep going where usable content remains (E7031, UDF FID, MPLS/CLPI truncation, whole-disc E7032) 2026-09-30 18:49:12 -07:00
Matthew Jackson d552ffed88 lpcm: keep the source bit depth (16-bit stays 16-bit, 20/24-bit output 24-bit) 2026-09-30 18:45:38 -07:00
Matthew Jackson 7c35ce6ef4 mux: timeline origin is the earliest sample of any track (mkv, mp4) 2026-09-30 18:43:34 -07:00
Matthew Jackson b6b888bb55 mux: zero read batch is coded E9085; de-flake two sleep tests; changelog and doc fixes from the 1.7.7 review 2026-09-30 18:42:56 -07:00
Matthew Jackson e52f190f89 changelog: 1.8.0 entry (changes since 1.7.7) 2026-09-30 18:33:07 -07:00
Matthew Jackson e05358624f build(deps): bump roxmltree from 0.20.0 to 0.21.1 (#28)
Bumps [roxmltree](https://github.com/RazrFalcon/roxmltree) from 0.20.0 to 0.21.1.
- [Changelog](https://github.com/RazrFalcon/roxmltree/blob/master/CHANGELOG.md)
- [Commits](https://github.com/RazrFalcon/roxmltree/compare/v0.20.0...v0.21.1)

---
updated-dependencies:
- dependency-name: roxmltree
  dependency-version: 0.21.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...
2026-09-30 16:31:10 -07:00
Matthew Jackson 0a3667729e build(deps): bump the minor-and-patch group with 2 updates (#27)
Bumps the minor-and-patch group with 2 updates: [rustix](https://github.com/bytecodealliance/rustix) and [crossbeam-channel](https://github.com/crossbeam-rs/crossbeam).

Updates `rustix` from 1.1.4 to 1.1.5
- [Release notes](https://github.com/bytecodealliance/rustix/releases)
- [Changelog](https://github.com/bytecodealliance/rustix/blob/main/CHANGES.md)
- [Commits](https://github.com/bytecodealliance/rustix/compare/v1.1.4...v1.1.5)

Updates `crossbeam-channel` from 0.5.16 to 0.5.17
- [Release notes](https://github.com/crossbeam-rs/crossbeam/releases)
- [Changelog](https://github.com/crossbeam-rs/crossbeam/blob/main/CHANGELOG.md)
- [Commits](https://github.com/crossbeam-rs/crossbeam/compare/crossbeam-channel-0.5.16...crossbeam-channel-0.5.17)

---
updated-dependencies:
- dependency-name: rustix
  dependency-version: 1.1.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: crossbeam-channel
  dependency-version: 0.5.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...
2026-09-30 16:31:10 -07:00
Matthew Jackson 132a64c814 deps: freemkv-unlock is a version dep with a git-tag override
The committed ../freemkv-unlock path dep made every Dependabot cargo run fail (it cannot fetch paths outside the repo). Now freemkv-unlock = "1.7" resolved by a [patch.crates-io] git tag, like every other sibling; release-orchestrate re-pins it. Local dev: .cargo/config.toml path-patches freemkv-unlock; CI does the same. Dependabot config unified.
2026-09-30 16:08:19 -07:00
Matthew Jackson c0618a0e8a mux: pin the live stream's dead-source code and resync gate in tests 2026-09-30 16:05:07 -07:00
Matthew Jackson 8e7b2e2821 network: sync the halt tests on handshakes, not sleeps 2026-09-30 16:05:06 -07:00
Matthew Jackson 1e3e18d861 mux: tighten driver tests to the exact error codes and outcomes they guard 2026-09-30 16:05:06 -07:00
Matthew Jackson 81f798646a mux: test skip_errors and selection through every source arm 2026-09-30 16:05:06 -07:00
Matthew Jackson f7a241b5e8 mux: make the demux worker exit and cancel tests able to fail 2026-09-30 16:05:06 -07:00
Matthew Jackson d176d9c71d mux: stamp a dir:// source as an image in the provenance header 2026-09-30 16:05:06 -07:00
Matthew Jackson f956511d5c mux: refuse an FMKV header with more streams than frame track ids 2026-09-30 16:05:06 -07:00
Matthew Jackson c827d9dd1e mux: check a keyed session's selection and key gate before taking its reader 2026-09-30 16:05:06 -07:00
Matthew Jackson cc8f7853cc network: try every vetted address with a bounded connect; keepalive on the sender 2026-09-30 16:05:06 -07:00
Matthew Jackson 239447fab1 mux: repeat the highway's terminal read error on later reads 2026-09-30 16:05:06 -07:00
Matthew Jackson 35a69c15a2 mux: count resync-dropped frames in the highway's errors 2026-09-30 16:05:06 -07:00
Matthew Jackson e6780eed56 mux: warn once per dropped PS stream, not once per packet 2026-09-30 16:05:06 -07:00
Matthew Jackson 0e6922cf8a mux: end a failed or stopped network title with a reset, not a clean close 2026-09-30 16:05:06 -07:00
Matthew Jackson 570c54fc3c mux: treat a stopped read in a bad zone as a stop, not bad media 2026-09-30 16:05:06 -07:00
Matthew Jackson 0c61a72cc1 mux: refuse a zero read batch on the live disc stream 2026-09-30 16:05:06 -07:00
Matthew Jackson bfed25dbb6 session: from_drive and source_mut so follow-ups end on the held handle 2026-09-30 16:03:19 -07:00
Matthew Jackson 2f3d656601 mux: pass the measured field order through the test write wrapper 2026-09-30 15:56:28 -07:00
Matthew Jackson 512292d476 mux: share the audio purpose id between the fmkv header and json sink 2026-09-30 15:56:28 -07:00
Matthew Jackson 80d0454a54 mux: subtract pixel crop from the default mkv display size 2026-09-30 15:56:28 -07:00
Matthew Jackson 3478b46174 mux: drop and count undecodable mkv frames instead of failing the read 2026-09-30 15:56:28 -07:00
Matthew Jackson 02dd7e853b mux: declare bd-ts in the m2ts fmkv header whatever the source format 2026-09-30 15:56:28 -07:00
Matthew Jackson 5de6502980 mux: propagate the void_element result for the field order reserve 2026-09-30 15:56:28 -07:00
Matthew Jackson abfdee110f mux: write the majority field order when an mkv track is promoted to interlaced 2026-09-30 15:56:27 -07:00
Matthew Jackson 219f568579 mux: pin mkv cue probe errors for bad seekhead targets 2026-09-30 15:56:27 -07:00
Matthew Jackson dbdb81ceff mux: test mkv set_codec_private stream-to-track mapping 2026-09-30 15:56:27 -07:00
Matthew Jackson 17a4f4e070 mux: test 3d mkv output end to end 2026-09-30 15:56:27 -07:00
Matthew Jackson d3cb5ad3cb mux: test the mkv writer's pending-frame cap build 2026-09-30 15:56:27 -07:00
Matthew Jackson 9290ea081f mux: mark the fvi sink finished only after its flush succeeds 2026-09-30 15:56:27 -07:00
Matthew Jackson a0a1445bad mux: fail the mkv writer after a frame write error 2026-09-30 15:56:27 -07:00
Matthew Jackson f859b63fc3 mux: map mkv sampling frequency to its own standard rate only 2026-09-30 15:56:27 -07:00
Matthew Jackson 16ff692b33 mux: decode long mkv block track numbers and count malformed blocks 2026-09-30 15:56:27 -07:00
Matthew Jackson bac7f5d44d mux: escape webvtt chapter names and pin cue end times 2026-09-30 15:56:27 -07:00
Matthew Jackson 88a5fbc9c1 mux: time mkv duration with the clamped scale and drop a non-finite one 2026-09-30 15:56:27 -07:00
Matthew Jackson 5d70d6c9f4 mux: read mkv track language with the spec default and LanguageBCP47 2026-09-30 15:56:26 -07:00
Matthew Jackson c9e33dd68e mux: only derive a pid for mkv track types that are carried 2026-09-30 15:56:26 -07:00
Matthew Jackson beb7b5c187 mux: cap mkv track entries and reject duplicate track numbers 2026-09-30 15:56:26 -07:00
Matthew Jackson 1ce340a600 mux: bound the mkv pcm depth probe by frame count and index frames per track 2026-09-30 15:56:26 -07:00
Matthew Jackson f1f52c9fcf mux: carry display aspect, cicp, labels, chapters and format in the fmkv header 2026-09-30 15:56:26 -07:00
Matthew Jackson 5bddf66f35 mux: undo matroska content encodings on mkv read-back 2026-09-30 15:56:26 -07:00
Matthew Jackson 74b853fb0d mux: name the fields of a parsed mkv track entry 2026-09-30 15:56:26 -07:00
Matthew Jackson f8e49a7cab mux: carry display shape, colour and frame rate through mkv read-back 2026-09-30 15:56:26 -07:00
Matthew Jackson 69073cb054 m2ts: note that info() reports source PIDs 2026-09-30 15:52:06 -07:00
Matthew Jackson 1e61cd44c5 ts: resync to the next grid slot first when it has a sync byte 2026-09-30 15:52:06 -07:00
Matthew Jackson 40a9290258 ts: treat a same-CC packet as a duplicate only when it repeats the payload 2026-09-30 15:52:06 -07:00
Matthew Jackson 5b37f7a9ee ts: leave the PES flushed at a continuity gap unflagged again
A clip-boundary counter restart reads like a gap, and BD video PES carry no length, so flagging it dropped the last frame of every clip.
2026-09-30 15:52:06 -07:00
Matthew Jackson 14dc6345a2 ts: keep the packet grid across zero-filled gaps when resyncing 2026-09-30 15:52:06 -07:00
Matthew Jackson c1b58882de tsmux: pin parameter-set latch, PES split boundary and NAL length width in tests 2026-09-30 15:52:06 -07:00
Matthew Jackson e76b43973d ts: keep a PES buffer's allocation within its PID's share 2026-09-30 15:52:06 -07:00
Matthew Jackson 4e5da3758a ts: find MPEG audio layers for every PID in one walk of the scan head 2026-09-30 15:52:06 -07:00
Matthew Jackson e0d619c77a ts: resync to the next packet boundary after a byte slip 2026-09-30 15:52:06 -07:00
Matthew Jackson a4a6d372c3 ts: prefer a current, CRC-valid PAT/PMT copy when scanning streams 2026-09-30 15:52:06 -07:00
Matthew Jackson 328245b35e ts: drop the open PES on a packet flagged transport_error_indicator 2026-09-30 15:52:06 -07:00
Matthew Jackson 2d8b268927 ts: flag the PES flushed at a continuity gap unless its length shows it complete 2026-09-30 15:52:06 -07:00
Matthew Jackson 5075cd0f4a ts: gather a PES header that spans packets before reading its timestamps 2026-09-30 15:52:06 -07:00
Matthew Jackson 7b8658eb8c ts: skip duplicate packets and open no PES on a PUSI without a PES header 2026-09-30 15:52:06 -07:00
Matthew Jackson c833097fc6 decode_ts: read length-prefixed frames as such when a prefix looks like a start code 2026-09-30 15:52:05 -07:00
Matthew Jackson 0d4228527d m2ts: remap stream PIDs into BD ranges so tsmux sees video as video 2026-09-30 15:52:05 -07:00
Matthew Jackson cb9381eb0b E7013: a garbled head in a clear piece is damage at any LBA, never a strike
A resolved-clear piece has no partners and no key to be wrong, so a flagged
unit no held key opens is blanked and counted; the partnerless second-strike
stop applies only to keyed pieces. Fixes the review blocker; doc link to the
removed is_damage now names unopened.
2026-09-30 15:50:46 -07:00
Matthew Jackson d83c31e133 E7013 red: a second garbled head in a clear piece stops E7032 2026-09-30 15:50:46 -07:00
Matthew Jackson e2039557da E7013: derive the span in unopened (no new allow attribute) 2026-09-30 15:50:46 -07:00
Matthew Jackson d6e89948bb E7013: count a garbage seed with CPI clear as damage
Every BD-TS aligned unit, clear or encrypted, carries the TS sync at byte 4
(KS-2). A covered unit without it that is not clean TS is blanked and counted;
all-zero units and clear TS under a zeroed head are not.
2026-09-30 15:50:46 -07:00
Matthew Jackson 5029a7e236 E7013 red: a garbage seed with CPI clear passes uncounted 2026-09-30 15:50:46 -07:00
Matthew Jackson 88b58d2066 E7013: judge a wrong FMTS key per key, not across the read
Lone verify failures under different keys in one read are damage (blanked,
counted). A key still fails E7013 on two failures with none verifying, and a
failing unit another held key opens (a mis-ordered pool) is always E7013.
2026-09-30 15:50:46 -07:00
Matthew Jackson b215c17eba E7013 red: lone FMTS damage under two keys in one read is E7013 2026-09-30 15:50:46 -07:00
Matthew Jackson 3fdce66f52 E7013: on-arrival damage with no vouching partner is blanked, not a stop
A unit a held key opens whose partners all fail is a provisional proof. A unit
no held key opens is blanked and counted when a held key opens a partner (batch,
else side read) or no partner is readable; it stops when another held key opens
it, when readable partners exist and none opens, at a second partnerless unit,
or when the set holds no key for a piece not resolved clear.
2026-09-30 15:50:45 -07:00
Matthew Jackson eaa25c3c04 E7013 red: on-arrival damage with no vouching partner stops E7022/E7032 2026-09-30 15:50:45 -07:00
Matthew Jackson cb86968bb3 stdio: keep StdioStream Sync with injected reader and writer 2026-09-30 15:43:29 -07:00
Matthew Jackson ed151f2c1d mux: cover malformed VobSub SP_DCSQ chains 2026-09-30 15:35:24 -07:00
Matthew Jackson 0c651b072f mux: tighten the epoch-ratchet bound to 1 s past the 605 s timeline 2026-09-30 15:35:24 -07:00
Matthew Jackson 47fc74fc2f mux: pin negative and edge SimpleBlock relative timestamps 2026-09-30 15:35:24 -07:00
Matthew Jackson 85e5f3a5d0 mux: pin SeamPlanDroppedMost and the exact empty-title error code in mkv finish 2026-09-30 15:35:24 -07:00
Matthew Jackson 8024a443e5 mux: pin block abs times after a back-jump and the exact 2 s cluster edge 2026-09-30 15:35:24 -07:00
Matthew Jackson 255c09881a mux: assert FlagDefault and FlagForced values per TrackEntry 2026-09-30 15:35:24 -07:00
Matthew Jackson 56ebfe2e02 mux: name the cluster-driving track in the pre-cluster drop warning 2026-09-30 15:35:24 -07:00
Matthew Jackson 9e8a6cdd65 mux: replace mkv writer unwrap/expect and display-width overflow with checked paths 2026-09-30 15:35:24 -07:00
Matthew Jackson a2baedad53 stdio: fail a consumed non-FMKV or broken header instead of misreading frames 2026-09-30 15:35:24 -07:00
Matthew Jackson 4a20748f96 mux: derive the Dolby Vision level from the 2160p frame rate 2026-09-30 15:35:24 -07:00
Matthew Jackson de15252187 ebml: pin the huge-length and invalid-UTF-8 read errors 2026-09-30 15:35:24 -07:00
Matthew Jackson f29b4be6c6 ebml: refuse a 129-byte Void instead of writing the reserved 0xFF size 2026-09-30 15:35:23 -07:00
Matthew Jackson 5a3c20c499 mux: leave out streams whose codec has no Matroska CodecID 2026-09-30 15:35:23 -07:00
Matthew Jackson 06c62111e6 mux: tag SDR BT.2020 video with the BT.2020 transfer, not PQ 2026-09-30 15:35:23 -07:00
Matthew Jackson 6e16d51e8f ST-X1b: remove MuxOptions::send_deadline, NO_SEND_DEADLINE and POLL_INTERVAL
T27 retired the per-frame send deadline in every shell (ST-F1, ST-E1, ST-X1a);
the field, NO_SEND_DEADLINE and effective_send_deadline go. drive_mux's frame
pump is halt-aware only (Duration::MAX, send_with_halt's unbounded case): a
slow-but-alive sink blocks and Stop is the bound. The POLL_INTERVAL alias of
WAIT_SLICE goes too; mux/network.rs names WAIT_SLICE. A guard test keeps the
field from coming back.

Public API change: MuxOptions loses send_deadline; halt::POLL_INTERVAL is gone.
2026-09-30 15:35:01 -07:00
Matthew Jackson 6561bcc1c2 error: E9081 is WorkerLost { op } for the copy and verify workers 2026-09-30 15:26:25 -07:00
Matthew Jackson fc1f2f7235 docs: cite the standards, not other implementations; SHA-1 vector as H0..H4 words 2026-09-30 14:55:38 -07:00
Matthew Jackson 24c3234b2f error: codes E9077-E9084 for engine remux and preflight refusals 2026-09-30 14:51:08 -07:00
Matthew Jackson 86ea581a81 disc: keep the /dev/null mapfile in a private per-process temp dir 2026-09-30 14:40:08 -07:00
Matthew Jackson 6eadc36327 Merge branch 'codeaudit/tg-later' into codeaudit/land 2026-09-29 20:45:05 -07:00
Matthew Jackson 9fa5df0d35 Merge branch 'codeaudit/tg-busy-3' into codeaudit/land 2026-09-29 20:45:05 -07:00
Matthew Jackson 8239fd210d Merge branch 'codeaudit/tg-busy-2' into codeaudit/land 2026-09-29 20:45:04 -07:00
Matthew Jackson 43bf9895b3 Merge branch 'codeaudit/tg-busy-1' into codeaudit/land 2026-09-29 20:45:04 -07:00
Matthew Jackson 26b42bc6d7 Merge branch 'codeaudit/tg-flush' into codeaudit/land 2026-09-29 20:45:04 -07:00
Matthew Jackson fb1d5c3ac1 tests: lint_stop wrapped atomic receivers, macOS preallocation blocks 2026-09-29 20:44:17 -07:00
Matthew Jackson 82d4a87041 tests: mpg PSM cap boundary, tail plan, mpg->mkv timestamp offset 2026-09-29 20:44:16 -07:00
Matthew Jackson 3aba5b0262 tests: writeback p95/eviction, WritebackFile finish/no-op seek, fs_type magic mapping 2026-09-29 20:44:16 -07:00
Matthew Jackson 414c4219b1 tests: byte_prefetcher recycle-park cancel and regrow; pipeline panic sink without global hook 2026-09-29 20:44:16 -07:00
Matthew Jackson 00d52768bf tests: labels class_reader Double/switch padding, dbp prefix gate, jar per-entry inflate cap 2026-09-29 20:44:16 -07:00
Matthew Jackson db9f9982fc tests: drop counting allocator; guard the macos fake-device knob 2026-09-29 20:40:19 -07:00
Matthew Jackson ceba71d8a1 tests: rustfmt 2026-09-29 20:40:12 -07:00
Matthew Jackson dee42e263d tests: fmts probe falls through unclean same-index segment 2026-09-29 20:39:37 -07:00
Matthew Jackson 9f0e5a2df4 tests: videomap header scan is progressive for progressive video 2026-09-29 20:38:56 -07:00
Matthew Jackson b40e7f94cf tests: macos execute CHECK CONDITION, IOKit failure and transfer clamp via fake-device knob 2026-09-29 20:38:20 -07:00
Matthew Jackson ebd23bce47 tests: second video track does not drive timeline epochs in mkv and demux sinks 2026-09-29 20:38:09 -07:00
Matthew Jackson d1ba39c424 tests: paramount stream numbers stop at u16 max on audio and sub 2026-09-29 20:35:52 -07:00
Matthew Jackson 5298367e72 tests: scsi windows normalize_device_path 2026-09-29 20:35:32 -07:00
Matthew Jackson 3e90614baf tests: keys resolve-retains mux leg runs for real 2026-09-29 20:35:31 -07:00
Matthew Jackson 7b6767b3bc tests: disc Key::Device redaction, locate_ranges ordering/cap, forced-probe truncation verdict 2026-09-29 20:35:31 -07:00
Matthew Jackson 6eaf59950a tests: mux codec-private oversize, mp2 finish warn, EBML lace deltas, error code, size VINT truncation, PAFF pts, PGS no-duration 2026-09-29 20:35:31 -07:00
Matthew Jackson f25866d11d tests: labels vocab full table, word boundaries, normalize_language, bdmt parse 2026-09-29 20:35:31 -07:00
Matthew Jackson a63fe29aa2 tests: scsi tur presence bubbles no-sense transport failure 2026-09-29 20:34:57 -07:00
Matthew Jackson 92469116a5 tests: mp4 round trip compares sample payload bytes 2026-09-29 20:34:27 -07:00
Matthew Jackson 6a08053f0c tests: vc1 PS reorder installed and flushed 2026-09-29 20:34:24 -07:00
Matthew Jackson 68aa6657a3 tests: mp4 ddts exact speaker masks 2026-09-29 20:33:36 -07:00
Matthew Jackson b632b687f1 tests: truehd AC-3 NeedMore reached with a real short frame 2026-09-29 20:33:18 -07:00
Matthew Jackson 760d82e7c5 tests: extract sanitize reserved chars and Borrowed random_access 2026-09-29 20:33:05 -07:00
Matthew Jackson c9f409ab55 tests: pgs mixed-track last-set forced, demotion share boundary 2026-09-29 20:32:38 -07:00
Matthew Jackson 3fa672026e tests: meta header field round trip and timing track index 2026-09-29 20:31:56 -07:00
Matthew Jackson 7d1da4731c tests: io::flush timing tests wait on witnesses, not sleeps
writer_backpressure_at_two_chunks waits for the blocked writer's sample
instead of a 150 ms sleep; durable_sync_file_reports_piece_completions
checks each window step against the piece time bounds the fake observes;
flush_slow_but_progressing_never_fails gates a two-chunk drain and only
accepts SyncTimeout when an observed no-progress gap reached the window.
2026-09-29 20:31:03 -07:00
Matthew Jackson 3ee96f121f tests: mpegaudio pin free-format min-spacing sizes 2026-09-29 20:30:23 -07:00
Matthew Jackson 1a2b390f0c tests: disc stream recovery-read stops and adaptive streak reset 2026-09-29 20:26:40 -07:00
Matthew Jackson 608109a257 tests: lpcm carry dropped on format change, gap, and BD partial sample 2026-09-29 20:26:11 -07:00
Matthew Jackson 0106f40d90 tests: hevc IRAP coding type, mixed sub-layer PTL flags, scan_sei allocation 2026-09-29 20:24:59 -07:00
Matthew Jackson b2ab7534b9 tests: dvdsub continuation clamp fails when unclamped 2026-09-29 20:22:17 -07:00
Matthew Jackson 5c47e2a58b tests: swap class_reader ALLOW_BASELINE entries for the item-level expect(dead_code) sites from batch 4 2026-09-29 20:21:53 -07:00
Matthew Jackson bcaafdd8c2 tests: adts ASC packing, 7350 Hz MPEG-4 index, multi-block frame duration 2026-09-29 20:21:42 -07:00
Matthew Jackson ee06d6536e tests: ac3 resync guard, all-acmod LFE channel counts, later-PES PTS adoption 2026-09-29 20:21:42 -07:00
Matthew Jackson 3308a74cb8 Merge codeaudit/tg-free-4 (c06fa0c) 2026-09-29 20:17:06 -07:00
Matthew Jackson d5e17a7974 Merge codeaudit/tg-free-3 (d1fa263) 2026-09-29 20:17:06 -07:00
Matthew Jackson 355c47f4fd Merge codeaudit/tg-free-2 (854cfce) 2026-09-29 20:17:06 -07:00
Matthew Jackson f0902903ba Merge codeaudit/tg-free-1 (3ac519d) 2026-09-29 20:17:05 -07:00
Matthew Jackson 115e642f80 Merge codeaudit/b09-mpg (e9a058f)
# Conflicts:
#	src/mux/mpg/scan.rs
2026-09-29 20:17:03 -07:00
Matthew Jackson 2ef95eecb7 Merge codeaudit/b05-labels-jar (211a632) 2026-09-29 20:14:47 -07:00
Matthew Jackson c7865985d9 Merge codeaudit/b04-io (3feb0e0)
# Conflicts:
#	src/platform/fs_type/mod.rs
2026-09-29 20:14:44 -07:00
Matthew Jackson 854cfce434 tests: key-api guard catches UFCS calls, correct pass_n header; examples: valid NDJSON errors 2026-09-29 20:12:24 -07:00
Matthew Jackson 8412bcb45d tests: labels pin png composite suffix, xml multibyte slice, criterion parse wiring, dominance boundary 2026-09-29 20:12:23 -07:00
Matthew Jackson 58630e0c82 tests: sector decorators pin fua forwarding, key redaction, span clip, random_access, batch clamp, progress events 2026-09-29 20:12:23 -07:00
Matthew Jackson 3ac519d209 tests: mux sinks, header gate, m2ts LPCM PTS, mpg fit ids, stale names 2026-09-29 20:10:25 -07:00
Matthew Jackson 148e0f6989 tests: mux/demux_thread fix halt race and pin TS source offsets 2026-09-29 20:10:25 -07:00
Matthew Jackson 13db988177 tests: mux/codec pin flac, h264, mpeg2 caps and timing, reorder, read_ue, mp2 copy sources 2026-09-29 20:10:25 -07:00
Matthew Jackson e9a058fc6e mpg: re-base moves forced-drain entries past the lead so the EOF drain cannot spin 2026-09-29 20:06:18 -07:00
Matthew Jackson c06fa0cfc6 tests: fmt drive stop_tests 2026-09-29 20:06:16 -07:00
Matthew Jackson d2f7be2a3a tests: drop tautological/redundant crypto+scsi tests, fix header/comment, tighten macos rescan and bench byte count 2026-09-29 20:06:13 -07:00
Matthew Jackson d1fa263485 tests: UDF not-filesystem verdicts, codec id round-trip, exact URL error codes, scsi timeout bound 2026-09-29 20:04:49 -07:00
Matthew Jackson fcc418ee89 tests: dirimage IFO mtime change detection 2026-09-29 20:04:49 -07:00
Matthew Jackson caa6f8c6ee tests: UdpSocketSink IPv6 bind, fix sequential-sink comment 2026-09-29 20:04:48 -07:00
Matthew Jackson 31ca296eed tests: file_sector_source drop-window wiring, drop env-mutating test 2026-09-29 20:04:48 -07:00
Matthew Jackson 1a325bc5d6 tests: bounded stall WorkerLost, drop duplicate halt test 2026-09-29 20:04:48 -07:00
Matthew Jackson 5c7b629eca tests: scan_order bus_step grep ignores comments 2026-09-29 20:04:48 -07:00
Matthew Jackson c354ebd471 tests: hddvd XPL depth/fallback/probe-memo and VTI tie-break coverage; rename encrypt vid test 2026-09-29 20:04:48 -07:00
Matthew Jackson 90cde7b496 tests: pin default-flag hoisting for secondary tracks 2026-09-29 20:04:48 -07:00
Matthew Jackson 211a632d54 labels: keep jar detection, early-ended bytecode drift and unknown CodingType handling faithful 2026-09-29 20:04:10 -07:00
Matthew Jackson 0e517dd599 tests: drive REPORT KEY opcode, MODE SELECT CDB, drive_status length guard, eject unlock, transport-field guard 2026-09-29 20:02:03 -07:00
Matthew Jackson 3370ba5138 tests: shrink ALLOW_BASELINE for scsi/udf allow sites narrowed or removed in batch 3 2026-09-29 19:57:06 -07:00
Matthew Jackson 528d83fd1f tests: mux/codec/coding pin rff-interlaced nb_fields and field-picture order 2026-09-29 19:56:19 -07:00
Matthew Jackson 950385b34b tests: dvdnav nav Goto, compare ops, swap, JumpTT 0/vtsn 0, link-flag boundary 2026-09-29 19:55:01 -07:00
Matthew Jackson e32aa31e07 mpg: re-base only pathological gaps (>300 s), keep written AUs' decode times, round re-base up 2026-09-29 19:50:07 -07:00
Matthew Jackson e041e37c51 Merge codeaudit/b10-misc (d09c200) 2026-09-29 19:48:43 -07:00
Matthew Jackson f8a3904023 Merge codeaudit/b11-scsi (b25faa1) 2026-09-29 19:48:42 -07:00
Matthew Jackson e91777e117 Merge codeaudit/b09-mp4net (bba24c4) 2026-09-29 19:48:42 -07:00
Matthew Jackson 2f0f316abe Merge codeaudit/b08-mkv (f72fbc2) 2026-09-29 19:48:42 -07:00
Matthew Jackson 46b2f6a39c Merge codeaudit/b07-mux (00e0a4e)
# Conflicts:
#	src/keys/fmts.rs
#	src/mux/resolve.rs
2026-09-29 19:48:42 -07:00
Matthew Jackson 3c3a3d0860 Merge codeaudit/b03-extract-dvd (3b343f1)
# Conflicts:
#	src/disc/extract.rs
2026-09-29 19:47:35 -07:00
Matthew Jackson 525bf73c8a Merge codeaudit/b01-bd (07d1c7a)
# Conflicts:
#	src/disc/bluray.rs
2026-09-29 19:47:24 -07:00
Matthew Jackson 922026f763 Merge codeaudit/disc-bluray (4c198c7) 2026-09-29 19:47:13 -07:00
Matthew Jackson ac0ec2a039 Merge codeaudit/udf (ff3d10f)
# Conflicts:
#	src/udf.rs
2026-09-29 19:47:13 -07:00
Matthew Jackson 26b029bda4 Merge codeaudit/b05-labels-core (02fb788) 2026-09-29 19:47:04 -07:00
Matthew Jackson baecb0475c Merge codeaudit/b06-codec-audio (dc8e1a8) 2026-09-29 19:47:04 -07:00
Matthew Jackson 715cd0be7e labels: replace class_reader's blanket dead_code allow with item-level expects and test-only helpers 2026-09-29 19:45:53 -07:00
Matthew Jackson 3feb0e0f8b io: remove unused open_for_mkv and path-based fs_type detection 2026-09-29 19:45:46 -07:00
Matthew Jackson 339a4b780a io: keep LocalFileSink and pipeline::debug_enabled public API as at base 2026-09-29 19:45:45 -07:00
Matthew Jackson 93bf291deb io: release the size-hint reservation on a clean local close, including drop 2026-09-29 19:45:45 -07:00
Matthew Jackson c2eb2b4426 test: flush_slow_but_progressing_never_fails asserts the backpressure-guaranteed chunk count 2026-09-29 19:45:45 -07:00
Matthew Jackson 15ae09f269 io: re-request flusher bytes under the lock, bound prefetcher drop joins 2026-09-29 19:45:44 -07:00
Matthew Jackson e6b7841153 labels: detect dbp, Deluxe and Fox jars from the central directory alone 2026-09-29 19:43:01 -07:00
Matthew Jackson 43b97465da labels: map DRA CodingTypes, drop the non-BD-J Atmos name, share the Deluxe fingerprint predicate and log dropped enum fields 2026-09-29 19:40:42 -07:00
Matthew Jackson 3b343f1476 extract: narrow failed batches with one read per unit; decrypt failure loses only its unit 2026-09-29 19:40:26 -07:00
Matthew Jackson 5fd3d7c63d labels: interactive-graphics and video Deluxe bindings take no audio or subtitle slot 2026-09-29 19:39:28 -07:00
Matthew Jackson 4e2acd1fbb labels: model JVM stack effects of loads, stores, dup/swap, arithmetic and branches with long/double as two-word values in the Deluxe decoder 2026-09-29 19:36:48 -07:00
Matthew Jackson 07d1c7aa1e bdnav: warn on dropped MPLS, dedupe be_u16, pin SET taint test 2026-09-29 19:36:34 -07:00
Matthew Jackson bba24c45fe mux/mp4: frame rate from stts median-window mean, reject off-rate 2026-09-29 19:36:04 -07:00
Matthew Jackson 00e0a4e00c mux: test H.264/VC-1 fragmented AUs through DiscStream PS EOF drain 2026-09-29 19:33:07 -07:00
Matthew Jackson 0753b4d25f extract: skip bad ECC blocks, cap failed unit reads, retry batch once 2026-09-29 19:32:22 -07:00
Matthew Jackson f72fbc20a5 mkv: make truncated-Tags test actually truncate Tags; log malformed Chapters via tracing 2026-09-29 19:31:38 -07:00
Matthew Jackson becfa26c68 labels: count Deluxe stack drift, keep lost bindings' slots and downgrade drifted results to low confidence 2026-09-29 19:31:14 -07:00
Matthew Jackson d09c200de5 mux: unknown SD aspect, coded-size square pixels, no MPEG-1 DAR guess, no BLA at gap-flagged joins 2026-09-29 19:30:34 -07:00
Matthew Jackson 2db7ba9cdf mp4: read frame rate via muxer table, label MPEG Layer II as Mp2, revert network.rs changes 2026-09-29 19:30:33 -07:00
Matthew Jackson b25faa112a scsi: match IOCompactDiscServices, drop unbounded sg reopen, restore mid-open cancel test 2026-09-29 19:30:32 -07:00
Matthew Jackson 94eda804cf Merge codeaudit/b06-codec-video (f754fa1) 2026-09-29 19:30:01 -07:00
Matthew Jackson 8e7f8d81af Merge codeaudit/b01-aacs (a025571)
# Conflicts:
#	src/aacs/variant.rs
2026-09-29 19:29:58 -07:00
Matthew Jackson 02fb788b9f labels: reuse b06 pixelogic cap, real ctrm order test, setNumber fallback 2026-09-29 19:27:31 -07:00
Matthew Jackson bde66445da Merge codeaudit/b01-ifo (8369d68) 2026-09-29 19:26:34 -07:00
Matthew Jackson c6b994d9b4 Merge codeaudit/b01-core (d463892) 2026-09-29 19:26:33 -07:00
Matthew Jackson 7b44773872 Merge codeaudit/testutil (0e7e458) 2026-09-29 19:26:33 -07:00
Matthew Jackson f51b663104 labels: share one inflate budget across every jar sweep in a parse and log when it runs out 2026-09-29 19:24:05 -07:00
Matthew Jackson dc8e1a84d9 dts: never trap a cored DTS-HD stream in EXSS-only mode
Enter EXSS-only mode only after 3 chained core-less EXSS frames, leave it
when a valid core sync precedes the next EXSS, and count EXSS-only resync
bytes as drops. Frames without EXSS static fields reuse the last parsed
duration instead of a fixed 512 samples at 48 kHz.
2026-09-29 19:23:08 -07:00
Matthew Jackson 21d0893851 dvd: fall back to VIDEO_TS.BUP / VTS_xx_0.BUP when an IFO is unusable 2026-09-29 19:23:04 -07:00
Matthew Jackson d463892bcd core: trim before sanitising GC text, cover scan-failure key restore, doc sampler stop 2026-09-29 19:21:01 -07:00
Matthew Jackson f08704a2de mkv: make chapter/attachment/tag parsing best-effort, honour default edition and hidden/disabled chapters 2026-09-29 19:20:54 -07:00
Matthew Jackson f754fa12d0 mux: reset MPEG-2 field pairing at GOP and sequence headers 2026-09-29 19:19:50 -07:00
Matthew Jackson 8369d682f4 decrypt: keep outer key range tail under nested ranges; ifo: fall back to 1:1 for out-of-table PTT_SRPT PGCN; revert angle-cell time exclusion 2026-09-29 19:18:38 -07:00
Matthew Jackson 2d6d210261 dvd: drop the partial angle-cell filter; angle blocks need ILVU selection 2026-09-29 19:17:42 -07:00
Matthew Jackson 4c198c7979 bluray: test the extent filter with a multi-extent clip 2026-09-29 19:17:34 -07:00
Matthew Jackson 5ccfbfd733 extract: bound per-unit re-reads of a failed batch on contiguous damage 2026-09-29 19:16:40 -07:00
Matthew Jackson a02557112f aacs: pin V21 resolver key search, reject holed multi-pack MKB, register E71xx codes, share variant chain 2026-09-29 19:16:16 -07:00
Matthew Jackson 6ad36a6b1b ci: drop new qa clippy jobs pending decision 2026-09-29 19:15:40 -07:00
Matthew Jackson 0e7e458d2c test_util: pad a partial final unit instead of panicking 2026-09-29 19:13:14 -07:00
Matthew Jackson 96434f286c mpg: re-base quiet forward timestamp gaps instead of refusing; honour signalled SD colour; drop E9076 2026-09-29 19:12:28 -07:00
Matthew Jackson 6a08f2ab62 mp4/net: reject write after finish, round CTS, read colr/dac3/esds OTI, tighten SSRF blocklist 2026-09-29 19:11:19 -07:00
Matthew Jackson 3e8f834e6f Merge codeaudit/b04-keys-labels (76b6bf2) 2026-09-29 19:10:29 -07:00
Matthew Jackson 344d77cd48 Merge codeaudit/b11-ci-misc (1558ecf) 2026-09-29 19:10:28 -07:00
Matthew Jackson bb72b0e327 Merge codeaudit/b03-drive-nav (8ac8b53) 2026-09-29 19:10:28 -07:00
Matthew Jackson 5bdcf07fe7 Merge codeaudit/dirimage (496fa3b) 2026-09-29 19:10:28 -07:00
Matthew Jackson ca1b180521 Merge codeaudit/whole-disc (d7183b4) 2026-09-29 19:10:27 -07:00
Matthew Jackson 9f10e8dd1c Merge codeaudit/aacs-diag (cd9d5de) 2026-09-29 19:10:27 -07:00
Matthew Jackson b88bfaa0e5 dts: frame core-less DTS Express (EXSS-only) streams instead of dropping them 2026-09-29 19:10:26 -07:00
Matthew Jackson c981655851 dts: use a single core sample-rate table 2026-09-29 19:10:26 -07:00
Matthew Jackson 8ab15815e2 test_util: prove fixture dir is removed when image build panics 2026-09-29 19:10:16 -07:00
Matthew Jackson 76b6bf2022 keys: refuse unparseable FMTS segment table; cap keys per source answer 2026-09-29 19:08:55 -07:00
Matthew Jackson eb185797ca pgs: after a gap emit a complete held set, then skip to the next PCS 2026-09-29 19:08:51 -07:00
Matthew Jackson a526609ae0 adts: build the ASC on the stack, count PCE-only config changes 2026-09-29 19:08:50 -07:00
Matthew Jackson 9144baf0f0 labels: cap untrusted inputs, fix B/T language match, anchor/PID scoping, bdmt setNumber, drop CLPI orphan pass 2026-09-29 19:08:10 -07:00
Matthew Jackson 8d1791b84e scsi: resolve optical drive before unmount, cover DVD/CD drives, macOS 11 IOKit compat, sense/timeout/inquiry fixes 2026-09-29 19:07:54 -07:00
Matthew Jackson 8ac8b53eb6 drive/dvdnav: review follow-ups (ceiling doc/log, pread pre-check, linux FFI helpers, has_unlocker, EJECT_CDB, set-op consts) 2026-09-29 19:07:07 -07:00
Matthew Jackson 0f0c906d1f ifo: map titles via PTT_SRPT, count one angle, reject TTN 0; decrypt: cache pool failure, disjoint key ranges; tests 2026-09-29 19:06:28 -07:00
Matthew Jackson ff3d10f0a6 udf: keep mounting past an LVD fault, tighten dir caps test, tolerate corrupt subdirs, propagate Stop from prefetch 2026-09-29 19:05:35 -07:00
Matthew Jackson 1558ecf617 ci: restore checkout v7, SHA-pin remaining actions, revert dtolnay to branch refs, REUSE for dist10 2026-09-29 19:05:27 -07:00
Matthew Jackson 7c1d2a203b disc/bluray: share CLPI packet-count memo across playlists; test XML title decoding 2026-09-29 19:04:43 -07:00
Matthew Jackson 7e855da188 mux: fix mkv/mp4 audit findings (chapters, codec ids, cues, MaxBlockAdditionID, bounds, reserved rates) 2026-09-29 19:02:54 -07:00
Matthew Jackson d7183b4de8 whole_disc: inject a variant the reader never produces in the propagation test 2026-09-29 19:01:50 -07:00
Matthew Jackson 11a901571e keys: caller-bug refusals are typed E7013 in debug builds; move FMTS forensic helpers to keys/fmts, warn on TrueHD probe failures, one keys log target, share demux sink setup 2026-09-29 19:01:47 -07:00
Matthew Jackson 5faff8ad98 mux: DiscStream routes PS packets by value through the AU assembler and shares the B1 admit gate 2026-09-29 19:01:41 -07:00
Matthew Jackson a67bcde42b mux: vc1 share advanced-profile seq-bit extraction, handle_header returns the redefinition 2026-09-29 19:01:40 -07:00
Matthew Jackson e1d9f8c3eb mux: hevc/avcC truncation flag covers cut array headers, truehd drop reasons, truehd_channels_from_stream via sync info 2026-09-29 19:01:40 -07:00
Matthew Jackson 8be92d3c7d Merge branch 'codeaudit/b03-hddvd' into codeaudit/land 2026-09-29 18:57:58 -07:00
Matthew Jackson 027f1a848d Merge branch 'codeaudit/b03-discmod' into codeaudit/land 2026-09-29 18:57:57 -07:00
Matthew Jackson 3e9e8315ed Merge branch 'codeaudit/css' into codeaudit/land 2026-09-29 18:57:56 -07:00
Matthew Jackson 9fc09be25f Merge remote-tracking branch 'origin/dev' into codeaudit/land 2026-09-29 18:57:55 -07:00
Matthew Jackson 496fa3b10d dirimage: narrow io-error mapping, round-trip volume id, fix dir-size comment order 2026-09-29 18:57:37 -07:00
Matthew Jackson 2eb33647bd codec-video: second field inherits first field's order; use push_length_prefixed in H.264 slice arm; document field-picture top_field_first 2026-09-29 18:57:25 -07:00
Matthew Jackson c69937267b disc: narrow extract read holes, DVD IFO errors and angle cells, sanitizer and probe fixes 2026-09-29 18:57:16 -07:00
Matthew Jackson cd9d5def4a aacs: use shared VERIFY_MAGIC in km_verifies 2026-09-29 18:57:14 -07:00
Matthew Jackson bd6653f3cd mpg: bound the origin window, padding gap and scanned streams; typed errors, SD colour, ordering fixes 2026-09-29 18:56:13 -07:00
Matthew Jackson 14fd002856 disc: HD DVD triple-layer count, explicit combo audio_format arm 2026-09-29 18:55:56 -07:00
Matthew Jackson 5a6b5617e6 bdnav/mpls/clpi: fix HDMV BC compare, reject truncated MPLS/CLPI, add TextST language, cover parser gaps 2026-09-29 18:55:02 -07:00
Matthew Jackson deacbf232c core: halt busy ordering, key sampling stop, scan key-source retry, untrusted text hardening 2026-09-29 18:53:16 -07:00
Matthew Jackson 7d2de0cc7b mux: arm CRA->BLA on gaps, SD display aspect, atomic recovery slot, tsmux keyframe warn, misc cleanups 2026-09-29 18:52:41 -07:00
Matthew Jackson 278cbdeeda ci: doctest gate, timeouts, SHA pins, least-privilege release, platform clippy; REUSE coverage; doc fixes 2026-09-29 18:48:07 -07:00
Matthew Jackson 843f9501c4 aacs,diag: consolidate VERIFY_MAGIC, test DK shift guard and opening-capture cap 2026-09-29 18:46:04 -07:00
Matthew Jackson 54522a656b mux/codec audio: cap label strings, fix ADTS/DTS/LPCM/subtitle edge cases 2026-09-29 18:43:36 -07:00
Matthew Jackson c065bb5a61 labels: model long consts and array stores in Deluxe decoder, bound class sweeps, fix CodingType and surrogate handling 2026-09-29 18:42:23 -07:00
Matthew Jackson dfff4f3656 test_util: deterministic stall-generation test, assert whole aligned units 2026-09-29 18:41:23 -07:00
Matthew Jackson 2338a19857 aacs: reject short/holed MKB packs, try every V21 device key, tighten tests and docs 2026-09-29 18:41:18 -07:00
Matthew Jackson 0f6e183808 whole_disc: use consts::SECTOR_BYTES instead of a local sector constant 2026-09-29 18:34:31 -07:00
Matthew Jackson 4420390bf3 css: hoist MAX_TRIES, merge crack_key_scan into crack_key_outcome, share seed helper, hide internal helpers; revert unrelated Cargo.lock churn 2026-09-29 18:34:31 -07:00
Matthew Jackson 2e433fcbd3 disc/bluray: test halt poll, feed spans, chapter names, meta title language 2026-09-29 18:30:55 -07:00
Matthew Jackson ea418670b2 keys, labels: prove on a distinct unit, cap key pool and candidates, surface probe/list errors 2026-09-29 18:30:35 -07:00
Matthew Jackson 0f83cdcdc7 hddvd: give oversized XPL its own code, guard the lba-0 scan wiring, test XPL/probe diagnostics and Halted 2026-09-29 18:30:10 -07:00
Matthew Jackson c898c4a879 udf: tighten test assertions (merge_ranges length, unrecorded-extent variant), rename oversized-file test 2026-09-29 18:27:05 -07:00
Matthew Jackson 988ccc1c30 diag: list every freemkv::diag tag in the module doc 2026-09-29 18:25:29 -07:00
Matthew Jackson 86a399feda codec-video: fix field order, CRA splice slices, reorder calibration, SPS range checks 2026-09-29 18:25:21 -07:00
Matthew Jackson 71e71b5ef4 css: make CSS tests non-vacuous, tighten locked-bail and short-buffer coverage 2026-09-29 18:25:16 -07:00
Matthew Jackson 6d296d0755 io: trim size-hint reservation, fix flusher wait_room stall, prefetcher/image_writer hardening, doc fixes 2026-09-29 18:24:10 -07:00
Matthew Jackson 77c152993c mux: truehd runt AU after baseline is corrupt, warn on truncated avcC/hvcC, drop debug_assert in mux_keyed, trim comments 2026-09-29 18:22:51 -07:00
Matthew Jackson 0486dd73f5 whole_disc: test inner read errors propagate on both paths 2026-09-29 18:21:44 -07:00
Matthew Jackson 03a128e041 dirimage: test tag validity, IFO mtime guard, duplicate-group IFO, VMG menu VOB, depth cap 2026-09-29 18:21:14 -07:00
Matthew Jackson 9f279cd184 drive/dvdnav: wait_ready ceiling, SetTmpPML goto, pread Stop check, init doc 2026-09-29 18:19:38 -07:00
Matthew Jackson 4b7b02a54e disc: fix combo audio channels, layer count, image-scan halt, duplicate MKB, per-process mapfile, reserved names 2026-09-29 18:17:56 -07:00
Matthew Jackson badb0ce398 whole_disc: checked join of contiguous extents 2026-09-29 18:17:24 -07:00
Matthew Jackson 693f02652b hddvd: cap XPL size, drop titles naming absent/truncated clips, log probe and XPL read errors, key probe cache on probed head 2026-09-29 18:14:11 -07:00
Matthew Jackson 8e5ce67a37 udf: bound range/dir walks, surface VDS faults, Latin-1 CS0, error on corrupt FIDs 2026-09-29 18:14:03 -07:00
Matthew Jackson 94271c382f disc/bluray: memoize clip info, refuse missing stream, log skipped playlists, decode XML title 2026-09-29 18:11:26 -07:00
Matthew Jackson 9aa650ed05 aacs,diag: reject unwalkable DK nodes, fix diag tag vocabulary, harden opening capture 2026-09-29 18:10:52 -07:00
Matthew Jackson bae6d39c97 dirimage: name files in plan/read errors, cap dir size, fix uid range, volume id, collision set 2026-09-29 18:10:42 -07:00
Matthew Jackson 81e8d9a994 css: fix descramble_region rustdoc attachment, trim rustdocs, use ENCRYPTED_START 2026-09-29 18:07:59 -07:00
Matthew Jackson effb7f660b testutil: drop-guard fixture dir, snapshot stall generation under the log lock, tidy testlog comment 2026-09-29 18:07:02 -07:00
Matthew Jackson 0b351c6060 Test forensic phase fallback and stop after a successful lookup 2026-09-29 17:20:43 -07:00
Matthew Jackson 3d153784c9 mux: drop orphan comment that tripped comment-guard 2026-09-29 09:10:51 -07:00
Matthew Jackson eb1ad87f8c Share tuned ISO mux batch size with image remux 2026-09-28 18:45:33 -07:00
Matthew Jackson 1f3ababe25 test: the tmpfs check's f_type cast is needed on some targets 2026-09-28 12:46:04 -07:00
Matthew Jackson 071db05822 clippy (newer stable): is_none_or and a simpler boolean 2026-09-28 12:34:48 -07:00
Matthew Jackson ed715b7ff6 test: fvi pipeline tests hold the Drive-holder lock; the lock exists under loom too 2026-09-28 12:26:20 -07:00
Matthew Jackson a3f11963f3 KU-X2: a whole-disc sweep refuses a Lazy piece no held key opens up front (E7032)
resolve records when a Lazy piece has intact encrypted probes and no held key
opens any of them. A whole-disc sweep reads those units and the on-arrival
proof can only stop there (no partner opens, so it is not damage), so the key
set's whole_disc_reader refuses E7032 before the ISO is created (KU §2.1 (6)).

Not counted: a probe with a damaged seed (no TS sync; blanked on read, E7013
option A), and a piece where a held key opens some probe (arrival blanks the
unopened unit as damage). Title readers keep the on-arrival stop: a title rip
may never read the unit (LK10, step 9.2).
2026-09-28 11:30:47 -07:00
Matthew Jackson e7119a566f KU-X2 red: a whole-disc sweep refuses a Lazy piece no held key opens up front (E7032)
A multi-CPS disc whose unplayed file has one encrypted unit no held key opens:
too little ciphertext to ask with, so resolve leaves the piece Lazy (step 9.2).
A whole-disc sweep reads that unit, so the on-arrival proof must stop there with
E7032, after the ISO is created. KU §2.1 (6): refuse before output. Engine:
a_multi_cps_sweep_refuses_a_first_unit_no_key_opens_before_output.
2026-09-28 11:30:46 -07:00
Matthew Jackson a3847073b2 KU-X2: the whole-disc reader refuses titles with no stream folder (E6003)
resolve records when it found no stream file; the key set's whole_disc_reader
then refuses an AACS disc whose titles have content with UdfNotFound naming
/BDMV/STREAM (or /HVDVD_TS), as the pre-KU-X2 reader did (freemkv#55).
2026-09-28 11:30:46 -07:00
Matthew Jackson 0bbe3dc3d4 KU-X2 red: the whole-disc reader refuses titles with no stream folder (E6003)
The pre-KU-X2 whole_disc_reader refused an AACS disc whose titles have content
but whose image has no /BDMV/STREAM (or /HVDVD_TS) with UdfNotFound. The key
set's reader sweeps it: resolve keys the bare title extents as pieces and the
reader never looks for the folder. Engine: an_empty_stream_map_with_titles_fails_loud.
2026-09-28 11:30:46 -07:00
Matthew Jackson 48be07b719 KU-X2: remove the legacy key API; ResolvedKeySet is the only AACS door
KU §2.2 closures and the §3.1 KU-X2 removals. Behaviour is unchanged: every
read path already takes the set (KU-L2, KU-E1, KU-F1, KU-X1).

- DecryptKeys::Aacs is #[non_exhaustive]; AacsKeyMap::from_ranges*,
  DecryptingSectorSource::{with_key_map, set_key_map},
  aacs::content::decrypt_unit, Disc::decrypt_keys, build_iso_pipeline are
  pub(crate). DecryptKeys and AacsKeyMap leave the crate root re-exports.
- Removed: KeyFetch, KeyFetchFn, keysource::key_fetch, fetch_unit_keys,
  fetch_fmts_indexes, resolve_and_apply(_traced), resolve_keys_for,
  session ResolvedKeys, DiscSession::{resolve_keys, key_fetch},
  InputOptions::{unit_keys, key_fetch}, MuxInput, mux_stream,
  resolve_mux_key_map, resolve_content_key_map, the free whole_disc_reader,
  Disc::{decrypt_with, ensure_decryptable, ensure_decryptable_keys,
  ensure_title_decryptable, decrypt_keys_for_title},
  AacsState::{unit_keys, vuk, key_source} and their test_util builders.
- DiscStream::with_key_map goes too: the set's map is installed by
  keys::install_key_map, so the map never leaves keys (LK20).
- The FMTS phase probe moves from mux/resolve.rs into keys/fmts.rs, its
  only caller.
- tests/crypto_tests.rs decrypts through test_util::decrypt_unit via a
  self dev-dependency with test-util (KU §2.2, Sonnet r4 item 3).
- The library gates lose the disc-banked fallback (KU-L2 -> KU-X2).
2026-09-28 11:30:46 -07:00
Matthew Jackson 85b204bea1 KU-X2 red: LK19 and LK20, no public AACS decrypt door outside ResolvedKeySet
LK19: compile_fail doctests for each KU §2.2 door (DecryptKeys::Aacs,
AacsKeyMap::from_ranges*, Disc::decrypt_with, with_key_map/set_key_map,
DiscStream::with_key_map, aacs::content::decrypt_unit). All eight compile
on dev today.

LK20: the §2.2 structural guard over src/ and tests/, allow-path src/keys/**,
src/decrypt.rs, src/sector/decrypting.rs, src/aacs/**. Dev has 39 hits.
2026-09-28 11:30:33 -07:00
Matthew Jackson e5c59cec2e KU-X2: a raw whole-disc reader that never decrypts
whole_disc::raw_whole_disc_reader(reader) is the old free whole_disc_reader
with decrypt: false, and nothing else: DecryptKeys::None, no content ranges, no
unit spans. It carries the --raw sweep/patch; decrypting copies read through
ResolvedKeySet::whole_disc_reader. It takes no disc and no keys (LK20 green).
2026-09-28 11:30:05 -07:00
Matthew Jackson 0968505dc1 KU-X2 red: a raw whole-disc reader that never decrypts
KU-X2 removed the free whole_disc_reader, and with it the engine's --raw
sweep/patch reader. The raw reader must pass AACS ciphertext and CSS-scrambled
packs through byte for byte, as the old reader did with decrypt: false.
2026-09-28 11:29:56 -07:00
Matthew Jackson 1eae1a6f12 E7013: side reads when batch partners fail; wrong FMTS key judged per key
- prove: when no opener opens any partner in the read (they may be garbled
  heads that kept their sync), the side reads are tried before a verdict. It
  stops only if a partner exists and none, in the read or beside it, opens.
- apply_aacs_map: failed and verified FMTS units are tallied per mapped key.
  A key with two or more failures and none verifying is wrong (E7013), even
  beside a good range in the same read; the per-read check stays too.
- arrival module doc trimmed to 5 lines.
2026-09-28 11:12:55 -07:00
Matthew Jackson fb01e4f6c4 E7013 red: damaged batch partners fall back to side reads; wrong key judged per key
A unit that opens, whose only partners in its read are garbled heads that
kept their sync, stops E7022 without trying the side reads. A read spanning
a good FMTS range and a wrong-keyed one blanks the wrong one instead of
stopping, because the verdict counts the whole read, not each key.
2026-09-28 11:10:09 -07:00
Matthew Jackson cb76dccbeb E7013: a lone unit that fails its key check is damage, never a stop
A garbage head that keeps the CPI flag and 0x47 at byte 4 passes the seed test,
so it reached the key checks and stopped the rip. Now:

- FMTS Even/Odd (apply_aacs_map): a unit failing the correct-phase verify is
  blanked and counted. The read is a wrong key (E7013) only when two or more
  of its units fail and none verifies: a wrong key fails every unit it keys,
  damage fails one, and resolve's phase probe catches a wrong key first.
- On arrival (KU 2.4): a unit no held key opens, while a held key (the
  provisional one, when confirming) opens a partner in the same batch, is
  blanked and counted and proves nothing. With no such partner it still
  stops E7022/E7032; no side read is added (KU 2.4 review item 5).

Both counts reach the reader's blanked-unit tally (mux loss, extract's
unreadable bytes, the end-of-rip log).
2026-09-28 11:02:40 -07:00
Matthew Jackson 5015565523 E7013 red: a garbled head that keeps its sync is damage, never a stop
A damaged unit whose garbage head keeps both the CPI flag and 0x47 at byte 4
passes the seed test, so it is not blanked up front. It then stops the rip:
E7013 when an FMTS Even/Odd unit fails the correct-phase verify, and E7022
when it is the unit that proves or confirms a key on arrival. Both should be
blanked and counted. A wrong key still stops: the FMTS wrong-key guard now
keys two even units (a wrong key fails every unit it keys), and a new case
reads a wrong key through the on-arrival proof and the FMTS reader.
2026-09-28 11:01:16 -07:00
Matthew Jackson 5ebbf325be E7013: blank and count damaged AACS units on every path, never E7013
User decision (option A): a flagged unit no key can open (no TS sync in its
seed, or a zero-filled head over ciphertext) is zero-filled, warned and
counted, and the rip carries on, on the ISO mux, live mux, extract and the
whole-disc reader (sweep, patch, image->ISO). The grid-proof guard (two or
more unsynced units without a proven grid -> E7013, grid_proven, the per-read
base proof) is gone; apply_aacs_map blanks an off-grid chunk instead of
refusing. A wrong key is still E7013 at resolve and E7022 on arrival; a
blanked unit is never an on-arrival partner.

The count surfaces as mux errors/lost_bytes (ISO via the shared counter on
PipelinedPesStream, live via DiscStream), extract's unreadable bytes (a folder
with blanked units is not complete), and blanked_units() on the readers. The
reader logs "N damaged AACS units blanked" once when it is dropped. The
Dunkirk test now also counts the units whose head a zero run took.
2026-09-28 09:14:02 -07:00
Matthew Jackson 48b1024c13 E7013 red: blank and count damaged units on every path, never E7013
User decision (replaces the grid-proof guard): a flagged unit no key can open
(no TS sync in its seed, or a zero-filled head over ciphertext) is blanked,
logged and counted, and the rip carries on, as 1.7.7 did. Covers a single
unit, a cluster and an off-grid run on the ISO mux, the live mux, extract and
the whole-disc reader, and the Dunkirk damage pattern. The count shows as mux
loss (errors, lost_bytes) and extract's unreadable bytes. The live-mux
alignment test gets the key map AACS decrypts through. The counter accessors
are stubs (0) here.
2026-09-28 09:14:02 -07:00
Matthew Jackson a24e62f43e E7013: hole damaged AACS units before any key verdict
b4d322e made apply_aacs_map refuse a CPI-flagged unit with no TS sync in its
seed (a read cut off its file grid). A damaged seed looks the same, so a
damaged rip muxed through before now fails E7013 mid-title, and the on-arrival
proof stops E7022 on it.

DecryptingSectorSource now holes read damage before the key map or the
on-arrival proof sees it (decrypt::hole_damaged_units): a flagged unit whose
seed lacks the sync, or a unit whose zero-filled head sector took the seed
over a non-TS rest (KS-4, KS-2, KS-3). The grid guard stays for what it is
for: two or more unsynced units with no grid witness, in the read or earlier
on the same base, are still E7013. A damaged unit is not an on-arrival
partner, so it neither proves nor refutes a key; a readable unit no held key
opens still stops E7022, and a wrong key at resolve is still E7013.
2026-09-28 09:14:02 -07:00
Matthew Jackson a5a74922bb E7013 red: read damage mid-title is a hole, never a key failure
An ISO mux of a damaged rip fails E7013 when a unit's first sector read back
as garbage (CPI set, no TS sync at byte 4), and the on-arrival proof stops
E7022 on the same unit; a unit whose head sector is zero-filled passes its
ciphertext tail out. Zero-filled runs, aligned or not, already mux through
(guards). Regression from b4d322e (off-grid seed guard).
2026-09-28 09:14:02 -07:00
Matthew Jackson e867e1c43a test(flush): widen chunk_halves_on_slow_flush timing (20ms slow bound flaked under CI load) 2026-09-28 09:11:34 -07:00
Matthew Jackson 094996d8c4 ST-X2: Stop lints (stop design 5.8) as grep tests
tests/lint_stop.rs walks the production module tree from src/lib.rs (items
and files gated on a test-only cfg dropped, comments and literals masked):

- no thread::sleep in src/{drive,disc,sector,io,mux}; two listed sites
  (extract.rs case probe retry, network.rs accept poll) remain open items;
- no raw transport execute in drive/ or identity.rs outside Drive::dispatch
  and DriveId::from_drive;
- no raw halt flag access outside halt.rs except the from_arc bridges
  (Drive::halt_flag, the macOS shim's cancel_byte);
- no is_finished outside halt.rs (use join_within);
- #[allow]/#[expect] only in halt.rs and tests, with the current sites as a
  shrink-only baseline.

Allow-list entries that match nothing fail the test.
2026-09-28 08:53:36 -07:00
Matthew Jackson abf5c1c571 ST-X2: drop #[allow] attributes that suppress nothing
Each was removed and clippy -D warnings stays clean on macOS, Linux and
Windows (all targets), aarch64/musl/windows-gnu/x86_64-darwin (lib),
--features test-util, and --no-default-features --features scsi:
ifo.rs DvdTitle/DvdVideoAttr, mpls.rs play_item_ref, mp4 build_mdia (6 args),
io::pipeline Flow::Stop, WritebackFile::create, labels StreamLabel, and the
unused_imports on scsi/mod.rs's Error/Result import.
2026-09-28 08:53:36 -07:00
Matthew Jackson a867d5f20b ST-X2: FE re-read pause waits via Halt::wait
FeRereads::pause hand-rolled a halt-aware sleep loop (50 ms thread::sleep
slices). It now calls Halt::wait on the scan's token, or on a fresh
never-cancelled Halt when there is none, so the pause is waited out in full.

the_reread_pause_is_a_halt_wait asserts the pause is refused under a
NoBlockingScope (red on the old loop); the_reread_pause_without_a_token_is_waited_out
guards the no-token case.
2026-09-28 08:53:36 -07:00
Matthew Jackson 104cf02f66 ST-L3 review minors: session token wins in resolve; lock retries wait
1. resolve_key_set: under open_with the session's op token wins over a
   caller's ResolveKeysOptions.halt, with a warn! when they differ, as
   Drive::alias does (§2.2). Test: session_token_wins_over_the_callers_in_resolve.
2. Pipeline::set_op_token rustdoc: a stopped close returns its own result;
   only a close leaked after its grace gives Halted.
3. ArtifactLock: ESTALE (or ENOENT) from fstat of the open sidecar retries too
   (§2.5 "ESTALE from fstat/stat … is treated as retry"). Test:
   estale_from_fstat_retries.
4. ArtifactLock: both retry arms wait one halt-aware slice under the T10 timer,
   so a lasting identity mismatch ends E9073 after the window instead of
   spinning. Test: persistent_identity_mismatch_times_out_not_spins.

Red against the previous code: (1) the resolve ended in a refusal under the
caller's token; (3) the injected ESTALE failed the acquire; (4) the acquire
spun until killed.
2026-09-28 08:26:06 -07:00
Matthew Jackson 1d3ce1fd62 ST-L3 artifact lock: acquire, identity re-check and the T10 stall wait
Stop design v5.6 §2.5 acquire loop: open the sidecar read-write (SS-8: "in
order to place an exclusive lock, the file must be opened for writing"), take
an exclusive non-blocking lock polled every WAIT_SLICE, then check that the
open file is still the one the path names (SS-9 st_dev + st_ino; SS-10 volume
serial + file ID); a mismatch, ENOENT or ESTALE retries. On Windows the
sidecar is shared read/write/delete and a delete-pending ERROR_ACCESS_DENIED
retries, so a lasting permission failure surfaces as E9073 (§2.5).

T10: the wait fails TimedOut{artifact_lock} (E9073) only after 30 s with no
change in the size or mtime of <final>.partial or a watched file, read by
path each poll; a Stop returns Halted within a slice. delete removes the
sidecar while still holding it, then releases.

LP14a/b, the cancel case, the unlink-race re-check, the lifetime rule and the
watched-file case are green; SP6/SP7 stay green (SP8 compiles, runs on qa).
2026-09-28 08:23:48 -07:00
Matthew Jackson cfe7a3f008 ST-L3 artifact lock red: the <final>.lock sidecar and the T10 wait
Stop design v5.6 §2.5 "Artifact lock" and T10: a sidecar named after the final
artifact (<final>.lock), acquired as open read-write + exclusive lock + an
identity re-check with retry, held for the whole op, deleted while held on
success or Discard and kept otherwise. The wait is halt-aware and fails
TimedOut{artifact_lock} (E9073) only after 30 s in which the .partial (and
any watched file, e.g. the mapfile) did not change.

Adds libfreemkv::io::artifact_lock (ArtifactLock, lock_path,
ARTIFACT_LOCK_WINDOW) with the per-OS primitives (flock on Linux/macOS,
LockFileEx + FILE_SHARE_DELETE + the file ID on Windows), the SS-8, SS-9 and
SS-10 quotes, registry lines and site rows, and the tests LP14a/b, the cancel
case, the unlink-race identity re-check, the lifetime rule, the watched-file
progress case and the SP6/SP7/SP8 spec tests. acquire and delete are
old-behaviour stubs (nothing waits, nothing is deleted; dead code allowed for
this commit only). Red: every waiter gets the lock at once.
2026-09-28 08:23:48 -07:00
Matthew Jackson bdbe9fa51e ST-L3 pipeline: a consumer that closes after the cancel keeps *.partial
Stop design v5.6 §2.5 (the ST-L3 rule): "A consumer that entered CLOSING
after the cancel keeps its output under *.partial. The leaked consumer
therefore cannot later produce a final-named file." §2.6: "A post-cancel
close() leaves *.partial."

The consumer reads the op's token when it commits to close(): after a cancel
it commits as CLOSING_STOPPED and runs Sink::close_stopped (defaulted to
close, so sinks that never rename are unchanged, and the engine sweep keeps its
summary). T8 (D2, "5 s for a CLOSING one"): such a close gets one grace and is
then leaked with Halted; only a close committed before the cancel is waited
for while it progresses (LP2). finish_with_halt sets its halt as the op token
if none was set; the mux driver sets its op token at spawn, so its wedge token
never counts as a Stop.

LP3 (closing_after_cancel_keeps_partial,
closing_after_cancel_gets_one_grace_and_never_renames) is green; LP1's
*.partial half and the default-sink guard stay green.
2026-09-28 08:23:48 -07:00
Matthew Jackson d5d263e843 ST-L3 pipeline red: a consumer that closes after the cancel keeps *.partial
Stop design v5.6 §2.5: "A consumer that entered CLOSING after the cancel keeps
its output under *.partial (ST-L3 rule). The leaked consumer therefore cannot
later produce a final-named file." §2.6: "A post-cancel close() leaves
*.partial." T8 (D2): 5 s for a RUNNING consumer and 5 s for a CLOSING one.

Adds Sink::close_stopped (defaulted to close) and Pipeline::set_op_token as
old-behaviour stubs, the LP3 tests, LP1's *.partial half (GUARD) and the
default-sink guard. Red: a consumer that reaches close() after the cancel
still runs close() and renames to the final name, and the caller waits for
it while it progresses instead of giving it one grace.
2026-09-28 08:23:48 -07:00
Matthew Jackson 2a10ec9a4c ST-L3 session: open_with, scan_with and finish on the held handle
Stop design v5.6 §6 ST-L3, §2.2 and §2.5:
- open_with opens under the caller's op token; its bring-up stays advisory
  except for a Stop, which ends it Halted with the handle closed, and a final
  check catches a Stop after the last CDB. open keeps today's behaviour.
- scan_with scans under the alias rule (Disc::scan) and checks the session's
  op token last, so a stopped scan stores no Disc.
- finish(Finish::{Release, Unlock, Eject}) ends the session on the handle it
  holds. Eject is the ALLOW (if this Drive locked the tray) then START STOP
  UNIT LoEj 1 through exec_cleanup(FinishEject), so it still runs after a Stop
  (§2.4 row 2; SS-6 MMC-6 Table 633 "Eject the disc if permitted").
- resolve_key_set checks the session's token first, uses it when the caller
  passes none, and reports to attach_progress's Progress (T29).

LSe1, LSe2, LSe3, LS7 (scan half), open_with's Stop and final check are green;
the session alias-rule guard stays green.
2026-09-28 08:23:48 -07:00
Matthew Jackson 756784df99 ST-L3 session red: DiscSession::{open_with, scan_with, finish}
Stop design v5.6 §6 ST-L3 and §2.5 ("Follow-ups use the held handle"):
DiscSession::open_with runs the bring-up under the caller's op token,
scan_with scans under it, and finish(Finish::{Release, Unlock, Eject}) ends the
session on the handle it holds; there is no resolve_keys_with (§2.12).

Adds the API as old-behaviour stubs (open_with/bring_up keep the advisory
bring-up and drop the token, scan_with is scan, finish only drops the session,
attach_progress records nothing) and the tests LSe1, LSe2, LSe3, LS7 (scan
half), open_with's Stop and final check, plus the session alias-rule guard.
Red: finish sends no eject and no ALLOW, a Stop during the bring-up still
builds a session, and a resolve after a stopped scan is DeviceNotReady.
2026-09-28 08:23:48 -07:00
Matthew Jackson 517d7a0ed0 ST-L3 keys: ResolveCtx::halt()/progress() from the one ctx; busy per source call
Stop design v5.6 §2.12: "ST-L3 adds ResolveCtx::halt() (defaulted). KU's keys
module builds the one ctx struct and returns that halt from it." DiscInputsCtx
now carries the op's token and progress, and resolve hands them to every
source. §2.1 item 2 (ST4-2): each source call holds busy() on
ResolveCtx::progress(), so the idle-only T29 probe never counts a keydb parse
as idle. resolve ends with a final check, so a Stop during the last source call
is Halted and builds no set. ResolvedKeySet::resolve_with_progress is the
additive entry that supplies the progress (ResolveKeysOptions is built by
struct literal in the engine, so it gains no field).

LSe4 resolve_passes_halt_to_every_source_ctx and
resolve_holds_busy_around_every_source_call are green.
2026-09-28 08:23:48 -07:00
Matthew Jackson 78ec324d1e ST-L3 keys red: every source ctx carries the op token and progress
Stop design v5.6 §2.12 (:575) and §2.1 item 2 (ST4-2): KU's keys module builds
the one ctx struct, so ResolveCtx::halt() and ResolveCtx::progress() come from
it, and each resolve source call holds busy() on the op's progress.

Adds the defaulted ResolveCtx::halt()/progress() (None), the
DiscInputsCtx::with_stop seam and ResolvedKeySet::resolve_with_progress as
old-behaviour stubs, plus LSe4 and the busy test. Red: the spies see no token,
no progress, and a Stop during the last source call ends in a refusal.
2026-09-28 08:23:48 -07:00
Matthew Jackson 949113b8f3 L3 review r4 (B-1, m-a, m-b, m-c): judge an mpg:// pack by its first PES header
- ps_scrambled_at walks leading packets that carry no PES header (the
  system header, and every stream_id Table 2-17 exempts: MS-31, new) by
  their length, then requires a start code, the PES '10' marker and a
  header inside the clear first 0x80 bytes: a map-first clear pack is
  clear, and a scrambled PES after the map is found at its own flags.
  Only a pack's first PES is judged (documented).
- crack_key_scan_with takes the flags-offset function: the disc path
  passes is_scrambled_pack at 0x14 and cracks the sector as before; only
  the mpg:// scan presents a moved flags byte to the keyless crack.
- The mpg:// crack's rustdoc names ps_scrambled_at, not an unstuffed test.
2026-09-28 07:25:53 -07:00
Matthew Jackson 5fe32e1d13 L3 review r4 red (B-1): a clear PS opening with a program stream map is E7023
ps_scrambled_at reads 0x14 + stuffing as PES flags whatever packet sits
there; a PSM's version byte (0xE0) reads as scrambled, so a clear file
whose pack begins with its map is refused. The PES after the map is
never judged.
2026-09-28 07:25:53 -07:00
Matthew Jackson 4da13b94be L3 review r3 (m1, m2): the mpg:// CSS layer reads each pack's own flags
- ps_scrambled_at finds a 13818-1 pack's PES flags at 0x0E + stuffing + 6,
  so the strict crack scan judges stuffed packs by their real flags and
  cracks them (m1).
- PsFileCss, the mpg:// pipeline's CSS layer, descrambles with that
  offset (descramble_ps_region, the disc path's crib and re-crack logic),
  so a clear stuffed pack is never "descrambled" (m2). Without a key it
  refuses a scrambled pack with E7023 (CssKeyMissing), so scrambling past
  the crack's 50 000-sector budget is never muxed as ciphertext (m1).
- descramble_region and is_scrambled_pack keep their behaviour; the
  shared crack scan here still applied the stuffed-flags swap on every
  path (gated to mpg:// in r4, m-a).
2026-09-28 07:25:53 -07:00
Matthew Jackson 67c8306b1f L3 review r3 red (m1, m2): stuffed or late CSS packs reach the mux as ciphertext
- An MPEG-2 file whose scrambled packs are all stuffed reads as clear to
  the strict scan and is muxed as ciphertext.
- Scrambling first met past the crack's 50 000-sector budget is muxed as
  ciphertext.
- In a CSS file, a clear stuffed pack whose byte 0x14 holds set bits is
  "descrambled" by descramble_region.
2026-09-28 07:25:53 -07:00
Matthew Jackson eeaf5d0ae3 L3 review r2 (D3): stuffing is checked only by the mpg:// crack; one crack scan
- is_scrambled_pack keeps B2's '01' marker test for every read path but
  no longer requires pack_stuffing_length 0: on a disc/ISO a stuffed
  scrambled pack is detected and descrambled, as libdvdcss reads 0x14.
- The mpg:// file crack (css::resolve_ps_file_title_key) scans with the
  strict is_scrambled_unstuffed_pack, where a foreign stuffed pack's 0x14
  is PES header.
- Its verdict, clear included, is final: build_iso_pipeline is told not
  to crack again.
2026-09-28 07:25:52 -07:00
Matthew Jackson 51c9d5c0d4 L3 review r2 red (D3): stuffed scrambled packs pass on discs; clear files scan twice
B2's pack_stuffing_length == 0 test in is_scrambled_pack applies to every
DVD/ISO/disc read, so a stuffed scrambled pack would pass through as
ciphertext. And build_ps_pipeline hands build_iso_pipeline keys None for
a clear file, which cracks again.
2026-09-28 07:25:52 -07:00
Matthew Jackson db0338cfbc L3 review (MPG2-7): merge a PTS-less second field into its first
SecondFieldMerge holds each assembled H.264 AU on the PS path until the
next shows whether it is its second field: a PTS-less AU whose first slice
codes the opposite field of the same frame_num (SPS taken in-band) is
appended to it, so the IR frame is the field pair (design §2.3 "Reader
side (L3)"). A PTS-less frame picture stays its own AU.
2026-09-28 07:25:52 -07:00
Matthew Jackson ff0bf7d275 L3 review red (MPG2-7): a PTS-less second field stays its own AU
One AUD per field splits a PAFF H.264 field pair into two AUs on the PS
path, the second with no PES PTS, so the parser emits it as a frame of its
own (h264.rs stamps it PTS 0).
2026-09-28 07:25:52 -07:00
Matthew Jackson 5aecf952ec L3 review (G17): widen the E-AC-3 sub-id range to 0xC0-0xCF
dvd_audio_pid, the private_stream_1 sub-header strip and the mpg:// scan
take the whole E-AC-3 range, so a sub-stream at 0xC8-0xCF (mpg://'s
allocation, F8) is routed and read back.
2026-09-28 07:25:52 -07:00
Matthew Jackson b120b50e58 L3 review red (G17): E-AC-3 sub-ids 0xC8-0xCF are neither routed nor stripped
dvd_audio_pid and the private_stream_1 sub-header strip stop at 0xC7, so an
E-AC-3 sub-stream at 0xC8-0xCF (where mpg:// allocates) is dropped.
2026-09-28 07:25:52 -07:00
Matthew Jackson ab577935e2 L3 review: the mpg:// scan pairs by layer index, finds syncs, reads mc_header
- An extension is paired in a second pass by its map's
  hierarchy_embedded_layer_index (MS-23), wherever its base sits; one the
  IR cannot pair (0xD0|n with 0xC0|n, M1) is left out with a warning.
- Without a map, 0xD0-0xD7 are classified by the first sync word in the
  probe bytes.
- MPEG audio channels come from a CRC-verified mc_header run (M1's
  ChannelTracker), design §4 step 3.
- Probes are keyed by the real video stream_id and one video stream is
  chosen; the pipeline routes only it and counts the rest.
2026-09-28 07:25:52 -07:00
Matthew Jackson 842e9e0de3 L3 review red: the mpg:// scan pairs, classifies, counts and routes naively
- 0xD0-0xD7 without a map are classified by their first two bytes only.
- An extension is paired by stream id and only after its base in map
  order, not by hierarchy_embedded_layer_index.
- MPEG audio channels come from the MPEG-1 header, not the mc_header.
- Every video stream_id 0xE0-0xEF is routed into the one video track.
2026-09-28 07:25:52 -07:00
Matthew Jackson 47b5b26f6d L3 review: clamp the padded read length before narrowing it
open_padded's short read took the file's remaining length as usize before
clamping it to the batch; on a 32-bit target that truncates. No red test
is possible on a 64-bit host, where the cast cannot truncate.
2026-09-28 07:25:52 -07:00
Matthew Jackson 2076c64a21 L3 review: a damaged or header-less frame breaks the mc_header run
The three CRC-valid frames ChannelTracker needs before committing a count
above nch are now strictly consecutive.
2026-09-28 07:25:52 -07:00
Matthew Jackson 1d2ca8764a L3 review red: a damaged frame does not break the mc_header run
ChannelTracker::observe keeps its run across a Damaged or NoHeader frame,
so "three consecutive CRC-valid frames" can span a damaged one.
2026-09-28 07:25:52 -07:00
Matthew Jackson f2c8ce72e1 L3 review: the mpg:// crack takes the caller's halt and runs with raw = false
build_ps_pipeline threads halt into the crack and the pipeline (the Stop
rule), and cracks even under --raw so the head scan reads the streams
(design §4 step 2.1); the mux stays raw, and a raw read of an
uncrackable file scans the ciphertext rather than failing.
2026-09-28 07:25:52 -07:00
Matthew Jackson 58dbbbc0fd L3 review red: the mpg:// crack ignores halt and is skipped under --raw
Design §4 step 2.1 cracks with raw = false and the caller's halt; the Mpg
input arm passes neither, so a stop cannot interrupt the crack and a raw
read scans ciphertext.
2026-09-28 07:25:52 -07:00
Matthew Jackson 8308bcac29 L3 review B2: a clear MPEG-1 or stuffed MPEG-2 PS is never read as scrambled
(a) build_ps_pipeline cracks CSS only when the file's first pack is a
    13818-1 pack ('01'): an 11172-1 system stream cannot be CSS.
(b) is_scrambled_pack requires '01' at byte 4 and pack_stuffing_length 0,
    the only layout with the first PES's stream_id at 0x11 and its flags
    at 0x14. The CSS fixtures now write a 13818-1 pack header.
2026-09-28 07:25:52 -07:00
Matthew Jackson 361f8611e0 L3 review B2 red: a clear MPEG-1 PS is refused with E7023
is_scrambled_pack assumes an unstuffed 14-byte 13818-1 pack and reads
0x11/0x14; in an 11172-1 pack those are a packet's length and PTS (FFmpeg
writes the STD buffer field, so 0x14 is '0010' PTS), and a stuffed
13818-1 pack moves the PES there. The crack then reports
ScrambledUncracked.
2026-09-28 07:25:51 -07:00
Matthew Jackson 48cedd6964 L3: mpg:// as a source, through the DVD sector pipeline
mpg-output-design v5 §4 (L3), under J23/J24:
- input(mpg://) -> build_ps_pipeline: every file takes the sector path,
  one extent over the whole file (FileSectorSource::open_padded
  zero-pads a partial tail sector, J13); the keyless CSS crack runs
  first, the 4 MiB head is read through the resolved keys, and
  build_iso_pipeline gets those keys, so a scrambled .vob is
  descrambled and a clear file passes through. No pack -> E6009.
- mux/mpg/scan.rs: the program stream map (CRC_32-checked) gives track
  order, stream types (0x01/0x02 video), ISO 639 languages, the 13818-3
  hierarchy (type 5 -> extension track) and the FMKV tag-0xFA
  sub-stream languages, forced flags and palette (DvdSub idx text).
  In-band probes in every case: MPEG-1/2 sequence header (codec,
  resolution, rate), MPEG audio header, LPCM header; without a map,
  0xD0-0xD7 split by sync (0x7FF extension, 0xFFF audio).
- ps.rs: each pack's layout chosen by its marker bits ('01' 13818-1,
  '0010' 11172-1, else resync); 11172-1 packets parsed (stuffing, STD
  buffer, '0010'/'0011'/0x0F); the PSM kept and skipped by its length.
- codec: Mpeg1 is framed by Mpeg2Parser (the Av1 fallback stays).

Not here (design deviations, reported): the G17 E-AC-3 widening and the
PTS-less second-field merge serve only E-AC-3/H.264 output, deferred
to F8 by J24; 0xC8-0xCF/0xD8-0xDF stay unrouted (M1's dvd_pid guard).

Tests: PS -> IR read-back of every stream and its metadata, frames
exact modulo one tick offset; PS -> IR -> mpg -> IR exact; mpg -> mkv
through mux_stream (the extension reported undelivered, J23); a
CSS-scrambled .vob; a truncated file; a hand-built 11172-1 system
stream remuxed as stream_type 0x01; MPEG-1 packet forms, mixed and
lost-sync packs, the kept map, a bad map CRC, sync classification,
open_padded.
2026-09-28 07:25:51 -07:00
Matthew Jackson dfbb3078cb L2 review r3 (m7): say the PES holding the mark carries the PTS
Comment only: the tail's clamp keeps the mark out of a PTS-less PES; the
PES holding it carries the PTS (MS-15).
2026-09-28 07:25:51 -07:00
Matthew Jackson 1b4461a3f6 L2 review r2 (nit): count and warn the forced EOF drain
pump(eof) counts each pass that lifts the lead and whole-frame waits in
PstdCounters::forced_eof and warns once with the AUs it drains.
2026-09-28 07:25:51 -07:00
Matthew Jackson 80950dee65 L2 review r2 red (nit): the forced EOF drain is silent
The EOF pass that lifts the 0.95 s lead and the whole-frame wait runs
uncounted; PstdCounters gains forced_eof, never incremented yet.
2026-09-28 07:25:51 -07:00
Matthew Jackson 812f9c1d70 L2 review r2 (D1): a tail stops at the picture start
When the head AU has not reached its commencement byte, the tail branch
clamps its length to the mark, so the mark always starts the PES that
carries the PTS (MS-15).
2026-09-28 07:25:51 -07:00
Matthew Jackson b9a62683be L2 review r2 red (D1): a tail can cross the picture start without a PTS
With cap <= dist < cap + 5 (+10 with a DTS) the commencing PES does not
fit, and the tail branch then runs past the mark with no PTS (avail 494,
dist 484 -> a 485-byte tail).
2026-09-28 07:25:51 -07:00
Matthew Jackson fdb73a2c32 L2 review: cap the start-up hold at 1 s of IR timeline as well as 64 MiB
While the DTS window waits for unit R, 1 s of IR past the oldest held
video frame (or 64 MiB held) releases it in arrival order, counted in
dts_hold_overflow, before and after the origin alike (design §2.3).
2026-09-28 07:25:51 -07:00
Matthew Jackson ea48d57f2a L2 review red: the start-up hold has no 1 s timeline cap
A slideshow DVD (I at 0 s, P at 5 s) holds 5 s of IR behind the I and is
never counted in dts_hold_overflow: only the 64 MiB cap is applied.
2026-09-28 07:25:51 -07:00
Matthew Jackson 30060ab407 L2 review: re-anchor 20/24-bit LPCM PTS on every frame
An LPCM AU cut on a frame takes the frame's PTS less the carried samples'
duration; the running arithmetic remains only for the EOF flush.
2026-09-28 07:25:51 -07:00
Matthew Jackson 2c2f679d2c L2 review red: 20/24-bit LPCM PTS follow a running sample count
With samples carried between frames, an AU's PTS continues the previous
cut's arithmetic instead of re-anchoring on the frame it is cut from, so
the output drifts from a source clock that runs apart from its samples.
2026-09-28 07:25:51 -07:00
Matthew Jackson 4cf69adf39 L2 review: a declared-only extension is neither waited for nor described
The extension output holds the lookahead only from its first AU. The map
and system header describe only extensions seen in the origin window; one
first seen later is left out and reported (J23), and its base is then
plain 11172/13818-3 audio with no hierarchy descriptor.
2026-09-28 07:25:51 -07:00
Matthew Jackson 27a273dfba L2 review red: a declared-only extension trips the interleave cap
IFO coding mode 3 only declares the MPEG-2 extension. With no packets, its
output still holds the lookahead from the start (a false interleave_cap
warning on a clean disc), and the map and system header describe it.
2026-09-28 07:25:51 -07:00
Matthew Jackson 6992284b53 L2 review: an oversize AU is counted late; lateness by its last byte
An AU larger than its buffer (the room bypass) is counted in
pstd_late_aus. Lateness is now judged by the AU's last byte's own arrival
(MS-4 eq. 2-21 from the pack's SCR and rate, after any PES-header
stuffing), not the pack's end, so the replay matches the count exactly.
2026-09-28 07:25:50 -07:00
Matthew Jackson ad74f80a37 L2 review red: the replayer checks exactly, and an oversize AU goes uncounted
The replayer now uses its own CRC_32 and picture start parse, checks the
pack and PES marker bits, PES_header_data_length and stuffing <= 32, and
compares late AUs and PTS gaps with the sink's counters exactly. Two
always-true asserts are gone. An AU larger than its buffer takes the
oversize bypass without being counted in pstd_late_aus.
2026-09-28 07:25:50 -07:00
Matthew Jackson c39ff6bb3a L2 review B1: no AU wedges its stream; EOF never leaves one behind
- MpgSink::write drops an empty frame before the DTS deriver: no AU.
- A video AU whose picture start code no PES of a fresh pack reaches sends
  the bytes before it without a PTS; the PES holding it carries the PTS
  (MS-15). The replayer allows exactly that split.
- pump(eof) with nothing schedulable lifts the lead and whole-frame waits
  and drops empty AUs once; anything still stuck is an error, never Ok.
2026-09-28 07:25:50 -07:00
Matthew Jackson 32dfec84cd L2 review B1 red: an empty AU or a far picture start wedges its stream
An empty frame (a Matroska empty Block), or a video AU whose picture start
code no PES of a fresh pack reaches, stops its stream; finish() returns Ok
with every later AU still queued.
2026-09-28 07:25:50 -07:00
Matthew Jackson 0fd7ea5247 L2: an audio frame that fits one PES goes whole
A PES whose AC-3 frame starts but does not complete in it loses its
PTS in our Ac3Parser (it adopts a PES PTS only for a frame it can close
there), so the first frame read back at 0. MPEG audio, AC-3 and DTS
AUs now go whole into one PES when a fresh pack could hold them (they
wait for one); only a larger frame (BD AC-3 at 640 kbit/s) spans PES.
The scheduler's wake time now waits for a removal once the 0.95 s lead
is met, instead of stepping one tick. Red: plan_pes unit test, the L3
read-back (first AC-3 frame at PTS 0).
2026-09-28 07:25:50 -07:00
Matthew Jackson 86e34f10b6 L2: a PES that carries a PTS begins at its AU
MS-15 lets a PES open with the tail of the previous AU before the one
its PTS names, but a DVD encoder starts each PTS-bearing PES at its
access unit, and PS readers rely on it: our own AuAssembler gives a
PES's PTS to the AU holding its first byte, so the previous picture
took it. A PES is now either a pure tail (no PTS) or starts at an AU
(first_access_unit_pointer 1 on 0xBD). Red: plan_pes unit test
((983, commencing) vs (10, tail)); the L3 read-back.
2026-09-28 07:25:50 -07:00
Matthew Jackson 5cb4f7d710 L2: an AU begins in the PES that carries its PTS
MS-15 makes a video PTS name the AU whose picture start code commences
in the PES. The sink honoured that but could end a PES between an
I-picture's sequence/GOP headers and its picture start code, so the
headers rode one PES and the PTS the next. A DVD encoder never does
that, and PS readers (our own Mpeg2Parser via the DVD path included)
attach the PTS to the AU that starts in its PES: read back, the GOP
anchor slipped three pictures. A PES now ends before the next AU's
first byte. Red: plan_pes unit test (120 bytes vs 100) and the L3
read-back; the replayer now also checks the first byte shares the PTS
PES.
2026-09-28 07:25:50 -07:00
Matthew Jackson 7897e5cb07 L2: spare pack bytes stuff the last PES header, never the pack header
Design §2.3 pack fill offers both for 1-5 spare bytes; take PES-header
stuffing (MS-13 "No more than 32 stuffing bytes shall be present in one
PES packet header"). A stuffed pack header moves the first PES off
offset 0x11/0x14, where CSS scrambling detection (is_scrambled_pack)
reads a VOB pack, so our clear output read as scrambled when fed back
as mpg:// (L3). Guard: no pack header carries stuffing and no clear
pack reads as scrambled.
2026-09-28 07:25:50 -07:00
Matthew Jackson 51a9372651 L2: mpg:// output, an H.222.0 program stream sink on the PES IR
mpg-output-design v5 §2 (L2) under J24: the DVD core the 2000 edition of
H.222.0 covers. StreamUrl::Mpg + parse/scheme/path, output() arm (input()
is StreamWriteOnly until L3), fit_report(Mpg) = the mpg plan,
url_medium File. MpgNoVideoTrack (E9074) when no MPEG-1/2 video is left.

- mux/mpg/fit.rs: carriage and ids (§2.1-§2.2). DVD sources keep their
  ids; others take the lowest free id; a full range is NoStreamId.
  MPEG audio 0xC0-0xC7 and its 13818-3 extension 0xD0|n bound to the
  carried base; AC-3/DTS/LPCM(48/96 kHz, 1-8 ch)/subpictures on 0xBD.
  H.264/HEVC/VC-1/AV1 UnmappableVideo, E-AC-3 and the F1 codecs
  UnmappableAudio, PGS BitmapSubtitle, text UnmappableSubtitle (J24, F8).
  A declared extension without a carried base is reported once seen (J23).
- mux/mpg/pack.rs: pack header, system header (flags 0, rate_bound
  0x3FFFFF, one bound per stream_id), PSM with Annex A CRC_32 and the
  1018-byte cap, hierarchy (15/5) and ISO 639 descriptors, the FMKV
  tag-0xFA sub-stream table and palette, PES header with P-STD fields,
  padding PES.
- mux/mpg/pstd.rs: EDF over a P-STD model (one Bn per stream_id, 0xBD
  shared), per-pack rate from queued deadlines (R0 10.08/128 Mbit/s,
  rounded up to 50 B/s), SCR advance so packs never overlap, padding
  packs keeping SCR spacing <= 0.7 s, the 0.95 s commencement lead,
  per-track lookahead with a 10 s / 256 MiB interleave cap, <= 1 AU
  commencement per PES (for video the picture start code, MS-15/MS-19),
  LPCM whole units, one extension frame per PES, AC-3/DTS/LPCM
  private headers (FFmpeg convention), late AUs and PTS gaps counted.
- mux/mpg/mod.rs: integer-tick PTS via ns_to_ticks, DTS from the shared
  S0 DtsDeriver (API unchanged), the >= 1 s origin window over all
  tracks (lowest -> 1.5 s), the video drop guard, a Matroska sequence
  header restored from codec_private, LPCM re-chunked into whole units
  at a sticky lossless depth, the first pack's system header and map.
- codec/lpcm.rs: dvd_pack/dvd_header, the exact inverse of the DVD
  parser (G8), round-trip tested for 1-8 channels at 16/20/24 bits.
- spec/mpg.rs: MS-1..MS-30, H.222.0 (2000) verbatim plus FFmpeg
  corroboration of the private_stream_1 headers; registry and site rows.

Tests: a test-only parser and P-STD replayer (arrival from SCR and mux
rate, buffers, 1 s delay, SCR spacing, pack overlap, P-STD fields, PSM
CRC, one PTS per AU naming its commencing AU) that must find no more
than the sink counted, and rejects tampered streams; ES and PTS round
trips (exact modulo one tick offset); read back through PsDemuxer;
DTS iff differs; extension PTS; LPCM groups and depth; map contents;
damage gap, audio lead, 12 subpicture streams, high-rate burst,
slideshow, lagging interleave, MPEG-1, E9074, MuxEmpty.
2026-09-28 07:25:50 -07:00
Matthew Jackson 789581c3f4 L1: container-neutral pre-mux fit plan (fit_report, SkipReason)
mpg-output-design v5 §1.1 G3, §8 L1 as revised by J23:
- mux::fit::fit_report(&StreamUrl, &DiscTitle) -> FitReport, and the
  #[non_exhaustive] SkipReason with Mp2Extension, NoStreamId and
  UnmappableSubtitle beside the mp4 reasons (From<Mp4SkipReason>).
- mp4:// is its existing plan through the kept mp4 wrappers
  (mp4_fit_report, Mp4FitReport, Mp4SkipReason unchanged).
- m2ts:// plans out LPCM BD LPCM cannot carry, via lpcm_bd_header,
  the predicate M2tsStream::create now shares.
- Class sinks (video/audio/sub://) include only their class and skip
  nothing; every other destination carries everything.
- J23: a declared-only MPEG-2 extension track is never planned out on
  any scheme; its note stays post-mux, once its packets arrive
  (undelivered_streams / Mp4Sink::final_report), as M1 landed it.
2026-09-28 07:25:50 -07:00
Matthew Jackson d14a3425a1 KU-E1b: the run's first source failure outlives its piece
- Item 4: Run::ask records every terminal failure in the run's
  first_failure (Asked::Failed carries no error now). Any refusal falls
  back to it before E7022/E7032: a Missing piece, the empty-pool keyless
  case, HD DVD, and the FMTS anchor (before E7026). A source that
  failed and went dead on one piece is still the refusal for a piece
  that skipped it, so an outage is never coded Missing.
- Item 5: a source that itself returns Halted pushes the request's
  trace step (unanswered) before Halted, so the "a request went out"
  rule survives ST-K1b.
2026-09-28 07:18:11 -07:00
Matthew Jackson d8d922c840 KU-E1b red: the run's first source failure outlives its piece
- Item 4: sources A (fails, dead) and B (keys piece 1 only). Piece 2
  skips A, B holds no key, and the refusal is E7022 instead of A's
  E7028. The FMTS anchor likewise gives E7026 when the source that
  would answer it died on the base content.
- Item 5: a source that itself returns Halted leaves no trace step for
  the request that went out.
2026-09-28 07:16:51 -07:00
Matthew Jackson 466d9c238b KU-E1 review minor 1: a Stop seen at the next retry keeps the asked step
ask() records the unanswered request whenever a Stop ends it after at
least one attempt: in the backoff wait, or at the next attempt's halt
check.
2026-09-28 06:33:04 -07:00
Matthew Jackson dc0bf2133d KU-E1 review minor 1 red: a Stop seen at the next retry keeps the asked step
A retry wait that ends on a Stop without reporting it leaves the halt
to the next attempt's check, which returns Halted with no trace step,
though a request went out.
2026-09-28 06:32:40 -07:00
Matthew Jackson e979d069ba KU-E1 review B-1: an unreachable key service is never coded Missing
- resolve keeps the run's first source failure (step 8's too). A Missing
  refusal is the failure a Missing piece carries, preferring a
  failure-carrying piece, then the run's; only with no failure at all is
  it E7022/E7032. The empty-pool keyless case uses it too. So a
  source outage is never coded Missing, and the engine never turns it
  into E7034.
- Minor 1: a Stop during the backoff after a transport failure pushes
  the unanswered request's trace step before returning Halted.
- Minor 4: a matched NoVid miss is recorded as KeyOutcome::MissingVid.
  The miss_path doc notes that NoVid beside partial keys still counts.
- Minor 7: covers() says it is structural coverage only, and none()
  covers every scope.
2026-09-28 06:28:23 -07:00
Matthew Jackson b609c6c650 KU-E1 review B-1 red: an unreachable key service is never coded Missing
- B-1: with the online source down, the larger title's piece is asked
  first and records E7028; a smaller title's piece earlier on the disc
  is skipped, and the refusal picks it: E7022 (which the engine could
  turn into E7034).
- Minor 1: a Stop during backoff after a transport failure leaves no
  trace step, though a request went out.
- Minor 4: a matched NoVid miss is recorded as NoKey, not MissingVid; a
  guard that partial stored keys plus a Km without a VID set
  vid_would_help.
2026-09-28 06:27:06 -07:00
Matthew Jackson c111fae949 KU-E1: resolve hands back its trace on a refusal; is_aacs is public
- ResolveKeysOptions::trace (additive) is filled with the per-source
  walk on Ok and Err alike. The trace is redacted by construction
  (labels, node enums, booleans, counts), and a guard test pins that no
  key, VID or MKB byte appears in any rendering.
- ResolvedKeySet::is_aacs is public, so a caller can tell none() (no
  AACS keys) from a resolved set; covers(scope) has no disc to decide
  that.
2026-09-28 05:34:59 -07:00
Matthew Jackson 1698faefbd KU-E1 red: resolve hands back its trace on a refusal; is_aacs is public
The per-source walk is the operator's answer to "why no key", but a
refusal drops it. Adds tests for a ResolveKeysOptions::trace
out-param (filled on Ok and Err), a guard that the trace carries no
key, VID or MKB bytes, and a public is_aacs that tells none() from a
resolved set.
2026-09-28 05:34:25 -07:00
Matthew Jackson 6fbcf4432c KU-E1 J23: resolve reports whether the disc's VID would help
- KeySource::uses_vid(&self) -> bool { false } (defaulted, like J15):
  the source derives keys from the VID it is sent.
- ResolveKeysOptions::vid_would_help (additive): resolve sets it when
  no VID was in hand and either a source reported a Media Key it could
  not finish without the VID (miss_path KeyNode::NoVid) or a configured
  source uses_vid().
- The trace keeps a matched source's own miss path (e.g. MatchedDisc >
  NoVid) instead of flattening it to NoDerivableKey, as
  resolve_and_apply_traced already does.

The red test's Km fake now holds no key, as a real keydb with a Km and
no VID answers.
2026-09-28 05:30:35 -07:00
Matthew Jackson 9c74ce9714 KU-E1 J23 red: would the disc's VID help a Missing piece?
Tests for a defaulted KeySource::uses_vid, the additive
ResolveKeysOptions::vid_would_help out-flag, and a trace that keeps a
source's own miss path. The flag is set when no VID was in hand and
either a source reported "matched, Media Key, no VID" (KeyNode::NoVid)
or a configured source consumes the VID. Today resolve flattens the
miss path to NoDerivableKey.
2026-09-28 05:28:10 -07:00
Matthew Jackson be8a5b9ecd KU-L2 review r4: step 10 keys on step 9's evidence (J22, J21 parity)
With one declared CPS unit, a Lazy piece is keyed only when the proven
key opens every Enc probe read of it and there are at least two, or the
piece is one unit long. One opened probe of a longer piece is not proof
(is_clean accepts a wrong key about 1e-5 of the time, and a Keyed piece
skips the on-arrival proof), so the piece stays Lazy with the proven key
as its first candidate.
2026-09-28 03:58:27 -07:00
Matthew Jackson 10dcbdb51e KU-L2 review r4 red: step 10 keys on one probe of a longer piece
With one declared CPS unit, step 10 keys a piece whose single readable
Enc probe the proven key opens, although step 9 (J21) never keys a piece
longer than one unit from one probe. A false accept there (about 1e-5)
would skip the on-arrival proof and ship garbage. Red here.
2026-09-28 03:58:27 -07:00
Matthew Jackson dd7682caba KU-L2 review r3: a Session's keyless set carries its disc's identity
keyless_for_disc builds the Session's keyless set with the disc's hash,
format and capacity: its E7022 names the disc, and it passes is_for on
that disc like any set. The is_for bypass for keyless sets is removed;
Iso/Live keyless sets are never checked against a disc.
2026-09-28 03:58:27 -07:00
Matthew Jackson e4ece31b5e KU-L2 review r3 red: the keyless Session E7022 names no disc
The keyless set a Session mux builds carries no disc identity, so its
E7022 has an empty disc hash (and only passes is_for through a keyless
bypass). The test wants the disc's hash in the error. Also a guard:
keyless_for keeps adjacent clips on their own unit grids (KS-1). Red here.
2026-09-28 03:58:27 -07:00
Matthew Jackson 2ebcbb9378 KU-L2 review r3: a piece with no readable probe is never keyed (J22)
With one declared CPS unit, step 10 keys a Lazy piece only when Enc
probes of it were read and the proven key opens them all. A piece whose
every probe faulted stays Lazy with the proven key as its first candidate
(J22 as amended: invariant 2, "never guessed") and is proven on arrival
or stops.
2026-09-28 03:58:27 -07:00
Matthew Jackson 600ae9b8d5 KU-L2 review r3 red: a piece with no readable probe is keyed blind
J22's vacuous clause keys a Lazy piece whose every probe faulted (empty
Enc set) with the proven key: it never reaches the on-arrival proof, and a
readable unit under another key is decrypted to garbage with Ok. LK4 now
expects such a piece Lazy with the proven key as candidate (still read
correctly on arrival), and a new test has it stop E7022 when its units are
under a key no source holds. Red here.
2026-09-28 03:58:27 -07:00
Matthew Jackson 2178dbd76b KU-L2 review r2: quote KU §3.1 verbatim in mux_with_keys 2026-09-28 03:58:27 -07:00
Matthew Jackson 2ab5d82a45 KU-L2 review r2: keyless_for merges overlapping extents
The keyless set's arrival spans are disjoint: overlapping title extents
merge into one span (adjacent clips keep their own grids), so every AACS
unit of the title is found and stops with E7022.
2026-09-28 03:58:27 -07:00
Matthew Jackson 98125bd96f KU-L2 review r2 red: keyless_for breaks on overlapping extents
keyless_for drops only exact duplicate spans. A title extent inside
another leaves overlapping arrival spans; span_at then misses the outer
span's tail and its AACS units fail as DecryptFailed (bad-media handling),
not E7022. Red here.
2026-09-28 03:58:26 -07:00
Matthew Jackson fe206e637b KU-L2 review r2: n_decl == 1 keys only pieces its key opens (J22)
Step 10 keys a Lazy piece with the proven key only when that key opens
every Enc probe of it (vacuously, none read). A piece with a probe it does
not open stays Lazy and is proven on arrival or stops (J22: invariant 2,
"never guessed", governs step 10).
2026-09-28 03:58:26 -07:00
Matthew Jackson 92ea9eae74 KU-L2 review r2 red: n_decl == 1 keys pieces its key does not open
With one declared CPS unit, step 10 keys every Lazy piece with the proven
key, including one whose Enc probes that key does not open: (a) units
under a key no source holds, (b) probes opened by two different keys. The
piece is then decrypted to garbage with Ok. Red here.
2026-09-28 03:58:26 -07:00
Matthew Jackson 51c72fb7f3 KU-L2 review r2: a keyless Session never muxes ciphertext
mux_with_keys(Session, None) on an AACS disc with no banked key reads a
BD-TS title through keyless_for (E7022 at the first AACS unit) and refuses
an HD DVD up front with E7022 (never probed, KU §2.6). A disc with banked
keys keeps the legacy path until KU-X2 (J20). A keyless set fits any
disc: it holds nothing to misapply.
2026-09-28 03:58:26 -07:00
Matthew Jackson b4c34dfd18 KU-L2 review r2 red: a keyless Session mux writes ciphertext
mux_with_keys(Session, None) on an AACS disc with no banked key reaches
session_mux_keys -> DecryptKeys::None and muxes the AACS units as
content, returning Ok. The test wants E7022 for BD-TS (at the first AACS
unit) and for HD DVD (up front). Red here.
2026-09-28 03:58:26 -07:00
Matthew Jackson 22b5fa2b59 KU-L2 review: try the held keys on U before any side read
prove() first collects the held keys that open the unit (the candidate
first). None: the loud stop, with no side read. Otherwise it looks for a
partner (batch, then at most one side read each way) and proves the first
opener that also opens a partner, or records it provisionally when no
partner is readable.
2026-09-28 03:58:26 -07:00
Matthew Jackson 9c7f1b8aac KU-L2 review red: side reads happen before any held key is tried
prove() side-reads for a partner before trying the held keys on the unit,
so a readable unit no key opens costs up to two side reads (seconds each
on damaged media) before the stop. Red here.
2026-09-28 03:58:26 -07:00
Matthew Jackson a3fce75924 KU-L2 review: n_decl == 1 keys only pieces no other key opened
The single-declared-unit rule converts Lazy pieces with no candidate or
with the proven key as candidate; a piece another held key opened stays
Lazy and is proven on first read.
2026-09-28 03:58:26 -07:00
Matthew Jackson ceb8c1a1eb KU-L2 review red: n_decl == 1 must not key a piece another key opened
apply_single_unit_rule turns every Lazy piece into Keyed(proven slot),
including one whose single readable probe a different held key opened;
that piece is then decrypted with the wrong key. Red here.
2026-09-28 03:58:26 -07:00
Matthew Jackson 7a393b7f78 KU-L2 review: no AACS set, no AACS content muxed
mux_with_keys reads a BD-TS Iso or Live source through a keyless set when
it has no AACS set (none, or a non-AACS set) and is not raw: every title
extent goes to the on-arrival proof, which holds no key, so the first
AACS-flagged unit stops with E7022 and clear units mux. Raw is unchanged,
and so is MPEG-PS (a DVD cracks its own CSS key).
2026-09-28 03:58:26 -07:00
Matthew Jackson 6d9dc55875 KU-L2 review red: mux_with_keys without an AACS set muxes ciphertext
KU §3.1: "keys must be Some for AACS". Iso and Live with no set, or a
non-AACS set such as ResolvedKeySet::none(), map to DecryptKeys::None and
write AACS ciphertext as if it were content. The test wants E7022 at the
first AACS-flagged unit, raw to pass through, and clear content to mux.
Red here.
2026-09-28 03:58:26 -07:00
Matthew Jackson d361992cd5 KU-L2 review: the live reader returns the key-set stop at once
fill_extents returns E7022/E7032 from either read (first attempt or ECC
recovery) before the transport, shrink, recovery and skip logic, so an
unkeyed piece is never zero-filled as a bad sector or reported as E6000.
2026-09-28 03:58:26 -07:00
Matthew Jackson a8a93ee967 KU-L2 review red: the on-arrival stop is not bad media on the live reader
DiscStream::fill_extents treats the key set's loud stop (E7022/E7032) as a
bad sector: it shrinks the batch, runs a 60 s ECC recovery read, then
zero-fills the unit (skip_errors) or reports E6000. The test drives an
unkeyed Lazy piece through the inline reader in both skip modes and wants
E7022 at once, no recovery read and no skipped bytes. Red here.

Also a guard (review item 1): a whole-disc resolve over a disc with no
scanned titles reports (keyed, proven) = (1, 1), as FK9 asserts.
2026-09-28 03:58:26 -07:00
Matthew Jackson 1ca9ad13f9 KU-L2: surfaces read through the key set
- iso:// / dir:// input with InputOptions.keys: check_decryptable over the
  set, then the TrueHD probe and the title mux through the set's reader
  (its map and on-arrival proof); no lookup, no banking.
- ExtractOptions.keys: every file through the set's reader; the on-arrival
  loud stop (E7032) ends the run instead of becoming a hole (K-2 on this
  path).
- mux_with_keys: Session and Live through an inline DiscStream carrying
  the set's keys, map and on-arrival proof; Iso muxes the caller's
  already-scanned title through the set with no rescan (J14), refusing a
  set that does not cover the title or is not for a sector-exact image;
  Url passes the set in InputOptions.keys. Without an AACS set the legacy
  paths run unchanged (until KU-X2).
- The ISO pipeline's tail is shared by build_iso_pipeline and the keyed
  builder.
2026-09-28 03:58:26 -07:00
Matthew Jackson f67c25df2c KU-L2 red: surfaces take the key set
InputOptions.keys, ExtractOptions.keys, MuxSource + mux_with_keys (events
last, J17) and DiscSession::resolve_key_set, with the set still ignored:
mux_with_keys passes through to mux_stream with no AACS keys, and extract
and iso:// input use the disc-banked keys as today.

Tests: LK11 (extract never writes a file no held key opens, K-2), LK7
(nothing retains a source after resolve, across readers, passes and a
mux), iso:// input through the set, mux_with_keys Live / Session / Iso
(the Iso title is muxed as given, never rescanned, J14), and the session
resolve. Red here.
2026-09-28 03:58:26 -07:00
Matthew Jackson e44ee04c27 KU-L2: ResolvedKeySet::resolve, proof on first read, forensic keys up front
resolve (KU §2.3) keys the rip's scope once, before any output:
- pieces are the stream files the scope touches, on each file's own unit
  grid (KS-1), plus uncovered title extents; up to 32 probes each;
- n_decl is the declared Num_of_CPS_Unit (KS-14), never the held count:
  K-1 and K-3 fixed on this path; a damaged extent is Lazy, never keyed by
  its neighbour (K-11);
- verdicts in order: Keyed (a key opens two probes), ask the sample-
  dependent sources with >= 8 units borrowed only within one playlist
  (KS-10), Lazy with a candidate when exactly one probe opened (J21,
  OQ-L2-1), Clear, Lazy; Missing refuses (E7022 / E7032, E7028-30);
- keydb-like sources asked once; n_decl == 1 keys every Lazy piece, or
  refuses E7013 when the held key opens none of the ciphertext;
- a source that gave no answer (last_failure_was_transport) is retried
  1 s doubling to 8 s until 60 s pass with no answer (J13, J15);
- FMTS: the index-1 anchor asked once (KS-26 evidence), phases probed,
  Pending when every anchor read faulted; segment units are never base
  probes.

The set's readers prove a Lazy or Clear piece on first read (KU §2.4):
in-batch partner, else one backward and one forward side read that are
never retried or surfaced, else a provisional one-unit proof confirmed or
contradicted by the next readable unit. A readable unit is never
withheld; one no held key opens stops (E7022 / E7032). Proofs live in the
set's shared ProofCache (J16). Readers refuse a non-random-access source
(SG27) and, for content touching a Pending forensic clip, E7026.
2026-09-28 03:58:26 -07:00
Matthew Jackson 36ab86e611 KU-L2 red: keys module tests on today's keying rules
The keys module's API (KeyScope, ResolvedKeySet::resolve, the set's
readers, ProofCache, check_decryptable, decrypt_status) over a stub
resolve that keys the way the per-title resolver does today: sources
asked once, the held-count single-key rule (K-1), extent inheritance
(K-11), no on-arrival proof, no retry, no forensic anchor. Adds the
defaulted KeySource::answer_depends_on_samples and
last_failure_was_transport (J15), SectorSource::random_access
(PrefetchedSectorSource false; every wrapper forwards), and sha2 for the
set's fingerprints (J18).

Tests: LK1-LK6, LK8-LK10, LK12, LK13, LK15 (a)-(f), (h), (i), LK16-LK18,
LK22, LK23a/b, SG23, SG25-SG27, and the J21 (OQ-L2-1) guard. Red here.
2026-09-28 03:58:25 -07:00
Matthew Jackson d264354efc LB8 follow-up: non-Option channels, drop the last prod unsafe
Review follow-ups on fix/lb8:

- BytePrefetcher::rx/recycle_tx are now plain Receiver/Sender, not
  Option. into_channels and Drop swap in a disconnected dead-channel
  stand-in (mem::replace, matching sector::PrefetchedSectorSource)
  instead of taking an Option and falling back to a dead channel on
  None. That fallback branch is gone by construction, so a missing rx
  can no longer read as a silent, truncating clean EOF.
- The producer's regrow-to-chunk_bytes fill used `unsafe { buf.set_len
  (chunk_bytes) }` when a recycled buffer's len was already >=
  chunk_bytes, the same "guarded only by capacity" UB risk as
  GHSA-j8ww-f5fg-9pmh in sector::prefetched. Replaced with the same
  sound buf.resize(chunk_bytes, 0) prefetched.rs already uses (a no-op
  when len is already there). Added a test exercising that fill path
  with no short read in between (three full-size chunks back to back).

producer stays Option<JoinHandle<()>>/take, as directed.
2026-09-28 03:57:56 -07:00
Matthew Jackson 938e06e86f L106: drop prod unsafe in prefetcher into_channels
sector/prefetched.rs and io/byte_prefetcher.rs `into_channels` used
`ManuallyDrop` + `unsafe ptr::read` to move fields out of a
Drop-implementing struct (sound, per the existing SAFETY comments, but
against the no-prod-unsafe-outside-FFI rule). Replace both with the
same dead-channel `mem::replace` / `Option::take` pattern their own
`Drop` impls already use, so `self`'s ordinary Drop glue neutralises
the leftover fields instead of being bypassed. byte_prefetcher.rs also
drops its 2 `.expect()`s on the `Option<Receiver>`/`Option<Sender>`
fields, falling back to a disconnected stand-in channel instead of a
possible prod panic.

Behaviour is unchanged (still Sound before, still Sound after); added
regression tests that into_channels+drop leaks no drive-holder/thread
and that the returned channels still deliver real data end-to-end.
2026-09-28 03:57:56 -07:00
Matthew Jackson 01c99056d5 LB7 follow-up: doc-only citations for VariantMkb.kvn and new_cipher_for
- variant.rs VariantMkb.kvn: add the [C] §3.2.5.2.2 "Variant Number
  Record" (Table 3-12, PDF p.29) citation, quoting "Kvn = [AES-G(Kp,
  Nonce)]lsb_10" verbatim from freemkv-private/research/aacs/references/
  AACS_Spec_Common_Final_0953.pdf. Noted as the closest published
  precedent for this Kvn shape, not the same record: that one is a
  10-bit content-variation selector (type 0x0D), while this module's
  0x2d/0x2f chain is a distinct, reverse-engineered AACS 2.1 scheme
  with a 16-bit Kvn.
- crypto.rs new_cipher_for doc: note the aacs::derive cvalue-candidate
  ECB scan (L104) also uses it, sharing one schedule per Processing Key.

Doc-only; no behaviour change. Duplicated validate bodies left as-is.
2026-09-28 03:51:48 -07:00
Matthew Jackson b602dd6730 L103/L104: hoist loop-invariant AES work out of AACS derive hot paths
L103 (aacs/variant.rs): variant_km_for_slot recomputed the slot-
independent Kvn = AES-G(Kp, Nonce) for each of the ~46k slots a
Processing Key is tried against. Hoist it into derive_media_key_variant,
computed once per call and threaded through VariantMkb instead of being
rebuilt per slot.

L104 (aacs/derive.rs): try_pk_against_tables's cvalue-candidate loop
rebuilt an AES-128 key schedule for the Processing Key on every
(uv, cvalue) candidate via validate_processing_key. Build the schedule
once per pk (new_cipher_for) and drive the candidates through a new
validate_processing_key_with_cipher, byte-identical to
validate_processing_key but taking the shared schedule. derive.rs's
resolve_candidate and aacs/resolve.rs are untouched (KU-owned).

content.rs: no redundant-copy pattern found at this head to fix — the
bus decrypt/encrypt hot paths already hoist their key schedule (an
earlier perf fix), and decrypt_unit/encrypt_unit's 16-byte header
copies are load-bearing, not wasteful. Left unchanged.

Tests: equivalence tests against a test-only oracle (a verbatim copy of
the pre-hoist per-candidate/per-slot formula) for both hoists; a
call-count regression per item, using new test-only AES_G_CALLS
(crypto.rs) and the existing KEY_EXPANSIONS counters, verified red
against the pre-fix code before the fix landed; and spec-quoted tests
citing [C] (AACS Introduction and Common Cryptographic Elements, Final
Rev 0.953) §2.1.3 (AES-G, via the existing KS-17 registry entry) and
§3.2.4 (Media Key from cvalue).
2026-09-28 03:39:47 -07:00
Matthew Jackson 8d46c7ed43 ST-M1 review: end-to-end mid-open cancel test; timeout_ms 0 is 60 s
shim_selftest_cancel_mid_open_is_halted opens a missing device with a live
token and cancels 50 ms in: the open must return Halted within a few slices,
proving shim_open_exclusive hands its cancel byte to its own waits.

A timeout_ms of 0 would make SetTimeoutDuration wait forever on macOS; it
now becomes 60 s, Linux SG_IO's default. The shim_selftest_* hooks are
hidden-visibility.
2026-09-28 03:12:47 -07:00
Matthew Jackson a73fc8e0e3 ST-M1: macOS shim open is cancellable and CDB timeouts are passed through
M1: shim_execute sets the caller's timeout_ms on the SCSITask instead of a
fixed 30 s. M2: shim_open_exclusive takes the Halt's flag as a cancel byte;
the diskutil unmount wait, the settle sleep, the ObtainExclusiveAccess
retries and the DiskArbitration claim wait are sliced at 20 ms, and a cancel
kills and reaps diskutil, undoes the open and returns SHIM_CANCELLED, which
MacScsiTransport maps to Error::Halted. Drive::open/open_with hand the
Drive's token to the transport open (scsi::open_with).
2026-09-28 03:05:55 -07:00
Matthew Jackson 9f3dc05fdc ST-M1 tests: shim_selftest (LM1) on today's shim waits (red)
The shim's open waits move into helpers (sliced_sleep, sliced_sem_wait,
run_and_reap) that keep today's behaviour; shim_open_exclusive takes the
cancel byte and shim_execute takes timeout_ms, both still ignored. The
shim_selftest_* tests drive the helpers and a fake SCSITaskDeviceInterface
through test hooks, so they run without a drive (qa release-tests, D6).
2026-09-28 03:05:55 -07:00
Matthew Jackson 43451aa3c1 MPG-L0: declare E_MPG_NO_VIDEO_TRACK (9074), Error::MpgNoVideoTrack
Declaration only, per the shared error-code cascade (mpg-output-design v5
§8 MPG-L0, the CC-L0 pattern): const, #[non_exhaustive] Error variant,
code(), Display ("E9074"), ErrorKind::InvalidData (the E9048 bucket) and
table rows. "No carriable video → MpgNoVideoTrack (E9074), as mp4:// does
with E9048" (§0). Raised nowhere yet; the mpg:// sink (L2) raises it.
Lands right after freemkv MPG-F0 excuses it in PENDING_VARIANTS.
2026-09-28 02:50:37 -07:00
Matthew Jackson 50166c151b halt: gate Progress::is_busy on rip (only rip-gated tests use it; fixes loom clippy dead_code) 2026-09-28 02:37:10 -07:00
Matthew Jackson 6c73749c36 ST-L2: Stop's SS-n rows in the spec-governed site registry (SQ3)
Now that spec_governed_sites_cite_the_spec is on dev (KU-L1), register the drive
and sense-parsing sites that cite SS-1..SS-7. The flush sites (SS-12..SS-15) sit
on per-OS impls whose fn names repeat within io/flush.rs, which the checker
rejects as ambiguous, so they stay cited in comments only.
2026-09-28 02:28:24 -07:00
Matthew Jackson 4d48a8d9ac ST-L2 review: SS-6 re-sourced as Normative from MMC-6 Table 633
Design review item 5: START STOP UNIT's LoEj/Start meanings now quote MMC-6 r02g
§6.42.2.6 Table 633 verbatim ("Eject the disc if permitted", "Start the disc and
make ready for access") instead of the Seagate SBC text; cited at eject,
start_unit and the §2.4 eject row.
2026-09-28 02:27:39 -07:00
Matthew Jackson 842ae28e24 ST-L2 review: after a Stop, a close counts only its own chunk flushes
Design review item 3 (JUDGEMENT): on NFS the mountstats counters are per mount,
so other writers kept re-arming the CLOSING wait of a Stop-pending close without
bound. Once the file's halt is cancelled, the flusher's wait ignores the sampled
counter; only this file's own chunk completions (and its own writes) are progress,
so a blocked close fails E9056 after the 60 s window.

Test: stop_pending_close_ignores_mountstats (red before: the rising sampler kept
flush() waiting until the chunk was released, ~6 windows, then Ok).
2026-09-28 02:27:39 -07:00
Matthew Jackson 55c060d774 ST-L2 review: a Stop on the highway is Halted, never a clean EOF
Design review item 2 (LP11, L096): the demux worker sent the clean Eof sentinel
when it saw the halt, and also after a halted prefetcher closed its channel
(`Err(_) => break`), so a Stop could finalise a truncated container. It now sends
Err(Halted) in both cases (halt checked before the Eof), and PipelinedPesStream
maps an Eof that raced a Stop to Halted.

Tests: halt_cancellation_sends_halted_not_eof (replaces
halt_cancellation_sends_eof_not_panic; red on the old worker: its last batch was
Eof) and highway_stop_mid_stream_is_halted_never_eof (a real demux worker and
stream: a mid-stream Stop is Err(Halted), never Ok(None)).
2026-09-28 02:27:38 -07:00
Matthew Jackson faf949029e ST-L2 review: flush chunks start at the 4 MiB floor and grow
Design review item 1 (JUDGEMENT): a first chunk of 64 MiB on a slow device with no
sampled signal (macOS, Windows, degraded Linux) cannot complete inside the 60 s T12
window, so the drain latched a false SyncTimeout. C now starts at CHUNK_BYTES_MIN
(4 MiB), doubles after a chunk under a quarter of the 15 s slow bound up to 64 MiB,
and halves after a slow one as before. durable_sync_file's windows (§4.5) do the
same: start at 1 MiB, double to 64 MiB, halve over 2 s. One next_size rule serves
both.

Tests: slow_device_keeps_progressing_on_a_fake_clock (virtual clock, production
sizes: 100 KiB/s writer and device never go 60 s silent; a dead device does) and
throttled_writer_never_latches_sync_timeout (scaled wall clock; its stalled half
still fails E9056). Red on the old sizing: the throttled run's sync_all returned
SyncTimeout. LP13f and LP19 now assert growth from the floor.
2026-09-28 02:27:38 -07:00
Matthew Jackson 4ee124ca6f ST-L2 flush: durable flush with measured progress (T12, §2.10, §4.5)
- WritebackFile: an in-write flusher per file where the page cache has no
  bounded writeback (Linux NFS or degraded, macOS, Windows): chunks of C
  (64 MiB, halving on a >15 s flush to the 4 MiB CHUNK_BYTES_MIN floor),
  halt-aware writer backpressure past 2 x C, and a 60 s stall latching
  SyncTimeout (sticky). Accepted writes, completed chunks, Linux-local
  WAIT_AFTER completions and NFS mountstats deltas are progress on a shared
  FlushProgress. sync_all drains the flusher, then durable_sync_file; it
  fails E9056 only after 60 s with no progress, Halted at once on a cancel.
  flush() (a container close) drains stall-bounded but not halt-aware.
- io::durable_sync_file: pieces sized for ~2 s (Linux local
  sync_file_range windows 64 MiB..1 MiB, then fsync; NFS mountstats deltas;
  F_FULLFSYNC with the ENOTSUP fsync fallback; FlushFileBuffers), each
  completion reported, stall-bounded via bounded_syscall_stall, which also
  makes bounded.rs live on Windows (dead-code allow dropped).
- mux: the output file shares the consumer's Progress and the op halt;
  finish_pumped forwards each durable-byte increase to the defaulted
  MuxEvents::on_flush_progress at most 4 times a second (LP20).
- Cites SS-12..SS-15 at the fsync/fdatasync, sync_file_range, F_FULLFSYNC and
  FlushFileBuffers sites.
2026-09-28 02:27:38 -07:00
Matthew Jackson b993214df6 ST-L2 flush tests: LP13a-h, LP19, LP20, SP10-SP13, G19 on stubs (red)
FakeFlushOps seam, FlushTiming/DurableTiming, FlushProgress, the additive
WritebackFile::set_halt / set_flush_progress / flush_progress,
io::durable_sync_file, MuxEvents::on_flush_progress (defaulted) and
Pipeline::finish_with_halt_observed, all keeping today's behaviour.
Spec rows SS-12 (POSIX fsync/fdatasync), SS-13 (sync_file_range(2)),
SS-14 (xnu fcntl(2) F_FULLFSYNC), SS-15 (FlushFileBuffers) + registry lines.
2026-09-28 02:27:38 -07:00
Matthew Jackson 6091ba9844 ST-L2 io: halt-aware prefetchers and pipeline join/grace on StallTimer
- Pipeline: the consumer bumps a Progress per item applied and at close()
  entry/exit; finish_with_halt joins with join_within + a 600 s StallTimer
  (T7 is a stall window, not a total); the grace (T8, D2) is 5 s for a
  RUNNING consumer, then a CLOSING wait whose 5 s window re-arms on each
  bump. send_with_halt rides Halt::send_timeout and still returns the item.
- Sector prefetcher: the producer is a Drive holder (spawn_drive_holder, LP7),
  its recycle recv and every send are halt-aware, and read_sectors reports a
  halted close as Err(Halted), not EOF (L096, LP17). Tests that spawn it hold
  DRIVE_HOLDER_TEST_LOCK (enforced in cfg(test)).
- Byte prefetcher, bounded_syscall and PipelinedPesStream wait via
  Halt::recv_timeout / send_timeout (LP11).
2026-09-28 02:27:38 -07:00
Matthew Jackson 1033673421 ST-L2 io tests: LP1-LP12, LP15, LP17, G7/G8 on old-behaviour stubs (red)
Pipeline join/grace on a scaled clock (JoinTiming seam, spawn_named_with_progress,
Pipeline::progress), sector/byte prefetcher cancel and drop, the demux and
pipelined-stream readers, bounded_syscall's leaked worker, and T11's degrade.
Red: LP2, LP4a, LP5a, LP7, LP8 (shell), LP11, LP17, consumer progress bumps.
2026-09-28 02:27:38 -07:00
Matthew Jackson 9b9b8b9ebc ST-L2 drive: the op token on every CDB, exec_cleanup, wait_ready 60 s no-progress
Stop design v5.6 §2.2-§2.4, §2.8, §2.11, ST4-2, T5/T6:

- Drive::exec checks the attached token before dispatch and re-checks READ-class
  CDBs on completion; every CDB in drive/mod.rs and DriveId identification goes
  through it (lock_tray pre-checks and sends nothing after a Stop).
- exec_cleanup applies the §2.4 allow-list (drive/allow.rs) with the Drive ledger
  (tray_locked, AGID bits from REPORT KEY 0 / 3Fh): unlock_tray and Drop use it, so
  a stopped Drive still releases its own PREVENT, exactly once.
- attach_progress: bumped per CDB completion, busy while one is in flight.
- The ScanOptions.halt alias: over the Drive's own token it is the scan's op token
  (restored on every exit, unwind included); an attached token wins, with a warn.
- The unlock adapter runs over &mut Drive: execute through exec (the uncancellable
  path inside a pre-cancel critical span), pause on the token, begin_critical
  refused after a cancel, execute_cleanup through exec_cleanup.
- Drive::init and both scan bus steps (via bus_step_guard, which also holds busy()
  for the whole step, covering the first keydb parse) map a Stop to Halted before
  any other result mapping.
- UDF prefetch and prefetch_ranges return Halted; the scan's title parses use the
  op token; a Stop after the last CDB still ends Disc::scan with Halted.
- wait_ready fails only after 60 s without progress: an answer not yet seen in the
  wait, or a rising sense-key specific progress indication (parse_sense_progress,
  ScsiTransport::last_sense_progress, defaulted); T5 and the terminal answers keep.
2026-09-28 02:27:38 -07:00
Matthew Jackson 3ba04c7f1c ST-L2 drive tests: LD1-LD16, LS1-LS6, SP1-SP5 on old-behaviour stubs (red)
Stop design v5.6 §5.1 "Drive" and "Scan", §5.9 SP1-SP5, G1/G4/G5/G21, over the
new test_util::FakeTransport (feature test-util: scripted modes, CDB log, tray and
AGID ledger, live-handle count, panics on a post-cancel CDB outside §2.4).

New API as signatures that keep the old behaviour: the Drive Slot (open_with,
attach, detach, token, the ScanOptions.halt alias as a no-op), exec (the old
checked_exec), exec_cleanup / exec_uncancellable, attach_progress, drive/allow.rs,
wait_ready_with (the old 60-poll cap), DriveId::identify, the adapter over
&mut Drive (still blind to the token), bus_step_guard (no busy span),
parse_sense_progress (always None), and the SS-1..SS-7 spec rows. clear_halt goes;
sleep_until_halted becomes Drive::pause.
2026-09-28 02:27:38 -07:00
Matthew Jackson 0bdf9b9848 spec sites: match quote IDs on word boundaries; SG3 guard note
spec_governed_sites_cite_the_spec now requires a whole ID: KS-2 no longer
matches inside KS-22 or KS-27 (self-test cases added; a KS-2 row on
single_cps_key_slot, which cites KS-14/KS-27, now fails as it should).
cbc_chain_restarts_every_unit carries the 'per spec; do not change without a
spec citation proving otherwise' note.
2026-09-28 02:15:19 -07:00
Matthew Jackson a64b0b7447 content.rs: code-quote [BD] in the needs_decrypt doc (broken intra-doc link) 2026-09-28 02:15:19 -07:00
Matthew Jackson 29b1231512 spec_governed_sites_cite_the_spec: the (file, fn, id) site registry
KU design v3.4 §3.6.1 rule 11. tests/spec_sites.txt lists each spec-governed
site and the quote it must name; the guard checks the fn body. Rows for the
KU-L1 sites so far (CPI clear, Phase::Verify, n_decl, K-8) and the existing
KS-18 de-bus gate; Stop adds its SS-n rows to the same file. A self-test
covers the body finder. KS-6 and KS-22 lose their 'cited later' notes.
2026-09-28 02:15:19 -07:00
Matthew Jackson 7891ff8d06 K-8: n_decl through parse_title_keys (Disc::declared_cps_units)
KU design v3.4 §2.3 step 3: n_decl = parse_title_keys(uk_ro, version)
.encrypted_keys.len(), None when unparseable (fails closed). single_cps_key_slot
reads it, so an HD DVD VTKF declaring one key is single-CPS (KS-14; KS-27).
2026-09-28 02:15:19 -07:00
Matthew Jackson dab65c2e1c K-8 red: single_cps_key_slot must read an HD DVD title-key file too
single_cps_reads_hd_dvd_title_keys_too (KS-14; KS-27 evidence): a VTKF with one
available key is single-CPS. Red today: parse_unit_key_ro reads the VTKF magic
as a start address and returns None.
2026-09-28 02:15:19 -07:00
Matthew Jackson a9e8fcf1b2 K-5: Phase::Verify for a forensic index whose every phase probe faulted
KU design v3.4 §5.3: such an index no longer defaults to Phase::All (which
garbled the other variant's half). Each unit is decrypted and kept, CPI
cleared, only if is_clean passes; otherwise its ciphertext is restored for the
muxer to drop. A Verify range reads both halves (read_plan). Not memoised,
as before. SG9 gains the kept-Verify leg, SG10 the failed-Verify leg.
No public FMTS spec: KS-25, KS-26 (evidence); per-unit per KS-3.
2026-09-28 02:15:19 -07:00
Matthew Jackson 313fb73ab5 K-5 red: LK14, an FMTS index with unprobeable phase keeps only verified units
fmts_verify_phase_drops_wrong_half (KS-3; KS-25, KS-26 evidence): every phase
probe of index 2 faults, then the segment is decrypted through the map. Red
today: the index defaults to Phase::All and garbles the other variant's half.
2026-09-28 02:15:19 -07:00
Matthew Jackson e47799e669 K-13: clear CPI on every unit freemkv decrypts (KS-5, KS-6, KS-22)
apply_aacs_map clears the Copy_permission_indicator (top 2 bits of byte 0 of
each of the 32 TP_extra_headers; the 30-bit ATS is untouched) on every unit it
decrypts, including a damaged map-keyed unit. BD-TS only; HD DVD is excluded.
Units left as ciphertext (alternate phase, orphan, clear) keep their CPI.
The content.rs doc ('which decryption never rewrites') is rewritten per KS-5.
Tests that compared a decrypt against CPI-11 plaintext now expect CPI 00
(cpi_cleared test helper). KU design v3.4 §5.4, §1.5 K-13.
2026-09-28 02:15:19 -07:00
Matthew Jackson a0fe55ee71 K-13 red: SG9 and LK21, every decrypted unit clears CPI in all 32 packets
SG9 every_decrypted_unit_clears_cpi_in_all_32_packets (KS-5, KS-6, KS-22):
a map-keyed unit, a damaged map-keyed unit and our forensic phase.
LK21 decrypt_clears_cpi_on_every_source_packet: a decrypted image carries
CPI 00 in every packet, and a re-scan asks no source.
Red today: apply_aacs_map leaves CPI 11 on plaintext.
2026-09-28 02:15:19 -07:00
Matthew Jackson 22c40c2424 ST-L1 review: send_timeout returns SendOutcome, Err(Halted(item)) on a stop
send_timeout now reports Sent / TimedOut(item) / Disconnected(item), and a
cancel is Err(Halted(item)), which converts to Error::Halted with ?. A gone
receiver returns Disconnected at once, so a caller looping on it cannot
spin. One attempt is still always made. Recorded as a spec deviation in
stop design §2.1.
2026-09-28 01:53:06 -07:00
Matthew Jackson 3dbef556a2 ST-L1 review: Recv::Disconnected, one attempt at zero budget, backoff, serial holder tests
recv_timeout returns Recv::{Item, TimedOut, Disconnected}, so a caller
looping on a dead channel exits instead of spinning. recv_timeout and
send_timeout always make one non-blocking attempt, so a zero or expired
budget still takes a queued item or sends into room. The SyncSender poll
backs off from 1 ms to WAIT_SLICE. Drive-holder tests serialise on a test
lock and assert the exact live count. Both signature changes are recorded
as spec deviations in stop design §2.1.
2026-09-28 01:53:06 -07:00
Matthew Jackson 432ea98619 ST-L1: halt-aware waits, 20 ms WAIT_SLICE, Release/Acquire, StallTimer
Stop design v5.6 §2.1. Halt::{check, wait, recv_timeout, send_timeout}
re-check the token every WAIT_SLICE (20 ms; POLL_INTERVAL is now its
alias). cancel stores Release and every load is Acquire (SS-23, LT3).
Progress + busy(), StallTimer (plain, ignores busy) and the opt-in
StallTimer::idle_only; join_within/join_finished; spawn_drive_holder
(debug panic when dropped unjoined); diag::NoBlockingScope trips on a
blocking exec/sleep/join in debug. Additive; no call site converted.
2026-09-28 01:53:06 -07:00
Matthew Jackson 2e1f8181fb ST-L1 tests: LT1-LT7, LT5c on old-behaviour stubs (red)
Stop design v5.6 §2.1/§5.1. New halt primitives land as signatures that
keep today's behaviour (250 ms slice, Relaxed flag, inert check/StallTimer/
NoBlockingScope/DriveHolder drop), so the new tests compile and fail on
their assertions. spec::stop gains SS-23 (Release/Acquire); the loom job
runs LT3.
2026-09-28 01:53:06 -07:00
Matthew Jackson fc29a46941 audio framing: document the one-frame loss at a gap plus key change
A flagged discontinuity (a BD clip boundary's discontinuity_indicator
too) followed by a stream-key change with only one frame before EOS or
the next gap loses that frame, since a lock in a new key must chain
twice: at worst 1-2 AUs at the end of a title that changes format.
Doc only.
2026-09-28 01:11:12 -07:00
Matthew Jackson f7a1f0aab3 audio framing: no lock that cannot complete; ADTS+MPEG property fuzz
A false header in corrupt payload whose length runs past the data was
taken as the lock at EOS or a gap, and the good frames after it were
cleared with the buffer. A frame that cannot complete is now never a lock;
scanning continues.

A new seeded property fuzz covers ADTS and MPEG audio with realistic
payloads (0xFF at 1/256), corrupt runs that reach EOS, gaps anywhere and a
mid-stream stream-key change. Its oracle is built from the generated truth
only and checks per-key strictly increasing PTS, no junk or repeated AU,
good frames kept, I1 within one false sync per gap, the I2 byte caps, a
lower bound on dropped_frames, and PTS error within the AUs lost between
bracketing timestamps. It fails on the previous head (1354 of 20k cases)
and passes 20k here. What it found, now fixed:

- After a gap, a false header in the fragment the PES carries was taken
  as a frame: the first frame after a gap must now chain.
- A false header chaining to a real frame by length, in another key: a
  lock in a key other than the stream's must chain twice within its own.
- Two candidates ending at the same byte (one of them false) are both
  refused: one good frame lost rather than a junk AU emitted.
- EOS acceptance before a short tail needs the stream key and a sync-shaped
  tail; the free-format "size unknown" case is no longer a lock.
- Walks to the next PTS sum each frame's own duration (a key or rate
  change may lie on the way); a lock between breaks takes the fewest slots,
  as a byte split could err late.
- With no clock since a gap, the latest skipped PES timestamp places the
  next run or lock; a PES's first byte no longer implies a clock.
- A forward PTS jump mid-run is followed, not a new run (counts stay capped
  by bytes, including runs placed by estimate since the clock was pinned).

dropped_frames is documented as a lower bound where no later timestamp
measured a run.
2026-09-28 01:10:25 -07:00
Matthew Jackson e64a4b4bf2 audio framing: walk every chain to the next PTS; clock-derived losses; I3 backstop
Placement after a resync run is now structural. The walk from a lock to
the next PTS covers every chain and break before it. Unbroken, the PTS
places the lock exactly; with breaks, the timestamps fix the AUs lost in
the run and the breaks together, bytes only split them, and the lock stays
below the PTS by every later frame and break. Each later chain's lock is
placed the same way, so the chain that reaches the PTS is exact.

Lost counts are the clock skip the placement takes, capped by what the
skipped bytes hold, so they cannot disagree with the timestamps. Without a
later PTS (EOS, a gap, no clock, MAX_HOLD) a lock takes the fewest slots
the run allows: early by the AUs lost after that point, never late. A
byte yardstick cannot tell one large AU's tail from several small AUs and
can overshoot without bound. A run opening at a PES's first byte after a
gap has a clock (that PES's PTS names it).

An emission backstop guarantees I3: within a stream key a frame is never
emitted at or before the last one; a timestamp behind the clock continues
from the last frame, logged once at debug.

A seeded property fuzz (AUDIO_FUZZ_CASES, 300 in CI; 100k run locally)
generates VBR ADTS streams with corrupt runs, PES splits, sparse or dense
PTS, gaps and EOS, and checks I1, I2, I3, no junk or repeated frames,
every good frame between good neighbours emitted, and PTS error at most
the AUs lost between the frame's bracketing timestamps. The review's
regression cases A and B are fixed tests.
2026-09-28 01:10:25 -07:00
Matthew Jackson d5e2b91d83 audio framing: place a lock from the next PTS only when its walk reaches it
A lock was placed at q minus the frames walked to the next timestamp's
PES even when the walk left the header chain (a second corruption with
tiny VBR/silence frames after it); the bytes left were estimated and the
placement marked exact, stamping the lock late and repeating a slot.
Now a walk that breaks gives a byte estimate, never exact, capped below q
by the walked frames, one lost AU and the next chain that reaches q (whose
lock is then placed exactly), and never behind the run's start (I3, I4).

The MAX_HOLD comment now states the 0.7 s PTS interval as a recollection
of 13818-1 §2.7.4 and that it fits 64 KiB only below about 750 kbit/s.

dropgate and audio_frames document that one verified fault per run keeps
the poison verdict out of reach for ADTS/MPEG audio while any frame
decodes, per project principle, with a guard test.
2026-09-28 01:10:25 -07:00
Matthew Jackson fadfcb9be9 audio framing: resync runs keep stated invariants; place locks by PTS
The run rules now follow invariants listed in the audio_frames module doc:
one run is one verified fault (I1); a run never counts more lost AUs than
its bytes hold (I2); emitted PTS never repeat or go backwards (I3);
consistent timestamps are authoritative (I4); byte estimates are used
only where nothing later can contradict them, with limits documented (I5).

After a run the locked frame waits for the next timestamp. A PES starting
at the lock names it by its PTS; otherwise the next buffered PTS places it
at q minus the frames walked from the lock to that PES, so a successor
split across PES no longer duplicates a slot and a VBR estimate no longer
overshoots and jumps back. Lost AUs follow from the placed stamp; a jump
beyond what the bytes hold is an unflagged discontinuity, logged once.
Only EOS, a gap, a run with no prior clock, or 64 KiB without a PTS use a
byte estimate, sized by the run's own AUs where a mid-run PTS measured
them (floored at a tenth of the mean) and bounded by I2.

A PES timestamp met mid-run is recorded rather than restarting the run,
so a false sync in a carried fragment is no second fault and no longer
takes the PTS. Timestamp-measured losses report their duration.
2026-09-28 01:10:25 -07:00
Matthew Jackson 958b0911a7 audio framing: timestamps count lost AUs; PES PTS names its first AU
Per 13818-1 §2.4.3.7 a PES's PTS belongs to the first AU starting in it.
Mid-run, the first sync-shaped byte of a PES carrying a new PTS now
restarts the run there (settling the old one), so the next good frame is
no longer stamped with the corrupt AU's timestamp, one slot early.

Lost AUs came from skipped bytes over the last good frame's size, which
VBR audio (AAC always, MP3 often) makes wrong by up to 10x. A run that
ends at a fresh PTS is now counted exactly from timestamps, using the
pre-run frame duration. Otherwise (no PTS at the lock, EOS, gap) the
estimate is skipped bytes over the mean kept frame size for the stream
key, at least one, and the clock never passes a PTS already buffered.

Once a track is poisoned nothing more is emitted, including frames later
in the PES that poisoned it; each framed AU counts as a collateral drop,
not one per PES. Bytes cleared at a gap are noted as an uncounted
fragment.
2026-09-28 01:10:25 -07:00
Matthew Jackson 56a262ac0a audio framing: one fault per run across PES; settle restarts; unkey mode
A resync run spanning several PES without a PTS counted a verified drop at
each PES start (10+10 garbage frames: dropped 21, verified 2). A run that
is already open now continues without a new drop unless a new PES
timestamp restarts it, and a restart settles the old run's lost AUs
instead of discarding them.

settle_run counted a run's lost AUs only when the clock could advance, so
a garbage run right after a discontinuity reported one drop. The count is
now always recorded; only the clock advance still needs a prior anchor.

MPEG audio's stream key drops the mode: 11172-3 §2.4.2.3 requires a
decoder reset only to change the layer or sampling rate, so a mode change
(stereo to single_channel at a programme boundary) is kept after a drop.
The ADTS key comment now names the fixed-header fields it leaves out, and
the gap-side PTS limit is noted.
2026-09-28 01:10:25 -07:00
Matthew Jackson 101304f793 audio framing: settle runs at EOS and gaps; spec-quoted header rules
A resync run still open at EOS or cut by a discontinuity now reports its
lost slots (skipped bytes over the last frame's size) like a run that ends
at a frame; before, a 4200-byte garbage run reported one drop, not 20.

MPEG audio's stream key treats mode '00' stereo and '01' joint_stereo
alike (11172-3 §2.4.2.3 requires a decoder reset only to change the layer
or sampling rate), while dual_channel and single_channel stay distinct.
ADTS keys the adts_fixed_header() fields, which per 13818-7 §8.1.1.1 do
not change from frame to frame.

ADTS with ID '1' (MPEG-2) now rejects sampling_frequency_index 0xc, which
13818-7 Table 35 reserves (7350 Hz is MPEG-4 only).

The header decode, masks and tables in adts.rs, mpegaudio.rs and
audio_frames.rs quote their spec clause, and spec_* tests pin each rule
with its quote. Note the known one-slot PTS limit on a PES that starts
mid-frame on a sync-shaped byte.
2026-09-28 01:10:25 -07:00
Matthew Jackson 0b9adee672 audio framing: report every lost slot; keep good frames beside corrupt ones
Review follow-ups to the resync chain rule.

Lost audio was undercounted: a run's extra lost slots (skipped bytes over
the last frame's size, beyond the verified first) are now recorded as
collateral drops, so dropped_frames matches what the clock skips.

While resyncing, an unchained frame is kept if it matches the last good
frame's fixed header (ADTS ID/layer/profile/rate/channels; MPEG version/
layer/rate/mode) and ends at a sync-shaped header, or before a short tail
at EOS. A good frame next to a corrupt one no longer loses its audio.

A header failure where one was due (after an emitted frame) now opens a
run even without a sync, so a false header in that payload is not emitted
as a junk AU. A run restarts under a new PES timestamp only at a valid
header or the PES's first byte, not at any sync-shaped byte.
2026-09-28 01:10:25 -07:00
Matthew Jackson b754cd4831 audio framing: chain headers while resyncing; count lost slots by bytes
Follow-ups to the LB1 review.

After a verified drop, a resync run now accepts a header only if another
valid header follows it (or, at EOS, it ends the data), waiting for the
successor if it is not buffered yet. A false header inside corrupt payload
no longer emits a junk AU or swallows real audio. The check uses the pure
Sync::frame_len, so a rejected candidate never reaches the ADTS config.
ADTS now frames at EOS and before a gap, like MPEG audio, so a frame
awaiting its successor is not lost.

Frames lost in a run are skipped bytes over the last frame's size (rounded,
at least one), and only when the clock was running: after a discontinuity
the leading bytes are a fragment, so the first real AU keeps its PES PTS.

adts_verdict rejects layer != 00 and profile 3 when ID = 1 (MPEG-2).
2026-09-28 01:10:25 -07:00
Matthew Jackson bd35fee5d7 disc: MKV staging ranges and E6022 for a staged (partial) image
Disc::mkv_staging_ranges(reader, titles) lists what an image staged for an
MKV rip needs: the volume structures before the partition, the Metadata File,
every directory and File Entry (stream files' too), the data of every file
outside /BDMV/STREAM, and the chosen titles' extents. AACS BD Pre-recorded
Book 0.953 section 3.7: "the BEF shall be set to 0b for the sectors that do not
correspond to Clip AV stream files under \BDMV\STREAM directory", and every
title extent is in the drive's bus map, so the staged image holds no
bus-encrypted byte even when a stream file is unmapped.

Error::ImageScoped (E6022) names an image staged that way when it is offered
as a whole-disc source.
2026-09-28 01:08:34 -07:00
Matthew Jackson a303d9d3a0 disc: re-read an unreadable stream File Entry; forward the unmapped list everywhere
Review fixes for the unlocatable bus-encrypted stream file:

- A File Entry whose read fails gets at most 2 recovery+FUA re-reads (past the
  buffered reader's cache), from a budget of 4 per scan, each after the
  engine's 5 s FAIL_PAUSE, which a Stop ends as Halted. A count, not a
  timeout: HARDWARE ERROR / ILLEGAL REQUEST sense (the BU40N fast-fail wedge)
  gets no re-read, nor does a dead transport, a gone source or an entry that
  read but does not parse. Only then is the file recorded as unmapped.
- E6021 names each file with its read cause: `path (cause)`.
- DecryptingSectorSource, whole_disc::UnitAligned, PrefetchedSectorSource
  (snapshotted at construction) and FileReadAhead forward
  unmapped_stream_files(). A source-scan test fails any production
  SectorSource impl that neither forwards nor is a listed leaf.
2026-09-28 01:08:33 -07:00
Matthew Jackson 163f8e08bc disc: record, don't drop or fail on, an unlocatable bus-encrypted stream file
On a host-key (cert-route) bus-encrypted disc the bus map is built from every
/BDMV/STREAM file. A file whose File Entry extents could not be read was
dropped with a warning, so its sectors passed through still bus-encrypted
into iso://, sweep and dir:// output.

AACS BD Pre-recorded Book 0.953 section 3.7 sets the BEF on the Clip AV
stream files under \BDMV\STREAM, and its Note requires the PC host to decrypt
a bus-encrypted Clip AV stream file. A file that cannot be located cannot be.

The scan now records such files (path, ICB, cause) on the drive's bus map
and carries on: titles that play the clip are dropped as before, and every
locatable file is still de-bussed, so MKV rips and disc info are unaffected.
ensure_image_debussable() refuses a whole-disc image with the new
Error::BusStreamUnmapped (E6021) naming the file(s); SectorSource gains
unmapped_stream_files(), forwarded by every wrapper. extract_tree never reads
or writes such a file and counts it lost in bytes_unreadable.
stream_content_ranges fails closed with E6021. A Stop during the walk still
returns Halted and is not logged as a disc fault; an embedded file holds no
6144-byte Aligned Unit (section 3.10.1) and is skipped. Tests quote the spec.
2026-09-28 01:08:33 -07:00
Matthew Jackson af05fc224c S0b: round ns to 90 kHz ticks to nearest; integer-tick m2ts origin
Implements S0b of mpg-output-design v5 (§2.3 "Timestamps", J16, J20).

- One shared helper, codec::ns_to_ticks(ns) = (ns*9 + 50_000).div_euclid(
  100_000) on signed i64 with saturating arithmetic: round half up on both
  sides of zero, no saturation inside the helper (a -40 ms video and a
  -80 ms audio keep their offset; MPG4-2). It inverts pts_to_ns exactly:
  |ns - p*100000/9| <= 0.5, a tick error <= 4.5e-5.
- tsmux converts absolute ns to ticks first, then subtracts an integer-tick
  origin = ticks(first written frame) - 90_000 (its 1 s headroom); only
  ticks - origin saturates at 0. The seeding frame is the same as before and
  still lands at exactly 90_000. The DTS deriver keeps seeing exactly the
  header's tick (tick-domain rule 3).
- The PGS `.sup` writer (demux_sink ns_to_90k) uses the same helper,
  keeping its <= 0 -> 0 and u32::MAX clamps.
- Guard tests: ticks(pts_to_ns(p)) == p for every p in -200_000..200_000
  (every residue mod 9), near +/-2^33 and beyond; signed rounding; a
  negative-PTS audio/video pair keeps its offset; m2ts PTS equal the source
  tick; .sup ticks equal the disc tick. The S0a goldens are kept and
  compared after undoing exactly the S0b delta (each PTS rewritten to
  S0a's floor value reproduces the pre-S0 digests).

m2ts output change (intended correctness fix, J16): every written PTS moves
by at most 1 tick, always +1, toward the disc's true tick; nothing else
changes. S0a's floor of the ns-domain offset lost one tick exactly when the
value's residue error (100000 = 1 mod 9) sits below the seeding frame's: 4/9
of 90 kHz-sourced values when the seed is on a residue-0 tick, (4-r)/9 or
(12-r)/9 for other seed residues r (32/81 averaged over seeds). Steady-state
DTS, being other units' PTS, move the same way; start-up DTS (the first R
units of a sequence) are re-derived from the corrected ticks. Millisecond
sources (MKV) convert exactly under both helpers and are unchanged.

demux `.sup` output: the PTS/DTS fields hold absolute ticks, so exactly the
90 kHz-sourced values with p mod 9 in 1..4 (4/9) move +1 to the disc tick;
millisecond sources are unchanged.
2026-09-28 00:50:09 -07:00
Matthew Jackson 56a93ff33d S0a: derive DTS for reordered video in m2ts (H.222.0 §2.7.5)
m2ts wrote PTS-only PES for every picture, violating §2.7.5 (h222:6425-6429):
"A decoding_timestamp (DTS) shall appear in a PES packet header if and only if
... a PTS is present in the PES packet header; the decoding time differs from
the presentation time." Implements S0a of mpg-output-design v5 §2.3.

New src/mux/decode_ts.rs: DtsDeriver::for_codec(codec, codec_private) then
push(pts_ticks, es) per IR frame, reading only ES bytes and codec_private
(never PesFrame.coding, which the network/stdio hop drops).
- R parsed, never learned, only increasing (h222:2013-2016): MPEG-2/MPEG-1
  R = 1, or 0 with low_delay; H.264 max_num_reorder_frames, else 0 for the
  intra profiles with constraint_set3 and for POC type 2, else E.2.1
  MaxDpbFrames from the level (1b, unknown -> 16); HEVC
  sps_max_num_reorder_pics[max_sub_layers_minus1]; VC-1 R = 1. R = 0 until a
  parameter set parses.
- H.264 SPS walk (SpsReader + unescape_ebsp + new read_se): scaling lists,
  POC type 1, VUI, NAL/VCL hrd_parameters, bitstream_restriction. HEVC
  parse_sps_chroma extended to the sub-layer ordering loop.
- Units: frames or complementary field pairs (MPEG-2 picture_structure, H.264
  field_pic_flag/bottom_field_flag/frame_num); a two-field IR frame is one
  unit; cross-frame pairing only within one field period + 1 tick.
- DTS_k = min(S[k-R], PTS_k); start-up S[0] - (L-k)*T, where T is the
  stated frame period (the reorder delay "is a multiple of the nominal
  picture period", h222:2012-2013): MPEG-2 frame_rate_code with the
  frame_rate_extension, H.264 two VUI clock ticks, HEVC the VUI picture
  period (new walk through scaling lists, PCM, st_ref_pic_set and the
  long-term set). Unstated: the smallest positive window gap, else 1/24 s.
  T is floored at 2*dPTS_field. R-upgrade re-start with one slot per field
  in (DTS_last, S_new[0]); one order guard (DTS_last + 1, else PTS only)
  after every path; counted in dts_order_violations / dts_hold_overflow.

tsmux: video PIDs 0x1011-0x101F only (0xE0/0xFD55/0x1100+ untouched, F6/F7).
The drop guard and origin seed run at arrival; a global arrival-order hold
(cap 1 s of IR timeline or 64 MiB) replays through the unchanged write path;
finish() drains before MuxEmpty. The MVC dependent view has its own deriver
following the base view's R and frame period, so its DTS match the base's
whichever view arrives first (SSIF reads deliver the dependent first).
build_pes_header gains the optional DTS (PES_header_data_length 10).

m2ts output: PES whose DTS equals PTS are byte-identical; origin, drop
decisions, PES interleave and audio PIDs are unchanged (goldens recorded
against the pre-S0a writer). The only TS-level effect is the video PID's
continuity_counter after a DTS-bearing PES that gains a TS packet.
2026-09-28 00:50:09 -07:00
Matthew Jackson dc0eebb64d SG3: run the swapped two-unit buffer through decrypt_sectors_mapped
Calling decrypt_unit per chunk could not see a decrypt that chains one unit's
ciphertext into the next. Red with apply_aacs_map's serial loop temporarily
made one CBC chain (order [0, 1] fails); green after the revert.
2026-09-28 00:37:45 -07:00
Matthew Jackson fcf1dd46ce bus_removal: cite KS-18 at the de-bus gate as a recorded divergence
KU §7.0.4, backlog: BEE is not read, and unmapped or unknown-grid content is
de-bussed by LBA range whatever its CPI. Comment only; no guard, so the
divergent gating is not locked in.
2026-09-28 00:37:45 -07:00
Matthew Jackson 5158e1b285 spec: KS-23 registry verbatim to libaacs 55be92be; rule-9 markers
The KS-23 passage read crypto_err (a later libaacs); 55be92be spells it
crypto_error. New ignored test libaacs_quotes_match_source checks the
KS-22..KS-24 passages against a libaacs aacs.c (LIBAACS_AACS_C): passes at
55be92be, fails on the later copy. Registry comments mark KS-23, KS-24 and
KS-29 test-only, and KS-6, KS-10, KS-22 as cited later (SG9, SG23, §3.6.2).
2026-09-28 00:37:45 -07:00
Matthew Jackson 04d570bf37 spec guards SG1-SG8, SG10-SG22, SG24, EG1-EG3 (green today)
KU-P0 (keys-upfront-design v3.4 §7.8): behaviour correct by the quoted spec
that KU must not break. Each guard's doc line reads 'per spec; do not change
without a spec citation' (Evidence rows: 'per evidence'), asserts against the
spec::keys const text and the behaviour, and targets a plausible wrong change.
Public-API guards in tests/spec_guards.rs; the ones on private items or the
unit decrypt (pub(crate) at KU-X2) sit next to their code. SG10 covers the
alternate FMTS phase and the orphan; its failed-Verify leg arrives with
Phase::Verify in KU-L1. SG9, SG23, SG25-SG28 are introduced by later steps.
2026-09-28 00:37:45 -07:00
Matthew Jackson 6757f2d241 spec, test_util: drop outer module docs that broke intra-doc links
An outer /// on the mod item resolved the inner //! links in lib.rs scope
(rustdoc -D broken_intra_doc_links); the module docs alone carry them.
2026-09-28 00:37:45 -07:00
Matthew Jackson 28f961416d test_util: fixtures behind the new test-util feature
KU-P0 (keys-upfront-design v3.4 §3.1, §8.2): libfreemkv::test_util, built for
the crate's own tests and for consumers enabling feature test-util:
- unit_key_ro: a Unit_Key_RO.inf laid out per KS-12/13/14 (stride per KS-25);
- aacs_state: the one sanctioned AacsState builder (KU-P1 migrates the 13
  consumer literals to it and adds the literal guard);
- encrypted_bd_image: a real UDF image whose stream files are encrypted per
  aligned unit on each file's own grid (KS-1..KS-4), with its plaintext;
- CountingSource + ReadLog, MemSource;
- decrypt_unit: the test-side door (KU-X2 makes the internal one pub(crate)).
Red before: cargo test --features test-util failed (no such feature).
2026-09-28 00:37:44 -07:00
Matthew Jackson 94aef92216 spec: rustfmt the registry test 2026-09-28 00:37:44 -07:00
Matthew Jackson a6fc43e565 spec: libfreemkv::spec with the KS-1..KS-29 quotes (green)
KU-P0 creates the shared spec module (keys-upfront-design v3.4 §3.6.1, X3-2):
one pub text-only module, one SpecQuote type with a binding QuoteKind, and
spec::keys holding KS-1..KS-29 verbatim from the AACS Final 0.953 books,
ECMA-167 3rd ed., libaacs 55be92be (corroboration) and the evidence rows.
spec::ALL lists one group per design, so Stop adds spec::stop (SS-n) rows
and its group without touching anything else.
2026-09-28 00:37:44 -07:00
Matthew Jackson 29330ea3ce spec: registry + spec_quotes_match_registry (red: no libfreemkv::spec yet)
KU-P0 (keys-upfront-design v3.4 §3.6.1 rules 7-8): tests/spec_quotes.txt holds
the unelided passage per quote ID; the test checks every spec::ALL const
against it (fragments in order, unique KS-/SS- IDs, non-empty section and
locator) and that const names agree with IDs. Red by compile failure until
the module lands.
2026-09-28 00:37:44 -07:00
Matthew Jackson 5923a03f7b driver: keep never-written MP2 extension tracks out of the opened title
Round 3 made undelivered_streams() seen-only, so at open it is empty and
on_output_opened got a title still listing a declared MPEG-2 extension
track that mkv/mp4/m2ts/demux/audio never write; the CLI's stream list
then disagreed with the GUI. The driver now always drops extension tracks
from the opened title for those sinks; the loss warning and
undelivered_streams stay seen-only. The FMKV wire (network/stdio) keeps it.
2026-09-28 00:31:27 -07:00
Matthew Jackson 13a4b11d2f mux: MP2 mc count needs a CRC-valid run; report the extension only once seen
- ChannelTracker commits a count above nch only after RUN_FRAMES (3)
  consecutive CRC-valid frames with an identical mc_header (13818-3
  §2.5.3.1 CRC gate; a 16-bit match on plain ancillary data is 1 in 2^16,
  ~2^-43 over the look). A failed mc_crc_check (any clean frame without a
  verified mc_header) resets the run; damaged or header-less frames are
  skipped. A run under way at MAX_FRAMES is followed to its end. A count
  equal to nch still commits on one frame.
- StreamSelection: listing an extension PID without its base pulls the
  base in (logged) instead of dropping what was asked for.
- IFO coding mode 3 alone no longer warns or lists the extension as
  excluded: mkv/m2ts/demux/audio/mp4 report it (warn once, then
  undelivered_streams / Mp4SkipReason::Mp2Extension post-mux) only once its
  0xD0|n packets arrive. mp4's pre-mux fit_report no longer lists it.
- ffmpeg_mp2 fixtures: PROVENANCE with the byte-exact FFmpeg 9.0.2
  commands (synthetic 440 Hz sine, MIT like the repo).
- dvd test cites §2.5.2.13 for ext '0'.
2026-09-28 00:31:27 -07:00
Matthew Jackson 32d8a7fa6a mux: carry the DVD MPEG-2 extension stream in the IR; verify mc data by CRC
The MPEG-2 multichannel extension bit stream (PES 0xD0|n) now enters the
PES IR as a dependent track instead of being dropped at the PS bridge:

- ifo: coding mode 3 ("Mpeg-2ext") is kept on DvdAudioAttr (crate-private
  module) and title_audio_streams declares pid 0xD0|n right after its base
  0xC0|n, marked by MP2_EXTENSION_LABEL / AudioStream::is_mp2_extension()
  (label + PID, like the MVC dependent view). No Codec variant, no new
  public struct field.
- ps: dvd_pid admits 0xD0..=0xD7; build_demux_state gives the track a
  per-PES passthrough parser (ext_frames are not Layer II frames).
  0xD0|n packets with no declared track are counted and warned once at
  EOF on both read paths.
- StreamSelection::apply keeps an extension track iff its base is kept.
- mkv, m2ts, demux/audio list it in undelivered_streams(); mp4 skips it
  as Mp4SkipReason::Mp2Extension (#[non_exhaustive]). The FMKV wire keeps it.
  apply_labels gives it no vendor slot.

Channel count (13818-3 2nd ed., 11172-3):
- mc data is trusted only when mc_crc_check verifies (§2.5.3.1), and the
  frame CRC when protection_bit is 0; a failed parse no longer locks the
  base count: the tracker retries up to 32 frames and commits only on a
  clean frame, keeping the IFO value when none is clean.
- surround '11' is a second stereo programme, not surround.
- the joint-stereo bound is capped at sblimit (FFmpeg/dist10) rather
  than falling back.
- Layer II bitrate/mode combinations 11172-3 §2.4.2.3 forbids are rejected.
- ext '0' keeps the full main-programme count, with no track-name note.
2026-09-28 00:31:27 -07:00
Matthew Jackson 96c4487ea8 mux: MP2 Channels = what the stored Layer II frames carry, per spec
A DVD MP2 stream coded as MPEG-2 multichannel (IFO coding mode 3)
declares its whole program, 6 channels for 5.1. The IFO field is
"channels-1" and libdvdread prints channels+1. Only the MPEG-1-compatible
base on PES 0xC0|n is muxed, yet Channels was written from the IFO. It
said 6 even when the multichannel remainder is in an extension bit
stream that is never stored.

New mux::codec::mp2_channels walks the base frame's Layer II
audio_data() (11172-3 §2.4.1.6, §2.4.2.6, §2.4.3.3, Tables 3-B.2a-d and
3-B.4) to the mc_header() that 13818-3 §2.5.1.3 places after it. It reads
ext_bit_stream_present, centre, surround and lfe (§2.5.1.8, §2.5.2.8):
- ext '1': the track holds the base only, so Channels = nch (1 or 2
  from the header mode).
- ext '0': all multichannel data is stored, so nch + centre + surround
  + LFE.
- No multichannel declared: the header count, and ancillary bits are
  never read as an mc_header.

The mc_header is only looked for when the source declares more than the
2 channels an MPEG-1 base can carry. The ISO reference decoder is told
the same thing externally rather than guessing, and the coding-mode bit
itself cannot reach the muxer without a public API change. Any parse
failure (reserved centre, free format, Layer I/III, lower sampling
rates, bound above sblimit, truncation) falls back to the header count
and logs tag=mp2.channels to diag. The AC-3 acmod fixup is the same
mechanism, now shared as ChannelFixup.

The Annex B tables are not in the public 11172-3 text. The data is the
ISO reference software's (dist10 alloc_0..3), added as testdata and
checked entry for entry. It also matches FFmpeg's transcription.
2026-09-28 00:31:27 -07:00
Matthew Jackson 191021f60d dvd: quote the specs behind the SPST_CTL/AST_CTL routing
Comment-only. Every decode and test of the L084/L084b routing now
quotes its source, so a later change has to argue with the citation:
libdvdnav vm_get_audio_stream/vm_get_subp_stream, libdvdread pgc_t and
ifo_print, mpucoder's PGC, PES and LPCM tables, and VLC's ps.h sub-stream
ranges. The routing tests say they are per spec. The two freemkv
policies are labelled as policy, not spec: keep the first stream on a
duplicate id, and use positional routing when AST_CTL marks nothing.
2026-09-28 00:31:27 -07:00
Matthew Jackson bbcce1dff4 CC-L0: declare E_TIMED_OUT (9073) and E_AACS_VID_NEEDS_DISC (7034)
Additive-only per the shared error-code cascade (stop-design-v5 §6
R7): const, Error variant, code()/Display/ErrorKind mapping and test
coverage for Error::TimedOut { op } (Stop D1) and
Error::AacsVidNeedsDisc (keys-upfront-design v3.4 J11). Enum stays
#[non_exhaustive]; the engine wiring that raises these lands later
(ST-L1/L2, KU-E1).
2026-09-27 23:39:22 -07:00
Matthew Jackson 11458235dd ci: drop autorip from the consumers job
autorip is frozen and its daemon moves into freemkv for 1.8.0; the
consumers job no longer builds or tests it.
2026-09-27 22:48:38 -07:00
Matthew Jackson c2ad9290ef pgs: point the pending-bytes doc link at Self::complete_clear_pts 2026-09-27 22:42:34 -07:00
Matthew Jackson 21b6665537 mux: public header-only MKV probe
probe_mkv reads the EBML header, Segment Info and Tracks and stops before
the first cluster, returning the muxing/writing app, declared duration,
title, timestamp scale and a minimal per-track view (number, kind, codec
id, language). parse_mkv_header now captures MuxingApp and WritingApp and
keeps "no Duration" distinct from a zero one; MkvStream::open is otherwise
unchanged.

parse_freemkv_version pulls (major, minor, patch) out of the stamp our
writer puts in MuxingApp ("freemkv 1.7.7 (gc8e67f1)"). Older builds wrote
a bare "freemkv" and get None.

On Duration: finish() only back-patches it when the title declared no
runtime (the placeholder path, e.g. HD-DVD). A title with a declared
duration keeps that value in the header whatever was actually muxed, so
the header alone can't tell a short mux from a full one. For that,
probe_mkv_with_cues follows the SeekHead to the Cues (seeking past the
clusters, never reading one) and reports the last cue time as a lower
bound on the muxed runtime. declared_duration_is_kept_not_measured pins
the current writer behaviour.
2026-09-27 22:25:28 -07:00
Matthew Jackson 222624b6dc dvd: name the all-absent AST_CTL fallback for what it does now
The diag outcome still said "positional sub-stream ids", from before MP2
routed by PES id. The fallback now uses positional routing (codec base |
position) for every codec, and the test pins that text.
2026-09-27 21:44:27 -07:00
Matthew Jackson 2dc8a19836 driver: drop the stale probe_and_remap step from the inline prune comment 2026-09-27 21:44:27 -07:00
Matthew Jackson bf39ffc14f dvd: feed MPEG audio from its PES id, routed by AST_CTL; log routes
dvd_pid() mapped nothing for MPEG-audio PES (stream_id 0xC0..), while the
scanner gave MP2 tracks a placeholder PID 0xBD00+i. The demuxer dropped
every MP2 packet, so DVDs with MP2 audio (common on PAL) ripped an empty
track.

MPEG audio stream n rides PES id 0xC0|n (HandBrake, VLC), with n taken
from PGC_AST_CTL exactly like the private_stream_1 codecs. The new
ps::dvd_mpeg_audio_pid maps 0xC0..=0xC7 to PID 0x00C0|n, which stays
clear of 0xBD00.. (HD-DVD E-AC-3 already uses sub-ids 0xC0..), VobSub
0x20.. and video 0xE0. The scanner and dvd_pid() share it through
ifo::audio_pid(codec, n). Only reserved coding modes keep a placeholder.
freemkv, freemkv-engine and autorip never interpret DVD PID values.

The positional DvdAudioAttr::sub_stream_id is gone. It was only used by
the all-absent fallback, which now uses audio_pid(codec, position), and
by the dvd.aattr diag line, where it showed an id the title might not
use. A per-title tag=dvd.aroute line now logs each declared stream's raw
AST_CTL entry and the PID it muxes from, or that it is absent.
2026-09-27 21:44:26 -07:00
Matthew Jackson 31f80b04dd dvd: route audio by PGC AST_CTL, drop the channel-count remap (L084b)
Logical audio stream i was sent to physical stream i (codec base | i),
ignoring the per-title PGC_AST_CTL (PGC+0x0C) map every player uses.
Where a disc's physical order differs, the wrong track was muxed.

parse_pgc now reads AST_CTL into DvdTitle::ast_ctl. Each title's audio
routes by it with libdvdnav semantics: bit 15 present, bits 10-8 the
physical stream number, sub-id = codec base | number. Absent streams get
no track, and a repeated physical id keeps the first stream (a diag line
names both). A PGC marking nothing present keeps the positional ids so
the title does not rip silent, and a diag line records it.

The mux-time AC-3 channel probe existed to patch this exact case
(0x80 = 2.0 downmix, real 5.1 elsewhere) by guessing from channel
counts. With AST_CTL honoured it is redundant, and it could move a
stream off the track the disc plays whenever the IFO channel attribute
disagrees with the VOB. Its remap is removed. probe_and_remap keeps its
signature (called from mux/resolve.rs) but only logs the physical
channel counts, and reads nothing unless freemkv::diag is enabled.
2026-09-27 21:44:26 -07:00
Matthew Jackson 7af639be0d dvd: route subtitles by PGC SPST_CTL, not stream ordinal (L084)
Logical subpicture stream i was hard-mapped to VobSub sub-id 0x20+i. On
anamorphic DVDs each language carries wide/letterbox/pan-scan variants,
so the physical ids are not ordinal: tracks got the wrong language and
some languages were never muxed.

parse_pgc now reads PGC_SPST_CTL (PGC+0x1C) into DvdTitle::spst_ctl, and
the scanner takes each stream's physical id from it: the wide id for
16:9 video, the 4:3 id for 4:3. Streams the PGC marks absent get no
track, and a repeated physical id keeps only the first stream.
2026-09-27 21:44:26 -07:00
Matthew Jackson 6397512d16 css: deprecate the public crack_key in favour of crack_key_outcome
crack_key collapses Halted and Unreadable to a plain None, indistinguishable
from a genuine crack failure, and has no production callers left in any
repo. Mark it #[deprecated] pointing callers at crack_key_outcome, and
fix the module doc example to match.
2026-09-27 21:42:19 -07:00
Matthew Jackson 1782932e17 css: bound the crib-false-positive re-crack suppression window
Suppressing every re-crack for the rest of a mismatch run only re-armed
on a validated cache hit, so a GENUINE key change landing right after a
false-positive run was never retried for the rest of that
descramble_region call — silently corrupting the remaining buffer.
Bound the suppression to RECRACK_RETRY_EVERY (16) sectors: still cheap
for a long false-positive run, but a real key change is always found
within one retry window instead of ridden out on the stale key.
2026-09-27 21:42:16 -07:00
Matthew Jackson 45a5ef1be8 dirimage: raise the PVD's interchange level to 3
primary_volume declared ECMA-167 interchange level 2, which does not
permit a file to span multiple extents — needed for the large VOB/M2TS
files a hybrid video disc carries. file_set already declares level 3
for the same reason; bring the PVD's declared level up to match what
the image actually requires.
2026-09-27 21:36:00 -07:00
Matthew Jackson d9377222e8 disc: fold the lowest-playlist-id tiebreak into CANONICAL_TITLE_ORDER_KEYS
canonical_title_order's final sort key (lowest playlist_id) was left out
of the const naming its keys, so diag.rs's main_feature_reason had to
append the string "lowest-playlist-id" by hand to keep the log line
correct. Name it in the const itself, the same as the other six keys.
2026-09-27 21:35:08 -07:00
Matthew Jackson b89ef83f10 vc1: reuse the shared start-code scanner instead of a hand-rolled loop
Vc1Parser::parse and its local find_next_sc duplicated the byte-by-byte
start-code scan that startcode::find_start_code already provides (SIMD-
accelerated via memchr). Switch both to the shared helper and drop the
now-dead local copy and its redundant tests.
2026-09-27 21:35:08 -07:00
Matthew Jackson 99eeb7ae3a truehd: drain a zero-header run in one call, not one per header
TrueHdParser::parse walked a run of zero-length AU headers 4 bytes at a
time, each via PesBuf::drain — which shifts the whole remaining buffer
on every call, turning a bounded zero run into O(run_len^2) work. Scan
the contiguous zero-header run up front and drain it in a single call.
2026-09-27 21:35:08 -07:00
Matthew Jackson 7533d876c4 mux/codec/pgs: replace timing-based L026 test with deterministic proofs
Review flagged the wall-clock ratio test as a flake risk. Replace it with
two deterministic checks: an invariant that clear_scan_offset always
tracks the pending clear set's confirmed-complete length (with a
split-segment case proving the offset holds at an incomplete segment's
start), and a #[cfg(test)] step counter proving the walk stays O(n) over
N one-segment appends instead of O(n^2).

Also drop two now-unreachable unwrap_or(0) PTS fallbacks in dvdsub.rs
that the L054 orphan-drop fix left dead, restructuring parse() to bind
a guaranteed-Some pts instead, with a comment on the one that remains
(defensive, not reachable) in take_if_complete.
2026-09-27 21:27:57 -07:00
Matthew Jackson ef42eb2ef8 mux/codec: fix PGS/DVD subtitle parser bugs (LB2)
- pgs: complete_clear_pts rescanned a pending clear set's whole buffer
  from byte 0 on every PES; resume from the last confirmed offset so a
  garbage stream that appends thousands of small PES before an END (or
  never) is O(n) instead of O(n^2) (L026).
- pgs: emit_pending clamped a computed duration over the 30s cap down to
  the unrelated 5s fallback dwell instead of to the cap itself, causing
  long signs/captions to vanish early in players honouring BlockDuration
  (L053).
- dvdsub: take_if_complete carried an unreachable-but-present prod
  unwrap() guarded two lines earlier under &mut self; rewritten without
  it (L027).
- dvdsub: an orphan continuation PES (no pending SPU, no PTS) was
  treated as a fresh SPU at pts 0, putting a garbage bitmap at
  00:00:00; now dropped, matching PGS's existing behavior for the same
  case (L054).
2026-09-27 21:27:57 -07:00
Matthew Jackson ac9fbbe263 audio framing: stop false syncs poisoning tracks; keep the clock on drops
L043: ADTS resync matched MPEG's 11-bit sync and counted every sync-looking
byte of a resync run as a verified drop, so a few KB of garbage could poison
a good track. The sync mask is now per codec, and a resync run is one
verified drop per PES.

L042: a first PES that starts mid-frame (ts:// or m2ts:// joined mid-stream)
now drops the leading fragment when a chained header confirms framing,
instead of emitting fragment plus frames as one block. The probe uses a
side-effect-free frame length, so no config is learned from a false header.

L052: a dropped AU keeps its slot on the clock (last good frame's length)
and takes its PES timestamp, so later frames are not stamped a frame early.

L044: drops on a poisoned track are recorded as collateral.

L056: noisy-payload ADTS tests exercise the false-sync and resync paths.
2026-09-27 21:17:53 -07:00
Matthew Jackson fc64ecd01b whole_disc: name a missing stream folder; stop after a late halt
Titles with no /BDMV/STREAM (or /HVDVD_TS) fail as E6003 naming the folder, not
E7013's key-refresh advice. A stop landing in the last key lookup ends before any
output. Docs: CSS is descrambled; the mid-pass E7032 is the one late refusal.
2026-09-27 20:17:01 -07:00
Matthew Jackson 4e3e9fffff whole_disc: say when the key source is actually asked
The resolver asks fetch only on a pool of several base keys where none opens a
file's samples; a one-key pool is used as is.
2026-09-27 20:14:24 -07:00
Matthew Jackson e2fc55cf01 whole_disc: one shared reader for every whole-disc/image → ISO copy
Moved from freemkv-engine and freemkv so the GUI, CLI and image paths share one
set of rules. A key must open two probed units before it keys an unplayed file,
and alternates are the held base keys only (forensic FMTS keys never).
2026-09-27 20:13:23 -07:00
Matthew Jackson 52131315a3 error: add E7032 WholeDiscKeyMissing
A decrypted whole-disc image would keep encrypted pieces: a stream file no
title plays is encrypted and no held key opens it. The fix is an MKV rip or a
raw copy, not a key, so it gets its own code instead of E7013.
2026-09-27 20:13:22 -07:00
Matthew Jackson 07cb513bae tests: gate scsi_recovery.rs to Linux instead of a silent SKIP pass
The old per-block #[cfg(not(target_os = "linux"))] printed a SKIP line and
returned Ok, so cargo test reported PASS on macOS/Windows having exercised
nothing. Gate the whole file with #![cfg(target_os = "linux")] so non-Linux
hosts report 0 tests instead of a false green, matching the project's own
panic-instead-of-silently-skip convention in app_core.rs/engine_bridge.rs.
2026-09-27 20:02:07 -07:00
Matthew Jackson c3eac58970 extract: plan_tree reuses aacs::find_hddvd_aacs_dir, no inline duplicate
The HD DVD X! dir check was reimplemented inline instead of calling the
same discovery aacs_dir_present and the key-file reads use, so the two
copies could drift out of sync.
2026-09-27 19:32:55 -07:00
Matthew Jackson bef728a5e1 aacs: renumber E_AACS_NO_USABLE_HOST_CERT to 7033
7032 clashed with fix/multikey-refuse's in-review WholeDiscKeyMissing. No
other reference to the literal exists; the constant name is unchanged.
2026-09-27 19:14:14 -07:00
Matthew Jackson 00649dfe7f aacs: distinguish an all-broken host-cert list from no-usable-cert
CertUnlockFailure::NoUsableHostCert (freemkv-unlock: every offered cert
failed a local keydb check, never reaching the drive) and NoHostCert (zero
certs offered) both folded into Error::AacsNoHostCert, so a broken keydb
pairing read the same as having no host certificate at all — misleading,
since certs were actually present. Give the all-broken-list case its own
E7032 (AacsNoUsableHostCert), kept distinct from AacsNoHostCert (no cert
offered) and AacsHostCertRejected (the drive itself rejected a cert it saw).
handshake_class_error gains the matching arm so the bus-blocked refusal and
verdict recording still treat it as a handshake-class failure.
2026-09-27 19:14:08 -07:00
Matthew Jackson 74ae0a3fa5 disc: HD DVD's X! AACS dir must gate identify/scan like BD's AACS/
aacs_dir_present only checked /AACS and /BDMV/AACS, so an HD DVD disc's
discovered X! AACS dir (e.g. AAC!) never counted as an AACS gate: identify
reported it as unencrypted and scan skipped capture and the handshake
entirely. Reuse aacs::find_hddvd_aacs_dir, the same discovery the key-file
reads already use, so both agree. Also fixes the matching extract gap: the
tree-walk plan_tree() only stripped the top-level AACS/ and CERTIFICATE/
dirs by name, so a decrypted HD DVD extraction kept its AAC! dir and would
rescan as still AACS-encrypted; it now strips X! the same way, by its
MKBROM.AACS member.
2026-09-27 19:14:08 -07:00
Matthew Jackson 7cf94da55e changelog: DecryptingSectorSource unit base is required; clear_unit_base 2026-09-27 18:54:29 -07:00
Matthew Jackson 448e3eabb5 decrypt: anchor the AACS fail-loud witnesses; document grid-check limits
The set_raw and write_image witnesses set a unit base so they fail loud past the
no-base gate (misalignment itself is pinned by the alignment-gate test). Document
the CPI=0 slip rate and that a corrupt on-grid seed now fails the whole read.
2026-09-27 18:54:29 -07:00
Matthew Jackson b4d322eac4 decrypt: require an explicit AACS unit base and refuse off-grid units
DecryptingSectorSource no longer defaults to the disc-LBA-0 grid: an AACS
content read with no set_unit_base fails DecryptFailed. apply_aacs_map refuses
a CPI-flagged BD-TS unit whose clear seed lacks the TS sync (cut off its file grid).
2026-09-27 18:54:29 -07:00
Matthew Jackson b90a4cba6c tests: scan-order clippy fixes (type alias, let-else) 2026-09-27 18:46:59 -07:00
Matthew Jackson 35c9e0f614 tests: pin a DVD with /AACS: no AACS SCSI, missing key file recorded 2026-09-27 17:50:57 -07:00
Matthew Jackson adfccbc1ae disc: a DVD's /AACS key file keeps the record-and-continue rule 2026-09-27 17:50:57 -07:00
Matthew Jackson 0c407748b4 disc: stop capture at a fatal key-file error; capture /AACS on a DVD too
capture takes CaptureFrom (Image | Live{raw_copy}) and returns E7031 before the
cert and MKB reads. A DVD with /AACS is still captured, without a handshake.
Pin Stop propagation from each capture read directly.
2026-09-27 17:50:57 -07:00
Matthew Jackson f9dcda21e6 disc: read UDF and the AACS files before the handshake
Standard order: UDF, then Unit_Key_RO.inf, the content cert and the MKB as plain
reads, then the handshake only on an AACS disc. An unreadable key file on a live
disc fails the scan with the new E7031 unless ScanOptions::raw_copy is set; a dead
bus or Stop during the AACS/CSS handshake aborts it; no drive MKB read for certs.
2026-09-27 17:50:57 -07:00
Matthew Jackson 0a0f319bb3 ci: bump actions/upload-artifact v4 -> v7 2026-09-27 17:07:49 -07:00
Matthew Jackson 1917830812 ci: bump github/codeql-action/upload-sarif v3 -> v4 2026-09-27 17:07:49 -07:00
Matthew Jackson 866a0733e6 hddvd: XPL timecode frames count in timeBase, not tickBase
tickBase is the markup clock; timeBase (fallback tickBase, then 60) sets FF and
the measured 60000/1001 gate. Other timeBases stay unadjusted.
2026-09-27 17:04:05 -07:00
Matthew Jackson 63ec0dd8c3 hddvd: XPL 60fps tick base is 60000/1001, not 60
Title/clip/chapter times ran 0.1% short: the feature's container duration
was 6.4 s shorter than its own packets, which the real-media timeline check rejects.
2026-09-27 17:04:05 -07:00
Matthew Jackson 5bbc24d915 image_writer: document the batch vs AACS unit grid contract
Pin that an unwrapped AACS decrypting source fails loud at a misaligned batch.
2026-09-27 12:40:51 -07:00
Matthew Jackson 5488082737 mp4: DTS entry rate for the 4x core rates; read ddts rates as given
12/11.025/8 kHz join the 48/44.1/32 kHz families (ETSI TS 102 114 Table 5-6).
The reader keeps a ddts rate SampleRate cannot name as Unknown, not the base.
2026-09-27 12:40:50 -07:00
Matthew Jackson 0aee5d0ac2 network: wait on readiness, not SO_RCVTIMEO, in the halt-aware receive
A Windows receive timeout can cancel a completing recv and lose its data,
desyncing the FMKV/PES stream. Poll (poll/WSAPoll via rustix) per tick and
read only when readable; keepalive and halt behaviour are unchanged.
2026-09-27 12:40:50 -07:00
Matthew Jackson 4659ee7a97 mp4: then_some for the DTS-HD entry flag (clippy) 2026-09-27 11:55:07 -07:00
Matthew Jackson 6de0e6ed7b network: mark the Windows SO_ERROR check as unverified 2026-09-27 11:55:07 -07:00
Matthew Jackson 6e432d32ed mp4: ddts FrameDuration at the max rate; DTS-HD-only 2x/4x timescale
FrameDuration counts samples at DTSSamplingFrequency. Without ddts, only a
dtsh/dtsl entry takes an mdhd timescale of exactly 2x or 4x its rate.
2026-09-27 11:55:07 -07:00
Matthew Jackson 190ca2437e mkvstream: no PCM depth without evidence at the probe cap
A track with no frames or no span beyond timestamp error is not LPCM; only a
measured but ambiguous span defaults to 16-bit, with a warning.
2026-09-27 11:55:07 -07:00
Matthew Jackson 90a4b826d3 driver: link input by path in MuxSource docs 2026-09-27 11:55:07 -07:00
Matthew Jackson e0e679b108 changelog: note the mp4 DTS, mkv PCM probe and network keepalive fixes 2026-09-27 11:55:07 -07:00
Matthew Jackson c52ea7e971 driver: make the abandoned-consumer test discriminating
Wait for the wedged write to return, then assert the leaked consumer neither
applies the queued item nor closes, instead of a fixed sleep.
2026-09-27 11:54:56 -07:00
Matthew Jackson cdf6ac49be m2ts_mux: drop the uncalled DTS path; tsmux: cite 13818-1 2.4.2.6
Frames carry no DTS, so the PCR is PTS-led and held monotonic; the doc now says
so. The 1 s T-STD buffer-delay rule is in 2.4.2.6 Buffer management.
2026-09-27 11:54:56 -07:00
Matthew Jackson 21959049c2 network: keepalive on every receive socket, Windows dead-peer TimedOut
Arm keepalive for listen/accept_from too and warn instead of failing when an
option is rejected; a Windows TimedOut with a pending SO_ERROR now surfaces.
2026-09-27 11:54:56 -07:00
Matthew Jackson dfc38dae91 mkvstream: keep A/V from the origin on, apply clip IN only to Blu-ray
Frames between the reach floor and the origin were squashed to t=0; DVD/HD-DVD
clip marks run on another clock, so they no longer move the origin.
2026-09-27 11:54:55 -07:00
Matthew Jackson f2099ddc04 mkvstream: bound the PCM depth probe and infer from a measured span
Cap open() read-ahead at 64 MiB, count only the inferred track's frames, and
judge 16/24-bit from bytes over a multi-block span with tick error allowed.
2026-09-27 11:54:55 -07:00
Matthew Jackson 70315f0bf2 mp4: DTS rate per ETSI TS 102 114 Annex E
Write the stream's maximum rate in ddts and the family base in the sample
entry; read DTS rate from ddts, else from an mdhd timescale multiple.
2026-09-27 11:54:55 -07:00
Matthew Jackson 740cfcb9cf mux: address review of audit fixes
Keepalive instead of a network idle limit, mkvmerge-style MKV origin, PCM depth
inference, VobSub open-end patching, DTS-based monotonic PCR, and numeric codes.
2026-09-27 11:54:55 -07:00
Matthew Jackson 45e6f960cb mux: carry track timing and DiscardPadding over the FMKV wire
Header v2 (only when a track has decoder timing) adds per-track CodecDelay/
SeekPreRoll and a per-frame DiscardPadding field; v1 streams are unchanged.
2026-09-27 11:54:40 -07:00
Matthew Jackson 38fa3203dc mux: fix audit findings in mkv/mp4/network/ts sinks and readers
Pre-IDR audio, VobSub/PGS durations, mp4 audio rate, halt-aware network
receive, failed-mux state, PCM/FLAC/Opus remux, TS PTS origin, and LOW/NITs.
2026-09-27 11:54:40 -07:00
Matthew Jackson af47b2cb4e lpcm: resolve the OUTPUT_BIT_DEPTH intra-doc link by full path 2026-09-27 11:54:34 -07:00
Matthew Jackson 5683e77946 lpcm: advertise the packed BD layout byte in the M2TS FMKV header
A rejected source layout byte no longer reaches the header (m2ts:// read-back mislabelled channels).
Header-gate wording and CONFIG_WAIT_* names now cover BD LPCM as well as AAC.
2026-09-27 11:54:34 -07:00
Matthew Jackson 975bb5acaf lpcm: header gate waits for the BD LPCM layout byte
BdTs LPCM joins AAC in the bounded header wait, so late layout bytes still correct channels/rate (audio-only too).
Documents compacted on_output_opened titles, untagged-byte fallback, and the missing 3.0/7.0 AudioChannels.
2026-09-27 11:54:34 -07:00
Matthew Jackson 6a817e7fca lpcm: correct BD LPCM layout for every mux input; report M2TS-dropped tracks
drive_mux derives channels/rate from the parser's tagged layout byte (replaces the iso/dir-only probe).
M2TS reports LPCM it can't carry as undelivered and the opened title omits it; untagged foreign bytes are ignored.
2026-09-27 11:54:34 -07:00
Matthew Jackson 9c47c0697b lpcm: probe BD LPCM layout, 5 ms BD PES on M2TS, keep source assignment
Mux-time probe reads the first BD LPCM header for true channels/rate (MPLS says 5.1).
M2TS re-pack emits rate/200-sample PES, reuses the parser's channel_assignment, and omits uncarriable LPCM at create.
A_PCM tracks never write CodecPrivate; comment-guard fix.
2026-09-27 11:54:34 -07:00
Matthew Jackson c6ab9886d2 lpcm: whole-frame DVD blocks, fixed 24-bit output + BitDepth, BD LPCM on M2TS write
DVD 20/24-bit units are whole ffmpeg blocks; reserved headers (incl. BD 20-bit) drop the packet; carried bytes shift the PTS.
MKV writes BitDepth=24; M2TS re-synthesises the BD LPCM header/pad/order (round-trip tested).
dvd_audio_probe pass 3 prefers probed subs and logs unavoidable PID sharing.
2026-09-27 11:54:34 -07:00
Matthew Jackson 01ca1b3039 lpcm: decode BD/DVD layouts to plain PCM; no shared DVD audio PID
BD: drop the odd-channel pad, remap LFE/surrounds to WAVEFORMATEXTENSIBLE order. DVD: keep the 3-byte audio header and unpack grouped 20/24-bit samples to 24-bit BE.
PTS-less PES advance by the emitted duration. dvd_audio_probe: a sub-stream is claimed at most once.
2026-09-27 11:54:34 -07:00
Matthew Jackson 1225ecea81 scsi: a cleaning cartridge reads as no disc
30/03 and 30/07 were Settling, which counts as a disc, so autorip would rip a
cleaning cartridge. Nothing is rippable and it does not settle: Absent.
2026-09-27 11:45:57 -07:00
Matthew Jackson 5ede33de4a drive/scsi: empty-drive fail-fast, no inline ALLOW on a full cap, EACCES unlisted
wait_ready returns the 3Ah sense after 10 empty polls with no 04/01 seen; Drop
sends ALLOW inline only if the spawn fails; no-sysfs list drops EACCES/EPERM
nodes; start_unit moved off spin_cycle's doc; presence docs say "derived".
2026-09-27 11:45:57 -07:00
Matthew Jackson dda18dd483 drive/scsi: tri-state DiscPresence per MMC-6; wait_ready dead-bus budget
disc_presence(path) -> Present/Absent/Settling (only 3Ah absent; 04/01 Settling);
drive_has_disc counts Settling as a disc. wait_ready: 5 s budget from the first
failure's completion, START UNIT on 04/02, fail fast on 30h. Drop/list fixes.
2026-09-27 11:45:57 -07:00
Matthew Jackson c5748d4205 drive/scsi: tray unlock only for our own lock; TUR/wait_ready review fixes
Drop never reopens inline: ALLOW goes on an owned fd from a capped thread, and
only after our PREVENT; Drive unlocks only if it locked. TUR retries once after
UNIT ATTENTION; wait_ready's dead-bus budget is 5 s; sysfs-less INQUIRY filter.
2026-09-27 11:45:48 -07:00
Matthew Jackson 90dfe0315d drive/scsi: fix drivescsi audit cluster (TUR, GET CONFIG, tray, SPTI)
wait_ready tolerates up to 3 consecutive transport failures; GET CONFIGURATION
honours Data/Additional Length and feature code; MODE SELECT list = MDL+2;
TUR presence reads the ASC; tray ALLOW only after our PREVENT (also mid-recovery);
private sg fds; SPTI timeout/AlignmentMask/bounce reuse; DA claim leak; tests.
2026-09-27 11:45:48 -07:00
Matthew Jackson ed1a665bde diag: name the lowest-playlist-id tiebreak in the main-feature reason
The comparator's final key (issue #45 siblings) was missing from the reason
string, and the test pinned the incomplete list.
2026-09-27 11:45:48 -07:00
Matthew Jackson 5b78796da8 disc: note the TrueHD layout fix and its unnamed-layout limit
CHANGELOG lists the label correction under Fixed with the old-receiver caveat.
2026-09-27 11:42:56 -07:00
Matthew Jackson 4512f641ef disc: fall back to the TrueHD count for unnamed layouts
A layout with no N.M variant (e.g. 5.2) used to keep the container value,
understating MKV Channels; use from_count instead. Note the new layout strings.
2026-09-27 11:24:12 -07:00
Matthew Jackson 0120d111e7 truehd: count the 6ch Lvh/Rvh bit as a pair
THD_6CH bit 4 is a channel pair (ffmpeg thd_chancount); it was counted as 1.
2026-09-27 11:24:12 -07:00
Matthew Jackson c55b1cffa2 disc: keep the LFE split in audio channel layouts
Add 3.0/3.1/4.1/6.0/7.0 AudioChannels and from_layout(full, lfe); TrueHD correction
reads the mask's LFE bits, so 3.0 no longer labels "2.1" nor 7.0 "6.1".
2026-09-27 11:24:12 -07:00
Matthew Jackson 028a6ee2bd clpi_audit: a first clip is covered only if its STN kept a stream
Adds an audit-level test: a covered clip's orphan PID yields a CLPI-only row,
and a streamless STN leaves its clip unaudited.
2026-09-27 11:12:56 -07:00
Matthew Jackson 4bc3398f5d jar: accept the central directory at eocd - cd_size; probe the tail from one cached read
Prepended-data jars now open in place (as zip readers and the JDK locate the
CD). The EOCD search and CDFH checks run on a single cached tail span. Tests
cover a doubly-corrupt CD location, prepended data, and fake EOCD signatures
in the comment up to and past MAX_EOCD_PROBES.
2026-09-27 11:11:11 -07:00
Matthew Jackson 8a8f6a7410 clpi_audit: audit only clips an MPLS STN describes
Non-first-item clips cannot be compared (only item 0's STN_table is parsed), so
they get no rows; CLPI-only now means a real orphan PID.
2026-09-26 20:14:35 -07:00
Matthew Jackson 37023f1bd0 jar: validate the EOCD before opening a large jar in place; keep the tail cached
Require the central directory to lie before the EOCD and start with a CDFH
signature (at most 4 candidates probed), so a corrupt EOCD cannot trigger the
zip reader's whole-file rescan. The last chunk stays cached for the zip reader,
and backward fills are used only for seeks just below the cached chunk.
2026-09-26 20:12:27 -07:00
Matthew Jackson 2b8a6ec051 clpi_audit: compare MPLS streams with the first play item's clip only
Each PlayItem has its own STN_table and only the first is parsed.
2026-09-26 20:07:57 -07:00
Matthew Jackson 5f02a7ac47 paramount: more extras words (making, deleted, scenes, behind, interview, recap, sneak) 2026-09-26 20:07:57 -07:00
Matthew Jackson 1229e7e0c4 jar/bdj_feature: EOCD tail check and exact extents for in-place jars; re-read unscored candidates
A large jar without an end-of-central-directory record is rejected after one tail
read; backward fills align to the read direction and extents use exact byte
lengths. Candidates missing from scan stats are re-read (unreadable or past the
read budget → abstain). props: PLAYLIST: locators may continue (.MARK:),
and `file` alone no longer names a playlist.
2026-09-26 20:07:56 -07:00
Matthew Jackson d962604971 deluxe: move a test doc comment back onto its test 2026-09-26 20:07:56 -07:00
Matthew Jackson 24979f52fb clpi_audit: key MPLS streams by every play item's clip 2026-09-26 20:07:56 -07:00
Matthew Jackson 839f948edf bluray: keep PiP (secondary) PG entries out of the subtitle list
They belong to the picture-in-picture overlay; now that mpls reads them at
the right STN position they would otherwise surface as feature subtitles.
2026-09-26 20:07:56 -07:00
Matthew Jackson 8ee3c62e5b jar: open large jars in place; a budget-exact entry is complete
Jars past the in-memory cap are read through an on-demand extent reader (zip
directory plus used entries) instead of counting as unreadable, so image-heavy
menus no longer make Tier 2 abstain. An entry exactly filling the remaining
inflate budget is offered rather than dropped as truncated.
2026-09-26 20:07:56 -07:00
Matthew Jackson d372a1f4be bdj_feature: props locator values, playlist-naming keys, skip missing ids; score scan-dropped candidates as short
props_hint accepts bd://0.PLAYLIST:NNNNN and .mpls values, needs a playlist word
otherwise, and moves on past lines naming absent playlists. Candidates the scan
dropped score as short instead of being re-read or aborting Tier 2.
2026-09-26 20:07:56 -07:00
Matthew Jackson 4eb7f4eb37 paramount: feature-word match splits camelCase and rejects extras compounds
MainFeature/FeatureFilm now match; Feature_Trailer/FeatureCommentary do not.
The word splitter is shared with bdj_feature's props keys.
2026-09-26 20:07:56 -07:00
Matthew Jackson ad398361b9 bdnav: unmasked bit/shift counts like libbluray; RND-derived values abstain
BITSET/BITCLR by >=32 are no-ops and SHL/SHR by >=32 give 0. GPRs set from RND
are tracked, and a branch, play or compare reading one abstains.
2026-09-26 20:07:55 -07:00
Matthew Jackson e154aa08b2 dvdnav: a set's LinkNoLink sub-instruction also ends the command list
libdvdnav eval_link_subins returns cond for linkop 0, so the rest of the list
never runs; abstain like the type-1 link path.
2026-09-26 20:07:55 -07:00
Matthew Jackson 545897c118 labels/dvdnav: shorten comment blocks to the comment-guard cap 2026-09-26 20:07:55 -07:00
Matthew Jackson 7c15666a60 bdjo: use is_multiple_of in the test fixture (clippy) 2026-09-26 20:07:55 -07:00
Matthew Jackson 777d546821 labels/bdnav tests: pin real behaviour; deluxe studio scan tracks quotes
deluxe: studio= inside a quoted value is ignored and an empty/over-long value
is skipped instead of ending the scan. bdjo fixtures now exercise cache items,
playlists, profiles, names and params; truncation asserts None. ifo palette
check is no longer tautological; class_reader test comment is honest.
2026-09-26 20:07:55 -07:00
Matthew Jackson d61c3a3ad2 pes: observe the truncated-frame read size, not just the error kind 2026-09-26 20:07:55 -07:00
Matthew Jackson 685245d9b3 criterion: drop a stream whose mapped number is already claimed
A duplicate playbackconfig mapping is contradictory data; binding it to a
synthesized slot labels a stream no data describes. Taken-number sets are HashSets.
2026-09-26 20:07:54 -07:00
Matthew Jackson 8e058cd202 clpi_audit: key streams by (clip, PID), not PID alone
A PID is only unique within one clip's transport stream; MPLS STN entries
are compared against their first play item's clip.
2026-09-26 20:07:54 -07:00
Matthew Jackson a8c553675e labels: bound and single-pass the BD-J menu-walk; tighten Tier-1/Tier-2 evidence
Filter jar entries by name before inflating, charge all inflation to a shared
budget, read each jar once for both tiers, reuse scanned playlist stats, require
Tier-1 hints to name a real playlist, and abstain when Tier-2 evidence is
unreadable. props_hint only accepts feature-playlist-id keys and bare numbers.
2026-09-26 20:07:54 -07:00
Matthew Jackson 74ab7fd704 labels: match the word "feature" in paramount tier 2; share hint/duration helpers
Featurette/Bonus_Features no longer outrank the most-audio feature. Candidates
borrow the document; fox selects its feature once per parse.
2026-09-26 20:07:54 -07:00
Matthew Jackson 853be657da bdnav: saturate HDMV SET ADD/MUL like libbluray ADD_u32/MUL_u32 2026-09-26 20:07:54 -07:00
Matthew Jackson 2b1e29209b dvdnav: match libdvdnav set-op semantics, model SetGPRMMD and set-links
Saturating add/mul, clamped sub, 0xFFFF on div/mod by 0, swap writes both
registers, rnd and counter-mode GPRMs taint, and a set's link sub-instruction
abstains instead of falling through.
2026-09-26 20:07:54 -07:00
Matthew Jackson b3ddd97cee mpls: parse PiP PG with primary PG before IG; TextST language offset
Per libbluray _parse_stn, PiP PG entries share the PG loop and carry no ref
block; TextST attributes put character_code before the language.
2026-09-26 20:07:54 -07:00
Matthew Jackson 25ee75a60e io: never wait on writeback during seeks
handle_seek only kicks off the tail; ENOMEM/EINTR/EAGAIN are warn-only;
write_image gets a dir-fsync seam and a failure test.
2026-09-26 19:53:08 -07:00
Matthew Jackson 93eb6b79e0 io: latch all but unsupported-call errnos, wait on the writeback tail
WAIT_AFTER errors are latched unless EINVAL/ESPIPE/EBADF/ENOSYS/EOPNOTSUPP;
finalize also waits on the partial tail; dir fsync tolerates the errnos
PostgreSQL's fsync_fname_ext does.
2026-09-26 19:53:08 -07:00
Matthew Jackson e6252c214d io: address review of writeback, truncation and fsync fixes
Latch only data-loss errnos and skip sync_file_range on non-regular fds; flush
surfaces the latched error. New E6020 ImageEndsBeforeRead aborts DiscStream even
with skip_errors. Evict the read window on jumps; write_image propagates dir fsync.
2026-09-26 19:53:07 -07:00
Matthew Jackson 2b877cca77 io: overflow-safe deadlines; allow unused bounded_syscall on Windows
bounded_syscall and finish_with_grace treat an unrepresentable deadline as
unbounded instead of panicking; bounded.rs is dead code off Linux/macOS.
2026-09-26 19:53:07 -07:00
Matthew Jackson 041187ecb6 io: latch writeback errors, fix read eviction window, typed image truncation
sync_file_range(WAIT_AFTER) errors are latched and returned from sync_all/writes;
DONTNEED window follows actual read offsets; EOF reads return ImageTruncated;
NFS probe uses dest's directory; write_image fsyncs the parent; send_with_halt
treats an overflowing deadline as unbounded.
2026-09-26 19:53:07 -07:00
Matthew Jackson 8c8fe3c97d disc: structure read-ahead keeps embedded files, stops batching after a failure
FileReadAhead memoises the ICB, falls back to pass-through without an extent list and
reads sector by sector after a failed batch. Bus-blocked key resolution records a
BUS_BLOCKED trace step; a non-handshake capture error is surfaced.
2026-09-26 19:47:06 -07:00
Matthew Jackson cb14a008b1 disc: halt-aware capacity retry, bounded structure bundle, sticky bus-blocked AACS
READ CAPACITY honours Stop and stops on permanent sense; read_structure_files is capped,
sanitized, extent-bounded and Halted-aware; BusSource encodes each bus-removal route and
a failed live handshake on a bus-encrypted disc keeps triage metadata but refuses keys.
2026-09-26 19:47:06 -07:00
Matthew Jackson 6bf4eb9714 extract: halt on every Stop path; hole short reads
Use the crack's own Halted/Unreadable outcomes (no post-hoc probe) and end a
Stop during AACS key-map resolution as a halted run. Short reads retry then
hole; the batch end is overflow-checked; only Halted short-circuits a read.
2026-09-26 19:45:35 -07:00
Matthew Jackson 1b87f11b39 extract: treat a drive Stop as a halt, not a hole
Halted from the drive (and a cancel during the per-VTS CSS crack or an inline
file's progress report) now halts the run and leaves the file .partial instead
of finalizing zeros. Also: checked extent LBA add, probe-marker removal retry,
SAFETY notes on the free-space FFI, and host-independent case-fold tests.
2026-09-26 19:38:09 -07:00
Matthew Jackson 2af01428e1 disc: assume bus encryption for a missing cert only on a known-UHD disc
The defaulted cert version made every live drive with no cert refuse keys,
regressing AACS1 BDs. Require index.bdmv 0300 or an AACS2 MKB instead.
2026-09-26 19:33:00 -07:00
Matthew Jackson 44c33b70fb bus: cache failed unit-head reads, fail safe on missing certs, expose stream ranges
Head fetches use the fast timeout and cache failure as encrypted; title extents
outside stream files are de-bussed as unknown alignment; unrecorded extents keep
file offsets. Add Disc::stream_content_ranges; no cert on a live drive gates keys.
2026-09-26 19:33:00 -07:00
Matthew Jackson 8db86fad4b bus: shorten comment blocks to satisfy comment-guard 2026-09-26 19:33:00 -07:00
Matthew Jackson 44c10a3062 disc: treat an unparseable content cert as bus-encrypted in the key gate
A cert that reads but does not parse may declare bus encryption, so a live
drive without a Read Data Key refuses keys instead of emitting garbage.
2026-09-26 19:33:00 -07:00
Matthew Jackson ea432f2eba bus: gate host-key de-bus per AACS unit copy-permission bits
BusMap keeps per-file stream extents so each LBA maps to its aligned unit's
first sector; units with CPI=0 pass through (libaacs aacs_decrypt_bus). Split
scan() post-handshake for a wiring test; skip the stream walk without a key.
2026-09-26 19:33:00 -07:00
Matthew Jackson 5a618f8bfe aacs: reject content certs with an unknown type byte
Only 0x00 (AACS1/HD DVD) and 0x10 (AACS2) parse; anything else is None,
so a corrupt cert falls to the keep-key bus path instead of a V20 guess.
2026-09-26 19:32:59 -07:00
Matthew Jackson 2615c86ec3 disc: bus-remove every BDMV/STREAM file and honour the BEE flag
The cert-route bus gate covered kept titles only, leaving non-title clips
bus-encrypted in dir://, sweep and ISO. Gate on all stream files, and drop
the Read Data Key when the content cert says bus encryption is off.
2026-09-26 19:32:59 -07:00
Matthew Jackson 5070efe9e7 css: keep images listable when the scan-time crack extent is unreadable
The per-title crack reports the read fault; folder probe shares main_title.
2026-09-26 19:31:25 -07:00
Matthew Jackson 248341bbe5 css/keys: honour halt in scan crack paths; surface read faults and rejections
CrackOutcome gains Unreadable and is non_exhaustive; decrypt_keys_for_title
takes a halt token and returns Result; all-rejected read-time keys are
AacsKeyRejected; Disc::inputs_with_samples fills validation samples.
2026-09-26 19:31:25 -07:00
Matthew Jackson 0f8d557305 keys: validate read-time key fetch; surface source/halt/drive faults; AACS conformance
Read-time fetch skips keys that open no sample and returns source failures as Err;
CSS crack fails closed on no-verdict scans and reports Halted; MKB fallback is
DUPLICATE/MKB_RO, 0x07 never read as cvalues; HD DVD VTKF parsed; perf/test cleanups.
2026-09-26 19:31:25 -07:00
Matthew Jackson 1c3a39b0a1 mux: tidy late AAC config reporting and CodecPrivate encoding
Log unsupported late-config sinks and never-resolved AAC tracks at debug, build
the patch with ebml helpers, and cover the MVC track remap.
2026-09-26 19:29:26 -07:00
Matthew Jackson a1b29c1712 mux: backpatch a late AAC CodecPrivate into seekable MKV
Reserve a Void where an unknown AAC CodecPrivate goes and fill it when the
reader resolves the ASC after headers; non-seekable sinks keep the bounded wait.
2026-09-26 19:25:59 -07:00
Matthew Jackson 4beb426b32 mux: per-track AAC header wait and surface ADTS config changes
Measure the AAC wait per track so interleaved tracks far apart in PTS do not
double-count, and apply the frame backstop only to frames that stall the clock.
Report mid-stream config changes per track via Stream::config_changes.
2026-09-26 19:25:59 -07:00
Matthew Jackson 5867cd7d31 mux/codec: MPEG free-format per-layer caps, gap and EOS framing
Free-format search is bounded by per-layer byte caps derived from the frame syntax.
The pending free-format frame is emitted before a discontinuity clears the buffer; EOS frames the learned size so trailing ID3v1 bytes are not absorbed.
2026-09-26 19:14:07 -07:00
Matthew Jackson e1555397a3 mux/codec: DTS EXSS alignment, bounded MPEG free-format framing
DTS: EXSS after a non-multiple-of-4 core is found past its alignment pad in parse() and at EOS; truncated extensions and short tails leave the core alone.
MPEG audio: free-format search capped at the largest legal frame, size re-verified and relearned, lone last frame emitted at flush.
HEVC: stale SEI comments.
2026-09-26 19:14:07 -07:00
Matthew Jackson 78f030149a mux/codec: review follow-ups for DTS, MPEG free-format, HEVC SEI, H.264
DTS: EOS framing mirrors parse(), bogus-sync resync in one drain, PTS-less PES after a gap keeps the timeline.
MPEG audio: free-format size from sync spacing. HEVC: SEI scanned without RBSP copies, true ue(63) fixture, guard property test.
H.264: truncated second-field slice_type no longer vetoes promotion; comments name the heuristic.
2026-09-26 19:14:06 -07:00
Matthew Jackson 80a8ae1d86 mux/codec: bound HEVC PPS table, promote 1080i I/P field anchors
HEVC stores num_extra per PPS id in a fixed 0..=63 table instead of an uncapped map.
H.264 open-GOP promotion checks only the first picture, so an I/P field pair is a keyframe.
DTS EOS tail applies the core plausibility check; guard covers private modules; test fixes.
2026-09-26 19:14:06 -07:00
Matthew Jackson c72c8bf51f udf: tolerate trailing metadata ADs and broken subdirectory chains
Only the Metadata File's first AD must be recorded; a later non-recorded AD ends the map.
A subdirectory with an unfollowable AD chain is listed empty with a warning; AEDs whose L_AD overruns the block are rejected.
2026-09-26 19:07:12 -07:00
Matthew Jackson 665fa360ef udf: validate AD chains and map fragmented metadata files
Any zero-length AD ends a list; continuation blocks must be tag-258 AEDs and never revisited; directory sector reads are capped.
Metadata File extents are honoured (first must be recorded) and the FSD is read where the LVD records it.
2026-09-26 19:03:18 -07:00
Matthew Jackson 9b45971ec4 udf: invalidate sector cache on failed miss; skip unrecorded dir extents
BufferedSectorReader cleared its window only on success, so a failed miss left it over a shrunk/overwritten buffer (panic or stale data).
read_directory read type 1/2 extents as FID data and kept sector padding between extents; merge_ranges/parse_dstring drop prod unwraps.
2026-09-26 19:03:17 -07:00
Matthew Jackson 592015800a Shorten three comment blocks to satisfy comment-guard 2026-09-26 19:02:38 -07:00
Matthew Jackson b5e6e0a32b mux: harden the AAC header gate and keep the first ADTS config
Check halt after the header pump even when EOF released the gate. Bound the
AAC wait by summed forward PTS steps, frames, and half the buffer cap. Count
mid-stream ADTS config changes and keep the first ASC, as ffmpeg does.
2026-09-26 17:00:06 -07:00
Matthew Jackson 7319da8a74 mux: hold headers until every AAC track has its AudioSpecificConfig
headers_ready() only waited for video, so audio-only or video-first TS wrote
A_AAC without CodecPrivate over ADTS-stripped frames. The wait is bounded by
5 s of source PTS, 2048 frames, or EOF. Fix the vacuous AAC case in codec tests.
2026-09-26 16:46:39 -07:00
Matthew Jackson 58d99a194d drive: release a no-media drive before opening the next candidate
find_drive kept the first opened drive alive as the no-media fallback
while it opened the remaining candidates. On macOS MacScsiTransport
allows a single live handle per process, so every later open failed
with DeviceLocked: with two drives, the first empty and the second
holding a disc, find_drive returned the empty drive.

select_drive_with_media now takes the candidate paths plus an opener,
drops the held fallback just before opening the next candidate, and
reopens it by path only if it was released. Adds a regression test
with a single-open fake transport.
2026-09-26 16:16:23 -07:00
Matthew Jackson c22c7f9066 CI: check freemkv's app build (--features gui) as a consumer
freemkv now has a CLI build and an app build; test both so an engine or
library change can't silently break the desktop app.
2026-09-26 15:39:49 -07:00
381 changed files with 127483 additions and 133885 deletions
+50 -31
View File
@@ -1,33 +1,52 @@
# Version updates are monthly and grouped into one PR per ecosystem.
# Security updates are separate and run whenever an advisory is published.
version: 2
# Dependency updates land on `dev`, never on `main`.
#
# `main` here is a RELEASE POINTER that freemkv's release-orchestrate.yml
# moves to each tag. A bot commit on it would put work there that no tag
# contains, which is exactly the state that aborted the 1.6.2 cascade at the
# last step -- so pointing Dependabot at main would recreate that failure on a
# schedule.
updates:
- package-ecosystem: cargo
directory: /
target-branch: dev
schedule:
interval: monthly
open-pull-requests-limit: 5
groups:
all:
patterns:
- '*'
ignore:
- dependency-name: freemkv-unlock
- dependency-name: libfreemkv
- dependency-name: freemkv-keysources
- dependency-name: freemkv-i18n
- dependency-name: freemkv-engine
- package-ecosystem: github-actions
directory: /
target-branch: dev
schedule:
interval: monthly
open-pull-requests-limit: 5
groups:
all:
patterns:
- '*'
ignore:
- dependency-name: dtolnay/rust-toolchain
- dependency-name: freemkv/.github*
- package-ecosystem: cargo
directory: /
target-branch: dev
schedule:
interval: weekly
open-pull-requests-limit: 5
# One PR per week for the routine bumps instead of one per crate.
groups:
minor-and-patch:
update-types:
- minor
- patch
ignore:
# The freemkv crates depend on each other by GIT TAG, re-pinned by
# release-orchestrate.yml in the release commit. A bot bump would fight
# the release cascade and could pin a tag that does not exist yet.
- dependency-name: freemkv-unlock
- dependency-name: libfreemkv
- dependency-name: freemkv-keysources
- dependency-name: freemkv-i18n
- dependency-name: freemkv-engine
- package-ecosystem: github-actions
directory: /
target-branch: dev
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
actions:
update-types:
- minor
- patch
ignore:
# Versioned by the Rust release it installs; CI picks the toolchain via
# `toolchain:` on @master/@stable. Dependabot proposes Rust versions that
# do not exist. Toolchain bumps are a deliberate all-repo change.
- dependency-name: dtolnay/rust-toolchain
# The org's own reusable workflows/actions. @v1 and @v2 are independent
# moving tags (v2 is only the comment guard), not semver releases: a
# "bump" from v1 to v2 would move a caller onto an OLDER guard.
- dependency-name: "freemkv/.github*"
+2 -22
View File
@@ -1,14 +1,6 @@
# Changelog
## [1.8.1] — 2026-10-07
- `DriveInfo::display_name` is the name the OS shows users: the drive letter (`E:`) on Windows, `/dev/srN` on Linux and `diskN` on macOS while a disc is mounted; it equals `path` when the OS has no name. `path` remains the selector to open. Windows lists lettered drives in letter order, then unlettered drives by `CdRomN`.
- Drive listing enumerates instead of guessing device numbers. Windows lists every `CdRomN` from `QueryDosDeviceW` plus optical drive letters (previously only `CdRom0`–`15`), retries INQUIRY with 36 bytes for strict USB bridges, and logs skipped devices. Linux also lists optical `sr` devices that have no `sg` node (the `sg` module is not loaded) and, without sysfs, reads `/dev` instead of probing `sg0`–`15`. macOS no longer stops at 8 drives.
- macOS CLI/GUI/server transport diagnostics include selected IOKit service, plug-in metadata, interface initialization stages, exclusive-access attempts and cleanup results.
- macOS optical-drive enumeration keeps the same IOKit service selector with or without mounted media, so exclusive access during a rip does not turn one drive into two identities.
## [1.8.0] — 2026-10-05
## [1.8.0] — Unreleased
### Breaking
@@ -36,9 +28,6 @@
### Output-format changes
- **MPEG-2 video starts on a decodable picture:** pictures before the first I-picture, and the leading B-pictures of an open GOP whose forward reference is not in the output (at the start of a title, after a `broken_link` GOP, or where the timeline jumps at a join), are left out instead of showing as a green or corrupt frame. Every kept frame keeps its PTS, so audio sync is unchanged; a title that opens on a closed GOP is unchanged.
- **H.264, HEVC and VC-1 video start on a decodable picture, and every codec resumes cleanly:** pictures before the first random-access picture are left out, and so are leading pictures that predict from a picture the output does not hold: at the start of a title, at a join (the decode clock or PTS moves at the random-access picture), at a VC-1 `BROKEN_LINK`, and at the keyframe the resync gate resumes on after a gap (MPEG-2 too). These are H.264/VC-1 open-GOP leading B-pictures and HEVC RASL pictures (also after a stream-start CRA, which a conforming decoder discards anyway); an MVC dependent-view unit goes with its base picture, and a CRA at any join is written as BLA (before, only after a PTS step back of over 3 s). Where a clip's IN or OUT mark trims a picture another one predicts from, that one goes too, up to the next keyframe (and its leading pictures). Every kept frame keeps its PTS, so audio sync is unchanged; these are not counted as lost data. A closed-GOP or IDR-only title is unchanged.
- **MKV DefaultDuration of MPEG-2 video** follows the kept frames' measured period when that is another standard rate: a soft-telecined NTSC film title (DVD) declares 23.976 fps instead of the sequence's 29.97, so the frame count agrees with the duration. Video at its sequence rate is unchanged.
- **LPCM** (BD and DVD) is decoded to interleaved big-endian PCM in WAVE_FORMAT_EXTENSIBLE channel order (BD pad channel removed, LFE/surround remapped, DVD 20/24-bit groups unpacked) at the source depth: 16-bit stays 16-bit, 20- and 24-bit sources output 24-bit; MKV `BitDepth` matches, and M2TS/MPG output keeps the depth. BD 20-bit packets are kept (24-bit container); reserved header codes are dropped. On M2TS output LPCM is repacked to the BD layout; LPCM BD cannot carry (e.g. 44.1 kHz) is dropped and reported.
- **M2TS:** reordered video (H.264, HEVC, MPEG-2, VC-1) now carries a DTS (H.222.0 2.7.5); PTS round to the nearest 90 kHz tick (a PTS can be 1 tick later than 1.7.7), the first frame lands one second above zero, PCR never decreases with B-frames; output always declares `bd-ts` in its FMKV header. PGS `.sup` ticks use the same rounding.
- **MKV:** DTS, DTS-HD HRA and DTS-HD MA are all `A_DTS`; streams with no Matroska CodecID (text subtitles, unknown codecs) are omitted rather than declared under another codec; SDR BT.2020 video is tagged with the BT.2020 transfer (14), not PQ; the timeline origin is the earliest sample of any selected track (audio and subtitles before the first video keyframe are kept at their true offsets; video starts at its first keyframe), and Blu-ray clip IN applies only to BD-TS sources, whose frames before the IN are dropped and counted; VobSub cues without a stop command end at the next subtitle (10 s cap) instead of 30 s. **MP4:** a track that starts after the earliest sample keeps its offset through an empty edit, and video before its first keyframe is not stored.
@@ -49,7 +38,7 @@
- **`json://`** audio `sample_rate` is a number in Hz (`null` when unknown), not a string such as `"48kHz"`; a `sample_rates` array lists every rate a stream carries.
- **FMKV (`network://`, `stdio://`) header version 2** is used when a track carries decoder timing (Opus CodecDelay/SeekPreRoll) and adds per-frame DiscardPadding; streams without timing stay version 1. Headers now also carry display aspect, CICP, stream purpose/qualifier, chapters and content format, and PIDs actually written by M2TS. A header declaring more than 256 streams is refused with E9008. An older receiver omits the MKV Channels element for the new channel-layout strings; keep both ends on one version.
- **`dir://` image bytes:** the PVD declares interchange level 3, Unique IDs start at 16 and the volume id round-trips through the d-string encoder; `dir://` sources are stamped as images in the `fvi://` provenance header.
- **Output title names:** a title read from a drive (`disc://`, a server live rip) is named by the disc's meta title (else its volume id) in every container's title field and in `fvi://` provenance; one read from an image or folder (`iso://`, `dir://`, a staged image) by the disc's meta title, else its own playlist name as in 1.7.7, so an image and its extracted folder name a title alike. 1.7.7 named server-live rips by the MPLS playlist file. `fvi://` provenance of an `iso://`/`dir://` source now carries the volume id.
- **One output title name:** a title read from a disc layout (`disc://`, `iso://`, `dir://`, a staged image, a server live rip) is named by the disc's meta title (else its volume id) in every container's title field and in `fvi://` provenance; 1.7.7 named image and server-live rips by the MPLS playlist file. `fvi://` provenance of an `iso://`/`dir://` source now carries the volume id.
- **TrueHD 7.1 / Atmos labels on every path:** the playlist labels a 7.1 or Atmos TrueHD track 5.1 at 48 kHz; every mux (live disc, staged image, `iso://`/`dir://`, containers) now completes the track's channels, sample rate and label from its first major sync before the output opens, and `json://` / `chapters://` get the completed title too. 1.7.7 corrected only `iso://`/`dir://` URL rips, so live and staged-image rips now label such tracks 7.1 / Atmos.
- **Track selection applies to container inputs:** `selection` keeps a subset of the tracks of an `mkv://`, `mp4://`, `m2ts://`, `network://` or `stdio://` input (renumbered in order, video always kept), as it already did for disc titles; an unknown PID is refused at open.
- **A live DVD extracted to `dir://` is descrambled:** `Disc::extract_tree` finds and cracks each scrambled VTS from its content on every DVD-Video layout. A live drive scan records no disc-wide CSS key, so a live DVD's title VOBs were written still scrambled.
@@ -62,10 +51,6 @@
### Added
- DVD: the region mask from the VMG (`DvdInfo.region_mask`, `DiscRegion::Unknown`), stream labels from the audio and subpicture code-extension byte, chapter names from the TXTDT text data, and a probe of each title's subpictures for forced-display (`FSTA_DSP`) commands.
- HD DVD: AACS decryption per pack, keyed by each EVOBU's CPI; AACS-wrapped playlists and features split across `PEVOB_n` files.
- AACS: a Class II MKB is reported as an unsupported class (E7109) instead of as a missing key.
- UHD: HDR10 mastering metadata is kept when content light level is absent; `hdr_plus_flag` is read from the HEVC stream attributes; the Dolby Vision enhancement-layer track's `dvcC` carries `bl_present=0`.
- `mpg://` as a source and a destination: a DVD-Video / MPEG-2 program stream file reads through the decryption stage and the DVD sector pipeline (a scrambled `.vob` is cracked once, `--raw` keeps the ciphertext; a clear MPEG-1 or stuffed MPEG-2 stream is no longer read as scrambled, and a scrambled pack with no key fails E7023). `MpgSink` writes an H.222.0 program stream with P-STD pack timing, system header and PSM; `Error::MpgNoVideoTrack` (E9074), `Error::MpgUnpacketized` (E9075). `FileSectorSource::open_padded` zero-pads a partial tail sector.
- `keys` module: `KeyRing::acquire(evidence, sampler, sources, opts, ctx)` (`KeyScope::{None, Titles, WholeDisc}`, `AcquireOptions`, `KeyResolution`) asks the key sources once before any output and proves each held key against the disc's ciphertext per stream file; keys and the Volume ID stay in memory (`Debug` redacts). Also `title_reader`, `whole_disc_reader`, `decrypt_status`, `check_decryptable`, `KeySetStatus`, `ForensicState`, `DecryptStatus`, `ProofCache`. A piece that cannot be proven up front is proven on first read; a unit no held key opens stops a title rip with E7022; in an image or folder it is blanked, warned and counted. `ResolveCtx::halt()` / `progress()` give sources the Stop token; key-service retries back off 1 s to 8 s. `MediaKeyVariantError::code()` returns E7100-E7108.
- `disc_root_of(path)`: the AACS disc folder a loose Blu-ray clip sits in (`<root>/BDMV/STREAM/x.m2ts` with `<root>/AACS`), so a caller can resolve the clip's keys on `dir://<root>`.
@@ -85,7 +70,6 @@
### Changed
- **DVD titles are `ContentFormat::DvdPs`**, set only by the DVD scan; `MpegPs` now means HD DVD or a plain program-stream file. DVD-only steps (cell filter and clock joins, CSS title-key crack, forced-subtitle probe) no longer run on HD DVD or `mpg://`. The `json://` `format` field reads `"DvdPs"` for DVD titles.
- **AACS damage policy (E7013):** a flagged AACS unit no key opens (no TS sync, zero-filled or garbled head, lone FMTS verify failure, off-grid) is zero-filled, warned and counted on every path (ISO mux, live mux, extract, whole-disc reader) instead of failing with E7013, with an end-of-rip "N damaged AACS units blanked" log. A wrong key still fails: E7013 at resolve (or when two or more FMTS units fail under one key with none verifying), E7022 on arrival in a title rip (an image or folder blanks the unit instead).
- A BD-TS mux with no AACS key set fails E7022 on the first AACS-flagged unit instead of writing ciphertext; an AACS HD DVD session with no set fails `NoDiscKey`. A key-set stop (E7022) is never shrunk, recovered or skipped.
- A Stop during a mux is always `completed = false, halted = true` (`MuxOutcome::halted`), whether it lands during the open or the pump: `mux_with_keys` no longer returns `Err(Halted)` from a stopped open. A Stop reaches `iso://` and `dir://` sources inside a blocked read, the image scan and the CSS crack (it was seen only between frames), and `iso://`, `dir://` and `mpg://` URL muxes report read progress like the disc arms. A stopped read is a stop, not a skipped sector. A read error repeats on every later `read()` of the highway stream. A halted `PrefetchedSectorSource` close is `Err(Halted)`, not EOF.
@@ -111,10 +95,6 @@
### Fixed
- A program-stream PES that opens inside one picture times the next picture commencing in it (ISO/IEC 13818-1 §2.4.3.7), not the one it opens in. A program stream packing pictures back to back gave each picture its successor's PTS, so MPEG-2 GOPs re-locked a frame off and an open GOP's leading B-pictures were dropped as after a join. DVD and HD DVD titles, which open a PES at each timed picture, are unchanged.
- `m2ts://` output of a multi-clip title rides the clip-join timeline like the MKV, MP4 and MPG sinks: the PTS no longer restarts at each join, and reordered video keeps its DTS after the first join instead of falling back to PTS only.
- An image title whose extent ends off the 3-sector AACS unit grid (an HD DVD `.EVO`) no longer fails E9030 at that extent's end: the sub-unit tail is read, passed when clear and refused (E7013) when it is part of an encrypted unit.
- Every AACS format takes one encryption decision in `KeyRing::acquire`: content its own detector reads in the clear needs no key and asks no source, whatever key files the disc declares (an HD DVD copy in the clear declaring several keys, and an FMTS disc whose forensic segments are also clear, no longer ask for or refuse on keys); encrypted content is keyed by the format's rules and refused (E7022 / E7032) when no source has the key.
- A zero read batch (`MuxOptions::default()` carries `batch_sectors` 0) is refused with `Error::MuxBatchSectorsZero` (E9085) on every path (live, session, ISO, URL) instead of spinning until Stop.
- A stopped or failed read can no longer finalise a truncated MKV / M2TS as a complete title; `extract` after a drive Stop leaves the file `.partial` instead of finalising zeros.
- DVDs with MPEG audio (common on PAL) ripped an empty track; anamorphic DVDs got wrong or missing subtitle languages; a partial angle-cell filter dropped cells.
Generated
+2 -9
View File
@@ -221,13 +221,12 @@ dependencies = [
[[package]]
name = "freemkv-unlock"
version = "1.8.0"
version = "1.7.7"
dependencies = [
"aes",
"base64",
"num-bigint",
"num-traits",
"pioneer-optical",
"rand",
"serde",
"serde_json",
@@ -318,7 +317,7 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libfreemkv"
version = "1.8.0"
version = "1.7.7"
dependencies = [
"aes",
"base64",
@@ -431,12 +430,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pioneer-optical"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf5074a27cc39fb664d0b994814ffe8530e3e8590aff0d104723332dbcb1abc4"
[[package]]
name = "proc-macro2"
version = "1.0.107"
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.8.1"
version = "1.7.7"
edition = "2024"
rust-version = "1.88"
license = "MIT"
@@ -66,7 +66,7 @@ sha2 = { version = "0.11", optional = true }
aes = { version = "0.9", optional = true }
# Resolved by the git-tag [patch.crates-io] below (re-pinned by
# release-orchestrate.yml); local dev patches it to ../freemkv-unlock.
freemkv-unlock = { version = "1.8", optional = true }
freemkv-unlock = { version = "1.7", optional = true }
zip = { version = "8", default-features = false, features = ["deflate"], optional = true }
# zlib for Matroska ContentCompression (mkvmerge compresses S_VOBSUB by default); the
# same backend `zip` already pulls in.
@@ -146,7 +146,7 @@ harness = false
# freemkv-unlock is off crates.io: resolve it from its GIT TAG. Consumers carry
# the same patch; a gitignored .cargo/config.toml overrides it to a local path.
[patch.crates-io]
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.8.1" }
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.7.7" }
[lints.rust]
# `loom` is set by RUSTFLAGS for the model checker above, never by a feature.
+1458 -26
View File
File diff suppressed because it is too large Load Diff
-157
View File
@@ -1,157 +0,0 @@
use super::*;
use crate::spec::keys::*;
use aes::cipher::{BlockCipherDecrypt, BlockCipherEncrypt};
const KCU: [u8; 16] = [
0x6B, 0x1D, 0xE2, 0x04, 0x93, 0x5A, 0xC7, 0x38, 0x0F, 0xB4, 0x71, 0x2E, 0xD9, 0x86, 0x45, 0x10,
];
// Ciphertext-looking unit with no all-zero packet and a flagged, varied seed.
fn unit(salt: u8) -> Vec<u8> {
let mut u: Vec<u8> = (0..ALIGNED_UNIT_LEN)
.map(|i| (i as u8).wrapping_mul(31).wrapping_add(salt) | 1)
.collect();
u[0] |= 0xC0;
u
}
fn aes_e(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] {
let mut b: Array<u8, _> = (*block).into();
Aes128::new(&(*key).into()).encrypt_block(&mut b);
b.into()
}
// KS-3 / KS-19 by hand: AES-128-CBC decrypt of 16..6144 under iv0, with the
// KS-4 / KS-23 Block Key = AES-128E(Kcu, seed) ⊕ seed.
fn reference_decrypt(unit: &[u8], kcu: &[u8; 16]) -> Vec<u8> {
let seed: [u8; 16] = unit[..16].try_into().unwrap();
let mut bk = aes_e(kcu, &seed);
bk.iter_mut().zip(seed).for_each(|(b, s)| *b ^= s);
let cipher = Aes128::new(&bk.into());
let mut prev = AACS_IV;
let mut out = unit.to_vec();
for (o, c) in out[16..].chunks_mut(16).zip(unit[16..].chunks(16)) {
let mut b: Array<u8, _> = <[u8; 16]>::try_from(c).unwrap().into();
cipher.decrypt_block(&mut b);
o.iter_mut()
.zip(b.iter().zip(prev))
.for_each(|(o, (p, v))| *o = p ^ v);
prev = c.try_into().unwrap();
}
out
}
/// per spec; do not change without a spec citation — KS-3, KS-4 [BD] §3.10.1: "The
/// first 16 bytes of each Aligned Unit is used as the seed"; "The final 6128 bytes … encrypted".
#[test]
fn seed_is_first_16_bytes_and_stays_clear() {
assert!(
KS_4_SEED
.text
.starts_with("The first 16 bytes of each Aligned Unit")
);
assert!(
KS_3_CBC_PER_UNIT
.text
.starts_with("The final 6128 bytes of each Aligned")
);
let enc = unit(7);
let mut dec = enc.clone();
decrypt_unit(&mut dec, &KCU);
assert_eq!(dec[..16], enc[..16], "the seed is never decrypted");
let changed = dec[16..]
.chunks(16)
.zip(enc[16..].chunks(16))
.all(|(d, e)| d != e);
assert!(changed, "every block of the final 6128 bytes is decrypted");
}
/// per spec; do not change without a spec citation proving otherwise — KS-3 [BD]
/// §3.10.1: "A new CBC cipher chain is started for each Aligned Unit": the mapped decrypt
/// of a multi-unit buffer, in either order, never chains one unit into the next.
#[test]
fn cbc_chain_restarts_every_unit() {
use crate::decrypt::{AacsKeyMap, DecryptKeys, Phase, decrypt_sectors_mapped};
assert!(
KS_3_CBC_PER_UNIT
.text
.contains("new CBC cipher chain is started for each")
);
// Encrypted TS units (sync at +4, CPI 11₂ per packet) so the mapped path decrypts.
let plain: Vec<Vec<u8>> = (0..2)
.map(|i| {
let mut u = unit(40 + i);
for p in u.chunks_mut(crate::consts::BD_SOURCE_PACKET_BYTES) {
p[0] |= 0xC0;
p[4] = TS_SYNC;
}
u
})
.collect();
let enc: Vec<Vec<u8>> = plain
.iter()
.map(|p| {
let mut u = p.clone();
assert!(encrypt_unit(&mut u, &KCU));
u
})
.collect();
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, KCU)],
format: crate::disc::ContentFormat::BdTs,
};
let map = AacsKeyMap::from_ranges_phased(vec![(0, 2 * ALIGNED_UNIT_SECTORS, 0, Phase::All)]);
for order in [[0, 1], [1, 0]] {
let mut buf: Vec<u8> = order.iter().flat_map(|&i| enc[i].clone()).collect();
decrypt_sectors_mapped(&mut buf, &keys, 0, &map).expect("both units keyed");
// KS-5: a decrypted unit's CPI reads 00₂ (KU design §5.4); the chain rule is KS-3.
let want: Vec<u8> = order
.iter()
.flat_map(|&i| super::cpi_cleared(plain[i].clone()))
.collect();
assert!(
buf == want,
"order {order:?}: each unit decrypts on its own chain"
);
}
}
/// per spec; do not change without a spec citation — KS-4 [BD] §3.10.1 (Figure 3-8),
/// corroborated by KS-23 libaacs: Block Key = AES-128E(Kcu, seed) ⊕ seed.
#[test]
fn block_key_is_aes128e_of_seed_xor_seed() {
assert!(
KS_23_LIBAACS_BLOCK_KEY
.text
.contains("key[a] ^= out_buf[a];")
);
let plain = unit(3);
let seed: [u8; 16] = plain[..16].try_into().unwrap();
let mut bk = aes_e(&KCU, &seed);
bk.iter_mut().zip(seed).for_each(|(b, s)| *b ^= s);
// First CBC block under the independent Block Key and iv0.
let mut x: [u8; 16] = plain[16..32].try_into().unwrap();
x.iter_mut().zip(AACS_IV).for_each(|(b, v)| *b ^= v);
let mut u = plain.clone();
assert!(encrypt_unit(&mut u, &KCU));
assert_eq!(
u[16..32],
aes_e(&bk, &x),
"encrypt uses AES-128E(Kcu, seed) ⊕ seed"
);
decrypt_unit(&mut u, &KCU);
assert_eq!(u, plain);
}
/// per spec; do not change without a spec citation — KS-3 [BD] §3.10.1 "AES-128CBCE";
/// KS-19 [CM] §2.1.2 iv0: the unit decrypt equals an independent AES-128-CBC.
#[test]
fn bd_aes_primitives_match_independent_impl() {
assert!(KS_3_CBC_PER_UNIT.text.contains("Block Key and AES-128CBCE"));
for salt in [0u8, 99, 200] {
let enc = unit(salt);
let mut got = enc.clone();
decrypt_unit(&mut got, &KCU);
assert_eq!(got, reference_decrypt(&enc, &KCU), "salt {salt}");
}
}
File diff suppressed because it is too large Load Diff
+100 -4
View File
@@ -176,9 +176,105 @@ pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
}
#[cfg(test)]
#[path = "crypto_tests.rs"]
mod tests;
mod tests {
use super::*;
// s0 transcribed independently from `[C]` §3.2.2, not read from AESG3_SEED, so this can't
// assert the production constant against itself.
const S0: [u8; 16] = [
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B,
0xD9,
];
/// An arbitrary non-degenerate key. Nothing about it is secret or special;
/// the AES-G3 relation holds for every key, and a constant-returning body
/// cannot satisfy it for any.
const K: [u8; 16] = [
0x0F, 0x1E, 0x2D, 0x3C, 0x4B, 0x5A, 0x69, 0x78, 0x87, 0x96, 0xA5, 0xB4, 0xC3, 0xD2, 0xE1,
0xF0,
];
// aesg3 is the SD-tree node function; a wrong `^` or a constant body would derive a
// plausible-but-wrong Processing Key. Pinned via the spec relation.
#[test]
fn aesg3_inverts_to_the_spec_seed_under_aes_encrypt() {
for inc in 0u8..=2 {
let mut seed = S0;
seed[15] = seed[15].wrapping_add(inc);
let out = aesg3(&K, inc);
// out == AES-128D(K, seed) XOR seed, so out XOR seed is the raw
// decryption and re-encrypting it must land back on the seed.
let mut pre = [0u8; 16];
for i in 0..16 {
pre[i] = out[i] ^ seed[i];
}
assert_eq!(
aes_ecb_encrypt(&K, &pre),
seed,
"AES-G3 inc={inc} must satisfy out = AES-128D(k, s0+inc) XOR (s0+inc)"
);
}
}
// The Triple Generator's three outputs are one node's two children plus its Processing Key;
// if `inc` were ignored a descent would revisit its own parent.
#[test]
fn aesg3_yields_three_distinct_subkeys_for_the_three_increments() {
let left = aesg3(&K, 0);
let pk = aesg3(&K, 1);
let right = aesg3(&K, 2);
assert_ne!(left, pk, "left child and Processing Key must differ");
assert_ne!(pk, right, "Processing Key and right child must differ");
assert_ne!(left, right, "left and right children must differ");
}
/// Distinct parent keys must yield distinct subkeys — the tree would
/// collapse otherwise.
#[test]
fn aesg3_separates_distinct_parent_keys() {
let mut other = K;
other[0] ^= 0x01;
assert_ne!(aesg3(&K, 1), aesg3(&other, 1));
}
// The batched CBC decrypt inverts the (block-at-a-time) CBC encrypt at every
// batch-boundary shape: one block, exactly one batch, a batch plus one, and a
// whole de-bussed sector body.
#[test]
fn batched_cbc_decrypt_inverts_cbc_encrypt_across_batch_boundaries() {
for blocks in [1usize, 31, 32, 33, 64, 383] {
let plain: Vec<u8> = (0..blocks * 16).map(|i| (i * 7 + 3) as u8).collect();
let mut data = plain.clone();
aes_cbc_encrypt(&K, &mut data);
assert_ne!(data, plain);
aes_cbc_decrypt(&K, &mut data);
assert_eq!(data, plain, "{blocks} blocks must round-trip");
}
}
}
#[cfg(test)]
#[path = "crypto_spec_guards_tests.rs"]
mod spec_guards;
mod spec_guards {
use super::AACS_IV;
use crate::spec::keys::KS_19_IV0;
/// per spec; do not change without a spec citation — KS-19 [CM] §2.1.2: "iv0, which
/// is: 0BA0F8DDFEA61FB3D8DF9F566A050F78₁₆" (the value is read from the quote).
#[test]
fn cbc_iv_is_iv0() {
let hex = KS_19_IV0
.text
.rsplit(' ')
.next()
.and_then(|h| h.strip_suffix("₁₆"))
.expect("KS-19 ends with the iv0 value");
let iv0: Vec<u8> = (0..32)
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
.collect();
assert_eq!(hex.len(), 32);
assert_eq!(AACS_IV.to_vec(), iv0);
}
}
-20
View File
@@ -1,20 +0,0 @@
use super::AACS_IV;
use crate::spec::keys::KS_19_IV0;
/// per spec; do not change without a spec citation — KS-19 [CM] §2.1.2: "iv0, which
/// is: 0BA0F8DDFEA61FB3D8DF9F566A050F78₁₆" (the value is read from the quote).
#[test]
fn cbc_iv_is_iv0() {
let hex = KS_19_IV0
.text
.rsplit(' ')
.next()
.and_then(|h| h.strip_suffix("₁₆"))
.expect("KS-19 ends with the iv0 value");
let iv0: Vec<u8> = (0..32)
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
.collect();
assert_eq!(hex.len(), 32);
assert_eq!(AACS_IV.to_vec(), iv0);
}
-74
View File
@@ -1,74 +0,0 @@
use super::*;
// s0 transcribed independently from `[C]` §3.2.2, not read from AESG3_SEED, so this can't
// assert the production constant against itself.
const S0: [u8; 16] = [
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
];
/// An arbitrary non-degenerate key. Nothing about it is secret or special;
/// the AES-G3 relation holds for every key, and a constant-returning body
/// cannot satisfy it for any.
const K: [u8; 16] = [
0x0F, 0x1E, 0x2D, 0x3C, 0x4B, 0x5A, 0x69, 0x78, 0x87, 0x96, 0xA5, 0xB4, 0xC3, 0xD2, 0xE1, 0xF0,
];
// aesg3 is the SD-tree node function; a wrong `^` or a constant body would derive a
// plausible-but-wrong Processing Key. Pinned via the spec relation.
#[test]
fn aesg3_inverts_to_the_spec_seed_under_aes_encrypt() {
for inc in 0u8..=2 {
let mut seed = S0;
seed[15] = seed[15].wrapping_add(inc);
let out = aesg3(&K, inc);
// out == AES-128D(K, seed) XOR seed, so out XOR seed is the raw
// decryption and re-encrypting it must land back on the seed.
let mut pre = [0u8; 16];
for i in 0..16 {
pre[i] = out[i] ^ seed[i];
}
assert_eq!(
aes_ecb_encrypt(&K, &pre),
seed,
"AES-G3 inc={inc} must satisfy out = AES-128D(k, s0+inc) XOR (s0+inc)"
);
}
}
// The Triple Generator's three outputs are one node's two children plus its Processing Key;
// if `inc` were ignored a descent would revisit its own parent.
#[test]
fn aesg3_yields_three_distinct_subkeys_for_the_three_increments() {
let left = aesg3(&K, 0);
let pk = aesg3(&K, 1);
let right = aesg3(&K, 2);
assert_ne!(left, pk, "left child and Processing Key must differ");
assert_ne!(pk, right, "Processing Key and right child must differ");
assert_ne!(left, right, "left and right children must differ");
}
/// Distinct parent keys must yield distinct subkeys — the tree would
/// collapse otherwise.
#[test]
fn aesg3_separates_distinct_parent_keys() {
let mut other = K;
other[0] ^= 0x01;
assert_ne!(aesg3(&K, 1), aesg3(&other, 1));
}
// The batched CBC decrypt inverts the (block-at-a-time) CBC encrypt at every
// batch-boundary shape: one block, exactly one batch, a batch plus one, and a
// whole de-bussed sector body.
#[test]
fn batched_cbc_decrypt_inverts_cbc_encrypt_across_batch_boundaries() {
for blocks in [1usize, 31, 32, 33, 64, 383] {
let plain: Vec<u8> = (0..blocks * 16).map(|i| (i * 7 + 3) as u8).collect();
let mut data = plain.clone();
aes_cbc_encrypt(&K, &mut data);
assert_ne!(data, plain);
aes_cbc_decrypt(&K, &mut data);
assert_eq!(data, plain, "{blocks} blocks must round-trip");
}
}
+1573 -46
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-531
View File
@@ -1,531 +0,0 @@
use super::*;
use crate::aacs::crypto::aes_ecb_encrypt;
// A Class II MKB is reported as an unsupported class (typed, E7109), not as "no key
// matched": derivation stops before reading its tables. A pre-recorded MKB passes.
#[test]
fn a_class_ii_mkb_is_an_explicit_unsupported_class() {
let mkb = |t: u32| {
let mut m = vec![0x10, 0x00, 0x00, 0x0C];
m.extend_from_slice(&t.to_be_bytes());
m.extend_from_slice(&[0, 0, 0, 1]);
m
};
let class2 = mkb(MKB_TYPE_10_CLASS_II);
let err = check_mkb_class(&class2).expect_err("class II is unsupported");
assert_eq!(err, MkbClassError::Unsupported(MkbType::ClassII));
assert_eq!(err.code(), crate::error::E_MKB_CLASS_UNSUPPORTED);
assert!(MkbTables::parse(&class2).is_none());
assert_eq!(derive_media_key_from_pk(&class2, &[[0u8; 16]]), None);
assert_eq!(check_mkb_class(&mkb(MKB_TYPE_4_PRERECORDED)), Ok(()));
assert_eq!(check_mkb_class(&mkb(MKB_20_CATEGORY_C)), Ok(()));
assert_eq!(check_mkb_class(&[]), Ok(()));
}
// km_verifies gates every candidate Media Key; mutation testing found `-> true` surviving
// all 2,556 tests, i.e. unverified verification.
#[test]
fn km_verifies_accepts_only_the_key_its_record_was_built_for() {
use crate::aacs::mkb::mkb_find_mk_dv;
let km: [u8; 16] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF,
];
let mut plain = [0u8; 16];
plain[..8].copy_from_slice(&VERIFY_MAGIC);
plain[8..].copy_from_slice(&[0xA5; 8]);
let mk_dv = aes_ecb_encrypt(&km, &plain);
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
];
mkb.extend_from_slice(&[0x81, 0x00, 0x00, 0x18]);
mkb.extend_from_slice(&mk_dv);
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
assert_eq!(
mkb_find_mk_dv(&mkb),
Some(mk_dv),
"fixture malformed — the verify record is not being found at all"
);
assert!(
probe::km_verifies(&mkb, &km),
"the key the record was built for must verify"
);
// The half that kills the `-> true` mutant. One flipped bit is the
// strongest form of wrong key: a near-miss, not a random one.
let mut wrong = km;
wrong[15] ^= 0x01;
assert!(
!probe::km_verifies(&mkb, &wrong),
"a key differing by ONE BIT must not verify; if it did, the MK-pool \
brute force would accept whichever candidate it happened to try first"
);
// No verify record means UNVERIFIABLE, which is not the same as verified.
let bare = vec![0x10, 0x00, 0x00, 0x0C, 0, 0, 0, 0, 0, 0, 0, 1];
assert!(
!probe::km_verifies(&bare, &km),
"an MKB with no verify record must not default to yes"
);
}
/// `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> {
let uk_pos = 32usize;
let stride = 48usize;
let n = encs.len();
let total = uk_pos + 48 + n.saturating_sub(1) * stride + 16;
let mut inf = vec![0u8; total.max(20)];
inf[..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
inf[uk_pos..uk_pos + 2].copy_from_slice(&(n as u16).to_be_bytes());
for (i, k) in encs.iter().enumerate() {
let o = uk_pos + 48 + i * stride;
inf[o..o + 16].copy_from_slice(k);
}
inf
}
/// A VUK candidate boils to ALL the disc's unit keys, each paired with its
/// declared CPS-unit number, and each key equals the VUK-decrypt of its slot.
#[test]
fn resolve_candidate_vuk_returns_all_cps_units() {
let vuk = Vuk([0x33u8; 16]);
let encs = [[0x11u8; 16], [0x22u8; 16], [0x44u8; 16]];
let inf = synth_inf(&encs);
let r = resolve_candidate(&KeyCandidate::Vuk(vuk), &[], &inf, None, AacsVersion::V10)
.expect("vuk derives");
let cps: Vec<u32> = r.unit_keys.iter().map(|(c, _)| *c).collect();
assert_eq!(
cps,
vec![1, 2, 3],
"every CPS unit surfaced, numbered from the inf"
);
for ((_, key), enc) in r.unit_keys.iter().zip(encs.iter()) {
assert_eq!(
*key,
decrypt_unit_key(&vuk.0, enc),
"key = VUK-decrypt of its slot"
);
}
assert_eq!(r.vuk, Some(vuk));
assert!(r.mk.is_none() && r.pk.is_none() && r.dk.is_none());
}
/// A bare UK candidate is terminal — it returns itself keyed by its own idx.
#[test]
fn resolve_candidate_uk_is_itself() {
let uk = UnitKey::new(2, [0x9u8; 16]);
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None, AacsVersion::V10)
.expect("uk is terminal");
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
assert!(r.vuk.is_none() && r.mk.is_none());
}
/// A UK candidate's positional idx is reported as the declared CPS-unit number.
#[test]
fn resolve_candidate_uk_reports_the_declared_cps_unit_number() {
let inf = synth_inf(&[[0x11u8; 16], [0x22u8; 16]]);
let uk = UnitKey::new(1, [0x9u8; 16]);
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &inf, None, AacsVersion::V10)
.expect("terminal");
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
}
/// The declared number comes from the file's own numbering (an HD DVD VTKF with a gap), and
/// falls back to the position when the file does not parse or lacks that slot.
#[test]
fn resolve_candidate_uk_declared_number_follows_the_file_with_positional_fallback() {
let mut vtkf = vec![0u8; 0x80 + 64 * 0x24];
vtkf[..12].copy_from_slice(b"DVD_HD_V_TKF");
// Slots 2 and 5 (1-based) are present; slot 1 is a gap.
for slot in [1usize, 4] {
vtkf[0x80 + slot * 0x24] = 0x80;
}
let at = |idx: u32| {
let uk = UnitKey::new(idx, [0x9u8; 16]);
resolve_candidate(&KeyCandidate::Uk(uk), &[], &vtkf, None, AacsVersion::V10)
.expect("terminal")
.unit_keys[0]
.0
};
assert_eq!(at(0), 2);
assert_eq!(at(1), 5, "the file's number, not idx + 1");
assert_eq!(at(7), 7, "no such slot: the position");
let uk = UnitKey::new(3, [0x9u8; 16]);
let r = resolve_candidate(
&KeyCandidate::Uk(uk),
&[],
&[0u8; 4],
None,
AacsVersion::V10,
)
.unwrap();
assert_eq!(r.unit_keys[0].0, 3, "unparseable file: the position");
}
/// MK/PK/DK paths derive the VUK from a VID; without one, derivation stops.
#[test]
fn resolve_candidate_mk_requires_vid() {
let r = resolve_candidate(
&KeyCandidate::Mk(MediaKey([1u8; 16])),
&[],
&[],
None,
AacsVersion::V10,
);
assert!(r.is_none(), "MK path returns None without a VID");
}
/// A planted Processing Key resolves against a synthetic MKB and drives the
/// FULL chain PK → MK → VUK → UK — proving a PK candidate yields real keys.
#[test]
fn resolve_candidate_pk_drives_full_chain() {
let pk: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
0x00,
];
let mk: [u8; 16] = [
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE,
0xAF,
];
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
let mut mk_raw = mk;
for a in 0..4 {
mk_raw[12 + a] ^= uv[a];
}
let cv = aes_ecb_encrypt(&pk, &mk_raw);
let mut vd = [0x11u8; 16];
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
let mk_dv = aes_ecb_encrypt(&mk, &vd);
// 4-byte record header (type + BE24 total length) + body.
let rec = |t: u8, body: &[u8]| -> Vec<u8> {
let total = 4 + body.len();
let mut r = vec![
t,
((total >> 16) & 0xFF) as u8,
((total >> 8) & 0xFF) as u8,
(total & 0xFF) as u8,
];
r.extend_from_slice(body);
r
};
let mut sd = vec![0u8];
sd.extend_from_slice(&uv);
let mut mkb = Vec::new();
mkb.extend_from_slice(&rec(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
mkb.extend_from_slice(&rec(0x86, &mk_dv));
mkb.extend_from_slice(&rec(0x04, &sd));
mkb.extend_from_slice(&rec(0x05, &cv));
let vid = Vid([0x42u8; 16]);
let plain_uk = [0x7Eu8; 16];
let vuk = derive_vuk(&mk, &vid.0);
let enc = aes_ecb_encrypt(&vuk, &plain_uk);
let inf = synth_inf(std::slice::from_ref(&enc));
let r = resolve_candidate(
&KeyCandidate::Pk(ProcessingKey(pk)),
&mkb,
&inf,
Some(vid),
AacsVersion::V10,
)
.expect("planted PK resolves the full chain");
assert_eq!(r.mk, Some(MediaKey(mk)), "PK recovers the planted MK");
assert_eq!(r.unit_keys.len(), 1);
assert_eq!(
r.unit_keys[0].1, plain_uk,
"PK chain recovers the title key"
);
}
// VUK relation `Kvu = AES-128D(Km, IDv) XOR IDv` ([PR]/[BD] §3.3), anchored
// to the FIPS-197 AES-128 known-answer vector so the test pins BOTH the AES
// primitive and the trailing XOR (a dropped XOR or encrypt-swap changes it).
#[test]
fn derive_vuk_matches_the_fips197_aes_decrypt_xor_relation() {
use crate::aacs::crypto::aes_ecb_decrypt;
// FIPS-197 Appendix example: AES-128D(key, ciphertext) == plaintext.
let key: [u8; 16] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f,
];
let ciphertext: [u8; 16] = [
0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4, 0xc5,
0x5a,
];
let plaintext: [u8; 16] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
0xff,
];
// The known AES-128 decrypt result — pins the primitive derive_vuk builds on.
assert_eq!(
aes_ecb_decrypt(&key, &ciphertext),
plaintext,
"FIPS-197 AES-128 decrypt vector"
);
// Treating `key` as the Media Key and `ciphertext` as the Volume ID, the
// VUK is the known plaintext XOR the Volume ID.
let mut expected = plaintext;
for i in 0..16 {
expected[i] ^= ciphertext[i];
}
assert_eq!(
derive_vuk(&key, &ciphertext),
expected,
"VUK = AES-128D(Km, IDv) XOR IDv against a known AES vector"
);
// And it is genuinely XOR: a Volume ID sharing set bits with the decrypt
// must CLEAR them, which an OR could never do.
let mk = [0xFFu8; 16];
let vid = [0xABu8; 16];
let mut want = aes_ecb_decrypt(&mk, &vid);
for i in 0..16 {
want[i] ^= vid[i];
}
assert_eq!(derive_vuk(&mk, &vid), want, "XOR, not OR, of IDv");
}
/// A single-CPS `Unit_Key_RO.inf` in the real on-disc shape (Bruges/Titanic:
/// one encrypted title key at offset 0x50) parses to exactly one key numbered
/// CPS unit 1, and `derive_unit_keys` unwraps it with the VUK.
#[test]
fn single_cps_inf_parses_one_key_at_offset_0x50_and_boils_it() {
// uk_pos = 0x20; first (only) key at uk_pos + 48 = 0x50.
let mut inf = vec![0u8; 0x50 + 16];
inf[0..4].copy_from_slice(&0x20u32.to_be_bytes());
inf[0x20..0x22].copy_from_slice(&1u16.to_be_bytes()); // num_unit_keys = 1
let enc = [0x9Au8; 16];
inf[0x50..0x60].copy_from_slice(&enc);
let ukf = parse_unit_key_ro(&inf, AacsVersion::V10).expect("valid single-CPS inf");
assert_eq!(
ukf.encrypted_keys,
vec![(1u32, enc)],
"one CPS unit, numbered 1, read from offset 0x50"
);
let vuk = [0x5Cu8; 16];
let boiled = derive_unit_keys(&ukf, &vuk);
assert_eq!(
boiled,
vec![(1u32, decrypt_unit_key(&vuk, &enc))],
"the single unit key is the VUK-decrypt of its slot, keyed by CPS 1"
);
}
/// Build an `n`-key `Unit_Key_RO.inf` at a chosen stride, each slot filled
/// with a distinct byte so a mis-strided read is visible.
fn multi_cps_inf(n: usize, stride: usize) -> (Vec<u8>, Vec<[u8; 16]>) {
let uk_pos = 0x20usize;
let key0 = uk_pos + 48;
let total = key0 + (n.saturating_sub(1)) * stride + 16;
let mut inf = vec![0u8; total];
inf[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
inf[uk_pos..uk_pos + 2].copy_from_slice(&(n as u16).to_be_bytes());
let mut encs = Vec::new();
for i in 0..n {
let k = [(0x10 + i as u8); 16];
let o = key0 + i * stride;
inf[o..o + 16].copy_from_slice(&k);
encs.push(k);
}
(inf, encs)
}
/// A MULTI-CPS inf yields one key per CPS unit, numbered 1..=n in on-disc
/// order, and each boils to the VUK-decrypt of its own slot — the map cannot
/// drift or renumber.
#[test]
fn multi_cps_inf_numbers_each_unit_and_maps_to_its_slot() {
let (inf, encs) = multi_cps_inf(4, 48); // V10 stride
let ukf = parse_unit_key_ro(&inf, AacsVersion::V10).expect("valid multi-CPS inf");
assert_eq!(ukf.encrypted_keys.len(), 4, "one key per CPS unit");
let cps: Vec<u32> = ukf.encrypted_keys.iter().map(|(c, _)| *c).collect();
assert_eq!(cps, vec![1, 2, 3, 4], "CPS units numbered 1..=n in order");
let vuk = [0x33u8; 16];
let boiled = derive_unit_keys(&ukf, &vuk);
assert_eq!(boiled.len(), 4, "every CPS unit boils to a key");
for (i, (num, key)) in boiled.iter().enumerate() {
assert_eq!(*num, (i + 1) as u32, "CPS number is the 1-based slot index");
assert_eq!(
*key,
decrypt_unit_key(&vuk, &encs[i]),
"each unit key is the VUK-decrypt of its own encrypted slot"
);
}
}
/// The stride `parse_unit_key_ro` walks is driven by the AACS generation:
/// V10 reads 48-byte spacing, V20/V21 read 64-byte spacing. Same buffer, two
/// versions, DIFFERENT second key — the V10-vs-2.x distinction the on-disc
/// layout hinges on.
#[test]
fn parse_unit_key_ro_stride_follows_the_aacs_version() {
// Two keys spaced at the V20 (64-byte) stride. Key 0 is shared (both
// strides read +48); key 1 sits at +64, which a V10 parse (+48) misses.
let uk_pos = 0x20usize;
let key0 = uk_pos + 48;
let v10_key1 = key0 + 48;
let v20_key1 = key0 + 64;
let mut inf = vec![0u8; v20_key1 + 16];
inf[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
inf[uk_pos..uk_pos + 2].copy_from_slice(&2u16.to_be_bytes());
inf[key0..key0 + 16].fill(0xA0);
inf[v10_key1..v10_key1 + 16].fill(0x10);
inf[v20_key1..v20_key1 + 16].fill(0x20);
let v10 = parse_unit_key_ro(&inf, AacsVersion::V10).expect("v10");
let v20 = parse_unit_key_ro(&inf, AacsVersion::V20).expect("v20");
let v21 = parse_unit_key_ro(&inf, AacsVersion::V21).expect("v21");
assert_eq!(v10.encrypted_keys[0].1, [0xA0; 16], "key 0 shared");
assert_eq!(v20.encrypted_keys[0].1, [0xA0; 16]);
assert_eq!(v10.encrypted_keys[1].1, [0x10; 16], "V10 reads +48");
assert_eq!(v20.encrypted_keys[1].1, [0x20; 16], "V20 reads +64");
assert_eq!(
v21.encrypted_keys[1].1, [0x20; 16],
"V21 shares the V20 64-byte stride"
);
assert_ne!(v10.encrypted_keys[1].1, v20.encrypted_keys[1].1);
}
/// `resolve_candidate` parses the inf at the version it is given (from the shared
/// `resolve_aacs_version`), not at its own guess from the MKB: an unreadable MKB on a
/// UHD still reads the second key at +64, and a V10 cert outranks a 2.0 MKB.
#[test]
fn resolve_candidate_boils_at_the_resolved_version_stride() {
// A two-key inf whose 2nd key differs by stride (see the parse test).
let uk_pos = 0x20usize;
let key0 = uk_pos + 48;
let v10_key1 = key0 + 48;
let v20_key1 = key0 + 64;
let mut inf = vec![0u8; v20_key1 + 16];
inf[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
inf[uk_pos..uk_pos + 2].copy_from_slice(&2u16.to_be_bytes());
inf[key0..key0 + 16].fill(0xA0);
inf[v10_key1..v10_key1 + 16].fill(0x10);
inf[v20_key1..v20_key1 + 16].fill(0x20);
let vuk = Vuk([0x44u8; 16]);
// A minimal Category-C 2.0 Type-and-Version record → generation V20.
let v20_mkb: [u8; 12] = [
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
];
assert_eq!(
mkb_type(&v20_mkb).map(|t| t.generation()),
Some(AacsVersion::V20),
"fixture check: this MKB declares AACS 2.0"
);
let boil = |mkb: &[u8], version| {
resolve_candidate(&KeyCandidate::Vuk(vuk), mkb, &inf, None, version)
.expect("boils the inf")
.unit_keys[1]
.1
};
let at64 = decrypt_unit_key(&vuk.0, &[0x20; 16]);
let at48 = decrypt_unit_key(&vuk.0, &[0x10; 16]);
// The stride follows the version handed in, never a re-read of the MKB: a UHD whose
// MKB is unreadable (empty) still reads key 2 at +64 ...
let uhd = resolve_aacs_version(None, &[], Some(true));
assert_eq!(boil(&[], uhd), at64);
// ... and when the certificate (V10) disagrees with the MKB (2.0), the cert wins.
let bd = resolve_aacs_version(Some(1), &v20_mkb, Some(true));
assert_eq!(bd, AacsVersion::V10);
assert_eq!(boil(&v20_mkb, bd), at48);
assert_eq!(
boil(&v20_mkb, resolve_aacs_version(None, &v20_mkb, None)),
at64
);
}
/// PIN: a `Mk` candidate is PURE DERIVATION — `resolve_candidate` does NOT
/// validate the Media Key against the MKB. A WRONG MK (one the MKB's verify
/// record would reject) still boils a full set of unit keys, from the VUK
/// that wrong MK produces. This documents the "computed-but-wrong-UK" case:
/// the caller's sample/`is_clean_ts` step, not this function, is the gate.
#[test]
fn resolve_candidate_mk_does_not_validate_the_media_key_against_the_mkb() {
// An MKB carrying a 0x86 verify record built for a DIFFERENT media key,
// so `km_verifies` would reject the candidate below.
let real_km = [0xC3u8; 16];
let mut vd = [0x11u8; 16];
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
let mk_dv = aes_ecb_encrypt(&real_km, &vd);
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
];
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&mk_dv);
// The MK we hand in is NOT the one the verify record was built for.
let wrong_mk = MediaKey([0x77u8; 16]);
assert!(
!probe::km_verifies(&mkb, &wrong_mk.0),
"fixture check: the wrong MK must fail the MKB verify record"
);
let vid = Vid([0x42u8; 16]);
let enc = [0x9Au8; 16];
let inf = synth_inf(std::slice::from_ref(&enc));
let r = resolve_candidate(
&KeyCandidate::Mk(wrong_mk),
&mkb,
&inf,
Some(vid),
AacsVersion::V10,
)
.expect("resolve_candidate boils regardless of MK-vs-MKB validity");
// The chain is derived straight from the (wrong) MK, no gate applied.
let expected_vuk = derive_vuk(&wrong_mk.0, &vid.0);
assert_eq!(
r.vuk,
Some(Vuk(expected_vuk)),
"VUK is derived from the given MK"
);
assert_eq!(
r.mk,
Some(wrong_mk),
"the candidate MK is carried through unchecked"
);
assert_eq!(
r.unit_keys,
vec![(1u32, decrypt_unit_key(&expected_vuk, &enc))],
"unit keys are boiled from the wrong VUK — derivation does not validate"
);
}
-30
View File
@@ -1,30 +0,0 @@
use super::*;
use crate::spec::keys::KS_15_KCU_WRAP;
use crate::test_util::unit_key_ro;
use aes::Aes128;
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
fn aes_e(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] {
let mut b: Array<u8, _> = (*block).into();
Aes128::new(&(*key).into()).encrypt_block(&mut b);
b.into()
}
/// per spec; do not change without a spec citation — KS-15 [BD] §3.9.3: "The CPS Unit
/// Key is encrypted as follows: AES-128E( Kvu, Kcu )", so Kcu = AES-128D(Kvu, stored).
#[test]
fn kcu_is_aes128d_of_encrypted_key_under_kvu() {
assert!(KS_15_KCU_WRAP.text.ends_with("AES-128E( Kvu, Kcu )"));
let kvu: [u8; 16] = core::array::from_fn(|i| 0x30 + i as u8 * 7);
let kcu = [[0x5Au8; 16], core::array::from_fn(|i| i as u8)];
let wrapped = [aes_e(&kvu, &kcu[0]), aes_e(&kvu, &kcu[1])];
let inf = unit_key_ro(AacsVersion::V10, &wrapped, &[1, 2]);
let ukf = parse_unit_key_ro(&inf, AacsVersion::V10).unwrap();
assert_eq!(derive_unit_keys(&ukf, &kvu), vec![(1, kcu[0]), (2, kcu[1])]);
assert_eq!(decrypt_unit_key(&kvu, &wrapped[1]), kcu[1]);
assert_ne!(
derive_unit_keys(&ukf, &kcu[0])[0].1,
kcu[0],
"Kvu is the wrapping key"
);
}
-217
View File
@@ -1,217 +0,0 @@
//! HD DVD pack encryption: `[HD]` = AACS "HD DVD and DVD Pre-recorded Book", Final Rev 0.952.
//!
//! An EVOB is encrypted per 2048-byte pack (`[HD]` §4.3.2): bytes 0..128 are the Unencrypted
//! Portion, bytes 128..2048 the Encrypted Portion, CBC-encrypted under the AACS IV with the
//! Content Key `Kc = AES-G(Kt, Dtk || CPI_lsb96)`. `Dtk` is pack bytes 84..88 (Table 4-7).
//! `Kt` is the Title Key that `TITLE_KEY_PTR` of the EVOBU's CPI names (Table 4-2), and the CPI
//! rides in the GCI packet of the NV_PCK that leads every EVOBU. A pack is encrypted when its
//! `PES_scrambling_control` is `01` (§4.3.2); an HL_PCK is always encrypted while KEY_VF is
//! `01` or `10` (§4.3.2). NV_PCK and ADV_PCK are never encrypted (§4.3.1).
use super::crypto::{aes_cbc_decrypt, aes_g};
use crate::consts::SECTOR_BYTES;
/// One HD DVD pack: a 2048-byte sector.
pub(crate) const PACK_LEN: usize = SECTOR_BYTES;
/// `[HD]` §4.3.2: "the first 128 bytes are called the Unencrypted Portion".
pub(crate) const CLEAR_LEN: usize = 128;
// `[HD]` Table 4-7: Title Key Data (Dtk) is bytes 84..=87 of the pack.
const DTK_OFF: usize = 84;
const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA];
const SYSTEM_HEADER: u8 = 0xBB;
const PRIVATE_STREAM_1: u8 = 0xBD;
const PADDING: u8 = 0xBE;
const PRIVATE_STREAM_2: u8 = 0xBF;
// private_stream_2 sub_stream_ids: the GCI packet of an NV_PCK, and an HL_PCK.
const GCI_SUB_STREAM_ID: u8 = 0x04;
const HLI_SUB_STREAM_ID: u8 = 0x08;
// The CPI is 16 bytes at offset 12 of the GCI data (after its sub_stream_id). The HD DVD-Video
// book that fixes it is not public; measured on encrypted discs (pack offset 60 of an NV_PCK).
const CPI_IN_GCI: usize = 12;
const SCRAMBLE_MASK: u8 = 0x30;
/// The 16-byte Content Protection Information of an EVOBU (`[HD]` §4.2, Tables 4-1 and 4-2).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Cpi(pub(crate) [u8; 16]);
impl Cpi {
/// `KEY_VF` (Table 4-2): `0b10` the Title Key Pointer is valid, `0b01` the Segment Key
/// Pointer, `0b00` neither.
pub(crate) fn key_vf(&self) -> u8 {
self.0[0] >> 6
}
/// `TITLE_KEY_PTR` (Table 4-2): the 1-based Title Key entry of the Title Key File.
pub(crate) fn title_key_ptr(&self) -> u32 {
u32::from(u16::from_be_bytes([self.0[1], self.0[2]]))
}
}
/// What a pack is, as far as decryption is concerned.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PackKind {
/// An NV_PCK, with the CPI of its GCI packet when one parses.
Nav(Option<Cpi>),
/// A PES pack whose `PES_scrambling_control` is not `00`.
Scrambled,
/// An HL_PCK: encrypted whenever its EVOBU's KEY_VF is `01` or `10`.
Highlight,
/// Anything else: never encrypted.
Clear,
}
// Offset of the first packet after an MPEG-2 pack header (with its stuffing), or `None`.
fn first_packet(pack: &[u8]) -> Option<usize> {
if pack.len() < 14 || pack[..4] != PACK_START || pack[4] & 0xC0 != 0x40 {
return None;
}
Some(14 + usize::from(pack[13] & 0x07))
}
// Does stream id `sid` carry the optional PES header (with PES_scrambling_control)?
fn has_pes_flags(sid: u8) -> bool {
sid >= PRIVATE_STREAM_1 && !matches!(sid, PADDING | PRIVATE_STREAM_2)
}
/// Classify one pack. A slice shorter than a pack is judged on the bytes it holds.
pub(crate) fn classify(pack: &[u8]) -> PackKind {
let Some(o) = first_packet(pack) else {
return PackKind::Clear;
};
if pack.len() < o + 7 || pack[o..o + 3] != [0, 0, 1] {
return PackKind::Clear;
}
match pack[o + 3] {
SYSTEM_HEADER => PackKind::Nav(nav_cpi(pack, o)),
PRIVATE_STREAM_2 if pack[o + 6] == HLI_SUB_STREAM_ID => PackKind::Highlight,
sid if has_pes_flags(sid) && pack[o + 6] & 0xC0 == 0x80 => {
match pack[o + 6] & SCRAMBLE_MASK {
0 => PackKind::Clear,
_ => PackKind::Scrambled,
}
}
_ => PackKind::Clear,
}
}
// The CPI of the GCI packet in an NV_PCK whose first packet (the system header) is at `o`.
fn nav_cpi(pack: &[u8], mut q: usize) -> Option<Cpi> {
while q + 7 <= pack.len() && pack[q..q + 3] == [0, 0, 1] {
let len = usize::from(u16::from_be_bytes([pack[q + 4], pack[q + 5]]));
if pack[q + 3] == PRIVATE_STREAM_2 && pack[q + 6] == GCI_SUB_STREAM_ID {
if len < 1 + CPI_IN_GCI + 16 {
return None;
}
let at = q + 7 + CPI_IN_GCI;
return Some(Cpi(pack.get(at..at + 16)?.try_into().ok()?));
}
q += 6 + len;
}
None
}
/// Whether `pack` must be decrypted under an EVOBU whose CPI is `cpi`.
pub(crate) fn needs_key(kind: PackKind, cpi: Option<&Cpi>) -> bool {
match kind {
PackKind::Scrambled => true,
PackKind::Highlight => cpi.is_some_and(|c| matches!(c.key_vf(), 0b01 | 0b10)),
PackKind::Nav(_) | PackKind::Clear => false,
}
}
/// `[HD]` §4.3.2: `Kc = AES-G(Kt, Dtk || CPI_lsb96)`, `Dtk` = pack bytes 84..88.
pub(crate) fn content_key(title_key: &[u8; 16], pack: &[u8], cpi: &Cpi) -> [u8; 16] {
let mut d = [0u8; 16];
d[..4].copy_from_slice(&pack[DTK_OFF..DTK_OFF + 4]);
d[4..].copy_from_slice(&cpi.0[4..]);
aes_g(title_key, &d)
}
/// Decrypt one whole pack in place (`[HD]` §4.3.4 step 3) and mark a PES pack unscrambled.
/// Bytes 0..128 are never touched but for that flag. A short slice is left alone.
pub(crate) fn decrypt_pack(pack: &mut [u8], title_key: &[u8; 16], cpi: &Cpi) {
if pack.len() != PACK_LEN {
return;
}
let kc = content_key(title_key, pack, cpi);
aes_cbc_decrypt(&kc, &mut pack[CLEAR_LEN..]);
clear_scrambling(pack);
}
// `[HD]` §4.3.2: "Otherwise, the PES_scrambling_control shall be 00₂": true of the plaintext.
fn clear_scrambling(pack: &mut [u8]) {
if let Some(o) = first_packet(pack)
&& pack.len() >= o + 7
&& has_pes_flags(pack[o + 3])
&& pack[o + 6] & 0xC0 == 0x80
{
pack[o + 6] &= !SCRAMBLE_MASK;
}
}
/// The exact inverse of [`decrypt_pack`] for test fixtures: sets the scrambling flag of a PES
/// pack, then CBC-encrypts bytes 128..2048 (`[HD]` §4.3.2, `Ce = AES-128CBCE(Kc, C)`).
#[cfg(test)]
pub(crate) fn encrypt_pack(pack: &mut [u8], title_key: &[u8; 16], cpi: &Cpi) {
if let Some(o) = first_packet(pack)
&& has_pes_flags(pack[o + 3])
{
pack[o + 6] = (pack[o + 6] & !SCRAMBLE_MASK) | 0x10;
}
let kc = content_key(title_key, pack, cpi);
super::crypto::aes_cbc_encrypt(&kc, &mut pack[CLEAR_LEN..]);
}
/// Structural proof that a pack's bytes past 128 are plaintext, for key proof and verify.
///
/// `Some(false)` when a test that random bytes pass about once in 2^16 fails: a PES or padding
/// start code where the chain of packet lengths puts one at or past byte 128, or the AC-3 /
/// E-AC-3 sync word where a `private_stream_1` first-access-unit pointer lands at or past it.
/// `Some(true)` when such a test ran and passed, `None` when none applied (most video packs,
/// whose one PES fills the pack).
pub(crate) fn payload_check(pack: &[u8]) -> Option<bool> {
if pack.len() != PACK_LEN {
return None;
}
let mut q = first_packet(pack)?;
let mut tested = false;
while q + 6 <= PACK_LEN {
if pack[q..q + 3] != [0, 0, 1] {
// A broken chain in the clear head proves nothing about the key.
return (q >= CLEAR_LEN).then_some(false).or(tested.then_some(true));
}
tested |= q >= CLEAR_LEN;
let len = usize::from(u16::from_be_bytes([pack[q + 4], pack[q + 5]]));
if pack[q + 3] == PRIVATE_STREAM_1 {
match ac3_sync_check(pack, q, len) {
Some(false) => return Some(false),
Some(true) => tested = true,
None => {}
}
}
q += 6 + len;
}
tested.then_some(true)
}
// The AC-3 / E-AC-3 sync word at the first access unit of a private_stream_1 packet at `q`.
// Sub-stream ids 0x80-0x87 (AC-3) and 0xC0-0xC7 (E-AC-3); the pointer counts from its own
// last byte, as on DVD.
fn ac3_sync_check(pack: &[u8], q: usize, len: usize) -> Option<bool> {
if pack.get(q + 6)? & 0xC0 != 0x80 {
return None;
}
let hd = q + 9 + usize::from(*pack.get(q + 8)?);
let sub = *pack.get(hd)?;
if !matches!(sub, 0x80..=0x87 | 0xC0..=0xC7) || hd + 4 > PACK_LEN {
return None;
}
let ptr = usize::from(u16::from_be_bytes([pack[hd + 2], pack[hd + 3]]));
let at = hd + 3 + ptr;
let end = (q + 6 + len).min(PACK_LEN);
(ptr != 0 && at >= CLEAR_LEN && at + 2 <= end).then(|| pack[at..at + 2] == [0x0B, 0x77])
}
#[cfg(test)]
#[path = "hddvd_tests.rs"]
pub(crate) mod tests;
-176
View File
@@ -1,176 +0,0 @@
use super::*;
// A pack header with no stuffing, SCR and mux rate zero.
fn pack_header() -> Vec<u8> {
let mut v = PACK_START.to_vec();
v.extend_from_slice(&[0x44, 0, 0x04, 0, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8]);
v
}
// A PES packet with an empty optional header, `payload` bytes long.
fn pes(sid: u8, payload: &[u8]) -> Vec<u8> {
let mut v = vec![0, 0, 1, sid];
v.extend_from_slice(&((payload.len() + 3) as u16).to_be_bytes());
v.extend_from_slice(&[0x80, 0x00, 0x00]);
v.extend_from_slice(payload);
v
}
// A padding packet filling the pack to 2048 bytes.
fn pad_to_pack(mut v: Vec<u8>) -> Vec<u8> {
let left = PACK_LEN - v.len() - 6;
v.extend_from_slice(&[0, 0, 1, PADDING]);
v.extend_from_slice(&(left as u16).to_be_bytes());
v.resize(PACK_LEN, 0xFF);
v
}
/// A synthetic CPI: KEY_VF `10`, `TITLE_KEY_PTR` = `ptr`, CH_PTR = `ch`.
pub(crate) fn cpi(ptr: u16, ch: u32) -> Cpi {
let mut c = [0u8; 16];
c[0] = 0x80;
c[1..3].copy_from_slice(&ptr.to_be_bytes());
c[4..8].copy_from_slice(&ch.to_be_bytes());
c[8] = 0x80;
Cpi(c)
}
/// An NV_PCK: system header, then a GCI packet carrying `cpi` at GCI data offset 12.
pub(crate) fn nav_pack(cpi: &Cpi) -> Vec<u8> {
let mut v = pack_header();
v.extend_from_slice(&[0, 0, 1, SYSTEM_HEADER, 0, 21]);
v.extend_from_slice(&[0x80; 21]);
let mut gci = vec![0, 0, 1, PRIVATE_STREAM_2, 0x01, 0x01, GCI_SUB_STREAM_ID];
gci.extend_from_slice(&[0x11; CPI_IN_GCI]);
gci.extend_from_slice(&cpi.0);
gci.resize(6 + 0x101, 0);
v.extend_from_slice(&gci);
pad_to_pack(v)
}
/// A plaintext E-AC-3 audio pack: its first access unit (sync `0B 77`) at byte 400, then
/// padding from byte 1800. `seed` varies the Dtk bytes and the payload.
pub(crate) fn audio_pack(seed: u8) -> Vec<u8> {
let mut v = pack_header();
let hd_len = 1800 - v.len() - 6 - 3;
let mut body: Vec<u8> = (0..hd_len).map(|i| (i as u8).wrapping_mul(seed)).collect();
body[0] = 0xC0;
body[1] = 1;
// The pointer counts from its own last byte (body[3], pack byte 26).
let ptr = 400 - 26;
body[2..4].copy_from_slice(&(ptr as u16).to_be_bytes());
body[400 - 23..400 - 21].copy_from_slice(&[0x0B, 0x77]);
v.extend_from_slice(&pes(PRIVATE_STREAM_1, &body));
pad_to_pack(v)
}
/// A plaintext video pack whose one PES fills the pack: no structural test applies.
pub(crate) fn video_pack(seed: u8) -> Vec<u8> {
let mut v = pack_header();
let n = PACK_LEN - v.len() - 9;
let body: Vec<u8> = (0..n).map(|i| (i as u8) ^ seed).collect();
v.extend_from_slice(&pes(0xE0, &body));
v
}
fn hl_pack() -> Vec<u8> {
let mut v = pack_header();
v.extend_from_slice(&[0, 0, 1, PRIVATE_STREAM_2, 0x07, 0xEC, HLI_SUB_STREAM_ID]);
v.resize(PACK_LEN, 0x5A);
v
}
const KT: [u8; 16] = [0x42; 16];
#[test]
fn nav_pack_yields_its_cpi_and_fields() {
let c = cpi(17, 9);
assert_eq!(classify(&nav_pack(&c)), PackKind::Nav(Some(c)));
assert_eq!(c.key_vf(), 0b10);
assert_eq!(c.title_key_ptr(), 17);
}
#[test]
fn content_key_is_aes_g_of_dtk_and_cpi_lsb96() {
let c = cpi(1, 0x0102_0304);
let pack = audio_pack(3);
let mut d = [0u8; 16];
d[..4].copy_from_slice(&pack[84..88]);
d[4..].copy_from_slice(&c.0[4..16]);
assert_eq!(content_key(&KT, &pack, &c), aes_g(&KT, &d));
// CPI msb bytes (KMI) are not key input; Dtk and CH_PTR are.
let mut other = c;
other.0[3] ^= 0xFF;
assert_eq!(content_key(&KT, &pack, &other), aes_g(&KT, &d));
assert_ne!(content_key(&KT, &pack, &cpi(1, 5)), aes_g(&KT, &d));
}
#[test]
fn encrypt_keeps_the_clear_head_and_decrypt_restores_the_pack() {
let c = cpi(3, 77);
let plain = audio_pack(5);
let mut p = plain.clone();
encrypt_pack(&mut p, &KT, &c);
assert_eq!(classify(&p), PackKind::Scrambled);
assert_eq!(p[..20], plain[..20]);
assert_eq!(p[21..CLEAR_LEN], plain[21..CLEAR_LEN]);
assert_ne!(p[CLEAR_LEN..], plain[CLEAR_LEN..]);
assert_eq!(p[..4], PACK_START, "an encrypted pack keeps its start code");
decrypt_pack(&mut p, &KT, &c);
assert_eq!(p, plain);
assert_eq!(classify(&p), PackKind::Clear);
}
#[test]
fn a_shifted_dtk_or_wrong_key_does_not_open_the_pack() {
let c = cpi(3, 77);
let mut p = audio_pack(5);
encrypt_pack(&mut p, &KT, &c);
let mut wrong = p.clone();
decrypt_pack(&mut wrong, &[0x43; 16], &c);
assert_eq!(payload_check(&wrong), Some(false));
// Dtk one byte early (83..87) is the wrong key input.
let mut shifted = p.clone();
let mut d = [0u8; 16];
d[..4].copy_from_slice(&shifted[83..87]);
d[4..].copy_from_slice(&c.0[4..]);
aes_cbc_decrypt(&aes_g(&KT, &d), &mut shifted[CLEAR_LEN..]);
assert_eq!(payload_check(&shifted), Some(false));
decrypt_pack(&mut p, &KT, &c);
assert_eq!(payload_check(&p), Some(true));
}
#[test]
fn payload_check_judges_only_bytes_past_the_clear_head() {
assert_eq!(payload_check(&audio_pack(9)), Some(true));
assert_eq!(payload_check(&video_pack(9)), None);
let mut bad = audio_pack(9);
bad[400] ^= 1;
assert_eq!(payload_check(&bad), Some(false));
let mut bad_pad = audio_pack(9);
bad_pad[1800] = 0x47;
assert_eq!(payload_check(&bad_pad), Some(false));
}
#[test]
fn scrambling_is_read_per_pack_from_the_pes_header() {
let mut v = video_pack(1);
assert_eq!(classify(&v), PackKind::Clear);
v[20] |= 0x10;
assert_eq!(classify(&v), PackKind::Scrambled);
// A system header's byte 20 is rate_bound, never a scrambling flag.
let mut nav = nav_pack(&cpi(1, 1));
nav[20] = 0xB2;
assert!(matches!(classify(&nav), PackKind::Nav(_)));
}
#[test]
fn highlight_packs_need_a_key_only_under_a_valid_key_pointer() {
let hl = hl_pack();
assert_eq!(classify(&hl), PackKind::Highlight);
assert!(needs_key(classify(&hl), Some(&cpi(1, 1))));
let mut none = cpi(1, 1);
none.0[0] = 0;
assert!(!needs_key(classify(&hl), Some(&none)));
assert!(!needs_key(classify(&hl), None));
}
+115 -2
View File
@@ -71,5 +71,118 @@ pub fn unit_disposition(
}
#[cfg(test)]
#[path = "index_select_tests.rs"]
mod tests;
mod tests {
use super::*;
use crate::aacs::content::ALIGNED_UNIT_LEN;
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
/// Build a one-record segment table (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());
v.extend_from_slice(&(recs.len() as u16).to_be_bytes());
v.extend_from_slice(&16u16.to_be_bytes());
for &(n, s, e) in recs {
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
v.extend_from_slice(&n.to_be_bytes());
v.extend_from_slice(&1u16.to_be_bytes());
v.extend_from_slice(&s.to_be_bytes());
v.extend_from_slice(&e.to_be_bytes());
}
parse_individual_segments(&v).expect("parse")
}
fn uk(idx: u32, index: u8) -> UnitKey {
if index == 0 {
UnitKey::new(idx, [0u8; 16])
} else {
UnitKey::forensic(idx, [index; 16], index)
}
}
#[test]
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]
fn unit_outside_segments_is_default() {
let segs = tbl(&[(1, 343680, 346239)]);
let off = 1000u64 * SOURCE_PACKET_LEN; // well before the segment
assert_eq!(
unit_disposition(off, &segs, Some(1)),
UnitDisposition::Default
);
// With no segments at all (1.0 / 2.0), everything is Default.
assert_eq!(
unit_disposition(off, &[], Some(1)),
UnitDisposition::Default
);
}
#[test]
fn unit_in_our_index_decrypts() {
let segs = tbl(&[(7, 100, 200)]);
let off = 120u64 * SOURCE_PACKET_LEN;
assert_eq!(
unit_disposition(off, &segs, Some(7)),
UnitDisposition::Index(7)
);
}
#[test]
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::DropForeignIndex(7)
);
}
#[test]
fn forensic_unit_with_no_key_is_concealed() {
// 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!(
unit_disposition(off, &segs, None),
UnitDisposition::ForensicNoKey(7)
);
}
#[test]
fn out_of_range_index_does_not_truncate_into_ours() {
// Index 288 (0x0120) truncates to 32 in a u8, which would alias our
// resolved index 32 under the old `as u8` compare; u16 compare must
// classify it as foreign instead.
let segs = tbl(&[(288, 100, 200)]);
let off = 120u64 * SOURCE_PACKET_LEN;
assert_eq!(
unit_disposition(off, &segs, Some(32)),
UnitDisposition::DropForeignIndex(255)
);
}
#[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 segment_for_unit's span test).
let segs = tbl(&[(5, 100, 200)]);
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
// Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100.
let off = 80u64 * SOURCE_PACKET_LEN;
assert!(80 + unit_packets > 100, "sanity: unit tails into seg");
assert_eq!(
unit_disposition(off, &segs, Some(5)),
UnitDisposition::Index(5)
);
}
}
-113
View File
@@ -1,113 +0,0 @@
use super::*;
use crate::aacs::content::ALIGNED_UNIT_LEN;
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
/// Build a one-record segment table (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());
v.extend_from_slice(&(recs.len() as u16).to_be_bytes());
v.extend_from_slice(&16u16.to_be_bytes());
for &(n, s, e) in recs {
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
v.extend_from_slice(&n.to_be_bytes());
v.extend_from_slice(&1u16.to_be_bytes());
v.extend_from_slice(&s.to_be_bytes());
v.extend_from_slice(&e.to_be_bytes());
}
parse_individual_segments(&v).expect("parse")
}
fn uk(idx: u32, index: u8) -> UnitKey {
if index == 0 {
UnitKey::new(idx, [0u8; 16])
} else {
UnitKey::forensic(idx, [index; 16], index)
}
}
#[test]
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]
fn unit_outside_segments_is_default() {
let segs = tbl(&[(1, 343680, 346239)]);
let off = 1000u64 * SOURCE_PACKET_LEN; // well before the segment
assert_eq!(
unit_disposition(off, &segs, Some(1)),
UnitDisposition::Default
);
// With no segments at all (1.0 / 2.0), everything is Default.
assert_eq!(
unit_disposition(off, &[], Some(1)),
UnitDisposition::Default
);
}
#[test]
fn unit_in_our_index_decrypts() {
let segs = tbl(&[(7, 100, 200)]);
let off = 120u64 * SOURCE_PACKET_LEN;
assert_eq!(
unit_disposition(off, &segs, Some(7)),
UnitDisposition::Index(7)
);
}
#[test]
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::DropForeignIndex(7)
);
}
#[test]
fn forensic_unit_with_no_key_is_concealed() {
// 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!(
unit_disposition(off, &segs, None),
UnitDisposition::ForensicNoKey(7)
);
}
#[test]
fn out_of_range_index_does_not_truncate_into_ours() {
// Index 288 (0x0120) truncates to 32 in a u8, which would alias our
// resolved index 32 under the old `as u8` compare; u16 compare must
// classify it as foreign instead.
let segs = tbl(&[(288, 100, 200)]);
let off = 120u64 * SOURCE_PACKET_LEN;
assert_eq!(
unit_disposition(off, &segs, Some(32)),
UnitDisposition::DropForeignIndex(255)
);
}
#[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 segment_for_unit's span test).
let segs = tbl(&[(5, 100, 200)]);
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
// Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100.
let off = 80u64 * SOURCE_PACKET_LEN;
assert!(80 + unit_packets > 100, "sanity: unit tails into seg");
assert_eq!(
unit_disposition(off, &segs, Some(5)),
UnitDisposition::Index(5)
);
}
+641 -8
View File
@@ -396,17 +396,650 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
}
#[cfg(test)]
#[path = "inf_vtkf_tests.rs"]
mod vtkf_tests;
mod vtkf_tests {
use super::*;
// Synthesize a `VTKF%%%.AACS`: magic, BE32 size, playlist name, reserved to 0x80, 64 entry
// slots (first `keys.len()` present), MAC trailer.
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(&(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)
}
}
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_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 — empty slots and the trailing 16-byte
// TKF MAC are not mistaken for keys. k2/k3 are read at the 36-byte stride
// (0xA4, 0xC8); the old 32-byte stride misread them from the prior 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");
// disc_hash is SHA1 of the whole file (the KEYDB lookup key).
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_a_file_cut_inside_the_entry_table() {
let full = synth_vtkf(&[[0x11u8; 16], [0x22u8; 16]]);
let cut = VTKF_HEADER_LEN + 10 * VTKF_ENTRY_LEN;
assert!(parse_vtkf(&full[..cut]).is_none());
}
#[test]
fn parse_vtkf_rejects_non_magic() {
let mut data = synth_vtkf(&[[0x11u8; 16]]);
data[0] = b'X'; // corrupt magic
assert!(
parse_vtkf(&data).is_none(),
"non-VTKF magic must be rejected"
);
assert!(
parse_vtkf(&[0u8; 4]).is_none(),
"too short must be rejected"
);
}
#[test]
fn parse_title_keys_dispatches_by_magic() {
// VTKF magic → parse_vtkf.
let data = synth_vtkf(&[[0x44u8; 16], [0x55u8; 16]]);
let ukf = parse_title_keys(&data, AacsVersion::V10).expect("VTKF dispatch");
assert_eq!(ukf.encrypted_keys.len(), 2);
// Non-VTKF → parse_unit_key_ro (a 2-byte buffer is not a valid inf, so
// this proves it ROUTED to the BD parser rather than parse_vtkf).
assert!(
parse_title_keys(&[0x00, 0x00], AacsVersion::V10).is_none(),
"non-magic input must route to parse_unit_key_ro"
);
}
/// The whole point of the seam: a parsed VTKF feeds the SHARED VUK→title-key
/// crypto (`decrypt_unit_key`) exactly like a BD `Unit_Key_RO.inf` would —
/// no HD-DVD-specific crypto path.
#[test]
fn vtkf_encrypted_keys_feed_shared_vuk_unwrap() {
let enc = [0x9Au8; 16];
let data = synth_vtkf(&[enc]);
let ukf = parse_vtkf(&data).unwrap();
let vuk = [0x5Cu8; 16];
let derived = super::super::derive::decrypt_unit_key(&vuk, &ukf.encrypted_keys[0].1);
// Same as applying the shared unwrap directly to the stored enc key.
assert_eq!(derived, super::super::derive::decrypt_unit_key(&vuk, &enc));
}
// Sentinel byte 0xD5 = decimal 213: a derived Debug would render it in decimal.
#[test]
fn unit_key_file_debug_is_redacted() {
let f = UnitKeyFile {
disc_hash: [0xD5; 20],
app_type: 1,
num_bdmv_dir: 1,
use_skb_mkb: false,
version: AacsVersion::V20,
encrypted_keys: vec![(0, [0xD5; 16]), (1, [0xD5; 16])],
title_cps_unit: vec![0, 1],
};
let dbg = format!("{f:?}");
assert!(
!dbg.contains("213"),
"UnitKeyFile Debug leaked key bytes (decimal 213): {dbg}"
);
assert!(
dbg.contains("redacted"),
"UnitKeyFile Debug missing redaction marker: {dbg}"
);
// Non-secret shape is still useful for diagnostics.
assert!(dbg.contains("encrypted_keys_len: 2"), "{dbg}");
}
}
#[cfg(test)]
#[path = "inf_read_mkb_tests.rs"]
mod read_mkb_tests;
mod read_mkb_tests {
use super::*;
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE, ScsiResult, ScsiTransport};
/// A drive that answers READ DISC STRUCTURE format 0x83 from a scripted set
/// of packs and records every CDB it was handed.
struct MkbDrive {
/// One entry per pack: the pack's MKB payload bytes.
packs: Vec<Vec<u8>>,
cdbs: Vec<Vec<u8>>,
}
impl ScsiTransport for MkbDrive {
fn execute(
&mut self,
cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
self.cdbs.push(cdb.to_vec());
// Pack number is carried in the CDB address field (bytes 2..6),
// MMC-6 READ DISC STRUCTURE.
let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]) as usize;
let body = self.packs.get(pack).cloned().unwrap_or_default();
// Header: BE16 data length (counts the 2 header bytes that follow
// it plus the payload), reserved byte, pack count, then payload.
let data_len = body.len() + 2;
data[0..2].copy_from_slice(&(data_len as u16).to_be_bytes());
data[2] = 0x00;
data[3] = self.packs.len() as u8;
data[4..4 + body.len()].copy_from_slice(&body);
Ok(ScsiResult {
status: 0,
bytes_transferred: 4 + body.len(),
sense: [0u8; 32],
})
}
}
// Pins the CONTENT: concatenated pack payloads, in pack order, byte for byte.
#[test]
fn read_mkb_from_drive_returns_the_concatenated_pack_payload() {
let pack0: Vec<u8> = (0..600u32).map(|i| (i % 251) as u8).collect();
let pack1: Vec<u8> = (0..300u32).map(|i| (i % 253) as u8 ^ 0xA5).collect();
let mut drive = MkbDrive {
packs: vec![pack0.clone(), pack1.clone()],
cdbs: Vec::new(),
};
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
let mut expected = pack0.clone();
expected.extend_from_slice(&pack1);
assert_eq!(
mkb.len(),
expected.len(),
"every pack's payload must be concatenated, none dropped"
);
assert!(
mkb == expected,
"MKB bytes must be the drive's payload in pack order; first \
mismatch at {:?}",
(0..expected.len()).find(|&i| mkb[i] != expected[i])
);
// MMC-6 READ DISC STRUCTURE with the AACS MKB format code, one command
// per pack, pack number in the address field.
assert_eq!(drive.cdbs.len(), 2, "one command per declared pack");
for (i, cdb) in drive.cdbs.iter().enumerate() {
assert_eq!(cdb[0], SCSI_READ_DISC_STRUCTURE, "opcode");
assert_eq!(cdb[7], 0x83, "AACS MKB disc-structure format code");
assert_eq!(
u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]),
i as u32,
"pack {i} must be requested by number"
);
}
}
// Pins the WHOLE 12-byte CDB (MMC-6 READ DISC STRUCTURE, AACS MKB format) so no field can
// drift.
#[test]
fn read_mkb_from_drive_issues_the_exact_mmc_cdb_for_each_pack() {
let mut drive = MkbDrive {
packs: vec![vec![0x11u8; 64], vec![0x22u8; 64], vec![0x33u8; 64]],
cdbs: Vec::new(),
};
read_mkb_from_drive(&mut drive).expect("scripted drive answers");
assert_eq!(drive.cdbs.len(), 3, "one command per declared pack");
for (pack, cdb) in drive.cdbs.iter().enumerate() {
let p = pack as u32;
let expected: [u8; 12] = [
SCSI_READ_DISC_STRUCTURE,
0x01,
(p >> 24) as u8,
(p >> 16) as u8,
(p >> 8) as u8,
p as u8,
0x00,
0x83, // AACS MKB disc-structure format
0x80, // allocation length 32772 = 0x8004, high byte
0x04, // …low byte
0x00,
0x00,
];
assert_eq!(
cdb.as_slice(),
&expected[..],
"CDB for pack {pack} must match the MMC-6 READ DISC STRUCTURE layout"
);
}
}
// A full 32768-byte pack must come back whole; small payloads elsewhere never exercise this
// bound.
#[test]
fn read_mkb_from_drive_accepts_a_full_size_pack() {
let full: Vec<u8> = (0..32768u32).map(|i| (i % 251) as u8).collect();
let other: Vec<u8> = (0..32768u32).map(|i| (i % 241) as u8 ^ 0x5A).collect();
// TWO maximal packs: the first-pack read and the per-pack loop carry
// separate bounds, so both must accept a full-window payload.
let mut drive = MkbDrive {
packs: vec![full.clone(), other.clone()],
cdbs: Vec::new(),
};
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
assert_eq!(
mkb.len(),
65536,
"neither maximal pack may be dropped at the size bound"
);
let mut expected = full.clone();
expected.extend_from_slice(&other);
assert!(mkb == expected, "both maximal packs' bytes must be intact");
}
/// A header-only pack inside a multi-pack MKB is a hole: fail rather than return a
/// silently shortened MKB. A single-pack header-only response stays an empty MKB.
#[test]
fn read_mkb_from_drive_zero_length_pack_contributes_nothing() {
let mut drive = MkbDrive {
packs: vec![Vec::new(), vec![0xABu8; 32]],
cdbs: Vec::new(),
};
assert!(matches!(
read_mkb_from_drive(&mut drive),
Err(crate::error::Error::AacsKeyRead)
));
let mut drive = MkbDrive {
packs: vec![vec![0xABu8; 32], Vec::new()],
cdbs: Vec::new(),
};
assert!(matches!(
read_mkb_from_drive(&mut drive),
Err(crate::error::Error::AacsKeyRead)
));
let mut drive = MkbDrive {
packs: vec![Vec::new()],
cdbs: Vec::new(),
};
assert_eq!(
read_mkb_from_drive(&mut drive).expect("single pack"),
Vec::<u8>::new()
);
}
// A drive-declared length past the 32772-byte buffer is a drive fault: the MKB
// would have a hole, so fail rather than return a corrupt MKB as Ok.
#[test]
fn read_mkb_from_drive_rejects_a_pack_declaring_more_than_the_buffer_holds() {
/// Pack 0 is honest; pack 1 declares a 60000-byte payload it never sent.
struct LyingDrive {
honest: Vec<u8>,
}
impl ScsiTransport for LyingDrive {
fn execute(
&mut self,
cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]);
data[3] = 2; // two packs declared
if pack == 0 {
let dl = self.honest.len() + 2;
data[0..2].copy_from_slice(&(dl as u16).to_be_bytes());
data[4..4 + self.honest.len()].copy_from_slice(&self.honest);
} else {
// A length far beyond the 32772-byte response buffer.
data[0..2].copy_from_slice(&60_000u16.to_be_bytes());
}
Ok(ScsiResult {
status: 0,
bytes_transferred: 4,
sense: [0u8; 32],
})
}
}
let mut drive = LyingDrive {
honest: vec![0xC7u8; 256],
};
let err = read_mkb_from_drive(&mut drive).expect_err("a holed MKB is not Ok");
assert_eq!(err.code(), crate::error::Error::AacsKeyRead.code());
}
/// The same over-declaration on the FIRST pack, which uses a separate bound
/// from the loop's.
#[test]
fn read_mkb_from_drive_rejects_a_first_pack_declaring_more_than_the_buffer() {
struct LyingFirst;
impl ScsiTransport for LyingFirst {
fn execute(
&mut self,
_cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
data[0..2].copy_from_slice(&60_000u16.to_be_bytes());
data[3] = 1;
Ok(ScsiResult {
status: 0,
bytes_transferred: 4,
sense: [0u8; 32],
})
}
}
let err = read_mkb_from_drive(&mut LyingFirst).expect_err("over-declared first pack");
assert_eq!(err.code(), crate::error::Error::AacsKeyRead.code());
}
/// A drive declaring a payload but transferring fewer bytes (zero-filled tail) is a
/// fault; so is a header-only pack after a real first pack (a hole).
#[test]
fn read_mkb_from_drive_rejects_short_transfer_and_holed_pack() {
struct Scripted {
declared: [u16; 2],
xfer: usize,
}
impl ScsiTransport for Scripted {
fn execute(
&mut self,
cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
let pack = cdb[5] as usize;
data[0..2].copy_from_slice(&self.declared[pack].to_be_bytes());
data[3] = 2;
Ok(ScsiResult {
status: 0,
bytes_transferred: if pack == 0 { self.xfer } else { 4 },
sense: [0u8; 32],
})
}
}
let short = read_mkb_from_drive(&mut Scripted {
declared: [102, 2],
xfer: 4,
})
.expect_err("short transfer");
assert_eq!(short.code(), crate::error::Error::AacsKeyRead.code());
let hole = read_mkb_from_drive(&mut Scripted {
declared: [102, 0],
xfer: 104,
})
.expect_err("holed pack");
assert_eq!(hole.code(), crate::error::Error::AacsKeyRead.code());
}
/// A single-pack disc still yields that pack's bytes — the common case, and
/// the one where a body returning an empty vector looks most plausible.
#[test]
fn read_mkb_from_drive_returns_a_single_packs_payload() {
let pack: Vec<u8> = (0..1024u32).map(|i| (i * 7 % 256) as u8).collect();
let mut drive = MkbDrive {
packs: vec![pack.clone()],
cdbs: Vec::new(),
};
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
assert_eq!(mkb.len(), pack.len(), "single pack payload length");
assert!(mkb == pack, "single pack payload bytes");
}
/// A drive that reports a header-only response (`data_len < 2`) has no MKB
/// to give. That must be an EMPTY vec, not a partial one — the distinction
/// matters because the AACS paths treat a non-empty MKB as parseable.
#[test]
fn read_mkb_from_drive_empty_response_is_empty() {
struct NoMkb;
impl ScsiTransport for NoMkb {
fn execute(
&mut self,
_cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
data[0..2].copy_from_slice(&0u16.to_be_bytes());
Ok(ScsiResult {
status: 0,
bytes_transferred: 4,
sense: [0u8; 32],
})
}
}
let mkb = read_mkb_from_drive(&mut NoMkb).expect("no-MKB drive still returns Ok");
assert!(
mkb.is_empty(),
"a header-only response carries no MKB bytes"
);
}
/// A header the drive never transferred, or a zero-length first pack of a multi-pack MKB,
/// is a drive fault: `AacsKeyRead`, not an empty MKB.
#[test]
fn read_mkb_from_drive_untransferred_header_and_holed_first_pack_fail() {
struct Scripted {
xfer: usize,
num_packs: u8,
}
impl ScsiTransport for Scripted {
fn execute(
&mut self,
_cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
data[0..2].copy_from_slice(&0u16.to_be_bytes());
data[3] = self.num_packs;
Ok(ScsiResult {
status: 0,
bytes_transferred: self.xfer,
sense: [0u8; 32],
})
}
}
for (xfer, num_packs) in [(0, 0), (4, 2)] {
assert!(
matches!(
read_mkb_from_drive(&mut Scripted { xfer, num_packs }),
Err(crate::error::Error::AacsKeyRead)
),
"xfer {xfer}, packs {num_packs}"
);
}
}
/// A transport failure on the FIRST pack must propagate as an error — the
/// MKB is the root of the whole AACS ladder, so an unreadable one cannot be
/// downgraded to "an MKB with no records".
#[test]
fn read_mkb_from_drive_propagates_the_first_pack_failure() {
struct DeadDrive;
impl ScsiTransport for DeadDrive {
fn execute(
&mut self,
_cdb: &[u8],
_direction: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
Err(crate::error::Error::ScsiError {
opcode: SCSI_READ_DISC_STRUCTURE,
status: 0x02,
sense: None,
})
}
}
assert!(
read_mkb_from_drive(&mut DeadDrive).is_err(),
"an unreadable MKB must surface as an error, not an empty MKB"
);
}
/// A transport failure on a NON-first pack must ALSO propagate. Before the
/// fix the per-pack loop tested `.is_ok()` and dropped the error, so a
/// mid-walk failure silently TRUNCATED the MKB and returned the partial
/// pack-0 data as Ok. Pack 0 succeeds, pack 1 errors → the whole read errors.
#[test]
fn read_mkb_from_drive_propagates_a_mid_walk_pack_failure() {
struct FlakyDrive;
impl ScsiTransport for FlakyDrive {
fn execute(
&mut self,
cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]);
if pack == 0 {
// Honest first pack: 64 payload bytes, TWO packs declared so
// the loop goes on to request pack 1.
let body = [0x5Au8; 64];
data[0..2].copy_from_slice(&((body.len() + 2) as u16).to_be_bytes());
data[2] = 0x00;
data[3] = 2;
data[4..4 + body.len()].copy_from_slice(&body);
Ok(ScsiResult {
status: 0,
bytes_transferred: 4 + body.len(),
sense: [0u8; 32],
})
} else {
// Pack 1 read fails mid-walk.
Err(crate::error::Error::ScsiError {
opcode: SCSI_READ_DISC_STRUCTURE,
status: 0x02,
sense: None,
})
}
}
}
assert!(
read_mkb_from_drive(&mut FlakyDrive).is_err(),
"a transport error on pack 1 must surface, not truncate the MKB to pack 0"
);
}
}
#[cfg(test)]
#[path = "inf_unit_key_ro_tests.rs"]
mod unit_key_ro_tests;
mod unit_key_ro_tests {
use super::*;
/// Finding 17: a Unit_Key_RO.inf that parses its key list fully but declares
/// more title entries than the buffer can hold is TRUNCATED and must be
/// rejected. Pre-fix the loop silently skipped the missing entries and
/// returned `Some` with a short title→CPS map (mirrors the key-list check).
#[test]
fn parse_unit_key_ro_rejects_truncated_title_table() {
let uk_pos = 26usize;
let mut data = vec![0u8; 90];
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes()); // key storage @26
data[24..26].copy_from_slice(&20u16.to_be_bytes()); // num_titles=20 (needs bytes to 106)
data[uk_pos..uk_pos + 2].copy_from_slice(&1u16.to_be_bytes()); // num_uk=1
// One present key at uk_pos+48 = 74..90 (V10 48-byte stride) so the key
// list parses fully; only the title table overruns the 90-byte buffer.
data[74..90].copy_from_slice(&[0xAB; 16]);
assert!(
parse_unit_key_ro(&data, AacsVersion::V10).is_none(),
"a title table that runs past the buffer is truncated → reject"
);
}
/// Fixture guard for the test above: the SAME buffer with an honest title
/// count parses to `Some`, proving it is only the truncation that is rejected
/// (not some unrelated malformation).
#[test]
fn parse_unit_key_ro_accepts_the_same_buffer_with_an_honest_title_count() {
let uk_pos = 26usize;
let mut data = vec![0u8; 90];
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
data[24..26].copy_from_slice(&15u16.to_be_bytes()); // 15 titles: last entry ends at 88 <= 90
data[uk_pos..uk_pos + 2].copy_from_slice(&1u16.to_be_bytes());
data[74..90].copy_from_slice(&[0xAB; 16]);
let ukf = parse_unit_key_ro(&data, AacsVersion::V10).expect("fits → Some");
assert_eq!(ukf.encrypted_keys.len(), 1);
assert_eq!(ukf.title_cps_unit.len(), 2 + 15); // first_play + top_menu + 15 titles
}
/// Finding 15: a malformed key-storage offset pointing far past the buffer
/// must be rejected, never indexed. The `checked_add` guards also stop the
/// offset arithmetic (`uk_pos + 2`, `uk_pos + 48`) from overflowing `usize`
/// on 32-bit targets rather than panicking.
#[test]
fn parse_unit_key_ro_rejects_out_of_range_key_offset() {
let mut data = vec![0u8; 32];
data[0..4].copy_from_slice(&u32::MAX.to_be_bytes()); // uk_pos = 0xFFFFFFFF
assert!(
parse_unit_key_ro(&data, AacsVersion::V10).is_none(),
"a key-storage offset past the buffer must not be indexed"
);
}
}
#[cfg(test)]
#[path = "inf_content_cert_tests.rs"]
mod content_cert_tests;
mod content_cert_tests {
use super::*;
#[test]
fn parse_content_cert_reads_cc_id_from_bytes_14_to_20() {
let mut data = vec![0u8; 20];
data[1] = 0x80;
for (i, b) in data[14..20].iter_mut().enumerate() {
*b = 0xA0 + i as u8;
}
data[13] = 0xEE;
let cert = parse_content_cert(&data).expect("20-byte cert parses");
assert_eq!(cert.cc_id, [0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5]);
assert!(cert.bus_encryption);
assert!(
parse_content_cert(&data[..19]).is_none(),
"19 bytes is short"
);
}
}
-18
View File
@@ -1,18 +0,0 @@
use super::*;
#[test]
fn parse_content_cert_reads_cc_id_from_bytes_14_to_20() {
let mut data = vec![0u8; 20];
data[1] = 0x80;
for (i, b) in data[14..20].iter_mut().enumerate() {
*b = 0xA0 + i as u8;
}
data[13] = 0xEE;
let cert = parse_content_cert(&data).expect("20-byte cert parses");
assert_eq!(cert.cc_id, [0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5]);
assert!(cert.bus_encryption);
assert!(
parse_content_cert(&data[..19]).is_none(),
"19 bytes is short"
);
}
-424
View File
@@ -1,424 +0,0 @@
use super::*;
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE, ScsiResult, ScsiTransport};
/// A drive that answers READ DISC STRUCTURE format 0x83 from a scripted set
/// of packs and records every CDB it was handed.
struct MkbDrive {
/// One entry per pack: the pack's MKB payload bytes.
packs: Vec<Vec<u8>>,
cdbs: Vec<Vec<u8>>,
}
impl ScsiTransport for MkbDrive {
fn execute(
&mut self,
cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
self.cdbs.push(cdb.to_vec());
// Pack number is carried in the CDB address field (bytes 2..6),
// MMC-6 READ DISC STRUCTURE.
let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]) as usize;
let body = self.packs.get(pack).cloned().unwrap_or_default();
// Header: BE16 data length (counts the 2 header bytes that follow
// it plus the payload), reserved byte, pack count, then payload.
let data_len = body.len() + 2;
data[0..2].copy_from_slice(&(data_len as u16).to_be_bytes());
data[2] = 0x00;
data[3] = self.packs.len() as u8;
data[4..4 + body.len()].copy_from_slice(&body);
Ok(ScsiResult {
status: 0,
bytes_transferred: 4 + body.len(),
sense: [0u8; 32],
})
}
}
// Pins the CONTENT: concatenated pack payloads, in pack order, byte for byte.
#[test]
fn read_mkb_from_drive_returns_the_concatenated_pack_payload() {
let pack0: Vec<u8> = (0..600u32).map(|i| (i % 251) as u8).collect();
let pack1: Vec<u8> = (0..300u32).map(|i| (i % 253) as u8 ^ 0xA5).collect();
let mut drive = MkbDrive {
packs: vec![pack0.clone(), pack1.clone()],
cdbs: Vec::new(),
};
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
let mut expected = pack0.clone();
expected.extend_from_slice(&pack1);
assert_eq!(
mkb.len(),
expected.len(),
"every pack's payload must be concatenated, none dropped"
);
assert!(
mkb == expected,
"MKB bytes must be the drive's payload in pack order; first \
mismatch at {:?}",
(0..expected.len()).find(|&i| mkb[i] != expected[i])
);
// MMC-6 READ DISC STRUCTURE with the AACS MKB format code, one command
// per pack, pack number in the address field.
assert_eq!(drive.cdbs.len(), 2, "one command per declared pack");
for (i, cdb) in drive.cdbs.iter().enumerate() {
assert_eq!(cdb[0], SCSI_READ_DISC_STRUCTURE, "opcode");
assert_eq!(cdb[7], 0x83, "AACS MKB disc-structure format code");
assert_eq!(
u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]),
i as u32,
"pack {i} must be requested by number"
);
}
}
// Pins the WHOLE 12-byte CDB (MMC-6 READ DISC STRUCTURE, AACS MKB format) so no field can
// drift.
#[test]
fn read_mkb_from_drive_issues_the_exact_mmc_cdb_for_each_pack() {
let mut drive = MkbDrive {
packs: vec![vec![0x11u8; 64], vec![0x22u8; 64], vec![0x33u8; 64]],
cdbs: Vec::new(),
};
read_mkb_from_drive(&mut drive).expect("scripted drive answers");
assert_eq!(drive.cdbs.len(), 3, "one command per declared pack");
for (pack, cdb) in drive.cdbs.iter().enumerate() {
let p = pack as u32;
let expected: [u8; 12] = [
SCSI_READ_DISC_STRUCTURE,
0x01,
(p >> 24) as u8,
(p >> 16) as u8,
(p >> 8) as u8,
p as u8,
0x00,
0x83, // AACS MKB disc-structure format
0x80, // allocation length 32772 = 0x8004, high byte
0x04, // …low byte
0x00,
0x00,
];
assert_eq!(
cdb.as_slice(),
&expected[..],
"CDB for pack {pack} must match the MMC-6 READ DISC STRUCTURE layout"
);
}
}
// A full 32768-byte pack must come back whole; small payloads elsewhere never exercise this
// bound.
#[test]
fn read_mkb_from_drive_accepts_a_full_size_pack() {
let full: Vec<u8> = (0..32768u32).map(|i| (i % 251) as u8).collect();
let other: Vec<u8> = (0..32768u32).map(|i| (i % 241) as u8 ^ 0x5A).collect();
// TWO maximal packs: the first-pack read and the per-pack loop carry
// separate bounds, so both must accept a full-window payload.
let mut drive = MkbDrive {
packs: vec![full.clone(), other.clone()],
cdbs: Vec::new(),
};
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
assert_eq!(
mkb.len(),
65536,
"neither maximal pack may be dropped at the size bound"
);
let mut expected = full.clone();
expected.extend_from_slice(&other);
assert!(mkb == expected, "both maximal packs' bytes must be intact");
}
/// A header-only pack inside a multi-pack MKB is a hole: fail rather than return a
/// silently shortened MKB. A single-pack header-only response stays an empty MKB.
#[test]
fn read_mkb_from_drive_zero_length_pack_contributes_nothing() {
let mut drive = MkbDrive {
packs: vec![Vec::new(), vec![0xABu8; 32]],
cdbs: Vec::new(),
};
assert!(matches!(
read_mkb_from_drive(&mut drive),
Err(crate::error::Error::AacsKeyRead)
));
let mut drive = MkbDrive {
packs: vec![vec![0xABu8; 32], Vec::new()],
cdbs: Vec::new(),
};
assert!(matches!(
read_mkb_from_drive(&mut drive),
Err(crate::error::Error::AacsKeyRead)
));
let mut drive = MkbDrive {
packs: vec![Vec::new()],
cdbs: Vec::new(),
};
assert_eq!(
read_mkb_from_drive(&mut drive).expect("single pack"),
Vec::<u8>::new()
);
}
// A drive-declared length past the 32772-byte buffer is a drive fault: the MKB
// would have a hole, so fail rather than return a corrupt MKB as Ok.
#[test]
fn read_mkb_from_drive_rejects_a_pack_declaring_more_than_the_buffer_holds() {
/// Pack 0 is honest; pack 1 declares a 60000-byte payload it never sent.
struct LyingDrive {
honest: Vec<u8>,
}
impl ScsiTransport for LyingDrive {
fn execute(
&mut self,
cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]);
data[3] = 2; // two packs declared
if pack == 0 {
let dl = self.honest.len() + 2;
data[0..2].copy_from_slice(&(dl as u16).to_be_bytes());
data[4..4 + self.honest.len()].copy_from_slice(&self.honest);
} else {
// A length far beyond the 32772-byte response buffer.
data[0..2].copy_from_slice(&60_000u16.to_be_bytes());
}
Ok(ScsiResult {
status: 0,
bytes_transferred: 4,
sense: [0u8; 32],
})
}
}
let mut drive = LyingDrive {
honest: vec![0xC7u8; 256],
};
let err = read_mkb_from_drive(&mut drive).expect_err("a holed MKB is not Ok");
assert_eq!(err.code(), crate::error::Error::AacsKeyRead.code());
}
/// The same over-declaration on the FIRST pack, which uses a separate bound
/// from the loop's.
#[test]
fn read_mkb_from_drive_rejects_a_first_pack_declaring_more_than_the_buffer() {
struct LyingFirst;
impl ScsiTransport for LyingFirst {
fn execute(
&mut self,
_cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
data[0..2].copy_from_slice(&60_000u16.to_be_bytes());
data[3] = 1;
Ok(ScsiResult {
status: 0,
bytes_transferred: 4,
sense: [0u8; 32],
})
}
}
let err = read_mkb_from_drive(&mut LyingFirst).expect_err("over-declared first pack");
assert_eq!(err.code(), crate::error::Error::AacsKeyRead.code());
}
/// A drive declaring a payload but transferring fewer bytes (zero-filled tail) is a
/// fault; so is a header-only pack after a real first pack (a hole).
#[test]
fn read_mkb_from_drive_rejects_short_transfer_and_holed_pack() {
struct Scripted {
declared: [u16; 2],
xfer: usize,
}
impl ScsiTransport for Scripted {
fn execute(
&mut self,
cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
let pack = cdb[5] as usize;
data[0..2].copy_from_slice(&self.declared[pack].to_be_bytes());
data[3] = 2;
Ok(ScsiResult {
status: 0,
bytes_transferred: if pack == 0 { self.xfer } else { 4 },
sense: [0u8; 32],
})
}
}
let short = read_mkb_from_drive(&mut Scripted {
declared: [102, 2],
xfer: 4,
})
.expect_err("short transfer");
assert_eq!(short.code(), crate::error::Error::AacsKeyRead.code());
let hole = read_mkb_from_drive(&mut Scripted {
declared: [102, 0],
xfer: 104,
})
.expect_err("holed pack");
assert_eq!(hole.code(), crate::error::Error::AacsKeyRead.code());
}
/// A single-pack disc still yields that pack's bytes — the common case, and
/// the one where a body returning an empty vector looks most plausible.
#[test]
fn read_mkb_from_drive_returns_a_single_packs_payload() {
let pack: Vec<u8> = (0..1024u32).map(|i| (i * 7 % 256) as u8).collect();
let mut drive = MkbDrive {
packs: vec![pack.clone()],
cdbs: Vec::new(),
};
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
assert_eq!(mkb.len(), pack.len(), "single pack payload length");
assert!(mkb == pack, "single pack payload bytes");
}
/// A drive that reports a header-only response (`data_len < 2`) has no MKB
/// to give. That must be an EMPTY vec, not a partial one — the distinction
/// matters because the AACS paths treat a non-empty MKB as parseable.
#[test]
fn read_mkb_from_drive_empty_response_is_empty() {
struct NoMkb;
impl ScsiTransport for NoMkb {
fn execute(
&mut self,
_cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
data[0..2].copy_from_slice(&0u16.to_be_bytes());
Ok(ScsiResult {
status: 0,
bytes_transferred: 4,
sense: [0u8; 32],
})
}
}
let mkb = read_mkb_from_drive(&mut NoMkb).expect("no-MKB drive still returns Ok");
assert!(
mkb.is_empty(),
"a header-only response carries no MKB bytes"
);
}
/// A header the drive never transferred, or a zero-length first pack of a multi-pack MKB,
/// is a drive fault: `AacsKeyRead`, not an empty MKB.
#[test]
fn read_mkb_from_drive_untransferred_header_and_holed_first_pack_fail() {
struct Scripted {
xfer: usize,
num_packs: u8,
}
impl ScsiTransport for Scripted {
fn execute(
&mut self,
_cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
data[0..2].copy_from_slice(&0u16.to_be_bytes());
data[3] = self.num_packs;
Ok(ScsiResult {
status: 0,
bytes_transferred: self.xfer,
sense: [0u8; 32],
})
}
}
for (xfer, num_packs) in [(0, 0), (4, 2)] {
assert!(
matches!(
read_mkb_from_drive(&mut Scripted { xfer, num_packs }),
Err(crate::error::Error::AacsKeyRead)
),
"xfer {xfer}, packs {num_packs}"
);
}
}
/// A transport failure on the FIRST pack must propagate as an error — the
/// MKB is the root of the whole AACS ladder, so an unreadable one cannot be
/// downgraded to "an MKB with no records".
#[test]
fn read_mkb_from_drive_propagates_the_first_pack_failure() {
struct DeadDrive;
impl ScsiTransport for DeadDrive {
fn execute(
&mut self,
_cdb: &[u8],
_direction: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
Err(crate::error::Error::ScsiError {
opcode: SCSI_READ_DISC_STRUCTURE,
status: 0x02,
sense: None,
})
}
}
assert!(
read_mkb_from_drive(&mut DeadDrive).is_err(),
"an unreadable MKB must surface as an error, not an empty MKB"
);
}
/// A transport failure on a NON-first pack must ALSO propagate. Before the
/// fix the per-pack loop tested `.is_ok()` and dropped the error, so a
/// mid-walk failure silently TRUNCATED the MKB and returned the partial
/// pack-0 data as Ok. Pack 0 succeeds, pack 1 errors → the whole read errors.
#[test]
fn read_mkb_from_drive_propagates_a_mid_walk_pack_failure() {
struct FlakyDrive;
impl ScsiTransport for FlakyDrive {
fn execute(
&mut self,
cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]);
if pack == 0 {
// Honest first pack: 64 payload bytes, TWO packs declared so
// the loop goes on to request pack 1.
let body = [0x5Au8; 64];
data[0..2].copy_from_slice(&((body.len() + 2) as u16).to_be_bytes());
data[2] = 0x00;
data[3] = 2;
data[4..4 + body.len()].copy_from_slice(&body);
Ok(ScsiResult {
status: 0,
bytes_transferred: 4 + body.len(),
sense: [0u8; 32],
})
} else {
// Pack 1 read fails mid-walk.
Err(crate::error::Error::ScsiError {
opcode: SCSI_READ_DISC_STRUCTURE,
status: 0x02,
sense: None,
})
}
}
}
assert!(
read_mkb_from_drive(&mut FlakyDrive).is_err(),
"a transport error on pack 1 must surface, not truncate the MKB to pack 0"
);
}
-51
View File
@@ -1,51 +0,0 @@
use super::*;
/// Finding 17: a Unit_Key_RO.inf that parses its key list fully but declares
/// more title entries than the buffer can hold is TRUNCATED and must be
/// rejected. Pre-fix the loop silently skipped the missing entries and
/// returned `Some` with a short title→CPS map (mirrors the key-list check).
#[test]
fn parse_unit_key_ro_rejects_truncated_title_table() {
let uk_pos = 26usize;
let mut data = vec![0u8; 90];
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes()); // key storage @26
data[24..26].copy_from_slice(&20u16.to_be_bytes()); // num_titles=20 (needs bytes to 106)
data[uk_pos..uk_pos + 2].copy_from_slice(&1u16.to_be_bytes()); // num_uk=1
// One present key at uk_pos+48 = 74..90 (V10 48-byte stride) so the key
// list parses fully; only the title table overruns the 90-byte buffer.
data[74..90].copy_from_slice(&[0xAB; 16]);
assert!(
parse_unit_key_ro(&data, AacsVersion::V10).is_none(),
"a title table that runs past the buffer is truncated → reject"
);
}
/// Fixture guard for the test above: the SAME buffer with an honest title
/// count parses to `Some`, proving it is only the truncation that is rejected
/// (not some unrelated malformation).
#[test]
fn parse_unit_key_ro_accepts_the_same_buffer_with_an_honest_title_count() {
let uk_pos = 26usize;
let mut data = vec![0u8; 90];
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
data[24..26].copy_from_slice(&15u16.to_be_bytes()); // 15 titles: last entry ends at 88 <= 90
data[uk_pos..uk_pos + 2].copy_from_slice(&1u16.to_be_bytes());
data[74..90].copy_from_slice(&[0xAB; 16]);
let ukf = parse_unit_key_ro(&data, AacsVersion::V10).expect("fits → Some");
assert_eq!(ukf.encrypted_keys.len(), 1);
assert_eq!(ukf.title_cps_unit.len(), 2 + 15); // first_play + top_menu + 15 titles
}
/// Finding 15: a malformed key-storage offset pointing far past the buffer
/// must be rejected, never indexed. The `checked_add` guards also stop the
/// offset arithmetic (`uk_pos + 2`, `uk_pos + 48`) from overflowing `usize`
/// on 32-bit targets rather than panicking.
#[test]
fn parse_unit_key_ro_rejects_out_of_range_key_offset() {
let mut data = vec![0u8; 32];
data[0..4].copy_from_slice(&u32::MAX.to_be_bytes()); // uk_pos = 0xFFFFFFFF
assert!(
parse_unit_key_ro(&data, AacsVersion::V10).is_none(),
"a key-storage offset past the buffer must not be indexed"
);
}
-140
View File
@@ -1,140 +0,0 @@
use super::*;
// Synthesize a `VTKF%%%.AACS`: magic, BE32 size, playlist name, reserved to 0x80, 64 entry
// slots (first `keys.len()` present), MAC trailer.
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(&(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)
}
}
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_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 — empty slots and the trailing 16-byte
// TKF MAC are not mistaken for keys. k2/k3 are read at the 36-byte stride
// (0xA4, 0xC8); the old 32-byte stride misread them from the prior 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");
// disc_hash is SHA1 of the whole file (the KEYDB lookup key).
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_a_file_cut_inside_the_entry_table() {
let full = synth_vtkf(&[[0x11u8; 16], [0x22u8; 16]]);
let cut = VTKF_HEADER_LEN + 10 * VTKF_ENTRY_LEN;
assert!(parse_vtkf(&full[..cut]).is_none());
}
#[test]
fn parse_vtkf_rejects_non_magic() {
let mut data = synth_vtkf(&[[0x11u8; 16]]);
data[0] = b'X'; // corrupt magic
assert!(
parse_vtkf(&data).is_none(),
"non-VTKF magic must be rejected"
);
assert!(
parse_vtkf(&[0u8; 4]).is_none(),
"too short must be rejected"
);
}
#[test]
fn parse_title_keys_dispatches_by_magic() {
// VTKF magic → parse_vtkf.
let data = synth_vtkf(&[[0x44u8; 16], [0x55u8; 16]]);
let ukf = parse_title_keys(&data, AacsVersion::V10).expect("VTKF dispatch");
assert_eq!(ukf.encrypted_keys.len(), 2);
// Non-VTKF → parse_unit_key_ro (a 2-byte buffer is not a valid inf, so
// this proves it ROUTED to the BD parser rather than parse_vtkf).
assert!(
parse_title_keys(&[0x00, 0x00], AacsVersion::V10).is_none(),
"non-magic input must route to parse_unit_key_ro"
);
}
/// The whole point of the seam: a parsed VTKF feeds the SHARED VUK→title-key
/// crypto (`decrypt_unit_key`) exactly like a BD `Unit_Key_RO.inf` would —
/// no HD-DVD-specific crypto path.
#[test]
fn vtkf_encrypted_keys_feed_shared_vuk_unwrap() {
let enc = [0x9Au8; 16];
let data = synth_vtkf(&[enc]);
let ukf = parse_vtkf(&data).unwrap();
let vuk = [0x5Cu8; 16];
let derived = super::super::derive::decrypt_unit_key(&vuk, &ukf.encrypted_keys[0].1);
// Same as applying the shared unwrap directly to the stored enc key.
assert_eq!(derived, super::super::derive::decrypt_unit_key(&vuk, &enc));
}
// Sentinel byte 0xD5 = decimal 213: a derived Debug would render it in decimal.
#[test]
fn unit_key_file_debug_is_redacted() {
let f = UnitKeyFile {
disc_hash: [0xD5; 20],
app_type: 1,
num_bdmv_dir: 1,
use_skb_mkb: false,
version: AacsVersion::V20,
encrypted_keys: vec![(0, [0xD5; 16]), (1, [0xD5; 16])],
title_cps_unit: vec![0, 1],
};
let dbg = format!("{f:?}");
assert!(
!dbg.contains("213"),
"UnitKeyFile Debug leaked key bytes (decimal 213): {dbg}"
);
assert!(
dbg.contains("redacted"),
"UnitKeyFile Debug missing redaction marker: {dbg}"
);
// Non-secret shape is still useful for diagnostics.
assert!(dbg.contains("encrypted_keys_len: 2"), "{dbg}");
}
+267 -27
View File
@@ -148,29 +148,6 @@ impl AacsVersion {
}
}
/// The AACS generation that sets the `Unit_Key_RO.inf` stride, from the strongest signal
/// present: the content certificate's major version, then a pre-recorded MKB's type, then
/// `index.bdmv` (`INDX0300` is UHD). With none of them, UHD's 64-byte stride. The one
/// resolver for every path that parses the unit-key file, so they cannot disagree.
pub fn resolve_aacs_version(
cert_major: Option<u8>,
mkb: &[u8],
index_is_uhd: Option<bool>,
) -> AacsVersion {
if let Some(major) = cert_major {
return AacsVersion::from_major(major);
}
if let Some(t @ (MkbType::Prerecorded | MkbType::CategoryC20 | MkbType::CategoryC21)) =
mkb_type(mkb)
{
return t.generation();
}
match index_is_uhd {
Some(false) => AacsVersion::V10,
_ => AacsVersion::V20,
}
}
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
/// 0x81: `[C]` §3.2.5.1.4. 0x86 (AACS 2.x): `[RE]` — not in the public spec (from real 2.x MKBs).
pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
@@ -210,8 +187,7 @@ pub(crate) fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
}
// Find the Media Key Data Record (cvalues table) in an MKB. `[C]` §3.2.4 / §3.2.5.1.7. 0x05 is
// the pre-recorded / Category C table (libaacs `mkb_cvalues`); a Class II MKB carries 0x0c
// instead (see `check_mkb_class`). 0x07 is the Subset-Difference Index.
// the only cvalue table (libaacs `mkb_cvalues`); 0x07 is the Subset-Difference Index.
pub(crate) fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
find_record_body(mkb, REC_MEDIA_KEY_DATA)
}
@@ -356,5 +332,269 @@ pub fn mkb_is_uhd(mkb: &[u8]) -> Option<bool> {
}
#[cfg(test)]
#[path = "mkb_tests.rs"]
mod tests;
mod tests {
use super::*;
/// One MKB record: 1 type byte + big-endian 24-bit total length + body.
fn rec(rec_type: u8, body: &[u8]) -> Vec<u8> {
let len = 4 + body.len();
let mut v = vec![rec_type, (len >> 16) as u8, (len >> 8) as u8, len as u8];
v.extend_from_slice(body);
v
}
/// Type-and-Version record (0x10): body = 4-byte MKBType + 4-byte version.
fn type_and_version(mkb_type: u32, version: u32) -> Vec<u8> {
let mut body = mkb_type.to_be_bytes().to_vec();
body.extend_from_slice(&version.to_be_bytes());
rec(REC_TYPE_AND_VERSION, &body)
}
#[test]
fn walker_frames_records_and_stops_at_end_marker() {
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 77);
mkb.extend(rec(REC_VKD_TABLE, &[0xAA; 16]));
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
mkb.extend(rec(0x99, &[0xFF; 8])); // must NOT be walked (past the marker)
let recs = walk_mkb(&mkb);
assert_eq!(recs.len(), 2, "walk stops at the 00 000000 end marker");
assert_eq!(recs[0].rec_type, REC_TYPE_AND_VERSION);
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
assert_eq!(recs[1].body, vec![0xAA; 16]);
}
#[test]
fn walker_stops_on_malformed_or_out_of_bounds_length() {
// A record whose declared length runs past the buffer end must terminate
// the walk rather than panic or read OOB.
let mkb = vec![REC_VKD_TABLE, 0x00, 0xFF, 0xFF, 0x01, 0x02]; // len=0xFFFF, only 6 bytes
assert!(
walk_mkb(&mkb).is_empty(),
"over-long record yields no records"
);
// A sub-4 length (shorter than the header itself) is also rejected.
let short = vec![REC_VKD_TABLE, 0x00, 0x00, 0x02];
assert!(walk_mkb(&short).is_empty(), "sub-4 length is rejected");
// A truncated header (< 4 bytes) yields nothing.
assert!(walk_mkb(&[0x10, 0x00]).is_empty());
}
#[test]
fn mkb_type_and_version_decode_from_the_type_record() {
let mut mkb = type_and_version(MKB_21_CATEGORY_C, 100);
mkb.extend([0x00, 0x00, 0x00, 0x00]);
assert_eq!(mkb_type_raw(&mkb), Some(MKB_21_CATEGORY_C));
assert_eq!(mkb_version(&mkb), Some(100));
assert_eq!(mkb_is_uhd(&mkb), Some(true), "2.1 Category C is UHD");
let bd = type_and_version(MKB_TYPE_4_PRERECORDED, 68);
assert_eq!(
mkb_is_uhd(&bd),
Some(false),
"AACS 1.0 prerecorded is not UHD"
);
// No Type record → None (not a panic, not a fabricated value).
assert_eq!(mkb_version(&rec(REC_VKD_TABLE, &[0; 16])), None);
assert_eq!(mkb_type_raw(&[]), None);
}
// A genuinely-old AACS 1.0 Blu-ray really does carry MKB version 1 (the "50
// First Dates" disc, MKB 12628 bytes, is a real example). `mkb_version` must
// read that `00 00 00 01` verbatim — never reject/clamp a low value as garbage.
#[test]
fn mkb_version_reads_a_genuine_version_one_prerecorded_mkb() {
// Realistic early-BD layout: Type-and-Version (type 0x00041003, v1)
// followed by another framed record, then the end marker.
let mut mkb = type_and_version(MKB_TYPE_4_PRERECORDED, 1);
mkb.extend(rec(REC_MEDIA_KEY_DATA, &[0x5A; 32]));
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
assert_eq!(
mkb_version(&mkb),
Some(1),
"version 1 is a real, valid MKB version and must be read verbatim"
);
assert_eq!(
mkb_type(&mkb),
Some(MkbType::Prerecorded),
"AACS 1.0 pre-recorded Blu-ray type must still decode alongside v1"
);
assert_eq!(mkb_is_uhd(&mkb), Some(false), "a v1 BD is not UHD");
}
#[test]
fn trim_mkb_keeps_only_the_framed_records() {
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 1);
let content_len = mkb.len(); // the single framed record, no end marker
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
mkb.extend([0xDE; 4096]); // trailing padding past the end marker
let trimmed = trim_mkb(mkb);
assert_eq!(
trimmed.len(),
content_len,
"trim keeps the framed records, dropping the end marker and padding"
);
}
// BE24 length field (all THREE bytes): a walker that dropped the high byte would mis-frame
// every large record (real cvalue/variant tables).
#[test]
fn mkb_records_honors_the_high_byte_of_the_be24_length() {
const TOTAL: usize = 0x0001_0004; // 65_540 — high byte 0x01
let mut mkb = vec![REC_VKD_TABLE, 0x01, 0x00, 0x04];
mkb.resize(TOTAL, 0xAB);
// A second record follows, so a walker that mis-read the length would
// frame a different number of records rather than merely a short one.
mkb.extend(rec(REC_TYPE_AND_VERSION, &[0x11; 8]));
let recs = walk_mkb(&mkb);
assert_eq!(recs.len(), 2, "the big record must be framed as ONE record");
assert_eq!(
recs[0].rec_len, TOTAL,
"rec_len must include the high BE24 byte"
);
assert_eq!(recs[0].body.len(), TOTAL - 4);
assert_eq!(
recs[1].rec_type, REC_TYPE_AND_VERSION,
"the following record must start where the big one ends"
);
}
// Header-only records and the exact end marker: `rec_len == 4` is a well-formed HEADER-ONLY
// record, including one at the buffer end.
#[test]
fn mkb_records_yields_a_header_only_record_at_the_buffer_end() {
let mut mkb = rec(REC_TYPE_AND_VERSION, &[0xAA, 0xBB]);
mkb.extend([REC_VKD_TABLE, 0x00, 0x00, 0x04]); // 4-byte, empty body, at EOF
assert_eq!(
mkb.len(),
10,
"sanity: the last record ends at the buffer end"
);
let recs = walk_mkb(&mkb);
assert_eq!(recs.len(), 2, "the trailing header-only record is a record");
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
assert_eq!(recs[1].rec_len, 4);
assert!(recs[1].body.is_empty());
}
// ONLY the exact `00 00 00 00` marker ends the walk. A type-0 record with a
// real length is a record, not the end — stopping there would truncate
// everything after it, including the cvalue/verify records.
#[test]
fn mkb_records_stops_only_on_the_all_zero_end_marker() {
// A type-0 record of length 8, then a normal record, then the marker.
let mut mkb = vec![0x00, 0x00, 0x00, 0x08, 1, 2, 3, 4];
mkb.extend(rec(REC_VKD_TABLE, &[0x55; 16]));
mkb.extend([0x00, 0x00, 0x00, 0x00]); // the real end marker
mkb.extend(rec(0x99, &[0xFF; 4])); // past the marker: not walked
let recs = walk_mkb(&mkb);
assert_eq!(
recs.len(),
2,
"a type-0 record with a non-zero length is a record, not the end"
);
assert_eq!(recs[0].rec_type, 0x00);
assert_eq!(recs[0].rec_len, 8);
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
assert_eq!(recs[1].body, vec![0x55; 16]);
}
// `mkb_type_raw` reports the 32-bit MKBType field verbatim, including unrecognised values;
// all four bytes must come from the record body.
#[test]
fn mkb_type_raw_reads_all_four_body_bytes() {
const RAW: u32 = 0xDEAD_BEEF;
let mkb = type_and_version(RAW, 7);
assert_eq!(
mkb_type_raw(&mkb),
Some(RAW),
"every byte of the MKBType field must come from the record body"
);
assert_eq!(mkb_version(&mkb), Some(7));
}
// Every MkbType category the Type-and-Version field can name must decode to
// its variant, and an unknown value must round-trip verbatim through
// `Other(raw)` — the categorisation the stride/generation dispatch keys on.
#[test]
fn mkb_type_classifies_every_category_byte() {
let cases: &[(u32, MkbType)] = &[
(MKB_TYPE_3_RECORDABLE, MkbType::Recordable),
(MKB_TYPE_4_PRERECORDED, MkbType::Prerecorded),
(MKB_TYPE_10_CLASS_II, MkbType::ClassII),
(MKB_20_CATEGORY_C, MkbType::CategoryC20),
(MKB_21_CATEGORY_C, MkbType::CategoryC21),
];
for &(raw, expected) in cases {
assert_eq!(
MkbType::from_raw(raw),
expected,
"raw {raw:#010x} must decode to {expected:?}"
);
// …and through the byte-level path a real MKB takes.
let mut mkb = type_and_version(raw, 1);
mkb.extend([0, 0, 0, 0]);
assert_eq!(mkb_type(&mkb), Some(expected));
}
// An unrecognised MKBType is preserved, not coerced to a known variant.
const UNKNOWN: u32 = 0x1234_5678;
assert_eq!(MkbType::from_raw(UNKNOWN), MkbType::Other(UNKNOWN));
assert!(!matches!(
MkbType::from_raw(UNKNOWN),
MkbType::CategoryC20 | MkbType::CategoryC21
));
// No Type-and-Version record at all → no classification (not a default).
assert_eq!(mkb_type(&[]), None);
assert_eq!(mkb_type_raw(&[]), None);
}
// generation()/major()/is_uhd()/unit_key_stride() must agree per category:
// only Category C is UHD (2.x, 64-byte stride), and only CategoryC21 is V21.
#[test]
fn mkb_generation_stride_and_major_per_category() {
// Category C 2.1 → V21, UHD, major 2, 64-byte stride.
assert_eq!(MkbType::CategoryC21.generation(), AacsVersion::V21);
assert!(MkbType::CategoryC21.is_uhd());
// Category C 2.0 → V20, UHD, major 2, 64-byte stride.
assert_eq!(MkbType::CategoryC20.generation(), AacsVersion::V20);
assert!(MkbType::CategoryC20.is_uhd());
// Everything else → V10 (BD), NOT UHD.
for t in [
MkbType::Recordable,
MkbType::Prerecorded,
MkbType::ClassII,
MkbType::Other(0xDEAD_BEEF),
] {
assert_eq!(t.generation(), AacsVersion::V10, "{t:?} is AACS 1.0");
assert!(!t.is_uhd(), "{t:?} is not UHD");
}
// Stride and major are the two consumers of the generation enum.
assert_eq!(AacsVersion::V10.unit_key_stride(), 48);
assert_eq!(AacsVersion::V20.unit_key_stride(), 64);
assert_eq!(AacsVersion::V21.unit_key_stride(), 64);
assert_eq!(AacsVersion::V10.major(), AACS_MAJOR_BD);
assert_eq!(AacsVersion::V20.major(), AACS_MAJOR_UHD);
assert_eq!(AacsVersion::V21.major(), AACS_MAJOR_UHD);
}
// from_major is the inverse the keysource stride dispatch relies on: ONLY
// the BD major (1) is V10; every other integer takes the V20 64-byte stride.
#[test]
fn aacs_version_from_major_only_bd_is_v10() {
assert_eq!(AacsVersion::from_major(AACS_MAJOR_BD), AacsVersion::V10);
assert_eq!(AacsVersion::from_major(AACS_MAJOR_UHD), AacsVersion::V20);
// Unknown majors are NOT V10 — they must not take the 48-byte stride.
for m in [0u8, 3, 4, 255] {
assert_eq!(
AacsVersion::from_major(m),
AacsVersion::V20,
"major {m} must not select the V10 stride"
);
assert_eq!(AacsVersion::from_major(m).unit_key_stride(), 64);
}
}
}
-297
View File
@@ -1,297 +0,0 @@
use super::*;
// One stride resolver: cert, then a pre-recorded MKB's type, then index.bdmv, then UHD.
// Each later signal is consulted only when every earlier one is missing.
#[test]
fn resolve_aacs_version_ranks_cert_then_mkb_then_index() {
let rec = |t: u32| {
let mut m = vec![0x10, 0x00, 0x00, 0x0C];
m.extend_from_slice(&t.to_be_bytes());
m.extend_from_slice(&[0, 0, 0, 1]);
m
};
let (bd, uhd21) = (rec(MKB_TYPE_4_PRERECORDED), rec(MKB_21_CATEGORY_C));
let class2 = rec(MKB_TYPE_10_CLASS_II);
use AacsVersion::*;
// The certificate wins over a disagreeing MKB and index.
assert_eq!(
resolve_aacs_version(Some(AACS_MAJOR_BD), &uhd21, Some(true)),
V10
);
assert_eq!(
resolve_aacs_version(Some(AACS_MAJOR_UHD), &bd, Some(false)),
V20
);
// No cert: the MKB type wins over a disagreeing index.
assert_eq!(resolve_aacs_version(None, &bd, Some(true)), V10);
assert_eq!(resolve_aacs_version(None, &uhd21, Some(false)), V21);
// No cert, no pre-recorded MKB type: index.bdmv decides.
assert_eq!(resolve_aacs_version(None, &[], Some(false)), V10);
assert_eq!(resolve_aacs_version(None, &class2, Some(false)), V10);
assert_eq!(resolve_aacs_version(None, &[], Some(true)), V20);
// Nothing at all: UHD's 64-byte stride.
assert_eq!(resolve_aacs_version(None, &[], None), V20);
}
/// One MKB record: 1 type byte + big-endian 24-bit total length + body.
fn rec(rec_type: u8, body: &[u8]) -> Vec<u8> {
let len = 4 + body.len();
let mut v = vec![rec_type, (len >> 16) as u8, (len >> 8) as u8, len as u8];
v.extend_from_slice(body);
v
}
/// Type-and-Version record (0x10): body = 4-byte MKBType + 4-byte version.
fn type_and_version(mkb_type: u32, version: u32) -> Vec<u8> {
let mut body = mkb_type.to_be_bytes().to_vec();
body.extend_from_slice(&version.to_be_bytes());
rec(REC_TYPE_AND_VERSION, &body)
}
#[test]
fn walker_frames_records_and_stops_at_end_marker() {
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 77);
mkb.extend(rec(REC_VKD_TABLE, &[0xAA; 16]));
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
mkb.extend(rec(0x99, &[0xFF; 8])); // must NOT be walked (past the marker)
let recs = walk_mkb(&mkb);
assert_eq!(recs.len(), 2, "walk stops at the 00 000000 end marker");
assert_eq!(recs[0].rec_type, REC_TYPE_AND_VERSION);
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
assert_eq!(recs[1].body, vec![0xAA; 16]);
}
#[test]
fn walker_stops_on_malformed_or_out_of_bounds_length() {
// A record whose declared length runs past the buffer end must terminate
// the walk rather than panic or read OOB.
let mkb = vec![REC_VKD_TABLE, 0x00, 0xFF, 0xFF, 0x01, 0x02]; // len=0xFFFF, only 6 bytes
assert!(
walk_mkb(&mkb).is_empty(),
"over-long record yields no records"
);
// A sub-4 length (shorter than the header itself) is also rejected.
let short = vec![REC_VKD_TABLE, 0x00, 0x00, 0x02];
assert!(walk_mkb(&short).is_empty(), "sub-4 length is rejected");
// A truncated header (< 4 bytes) yields nothing.
assert!(walk_mkb(&[0x10, 0x00]).is_empty());
}
#[test]
fn mkb_type_and_version_decode_from_the_type_record() {
let mut mkb = type_and_version(MKB_21_CATEGORY_C, 100);
mkb.extend([0x00, 0x00, 0x00, 0x00]);
assert_eq!(mkb_type_raw(&mkb), Some(MKB_21_CATEGORY_C));
assert_eq!(mkb_version(&mkb), Some(100));
assert_eq!(mkb_is_uhd(&mkb), Some(true), "2.1 Category C is UHD");
let bd = type_and_version(MKB_TYPE_4_PRERECORDED, 68);
assert_eq!(
mkb_is_uhd(&bd),
Some(false),
"AACS 1.0 prerecorded is not UHD"
);
// No Type record → None (not a panic, not a fabricated value).
assert_eq!(mkb_version(&rec(REC_VKD_TABLE, &[0; 16])), None);
assert_eq!(mkb_type_raw(&[]), None);
}
// A genuinely-old AACS 1.0 Blu-ray really does carry MKB version 1 (the "50
// First Dates" disc, MKB 12628 bytes, is a real example). `mkb_version` must
// read that `00 00 00 01` verbatim — never reject/clamp a low value as garbage.
#[test]
fn mkb_version_reads_a_genuine_version_one_prerecorded_mkb() {
// Realistic early-BD layout: Type-and-Version (type 0x00041003, v1)
// followed by another framed record, then the end marker.
let mut mkb = type_and_version(MKB_TYPE_4_PRERECORDED, 1);
mkb.extend(rec(REC_MEDIA_KEY_DATA, &[0x5A; 32]));
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
assert_eq!(
mkb_version(&mkb),
Some(1),
"version 1 is a real, valid MKB version and must be read verbatim"
);
assert_eq!(
mkb_type(&mkb),
Some(MkbType::Prerecorded),
"AACS 1.0 pre-recorded Blu-ray type must still decode alongside v1"
);
assert_eq!(mkb_is_uhd(&mkb), Some(false), "a v1 BD is not UHD");
}
#[test]
fn trim_mkb_keeps_only_the_framed_records() {
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 1);
let content_len = mkb.len(); // the single framed record, no end marker
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
mkb.extend([0xDE; 4096]); // trailing padding past the end marker
let trimmed = trim_mkb(mkb);
assert_eq!(
trimmed.len(),
content_len,
"trim keeps the framed records, dropping the end marker and padding"
);
}
// BE24 length field (all THREE bytes): a walker that dropped the high byte would mis-frame
// every large record (real cvalue/variant tables).
#[test]
fn mkb_records_honors_the_high_byte_of_the_be24_length() {
const TOTAL: usize = 0x0001_0004; // 65_540 — high byte 0x01
let mut mkb = vec![REC_VKD_TABLE, 0x01, 0x00, 0x04];
mkb.resize(TOTAL, 0xAB);
// A second record follows, so a walker that mis-read the length would
// frame a different number of records rather than merely a short one.
mkb.extend(rec(REC_TYPE_AND_VERSION, &[0x11; 8]));
let recs = walk_mkb(&mkb);
assert_eq!(recs.len(), 2, "the big record must be framed as ONE record");
assert_eq!(
recs[0].rec_len, TOTAL,
"rec_len must include the high BE24 byte"
);
assert_eq!(recs[0].body.len(), TOTAL - 4);
assert_eq!(
recs[1].rec_type, REC_TYPE_AND_VERSION,
"the following record must start where the big one ends"
);
}
// Header-only records and the exact end marker: `rec_len == 4` is a well-formed HEADER-ONLY
// record, including one at the buffer end.
#[test]
fn mkb_records_yields_a_header_only_record_at_the_buffer_end() {
let mut mkb = rec(REC_TYPE_AND_VERSION, &[0xAA, 0xBB]);
mkb.extend([REC_VKD_TABLE, 0x00, 0x00, 0x04]); // 4-byte, empty body, at EOF
assert_eq!(
mkb.len(),
10,
"sanity: the last record ends at the buffer end"
);
let recs = walk_mkb(&mkb);
assert_eq!(recs.len(), 2, "the trailing header-only record is a record");
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
assert_eq!(recs[1].rec_len, 4);
assert!(recs[1].body.is_empty());
}
// ONLY the exact `00 00 00 00` marker ends the walk. A type-0 record with a
// real length is a record, not the end — stopping there would truncate
// everything after it, including the cvalue/verify records.
#[test]
fn mkb_records_stops_only_on_the_all_zero_end_marker() {
// A type-0 record of length 8, then a normal record, then the marker.
let mut mkb = vec![0x00, 0x00, 0x00, 0x08, 1, 2, 3, 4];
mkb.extend(rec(REC_VKD_TABLE, &[0x55; 16]));
mkb.extend([0x00, 0x00, 0x00, 0x00]); // the real end marker
mkb.extend(rec(0x99, &[0xFF; 4])); // past the marker: not walked
let recs = walk_mkb(&mkb);
assert_eq!(
recs.len(),
2,
"a type-0 record with a non-zero length is a record, not the end"
);
assert_eq!(recs[0].rec_type, 0x00);
assert_eq!(recs[0].rec_len, 8);
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
assert_eq!(recs[1].body, vec![0x55; 16]);
}
// `mkb_type_raw` reports the 32-bit MKBType field verbatim, including unrecognised values;
// all four bytes must come from the record body.
#[test]
fn mkb_type_raw_reads_all_four_body_bytes() {
const RAW: u32 = 0xDEAD_BEEF;
let mkb = type_and_version(RAW, 7);
assert_eq!(
mkb_type_raw(&mkb),
Some(RAW),
"every byte of the MKBType field must come from the record body"
);
assert_eq!(mkb_version(&mkb), Some(7));
}
// Every MkbType category the Type-and-Version field can name must decode to
// its variant, and an unknown value must round-trip verbatim through
// `Other(raw)` — the categorisation the stride/generation dispatch keys on.
#[test]
fn mkb_type_classifies_every_category_byte() {
let cases: &[(u32, MkbType)] = &[
(MKB_TYPE_3_RECORDABLE, MkbType::Recordable),
(MKB_TYPE_4_PRERECORDED, MkbType::Prerecorded),
(MKB_TYPE_10_CLASS_II, MkbType::ClassII),
(MKB_20_CATEGORY_C, MkbType::CategoryC20),
(MKB_21_CATEGORY_C, MkbType::CategoryC21),
];
for &(raw, expected) in cases {
assert_eq!(
MkbType::from_raw(raw),
expected,
"raw {raw:#010x} must decode to {expected:?}"
);
// …and through the byte-level path a real MKB takes.
let mut mkb = type_and_version(raw, 1);
mkb.extend([0, 0, 0, 0]);
assert_eq!(mkb_type(&mkb), Some(expected));
}
// An unrecognised MKBType is preserved, not coerced to a known variant.
const UNKNOWN: u32 = 0x1234_5678;
assert_eq!(MkbType::from_raw(UNKNOWN), MkbType::Other(UNKNOWN));
assert!(!matches!(
MkbType::from_raw(UNKNOWN),
MkbType::CategoryC20 | MkbType::CategoryC21
));
// No Type-and-Version record at all → no classification (not a default).
assert_eq!(mkb_type(&[]), None);
assert_eq!(mkb_type_raw(&[]), None);
}
// generation()/major()/is_uhd()/unit_key_stride() must agree per category:
// only Category C is UHD (2.x, 64-byte stride), and only CategoryC21 is V21.
#[test]
fn mkb_generation_stride_and_major_per_category() {
// Category C 2.1 → V21, UHD, major 2, 64-byte stride.
assert_eq!(MkbType::CategoryC21.generation(), AacsVersion::V21);
assert!(MkbType::CategoryC21.is_uhd());
// Category C 2.0 → V20, UHD, major 2, 64-byte stride.
assert_eq!(MkbType::CategoryC20.generation(), AacsVersion::V20);
assert!(MkbType::CategoryC20.is_uhd());
// Everything else → V10 (BD), NOT UHD.
for t in [
MkbType::Recordable,
MkbType::Prerecorded,
MkbType::ClassII,
MkbType::Other(0xDEAD_BEEF),
] {
assert_eq!(t.generation(), AacsVersion::V10, "{t:?} is AACS 1.0");
assert!(!t.is_uhd(), "{t:?} is not UHD");
}
// Stride and major are the two consumers of the generation enum.
assert_eq!(AacsVersion::V10.unit_key_stride(), 48);
assert_eq!(AacsVersion::V20.unit_key_stride(), 64);
assert_eq!(AacsVersion::V21.unit_key_stride(), 64);
assert_eq!(AacsVersion::V10.major(), AACS_MAJOR_BD);
assert_eq!(AacsVersion::V20.major(), AACS_MAJOR_UHD);
assert_eq!(AacsVersion::V21.major(), AACS_MAJOR_UHD);
}
// from_major is the inverse the keysource stride dispatch relies on: ONLY
// the BD major (1) is V10; every other integer takes the V20 64-byte stride.
#[test]
fn aacs_version_from_major_only_bd_is_v10() {
assert_eq!(AacsVersion::from_major(AACS_MAJOR_BD), AacsVersion::V10);
assert_eq!(AacsVersion::from_major(AACS_MAJOR_UHD), AacsVersion::V20);
// Unknown majors are NOT V10 — they must not take the 48-byte stride.
for m in [0u8, 3, 4, 255] {
assert_eq!(
AacsVersion::from_major(m),
AacsVersion::V20,
"major {m} must not select the V10 stride"
);
assert_eq!(AacsVersion::from_major(m).unit_key_stride(), 64);
}
}
+289 -15
View File
@@ -5,8 +5,6 @@
pub mod content;
pub mod crypto;
pub mod derive;
// HD DVD per-pack content encryption (`[HD]` §4.3).
pub(crate) mod hddvd;
pub mod host_certs;
// No production caller; not the live FMTS path.
#[doc(hidden)]
@@ -47,22 +45,16 @@ pub enum AacsRole {
ContentCert,
}
// The HD DVD AACS directory in a parsed UDF tree, if present: any root directory holding
// MKBROM.AACS, whatever its name (`ANY!`, `AAC!`, plain `AACS` on "300"). A `*_BAK` mirror
// is used only when no primary copy exists.
// The HD DVD AACS directory in a parsed UDF tree, if present. Identified structurally (name
// ends `!`, contains MKBROM.AACS), not by a hardcoded name.
pub(crate) fn find_hddvd_aacs_dir(udf: &crate::udf::UdfFs) -> Option<&crate::udf::DirEntry> {
let mut dirs = udf.root.entries.iter().filter(|e| {
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"))
});
let is_bak = |e: &crate::udf::DirEntry| e.name.to_ascii_uppercase().ends_with("_BAK");
let first = dirs.next()?;
if !is_bak(first) {
return Some(first);
}
dirs.find(|e| !is_bak(e)).or(Some(first))
})
}
// Ordered candidate paths for an AACS key role: fixed BD/UHD `/AACS/…` paths
@@ -142,5 +134,287 @@ pub use derive::derive_vuk;
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
mod tests {
//! Surface guards. The public API is the module tree itself (no facade).
//! Touching one representative item per module keeps these as a
//! compile-time contract that the module paths stay stable.
use super::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;
#[test]
fn aligned_unit_len_is_three_2048_byte_sectors() {
// ALIGNED_UNIT_LEN is the AACS aligned-unit size (3 × 2048 = 6144);
// pin it here so the public constant tracks the spec.
assert_eq!(ALIGNED_UNIT_LEN, 6144);
assert_eq!(ALIGNED_UNIT_LEN, 3 * 2048);
}
// Any error but UdfNotFound / DiscRead is a hard failure: a good DUPLICATE copy must not
// mask it.
#[test]
fn read_first_propagates_a_hard_error_without_trying_the_duplicate() {
use crate::error::Error;
let candidates = ["/AACS/Unit_Key_RO.inf", "/AACS/DUPLICATE/Unit_Key_RO.inf"];
let mut tried = Vec::new();
let out = super::read_first(&candidates, |p| {
tried.push(p.to_string());
if p == "/AACS/DUPLICATE/Unit_Key_RO.inf" {
Ok(vec![0xAA])
} else {
Err(Error::DecryptFailed)
}
});
assert!(matches!(out, Err(Error::DecryptFailed)));
assert_eq!(tried, vec!["/AACS/Unit_Key_RO.inf".to_string()]);
}
// AACS `/AACS/DUPLICATE/` redundancy: a `DiscRead` on the primary managed
// file must fall through to the backup copy, not abort the whole read.
#[test]
fn read_first_falls_through_a_primary_disc_read_to_the_duplicate() {
use crate::error::Error;
let candidates = ["/AACS/Unit_Key_RO.inf", "/AACS/DUPLICATE/Unit_Key_RO.inf"];
let out = super::read_first(&candidates, |p| {
if p == "/AACS/DUPLICATE/Unit_Key_RO.inf" {
Ok(vec![0xAA, 0xC5])
} else {
Err(Error::DiscRead {
sector: 42,
status: Some(0x02),
sense: None,
})
}
});
assert_eq!(
out.unwrap(),
vec![0xAA, 0xC5],
"a primary DiscRead must fall through to the DUPLICATE copy"
);
}
// When every candidate fails to read, the remembered `DiscRead` propagates
// (more informative than a bare `AacsNoKeys`); a purely absent set yields
// `AacsNoKeys`.
#[test]
fn read_first_propagates_disc_read_only_after_all_candidates_fail() {
use crate::error::Error;
let candidates = ["/AACS/Unit_Key_RO.inf", "/AACS/DUPLICATE/Unit_Key_RO.inf"];
let err = super::read_first(&candidates, |p| {
if p.contains("DUPLICATE") {
Err(Error::UdfNotFound { path: p.into() })
} else {
Err(Error::DiscRead {
sector: 7,
status: None,
sense: None,
})
}
})
.unwrap_err();
assert!(
matches!(err, Error::DiscRead { sector: 7, .. }),
"the primary DiscRead must propagate once the DUPLICATE is absent, got {err:?}"
);
let all_absent =
super::read_first(&candidates, |p| Err(Error::UdfNotFound { path: p.into() }))
.unwrap_err();
assert!(
matches!(all_absent, Error::AacsNoKeys),
"an entirely absent candidate set yields AacsNoKeys, got {all_absent:?}"
);
}
#[test]
fn version_strides_are_reexported_and_distinct() {
// V10 (48) vs V20/V21 (64) stride is the load-bearing distinction;
// confirm the enum re-export is usable and variants are distinct.
assert_ne!(AacsVersion::V10, AacsVersion::V20);
assert_ne!(AacsVersion::V20, AacsVersion::V21);
}
#[test]
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 _ = !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"));
let _ = super::derive::resolve_candidate(
&super::derive::KeyCandidate::Uk(super::types::UnitKey::new(0, [0u8; 16])),
&[],
&[],
None,
);
}
// ── HD DVD AACS directory / filename discovery ────────────────────────
// Dir/filename are authoring-specific (previously hardcoded to `/ANY!/VTKF000.AACS`);
// verify against 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"
);
}
// Both the `!`-suffix AND MKBROM.AACS presence are required (conjunction, not disjunction)
// — else the HD DVD path resolves key files under a dir holding none.
#[test]
fn a_bang_suffixed_directory_without_mkbrom_is_not_the_aacs_directory() {
use crate::udf::fixture::*;
let mut disc = MemDisc::new();
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
// Ends in '!' — but carries no MKBROM.AACS, so it is not the
// HD DVD AACS directory.
name: "AAC!".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: vec![
file("VTKF090.AACS", 102, 5200, 2048, true),
file("CONTENT_CERT.AACS", 103, 5300, 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(),
"a '!' directory without MKBROM.AACS is not the AACS directory"
);
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(),
],
"no HD DVD candidates may be appended from a directory that was \
never identified as the AACS directory"
);
}
#[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(),
]
);
// MKB fallback is the DUPLICATE copy of MKB_RO (libaacs `_mkb_open`), never
// MKB_RW.inf — a different MKB that derives a different Media Key.
assert_eq!(
super::role_paths(&udf, super::AacsRole::Mkb),
vec![
"/AACS/MKB_RO.inf".to_string(),
"/AACS/DUPLICATE/MKB_RO.inf".to_string(),
]
);
}
}
-352
View File
@@ -1,352 +0,0 @@
//! Surface guards. The public API is the module tree itself (no facade).
//! Touching one representative item per module keeps these as a
//! compile-time contract that the module paths stay stable.
use super::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;
#[test]
fn aligned_unit_len_is_three_2048_byte_sectors() {
// ALIGNED_UNIT_LEN is the AACS aligned-unit size (3 × 2048 = 6144);
// pin it here so the public constant tracks the spec.
assert_eq!(ALIGNED_UNIT_LEN, 6144);
assert_eq!(ALIGNED_UNIT_LEN, 3 * 2048);
}
// Any error but UdfNotFound / DiscRead is a hard failure: a good DUPLICATE copy must not
// mask it.
#[test]
fn read_first_propagates_a_hard_error_without_trying_the_duplicate() {
use crate::error::Error;
let candidates = ["/AACS/Unit_Key_RO.inf", "/AACS/DUPLICATE/Unit_Key_RO.inf"];
let mut tried = Vec::new();
let out = super::read_first(&candidates, |p| {
tried.push(p.to_string());
if p == "/AACS/DUPLICATE/Unit_Key_RO.inf" {
Ok(vec![0xAA])
} else {
Err(Error::DecryptFailed)
}
});
assert!(matches!(out, Err(Error::DecryptFailed)));
assert_eq!(tried, vec!["/AACS/Unit_Key_RO.inf".to_string()]);
}
// AACS `/AACS/DUPLICATE/` redundancy: a `DiscRead` on the primary managed
// file must fall through to the backup copy, not abort the whole read.
#[test]
fn read_first_falls_through_a_primary_disc_read_to_the_duplicate() {
use crate::error::Error;
let candidates = ["/AACS/Unit_Key_RO.inf", "/AACS/DUPLICATE/Unit_Key_RO.inf"];
let out = super::read_first(&candidates, |p| {
if p == "/AACS/DUPLICATE/Unit_Key_RO.inf" {
Ok(vec![0xAA, 0xC5])
} else {
Err(Error::DiscRead {
sector: 42,
status: Some(0x02),
sense: None,
})
}
});
assert_eq!(
out.unwrap(),
vec![0xAA, 0xC5],
"a primary DiscRead must fall through to the DUPLICATE copy"
);
}
// When every candidate fails to read, the remembered `DiscRead` propagates
// (more informative than a bare `AacsNoKeys`); a purely absent set yields
// `AacsNoKeys`.
#[test]
fn read_first_propagates_disc_read_only_after_all_candidates_fail() {
use crate::error::Error;
let candidates = ["/AACS/Unit_Key_RO.inf", "/AACS/DUPLICATE/Unit_Key_RO.inf"];
let err = super::read_first(&candidates, |p| {
if p.contains("DUPLICATE") {
Err(Error::UdfNotFound { path: p.into() })
} else {
Err(Error::DiscRead {
sector: 7,
status: None,
sense: None,
})
}
})
.unwrap_err();
assert!(
matches!(err, Error::DiscRead { sector: 7, .. }),
"the primary DiscRead must propagate once the DUPLICATE is absent, got {err:?}"
);
let all_absent =
super::read_first(&candidates, |p| Err(Error::UdfNotFound { path: p.into() })).unwrap_err();
assert!(
matches!(all_absent, Error::AacsNoKeys),
"an entirely absent candidate set yields AacsNoKeys, got {all_absent:?}"
);
}
#[test]
fn version_strides_are_reexported_and_distinct() {
// V10 (48) vs V20/V21 (64) stride is the load-bearing distinction;
// confirm the enum re-export is usable and variants are distinct.
assert_ne!(AacsVersion::V10, AacsVersion::V20);
assert_ne!(AacsVersion::V20, AacsVersion::V21);
}
#[test]
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 _ =
!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"));
let _ = super::derive::resolve_candidate(
&super::derive::KeyCandidate::Uk(super::types::UnitKey::new(0, [0u8; 16])),
&[],
&[],
None,
super::mkb::AacsVersion::V10,
);
}
// ── HD DVD AACS directory / filename discovery ────────────────────────
// Dir/filename are authoring-specific (previously hardcoded to `/ANY!/VTKF000.AACS`);
// verify against 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 (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"
);
}
// "300" HD DVDs keep MKBROM/VTKF/CONTENT_CERT in a plain `/AACS` (mirror `AACS_BAK`):
// found by MKBROM.AACS, not the name, and the mirror is skipped even when listed first.
#[test]
fn a_plain_aacs_directory_holding_mkbrom_is_the_hddvd_aacs_directory() {
use crate::udf::fixture::*;
let mut disc = MemDisc::new();
let dir = |name: &str, lba: u32, files| DirSpec {
name: name.to_string(),
icb_lba: lba,
dir_data_lba: lba + 1,
files,
subdirs: vec![],
};
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![
dir(
"AACS_BAK",
30,
vec![file("MKBROM.AACS", 110, 6000, 4096, true)],
),
dir(
"AACS",
20,
vec![
file("MKBROM.AACS", 100, 5000, 4096, true),
file("CONTENT_CERT.AACS", 101, 5100, 2048, true),
file("VTKF000.AACS", 102, 5200, 2048, true),
],
),
],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
assert_eq!(super::find_hddvd_aacs_dir(&udf).expect("dir").name, "AACS");
let mkb = super::role_paths(&udf, super::AacsRole::Mkb);
assert_eq!(mkb.last().unwrap(), "/AACS/MKBROM.AACS");
let uk = super::role_paths(&udf, super::AacsRole::UnitKey);
assert_eq!(uk.last().unwrap(), "/AACS/VTKF000.AACS");
}
// A mirror alone is still used: a damaged primary must not leave the disc unkeyable.
#[test]
fn a_lone_bak_mirror_is_used_when_no_primary_exists() {
use crate::udf::fixture::*;
let mut disc = MemDisc::new();
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "ANY!_BAK".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: vec![file("MKBROM.AACS", 100, 5000, 4096, true)],
subdirs: vec![],
}],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
assert_eq!(
super::find_hddvd_aacs_dir(&udf).expect("dir").name,
"ANY!_BAK"
);
}
// MKBROM.AACS presence is required — else the HD DVD path resolves key files under a
// dir holding none.
#[test]
fn a_bang_suffixed_directory_without_mkbrom_is_not_the_aacs_directory() {
use crate::udf::fixture::*;
let mut disc = MemDisc::new();
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
// Ends in '!' — but carries no MKBROM.AACS, so it is not the
// HD DVD AACS directory.
name: "AAC!".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: vec![
file("VTKF090.AACS", 102, 5200, 2048, true),
file("CONTENT_CERT.AACS", 103, 5300, 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(),
"a '!' directory without MKBROM.AACS is not the AACS directory"
);
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(),
],
"no HD DVD candidates may be appended from a directory that was \
never identified as the AACS directory"
);
}
#[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(),
]
);
// MKB fallback is the DUPLICATE copy of MKB_RO (libaacs `_mkb_open`), never
// MKB_RW.inf — a different MKB that derives a different Media Key.
assert_eq!(
super::role_paths(&udf, super::AacsRole::Mkb),
vec![
"/AACS/MKB_RO.inf".to_string(),
"/AACS/DUPLICATE/MKB_RO.inf".to_string(),
]
);
}
+296 -2
View File
@@ -188,5 +188,299 @@ pub fn fmts_key_ranges(
}
#[cfg(test)]
#[path = "segment_tests.rs"]
mod tests;
mod tests {
use super::*;
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
/// 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
v.extend_from_slice(&(recs.len() as u16).to_be_bytes()); // count
v.extend_from_slice(&(SEGMENT_RECORD_LEN as u16).to_be_bytes()); // record_size
for &(n, s, e) in recs {
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // marker
v.extend_from_slice(&n.to_be_bytes());
v.extend_from_slice(&1u16.to_be_bytes()); // flag
v.extend_from_slice(&s.to_be_bytes());
v.extend_from_slice(&e.to_be_bytes());
}
v
}
#[test]
fn 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 a positive AacsKeyMap: an LBA in no range has no key.
let map = crate::decrypt::AacsKeyMap::from_ranges(ranges);
assert_eq!(map.key_idx_for(500), None, "outside any segment → no key");
assert_eq!(
map.key_idx_for(1012),
Some(5),
"inside index-5 segment → key 5"
);
assert_eq!(
map.key_idx_for(1940),
Some(7),
"inside index-7 segment → key 7"
);
assert_eq!(
map.key_idx_for(1019),
None,
"segment end is exclusive → no key"
);
}
#[test]
fn fmts_key_ranges_carries_a_non_2048_aligned_segment() {
use crate::disc::Extent;
let extents = vec![Extent {
start_lba: 1000,
sector_count: 1_000_000,
}];
// start_spn=1 → start_byte=192 (NOT 2048-aligned); end_spn=10 → end_byte-1=2111.
// Correct carry floor(2111/2048)-floor(192/2048)=1 maps to LBA 1000..=1001; the
// old aligned (2111-192)/2048=0 mismatched b-a=1 and DROPPED it to the Unit Key (empty map).
let segs = vec![Segment {
index: 5,
start_spn: 1,
end_spn: 10,
}];
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
assert_eq!(
ranges.len(),
1,
"a non-2048-aligned segment must still carry, not be dropped"
);
assert_eq!(ranges[0], (1000, 1002, 5));
}
#[test]
fn fmts_key_ranges_skips_inverted_segment_without_underflow() {
use crate::disc::Extent;
let extents = vec![Extent {
start_lba: 1000,
sector_count: 1_000_000,
}];
// start_spn == end_spn + 1: `end_byte - 1 - start_byte` would underflow.
// The record must be skipped rather than panic (debug) / wrap (release).
let segs = vec![Segment {
index: 5,
start_spn: 200,
end_spn: 199,
}];
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
assert!(ranges.is_empty(), "inverted segment yields no range");
}
#[test]
fn fmts_key_ranges_skips_a_segment_straddling_non_adjacent_extents() {
use crate::disc::Extent;
// Clip bytes [0, 20480) at LBA 100.., [20480, 40960) at LBA 500.. (or reversed).
// Packets 104..=109 span bytes 19968..21119, crossing the extent boundary.
let segs = vec![Segment {
index: 5,
start_spn: 104,
end_spn: 109,
}];
for (first, second) in [(100, 500), (500, 100)] {
let extents = vec![
Extent {
start_lba: first,
sector_count: 10,
},
Extent {
start_lba: second,
sector_count: 10,
},
];
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
assert!(ranges.is_empty(), "{first}/{second}: {ranges:?}");
}
}
#[test]
fn segment_edges_are_inclusive() {
let seg = Segment {
index: 1,
start_spn: 100,
end_spn: 200,
};
assert!(seg.contains_spn(100) && seg.contains_spn(200));
assert!(!seg.contains_spn(99) && !seg.contains_spn(201));
// A unit touching exactly one edge packet overlaps.
assert!(seg.overlaps_spn(69, 100));
assert!(seg.overlaps_spn(200, 231));
assert!(!seg.overlaps_spn(68, 99));
assert!(!seg.overlaps_spn(201, 232));
}
#[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: the variant
// field counts 1,2,3,… (it wraps at 32 further into the table — see
// `index_field_cycles_one_to_thirty_two`), segments are 2560 packets.
let tbl = build_tbl(&[
(1, 343680, 346239),
(2, 695616, 698175),
(3, 1051840, 1054399),
]);
let segs = parse_individual_segments(&tbl).expect("parse");
assert_eq!(segs.len(), 3);
assert_eq!(segs[0].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);
assert_eq!(segs[0].byte_len(), 2560 * 192);
assert_eq!(segs[0].start_byte(), 343680 * 192);
assert!(segs[0].contains_spn(345000));
assert!(!segs[0].contains_spn(343679));
assert!(!segs[0].contains_spn(346240));
}
#[test]
fn rejects_wrong_record_size() {
let mut tbl = build_tbl(&[(1, 0, 10)]);
tbl[6..8].copy_from_slice(&20u16.to_be_bytes()); // record_size != 16
assert!(parse_individual_segments(&tbl).is_none());
}
#[test]
fn rejects_truncated_and_overrun() {
assert!(parse_individual_segments(&[0u8; 4]).is_none()); // < header
let mut tbl = build_tbl(&[(1, 0, 10)]);
tbl[4..6].copy_from_slice(&99u16.to_be_bytes()); // claims 99 recs, has 1
assert!(parse_individual_segments(&tbl).is_none());
}
#[test]
fn empty_table_is_empty_not_none() {
let tbl = build_tbl(&[]);
assert_eq!(parse_individual_segments(&tbl), Some(Vec::new()));
}
#[test]
fn packets_per_unit_is_thirty_two() {
// 6144-byte aligned unit / 192-byte source packet.
assert_eq!(PACKETS_PER_UNIT, 32);
}
#[test]
fn unit_inside_segment_routes_to_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 = segment_for_unit(&segs, off).expect("inside the segment");
assert_eq!(hit.index, 1);
}
#[test]
fn index_field_cycles_one_to_thirty_two() {
// Reality on a retail 2.1 disc: 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;
for row in 0..2 {
for v in 1..=32u16 {
recs.push((v, spn, spn + 2559));
spn += 50_000; // ~one segment every ~67 MB
}
let _ = row;
}
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
assert_eq!(segs.len(), 64);
assert_eq!(segs[31].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]
fn unit_outside_every_segment_is_unit_key_miss() {
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
// A unit well before the segment is ordinary content → None (unit-key path).
let off = 1000u64 * SOURCE_PACKET_LEN;
assert!(segment_for_unit(&segs, off).is_none());
}
#[test]
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 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 = 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!(segment_for_unit(&segs, before).is_none());
}
#[test]
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!(segment_for_unit(&[], lba_byte_offset(0)).is_none());
assert!(segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
}
#[test]
fn lba_maps_to_the_packet_grid() {
// A unit is 3 sectors (6144 bytes) = 32 packets. Clip-relative LBA 3 is
// the second aligned unit, which starts at packet 32.
let off = lba_byte_offset(3);
assert_eq!(off / SOURCE_PACKET_LEN, 32);
}
}
+60 -2
View File
@@ -91,5 +91,63 @@ impl<'a> SegmentKeyTable<'a> {
}
#[cfg(test)]
#[path = "segment_key_tests.rs"]
mod tests;
mod tests {
use super::*;
/// Build a container with `record_size` and the given `index_space`, filling
/// each record with a distinguishable byte so lookups can be checked.
fn build(index_space: u16, record_size: u16) -> Vec<u8> {
let count = if index_space == 0xffff {
0x1_0000
} else {
index_space as usize
};
let mut v = Vec::with_capacity(HEADER_LEN + count * record_size as usize);
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // tag
v.extend_from_slice(&index_space.to_be_bytes());
v.extend_from_slice(&record_size.to_be_bytes());
for i in 0..count {
let mut rec = vec![(i & 0xff) as u8; record_size as usize];
// sub-header, as seen on disc
rec[..8].copy_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
v.extend_from_slice(&rec);
}
v
}
#[test]
fn parses_retail_container_geometry() {
// The real disc: 0xffff index space, 536-byte records, 35,127,304 total.
let data = build(0xffff, 536);
assert_eq!(
data.len(),
35_127_304,
"matches the retail file size exactly"
);
let t = SegmentKeyTable::parse(&data).expect("parse");
assert_eq!(t.record_count(), 65_536);
assert_eq!(t.record_size(), 536);
let rec = t.record(0x1234).expect("record");
assert_eq!(rec.len(), 536);
assert_eq!(rec[8], 0x34, "record 0x1234 carries its own fixture tag");
assert_eq!(&rec[..8], &[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
assert_eq!(t.record_payload(0x1234).unwrap().len(), 528);
}
#[test]
fn small_index_space_bounds_lookups() {
let data = build(4, 32);
let t = SegmentKeyTable::parse(&data).expect("parse");
assert_eq!(t.record_count(), 4);
assert!(t.record(3).is_some());
assert!(t.record(4).is_none(), "selector past the table is None");
}
#[test]
fn rejects_size_mismatch_and_truncation() {
assert!(SegmentKeyTable::parse(&[0u8; 4]).is_none());
let mut data = build(4, 32);
data.truncate(data.len() - 1); // body no longer matches header
assert!(SegmentKeyTable::parse(&data).is_none());
}
}
-58
View File
@@ -1,58 +0,0 @@
use super::*;
/// Build a container with `record_size` and the given `index_space`, filling
/// each record with a distinguishable byte so lookups can be checked.
fn build(index_space: u16, record_size: u16) -> Vec<u8> {
let count = if index_space == 0xffff {
0x1_0000
} else {
index_space as usize
};
let mut v = Vec::with_capacity(HEADER_LEN + count * record_size as usize);
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // tag
v.extend_from_slice(&index_space.to_be_bytes());
v.extend_from_slice(&record_size.to_be_bytes());
for i in 0..count {
let mut rec = vec![(i & 0xff) as u8; record_size as usize];
// sub-header, as seen on disc
rec[..8].copy_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
v.extend_from_slice(&rec);
}
v
}
#[test]
fn parses_retail_container_geometry() {
// The real disc: 0xffff index space, 536-byte records, 35,127,304 total.
let data = build(0xffff, 536);
assert_eq!(
data.len(),
35_127_304,
"matches the retail file size exactly"
);
let t = SegmentKeyTable::parse(&data).expect("parse");
assert_eq!(t.record_count(), 65_536);
assert_eq!(t.record_size(), 536);
let rec = t.record(0x1234).expect("record");
assert_eq!(rec.len(), 536);
assert_eq!(rec[8], 0x34, "record 0x1234 carries its own fixture tag");
assert_eq!(&rec[..8], &[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
assert_eq!(t.record_payload(0x1234).unwrap().len(), 528);
}
#[test]
fn small_index_space_bounds_lookups() {
let data = build(4, 32);
let t = SegmentKeyTable::parse(&data).expect("parse");
assert_eq!(t.record_count(), 4);
assert!(t.record(3).is_some());
assert!(t.record(4).is_none(), "selector past the table is None");
}
#[test]
fn rejects_size_mismatch_and_truncation() {
assert!(SegmentKeyTable::parse(&[0u8; 4]).is_none());
let mut data = build(4, 32);
data.truncate(data.len() - 1); // body no longer matches header
assert!(SegmentKeyTable::parse(&data).is_none());
}
-294
View File
@@ -1,294 +0,0 @@
use super::*;
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
/// 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
v.extend_from_slice(&(recs.len() as u16).to_be_bytes()); // count
v.extend_from_slice(&(SEGMENT_RECORD_LEN as u16).to_be_bytes()); // record_size
for &(n, s, e) in recs {
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // marker
v.extend_from_slice(&n.to_be_bytes());
v.extend_from_slice(&1u16.to_be_bytes()); // flag
v.extend_from_slice(&s.to_be_bytes());
v.extend_from_slice(&e.to_be_bytes());
}
v
}
#[test]
fn 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 a positive AacsKeyMap: an LBA in no range has no key.
let map = crate::decrypt::AacsKeyMap::from_ranges(ranges);
assert_eq!(map.key_idx_for(500), None, "outside any segment → no key");
assert_eq!(
map.key_idx_for(1012),
Some(5),
"inside index-5 segment → key 5"
);
assert_eq!(
map.key_idx_for(1940),
Some(7),
"inside index-7 segment → key 7"
);
assert_eq!(
map.key_idx_for(1019),
None,
"segment end is exclusive → no key"
);
}
#[test]
fn fmts_key_ranges_carries_a_non_2048_aligned_segment() {
use crate::disc::Extent;
let extents = vec![Extent {
start_lba: 1000,
sector_count: 1_000_000,
}];
// start_spn=1 → start_byte=192 (NOT 2048-aligned); end_spn=10 → end_byte-1=2111.
// Correct carry floor(2111/2048)-floor(192/2048)=1 maps to LBA 1000..=1001; the
// old aligned (2111-192)/2048=0 mismatched b-a=1 and DROPPED it to the Unit Key (empty map).
let segs = vec![Segment {
index: 5,
start_spn: 1,
end_spn: 10,
}];
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
assert_eq!(
ranges.len(),
1,
"a non-2048-aligned segment must still carry, not be dropped"
);
assert_eq!(ranges[0], (1000, 1002, 5));
}
#[test]
fn fmts_key_ranges_skips_inverted_segment_without_underflow() {
use crate::disc::Extent;
let extents = vec![Extent {
start_lba: 1000,
sector_count: 1_000_000,
}];
// start_spn == end_spn + 1: `end_byte - 1 - start_byte` would underflow.
// The record must be skipped rather than panic (debug) / wrap (release).
let segs = vec![Segment {
index: 5,
start_spn: 200,
end_spn: 199,
}];
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
assert!(ranges.is_empty(), "inverted segment yields no range");
}
#[test]
fn fmts_key_ranges_skips_a_segment_straddling_non_adjacent_extents() {
use crate::disc::Extent;
// Clip bytes [0, 20480) at LBA 100.., [20480, 40960) at LBA 500.. (or reversed).
// Packets 104..=109 span bytes 19968..21119, crossing the extent boundary.
let segs = vec![Segment {
index: 5,
start_spn: 104,
end_spn: 109,
}];
for (first, second) in [(100, 500), (500, 100)] {
let extents = vec![
Extent {
start_lba: first,
sector_count: 10,
},
Extent {
start_lba: second,
sector_count: 10,
},
];
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
assert!(ranges.is_empty(), "{first}/{second}: {ranges:?}");
}
}
#[test]
fn segment_edges_are_inclusive() {
let seg = Segment {
index: 1,
start_spn: 100,
end_spn: 200,
};
assert!(seg.contains_spn(100) && seg.contains_spn(200));
assert!(!seg.contains_spn(99) && !seg.contains_spn(201));
// A unit touching exactly one edge packet overlaps.
assert!(seg.overlaps_spn(69, 100));
assert!(seg.overlaps_spn(200, 231));
assert!(!seg.overlaps_spn(68, 99));
assert!(!seg.overlaps_spn(201, 232));
}
#[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: the variant
// field counts 1,2,3,… (it wraps at 32 further into the table — see
// `index_field_cycles_one_to_thirty_two`), segments are 2560 packets.
let tbl = build_tbl(&[
(1, 343680, 346239),
(2, 695616, 698175),
(3, 1051840, 1054399),
]);
let segs = parse_individual_segments(&tbl).expect("parse");
assert_eq!(segs.len(), 3);
assert_eq!(segs[0].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);
assert_eq!(segs[0].byte_len(), 2560 * 192);
assert_eq!(segs[0].start_byte(), 343680 * 192);
assert!(segs[0].contains_spn(345000));
assert!(!segs[0].contains_spn(343679));
assert!(!segs[0].contains_spn(346240));
}
#[test]
fn rejects_wrong_record_size() {
let mut tbl = build_tbl(&[(1, 0, 10)]);
tbl[6..8].copy_from_slice(&20u16.to_be_bytes()); // record_size != 16
assert!(parse_individual_segments(&tbl).is_none());
}
#[test]
fn rejects_truncated_and_overrun() {
assert!(parse_individual_segments(&[0u8; 4]).is_none()); // < header
let mut tbl = build_tbl(&[(1, 0, 10)]);
tbl[4..6].copy_from_slice(&99u16.to_be_bytes()); // claims 99 recs, has 1
assert!(parse_individual_segments(&tbl).is_none());
}
#[test]
fn empty_table_is_empty_not_none() {
let tbl = build_tbl(&[]);
assert_eq!(parse_individual_segments(&tbl), Some(Vec::new()));
}
#[test]
fn packets_per_unit_is_thirty_two() {
// 6144-byte aligned unit / 192-byte source packet.
assert_eq!(PACKETS_PER_UNIT, 32);
}
#[test]
fn unit_inside_segment_routes_to_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 = segment_for_unit(&segs, off).expect("inside the segment");
assert_eq!(hit.index, 1);
}
#[test]
fn index_field_cycles_one_to_thirty_two() {
// Reality on a retail 2.1 disc: 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;
for row in 0..2 {
for v in 1..=32u16 {
recs.push((v, spn, spn + 2559));
spn += 50_000; // ~one segment every ~67 MB
}
let _ = row;
}
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
assert_eq!(segs.len(), 64);
assert_eq!(segs[31].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]
fn unit_outside_every_segment_is_unit_key_miss() {
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
// A unit well before the segment is ordinary content → None (unit-key path).
let off = 1000u64 * SOURCE_PACKET_LEN;
assert!(segment_for_unit(&segs, off).is_none());
}
#[test]
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 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 = 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!(segment_for_unit(&segs, before).is_none());
}
#[test]
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!(segment_for_unit(&[], lba_byte_offset(0)).is_none());
assert!(segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
}
#[test]
fn lba_maps_to_the_packet_grid() {
// A unit is 3 sectors (6144 bytes) = 32 packets. Clip-relative LBA 3 is
// the second aligned unit, which starts at packet 32.
let off = lba_byte_offset(3);
assert_eq!(off / SOURCE_PACKET_LEN, 32);
}
+37 -2
View File
@@ -168,5 +168,40 @@ pub enum KeyOrigin {
// map variants to display text (see freemkv's `disc_info::key_origin_label`).
#[cfg(test)]
#[path = "trace_tests.rs"]
mod tests;
mod tests {
use super::*;
/// The trace types are constructible, derive the required traits, and an
/// empty trace round-trips. Pins the structural contract apps build against.
#[test]
fn trace_is_constructible_and_comparable() {
let t = ResolutionTrace {
unlock: vec![UnlockStep {
who: "AACS cert".to_string(),
outcome: UnlockOutcome::NoUsableHostCert { mkb: Some(68) },
}],
keys: vec![KeyStep {
who: "keydb".to_string(),
path: vec![
KeyNode::MatchedDisc,
KeyNode::FoundVuk,
KeyNode::DerivedUnitKeys,
],
outcome: KeyOutcome::Resolved,
matched_entry: None,
store_entries: None,
}],
};
// Clone + PartialEq (derive contract the renderers rely on).
assert_eq!(t.clone(), t);
// `who` is the source's name carried verbatim.
assert_eq!(t.keys[0].who, "keydb");
assert_eq!(t.unlock[0].who, "AACS cert");
// Apps match on this id.
assert_eq!(BUS_BLOCKED, "bus_blocked");
// Default / new is empty.
assert_eq!(ResolutionTrace::new(), ResolutionTrace::default());
assert!(ResolutionTrace::new().unlock.is_empty());
assert!(ResolutionTrace::new().keys.is_empty());
}
}
-35
View File
@@ -1,35 +0,0 @@
use super::*;
/// The trace types are constructible, derive the required traits, and an
/// empty trace round-trips. Pins the structural contract apps build against.
#[test]
fn trace_is_constructible_and_comparable() {
let t = ResolutionTrace {
unlock: vec![UnlockStep {
who: "AACS cert".to_string(),
outcome: UnlockOutcome::NoUsableHostCert { mkb: Some(68) },
}],
keys: vec![KeyStep {
who: "keydb".to_string(),
path: vec![
KeyNode::MatchedDisc,
KeyNode::FoundVuk,
KeyNode::DerivedUnitKeys,
],
outcome: KeyOutcome::Resolved,
matched_entry: None,
store_entries: None,
}],
};
// Clone + PartialEq (derive contract the renderers rely on).
assert_eq!(t.clone(), t);
// `who` is the source's name carried verbatim.
assert_eq!(t.keys[0].who, "keydb");
assert_eq!(t.unlock[0].who, "AACS cert");
// Apps match on this id.
assert_eq!(BUS_BLOCKED, "bus_blocked");
// Default / new is empty.
assert_eq!(ResolutionTrace::new(), ResolutionTrace::default());
assert!(ResolutionTrace::new().unlock.is_empty());
assert!(ResolutionTrace::new().keys.is_empty());
}
+123 -4
View File
@@ -188,9 +188,128 @@ impl std::fmt::Debug for DiscEntry {
}
#[cfg(test)]
#[path = "types_unit_key_tests.rs"]
mod unit_key_tests;
mod unit_key_tests {
use super::*;
// Pinned against the two named constructors: `UnitKey::new` is the ordinary key,
// `UnitKey::forensic` is an index key.
#[test]
fn is_default_index_separates_the_two_constructors() {
let ordinary = UnitKey::new(0, [0xAA; 16]);
assert!(
ordinary.is_default_index(),
"UnitKey::new builds the ordinary (index-0) key"
);
// Every forensic index the spec allows must be reported as NOT default.
for n in 1u8..=32 {
let k = UnitKey::forensic(0, [0xAA; 16], n);
assert!(
!k.is_default_index(),
"UnitKey::forensic({n}) is an index key, not the default key"
);
}
}
// Must agree with `resolve_disc_index`, the one consumer of `index_number`.
#[test]
fn is_default_index_agrees_with_the_forensic_index_resolver() {
use crate::aacs::index_select::resolve_disc_index;
let keys = [
UnitKey::new(0, [0x11; 16]),
UnitKey::forensic(1, [0x22; 16], 7),
];
assert_eq!(
resolve_disc_index(&keys),
Some(7),
"sanity: the resolver picks the forensic key's index"
);
let non_default: Vec<u8> = keys
.iter()
.filter(|k| !k.is_default_index())
.map(|k| k.index_number)
.collect();
assert_eq!(
non_default,
vec![7],
"exactly the key the resolver picked must be non-default"
);
// An all-ordinary key set resolves no index, and every key must report
// itself default.
let plain = [UnitKey::new(0, [0x11; 16]), UnitKey::new(1, [0x22; 16])];
assert_eq!(resolve_disc_index(&plain), None);
assert!(plain.iter().all(|k| k.is_default_index()));
}
}
#[cfg(test)]
#[path = "types_redaction_tests.rs"]
mod redaction_tests;
mod redaction_tests {
use super::*;
// Sentinel byte 0xD5 = decimal 213: a derived `Debug` prints `[u8;N]` as
// decimal, so a leak surfaces "213" (no non-secret field is 213). Each type
// must also carry a "redacted" marker so re-adding `#[derive(Debug)]` fails.
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:?}"));
}
}
-65
View File
@@ -1,65 +0,0 @@
use super::*;
// Sentinel byte 0xD5 = decimal 213: a derived `Debug` prints `[u8;N]` as
// decimal, so a leak surfaces "213" (no non-secret field is 213). Each type
// must also carry a "redacted" marker so re-adding `#[derive(Debug)]` fails.
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:?}"));
}
-54
View File
@@ -1,54 +0,0 @@
use super::*;
// Pinned against the two named constructors: `UnitKey::new` is the ordinary key,
// `UnitKey::forensic` is an index key.
#[test]
fn is_default_index_separates_the_two_constructors() {
let ordinary = UnitKey::new(0, [0xAA; 16]);
assert!(
ordinary.is_default_index(),
"UnitKey::new builds the ordinary (index-0) key"
);
// Every forensic index the spec allows must be reported as NOT default.
for n in 1u8..=32 {
let k = UnitKey::forensic(0, [0xAA; 16], n);
assert!(
!k.is_default_index(),
"UnitKey::forensic({n}) is an index key, not the default key"
);
}
}
// Must agree with `resolve_disc_index`, the one consumer of `index_number`.
#[test]
fn is_default_index_agrees_with_the_forensic_index_resolver() {
use crate::aacs::index_select::resolve_disc_index;
let keys = [
UnitKey::new(0, [0x11; 16]),
UnitKey::forensic(1, [0x22; 16], 7),
];
assert_eq!(
resolve_disc_index(&keys),
Some(7),
"sanity: the resolver picks the forensic key's index"
);
let non_default: Vec<u8> = keys
.iter()
.filter(|k| !k.is_default_index())
.map(|k| k.index_number)
.collect();
assert_eq!(
non_default,
vec![7],
"exactly the key the resolver picked must be non-default"
);
// An all-ordinary key set resolves no index, and every key must report
// itself default.
let plain = [UnitKey::new(0, [0x11; 16]), UnitKey::new(1, [0x22; 16])];
assert_eq!(resolve_disc_index(&plain), None);
assert!(plain.iter().all(|k| k.is_default_index()));
}
+1540 -2
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+285 -2
View File
@@ -227,5 +227,288 @@ impl<'a> BitReader<'a> {
}
#[cfg(test)]
#[path = "bdjo_tests.rs"]
mod tests;
mod tests {
use super::*;
// ── Bit reader unit tests ──────────────────────────────────────────────
#[test]
fn bit_reader_reads_msb_first_and_bounds_checks() {
let data = [0b1011_0010u8, 0b0100_0001];
let mut r = BitReader::new(&data);
assert_eq!(r.read(1), Some(1));
assert_eq!(r.read(3), Some(0b011));
assert_eq!(r.read(4), Some(0b0010));
assert_eq!(r.read(8), Some(0b0100_0001));
// Nothing left.
assert_eq!(r.read(1), None);
assert_eq!(r.read(0), Some(0));
}
#[test]
fn bit_reader_skip_past_end_is_none() {
let data = [0u8; 2];
let mut r = BitReader::new(&data);
assert_eq!(r.skip(16), Some(()));
assert_eq!(r.skip(1), None);
}
// ── Fixture builder ─────────────────────────────────────────────────────
// Byte-aligned BDJO builder for a single-app AMT. All bit fields here happen
// to fall on byte boundaries, so the fixture can be assembled from bytes.
struct AppSpec {
control_code: u8,
base_dir: &'static str,
classpath_extension: &'static str,
initial_class: &'static str,
}
// Optional content the minimal fixture leaves empty, laid out per the BD-J
// Object (BDJO) file format: cache items, accessible playlists, and
// per-app profiles / application_name bytes / parameter bytes.
#[derive(Default)]
struct Extras {
cache_items: usize,
playlists: usize,
profiles: usize,
name_bytes: usize,
param_bytes: usize,
}
fn push_app_string(buf: &mut Vec<u8>, s: &str) {
buf.push(s.len() as u8);
buf.extend_from_slice(s.as_bytes());
if s.len().is_multiple_of(2) {
buf.push(0); // word-align pad on even length
}
}
fn build_bdjo(apps: &[AppSpec]) -> Vec<u8> {
build_bdjo_with(apps, &Extras::default())
}
fn build_bdjo_with(apps: &[AppSpec], x: &Extras) -> Vec<u8> {
let mut b = Vec::new();
// Header
b.extend_from_slice(b"BDJO");
b.extend_from_slice(b"0200");
b.extend_from_slice(&[0u8; 40]); // section-address table
// TerminalInfo: length(4) + default_font(5) + 1 byte (havi+masks) + pad(34 bits)
b.extend_from_slice(&[0u8; 4]);
b.extend_from_slice(&[0u8; 5]);
// 4+1+1 = 6 bits then 34 bits padding = 40 bits = 5 bytes total.
b.extend_from_slice(&[0u8; 5]);
// AppCacheInfo: length(4) + num_item(1) + pad(1), then 12-byte items:
// type(1) + ref_to_name(5) + lang_code(3) + pad(3).
b.extend_from_slice(&[0u8; 4]);
b.push(x.cache_items as u8);
b.push(0);
for _ in 0..x.cache_items {
b.push(1);
b.extend_from_slice(b"00007eng");
b.extend_from_slice(&[0u8; 3]);
}
// AccessiblePlaylists: length(4) + [num_pl(11)+flags(2)+pad(19)], then
// 6-byte entries: name(5) + pad(1).
b.extend_from_slice(&[0u8; 4]);
b.extend_from_slice(&(((x.playlists as u32) << 21) | (1 << 20)).to_be_bytes());
for _ in 0..x.playlists {
b.extend_from_slice(b"00800\0");
}
// AppManagementTable: length(4) + num_app(1) + pad(1)
b.extend_from_slice(&[0u8; 4]);
b.push(apps.len() as u8);
b.push(0);
for a in apps {
b.push(a.control_code); // control_code(8)
b.push(0); // type(4)+reserved(4)
b.extend_from_slice(&[0u8; 4]); // org_id(32)
b.extend_from_slice(&[0u8; 2]); // app_id(16)
b.extend_from_slice(&[0u8; 10]); // descriptor tag+length(80)
// num_profile(4) + pad(12), then 6-byte profiles:
// profile(2) + major(1) + minor(1) + micro(1) + pad(1).
b.push((x.profiles as u8) << 4);
b.push(0);
for _ in 0..x.profiles {
b.extend_from_slice(&[0, 1, 1, 0, 0, 0]);
}
b.push(0); // priority(8)
b.push(0); // binding(2)+visibility(2)+reserved(4)
// application_name: data_length(16) + bytes, word-aligned (pad when odd).
b.extend_from_slice(&(x.name_bytes as u16).to_be_bytes());
b.extend(std::iter::repeat_n(b'n', x.name_bytes));
if !x.name_bytes.is_multiple_of(2) {
b.push(0);
}
// icon_locator (empty word-aligned string): len=0 + pad
push_app_string(&mut b, "");
b.extend_from_slice(&[0u8; 2]); // icon_flags(16)
push_app_string(&mut b, a.base_dir);
push_app_string(&mut b, a.classpath_extension);
push_app_string(&mut b, a.initial_class);
// application_parameters: data_length(8) + bytes, word-aligned (pad when even).
b.push(x.param_bytes as u8);
b.extend(std::iter::repeat_n(b'p', x.param_bytes));
if x.param_bytes.is_multiple_of(2) {
b.push(0);
}
}
b
}
// Every skipped-by-width region populated (odd and even name/param lengths):
// a wrong item/profile/padding width misaligns the strings that follow.
#[test]
fn parses_apps_with_cache_items_playlists_profiles_names_and_params() {
for (name_bytes, param_bytes) in [(5, 3), (6, 4)] {
let x = Extras {
cache_items: 2,
playlists: 3,
profiles: 2,
name_bytes,
param_bytes,
};
let bytes = build_bdjo_with(
&[
AppSpec {
control_code: 2,
base_dir: "00009",
classpath_extension: "",
initial_class: "com.studio.Helper",
},
AppSpec {
control_code: 1,
base_dir: "00000",
classpath_extension: "00001",
initial_class: "com.studio.MainXlet",
},
],
&x,
);
let apps = parse(&bytes).expect("parses");
assert_eq!(apps.len(), 2);
assert_eq!(apps[0].initial_class, "com.studio.Helper");
assert_eq!(apps[1].jar_ids(), vec!["00000", "00001"]);
assert_eq!(apps[1].initial_class, "com.studio.MainXlet");
}
}
#[test]
fn app_strings_drop_trailing_nul_padding() {
let bytes = build_bdjo(&[AppSpec {
control_code: 1,
base_dir: "0000\0",
classpath_extension: "",
initial_class: "a.B\0\0",
}]);
let apps = parse(&bytes).expect("parses");
assert_eq!(apps[0].base_directory, "0000");
assert_eq!(apps[0].initial_class, "a.B");
assert_eq!(apps[0].jar_ids(), vec!["0000"]);
}
#[test]
fn parses_autostart_app_fqcn_and_jar_ids() {
let bytes = build_bdjo(&[AppSpec {
control_code: 1,
base_dir: "00000",
classpath_extension: "00001;00002",
initial_class: "com.foxbd.StandardMenuXlet",
}]);
let apps = parse(&bytes).expect("parses");
assert_eq!(apps.len(), 1);
let a = &apps[0];
assert!(a.is_autostart());
assert_eq!(a.initial_class, "com.foxbd.StandardMenuXlet");
assert_eq!(a.base_directory, "00000");
assert_eq!(a.jar_ids(), vec!["00000", "00001", "00002"]);
}
#[test]
fn parses_multiple_apps_and_finds_the_autostart_one() {
let bytes = build_bdjo(&[
AppSpec {
control_code: 2, // PRESENT, not autostart
base_dir: "00009",
classpath_extension: "",
initial_class: "com.studio.Helper",
},
AppSpec {
control_code: 1, // AUTOSTART
base_dir: "00000",
classpath_extension: "",
initial_class: "com.studio.MainXlet",
},
]);
let apps = parse(&bytes).expect("parses");
assert_eq!(apps.len(), 2);
let auto: Vec<&BdjoApp> = apps.iter().filter(|a| a.is_autostart()).collect();
assert_eq!(auto.len(), 1);
assert_eq!(auto[0].initial_class, "com.studio.MainXlet");
assert_eq!(auto[0].jar_ids(), vec!["00000"]);
}
#[test]
fn rejects_bad_magic() {
let mut bytes = build_bdjo(&[AppSpec {
control_code: 1,
base_dir: "00000",
classpath_extension: "",
initial_class: "X",
}]);
bytes[0..4].copy_from_slice(b"NOPE");
assert!(parse(&bytes).is_none());
}
#[test]
fn truncation_yields_none_never_panics() {
let bytes = build_bdjo(&[AppSpec {
control_code: 1,
base_dir: "00000",
classpath_extension: "",
initial_class: "com.studio.MainXlet",
}]);
// Every truncation length must return None, never panic or a partial Some.
for cut in 0..bytes.len() {
assert_eq!(parse(&bytes[..cut]), None, "cut={cut}");
}
}
#[test]
fn parse_never_panics_on_random_bytes() {
let mut state: u64 = 0xB0D0_DEAD_BEEF_1234u64;
for _ in 0..500 {
// xorshift64*
let mut x = state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
state = x;
let len = (x % 300) as usize;
let mut buf = vec![0u8; len];
for (i, b) in buf.iter_mut().enumerate() {
*b = ((x >> (i % 57)) & 0xFF) as u8;
}
// Give some of them a valid magic to exercise deeper paths.
if len >= 4 && x & 1 == 0 {
buf[0..4].copy_from_slice(b"BDJO");
}
let _ = parse(&buf);
}
}
#[test]
fn jar_ids_dedups_and_skips_empties() {
let a = BdjoApp {
control_code: 1,
base_directory: "00000".into(),
classpath_extension: "00000;;00003; ".into(),
initial_class: "X".into(),
};
assert_eq!(a.jar_ids(), vec!["00000", "00003"]);
}
}
-283
View File
@@ -1,283 +0,0 @@
use super::*;
// ── Bit reader unit tests ──────────────────────────────────────────────
#[test]
fn bit_reader_reads_msb_first_and_bounds_checks() {
let data = [0b1011_0010u8, 0b0100_0001];
let mut r = BitReader::new(&data);
assert_eq!(r.read(1), Some(1));
assert_eq!(r.read(3), Some(0b011));
assert_eq!(r.read(4), Some(0b0010));
assert_eq!(r.read(8), Some(0b0100_0001));
// Nothing left.
assert_eq!(r.read(1), None);
assert_eq!(r.read(0), Some(0));
}
#[test]
fn bit_reader_skip_past_end_is_none() {
let data = [0u8; 2];
let mut r = BitReader::new(&data);
assert_eq!(r.skip(16), Some(()));
assert_eq!(r.skip(1), None);
}
// ── Fixture builder ─────────────────────────────────────────────────────
// Byte-aligned BDJO builder for a single-app AMT. All bit fields here happen
// to fall on byte boundaries, so the fixture can be assembled from bytes.
struct AppSpec {
control_code: u8,
base_dir: &'static str,
classpath_extension: &'static str,
initial_class: &'static str,
}
// Optional content the minimal fixture leaves empty, laid out per the BD-J
// Object (BDJO) file format: cache items, accessible playlists, and
// per-app profiles / application_name bytes / parameter bytes.
#[derive(Default)]
struct Extras {
cache_items: usize,
playlists: usize,
profiles: usize,
name_bytes: usize,
param_bytes: usize,
}
fn push_app_string(buf: &mut Vec<u8>, s: &str) {
buf.push(s.len() as u8);
buf.extend_from_slice(s.as_bytes());
if s.len().is_multiple_of(2) {
buf.push(0); // word-align pad on even length
}
}
fn build_bdjo(apps: &[AppSpec]) -> Vec<u8> {
build_bdjo_with(apps, &Extras::default())
}
fn build_bdjo_with(apps: &[AppSpec], x: &Extras) -> Vec<u8> {
let mut b = Vec::new();
// Header
b.extend_from_slice(b"BDJO");
b.extend_from_slice(b"0200");
b.extend_from_slice(&[0u8; 40]); // section-address table
// TerminalInfo: length(4) + default_font(5) + 1 byte (havi+masks) + pad(34 bits)
b.extend_from_slice(&[0u8; 4]);
b.extend_from_slice(&[0u8; 5]);
// 4+1+1 = 6 bits then 34 bits padding = 40 bits = 5 bytes total.
b.extend_from_slice(&[0u8; 5]);
// AppCacheInfo: length(4) + num_item(1) + pad(1), then 12-byte items:
// type(1) + ref_to_name(5) + lang_code(3) + pad(3).
b.extend_from_slice(&[0u8; 4]);
b.push(x.cache_items as u8);
b.push(0);
for _ in 0..x.cache_items {
b.push(1);
b.extend_from_slice(b"00007eng");
b.extend_from_slice(&[0u8; 3]);
}
// AccessiblePlaylists: length(4) + [num_pl(11)+flags(2)+pad(19)], then
// 6-byte entries: name(5) + pad(1).
b.extend_from_slice(&[0u8; 4]);
b.extend_from_slice(&(((x.playlists as u32) << 21) | (1 << 20)).to_be_bytes());
for _ in 0..x.playlists {
b.extend_from_slice(b"00800\0");
}
// AppManagementTable: length(4) + num_app(1) + pad(1)
b.extend_from_slice(&[0u8; 4]);
b.push(apps.len() as u8);
b.push(0);
for a in apps {
b.push(a.control_code); // control_code(8)
b.push(0); // type(4)+reserved(4)
b.extend_from_slice(&[0u8; 4]); // org_id(32)
b.extend_from_slice(&[0u8; 2]); // app_id(16)
b.extend_from_slice(&[0u8; 10]); // descriptor tag+length(80)
// num_profile(4) + pad(12), then 6-byte profiles:
// profile(2) + major(1) + minor(1) + micro(1) + pad(1).
b.push((x.profiles as u8) << 4);
b.push(0);
for _ in 0..x.profiles {
b.extend_from_slice(&[0, 1, 1, 0, 0, 0]);
}
b.push(0); // priority(8)
b.push(0); // binding(2)+visibility(2)+reserved(4)
// application_name: data_length(16) + bytes, word-aligned (pad when odd).
b.extend_from_slice(&(x.name_bytes as u16).to_be_bytes());
b.extend(std::iter::repeat_n(b'n', x.name_bytes));
if !x.name_bytes.is_multiple_of(2) {
b.push(0);
}
// icon_locator (empty word-aligned string): len=0 + pad
push_app_string(&mut b, "");
b.extend_from_slice(&[0u8; 2]); // icon_flags(16)
push_app_string(&mut b, a.base_dir);
push_app_string(&mut b, a.classpath_extension);
push_app_string(&mut b, a.initial_class);
// application_parameters: data_length(8) + bytes, word-aligned (pad when even).
b.push(x.param_bytes as u8);
b.extend(std::iter::repeat_n(b'p', x.param_bytes));
if x.param_bytes.is_multiple_of(2) {
b.push(0);
}
}
b
}
// Every skipped-by-width region populated (odd and even name/param lengths):
// a wrong item/profile/padding width misaligns the strings that follow.
#[test]
fn parses_apps_with_cache_items_playlists_profiles_names_and_params() {
for (name_bytes, param_bytes) in [(5, 3), (6, 4)] {
let x = Extras {
cache_items: 2,
playlists: 3,
profiles: 2,
name_bytes,
param_bytes,
};
let bytes = build_bdjo_with(
&[
AppSpec {
control_code: 2,
base_dir: "00009",
classpath_extension: "",
initial_class: "com.studio.Helper",
},
AppSpec {
control_code: 1,
base_dir: "00000",
classpath_extension: "00001",
initial_class: "com.studio.MainXlet",
},
],
&x,
);
let apps = parse(&bytes).expect("parses");
assert_eq!(apps.len(), 2);
assert_eq!(apps[0].initial_class, "com.studio.Helper");
assert_eq!(apps[1].jar_ids(), vec!["00000", "00001"]);
assert_eq!(apps[1].initial_class, "com.studio.MainXlet");
}
}
#[test]
fn app_strings_drop_trailing_nul_padding() {
let bytes = build_bdjo(&[AppSpec {
control_code: 1,
base_dir: "0000\0",
classpath_extension: "",
initial_class: "a.B\0\0",
}]);
let apps = parse(&bytes).expect("parses");
assert_eq!(apps[0].base_directory, "0000");
assert_eq!(apps[0].initial_class, "a.B");
assert_eq!(apps[0].jar_ids(), vec!["0000"]);
}
#[test]
fn parses_autostart_app_fqcn_and_jar_ids() {
let bytes = build_bdjo(&[AppSpec {
control_code: 1,
base_dir: "00000",
classpath_extension: "00001;00002",
initial_class: "com.foxbd.StandardMenuXlet",
}]);
let apps = parse(&bytes).expect("parses");
assert_eq!(apps.len(), 1);
let a = &apps[0];
assert!(a.is_autostart());
assert_eq!(a.initial_class, "com.foxbd.StandardMenuXlet");
assert_eq!(a.base_directory, "00000");
assert_eq!(a.jar_ids(), vec!["00000", "00001", "00002"]);
}
#[test]
fn parses_multiple_apps_and_finds_the_autostart_one() {
let bytes = build_bdjo(&[
AppSpec {
control_code: 2, // PRESENT, not autostart
base_dir: "00009",
classpath_extension: "",
initial_class: "com.studio.Helper",
},
AppSpec {
control_code: 1, // AUTOSTART
base_dir: "00000",
classpath_extension: "",
initial_class: "com.studio.MainXlet",
},
]);
let apps = parse(&bytes).expect("parses");
assert_eq!(apps.len(), 2);
let auto: Vec<&BdjoApp> = apps.iter().filter(|a| a.is_autostart()).collect();
assert_eq!(auto.len(), 1);
assert_eq!(auto[0].initial_class, "com.studio.MainXlet");
assert_eq!(auto[0].jar_ids(), vec!["00000"]);
}
#[test]
fn rejects_bad_magic() {
let mut bytes = build_bdjo(&[AppSpec {
control_code: 1,
base_dir: "00000",
classpath_extension: "",
initial_class: "X",
}]);
bytes[0..4].copy_from_slice(b"NOPE");
assert!(parse(&bytes).is_none());
}
#[test]
fn truncation_yields_none_never_panics() {
let bytes = build_bdjo(&[AppSpec {
control_code: 1,
base_dir: "00000",
classpath_extension: "",
initial_class: "com.studio.MainXlet",
}]);
// Every truncation length must return None, never panic or a partial Some.
for cut in 0..bytes.len() {
assert_eq!(parse(&bytes[..cut]), None, "cut={cut}");
}
}
#[test]
fn parse_never_panics_on_random_bytes() {
let mut state: u64 = 0xB0D0_DEAD_BEEF_1234u64;
for _ in 0..500 {
// xorshift64*
let mut x = state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
state = x;
let len = (x % 300) as usize;
let mut buf = vec![0u8; len];
for (i, b) in buf.iter_mut().enumerate() {
*b = ((x >> (i % 57)) & 0xFF) as u8;
}
// Give some of them a valid magic to exercise deeper paths.
if len >= 4 && x & 1 == 0 {
buf[0..4].copy_from_slice(b"BDJO");
}
let _ = parse(&buf);
}
}
#[test]
fn jar_ids_dedups_and_skips_empties() {
let a = BdjoApp {
control_code: 1,
base_directory: "00000".into(),
classpath_extension: "00000;;00003; ".into(),
initial_class: "X".into(),
};
assert_eq!(a.jar_ids(), vec!["00000", "00003"]);
}
+84 -3
View File
@@ -60,7 +60,7 @@ pub(crate) fn parse(d: &[u8]) -> Option<Index> {
if d.get(0..4)? != b"INDX" {
return None;
}
// version at 4..8 ("0100"/"0200"/"0240"/"0300") is not load-bearing for resolution.
// version at 4..8 ("0100"/"0200"/"0300") is not load-bearing for resolution.
let indexes_start = be_u32(d, 8)? as usize;
// At indexes_start: u32 index_len, First-Play(12), Top-Menu(12), u16 titles.
let mut o = indexes_start.checked_add(4)?;
@@ -89,5 +89,86 @@ pub(crate) fn parse(d: &[u8]) -> Option<Index> {
}
#[cfg(test)]
#[path = "index_tests.rs"]
mod tests;
mod tests {
use super::*;
/// Build a 12-byte object record: `object_type` in the top two bits of
/// byte 0, HDMV `id_ref` big-endian at offset 6.
fn hdmv_obj(id_ref: u16) -> [u8; 12] {
let mut b = [0u8; 12];
b[0] = 1 << 6;
b[6..8].copy_from_slice(&id_ref.to_be_bytes());
b
}
fn bdj_obj() -> [u8; 12] {
let mut b = [0u8; 12];
b[0] = 2 << 6;
b
}
fn build(first: [u8; 12], top: [u8; 12], titles: &[[u8; 12]]) -> Vec<u8> {
let indexes_start = 48u32;
let mut d = vec![0u8; indexes_start as usize];
d[0..4].copy_from_slice(b"INDX");
d[4..8].copy_from_slice(b"0300");
d[8..12].copy_from_slice(&indexes_start.to_be_bytes());
d.extend_from_slice(&0u32.to_be_bytes()); // index_len (unused by parser)
d.extend_from_slice(&first);
d.extend_from_slice(&top);
d.extend_from_slice(&(titles.len() as u16).to_be_bytes());
for t in titles {
d.extend_from_slice(t);
}
d
}
#[test]
fn parses_hdmv_first_play_and_titles() {
let d = build(hdmv_obj(0), bdj_obj(), &[bdj_obj(), bdj_obj()]);
let idx = parse(&d).expect("parses");
assert_eq!(idx.first_play, PlaybackObj::Hdmv { id_ref: 0 });
assert_eq!(idx.top_menu, PlaybackObj::BdJ);
assert_eq!(idx.titles.len(), 2);
assert_eq!(idx.titles[0], PlaybackObj::BdJ);
}
#[test]
fn rejects_bad_magic_and_truncation() {
assert!(parse(b"NOPE").is_none());
let d = build(hdmv_obj(3), hdmv_obj(0), &[hdmv_obj(1)]);
// Truncating below the declared title span must yield None, never panic.
assert!(parse(&d[..d.len() - 4]).is_none());
}
#[test]
fn rejects_title_count_over_the_cap() {
let mut d = build(hdmv_obj(0), bdj_obj(), &[bdj_obj()]);
// num_titles (u16) is at 48 (indexes_start) + 4 (index_len) + 12 + 12 = 76.
// Overwrite it with MAX_TITLES + 1: the cap must reject before any record
// read, so an attacker-huge count can't drive a 4097-entry parse/alloc.
d[76..78].copy_from_slice(&((MAX_TITLES as u16) + 1).to_be_bytes());
assert!(
parse(&d).is_none(),
"title count over MAX_TITLES must be rejected"
);
}
#[test]
fn rejects_zero_titles() {
let d = build(hdmv_obj(0), bdj_obj(), &[]);
assert!(parse(&d).is_none(), "num_titles == 0 must be rejected");
}
#[test]
fn unknown_object_types_parse_as_unknown() {
let mut none = [0u8; 12]; // object_type 0
let mut reserved = [0u8; 12];
reserved[0] = 3 << 6; // object_type 3
none[0] = 0x3F; // low bits set must not leak into the type
let d = build(none, reserved, &[reserved]);
let idx = parse(&d).expect("parses");
assert_eq!(idx.first_play, PlaybackObj::Unknown);
assert_eq!(idx.top_menu, PlaybackObj::Unknown);
assert_eq!(idx.titles[0], PlaybackObj::Unknown);
}
}
-81
View File
@@ -1,81 +0,0 @@
use super::*;
/// Build a 12-byte object record: `object_type` in the top two bits of
/// byte 0, HDMV `id_ref` big-endian at offset 6.
fn hdmv_obj(id_ref: u16) -> [u8; 12] {
let mut b = [0u8; 12];
b[0] = 1 << 6;
b[6..8].copy_from_slice(&id_ref.to_be_bytes());
b
}
fn bdj_obj() -> [u8; 12] {
let mut b = [0u8; 12];
b[0] = 2 << 6;
b
}
fn build(first: [u8; 12], top: [u8; 12], titles: &[[u8; 12]]) -> Vec<u8> {
let indexes_start = 48u32;
let mut d = vec![0u8; indexes_start as usize];
d[0..4].copy_from_slice(b"INDX");
d[4..8].copy_from_slice(b"0300");
d[8..12].copy_from_slice(&indexes_start.to_be_bytes());
d.extend_from_slice(&0u32.to_be_bytes()); // index_len (unused by parser)
d.extend_from_slice(&first);
d.extend_from_slice(&top);
d.extend_from_slice(&(titles.len() as u16).to_be_bytes());
for t in titles {
d.extend_from_slice(t);
}
d
}
#[test]
fn parses_hdmv_first_play_and_titles() {
let d = build(hdmv_obj(0), bdj_obj(), &[bdj_obj(), bdj_obj()]);
let idx = parse(&d).expect("parses");
assert_eq!(idx.first_play, PlaybackObj::Hdmv { id_ref: 0 });
assert_eq!(idx.top_menu, PlaybackObj::BdJ);
assert_eq!(idx.titles.len(), 2);
assert_eq!(idx.titles[0], PlaybackObj::BdJ);
}
#[test]
fn rejects_bad_magic_and_truncation() {
assert!(parse(b"NOPE").is_none());
let d = build(hdmv_obj(3), hdmv_obj(0), &[hdmv_obj(1)]);
// Truncating below the declared title span must yield None, never panic.
assert!(parse(&d[..d.len() - 4]).is_none());
}
#[test]
fn rejects_title_count_over_the_cap() {
let mut d = build(hdmv_obj(0), bdj_obj(), &[bdj_obj()]);
// num_titles (u16) is at 48 (indexes_start) + 4 (index_len) + 12 + 12 = 76.
// Overwrite it with MAX_TITLES + 1: the cap must reject before any record
// read, so an attacker-huge count can't drive a 4097-entry parse/alloc.
d[76..78].copy_from_slice(&((MAX_TITLES as u16) + 1).to_be_bytes());
assert!(
parse(&d).is_none(),
"title count over MAX_TITLES must be rejected"
);
}
#[test]
fn rejects_zero_titles() {
let d = build(hdmv_obj(0), bdj_obj(), &[]);
assert!(parse(&d).is_none(), "num_titles == 0 must be rejected");
}
#[test]
fn unknown_object_types_parse_as_unknown() {
let mut none = [0u8; 12]; // object_type 0
let mut reserved = [0u8; 12];
reserved[0] = 3 << 6; // object_type 3
none[0] = 0x3F; // low bits set must not leak into the type
let d = build(none, reserved, &[reserved]);
let idx = parse(&d).expect("parses");
assert_eq!(idx.first_play, PlaybackObj::Unknown);
assert_eq!(idx.top_menu, PlaybackObj::Unknown);
assert_eq!(idx.titles[0], PlaybackObj::Unknown);
}
+77 -2
View File
@@ -88,5 +88,80 @@ pub(crate) fn parse(d: &[u8]) -> Option<Vec<MovieObject>> {
}
#[cfg(test)]
#[path = "mobj_tests.rs"]
pub(crate) mod tests;
pub(crate) mod tests {
use super::*;
/// Encode one command from its opcode bytes + operands.
pub(crate) fn cmd(b0: u8, b1: u8, b2: u8, b3: u8, dst: u32, src: u32) -> [u8; 12] {
let mut b = [0u8; 12];
b[0] = b0;
b[1] = b1;
b[2] = b2;
b[3] = b3;
b[4..8].copy_from_slice(&dst.to_be_bytes());
b[8..12].copy_from_slice(&src.to_be_bytes());
b
}
pub(crate) fn build(objects: &[&[[u8; 12]]]) -> Vec<u8> {
let mut d = vec![0u8; 50];
d[0..4].copy_from_slice(b"MOBJ");
d[48..50].copy_from_slice(&(objects.len() as u16).to_be_bytes());
for obj in objects {
d.push(0); // flags
d.push(0); // reserved
d.extend_from_slice(&(obj.len() as u16).to_be_bytes());
for c in *obj {
d.extend_from_slice(c);
}
}
d
}
#[test]
fn parses_objects_and_commands() {
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
let b0 = (1 << 5) | 2;
let play = cmd(b0, 0x80, 0, 0, 11, 0);
let d = build(&[&[play]]);
let objs = parse(&d).expect("parses");
assert_eq!(objs.len(), 1);
assert_eq!(objs[0].cmds.len(), 1);
assert_eq!(objs[0].cmds[0].dst, 11);
assert_eq!(objs[0].cmds[0].sub_grp, 2);
}
#[test]
fn rejects_truncation() {
let play = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
let d = build(&[&[play]]);
assert!(parse(&d[..d.len() - 3]).is_none());
assert!(parse(b"NOPE").is_none());
}
#[test]
fn rejects_object_count_over_the_cap() {
// A real (non-truncated) buffer declaring MAX_OBJECTS + 1 empty objects:
// if the cap were removed, this would parse fine (nothing to truncate on),
// so only the cap itself can reject it.
let empty: Vec<&[[u8; 12]]> = vec![&[]; MAX_OBJECTS + 1];
let d = build(&empty);
assert!(
parse(&d).is_none(),
"object count over MAX_OBJECTS must be rejected"
);
}
// No command-count cap exists to test: `num_cmds` is a u16 (max 65535) and
// each object's span is bounded against the input length, so the former
// MAX_CMDS check was dead code and was removed rather than left flagged.
#[test]
fn decode_cmd_masks_ignore_reserved_bits() {
// b2/b3 upper bits are reserved and must not leak into cmp_opt/set_opt.
let c = decode_cmd(&cmd(0xFF, 0xFF, 0xF3, 0xE5, 0, 0));
assert_eq!((c.op_cnt, c.grp, c.sub_grp), (7, 3, 7));
assert!(c.imm_op1 && c.imm_op2);
assert_eq!((c.branch_opt, c.cmp_opt, c.set_opt), (0x0f, 0x03, 0x05));
}
}
-75
View File
@@ -1,75 +0,0 @@
use super::*;
/// Encode one command from its opcode bytes + operands.
pub(crate) fn cmd(b0: u8, b1: u8, b2: u8, b3: u8, dst: u32, src: u32) -> [u8; 12] {
let mut b = [0u8; 12];
b[0] = b0;
b[1] = b1;
b[2] = b2;
b[3] = b3;
b[4..8].copy_from_slice(&dst.to_be_bytes());
b[8..12].copy_from_slice(&src.to_be_bytes());
b
}
pub(crate) fn build(objects: &[&[[u8; 12]]]) -> Vec<u8> {
let mut d = vec![0u8; 50];
d[0..4].copy_from_slice(b"MOBJ");
d[48..50].copy_from_slice(&(objects.len() as u16).to_be_bytes());
for obj in objects {
d.push(0); // flags
d.push(0); // reserved
d.extend_from_slice(&(obj.len() as u16).to_be_bytes());
for c in *obj {
d.extend_from_slice(c);
}
}
d
}
#[test]
fn parses_objects_and_commands() {
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
let b0 = (1 << 5) | 2;
let play = cmd(b0, 0x80, 0, 0, 11, 0);
let d = build(&[&[play]]);
let objs = parse(&d).expect("parses");
assert_eq!(objs.len(), 1);
assert_eq!(objs[0].cmds.len(), 1);
assert_eq!(objs[0].cmds[0].dst, 11);
assert_eq!(objs[0].cmds[0].sub_grp, 2);
}
#[test]
fn rejects_truncation() {
let play = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
let d = build(&[&[play]]);
assert!(parse(&d[..d.len() - 3]).is_none());
assert!(parse(b"NOPE").is_none());
}
#[test]
fn rejects_object_count_over_the_cap() {
// A real (non-truncated) buffer declaring MAX_OBJECTS + 1 empty objects:
// if the cap were removed, this would parse fine (nothing to truncate on),
// so only the cap itself can reject it.
let empty: Vec<&[[u8; 12]]> = vec![&[]; MAX_OBJECTS + 1];
let d = build(&empty);
assert!(
parse(&d).is_none(),
"object count over MAX_OBJECTS must be rejected"
);
}
// No command-count cap exists to test: `num_cmds` is a u16 (max 65535) and
// each object's span is bounded against the input length, so the former
// MAX_CMDS check was dead code and was removed rather than left flagged.
#[test]
fn decode_cmd_masks_ignore_reserved_bits() {
// b2/b3 upper bits are reserved and must not leak into cmp_opt/set_opt.
let c = decode_cmd(&cmd(0xFF, 0xFF, 0xF3, 0xE5, 0, 0));
assert_eq!((c.op_cnt, c.grp, c.sub_grp), (7, 3, 7));
assert!(c.imm_op1 && c.imm_op2);
assert_eq!((c.branch_opt, c.cmp_opt, c.set_opt), (0x0f, 0x03, 0x05));
}
+108 -2
View File
@@ -47,5 +47,111 @@ fn resolve_inner(
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
mod tests {
use super::mobj::tests::{build as build_mobj, cmd};
use super::*;
use crate::udf::fixture::{DirSpec, MemDisc, build_udf_skeleton, file_with, lay_dir};
// Encode one 12-byte `index.bdmv` playback object: `object_type` in the top
// two bits of byte 0 (1 = HDMV, `id_ref` big-endian @6; 2 = BD-J). Mirrors
// index.rs's own private test builder (kept local).
fn hdmv_obj(id_ref: u16) -> [u8; 12] {
let mut b = [0u8; 12];
b[0] = 1 << 6;
b[6..8].copy_from_slice(&id_ref.to_be_bytes());
b
}
fn bdj_obj() -> [u8; 12] {
let mut b = [0u8; 12];
b[0] = 2 << 6;
b
}
/// Build a minimal, valid `index.bdmv`: First-Play = HDMV object 0,
/// Top Menu = BD-J, one (unused) title. Layout per `index::parse`.
fn build_index(first_play: [u8; 12]) -> Vec<u8> {
let indexes_start = 48u32;
let mut d = vec![0u8; indexes_start as usize];
d[0..4].copy_from_slice(b"INDX");
d[4..8].copy_from_slice(b"0300");
d[8..12].copy_from_slice(&indexes_start.to_be_bytes());
d.extend_from_slice(&0u32.to_be_bytes()); // index_len (unused by parser)
d.extend_from_slice(&first_play);
d.extend_from_slice(&bdj_obj()); // top_menu
d.extend_from_slice(&1u16.to_be_bytes()); // num_titles
d.extend_from_slice(&bdj_obj()); // titles[0] (not exercised here)
d
}
// Smoke/e2e test of the resolver: minimal index.bdmv + MovieObject.bdmv on
// an in-memory UDF disc, resolved via read_file + index::parse +
// mobj::parse + vm::resolve. First-Play HDMV object 0 unconditionally PlayPLs playlist 11.
#[test]
fn resolve_feature_end_to_end_resolves_playlist() {
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
let play_pl_11 = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
let mobj_bytes = build_mobj(&[&[play_pl_11]]);
let index_bytes = build_index(hdmv_obj(0));
let mut disc = MemDisc::new();
let bdmv = DirSpec {
name: "BDMV".to_string(),
icb_lba: 12,
dir_data_lba: 13,
files: vec![
file_with("index.bdmv", 14, 500, index_bytes, true),
file_with("MovieObject.bdmv", 15, 600, mobj_bytes, true),
],
subdirs: vec![],
};
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![bdmv],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
assert_eq!(
resolve_feature(&mut disc, &udf, |id| id == 11),
Some(11),
"First-Play's unconditional PlayPL 11 must resolve as the feature"
);
}
/// A malformed index (`index.bdmv` truncated to just its magic) must make
/// the whole resolver abstain, not panic — proving `resolve_feature`
/// really is wired through `index::parse`'s failure path end-to-end.
#[test]
fn resolve_feature_abstains_on_malformed_index() {
let play_pl_11 = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
let mobj_bytes = build_mobj(&[&[play_pl_11]]);
let mut disc = MemDisc::new();
let bdmv = DirSpec {
name: "BDMV".to_string(),
icb_lba: 12,
dir_data_lba: 13,
files: vec![
file_with("index.bdmv", 14, 500, b"INDX".to_vec(), true),
file_with("MovieObject.bdmv", 15, 600, mobj_bytes, true),
],
subdirs: vec![],
};
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![bdmv],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
assert_eq!(resolve_feature(&mut disc, &udf, |id| id == 11), None);
}
}
-106
View File
@@ -1,106 +0,0 @@
use super::mobj::tests::{build as build_mobj, cmd};
use super::*;
use crate::udf::fixture::{DirSpec, MemDisc, build_udf_skeleton, file_with, lay_dir};
// Encode one 12-byte `index.bdmv` playback object: `object_type` in the top
// two bits of byte 0 (1 = HDMV, `id_ref` big-endian @6; 2 = BD-J). Mirrors
// index.rs's own private test builder (kept local).
fn hdmv_obj(id_ref: u16) -> [u8; 12] {
let mut b = [0u8; 12];
b[0] = 1 << 6;
b[6..8].copy_from_slice(&id_ref.to_be_bytes());
b
}
fn bdj_obj() -> [u8; 12] {
let mut b = [0u8; 12];
b[0] = 2 << 6;
b
}
/// Build a minimal, valid `index.bdmv`: First-Play = HDMV object 0,
/// Top Menu = BD-J, one (unused) title. Layout per `index::parse`.
fn build_index(first_play: [u8; 12]) -> Vec<u8> {
let indexes_start = 48u32;
let mut d = vec![0u8; indexes_start as usize];
d[0..4].copy_from_slice(b"INDX");
d[4..8].copy_from_slice(b"0300");
d[8..12].copy_from_slice(&indexes_start.to_be_bytes());
d.extend_from_slice(&0u32.to_be_bytes()); // index_len (unused by parser)
d.extend_from_slice(&first_play);
d.extend_from_slice(&bdj_obj()); // top_menu
d.extend_from_slice(&1u16.to_be_bytes()); // num_titles
d.extend_from_slice(&bdj_obj()); // titles[0] (not exercised here)
d
}
// Smoke/e2e test of the resolver: minimal index.bdmv + MovieObject.bdmv on
// an in-memory UDF disc, resolved via read_file + index::parse +
// mobj::parse + vm::resolve. First-Play HDMV object 0 unconditionally PlayPLs playlist 11.
#[test]
fn resolve_feature_end_to_end_resolves_playlist() {
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
let play_pl_11 = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
let mobj_bytes = build_mobj(&[&[play_pl_11]]);
let index_bytes = build_index(hdmv_obj(0));
let mut disc = MemDisc::new();
let bdmv = DirSpec {
name: "BDMV".to_string(),
icb_lba: 12,
dir_data_lba: 13,
files: vec![
file_with("index.bdmv", 14, 500, index_bytes, true),
file_with("MovieObject.bdmv", 15, 600, mobj_bytes, true),
],
subdirs: vec![],
};
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![bdmv],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
assert_eq!(
resolve_feature(&mut disc, &udf, |id| id == 11),
Some(11),
"First-Play's unconditional PlayPL 11 must resolve as the feature"
);
}
/// A malformed index (`index.bdmv` truncated to just its magic) must make
/// the whole resolver abstain, not panic — proving `resolve_feature`
/// really is wired through `index::parse`'s failure path end-to-end.
#[test]
fn resolve_feature_abstains_on_malformed_index() {
let play_pl_11 = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
let mobj_bytes = build_mobj(&[&[play_pl_11]]);
let mut disc = MemDisc::new();
let bdmv = DirSpec {
name: "BDMV".to_string(),
icb_lba: 12,
dir_data_lba: 13,
files: vec![
file_with("index.bdmv", 14, 500, b"INDX".to_vec(), true),
file_with("MovieObject.bdmv", 15, 600, mobj_bytes, true),
],
subdirs: vec![],
};
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![bdmv],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
assert_eq!(resolve_feature(&mut disc, &udf, |id| id == 11), None);
}
+474 -2
View File
@@ -289,5 +289,477 @@ fn run(vm: &mut Vm, mut obj_id: usize, is_feature: &dyn Fn(u16) -> bool) -> Opti
}
#[cfg(test)]
#[path = "vm_tests.rs"]
mod tests;
mod tests {
use super::super::index::{Index, PlaybackObj};
use super::super::mobj::{self, tests::build, tests::cmd};
use super::*;
fn play_pl(id: u16) -> [u8; 12] {
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
cmd((1 << 5) | 2, 0x80, 0, 0, id as u32, 0)
}
fn jump_object(obj: u16) -> [u8; 12] {
// grp=BRANCH(0), sub_grp=JUMP(1), branch_opt=JUMP_OBJECT(0), imm dst.
cmd((1 << 5) | 1, 0x80, 0, 0, obj as u32, 0)
}
fn jump_title(title: u16) -> [u8; 12] {
// sub_grp=JUMP(1), branch_opt=JUMP_TITLE(1), imm dst.
cmd((1 << 5) | 1, 0x81, 0, 0, title as u32, 0)
}
fn call_object(obj: u16) -> [u8; 12] {
// sub_grp=JUMP(1), branch_opt=CALL_OBJECT(0x02), imm dst. For play-path
// resolution a Call follows the same target as a Jump (the return
// address is not modelled).
cmd((1 << 5) | 1, 0x82, 0, 0, obj as u32, 0)
}
fn call_title(title: u16) -> [u8; 12] {
// sub_grp=JUMP(1), branch_opt=CALL_TITLE(0x03), imm dst.
cmd((1 << 5) | 1, 0x83, 0, 0, title as u32, 0)
}
fn set_move_gpr(reg: u16, imm: u16) -> [u8; 12] {
// grp=SET(2), sub_grp=SET(0), set_opt=MOVE(1); dst=reg (GPR), imm src.
// op_cnt=2, imm_op2=1 (src immediate), imm_op1=0 (dst is a register).
cmd((2 << 5) | (2 << 3), 0x40, 0, 0x01, reg as u32, imm as u32)
}
fn play_pl_reg(reg: u16) -> [u8; 12] {
// PLAY_PL with dst = GPR[reg] (op_cnt=1, imm_op1=0).
cmd((1 << 5) | 2, 0x00, 0, 0, reg as u32, 0)
}
fn idx(first: PlaybackObj, titles: Vec<PlaybackObj>) -> Index {
Index {
first_play: first,
top_menu: PlaybackObj::BdJ,
titles,
}
}
#[test]
fn resolves_immediate_playpl() {
// First-Play HDMV obj0 → JumpObject 1 → PlayPL 11.
let d = build(&[&[jump_object(1)], &[play_pl(11)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(resolve(&index, &mobjs, &|id| id == 11), Some(11));
}
#[test]
fn skips_non_feature_playpl_then_returns_feature() {
// Autoplay chain: logo PlayPL 99 (not a candidate) → feature PlayPL 1.
let d = build(&[&[play_pl(99), play_pl(1)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
// Only 1 is a feature candidate; 99 is a logo.
assert_eq!(resolve(&index, &mobjs, &|id| id == 1), Some(1));
}
#[test]
fn call_object_follows_the_play_path_like_jump_object() {
// First-Play HDMV obj0 → CallObject 1 → PlayPL 11. CallObject follows
// the same target as JumpObject for feature resolution.
let d = build(&[&[call_object(1)], &[play_pl(11)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(resolve(&index, &mobjs, &|id| id == 11), Some(11));
}
#[test]
fn call_title_resolves_through_the_index_like_jump_title() {
// CallTitle 1 → titles[0] = HDMV obj 1 (which PlayPLs 42).
let d = build(&[&[call_title(1)], &[play_pl(42)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(
PlaybackObj::Hdmv { id_ref: 0 },
vec![PlaybackObj::Hdmv { id_ref: 1 }],
);
assert_eq!(resolve(&index, &mobjs, &|id| id == 42), Some(42));
}
#[test]
fn abstains_when_first_play_jumps_to_bdj_title() {
// Computed-title-number shape: First-Play computes a title number in a
// GPR and JumpTitles to it; the target title is BD-J → abstain (None).
let d = build(&[&[set_move_gpr(0xFEB & 0xfff, 2), {
// JumpTitle with dst = GPR[0xFEB].
cmd((1 << 5) | 1, 0x01, 0, 0, 0x0000_0FEB, 0)
}]]);
let mobjs = mobj::parse(&d).unwrap();
// title 2 → titles[1] = BD-J.
let index = idx(
PlaybackObj::Hdmv { id_ref: 0 },
vec![PlaybackObj::Hdmv { id_ref: 5 }, PlaybackObj::BdJ],
);
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn abstains_when_first_play_is_bdj() {
let d = build(&[&[play_pl(1)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::BdJ, vec![]);
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn jumptitle_first_play_is_0xffff_not_n_plus_1() {
// BD-ROM title numbering: 0 = Top Menu, 0xFFFF = First Play,
// 1..=N = titles. `N+1` is an INVALID title ref.
let index = idx(
PlaybackObj::Hdmv { id_ref: 7 },
vec![PlaybackObj::Hdmv { id_ref: 1 }],
);
let vm = Vm::new(&[], &index);
assert_eq!(vm.title_obj(0), Some(index.top_menu), "title 0 = Top Menu");
assert_eq!(
vm.title_obj(1),
Some(PlaybackObj::Hdmv { id_ref: 1 }),
"title 1 = titles[0]"
);
assert_eq!(
vm.title_obj(0xFFFF),
Some(PlaybackObj::Hdmv { id_ref: 7 }),
"0xFFFF = First Play (abstained wrongly before the fix)"
);
assert_eq!(
vm.title_obj(2),
None,
"N+1 is an invalid title ref, not First Play (resolved wrongly before the fix)"
);
}
#[test]
fn set_to_psr_is_refused() {
// SET MOVE to PSR6 must be a no-op (PSR stores are refused by spec).
// Program: MOVE PSR6<-5; CMP PSR6==5; PlayPL 100 else 200. Refused write
// keeps PSR6 at 0 -> PlayPL 200; an illegal write would wrongly pick 100.
let set_psr6 = cmd((2 << 5) | (2 << 3), 0x40, 0, 0x01, 0x8000_0006, 5);
let cmp_psr6_eq_5 = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0x8000_0006, 5);
let d = build(&[&[set_psr6, cmp_psr6_eq_5, play_pl(100), play_pl(200)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(
resolve(&index, &mobjs, &|id| id == 100 || id == 200),
Some(200),
"PSR store must be refused, so the compare is false and 200 is reached"
);
}
#[test]
fn register_fold_and_cmp_skip_reach_playpl() {
// SET GPR[3]=7; CMP GPR[3]==7 (true → do NOT skip); PlayPL GPR[3].
// grp=CMP(1), op_cnt=2, imm_op2=1 (src=7 immediate), cmp_opt=EQ(2); dst=GPR3.
let cmp_eq_reg = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 3, 0x0000_0007);
let d = build(&[&[set_move_gpr(3, 7), cmp_eq_reg, play_pl_reg(3), play_pl(999)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
// If the compare wrongly skipped, we'd hit PlayPL 999 instead of 7.
assert_eq!(resolve(&index, &mobjs, &|id| id == 7 || id == 999), Some(7));
}
#[test]
fn jumptitle_namespace_differs_from_jumpobject() {
// JumpTitle 1 → titles[0] = HDMV obj 1 (which PlayPLs 42). Proves title
// numbers resolve through the index, not as object indices.
let d = build(&[&[jump_title(1)], &[play_pl(42)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(
PlaybackObj::Hdmv { id_ref: 0 },
vec![PlaybackObj::Hdmv { id_ref: 1 }],
);
assert_eq!(resolve(&index, &mobjs, &|id| id == 42), Some(42));
}
// ADD/MUL saturate at 0xFFFFFFFF (libbluray hdmv_vm.c ADD_u32/MUL_u32,
// reverse-engineered player behaviour), like SUB and DIV/MOD by 0 already do.
#[test]
fn add_and_mul_saturate() {
let set = |opt: u8, reg: u32, imm: u32| cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm);
let cmp_eq_0 = |reg: u32| cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, reg, 0);
for ops in [
[set(0x01, 0, 0xffff_ffff), set(0x03, 0, 1)],
[set(0x01, 0, 0x1_0000), set(0x05, 0, 0x1_0000)],
] {
// A wrap to 0 makes the compare true and plays 1; saturation plays 800.
let d = build(&[&[ops[0], ops[1], cmp_eq_0(0), play_pl(1), play_pl(800)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(
resolve(&index, &mobjs, &|id| id == 1 || id == 800),
Some(800)
);
}
}
#[test]
fn swap_exchanges_two_registers() {
// MOVE g0=11; MOVE g1=22; SWAP g0<->g1; PlayPL GPR[0]. SWAP: grp=SET(2),
// sub_grp=SET(0), set_opt=SWAP(2), both operands registers (imm_op*=0).
let swap01 = cmd((2 << 5) | (2 << 3), 0x00, 0, 0x02, 0, 1);
let d = build(&[&[
set_move_gpr(0, 11),
set_move_gpr(1, 22),
swap01,
play_pl_reg(0),
]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
let mut vm = Vm::new(&mobjs, &index);
// After the swap GPR0 holds the old GPR1 (22) and PlayPL plays it.
assert_eq!(run(&mut vm, 0, &|id| id == 22), Some(22));
// Full exchange: GPR1 now holds the old GPR0 (11).
assert_eq!(vm.gpr[1], 11);
}
#[test]
fn non_convergent_self_goto_bails_via_max_steps() {
// An unconditional GOTO to its own line never reaches a PlayPL: the
// MAX_STEPS guard must bail (None) rather than loop forever. If the
// guard were removed this would hang instead of returning.
let self_goto = cmd(1 << 5, 0x81, 0, 0, 0, 0);
let d = build(&[&[self_goto]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn goto_lands_on_its_target_and_break_terminates() {
// op_cnt=1, grp=BRANCH(0), sub_grp=GOTO(0), imm dst; branch_opt GOTO(1) / BREAK(2).
let goto = |line: u32| cmd(1 << 5, 0x81, 0, 0, line, 0);
let brk = cmd(1 << 5, 0x82, 0, 0, 0, 0);
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
// GOTO line 2 jumps over the logo PlayPL 99 (a candidate) to the feature PlayPL 1.
let d = build(&[&[goto(2), play_pl(99), play_pl(1)]]);
let mobjs = mobj::parse(&d).unwrap();
assert_eq!(resolve(&index, &mobjs, &|_| true), Some(1));
let d = build(&[&[brk, play_pl(1)]]);
let mobjs = mobj::parse(&d).unwrap();
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn only_the_play_pl_family_emits_a_playlist() {
// sub_grp=PLAY(2); branch_opt 3 is Terminate/Link, which never plays dst.
let d = build(&[&[cmd((1 << 5) | 2, 0x83, 0, 0, 11, 0)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn setsystem_does_not_execute_as_a_gpr_set() {
// grp=SET(2), sub_grp=SETSYSTEM(1), set_opt=MOVE(1): must not write GPR5.
let setsys = cmd((2 << 5) | (2 << 3) | 1, 0x40, 0, 0x01, 5, 77);
let d = build(&[&[setsys]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
let mut vm = Vm::new(&mobjs, &index);
assert_eq!(run(&mut vm, 0, &|_| false), None);
assert_eq!(vm.gpr[5], 0);
}
#[test]
fn swap_immediate_operand_is_not_written_back() {
// SWAP dst=GPR0 with src=IMMEDIATE 5 (imm_op1=0, imm_op2=1) must refuse
// writing to the aliased register: GPR[5] stays 77, only GPR0 gets 5.
// Dropping the `imm_op1`/`imm_op2` guard makes this assertion fail.
let swap_imm = cmd((2 << 5) | (2 << 3), 0x40, 0, 0x02, 0, 5);
let d = build(&[&[set_move_gpr(5, 77), swap_imm]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
let mut vm = Vm::new(&mobjs, &index);
// No PlayPL → program runs off the end (None); inspect registers after.
assert_eq!(run(&mut vm, 0, &|_| false), None);
assert_eq!(
vm.gpr[5], 77,
"immediate operand must not be a write target"
);
assert_eq!(vm.gpr[0], 5, "register operand receives the immediate");
}
// libbluray hdmv_vm.c: BITSET/BITCLR with a bit number >= 32 are no-ops and
// SHL/SHR by >= 32 give 0 (no `& 31` masking).
#[test]
fn bit_ops_and_shifts_past_31_follow_libbluray() {
let set = |opt: u8, reg: u32, imm: u32| cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm);
let d = build(&[&[
set(0x0c, 0, 32),
set(0x01, 1, 1),
set(0x0e, 1, 32),
set(0x01, 2, 0x8000_0000),
set(0x0f, 2, 40),
set(0x01, 3, 0xffff_ffff),
set(0x0d, 3, 33),
]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
let mut vm = Vm::new(&mobjs, &index);
assert_eq!(run(&mut vm, 0, &|_| false), None);
assert_eq!(vm.gpr[..4], [0, 0, 0, 0xffff_ffff]);
}
// RND is non-deterministic: a compare on its result abstains.
#[test]
fn compare_on_rnd_result_abstains() {
let set = |opt: u8, reg: u32, imm: u32| cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm);
let cmp_eq = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0, 5);
let d = build(&[&[
set(0x01, 0, 5),
set(0x08, 0, 10),
cmp_eq,
play_pl(1),
play_pl(800),
]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(resolve(&index, &mobjs, &|id| id == 1 || id == 800), None);
}
fn run_gpr0(prog: &[[u8; 12]], gprs: usize) -> Vec<u32> {
let d = build(&[prog]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
let mut vm = Vm::new(&mobjs, &index);
assert_eq!(run(&mut vm, 0, &|_| false), None);
vm.gpr[..gprs].to_vec()
}
fn set_imm(opt: u8, reg: u32, imm: u32) -> [u8; 12] {
cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm)
}
// CMP dst(GPR0 = a) against imm b: true falls through to PlayPL 1, false skips to 2.
fn cmp_picks_true(opt: u8, a: u32, b: u32) -> bool {
let cmp = cmd((2 << 5) | (1 << 3), 0x40, opt, 0, 0, b);
let d = build(&[&[set_imm(0x01, 0, a), cmp, play_pl(1), play_pl(2)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
resolve(&index, &mobjs, &|id| id == 1 || id == 2) == Some(1)
}
#[test]
fn cmp_ops_ge_gt_le_lt_ne() {
// (cmp_opt, a, b, expected)
let cases = [
(0x04, 5, 5, true),
(0x04, 4, 5, false),
(0x04, 6, 5, true),
(0x05, 5, 5, false),
(0x05, 6, 5, true),
(0x05, 4, 5, false),
(0x06, 5, 5, true),
(0x06, 6, 5, false),
(0x06, 4, 5, true),
(0x07, 5, 5, false),
(0x07, 4, 5, true),
(0x07, 6, 5, false),
(0x03, 5, 5, false),
(0x03, 5, 6, true),
];
for (opt, a, b, want) in cases {
assert_eq!(cmp_picks_true(opt, a, b), want, "opt {opt:#x} {a} vs {b}");
}
}
// libbluray INSN_BC skips the next command when `dst & ~src` is non-zero:
// true iff every bit of dst is set in src.
#[test]
fn cmp_bc_tests_dst_bits_within_src() {
assert!(!cmp_picks_true(0x01, 0b1110, 0b0110));
assert!(cmp_picks_true(0x01, 0b0100, 0b0110));
assert!(cmp_picks_true(0x01, 0b0110, 0b1110));
}
#[test]
fn set_arithmetic_and_logic_ops() {
// (set_opt, a, b, expected)
let cases = [
(0x03, 2, 3, 5),
(0x04, 10, 3, 7),
(0x04, 3, 10, 0),
(0x05, 2, 3, 6),
(0x06, 10, 3, 3),
(0x06, 10, 0, 0xffff_ffff),
(0x07, 10, 3, 1),
(0x07, 10, 0, 0xffff_ffff),
(0x09, 0b1100, 0b1010, 0b1000),
(0x0a, 0b1100, 0b1010, 0b1110),
(0x0b, 0b1100, 0b1010, 0b0110),
(0x0c, 0, 3, 8),
(0x0d, 0xf, 1, 0xd),
(0x0e, 1, 4, 16),
(0x0f, 0x100, 4, 0x10),
];
for (opt, a, b, want) in cases {
let g = run_gpr0(&[set_imm(0x01, 0, a), set_imm(opt, 0, b)], 1);
assert_eq!(g[0], want, "set_opt {opt:#x} on {a}, {b}");
}
}
#[test]
fn branch_or_compare_on_rnd_value_abstains() {
// GPR0 = RND; then PlayPL GPR0 (branch grp, dst tainted).
let rnd = set_imm(0x08, 0, 10);
let d = build(&[&[rnd, play_pl_reg(0), play_pl(1)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
// Compare with a clean dst (GPR1) and a tainted register src (GPR0).
let cmp = cmd((2 << 5) | (1 << 3), 0x00, 0x02, 0, 1, 0);
let d = build(&[&[rnd, cmp, play_pl(1), play_pl(2)]]);
let mobjs = mobj::parse(&d).unwrap();
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn set_from_rnd_source_taints_destination() {
// GPR0 = RND; GPR1 (clean) += GPR0 (register src); PlayPL GPR1 must abstain.
let rnd = set_imm(0x08, 0, 10);
let add = cmd((2 << 5) | (2 << 3), 0x00, 0, 0x03, 1, 0);
let d = build(&[&[rnd, add, play_pl_reg(1), play_pl(1)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn playpl_operand_over_16_bits_is_not_a_playlist_id() {
// 0x1_0001 must not alias playlist 1; the VM falls through to 800.
let wide = cmd((1 << 5) | 2, 0x80, 0, 0, 0x1_0001, 0);
let d = build(&[&[wide, play_pl(800)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(
resolve(&index, &mobjs, &|id| id == 1 || id == 800),
Some(800)
);
}
#[test]
fn power_on_psr_defaults_are_visible_to_compares() {
// (psr, expected power-on value)
let cases: [(u32, u32); 12] = [
(0, 1),
(1, 0xff),
(3, 1),
(4, 0xffff),
(5, 0xffff),
(6, 0),
(7, 0),
(8, 0),
(20, 2),
(31, 0x0003_0200),
(48, 0xffff_ffff),
(62, 0),
];
for (psr, want) in cases {
// CMP PSR == want (imm): true plays 1, false plays 2.
let cmp = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0x8000_0000 | psr, want);
let d = build(&[&[cmp, play_pl(1), play_pl(2)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
let got = resolve(&index, &mobjs, &|id| id == 1 || id == 2);
assert_eq!(got, Some(1), "PSR{psr} != {want:#x}");
}
}
}
-472
View File
@@ -1,472 +0,0 @@
use super::super::index::{Index, PlaybackObj};
use super::super::mobj::{self, tests::build, tests::cmd};
use super::*;
fn play_pl(id: u16) -> [u8; 12] {
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
cmd((1 << 5) | 2, 0x80, 0, 0, id as u32, 0)
}
fn jump_object(obj: u16) -> [u8; 12] {
// grp=BRANCH(0), sub_grp=JUMP(1), branch_opt=JUMP_OBJECT(0), imm dst.
cmd((1 << 5) | 1, 0x80, 0, 0, obj as u32, 0)
}
fn jump_title(title: u16) -> [u8; 12] {
// sub_grp=JUMP(1), branch_opt=JUMP_TITLE(1), imm dst.
cmd((1 << 5) | 1, 0x81, 0, 0, title as u32, 0)
}
fn call_object(obj: u16) -> [u8; 12] {
// sub_grp=JUMP(1), branch_opt=CALL_OBJECT(0x02), imm dst. For play-path
// resolution a Call follows the same target as a Jump (the return
// address is not modelled).
cmd((1 << 5) | 1, 0x82, 0, 0, obj as u32, 0)
}
fn call_title(title: u16) -> [u8; 12] {
// sub_grp=JUMP(1), branch_opt=CALL_TITLE(0x03), imm dst.
cmd((1 << 5) | 1, 0x83, 0, 0, title as u32, 0)
}
fn set_move_gpr(reg: u16, imm: u16) -> [u8; 12] {
// grp=SET(2), sub_grp=SET(0), set_opt=MOVE(1); dst=reg (GPR), imm src.
// op_cnt=2, imm_op2=1 (src immediate), imm_op1=0 (dst is a register).
cmd((2 << 5) | (2 << 3), 0x40, 0, 0x01, reg as u32, imm as u32)
}
fn play_pl_reg(reg: u16) -> [u8; 12] {
// PLAY_PL with dst = GPR[reg] (op_cnt=1, imm_op1=0).
cmd((1 << 5) | 2, 0x00, 0, 0, reg as u32, 0)
}
fn idx(first: PlaybackObj, titles: Vec<PlaybackObj>) -> Index {
Index {
first_play: first,
top_menu: PlaybackObj::BdJ,
titles,
}
}
#[test]
fn resolves_immediate_playpl() {
// First-Play HDMV obj0 → JumpObject 1 → PlayPL 11.
let d = build(&[&[jump_object(1)], &[play_pl(11)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(resolve(&index, &mobjs, &|id| id == 11), Some(11));
}
#[test]
fn skips_non_feature_playpl_then_returns_feature() {
// Autoplay chain: logo PlayPL 99 (not a candidate) → feature PlayPL 1.
let d = build(&[&[play_pl(99), play_pl(1)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
// Only 1 is a feature candidate; 99 is a logo.
assert_eq!(resolve(&index, &mobjs, &|id| id == 1), Some(1));
}
#[test]
fn call_object_follows_the_play_path_like_jump_object() {
// First-Play HDMV obj0 → CallObject 1 → PlayPL 11. CallObject follows
// the same target as JumpObject for feature resolution.
let d = build(&[&[call_object(1)], &[play_pl(11)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(resolve(&index, &mobjs, &|id| id == 11), Some(11));
}
#[test]
fn call_title_resolves_through_the_index_like_jump_title() {
// CallTitle 1 → titles[0] = HDMV obj 1 (which PlayPLs 42).
let d = build(&[&[call_title(1)], &[play_pl(42)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(
PlaybackObj::Hdmv { id_ref: 0 },
vec![PlaybackObj::Hdmv { id_ref: 1 }],
);
assert_eq!(resolve(&index, &mobjs, &|id| id == 42), Some(42));
}
#[test]
fn abstains_when_first_play_jumps_to_bdj_title() {
// Computed-title-number shape: First-Play computes a title number in a
// GPR and JumpTitles to it; the target title is BD-J → abstain (None).
let d = build(&[&[set_move_gpr(0xFEB & 0xfff, 2), {
// JumpTitle with dst = GPR[0xFEB].
cmd((1 << 5) | 1, 0x01, 0, 0, 0x0000_0FEB, 0)
}]]);
let mobjs = mobj::parse(&d).unwrap();
// title 2 → titles[1] = BD-J.
let index = idx(
PlaybackObj::Hdmv { id_ref: 0 },
vec![PlaybackObj::Hdmv { id_ref: 5 }, PlaybackObj::BdJ],
);
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn abstains_when_first_play_is_bdj() {
let d = build(&[&[play_pl(1)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::BdJ, vec![]);
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn jumptitle_first_play_is_0xffff_not_n_plus_1() {
// BD-ROM title numbering: 0 = Top Menu, 0xFFFF = First Play,
// 1..=N = titles. `N+1` is an INVALID title ref.
let index = idx(
PlaybackObj::Hdmv { id_ref: 7 },
vec![PlaybackObj::Hdmv { id_ref: 1 }],
);
let vm = Vm::new(&[], &index);
assert_eq!(vm.title_obj(0), Some(index.top_menu), "title 0 = Top Menu");
assert_eq!(
vm.title_obj(1),
Some(PlaybackObj::Hdmv { id_ref: 1 }),
"title 1 = titles[0]"
);
assert_eq!(
vm.title_obj(0xFFFF),
Some(PlaybackObj::Hdmv { id_ref: 7 }),
"0xFFFF = First Play (abstained wrongly before the fix)"
);
assert_eq!(
vm.title_obj(2),
None,
"N+1 is an invalid title ref, not First Play (resolved wrongly before the fix)"
);
}
#[test]
fn set_to_psr_is_refused() {
// SET MOVE to PSR6 must be a no-op (PSR stores are refused by spec).
// Program: MOVE PSR6<-5; CMP PSR6==5; PlayPL 100 else 200. Refused write
// keeps PSR6 at 0 -> PlayPL 200; an illegal write would wrongly pick 100.
let set_psr6 = cmd((2 << 5) | (2 << 3), 0x40, 0, 0x01, 0x8000_0006, 5);
let cmp_psr6_eq_5 = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0x8000_0006, 5);
let d = build(&[&[set_psr6, cmp_psr6_eq_5, play_pl(100), play_pl(200)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(
resolve(&index, &mobjs, &|id| id == 100 || id == 200),
Some(200),
"PSR store must be refused, so the compare is false and 200 is reached"
);
}
#[test]
fn register_fold_and_cmp_skip_reach_playpl() {
// SET GPR[3]=7; CMP GPR[3]==7 (true → do NOT skip); PlayPL GPR[3].
// grp=CMP(1), op_cnt=2, imm_op2=1 (src=7 immediate), cmp_opt=EQ(2); dst=GPR3.
let cmp_eq_reg = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 3, 0x0000_0007);
let d = build(&[&[set_move_gpr(3, 7), cmp_eq_reg, play_pl_reg(3), play_pl(999)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
// If the compare wrongly skipped, we'd hit PlayPL 999 instead of 7.
assert_eq!(resolve(&index, &mobjs, &|id| id == 7 || id == 999), Some(7));
}
#[test]
fn jumptitle_namespace_differs_from_jumpobject() {
// JumpTitle 1 → titles[0] = HDMV obj 1 (which PlayPLs 42). Proves title
// numbers resolve through the index, not as object indices.
let d = build(&[&[jump_title(1)], &[play_pl(42)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(
PlaybackObj::Hdmv { id_ref: 0 },
vec![PlaybackObj::Hdmv { id_ref: 1 }],
);
assert_eq!(resolve(&index, &mobjs, &|id| id == 42), Some(42));
}
// ADD/MUL saturate at 0xFFFFFFFF (libbluray hdmv_vm.c ADD_u32/MUL_u32,
// reverse-engineered player behaviour), like SUB and DIV/MOD by 0 already do.
#[test]
fn add_and_mul_saturate() {
let set = |opt: u8, reg: u32, imm: u32| cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm);
let cmp_eq_0 = |reg: u32| cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, reg, 0);
for ops in [
[set(0x01, 0, 0xffff_ffff), set(0x03, 0, 1)],
[set(0x01, 0, 0x1_0000), set(0x05, 0, 0x1_0000)],
] {
// A wrap to 0 makes the compare true and plays 1; saturation plays 800.
let d = build(&[&[ops[0], ops[1], cmp_eq_0(0), play_pl(1), play_pl(800)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(
resolve(&index, &mobjs, &|id| id == 1 || id == 800),
Some(800)
);
}
}
#[test]
fn swap_exchanges_two_registers() {
// MOVE g0=11; MOVE g1=22; SWAP g0<->g1; PlayPL GPR[0]. SWAP: grp=SET(2),
// sub_grp=SET(0), set_opt=SWAP(2), both operands registers (imm_op*=0).
let swap01 = cmd((2 << 5) | (2 << 3), 0x00, 0, 0x02, 0, 1);
let d = build(&[&[
set_move_gpr(0, 11),
set_move_gpr(1, 22),
swap01,
play_pl_reg(0),
]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
let mut vm = Vm::new(&mobjs, &index);
// After the swap GPR0 holds the old GPR1 (22) and PlayPL plays it.
assert_eq!(run(&mut vm, 0, &|id| id == 22), Some(22));
// Full exchange: GPR1 now holds the old GPR0 (11).
assert_eq!(vm.gpr[1], 11);
}
#[test]
fn non_convergent_self_goto_bails_via_max_steps() {
// An unconditional GOTO to its own line never reaches a PlayPL: the
// MAX_STEPS guard must bail (None) rather than loop forever. If the
// guard were removed this would hang instead of returning.
let self_goto = cmd(1 << 5, 0x81, 0, 0, 0, 0);
let d = build(&[&[self_goto]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn goto_lands_on_its_target_and_break_terminates() {
// op_cnt=1, grp=BRANCH(0), sub_grp=GOTO(0), imm dst; branch_opt GOTO(1) / BREAK(2).
let goto = |line: u32| cmd(1 << 5, 0x81, 0, 0, line, 0);
let brk = cmd(1 << 5, 0x82, 0, 0, 0, 0);
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
// GOTO line 2 jumps over the logo PlayPL 99 (a candidate) to the feature PlayPL 1.
let d = build(&[&[goto(2), play_pl(99), play_pl(1)]]);
let mobjs = mobj::parse(&d).unwrap();
assert_eq!(resolve(&index, &mobjs, &|_| true), Some(1));
let d = build(&[&[brk, play_pl(1)]]);
let mobjs = mobj::parse(&d).unwrap();
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn only_the_play_pl_family_emits_a_playlist() {
// sub_grp=PLAY(2); branch_opt 3 is Terminate/Link, which never plays dst.
let d = build(&[&[cmd((1 << 5) | 2, 0x83, 0, 0, 11, 0)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn setsystem_does_not_execute_as_a_gpr_set() {
// grp=SET(2), sub_grp=SETSYSTEM(1), set_opt=MOVE(1): must not write GPR5.
let setsys = cmd((2 << 5) | (2 << 3) | 1, 0x40, 0, 0x01, 5, 77);
let d = build(&[&[setsys]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
let mut vm = Vm::new(&mobjs, &index);
assert_eq!(run(&mut vm, 0, &|_| false), None);
assert_eq!(vm.gpr[5], 0);
}
#[test]
fn swap_immediate_operand_is_not_written_back() {
// SWAP dst=GPR0 with src=IMMEDIATE 5 (imm_op1=0, imm_op2=1) must refuse
// writing to the aliased register: GPR[5] stays 77, only GPR0 gets 5.
// Dropping the `imm_op1`/`imm_op2` guard makes this assertion fail.
let swap_imm = cmd((2 << 5) | (2 << 3), 0x40, 0, 0x02, 0, 5);
let d = build(&[&[set_move_gpr(5, 77), swap_imm]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
let mut vm = Vm::new(&mobjs, &index);
// No PlayPL → program runs off the end (None); inspect registers after.
assert_eq!(run(&mut vm, 0, &|_| false), None);
assert_eq!(
vm.gpr[5], 77,
"immediate operand must not be a write target"
);
assert_eq!(vm.gpr[0], 5, "register operand receives the immediate");
}
// libbluray hdmv_vm.c: BITSET/BITCLR with a bit number >= 32 are no-ops and
// SHL/SHR by >= 32 give 0 (no `& 31` masking).
#[test]
fn bit_ops_and_shifts_past_31_follow_libbluray() {
let set = |opt: u8, reg: u32, imm: u32| cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm);
let d = build(&[&[
set(0x0c, 0, 32),
set(0x01, 1, 1),
set(0x0e, 1, 32),
set(0x01, 2, 0x8000_0000),
set(0x0f, 2, 40),
set(0x01, 3, 0xffff_ffff),
set(0x0d, 3, 33),
]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
let mut vm = Vm::new(&mobjs, &index);
assert_eq!(run(&mut vm, 0, &|_| false), None);
assert_eq!(vm.gpr[..4], [0, 0, 0, 0xffff_ffff]);
}
// RND is non-deterministic: a compare on its result abstains.
#[test]
fn compare_on_rnd_result_abstains() {
let set = |opt: u8, reg: u32, imm: u32| cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm);
let cmp_eq = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0, 5);
let d = build(&[&[
set(0x01, 0, 5),
set(0x08, 0, 10),
cmp_eq,
play_pl(1),
play_pl(800),
]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(resolve(&index, &mobjs, &|id| id == 1 || id == 800), None);
}
fn run_gpr0(prog: &[[u8; 12]], gprs: usize) -> Vec<u32> {
let d = build(&[prog]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
let mut vm = Vm::new(&mobjs, &index);
assert_eq!(run(&mut vm, 0, &|_| false), None);
vm.gpr[..gprs].to_vec()
}
fn set_imm(opt: u8, reg: u32, imm: u32) -> [u8; 12] {
cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm)
}
// CMP dst(GPR0 = a) against imm b: true falls through to PlayPL 1, false skips to 2.
fn cmp_picks_true(opt: u8, a: u32, b: u32) -> bool {
let cmp = cmd((2 << 5) | (1 << 3), 0x40, opt, 0, 0, b);
let d = build(&[&[set_imm(0x01, 0, a), cmp, play_pl(1), play_pl(2)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
resolve(&index, &mobjs, &|id| id == 1 || id == 2) == Some(1)
}
#[test]
fn cmp_ops_ge_gt_le_lt_ne() {
// (cmp_opt, a, b, expected)
let cases = [
(0x04, 5, 5, true),
(0x04, 4, 5, false),
(0x04, 6, 5, true),
(0x05, 5, 5, false),
(0x05, 6, 5, true),
(0x05, 4, 5, false),
(0x06, 5, 5, true),
(0x06, 6, 5, false),
(0x06, 4, 5, true),
(0x07, 5, 5, false),
(0x07, 4, 5, true),
(0x07, 6, 5, false),
(0x03, 5, 5, false),
(0x03, 5, 6, true),
];
for (opt, a, b, want) in cases {
assert_eq!(cmp_picks_true(opt, a, b), want, "opt {opt:#x} {a} vs {b}");
}
}
// libbluray INSN_BC skips the next command when `dst & ~src` is non-zero:
// true iff every bit of dst is set in src.
#[test]
fn cmp_bc_tests_dst_bits_within_src() {
assert!(!cmp_picks_true(0x01, 0b1110, 0b0110));
assert!(cmp_picks_true(0x01, 0b0100, 0b0110));
assert!(cmp_picks_true(0x01, 0b0110, 0b1110));
}
#[test]
fn set_arithmetic_and_logic_ops() {
// (set_opt, a, b, expected)
let cases = [
(0x03, 2, 3, 5),
(0x04, 10, 3, 7),
(0x04, 3, 10, 0),
(0x05, 2, 3, 6),
(0x06, 10, 3, 3),
(0x06, 10, 0, 0xffff_ffff),
(0x07, 10, 3, 1),
(0x07, 10, 0, 0xffff_ffff),
(0x09, 0b1100, 0b1010, 0b1000),
(0x0a, 0b1100, 0b1010, 0b1110),
(0x0b, 0b1100, 0b1010, 0b0110),
(0x0c, 0, 3, 8),
(0x0d, 0xf, 1, 0xd),
(0x0e, 1, 4, 16),
(0x0f, 0x100, 4, 0x10),
];
for (opt, a, b, want) in cases {
let g = run_gpr0(&[set_imm(0x01, 0, a), set_imm(opt, 0, b)], 1);
assert_eq!(g[0], want, "set_opt {opt:#x} on {a}, {b}");
}
}
#[test]
fn branch_or_compare_on_rnd_value_abstains() {
// GPR0 = RND; then PlayPL GPR0 (branch grp, dst tainted).
let rnd = set_imm(0x08, 0, 10);
let d = build(&[&[rnd, play_pl_reg(0), play_pl(1)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
// Compare with a clean dst (GPR1) and a tainted register src (GPR0).
let cmp = cmd((2 << 5) | (1 << 3), 0x00, 0x02, 0, 1, 0);
let d = build(&[&[rnd, cmp, play_pl(1), play_pl(2)]]);
let mobjs = mobj::parse(&d).unwrap();
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn set_from_rnd_source_taints_destination() {
// GPR0 = RND; GPR1 (clean) += GPR0 (register src); PlayPL GPR1 must abstain.
let rnd = set_imm(0x08, 0, 10);
let add = cmd((2 << 5) | (2 << 3), 0x00, 0, 0x03, 1, 0);
let d = build(&[&[rnd, add, play_pl_reg(1), play_pl(1)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn playpl_operand_over_16_bits_is_not_a_playlist_id() {
// 0x1_0001 must not alias playlist 1; the VM falls through to 800.
let wide = cmd((1 << 5) | 2, 0x80, 0, 0, 0x1_0001, 0);
let d = build(&[&[wide, play_pl(800)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(
resolve(&index, &mobjs, &|id| id == 1 || id == 800),
Some(800)
);
}
#[test]
fn power_on_psr_defaults_are_visible_to_compares() {
// (psr, expected power-on value)
let cases: [(u32, u32); 12] = [
(0, 1),
(1, 0xff),
(3, 1),
(4, 0xffff),
(5, 0xffff),
(6, 0),
(7, 0),
(8, 0),
(20, 2),
(31, 0x0003_0200),
(48, 0xffff_ffff),
(62, 0),
];
for (psr, want) in cases {
// CMP PSR == want (imm): true plays 1, false plays 2.
let cmp = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0x8000_0000 | psr, want);
let d = build(&[&[cmp, play_pl(1), play_pl(2)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
let got = resolve(&index, &mobjs, &|id| id == 1 || id == 2);
assert_eq!(got, Some(1), "PSR{psr} != {want:#x}");
}
}
+404 -2
View File
@@ -137,5 +137,407 @@ fn parse_program_info(data: &[u8]) -> Vec<ClpiStream> {
}
#[cfg(test)]
#[path = "clpi_tests.rs"]
mod tests;
mod tests {
use super::*;
/// Build a minimal CLPI binary.
/// `cpi_data` is the raw CPI section bytes (starting with the 4-byte CPI length).
fn build_clpi(source_packet_count: u32, cpi_data: Option<&[u8]>) -> Vec<u8> {
// Need at least 60 bytes for the header area: "HDMV"/"0200" magic,
// seq_info_start/prog_info_start (unused here), cpi_start, reserved
// padding, then the ClipInfo section ending in source_packet_count.
let cpi_start: u32 = if cpi_data.is_some() { 60 } else { 0 };
let mut buf = vec![0u8; 60];
// Magic + version
buf[0..4].copy_from_slice(b"HDMV");
buf[4..8].copy_from_slice(b"0200");
// seq_info_start = 0
// prog_info_start = 0
// cpi_start
buf[16..20].copy_from_slice(&cpi_start.to_be_bytes());
// source_packet_count at offset 56
buf[56..60].copy_from_slice(&source_packet_count.to_be_bytes());
if let Some(cpi) = cpi_data {
buf.extend_from_slice(cpi);
}
buf
}
#[test]
fn parse_truncated_clipinfo_parses_with_unknown_count() {
let full = build_clpi(1000, None);
for len in 16..60usize {
let c = parse(&full[..len]).unwrap_or_else(|e| panic!("len {len}: {e:?}"));
assert_eq!(c.source_packet_count, 0, "len {len}");
}
assert_eq!(parse(&full[..60]).expect("60").source_packet_count, 1000);
}
#[test]
fn parse_invalid_magic() {
let mut data = build_clpi(1000, None);
data[0] = b'X';
data[1] = b'X';
data[2] = b'X';
data[3] = b'X';
assert!(parse(&data).is_err());
}
// Added hardening tests, grounded in the BD-ROM CLPI spec byte layout. Build a ProgramInfo
// section; `streams` = Vec<(pid, sci_bytes)>.
fn build_program_info(streams: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut body = Vec::new();
body.push(0); // reserved (offset 4)
body.push(1); // num_programs = 1 (offset 5)
// program 0 header (8 bytes)
body.extend_from_slice(&0u32.to_be_bytes()); // spn_program_sequence_start
body.extend_from_slice(&0u16.to_be_bytes()); // program_map_pid
body.push(streams.len() as u8); // num_streams
body.push(0); // num_groups
for (pid, sci) in streams {
body.extend_from_slice(&pid.to_be_bytes());
body.push(sci.len() as u8);
body.extend_from_slice(sci);
}
// Prepend length(4) = bytes after the length field.
let mut out = Vec::new();
out.extend_from_slice(&(body.len() as u32).to_be_bytes());
out.extend_from_slice(&body);
out
}
/// Build a CLPI with a ProgramInfo section. prog_info_start is placed
/// right after the 60-byte header; cpi (if any) follows program_info.
fn build_clpi_with_proginfo(
source_packet_count: u32,
prog_info: &[u8],
cpi_data: Option<&[u8]>,
) -> Vec<u8> {
let mut buf = vec![0u8; 60];
buf[0..4].copy_from_slice(b"HDMV");
buf[4..8].copy_from_slice(b"0200");
let prog_info_start: u32 = 60;
buf[12..16].copy_from_slice(&prog_info_start.to_be_bytes());
let cpi_start: u32 = if cpi_data.is_some() {
(60 + prog_info.len()) as u32
} else {
0
};
buf[16..20].copy_from_slice(&cpi_start.to_be_bytes());
buf[56..60].copy_from_slice(&source_packet_count.to_be_bytes());
buf.extend_from_slice(prog_info);
if let Some(cpi) = cpi_data {
buf.extend_from_slice(cpi);
}
buf
}
/// source_packet_count is a big-endian u32 at offset [56..60]. Verify
/// BE decode of a value with all four bytes distinct (not LE / wrong
/// offset).
#[test]
fn source_packet_count_big_endian_offset_56() {
let data = build_clpi(0x01020304, None);
let clip = parse(&data).expect("should parse");
assert_eq!(clip.source_packet_count, 0x01020304);
}
/// Magic must be exactly "HDMV" at [0..4]. Anything else → ClpiParse.
/// Spec: CLPI files begin with the type_indicator "HDMV".
#[test]
fn wrong_magic_rejected() {
let mut data = build_clpi(1000, None);
data[0..4].copy_from_slice(b"INDX");
assert!(parse(&data).is_err());
}
/// Input shorter than the 16-byte header is rejected before any field read.
#[test]
fn under_16_bytes_rejected() {
assert!(parse(&[0u8; 15]).is_err());
assert!(parse(b"HDMV0200").is_err());
assert!(parse(&[]).is_err());
}
/// ProgramInfo: a video stream (coding 0x1B = H.264) carries
/// format/rate in sci[1] nibbles and NO language. Verify the video
/// arm: format hi-nibble, rate lo-nibble, language stays empty.
#[test]
fn program_info_video_stream() {
// sci = coding_type(0x1B) + format_rate(0x61 → fmt 6, rate 1)
let sci = vec![0x1Bu8, 0x61];
let pi = build_program_info(&[(0x1011, sci)]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should parse");
assert_eq!(clip.streams.len(), 1);
assert_eq!(clip.streams[0].pid, 0x1011);
assert_eq!(clip.streams[0].coding_type, 0x1B);
assert_eq!(clip.streams[0].language, "");
}
/// ProgramInfo primary-audio (coding 0x80..=0x86): sci[1] = format/rate
/// nibbles, sci[2..5] = ISO 639 language. Verify TrueHD (0x83) at
/// offset, 5.1 / 48kHz, language "eng".
#[test]
fn program_info_audio_stream_lang_offset() {
// sci = 0x83 + 0x61 (fmt 6, rate 1) + "eng"
let sci = vec![0x83u8, 0x61, b'e', b'n', b'g'];
let pi = build_program_info(&[(0x1100, sci)]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should parse");
assert_eq!(clip.streams[0].coding_type, 0x83);
assert_eq!(clip.streams[0].language, "eng");
}
/// An audio or TextST sci shorter than 5 bytes has no language field: the stream is
/// kept, the language stays empty, and nothing indexes past the sci.
#[test]
fn program_info_short_audio_and_textst_sci_keep_the_stream_without_a_language() {
let pi = build_program_info(&[
(0x1100, vec![0x83u8, 0x61, b'e', b'n']),
(0x1200, vec![0x92u8, 0x01, b'e', b'n']),
]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should parse");
assert_eq!(clip.streams.len(), 2);
assert!(clip.streams.iter().all(|s| s.language.is_empty()));
}
/// ProgramInfo PG (0x90)/IG (0x91): layout is coding_type(1)+lang(3),
/// so language is at sci[1..4] (NOT sci[2..5] like audio). Verify the
/// PG arm reads from the right offset.
#[test]
fn program_info_pg_lang_offset() {
// sci = 0x90 + "fra" (lang directly after coding_type)
let sci = vec![0x90u8, b'f', b'r', b'a'];
let pi = build_program_info(&[(0x1200, sci)]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should parse");
assert_eq!(clip.streams[0].coding_type, 0x90);
assert_eq!(clip.streams[0].language, "fra");
// Audio nibbles must NOT be populated for a PG stream.
}
/// ProgramInfo with multiple streams: PID and coding for each must be
/// read from the correct per-stream offset (pid(2)+sci_len(1)+sci).
/// Three mixed streams must all parse with distinct PIDs in order.
#[test]
fn program_info_multiple_streams_advance_correctly() {
let v = (0x1011u16, vec![0x24u8, 0x81]); // HEVC video
let a = (0x1100u16, vec![0x86u8, 0x61, b'e', b'n', b'g']); // DTS-HD MA
let s = (0x1200u16, vec![0x90u8, b'j', b'p', b'n']); // PG
let pi = build_program_info(&[v, a, s]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should parse");
assert_eq!(clip.streams.len(), 3);
assert_eq!(clip.streams[0].pid, 0x1011);
assert_eq!(clip.streams[0].coding_type, 0x24);
assert_eq!(clip.streams[1].pid, 0x1100);
assert_eq!(clip.streams[1].coding_type, 0x86);
assert_eq!(clip.streams[1].language, "eng");
assert_eq!(clip.streams[2].pid, 0x1200);
assert_eq!(clip.streams[2].language, "jpn");
}
// Best-effort: a stream whose sci_len runs past the section end
// (`sci_end > data.len()`) returns streams collected so far (none
// here), never panics — source returns `out` early on overflow.
#[test]
fn program_info_truncated_sci_no_panic() {
// One stream claiming sci_len = 200 but with no body.
let mut body = Vec::new();
body.push(0); // reserved
body.push(1); // num_programs
body.extend_from_slice(&0u32.to_be_bytes());
body.extend_from_slice(&0u16.to_be_bytes());
body.push(1); // num_streams
body.push(0); // num_groups
body.extend_from_slice(&0x1011u16.to_be_bytes()); // pid
body.push(200); // sci_len = 200, no body follows
let mut pi = Vec::new();
pi.extend_from_slice(&(body.len() as u32).to_be_bytes());
pi.extend_from_slice(&body);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should not panic");
assert!(clip.streams.is_empty());
}
/// parse_program_info rejects sci_len == 0 (`sci_len < 1` → return).
/// A zero-length stream_coding_info is unusable.
#[test]
fn program_info_zero_sci_len_yields_no_stream() {
let mut body = Vec::new();
body.push(0);
body.push(1);
body.extend_from_slice(&0u32.to_be_bytes());
body.extend_from_slice(&0u16.to_be_bytes());
body.push(1);
body.push(0);
body.extend_from_slice(&0x1011u16.to_be_bytes());
body.push(0); // sci_len = 0
let mut pi = Vec::new();
pi.extend_from_slice(&(body.len() as u32).to_be_bytes());
pi.extend_from_slice(&body);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should parse");
assert!(clip.streams.is_empty());
}
// Section-offset gates in `parse`: prog_info_start == 0 means "no ProgramInfo section" —
// offset-0 bytes must NOT be reinterpreted as a table.
#[test]
fn prog_info_start_zero_does_not_parse_header_as_program_info() {
let mut data = build_clpi(1000, None);
data[5] = 2; // num_programs = 2 if read from offset 0
// program 0 header = data[6..14]; its num_streams byte is data[12],
// which is prog_info_start's first byte and must stay 0.
data[20] = 1; // program 1 (header data[14..22]) declares 1 stream
data[22..24].copy_from_slice(&0x1011u16.to_be_bytes()); // pid
data[24] = 2; // sci_len
data[25] = 0x1B; // coding_type H.264
data[26] = 0x61; // video format 6 / rate 1
let clip = parse(&data).expect("should parse");
assert!(
clip.streams.is_empty(),
"prog_info_start == 0 must mean absent, got {:?}",
clip.streams
);
}
// parse_program_info: secondary audio (0xA1/0xA2) shares primary
// audio's sci layout — sci[1] = presentation-type/rate nibbles,
// sci[2..5] = ISO 639-2 language. Both sub-fields must be populated.
#[test]
fn program_info_secondary_audio_fields() {
for coding in [c_ac3_plus_secondary(), c_dts_hd_secondary()] {
let sci = vec![coding, 0x61, b'd', b'e', b'u'];
let pi = build_program_info(&[(0x1A00, sci)]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should parse");
assert_eq!(clip.streams.len(), 1, "coding {coding:#04x}");
let s = &clip.streams[0];
assert_eq!(s.coding_type, coding);
// 0x61: high nibble 6, low nibble 1 — distinct values, so a
// swapped/ORed/XORed nibble extraction cannot pass.
assert_eq!(s.language, "deu", "coding {coding:#04x}");
}
}
fn c_ac3_plus_secondary() -> u8 {
crate::consts::coding_type::AC3_PLUS_SECONDARY
}
fn c_dts_hd_secondary() -> u8 {
crate::consts::coding_type::DTS_HD_SECONDARY
}
// A 1-byte sci (coding_type only) is the accepted minimum: PID and
// coding_type get recorded, all sub-fields needing more bytes stay
// empty — a PG stream must NOT read sci[1..4] when only sci[0] exists.
#[test]
fn program_info_sci_len_one_yields_bare_stream() {
let pi = build_program_info(&[(0x1200, vec![0x90u8])]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should not panic");
assert_eq!(clip.streams.len(), 1);
assert_eq!(clip.streams[0].pid, 0x1200);
assert_eq!(clip.streams[0].coding_type, 0x90);
assert_eq!(clip.streams[0].language, "");
}
/// Below the 6-byte ProgramInfo header (length(4)+reserved(1)+
/// num_programs(1)) there is nothing to read; the length guard must fire
/// before `data[5]`.
#[test]
fn program_info_below_header_size_is_empty() {
for len in 0..6usize {
assert!(parse_program_info(&vec![0u8; len]).is_empty(), "len={len}");
}
}
/// A declared program whose 8-byte header runs past the section end must
/// stop before reading num_streams at `data[pos + 6]`.
#[test]
fn program_info_truncated_program_header_is_empty() {
// length(4) + reserved(1) + num_programs=1 (1) + only 4 of the 8
// program-header bytes.
let mut data = vec![0u8; 6];
data[5] = 1;
data.extend_from_slice(&[0u8; 4]);
assert!(parse_program_info(&data).is_empty());
}
/// A declared stream whose 3-byte header (pid(2)+sci_len(1)) runs past
/// the section end must stop before reading the PID.
#[test]
fn program_info_truncated_stream_header_is_empty() {
let mut data = vec![0u8; 6];
data[5] = 1; // num_programs
data.extend_from_slice(&[0u8; 8]); // program header
data[6 + 6] = 1; // num_streams = 1
data.extend_from_slice(&[0u8; 2]); // only 2 of the 3 stream bytes
assert_eq!(data.len(), 16);
assert!(parse_program_info(&data).is_empty());
}
/// TextST (0x92): coding_type + character_code + 3-byte language, so the
/// language sits at sci[2..5].
#[test]
fn program_info_text_subtitle_lang_offset() {
let sci = vec![0x92u8, 0x01, b'd', b'e', b'u'];
let pi = build_program_info(&[(0x1800, sci)]);
let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse");
assert_eq!(clip.streams[0].coding_type, 0x92);
assert_eq!(clip.streams[0].language, "deu");
}
/// IG (0x91) shares the PG layout: language at sci[1..4].
#[test]
fn program_info_ig_lang_offset() {
let sci = vec![0x91u8, b'e', b's', b'p'];
let pi = build_program_info(&[(0x1400, sci)]);
let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse");
assert_eq!(clip.streams[0].language, "esp");
}
/// Streams from every program are collected, in order.
#[test]
fn program_info_multiple_programs_all_collected() {
let mut body = vec![0u8, 2]; // reserved, num_programs = 2
for (pid, lang) in [(0x1200u16, b"fra"), (0x1201u16, b"jpn")] {
body.extend_from_slice(&[0u8; 6]); // spn + pmt_pid
body.extend_from_slice(&[1, 0]); // num_streams, num_groups
body.extend_from_slice(&pid.to_be_bytes());
body.push(4);
body.push(0x90);
body.extend_from_slice(lang);
}
let mut pi = (body.len() as u32).to_be_bytes().to_vec();
pi.extend_from_slice(&body);
let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse");
assert_eq!(clip.streams.len(), 2);
assert_eq!(clip.streams[0].language, "fra");
assert_eq!(clip.streams[1].pid, 0x1201);
assert_eq!(clip.streams[1].language, "jpn");
}
/// A trailing CPI section after ProgramInfo does not disturb parsing.
#[test]
fn cpi_section_present_still_parses() {
let pi = build_program_info(&[(0x1200, vec![0x90u8, b'f', b'r', b'a'])]);
let cpi = [0u8, 0, 0, 2, 0, 0];
let data = build_clpi_with_proginfo(777, &pi, Some(&cpi));
let clip = parse(&data).expect("parse");
assert_eq!(clip.source_packet_count, 777);
assert_eq!(clip.streams.len(), 1);
let clip = parse(&build_clpi(5, Some(&cpi))).expect("parse");
assert_eq!(clip.source_packet_count, 5);
}
// ─────────────────────────────────────────────────────────────────────
// parse_cpi — low-level fixtures
// ─────────────────────────────────────────────────────────────────────
}
-402
View File
@@ -1,402 +0,0 @@
use super::*;
/// Build a minimal CLPI binary.
/// `cpi_data` is the raw CPI section bytes (starting with the 4-byte CPI length).
fn build_clpi(source_packet_count: u32, cpi_data: Option<&[u8]>) -> Vec<u8> {
// Need at least 60 bytes for the header area: "HDMV"/"0200" magic,
// seq_info_start/prog_info_start (unused here), cpi_start, reserved
// padding, then the ClipInfo section ending in source_packet_count.
let cpi_start: u32 = if cpi_data.is_some() { 60 } else { 0 };
let mut buf = vec![0u8; 60];
// Magic + version
buf[0..4].copy_from_slice(b"HDMV");
buf[4..8].copy_from_slice(b"0200");
// seq_info_start = 0
// prog_info_start = 0
// cpi_start
buf[16..20].copy_from_slice(&cpi_start.to_be_bytes());
// source_packet_count at offset 56
buf[56..60].copy_from_slice(&source_packet_count.to_be_bytes());
if let Some(cpi) = cpi_data {
buf.extend_from_slice(cpi);
}
buf
}
#[test]
fn parse_truncated_clipinfo_parses_with_unknown_count() {
let full = build_clpi(1000, None);
for len in 16..60usize {
let c = parse(&full[..len]).unwrap_or_else(|e| panic!("len {len}: {e:?}"));
assert_eq!(c.source_packet_count, 0, "len {len}");
}
assert_eq!(parse(&full[..60]).expect("60").source_packet_count, 1000);
}
#[test]
fn parse_invalid_magic() {
let mut data = build_clpi(1000, None);
data[0] = b'X';
data[1] = b'X';
data[2] = b'X';
data[3] = b'X';
assert!(parse(&data).is_err());
}
// Added hardening tests, grounded in the BD-ROM CLPI spec byte layout. Build a ProgramInfo
// section; `streams` = Vec<(pid, sci_bytes)>.
fn build_program_info(streams: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut body = Vec::new();
body.push(0); // reserved (offset 4)
body.push(1); // num_programs = 1 (offset 5)
// program 0 header (8 bytes)
body.extend_from_slice(&0u32.to_be_bytes()); // spn_program_sequence_start
body.extend_from_slice(&0u16.to_be_bytes()); // program_map_pid
body.push(streams.len() as u8); // num_streams
body.push(0); // num_groups
for (pid, sci) in streams {
body.extend_from_slice(&pid.to_be_bytes());
body.push(sci.len() as u8);
body.extend_from_slice(sci);
}
// Prepend length(4) = bytes after the length field.
let mut out = Vec::new();
out.extend_from_slice(&(body.len() as u32).to_be_bytes());
out.extend_from_slice(&body);
out
}
/// Build a CLPI with a ProgramInfo section. prog_info_start is placed
/// right after the 60-byte header; cpi (if any) follows program_info.
fn build_clpi_with_proginfo(
source_packet_count: u32,
prog_info: &[u8],
cpi_data: Option<&[u8]>,
) -> Vec<u8> {
let mut buf = vec![0u8; 60];
buf[0..4].copy_from_slice(b"HDMV");
buf[4..8].copy_from_slice(b"0200");
let prog_info_start: u32 = 60;
buf[12..16].copy_from_slice(&prog_info_start.to_be_bytes());
let cpi_start: u32 = if cpi_data.is_some() {
(60 + prog_info.len()) as u32
} else {
0
};
buf[16..20].copy_from_slice(&cpi_start.to_be_bytes());
buf[56..60].copy_from_slice(&source_packet_count.to_be_bytes());
buf.extend_from_slice(prog_info);
if let Some(cpi) = cpi_data {
buf.extend_from_slice(cpi);
}
buf
}
/// source_packet_count is a big-endian u32 at offset [56..60]. Verify
/// BE decode of a value with all four bytes distinct (not LE / wrong
/// offset).
#[test]
fn source_packet_count_big_endian_offset_56() {
let data = build_clpi(0x01020304, None);
let clip = parse(&data).expect("should parse");
assert_eq!(clip.source_packet_count, 0x01020304);
}
/// Magic must be exactly "HDMV" at [0..4]. Anything else → ClpiParse.
/// Spec: CLPI files begin with the type_indicator "HDMV".
#[test]
fn wrong_magic_rejected() {
let mut data = build_clpi(1000, None);
data[0..4].copy_from_slice(b"INDX");
assert!(parse(&data).is_err());
}
/// Input shorter than the 16-byte header is rejected before any field read.
#[test]
fn under_16_bytes_rejected() {
assert!(parse(&[0u8; 15]).is_err());
assert!(parse(b"HDMV0200").is_err());
assert!(parse(&[]).is_err());
}
/// ProgramInfo: a video stream (coding 0x1B = H.264) carries
/// format/rate in sci[1] nibbles and NO language. Verify the video
/// arm: format hi-nibble, rate lo-nibble, language stays empty.
#[test]
fn program_info_video_stream() {
// sci = coding_type(0x1B) + format_rate(0x61 → fmt 6, rate 1)
let sci = vec![0x1Bu8, 0x61];
let pi = build_program_info(&[(0x1011, sci)]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should parse");
assert_eq!(clip.streams.len(), 1);
assert_eq!(clip.streams[0].pid, 0x1011);
assert_eq!(clip.streams[0].coding_type, 0x1B);
assert_eq!(clip.streams[0].language, "");
}
/// ProgramInfo primary-audio (coding 0x80..=0x86): sci[1] = format/rate
/// nibbles, sci[2..5] = ISO 639 language. Verify TrueHD (0x83) at
/// offset, 5.1 / 48kHz, language "eng".
#[test]
fn program_info_audio_stream_lang_offset() {
// sci = 0x83 + 0x61 (fmt 6, rate 1) + "eng"
let sci = vec![0x83u8, 0x61, b'e', b'n', b'g'];
let pi = build_program_info(&[(0x1100, sci)]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should parse");
assert_eq!(clip.streams[0].coding_type, 0x83);
assert_eq!(clip.streams[0].language, "eng");
}
/// An audio or TextST sci shorter than 5 bytes has no language field: the stream is
/// kept, the language stays empty, and nothing indexes past the sci.
#[test]
fn program_info_short_audio_and_textst_sci_keep_the_stream_without_a_language() {
let pi = build_program_info(&[
(0x1100, vec![0x83u8, 0x61, b'e', b'n']),
(0x1200, vec![0x92u8, 0x01, b'e', b'n']),
]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should parse");
assert_eq!(clip.streams.len(), 2);
assert!(clip.streams.iter().all(|s| s.language.is_empty()));
}
/// ProgramInfo PG (0x90)/IG (0x91): layout is coding_type(1)+lang(3),
/// so language is at sci[1..4] (NOT sci[2..5] like audio). Verify the
/// PG arm reads from the right offset.
#[test]
fn program_info_pg_lang_offset() {
// sci = 0x90 + "fra" (lang directly after coding_type)
let sci = vec![0x90u8, b'f', b'r', b'a'];
let pi = build_program_info(&[(0x1200, sci)]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should parse");
assert_eq!(clip.streams[0].coding_type, 0x90);
assert_eq!(clip.streams[0].language, "fra");
// Audio nibbles must NOT be populated for a PG stream.
}
/// ProgramInfo with multiple streams: PID and coding for each must be
/// read from the correct per-stream offset (pid(2)+sci_len(1)+sci).
/// Three mixed streams must all parse with distinct PIDs in order.
#[test]
fn program_info_multiple_streams_advance_correctly() {
let v = (0x1011u16, vec![0x24u8, 0x81]); // HEVC video
let a = (0x1100u16, vec![0x86u8, 0x61, b'e', b'n', b'g']); // DTS-HD MA
let s = (0x1200u16, vec![0x90u8, b'j', b'p', b'n']); // PG
let pi = build_program_info(&[v, a, s]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should parse");
assert_eq!(clip.streams.len(), 3);
assert_eq!(clip.streams[0].pid, 0x1011);
assert_eq!(clip.streams[0].coding_type, 0x24);
assert_eq!(clip.streams[1].pid, 0x1100);
assert_eq!(clip.streams[1].coding_type, 0x86);
assert_eq!(clip.streams[1].language, "eng");
assert_eq!(clip.streams[2].pid, 0x1200);
assert_eq!(clip.streams[2].language, "jpn");
}
// Best-effort: a stream whose sci_len runs past the section end
// (`sci_end > data.len()`) returns streams collected so far (none
// here), never panics — source returns `out` early on overflow.
#[test]
fn program_info_truncated_sci_no_panic() {
// One stream claiming sci_len = 200 but with no body.
let mut body = Vec::new();
body.push(0); // reserved
body.push(1); // num_programs
body.extend_from_slice(&0u32.to_be_bytes());
body.extend_from_slice(&0u16.to_be_bytes());
body.push(1); // num_streams
body.push(0); // num_groups
body.extend_from_slice(&0x1011u16.to_be_bytes()); // pid
body.push(200); // sci_len = 200, no body follows
let mut pi = Vec::new();
pi.extend_from_slice(&(body.len() as u32).to_be_bytes());
pi.extend_from_slice(&body);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should not panic");
assert!(clip.streams.is_empty());
}
/// parse_program_info rejects sci_len == 0 (`sci_len < 1` → return).
/// A zero-length stream_coding_info is unusable.
#[test]
fn program_info_zero_sci_len_yields_no_stream() {
let mut body = Vec::new();
body.push(0);
body.push(1);
body.extend_from_slice(&0u32.to_be_bytes());
body.extend_from_slice(&0u16.to_be_bytes());
body.push(1);
body.push(0);
body.extend_from_slice(&0x1011u16.to_be_bytes());
body.push(0); // sci_len = 0
let mut pi = Vec::new();
pi.extend_from_slice(&(body.len() as u32).to_be_bytes());
pi.extend_from_slice(&body);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should parse");
assert!(clip.streams.is_empty());
}
// Section-offset gates in `parse`: prog_info_start == 0 means "no ProgramInfo section" —
// offset-0 bytes must NOT be reinterpreted as a table.
#[test]
fn prog_info_start_zero_does_not_parse_header_as_program_info() {
let mut data = build_clpi(1000, None);
data[5] = 2; // num_programs = 2 if read from offset 0
// program 0 header = data[6..14]; its num_streams byte is data[12],
// which is prog_info_start's first byte and must stay 0.
data[20] = 1; // program 1 (header data[14..22]) declares 1 stream
data[22..24].copy_from_slice(&0x1011u16.to_be_bytes()); // pid
data[24] = 2; // sci_len
data[25] = 0x1B; // coding_type H.264
data[26] = 0x61; // video format 6 / rate 1
let clip = parse(&data).expect("should parse");
assert!(
clip.streams.is_empty(),
"prog_info_start == 0 must mean absent, got {:?}",
clip.streams
);
}
// parse_program_info: secondary audio (0xA1/0xA2) shares primary
// audio's sci layout — sci[1] = presentation-type/rate nibbles,
// sci[2..5] = ISO 639-2 language. Both sub-fields must be populated.
#[test]
fn program_info_secondary_audio_fields() {
for coding in [c_ac3_plus_secondary(), c_dts_hd_secondary()] {
let sci = vec![coding, 0x61, b'd', b'e', b'u'];
let pi = build_program_info(&[(0x1A00, sci)]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should parse");
assert_eq!(clip.streams.len(), 1, "coding {coding:#04x}");
let s = &clip.streams[0];
assert_eq!(s.coding_type, coding);
// 0x61: high nibble 6, low nibble 1 — distinct values, so a
// swapped/ORed/XORed nibble extraction cannot pass.
assert_eq!(s.language, "deu", "coding {coding:#04x}");
}
}
fn c_ac3_plus_secondary() -> u8 {
crate::consts::coding_type::AC3_PLUS_SECONDARY
}
fn c_dts_hd_secondary() -> u8 {
crate::consts::coding_type::DTS_HD_SECONDARY
}
// A 1-byte sci (coding_type only) is the accepted minimum: PID and
// coding_type get recorded, all sub-fields needing more bytes stay
// empty — a PG stream must NOT read sci[1..4] when only sci[0] exists.
#[test]
fn program_info_sci_len_one_yields_bare_stream() {
let pi = build_program_info(&[(0x1200, vec![0x90u8])]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should not panic");
assert_eq!(clip.streams.len(), 1);
assert_eq!(clip.streams[0].pid, 0x1200);
assert_eq!(clip.streams[0].coding_type, 0x90);
assert_eq!(clip.streams[0].language, "");
}
/// Below the 6-byte ProgramInfo header (length(4)+reserved(1)+
/// num_programs(1)) there is nothing to read; the length guard must fire
/// before `data[5]`.
#[test]
fn program_info_below_header_size_is_empty() {
for len in 0..6usize {
assert!(parse_program_info(&vec![0u8; len]).is_empty(), "len={len}");
}
}
/// A declared program whose 8-byte header runs past the section end must
/// stop before reading num_streams at `data[pos + 6]`.
#[test]
fn program_info_truncated_program_header_is_empty() {
// length(4) + reserved(1) + num_programs=1 (1) + only 4 of the 8
// program-header bytes.
let mut data = vec![0u8; 6];
data[5] = 1;
data.extend_from_slice(&[0u8; 4]);
assert!(parse_program_info(&data).is_empty());
}
/// A declared stream whose 3-byte header (pid(2)+sci_len(1)) runs past
/// the section end must stop before reading the PID.
#[test]
fn program_info_truncated_stream_header_is_empty() {
let mut data = vec![0u8; 6];
data[5] = 1; // num_programs
data.extend_from_slice(&[0u8; 8]); // program header
data[6 + 6] = 1; // num_streams = 1
data.extend_from_slice(&[0u8; 2]); // only 2 of the 3 stream bytes
assert_eq!(data.len(), 16);
assert!(parse_program_info(&data).is_empty());
}
/// TextST (0x92): coding_type + character_code + 3-byte language, so the
/// language sits at sci[2..5].
#[test]
fn program_info_text_subtitle_lang_offset() {
let sci = vec![0x92u8, 0x01, b'd', b'e', b'u'];
let pi = build_program_info(&[(0x1800, sci)]);
let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse");
assert_eq!(clip.streams[0].coding_type, 0x92);
assert_eq!(clip.streams[0].language, "deu");
}
/// IG (0x91) shares the PG layout: language at sci[1..4].
#[test]
fn program_info_ig_lang_offset() {
let sci = vec![0x91u8, b'e', b's', b'p'];
let pi = build_program_info(&[(0x1400, sci)]);
let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse");
assert_eq!(clip.streams[0].language, "esp");
}
/// Streams from every program are collected, in order.
#[test]
fn program_info_multiple_programs_all_collected() {
let mut body = vec![0u8, 2]; // reserved, num_programs = 2
for (pid, lang) in [(0x1200u16, b"fra"), (0x1201u16, b"jpn")] {
body.extend_from_slice(&[0u8; 6]); // spn + pmt_pid
body.extend_from_slice(&[1, 0]); // num_streams, num_groups
body.extend_from_slice(&pid.to_be_bytes());
body.push(4);
body.push(0x90);
body.extend_from_slice(lang);
}
let mut pi = (body.len() as u32).to_be_bytes().to_vec();
pi.extend_from_slice(&body);
let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse");
assert_eq!(clip.streams.len(), 2);
assert_eq!(clip.streams[0].language, "fra");
assert_eq!(clip.streams[1].pid, 0x1201);
assert_eq!(clip.streams[1].language, "jpn");
}
/// A trailing CPI section after ProgramInfo does not disturb parsing.
#[test]
fn cpi_section_present_still_parses() {
let pi = build_program_info(&[(0x1200, vec![0x90u8, b'f', b'r', b'a'])]);
let cpi = [0u8, 0, 0, 2, 0, 0];
let data = build_clpi_with_proginfo(777, &pi, Some(&cpi));
let clip = parse(&data).expect("parse");
assert_eq!(clip.source_packet_count, 777);
assert_eq!(clip.streams.len(), 1);
let clip = parse(&build_clpi(5, Some(&cpi))).expect("parse");
assert_eq!(clip.source_packet_count, 5);
}
// ─────────────────────────────────────────────────────────────────────
// parse_cpi — low-level fixtures
// ─────────────────────────────────────────────────────────────────────
+673 -2
View File
@@ -270,5 +270,676 @@ fn crack_title_key_inner(sector: &[u8]) -> Option<[u8; 5]> {
}
#[cfg(test)]
#[path = "keyless_tests.rs"]
mod tests;
mod tests {
use super::super::lfsr::scramble_sector;
use super::*;
// Synthesize a scrambled sector for a title key/seed with `plain` at byte 0x80.
fn synth_sector(title_key: &[u8; 5], seed: &[u8; 5], plain: &[u8]) -> (Vec<u8>, Vec<u8>) {
let mut plaintext = vec![0u8; SECTOR_BYTES];
plaintext[0..4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
plaintext[4] = 0x44; // '01': a 13818-1 pack
plaintext[FLAG_BYTE] = 0x10;
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
plaintext[ENCRYPTED_START..ENCRYPTED_START + plain.len()].copy_from_slice(plain);
let body = plaintext.clone();
// scramble_sector turns the plaintext body into ciphertext and sets
// the scramble flag.
scramble_sector(title_key, &mut plaintext);
(plaintext, body)
}
// Synthesize a sector whose cleartext ends in a periodic run continuing into the encrypted
// region.
fn synth_periodic_sector(
title_key: &[u8; 5],
seed: &[u8; 5],
period: usize,
) -> (Vec<u8>, Vec<u8>) {
let mut plaintext = vec![0u8; SECTOR_BYTES];
plaintext[FLAG_BYTE] = 0x10;
// Periodic run over the cleartext header tail (RUN_START..0x80) into
// the encrypted region, mirroring a real VOB. Must not overlap the seed
// bytes (0x54..0x59); RUN_START=0x59 gives >=2 cycles of every period.
const RUN_START: usize = 0x59;
let pat: Vec<u8> = (0..period)
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
.collect();
for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) {
*b = pat[i % period];
}
// Seed sits below the run, undisturbed.
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
let body = plaintext.clone();
scramble_sector(title_key, &mut plaintext);
(plaintext, body)
}
#[test]
fn crack_unscrambled_returns_none() {
let sector = vec![0u8; SECTOR_BYTES];
assert!(crack_title_key(&sector).is_none());
}
#[test]
fn crack_too_short_returns_none() {
let sector = vec![0u8; 100];
assert!(crack_title_key(&sector).is_none());
}
#[test]
fn recover_needs_min_plain() {
let sector = vec![0u8; SECTOR_BYTES];
let short_plain = [0u8; 4];
assert!(recover_title_key(&sector, &short_plain).is_none());
}
/// The known plaintext used at byte 0x80 for the direct-recovery tests.
/// A realistic MPEG-2 PES header start.
const PES: [u8; 10] = [0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21];
// MANDATORY round-trip (Task C.1): recovered key must descramble the body back to
// plaintext.
#[test]
fn recover_round_trips_known_keys() {
let cases: &[([u8; 5], [u8; 5])] = &[
(
[0x42, 0x13, 0x37, 0xBE, 0xEF],
[0x11, 0x22, 0x33, 0x44, 0x55],
),
(
[0x01, 0x02, 0x03, 0x04, 0x05],
[0xDE, 0xAD, 0xBE, 0xEF, 0x42],
),
(
[0xFE, 0xDC, 0xBA, 0x98, 0x76],
[0x00, 0xFF, 0x80, 0x7F, 0x01],
),
(
[0x9A, 0x78, 0x56, 0x34, 0x12],
[0xA5, 0x5A, 0x0F, 0xF0, 0xCC],
),
(
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
[0x01, 0x01, 0x01, 0x01, 0x01],
),
];
for (title_key, seed) in cases {
let (mut sector, body) = synth_sector(title_key, seed, &PES);
let recovered =
recover_title_key(&sector, &PES).expect("recover_title_key returned None");
descramble_sector(&recovered, &mut sector);
assert_eq!(
&sector[ENCRYPTED_START..SECTOR_BYTES],
&body[ENCRYPTED_START..SECTOR_BYTES],
"recovered key did not descramble the full body for \
title={title_key:02x?} seed={seed:02x?}"
);
}
}
// descramble_matches is the ONLY gate between the LFSR search and a key handed to the
// caller.
#[test]
fn descramble_matches_accepts_only_the_key_the_sector_was_scrambled_with() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let (sector, _body) = synth_sector(&title_key, &seed, &PES);
assert!(
descramble_matches(&sector, &title_key, &PES),
"the key the sector was scrambled with must be accepted"
);
for byte in 0..5usize {
for bit in 0..8u32 {
let mut wrong = title_key;
wrong[byte] ^= 1u8 << bit;
assert!(
!descramble_matches(&sector, &wrong, &PES),
"key differing only in byte {byte} bit {bit} must be rejected"
);
}
}
}
// The gate must verify against a COPY, never mutate the caller's sector.
#[test]
fn descramble_matches_does_not_disturb_the_caller_s_sector() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let (sector, _body) = synth_sector(&title_key, &seed, &PES);
let before = sector.clone();
assert!(descramble_matches(&sector, &title_key, &PES));
let mut wrong = title_key;
wrong[0] ^= 0x01;
assert!(!descramble_matches(&sector, &wrong, &PES));
assert_eq!(
sector, before,
"verification must leave the sector byte-for-byte unchanged"
);
}
/// MANDATORY (Task C.1): the crib-based entry point crack_title_key —
/// no plaintext supplied — recovers a round-tripping key when the
/// cleartext ends in a periodic run that continues into 0x80.
#[test]
fn crack_title_key_recovers_via_attack_pattern() {
for &period in &[2usize, 3, 5, 8, 16] {
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
let (sector, body) = synth_periodic_sector(&title_key, &seed, period);
let cracked = crack_title_key(&sector)
.unwrap_or_else(|| panic!("crack_title_key returned None for period {period}"));
let mut test = sector.clone();
descramble_sector(&cracked, &mut test);
assert_eq!(
&test[ENCRYPTED_START..SECTOR_BYTES],
&body[ENCRYPTED_START..SECTOR_BYTES],
"crack_title_key key did not round-trip the body (period {period})"
);
}
}
/// recover_title_key_from_plain inverts descramble_sector exactly: scramble
/// a known body, hand back the keystream-derived key, and the recovered
/// key (XOR-back included) reproduces the plaintext.
#[test]
fn recovered_key_descrambles_back_to_plaintext() {
let cases: &[([u8; 5], [u8; 5])] = &[
(
[0x42, 0x13, 0x37, 0xBE, 0xEF],
[0x11, 0x22, 0x33, 0x44, 0x55],
),
(
[0x9A, 0x78, 0x56, 0x34, 0x12],
[0xA5, 0x5A, 0x0F, 0xF0, 0xCC],
),
(
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
[0x01, 0x01, 0x01, 0x01, 0x01],
),
];
for (title_key, seed) in cases {
let (mut sector, body) = synth_sector(title_key, seed, &PES);
let recovered =
recover_title_key(&sector, &PES).expect("recover_title_key returned None");
descramble_sector(&recovered, &mut sector);
assert_eq!(
&sector[ENCRYPTED_START..SECTOR_BYTES],
&body[ENCRYPTED_START..SECTOR_BYTES],
"descramble with recovered key did not reproduce the body \
for title={title_key:02x?} seed={seed:02x?}"
);
}
}
// ── early-return guards ────────────────────────────────────────────────
#[test]
fn recover_rejects_sector_one_byte_short() {
let mut sector = vec![0u8; SECTOR_BYTES - 1];
sector[FLAG_BYTE] = 0x30;
assert!(recover_title_key(&sector, &PES).is_none());
}
#[test]
fn recover_rejects_unscrambled_sector() {
let sector = vec![0x00u8; SECTOR_BYTES];
assert!(recover_title_key(&sector, &PES).is_none());
}
#[test]
fn recover_high_flag_bits_are_not_scramble() {
for &flag in &[0x40u8, 0x80, 0xC0] {
let mut sector = vec![0x11u8; SECTOR_BYTES];
sector[FLAG_BYTE] = flag;
assert!(
recover_title_key(&sector, &PES).is_none(),
"flag {flag:#04x} has scramble bits clear; recover must return None"
);
}
}
#[test]
fn crack_high_flag_bits_are_not_scramble() {
for &flag in &[0x40u8, 0x80, 0xC0] {
let mut sector = vec![0x11u8; SECTOR_BYTES];
sector[FLAG_BYTE] = flag;
assert!(
crack_title_key(&sector).is_none(),
"flag {flag:#04x} clear scramble bits -> crack must return None"
);
}
}
#[test]
fn crack_rejects_sector_one_byte_short() {
let mut sector = vec![0u8; SECTOR_BYTES - 1];
if sector.len() > FLAG_BYTE {
sector[FLAG_BYTE] = 0x30;
}
assert!(crack_title_key(&sector).is_none());
}
/// crack_title_key must never panic on a fully scrambled sector with
/// arbitrary (non-periodic) content — it just returns None.
#[test]
fn crack_full_path_never_panics() {
for seed in 0u32..3 {
let mut sector = vec![0u8; SECTOR_BYTES];
sector[FLAG_BYTE] = 0x30;
let mut x = seed.wrapping_mul(2_654_435_761).wrapping_add(7);
for b in sector.iter_mut().skip(0x80) {
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
*b = (x >> 16) as u8;
}
for (i, b) in sector[SEED_OFFSET..SEED_OFFSET + 5].iter_mut().enumerate() {
*b = (seed.wrapping_add(i as u32) ^ 0xA5) as u8;
}
let _ = crack_title_key(&sector);
}
}
// ── entry-point guards on caller- and disc-supplied lengths: a sector buffer that ENDS
// inside the encrypted region must be refused, not sliced.
#[test]
fn recover_rejects_a_sector_that_ends_inside_the_encrypted_region() {
for len in [0x81usize, 0x85, 0x89] {
let mut sector = vec![0x11u8; len];
sector[FLAG_BYTE] = 0x30; // scrambled, so no other guard fires first
assert!(
recover_title_key(&sector, &PES).is_none(),
"a {len}-byte buffer cannot supply ten ciphertext bytes at 0x80"
);
}
}
// A buffer LONGER than one sector is still one sector: both entry points must recover from
// the first SECTOR_BYTES.
#[test]
fn a_buffer_longer_than_one_sector_still_yields_its_key() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let (sector, _) = synth_sector(&title_key, &seed, &PES);
let mut padded = sector.clone();
padded.extend_from_slice(&[0xA7u8; 512]);
assert_eq!(
recover_title_key(&padded, &PES),
Some(title_key),
"a two-and-a-bit-sector buffer must still recover the first sector's key"
);
let (periodic, _) = synth_periodic_sector(&title_key, &seed, 5);
let mut padded = periodic.clone();
padded.extend_from_slice(&[0xA7u8; 512]);
assert_eq!(
crack_title_key(&padded),
crack_title_key(&periodic),
"padding past the sector must not change the crack result"
);
assert!(crack_title_key(&padded).is_some());
}
// recover_title_key accepts and uses MORE than ten bytes of known plaintext (ten is a
// MINIMUM).
#[test]
fn recover_accepts_more_than_ten_bytes_of_known_plaintext() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let long_plain: Vec<u8> = (0..64u8)
.map(|k| k.wrapping_mul(37).wrapping_add(5))
.collect();
let (sector, _) = synth_sector(&title_key, &seed, &long_plain);
assert_eq!(
recover_title_key(&sector, &long_plain),
Some(title_key),
"64 bytes of known plaintext must be accepted, not rejected as \
'more than ten'"
);
}
// The scramble-flag gate on a sector whose BODY really is ciphertext and whose key IS
// recoverable.
#[test]
fn a_recoverable_sector_with_the_scramble_bits_cleared_is_still_refused() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let (mut sector, _) = synth_sector(&title_key, &seed, &PES);
assert_eq!(
recover_title_key(&sector, &PES),
Some(title_key),
"fixture check: with the flag set this sector's key IS recoverable"
);
sector[FLAG_BYTE] = 0x00;
assert_eq!(
recover_title_key(&sector, &PES),
None,
"scramble bits clear → no title key, even though one could be found"
);
let (mut periodic, _) = synth_periodic_sector(&title_key, &seed, 5);
assert!(
crack_title_key(&periodic).is_some(),
"fixture check: with the flag set this sector cracks"
);
assert!(
attack_crib(&periodic).is_some(),
"fixture check: with the flag set this sector has a usable crib"
);
periodic[FLAG_BYTE] = 0x00;
assert_eq!(
crack_title_key(&periodic),
None,
"scramble bits clear → no crack, even though one would succeed"
);
// `attack_crib` carries its own copy of the same gate and is the one that
// actually stops the crack (`crack_title_key`'s is defensive duplication);
// it also doubles as the decrypt path's cached-key oracle.
assert_eq!(
attack_crib(&periodic),
None,
"an unscrambled sector has no predicted plaintext to offer"
);
}
// ── descramble_matches: the verification gate's own mechanics — the gate must verify
// against the sector's CIPHERTEXT regardless of the flag byte.
#[test]
fn descramble_matches_forces_the_scramble_flag_on_its_own_copy() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let (mut sector, _) = synth_sector(&title_key, &seed, &PES);
sector[FLAG_BYTE] = 0x00;
assert!(
descramble_matches(&sector, &title_key, &PES),
"the body is ciphertext and the key is right — the gate must \
descramble it even though the flag byte says otherwise"
);
let mut wrong = title_key;
wrong[0] ^= 0x01;
assert!(!descramble_matches(&sector, &wrong, &PES));
}
// The gate compares the WHOLE supplied plaintext, clamped to the encrypted region.
#[test]
fn descramble_matches_compares_all_of_the_plaintext_and_no_more_than_the_sector() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let body: Vec<u8> = (0..64u8)
.map(|k| k.wrapping_mul(29).wrapping_add(3))
.collect();
let (sector, _) = synth_sector(&title_key, &seed, &body);
assert!(descramble_matches(&sector, &title_key, &body));
// A crib agreeing for the first 16 bytes and diverging after must be
// rejected: the comparison window is the crib's length, not a fixed
// prefix.
let mut tail_wrong = body.clone();
tail_wrong[40] ^= 0xFF;
assert!(
!descramble_matches(&sector, &title_key, &tail_wrong),
"a crib that diverges at byte 40 must not match"
);
assert_eq!(
tail_wrong[..16],
body[..16],
"fixture check: the first 16 bytes are identical, so only a \
comparison that runs past them can tell these apart"
);
// A crib LONGER than the encrypted region: the comparison is clamped to
// the sector, not run off the end of it.
let plain_len = SECTOR_BYTES - ENCRYPTED_START;
let mut over_long = vec![0u8; plain_len + 10];
let (full_sector, full_body) = synth_sector(&title_key, &seed, &[0x00u8; 10]);
over_long[..plain_len].copy_from_slice(&full_body[ENCRYPTED_START..]);
assert!(
descramble_matches(&full_sector, &title_key, &over_long),
"a crib longer than the encrypted region must be clamped, not \
compared past the end of the sector"
);
}
// ── attack_crib: known-answer vectors. Sector whose clear header ends in
// a period-length run of run_len bytes before 0x80, rest 0xFF.
fn sector_with_trailing_run(period: usize, run_len: usize) -> Vec<u8> {
assert!(
run_len < ENCRYPTED_START,
"the run lives in the clear header"
);
let mut sector = vec![0u8; SECTOR_BYTES];
sector[FLAG_BYTE] = 0x10;
for b in sector[ENCRYPTED_START..].iter_mut() {
*b = 0xFF;
}
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
for x in (ENCRYPTED_START - run_len)..ENCRYPTED_START {
sector[x] = pat[x % period];
}
sector
}
/// The crib the run PREDICTS: the periodic pattern continued past 0x80.
fn expected_crib(period: usize) -> [u8; 10] {
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
let mut out = [0u8; 10];
for (i, o) in out.iter_mut().enumerate() {
*o = pat[(ENCRYPTED_START + i) % period];
}
out
}
// KNOWN ANSWER: the crib is the period-5 run continued forward, the same
// ten bytes for every run length.
#[test]
fn attack_crib_predicts_the_periodic_run_continuing_past_0x80() {
for &run_len in &[11usize, 12, 13, 14, 15, 16, 20, 31] {
let sector = sector_with_trailing_run(5, run_len);
assert_eq!(
attack_crib(&sector),
Some(expected_crib(5)),
"period-5 run of {run_len} bytes must predict the run continuing"
);
}
}
/// The same known answer across several periods, including a period that
/// does NOT divide 0x80 (so the crib's phase is non-zero and a body that
/// restarted the pattern at index 0 gives a different answer).
#[test]
fn attack_crib_recovers_the_run_period_and_phase() {
// 0x80 % period: 3 for 5, 2 for 6, 2 for 7, 8 for 0x18 — all non-zero,
// so the predicted first byte is NOT pat[0] in any of these cases.
for &period in &[5usize, 6, 7, 0x18] {
let sector = sector_with_trailing_run(period, 3 * period + 1);
let crib =
attack_crib(&sector).unwrap_or_else(|| panic!("no crib for period {period}"));
assert_eq!(crib, expected_crib(period), "period {period}");
assert_ne!(
crib[0], 0xD0,
"period {period} does not divide 0x80, so the crib must not \
start at pattern index 0"
);
assert!(
crib.iter().all(|&b| b != 0xFF),
"period {period}: the crib must never contain a byte read from \
the encrypted region"
);
}
}
// A run of exactly ONE cycle is not enough to predict forward; attack_crib requires two
// full cycles.
#[test]
fn attack_crib_refuses_a_run_shorter_than_two_cycles() {
// period 8, run of 9 bytes: best_plen = 8, 8 / 8 == 1 cycle.
assert_eq!(attack_crib(&sector_with_trailing_run(8, 9)), None);
// period 0x18, run of 0x19 bytes: one cycle.
assert_eq!(attack_crib(&sector_with_trailing_run(0x18, 0x19)), None);
// ...and one more byte of run does not conjure a second cycle either.
assert_eq!(attack_crib(&sector_with_trailing_run(8, 10)), None);
}
/// A header with no repeating tail at all yields no crib. Asserted on a
/// header whose bytes are pairwise distinct right up to 0x80, so no cycle
/// length in 2..0x2F can match even one byte.
#[test]
fn attack_crib_refuses_a_header_with_no_periodic_tail() {
let mut sector = vec![0u8; SECTOR_BYTES];
sector[FLAG_BYTE] = 0x10;
// 0x00..0x80 strictly increasing: sec[a] == sec[b] iff a == b, so the
// detector's `sec[0x7f - (j % i)] == sec[0x7f - j]` needs j % i == j,
// which the scan's starting `j = i + 1` already excludes.
for (x, b) in sector[..ENCRYPTED_START].iter_mut().enumerate() {
*b = x as u8;
}
assert_eq!(attack_crib(&sector), None);
// And the cracker built on it reports no key rather than guessing.
assert_eq!(crack_title_key(&sector), None);
}
// attack_crib indexes sector[0x7f - j] unbounded, so its own length guard is the only thing
// stopping an out-of-bounds read.
#[test]
fn attack_crib_refuses_a_buffer_shorter_than_a_sector() {
for len in [0x15usize, 0x40, 0x7F, SECTOR_BYTES - 1] {
let mut sector = vec![0x11u8; len];
sector[FLAG_BYTE] = 0x30; // scrambled, so the flag half cannot fire
assert_eq!(
attack_crib(&sector),
None,
"a {len}-byte buffer is not a sector"
);
}
}
// A header periodic ALL THE WAY to offset 0 must not walk the backward scan off the front
// of the sector.
#[test]
fn attack_crib_survives_a_header_that_is_periodic_to_offset_zero() {
let mut sector = vec![0u8; SECTOR_BYTES];
sector[FLAG_BYTE] = 0x30;
let period = 5usize;
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
for (x, b) in sector[..ENCRYPTED_START].iter_mut().enumerate() {
*b = pat[x % period];
}
for b in sector[ENCRYPTED_START..].iter_mut() {
*b = 0xFF;
}
// The FLAG byte at 0x14 would interrupt the pattern, but j only needs to
// reach 0x7f - 0x14 = 0x6b, well short of the bound. So instead put the
// scramble flag bits into a byte value that IS the pattern's.
sector[FLAG_BYTE] = pat[FLAG_BYTE % period];
assert_ne!(
sector[FLAG_BYTE] & 0x30,
0,
"fixture check: the pattern byte at 0x14 must itself carry \
scramble bits, so the header stays unbroken"
);
assert_eq!(
attack_crib(&sector),
Some(expected_crib(period)),
"a fully periodic header must predict its own continuation, and \
the backward scan must stop at offset 0"
);
}
// The crib is read from the CLEAR header only, never the encrypted region.
#[test]
fn attack_crib_is_independent_of_the_encrypted_region() {
let base = sector_with_trailing_run(5, 11);
let crib = attack_crib(&base).expect("crib");
for fill in [0x00u8, 0x5A, 0xD1, 0xFF] {
let mut s = base.clone();
for b in s[ENCRYPTED_START..].iter_mut() {
*b = fill;
}
assert_eq!(
attack_crib(&s),
Some(crib),
"the crib must not depend on the encrypted region (fill {fill:#04x})"
);
}
}
// ── recover_title_key_from_plain: input-length guard is the only thing standing between a
// short slice and an index-out-of-bounds PANIC.
#[test]
fn recover_title_key_from_plain_refuses_fewer_than_ten_bytes_of_either_input() {
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let full = [0xA5u8; 10];
for n in 0..10usize {
assert_eq!(
recover_title_key_from_plain(&full[..n], &full, &seed),
None,
"{n} ciphertext bytes is fewer than the ten the cipher iterates"
);
assert_eq!(
recover_title_key_from_plain(&full, &full[..n], &seed),
None,
"{n} plaintext bytes is fewer than the ten the cipher iterates"
);
}
// Exactly ten of each is ACCEPTED — the boundary is `< 10`, not `<= 10`.
// What must not happen is an early `None` from the guard; proven via the
// round-trip fixture, whose inputs are exactly ten bytes.
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let (sector, _) = synth_sector(&title_key, &seed, &PES);
assert_eq!(
recover_title_key_from_plain(
&sector[ENCRYPTED_START..ENCRYPTED_START + 10],
&PES,
&seed
),
Some(title_key),
"exactly ten bytes of each input must run the search, not trip the guard"
);
}
// The seed XOR-back turns the recovered LFSR key into the TITLE key: key ^= sector_seed.
#[test]
fn recover_title_key_from_plain_xors_the_sector_seed_back_out() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let (sector, _) = synth_sector(&title_key, &seed, &PES);
let crypted = &sector[ENCRYPTED_START..ENCRYPTED_START + 10];
// The cipher is seeded from `title_key XOR seed`, so re-running the SAME
// ciphertext/plaintext against a seed differing in one byte must return
// a title key differing in exactly that byte — the XOR is a bijection.
assert_eq!(
recover_title_key_from_plain(crypted, &PES, &seed),
Some(title_key)
);
for byte in 0..5usize {
for bit in [0u32, 3, 7] {
let mut alt_seed = seed;
alt_seed[byte] ^= 1u8 << bit;
let mut expected = title_key;
expected[byte] ^= 1u8 << bit;
assert_eq!(
recover_title_key_from_plain(crypted, &PES, &alt_seed),
Some(expected),
"seed byte {byte} bit {bit} must XOR straight through to the \
title key"
);
}
}
}
}
-660
View File
@@ -1,660 +0,0 @@
use super::super::lfsr::scramble_sector;
use super::*;
// Synthesize a scrambled sector for a title key/seed with `plain` at byte 0x80.
fn synth_sector(title_key: &[u8; 5], seed: &[u8; 5], plain: &[u8]) -> (Vec<u8>, Vec<u8>) {
let mut plaintext = vec![0u8; SECTOR_BYTES];
plaintext[0..4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
plaintext[4] = 0x44; // '01': a 13818-1 pack
plaintext[FLAG_BYTE] = 0x10;
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
plaintext[ENCRYPTED_START..ENCRYPTED_START + plain.len()].copy_from_slice(plain);
let body = plaintext.clone();
// scramble_sector turns the plaintext body into ciphertext and sets
// the scramble flag.
scramble_sector(title_key, &mut plaintext);
(plaintext, body)
}
// Synthesize a sector whose cleartext ends in a periodic run continuing into the encrypted
// region.
fn synth_periodic_sector(title_key: &[u8; 5], seed: &[u8; 5], period: usize) -> (Vec<u8>, Vec<u8>) {
let mut plaintext = vec![0u8; SECTOR_BYTES];
plaintext[FLAG_BYTE] = 0x10;
// Periodic run over the cleartext header tail (RUN_START..0x80) into
// the encrypted region, mirroring a real VOB. Must not overlap the seed
// bytes (0x54..0x59); RUN_START=0x59 gives >=2 cycles of every period.
const RUN_START: usize = 0x59;
let pat: Vec<u8> = (0..period)
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
.collect();
for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) {
*b = pat[i % period];
}
// Seed sits below the run, undisturbed.
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
let body = plaintext.clone();
scramble_sector(title_key, &mut plaintext);
(plaintext, body)
}
#[test]
fn crack_unscrambled_returns_none() {
let sector = vec![0u8; SECTOR_BYTES];
assert!(crack_title_key(&sector).is_none());
}
#[test]
fn crack_too_short_returns_none() {
let sector = vec![0u8; 100];
assert!(crack_title_key(&sector).is_none());
}
#[test]
fn recover_needs_min_plain() {
let sector = vec![0u8; SECTOR_BYTES];
let short_plain = [0u8; 4];
assert!(recover_title_key(&sector, &short_plain).is_none());
}
/// The known plaintext used at byte 0x80 for the direct-recovery tests.
/// A realistic MPEG-2 PES header start.
const PES: [u8; 10] = [0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21];
// MANDATORY round-trip (Task C.1): recovered key must descramble the body back to
// plaintext.
#[test]
fn recover_round_trips_known_keys() {
let cases: &[([u8; 5], [u8; 5])] = &[
(
[0x42, 0x13, 0x37, 0xBE, 0xEF],
[0x11, 0x22, 0x33, 0x44, 0x55],
),
(
[0x01, 0x02, 0x03, 0x04, 0x05],
[0xDE, 0xAD, 0xBE, 0xEF, 0x42],
),
(
[0xFE, 0xDC, 0xBA, 0x98, 0x76],
[0x00, 0xFF, 0x80, 0x7F, 0x01],
),
(
[0x9A, 0x78, 0x56, 0x34, 0x12],
[0xA5, 0x5A, 0x0F, 0xF0, 0xCC],
),
(
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
[0x01, 0x01, 0x01, 0x01, 0x01],
),
];
for (title_key, seed) in cases {
let (mut sector, body) = synth_sector(title_key, seed, &PES);
let recovered = recover_title_key(&sector, &PES).expect("recover_title_key returned None");
descramble_sector(&recovered, &mut sector);
assert_eq!(
&sector[ENCRYPTED_START..SECTOR_BYTES],
&body[ENCRYPTED_START..SECTOR_BYTES],
"recovered key did not descramble the full body for \
title={title_key:02x?} seed={seed:02x?}"
);
}
}
// descramble_matches is the ONLY gate between the LFSR search and a key handed to the
// caller.
#[test]
fn descramble_matches_accepts_only_the_key_the_sector_was_scrambled_with() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let (sector, _body) = synth_sector(&title_key, &seed, &PES);
assert!(
descramble_matches(&sector, &title_key, &PES),
"the key the sector was scrambled with must be accepted"
);
for byte in 0..5usize {
for bit in 0..8u32 {
let mut wrong = title_key;
wrong[byte] ^= 1u8 << bit;
assert!(
!descramble_matches(&sector, &wrong, &PES),
"key differing only in byte {byte} bit {bit} must be rejected"
);
}
}
}
// The gate must verify against a COPY, never mutate the caller's sector.
#[test]
fn descramble_matches_does_not_disturb_the_caller_s_sector() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let (sector, _body) = synth_sector(&title_key, &seed, &PES);
let before = sector.clone();
assert!(descramble_matches(&sector, &title_key, &PES));
let mut wrong = title_key;
wrong[0] ^= 0x01;
assert!(!descramble_matches(&sector, &wrong, &PES));
assert_eq!(
sector, before,
"verification must leave the sector byte-for-byte unchanged"
);
}
/// MANDATORY (Task C.1): the crib-based entry point crack_title_key —
/// no plaintext supplied — recovers a round-tripping key when the
/// cleartext ends in a periodic run that continues into 0x80.
#[test]
fn crack_title_key_recovers_via_attack_pattern() {
for &period in &[2usize, 3, 5, 8, 16] {
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
let (sector, body) = synth_periodic_sector(&title_key, &seed, period);
let cracked = crack_title_key(&sector)
.unwrap_or_else(|| panic!("crack_title_key returned None for period {period}"));
let mut test = sector.clone();
descramble_sector(&cracked, &mut test);
assert_eq!(
&test[ENCRYPTED_START..SECTOR_BYTES],
&body[ENCRYPTED_START..SECTOR_BYTES],
"crack_title_key key did not round-trip the body (period {period})"
);
}
}
/// recover_title_key_from_plain inverts descramble_sector exactly: scramble
/// a known body, hand back the keystream-derived key, and the recovered
/// key (XOR-back included) reproduces the plaintext.
#[test]
fn recovered_key_descrambles_back_to_plaintext() {
let cases: &[([u8; 5], [u8; 5])] = &[
(
[0x42, 0x13, 0x37, 0xBE, 0xEF],
[0x11, 0x22, 0x33, 0x44, 0x55],
),
(
[0x9A, 0x78, 0x56, 0x34, 0x12],
[0xA5, 0x5A, 0x0F, 0xF0, 0xCC],
),
(
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
[0x01, 0x01, 0x01, 0x01, 0x01],
),
];
for (title_key, seed) in cases {
let (mut sector, body) = synth_sector(title_key, seed, &PES);
let recovered = recover_title_key(&sector, &PES).expect("recover_title_key returned None");
descramble_sector(&recovered, &mut sector);
assert_eq!(
&sector[ENCRYPTED_START..SECTOR_BYTES],
&body[ENCRYPTED_START..SECTOR_BYTES],
"descramble with recovered key did not reproduce the body \
for title={title_key:02x?} seed={seed:02x?}"
);
}
}
// ── early-return guards ────────────────────────────────────────────────
#[test]
fn recover_rejects_sector_one_byte_short() {
let mut sector = vec![0u8; SECTOR_BYTES - 1];
sector[FLAG_BYTE] = 0x30;
assert!(recover_title_key(&sector, &PES).is_none());
}
#[test]
fn recover_rejects_unscrambled_sector() {
let sector = vec![0x00u8; SECTOR_BYTES];
assert!(recover_title_key(&sector, &PES).is_none());
}
#[test]
fn recover_high_flag_bits_are_not_scramble() {
for &flag in &[0x40u8, 0x80, 0xC0] {
let mut sector = vec![0x11u8; SECTOR_BYTES];
sector[FLAG_BYTE] = flag;
assert!(
recover_title_key(&sector, &PES).is_none(),
"flag {flag:#04x} has scramble bits clear; recover must return None"
);
}
}
#[test]
fn crack_high_flag_bits_are_not_scramble() {
for &flag in &[0x40u8, 0x80, 0xC0] {
let mut sector = vec![0x11u8; SECTOR_BYTES];
sector[FLAG_BYTE] = flag;
assert!(
crack_title_key(&sector).is_none(),
"flag {flag:#04x} clear scramble bits -> crack must return None"
);
}
}
#[test]
fn crack_rejects_sector_one_byte_short() {
let mut sector = vec![0u8; SECTOR_BYTES - 1];
if sector.len() > FLAG_BYTE {
sector[FLAG_BYTE] = 0x30;
}
assert!(crack_title_key(&sector).is_none());
}
/// crack_title_key must never panic on a fully scrambled sector with
/// arbitrary (non-periodic) content — it just returns None.
#[test]
fn crack_full_path_never_panics() {
for seed in 0u32..3 {
let mut sector = vec![0u8; SECTOR_BYTES];
sector[FLAG_BYTE] = 0x30;
let mut x = seed.wrapping_mul(2_654_435_761).wrapping_add(7);
for b in sector.iter_mut().skip(0x80) {
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
*b = (x >> 16) as u8;
}
for (i, b) in sector[SEED_OFFSET..SEED_OFFSET + 5].iter_mut().enumerate() {
*b = (seed.wrapping_add(i as u32) ^ 0xA5) as u8;
}
let _ = crack_title_key(&sector);
}
}
// ── entry-point guards on caller- and disc-supplied lengths: a sector buffer that ENDS
// inside the encrypted region must be refused, not sliced.
#[test]
fn recover_rejects_a_sector_that_ends_inside_the_encrypted_region() {
for len in [0x81usize, 0x85, 0x89] {
let mut sector = vec![0x11u8; len];
sector[FLAG_BYTE] = 0x30; // scrambled, so no other guard fires first
assert!(
recover_title_key(&sector, &PES).is_none(),
"a {len}-byte buffer cannot supply ten ciphertext bytes at 0x80"
);
}
}
// A buffer LONGER than one sector is still one sector: both entry points must recover from
// the first SECTOR_BYTES.
#[test]
fn a_buffer_longer_than_one_sector_still_yields_its_key() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let (sector, _) = synth_sector(&title_key, &seed, &PES);
let mut padded = sector.clone();
padded.extend_from_slice(&[0xA7u8; 512]);
assert_eq!(
recover_title_key(&padded, &PES),
Some(title_key),
"a two-and-a-bit-sector buffer must still recover the first sector's key"
);
let (periodic, _) = synth_periodic_sector(&title_key, &seed, 5);
let mut padded = periodic.clone();
padded.extend_from_slice(&[0xA7u8; 512]);
assert_eq!(
crack_title_key(&padded),
crack_title_key(&periodic),
"padding past the sector must not change the crack result"
);
assert!(crack_title_key(&padded).is_some());
}
// recover_title_key accepts and uses MORE than ten bytes of known plaintext (ten is a
// MINIMUM).
#[test]
fn recover_accepts_more_than_ten_bytes_of_known_plaintext() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let long_plain: Vec<u8> = (0..64u8)
.map(|k| k.wrapping_mul(37).wrapping_add(5))
.collect();
let (sector, _) = synth_sector(&title_key, &seed, &long_plain);
assert_eq!(
recover_title_key(&sector, &long_plain),
Some(title_key),
"64 bytes of known plaintext must be accepted, not rejected as \
'more than ten'"
);
}
// The scramble-flag gate on a sector whose BODY really is ciphertext and whose key IS
// recoverable.
#[test]
fn a_recoverable_sector_with_the_scramble_bits_cleared_is_still_refused() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let (mut sector, _) = synth_sector(&title_key, &seed, &PES);
assert_eq!(
recover_title_key(&sector, &PES),
Some(title_key),
"fixture check: with the flag set this sector's key IS recoverable"
);
sector[FLAG_BYTE] = 0x00;
assert_eq!(
recover_title_key(&sector, &PES),
None,
"scramble bits clear → no title key, even though one could be found"
);
let (mut periodic, _) = synth_periodic_sector(&title_key, &seed, 5);
assert!(
crack_title_key(&periodic).is_some(),
"fixture check: with the flag set this sector cracks"
);
assert!(
attack_crib(&periodic).is_some(),
"fixture check: with the flag set this sector has a usable crib"
);
periodic[FLAG_BYTE] = 0x00;
assert_eq!(
crack_title_key(&periodic),
None,
"scramble bits clear → no crack, even though one would succeed"
);
// `attack_crib` carries its own copy of the same gate and is the one that
// actually stops the crack (`crack_title_key`'s is defensive duplication);
// it also doubles as the decrypt path's cached-key oracle.
assert_eq!(
attack_crib(&periodic),
None,
"an unscrambled sector has no predicted plaintext to offer"
);
}
// ── descramble_matches: the verification gate's own mechanics — the gate must verify
// against the sector's CIPHERTEXT regardless of the flag byte.
#[test]
fn descramble_matches_forces_the_scramble_flag_on_its_own_copy() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let (mut sector, _) = synth_sector(&title_key, &seed, &PES);
sector[FLAG_BYTE] = 0x00;
assert!(
descramble_matches(&sector, &title_key, &PES),
"the body is ciphertext and the key is right — the gate must \
descramble it even though the flag byte says otherwise"
);
let mut wrong = title_key;
wrong[0] ^= 0x01;
assert!(!descramble_matches(&sector, &wrong, &PES));
}
// The gate compares the WHOLE supplied plaintext, clamped to the encrypted region.
#[test]
fn descramble_matches_compares_all_of_the_plaintext_and_no_more_than_the_sector() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let body: Vec<u8> = (0..64u8)
.map(|k| k.wrapping_mul(29).wrapping_add(3))
.collect();
let (sector, _) = synth_sector(&title_key, &seed, &body);
assert!(descramble_matches(&sector, &title_key, &body));
// A crib agreeing for the first 16 bytes and diverging after must be
// rejected: the comparison window is the crib's length, not a fixed
// prefix.
let mut tail_wrong = body.clone();
tail_wrong[40] ^= 0xFF;
assert!(
!descramble_matches(&sector, &title_key, &tail_wrong),
"a crib that diverges at byte 40 must not match"
);
assert_eq!(
tail_wrong[..16],
body[..16],
"fixture check: the first 16 bytes are identical, so only a \
comparison that runs past them can tell these apart"
);
// A crib LONGER than the encrypted region: the comparison is clamped to
// the sector, not run off the end of it.
let plain_len = SECTOR_BYTES - ENCRYPTED_START;
let mut over_long = vec![0u8; plain_len + 10];
let (full_sector, full_body) = synth_sector(&title_key, &seed, &[0x00u8; 10]);
over_long[..plain_len].copy_from_slice(&full_body[ENCRYPTED_START..]);
assert!(
descramble_matches(&full_sector, &title_key, &over_long),
"a crib longer than the encrypted region must be clamped, not \
compared past the end of the sector"
);
}
// ── attack_crib: known-answer vectors. Sector whose clear header ends in
// a period-length run of run_len bytes before 0x80, rest 0xFF.
fn sector_with_trailing_run(period: usize, run_len: usize) -> Vec<u8> {
assert!(
run_len < ENCRYPTED_START,
"the run lives in the clear header"
);
let mut sector = vec![0u8; SECTOR_BYTES];
sector[FLAG_BYTE] = 0x10;
for b in sector[ENCRYPTED_START..].iter_mut() {
*b = 0xFF;
}
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
for x in (ENCRYPTED_START - run_len)..ENCRYPTED_START {
sector[x] = pat[x % period];
}
sector
}
/// The crib the run PREDICTS: the periodic pattern continued past 0x80.
fn expected_crib(period: usize) -> [u8; 10] {
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
let mut out = [0u8; 10];
for (i, o) in out.iter_mut().enumerate() {
*o = pat[(ENCRYPTED_START + i) % period];
}
out
}
// KNOWN ANSWER: the crib is the period-5 run continued forward, the same
// ten bytes for every run length.
#[test]
fn attack_crib_predicts_the_periodic_run_continuing_past_0x80() {
for &run_len in &[11usize, 12, 13, 14, 15, 16, 20, 31] {
let sector = sector_with_trailing_run(5, run_len);
assert_eq!(
attack_crib(&sector),
Some(expected_crib(5)),
"period-5 run of {run_len} bytes must predict the run continuing"
);
}
}
/// The same known answer across several periods, including a period that
/// does NOT divide 0x80 (so the crib's phase is non-zero and a body that
/// restarted the pattern at index 0 gives a different answer).
#[test]
fn attack_crib_recovers_the_run_period_and_phase() {
// 0x80 % period: 3 for 5, 2 for 6, 2 for 7, 8 for 0x18 — all non-zero,
// so the predicted first byte is NOT pat[0] in any of these cases.
for &period in &[5usize, 6, 7, 0x18] {
let sector = sector_with_trailing_run(period, 3 * period + 1);
let crib = attack_crib(&sector).unwrap_or_else(|| panic!("no crib for period {period}"));
assert_eq!(crib, expected_crib(period), "period {period}");
assert_ne!(
crib[0], 0xD0,
"period {period} does not divide 0x80, so the crib must not \
start at pattern index 0"
);
assert!(
crib.iter().all(|&b| b != 0xFF),
"period {period}: the crib must never contain a byte read from \
the encrypted region"
);
}
}
// A run of exactly ONE cycle is not enough to predict forward; attack_crib requires two
// full cycles.
#[test]
fn attack_crib_refuses_a_run_shorter_than_two_cycles() {
// period 8, run of 9 bytes: best_plen = 8, 8 / 8 == 1 cycle.
assert_eq!(attack_crib(&sector_with_trailing_run(8, 9)), None);
// period 0x18, run of 0x19 bytes: one cycle.
assert_eq!(attack_crib(&sector_with_trailing_run(0x18, 0x19)), None);
// ...and one more byte of run does not conjure a second cycle either.
assert_eq!(attack_crib(&sector_with_trailing_run(8, 10)), None);
}
/// A header with no repeating tail at all yields no crib. Asserted on a
/// header whose bytes are pairwise distinct right up to 0x80, so no cycle
/// length in 2..0x2F can match even one byte.
#[test]
fn attack_crib_refuses_a_header_with_no_periodic_tail() {
let mut sector = vec![0u8; SECTOR_BYTES];
sector[FLAG_BYTE] = 0x10;
// 0x00..0x80 strictly increasing: sec[a] == sec[b] iff a == b, so the
// detector's `sec[0x7f - (j % i)] == sec[0x7f - j]` needs j % i == j,
// which the scan's starting `j = i + 1` already excludes.
for (x, b) in sector[..ENCRYPTED_START].iter_mut().enumerate() {
*b = x as u8;
}
assert_eq!(attack_crib(&sector), None);
// And the cracker built on it reports no key rather than guessing.
assert_eq!(crack_title_key(&sector), None);
}
// attack_crib indexes sector[0x7f - j] unbounded, so its own length guard is the only thing
// stopping an out-of-bounds read.
#[test]
fn attack_crib_refuses_a_buffer_shorter_than_a_sector() {
for len in [0x15usize, 0x40, 0x7F, SECTOR_BYTES - 1] {
let mut sector = vec![0x11u8; len];
sector[FLAG_BYTE] = 0x30; // scrambled, so the flag half cannot fire
assert_eq!(
attack_crib(&sector),
None,
"a {len}-byte buffer is not a sector"
);
}
}
// A header periodic ALL THE WAY to offset 0 must not walk the backward scan off the front
// of the sector.
#[test]
fn attack_crib_survives_a_header_that_is_periodic_to_offset_zero() {
let mut sector = vec![0u8; SECTOR_BYTES];
sector[FLAG_BYTE] = 0x30;
let period = 5usize;
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
for (x, b) in sector[..ENCRYPTED_START].iter_mut().enumerate() {
*b = pat[x % period];
}
for b in sector[ENCRYPTED_START..].iter_mut() {
*b = 0xFF;
}
// The FLAG byte at 0x14 would interrupt the pattern, but j only needs to
// reach 0x7f - 0x14 = 0x6b, well short of the bound. So instead put the
// scramble flag bits into a byte value that IS the pattern's.
sector[FLAG_BYTE] = pat[FLAG_BYTE % period];
assert_ne!(
sector[FLAG_BYTE] & 0x30,
0,
"fixture check: the pattern byte at 0x14 must itself carry \
scramble bits, so the header stays unbroken"
);
assert_eq!(
attack_crib(&sector),
Some(expected_crib(period)),
"a fully periodic header must predict its own continuation, and \
the backward scan must stop at offset 0"
);
}
// The crib is read from the CLEAR header only, never the encrypted region.
#[test]
fn attack_crib_is_independent_of_the_encrypted_region() {
let base = sector_with_trailing_run(5, 11);
let crib = attack_crib(&base).expect("crib");
for fill in [0x00u8, 0x5A, 0xD1, 0xFF] {
let mut s = base.clone();
for b in s[ENCRYPTED_START..].iter_mut() {
*b = fill;
}
assert_eq!(
attack_crib(&s),
Some(crib),
"the crib must not depend on the encrypted region (fill {fill:#04x})"
);
}
}
// ── recover_title_key_from_plain: input-length guard is the only thing standing between a
// short slice and an index-out-of-bounds PANIC.
#[test]
fn recover_title_key_from_plain_refuses_fewer_than_ten_bytes_of_either_input() {
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let full = [0xA5u8; 10];
for n in 0..10usize {
assert_eq!(
recover_title_key_from_plain(&full[..n], &full, &seed),
None,
"{n} ciphertext bytes is fewer than the ten the cipher iterates"
);
assert_eq!(
recover_title_key_from_plain(&full, &full[..n], &seed),
None,
"{n} plaintext bytes is fewer than the ten the cipher iterates"
);
}
// Exactly ten of each is ACCEPTED — the boundary is `< 10`, not `<= 10`.
// What must not happen is an early `None` from the guard; proven via the
// round-trip fixture, whose inputs are exactly ten bytes.
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let (sector, _) = synth_sector(&title_key, &seed, &PES);
assert_eq!(
recover_title_key_from_plain(&sector[ENCRYPTED_START..ENCRYPTED_START + 10], &PES, &seed),
Some(title_key),
"exactly ten bytes of each input must run the search, not trip the guard"
);
}
// The seed XOR-back turns the recovered LFSR key into the TITLE key: key ^= sector_seed.
#[test]
fn recover_title_key_from_plain_xors_the_sector_seed_back_out() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let (sector, _) = synth_sector(&title_key, &seed, &PES);
let crypted = &sector[ENCRYPTED_START..ENCRYPTED_START + 10];
// The cipher is seeded from `title_key XOR seed`, so re-running the SAME
// ciphertext/plaintext against a seed differing in one byte must return
// a title key differing in exactly that byte — the XOR is a bijection.
assert_eq!(
recover_title_key_from_plain(crypted, &PES, &seed),
Some(title_key)
);
for byte in 0..5usize {
for bit in [0u32, 3, 7] {
let mut alt_seed = seed;
alt_seed[byte] ^= 1u8 << bit;
let mut expected = title_key;
expected[byte] ^= 1u8 << bit;
assert_eq!(
recover_title_key_from_plain(crypted, &PES, &alt_seed),
Some(expected),
"seed byte {byte} bit {bit} must XOR straight through to the \
title key"
);
}
}
}
+310 -2
View File
@@ -119,5 +119,313 @@ static TAB1_INV: std::sync::LazyLock<[u8; 256]> = std::sync::LazyLock::new(|| {
});
#[cfg(test)]
#[path = "lfsr_tests.rs"]
mod tests;
mod tests {
use super::*;
#[test]
fn descramble_skips_unscrambled() {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
let mut sector = vec![0xAA; 2048];
sector[0x14] = 0x00;
let original = sector.clone();
descramble_sector(&key, &mut sector);
assert_eq!(sector, original);
}
// Regression vector pinning this implementation's own output (NOT an independent CSS
// known-answer). key = 42 13 37 BE EF, seed = DE AD BE EF 42, body = 0xAA.
#[test]
fn descramble_output_is_pinned_regression_vector() {
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let mut sector = vec![0xAAu8; 2048];
sector[0x14] = 0x30;
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
descramble_sector(&key, &mut sector);
assert_eq!(
&sector[0x80..0x90],
&[
0x81, 0x92, 0x24, 0xA2, 0x46, 0x70, 0x3C, 0x64, 0xA6, 0x91, 0x84, 0xF5, 0x1F, 0x98,
0xA0, 0x31
],
"descramble body head changed from the pinned output"
);
assert_eq!(
&sector[0x7F8..0x800],
&[0x46, 0x94, 0x80, 0x0E, 0x67, 0x36, 0x65, 0xBC],
"descramble body tail changed from the pinned output"
);
}
#[test]
fn descramble_modifies_scrambled() {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
let mut sector = vec![0xAA; 2048];
sector[0x14] = 0x30; // scramble flag set
// Set a sector seed
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
let original = sector.clone();
descramble_sector(&key, &mut sector);
// Header (0..128) unchanged except byte 0x14 (flag cleared)
for i in 0..128 {
if i == 0x14 {
continue;
}
assert_eq!(sector[i], original[i], "header byte {} changed", i);
}
// Encrypted region should be different
assert_ne!(&sector[128..256], &original[128..256]);
}
#[test]
fn descramble_clears_flags() {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
let mut sector = vec![0x00; 2048];
sector[0x14] = 0x30;
sector[0x54..0x59].copy_from_slice(&[0x00; 5]);
descramble_sector(&key, &mut sector);
assert_eq!(sector[0x14] & 0x30, 0x00);
}
// Not a plain XOR involution (TAB1 applies to ciphertext); scramble_sector is the true
// inverse.
#[test]
fn css_descramble_inverts_scramble_over_body() {
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let mut sector = vec![0xAAu8; 2048];
sector[0x14] = 0x30; // scramble flag
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
let original = sector.clone();
// Scramble the plaintext body into ciphertext.
scramble_sector(&title_key, &mut sector);
// Header (0..128) unchanged except the flag byte (set by scramble).
for i in 0..128 {
if i == 0x14 {
continue;
}
assert_eq!(sector[i], original[i], "header byte {} changed", i);
}
// Encrypted region modified
assert_ne!(&sector[128..256], &original[128..256]);
// Descramble restores the plaintext body byte-for-byte.
descramble_sector(&title_key, &mut sector);
assert_eq!(sector[0x14] & 0x30, 0x00, "flag cleared after descramble");
assert_eq!(
&sector[128..2048],
&original[128..2048],
"descramble(scramble(body)) did not restore the body"
);
}
// TAB1 is a substitution table; verify it is a permutation of 0..255.
#[test]
fn css_tab1_is_permutation() {
let mut seen = [false; 256];
for tab1_val in &TAB1 {
let v = *tab1_val as usize;
assert!(!seen[v], "TAB1 maps two inputs to {:#04x}", v);
seen[v] = true;
}
}
/// css_tab4_is_bit_reversal
///
/// TAB4 reverses the bits of each byte: TAB4[0x01] = 0x80, TAB4[0x80] = 0x01, etc.
#[test]
fn css_tab4_is_bit_reversal() {
for i in 0u16..256 {
let expected = (0..8).fold(0u8, |acc, bit| acc | (((i as u8 >> bit) & 1) << (7 - bit)));
assert_eq!(
TAB4[i as usize], expected,
"TAB4[{:#04x}] = {:#04x}, expected {:#04x} (bit reversal)",
i, TAB4[i as usize], expected
);
}
// Also verify TAB4 is an involution: TAB4[TAB4[x]] == x
for i in 0..256 {
assert_eq!(
TAB4[TAB4[i] as usize], i as u8,
"TAB4 is not an involution at {:#04x}",
i
);
}
}
// Scramble-flag detection (byte 0x14, bits 4-5): only bits 4-5 are the flag (mask 0x30);
// 0x40/0x80 must read UNSCRAMBLED.
#[test]
fn descramble_treats_high_bits_of_0x14_as_clear() {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
for &flag in &[0x40u8, 0x80, 0xC0, 0x0F, 0x4F, 0x8F] {
let mut sector = vec![0xAA; 2048];
sector[0x14] = flag;
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
let original = sector.clone();
descramble_sector(&key, &mut sector);
assert_eq!(
sector, original,
"byte 0x14 = {flag:#04x} has flag bits 4-5 clear; sector must be untouched"
);
}
}
// Each scramble bit (4 and 5) independently marks a sector encrypted: 0x10 and 0x20 must
// both trigger descrambling.
#[test]
fn descramble_triggers_on_either_flag_bit() {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
for &flag in &[0x10u8, 0x20, 0x30] {
let mut sector = vec![0xAA; 2048];
sector[0x14] = flag;
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
let original = sector.clone();
descramble_sector(&key, &mut sector);
assert_ne!(
&sector[128..256],
&original[128..256],
"flag {flag:#04x} (bits 4-5 nonzero) must descramble the body"
);
}
}
// Only the two scramble bits are cleared (`& 0xCF`); bits 6-7 of byte 0x14 survive: 0xF0
// becomes 0xC0, NOT 0x00.
#[test]
fn descramble_clear_preserves_high_bits_of_0x14() {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
let mut sector = vec![0x00; 2048];
sector[0x14] = 0xF0; // bits 4-7 set; bits 4-5 are the flag
sector[0x54..0x59].copy_from_slice(&[0x00; 5]);
descramble_sector(&key, &mut sector);
assert_eq!(
sector[0x14], 0xC0,
"scramble bits cleared, bits 6-7 preserved (0xF0 & 0xCF)"
);
}
// ── header / body boundary (encrypted region is 0x80..0x800) ───────────
// Encrypted region is exactly 0x80..0x800; header bytes 0x00..0x80 (incl. the seed at
// 0x54..0x59) must stay untouched.
#[test]
fn descramble_leaves_header_and_seed_intact() {
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let mut sector = vec![0x5Au8; 2048];
sector[0x14] = 0x30;
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
sector[0x54..0x59].copy_from_slice(&seed);
let original = sector.clone();
descramble_sector(&key, &mut sector);
for i in 0..0x80usize {
if i == 0x14 {
continue;
}
assert_eq!(
sector[i], original[i],
"header byte {i:#04x} must be untouched"
);
}
assert_eq!(&sector[0x54..0x59], &seed, "sector seed must survive");
}
// The descrambler must touch the WHOLE body, including the final byte (index 2047),
// guarding the `.take(2048)` bound.
#[test]
fn descramble_covers_final_body_byte() {
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let mut sector = vec![0x00u8; 2048];
sector[0x14] = 0x30;
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
descramble_sector(&key, &mut sector);
// Body was all zero; any nonzero in [0x80,0x800) is keystream. Confirm
// the keystream reaches the final byte.
assert_ne!(
&sector[2040..2048],
&[0u8; 8][..],
"the tail of the body must be descrambled (loop must reach index 2047)"
);
}
// The length guard is a FLOOR, not a ceiling: descramble_sector processes the first sector
// of any over-long buffer.
#[test]
fn descramble_processes_the_first_sector_of_an_over_long_buffer() {
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
// Two sectors' worth of buffer; only the first is a sector.
let mut buf = vec![0xAAu8; 4096];
buf[0x14] = 0x30;
buf[0x54..0x59].copy_from_slice(&seed);
let original = buf.clone();
descramble_sector(&title_key, &mut buf);
assert_ne!(
&buf[0x80..0x800],
&original[0x80..0x800],
"the first sector's body must be descrambled"
);
assert_eq!(buf[0x14] & 0x30, 0x00, "and its scramble flag cleared");
assert_eq!(
&buf[2048..4096],
&original[2048..4096],
"bytes past the first sector must be left untouched"
);
// The result must equal what a caller gets by passing exactly one
// sector — the same transform, not a length-dependent one.
let mut one = original[..2048].to_vec();
descramble_sector(&title_key, &mut one);
assert_eq!(
&buf[..2048],
&one[..],
"the first sector must descramble identically either way"
);
}
// Descramble is keyed by `title_key XOR seed`: two different title keys must produce two
// different bodies.
#[test]
fn descramble_output_depends_on_title_key() {
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
let make = |k: &[u8; 5]| {
let mut s = vec![0x00u8; 2048];
s[0x14] = 0x30;
s[0x54..0x59].copy_from_slice(&seed);
descramble_sector(k, &mut s);
s
};
let a = make(&[0x01, 0x02, 0x03, 0x04, 0x05]);
let b = make(&[0x01, 0x02, 0x03, 0x04, 0x06]); // differs in last byte
assert_ne!(
&a[128..2048],
&b[128..2048],
"different title keys must descramble differently"
);
}
// Descramble is keyed by the sector seed too: same title key, different seed -> different
// body.
#[test]
fn descramble_output_depends_on_seed() {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
let make = |seed: [u8; 5]| {
let mut s = vec![0x00u8; 2048];
s[0x14] = 0x30;
s[0x54..0x59].copy_from_slice(&seed);
descramble_sector(&key, &mut s);
s
};
let a = make([0x11, 0x22, 0x33, 0x44, 0x55]);
let b = make([0x11, 0x22, 0x33, 0x44, 0x56]);
assert_ne!(
&a[128..2048],
&b[128..2048],
"different seeds must descramble differently"
);
}
}
-308
View File
@@ -1,308 +0,0 @@
use super::*;
#[test]
fn descramble_skips_unscrambled() {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
let mut sector = vec![0xAA; 2048];
sector[0x14] = 0x00;
let original = sector.clone();
descramble_sector(&key, &mut sector);
assert_eq!(sector, original);
}
// Regression vector pinning this implementation's own output (NOT an independent CSS
// known-answer). key = 42 13 37 BE EF, seed = DE AD BE EF 42, body = 0xAA.
#[test]
fn descramble_output_is_pinned_regression_vector() {
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let mut sector = vec![0xAAu8; 2048];
sector[0x14] = 0x30;
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
descramble_sector(&key, &mut sector);
assert_eq!(
&sector[0x80..0x90],
&[
0x81, 0x92, 0x24, 0xA2, 0x46, 0x70, 0x3C, 0x64, 0xA6, 0x91, 0x84, 0xF5, 0x1F, 0x98,
0xA0, 0x31
],
"descramble body head changed from the pinned output"
);
assert_eq!(
&sector[0x7F8..0x800],
&[0x46, 0x94, 0x80, 0x0E, 0x67, 0x36, 0x65, 0xBC],
"descramble body tail changed from the pinned output"
);
}
#[test]
fn descramble_modifies_scrambled() {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
let mut sector = vec![0xAA; 2048];
sector[0x14] = 0x30; // scramble flag set
// Set a sector seed
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
let original = sector.clone();
descramble_sector(&key, &mut sector);
// Header (0..128) unchanged except byte 0x14 (flag cleared)
for i in 0..128 {
if i == 0x14 {
continue;
}
assert_eq!(sector[i], original[i], "header byte {} changed", i);
}
// Encrypted region should be different
assert_ne!(&sector[128..256], &original[128..256]);
}
#[test]
fn descramble_clears_flags() {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
let mut sector = vec![0x00; 2048];
sector[0x14] = 0x30;
sector[0x54..0x59].copy_from_slice(&[0x00; 5]);
descramble_sector(&key, &mut sector);
assert_eq!(sector[0x14] & 0x30, 0x00);
}
// Not a plain XOR involution (TAB1 applies to ciphertext); scramble_sector is the true
// inverse.
#[test]
fn css_descramble_inverts_scramble_over_body() {
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let mut sector = vec![0xAAu8; 2048];
sector[0x14] = 0x30; // scramble flag
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
let original = sector.clone();
// Scramble the plaintext body into ciphertext.
scramble_sector(&title_key, &mut sector);
// Header (0..128) unchanged except the flag byte (set by scramble).
for i in 0..128 {
if i == 0x14 {
continue;
}
assert_eq!(sector[i], original[i], "header byte {} changed", i);
}
// Encrypted region modified
assert_ne!(&sector[128..256], &original[128..256]);
// Descramble restores the plaintext body byte-for-byte.
descramble_sector(&title_key, &mut sector);
assert_eq!(sector[0x14] & 0x30, 0x00, "flag cleared after descramble");
assert_eq!(
&sector[128..2048],
&original[128..2048],
"descramble(scramble(body)) did not restore the body"
);
}
// TAB1 is a substitution table; verify it is a permutation of 0..255.
#[test]
fn css_tab1_is_permutation() {
let mut seen = [false; 256];
for tab1_val in &TAB1 {
let v = *tab1_val as usize;
assert!(!seen[v], "TAB1 maps two inputs to {:#04x}", v);
seen[v] = true;
}
}
/// css_tab4_is_bit_reversal
///
/// TAB4 reverses the bits of each byte: TAB4[0x01] = 0x80, TAB4[0x80] = 0x01, etc.
#[test]
fn css_tab4_is_bit_reversal() {
for i in 0u16..256 {
let expected = (0..8).fold(0u8, |acc, bit| acc | (((i as u8 >> bit) & 1) << (7 - bit)));
assert_eq!(
TAB4[i as usize], expected,
"TAB4[{:#04x}] = {:#04x}, expected {:#04x} (bit reversal)",
i, TAB4[i as usize], expected
);
}
// Also verify TAB4 is an involution: TAB4[TAB4[x]] == x
for i in 0..256 {
assert_eq!(
TAB4[TAB4[i] as usize], i as u8,
"TAB4 is not an involution at {:#04x}",
i
);
}
}
// Scramble-flag detection (byte 0x14, bits 4-5): only bits 4-5 are the flag (mask 0x30);
// 0x40/0x80 must read UNSCRAMBLED.
#[test]
fn descramble_treats_high_bits_of_0x14_as_clear() {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
for &flag in &[0x40u8, 0x80, 0xC0, 0x0F, 0x4F, 0x8F] {
let mut sector = vec![0xAA; 2048];
sector[0x14] = flag;
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
let original = sector.clone();
descramble_sector(&key, &mut sector);
assert_eq!(
sector, original,
"byte 0x14 = {flag:#04x} has flag bits 4-5 clear; sector must be untouched"
);
}
}
// Each scramble bit (4 and 5) independently marks a sector encrypted: 0x10 and 0x20 must
// both trigger descrambling.
#[test]
fn descramble_triggers_on_either_flag_bit() {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
for &flag in &[0x10u8, 0x20, 0x30] {
let mut sector = vec![0xAA; 2048];
sector[0x14] = flag;
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
let original = sector.clone();
descramble_sector(&key, &mut sector);
assert_ne!(
&sector[128..256],
&original[128..256],
"flag {flag:#04x} (bits 4-5 nonzero) must descramble the body"
);
}
}
// Only the two scramble bits are cleared (`& 0xCF`); bits 6-7 of byte 0x14 survive: 0xF0
// becomes 0xC0, NOT 0x00.
#[test]
fn descramble_clear_preserves_high_bits_of_0x14() {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
let mut sector = vec![0x00; 2048];
sector[0x14] = 0xF0; // bits 4-7 set; bits 4-5 are the flag
sector[0x54..0x59].copy_from_slice(&[0x00; 5]);
descramble_sector(&key, &mut sector);
assert_eq!(
sector[0x14], 0xC0,
"scramble bits cleared, bits 6-7 preserved (0xF0 & 0xCF)"
);
}
// ── header / body boundary (encrypted region is 0x80..0x800) ───────────
// Encrypted region is exactly 0x80..0x800; header bytes 0x00..0x80 (incl. the seed at
// 0x54..0x59) must stay untouched.
#[test]
fn descramble_leaves_header_and_seed_intact() {
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let mut sector = vec![0x5Au8; 2048];
sector[0x14] = 0x30;
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
sector[0x54..0x59].copy_from_slice(&seed);
let original = sector.clone();
descramble_sector(&key, &mut sector);
for i in 0..0x80usize {
if i == 0x14 {
continue;
}
assert_eq!(
sector[i], original[i],
"header byte {i:#04x} must be untouched"
);
}
assert_eq!(&sector[0x54..0x59], &seed, "sector seed must survive");
}
// The descrambler must touch the WHOLE body, including the final byte (index 2047),
// guarding the `.take(2048)` bound.
#[test]
fn descramble_covers_final_body_byte() {
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let mut sector = vec![0x00u8; 2048];
sector[0x14] = 0x30;
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
descramble_sector(&key, &mut sector);
// Body was all zero; any nonzero in [0x80,0x800) is keystream. Confirm
// the keystream reaches the final byte.
assert_ne!(
&sector[2040..2048],
&[0u8; 8][..],
"the tail of the body must be descrambled (loop must reach index 2047)"
);
}
// The length guard is a FLOOR, not a ceiling: descramble_sector processes the first sector
// of any over-long buffer.
#[test]
fn descramble_processes_the_first_sector_of_an_over_long_buffer() {
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
// Two sectors' worth of buffer; only the first is a sector.
let mut buf = vec![0xAAu8; 4096];
buf[0x14] = 0x30;
buf[0x54..0x59].copy_from_slice(&seed);
let original = buf.clone();
descramble_sector(&title_key, &mut buf);
assert_ne!(
&buf[0x80..0x800],
&original[0x80..0x800],
"the first sector's body must be descrambled"
);
assert_eq!(buf[0x14] & 0x30, 0x00, "and its scramble flag cleared");
assert_eq!(
&buf[2048..4096],
&original[2048..4096],
"bytes past the first sector must be left untouched"
);
// The result must equal what a caller gets by passing exactly one
// sector — the same transform, not a length-dependent one.
let mut one = original[..2048].to_vec();
descramble_sector(&title_key, &mut one);
assert_eq!(
&buf[..2048],
&one[..],
"the first sector must descramble identically either way"
);
}
// Descramble is keyed by `title_key XOR seed`: two different title keys must produce two
// different bodies.
#[test]
fn descramble_output_depends_on_title_key() {
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
let make = |k: &[u8; 5]| {
let mut s = vec![0x00u8; 2048];
s[0x14] = 0x30;
s[0x54..0x59].copy_from_slice(&seed);
descramble_sector(k, &mut s);
s
};
let a = make(&[0x01, 0x02, 0x03, 0x04, 0x05]);
let b = make(&[0x01, 0x02, 0x03, 0x04, 0x06]); // differs in last byte
assert_ne!(
&a[128..2048],
&b[128..2048],
"different title keys must descramble differently"
);
}
// Descramble is keyed by the sector seed too: same title key, different seed -> different
// body.
#[test]
fn descramble_output_depends_on_seed() {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
let make = |seed: [u8; 5]| {
let mut s = vec![0x00u8; 2048];
s[0x14] = 0x30;
s[0x54..0x59].copy_from_slice(&seed);
descramble_sector(&key, &mut s);
s
};
let a = make([0x11, 0x22, 0x33, 0x44, 0x55]);
let b = make([0x11, 0x22, 0x33, 0x44, 0x56]);
assert_ne!(
&a[128..2048],
&b[128..2048],
"different seeds must descramble differently"
);
}
+1380 -4
View File
File diff suppressed because it is too large Load Diff
-1405
View File
File diff suppressed because it is too large Load Diff
+130 -2
View File
@@ -133,5 +133,133 @@ pub const TAB5: [u8; 256] = [
];
#[cfg(test)]
#[path = "tables_tests.rs"]
mod tests;
mod tests {
use super::*;
/// Pins the documented relationship `TAB5[i] == TAB4[i] ^ 0xFF` so the
/// table doc cannot drift from the data.
#[test]
fn tab5_is_complement_of_tab4() {
for i in 0..256 {
assert_eq!(
TAB5[i],
TAB4[i] ^ 0xFF,
"TAB5[{i:#04x}] != TAB4[{i:#04x}] ^ 0xFF"
);
}
}
// TAB1 is a bijection on 0..256; CSS uses it as an invertible output permutation in
// css_DecryptKey.
#[test]
fn tab1_is_a_permutation() {
let mut seen = [false; 256];
for (i, &v) in TAB1.iter().enumerate() {
assert!(
!seen[v as usize],
"TAB1 maps two inputs to {v:#04x} (collision at index {i:#04x})"
);
seen[v as usize] = true;
}
}
// TAB1's fixed structural anchors: TAB1[0x00] == 0x33 and TAB1[0x33] == 0x00, the canonical
// CSS-spec landmarks pinning the table's orientation.
#[test]
fn tab1_known_spec_anchors() {
assert_eq!(TAB1[0x00], 0x33, "TAB1[0] is the published 0x33");
assert_eq!(TAB1[0x33], 0x00, "TAB1[0x33] is the published 0x00");
}
// FNV-1a over a whole table: a swapped pair keeps a permutation and the anchors, so only
// a full-content pin catches a mistyped or regenerated table.
fn fnv1a(table: &[u8]) -> u64 {
table.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &b| {
(h ^ b as u64).wrapping_mul(0x0000_0100_0000_01b3)
})
}
#[test]
fn tab1_and_tab2_contents_are_pinned() {
assert_eq!(fnv1a(&TAB1), 0xcdb8_7228_1c08_c265, "TAB1 content changed");
assert_eq!(fnv1a(&TAB2), 0x79e0_69d7_0f3d_2f25, "TAB2 content changed");
}
// TAB2 is a permutation of 0..256 (LFSR1 high-byte feedback substitution); a non-bijective
// table would bias the LFSR1 keystream.
#[test]
fn tab2_is_a_permutation() {
let mut seen = [false; 256];
for (i, &v) in TAB2.iter().enumerate() {
assert!(
!seen[v as usize],
"TAB2 maps two inputs to {v:#04x} (collision at index {i:#04x})"
);
seen[v as usize] = true;
}
}
// TAB3 is CSS's LFSR1 low-word table: BASE = [0x00,0x24,0x49,0x6d,0x92, 0xb6,0xdb,0xff]
// repeated 64x (TAB3[i] == BASE[i & 7]); only the low 3 bits of the 9-bit index matter.
#[test]
fn tab3_matches_lfsr1_generating_formula() {
const BASE: [u8; 8] = [0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff];
for i in 0..512usize {
let expected = BASE[i & 7];
assert_eq!(
TAB3[i], expected,
"TAB3[{i:#05x}] = {:#04x}, formula BASE[i&7] = {expected:#04x}",
TAB3[i]
);
}
}
// TAB4 is the exact bit-reversal of each byte (CSS permutes LFSR0 bytes with it on
// seed/output), hence also an involution: TAB4[TAB4[b]] == b.
#[test]
fn tab4_is_exact_bit_reversal_and_involution() {
for b in 0u16..256 {
let rev = (0..8).fold(0u8, |acc, k| acc | (((b as u8 >> k) & 1) << (7 - k)));
assert_eq!(
TAB4[b as usize], rev,
"TAB4[{b:#04x}] is not the bit-reversal {rev:#04x}"
);
}
for b in 0..256usize {
assert_eq!(
TAB4[TAB4[b] as usize], b as u8,
"TAB4 not an involution at {b:#04x}"
);
}
// Spec landmark entries.
assert_eq!(TAB4[0x01], 0x80);
assert_eq!(TAB4[0x80], 0x01);
assert_eq!(TAB4[0x00], 0x00);
assert_eq!(TAB4[0xFF], 0xFF);
}
// TAB4 is a permutation (bit-reversal is bijective); distinct from the reversal test since
// "reversal except swapped/duplicated entries" cases would pass one test but fail the
// other.
#[test]
fn tab4_is_a_permutation() {
let mut seen = [false; 256];
for &v in TAB4.iter() {
assert!(!seen[v as usize], "TAB4 maps two inputs to {v:#04x}");
seen[v as usize] = true;
}
}
// TAB5 is a permutation (complement of a bijection) with orientation anchors
// TAB5[0x00]==0xFF and TAB5[0xFF]==0x00, independent of the complement-loop test.
#[test]
fn tab5_is_permutation_with_anchors() {
let mut seen = [false; 256];
for &v in TAB5.iter() {
assert!(!seen[v as usize], "TAB5 maps two inputs to {v:#04x}");
seen[v as usize] = true;
}
assert_eq!(TAB5[0x00], 0xFF, "TAB5[0] = TAB4[0]^0xFF = 0xFF");
assert_eq!(TAB5[0xFF], 0x00, "TAB5[0xFF] = TAB4[0xFF]^0xFF = 0x00");
}
}
-128
View File
@@ -1,128 +0,0 @@
use super::*;
/// Pins the documented relationship `TAB5[i] == TAB4[i] ^ 0xFF` so the
/// table doc cannot drift from the data.
#[test]
fn tab5_is_complement_of_tab4() {
for i in 0..256 {
assert_eq!(
TAB5[i],
TAB4[i] ^ 0xFF,
"TAB5[{i:#04x}] != TAB4[{i:#04x}] ^ 0xFF"
);
}
}
// TAB1 is a bijection on 0..256; CSS uses it as an invertible output permutation in
// css_DecryptKey.
#[test]
fn tab1_is_a_permutation() {
let mut seen = [false; 256];
for (i, &v) in TAB1.iter().enumerate() {
assert!(
!seen[v as usize],
"TAB1 maps two inputs to {v:#04x} (collision at index {i:#04x})"
);
seen[v as usize] = true;
}
}
// TAB1's fixed structural anchors: TAB1[0x00] == 0x33 and TAB1[0x33] == 0x00, the canonical
// CSS-spec landmarks pinning the table's orientation.
#[test]
fn tab1_known_spec_anchors() {
assert_eq!(TAB1[0x00], 0x33, "TAB1[0] is the published 0x33");
assert_eq!(TAB1[0x33], 0x00, "TAB1[0x33] is the published 0x00");
}
// FNV-1a over a whole table: a swapped pair keeps a permutation and the anchors, so only
// a full-content pin catches a mistyped or regenerated table.
fn fnv1a(table: &[u8]) -> u64 {
table.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &b| {
(h ^ b as u64).wrapping_mul(0x0000_0100_0000_01b3)
})
}
#[test]
fn tab1_and_tab2_contents_are_pinned() {
assert_eq!(fnv1a(&TAB1), 0xcdb8_7228_1c08_c265, "TAB1 content changed");
assert_eq!(fnv1a(&TAB2), 0x79e0_69d7_0f3d_2f25, "TAB2 content changed");
}
// TAB2 is a permutation of 0..256 (LFSR1 high-byte feedback substitution); a non-bijective
// table would bias the LFSR1 keystream.
#[test]
fn tab2_is_a_permutation() {
let mut seen = [false; 256];
for (i, &v) in TAB2.iter().enumerate() {
assert!(
!seen[v as usize],
"TAB2 maps two inputs to {v:#04x} (collision at index {i:#04x})"
);
seen[v as usize] = true;
}
}
// TAB3 is CSS's LFSR1 low-word table: BASE = [0x00,0x24,0x49,0x6d,0x92, 0xb6,0xdb,0xff]
// repeated 64x (TAB3[i] == BASE[i & 7]); only the low 3 bits of the 9-bit index matter.
#[test]
fn tab3_matches_lfsr1_generating_formula() {
const BASE: [u8; 8] = [0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff];
for i in 0..512usize {
let expected = BASE[i & 7];
assert_eq!(
TAB3[i], expected,
"TAB3[{i:#05x}] = {:#04x}, formula BASE[i&7] = {expected:#04x}",
TAB3[i]
);
}
}
// TAB4 is the exact bit-reversal of each byte (CSS permutes LFSR0 bytes with it on
// seed/output), hence also an involution: TAB4[TAB4[b]] == b.
#[test]
fn tab4_is_exact_bit_reversal_and_involution() {
for b in 0u16..256 {
let rev = (0..8).fold(0u8, |acc, k| acc | (((b as u8 >> k) & 1) << (7 - k)));
assert_eq!(
TAB4[b as usize], rev,
"TAB4[{b:#04x}] is not the bit-reversal {rev:#04x}"
);
}
for b in 0..256usize {
assert_eq!(
TAB4[TAB4[b] as usize], b as u8,
"TAB4 not an involution at {b:#04x}"
);
}
// Spec landmark entries.
assert_eq!(TAB4[0x01], 0x80);
assert_eq!(TAB4[0x80], 0x01);
assert_eq!(TAB4[0x00], 0x00);
assert_eq!(TAB4[0xFF], 0xFF);
}
// TAB4 is a permutation (bit-reversal is bijective); distinct from the reversal test since
// "reversal except swapped/duplicated entries" cases would pass one test but fail the
// other.
#[test]
fn tab4_is_a_permutation() {
let mut seen = [false; 256];
for &v in TAB4.iter() {
assert!(!seen[v as usize], "TAB4 maps two inputs to {v:#04x}");
seen[v as usize] = true;
}
}
// TAB5 is a permutation (complement of a bijection) with orientation anchors
// TAB5[0x00]==0xFF and TAB5[0xFF]==0x00, independent of the complement-loop test.
#[test]
fn tab5_is_permutation_with_anchors() {
let mut seen = [false; 256];
for &v in TAB5.iter() {
assert!(!seen[v as usize], "TAB5 maps two inputs to {v:#04x}");
seen[v as usize] = true;
}
assert_eq!(TAB5[0x00], 0xFF, "TAB5[0] = TAB4[0]^0xFF = 0xFF");
assert_eq!(TAB5[0xFF], 0x00, "TAB5[0xFF] = TAB4[0xFF]^0xFF = 0x00");
}
+47 -2
View File
@@ -135,5 +135,50 @@ impl Diag {
}
#[cfg(test)]
#[path = "ctx_tests.rs"]
mod tests;
mod tests {
use super::*;
// Clones share the token, the events and the counters: a stage holding a clone
// observes the caller's Stop and reports into the caller's totals.
#[test]
fn a_clone_shares_halt_and_stats() {
let ctx = Ctx::default();
let stage = ctx.clone();
ctx.halt.cancel();
stage.stats.add_skip(2048);
stage.stats.add_blanked(2);
stage.stats.add_resync_dropped(3);
assert!(stage.halt.is_cancelled());
assert_eq!(
ctx.stats.snapshot(),
LossReport {
read_skips: 1,
bytes_lost: 2048,
units_blanked: 2,
resync_dropped: 3,
}
);
}
// Each switch follows its own variable; no other test reads either one.
#[test]
fn diag_from_env_reads_each_switch_from_its_own_variable() {
let set = |k: &str, on: bool| unsafe {
if on {
std::env::set_var(k, "1")
} else {
std::env::remove_var(k)
}
};
set("FREEMKV_SKIP_PARSE", true);
set("FREEMKV_PROFILE", false);
let d = Diag::from_env();
assert!(d.skip_parse && !d.profile);
set("FREEMKV_SKIP_PARSE", false);
set("FREEMKV_PROFILE", true);
let d = Diag::from_env();
assert!(!d.skip_parse && d.profile);
set("FREEMKV_PROFILE", false);
assert_eq!(Diag::from_env(), Diag::default());
}
}
-45
View File
@@ -1,45 +0,0 @@
use super::*;
// Clones share the token, the events and the counters: a stage holding a clone
// observes the caller's Stop and reports into the caller's totals.
#[test]
fn a_clone_shares_halt_and_stats() {
let ctx = Ctx::default();
let stage = ctx.clone();
ctx.halt.cancel();
stage.stats.add_skip(2048);
stage.stats.add_blanked(2);
stage.stats.add_resync_dropped(3);
assert!(stage.halt.is_cancelled());
assert_eq!(
ctx.stats.snapshot(),
LossReport {
read_skips: 1,
bytes_lost: 2048,
units_blanked: 2,
resync_dropped: 3,
}
);
}
// Each switch follows its own variable; no other test reads either one.
#[test]
fn diag_from_env_reads_each_switch_from_its_own_variable() {
let set = |k: &str, on: bool| unsafe {
if on {
std::env::set_var(k, "1")
} else {
std::env::remove_var(k)
}
};
set("FREEMKV_SKIP_PARSE", true);
set("FREEMKV_PROFILE", false);
let d = Diag::from_env();
assert!(d.skip_parse && !d.profile);
set("FREEMKV_SKIP_PARSE", false);
set("FREEMKV_PROFILE", true);
let d = Diag::from_env();
assert!(!d.skip_parse && d.profile);
set("FREEMKV_PROFILE", false);
assert_eq!(Diag::from_env(), Diag::default());
}
+1700 -115
View File
File diff suppressed because it is too large Load Diff
-166
View File
@@ -1,166 +0,0 @@
use super::*;
use crate::aacs::content::{ALIGNED_UNIT_LEN, encrypt_unit};
use crate::disc::ContentFormat;
use crate::spec::keys::{KS_5_CPI, KS_6_TP_EXTRA_HEADER, KS_22_LIBAACS_VERIFY_TS};
const OURS: [u8; 16] = [0xA1; 16];
const ALT: [u8; 16] = [0xB2; 16];
// An encrypted unit: TS with CPI 11₂ in every packet (KS-5), encrypted under `key`.
fn encrypted(key: &[u8; 16], salt: u8) -> Vec<u8> {
let mut u: Vec<u8> = (0..ALIGNED_UNIT_LEN)
.map(|i| (i as u8).wrapping_add(salt) | 1)
.collect();
for p in u.chunks_mut(192) {
p[0] |= 0xC0;
p[4] = 0x47;
}
assert!(encrypt_unit(&mut u, key));
u
}
fn cpi(unit: &[u8]) -> Vec<u8> {
unit.chunks(192).map(|p| p[0] & 0xC0).collect()
}
/// per spec; do not change without a spec citation — KS-5 [BD] §3.10.2: "shall be set
/// to 11₂ if the data is encrypted": a unit left as ciphertext keeps its CPI bits.
/// Covers the alternate FMTS phase, an orphan, and a `Phase::Verify` unit that fails
/// `is_clean` and is restored.
#[test]
fn ciphertext_units_keep_cpi() {
assert!(
KS_5_CPI
.text
.contains("set to 11₂ if the data is encrypted")
);
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, OURS)],
format: ContentFormat::BdTs,
};
// Alternate phase: unit 1 of an Even-phase range is the other variant's.
let mut buf = [encrypted(&OURS, 1), encrypted(&ALT, 2)].concat();
let alt_before = buf[ALIGNED_UNIT_LEN..].to_vec();
let map = AacsKeyMap::from_ranges_phased(vec![(0, 6, 0, Phase::Even)]);
decrypt_sectors_mapped(&mut buf, &keys, 0, &map).expect("our phase decrypts");
assert_eq!(
buf[ALIGNED_UNIT_LEN..],
alt_before[..],
"alternate phase untouched"
);
assert_eq!(
cpi(&buf[ALIGNED_UNIT_LEN..]),
cpi(&alt_before),
"CPI of all 32 packets"
);
assert_eq!(
buf[ALIGNED_UNIT_LEN] & 0xC0,
0xC0,
"packet 0's clear CPI still 11₂"
);
// Orphan: an encrypted unit no range covers is refused, and left as it was.
let mut orphan = encrypted(&ALT, 3);
let before = orphan.clone();
let map = AacsKeyMap::from_ranges_phased(vec![(30, 36, 0, Phase::All)]);
let got = decrypt_sectors_mapped(&mut orphan, &keys, 0, &map);
assert!(
matches!(got, Err(crate::error::Error::DecryptFailed)),
"{got:?}"
);
assert_eq!(orphan, before, "the orphan keeps every byte, CPI included");
assert_eq!(orphan[0] & 0xC0, 0xC0);
// A `Verify` unit our key does not open: restored, every byte and CPI kept.
let mut failed = encrypted(&ALT, 4);
let before = failed.clone();
let map = AacsKeyMap::from_ranges_phased(vec![(0, 3, 0, Phase::Verify)]);
decrypt_sectors_mapped(&mut failed, &keys, 0, &map).expect("a failed verify is no error");
assert_eq!(
failed, before,
"the failed Verify unit is restored as ciphertext"
);
assert_eq!(cpi(&failed), cpi(&before), "CPI of all 32 packets");
assert_eq!(failed[0] & 0xC0, 0xC0, "packet 0's clear CPI still 11₂");
}
/// per spec; do not change without a spec citation — KS-5 [BD] §3.10.2: "shall be set
/// to 11₂ if the data is encrypted, or … 00₂ if the data is not encrypted"; KS-6 (CPI is
/// the top 2 bits of TP_extra_header); corroborated by KS-22 libaacs `buf[i] &= ~0xc0`.
#[test]
fn every_decrypted_unit_clears_cpi_in_all_32_packets() {
assert!(KS_5_CPI.text.contains("00₂ if the data is not encrypted"));
assert!(
KS_6_TP_EXTRA_HEADER
.text
.contains("Copy_permission_indicator 2 uimsbf Arrival_time_stamp 30 uimsbf")
);
assert!(KS_22_LIBAACS_VERIFY_TS.text.contains("buf[i] &= ~0xc0;"));
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, OURS)],
format: ContentFormat::BdTs,
};
let check = |label: &str, got: &[u8], plain: Vec<u8>| {
assert!(
cpi(got).iter().all(|&c| c == 0),
"{label}: CPI of all 32 packets"
);
// Byte 0 of each packet keeps its 6 Arrival_time_stamp bits (KS-6).
let want = crate::aacs::content::cpi_cleared(plain);
assert_eq!(got, &want[..], "{label}: ATS and payload untouched");
};
// A map-keyed unit (`Phase::All`, the base Unit Key).
let mut unit = encrypted(&OURS, 7);
let mut plain = unit.clone();
crate::aacs::content::decrypt_unit(&mut plain, &OURS);
let map = AacsKeyMap::from_ranges(vec![(0, 3, 0)]);
decrypt_sectors_mapped(&mut unit, &keys, 0, &map).expect("map-keyed unit decrypts");
check("map-keyed", &unit, plain);
// A DAMAGED map-keyed unit: media garbage, still decrypted, so no longer ciphertext.
let mut damaged: Vec<u8> = (0..ALIGNED_UNIT_LEN)
.map(|i| (i as u8).wrapping_mul(37) ^ 0x5A)
.collect();
damaged[0] |= 0xC0;
damaged[4] = 0x47; // the clear seed's sync: read on the unit grid
let mut plain = damaged.clone();
crate::aacs::content::decrypt_unit(&mut plain, &OURS);
decrypt_sectors_mapped(&mut damaged, &keys, 0, &map).expect("no verify on Phase::All");
check("damaged map-keyed", &damaged, plain);
// Our half of a forensic segment (`Phase::Even`, unit 0 of the range).
let mut ours = encrypted(&OURS, 9);
let mut plain = ours.clone();
crate::aacs::content::decrypt_unit(&mut plain, &OURS);
let map = AacsKeyMap::from_ranges_phased(vec![(0, 3, 0, Phase::Even)]);
decrypt_sectors_mapped(&mut ours, &keys, 0, &map).expect("our phase decrypts");
check("our forensic phase", &ours, plain);
// A kept `Phase::Verify` unit (it verified under our key). The on-arrival leg
// joins with the on-arrival proof (KU-L2).
let mut kept = encrypted(&OURS, 11);
let mut plain = kept.clone();
crate::aacs::content::decrypt_unit(&mut plain, &OURS);
let map = AacsKeyMap::from_ranges_phased(vec![(0, 3, 0, Phase::Verify)]);
decrypt_sectors_mapped(&mut kept, &keys, 0, &map).expect("a verified unit decrypts");
check("kept Verify", &kept, plain);
}
// A flagged partial unit is blanked only when it ends the source (a truncated copy); a
// partial read mid-content is left for the decrypt to refuse.
#[test]
fn a_flagged_partial_unit_is_blanked_only_at_the_end() {
use crate::disc::ContentFormat;
let mut tail = vec![0x5Au8; 4096];
tail[0] |= 0xC0;
tail[4] = 0x47;
let mut mid = tail.clone();
assert_eq!(
blank_damaged_units(&mut mid, 0, ContentFormat::BdTs, &|_| true, false),
0
);
assert_eq!(mid, tail);
assert_eq!(
blank_damaged_units(&mut tail, 0, ContentFormat::BdTs, &|_| true, true),
1
);
assert!(tail.iter().all(|&b| b == 0));
}
-1625
View File
File diff suppressed because it is too large Load Diff
+272 -2
View File
@@ -631,5 +631,275 @@ fn dump_title(ti: usize, title: &DiscTitle) {
}
#[cfg(test)]
#[path = "diag_tests.rs"]
mod tests;
mod tests {
use super::*;
// Needed only by the tests: the production code in this file no longer names
// these types directly, since the local channel/sample-rate duplicates were
// deleted in favour of the canonical accessors.
use crate::disc::{AudioChannels, SampleRate};
// The main-feature decision row must NAME the comparator's real sort keys, not a restated
// (driftable) copy.
#[test]
fn main_feature_reason_names_the_comparators_real_keys() {
use crate::disc::{Clip, Disc, DiscTitle};
let sized = |size_bytes: u64, n_clips: usize| DiscTitle {
size_bytes,
clips: (0..n_clips)
.map(|i| Clip {
feed_span: None,
clip_id: format!("{i:05}"),
in_time: 0,
out_time: 0,
duration_secs: 0.0,
source_packets: 0,
})
.collect(),
..DiscTitle::empty()
};
// A 40-clip 8 GB title beats a 1-clip 1 GB title: the comparator's primary
// key among disc-fitting titles is LARGEST SIZE, not "fewest clips" (the
// drifted string described a rule the comparator does not implement).
let many_clips_big = sized(8_000_000_000, 40);
let one_clip_small = sized(1_000_000_000, 1);
assert_eq!(
Disc::canonical_title_order(&many_clips_big, &one_clip_small, 25_000_000_000),
std::cmp::Ordering::Less,
"largest size wins regardless of clip count"
);
let reason = main_feature_reason();
assert!(
!reason.contains("clips"),
"the reason must not advertise a clip-count key the comparator dropped: {reason}"
);
// Equal-size seamless siblings (issue #45) are split ONLY by the final
// lowest-playlist-id key, so the reason must name it.
let sibling = |playlist_id: u16| DiscTitle {
playlist_id,
..sized(8_000_000_000, 1)
};
assert_eq!(
Disc::canonical_title_order(&sibling(800), &sibling(808), 25_000_000_000),
std::cmp::Ordering::Less,
"the comparator's final key is lowest playlist id"
);
assert!(
reason.ends_with(", lowest-playlist-id)"),
"the final tiebreak must be named: {reason}"
);
assert_eq!(
reason,
"main_feature_order(nav-feature, authoring-feature, standalone, has-video, fits-disc, largest-size, longest, richest-audio, more-video, more-subs, lowest-playlist-id)",
"the reason must name the selection keys (authoring, standalone-over-composite, and has-video gates, then the physical keys) in priority order"
);
}
// A track index that does not fit the u8 record field is skipped, not aliased.
#[test]
fn opening_capture_skips_track_indices_above_255() {
let path = std::env::temp_dir().join(format!("fmk-diag-hi-{}.bin", std::process::id()));
let file = std::fs::File::create(&path).unwrap();
let mut cap = OpeningCapture {
file,
counts: vec![0; 300],
};
cap.record(256, 0, true, b"x");
let len = std::fs::metadata(&path).unwrap().len();
let _ = std::fs::remove_file(&path);
assert_eq!(len, 0, "idx 256 must not write an aliased track-0 record");
assert_eq!(cap.counts[256], 0);
}
// A failed write may leave a torn record, so capture stops on every track.
#[test]
fn opening_capture_write_error_disables_all_tracks() {
let path = std::env::temp_dir().join(format!("fmk-diag-ro-{}.bin", std::process::id()));
std::fs::write(&path, b"").unwrap();
let file = std::fs::File::open(&path).unwrap(); // read-only: writes fail
let mut cap = OpeningCapture {
file,
counts: vec![0; 2],
};
cap.record(0, 0, true, b"x");
let _ = std::fs::remove_file(&path);
assert_eq!(
cap.counts[1], OPENING_FRAMES_PER_TRACK,
"other tracks must stop after a write error"
);
}
// The per-track cap stops one track at OPENING_FRAMES_PER_TRACK records without
// affecting another, and an out-of-range track index is ignored.
#[test]
fn opening_capture_caps_each_track_and_ignores_unknown_tracks() {
let path = std::env::temp_dir().join(format!("fmk-diag-cap-{}.bin", std::process::id()));
let file = std::fs::File::create(&path).unwrap();
let mut cap = OpeningCapture {
file,
counts: vec![0; 2],
};
for _ in 0..OPENING_FRAMES_PER_TRACK + 5 {
cap.record(0, 0, true, b"x");
}
cap.record(1, 0, false, b"y");
cap.record(2, 0, true, b"z");
let len = std::fs::metadata(&path).unwrap().len();
let _ = std::fs::remove_file(&path);
assert_eq!(cap.counts, vec![OPENING_FRAMES_PER_TRACK, 1]);
assert_eq!(len as usize, (OPENING_FRAMES_PER_TRACK + 1) * 15);
}
// With the diag target off, no side file is created and the rip is unaffected.
#[test]
fn opening_capture_new_is_none_when_diag_is_off() {
let path = std::env::temp_dir().join(format!("fmk-diag-off-{}.mkv", std::process::id()));
assert!(OpeningCapture::new(&path, 3).is_none());
let mut side = path.as_os_str().to_os_string();
side.push(".opening.bin");
assert!(!std::path::Path::new(&side).exists());
}
#[test]
fn res_str_keeps_interlace_marker() {
assert_eq!(res_str(Resolution::R576i), "576i");
assert_eq!(res_str(Resolution::R480i), "480i");
assert_eq!(res_str(Resolution::R2160p), "2160p");
}
#[test]
fn fps_and_tv_system() {
assert_eq!(fps_str(FrameRate::F25), "25");
assert_eq!(tv_system_str(FrameRate::F25), "PAL");
assert_eq!(fps_str(FrameRate::F29_97), "29.97");
assert_eq!(tv_system_str(FrameRate::F29_97), "NTSC");
assert_eq!(tv_system_str(FrameRate::F50), "PAL");
assert_eq!(tv_system_str(FrameRate::F23_976), "NTSC");
assert_eq!(tv_system_str(FrameRate::F59_94), "NTSC");
}
#[test]
fn color_and_hdr() {
assert_eq!(color_str(ColorSpace::Bt470bg), "BT.470BG");
assert_eq!(color_str(ColorSpace::Bt2020), "BT.2020");
assert_eq!(hdr_str(HdrFormat::Hdr10), "HDR10");
assert_eq!(hdr_str(HdrFormat::DolbyVision), "DoVi");
assert_eq!(hdr_str(HdrFormat::Sdr), "SDR");
}
/// Moved from the deleted local duplicates onto the canonical accessors,
/// with the Unknown case added — which is the whole point of the change.
#[test]
fn channel_count_matches_layout_and_is_zero_when_unknown() {
assert_eq!(AudioChannels::Mono.count(), 1);
assert_eq!(AudioChannels::Stereo.count(), 2);
assert_eq!(AudioChannels::Surround51.count(), 6);
assert_eq!(AudioChannels::Surround71.count(), 8);
// The one that matters. This used to return 6, which is indistinguishable
// from a real 5.1 track and left every caller responsible for checking
// the variant first.
assert_eq!(
AudioChannels::Unknown.count(),
0,
"an unknown layout must not report a plausible channel count"
);
}
#[test]
fn sample_rate_hz_values_and_zero_when_unknown() {
assert_eq!(SampleRate::S48.hz(), 48000.0);
assert_eq!(SampleRate::S96.hz(), 96000.0);
assert_eq!(
SampleRate::Unknown.hz(),
0.0,
"an unknown sample rate must not report a plausible 48 kHz"
);
}
#[test]
fn codec_private_hex_renders_caps_and_handles_empty() {
// None / empty → "none" (no hex). The Windows-fps diagnosis only needs
// the seq-header prefix, so render it but cap long blobs.
assert_eq!(codec_private_hex(None), "none");
assert_eq!(codec_private_hex(Some(&[])), "none");
// Short blob: full uppercase hex, no suffix. An MPEG-2 seq header starts
// 00 00 01 B3 — exactly what a reader greps for in a bug log.
assert_eq!(
codec_private_hex(Some(&[0x00, 0x00, 0x01, 0xB3])),
"000001B3"
);
// Over the cap: first CODEC_PRIVATE_HEX_CAP bytes + a "..(+NB)" summary.
let big = vec![0xABu8; CODEC_PRIVATE_HEX_CAP + 5];
let s = codec_private_hex(Some(&big));
assert!(s.starts_with(&"AB".repeat(CODEC_PRIVATE_HEX_CAP)), "{s}");
assert!(s.ends_with("..(+5B)"), "{s}");
}
#[test]
fn frame_record_layout_is_parseable() {
// The .opening.bin record framing must round-trip so a future tool can
// split the side file back into frames without the disc:
// [track:u8][keyframe:u8][pts_ns:i64 LE][len:u32 LE][raw bytes].
let data = [0xDEu8, 0xAD, 0xBE, 0xEF];
let rec = frame_record(2, -40_000_000, true, &data);
assert_eq!(rec.len(), 14 + data.len());
assert_eq!(rec[0], 2, "track index");
assert_eq!(rec[1], 1, "keyframe flag");
assert_eq!(
i64::from_le_bytes(rec[2..10].try_into().unwrap()),
-40_000_000,
"pts_ns survives (signed — opening back-anchor can be negative)"
);
assert_eq!(
u32::from_le_bytes(rec[10..14].try_into().unwrap()),
4,
"len"
);
assert_eq!(&rec[14..], &data, "raw frame bytes follow");
// A non-keyframe records the flag as 0.
let delta = frame_record(0, 0, false, &[]);
assert_eq!(delta[1], 0);
assert_eq!(u32::from_le_bytes(delta[10..14].try_into().unwrap()), 0);
}
/// The cell row shows the raw category byte (0xNN) beside the decode, and
/// the keep/drop verdict. A plain feature cell (0x00) is "keep"; a leading
/// secondary-block cell flagged dropped reads "DROP".
#[test]
fn cell_row_shows_raw_byte_and_verdict() {
let plain = crate::ifo::DvdCell {
first_sector: 100,
last_sector: 199,
category: 0x00,
duration_secs: 12.5,
};
let row = dvd_cell_row(0, &plain, false);
assert!(row.contains("cat=0x00"), "{row}");
assert!(row.contains("block_mode=0"), "{row}");
assert!(row.contains("first=100"), "{row}");
assert!(row.contains("last=199"), "{row}");
assert!(row.contains("dur=12.5s"), "{row}");
assert!(row.contains("keep(plain-feature)"), "{row}");
assert!(!row.contains("DROP"), "{row}");
// 0x90 = in-block cell of an angle block (block_mode=2, block_type=1),
// shown dropped as a leading secondary piece.
let sec = crate::ifo::DvdCell {
first_sector: 0,
last_sector: 9,
category: 0x90,
duration_secs: 1.0,
};
let row = dvd_cell_row(0, &sec, true);
assert!(row.contains("cat=0x90"), "{row}");
assert!(row.contains("block_mode=2"), "{row}");
assert!(row.contains("block_type=1"), "{row}");
assert!(row.contains("DROP(leading-secondary-block-piece)"), "{row}");
// The same secondary piece past the leading run is kept, as feature body.
let row = dvd_cell_row(3, &sec, false);
assert!(row.contains("keep(feature-body)"), "{row}");
assert!(!row.contains("keep(plain-feature)"), "{row}");
}
}
-270
View File
@@ -1,270 +0,0 @@
use super::*;
// Needed only by the tests: the production code in this file no longer names
// these types directly, since the local channel/sample-rate duplicates were
// deleted in favour of the canonical accessors.
use crate::disc::{AudioChannels, SampleRate};
// The main-feature decision row must NAME the comparator's real sort keys, not a restated
// (driftable) copy.
#[test]
fn main_feature_reason_names_the_comparators_real_keys() {
use crate::disc::{Clip, Disc, DiscTitle};
let sized = |size_bytes: u64, n_clips: usize| DiscTitle {
size_bytes,
clips: (0..n_clips)
.map(|i| Clip {
feed_span: None,
clip_id: format!("{i:05}"),
in_time: 0,
out_time: 0,
duration_secs: 0.0,
source_packets: 0,
})
.collect(),
..DiscTitle::empty()
};
// A 40-clip 8 GB title beats a 1-clip 1 GB title: the comparator's primary
// key among disc-fitting titles is LARGEST SIZE, not "fewest clips" (the
// drifted string described a rule the comparator does not implement).
let many_clips_big = sized(8_000_000_000, 40);
let one_clip_small = sized(1_000_000_000, 1);
assert_eq!(
Disc::canonical_title_order(&many_clips_big, &one_clip_small, 25_000_000_000),
std::cmp::Ordering::Less,
"largest size wins regardless of clip count"
);
let reason = main_feature_reason();
assert!(
!reason.contains("clips"),
"the reason must not advertise a clip-count key the comparator dropped: {reason}"
);
// Equal-size seamless siblings (issue #45) are split ONLY by the final
// lowest-playlist-id key, so the reason must name it.
let sibling = |playlist_id: u16| DiscTitle {
playlist_id,
..sized(8_000_000_000, 1)
};
assert_eq!(
Disc::canonical_title_order(&sibling(800), &sibling(808), 25_000_000_000),
std::cmp::Ordering::Less,
"the comparator's final key is lowest playlist id"
);
assert!(
reason.ends_with(", lowest-playlist-id)"),
"the final tiebreak must be named: {reason}"
);
assert_eq!(
reason,
"main_feature_order(nav-feature, authoring-feature, standalone, has-video, fits-disc, largest-size, longest, richest-audio, more-video, more-subs, lowest-playlist-id)",
"the reason must name the selection keys (authoring, standalone-over-composite, and has-video gates, then the physical keys) in priority order"
);
}
// A track index that does not fit the u8 record field is skipped, not aliased.
#[test]
fn opening_capture_skips_track_indices_above_255() {
let path = std::env::temp_dir().join(format!("fmk-diag-hi-{}.bin", std::process::id()));
let file = std::fs::File::create(&path).unwrap();
let mut cap = OpeningCapture {
file,
counts: vec![0; 300],
};
cap.record(256, 0, true, b"x");
let len = std::fs::metadata(&path).unwrap().len();
let _ = std::fs::remove_file(&path);
assert_eq!(len, 0, "idx 256 must not write an aliased track-0 record");
assert_eq!(cap.counts[256], 0);
}
// A failed write may leave a torn record, so capture stops on every track.
#[test]
fn opening_capture_write_error_disables_all_tracks() {
let path = std::env::temp_dir().join(format!("fmk-diag-ro-{}.bin", std::process::id()));
std::fs::write(&path, b"").unwrap();
let file = std::fs::File::open(&path).unwrap(); // read-only: writes fail
let mut cap = OpeningCapture {
file,
counts: vec![0; 2],
};
cap.record(0, 0, true, b"x");
let _ = std::fs::remove_file(&path);
assert_eq!(
cap.counts[1], OPENING_FRAMES_PER_TRACK,
"other tracks must stop after a write error"
);
}
// The per-track cap stops one track at OPENING_FRAMES_PER_TRACK records without
// affecting another, and an out-of-range track index is ignored.
#[test]
fn opening_capture_caps_each_track_and_ignores_unknown_tracks() {
let path = std::env::temp_dir().join(format!("fmk-diag-cap-{}.bin", std::process::id()));
let file = std::fs::File::create(&path).unwrap();
let mut cap = OpeningCapture {
file,
counts: vec![0; 2],
};
for _ in 0..OPENING_FRAMES_PER_TRACK + 5 {
cap.record(0, 0, true, b"x");
}
cap.record(1, 0, false, b"y");
cap.record(2, 0, true, b"z");
let len = std::fs::metadata(&path).unwrap().len();
let _ = std::fs::remove_file(&path);
assert_eq!(cap.counts, vec![OPENING_FRAMES_PER_TRACK, 1]);
assert_eq!(len as usize, (OPENING_FRAMES_PER_TRACK + 1) * 15);
}
// With the diag target off, no side file is created and the rip is unaffected.
#[test]
fn opening_capture_new_is_none_when_diag_is_off() {
let path = std::env::temp_dir().join(format!("fmk-diag-off-{}.mkv", std::process::id()));
assert!(OpeningCapture::new(&path, 3).is_none());
let mut side = path.as_os_str().to_os_string();
side.push(".opening.bin");
assert!(!std::path::Path::new(&side).exists());
}
#[test]
fn res_str_keeps_interlace_marker() {
assert_eq!(res_str(Resolution::R576i), "576i");
assert_eq!(res_str(Resolution::R480i), "480i");
assert_eq!(res_str(Resolution::R2160p), "2160p");
}
#[test]
fn fps_and_tv_system() {
assert_eq!(fps_str(FrameRate::F25), "25");
assert_eq!(tv_system_str(FrameRate::F25), "PAL");
assert_eq!(fps_str(FrameRate::F29_97), "29.97");
assert_eq!(tv_system_str(FrameRate::F29_97), "NTSC");
assert_eq!(tv_system_str(FrameRate::F50), "PAL");
assert_eq!(tv_system_str(FrameRate::F23_976), "NTSC");
assert_eq!(tv_system_str(FrameRate::F59_94), "NTSC");
}
#[test]
fn color_and_hdr() {
assert_eq!(color_str(ColorSpace::Bt470bg), "BT.470BG");
assert_eq!(color_str(ColorSpace::Bt2020), "BT.2020");
assert_eq!(hdr_str(HdrFormat::Hdr10), "HDR10");
assert_eq!(hdr_str(HdrFormat::DolbyVision), "DoVi");
assert_eq!(hdr_str(HdrFormat::Sdr), "SDR");
}
/// Moved from the deleted local duplicates onto the canonical accessors,
/// with the Unknown case added — which is the whole point of the change.
#[test]
fn channel_count_matches_layout_and_is_zero_when_unknown() {
assert_eq!(AudioChannels::Mono.count(), 1);
assert_eq!(AudioChannels::Stereo.count(), 2);
assert_eq!(AudioChannels::Surround51.count(), 6);
assert_eq!(AudioChannels::Surround71.count(), 8);
// The one that matters. This used to return 6, which is indistinguishable
// from a real 5.1 track and left every caller responsible for checking
// the variant first.
assert_eq!(
AudioChannels::Unknown.count(),
0,
"an unknown layout must not report a plausible channel count"
);
}
#[test]
fn sample_rate_hz_values_and_zero_when_unknown() {
assert_eq!(SampleRate::S48.hz(), 48000.0);
assert_eq!(SampleRate::S96.hz(), 96000.0);
assert_eq!(
SampleRate::Unknown.hz(),
0.0,
"an unknown sample rate must not report a plausible 48 kHz"
);
}
#[test]
fn codec_private_hex_renders_caps_and_handles_empty() {
// None / empty → "none" (no hex). The Windows-fps diagnosis only needs
// the seq-header prefix, so render it but cap long blobs.
assert_eq!(codec_private_hex(None), "none");
assert_eq!(codec_private_hex(Some(&[])), "none");
// Short blob: full uppercase hex, no suffix. An MPEG-2 seq header starts
// 00 00 01 B3 — exactly what a reader greps for in a bug log.
assert_eq!(
codec_private_hex(Some(&[0x00, 0x00, 0x01, 0xB3])),
"000001B3"
);
// Over the cap: first CODEC_PRIVATE_HEX_CAP bytes + a "..(+NB)" summary.
let big = vec![0xABu8; CODEC_PRIVATE_HEX_CAP + 5];
let s = codec_private_hex(Some(&big));
assert!(s.starts_with(&"AB".repeat(CODEC_PRIVATE_HEX_CAP)), "{s}");
assert!(s.ends_with("..(+5B)"), "{s}");
}
#[test]
fn frame_record_layout_is_parseable() {
// The .opening.bin record framing must round-trip so a future tool can
// split the side file back into frames without the disc:
// [track:u8][keyframe:u8][pts_ns:i64 LE][len:u32 LE][raw bytes].
let data = [0xDEu8, 0xAD, 0xBE, 0xEF];
let rec = frame_record(2, -40_000_000, true, &data);
assert_eq!(rec.len(), 14 + data.len());
assert_eq!(rec[0], 2, "track index");
assert_eq!(rec[1], 1, "keyframe flag");
assert_eq!(
i64::from_le_bytes(rec[2..10].try_into().unwrap()),
-40_000_000,
"pts_ns survives (signed — opening back-anchor can be negative)"
);
assert_eq!(
u32::from_le_bytes(rec[10..14].try_into().unwrap()),
4,
"len"
);
assert_eq!(&rec[14..], &data, "raw frame bytes follow");
// A non-keyframe records the flag as 0.
let delta = frame_record(0, 0, false, &[]);
assert_eq!(delta[1], 0);
assert_eq!(u32::from_le_bytes(delta[10..14].try_into().unwrap()), 0);
}
/// The cell row shows the raw category byte (0xNN) beside the decode, and
/// the keep/drop verdict. A plain feature cell (0x00) is "keep"; a leading
/// secondary-block cell flagged dropped reads "DROP".
#[test]
fn cell_row_shows_raw_byte_and_verdict() {
let plain = crate::ifo::DvdCell {
first_sector: 100,
last_sector: 199,
category: 0x00,
duration_secs: 12.5,
};
let row = dvd_cell_row(0, &plain, false);
assert!(row.contains("cat=0x00"), "{row}");
assert!(row.contains("block_mode=0"), "{row}");
assert!(row.contains("first=100"), "{row}");
assert!(row.contains("last=199"), "{row}");
assert!(row.contains("dur=12.5s"), "{row}");
assert!(row.contains("keep(plain-feature)"), "{row}");
assert!(!row.contains("DROP"), "{row}");
// 0x90 = in-block cell of an angle block (block_mode=2, block_type=1),
// shown dropped as a leading secondary piece.
let sec = crate::ifo::DvdCell {
first_sector: 0,
last_sector: 9,
category: 0x90,
duration_secs: 1.0,
};
let row = dvd_cell_row(0, &sec, true);
assert!(row.contains("cat=0x90"), "{row}");
assert!(row.contains("block_mode=2"), "{row}");
assert!(row.contains("block_type=1"), "{row}");
assert!(row.contains("DROP(leading-secondary-block-piece)"), "{row}");
// The same secondary piece past the leading run is kept, as feature body.
let row = dvd_cell_row(3, &sec, false);
assert!(row.contains("keep(feature-body)"), "{row}");
assert!(!row.contains("keep(plain-feature)"), "{row}");
}
+72 -2
View File
@@ -644,5 +644,75 @@ pub(super) fn encode(layout: &Layout) -> Result<MetaSectors> {
}
#[cfg(test)]
#[path = "encode_tests.rs"]
mod tests;
mod tests {
use super::*;
// Reference check value for CRC-16/XMODEM: poly 0x1021 seeded at 0
// (ECMA-167 7.2.4): "123456789" -> 0x31C3. CCITT-FALSE (seed 0xFFFF)
// gives 0x29B1, a mutant udf.rs wouldn't catch — it never verifies a CRC.
#[test]
fn crc16_matches_the_ecma167_check_value() {
assert_eq!(crc16(b"123456789"), 0x31C3);
}
/// ECMA-167 3/7.2.3: the checksum is the sum of the tag's first 16 bytes
/// EXCLUDING the checksum byte itself, modulo 256.
#[test]
fn tag_checksum_excludes_its_own_byte() {
let mut buf = [0u8; 512];
buf[16..24].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
finish_tag(&mut buf, 261, 0x1234, 512);
let sum: u32 = buf[0..16]
.iter()
.enumerate()
.filter(|(i, _)| *i != 4)
.map(|(_, b)| *b as u32)
.sum();
assert_eq!(buf[4] as u32, sum % 256);
// And the recorded CRC covers the body, not the tag.
let crc = u16::from_le_bytes([buf[8], buf[9]]);
assert_eq!(crc, crc16(&buf[16..512]));
assert_eq!(u16::from_le_bytes([buf[10], buf[11]]), 496);
assert_eq!(
u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]),
0x1234
);
}
/// ASCII takes compression ID 8; anything above takes 16 (UTF-16BE),
/// because `parse_udf_name` decodes compression-8 bytes as Latin-1.
#[test]
fn cs0_picks_the_encoding_the_parser_can_decode() {
assert_eq!(encode_cs0("AB"), vec![8, b'A', b'B']);
let e = encode_cs0("Ä");
assert_eq!(e[0], 16);
assert_eq!(&e[1..], &[0x00, 0xC4]);
assert_eq!(crate::udf::parse_udf_name(&e), "Ä");
}
/// A d-string records its used length in the field's LAST byte, and the
/// production parser must read the same string back.
#[test]
fn dstring_round_trips_through_the_production_parser() {
let mut field = [0u8; 32];
put_dstring(&mut field, "FREEMKV");
assert_eq!(field[31], 8, "compid byte + 7 characters");
assert_eq!(crate::udf::parse_dstring_for_test(&field), "FREEMKV");
}
/// L089: the PVD's interchange level must be 3 (not 2) — the level that
/// permits a file to span multiple extents, needed for a large VOB/M2TS
/// on a hybrid video disc. `file_set` already writes 3 for the same reason.
#[test]
fn primary_volume_interchange_level_permits_multi_extent_files() {
let pvd = primary_volume("FREEMKV", 16, 0);
assert_eq!(
(
u16::from_le_bytes([pvd[60], pvd[61]]),
u16::from_le_bytes([pvd[62], pvd[63]]),
),
(3, 3),
"PVD interchange level / max must be 3, permitting multi-extent files"
);
}
}
-70
View File
@@ -1,70 +0,0 @@
use super::*;
// Reference check value for CRC-16/XMODEM: poly 0x1021 seeded at 0
// (ECMA-167 7.2.4): "123456789" -> 0x31C3. CCITT-FALSE (seed 0xFFFF)
// gives 0x29B1, a mutant udf.rs wouldn't catch — it never verifies a CRC.
#[test]
fn crc16_matches_the_ecma167_check_value() {
assert_eq!(crc16(b"123456789"), 0x31C3);
}
/// ECMA-167 3/7.2.3: the checksum is the sum of the tag's first 16 bytes
/// EXCLUDING the checksum byte itself, modulo 256.
#[test]
fn tag_checksum_excludes_its_own_byte() {
let mut buf = [0u8; 512];
buf[16..24].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
finish_tag(&mut buf, 261, 0x1234, 512);
let sum: u32 = buf[0..16]
.iter()
.enumerate()
.filter(|(i, _)| *i != 4)
.map(|(_, b)| *b as u32)
.sum();
assert_eq!(buf[4] as u32, sum % 256);
// And the recorded CRC covers the body, not the tag.
let crc = u16::from_le_bytes([buf[8], buf[9]]);
assert_eq!(crc, crc16(&buf[16..512]));
assert_eq!(u16::from_le_bytes([buf[10], buf[11]]), 496);
assert_eq!(
u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]),
0x1234
);
}
/// ASCII takes compression ID 8; anything above takes 16 (UTF-16BE),
/// because `parse_udf_name` decodes compression-8 bytes as Latin-1.
#[test]
fn cs0_picks_the_encoding_the_parser_can_decode() {
assert_eq!(encode_cs0("AB"), vec![8, b'A', b'B']);
let e = encode_cs0("Ä");
assert_eq!(e[0], 16);
assert_eq!(&e[1..], &[0x00, 0xC4]);
assert_eq!(crate::udf::parse_udf_name(&e), "Ä");
}
/// A d-string records its used length in the field's LAST byte, and the
/// production parser must read the same string back.
#[test]
fn dstring_round_trips_through_the_production_parser() {
let mut field = [0u8; 32];
put_dstring(&mut field, "FREEMKV");
assert_eq!(field[31], 8, "compid byte + 7 characters");
assert_eq!(crate::udf::parse_dstring_for_test(&field), "FREEMKV");
}
/// L089: the PVD's interchange level must be 3 (not 2) — the level that
/// permits a file to span multiple extents, needed for a large VOB/M2TS
/// on a hybrid video disc. `file_set` already writes 3 for the same reason.
#[test]
fn primary_volume_interchange_level_permits_multi_extent_files() {
let pvd = primary_volume("FREEMKV", 16, 0);
assert_eq!(
(
u16::from_le_bytes([pvd[60], pvd[61]]),
u16::from_le_bytes([pvd[62], pvd[63]]),
),
(3, 3),
"PVD interchange level / max must be 3, permitting multi-extent files"
);
}
+291 -2
View File
@@ -689,5 +689,294 @@ pub(super) fn metadata_block_count(root: &DirNode) -> u64 {
}
#[cfg(test)]
#[path = "layout_tests.rs"]
mod tests;
mod tests {
use super::*;
// A file above the 30-bit AD ceiling must split on a block boundary, or the next extent's
// bytes start mid-sector.
#[test]
fn a_file_past_the_ad_ceiling_splits_on_a_block_boundary() {
let mut f = FileNode {
name: "BIG.M2TS".into(),
disc_path: "/BIG.M2TS".into(),
host: PathBuf::new(),
size: MAX_AD_BYTES + 4096,
mtime: None,
icb_lba: 0,
unique_id: 0,
extents: Vec::new(),
};
let end = place_file(&mut f, 1000).unwrap();
assert_eq!(f.extents.len(), 2);
assert_eq!(f.extents[0].bytes as u64, MAX_AD_BYTES);
assert_eq!(f.extents[0].bytes % SECTOR as u32, 0, "block multiple");
assert!(f.extents[0].bytes <= 0x3FFF_FFFF);
assert_eq!(f.extents[1].bytes, 4096);
assert_eq!(
f.extents[1].lba,
1000 + (MAX_AD_BYTES / SECTOR as u64) as u32,
"the second extent starts where the first ends"
);
assert_eq!(end, f.extents[1].lba + 2);
assert_eq!(
f.extents.iter().map(|e| e.bytes as u64).sum::<u64>(),
f.size,
"no bytes lost or invented"
);
}
/// The metadata ceiling must account for each directory's FID-list sectors, not just one
/// File Entry per node: a wide directory's FID list can span many sectors that the old
/// (dir_count + file_count) count ignored, so an oversized folder could slip past the guard
/// and be materialized in full.
#[test]
fn metadata_block_count_includes_the_fid_list_sectors() {
let files: Vec<FileNode> = (0..300)
.map(|i| FileNode {
name: format!("A_REASONABLY_LONG_FILENAME_{i:04}.M2TS"),
disc_path: format!("/{i}"),
host: PathBuf::new(),
size: 0,
mtime: None,
icb_lba: 0,
unique_id: 0,
extents: Vec::new(),
})
.collect();
let root = DirNode {
name: String::new(),
icb_lba: 0,
parent_icb_lba: 0,
data_lba: 0,
data_bytes: 0,
unique_id: 0,
dirs: Vec::new(),
files,
};
let mut dir_count = 0;
let mut file_count = 0;
count_nodes(&root, &mut dir_count, &mut file_count);
// Old formula: one File Entry per node only.
let file_entry_only = dir_count as u64 + file_count as u64;
let fid_sectors = dir_bytes(&root.dirs, &root.files).div_ceil(SECTOR) as u64;
assert!(
fid_sectors > 1,
"300 files must fill more than one FID sector"
);
// 2 (FSD + Terminating Descriptor) + File Entries + FID-list sectors.
assert_eq!(
metadata_block_count(&root),
2 + file_entry_only + fid_sectors
);
assert!(
metadata_block_count(&root) > file_entry_only,
"the ceiling must count FID-list sectors the old formula omitted"
);
}
/// A zero-byte file records no allocation descriptors at all and consumes
/// no blocks.
#[test]
fn an_empty_file_gets_no_extents() {
let mut f = FileNode {
name: "EMPTY".into(),
disc_path: "/EMPTY".into(),
host: PathBuf::new(),
size: 0,
mtime: None,
icb_lba: 0,
unique_id: 0,
extents: Vec::new(),
};
assert_eq!(place_file(&mut f, 500).unwrap(), 500);
assert!(f.extents.is_empty());
}
#[test]
fn host_artefacts_are_not_disc_content() {
assert!(is_excluded(".DS_Store"));
assert!(is_excluded("._00000.m2ts"));
assert!(is_excluded("00000.m2ts.partial"));
assert!(!is_excluded("00000.m2ts"));
assert!(!is_excluded("VTS_01_1.VOB"));
}
// Two host names the READER collapses into one must be refused by the planner. Calls `plan`
// on a real folder.
#[test]
fn two_names_the_reader_cannot_tell_apart_are_refused() {
let dir = std::env::temp_dir().join(format!(
"fmkv-shadow-{}-{:?}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let stream = dir.join("BDMV/STREAM");
std::fs::create_dir_all(&stream).expect("mkdir");
std::fs::write(stream.join("00000.m2ts"), b"a").expect("write");
// Same name to the reader: it trims the leading space.
std::fs::write(stream.join(" 00000.m2ts"), b"b").expect("write");
// Precondition — if this stops holding the fixture is wrong, not the code.
assert_eq!(
crate::udf::parse_udf_name(&crate::dirimage::encode::encode_cs0(" 00000.m2ts")),
crate::udf::parse_udf_name(&crate::dirimage::encode::encode_cs0("00000.m2ts")),
"fixture: these two host names must read back identically"
);
let got = plan(&dir);
let _ = std::fs::remove_dir_all(&dir);
assert!(
matches!(got, Err(Error::DirNameCollision { .. })),
"a name the reader cannot distinguish must be refused, got {got:?}"
);
}
// A name too long for the FID's one-byte length field is refused by the planner, on a real
// folder.
#[test]
fn an_over_long_name_is_refused_by_the_planner() {
let dir = std::env::temp_dir().join(format!(
"fmkv-longname-{}-{:?}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(dir.join("BDMV/STREAM")).expect("mkdir");
// 255 bytes: the exact length that used to narrow to zero.
let name = "a".repeat(255);
assert_eq!(
crate::dirimage::encode::encode_cs0(&name).len(),
256,
"fixture: NAME_MAX encodes to 256 bytes with the compression byte"
);
std::fs::write(dir.join("BDMV/STREAM").join(&name), b"x").expect("write");
let err = plan(&dir).expect_err("the planner must refuse this folder");
assert!(
matches!(err, Error::DirNameTooLong { .. }),
"expected DirNameTooLong, got {err:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// A name at the cap is accepted, so the guard rejects only what it must.
#[test]
fn a_name_at_the_cap_is_accepted() {
let dir = std::env::temp_dir().join(format!(
"fmkv-okname-{}-{:?}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(dir.join("BDMV/STREAM")).expect("mkdir");
// One byte of name shorter, so the encoding lands exactly on the cap.
let name = "a".repeat(MAX_CS0_NAME_BYTES - 1);
assert_eq!(
crate::dirimage::encode::encode_cs0(&name).len(),
MAX_CS0_NAME_BYTES
);
std::fs::write(dir.join("BDMV/STREAM").join(&name), b"x").expect("write");
assert!(
plan(&dir).is_ok(),
"a name whose encoding equals the cap must be accepted"
);
let _ = std::fs::remove_dir_all(&dir);
}
// The subdirectory cap must keep the 16-bit link count representable. Pins the arithmetic;
// does NOT exercise `walk`.
#[test]
fn the_subdir_cap_refuses_a_folder_with_too_many_subdirectories() {
// Executes the guard rather than restating the constant: link count (child
// dirs + 1) is 16 bits, so exceeding it wraps and the image lies about
// its own directory structure.
let dir = std::env::temp_dir().join(format!("fmkv-fanout-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for i in 0..=MAX_SUBDIRS {
std::fs::create_dir_all(dir.join(format!("d{i}"))).unwrap();
}
let err = plan(&dir).expect_err("more subdirectories than the link count can represent");
assert!(
matches!(err, Error::DirImageFanout { .. }),
"expected DirImageFanout, got {err:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
// `walk` counts every entry against `MAX_ENTRIES`: the entry that would pass it is refused.
#[test]
fn the_entry_cap_refuses_the_entry_past_max_entries() {
let dir = std::env::temp_dir().join(format!("fmkv-entries-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.bin"), b"x").unwrap();
let mut at_cap = MAX_ENTRIES - 1;
assert!(
walk(&dir, "/", 0, &mut at_cap).is_ok(),
"the last allowed entry"
);
let mut past = MAX_ENTRIES;
assert!(matches!(
walk(&dir, "/", 0, &mut past),
Err(Error::DirImageTooLarge)
));
let _ = std::fs::remove_dir_all(&dir);
}
// The nesting cap (`MAX_DEPTH`) refuses a tree deeper than the planner
// represents, before it recurses without bound. Executes `walk`'s depth
// guard on a real directory chain, not the constant.
#[test]
fn the_nesting_cap_refuses_a_tree_deeper_than_max_depth() {
let base = std::env::temp_dir().join(format!("fmkv-depth-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
// MAX_DEPTH + 2 nested levels below the root guarantees a `walk` call at
// a depth greater than MAX_DEPTH.
let mut deep = base.clone();
for _ in 0..(MAX_DEPTH as usize + 2) {
deep = deep.join("d");
}
std::fs::create_dir_all(&deep).unwrap();
let err = plan(&base).expect_err("a tree deeper than the nesting cap must be refused");
assert!(
matches!(err, Error::DirImageTooLarge),
"expected DirImageTooLarge, got {err:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
// Exactly MAX_DEPTH levels below the root is accepted; one more is refused.
#[test]
fn the_nesting_cap_accepts_exactly_max_depth() {
let mk = |tag: &str, levels: usize| {
let base = std::env::temp_dir().join(format!("fmkv-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
// BDMV is level 1, so the tree is a valid disc layout.
let mut deep = base.join("BDMV");
for _ in 1..levels {
deep = deep.join("d");
}
std::fs::create_dir_all(&deep).unwrap();
base
};
let ok = mk("depth-ok", MAX_DEPTH as usize);
assert!(plan(&ok).is_ok(), "MAX_DEPTH levels must be accepted");
let over = mk("depth-over", MAX_DEPTH as usize + 1);
assert!(matches!(plan(&over), Err(Error::DirImageTooLarge)));
let _ = std::fs::remove_dir_all(&ok);
let _ = std::fs::remove_dir_all(&over);
}
}
-289
View File
@@ -1,289 +0,0 @@
use super::*;
// A file above the 30-bit AD ceiling must split on a block boundary, or the next extent's
// bytes start mid-sector.
#[test]
fn a_file_past_the_ad_ceiling_splits_on_a_block_boundary() {
let mut f = FileNode {
name: "BIG.M2TS".into(),
disc_path: "/BIG.M2TS".into(),
host: PathBuf::new(),
size: MAX_AD_BYTES + 4096,
mtime: None,
icb_lba: 0,
unique_id: 0,
extents: Vec::new(),
};
let end = place_file(&mut f, 1000).unwrap();
assert_eq!(f.extents.len(), 2);
assert_eq!(f.extents[0].bytes as u64, MAX_AD_BYTES);
assert_eq!(f.extents[0].bytes % SECTOR as u32, 0, "block multiple");
assert!(f.extents[0].bytes <= 0x3FFF_FFFF);
assert_eq!(f.extents[1].bytes, 4096);
assert_eq!(
f.extents[1].lba,
1000 + (MAX_AD_BYTES / SECTOR as u64) as u32,
"the second extent starts where the first ends"
);
assert_eq!(end, f.extents[1].lba + 2);
assert_eq!(
f.extents.iter().map(|e| e.bytes as u64).sum::<u64>(),
f.size,
"no bytes lost or invented"
);
}
/// The metadata ceiling must account for each directory's FID-list sectors, not just one
/// File Entry per node: a wide directory's FID list can span many sectors that the old
/// (dir_count + file_count) count ignored, so an oversized folder could slip past the guard
/// and be materialized in full.
#[test]
fn metadata_block_count_includes_the_fid_list_sectors() {
let files: Vec<FileNode> = (0..300)
.map(|i| FileNode {
name: format!("A_REASONABLY_LONG_FILENAME_{i:04}.M2TS"),
disc_path: format!("/{i}"),
host: PathBuf::new(),
size: 0,
mtime: None,
icb_lba: 0,
unique_id: 0,
extents: Vec::new(),
})
.collect();
let root = DirNode {
name: String::new(),
icb_lba: 0,
parent_icb_lba: 0,
data_lba: 0,
data_bytes: 0,
unique_id: 0,
dirs: Vec::new(),
files,
};
let mut dir_count = 0;
let mut file_count = 0;
count_nodes(&root, &mut dir_count, &mut file_count);
// Old formula: one File Entry per node only.
let file_entry_only = dir_count as u64 + file_count as u64;
let fid_sectors = dir_bytes(&root.dirs, &root.files).div_ceil(SECTOR) as u64;
assert!(
fid_sectors > 1,
"300 files must fill more than one FID sector"
);
// 2 (FSD + Terminating Descriptor) + File Entries + FID-list sectors.
assert_eq!(
metadata_block_count(&root),
2 + file_entry_only + fid_sectors
);
assert!(
metadata_block_count(&root) > file_entry_only,
"the ceiling must count FID-list sectors the old formula omitted"
);
}
/// A zero-byte file records no allocation descriptors at all and consumes
/// no blocks.
#[test]
fn an_empty_file_gets_no_extents() {
let mut f = FileNode {
name: "EMPTY".into(),
disc_path: "/EMPTY".into(),
host: PathBuf::new(),
size: 0,
mtime: None,
icb_lba: 0,
unique_id: 0,
extents: Vec::new(),
};
assert_eq!(place_file(&mut f, 500).unwrap(), 500);
assert!(f.extents.is_empty());
}
#[test]
fn host_artefacts_are_not_disc_content() {
assert!(is_excluded(".DS_Store"));
assert!(is_excluded("._00000.m2ts"));
assert!(is_excluded("00000.m2ts.partial"));
assert!(!is_excluded("00000.m2ts"));
assert!(!is_excluded("VTS_01_1.VOB"));
}
// Two host names the READER collapses into one must be refused by the planner. Calls `plan`
// on a real folder.
#[test]
fn two_names_the_reader_cannot_tell_apart_are_refused() {
let dir = std::env::temp_dir().join(format!(
"fmkv-shadow-{}-{:?}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let stream = dir.join("BDMV/STREAM");
std::fs::create_dir_all(&stream).expect("mkdir");
std::fs::write(stream.join("00000.m2ts"), b"a").expect("write");
// Same name to the reader: it trims the leading space.
std::fs::write(stream.join(" 00000.m2ts"), b"b").expect("write");
// Precondition — if this stops holding the fixture is wrong, not the code.
assert_eq!(
crate::udf::parse_udf_name(&crate::dirimage::encode::encode_cs0(" 00000.m2ts")),
crate::udf::parse_udf_name(&crate::dirimage::encode::encode_cs0("00000.m2ts")),
"fixture: these two host names must read back identically"
);
let got = plan(&dir);
let _ = std::fs::remove_dir_all(&dir);
assert!(
matches!(got, Err(Error::DirNameCollision { .. })),
"a name the reader cannot distinguish must be refused, got {got:?}"
);
}
// A name too long for the FID's one-byte length field is refused by the planner, on a real
// folder.
#[test]
fn an_over_long_name_is_refused_by_the_planner() {
let dir = std::env::temp_dir().join(format!(
"fmkv-longname-{}-{:?}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(dir.join("BDMV/STREAM")).expect("mkdir");
// 255 bytes: the exact length that used to narrow to zero.
let name = "a".repeat(255);
assert_eq!(
crate::dirimage::encode::encode_cs0(&name).len(),
256,
"fixture: NAME_MAX encodes to 256 bytes with the compression byte"
);
std::fs::write(dir.join("BDMV/STREAM").join(&name), b"x").expect("write");
let err = plan(&dir).expect_err("the planner must refuse this folder");
assert!(
matches!(err, Error::DirNameTooLong { .. }),
"expected DirNameTooLong, got {err:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// A name at the cap is accepted, so the guard rejects only what it must.
#[test]
fn a_name_at_the_cap_is_accepted() {
let dir = std::env::temp_dir().join(format!(
"fmkv-okname-{}-{:?}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(dir.join("BDMV/STREAM")).expect("mkdir");
// One byte of name shorter, so the encoding lands exactly on the cap.
let name = "a".repeat(MAX_CS0_NAME_BYTES - 1);
assert_eq!(
crate::dirimage::encode::encode_cs0(&name).len(),
MAX_CS0_NAME_BYTES
);
std::fs::write(dir.join("BDMV/STREAM").join(&name), b"x").expect("write");
assert!(
plan(&dir).is_ok(),
"a name whose encoding equals the cap must be accepted"
);
let _ = std::fs::remove_dir_all(&dir);
}
// The subdirectory cap must keep the 16-bit link count representable. Pins the arithmetic;
// does NOT exercise `walk`.
#[test]
fn the_subdir_cap_refuses_a_folder_with_too_many_subdirectories() {
// Executes the guard rather than restating the constant: link count (child
// dirs + 1) is 16 bits, so exceeding it wraps and the image lies about
// its own directory structure.
let dir = std::env::temp_dir().join(format!("fmkv-fanout-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for i in 0..=MAX_SUBDIRS {
std::fs::create_dir_all(dir.join(format!("d{i}"))).unwrap();
}
let err = plan(&dir).expect_err("more subdirectories than the link count can represent");
assert!(
matches!(err, Error::DirImageFanout { .. }),
"expected DirImageFanout, got {err:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
// `walk` counts every entry against `MAX_ENTRIES`: the entry that would pass it is refused.
#[test]
fn the_entry_cap_refuses_the_entry_past_max_entries() {
let dir = std::env::temp_dir().join(format!("fmkv-entries-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.bin"), b"x").unwrap();
let mut at_cap = MAX_ENTRIES - 1;
assert!(
walk(&dir, "/", 0, &mut at_cap).is_ok(),
"the last allowed entry"
);
let mut past = MAX_ENTRIES;
assert!(matches!(
walk(&dir, "/", 0, &mut past),
Err(Error::DirImageTooLarge)
));
let _ = std::fs::remove_dir_all(&dir);
}
// The nesting cap (`MAX_DEPTH`) refuses a tree deeper than the planner
// represents, before it recurses without bound. Executes `walk`'s depth
// guard on a real directory chain, not the constant.
#[test]
fn the_nesting_cap_refuses_a_tree_deeper_than_max_depth() {
let base = std::env::temp_dir().join(format!("fmkv-depth-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
// MAX_DEPTH + 2 nested levels below the root guarantees a `walk` call at
// a depth greater than MAX_DEPTH.
let mut deep = base.clone();
for _ in 0..(MAX_DEPTH as usize + 2) {
deep = deep.join("d");
}
std::fs::create_dir_all(&deep).unwrap();
let err = plan(&base).expect_err("a tree deeper than the nesting cap must be refused");
assert!(
matches!(err, Error::DirImageTooLarge),
"expected DirImageTooLarge, got {err:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
// Exactly MAX_DEPTH levels below the root is accepted; one more is refused.
#[test]
fn the_nesting_cap_accepts_exactly_max_depth() {
let mk = |tag: &str, levels: usize| {
let base = std::env::temp_dir().join(format!("fmkv-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
// BDMV is level 1, so the tree is a valid disc layout.
let mut deep = base.join("BDMV");
for _ in 1..levels {
deep = deep.join("d");
}
std::fs::create_dir_all(&deep).unwrap();
base
};
let ok = mk("depth-ok", MAX_DEPTH as usize);
assert!(plan(&ok).is_ok(), "MAX_DEPTH levels must be accepted");
let over = mk("depth-over", MAX_DEPTH as usize + 1);
assert!(matches!(plan(&over), Err(Error::DirImageTooLarge)));
let _ = std::fs::remove_dir_all(&ok);
let _ = std::fs::remove_dir_all(&over);
}
+2658 -19
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1886 -223
View File
File diff suppressed because it is too large Load Diff
+239 -4
View File
@@ -87,8 +87,8 @@ pub fn probe_and_remap<S: SectorSource + ?Sized>(
if !tracing::enabled!(target: "freemkv::diag", tracing::Level::DEBUG) {
return;
}
// Only DVD-Video titles (`DvdPs`) carry these private_stream_1 AC-3 sub-streams.
if title.content_format != crate::disc::ContentFormat::DvdPs {
// Only DVD (MPEG-PS) titles carry private_stream_1 AC-3 sub-streams.
if title.content_format != crate::disc::ContentFormat::MpegPs {
return;
}
// Nothing to report unless there is at least one AC-3 audio stream.
@@ -120,5 +120,240 @@ pub fn probe_and_remap<S: SectorSource + ?Sized>(
}
#[cfg(test)]
#[path = "dvd_audio_probe_tests.rs"]
mod tests;
mod tests {
use super::*;
use crate::disc::{
AudioChannels, AudioStream, Codec, ContentFormat, DiscTitle, Extent, LabelPurpose,
SampleRate,
};
use crate::sector::SectorSource;
// Builds a correctly-SIZED AC-3 frame (128 bytes, matching frmsizecod=0) whose
// `acmod`/`lfeon` encode a known channel count, via a bit writer so the test never
// hand-miscomputes the lfeon offset.
fn ac3_frame(acmod: u8, lfeon: bool) -> Vec<u8> {
let mut bits: Vec<u8> = Vec::new();
let push = |val: u32, n: usize, bits: &mut Vec<u8>| {
for i in (0..n).rev() {
bits.push(((val >> i) & 1) as u8);
}
};
push(acmod as u32, 3, &mut bits);
if (acmod & 0x1) != 0 && acmod != 0x1 {
push(0, 2, &mut bits); // cmixlev
}
if (acmod & 0x4) != 0 {
push(0, 2, &mut bits); // surmixlev
}
if acmod == 0x2 {
push(0, 2, &mut bits); // dsurmod
}
push(lfeon as u32, 1, &mut bits);
// Pack the bit vector MSB-first into bytes (byte6 onward).
let mut tail = Vec::new();
let mut cur = 0u8;
for (i, b) in bits.iter().enumerate() {
cur = (cur << 1) | b;
if i % 8 == 7 {
tail.push(cur);
cur = 0;
}
}
let rem = bits.len() % 8;
if rem != 0 {
cur <<= 8 - rem;
tail.push(cur);
}
// AC-3 frame: 0x0B 0x77 crc(2) byte4(fscod=0,frmsizecod=0) bsid<<3 then BSI.
let mut frame = vec![0x0B, 0x77, 0x00, 0x00, 0x00, 8u8 << 3];
frame.extend_from_slice(&tail);
// frmsizecod=0 @ 48kHz → 64 words = 128 bytes. Pad to the real size so
// the frame-stepping in max_substream_channels lands on the next sync.
frame.resize(128, 0);
frame
}
// Builds a minimal `private_stream_1` PES carrying `frames` for `sub_id`, mirroring the
// on-disc layout the PS demux expects.
fn ps_ac3_frames(sub_id: u8, frames: &[Vec<u8>]) -> Vec<u8> {
// PES sub-header for AC-3: sub_id + frame_count + 2-byte access ptr.
let mut payload = vec![sub_id, frames.len() as u8, 0x00, 0x04];
for f in frames {
payload.extend_from_slice(f);
}
// PES packet: start code 00 00 01 BD, length(2), flags(2), hdr_len(0).
let pes_payload_len = 3 + payload.len(); // flags(2)+hdrlen(1)+payload
let mut pkt = vec![0x00, 0x00, 0x01, 0xBD];
pkt.extend_from_slice(&(pes_payload_len as u16).to_be_bytes());
pkt.extend_from_slice(&[0x80, 0x00, 0x00]); // no PTS, header_data_len=0
pkt.extend_from_slice(&payload);
pkt
}
/// Single-frame `private_stream_1` PES — the common case in existing tests.
fn ps_ac3(sub_id: u8, acmod: u8, lfeon: bool) -> Vec<u8> {
ps_ac3_frames(sub_id, &[ac3_frame(acmod, lfeon)])
}
fn ac3_stream(pid: u16, channels: AudioChannels) -> Stream {
Stream::Audio(AudioStream {
pid,
codec: Codec::Ac3,
channels,
language: "en".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
})
}
/// The probe decodes the real channel count of each physical sub-stream.
/// 0x80 carries a 2.0 frame (acmod=2,no lfe → 2ch); 0x81 carries 5.1
/// (acmod=7 + lfe → 6ch).
#[test]
fn probe_decodes_per_substream_channels() {
let mut bytes = ps_ac3(0x80, 2, false);
bytes.extend(ps_ac3(0x81, 7, true));
let probed = probe_ac3_substream_channels(&bytes);
assert_eq!(probed.get(&0x80), Some(&2), "0x80 is the 2.0 down-mix");
assert_eq!(probed.get(&0x81), Some(&6), "0x81 is the 5.1 main mix");
}
// Real-disc regression: the probe must read each sub-stream's TRUE (max-mix) channel count
// without cross-contaminating between sub-streams.
#[test]
fn probe_reads_max_channels_no_cross_contamination() {
let mut bytes = Vec::new();
// 0x80 opens with a 2.0 frame (the logo bed)...
bytes.extend(ps_ac3_frames(0x80, &[ac3_frame(2, false)]));
// ...0x81 interleaves a pure-2.0 PES (must NOT bleed 6 into 0x80)...
bytes.extend(ps_ac3_frames(
0x81,
&[ac3_frame(2, false), ac3_frame(2, false)],
));
// ...then 0x80 reaches its real 5.1 main mix (acmod=7 + lfe → 6 ch),
// with a trailing 2.0 frame in the SAME PES to prove we take the max,
// not the last frame.
bytes.extend(ps_ac3_frames(
0x80,
&[ac3_frame(7, true), ac3_frame(2, false)],
));
let probed = probe_ac3_substream_channels(&bytes);
assert_eq!(
probed.get(&0x80),
Some(&6),
"0x80's real 5.1 mix must win over its 2.0 head/tail frames"
);
assert_eq!(
probed.get(&0x81),
Some(&2),
"0x81 is a pure 2.0 stream — must not absorb 0x80's 6-channel frame"
);
}
// Mutation guard for `pos + rel` (not `pos - rel`, which could underflow `usize`) as the
// sync's true absolute position.
#[test]
fn max_substream_channels_locates_sync_after_leading_non_sync_bytes() {
let mut data = vec![0xAA, 0xAA, 0xAA]; // no 0x0B77 pattern in here
data.extend(ac3_frame(2, false)); // real 2.0 frame, sync at absolute offset 3
assert_eq!(
max_substream_channels(&data),
Some(2),
"must find and decode the frame whose sync is NOT at offset 0"
);
}
// On an unmappable AC-3 size, must fall back to a forward `start + 2` rescan (not loop or
// overshoot).
#[test]
fn max_substream_channels_unmappable_size_steps_forward_by_two() {
let mut real = ac3_frame(2, false);
// Overwrite the real frame's (unchecked) CRC bytes, which double as byte4/5
// of the bogus header at offset 4: 0xC0 (fscod=3 -> ac3_frame_size == 0,
// unmappable) and 0xF8 (bsid=31 -> acmod_channels == None, no spurious count).
real[2] = 0xC0;
real[3] = 0xF8;
let mut data = vec![0xAA, 0xAA, 0xAA, 0xAA]; // offsets 0..4, no sync
data.push(0x0B); // offset 4: bogus header sync byte 0
data.push(0x77); // offset 5: bogus header sync byte 1
data.extend(real); // offset 6..: the real frame (also serves as the
// bogus header's byte4/byte5 at offsets 8/9)
assert_eq!(
max_substream_channels(&data),
Some(2),
"must recover the real frame 2 bytes after the unmappable-size sync, not lose it"
);
}
// Same fallback, sync at offset 0 (`start - 2` would underflow).
#[test]
fn max_substream_channels_unmappable_size_at_start_steps_forward_not_back() {
let mut data = vec![0x0B, 0x77, 0x00, 0x00, 0xC0, 0xF8]; // bogus header, offsets 0..6
data.extend(ac3_frame(2, false)); // real 2.0 frame at offset 6
assert_eq!(
max_substream_channels(&data),
Some(2),
"must step forward past the bogus header at offset 0 and find the real frame at offset 6"
);
}
/// A `SectorSource` stub that hands back fixed bytes regardless of the
/// requested LBA/count, for exercising `probe_and_remap`'s end-to-end
/// wiring (format/AC-3/extent/count guards -> read -> probe -> remap).
struct FixedSource {
data: Vec<u8>,
}
impl SectorSource for FixedSource {
fn read_sectors(
&mut self,
_lba: u32,
_count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let n = self.data.len().min(buf.len());
buf[..n].copy_from_slice(&self.data[..n]);
Ok(n)
}
}
// L084b: the scanner routes by PGC_AST_CTL, which every player follows, so the probe must
// not move a stream off it, even when the IFO channel count disagrees with the VOB.
#[test]
fn probe_never_overrides_the_ast_ctl_route() {
let mut bytes = ps_ac3(0x80, 7, true); // 5.1 this PGC does not play
bytes.extend(ps_ac3(0x81, 2, false)); // the AST_CTL-routed stream, really 2.0
let mut title = DiscTitle {
playlist: "00001.ifo".into(),
playlist_id: 1,
duration_secs: 60.0,
size_bytes: bytes.len() as u64,
clips: Vec::new(),
streams: vec![ac3_stream(0xBD81, AudioChannels::Surround51)],
chapters: Vec::new(),
extents: vec![Extent {
start_lba: 0,
sector_count: 2,
}],
content_format: ContentFormat::MpegPs,
codec_privates: vec![None],
};
let ((), ev) = crate::testlog::capture(|| {
probe_and_remap(&mut FixedSource { data: bytes }, &mut title)
});
let probed: Vec<&str> = ev.iter().map(|e| e.message()).collect();
assert!(
probed.iter().any(|m| m.contains("sub_id=0x80 channels=6")),
"the probe still runs and reports the VOB's real layout: {probed:?}"
);
let Stream::Audio(a) = &title.streams[0] else {
panic!("audio")
};
assert_eq!(a.pid, 0xBD81);
assert_eq!(a.channels, AudioChannels::Surround51);
}
}
-233
View File
@@ -1,233 +0,0 @@
use super::*;
use crate::disc::{
AudioChannels, AudioStream, Codec, ContentFormat, DiscTitle, Extent, LabelPurpose, SampleRate,
};
use crate::sector::SectorSource;
// Builds a correctly-SIZED AC-3 frame (128 bytes, matching frmsizecod=0) whose
// `acmod`/`lfeon` encode a known channel count, via a bit writer so the test never
// hand-miscomputes the lfeon offset.
fn ac3_frame(acmod: u8, lfeon: bool) -> Vec<u8> {
let mut bits: Vec<u8> = Vec::new();
let push = |val: u32, n: usize, bits: &mut Vec<u8>| {
for i in (0..n).rev() {
bits.push(((val >> i) & 1) as u8);
}
};
push(acmod as u32, 3, &mut bits);
if (acmod & 0x1) != 0 && acmod != 0x1 {
push(0, 2, &mut bits); // cmixlev
}
if (acmod & 0x4) != 0 {
push(0, 2, &mut bits); // surmixlev
}
if acmod == 0x2 {
push(0, 2, &mut bits); // dsurmod
}
push(lfeon as u32, 1, &mut bits);
// Pack the bit vector MSB-first into bytes (byte6 onward).
let mut tail = Vec::new();
let mut cur = 0u8;
for (i, b) in bits.iter().enumerate() {
cur = (cur << 1) | b;
if i % 8 == 7 {
tail.push(cur);
cur = 0;
}
}
let rem = bits.len() % 8;
if rem != 0 {
cur <<= 8 - rem;
tail.push(cur);
}
// AC-3 frame: 0x0B 0x77 crc(2) byte4(fscod=0,frmsizecod=0) bsid<<3 then BSI.
let mut frame = vec![0x0B, 0x77, 0x00, 0x00, 0x00, 8u8 << 3];
frame.extend_from_slice(&tail);
// frmsizecod=0 @ 48kHz → 64 words = 128 bytes. Pad to the real size so
// the frame-stepping in max_substream_channels lands on the next sync.
frame.resize(128, 0);
frame
}
// Builds a minimal `private_stream_1` PES carrying `frames` for `sub_id`, mirroring the
// on-disc layout the PS demux expects.
fn ps_ac3_frames(sub_id: u8, frames: &[Vec<u8>]) -> Vec<u8> {
// PES sub-header for AC-3: sub_id + frame_count + 2-byte access ptr.
let mut payload = vec![sub_id, frames.len() as u8, 0x00, 0x04];
for f in frames {
payload.extend_from_slice(f);
}
// PES packet: start code 00 00 01 BD, length(2), flags(2), hdr_len(0).
let pes_payload_len = 3 + payload.len(); // flags(2)+hdrlen(1)+payload
let mut pkt = vec![0x00, 0x00, 0x01, 0xBD];
pkt.extend_from_slice(&(pes_payload_len as u16).to_be_bytes());
pkt.extend_from_slice(&[0x80, 0x00, 0x00]); // no PTS, header_data_len=0
pkt.extend_from_slice(&payload);
pkt
}
/// Single-frame `private_stream_1` PES — the common case in existing tests.
fn ps_ac3(sub_id: u8, acmod: u8, lfeon: bool) -> Vec<u8> {
ps_ac3_frames(sub_id, &[ac3_frame(acmod, lfeon)])
}
fn ac3_stream(pid: u16, channels: AudioChannels) -> Stream {
Stream::Audio(AudioStream {
pid,
codec: Codec::Ac3,
channels,
language: "en".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
})
}
/// The probe decodes the real channel count of each physical sub-stream.
/// 0x80 carries a 2.0 frame (acmod=2,no lfe → 2ch); 0x81 carries 5.1
/// (acmod=7 + lfe → 6ch).
#[test]
fn probe_decodes_per_substream_channels() {
let mut bytes = ps_ac3(0x80, 2, false);
bytes.extend(ps_ac3(0x81, 7, true));
let probed = probe_ac3_substream_channels(&bytes);
assert_eq!(probed.get(&0x80), Some(&2), "0x80 is the 2.0 down-mix");
assert_eq!(probed.get(&0x81), Some(&6), "0x81 is the 5.1 main mix");
}
// Real-disc regression: the probe must read each sub-stream's TRUE (max-mix) channel count
// without cross-contaminating between sub-streams.
#[test]
fn probe_reads_max_channels_no_cross_contamination() {
let mut bytes = Vec::new();
// 0x80 opens with a 2.0 frame (the logo bed)...
bytes.extend(ps_ac3_frames(0x80, &[ac3_frame(2, false)]));
// ...0x81 interleaves a pure-2.0 PES (must NOT bleed 6 into 0x80)...
bytes.extend(ps_ac3_frames(
0x81,
&[ac3_frame(2, false), ac3_frame(2, false)],
));
// ...then 0x80 reaches its real 5.1 main mix (acmod=7 + lfe → 6 ch),
// with a trailing 2.0 frame in the SAME PES to prove we take the max,
// not the last frame.
bytes.extend(ps_ac3_frames(
0x80,
&[ac3_frame(7, true), ac3_frame(2, false)],
));
let probed = probe_ac3_substream_channels(&bytes);
assert_eq!(
probed.get(&0x80),
Some(&6),
"0x80's real 5.1 mix must win over its 2.0 head/tail frames"
);
assert_eq!(
probed.get(&0x81),
Some(&2),
"0x81 is a pure 2.0 stream — must not absorb 0x80's 6-channel frame"
);
}
// Mutation guard for `pos + rel` (not `pos - rel`, which could underflow `usize`) as the
// sync's true absolute position.
#[test]
fn max_substream_channels_locates_sync_after_leading_non_sync_bytes() {
let mut data = vec![0xAA, 0xAA, 0xAA]; // no 0x0B77 pattern in here
data.extend(ac3_frame(2, false)); // real 2.0 frame, sync at absolute offset 3
assert_eq!(
max_substream_channels(&data),
Some(2),
"must find and decode the frame whose sync is NOT at offset 0"
);
}
// On an unmappable AC-3 size, must fall back to a forward `start + 2` rescan (not loop or
// overshoot).
#[test]
fn max_substream_channels_unmappable_size_steps_forward_by_two() {
let mut real = ac3_frame(2, false);
// Overwrite the real frame's (unchecked) CRC bytes, which double as byte4/5
// of the bogus header at offset 4: 0xC0 (fscod=3 -> ac3_frame_size == 0,
// unmappable) and 0xF8 (bsid=31 -> acmod_channels == None, no spurious count).
real[2] = 0xC0;
real[3] = 0xF8;
let mut data = vec![0xAA, 0xAA, 0xAA, 0xAA]; // offsets 0..4, no sync
data.push(0x0B); // offset 4: bogus header sync byte 0
data.push(0x77); // offset 5: bogus header sync byte 1
data.extend(real); // offset 6..: the real frame (also serves as the
// bogus header's byte4/byte5 at offsets 8/9)
assert_eq!(
max_substream_channels(&data),
Some(2),
"must recover the real frame 2 bytes after the unmappable-size sync, not lose it"
);
}
// Same fallback, sync at offset 0 (`start - 2` would underflow).
#[test]
fn max_substream_channels_unmappable_size_at_start_steps_forward_not_back() {
let mut data = vec![0x0B, 0x77, 0x00, 0x00, 0xC0, 0xF8]; // bogus header, offsets 0..6
data.extend(ac3_frame(2, false)); // real 2.0 frame at offset 6
assert_eq!(
max_substream_channels(&data),
Some(2),
"must step forward past the bogus header at offset 0 and find the real frame at offset 6"
);
}
/// A `SectorSource` stub that hands back fixed bytes regardless of the
/// requested LBA/count, for exercising `probe_and_remap`'s end-to-end
/// wiring (format/AC-3/extent/count guards -> read -> probe -> remap).
struct FixedSource {
data: Vec<u8>,
}
impl SectorSource for FixedSource {
fn read_sectors(
&mut self,
_lba: u32,
_count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let n = self.data.len().min(buf.len());
buf[..n].copy_from_slice(&self.data[..n]);
Ok(n)
}
}
// L084b: the scanner routes by PGC_AST_CTL, which every player follows, so the probe must
// not move a stream off it, even when the IFO channel count disagrees with the VOB.
#[test]
fn probe_never_overrides_the_ast_ctl_route() {
let mut bytes = ps_ac3(0x80, 7, true); // 5.1 this PGC does not play
bytes.extend(ps_ac3(0x81, 2, false)); // the AST_CTL-routed stream, really 2.0
let mut title = DiscTitle {
playlist: "00001.ifo".into(),
playlist_id: 1,
duration_secs: 60.0,
size_bytes: bytes.len() as u64,
clips: Vec::new(),
streams: vec![ac3_stream(0xBD81, AudioChannels::Surround51)],
chapters: Vec::new(),
extents: vec![Extent {
start_lba: 0,
sector_count: 2,
}],
content_format: ContentFormat::DvdPs,
codec_privates: vec![None],
};
let ((), ev) =
crate::testlog::capture(|| probe_and_remap(&mut FixedSource { data: bytes }, &mut title));
let probed: Vec<&str> = ev.iter().map(|e| e.message()).collect();
assert!(
probed.iter().any(|m| m.contains("sub_id=0x80 channels=6")),
"the probe still runs and reports the VOB's real layout: {probed:?}"
);
let Stream::Audio(a) = &title.streams[0] else {
panic!("audio")
};
assert_eq!(a.pid, 0xBD81);
assert_eq!(a.channels, AudioChannels::Surround51);
}
-401
View File
@@ -1,401 +0,0 @@
//! Content-based forced-subtitle detection for DVD VobSub tracks.
//!
//! The DVD counterpart of [`super::pgs_forced_probe`]: a subpicture unit (SPU) whose display
//! control sequence opens with FSTA_DSP (0x00, "Forced Start Display") is shown even with
//! subtitles off. A track is forced iff EVERY sampled displayed SPU is forced and at least one
//! was seen. Reads are bounded by the same budget and sample windows as the PGS probe, and the
//! probe only ever SETS `forced`: the IFO code extension 9 is an equal signal it never clears.
use super::pgs_forced_probe::{PROBE_BUDGET_SECTORS, PROMOTE_MIN_DISPLAY_SETS, plan_windows};
use crate::consts::SECTOR_BYTES;
use crate::disc::{Codec, DiscTitle, Extent, LabelQualifier, Stream};
use crate::mux::ps::PsDemuxer;
use crate::sector::SectorSource;
use std::collections::HashMap;
// Sectors per read (~2 MiB), the PGS probe's chunk.
const CHUNK_SECTORS: u16 = 1023;
// Sectors per read of the CSS crack, the image scan's batch.
const CRACK_BATCH_SECTORS: u16 = 32;
// An SPU's 16-bit size field bounds it; a unit growing past this is corrupt.
const MAX_SPU_BYTES: usize = 0xFFFF;
// SP_DCSQ commands (mpucoder "Sub-Pictures"; FFmpeg dvdsubdec.c decode_dvd_subtitles).
const FSTA_DSP: u8 = 0x00;
const STA_DSP: u8 = 0x01;
const CMD_END: u8 = 0xFF;
/// How a displayed subpicture unit is started.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum SpuStart {
/// FSTA_DSP: shown even when the player's subtitles are off.
Forced,
/// STA_DSP: shown only when the stream is selected.
Normal,
}
/// How the SPU in `spu` (SPUH included) starts displaying, from the first FSTA_DSP or STA_DSP
/// command along its SP_DCSQ chain; `None` for a unit that never starts a display or does not
/// parse. Offsets are big-endian u16s relative to the SPU start.
pub(crate) fn spu_start(spu: &[u8]) -> Option<SpuStart> {
let word = |o: usize| -> Option<usize> {
Some(u16::from_be_bytes([*spu.get(o)?, *spu.get(o + 1)?]) as usize)
};
let size = word(0)?.min(spu.len());
let mut dcsq = word(2)?;
// Each SP_DCSQ moves forward or points at itself (the last one); bound the walk anyway.
for _ in 0..64 {
if dcsq + 4 > size {
return None;
}
let next = word(dcsq + 2)?;
let mut pos = dcsq + 4;
while pos < size {
let cmd = spu[pos];
pos += 1;
let args = match cmd {
FSTA_DSP => return Some(SpuStart::Forced),
STA_DSP => return Some(SpuStart::Normal),
0x02 => 0,
0x03 | 0x04 => 2,
0x05 => 6,
0x06 => 4,
// CHG_COLCON: a size word counting itself, then its parameters.
0x07 => word(pos)?,
CMD_END => break,
_ => return None,
};
pos += args;
}
if next <= dcsq {
return None;
}
dcsq = next;
}
None
}
// Reassembles one sub-stream's SPUs: a PES with a PTS starts a unit whose first two bytes give
// its size; PTS-less PES continue it. A gap drops the open unit rather than splice across it.
#[derive(Default)]
struct SpuAssembler {
pending: Option<(usize, Vec<u8>)>,
}
impl SpuAssembler {
fn push(&mut self, starts_unit: bool, data: &[u8]) -> Option<Vec<u8>> {
if starts_unit {
let size = match data {
[hi, lo, ..] => u16::from_be_bytes([*hi, *lo]) as usize,
_ => 0,
};
self.pending = (size >= 4).then(|| (size, Vec::with_capacity(size)));
}
let (size, buf) = self.pending.as_mut()?;
buf.extend_from_slice(data);
if buf.len() > MAX_SPU_BYTES {
self.pending = None;
return None;
}
if buf.len() < *size {
return None;
}
let (size, mut buf) = self.pending.take()?;
buf.truncate(size);
Some(buf)
}
fn gap(&mut self) {
self.pending = None;
}
}
// What the sampled SPUs showed about one subpicture track.
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
struct SpuEvidence {
/// Displayed SPUs seen.
units: u32,
/// At least one of them started with STA_DSP.
non_forced: bool,
}
impl SpuEvidence {
fn observe(&mut self, start: SpuStart) {
self.units = self.units.saturating_add(1);
self.non_forced |= start == SpuStart::Normal;
}
}
// Why the read loop stopped; as in the PGS probe, only a designed stop lets "no non-forced SPU
// was seen" stand as a forced verdict.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum StopReason {
Exhausted,
Budget,
Halted,
ReadFailed,
}
/// Conclusive evidence of probed titles keyed by their extent list (with whether it was a
/// sample), so a title reading the same cells as one already probed is not read again.
#[derive(Default)]
pub(crate) struct DvdForcedProbeCache(HashMap<Vec<(u32, u32)>, TitleEvidence>);
// One probed title's per-track evidence, and whether it rests on a sample.
type TitleEvidence = (HashMap<u16, SpuEvidence>, bool);
// CSS for the probe's reads: the first scrambled pack runs the title's one keyless crack
// (the mux's own), after which packs are descrambled; with no key a scrambled pack is a gap.
#[derive(Default)]
struct Descrambler {
key: Option<[u8; 5]>,
tried: bool,
}
impl Descrambler {
// Leaves `sector` parseable (`Ok(true)`), or `Ok(false)` for a sector to skip; `Err` is a
// Stop during the crack.
fn clear(
&mut self,
sector: &mut [u8],
reader: &mut dyn SectorSource,
extents: &[Extent],
halt: Option<&crate::halt::Halt>,
) -> Result<bool, StopReason> {
if !crate::css::is_scrambled_pack(sector) {
return Ok(true);
}
if !self.tried {
self.tried = true;
match crate::css::crack_key_outcome(reader, extents, CRACK_BATCH_SECTORS, halt) {
crate::css::CrackOutcome::Cracked(state) => self.key = Some(state.title_key),
crate::css::CrackOutcome::Halted => return Err(StopReason::Halted),
_ => {}
}
}
Ok(match self.key.as_mut() {
Some(key) => crate::css::descramble_region(sector, key).is_ok(),
None => false,
})
}
}
/// Read a sample of the title's VobSub streams and set `forced` on each whose sampled SPUs all
/// start with FSTA_DSP. Best-effort: never fails, never clears a flag, and an inconclusive run
/// (halt, read fault) asserts nothing and is not memoised.
pub(crate) fn probe_and_set_forced(
reader: &mut dyn SectorSource,
title: &mut DiscTitle,
cache: &mut DvdForcedProbeCache,
halt: Option<&crate::halt::Halt>,
) {
// Streams the IFO already marks forced have nothing left to learn.
let pids: Vec<u16> = title
.streams
.iter()
.filter_map(|s| match s {
Stream::Subtitle(sub) if sub.codec == Codec::DvdSub && !sub.forced => Some(sub.pid),
_ => None,
})
.collect();
if pids.is_empty() || title.extents.is_empty() {
return;
}
let key: Vec<(u32, u32)> = title
.extents
.iter()
.map(|e| (e.start_lba, e.sector_count))
.collect();
if let Some((known, sampled)) = cache.0.get(&key)
&& pids.iter().all(|p| known.contains_key(p))
{
let forced = verdicts(known, *sampled);
apply_forced(title, &forced, true);
return;
}
let (evidence, stop, sampled, sectors_read) =
read_evidence(reader, &title.extents, &pids, halt);
let conclusive = matches!(stop, StopReason::Exhausted | StopReason::Budget);
let forced = apply_forced(title, &verdicts(&evidence, sampled), conclusive);
tracing::debug!(
target: "freemkv::scan",
title = title.playlist_id,
stop = ?stop,
sectors_read,
tracks = pids.len(),
forced,
"dvd forced-subtitle probe"
);
if conclusive {
cache.0.insert(key, (evidence, sampled));
} else {
tracing::debug!(
target: "freemkv::scan",
stop = ?stop,
sectors_read,
tracks = pids.len(),
"forced-subtitle probe truncated; verdicts limited and truncated extents not cached"
);
}
}
// The disc runs that title sectors `offset..offset + len` cover, counting along `extents` in
// read order: (the extent's start, first LBA, sectors) each.
fn title_runs(extents: &[Extent], offset: u32, len: u32) -> Vec<(u32, u32, u32)> {
let (from, to) = (u64::from(offset), u64::from(offset) + u64::from(len));
let mut runs = Vec::new();
let mut at = 0u64;
for ext in extents {
let end = at + u64::from(ext.sector_count);
let (lo, hi) = (from.max(at), to.min(end));
if lo < hi {
let lba = u64::from(ext.start_lba) + (lo - at);
if let Ok(lba) = u32::try_from(lba) {
runs.push((ext.start_lba, lba, (hi - lo) as u32));
}
}
if end >= to {
break;
}
at = end;
}
runs
}
// Sample the title for the tracks in `pids`: (evidence, why reading stopped, whether it was a
// sample, sectors read). Windows span the title end to end, so thousands of small extents are
// sampled across its length rather than read from the head until the budget runs out.
fn read_evidence(
reader: &mut dyn SectorSource,
extents: &[Extent],
pids: &[u16],
halt: Option<&crate::halt::Halt>,
) -> (HashMap<u16, SpuEvidence>, StopReason, bool, u32) {
let mut evidence: HashMap<u16, SpuEvidence> =
pids.iter().map(|&p| (p, SpuEvidence::default())).collect();
let total: u64 = extents.iter().map(|e| u64::from(e.sector_count)).sum();
let total = u32::try_from(total).unwrap_or(u32::MAX);
// Once every track has shown a non-forced SPU, nothing further can change a verdict.
let settled = |ev: &HashMap<u16, SpuEvidence>| ev.values().all(|e| e.non_forced);
let mut buf = vec![0u8; CHUNK_SECTORS as usize * SECTOR_BYTES];
let mut css = Descrambler::default();
let mut sectors_read: u32 = 0;
let plan = plan_windows(total, PROBE_BUDGET_SECTORS);
let sampled = !matches!(plan.as_slice(), [w] if w.offset == 0 && w.len == total);
for window in &plan {
// Demux and reassembly state are per window: windows are discontiguous. The runs of
// one window are the title's contiguous playback, so they share it.
let mut demux = PsDemuxer::new();
let mut spus: HashMap<u16, SpuAssembler> =
pids.iter().map(|&p| (p, SpuAssembler::default())).collect();
for (base, mut lba, mut remaining) in title_runs(extents, window.offset, window.len) {
reader.set_unit_base(base);
while remaining > 0 {
if halt.is_some_and(|h| h.is_cancelled()) {
return (evidence, StopReason::Halted, true, sectors_read);
}
if sectors_read >= PROBE_BUDGET_SECTORS {
return (evidence, StopReason::Budget, true, sectors_read);
}
let count = remaining
.min(u32::from(CHUNK_SECTORS))
.min(PROBE_BUDGET_SECTORS - sectors_read) as u16;
let want = count as usize * SECTOR_BYTES;
let got = match reader.read_sectors(lba, count, &mut buf[..want], false) {
Ok(n) => (n.min(want) / SECTOR_BYTES) as u32,
Err(_) => return (evidence, StopReason::ReadFailed, true, sectors_read),
};
if got == 0 {
return (evidence, StopReason::ReadFailed, true, sectors_read);
}
for sector in buf[..got as usize * SECTOR_BYTES].chunks_mut(SECTOR_BYTES) {
let clear = match css.clear(sector, reader, extents, halt) {
Ok(clear) => clear,
Err(stop) => return (evidence, stop, true, sectors_read),
};
if !clear {
observe(demux.flush(), &mut spus, &mut evidence);
spus.values_mut().for_each(SpuAssembler::gap);
continue;
}
observe(demux.feed(sector), &mut spus, &mut evidence);
}
lba = lba.saturating_add(got);
remaining = remaining.saturating_sub(got);
sectors_read += got;
if settled(&evidence) {
// The rest of the title goes unread: the evidence is a sample.
return (evidence, StopReason::Exhausted, true, sectors_read);
}
}
}
observe(demux.flush(), &mut spus, &mut evidence);
}
(evidence, StopReason::Exhausted, sampled, sectors_read)
}
// Feed demuxed packets to their tracks' SPU reassembly, recording each completed unit's start.
fn observe(
packets: Vec<crate::mux::ps::PsPacket>,
spus: &mut HashMap<u16, SpuAssembler>,
evidence: &mut HashMap<u16, SpuEvidence>,
) {
for p in packets {
let Some(pid) = p.dvd_pid() else { continue };
let (Some(asm), Some(ev)) = (spus.get_mut(&pid), evidence.get_mut(&pid)) else {
continue;
};
if let Some(start) = asm
.push(p.pts.is_some(), &p.data)
.as_deref()
.and_then(spu_start)
{
ev.observe(start);
}
}
}
// The tracks the evidence calls forced: seen, never non-forced, and over a sample at least
// PROMOTE_MIN_DISPLAY_SETS units, the PGS probe's guard against one stray forced unit.
fn verdicts(evidence: &HashMap<u16, SpuEvidence>, sampled: bool) -> HashMap<u16, SpuEvidence> {
evidence
.iter()
.filter(|(_, e)| e.units > 0 && !e.non_forced)
.filter(|(_, e)| !sampled || e.units >= PROMOTE_MIN_DISPLAY_SETS)
.map(|(&p, &e)| (p, e))
.collect()
}
// Mark the tracks in `forced` as forced when the run that found them may assert it; returns
// how many were marked.
fn apply_forced(
title: &mut DiscTitle,
forced: &HashMap<u16, SpuEvidence>,
conclusive: bool,
) -> usize {
if !conclusive {
return 0;
}
let mut marked = 0;
for s in &mut title.streams {
if let Stream::Subtitle(sub) = s
&& sub.codec == Codec::DvdSub
&& forced.contains_key(&sub.pid)
{
sub.forced = true;
if sub.qualifier == LabelQualifier::None {
sub.qualifier = LabelQualifier::Forced;
}
marked += 1;
}
}
marked
}
#[cfg(test)]
#[path = "dvd_forced_probe_tests.rs"]
mod tests;
-193
View File
@@ -1,193 +0,0 @@
use super::*;
use crate::disc::SubtitleStream;
// An SPU: header, 4 pixel bytes, a first SP_DCSQ opening with `start` (plus the usual
// SET_COLOR/SET_CONTR/SET_DAREA/SET_DSPXA), then a last, self-pointing STP_DSP SP_DCSQ.
fn spu(start: u8) -> Vec<u8> {
let mut u = vec![0, 0, 0, 0, 0x11, 0x22, 0x33, 0x44];
let first = u.len();
u.extend_from_slice(&[0, 0, 0, 0, start]);
u.extend_from_slice(&[0x03, 0x01, 0x23, 0x04, 0xFF, 0xF0]);
u.extend_from_slice(&[0x05, 0, 0, 0x2C, 0, 0, 0x1F, 0x06, 0, 4, 0, 6, CMD_END]);
let last = u.len();
u.extend_from_slice(&[0, 0x10, 0, 0, 0x02, CMD_END]);
u[first + 2..first + 4].copy_from_slice(&(last as u16).to_be_bytes());
u[last + 2..last + 4].copy_from_slice(&(last as u16).to_be_bytes());
let len = u.len() as u16;
u[0..2].copy_from_slice(&len.to_be_bytes());
u[2..4].copy_from_slice(&(first as u16).to_be_bytes());
u
}
#[test]
fn spu_start_reads_the_first_display_command() {
assert_eq!(spu_start(&spu(FSTA_DSP)), Some(SpuStart::Forced));
assert_eq!(spu_start(&spu(STA_DSP)), Some(SpuStart::Normal));
// A unit that only stops a display starts none.
assert_eq!(spu_start(&spu(0x02)), None);
// Truncated or garbage units do not parse.
assert_eq!(spu_start(&spu(FSTA_DSP)[..6]), None);
assert_eq!(spu_start(&[0, 8, 0, 4, 0, 0, 0, 4]), None);
}
#[test]
fn spu_start_skips_a_chg_colcon_parameter_area() {
let mut u = vec![0, 0, 0, 4, 0, 0, 0, 4];
// CHG_COLCON: size word 6 (itself + one LN_CTLI end marker), then STA_DSP.
u.extend_from_slice(&[0x07, 0, 6, 0x0F, 0xFF, 0xFF, 0xFF, STA_DSP, CMD_END]);
let len = u.len() as u16;
u[0..2].copy_from_slice(&len.to_be_bytes());
assert_eq!(spu_start(&u), Some(SpuStart::Normal));
}
#[test]
fn assembler_joins_a_unit_split_across_packets() {
let unit = spu(FSTA_DSP);
let mut asm = SpuAssembler::default();
assert_eq!(asm.push(true, &unit[..10]), None);
assert_eq!(asm.push(false, &unit[10..]), Some(unit.clone()));
// A gap drops the open unit; its continuation is not spliced on.
assert_eq!(asm.push(true, &unit[..10]), None);
asm.gap();
assert_eq!(asm.push(false, &unit[10..]), None);
}
// One 2048-byte DVD pack carrying `spu` whole on sub-stream `sub`, padded to the sector.
fn sector(sub: u8, spu: &[u8]) -> Vec<u8> {
let mut s = vec![0, 0, 1, 0xBA, 0x44, 0, 4, 0, 4, 1, 1, 0x89, 0xC3, 0xF8];
let pes_len = (3 + 5 + 1 + spu.len()) as u16;
s.extend_from_slice(&[0, 0, 1, 0xBD]);
s.extend_from_slice(&pes_len.to_be_bytes());
s.extend_from_slice(&[0x81, 0x80, 0x05, 0x21, 0, 1, 0, 1, sub]);
s.extend_from_slice(spu);
let pad = (2048 - s.len() - 6) as u16;
s.extend_from_slice(&[0, 0, 1, 0xBE]);
s.extend_from_slice(&pad.to_be_bytes());
s.resize(2048, 0xFF);
s
}
struct Image(Vec<u8>);
impl SectorSource for Image {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let at = lba as usize * SECTOR_BYTES;
let n = (count as usize * SECTOR_BYTES).min(self.0.len().saturating_sub(at));
buf[..n].copy_from_slice(&self.0[at..at + n]);
Ok(n)
}
}
fn vobsub(pid: u16) -> Stream {
Stream::Subtitle(SubtitleStream {
pid,
codec: Codec::DvdSub,
language: "deu".into(),
forced: false,
qualifier: LabelQualifier::None,
codec_data: None,
})
}
fn probed(sectors: &[Vec<u8>], pids: &[u16], halt: Option<&crate::halt::Halt>) -> Vec<bool> {
let mut title = DiscTitle {
content_format: crate::disc::ContentFormat::DvdPs,
streams: pids.iter().map(|&p| vobsub(p)).collect(),
extents: vec![Extent {
start_lba: 0,
sector_count: sectors.len() as u32,
}],
..DiscTitle::empty()
};
let mut reader = Image(sectors.concat());
let mut cache = DvdForcedProbeCache::default();
probe_and_set_forced(&mut reader, &mut title, &mut cache, halt);
title
.streams
.iter()
.filter_map(|s| match s {
Stream::Subtitle(s) => Some(s.forced),
_ => None,
})
.collect()
}
#[test]
fn a_stream_of_only_forced_units_is_forced_and_a_mixed_one_is_not() {
let sectors = vec![
sector(0x20, &spu(FSTA_DSP)),
sector(0x21, &spu(FSTA_DSP)),
sector(0x20, &spu(FSTA_DSP)),
sector(0x21, &spu(STA_DSP)),
sector(0x22, &spu(STA_DSP)),
];
// 0x20 all forced; 0x21 mixes both; 0x22 never forced; 0x23 never seen.
assert_eq!(
probed(&sectors, &[0x20, 0x21, 0x22, 0x23], None),
vec![true, false, false, false]
);
}
// A title of thousands of small extents (interleaved units) is sampled across its length
// within the budget, not read from its head until the budget runs out.
#[test]
fn many_small_extents_are_sampled_across_the_title() {
struct Spy(Vec<(u32, u16)>);
impl SectorSource for Spy {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
self.0.push((lba, count));
let n = count as usize * SECTOR_BYTES;
buf[..n].fill(0);
Ok(n)
}
}
let extents: Vec<Extent> = (0..5000)
.map(|i| Extent {
start_lba: i * 200,
sector_count: 100,
})
.collect();
let mut reader = Spy(Vec::new());
let (_, _, sampled, read) = read_evidence(&mut reader, &extents, &[0x20], None);
assert!(sampled);
assert!(read <= PROBE_BUDGET_SECTORS, "read {read}");
let last = reader.0.iter().map(|&(lba, _)| lba).max().unwrap();
assert!(last >= 4000 * 200, "reads stop at LBA {last}");
}
// A window's title sectors map onto the extents it crosses, in read order.
#[test]
fn title_runs_cross_extent_boundaries() {
let extents = [
Extent {
start_lba: 10,
sector_count: 5,
},
Extent {
start_lba: 100,
sector_count: 5,
},
];
assert_eq!(title_runs(&extents, 3, 4), vec![(10, 13, 2), (100, 100, 2)]);
assert_eq!(title_runs(&extents, 5, 5), vec![(100, 100, 5)]);
assert!(title_runs(&extents, 10, 5).is_empty());
}
#[test]
fn a_halted_probe_asserts_nothing() {
let sectors = vec![sector(0x20, &spu(FSTA_DSP)), sector(0x20, &spu(FSTA_DSP))];
let halt = crate::halt::Halt::new();
halt.cancel();
assert_eq!(probed(&sectors, &[0x20], Some(&halt)), vec![false]);
}
File diff suppressed because it is too large Load Diff
+1165 -36
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2716 -2
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2653 -101
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5713 -102
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+288 -2
View File
@@ -301,5 +301,291 @@ impl Disc {
}
#[cfg(test)]
#[path = "profile_tests.rs"]
mod tests;
mod tests {
use super::*;
use crate::disc::{
AudioChannels, AudioStream, Codec, ColorSpace, ContentFormat, DiscRegion, DiscTitle,
FrameRate, HdrFormat, Resolution, SampleRate, Stream, SubtitleStream, VideoStream,
};
/// A minimal titleless [`Disc`] for the disc-level tests.
fn test_disc() -> Disc {
Disc {
volume_id: String::new(),
meta_title: None,
format: DiscFormat::BluRay,
capacity_sectors: 0,
capacity_bytes: 0,
layers: 1,
titles: Vec::new(),
region: DiscRegion::Free,
aacs: None,
css: None,
encrypted: false,
aacs_error: None,
css_error: None,
content_format: ContentFormat::BdTs,
}
}
#[test]
fn main_title_is_none_on_empty_disc() {
// A data-only image (no /BDMV, /HVDVD_TS, /VIDEO_TS) scans to zero
// titles; main_title() must return None, not panic on titles[0].
let profile = DiscProfile::from_disc(&test_disc());
assert!(profile.titles.is_empty());
assert!(profile.main_title().is_none());
}
fn video(codec: Codec, res: Resolution, secondary: bool) -> Stream {
Stream::Video(VideoStream {
pid: 0x1011,
codec,
resolution: res,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Hdr10,
color_space: ColorSpace::Bt2020,
display_aspect: None,
secondary,
label: String::new(),
measured_cicp: None,
})
}
fn audio(lang: &str, secondary: bool, purpose: LabelPurpose, label: &str) -> Stream {
Stream::Audio(AudioStream {
pid: 0x1100,
codec: Codec::TrueHd,
channels: AudioChannels::Stereo,
language: lang.into(),
sample_rate: SampleRate::S48,
secondary,
purpose,
label: label.into(),
})
}
fn subtitle(lang: &str, forced: bool, qualifier: LabelQualifier) -> Stream {
Stream::Subtitle(SubtitleStream {
pid: 0x1200,
codec: Codec::Pgs,
language: lang.into(),
forced,
qualifier,
codec_data: None,
})
}
/// A BD transport-stream title with a mix of streams: two audio tracks
/// (main + commentary), two subtitles (forced + SDH), a video track.
fn bdts_title() -> DiscTitle {
let mut t = DiscTitle::empty();
t.content_format = ContentFormat::BdTs;
t.playlist = "00800.mpls".into();
t.duration_secs = 7200.0;
t.size_bytes = 30_000_000_000;
t.streams = vec![
video(Codec::Hevc, Resolution::R2160p, false),
audio("eng", false, LabelPurpose::Normal, "Dolby TrueHD 5.1"),
audio("eng", true, LabelPurpose::Commentary, "Director"),
subtitle("eng", true, LabelQualifier::Forced),
subtitle("eng", false, LabelQualifier::Sdh),
];
t
}
/// A DVD (MPEG program stream) title: two audio tracks (both non-secondary,
/// second is descriptive), one language-less subtitle, one video track.
fn dvd_title() -> DiscTitle {
let mut t = DiscTitle::empty();
t.content_format = ContentFormat::MpegPs;
t.playlist = "VTS_01".into();
t.duration_secs = 5400.0;
t.size_bytes = 4_000_000_000;
t.streams = vec![
video(Codec::Mpeg2, Resolution::R576i, false),
audio("eng", false, LabelPurpose::Normal, ""),
audio("eng", false, LabelPurpose::Descriptive, ""),
subtitle("", false, LabelQualifier::None),
];
t
}
#[test]
fn splits_streams_into_typed_vectors_across_formats() {
for (title, expect_subs) in [(bdts_title(), 2), (dvd_title(), 1)] {
let p = TitleProfile::from_title(&title, 0, true);
assert_eq!(p.video.len(), 1, "one video track");
assert_eq!(p.audio.len(), 2, "two audio tracks");
assert_eq!(p.subtitles.len(), expect_subs, "subtitle count per format");
// Accessors return the same vectors.
assert_eq!(p.video(), p.video.as_slice());
assert_eq!(p.audio(), p.audio.as_slice());
assert_eq!(p.subtitles(), p.subtitles.as_slice());
}
}
#[test]
fn hoists_default_flags_first_non_secondary() {
let p = TitleProfile::from_title(&bdts_title(), 0, true);
// First (non-secondary) video/audio is the default.
assert!(p.video[0].default, "first video defaults");
assert!(p.audio[0].default, "first non-secondary audio defaults");
// The commentary track is secondary → never default.
assert!(
!p.audio[1].default,
"secondary/commentary audio not default"
);
// Subtitles are never default.
assert!(p.subtitles.iter().all(|s| !s.default));
}
#[test]
fn secondary_first_or_only_track_is_never_default() {
let mut t = DiscTitle::empty();
t.streams = vec![
video(Codec::Hevc, Resolution::R2160p, true),
video(Codec::Hevc, Resolution::R2160p, false),
audio("eng", true, LabelPurpose::Commentary, "Director"),
audio("eng", false, LabelPurpose::Normal, ""),
];
let p = TitleProfile::from_title(&t, 0, true);
assert!(!p.video[0].default && p.video[1].default);
assert!(!p.audio[0].default && p.audio[1].default);
// Only a secondary track of each kind: nothing is default.
t.streams = vec![
video(Codec::Hevc, Resolution::R2160p, true),
audio("eng", true, LabelPurpose::Commentary, ""),
];
let p = TitleProfile::from_title(&t, 0, true);
assert!(!p.video[0].default && !p.audio[0].default);
}
#[test]
fn second_default_cleared_when_two_non_secondary_audio() {
// DVD title: BOTH audio tracks are non-secondary. Only the first keeps
// the default flag (mirrors the muxer's "keep only first default").
let p = TitleProfile::from_title(&dvd_title(), 0, true);
assert!(p.audio[0].default, "first audio default");
assert!(
!p.audio[1].default,
"second non-secondary audio default cleared"
);
assert!(p.audio[1].descriptive, "second audio is descriptive");
assert!(!p.audio[1].commentary);
}
#[test]
fn maps_qualifier_and_purpose_and_language_defaults() {
let p = TitleProfile::from_title(&bdts_title(), 0, true);
// Audio purpose → commentary/descriptive booleans.
assert!(!p.audio[0].commentary && !p.audio[0].descriptive);
assert!(p.audio[1].commentary);
assert_eq!(p.audio[1].name, "Director", "label maps to name");
// Subtitle qualifier → forced/sdh booleans.
assert!(p.subtitles[0].forced && !p.subtitles[0].sdh);
assert!(p.subtitles[1].sdh && !p.subtitles[1].forced);
// Language default: DVD title's blank subtitle language → "und".
let dvd = TitleProfile::from_title(&dvd_title(), 0, true);
assert_eq!(dvd.subtitles[0].language, "und");
assert_eq!(dvd.audio[0].language, "eng");
// Codec/resolution/hdr flattened as compact ids/labels.
assert_eq!(p.video[0].codec, "hevc");
assert_eq!(p.video[0].resolution, "2160p");
assert_eq!(p.video[0].hdr, "hdr10");
assert_eq!(dvd.video[0].codec, "mpeg2");
}
// Forced is the probe/STN flag OR the label qualifier: either alone is enough.
#[test]
fn subtitle_forced_is_the_flag_or_the_qualifier() {
for (flag, qualifier, want) in [
(false, LabelQualifier::None, false),
(true, LabelQualifier::None, true),
(false, LabelQualifier::Forced, true),
(true, LabelQualifier::Forced, true),
] {
let Stream::Subtitle(s) = subtitle("eng", flag, qualifier) else {
unreachable!()
};
assert_eq!(
SubtitleTrack::from_stream(&s).forced,
want,
"{flag} {qualifier:?}"
);
}
}
#[test]
fn from_disc_hoists_main_and_is_main() {
let mut disc = test_disc();
disc.format = DiscFormat::BluRay;
disc.meta_title = Some("SOME MOVIE".into());
disc.volume_id = "VOL_ID".into();
disc.titles = vec![bdts_title(), dvd_title()];
let profile = disc.profile();
assert_eq!(profile.format, "bluray");
assert_eq!(profile.disc_name, "SOME MOVIE");
assert_eq!(profile.disc_id, "VOL_ID");
assert_eq!(profile.main_title, 0);
assert!(profile.titles[0].is_main, "titles[0] is the main feature");
assert!(!profile.titles[1].is_main);
assert_eq!(profile.titles[1].index, 1);
assert_eq!(profile.main_title().unwrap().playlist, "00800.mpls");
}
#[test]
fn disc_name_falls_back_to_volume_id() {
let mut disc = test_disc();
disc.meta_title = None;
disc.volume_id = "PLAIN_VOLUME".into();
assert_eq!(disc.profile().disc_name, "PLAIN_VOLUME");
}
#[test]
fn encryption_status_and_key_error_surface_numerically() {
// Encrypted disc whose key resolution FAILED: the encrypted flag is set
// and the numeric aacs_error code surfaces (never English text), so it
// no longer serializes identically to a rippable disc.
let mut disc = test_disc();
disc.encrypted = true;
let err = crate::error::Error::AacsVidUnavailable;
let aacs_code = u32::from(err.code());
disc.aacs_error = Some(err);
let p = disc.profile();
assert!(p.encrypted);
assert_eq!(p.key_error, Some(aacs_code));
// AACS takes precedence: with BOTH errors set, the aacs code wins.
let mut both = test_disc();
both.encrypted = true;
both.aacs_error = Some(crate::error::Error::AacsVidUnavailable);
both.css_error = Some(crate::error::Error::CssKeyMissing);
assert_eq!(both.profile().key_error, Some(aacs_code));
// A CSS-only failure surfaces the css code.
let mut css = test_disc();
css.encrypted = true;
let css_err = crate::error::Error::CssKeyMissing;
let css_code = u32::from(css_err.code());
css.css_error = Some(css_err);
assert_eq!(css.profile().key_error, Some(css_code));
// A clean, rippable disc: not encrypted, no key error.
let clean = test_disc().profile();
assert!(!clean.encrypted);
assert_eq!(clean.key_error, None);
}
#[test]
fn serde_round_trip() {
let mut disc = test_disc();
disc.format = DiscFormat::Dvd;
disc.volume_id = "RT".into();
disc.titles = vec![bdts_title(), dvd_title()];
let profile = disc.profile();
let json = serde_json::to_string(&profile).expect("serialize");
let back: DiscProfile = serde_json::from_str(&json).expect("deserialize");
assert_eq!(profile, back, "profile round-trips through serde");
}
}
-286
View File
@@ -1,286 +0,0 @@
use super::*;
use crate::disc::{
AudioChannels, AudioStream, Codec, ColorSpace, ContentFormat, DiscRegion, DiscTitle, FrameRate,
HdrFormat, Resolution, SampleRate, Stream, SubtitleStream, VideoStream,
};
/// A minimal titleless [`Disc`] for the disc-level tests.
fn test_disc() -> Disc {
Disc {
volume_id: String::new(),
meta_title: None,
format: DiscFormat::BluRay,
capacity_sectors: 0,
capacity_bytes: 0,
layers: 1,
titles: Vec::new(),
region: DiscRegion::Free,
aacs: None,
css: None,
encrypted: false,
aacs_error: None,
css_error: None,
content_format: ContentFormat::BdTs,
}
}
#[test]
fn main_title_is_none_on_empty_disc() {
// A data-only image (no /BDMV, /HVDVD_TS, /VIDEO_TS) scans to zero
// titles; main_title() must return None, not panic on titles[0].
let profile = DiscProfile::from_disc(&test_disc());
assert!(profile.titles.is_empty());
assert!(profile.main_title().is_none());
}
fn video(codec: Codec, res: Resolution, secondary: bool) -> Stream {
Stream::Video(VideoStream {
pid: 0x1011,
codec,
resolution: res,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Hdr10,
color_space: ColorSpace::Bt2020,
display_aspect: None,
secondary,
label: String::new(),
measured_cicp: None,
})
}
fn audio(lang: &str, secondary: bool, purpose: LabelPurpose, label: &str) -> Stream {
Stream::Audio(AudioStream {
pid: 0x1100,
codec: Codec::TrueHd,
channels: AudioChannels::Stereo,
language: lang.into(),
sample_rate: SampleRate::S48,
secondary,
purpose,
label: label.into(),
})
}
fn subtitle(lang: &str, forced: bool, qualifier: LabelQualifier) -> Stream {
Stream::Subtitle(SubtitleStream {
pid: 0x1200,
codec: Codec::Pgs,
language: lang.into(),
forced,
qualifier,
codec_data: None,
})
}
/// A BD transport-stream title with a mix of streams: two audio tracks
/// (main + commentary), two subtitles (forced + SDH), a video track.
fn bdts_title() -> DiscTitle {
let mut t = DiscTitle::empty();
t.content_format = ContentFormat::BdTs;
t.playlist = "00800.mpls".into();
t.duration_secs = 7200.0;
t.size_bytes = 30_000_000_000;
t.streams = vec![
video(Codec::Hevc, Resolution::R2160p, false),
audio("eng", false, LabelPurpose::Normal, "Dolby TrueHD 5.1"),
audio("eng", true, LabelPurpose::Commentary, "Director"),
subtitle("eng", true, LabelQualifier::Forced),
subtitle("eng", false, LabelQualifier::Sdh),
];
t
}
/// A DVD (MPEG program stream) title: two audio tracks (both non-secondary,
/// second is descriptive), one language-less subtitle, one video track.
fn dvd_title() -> DiscTitle {
let mut t = DiscTitle::empty();
t.content_format = ContentFormat::MpegPs;
t.playlist = "VTS_01".into();
t.duration_secs = 5400.0;
t.size_bytes = 4_000_000_000;
t.streams = vec![
video(Codec::Mpeg2, Resolution::R576i, false),
audio("eng", false, LabelPurpose::Normal, ""),
audio("eng", false, LabelPurpose::Descriptive, ""),
subtitle("", false, LabelQualifier::None),
];
t
}
#[test]
fn splits_streams_into_typed_vectors_across_formats() {
for (title, expect_subs) in [(bdts_title(), 2), (dvd_title(), 1)] {
let p = TitleProfile::from_title(&title, 0, true);
assert_eq!(p.video.len(), 1, "one video track");
assert_eq!(p.audio.len(), 2, "two audio tracks");
assert_eq!(p.subtitles.len(), expect_subs, "subtitle count per format");
// Accessors return the same vectors.
assert_eq!(p.video(), p.video.as_slice());
assert_eq!(p.audio(), p.audio.as_slice());
assert_eq!(p.subtitles(), p.subtitles.as_slice());
}
}
#[test]
fn hoists_default_flags_first_non_secondary() {
let p = TitleProfile::from_title(&bdts_title(), 0, true);
// First (non-secondary) video/audio is the default.
assert!(p.video[0].default, "first video defaults");
assert!(p.audio[0].default, "first non-secondary audio defaults");
// The commentary track is secondary → never default.
assert!(
!p.audio[1].default,
"secondary/commentary audio not default"
);
// Subtitles are never default.
assert!(p.subtitles.iter().all(|s| !s.default));
}
#[test]
fn secondary_first_or_only_track_is_never_default() {
let mut t = DiscTitle::empty();
t.streams = vec![
video(Codec::Hevc, Resolution::R2160p, true),
video(Codec::Hevc, Resolution::R2160p, false),
audio("eng", true, LabelPurpose::Commentary, "Director"),
audio("eng", false, LabelPurpose::Normal, ""),
];
let p = TitleProfile::from_title(&t, 0, true);
assert!(!p.video[0].default && p.video[1].default);
assert!(!p.audio[0].default && p.audio[1].default);
// Only a secondary track of each kind: nothing is default.
t.streams = vec![
video(Codec::Hevc, Resolution::R2160p, true),
audio("eng", true, LabelPurpose::Commentary, ""),
];
let p = TitleProfile::from_title(&t, 0, true);
assert!(!p.video[0].default && !p.audio[0].default);
}
#[test]
fn second_default_cleared_when_two_non_secondary_audio() {
// DVD title: BOTH audio tracks are non-secondary. Only the first keeps
// the default flag (mirrors the muxer's "keep only first default").
let p = TitleProfile::from_title(&dvd_title(), 0, true);
assert!(p.audio[0].default, "first audio default");
assert!(
!p.audio[1].default,
"second non-secondary audio default cleared"
);
assert!(p.audio[1].descriptive, "second audio is descriptive");
assert!(!p.audio[1].commentary);
}
#[test]
fn maps_qualifier_and_purpose_and_language_defaults() {
let p = TitleProfile::from_title(&bdts_title(), 0, true);
// Audio purpose → commentary/descriptive booleans.
assert!(!p.audio[0].commentary && !p.audio[0].descriptive);
assert!(p.audio[1].commentary);
assert_eq!(p.audio[1].name, "Director", "label maps to name");
// Subtitle qualifier → forced/sdh booleans.
assert!(p.subtitles[0].forced && !p.subtitles[0].sdh);
assert!(p.subtitles[1].sdh && !p.subtitles[1].forced);
// Language default: DVD title's blank subtitle language → "und".
let dvd = TitleProfile::from_title(&dvd_title(), 0, true);
assert_eq!(dvd.subtitles[0].language, "und");
assert_eq!(dvd.audio[0].language, "eng");
// Codec/resolution/hdr flattened as compact ids/labels.
assert_eq!(p.video[0].codec, "hevc");
assert_eq!(p.video[0].resolution, "2160p");
assert_eq!(p.video[0].hdr, "hdr10");
assert_eq!(dvd.video[0].codec, "mpeg2");
}
// Forced is the probe/STN flag OR the label qualifier: either alone is enough.
#[test]
fn subtitle_forced_is_the_flag_or_the_qualifier() {
for (flag, qualifier, want) in [
(false, LabelQualifier::None, false),
(true, LabelQualifier::None, true),
(false, LabelQualifier::Forced, true),
(true, LabelQualifier::Forced, true),
] {
let Stream::Subtitle(s) = subtitle("eng", flag, qualifier) else {
unreachable!()
};
assert_eq!(
SubtitleTrack::from_stream(&s).forced,
want,
"{flag} {qualifier:?}"
);
}
}
#[test]
fn from_disc_hoists_main_and_is_main() {
let mut disc = test_disc();
disc.format = DiscFormat::BluRay;
disc.meta_title = Some("SOME MOVIE".into());
disc.volume_id = "VOL_ID".into();
disc.titles = vec![bdts_title(), dvd_title()];
let profile = disc.profile();
assert_eq!(profile.format, "bluray");
assert_eq!(profile.disc_name, "SOME MOVIE");
assert_eq!(profile.disc_id, "VOL_ID");
assert_eq!(profile.main_title, 0);
assert!(profile.titles[0].is_main, "titles[0] is the main feature");
assert!(!profile.titles[1].is_main);
assert_eq!(profile.titles[1].index, 1);
assert_eq!(profile.main_title().unwrap().playlist, "00800.mpls");
}
#[test]
fn disc_name_falls_back_to_volume_id() {
let mut disc = test_disc();
disc.meta_title = None;
disc.volume_id = "PLAIN_VOLUME".into();
assert_eq!(disc.profile().disc_name, "PLAIN_VOLUME");
}
#[test]
fn encryption_status_and_key_error_surface_numerically() {
// Encrypted disc whose key resolution FAILED: the encrypted flag is set
// and the numeric aacs_error code surfaces (never English text), so it
// no longer serializes identically to a rippable disc.
let mut disc = test_disc();
disc.encrypted = true;
let err = crate::error::Error::AacsVidUnavailable;
let aacs_code = u32::from(err.code());
disc.aacs_error = Some(err);
let p = disc.profile();
assert!(p.encrypted);
assert_eq!(p.key_error, Some(aacs_code));
// AACS takes precedence: with BOTH errors set, the aacs code wins.
let mut both = test_disc();
both.encrypted = true;
both.aacs_error = Some(crate::error::Error::AacsVidUnavailable);
both.css_error = Some(crate::error::Error::CssKeyMissing);
assert_eq!(both.profile().key_error, Some(aacs_code));
// A CSS-only failure surfaces the css code.
let mut css = test_disc();
css.encrypted = true;
let css_err = crate::error::Error::CssKeyMissing;
let css_code = u32::from(css_err.code());
css.css_error = Some(css_err);
assert_eq!(css.profile().key_error, Some(css_code));
// A clean, rippable disc: not encrypted, no key error.
let clean = test_disc().profile();
assert!(!clean.encrypted);
assert_eq!(clean.key_error, None);
}
#[test]
fn serde_round_trip() {
let mut disc = test_disc();
disc.format = DiscFormat::Dvd;
disc.volume_id = "RT".into();
disc.titles = vec![bdts_title(), dvd_title()];
let profile = disc.profile();
let json = serde_json::to_string(&profile).expect("serialize");
let back: DiscProfile = serde_json::from_str(&json).expect("deserialize");
assert_eq!(profile, back, "profile round-trips through serde");
}
+1 -29
View File
@@ -673,24 +673,7 @@ fn pre_ake_read_refusal_is_a_plain_scan_error() {
assert_eq!(rig.count(|c| matches!(c[0], 0xA3 | 0xA4)), 0);
}
// 64 video packs, each flagged scrambled (`[HD]` §4.3.2) or each clear (a decrypted rip).
fn evo(scrambled: bool) -> Vec<u8> {
(0..64u8)
.flat_map(|i| {
let mut p = crate::aacs::hddvd::tests::video_pack(i);
if scrambled {
p[20] |= 0x10;
}
p
})
.collect()
}
fn hddvd_disc() -> MemDisc {
hddvd_disc_with(true)
}
fn hddvd_disc_with(scrambled: bool) -> MemDisc {
let root = DirSpec {
name: String::new(),
icb_lba: 10,
@@ -701,7 +684,7 @@ fn hddvd_disc_with(scrambled: bool) -> MemDisc {
name: "HVDVD_TS".into(),
icb_lba: 20,
dir_data_lba: 21,
files: vec![file_with("MAIN.EVO", 100, 5_000, evo(scrambled), true)],
files: vec![file("MAIN.EVO", 100, 5_000, 3_000_000, true)],
subdirs: vec![],
},
DirSpec {
@@ -755,17 +738,6 @@ fn hddvd_image_aacs_dir_is_captured() {
assert!(d.encrypted && d.aacs.is_some() && d.aacs_error.is_none());
}
// A rip keeps a renamed AACS dir (`AACS!` here) over clear EVOs: encryption follows the packs'
// scrambling flags, not the directory, for the fast identify and the full scan alike.
#[test]
fn hddvd_rip_with_an_aacs_dir_over_clear_packs_is_not_encrypted() {
let mut rig = Rig::new(hddvd_disc_with(false), |_| {});
assert!(!Disc::identify(&mut rig.drive).expect("identify").encrypted);
let mut mem = hddvd_disc_with(false);
let d = Disc::scan_image(&mut mem, 9_999, &ScanOptions::default()).expect("scan");
assert!(!d.encrypted && d.aacs.is_none() && !d.titles.is_empty());
}
#[test]
fn halted_during_capture_propagates() {
for lba in [CERT_LBA, MKB_LBA] {
+67 -2
View File
@@ -75,5 +75,70 @@ pub(crate) fn allowed_after_cancel(cdb: &[u8], ledger: Ledger, ctx: CleanupCtx)
}
#[cfg(test)]
#[path = "allow_tests.rs"]
mod tests;
mod tests {
use super::*;
const ALLOW: [u8; 6] = [0x1E, 0, 0, 0, 0x00, 0];
const PREVENT: [u8; 6] = [0x1E, 0, 0, 0, 0x01, 0];
const EJECT: [u8; 6] = EJECT_CDB;
const START: [u8; 6] = [0x1B, 0, 0, 0, 0x01, 0];
const READ: [u8; 10] = [0x28, 0, 0, 0, 0, 0, 0, 0, 1, 0];
fn release(agid: u8) -> [u8; 12] {
let mut c = [0u8; 12];
c[0] = REPORT_KEY;
c[10] = (agid << 6) | KEY_FORMAT_INVALIDATE_AGID;
c
}
/// LD4 `exec_cleanup_allow_list`: one case per §2.4 row, and one refusal per row.
/// Per evidence SS-7 (AGID) and per the §2.4 table; do not change without a design
/// revision.
#[test]
fn exec_cleanup_allow_list() {
let none = Ledger::default();
let locked = Ledger {
tray_locked: true,
agids: 0,
};
let agid2 = Ledger {
tray_locked: false,
agids: 1 << 2,
};
use CleanupCtx::*;
// Row 1: ALLOW iff tray_locked.
assert!(allowed_after_cancel(&ALLOW, locked, Plain));
assert!(!allowed_after_cancel(&ALLOW, none, Plain));
assert!(
!allowed_after_cancel(&PREVENT, locked, Plain),
"never PREVENT"
);
// Row 2: LoEj only inside finish(Eject).
assert!(allowed_after_cancel(&EJECT, none, FinishEject));
assert!(!allowed_after_cancel(&EJECT, locked, Plain));
assert!(!allowed_after_cancel(&START, none, FinishEject), "LoEj=0");
// LoEj=1 with Start=1 loads the tray: not an eject.
let load = [0x1B, 0, 0, 0, 0x03, 0];
assert!(!allowed_after_cancel(&load, none, FinishEject), "Start=1");
// Row 3: 0x3F iff that AGID's bit is set.
assert!(allowed_after_cancel(&release(2), agid2, Plain));
assert!(!allowed_after_cancel(&release(1), agid2, Plain));
assert!(!allowed_after_cancel(&release(2), none, Plain));
// Row 4: any CDB inside a pre-cancel critical section.
assert!(allowed_after_cancel(&READ, none, Critical));
assert!(!allowed_after_cancel(&READ, locked, Plain));
assert!(!allowed_after_cancel(&READ, agid2, FinishEject));
}
#[test]
fn invalidated_agid_reads_byte_10() {
assert_eq!(invalidated_agid(&release(3)), Some(3));
let mut alloc = release(0);
alloc[10] = 0x00;
assert_eq!(invalidated_agid(&alloc), None, "format 0 allocates");
assert_eq!(invalidated_agid(&[0xA4, 0, 0]), None, "short CDB");
let mut other = release(3);
other[0] = 0x00;
assert_eq!(invalidated_agid(&other), None, "not REPORT KEY");
}
}
-65
View File
@@ -1,65 +0,0 @@
use super::*;
const ALLOW: [u8; 6] = [0x1E, 0, 0, 0, 0x00, 0];
const PREVENT: [u8; 6] = [0x1E, 0, 0, 0, 0x01, 0];
const EJECT: [u8; 6] = EJECT_CDB;
const START: [u8; 6] = [0x1B, 0, 0, 0, 0x01, 0];
const READ: [u8; 10] = [0x28, 0, 0, 0, 0, 0, 0, 0, 1, 0];
fn release(agid: u8) -> [u8; 12] {
let mut c = [0u8; 12];
c[0] = REPORT_KEY;
c[10] = (agid << 6) | KEY_FORMAT_INVALIDATE_AGID;
c
}
/// LD4 `exec_cleanup_allow_list`: one case per §2.4 row, and one refusal per row.
/// Per evidence SS-7 (AGID) and per the §2.4 table; do not change without a design
/// revision.
#[test]
fn exec_cleanup_allow_list() {
let none = Ledger::default();
let locked = Ledger {
tray_locked: true,
agids: 0,
};
let agid2 = Ledger {
tray_locked: false,
agids: 1 << 2,
};
use CleanupCtx::*;
// Row 1: ALLOW iff tray_locked.
assert!(allowed_after_cancel(&ALLOW, locked, Plain));
assert!(!allowed_after_cancel(&ALLOW, none, Plain));
assert!(
!allowed_after_cancel(&PREVENT, locked, Plain),
"never PREVENT"
);
// Row 2: LoEj only inside finish(Eject).
assert!(allowed_after_cancel(&EJECT, none, FinishEject));
assert!(!allowed_after_cancel(&EJECT, locked, Plain));
assert!(!allowed_after_cancel(&START, none, FinishEject), "LoEj=0");
// LoEj=1 with Start=1 loads the tray: not an eject.
let load = [0x1B, 0, 0, 0, 0x03, 0];
assert!(!allowed_after_cancel(&load, none, FinishEject), "Start=1");
// Row 3: 0x3F iff that AGID's bit is set.
assert!(allowed_after_cancel(&release(2), agid2, Plain));
assert!(!allowed_after_cancel(&release(1), agid2, Plain));
assert!(!allowed_after_cancel(&release(2), none, Plain));
// Row 4: any CDB inside a pre-cancel critical section.
assert!(allowed_after_cancel(&READ, none, Critical));
assert!(!allowed_after_cancel(&READ, locked, Plain));
assert!(!allowed_after_cancel(&READ, agid2, FinishEject));
}
#[test]
fn invalidated_agid_reads_byte_10() {
assert_eq!(invalidated_agid(&release(3)), Some(3));
let mut alloc = release(0);
alloc[10] = 0x00;
assert_eq!(invalidated_agid(&alloc), None, "format 0 allocates");
assert_eq!(invalidated_agid(&[0xA4, 0, 0]), None, "short CDB");
let mut other = release(3);
other[0] = 0x00;
assert_eq!(invalidated_agid(&other), None, "not REPORT KEY");
}
+19 -2
View File
@@ -94,5 +94,22 @@ pub fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> {
}
#[cfg(test)]
#[path = "linux_tests.rs"]
mod tests;
mod tests {
use super::*;
// Unfiltered sg nodes (sysfs unreadable) must be INQUIRY-checked before a
// Drive::open; sysfs-filtered ones are not probed at all.
#[test]
fn optical_candidates_probes_only_when_sysfs_did_not_filter() {
let paths = || ["/dev/sg0", "/dev/sg1"].map(String::from).into_iter();
let mut probed = Vec::new();
let kept = optical_candidates(paths(), true, |p| {
probed.push(p.to_string());
false
});
assert_eq!(kept, ["/dev/sg0", "/dev/sg1"]);
assert!(probed.is_empty(), "type-filtered paths need no INQUIRY");
let kept = optical_candidates(paths(), false, |p| p == "/dev/sg1");
assert_eq!(kept, ["/dev/sg1"], "non-optical sg0 dropped");
}
}
-17
View File
@@ -1,17 +0,0 @@
use super::*;
// Unfiltered sg nodes (sysfs unreadable) must be INQUIRY-checked before a
// Drive::open; sysfs-filtered ones are not probed at all.
#[test]
fn optical_candidates_probes_only_when_sysfs_did_not_filter() {
let paths = || ["/dev/sg0", "/dev/sg1"].map(String::from).into_iter();
let mut probed = Vec::new();
let kept = optical_candidates(paths(), true, |p| {
probed.push(p.to_string());
false
});
assert_eq!(kept, ["/dev/sg0", "/dev/sg1"]);
assert!(probed.is_empty(), "type-filtered paths need no INQUIRY");
let kept = optical_candidates(paths(), false, |p| p == "/dev/sg1");
assert_eq!(kept, ["/dev/sg1"], "non-optical sg0 dropped");
}
+28 -2
View File
@@ -17,5 +17,31 @@ pub fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> {
}
#[cfg(test)]
#[path = "macos_resolve_device_tests.rs"]
mod resolve_device_tests;
mod resolve_device_tests {
use super::*;
/// An existing path resolves unchanged as `Direct` — macOS has no
/// `sr`->`sg` substitution, so the returned path must be byte-identical
/// to the input, not some canonicalised/mutated form.
#[test]
fn existing_path_resolves_direct_unchanged() {
// Use the test binary's own executable path: guaranteed to exist,
// no fixture file needed.
let exe = std::env::current_exe().unwrap();
let path = exe.to_str().unwrap();
let (resolved, kind) = resolve_device(path).expect("existing path must resolve");
assert_eq!(resolved, path, "path must be returned unchanged");
assert_eq!(kind, DeviceResolution::Direct);
}
/// A path that does not exist must error with `DeviceNotFound` carrying
/// the original path, never silently succeed.
#[test]
fn missing_path_is_device_not_found() {
let path = "/dev/freemkv-definitely-does-not-exist-0xdead";
match resolve_device(path) {
Err(Error::DeviceNotFound { path: p }) => assert_eq!(p, path),
other => panic!("expected DeviceNotFound, got {other:?}"),
}
}
}
-26
View File
@@ -1,26 +0,0 @@
use super::*;
/// An existing path resolves unchanged as `Direct` — macOS has no
/// `sr`->`sg` substitution, so the returned path must be byte-identical
/// to the input, not some canonicalised/mutated form.
#[test]
fn existing_path_resolves_direct_unchanged() {
// Use the test binary's own executable path: guaranteed to exist,
// no fixture file needed.
let exe = std::env::current_exe().unwrap();
let path = exe.to_str().unwrap();
let (resolved, kind) = resolve_device(path).expect("existing path must resolve");
assert_eq!(resolved, path, "path must be returned unchanged");
assert_eq!(kind, DeviceResolution::Direct);
}
/// A path that does not exist must error with `DeviceNotFound` carrying
/// the original path, never silently succeed.
#[test]
fn missing_path_is_device_not_found() {
let path = "/dev/freemkv-definitely-does-not-exist-0xdead";
match resolve_device(path) {
Err(Error::DeviceNotFound { path: p }) => assert_eq!(p, path),
other => panic!("expected DeviceNotFound, got {other:?}"),
}
}
+2464 -4
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-95
View File
@@ -1,95 +0,0 @@
use super::*;
use std::time::{Duration, Instant};
struct Idle;
impl ScsiTransport for Idle {
fn execute(
&mut self,
_: &[u8],
_: crate::scsi::DataDirection,
_: &mut [u8],
_: u32,
) -> Result<crate::scsi::ScsiResult> {
unreachable!("pause issues no CDB")
}
}
fn idle() -> Drive {
Drive::from_transport_for_test(Box::new(Idle))
}
// `Drive::pause` (was `sleep_until_halted`) is `Halt::wait` on the attached token.
#[test]
fn pause_completes_when_not_halted() {
let t0 = Instant::now();
assert!(idle().pause(Duration::from_millis(150)).is_ok());
assert!(t0.elapsed() >= Duration::from_millis(140));
}
#[test]
fn pause_returns_immediately_if_prehalted() {
let d = idle();
d.halt();
let t0 = Instant::now();
assert!(matches!(
d.pause(Duration::from_secs(10)),
Err(Error::Halted)
));
assert!(t0.elapsed() < Duration::from_millis(200));
}
#[test]
fn pause_wakes_mid_sleep() {
let d = idle();
let flag = d.halt_flag();
let t0 = Instant::now();
let stopper = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(150));
flag.store(true, std::sync::atomic::Ordering::Release);
});
let r = d.pause(Duration::from_secs(10));
stopper.join().unwrap();
assert!(matches!(r, Err(Error::Halted)));
let waited = t0.elapsed();
assert!(waited < Duration::from_secs(2), "waited {waited:?}");
assert!(waited >= Duration::from_millis(140), "waited {waited:?}");
}
#[test]
fn pause_zero_duration_is_noop_when_not_halted() {
assert!(idle().pause(Duration::ZERO).is_ok());
}
#[test]
fn read_capacity_short_transfer_is_rejected() {
// bytes_transferred < 4 must NOT decode to capacity=1 from
// zero-init bytes.
let buf = [0u8; 8];
assert!(matches!(
decode_read_capacity(&buf, 0),
Err(Error::DiscCapacityMalformed)
));
assert!(matches!(
decode_read_capacity(&buf, 3),
Err(Error::DiscCapacityMalformed)
));
}
#[test]
fn read_capacity_full_transfer_decodes_last_lba_plus_one() {
// last_lba = 0x00012344 -> capacity 0x00012345.
let buf = [0x00, 0x01, 0x23, 0x44, 0, 0, 0, 0];
assert_eq!(decode_read_capacity(&buf, 8).unwrap(), 0x0001_2345);
}
#[test]
fn read_capacity_overflow_is_rejected() {
// last_lba = u32::MAX (the "capacity exceeds 32-bit" sentinel) -> +1
// overflows; reported as the distinct DiscCapacityOverflow, not the
// short-transfer DiscCapacityMalformed.
let buf = [0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0, 0];
assert!(matches!(
decode_read_capacity(&buf, 8),
Err(Error::DiscCapacityOverflow)
));
}
+34 -53
View File
@@ -138,7 +138,7 @@ fn every_drive_cdb_goes_through_exec() {
#[test]
fn no_raw_execute_outside_dispatch() {
let src = include_str!("mod.rs");
let body = src;
let body = &src[..src.find("#[cfg(test)]\nmod halt_tests").unwrap()];
let raw = body.matches(".execute(").count();
assert_eq!(raw, 1, "only `dispatch` calls the transport's execute");
let id = include_str!("../identity.rs");
@@ -438,28 +438,26 @@ fn wait_ready_stop_during_poll_and_start_unit() {
}
}
/// LD11a (GUARD, T5): transport failures broken by a drive answer every few polls,
/// for more than the dead-bus budget, never count as a dead bus.
/// LD11a (GUARD, T5): transport failures broken by a drive answer every 0.5 × the
/// dead-bus budget, for 4 budgets, never count as a dead bus.
#[test]
fn dead_bus_budget_resets_on_any_answer() {
let budget = Duration::from_secs(1);
// Polls are at least 10 ms apart, so 150 polls outlast the budget.
let mut n = 0u32;
let budget = MS(100);
let t0 = Instant::now();
let mut last = Instant::now();
let t = Script::new(move |_, d| {
n += 1;
if n >= 150 {
if t0.elapsed() >= budget * 4 {
return good(d);
}
if n.is_multiple_of(5) {
if last.elapsed() >= budget / 2 {
last = Instant::now();
return Err(not_ready(0x04, 0x01));
}
Err(fault())
});
let mut d = Drive::from_transport(Box::new(t));
let t0 = Instant::now();
let r = d.wait_ready_with(timing(Duration::from_secs(60), budget));
assert!(r.is_ok(), "{r:?}");
assert!(t0.elapsed() > budget, "the run outlasted the budget");
}
/// LD11b / G5 (GUARD): an unbroken run of transport failures for the budget is a
@@ -477,29 +475,29 @@ fn dead_bus_unbroken_run_fails() {
assert!(t0.elapsed() < Duration::from_secs(1));
}
/// LD12a: 04/01 with a progress indication rising every 0.25 × window for 3 windows,
/// LD12a: 04/01 with a progress indication rising every 0.5 × window for 4 windows,
/// then ready → `Ok`. Corroborated by SS-1: the field is "a percent complete indication".
#[test]
fn wait_ready_rides_out_progressing_spin_up() {
let window = Duration::from_secs(1);
let window = MS(200);
let t0 = Instant::now();
let cell: Arc<Mutex<Option<u16>>> = Arc::default();
let c2 = cell.clone();
let t = Script(
Box::new(move |_, d| {
let at = t0.elapsed();
if at >= window * 3 {
return good(d);
}
let step = (at.as_millis() / (window.as_millis() / 4)) as u16;
*c2.lock().unwrap() = Some(1000 * (step + 1));
Err(not_ready(0x04, 0x01))
}),
None,
cell,
);
let (t, cell) = Script::with_progress(move |_, d| {
if t0.elapsed() >= window * 4 {
return good(d);
}
Err(not_ready(0x04, 0x01))
});
let t0b = Instant::now();
let mover = std::thread::spawn(move || {
while t0b.elapsed() < window * 4 {
let step = (t0b.elapsed().as_millis() / (window.as_millis() / 2)) as u16;
*cell.lock().unwrap() = Some(1000 * (step + 1));
std::thread::sleep(MS(5));
}
});
let mut d = Drive::from_transport(Box::new(t));
let r = d.wait_ready_with(timing(window, MS(5_000)));
mover.join().unwrap();
assert!(r.is_ok(), "a progressing spin-up is not a stall: {r:?}");
assert!(t0.elapsed() >= window * 2, "ran past two windows");
}
@@ -528,28 +526,19 @@ fn wait_ready_gives_up_after_60s_without_progress() {
/// (The stricter reading of "the answer changes", §2.11; the user may overrule it.)
#[test]
fn wait_ready_new_answer_is_progress_flapping_is_not() {
let window = Duration::from_secs(2);
let window = MS(200);
let t0 = Instant::now();
let fresh = [0x00u8, 0x01, 0x04, 0x07, 0x08, 0x09, 0x0A, 0x11, 0x22];
// A new answer a quarter-window of wall time after the last one: 8 answers outlast
// the window, and each lands a window-minus-quarter before it would expire, however
// late a slow runner wakes the polls.
let mut step = 0usize;
let mut last = Instant::now();
let t = Script::new(move |_, d| {
if last.elapsed() >= window / 4 {
step += 1;
last = Instant::now();
}
let step = (t0.elapsed().as_millis() / (window.as_millis() / 2)) as usize;
match fresh.get(step) {
Some(&q) if step < 8 => Err(not_ready(0x04, q)),
_ => good(d),
}
});
let mut d = Drive::from_transport(Box::new(t));
let t0 = Instant::now();
let r = d.wait_ready_with(timing(window, MS(5_000)));
assert!(r.is_ok(), "new answers re-arm the window: {r:?}");
assert!(t0.elapsed() > window, "the run outlasted one window");
let mut n = 0u32;
let t = Script::new(move |_, _| {
@@ -563,21 +552,17 @@ fn wait_ready_new_answer_is_progress_flapping_is_not() {
let t0 = Instant::now();
let r = d.wait_ready_with(timing(window, MS(5_000)));
assert!(matches!(r, Err(Error::DeviceNotReady { .. })), "{r:?}");
assert!(t0.elapsed() < window + Duration::from_secs(5));
assert!(t0.elapsed() < window + Duration::from_secs(1));
}
/// LD12d (GUARD): a Stop mid-wait, with and without progress, ends the wait at once.
/// The Stop lands after a few polls, long before the window could expire.
/// LD12d (GUARD): a Stop at 0.75 × window, and mid-progress, ends the wait at once.
#[test]
fn wait_ready_stop_always_interrupts() {
let window = Duration::from_secs(60);
let window = MS(400);
for progressing in [false, true] {
let h = Halt::new();
let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let p2 = polls.clone();
let mut q = 0u8;
let t = Script::new(move |_, _| {
p2.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if progressing {
q = q.wrapping_add(1) % 0x30;
}
@@ -586,18 +571,14 @@ fn wait_ready_stop_always_interrupts() {
let mut d = Drive::from_transport_with(Box::new(t), &h);
let h2 = h.clone();
let stopper = std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(30);
while polls.load(std::sync::atomic::Ordering::SeqCst) < 5 {
assert!(Instant::now() < deadline, "the wait never polled");
std::thread::sleep(MS(1));
}
std::thread::sleep(window * 3 / 4);
h2.cancel();
Instant::now()
});
let r = d.wait_ready_with(timing(window, MS(5_000)));
let at = stopper.join().unwrap();
assert!(matches!(r, Err(Error::Halted)), "{r:?}");
assert!(at.elapsed() < Duration::from_secs(5));
assert!(at.elapsed() < Duration::from_secs(1));
}
}

Some files were not shown because too many files have changed in this diff Show More