Compare commits

...
36 Commits
Author SHA1 Message Date
Matthew Jackson 6980b04e63 v1.8.1: bump version 2026-10-07 03:56:38 +00:00
Matthew Jackson 777cd6275c docs: 1.8.1 release notes 2026-10-06 20:19:16 -07:00
Matthew Jackson 4d8cba6ff8 feat: report each drive's OS display name
DriveInfo gains display_name: the drive letter on Windows, /dev/srN on
Linux and diskN on macOS while a disc is mounted, or the path when the OS
has no name. path stays the selector to open. Windows lists lettered drives
in letter order, then unlettered drives by CdRomN.
2026-10-06 18:21:41 -07:00
Matthew Jackson 76ddaf5538 style: no new #[allow] for the Windows enumeration helpers
Name STORAGE_DEVICE_NUMBER's fields in snake_case (repr(C) layout is
unchanged) and compile the 36-byte INQUIRY retry only on Windows, which keeps
lint_stop's #[allow] baseline.
2026-10-06 16:53:57 -07:00
Matthew Jackson 0672db23e7 test: allow the allow-path modules' #[path] test side files in the key guard
0b146ef7 moved decrypt.rs and sector/decrypting.rs tests into
decrypt_tests.rs, decrypt_spec_guards_tests.rs and sector/decrypting_tests.rs;
they are the same modules' tests, so they share the allow-path.
2026-10-06 16:28:12 -07:00
Matthew Jackson 6621987c8a style: keep the Linux enumeration comment within the 3-line limit 2026-10-06 16:26:21 -07:00
Matthew Jackson 76507e677d fix: enumerate optical drives on Linux and macOS without limits
Linux also lists optical sr block devices that have no sg node (sg module
not loaded), and without sysfs reads /dev for sg nodes instead of probing
sg0..15. macOS grows its listing buffer instead of stopping at 8 drives.
Changelog covers the Windows enumeration fix as well.
2026-10-06 15:49:35 -07:00
Matthew Jackson c3ceb62f5f feat(macos): log native transport open stages for diagnostics
Report the selected IOKit service, plug-in metadata, each interface
initialization stage, exclusive-access attempts and cleanup results through
tracing, so a failed drive open shows which step failed.
2026-10-06 15:21:02 -07:00
Matthew Jackson d1e0a8a98c fix(windows): list every optical drive without guessing device numbers
Enumeration probed only \\.\CdRom0..15 and fell back to drive letters only
when that found nothing, so drives numbered above 15 (common after USB
replugging) were silently missing. List every CdRomN from QueryDosDeviceW,
add optical drive letters (GetDriveTypeW) not already covered by their
device number, retry INQUIRY with the standard 36 bytes for strict USB
bridges, and log each skipped device with its reason. Also syncs the stale
Cargo.lock with the 1.8.0 manifest.
2026-10-06 15:15:10 -07:00
Matthew Jackson 0b146ef784 Move tests to side files 2026-10-05 20:22:40 -07:00
Matthew Jackson a69a8d9bbd fix(macos): keep optical drive identity stable during exclusive access 2026-10-05 13:04:40 -07:00
Matthew Jackson 92847607b9 v1.8.0: bump version 2026-10-05 02:17:00 +00:00
Matthew Jackson 0a96b1ca81 changelog: 1.8.0 release notes 2026-10-04 18:28:23 -07:00
Matthew Jackson 561a757b60 dvd, uhd, aacs, hddvd: navigation-pack playback, HDR metadata, per-pack HD DVD AACS
DVD titles play the way their navigation packs say: interleaved cells
as their own units, chapter names from the VMG, region mask, IFO stream
labels, a whole-title forced-subtitle probe, DvdPs-only stages. UHD
keeps mastering metadata and hdr_plus_flag and fixes dvcC. HD DVD
decrypts AACS per pack by each EVOBU's CPI and joins PEVOB features;
Class II MKBs are reported.
2026-10-04 12:25:45 -07:00
Matthew Jackson f745a6f2e6 video: drop pictures a decoder cannot reconstruct at a start, join or resync
MPEG-2, H.264, HEVC and VC-1 drop pictures whose references are missing
at a title start, clip join, gap resync or a trimmed clip mark; the
M2TS sink rides the clip-join timeline. An image extent's sub-unit tail
is judged by the decrypt stage instead of refused (E9030).
Wall-clock-sensitive tests hardened; monthly grouped dependabot.
2026-10-02 20:24:26 -07:00
Matthew Jackson f855d604ae audit: second-round library audit fixes
Fixes and tests from the second audit across bdnav, labels, ifo, codec
parameter sets, mkv/mp4/stdio, css, aacs, keysource, keys, decrypt and
sector code. An HD DVD copy already in the clear needs no key, with one
encryption decision for every AACS format; image and folder titles are
named by the meta title, else the playlist, as in 1.7.7.
2026-10-01 22:46:14 -07:00
Matthew Jackson 46ffac8690 pipeline: one run context, KeyRing, Source, Read stage, block and tree sinks
One run context (halt, events, loss counters) serves every stage; keys
come from KeyEvidence into a KeyRing. open_source opens every input,
disc:// included, replacing MuxSource; PesSource/PesSink replace
pes::Stream; one Read stage replaces DiscStream and the byte
prefetcher. iso://, null:// and dir:// are sinks. Dead error variants
(E9009, E9063, E9082) and scaffolding removed.
2026-10-01 08:34:14 -07:00
Matthew Jackson 81ecc8acce decrypt: one content-detected decryption stage; keep going past damage
Every input decrypts through one stage that detects its content, with
strict stale-CPI handling and partial units only at EOF. Damage no
longer ends a rip where usable content remains (E7031, UDF FID,
MPLS/CLPI truncation, whole-disc E7032). M2TS output declares tracks in
an HDMV PMT and paces PCR; network:// allows LAN and loopback and
is_blocked_ip is public; parity goldens pin today's outputs.
2026-09-30 20:11:59 -07:00
Matthew Jackson cf2171e11a mux: MKV read-back, FMKV header metadata, live-stream stop handling
MKV read-back carries display shape, colour, frame rate, content
encodings and BCP47 language, and drops malformed blocks with a count.
The FMKV header carries display aspect, CICP, labels, chapters and
format. Live reads refuse a zero read batch (E9085) and treat a stopped
read as a stop. freemkv-unlock becomes a version dep with a git-tag
override; dependency bumps (#27, #28); LPCM keeps its source bit depth; 1.8.0
changelog.
2026-09-30 18:45:38 -07:00
Matthew Jackson 81b7e3ab4e ts: robust transport-stream demux
Skips duplicate packets, gathers PES headers that span packets, resyncs
after byte slips and across zero-filled gaps, prefers a current
CRC-valid PAT/PMT, and drops the open PES on transport_error_indicator.
M2TS output remaps PIDs into the BD ranges.
2026-09-30 15:52:06 -07:00
Matthew Jackson 5b9dd0fbec error, mux: coded remux errors; MKV writer hardening; damage rules for key checks
E9077-E9084 for engine remux and preflight refusals; MuxOptions loses
its send deadline. The MKV writer drops unwrap/expect and overflow
paths, tags SDR BT.2020 correctly, leaves out streams with no Matroska
CodecID and derives the Dolby Vision level. On-arrival damage with no
vouching partner is blanked, and a garbled head is damage, not a stop.
2026-09-30 15:50:46 -07:00
Matthew Jackson 4031513574 audit: module-by-module code audit fixes and test coverage
Lands the code-audit branches for aacs, css, keys, labels, udf, ifo and
dvd, bdnav/mpls/clpi, disc/bluray, extract, io, scsi, the mkv, mp4,
network and mpg muxers, the audio and video codecs, and CI. Fixes
include MKB and FMTS validation, UDF bounds, IFO fallback to the BUP,
DTS Express framing, MPEG-2 field pairing and mpg re-basing; the rest
pins existing behaviour with tests.
2026-09-29 20:45:05 -07:00
Matthew Jackson 1d78420366 keys: read damage is never a key failure; legacy key API removed
Damaged AACS units are holed, blanked and counted on every path instead
of stopping with E7013; failed batch partners fall back to side reads
and a wrong FMTS key is judged per key. ResolvedKeySet is the only AACS
decrypt door; a raw whole-disc reader never decrypts and refuses titles
with no stream folder (E6003) or a piece no held key opens (E7032).
2026-09-29 17:20:43 -07:00
Matthew Jackson 25fd393c04 session, io: Stop through held sessions, kept partials, <final>.lock
Key-source contexts carry the op token and progress; DiscSession gains
open_with, scan_with and finish on the held handle. A consumer that
closes after a cancel keeps *.partial, and an artifact lock sidecar
guards each final output. Stop rules are enforced as lint tests.
2026-09-28 09:11:34 -07:00
Matthew Jackson fdab162bc4 mux: mpg:// program-stream output and source
A container-neutral fit plan, then mpg:// as an H.222.0 program-stream
sink on the PES IR and as a source through the DVD sector pipeline. PES
and AU placement, start-up hold caps and 20/24-bit LPCM timing follow
the spec; a clear MPEG-1/2 PS is never read as scrambled and CSS is
judged per pack. E9074 for an output with no video track.
2026-09-28 07:25:53 -07:00
Matthew Jackson 9000c83c1b keys: one resolved key set per rip, proven on first read
ResolvedKeySet resolves keys up front, forensic keys included, and every
read surface decrypts through it. A keyless session never muxes
ciphertext; a single declared key keys only the pieces it opens; held
keys are tried before side reads. Resolve reports whether the disc's
VID would help, returns its trace on refusal, and never codes an
unreachable key service as Missing.
2026-09-28 07:18:11 -07:00
Matthew Jackson 1baf9ba061 halt: Stop-aware waits across channels, drive, io, flush and the macOS shim
Waits are halt-aware on a stall timer; every CDB carries the op token;
prefetchers and pipeline joins honour Stop; flush is durable with
measured progress; the macOS shim open is cancellable. Also: CPI is
cleared on every decrypted unit, FMTS phase verification, the HD DVD
declared CPS count, AES work hoisted out of AACS hot paths, and the
last production unsafe removed.
2026-09-28 03:57:56 -07:00
Matthew Jackson 3617ccf733 disc, audio: record unlocatable bus-encrypted files; resync without losing good frames
A bus-encrypted stream file whose File Entry cannot be located is
re-read, then recorded rather than failing the scan; MKV staging ranges
and E6022 for a partial image. Audio framing chains headers while
resyncing, reports every lost slot, keeps good frames beside corrupt
ones and places locks by PTS, with ADTS and MPEG property fuzzing.
2026-09-28 01:11:12 -07:00
Matthew Jackson cbd3db9d0e spec, test_util: specification quote registry, spec guards, test-util fixtures
libfreemkv::spec holds the verbatim specification quotes the code
cites, with guards tying each governed site to its quote. A test-util
feature exposes shared fixtures to downstream tests. M2TS output
derives DTS for reordered video and rounds to whole 90 kHz ticks.
2026-09-28 00:50:09 -07:00
Matthew Jackson 3207856c17 dvd, mux: route DVD audio and subtitles by the PGC tables; MP2 multichannel
DVD subtitles and audio route by PGC SPST_CTL and AST_CTL instead of
stream ordinal, and MPEG audio is fed from its PES id. The DVD MPEG-2
extension stream rides the IR with its channel count verified by CRC.
Audio framing stops false syncs poisoning a track; PGS and DVD subtitle
parser fixes; E9073 and E7034 declared.
2026-09-28 00:31:27 -07:00
Matthew Jackson 18982a7c0e disc, aacs: key files before the handshake; one whole-disc reader
Scan reads UDF and the AACS files before the drive handshake and records
a missing DVD /AACS key file instead of failing. Decryption needs an
explicit AACS unit base and refuses off-grid units; an all-broken
host-cert list is its own error (E7033). Every whole-disc and image to
ISO copy goes through one shared reader that refuses an unkeyable file
up front (E7032). HD DVD XPL tick and timecode fixes; CI action bumps.
2026-09-27 20:17:01 -07:00
Matthew Jackson 503a80884e mux: sink and reader audit fixes; DTS in MP4; halt-aware network receive
Fixes from the mkv, mp4, network and ts sink audit; track timing and
DiscardPadding ride the FMKV wire. MP4 writes DTS per ETSI TS 102 114
(ddts rates and timescales). The MKV PCM depth probe is bounded and
evidence-based. Network receives use keepalive and wait on readiness,
so a Stop or a dead peer ends them.
2026-09-27 12:40:50 -07:00
Matthew Jackson d6396005b8 scsi, lpcm: MMC-6 disc presence and tray handling; LPCM decoded to plain PCM
Disc presence is tri-state per MMC-6, an empty drive fails fast, a
cleaning cartridge reads as no disc, and the tray is unlocked only when
we locked it. BD and DVD LPCM decode to plain PCM, with whole-frame DVD
blocks and the BD layout carried through M2TS.
2026-09-27 11:54:34 -07:00
Matthew Jackson 8c707efbe1 nav, labels: navigation command semantics, BD-J feature detection, CLPI audit
DVD and HDMV navigation commands follow the reference semantics
(SetGPRMMD, set-links, saturating ADD/MUL, unmasked shifts). Label
detection bounds the BD-J menu walk, opens large jars in place and
tightens Paramount, Deluxe and BD-J feature evidence. The CLPI audit
keys streams by clip and PID; channel layouts keep the LFE split and
TrueHD counts for unnamed layouts.
2026-09-27 11:45:48 -07:00
Matthew Jackson 4c5098ce75 keys, disc, io: bus-encryption gates, halt-aware key paths, writeback error latching
Read-time key fetches are validated and source, halt and drive faults
surface. Every BDMV/STREAM file is bus-decrypted per the BEE flag and
unit copy-permission bits; a missing or unparseable cert fails safe.
Extract treats a drive Stop as a halt, not a hole. io latches writeback
errors, fixes the read eviction window and never waits on writeback
during seeks.
2026-09-26 19:53:08 -07:00
Matthew Jackson aa85906a87 udf, mux: post-1.7.7 fixes for UDF chains, AAC headers and codec framing
UDF validates allocation-descriptor chains, maps fragmented metadata
files and tolerates broken subdirectory chains. The muxer holds headers
until every AAC track has its AudioSpecificConfig and backpatches a late
CodecPrivate. HEVC PPS tables are bounded, DTS EXSS aligned and MPEG
free-format frames capped. A no-media drive is released before the next
candidate opens; CI builds freemkv's app as a consumer.
2026-09-26 19:29:26 -07:00
504 changed files with 189465 additions and 117714 deletions
+31 -61
View File
@@ -1,63 +1,33 @@
# 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 release.sh 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: weekly
open-pull-requests-limit: 5
# One PR per week for the routine bumps instead of one per crate. Eight
# repos times a handful of crates is a volume nobody reads, and an
# unread PR queue is indistinguishable from no updates at all.
groups:
minor-and-patch:
update-types:
- minor
- patch
ignore:
# The freemkv crates depend on each other by GIT TAG, re-pinned by
# release.sh as part of the release commit. Dependabot cannot see that
# cascade, so a PR bumping one of these would fight the release process
# and could pin a version whose tag does not exist yet.
- dependency-name: freemkv-unlock
- dependency-name: libfreemkv
- dependency-name: freemkv-keysources
- dependency-name: freemkv-i18n
- dependency-name: freemkv-engine
# The workflows are now real infrastructure -- the release cascade, the
# cross-platform hash matrix, the disc gate -- so their actions need the same
# attention as the crates.
- package-ecosystem: github-actions
directory: /
target-branch: dev
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
actions:
update-types:
- minor
- patch
ignore:
# NOT a dependency: `dtolnay/rust-toolchain` is versioned by the RUST
# release it installs, and the tag we pin is the toolchain CI is pinned
# to on purpose -- precommit.sh runs the same one locally so a lint that
# passes on a developer's newer default cannot pass CI by accident.
#
# Dependabot reads those tags as semver and proposed 1.97.0 -> 1.100.0,
# a Rust version that does not exist. Every such PR 404s on toolchain
# download across all eight repos, and they regenerate weekly -- eight
# permanently-red PRs that promote.yml then has to special-case when it
# decides whether dev is green.
#
# Bumping the toolchain is a deliberate, all-eight-repos change, made by
# hand together with precommit.sh. There is nothing here for a bot.
- dependency-name: dtolnay/rust-toolchain
- 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*
+66 -35
View File
@@ -19,10 +19,11 @@ jobs:
# cannot drift. clippy and `consumers` deliberately run on `stable`.
resolve-msrv:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
v: ${{ steps.msrv.outputs.v }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- id: msrv
shell: bash
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
@@ -32,8 +33,9 @@ jobs:
# whose entry isn't written yet is not blocked.
changelog:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Require a CHANGELOG entry for the crate version on main
shell: bash
run: |
@@ -55,21 +57,25 @@ jobs:
lint:
needs: resolve-msrv
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
path: libfreemkv
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: freemkv-unlock
patch: freemkv-unlock
self: libfreemkv
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
components: clippy
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
components: rustfmt
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: libfreemkv
- run: cargo fmt --check
@@ -80,23 +86,29 @@ jobs:
test:
needs: resolve-msrv
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
path: libfreemkv
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: freemkv-unlock
patch: freemkv-unlock
self: libfreemkv
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: libfreemkv
- run: cargo test --tests
working-directory: libfreemkv
# `--tests` skips doctests; the keys compile_fail doctests enforce spec LK19.
- run: cargo test --doc
working-directory: libfreemkv
# The memory orderings in `scsi::fd_handoff` compile to the same instructions
# The memory orderings in `scsi::fd_handoff` (and `halt`) compile to the same instructions
# on x86_64, so the WRONG ones pass every run; loom enumerates the executions
# the C++20 model permits and is the only regression coverage that code has.
# Its own job because RUSTFLAGS is part of the rust-cache key. Takes the sibling
@@ -105,24 +117,30 @@ jobs:
loom:
needs: resolve-msrv
runs-on: ubuntu-latest
timeout-minutes: 30
env:
RUSTFLAGS: --cfg loom
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
path: libfreemkv
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: freemkv-unlock
patch: freemkv-unlock
self: libfreemkv
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
components: clippy
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: libfreemkv
- run: cargo test --lib --no-default-features --features scsi fd_handoff
working-directory: libfreemkv
# LT3: `Halt`'s cancel Release / observe Acquire pair (stop design §2.1, SS-23).
- run: cargo test --lib --no-default-features --features scsi loom_halt
working-directory: libfreemkv
# `lint` runs clippy without --cfg loom, so this is the only place the
# model code is linted at all.
- run: cargo clippy --lib --no-default-features --features scsi --profile test -- -D warnings
@@ -130,18 +148,23 @@ jobs:
deny:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
path: libfreemkv
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: freemkv-unlock
patch: freemkv-unlock
self: libfreemkv
- uses: dtolnay/rust-toolchain@stable
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check advisories bans licenses sources
manifest-path: libfreemkv/Cargo.toml
toolchain: stable
- uses: taiki-e/install-action@cargo-deny
# Run from the repo dir so the generated .cargo/config.toml path patches apply.
- run: cargo deny --all-features check advisories bans licenses sources
working-directory: libfreemkv
# dev is the FAST lane: the platform jobs run on qa/main only (qa.yml also
# covers macOS/Windows). `if` SKIPS (does not queue) so a never-scheduled job
@@ -150,17 +173,20 @@ jobs:
needs: resolve-msrv
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: macos-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
path: libfreemkv
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: freemkv-unlock
patch: freemkv-unlock
self: libfreemkv
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: libfreemkv
- run: cargo check
@@ -170,17 +196,20 @@ jobs:
needs: resolve-msrv
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: windows-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
path: libfreemkv
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: freemkv-unlock
patch: freemkv-unlock
self: libfreemkv
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: libfreemkv
# Build the tests: forces full codegen of the Windows-only SPTI transport
@@ -188,23 +217,24 @@ jobs:
- run: cargo build --tests
working-directory: libfreemkv
# Did this change break anything downstream? libfreemkv sits below all five
# Did this change break anything downstream? libfreemkv sits below all four
# dependents; a break here is worth strictly more than anywhere else. Runs
# their suites (not just check) — a behavioural change keeps signatures but
# fails a dependent's tests, which is the shape worth catching. This caught an
# engine fixture break the moment it landed, so it stays on the dev fast lane.
consumers:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: { path: libfreemkv }
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: freemkv-unlock freemkv-keysources freemkv-engine freemkv-i18n freemkv autorip bdemu
checkout: freemkv-unlock freemkv-keysources freemkv-engine freemkv-i18n freemkv bdemu
- name: Point every dependent at THIS libfreemkv commit
shell: bash
run: |
for c in freemkv-keysources freemkv-engine freemkv autorip bdemu; do
for c in freemkv-keysources freemkv-engine freemkv bdemu; do
mkdir -p "$c/.cargo"
cat > "$c/.cargo/config.toml" <<'EOF'
[patch.crates-io]
@@ -212,14 +242,11 @@ jobs:
freemkv-keysources = { path = "../freemkv-keysources" }
freemkv-engine = { path = "../freemkv-engine" }
freemkv-i18n = { path = "../freemkv-i18n" }
freemkv-unlock = { path = "../freemkv-unlock" }
EOF
done
# Since freemkv 1.7.6 the `freemkv` consumer's Linux target compiles a
# GTK4 + libadwaita desktop shell, so `cargo test` on it needs the
# pkg-config dev headers the `gtk4-sys`/`glib-sys` build.rs scripts
# probe. Kept local (not in the shared reusable) — only freemkv links
# against GTK; adding the deps to every consumer would tax the six
# that don't.
# freemkv's app build (`--features gui`) compiles a GTK4 + libadwaita
# shell, so testing it needs these pkg-config dev headers.
- name: Install GTK4 + libadwaita development headers (for freemkv)
run: |
sudo apt-get update
@@ -228,13 +255,14 @@ jobs:
# satisfies the highest dependent floor (the library's own lower MSRV, which
# the `test` job verifies, cannot compile them).
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
toolchain: stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: |
freemkv-keysources
freemkv-engine
freemkv
autorip
bdemu
- run: cargo test --tests
working-directory: freemkv-keysources
@@ -242,8 +270,8 @@ jobs:
working-directory: freemkv-engine
- run: cargo test --tests
working-directory: freemkv
- run: cargo test --tests
working-directory: autorip
- run: cargo test --tests --features gui
working-directory: freemkv
- run: cargo test --tests
working-directory: bdemu
@@ -251,24 +279,27 @@ jobs:
coverage:
needs: resolve-msrv
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
path: libfreemkv
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: freemkv-unlock
patch: freemkv-unlock
self: libfreemkv
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
components: llvm-tools-preview
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: libfreemkv
- uses: taiki-e/install-action@cargo-llvm-cov
- uses: taiki-e/install-action@72f835688cb6c15903b796f704b01e652a2b8835 # cargo-llvm-cov
- run: cargo llvm-cov --workspace --ignore-run-fail --lcov --output-path lcov.info
working-directory: libfreemkv
- uses: codecov/codecov-action@v7
- uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: libfreemkv/lcov.info
+4 -3
View File
@@ -1,9 +1,9 @@
name: doc-links
# Thin caller → the canonical reusable doc-links gate in freemkv/.github.
# libfreemkv path-deps freemkv-unlock, so the reusable checks it out as a sibling
# (no [patch] needed — the layout resolves the path dep) and resolves the
# toolchain from rust-version.
# libfreemkv deps freemkv-unlock by version (git-tag override), so the reusable
# checks it out as a sibling and path-patches it, and resolves the toolchain
# from rust-version.
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -15,3 +15,4 @@ jobs:
with:
self: libfreemkv
checkout: freemkv-unlock
patch: freemkv-unlock
+5 -3
View File
@@ -16,12 +16,14 @@ jobs:
env:
FREEMKV_FFMPEG_ARTIFACT_DIR: ${{ github.workspace }}/ffmpeg-artifacts
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
path: libfreemkv
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: freemkv-unlock
patch: freemkv-unlock
self: libfreemkv
- name: Read the crate's MSRV
id: msrv
working-directory: libfreemkv
@@ -29,7 +31,7 @@ jobs:
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ steps.msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: libfreemkv
- name: Install software encoders and decoders
@@ -48,7 +50,7 @@ jobs:
cargo test --release --lib ffmpeg_ -- --ignored --nocapture 2>&1 | tee "$FREEMKV_FFMPEG_ARTIFACT_DIR/tests.log"
- name: Upload fixtures, probes, hashes and logs
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ffmpeg-validation-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ env.FREEMKV_FFMPEG_ARTIFACT_DIR }}
+35 -14
View File
@@ -33,10 +33,11 @@ jobs:
# clippy (cross-lint) runs on stable instead.
resolve-msrv:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
v: ${{ steps.msrv.outputs.v }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- id: msrv
shell: bash
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
@@ -56,11 +57,14 @@ jobs:
#
# release.yml excludes v*-rc* so a candidate never publishes a release.
rc-tag:
# A manual dispatch from another branch must not stamp a candidate.
if: github.ref_name == 'qa'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Stamp the next rc
@@ -71,10 +75,16 @@ jobs:
[ -n "$v" ] || { echo "no version in Cargo.toml" >&2; exit 1; }
# Numeric sort on the rc ordinal: -rc10 must beat -rc9, and a plain
# lexical sort gets that backwards from the tenth candidate on.
n=$(git tag -l "v$v-rc*" | sed "s|^v$v-rc||" | sort -n | tail -1)
tag="v$v-rc$(( ${n:-0} + 1 ))"
git tag "$tag"
git push origin "$tag"
# Retry on a lost race for the same ordinal: refetch tags, recompute.
for attempt in 1 2 3 4 5; do
git fetch --tags --force origin
n=$(git tag -l "v$v-rc*" | sed "s|^v$v-rc||" | sort -n | tail -1)
tag="v$v-rc$(( ${n:-0} + 1 ))"
git tag "$tag"
git push origin "$tag" && break
git tag -d "$tag"
[ "$attempt" -lt 5 ] || { echo "could not push an rc tag" >&2; exit 1; }
done
echo "### Candidate \`$tag\`" >> "$GITHUB_STEP_SUMMARY"
# The debug suite runs on every dev push. Release is a DIFFERENT build:
@@ -88,21 +98,26 @@ jobs:
matrix:
os: ['ubuntu-latest', 'macos-latest']
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
path: libfreemkv
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: freemkv-unlock
patch: freemkv-unlock
self: libfreemkv
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: libfreemkv
- run: cargo test --release --tests
working-directory: libfreemkv
- run: cargo test --release --doc
working-directory: libfreemkv
# Independent decoder/remux verification uses generated media and belongs
# on qa, alongside the release suite. Also runnable manually on a branch.
@@ -116,23 +131,26 @@ jobs:
# target is how a lint that CI rejects reaches a push.
cross-lint:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
path: libfreemkv
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: freemkv-unlock
patch: freemkv-unlock
self: libfreemkv
# clippy runs on stable (matches ci.yml's lint job); build/test stay on
# the MSRV pin. See ci.yml for why the lint toolchain need not be the MSRV.
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
components: clippy
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: libfreemkv
- run: rustup target add x86_64-unknown-linux-gnu
- run: cargo clippy --all-targets --target x86_64-unknown-linux-gnu -- -D warnings
- run: cargo clippy --all-targets -- -D warnings
working-directory: libfreemkv
# Windows compiles the tests but does not run them, matching the policy
@@ -142,17 +160,20 @@ jobs:
windows-build:
needs: resolve-msrv
runs-on: windows-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
path: libfreemkv
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: freemkv-unlock
patch: freemkv-unlock
self: libfreemkv
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: libfreemkv
- run: cargo build --release --tests
+15 -10
View File
@@ -11,13 +11,14 @@ on:
- '!v*-rc*'
permissions:
contents: write
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Verify Cargo.toml version matches tag
run: |
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
@@ -28,23 +29,24 @@ jobs:
echo "Version match: $CARGO_VER"
# Tests run as a PARALLEL TRIPWIRE: they fail the run if they fail, but the
# publish/release jobs do NOT `needs:` this job. The tag decision was already
# release job does NOT `needs:` this job. The tag decision was already
# gated by CI (ci.yml on dev, then a green qa.yml on the same commit). Binary consumers
# (freemkv/autorip/bdemu) git-tag-pin libfreemkv and therefore start building
# the instant this tag exists — so this test job and the crates.io publish
# below must NOT sit on their critical path.
# the instant this tag exists — so this test job must NOT sit on their
# critical path.
test:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- id: msrv
shell: bash
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ steps.msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
# Cargo.lock is now committed (issue #15), but --locked is still omitted:
# the sibling path-patch layout resolves the freemkv crates as path
# packages, which --locked can't satisfy.
@@ -58,12 +60,15 @@ jobs:
release:
# Only needs `verify`; the GitHub Release can be cut as soon as the version
# check passes, in parallel with test + publish.
# check passes, in parallel with test.
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3
with:
generate_release_notes: true
+3 -3
View File
@@ -14,14 +14,14 @@ jobs:
security-events: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: ossf/scorecard-action@v2.4.4
- uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- uses: github/codeql-action/upload-sarif@v3
- uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4
with:
sarif_file: results.sarif
+132
View File
@@ -1,5 +1,137 @@
# 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
### Breaking
- **AACS keys:** `keys::KeyRing` is the only door to AACS keys. Removed: crate-root `AacsKeyMap`, `DecryptKeys`, `resolve_and_apply`, `KeyFetch`; `resolve_and_apply_traced`, `keysource::{fetch_unit_keys, fetch_fmts_indexes, key_fetch}`, `sector::{KeyFetch, KeyFetchFn}`, `session::{resolve_keys_for, ResolvedKeys}`, `DiscSession::{resolve_keys, key_fetch}`, `Disc::{decrypt_keys, decrypt_keys_for_title, decrypt_with, ensure_decryptable, ensure_decryptable_keys, ensure_title_decryptable}`, `InputOptions::{unit_keys, key_fetch}`, `DiscStream::with_key_map`. `DecryptKeys` is `#[non_exhaustive]`; `AacsKeyMap::from_ranges*`, `DecryptingSectorSource::{with_key_map, set_key_map}` and `aacs::content::{decrypt_unit, decrypt_bus}` are no longer public. Acquire once with `DiscSession::resolve_key_set` / `KeyRing::acquire`.
- **Mux entry point:** `mux_stream` and `MuxInput` are replaced by `mux_with_keys(Source, Option<&KeyRing>, dest_url, opts, ctx)`; a `Source` carries no key material. `InputOptions::keys: Option<KeyRing>` replaces `unit_keys` / `key_fetch`; an AACS image opened without a set is refused before any output. Crate-root `build_iso_pipeline` and `resolve_mux_key_map` are removed.
- **One run context for every stage:** `Ctx { halt, events, stats, diag }` is passed to `input(url, opts, ctx)`, `mux_with_keys(.., ctx)`, `Disc::extract_tree(reader, dest, opts, ctx)`, `write_image(reader, dest, sectors, ctx)` and `PrefetchedSectorSource::new(.., ctx)`. One `Events` trait over one `Event` enum (`BytesRead`, `SectorSkipped`, `UnitBlanked`, `BatchSizeChanged`, `Pass`, `BytesWritten`, `BytesDurable`, `OutputOpened`) replaces `MuxEvents`, `event::{Event, EventKind}`, `progress::Progress`, `EventFn` / `PrefetchedSectorSource::new_with_events`, `DiscStream::{on_event, with_halt}` and `Drive::on_event`; `ExtractOptions::{halt, progress}` are removed (Stop is `ctx.halt`; extract progress is `PassKind::Extract`). `Stats` / `LossReport` count read skips, lost bytes, blanked units and resync drops across the run. `halt::Progress` is renamed `halt::Liveness`. `FREEMKV_SKIP_PARSE` / `FREEMKV_PROFILE` take effect only through `Diag::from_env()` on the caller's `Ctx`.
- **Key acquisition is not bound to a scanned disc:** `ResolvedKeySet` is renamed `KeyRing` and `ResolveKeysOptions` `AcquireOptions` (its `halt` is the run's `Ctx`; `resolve_with_progress` is `AcquireOptions::liveness`). `KeyRing::acquire(&KeyEvidence, &mut Sampler, sources, opts, ctx)` takes what a source can tell (`KeyEvidence`: `MediaId`, AACS structures, `Piece`s in scope, main title, `Detector`), and `KeyRing::acquire_for_disc(disc, reader, scope, sources, opts, ctx)` builds that evidence from a scanned disc (`KeyEvidence::from_disc`). `KeyRing::is_for` takes a `&MediaId` (`Disc::media_id()`), which includes the Volume ID fingerprint. Evidence with no disc hash derives it from the title-key file, so a keydb lookup no longer misses silently. The legacy `aacs::resolve` / `aacs::provider` ladder and `disc::Key` are removed; `KeyOrigin` lives in `aacs::trace` (still re-exported at the crate root).
- **One way to open any input:** `open_source(url, ScanOptions, ctx)` opens every input scheme, `disc://` included (the drive is brought up and scanned; `input("disc://…")` now opens it instead of returning `DiscUrlNotDirect`), and `Source::layout()` is the probe's scanned `Disc` for `disc://`, `iso://` and `dir://`. `MuxSource` is removed: `Source::from_session(&mut DiscSession)`, `Source::from_image(path, ScannedTitle)` (a title scanned elsewhere, never rescanned) and `Source::from_reader(reader, ScannedTitle)` replace `Session`, `Iso` and `Live`, and `mux_url(url, keys, dest, opts, ctx)` replaces `Url`. `MuxOptions` gains `title_index` (it replaces `MuxSource::Session::title_index` and `InputOptions::title_index` for a mux), and `MuxOptions::selection` applies to every source. `scan_iso` / `scan_dir` share the one image probe.
- **Sources and sinks are separate traits:** `pes::Stream` (and the `PesStream` re-export) is replaced by `PesSource` (`read`, `info`, `track_timing`, `codec_private`, `headers_ready`, `config_changes`, `errors`, `lost_bytes`) and `PesSink` (`write`, `finish`, `finish_incomplete`, `info`, `set_track_timing`, `set_codec_private`, `undelivered_streams`). `input()` returns `Box<dyn PesSource>` and `output()` `Box<dyn PesSink>`; a wrong-direction call no longer compiles instead of returning `StreamReadOnly` / `StreamWriteOnly`. `MkvStream`, `StdioStream` and `NetworkStream` implement both (and an inherent `info()`); `CountingStream` wraps a `PesSink`. `SinkCaps::of(&StreamUrl)` (`needs_frames`, `carries_mp2_extensions`) is the one table the mux consults for metadata sinks and MPEG-2 extension tracks.
- **One read loop and one demux for every sector source:** `DiscStream` is removed. A live drive is read by the same prefetch producer and demux as an image, under the drive's Read policy (`sector::read_stage::ReadPolicy::Live`: adaptive batches that shrink on a failing zone and regrow after a clean streak, one ECC recovery read for a unit that still fails, then a zero-filled, counted unit under `skip_errors` or a `DiscRead`); an image keeps fixed batches that stop on the first error (`ReadPolicy::Image`). Live reads now overlap demux and parse on their own thread. Read skips are reported in `MuxOutcome::errors` / `lost_bytes` as before.
- **Stop is the only send bound:** `MuxOptions::send_deadline`, `NO_SEND_DEADLINE` and `halt::POLL_INTERVAL` are removed (use `halt::WAIT_SLICE`); a slow sink blocks the mux until it drains or Stop.
- `Drive::clear_halt`, `Drive::scsi_mut` and `Drive::is_unlocked` and the path-based `platform::fs_type::detect` are removed; use `Drive::attach` / `detach` / `halt` / `token` and `DiscSession::source_mut`.
- **Block sinks:** `io::BlockSink` (`write_at(lba, bytes)`, `finish(Finish::{Complete, Incomplete})`) with `IsoSink` (a sector-exact image) and `NullBlockSink`, opened from a URL by `io::open_block_sink("iso://…" | "null://")`; `write_image` writes through `IsoSink`. `io::null_device()` / `is_null_device()` name the platform's null device (`NUL` on Windows), and `Disc::mapfile_for` keys its private mapfile on it, so a whole-disc copy to `null://` works on Windows.
- **Tree sink:** `io::TreeSink` is the `dir://` output, opened by `io::open_tree_sink("dir://…", force)`: host-safe names, collision refusal, `.partial` then rename, and the AACS/CERTIFICATE directories left out. `Disc::extract_into(reader, &mut TreeSink, keys, ctx)` runs the extraction into it; `Disc::extract_tree` is that chain over a sink it creates.
- **One decryption-stage constructor:** `DecryptingSectorSource::new(inner, keying)` takes `impl Into<sector::Keying>` — `DecryptKeys` (as before), a key ring's view, or the content-detected stage — replacing the crate-internal `detecting` constructor and `with_key_map` / `with_arrival` builders.
- **Removed `io::byte_prefetcher`:** `m2ts://` reads through the one Read stage and `PrefetchedSectorSource` (the same reads, in the same ~1 MiB batches, as before); `BytePrefetcher` and its `PrefetchShell` are gone.
- Error variants no path returns any more are removed with their codes: `DiscUrlNotDirect` (E9009; `disc://` opens through `input()`), `DirImageEncrypted` (E9063; an encrypted folder is keyed like its image) and `MultipassRequiresRaw` (E9082; a multipass recovery may decrypt).
- Unused scaffolding is removed: the `io::sink` module (`SequentialSink`, `RandomAccessSink`, `LocalFileSink`, `SocketSink`, `UdpSocketSink`; `WritebackFile` no longer implements them) and the unfinished `fmp4`, `m2ts_mux` and `HevcMux` muxers.
- `DvdAudioAttr::sub_stream_id` (positional id) is removed.
- `M2tsMeta` gains `timings`, `chapters`, `content_format` and `frame_padding` (all `#[serde(default)]`); a struct-literal build of it must name them.
- `css::CrackOutcome` is `#[non_exhaustive]` and gains `Halted` and `Unreadable(Error)`; it no longer derives `Clone`. `css::crack_key` is deprecated for `crack_key_outcome`.
- `AudioChannels` gains 3.0, 3.1, 4.1, 6.0 and 7.0; `SkipReason` is `#[non_exhaustive]`.
- `scsi::linux::SgIoTransport` `fd` / `fd_recovery` are private; `SptiTransport::reset` (Windows) is removed.
- `freemkv-unlock` is a version dependency resolved by a `[patch.crates-io]` git-tag override; consumers must carry the same patch.
### 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.
- **One decryption stage for every input:** `mpg://`, `m2ts://`, `mkv://`, `mp4://`, `network://` and `stdio://` pass the same content-detected stage as disc/`iso://`/`dir://`. CSS packs are cracked and descrambled, AACS units are decrypted with `InputOptions.keys`, and clear containers pass untouched. An encrypted loose `.m2ts` is decrypted (keys proven on arrival) or refused with E7022 before any output instead of being muxed as ciphertext; `--raw` passes ciphertext. A clear `.m2ts` whose CPI bits were left set still opens, also when a unit has damaged packets (half of them synced suffices). Encrypted content under a clear container's scheme is refused (E7023/E7022). A file whose start is zero-filled is judged from its first written sector; a file judged clear still refuses a scrambled pack (E7023). On `m2ts://` an unflagged unit with no TS structure is blanked and counted in `errors()`.
- **One CSS pack test for disc and file:** `css::is_scrambled_pack` (so the crack scan, `descramble_sector` and `descramble_region`) reads the scramble bits from the pack's first PES header: past pack stuffing and any system header, map, padding or nav packet, and only from MPEG-2 PES flags. Every DVD-Video pack is judged as before; a stuffed, map-first or MPEG-1-PES pack whose byte 0x14 merely looks flagged is no longer descrambled on the disc path, and a stuffed scrambled pack is descrambled by its own flags.
- **A flagged partial AACS unit that ends the source** (a truncated copy) is blanked and counted as damage instead of failing the read.
- **Decrypted BD-TS units have the Copy_permission_indicator cleared** (top 2 bits of each TP_extra_header), so decrypted output differs from 1.7.7 in those bits. HD DVD and units left as ciphertext are untouched.
- **`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.
- **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.
- **An encrypted disc folder is an ordinary `dir://` source:** a folder whose sampled content units are AACS-encrypted keeps its AACS verdict and is keyed and decrypted like an image of the same disc, instead of being refused with E9063; with no key it is refused before any output with E7022.
- **Frames dropped by the resync gate count as errors** in `MuxOutcome::errors` / `lost_bytes`, together with AACS units blanked as damaged.
- **A failed or stopped `network://` / `stdio://` sender resets the connection** (`Stream::finish_incomplete`), so the receiver errors instead of finishing a truncated title.
- **Key-service 404 / 422 answers are E7022** (no key opens the title), and a key-service outage or unreachable source is a source failure, never "no key for this disc".
- Diagnostics log tags: `tag=clip` is now `tag=bd.clip`; `tag=aacs` no longer logs key_source/vuk/unit_keys. `fvi://` and FMKV video `dar` for SD without a declared aspect is `0:1` (unknown), not 720:576. `Error::ImageTruncated` displays as `E<code>: have/want`.
- DVD audio and subtitles are routed by the title's PGC AST_CTL / SPST_CTL; track order, language and set may differ from 1.7.7 on discs whose physical ids are not ordinal. The mux-time AC-3 channel remap is gone.
### 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>`.
- `mux::fit_report` / `FitReport` / `SkipReason` (`Mp2Extension`, `NoStreamId`, `UnmappableSubtitle`): a container-neutral pre-mux plan of which streams a destination carries. `mp4_fit_report` is unchanged.
- `DiscSession::{open_with, scan_with, finish, attach_progress, token, from_drive, source_mut}` and `session::Finish::{Release, Unlock, Eject}`: a session runs under the caller's Stop token and ends on the handle it holds. `Drive::{attach, detach, attach_progress, halt, token}`: every CDB is checked against the op token.
- `halt`: waits re-check every `WAIT_SLICE` (20 ms); `Liveness`, `StallTimer`, `join_within`, `spawn_drive_holder`. `halt` is available with only the `scsi` feature.
- `NetworkStream::listen_with_halt` / `accept_from_with_halt`; `Event::BytesDurable { bytes, total }` while the output flushes; `io::{ArtifactLock, durable_sync_file, FlushProgress}` (`<final>.lock` sidecar; E9073 `TimedOut` after 30 s with no change); `Pipeline::{set_op_token, progress}`, `Sink::close_stopped` (a consumer closing after Stop keeps `*.partial`); `ISO_MUX_BATCH_SECTORS` is public.
- MPEG-2 multichannel extension audio (DVD `0xD0|n`) is a dependent track (`AudioStream::is_mp2_extension`) carried in the IR and over FMKV; sinks with no mapping list it in `MuxOutcome::undelivered_streams`, `mp4://` skips it (`Mp4SkipReason::Mp2Extension`). MPEG-1/2 audio (PES `0xC0|n`) is demuxed on DVD.
- Errors: E9085 `MuxBatchSectorsZero`, E6020 `ImageEndsBeforeRead`, E6021 `BusStreamUnmapped`, E6022 `ImageScoped`, E6023 XPL too large, E7031 `AacsKeyFileUnreadable`, E7032 `WholeDiscKeyMissing`, E7033 `AacsNoUsableHostCert`, E7034 `AacsVidNeedsDisc`, E7100-E7108 MKB variant faults, E9071 `StreamClosed`, E9072 `StreamHeaderWritten`, E9073 `TimedOut { op }`, E9074, E9075, and the remux/preflight set E9077 `RemuxVerifyFailed { kind, path }` (`E9077: <kind> <path>`, or `runtime-mismatch <have>/<want>`), E9078 `MuxIncomplete`, E9079 `RemuxStagingInvalid`, E9080 `StagedCopySizeMismatch`, E9081 `WorkerLost { op }`, E9083 `StreamLanguageUnknown`, E9084 `RemuxTargetExists`.
- `whole_disc::{whole_disc_reader, WholeDiscReader}`: the decrypting reader for whole-disc / image to ISO copies; it keys every stream file no title plays (probed at its first unit and up to 32 across it) and blanks, warns and counts the units of a stream file no held key opens (E7032 only when no file opens at all, a source faults, or no key is held). `ScanOptions::raw_copy` is kept but has no effect: an unreadable `Unit_Key_RO.inf` never fails the scan.
- `sector::bus_removal::{UnmappedStreamFile, ensure_image_debussable}`, `BusMap::unmapped`, `SectorSource::unmapped_stream_files`, `Disc::{mkv_staging_ranges, stream_content_ranges, inputs_with_samples}`, `DecryptingSectorSource::{blanked_units, clear_unit_base}`, `DiscPresence` / `disc_presence(path)`, `Playlist::duration_ticks`.
- `probe_mkv` / `probe_mkv_with_cues` read a Matroska file's Info and Tracks (and last Cues time) without reading a cluster; `parse_freemkv_version`.
- `mkv://` read-back carries display shape (aspect, PixelCrop), colour, frame rate, `LanguageBCP47` and chapters and undoes content encodings (header stripping, zlib); `mkv://` writes `MaxBlockAdditionID` for MVC 3D. `Mp4Reader` reports colour (CICP, HDR10, HLG, Dolby Vision), frame rate, duration, QuickTime v1/v2 and ISO v1 audio entries, `dac3`/`dec3` layouts and DTS `ddts` rate.
- M2TS output accepts DVD (MPEG-PS) sources: PIDs BD-TS cannot carry are remapped into BD PID ranges (a track with no free PID is omitted with a warning).
- `mux::codec::ns_to_ticks`, `CodecParser::config_changes()` / `Stream::config_changes`, `Stream::set_codec_private`. DTS Express (EXSS-only) streams are framed; MPEG-1 video is framed per picture.
- `libfreemkv::spec` (quoted spec text with registry check); `test-util` feature: `test_util` disc/image fixtures and `FakeTransport` for consumers' tests. Labels: large BD-J jars read in place, DRA CodingTypes mapped.
### 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.
- `Disc::scan` follows the standard AACS order (UDF, `Unit_Key_RO.inf`, certificate, MKB by plain reads, then the handshake, only on a disc with an AACS directory), no longer reads the MKB from the drive, and passes `None` to `KeySource::host_certs`. A live AACS disc whose `Unit_Key_RO.inf` is missing or unreadable is warned and scanned on, with E7031 recorded in `Disc::aacs_error` and every key refused (titles that need keys then fail E7022 on the normal key path); image and folder scans record it the same way. A handshake transport fault fails with `Drive::init`'s error and a Stop returns `Halted`. `DiscSession::scan` returns the key sources on failure so a retry keeps them.
- Host-key (cert-route) bus-encrypted discs: every `/BDMV/STREAM` file is de-bussed (BEE flag and per-unit CPI honoured; CPI 0 passes through). A stream file whose extents cannot be read is re-read with FUA (bounded, 5 s pause a Stop ends), then recorded; `ensure_image_debussable` refuses a whole-disc image with E6021 naming each file and cause, `extract_tree` counts such files lost whole, and `stream_content_ranges` fails E6021.
- `DecryptingSectorSource` has no default AACS unit base: a content read before `set_unit_base` fails `DecryptFailed`, as does a flagged BD-TS unit lacking TS sync at byte 4.
- `read_mkb_from_drive` returns `AacsKeyRead` for a short, over-long or header-only pack; record 0x07 is no longer read as a cvalue table; a content certificate type other than 0x00 / 0x10 no longer parses. AACS 2.1 tries every device key before declaring the MKB underivable; an HD DVD VTKF is parsed by its magic. A CSS crack that read no sector is `Unreadable`, never `Unencrypted`.
- Key-source failures return `Err` (never an empty key set); a read-time answer that opens no sample is skipped, and the key pool and per-answer keys are capped.
- `drive_has_disc` (Linux, Windows) classifies TEST UNIT READY sense: `Ok(false)` only for NOT READY 3Ah and 30/03, 30/07 (cleaning cartridge); 04/01 and other NOT READY states are `Ok(true)`; UNIT ATTENTION is re-polled up to 4 times. `Drive::wait_ready` rides out transport failures (5 s), sends one START UNIT on 04/02, fails at once on 30h, and returns 3Ah after 10 consecutive MEDIUM NOT PRESENT answers.
- `list_drives` on Linux without sysfs keeps an sg node unless INQUIRY says not optical, or open fails with ENOENT/ENXIO/ENODEV/EACCES/EPERM. Dropping a `Drive` or transport unlocks the tray only if it locked it. `DriveId::from_drive` reads firmware date and serial from GET CONFIGURATION features and returns `Halted` on Stop.
- macOS: opening a drive is cancellable, each CDB uses the caller's timeout (`timeout_ms` 0 is 60 s), and `find_drive` releases an empty drive before the next candidate. The shim is built against `MACOSX_DEPLOYMENT_TARGET` (11.0 aarch64, 10.12 x86_64).
- `Pipeline::finish_with_halt` waits on consumer progress (the 600 s join is a stall window). `sync_all` on a `WritebackFile` fails E9056 only after 60 s without flush progress and returns `Halted` on cancel; `WritebackFile` flushes in chunks (64 MiB, down to 4 MiB). Linux `sync_file_range` errors are latched; `write_image` fsyncs the parent directory; reads past EOF of an image return `ImageTruncated`.
- `network://` send and receive accept LAN targets: loopback, private (RFC 1918), link-local, ULA and CGNAT addresses connect. Only unspecified, multicast, broadcast and reserved addresses are refused (E9022); a caller that takes targets from untrusted input must vet them itself.
- `network://`: send connects to each vetted address in turn (10 s timeout); both ends enable TCP keepalive (a rejected option only warns); receive no longer blocks on a vanished sender or a Stop. `stdio://` input fails E9008 on a non-FMKV or damaged header once bytes were consumed.
- Container headers (MKV, fragmented MP4, M2TS, network) wait (5 s of source PTS, 2048 frames or half the header buffer) for the AAC AudioSpecificConfig and BD LPCM layout byte; a later config is back-patched into a seekable MKV. `Event::OutputOpened` lists only the streams the sink writes. A mux with `batch_sectors == 0` fails E9085.
- DVD: titles map through `VTS_PTT_SRPT` (TTN 0 rejected), one angle counted, an unusable IFO falls back to its BUP; MP2 `Channels` follows CRC-verified Layer II frames; the live path shares the file-backed path's access-unit assembly and H.264 second-field merge; unmapped PS streams warn once.
- Codec framing: ADTS and MPEG audio resync only on a header confirmed by the next, so a few KB of garbage no longer poisons a track; MPEG-2 field order follows picture_structure; H.264 1080i I/P field pairs are keyframes; HEVC arms CRA-to-BLA on gaps. TS demux skips duplicate packets, resyncs after a byte slip, honours transport_error_indicator.
- `mp4://` DTS follows ETSI TS 102 114 Annex E (`ddts` carries the stream's maximum rate; 96 kHz DTS-HD MA reads back as 96 kHz); video CTS is rounded to the nearest tick; `Mp4Reader` labels MPEG-audio `mp4a` as Mp3/Mp2.
- UDF: a corrupt directory FID is warned and stops that directory listing (the root keeps the entries before it, a subdirectory lists empty); AD chains are validated (`UdfAdChainTooLong`); fragmented Metadata Files are mapped. Directory images report `DirImagePlacement` / `DirImageFanout`. MPLS: a truncated play-item list keeps the items before the damage (warned; `MplsParse` only when none remain); a CLPI cut short of its 60-byte head still parses with an unknown (0) packet count, sized from the clip's extents. bdnav / dvdnav VM follows libbluray / libdvdnav.
- Labels: BD-J detection reads each jar once under a shared inflate budget and abstains on unreadable evidence; `clpi_audit` keys streams by (clip, PID).
- HD DVD: a title naming an absent or truncated clip is dropped; the `X!` AACS directory gates identify and scan and is stripped from extractions; oversized XPL is E6023.
- `Error::DirNameCollision` and `StreamLanguageUnknown` display with control characters escaped.
### 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.
- Blu-ray picture-in-picture PG entries no longer appear in the subtitle list; HD DVD triple-layer layer count and combo audio channel counts are correct; HD DVD XPL timecodes at 60fps timeBase run at 60000/1001.
- TrueHD channel labels keep the LFE split (3.0 was "2.1", 7.0 "6.1").
- A cleaning cartridge reads as no disc, so autorip no longer rips it; a drive becoming ready (04/01) is no longer "no disc".
- `mkv://` writer fails every later write and `finish()` after a frame write error and no longer panics; read-back counts malformed blocks and undecodable frames as lost data, rejects duplicate TrackNumbers and over 512 TrackEntries (`MkvSourceInvalid`), and treats chapters, tags and attachments as best-effort. PCM tracks without BitDepth use bounded read-ahead and a multi-block estimate.
- `Mp4Sink::write` after `finish()` fails E9071; `Mp4Reader` truncated 64-bit box size is E9049 and QuickTime v1 `esds` is found; `mp4://` no longer invents 48 kHz for reserved AC-3 / E-AC-3 / DTS rate codes.
- PGS clear events, durations over 30 s and gap handling; DVD-sub orphan continuations; chapter names escaped in WebVTT; TrueHD, DTS EXSS, MPEG audio free-format and `read_ue` edge cases; a first PES starting mid-frame drops the leading fragment.
- UDF stale cache window after a failed read; Stop is honoured in image scan, the FE re-read pause and READ CAPACITY retries; a `/dev/null` mapfile uses a private per-process temp dir; `extract` sanitises reserved file names and checks extent LBA arithmetic.
- `GET CONFIGURATION` honours Data/Additional Length and feature code; MODE SELECT list length is MDL+2; Windows SPTI timeout/alignment/bounce-buffer fixes; macOS DiskArbitration claim leak.
- Labels: Paramount "feature" matching, Criterion duplicate mappings, B/T language match, bdmt setNumber. Diagnostic opening capture no longer aliases track index 256 and above.
- `m2ts://` output now carries a PAT and an HDMV-registered PMT (PID 0x0100, repeated every 100 ms) declaring each track's stream_type, so FFmpeg and players identify every track: BD LPCM (0x80) decodes as `pcm_bluray` at the source depth instead of "unknown"/`mp3`. AAC is re-framed as ADTS from its AudioSpecificConfig (stream_type 0x0F; a config ADTS cannot signal is dropped and reported), MP2/MP3 are 0x03, and both use the MPEG-audio PES stream_id 0xC0.
- `m2ts://` output now carries a program clock: PCR packets on PID 0x1001 (the PMT's PCR_PID, BD-ROM convention) at most 100 ms apart, and every packet's TP_extra_header arrival time stamp on the same 27 MHz clock, at most 1 s ahead of its PES's DTS (it was 0). A timeline jump of more than 10 s restarts the clock with the discontinuity_indicator; PID 0x1001 is never given to a stream.
- `m2ts://` output keeps every raw_data_block of an AAC access unit taken from a multi-block ADTS frame: the ADTS header declares the block count (from the frame's duration) instead of one, which made decoders play only the first 1024 samples.
- Loose `m2ts://` input keeps AAC, MP2 and MP3 that other m2ts muxers declare as PES private data (stream_type 0x06): an MPEG-audio stream_id plus the ES sync names the codec. `mp4://` carries AAC, MP2 and MP3 (`mp4a` + `esds`) instead of skipping them.
## [1.7.7] — 2026-09-26
### Maintenance
+11
View File
@@ -16,6 +16,17 @@ cargo build
cargo test
```
### Parity goldens
`parity_*` tests pin what every input-to-output path produces today (output hashes, loss
counters, refusal codes) in `tests/goldens/*.golden`. A change that moves one must say why.
Re-bless on purpose, then review the diff:
```bash
FREEMKV_BLESS_GOLDENS=1 cargo test --lib parity_
git diff tests/goldens
```
## Code style
- Follow Rust conventions (`cargo fmt`, `cargo clippy`)
Generated
+33 -20
View File
@@ -124,9 +124,9 @@ dependencies = [
[[package]]
name = "crossbeam-channel"
version = "0.5.16"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
dependencies = [
"crossbeam-utils",
]
@@ -221,12 +221,13 @@ dependencies = [
[[package]]
name = "freemkv-unlock"
version = "1.7.7"
version = "1.8.0"
dependencies = [
"aes",
"base64",
"num-bigint",
"num-traits",
"pioneer-optical",
"rand",
"serde",
"serde_json",
@@ -317,20 +318,25 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libfreemkv"
version = "1.7.7"
version = "1.8.0"
dependencies = [
"aes",
"base64",
"crossbeam-channel",
"flate2",
"freemkv-unlock",
"libc",
"libfreemkv",
"loom",
"memchr",
"rayon",
"roxmltree",
"rustix",
"serde",
"serde_json",
"sha1",
"sha2",
"socket2",
"tempfile",
"tracing",
"zip",
@@ -425,6 +431,12 @@ 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"
@@ -505,15 +517,18 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "roxmltree"
version = "0.20.0"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]]
name = "rustix"
version = "1.1.4"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"errno",
@@ -626,6 +641,16 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.119"
@@ -836,15 +861,3 @@ dependencies = [
"log",
"simd-adler32",
]
[[patch.unused]]
name = "freemkv-engine"
version = "1.7.7"
[[patch.unused]]
name = "freemkv-i18n"
version = "1.7.7"
[[patch.unused]]
name = "freemkv-keysources"
version = "1.7.7"
+34 -12
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.7.7"
version = "1.8.1"
edition = "2024"
rust-version = "1.88"
license = "MIT"
@@ -27,8 +27,8 @@ codegen-units = 1
# none of the ripping dependency tree.
# `rip` — the full ripping tree (drive lifecycle, disc/UDF/MPLS/CLPI parsing,
# AACS/CSS decrypt, PES/mux pipeline, keydb, freemkv-unlock seam). All
# heavy deps (serde, zip, rayon, roxmltree, rand, crossbeam, memchr,
# sha1, aes, base64, freemkv-unlock, serde_json) live here.
# heavy deps (serde, zip, rayon, roxmltree, crossbeam, memchr,
# sha1, sha2, aes, base64, socket2, rustix, freemkv-unlock, serde_json) live here.
# `default = ["rip", "scsi"]` preserves today's full build byte-for-byte;
# consumers wanting transport only use `default-features = false, features =
# ["scsi"]`. `rip` implies `scsi` because the ripping tree reads through it.
@@ -40,39 +40,54 @@ rip = [
"dep:serde",
"dep:serde_json",
"dep:sha1",
"dep:sha2",
"dep:aes",
"dep:freemkv-unlock",
"dep:zip",
"dep:flate2",
"dep:base64",
"dep:roxmltree",
"dep:crossbeam-channel",
"dep:rayon",
"dep:memchr",
"dep:socket2",
"dep:rustix",
]
# `test-util`: `libfreemkv::test_util` fixtures for consumers' tests (dev-dependency
# only; never in a release build). Implies the rip tree the fixtures build on.
test-util = ["rip"]
[dependencies]
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
sha1 = { version = "0.11", optional = true }
# SHA-256 for the key-set fingerprints (VID and legacy key identity, keys-upfront §4.1, J18).
sha2 = { version = "0.11", optional = true }
aes = { version = "0.9", optional = true }
# Interim path dep for local cross-repo dev; `release-orchestrate.yml` re-pins
# this to `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` in the tagged commit
# (so the released tag resolves freemkv-unlock from git, not a sibling path) and
# restores the path dep on the branch tip.
freemkv-unlock = { path = "../freemkv-unlock", 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 }
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.
flate2 = { version = "1.1", default-features = false, features = ["zlib-rs"], optional = true }
base64 = { version = "0.23", optional = true }
# Safe TCP keepalive setup for `network://` receive (dead-peer detection);
# `all` provides TcpKeepalive::with_retries.
socket2 = { version = "0.6", features = ["all"], optional = true }
# Safe readiness poll (poll/WSAPoll) so a halt-aware `network://` receive never
# arms SO_RCVTIMEO, which on Windows can drop data when it cancels a recv.
rustix = { version = "1", default-features = false, features = ["std", "event"], optional = true }
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
# parser, not a hand-rolled scanner: the XPL is genuine XML (comments, varied
# attribute order, self-closing tags).
roxmltree = { version = "0.20", optional = true }
roxmltree = { version = "0.21", optional = true }
# Trace-level instrumentation for the read/transport path (SgIoTransport::execute
# and friends). Part of the `scsi` feature — the transport layer traces here.
# Permitted
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
# wire a tracing subscriber and pipe events into the JSONL debug log.
# Debug/trace logging is the permitted string output. Consumers (autorip) wire a
# tracing subscriber and pipe events into the JSONL debug log.
tracing = { version = "0.1", optional = true }
# Bounded MPSC channel with kernel-wakeup send_timeout. Used by `io::pipeline`
# so the halt-aware send/finish loops can BLOCK on consumer drain instead of
@@ -99,6 +114,9 @@ libc = "0.2"
[dev-dependencies]
tempfile = "3"
# The crate's own `test_util` for its integration tests (`tests/crypto_tests.rs` decrypts
# through `test_util::decrypt_unit`; the unit decrypt is crate-private, KU §2.2).
libfreemkv = { path = ".", features = ["test-util"] }
# Permutation model checker for the `scsi::fd_handoff` atomics — the fd
# hand-off between a Linux recovery thread and `SgIoTransport::drop`. A normal
@@ -125,6 +143,10 @@ loom = "0.7"
name = "sgio_read"
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" }
[lints.rust]
# `loom` is set by RUSTFLAGS for the model checker above, never by a feature.
+11
View File
@@ -0,0 +1,11 @@
ISO/IEC 11172-5 reference software (dist10) -- table data
src/mux/codec/testdata/dist10/alloc_0..3 are the Table 3-B.2a-d bit-allocation
tables of ISO/IEC 11172-3 Annex B, as shipped in the ISO/MPEG Audio Subgroup
Software Simulation Group's dist10 reference software (commit ea3c5ee of
joncampbell123/iso-dist10). They are used here only as test data to check the
Layer II parser.
Copyright remains with ISO/IEC and the original authors of that software. No
separate licence text accompanies these files; they are not covered by this
project's MIT licence, and their terms are those of the dist10 distribution.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Matthew Jackson & Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11 -8
View File
@@ -52,7 +52,8 @@ for title in &disc.titles {
// Stream pipeline — read PES frames from any source, write to any output
let opts = libfreemkv::InputOptions::default();
let mut input = libfreemkv::input("iso://Disc.iso", &opts)?;
let ctx = libfreemkv::Ctx::default(); // halt, events, loss counters for the run
let mut input = libfreemkv::input("iso://Disc.iso", &opts, &ctx)?;
let title = input.info().clone();
let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?;
while let Ok(Some(frame)) = input.read() {
@@ -86,15 +87,15 @@ cannot call into it; front-ends get recovery from the engine directly.
| Stream | Input | Output | Transport |
|--------|-------|--------|-----------|
| DiscStream | Yes | -- | Optical drive via SCSI |
| IsoStream | Yes | -- | Blu-ray ISO image file (read via stream pipeline; written by `freemkv_engine::recovery`) |
| `disc://` | Yes | -- | Optical drive via SCSI (live Read policy: adaptive batches, recovery read, skip or fail) |
| `iso://` / `dir://` | Yes | -- | Disc image file or folder (image Read policy; written by `freemkv_engine::recovery`) |
| MkvStream | Yes | Yes | Matroska container |
| M2tsStream | Yes | Yes | BD transport stream with FMKV metadata header |
| NetworkStream | Yes (listen) | Yes (connect) | TCP with FMKV metadata header |
| StdioStream | Yes (stdin) | Yes (stdout) | Raw byte pipe |
| NullStream | -- | Yes | Discard sink (byte counter for benchmarks) |
Streams implement a single unified `pes::Stream` trait (re-exported as `PesStream`) exposing `read()` and `write()` on one type. `input()` / `output()` resolve URL strings to PES stream instances. All URLs use the `scheme://path` format — bare paths are rejected.
Inputs implement `PesSource` (`read()`) and outputs `PesSink` (`write()`); a format that is both implements both. `open_source()` / `input()` open every input URL (`disc://` included) and `output()` every output URL. All URLs use the `scheme://path` format — bare paths are rejected.
### Keys
@@ -119,10 +120,12 @@ Disc — scan titles, streams, AACS/CSS state
├── AACS — key resolution + content decryption
└── CSS — DVD CSS (bus auth → player-key disc crack → known-plaintext title-key attack)
Streams — unified PES pipeline
├── PesStream — pes::Stream: one trait, read()/write() PES frames
├── DiscStream — sectors → decrypt → TS demux → PES
├── IsoStream — ISO file → decrypt → TS demux → PES
Streams — one PES pipeline
├── Source — open_source(url): drive, image, folder, file, socket, pipe
├── Read stage — extents → chunks under the source's ReadPolicy (live | image)
├── Decrypt stage — AACS / CSS, content-detected, damage blanked and counted
├── Demux — TS / PS → PES (one demux for every sector source)
├── PesSource/PesSink — read()/write() PES frames
├── MkvStream — MKV mux/demux
├── M2tsStream — BD transport stream
├── NetworkStream — TCP with FMKV metadata header
+17 -2
View File
@@ -9,8 +9,9 @@ version = 1
# Rust sources, documentation, configuration, workflows, scripts, binary assets,
# and the byte-exact `.golden` test fixtures — is covered here instead.
#
# The whole repository is the project's own work under the MIT licence; there is
# no third-party vendored source here.
# The repository is the project's own work under the MIT licence, except the
# MPEG audio ISO reference tables under src/mux/codec/testdata/dist10, which are
# third-party test data and carry their own annotation at the end of this file.
[[annotations]]
path = [
@@ -35,7 +36,21 @@ path = [
"Makefile",
"LICENSE",
"build.rs",
"**/*.c",
"**/*.mp2",
"**/*.class",
"**/PROVENANCE",
"tests/*.txt",
]
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 Matthew Jackson & Contributors"
SPDX-License-Identifier = "MIT"
# Table 3-B.2a-d allocation data copied from the ISO/IEC 11172-5 reference
# software (dist10, ISO/MPEG Audio Subgroup Software Simulation Group); this is
# not the project's work and is not MIT-licensed.
[[annotations]]
path = "src/mux/codec/testdata/dist10/alloc_*"
precedence = "aggregate"
SPDX-FileCopyrightText = "ISO/IEC JTC1/SC29/WG11 (MPEG) and the ISO/MPEG Audio Subgroup Software Simulation Group"
SPDX-License-Identifier = "LicenseRef-ISO-dist10"
+1 -1
View File
@@ -55,6 +55,7 @@ fn main() {
Ok(_) => {
writer.write_all(&buf).unwrap();
ok += 1;
bytes += buf.len() as u64;
}
Err(e) => {
fail += 1;
@@ -65,7 +66,6 @@ fn main() {
writer.write_all(&buf).unwrap();
}
}
bytes += buf.len() as u64;
if i % 50 == 0 && i > 0 {
let elapsed = start.elapsed().as_secs_f64();
+15 -1
View File
@@ -23,10 +23,24 @@ fn main() {
&target_arch // x86_64 → x86_64
};
// Match rustc's deployment target so the shim does not strongly link
// symbols newer than the app promises to run on (IOMainPort is 12+).
println!("cargo:rerun-if-env-changed=MACOSX_DEPLOYMENT_TARGET");
let min_ver = std::env::var("MACOSX_DEPLOYMENT_TARGET").unwrap_or_else(|_| {
if target_arch == "aarch64" {
"11.0"
} else {
"10.12"
}
.to_string()
});
let min_flag = format!("-mmacosx-version-min={min_ver}");
let cc_status = std::process::Command::new("cc")
.args([
"-arch",
clang_arch,
&min_flag,
"-c",
"src/scsi/macos_shim.c",
"-o",
@@ -61,7 +75,7 @@ fn main() {
/// Bake the git short hash into the build as `GIT_SUFFIX` so any muxed MKV or
/// FVI index is traceable to the exact source revision (e.g. ` (g835cc99)`).
/// Empty when git or the repo is unavailable (e.g. a crates.io tarball build),
/// Empty when git or the repo is unavailable (e.g. a source-archive build),
/// leaving just the package version. Always emitted so `env!("GIT_SUFFIX")`
/// resolves on every target.
fn emit_git_suffix() {
+4 -4
View File
@@ -43,9 +43,9 @@ fn dump_one(path: &Path) -> String {
Ok(r) => r,
Err(e) => {
return format!(
"{{\"iso\":{},\"error\":\"open: {:?}\"}}",
"{{\"iso\":{},\"error\":{}}}",
jstr(name),
format!("{e}")
jstr(&format!("open: {e}"))
);
}
};
@@ -55,9 +55,9 @@ fn dump_one(path: &Path) -> String {
Ok(d) => d,
Err(e) => {
return format!(
"{{\"iso\":{},\"error\":\"scan: {}\"}}",
"{{\"iso\":{},\"error\":{}}}",
jstr(name),
jstr(&format!("{e}"))
jstr(&format!("scan: {e}"))
);
}
};
+84 -1328
View File
File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
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
+58 -81
View File
@@ -24,13 +24,16 @@ thread_local! {
}
/// Build an AES-128 key schedule for a caller that will drive
/// [`cbc_decrypt_blocks`] over several regions under one key.
/// [`cbc_decrypt_blocks`] over several regions under one key. Also used by the
/// `aacs::derive` cvalue-candidate ECB scan (L104), which shares one schedule
/// per Processing Key across every candidate instead of building one per call.
pub(crate) fn new_cipher_for(key: &[u8; 16]) -> Aes128 {
new_cipher(key)
}
/// Build an AES-128 key schedule. The single construction site for the CBC
/// helpers, so `KEY_EXPANSIONS` can count them under test.
/// helpers, so `KEY_EXPANSIONS` can count them under test; the single-block ECB
/// helpers build their own and are not counted.
fn new_cipher(key: &[u8; 16]) -> Aes128 {
#[cfg(test)]
KEY_EXPANSIONS.with(|c| c.set(c.get() + 1));
@@ -50,6 +53,13 @@ pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
/// AES-128-ECB decrypt a single 16-byte block. `[C]` §2.1.1 (`AES-128D`).
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let cipher = Aes128::new(&(*key).into());
aes_ecb_decrypt_with_cipher(&cipher, data)
}
/// AES-128-ECB decrypt a single block under an already-expanded key schedule. Split out of
/// [`aes_ecb_decrypt`] for a candidate-scan loop that reuses one Processing Key's schedule
/// across many `(uv, cvalue)` pairs instead of rebuilding it per candidate (L104).
pub(crate) fn aes_ecb_decrypt_with_cipher(cipher: &Aes128, data: &[u8; 16]) -> [u8; 16] {
let mut block: Array<u8, _> = (*data).into();
cipher.decrypt_block(&mut block);
let mut out = [0u8; 16];
@@ -93,30 +103,54 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
// AES-128-CBC decrypt in place under AACS_IV with an already-expanded key schedule. Split out
// of aes_cbc_decrypt for callers that share one key across several regions.
pub(crate) fn cbc_decrypt_blocks(cipher: &Aes128, data: &mut [u8]) {
let num_blocks = data.len() / 16;
// Process blocks in reverse to avoid clobbering ciphertext needed for XOR
for i in (0..num_blocks).rev() {
let offset = i * 16;
let prev = if i == 0 {
AACS_IV
} else {
let mut p = [0u8; 16];
p.copy_from_slice(&data[(i - 1) * 16..i * 16]);
p
};
let mut chunk = [0u8; 16];
chunk.copy_from_slice(&data[offset..offset + 16]);
let mut block: Array<u8, _> = chunk.into();
cipher.decrypt_block(&mut block);
for j in 0..16 {
data[offset + j] = block[j] ^ prev[j];
// CBC decrypt parallelises: AES-decrypt a batch of blocks in one call (the
// backend pipelines them), then XOR each with its preceding ciphertext.
const BATCH: usize = 32;
let len = data.len() / 16 * 16;
let mut prev = AACS_IV;
let mut blocks = [aes::Block::default(); BATCH];
for chunk in data[..len].chunks_mut(16 * BATCH) {
let n = chunk.len() / 16;
for (b, c) in blocks.iter_mut().zip(chunk.as_chunks::<16>().0) {
b.copy_from_slice(c);
}
cipher.decrypt_blocks(&mut blocks[..n]);
// The next batch chains off this batch's last CIPHERTEXT block.
let mut last = [0u8; 16];
last.copy_from_slice(&chunk[(n - 1) * 16..]);
// Walk backwards so each block's preceding ciphertext is still intact.
for i in (0..n).rev() {
let iv = if i == 0 {
prev
} else {
let mut p = [0u8; 16];
p.copy_from_slice(&chunk[(i - 1) * 16..i * 16]);
p
};
for (d, (x, v)) in chunk[i * 16..(i + 1) * 16]
.iter_mut()
.zip(blocks[i].iter().zip(iv.iter()))
{
*d = x ^ v;
}
}
prev = last;
}
}
// Per-thread count of `aes_g` calls. Test-only: L103 hoists the slot-independent
// `aes_g(kp, nonce)` out of the AACS 2.1 variant per-slot loop (~46k slots/disc), so a test
// can assert it now runs once per variant MKB, not once per slot.
#[cfg(test)]
thread_local! {
pub(crate) static AES_G_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2 (`[C]` §2.1.3, uses AES-128D). Used by the Media Key
// Variant chain to derive Kvn and Kvu.
pub(crate) fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
#[cfg(test)]
AES_G_CALLS.with(|c| c.set(c.get() + 1));
let mut out = aes_ecb_decrypt(x1, x2);
for i in 0..16 {
out[i] ^= x2[i];
@@ -142,66 +176,9 @@ pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
}
#[cfg(test)]
mod tests {
use super::*;
#[path = "crypto_tests.rs"]
mod tests;
// 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));
}
}
#[cfg(test)]
#[path = "crypto_spec_guards_tests.rs"]
mod spec_guards;
+20
View File
@@ -0,0 +1,20 @@
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
@@ -0,0 +1,74 @@
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");
}
}
+148 -1357
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
@@ -0,0 +1,531 @@
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
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,217 @@
//! 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
@@ -0,0 +1,176 @@
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));
}
+2 -2
View File
@@ -5,8 +5,8 @@
/// Union the host certificates a scan can offer the drive: the explicit
/// `DriveCredentials`, then each key source's `host_certs(mkb)`. Host certs are
/// keysource-served, never compiled in. `mkb` lets a source pick a
/// generation-appropriate cert (the default impl ignores it).
/// keysource-served, never compiled in. The scan passes `None` for `mkb`: the
/// argument is reserved and will be removed at the next breaking window.
pub fn collect_host_certs(
opts: &crate::disc::ScanOptions,
mkb: Option<u32>,
+3 -116
View File
@@ -4,7 +4,7 @@
//! tags each segment with an index (see [`super::segment`]). Segments matching our
//! index are kept, others dropped; everything outside a segment is ordinary (index-0)
//! content. This module owns that classification only — no I/O, no keys, no cipher.
//! The decrypt pipeline consumes the [`UnitDisposition`] it returns.
//! Standalone helper: the live FMTS path is `keys::fmts` + `mux::resolve`, not this module.
use super::segment::{Segment, segment_for_unit};
use super::types::UnitKey;
@@ -71,118 +71,5 @@ pub fn unit_disposition(
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aacs::content::ALIGNED_UNIT_LEN;
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
/// Build a one-record segment table (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)
);
}
}
#[path = "index_select_tests.rs"]
mod tests;
+113
View File
@@ -0,0 +1,113 @@
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)
);
}
+69 -588
View File
@@ -118,8 +118,8 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
return None;
}
// Extract encrypted keys
let mut encrypted_keys = Vec::with_capacity(num_uk);
// Extract encrypted keys (capacity clamped: num_uk is untrusted)
let mut encrypted_keys = Vec::with_capacity(num_uk.min(data.len() / stride.max(1)));
let mut pos = keys_start;
for i in 0..num_uk {
if pos + 16 > data.len() {
@@ -217,7 +217,9 @@ const VTKF_AV_FLG: u8 = 0x80;
/// An absent slot is SKIPPED (not a terminator): the 1-based slot index IS
/// the CPS unit number, so `title_cps_unit` is left empty (playlist-owned).
pub fn parse_vtkf(data: &[u8]) -> Option<UnitKeyFile> {
if data.len() < VTKF_HEADER_LEN || &data[..12] != VTKF_MAGIC {
// A file cut inside the entry table would silently lose its later slots' keys.
if data.len() < VTKF_HEADER_LEN + VTKF_MAX_ENTRIES * VTKF_ENTRY_LEN || &data[..12] != VTKF_MAGIC
{
return None;
}
// SHA1 of the WHOLE file — the KEYDB lookup key. BackupHDDVD-family key
@@ -228,9 +230,6 @@ pub fn parse_vtkf(data: &[u8]) -> Option<UnitKeyFile> {
let mut encrypted_keys = Vec::new();
for n in 0..VTKF_MAX_ENTRIES {
let pos = VTKF_HEADER_LEN + n * VTKF_ENTRY_LEN;
if pos + VTKF_ENTRY_LEN > data.len() {
break;
}
// AV_FLG clear = empty slot: skip it, but keep the slot index as the CPS
// number (do NOT break — a gap must not renumber the keys that follow).
if data[pos] & VTKF_AV_FLG == 0 {
@@ -275,14 +274,15 @@ const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
/// MKB pack buffer size.
const MKB_PACK_SIZE: usize = 32772;
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
/// Returns the concatenated MKB data from all packs.
pub fn read_mkb_from_drive(
/// Read one MKB pack: returns (declared pack count, payload). A short transfer or a
/// payload past the buffer is a drive fault, never a holed MKB.
fn read_mkb_pack(
session: &mut dyn crate::scsi::ScsiTransport,
) -> crate::error::Result<Vec<u8>> {
pack: u32,
) -> crate::error::Result<(usize, Option<Vec<u8>>)> {
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
let cdb = [
let mut cdb = [
SCSI_READ_DISC_STRUCTURE,
0x01,
0x00,
@@ -296,52 +296,57 @@ pub fn read_mkb_from_drive(
0x00,
0x00,
];
let mut buf = vec![0u8; 32772];
session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
// Pack number goes in address field
cdb[2..6].copy_from_slice(&pack.to_be_bytes());
let mut buf = vec![0u8; MKB_PACK_SIZE];
// Transport errors propagate: swallowing one would return a truncated MKB as Ok.
let r = session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
// A GOOD status with no header transferred is a drive fault, not an empty MKB.
if r.bytes_transferred < 4 {
return Err(crate::error::Error::AacsKeyRead);
}
let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
let num_packs = buf[3] as usize;
if data_len < 2 {
return Ok(Vec::new());
return Ok((num_packs, None));
}
let len = data_len - 2;
let num_packs = buf[3] as usize;
if len > MKB_PACK_SIZE - 4 || r.bytes_transferred < 4 + len {
return Err(crate::error::Error::AacsKeyRead);
}
Ok((num_packs, Some(buf[4..4 + len].to_vec())))
}
let mut mkb = Vec::with_capacity(32768 * num_packs.max(1));
if len > 0 && len <= 32768 {
mkb.extend_from_slice(&buf[4..4 + len]);
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
/// Returns the concatenated MKB data from all packs. A short or over-long pack is a
/// drive fault (`AacsKeyRead`), never a holed MKB; a header-only pack in a multi-pack MKB is
/// `AacsKeyRead`; a single header-only pack is an empty MKB.
pub fn read_mkb_from_drive(
session: &mut dyn crate::scsi::ScsiTransport,
) -> crate::error::Result<Vec<u8>> {
let (num_packs, first) = read_mkb_pack(session, 0)?;
let Some(mut mkb) = first else {
// A declared multi-pack MKB whose first pack carries no data is a hole.
if num_packs > 1 {
return Err(crate::error::Error::AacsKeyRead);
}
return Ok(Vec::new());
};
// A header-only pack in a multi-pack MKB is a hole, not an empty MKB.
if num_packs > 1 && mkb.is_empty() {
return Err(crate::error::Error::AacsKeyRead);
}
// Read remaining packs
for pack in 1..num_packs {
let mut cdb = [
SCSI_READ_DISC_STRUCTURE,
0x01,
0x00,
0x00,
0x00,
0x00,
0x00,
MKB_DISC_STRUCTURE_FORMAT,
(MKB_PACK_SIZE >> 8) as u8,
(MKB_PACK_SIZE & 0xFF) as u8,
0x00,
0x00,
];
// Pack number goes in address field
cdb[2] = ((pack >> 24) & 0xFF) as u8;
cdb[3] = ((pack >> 16) & 0xFF) as u8;
cdb[4] = ((pack >> 8) & 0xFF) as u8;
cdb[5] = (pack & 0xFF) as u8;
let mut buf = vec![0u8; 32772];
// A transport error on a non-first pack must PROPAGATE: swallowing it
// (the prior `.is_ok()`) silently truncated the MKB and returned the
// partial data as Ok, corrupting the root of the whole AACS ladder.
session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
if len > 2 && len - 2 <= 32768 {
mkb.extend_from_slice(&buf[4..4 + len - 2]);
let (_, body) = read_mkb_pack(session, pack as u32)?;
let body = body.ok_or(crate::error::Error::AacsKeyRead)?;
if body.is_empty() {
return Err(crate::error::Error::AacsKeyRead);
}
mkb.extend_from_slice(&body);
}
Ok(mkb)
@@ -356,8 +361,8 @@ pub struct ContentCert {
pub cc_id: [u8; 6],
/// AACS generation indicated by the certificate type byte.
///
/// Cert type `0x00` → [`AacsVersion::V10`]; any other value →
/// [`AacsVersion::V20`]. The certificate alone cannot distinguish
/// Cert type `0x00` → [`AacsVersion::V10`], `0x10` → [`AacsVersion::V20`];
/// any other type does not parse. The certificate alone cannot distinguish
/// V20 from V21 — Variant detection happens after the MKB walk.
pub version: AacsVersion,
}
@@ -368,12 +373,13 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
return None;
}
// Content Certificate layout: [0] cert type (0x00 AACS1/0x10 AACS2),
// [1] bit7 bus_encryption_enabled_flag, [14..20] cc_id (6 bytes).
let version = if data[0] == 0x00 {
AacsVersion::V10
} else {
AacsVersion::V20
// Layout: [0] type, [1] bit7 BEE flag, [14..20] cc_id. Only observed types
// parse: 0x10 AACS2, 0x00 AACS1 (HD DVD's 0x00 is reverse-engineered from
// retail discs); anything else is None.
let version = match data[0] {
0x00 => AacsVersion::V10,
0x10 => AacsVersion::V20,
_ => return None,
};
// The flag is bit 7 of byte 1, NOT bit 0. Reading bit 0 (the prior bug) made
// a bus-encrypted cert (byte1=0x80) read as `false`, defeating the
@@ -390,542 +396,17 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
}
#[cfg(test)]
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_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}");
}
}
#[path = "inf_vtkf_tests.rs"]
mod vtkf_tests;
#[cfg(test)]
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 pack that declares only the 2-byte header and NO payload contributes
/// nothing, and must not push a phantom byte into the MKB — an off-by-one
/// at the zero-length boundary corrupts every following pack's alignment.
#[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(),
};
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
assert_eq!(
mkb.len(),
32,
"an empty pack adds no bytes; only pack 1's payload is present"
);
assert!(mkb == vec![0xABu8; 32], "and the bytes are pack 1's");
}
// A drive-declared length past the 32772-byte buffer must not be trusted.
#[test]
fn read_mkb_from_drive_ignores_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 honest = vec![0xC7u8; 256];
let mut drive = LyingDrive {
honest: honest.clone(),
};
let mkb = read_mkb_from_drive(&mut drive).expect("an over-declared pack is not an error");
assert_eq!(
mkb.len(),
honest.len(),
"only the honest pack's bytes may be taken; the over-declared pack \
contributes nothing and must not be read past the buffer"
);
assert!(mkb == honest, "and those bytes are pack 0's");
}
/// The same over-declaration on the FIRST pack, which uses a separate bound
/// from the loop's.
#[test]
fn read_mkb_from_drive_ignores_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 mkb = read_mkb_from_drive(&mut LyingFirst).expect("not an error");
assert!(
mkb.is_empty(),
"a first pack declaring more than the buffer holds yields no bytes"
);
}
/// 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 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"
);
}
}
#[path = "inf_read_mkb_tests.rs"]
mod read_mkb_tests;
#[cfg(test)]
mod unit_key_ro_tests {
use super::*;
#[path = "inf_unit_key_ro_tests.rs"]
mod unit_key_ro_tests;
/// 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;
+18
View File
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,424 @@
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
@@ -0,0 +1,51 @@
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
@@ -0,0 +1,140 @@
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}");
}
+43 -281
View File
@@ -12,12 +12,13 @@
/// Type-and-Version — carries the 32-bit MKBType / AACS generation.
pub(crate) const REC_TYPE_AND_VERSION: u8 = 0x10;
/// Subset-Difference index — the per-slot `(u_mask_shift, uv)` table.
/// Explicit Subset-Difference — the per-slot `(u_mask_shift, uv)` table.
pub(crate) const REC_SUBSET_DIFFERENCE: u8 = 0x04;
/// Media Key Data — the classical (1.0 / 2.0) per-subset cvalue table.
pub(crate) const REC_MEDIA_KEY_DATA: u8 = 0x05;
/// Explicit Subset-Difference — the smaller cvalue table some MKBs use.
pub(crate) const REC_EXPLICIT_SUBSET_DIFF: u8 = 0x07;
/// Subset-Difference Index — a search index into `0x04`, NOT a cvalue table.
#[cfg(test)]
pub(crate) const REC_SUBSET_DIFFERENCE_INDEX: u8 = 0x07;
/// Media Key Variant Data (AACS 2.1) — the per-subset-difference `C` table
/// (one 16-byte C per slot); the `Kmp` step reads C from HERE, not `0x2d`.
pub(crate) const REC_MEDIA_KEY_VARIANT_DATA: u8 = 0x0c;
@@ -93,11 +94,9 @@ pub(crate) fn mkb_find_body(records: &[MkbRecord], rec_type: u8) -> Option<&[u8]
/// AACS protection generation a disc carries.
///
/// The content cert byte distinguishes V10 (`0x00`) from V20 (`0x01`). V21
/// cannot be detected from the cert alone — a V21 disc carries a V20 cert
/// and is upgraded to `V21` only after the MKB walk turns up the real Variant
/// records `0x2d` / `0x2f` (Encrypted Media Key Variant Data and the Variant
/// Key Data table).
/// The content cert type byte distinguishes V10 (`0x00`) from V20 (`0x10`; any other
/// byte is rejected, see [`super::inf::parse_content_cert`]). A V21 disc carries a V20
/// cert and is upgraded to `V21` only when the MKB has Variant records `0x2d`/`0x2f`.
///
/// Key-storage stride in `Unit_Key_RO.inf` is 48 bytes for V10 and 64
/// bytes for V20 / V21.
@@ -117,6 +116,7 @@ pub enum AacsVersion {
/// the V10-vs-else stride choice it drives — lives in exactly one place.
pub const AACS_MAJOR_BD: u8 = 1;
/// AACS major version 2 (UHD-BD, 2.0 / 2.1): see [`AACS_MAJOR_BD`].
pub const AACS_MAJOR_UHD: u8 = 2;
impl AacsVersion {
@@ -148,6 +148,29 @@ 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]> {
@@ -183,25 +206,28 @@ pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
/// Find Subset-Difference records (type 0x04) in MKB. `[C]` §3.2.5.1.5.
pub(crate) fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
find_record_body(mkb, 0x04)
find_record_body(mkb, REC_SUBSET_DIFFERENCE)
}
// Find the Media Key Data Record (cvalues table) in an MKB. `[C]` §3.2.4 / §3.2.5.1.7. 0x05 is
// the canonical cvalue table on both AACS 1.0 and 2.x; 0x07 is a smaller fallback only.
// 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.
pub(crate) fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
if let Some(body) = find_record_body(mkb, 0x05) {
return Some(body);
}
find_record_body(mkb, 0x07)
find_record_body(mkb, REC_MEDIA_KEY_DATA)
}
/// Walk an MKB and return the payload (header stripped) of the first
/// record matching `rec_type`. Returns `None` if no such record exists or
/// the record is empty.
pub(crate) fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
find_record_slice(mkb, rec_type_wanted).map(<[u8]>::to_vec)
}
// Borrowing form of [`find_record_body`]: the body in place, no copy.
pub(crate) fn find_record_slice(mkb: &[u8], rec_type_wanted: u8) -> Option<&[u8]> {
mkb_records(mkb)
.find(|&(_, rt, len)| rt == rec_type_wanted && len > 4)
.map(|(o, _, len)| mkb[o + 4..o + len].to_vec())
.map(|(o, _, len)| &mkb[o + 4..o + len])
}
/// Real content length of an MKB: the byte offset where the record stream
@@ -330,269 +356,5 @@ pub fn mkb_is_uhd(mkb: &[u8]) -> Option<bool> {
}
#[cfg(test)]
mod tests {
use super::*;
/// One MKB record: 1 type byte + big-endian 24-bit total length + body.
fn rec(rec_type: u8, body: &[u8]) -> Vec<u8> {
let len = 4 + body.len();
let mut v = vec![rec_type, (len >> 16) as u8, (len >> 8) as u8, len as u8];
v.extend_from_slice(body);
v
}
/// Type-and-Version record (0x10): body = 4-byte MKBType + 4-byte version.
fn type_and_version(mkb_type: u32, version: u32) -> Vec<u8> {
let mut body = mkb_type.to_be_bytes().to_vec();
body.extend_from_slice(&version.to_be_bytes());
rec(REC_TYPE_AND_VERSION, &body)
}
#[test]
fn walker_frames_records_and_stops_at_end_marker() {
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 77);
mkb.extend(rec(REC_VKD_TABLE, &[0xAA; 16]));
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
mkb.extend(rec(0x99, &[0xFF; 8])); // must NOT be walked (past the marker)
let recs = walk_mkb(&mkb);
assert_eq!(recs.len(), 2, "walk stops at the 00 000000 end marker");
assert_eq!(recs[0].rec_type, REC_TYPE_AND_VERSION);
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
assert_eq!(recs[1].body, vec![0xAA; 16]);
}
#[test]
fn walker_stops_on_malformed_or_out_of_bounds_length() {
// A record whose declared length runs past the buffer end must terminate
// the walk rather than panic or read OOB.
let mkb = vec![REC_VKD_TABLE, 0x00, 0xFF, 0xFF, 0x01, 0x02]; // len=0xFFFF, only 6 bytes
assert!(
walk_mkb(&mkb).is_empty(),
"over-long record yields no records"
);
// A sub-4 length (shorter than the header itself) is also rejected.
let short = vec![REC_VKD_TABLE, 0x00, 0x00, 0x02];
assert!(walk_mkb(&short).is_empty(), "sub-4 length is rejected");
// A truncated header (< 4 bytes) yields nothing.
assert!(walk_mkb(&[0x10, 0x00]).is_empty());
}
#[test]
fn mkb_type_and_version_decode_from_the_type_record() {
let mut mkb = type_and_version(MKB_21_CATEGORY_C, 100);
mkb.extend([0x00, 0x00, 0x00, 0x00]);
assert_eq!(mkb_type_raw(&mkb), Some(MKB_21_CATEGORY_C));
assert_eq!(mkb_version(&mkb), Some(100));
assert_eq!(mkb_is_uhd(&mkb), Some(true), "2.1 Category C is UHD");
let bd = type_and_version(MKB_TYPE_4_PRERECORDED, 68);
assert_eq!(
mkb_is_uhd(&bd),
Some(false),
"AACS 1.0 prerecorded is not UHD"
);
// No Type record → None (not a panic, not a fabricated value).
assert_eq!(mkb_version(&rec(REC_VKD_TABLE, &[0; 16])), None);
assert_eq!(mkb_type_raw(&[]), None);
}
// 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);
}
}
}
#[path = "mkb_tests.rs"]
mod tests;
+297
View File
@@ -0,0 +1,297 @@
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);
}
}
+29 -277
View File
@@ -1,34 +1,34 @@
//! AACS decryption — Volume Unique Key lookup and title key derivation.
//!
//! Two paths: VUK lookup (disc_hash → KEYDB.cfg → VUK, fast, common) or a full
//! handshake (device_keys + MKB → Media Key → + Volume ID → VUK, fallback).
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf; title keys decrypt
//! AACS primitives: MKB, title-key file, derivation, unit crypto and the resolution trace.
//! Keys are acquired only through [`crate::keys::KeyRing::acquire`]; title keys decrypt
//! m2ts stream content (AES-128-CBC).
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)]
pub mod index_select;
pub mod inf;
pub mod mkb;
pub mod provider;
pub mod resolve;
pub mod segment;
// No production caller; not the live FMTS path.
#[doc(hidden)]
pub mod segment_key;
pub mod trace;
pub mod types;
pub mod variant;
/// On-disc UDF paths to the AACS key-input files, plus HD DVD AACS-directory
/// discovery (never hardcoded — see [`find_hddvd_aacs_dir`], [`role_paths`]).
///
/// Each [`AacsRole`] resolves to an ordered candidate list, walked by
/// [`read_first`] to the first that reads, so every reader shares one source
/// of truth for disc_hash / MKB / VID.
// On-disc UDF paths to the AACS key-input files; each AacsRole resolves to an ordered
// candidate list walked by read_first (see role_paths, find_hddvd_aacs_dir).
/// Primary Unit Key file path.
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf";
pub const PATH_MKB_RO_DUPLICATE: &str = "/AACS/DUPLICATE/MKB_RO.inf";
/// The recordable-media MKB — a DIFFERENT MKB, never a fallback for `MKB_RO`.
pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
@@ -40,23 +40,29 @@ pub enum AacsRole {
/// Title-key file: BD/UHD `Unit_Key_RO.inf`, HD DVD `VTKF*.AACS`
/// (magic `DVD_HD_V_TKF`). The disc_hash is `SHA1` of this file.
UnitKey,
/// Media Key Block: BD/UHD `MKB_RO/RW.inf`, HD DVD `MKBROM.AACS`.
/// Media Key Block: BD/UHD `MKB_RO.inf` (then its DUPLICATE copy), HD DVD `MKBROM.AACS`.
Mkb,
/// Content certificate: BD/UHD `Content000/001.cer`, HD DVD
/// `CONTENT_CERT.AACS` (byte 0 gives the AACS major).
ContentCert,
}
// The HD DVD AACS directory in a parsed UDF tree, if present. Identified structurally (name
// ends `!`, contains MKBROM.AACS), not by a hardcoded name.
// 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.
pub(crate) fn find_hddvd_aacs_dir(udf: &crate::udf::UdfFs) -> Option<&crate::udf::DirEntry> {
udf.root.entries.iter().find(|e| {
let mut dirs = udf.root.entries.iter().filter(|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
@@ -65,7 +71,7 @@ pub(crate) fn find_hddvd_aacs_dir(udf: &crate::udf::UdfFs) -> Option<&crate::udf
pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec<String> {
let mut v: Vec<String> = match role {
AacsRole::UnitKey => vec![PATH_UNIT_KEY_RO, PATH_UNIT_KEY_RO_DUPLICATE],
AacsRole::Mkb => vec![PATH_MKB_RO, PATH_MKB_RW],
AacsRole::Mkb => vec![PATH_MKB_RO, PATH_MKB_RO_DUPLICATE],
AacsRole::ContentCert => vec![PATH_CONTENT_CERT, PATH_CONTENT_CERT_ALT],
}
.into_iter()
@@ -129,266 +135,12 @@ where
}
// The module structure IS the public API — consumers import from the owning module
// (e.g. `aacs::content::decrypt_unit`). A small set of flat re-exports below is kept
// (e.g. `aacs::content::is_clean`). A small set of flat re-exports below is kept
// for typed key primitives and content-decrypt entry points that downstream crates rely on.
pub use content::ALIGNED_UNIT_LEN;
pub use derive::derive_vuk;
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
#[cfg(test)]
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);
}
// 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(),
]
);
}
}
#[path = "mod_tests.rs"]
mod tests;
+352
View File
@@ -0,0 +1,352 @@
//! 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(),
]
);
}
-405
View File
@@ -1,405 +0,0 @@
//! Key source abstraction for the AACS resolve chain.
//!
//! libfreemkv owns all AACS crypto; backends supply raw key material via
//! [`KeyProvider`]. Bulk methods (device/processing/media keys) are unioned
//! and deduped across providers, trying each; disc-keyed lookups
//! (`lookup_disc_by_hash`/`_by_vid`) short-circuit on the first hit, queried
//! in provider-array order.
//!
//! Default impls return empty/`None`; calls may block, so cache per-disc.
use super::types::{DeviceKey, DiscEntry, HostCert};
/// Source of AACS key material.
///
/// Implementors return raw material only — the resolver in
/// `aacs::resolve` and `aacs::derive` own the crypto (DK→PK walking, PK validation,
/// MK→VUK→TK derivation). See module docs for method semantics.
pub trait KeyProvider: Send + Sync {
/// Device keys (top-of-tree, walked by the resolver).
fn device_keys(&self) -> Vec<DeviceKey> {
Vec::new()
}
/// Processing keys — terminal PKs or walk-input PKs. The
/// resolver tries each as a terminal first (cheap validate).
fn processing_keys(&self) -> Vec<[u8; 16]> {
Vec::new()
}
/// Every Media Key this provider holds, regardless of which disc it was
/// filed under. An MK is MKB-scoped (shared across a pressing/MKB-family),
/// so the resolver can verify each against the disc's MKB (`km_verifies`)
/// and resolve a disc whose own hash/VID isn't directly keyed.
fn media_keys(&self) -> Vec<[u8; 16]> {
Vec::new()
}
/// AACS host certificates (with their private keys) for drive
/// authentication. Multiple in case some are revoked.
///
/// NOTE: not consumed by the resolver chain — the handshake reads
/// host certs from the caller-supplied credentials directly, so
/// overriding this method has no effect on drive authentication
/// today. Retained as a forward-looking extension point.
fn host_certs(&self) -> Vec<HostCert> {
Vec::new()
}
/// Direct per-disc lookup by SHA-1 of `Unit_Key_RO.inf`. Returns
/// `Some(entry)` if this provider has pre-computed material for
/// the disc (paths 4 and 5). Short-circuits the resolver.
fn lookup_disc_by_hash(&self, _disc_hash: &[u8; 20]) -> Option<DiscEntry> {
None
}
/// Lookup by Volume ID (path 3 — pre-computed MK + matching
/// VID). Short-circuits the resolver on hit.
fn lookup_disc_by_vid(&self, _volume_id: &[u8; 16]) -> Option<DiscEntry> {
None
}
}
// Resolver-side helper that aggregates KeyProvider material across a provider array
// (union-and-dedup for bulk methods, first-hit for disc-keyed lookups).
pub(crate) struct Providers<'a>(pub &'a [&'a dyn KeyProvider]);
impl Providers<'_> {
/// Union (deduped) — gather DKs from every provider.
pub fn device_keys(&self) -> Vec<DeviceKey> {
let mut v: Vec<DeviceKey> = self.0.iter().flat_map(|p| p.device_keys()).collect();
// DeviceKey has no Ord/Hash; dedup on the value-defining tuple.
v.sort_unstable_by_key(|d| (d.key, d.node, d.uv, d.u_mask_shift));
v.dedup_by_key(|d| (d.key, d.node, d.uv, d.u_mask_shift));
v
}
/// Union (deduped) — gather PKs from every provider.
pub fn processing_keys(&self) -> Vec<[u8; 16]> {
let mut v: Vec<[u8; 16]> = self.0.iter().flat_map(|p| p.processing_keys()).collect();
v.sort_unstable();
v.dedup();
v
}
/// Union of distinct Media Keys across every provider, for the MK-pool
/// brute (`km_verifies` against the disc's MKB).
pub fn media_keys(&self) -> Vec<[u8; 16]> {
let mut v: Vec<[u8; 16]> = self.0.iter().flat_map(|p| p.media_keys()).collect();
v.sort_unstable();
v.dedup();
v
}
/// Union — gather host certs from every provider. The SCSI handshake
/// reads host certs from the caller-supplied credentials directly and
/// does not call this, so it is currently unused by the resolver chain.
#[allow(dead_code)]
pub fn host_certs(&self) -> Vec<HostCert> {
self.0.iter().flat_map(|p| p.host_certs()).collect()
}
/// Short-circuit — query providers in array order, first hit wins.
pub fn lookup_disc_by_hash(&self, disc_hash: &[u8; 20]) -> Option<DiscEntry> {
self.0.iter().find_map(|p| p.lookup_disc_by_hash(disc_hash))
}
/// Short-circuit — query providers in array order, first hit wins.
pub fn lookup_disc_by_vid(&self, volume_id: &[u8; 16]) -> Option<DiscEntry> {
self.0.iter().find_map(|p| p.lookup_disc_by_vid(volume_id))
}
}
// Bridges a single caller-supplied key's raw material into a KeyProvider, for
// `Disc::decrypt_with`. Fills only its own level (DK/PK/MK/VUK) so the resolver runs the
// matching path.
pub(crate) struct SuppliedKey {
pub device_keys: Vec<DeviceKey>,
pub processing_keys: Vec<[u8; 16]>,
pub media_keys: Vec<[u8; 16]>,
pub disc_entry: Option<DiscEntry>,
}
impl KeyProvider for SuppliedKey {
fn device_keys(&self) -> Vec<DeviceKey> {
self.device_keys.clone()
}
fn processing_keys(&self) -> Vec<[u8; 16]> {
self.processing_keys.clone()
}
fn media_keys(&self) -> Vec<[u8; 16]> {
self.media_keys.clone()
}
fn lookup_disc_by_hash(&self, _disc_hash: &[u8; 20]) -> Option<DiscEntry> {
self.disc_entry.clone()
}
fn lookup_disc_by_vid(&self, _volume_id: &[u8; 16]) -> Option<DiscEntry> {
self.disc_entry.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(hash: &str, vuk: u8) -> DiscEntry {
DiscEntry {
disc_hash: hash.to_string(),
title: "t".to_string(),
media_key: None,
disc_id: None,
vuk: Some([vuk; 16]),
unit_keys: Vec::new(),
}
}
/// A host cert whose (non-secret) certificate body and private key are both
/// filled with `byte`, so a cert is identifiable in an aggregated list.
fn cert(byte: u8) -> HostCert {
HostCert {
private_key: [byte; 20],
certificate: vec![byte; 92],
private_key_v2: None,
certificate_v2: None,
}
}
fn dk(byte: u8, node: u16) -> DeviceKey {
DeviceKey {
key: [byte; 16],
node,
uv: 1,
u_mask_shift: 0,
}
}
/// A provider that returns fixed bulk material and an optional disc entry
/// keyed unconditionally (used to test array-order short-circuiting).
#[derive(Default)]
struct Fixed {
dks: Vec<DeviceKey>,
pks: Vec<[u8; 16]>,
mks: Vec<[u8; 16]>,
hash_hit: Option<DiscEntry>,
vid_hit: Option<DiscEntry>,
}
impl KeyProvider for Fixed {
fn device_keys(&self) -> Vec<DeviceKey> {
self.dks.clone()
}
fn processing_keys(&self) -> Vec<[u8; 16]> {
self.pks.clone()
}
fn media_keys(&self) -> Vec<[u8; 16]> {
self.mks.clone()
}
fn lookup_disc_by_hash(&self, _h: &[u8; 20]) -> Option<DiscEntry> {
self.hash_hit.clone()
}
fn lookup_disc_by_vid(&self, _v: &[u8; 16]) -> Option<DiscEntry> {
self.vid_hit.clone()
}
}
// ── KeyProvider default methods all return empty ───────────────────────
#[test]
fn default_provider_methods_return_empty() {
// A bare provider that overrides nothing must yield empty material so
// the resolver simply finds nothing through it (no surprise hits).
struct Empty;
impl KeyProvider for Empty {}
let e = Empty;
assert!(e.device_keys().is_empty());
assert!(e.processing_keys().is_empty());
assert!(e.media_keys().is_empty());
assert!(e.host_certs().is_empty());
assert!(e.lookup_disc_by_hash(&[0u8; 20]).is_none());
assert!(e.lookup_disc_by_vid(&[0u8; 16]).is_none());
}
// ── Providers::processing_keys: union + dedup ──────────────────────────
#[test]
fn providers_processing_keys_union_and_dedup() {
// Two providers each carrying overlapping PKs → the aggregate is the
// deduped union (the resolver must not re-validate identical material).
let a = Fixed {
pks: vec![[0x01u8; 16], [0x02u8; 16]],
..Default::default()
};
let b = Fixed {
pks: vec![[0x02u8; 16], [0x03u8; 16]],
..Default::default()
};
let arr: &[&dyn KeyProvider] = &[&a, &b];
let mut got = Providers(arr).processing_keys();
got.sort();
assert_eq!(got, vec![[0x01u8; 16], [0x02u8; 16], [0x03u8; 16]]);
}
#[test]
fn providers_media_keys_union_and_dedup() {
let a = Fixed {
mks: vec![[0xAAu8; 16]],
..Default::default()
};
let b = Fixed {
mks: vec![[0xAAu8; 16], [0xBBu8; 16]],
..Default::default()
};
let arr: &[&dyn KeyProvider] = &[&a, &b];
let mut got = Providers(arr).media_keys();
got.sort();
assert_eq!(got, vec![[0xAAu8; 16], [0xBBu8; 16]]);
}
#[test]
fn providers_device_keys_dedup_on_value_tuple() {
// DeviceKey has no Hash/Ord; dedup keys on (key,node,uv,u_mask_shift).
// Two identical DKs across providers collapse to one; a DK differing
// only in node is kept.
let a = Fixed {
dks: vec![dk(0x11, 5), dk(0x11, 5)],
..Default::default()
};
let b = Fixed {
dks: vec![dk(0x11, 5), dk(0x11, 6)],
..Default::default()
};
let arr: &[&dyn KeyProvider] = &[&a, &b];
let got = Providers(arr).device_keys();
assert_eq!(got.len(), 2, "identical DKs dedup; differing node kept");
let nodes: Vec<u16> = got.iter().map(|d| d.node).collect();
assert!(nodes.contains(&5) && nodes.contains(&6));
}
// ── Disc-keyed lookups: array-order short-circuit ──────────────────────
#[test]
fn providers_lookup_by_hash_first_hit_wins() {
// Querying providers in array order, the FIRST hit wins (closest /
// fastest first). Provider 0 hits → its entry is returned even though
// provider 1 also has one.
let a = Fixed {
hash_hit: Some(entry("first", 0x01)),
..Default::default()
};
let b = Fixed {
hash_hit: Some(entry("second", 0x02)),
..Default::default()
};
let arr: &[&dyn KeyProvider] = &[&a, &b];
let got = Providers(arr).lookup_disc_by_hash(&[0u8; 20]).unwrap();
assert_eq!(got.disc_hash, "first");
assert_eq!(got.vuk, Some([0x01u8; 16]));
}
#[test]
fn providers_lookup_by_hash_falls_through_to_later_provider() {
// Provider 0 misses, provider 1 hits → the later provider's entry is
// used (find_map continues past None).
let a = Fixed::default(); // hash_hit None
let b = Fixed {
hash_hit: Some(entry("second", 0x02)),
..Default::default()
};
let arr: &[&dyn KeyProvider] = &[&a, &b];
let got = Providers(arr).lookup_disc_by_hash(&[0u8; 20]).unwrap();
assert_eq!(got.disc_hash, "second");
}
#[test]
fn providers_lookup_by_vid_first_hit_wins() {
let a = Fixed {
vid_hit: Some(entry("vid-a", 0x07)),
..Default::default()
};
let b = Fixed {
vid_hit: Some(entry("vid-b", 0x08)),
..Default::default()
};
let arr: &[&dyn KeyProvider] = &[&a, &b];
let got = Providers(arr).lookup_disc_by_vid(&[0u8; 16]).unwrap();
assert_eq!(got.disc_hash, "vid-a");
}
// `host_certs` unions across the provider array, unwired into the handshake today; asserts
// full concatenation in array order since HostCert has no Ord/Hash to dedup.
#[test]
fn providers_host_certs_unions_every_providers_certs_in_array_order() {
struct Certs(Vec<HostCert>);
impl KeyProvider for Certs {
fn host_certs(&self) -> Vec<HostCert> {
self.0.clone()
}
}
// Distinguish certs by their (non-secret) certificate body, so the
// assertion lands on WHICH certs came back, not merely how many.
let a = Certs(vec![cert(0xA1), cert(0xA2)]);
let b = Certs(vec![cert(0xB1)]);
let arr: &[&dyn KeyProvider] = &[&a, &b];
let got = Providers(arr).host_certs();
let bodies: Vec<Vec<u8>> = got.iter().map(|c| c.certificate.clone()).collect();
assert_eq!(
bodies,
vec![vec![0xA1u8; 92], vec![0xA2u8; 92], vec![0xB1u8; 92]],
"every provider's certs must survive the union, in array order"
);
// The private key travels with the cert — a union that returned default
// certs would still have the right count.
assert_eq!(got[0].private_key, [0xA1u8; 20]);
assert_eq!(got[2].private_key, [0xB1u8; 20]);
}
#[test]
fn providers_empty_array_yields_nothing() {
let arr: &[&dyn KeyProvider] = &[];
let p = Providers(arr);
assert!(p.device_keys().is_empty());
assert!(p.processing_keys().is_empty());
assert!(p.media_keys().is_empty());
assert!(p.lookup_disc_by_hash(&[0u8; 20]).is_none());
assert!(p.lookup_disc_by_vid(&[0u8; 16]).is_none());
}
// ── SuppliedKey: each level exposes only its own material ──────────────
#[test]
fn supplied_key_exposes_only_populated_fields() {
// A SuppliedKey filled at the DK level exposes DKs and nothing else,
// so the resolver runs the matching (DK→…) path and no other.
let sk = SuppliedKey {
device_keys: vec![dk(0x33, 9)],
processing_keys: Vec::new(),
media_keys: Vec::new(),
disc_entry: None,
};
assert_eq!(sk.device_keys().len(), 1);
assert!(sk.processing_keys().is_empty());
assert!(sk.media_keys().is_empty());
assert!(sk.lookup_disc_by_hash(&[0u8; 20]).is_none());
assert!(sk.lookup_disc_by_vid(&[0u8; 16]).is_none());
}
#[test]
fn supplied_key_disc_entry_returned_for_any_hash_or_vid() {
// decrypt_with already knows the disc, so a present disc_entry is
// returned regardless of the hash/VID argument (the lookup args are
// irrelevant in this bridge).
let sk = SuppliedKey {
device_keys: Vec::new(),
processing_keys: Vec::new(),
media_keys: Vec::new(),
disc_entry: Some(entry("supplied", 0x44)),
};
// Two unrelated hashes both return the same entry.
let h1 = sk.lookup_disc_by_hash(&[0x01u8; 20]).unwrap();
let h2 = sk.lookup_disc_by_hash(&[0xFFu8; 20]).unwrap();
assert_eq!(h1.disc_hash, "supplied");
assert_eq!(h2.disc_hash, "supplied");
// And by VID likewise.
assert!(sk.lookup_disc_by_vid(&[0x00u8; 16]).is_some());
}
}
-2148
View File
File diff suppressed because it is too large Load Diff
+7 -258
View File
@@ -16,7 +16,7 @@
/// Fixed size of one `IndividualSegment.tbl` record.
pub const SEGMENT_RECORD_LEN: usize = 16;
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
pub const SOURCE_PACKET_LEN: u64 = 192;
pub const SOURCE_PACKET_LEN: u64 = crate::consts::BD_SOURCE_PACKET_BYTES as u64;
/// One forensic segment: the inclusive source-packet range it occupies in the
/// FMTS clip.
@@ -73,7 +73,7 @@ pub const PACKETS_PER_UNIT: u32 =
/// byte and this offset lines up with the source-packet grid the segment map
/// uses.
pub fn lba_byte_offset(lba: u32) -> u64 {
lba as u64 * 2048
lba as u64 * crate::consts::SECTOR_BYTES_U64
}
/// The forensic segment an AACS aligned unit belongs to, if any, given the
@@ -149,8 +149,9 @@ pub fn clip_byte_to_lba(extents: &[crate::disc::Extent], clip_byte: u64) -> Opti
/// segment are left for the map's default (the Unit Key). A segment straddling a UDF extent
/// boundary is skipped rather than emitting a wrong span.
///
/// Feeds [`AacsKeyMap::from_ranges`](crate::decrypt::AacsKeyMap::from_ranges)
/// with the Unit-Key index as the default.
/// Not used by the live FMTS path (`keys::fmts` + `mux::resolve`); kept as a public
/// helper for callers that build their own key map from the segment table.
#[doc(hidden)]
pub fn fmts_key_ranges(
segments: &[Segment],
extents: &[crate::disc::Extent],
@@ -187,257 +188,5 @@ pub fn fmts_key_ranges(
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
/// records. `recs` are `(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 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);
}
}
#[path = "segment_tests.rs"]
mod tests;
+2 -59
View File
@@ -91,62 +91,5 @@ impl<'a> SegmentKeyTable<'a> {
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a container with `record_size` and the given `index_space`, filling
/// each record with a distinguishable byte so lookups can be checked.
fn build(index_space: u16, record_size: u16) -> Vec<u8> {
let count = if index_space == 0xffff {
0x1_0000
} else {
index_space as usize
};
let mut v = Vec::with_capacity(HEADER_LEN + count * record_size as usize);
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // tag
v.extend_from_slice(&index_space.to_be_bytes());
v.extend_from_slice(&record_size.to_be_bytes());
for i in 0..count {
let mut rec = vec![(i & 0xff) as u8; record_size as usize];
// sub-header, as seen on disc
rec[..8].copy_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
v.extend_from_slice(&rec);
}
v
}
#[test]
fn parses_retail_container_geometry() {
// The real disc: 0xffff index space, 536-byte records, 35,127,304 total.
let data = build(0xffff, 536);
assert_eq!(
data.len(),
35_127_304,
"matches the retail file size exactly"
);
let t = SegmentKeyTable::parse(&data).expect("parse");
assert_eq!(t.record_count(), 65_536);
assert_eq!(t.record_size(), 536);
let rec = t.record(0x1234).expect("record");
assert_eq!(rec.len(), 536);
assert_eq!(&rec[..8], &[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
assert_eq!(t.record_payload(0x1234).unwrap().len(), 528);
}
#[test]
fn small_index_space_bounds_lookups() {
let data = build(4, 32);
let t = SegmentKeyTable::parse(&data).expect("parse");
assert_eq!(t.record_count(), 4);
assert!(t.record(3).is_some());
assert!(t.record(4).is_none(), "selector past the table is None");
}
#[test]
fn rejects_size_mismatch_and_truncation() {
assert!(SegmentKeyTable::parse(&[0u8; 4]).is_none());
let mut data = build(4, 32);
data.truncate(data.len() - 1); // body no longer matches header
assert!(SegmentKeyTable::parse(&data).is_none());
}
}
#[path = "segment_key_tests.rs"]
mod tests;
+58
View File
@@ -0,0 +1,58 @@
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
@@ -0,0 +1,294 @@
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);
}
+29 -35
View File
@@ -129,6 +129,10 @@ pub enum KeyNode {
DerivedUnitKeys,
}
/// [`KeyStep::who`] of the single step recorded when no source was consulted because
/// the disc is bus-blocked (its reason is on `Disc::aacs_error`).
pub const BUS_BLOCKED: &str = "bus_blocked";
/// The terminal outcome of a source's resolution attempt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyOutcome {
@@ -140,39 +144,29 @@ pub enum KeyOutcome {
NoKey,
}
#[cfg(test)]
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");
// Default / new is empty.
assert_eq!(ResolutionTrace::new(), ResolutionTrace::default());
assert!(ResolutionTrace::new().unlock.is_empty());
assert!(ResolutionTrace::new().keys.is_empty());
}
/// Where an acquired key came from, for the ring's trace and the engine's key status.
/// Variants are ordered root-of-trust → per-disc leaf.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyOrigin {
/// MKB + device keys → subset-difference tree → VUK
DeviceKey,
/// MKB + processing keys → media key → VUK
ProcessingKey,
/// Media key + Volume ID from KEYDB → derived VUK
KeyDbDerived,
/// VUK found directly in KEYDB by disc hash
KeyDb,
/// Pre-decrypted unit keys taken directly from KEYDB by disc hash.
/// No VUK present in the entry — `AacsState::vuk` is `None`.
KeyDbUnitKeys,
/// Unit key supplied directly by the caller (the external Unit Key path).
/// No keydb, no derivation — `AacsState::vuk` is `None`.
ExternalUk,
}
// No `KeyOrigin::name()`: the library holds ZERO user-facing English. Callers
// map variants to display text (see freemkv's `disc_info::key_origin_label`).
#[cfg(test)]
#[path = "trace_tests.rs"]
mod tests;
+35
View File
@@ -0,0 +1,35 @@
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());
}
+6 -125
View File
@@ -17,9 +17,9 @@ pub struct DeviceKey {
/// Host certificate + private key for AACS SCSI authentication.
#[derive(Clone)]
pub struct HostCert {
/// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes.
/// AACS 1.0 host private key, 20 bytes (AACS 2.0 uses `private_key_v2`).
pub private_key: [u8; 20],
/// AACS 1.0: 92 bytes. AACS 2.0: 132 bytes.
/// AACS 1.0 host certificate, 92 bytes (AACS 2.0 uses `certificate_v2`).
pub certificate: Vec<u8>,
/// AACS 2.0 host private key (P-256, 32 bytes). None for AACS 1.0 only.
pub private_key_v2: Option<[u8; 32]>,
@@ -188,128 +188,9 @@ impl std::fmt::Debug for DiscEntry {
}
#[cfg(test)]
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()));
}
}
#[path = "types_unit_key_tests.rs"]
mod unit_key_tests;
#[cfg(test)]
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:?}"));
}
}
#[path = "types_redaction_tests.rs"]
mod redaction_tests;
+65
View File
@@ -0,0 +1,65 @@
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
@@ -0,0 +1,54 @@
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()));
}
+88 -1406
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -207
View File
@@ -24,7 +24,7 @@ pub(crate) struct BdjoApp {
pub control_code: u8,
/// `base_directory` — the primary jar id (e.g. "00000"), naming
/// `/BDMV/JAR/<base_directory>.jar`.
pub base_dir: String,
pub base_directory: String,
/// `classpath_extension` — additional `;`-separated jar ids on the
/// classpath, or empty.
pub classpath_extension: String,
@@ -44,7 +44,7 @@ impl BdjoApp {
/// dropped. These name `/BDMV/JAR/<id>.jar`.
pub fn jar_ids(&self) -> Vec<String> {
let mut out = Vec::new();
let base = self.base_dir.trim();
let base = self.base_directory.trim();
if !base.is_empty() {
out.push(base.to_string());
}
@@ -65,10 +65,6 @@ const AUTOSTART: u8 = 1;
// gracefully rather than mis-reject a readable table.
const MAGIC: u32 = u32::from_be_bytes(*b"BDJO");
// Cap on applications parsed from one AMT. `number_of_applications` is a u8, so
// 255 is the hard ceiling already; the cap is a belt-and-suspenders bound.
const MAX_APPS: usize = 256;
/// Parse a `.bdjo` file's Application Management Table. Returns the list of
/// application records (in file order), or `None` on any structural problem.
pub(crate) fn parse(data: &[u8]) -> Option<Vec<BdjoApp>> {
@@ -108,9 +104,7 @@ pub(crate) fn parse(data: &[u8]) -> Option<Vec<BdjoApp>> {
r.skip(32)?; // length
let num_app = r.read(8)? as usize;
r.skip(8)?; // padding
if num_app > MAX_APPS {
return None;
}
// num_app is a u8 read, so it is bounded at 255 by construction.
let mut apps = Vec::with_capacity(num_app);
for _ in 0..num_app {
apps.push(parse_app(&mut r)?);
@@ -148,7 +142,7 @@ fn parse_app(r: &mut BitReader<'_>) -> Option<BdjoApp> {
read_app_string(r)?;
r.skip(16)?;
let base_dir = read_app_string(r)?;
let base_directory = read_app_string(r)?;
let classpath_extension = read_app_string(r)?;
let initial_class = read_app_string(r)?;
@@ -161,7 +155,7 @@ fn parse_app(r: &mut BitReader<'_>) -> Option<BdjoApp> {
Some(BdjoApp {
control_code,
base_dir,
base_directory,
classpath_extension,
initial_class,
})
@@ -233,199 +227,5 @@ impl<'a> BitReader<'a> {
}
#[cfg(test)]
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,
}
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> {
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)=0 + pad(1)
b.extend_from_slice(&[0u8; 4]);
b.push(0);
b.push(0);
// AccessiblePlaylists: length(4) + [num_pl(11)+flags(2)+pad(19)] = 4 bytes, num_pl=0
b.extend_from_slice(&[0u8; 4]);
b.extend_from_slice(&[0u8; 4]);
// 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)
b.push(0); // num_profile(4)+pad(4)... wait 4+12=16 bits
b.push(0); // (num_profile nibble high, then 12 bits pad) => 2 bytes, 0 profiles
b.push(0); // priority(8)
b.push(0); // binding(2)+visibility(2)+reserved(4)
// application_name: data_length(16)=0, no word-align (even 0)
b.extend_from_slice(&[0u8; 2]);
// 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)=0 + word-align (even) pad
b.push(0);
b.push(0);
}
b
}
#[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_dir, "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 (or Some), never panic.
for cut in 0..bytes.len() {
let _ = parse(&bytes[..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_dir: "00000".into(),
classpath_extension: "00000;;00003; ".into(),
initial_class: "X".into(),
};
assert_eq!(a.jar_ids(), vec!["00000", "00003"]);
}
}
#[path = "bdjo_tests.rs"]
mod tests;
+283
View File
@@ -0,0 +1,283 @@
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"]);
}
+5 -68
View File
@@ -3,6 +3,8 @@
//! documented binary format, never executed: every field is bounds-checked and
//! any malformed input yields `None` (the nav resolver then abstains).
use super::be_u16;
/// One playback/title object in `index.bdmv`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PlaybackObj {
@@ -29,9 +31,6 @@ const OBJ_LEN: usize = 12;
/// Sanity cap on the title count (real discs have well under this).
const MAX_TITLES: usize = 4096;
fn be_u16(d: &[u8], o: usize) -> Option<u16> {
Some(u16::from_be_bytes([*d.get(o)?, *d.get(o + 1)?]))
}
fn be_u32(d: &[u8], o: usize) -> Option<u32> {
Some(u32::from_be_bytes([
*d.get(o)?,
@@ -61,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"/"0300") is not load-bearing for resolution.
// version at 4..8 ("0100"/"0200"/"0240"/"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)?;
@@ -90,67 +89,5 @@ pub(crate) fn parse(d: &[u8]) -> Option<Index> {
}
#[cfg(test)]
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"
);
}
}
#[path = "index_tests.rs"]
mod tests;
+81
View File
@@ -0,0 +1,81 @@
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);
}
+4 -72
View File
@@ -7,6 +7,8 @@
//! byte 50. Each object is `flags`(1) + reserved(1) + `num_cmds`(u16) followed
//! by `num_cmds` 12-byte navigation commands.
use super::be_u16;
// One decoded 12-byte navigation command.
#[derive(Debug, Clone, Copy)]
pub(crate) struct Cmd {
@@ -36,10 +38,6 @@ const CMD_LEN: usize = 12;
/// separate cap would be dead code.
const MAX_OBJECTS: usize = 4096;
fn be_u16(d: &[u8], o: usize) -> Option<u16> {
Some(u16::from_be_bytes([*d.get(o)?, *d.get(o + 1)?]))
}
/// Decode one 12-byte command. Caller guarantees `b.len() == 12`.
fn decode_cmd(b: &[u8]) -> Cmd {
let (b0, b1, b2, b3) = (b[0], b[1], b[2], b[3]);
@@ -90,71 +88,5 @@ pub(crate) fn parse(d: &[u8]) -> Option<Vec<MovieObject>> {
}
#[cfg(test)]
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.
}
#[path = "mobj_tests.rs"]
pub(crate) mod tests;
+75
View File
@@ -0,0 +1,75 @@
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));
}
+6 -108
View File
@@ -14,6 +14,10 @@ pub(crate) mod vm;
use crate::sector::SectorSource;
use crate::udf::UdfFs;
pub(super) fn be_u16(d: &[u8], o: usize) -> Option<u16> {
Some(u16::from_be_bytes([*d.get(o)?, *d.get(o + 1)?]))
}
// Resolve the playlist id First-Play navigation plays as the feature, among ids the caller
// marks as feature candidates (`is_feature_candidate`). Returns `None` for BD-J discs,
// malformed nav data, or non-convergence.
@@ -43,111 +47,5 @@ fn resolve_inner(
}
#[cfg(test)]
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);
}
}
#[path = "mod_tests.rs"]
mod tests;
+106
View File
@@ -0,0 +1,106 @@
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);
}
+47 -263
View File
@@ -62,9 +62,29 @@ struct Vm<'a> {
index: &'a Index,
gpr: [u32; 4096],
psr: [u32; 128],
// Per-GPR: value derived from RND, so any branch on it is undecidable.
gpr_random: Box<[bool; 4096]>,
}
impl Vm<'_> {
impl<'a> Vm<'a> {
fn new(mobjs: &'a [MovieObject], index: &'a Index) -> Self {
Self {
mobjs,
index,
gpr: [0; 4096],
psr: psr_init(),
gpr_random: Box::new([false; 4096]),
}
}
// Whether a non-immediate operand reads an RND-derived GPR.
fn random(&self, imm: bool, raw: u32) -> bool {
!imm && raw & PSR_FLAG == 0 && self.gpr_random[(raw & 0xfff) as usize]
}
fn set_random(&mut self, raw: u32, r: bool) {
if raw & PSR_FLAG == 0 {
self.gpr_random[(raw & 0xfff) as usize] = r;
}
}
fn rd(&self, val: u32) -> u32 {
if val & PSR_FLAG != 0 {
self.psr[(val & 0x7f) as usize]
@@ -115,12 +135,7 @@ pub(crate) fn resolve(
PlaybackObj::Hdmv { id_ref } if (id_ref as usize) < mobjs.len() => id_ref as usize,
_ => return None,
};
let mut vm = Vm {
mobjs,
index,
gpr: [0; 4096],
psr: psr_init(),
};
let mut vm = Vm::new(mobjs, index);
run(&mut vm, start, is_feature)
}
@@ -147,6 +162,12 @@ fn run(vm: &mut Vm, mut obj_id: usize, is_feature: &dyn Fn(u16) -> bool) -> Opti
} else {
0
};
let dst_rnd = c.op_cnt > 0 && vm.random(c.imm_op1, c.dst);
let src_rnd = c.op_cnt > 1 && vm.random(c.imm_op2, c.src);
// A branch, play or compare on an RND-derived value is undecidable.
if (c.grp == 0 && dst_rnd) || (c.grp == 1 && (dst_rnd || src_rnd)) {
return None;
}
match c.grp {
// BRANCH
0 => match c.sub_grp {
@@ -193,8 +214,10 @@ fn run(vm: &mut Vm, mut obj_id: usize, is_feature: &dyn Fn(u16) -> bool) -> Opti
// rest — Terminate / Link — fall through). A logo/pre-roll whose
// id isn't a feature candidate lets autoplay resume at pc + 1.
2 if matches!(c.branch_opt, 0x00..=0x02) => {
let id = dst as u16;
if is_feature(id) {
// A playlist id is 16-bit; an operand that doesn't fit is not one.
if let Ok(id) = u16::try_from(dst)
&& is_feature(id)
{
return Some(id);
}
}
@@ -203,7 +226,7 @@ fn run(vm: &mut Vm, mut obj_id: usize, is_feature: &dyn Fn(u16) -> bool) -> Opti
// CMP — skip the next command when the compare is false.
1 => {
let truth = match c.cmp_opt {
0x01 => (dst & !src) == 0,
0x01 => (dst & !src) == 0, // BC: every dst bit set in src (hdmv_vm.c)
0x02 => dst == src,
0x03 => dst != src,
0x04 => dst >= src,
@@ -227,31 +250,36 @@ fn run(vm: &mut Vm, mut obj_id: usize, is_feature: &dyn Fn(u16) -> bool) -> Opti
// PSR stores.
if !c.imm_op1 {
vm.wr(c.dst, src);
vm.set_random(c.dst, src_rnd);
}
if !c.imm_op2 {
vm.wr(c.src, dst);
vm.set_random(c.src, dst_rnd);
}
None
}
0x03 => Some(dst.wrapping_add(src)),
0x03 => Some(dst.saturating_add(src)), // libbluray ADD_u32 saturates
0x04 => Some(dst.saturating_sub(src)),
0x05 => Some(dst.wrapping_mul(src)),
0x05 => Some(dst.saturating_mul(src)), // libbluray MUL_u32 saturates
0x06 => Some(dst.checked_div(src).unwrap_or(0xffff_ffff)),
0x07 => Some(dst.checked_rem(src).unwrap_or(0xffff_ffff)),
0x08 => Some(dst), // RND — deterministic stand-in
0x08 => Some(dst), // RND: value unknown, tainted below
0x09 => Some(dst & src),
0x0a => Some(dst | src),
0x0b => Some(dst ^ src),
0x0c => Some(dst | (1u32 << (src & 31))),
0x0d => Some(dst & !(1u32 << (src & 31))),
0x0e => Some(dst.wrapping_shl(src & 31)),
0x0f => Some(dst.wrapping_shr(src & 31)),
// libbluray: bit numbers / shift counts >= 32 are not masked.
0x0c => Some(1u32.checked_shl(src).map_or(dst, |b| dst | b)),
0x0d => Some(1u32.checked_shl(src).map_or(dst, |b| dst & !b)),
0x0e => Some(dst.checked_shl(src).unwrap_or(0)),
0x0f => Some(dst.checked_shr(src).unwrap_or(0)),
_ => None,
};
if let Some(r) = r
&& !c.imm_op1
{
vm.wr(c.dst, r);
let rnd = c.set_opt == 0x08 || src_rnd || (c.set_opt != 0x01 && dst_rnd);
vm.set_random(c.dst, rnd);
}
}
_ => {}
@@ -261,249 +289,5 @@ fn run(vm: &mut Vm, mut obj_id: usize, is_feature: &dyn Fn(u16) -> bool) -> Opti
}
#[cfg(test)]
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 {
mobjs: &[],
index: &index,
gpr: [0; 4096],
psr: psr_init(),
};
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));
}
#[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 {
mobjs: &mobjs,
index: &index,
gpr: [0; 4096],
psr: psr_init(),
};
// 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 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 {
mobjs: &mobjs,
index: &index,
gpr: [0; 4096],
psr: psr_init(),
};
// 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");
}
}
#[path = "vm_tests.rs"]
mod tests;
+472
View File
@@ -0,0 +1,472 @@
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}");
}
}
+16 -363
View File
@@ -34,31 +34,26 @@ pub(crate) struct ClpiStream {
pub language: String,
}
/// Parse a CLPI file from raw bytes.
/// Parse a CLPI file from raw bytes. A file too short to hold the packet count still
/// parses (count 0); only a bad magic or a head under 16 bytes errors.
pub fn parse(data: &[u8]) -> Result<ClipInfo> {
if data.len() < 40 {
return Err(Error::ClpiParse);
}
if &data[0..4] != b"HDMV" {
// 16 bytes reach the section offsets; below that, nothing usable.
if data.len() < 16 || &data[0..4] != b"HDMV" {
return Err(Error::ClpiParse);
}
// Header offsets
let _seq_info_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
let prog_info_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
// ClipInfo section at offset 40
// source_packet_count at offset 40 + 4(len) + 2(reserved) + 1(stream_type) + 1(app_type) + 4(reserved) + 4(ts_rate)
let source_packet_count = if data.len() >= 60 {
u32::from_be_bytes([data[56], data[57], data[58], data[59]])
} else {
0
};
// A file cut before [56..60] keeps its streams and reports 0 (unknown) packets.
let source_packet_count = data
.get(56..60)
.map_or(0, |b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]));
// Parse ProgramInfo (per-stream language + codec), best-effort: malformed
// program_info doesn't fail the parse, just yields an empty streams list.
// EP map is unaffected — sector-range lookups still work.
let streams = if prog_info_start > 0 && prog_info_start + 6 < data.len() {
parse_program_info(&data[prog_info_start..])
} else {
@@ -72,7 +67,7 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
}
// Parse the ProgramInfo section: per-stream (pid, coding_type, language, codec sub-fields).
// Returns Vec::new() on any structural mismatch.
// On a structural mismatch, returns the fully validated entries parsed so far.
fn parse_program_info(data: &[u8]) -> Vec<ClpiStream> {
use crate::consts::coding_type as c;
let mut out = Vec::new();
@@ -112,16 +107,15 @@ fn parse_program_info(data: &[u8]) -> Vec<ClpiStream> {
// Video — MPEG-2, H.264, HEVC
c::MPEG2_VIDEO | c::H264 | c::HEVC => {}
// Primary audio — LPCM, AC-3, DTS, TrueHD, AC-3+, DTS-HD HR, DTS-HD MA
c::LPCM..=c::DTS_HD_MA => {
// and secondary audio (AC-3+ secondary, DTS-HD secondary)
c::LPCM..=c::DTS_HD_MA | c::AC3_PLUS_SECONDARY | c::DTS_HD_SECONDARY => {
if sci.len() >= 5 {
language = String::from_utf8_lossy(&sci[2..5]).to_string();
}
}
// Secondary audio (AC-3+ secondary, DTS-HD secondary)
c::AC3_PLUS_SECONDARY | c::DTS_HD_SECONDARY => {
if sci.len() >= 5 {
language = String::from_utf8_lossy(&sci[2..5]).to_string();
}
// TextST: coding_type + character_code + 3-byte language
c::TEXT_SUBTITLE if sci.len() >= 5 => {
language = String::from_utf8_lossy(&sci[2..5]).to_string();
}
// PG, IG: coding_type + 3-byte language [+ char_code for PG]
c::PG | c::IG if sci.len() >= 4 => {
@@ -143,346 +137,5 @@ fn parse_program_info(data: &[u8]) -> Vec<ClpiStream> {
}
#[cfg(test)]
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_no_panic() {
// 57/58/59-byte CLPI with valid magic: passes the data.len() < 40
// guard but data[56..60] needs 60 bytes. Must not panic.
for len in 40..60usize {
let mut data = vec![0u8; len];
data[0..4].copy_from_slice(b"HDMV");
if len >= 8 {
data[4..8].copy_from_slice(b"0200");
}
let clip = parse(&data).expect("short CLPI should parse, not panic");
// source_packet_count is unreadable below 60 bytes → 0.
assert_eq!(clip.source_packet_count, 0);
}
}
#[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());
}
/// Under-40-byte input is rejected before any field read
/// (`data.len() < 40` guard).
#[test]
fn under_40_bytes_rejected() {
assert!(parse(&[0u8; 39]).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");
}
/// 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());
}
// ─────────────────────────────────────────────────────────────────────
// parse_cpi — low-level fixtures
// ─────────────────────────────────────────────────────────────────────
}
#[path = "clpi_tests.rs"]
mod tests;
+402
View File
@@ -0,0 +1,402 @@
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
// ─────────────────────────────────────────────────────────────────────
+6 -2
View File
@@ -105,12 +105,16 @@ pub mod coding_type {
/// 13818-1 Table 2-22). Shared by the program-stream demuxer and the TS/M2TS
/// muxers, so defined here once. Each is `u8` (matches the byte on the wire).
pub mod pes_stream_id {
/// Video stream (`110x xxxx`; freemkv emits the base id `0xE0`).
/// Video stream (`1110 xxxx`; freemkv emits the base id `0xE0`).
pub const VIDEO: u8 = 0xE0;
/// MPEG audio stream (`110x xxxx`; 11172-3/13818-3/13818-7, freemkv emits `0xC0`).
pub const MPEG_AUDIO: u8 = 0xC0;
/// system_header start code — the MPEG-PS `00 00 01 BB` structural header
/// (rate/bound bounds), never an elementary stream. On a DVD NAV pack it
/// follows the pack header, so it lands at sector offset 0x11.
pub const SYSTEM_HEADER: u8 = 0xBB;
/// program_stream_map — the `00 00 01 BC` structural table (MS-8).
pub const PROGRAM_STREAM_MAP: u8 = 0xBC;
/// private_stream_1 — AC-3 / DTS / LPCM / PGS subtitle payloads.
pub const PRIVATE_STREAM_1: u8 = 0xBD;
/// padding_stream — stuffing bytes only, no payload to demux.
@@ -118,7 +122,7 @@ pub mod pes_stream_id {
/// private_stream_2 — DVD navigation (PCI/DSI); carries no muxable ES.
pub const PRIVATE_STREAM_2: u8 = 0xBF;
/// Highest video stream_id — the `110x xxxx` video range tops out at 0xEF.
/// Highest video stream_id — the `1110 xxxx` video range tops out at 0xEF.
pub const VIDEO_MAX: u8 = 0xEF;
/// Inclusive range of every PES `stream_id` that carries demuxable payload:
+18 -699
View File
@@ -144,11 +144,10 @@ fn recover_title_key_from_plain(
/// `plain` is the expected plaintext at byte 0x80 (≥10 bytes); returns the key only
/// if it descrambles the sector back to `plain`, guarding a rare spurious LFSR match.
///
/// TEST-ONLY: the production crack path uses [`crack_title_key`], which derives
/// its own crib. `recover_title_key` is a known-plaintext helper exercised only
/// by this module's unit tests and `tests/crypto_tests.rs`. It stays `pub` so
/// that integration test (a separate crate linking the non-`cfg(test)` build)
/// can reach it — do NOT gate it behind `#[cfg(test)]`, which would break it.
/// TEST-ONLY: production cracks via [`crack_title_key`], which derives its own
/// crib. This helper is used by unit tests and `tests/crypto_tests.rs`; it stays
/// `pub` (not `#[cfg(test)]`) so that separate integration-test crate can reach it.
#[doc(hidden)]
pub fn recover_title_key(sector: &[u8], plain: &[u8]) -> Option<[u8; 5]> {
if sector.len() < SECTOR_BYTES || plain.len() < 10 {
return None;
@@ -157,13 +156,7 @@ pub fn recover_title_key(sector: &[u8], plain: &[u8]) -> Option<[u8; 5]> {
return None;
}
let seed: [u8; 5] = [
sector[SEED_OFFSET],
sector[SEED_OFFSET + 1],
sector[SEED_OFFSET + 2],
sector[SEED_OFFSET + 3],
sector[SEED_OFFSET + 4],
];
let seed = sector_seed(sector);
let crypted = &sector[ENCRYPTED_START..ENCRYPTED_START + 10];
let key = recover_title_key_from_plain(crypted, plain, &seed)?;
@@ -175,6 +168,12 @@ pub fn recover_title_key(sector: &[u8], plain: &[u8]) -> Option<[u8; 5]> {
}
}
fn sector_seed(sector: &[u8]) -> [u8; 5] {
let mut seed = [0u8; 5];
seed.copy_from_slice(&sector[SEED_OFFSET..SEED_OFFSET + 5]);
seed
}
/// Verify a title key by descrambling a copy of `sector` and checking the
/// known plaintext reappears at byte 0x80.
fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool {
@@ -185,13 +184,9 @@ fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool {
test[ENCRYPTED_START..ENCRYPTED_START + n] == plain[..n]
}
/// Find a repeating pattern just before the encrypted region and assume the
/// plaintext at 0x80 continues it — the known-plaintext step of the recovery.
/// Scans cleartext `sec[0x00..0x80]` for the longest run that repeats
/// with a cycle length in 2..0x2F. If the run is long enough (`plen > 3` and at
/// least two full cycles), the known plaintext at 0x80 is taken to be the
/// periodic run continuing forward, and [`recover_title_key_from_plain`] is
/// applied.
/// Crack the CSS title key from one scrambled sector with no external crib.
/// The crib is the periodic cleartext run before 0x80 (see `attack_crib`),
/// continued into the encrypted region and fed to `recover_title_key_from_plain`.
pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
if sector.len() < SECTOR_BYTES {
return None;
@@ -263,14 +258,8 @@ pub(crate) fn attack_crib(sector: &[u8]) -> Option<[u8; 10]> {
fn crack_title_key_inner(sector: &[u8]) -> Option<[u8; 5]> {
let plain = attack_crib(sector)?;
let seed: [u8; 5] = [
sector[SEED_OFFSET],
sector[SEED_OFFSET + 1],
sector[SEED_OFFSET + 2],
sector[SEED_OFFSET + 3],
sector[SEED_OFFSET + 4],
];
let crypted = &sector[0x80..0x80 + 10];
let seed = sector_seed(sector);
let crypted = &sector[ENCRYPTED_START..ENCRYPTED_START + 10];
if let Some(key) = recover_title_key_from_plain(crypted, &plain, &seed) {
// Verify against the same predicted plaintext.
if descramble_matches(sector, &key, &plain) {
@@ -281,675 +270,5 @@ fn crack_title_key_inner(sector: &[u8]) -> Option<[u8; 5]> {
}
#[cfg(test)]
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[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"
);
}
}
}
}
#[path = "keyless_tests.rs"]
mod tests;
+660
View File
@@ -0,0 +1,660 @@
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"
);
}
}
}
+7 -331
View File
@@ -12,19 +12,13 @@ use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
/// Descramble a CSS-encrypted DVD sector in place.
///
/// Registers are seeded from `title_key XOR sector_seed` (bytes
/// `0x54..0x59`). Only the body, bytes `0x80..0x800`, is transformed:
/// `body[i] = TAB1[body[i]] ^ (keystream & 0xff)`. The scramble flag at byte
/// `0x14` (bits 4-5) is CLEARED after unscrambling, so a descrambled sector
/// reads as `sector[0x14] & 0x30 == 0`.
/// Seeded from `title_key XOR sector_seed` (bytes `0x54..0x59`); transforms only
/// the body `0x80..0x800`: `body[i] = TAB1[body[i]] ^ (keystream & 0xff)`.
/// Flag bits 4-5 at byte `0x14` are cleared afterwards.
///
/// No-op (returns without modifying `sector`) if `sector.len() < 2048` or the scramble flag
/// bits are already zero.
/// No-op if `sector.len() < 2048` or the scramble flag bits are already zero.
#[doc(hidden)]
pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
debug_assert!(
sector.len() >= 2048,
"descramble_sector: buffer shorter than one 2048-byte sector"
);
if sector.len() < 2048 {
return;
}
@@ -125,323 +119,5 @@ static TAB1_INV: std::sync::LazyLock<[u8; 256]> = std::sync::LazyLock::new(|| {
});
#[cfg(test)]
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 the implementation's deterministic output. key = 42 13 37 BE
// EF, seed = DE AD BE EF 42, body = 0xAA.
#[test]
fn descramble_produces_the_reference_css_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 must match the reference CSS vector"
);
assert_eq!(
&sector[0x7F8..0x800],
&[0x46, 0x94, 0x80, 0x0E, 0x67, 0x36, 0x65, 0xBC],
"descramble body tail must match the reference CSS vector"
);
}
#[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 used in key mangling; verify it is a
// permutation of 0..255 (no two inputs map to the same output).
#[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;
}
// Check involution property: TAB1[TAB1[x]] should map back predictably
// TAB1 is not necessarily a strict involution, but we verify the
// composition TAB1[TAB1[x]] is also a permutation
let mut seen2 = [false; 256];
for i in 0..256 {
let v = TAB1[TAB1[i] as usize] as usize;
assert!(!seen2[v], "TAB1[TAB1[x]] maps two inputs to {:#04x}", v);
seen2[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"
);
}
}
#[path = "lfsr_tests.rs"]
mod tests;
+308
View File
@@ -0,0 +1,308 @@
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"
);
}
+228 -1138
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
+2 -116
View File
@@ -133,119 +133,5 @@ pub const TAB5: [u8; 256] = [
];
#[cfg(test)]
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");
}
// 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");
}
}
#[path = "tables_tests.rs"]
mod tests;
+128
View File
@@ -0,0 +1,128 @@
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");
}
+139
View File
@@ -0,0 +1,139 @@
//! The one run context (pipeline design §2.5): every stage of a run reads its stop token,
//! reports through its events and counts loss into its stats.
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::event::{Event, Events, NoEvents};
use crate::halt::Halt;
/// What every stage of one run shares: the stop token, the event sink, the loss counters
/// and the diagnostic switches. Clones share all of them, so a clone observes the same run.
#[derive(Clone)]
pub struct Ctx {
/// The run's one stop token. A stage that observes it returns [`Error::Halted`](crate::Error::Halted).
pub halt: Halt,
/// Where every stage reports progress.
pub events: Arc<dyn Events>,
/// Run-wide loss counters, cumulative over every stage built with this context.
pub stats: Arc<Stats>,
/// Developer diagnostics, fixed for the run.
pub diag: Diag,
}
impl Ctx {
/// A context over `halt` with no events, fresh stats and diagnostics off.
pub fn new(halt: Halt) -> Self {
Self {
halt,
events: Arc::new(NoEvents),
stats: Arc::default(),
diag: Diag::default(),
}
}
/// This context reporting to `events`.
pub fn with_events(mut self, events: Arc<dyn Events>) -> Self {
self.events = events;
self
}
/// This context with diagnostics `diag`.
pub fn with_diag(mut self, diag: Diag) -> Self {
self.diag = diag;
self
}
pub(crate) fn emit(&self, e: Event<'_>) {
self.events.event(&e);
}
}
impl Default for Ctx {
fn default() -> Self {
Self::new(Halt::new())
}
}
impl std::fmt::Debug for Ctx {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Ctx")
.field("halt", &self.halt)
.field("stats", &self.stats)
.field("diag", &self.diag)
.finish_non_exhaustive()
}
}
/// Loss counters every source reports through, so a count means the same thing on every
/// path. Atomic: the read producer and the mux pump update them while the run goes.
#[derive(Debug, Default)]
pub struct Stats {
read_skips: AtomicU64,
bytes_lost: AtomicU64,
units_blanked: AtomicU64,
resync_dropped: AtomicU64,
}
impl Stats {
/// The counters now.
pub fn snapshot(&self) -> LossReport {
LossReport {
read_skips: self.read_skips.load(Ordering::Relaxed),
bytes_lost: self.bytes_lost.load(Ordering::Relaxed),
units_blanked: self.units_blanked.load(Ordering::Relaxed),
resync_dropped: self.resync_dropped.load(Ordering::Relaxed),
}
}
// One read error zero-filled `bytes` of output.
pub(crate) fn add_skip(&self, bytes: u64) {
self.read_skips.fetch_add(1, Ordering::Relaxed);
self.bytes_lost.fetch_add(bytes, Ordering::Relaxed);
}
pub(crate) fn add_blanked(&self, units: u64) {
self.units_blanked.fetch_add(units, Ordering::Relaxed);
}
pub(crate) fn add_resync_dropped(&self, frames: u64) {
self.resync_dropped.fetch_add(frames, Ordering::Relaxed);
}
}
/// A snapshot of [`Stats`].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LossReport {
/// Zero-filled read-error skips: one per skipped read (live mux) or lossy batch
/// (extract); the byte count is `bytes_lost`.
pub read_skips: u64,
/// Bytes those skips zero-filled (blanked AACS units are not included).
pub bytes_lost: u64,
/// Damaged AACS units blanked (each one whole 6144-byte unit of zeros).
pub units_blanked: u64,
/// Video frames dropped after a gap until the next keyframe.
pub resync_dropped: u64,
}
/// Developer diagnostics a run carries instead of stages reading the environment.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Diag {
/// Bypass the codec parsers (raw demux throughput profiling).
pub skip_parse: bool,
/// Log per-stage read/feed/parse timings.
pub profile: bool,
}
impl Diag {
/// `FREEMKV_SKIP_PARSE` and `FREEMKV_PROFILE` (set = on), read once by the caller.
pub fn from_env() -> Self {
Self {
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
profile: std::env::var_os("FREEMKV_PROFILE").is_some(),
}
}
}
#[cfg(test)]
#[path = "ctx_tests.rs"]
mod tests;
+45
View File
@@ -0,0 +1,45 @@
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());
}
+399 -1306
View File
File diff suppressed because it is too large Load Diff
+166
View File
@@ -0,0 +1,166 @@
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
+60 -207
View File
@@ -2,9 +2,11 @@
//!
//! Emits one terse line per row (title, cell, stream, decision) under the `tracing` target
//! `freemkv::diag`, routed to `log.txt` at `--log-level 3`. Every line is prefixed `tag=`
//! (`disc`, `title`, `dvd.cell`, `dvd.vattr`, `dvd.aattr`, `bd.clip`, `bd.mark`, `aacs`,
//! `stream`, `decision`) so a log scraper can filter, and raw bytes are shown as `0xNN` beside
//! their decode. This module only reads already-parsed scan state.
//! (`disc`, `title`, `bd.clip`, `extent`, `stream`, `decision`, `decision.demoted`, `aacs`,
//! `dvd.cell`, `dvd.pgc`, `dvd.chap`, `dvd.vobs`, `dvd.vattr`, `dvd.aattr`, `dvd.sattr`,
//! `dvd.astctl`, `dvd.spstctl`, `dvd.substream`, `dvd.aroute`, `mkv.track`, `mkv.opening.*`,
//! `mp2.channels`) so a log scraper can filter. Raw bytes are shown as `0xNN` beside their
//! decode. This module only reads already-parsed scan state.
use crate::disc::{ColorSpace, Disc, DiscTitle, FrameRate, HdrFormat, Resolution, Stream};
use crate::ifo::{CellCategory, DvdTitle};
@@ -178,13 +180,13 @@ pub fn dump_dvd_attrs(ts: &crate::ifo::DvdTitleSet) {
for (i, a) in ts.audio_streams.iter().enumerate() {
tracing::debug!(
target: DIAG,
"tag=dvd.aattr vts={} idx={i} codec={:?} ch={} sr={}Hz lang={:?} sub_id={:?}",
"tag=dvd.aattr vts={} idx={i} codec={:?} ch={} sr={}Hz lang={:?} mpeg_ext={}",
ts.vts_number,
a.codec,
a.channels,
a.sample_rate,
a.language,
a.sub_stream_id.map(|x| format!("0x{x:02X}")),
a.mpeg_ext,
);
}
for (i, s) in ts.subtitle_streams.iter().enumerate() {
@@ -197,6 +199,43 @@ pub fn dump_dvd_attrs(ts: &crate::ifo::DvdTitleSet) {
}
}
/// A title whose PGC control table (`ast`/`spst`) marks none of its `declared` streams present,
/// so a disc that loses every audio or subtitle track to sloppy authoring is traceable.
pub fn dvd_ctl_none_present(kind: &str, vts: u8, title: u16, declared: usize, outcome: &str) {
tracing::debug!(
target: DIAG,
"tag=dvd.{kind}ctl vts={vts} title={title} declared={declared} present=0 -> {outcome}",
);
}
/// A logical stream dropped because an earlier one already routes to the same physical id
/// (`id`: the VobSub sub-id, or the audio routing PID).
pub fn dvd_ctl_duplicate(kind: &str, vts: u8, title: u16, id: u16, kept: &str, dropped: &str) {
tracing::debug!(
target: DIAG,
"tag=dvd.{kind}ctl vts={vts} title={title} id=0x{id:02X} kept={kept:?} dropped={dropped:?} (duplicate physical id)",
);
}
/// Where one title routes a declared audio stream: its raw AST_CTL entry and the PID it
/// muxes from, or `absent` when the PGC does not play it.
pub fn dvd_audio_route(
vts: u8,
title: u16,
idx: usize,
a: &crate::ifo::DvdAudioAttr,
ctl: u16,
pid: Option<u16>,
) {
let route = pid.map_or("absent".to_string(), |p| format!("pid=0x{p:04X}"));
tracing::debug!(
target: DIAG,
"tag=dvd.aroute vts={vts} title={title} idx={idx} codec={:?} lang={:?} ast=0x{ctl:04X} {route}",
a.codec,
a.language,
);
}
/// Emit the ACTUAL per-physical-sub-stream AC-3 channel counts read off the VOB during the
/// mux-time sub-stream probe. This is the ground truth the IFO nibble is compared against: each
/// row is `sub_id=0x8x channels=N` for a physical `private_stream_1` AC-3 sub-stream whose
@@ -261,7 +300,7 @@ fn codec_private_hex(cp: Option<&[u8]>) -> String {
// (no I/O); `record` appends the returned bytes to the side file.
fn frame_record(track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> Vec<u8> {
let mut rec = Vec::with_capacity(14 + data.len());
rec.push(track_idx as u8);
rec.push(u8::try_from(track_idx).unwrap_or(u8::MAX));
rec.push(keyframe as u8);
rec.extend_from_slice(&pts_ns.to_le_bytes());
rec.extend_from_slice(&(data.len() as u32).to_le_bytes());
@@ -369,23 +408,26 @@ impl OpeningCapture {
/// Record one coded frame for `track_idx` if that track is still under its
/// per-track cap. Writes the framed raw bytes to the side file and logs a
/// one-line summary. A write error disables further capture for the track
/// (counter pinned to the cap) but never propagates — the rip is unaffected.
/// one-line summary. A write error disables further capture for all tracks
/// (counters pinned to the cap) but never propagates — the rip is unaffected.
pub fn record(&mut self, track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) {
let Some(count) = self.counts.get_mut(track_idx) else {
return;
};
if *count >= OPENING_FRAMES_PER_TRACK {
// The record's track field is a u8; skip higher indices rather than alias them.
if *count >= OPENING_FRAMES_PER_TRACK || track_idx > usize::from(u8::MAX) {
return;
}
use std::io::Write;
let rec = frame_record(track_idx, pts_ns, keyframe, data);
if let Err(e) = self.file.write_all(&rec) {
// Stop trying on this track; a broken side file must not stall mux.
*count = OPENING_FRAMES_PER_TRACK;
// A torn record desyncs a len-framed reader: stop capture on every track.
self.counts
.iter_mut()
.for_each(|c| *c = OPENING_FRAMES_PER_TRACK);
tracing::debug!(
target: DIAG,
"tag=mkv.opening.frame track={track_idx} write_failed={e} (capture stopped for track)",
"tag=mkv.opening.frame track={track_idx} write_failed={e} (capture stopped)",
);
return;
}
@@ -462,8 +504,7 @@ pub fn dump_disc(disc: &Disc) {
}
}
// The `reason=` token on the main-feature decision row. DERIVED from
// `Disc::CANONICAL_TITLE_ORDER_KEYS`, never restated here.
// The `reason=` token on the main-feature decision row. DERIVED from the Disc key lists.
fn main_feature_reason() -> String {
let keys: Vec<&str> = Disc::MAIN_FEATURE_ORDER_KEYS
.iter()
@@ -484,22 +525,16 @@ fn dump_aacs(disc: &Disc) {
}
return;
};
// CPS-unit / unit-key counts: at scan `unit_keys` is empty (keys are
// resolved later); the unit-key count is the BE16 in the raw
// Unit_Key_RO.inf if captured. Report both: resolved count and raw len.
// Key material is never on the disc (KU §2.2); report the raw AACS inputs' sizes.
tracing::debug!(
target: DIAG,
"tag=aacs version={} bus_enc={} mkb_version={} disc_hash={} key_source={:?} \
vuk={} unit_keys_resolved={} uk_ro_bytes={} mkb_bytes={}",
"tag=aacs version={} bus_enc={} mkb_version={} disc_hash={} uk_ro_bytes={} mkb_bytes={}",
a.version,
a.bus_encryption,
a.mkb_version
.map(|v| v.to_string())
.unwrap_or_else(|| "unknown".to_string()),
a.disc_hash,
a.key_source,
a.vuk.is_some(),
a.unit_keys.len(),
a.uk_ro.len(),
a.mkb.len(),
);
@@ -532,7 +567,7 @@ fn dump_title(ti: usize, title: &DiscTitle) {
for (ci, c) in title.clips.iter().enumerate() {
tracing::debug!(
target: DIAG,
"tag=clip title={ti} idx={ci} id={:?} in={} out={} dur={:.1}s src_packets={}",
"tag=bd.clip title={ti} idx={ci} id={:?} in={} out={} dur={:.1}s src_packets={}",
c.clip_id,
c.in_time,
c.out_time,
@@ -596,187 +631,5 @@ fn dump_title(ti: usize, title: &DiscTitle) {
}
#[cfg(test)]
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}"
);
assert_eq!(
reason,
"main_feature_order(nav-feature, authoring-feature, standalone, has-video, fits-disc, largest-size, longest, richest-audio, more-video, more-subs)",
"the reason must name the selection keys (authoring, standalone-over-composite, and has-video gates, then the physical keys) in priority order"
);
}
#[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");
}
#[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}");
}
}
#[path = "diag_tests.rs"]
mod tests;
+270
View File
@@ -0,0 +1,270 @@
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}");
}
+8 -61
View File
@@ -136,7 +136,7 @@ fn put_domain_id(buf: &mut [u8]) {
/// OSTA CS0 d-string: a compression-ID byte, the characters, then the used
/// length in the FIELD'S LAST byte (ECMA-167 1/7.2.12 + UDF 2.1.3). An
/// all-zero field is the empty string.
fn put_dstring(buf: &mut [u8], s: &str) {
pub(super) fn put_dstring(buf: &mut [u8], s: &str) {
if s.is_empty() {
return;
}
@@ -221,8 +221,10 @@ fn primary_volume(volume_id: &str, lba: u32, seq: u32) -> Box<[u8; SECTOR]> {
put_dstring(&mut s[24..56], volume_id);
s[56..58].copy_from_slice(&1u16.to_le_bytes()); // volume sequence number
s[58..60].copy_from_slice(&1u16.to_le_bytes()); // max volume sequence number
s[60..62].copy_from_slice(&2u16.to_le_bytes()); // interchange level
s[62..64].copy_from_slice(&2u16.to_le_bytes()); // max interchange level
// Level 3 (not 2): permits a file to span multiple extents, which a
// large VOB/M2TS on a hybrid video disc needs.
s[60..62].copy_from_slice(&3u16.to_le_bytes()); // interchange level
s[62..64].copy_from_slice(&3u16.to_le_bytes()); // max interchange level
s[64..68].copy_from_slice(&1u32.to_le_bytes()); // character set list
s[68..72].copy_from_slice(&1u32.to_le_bytes()); // max character set list
// UDF 2.2.2.5: the first 8 characters of the volume set identifier must be
@@ -404,7 +406,7 @@ fn file_entry(
s[24..26].copy_from_slice(&1u16.to_le_bytes()); // max number of entries
s[27] = if is_dir { 4 } else { 5 }; // file type: directory / byte sequence
// s[28..34] parent ICB location: not recorded (permitted).
// s[34..36] ICB flags: 0 => short allocation descriptors. `udf.rs:601`
// s[34..36] ICB flags: 0 => short allocation descriptors. `udf::read_filesystem`
// reads exactly this word to pick its AD stride.
s[34..36].copy_from_slice(&0u16.to_le_bytes());
// UDF's sentinel for "not specified" is 0xFFFFFFFF, not 0 — 0 is a real
@@ -642,60 +644,5 @@ pub(super) fn encode(layout: &Layout) -> Result<MetaSectors> {
}
#[cfg(test)]
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);
assert_ne!(crc16(b"123456789"), 0x29B1, "not the 0xFFFF-seeded variant");
}
/// 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 UTF-8.
#[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");
}
}
#[path = "encode_tests.rs"]
mod tests;
+70
View File
@@ -0,0 +1,70 @@
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"
);
}
+53 -263
View File
@@ -198,7 +198,7 @@ fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result<
);
continue;
}
names.push(as_read.to_ascii_uppercase());
let key = as_read.to_ascii_uppercase();
if ft.is_dir() {
// Link count (child dirs + 1) is stored in 16 bits; the global entry
// cap alone permits a single directory holding more than that, which
@@ -208,6 +208,7 @@ fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result<
path: disc_path.to_string(),
});
}
names.push(key);
dirs.push(walk(&entry.path(), &child_path, depth + 1, entries)?);
} else {
let meta = match std::fs::metadata(entry.path()) {
@@ -215,7 +216,14 @@ fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result<
// A broken symlink or a file gone between readdir and stat: skip it
// rather than plan an unreadable extent. Anything else (e.g. perm
// denied) is NOT a missing file, so skipping it would drop it silently.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::warn!(
target: "freemkv::dirimage",
path = %child_path.escape_debug(),
"entry is a dangling link or vanished mid-walk; omitted from the image"
);
continue;
}
Err(e) => return Err(Error::from(e)),
};
if !meta.is_file() {
@@ -230,6 +238,7 @@ fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result<
);
continue;
}
names.push(key);
files.push(FileNode {
name,
disc_path: child_path,
@@ -243,6 +252,13 @@ fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result<
}
}
let dir_len = dir_bytes(&dirs, &files);
if dir_len.div_ceil(SECTOR) > (crate::udf::MAX_DIR_BYTES as usize) / SECTOR {
// The UDF reader rejects a larger directory; refuse it here, naming it.
return Err(Error::DirImageFanout {
path: disc_path.to_string(),
});
}
// `find_dir`/`read_file` match path components case-insensitively, so two
// entries differing only in case are indistinguishable to every consumer —
// the second would silently shadow the first. Only on a case-sensitive host.
@@ -334,14 +350,20 @@ fn be_u32(buf: &[u8], off: usize) -> Option<u32> {
// Read the first `n` bytes of an IFO so its placement offsets can be resolved. Errors propagate
// — an empty buffer would record NO placement constraint and the rip would read the wrong
// sectors.
fn read_head(path: &Path, n: usize) -> Result<Vec<u8>> {
fn read_head(path: &Path, disc_path: &str, n: usize) -> Result<Vec<u8>> {
use std::io::Read;
let mut buf = vec![0u8; n];
// `read_exact`, not `read`: a single `read` may legally return fewer bytes on
// a network/FUSE mount (a NAS-hosted backup is normal here). A short buffer
// records NO constraint, so the rip reads the wrong sectors at exit 0.
let mut f = std::fs::File::open(path).map_err(Error::from)?;
f.read_exact(&mut buf).map_err(Error::from)?;
f.read_exact(&mut buf).map_err(|e| match e.kind() {
// A short IFO cannot resolve placement; other errors stay retryable IoError.
std::io::ErrorKind::UnexpectedEof => Error::DirImagePlacement {
path: disc_path.to_string(),
},
_ => Error::from(e),
})?;
Ok(buf)
}
@@ -397,7 +419,7 @@ fn place_video_ts(vts: &mut DirNode, start: u32) -> Result<u32> {
if let Some(c) = class
&& c.role == Role::Ifo
{
let head = read_head(&vts.files[i].host, 0xC8)?;
let head = read_head(&vts.files[i].host, &vts.files[i].disc_path, 0xC8)?;
let menu = be_u32(&head, 0xC0).unwrap_or(0);
// One group per title set: `VTS_01_0.IFO` and `VTS_1_0.IFO` parse to
// the same group, and a second insert would overwrite the first's
@@ -507,9 +529,8 @@ fn count_nodes(dir: &DirNode, dirs: &mut u32, files: &mut u32) {
}
}
/// Total blocks the metadata region needs: File Set Descriptor, its
/// Terminating Descriptor, one File Entry per node, and each directory's FID
/// list.
/// Blocks for the File Entries and FID lists below `dir`; the FSD and
/// Terminating Descriptor are added by `metadata_block_count`.
fn metadata_blocks(dir: &DirNode) -> u64 {
let mut n = 1 + dir.files.len() as u64;
n += dir_bytes(&dir.dirs, &dir.files).div_ceil(SECTOR) as u64;
@@ -549,8 +570,10 @@ pub(super) fn plan(root: &Path) -> Result<Layout> {
// Metadata: block 0 is the FSD, block 1 its Terminating Descriptor.
let mut next = 2u32;
let mut uid = 0u64;
// UDF 3.2.1.1 reserves Unique IDs 1-15: the root is 0, the rest start at 16.
let mut uid = 16u64;
assign_metadata(&mut tree, 0, &mut next, &mut uid);
tree.unique_id = 0;
tree.parent_icb_lba = tree.icb_lba; // root's parent FID points at itself
let part_start = MIN_PART_START;
@@ -608,10 +631,24 @@ pub(super) fn plan(root: &Path) -> Result<Layout> {
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "FREEMKV".to_string());
// UDF volume identifiers are a 32-byte d-string: one compression byte, the
// characters, and a trailing length byte. Trim rather than let
// `put_dstring` cut a multi-byte character in half.
let volume_id: String = volume_id.chars().take(30).collect();
// A 32-byte d-string holds 30 ASCII chars, or 15 UTF-16 units once non-ASCII
// forces CS0 ID 16. Round-trip through the encoder and the reader so `volume_id`
// is exactly what the reader decodes (it drops surrogates and trims).
let mut units = 0;
let cut: String = volume_id
.chars()
.take_while(|c| {
units += if volume_id.is_ascii() {
1
} else {
c.len_utf16()
};
units <= if volume_id.is_ascii() { 30 } else { 15 }
})
.collect();
let mut field = [0u8; 32];
super::encode::put_dstring(&mut field, &cut);
let volume_id = crate::udf::parse_dstring(&field);
Ok(Layout {
part_start,
@@ -646,258 +683,11 @@ pub(super) fn flatten<'a>(dir: &'a DirNode, out: &mut Vec<&'a FileNode>) {
}
}
/// Metadata footprint in blocks, for diagnostics.
/// Metadata footprint in blocks; `plan` bounds it against `MAX_META_BYTES`.
pub(super) fn metadata_block_count(root: &DirNode) -> u64 {
2 + metadata_blocks(root)
}
#[cfg(test)]
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);
}
// 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);
}
}
#[path = "layout_tests.rs"]
mod tests;
+289
View File
@@ -0,0 +1,289 @@
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);
}
+11 -4
View File
@@ -183,8 +183,15 @@ impl DirImage {
self.open.insert(0, entry);
return Ok(&mut self.open[0].1);
}
let f = File::open(&self.files[file].host).map_err(Error::from)?;
let md = f.metadata().map_err(Error::from)?;
// A vanished file is a change; name it. Other io errors stay retryable IoError.
let changed = |e: std::io::Error| match e.kind() {
std::io::ErrorKind::NotFound => Error::DirImageFileChanged {
path: self.files[file].disc_path.clone(),
},
_ => Error::from(e),
};
let f = File::open(&self.files[file].host).map_err(changed)?;
let md = f.metadata().map_err(changed)?;
// Size AND mtime: size alone is content-blind, and VOB placement depends on
// IFO bytes 0xC0/0xC4 — an IFO rewritten in place keeps its sector-aligned
// length, so size alone would miss it. mtime only compared if both present.
@@ -207,7 +214,7 @@ impl DirImage {
// Fill `out` (whole sectors) from one data range starting at `lba`. `out`
// is pre-zeroed, so a tail sector zero-pads — matching `file_extents`'
// div_ceil(2048) (udf.rs:816) that every consumer expects.
// div_ceil(2048) (`udf::file_extents`) that every consumer expects.
fn fill(&mut self, r: &DataRange, lba: u32, out: &mut [u8]) -> Result<()> {
let within = (lba - r.start_lba) as u64 * SECTOR as u64;
let want = (r.bytes.saturating_sub(within)).min(out.len() as u64) as usize;
@@ -307,4 +314,4 @@ impl SectorSource for DirImage {
}
#[cfg(test)]
mod tests;
pub(crate) mod tests;
+578 -22
View File
@@ -13,7 +13,7 @@ use crate::udf;
use std::io::Write;
/// A scratch directory that removes itself.
struct Scratch(PathBuf);
pub(crate) struct Scratch(PathBuf);
impl Scratch {
fn new(tag: &str) -> Self {
@@ -34,7 +34,7 @@ impl Scratch {
));
p
}
fn path(&self) -> &Path {
pub(crate) fn path(&self) -> &Path {
&self.0
}
fn file(&self, rel: &str, bytes: &[u8]) {
@@ -201,6 +201,59 @@ fn a_file_past_the_ad_ceiling_reads_back_as_multiple_extents() {
);
}
// Past the 30-bit AD ceiling, each extent reads its own bytes of the file: a read at an
// extent's start, one sector in, and a batch crossing the extent boundary.
#[test]
fn a_multi_extent_file_reads_each_extents_own_bytes() {
use std::io::Seek;
let s = Scratch::new("bigdata");
s.file("BDMV/index.bdmv", &pattern(1, 16));
s.dir("BDMV/STREAM");
let big = s.path().join("BDMV/STREAM/00000.m2ts");
let ad = super::layout::MAX_AD_BYTES;
let size = ad + 3 * SECTOR as u64;
let mut f = std::fs::File::create(&big).unwrap();
f.set_len(size).unwrap();
for (at, b) in [
(0, 0xA1u8),
(ad - SECTOR as u64, 0xB2),
(ad, 0xC3),
(ad + SECTOR as u64, 0xD4),
] {
f.seek(SeekFrom::Start(at)).unwrap();
f.write_all(&[b]).unwrap();
}
drop(f);
let mut img = DirImage::open(s.path()).unwrap();
let fs = udf::read_filesystem(&mut img).unwrap();
let exts = fs
.file_extents(&mut img, "/BDMV/STREAM/00000.m2ts")
.unwrap();
assert_eq!(exts.len(), 2);
let (first, second) = (exts[0].0, exts[1].0);
let head = |img: &mut DirImage, lba: u32, n: u16| {
let mut buf = vec![0u8; n as usize * SECTOR];
img.read_sectors(lba, n, &mut buf, false).unwrap();
(0..n as usize)
.map(|i| buf[i * SECTOR])
.collect::<Vec<u8>>()
};
assert_eq!(head(&mut img, first, 1), [0xA1]);
assert_eq!(head(&mut img, second, 1), [0xC3]);
assert_eq!(
head(&mut img, second + 1, 1),
[0xD4],
"one sector into the extent"
);
let last = first + exts[0].1 - 1;
assert_eq!(
head(&mut img, last, 3),
[0xB2, 0xC3, 0xD4],
"a batch crossing the extent boundary"
);
}
/// An IN-RANGE gap (a sector below capacity that no extent covers) reads as
/// zeros, not an error — a real image has unrecorded sectors too, and
/// `read_filesystem` probes fixed LBAs (256, the VDS window) before it knows
@@ -347,6 +400,37 @@ fn a_file_that_shrinks_after_planning_fails_the_read() {
assert_eq!(err.code(), crate::error::E_DIR_IMAGE_FILE_CHANGED);
}
/// Bump a file's mtime by a minute without changing its length.
fn touch_later(path: &std::path::Path) {
let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
let t = f.metadata().unwrap().modified().unwrap() + std::time::Duration::from_secs(60);
f.set_modified(t).unwrap();
}
/// An IFO rewritten in place at the SAME length still moves VOB placement, so a later
/// mtime must fail the read; a same-length, later-mtime VOB (size-only check) must not.
#[test]
fn an_ifo_rewritten_at_the_same_length_fails_but_a_touched_vob_does_not() {
let s = Scratch::new("ifo-mtime");
s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]);
s.file("VIDEO_TS/VTS_01_0.IFO", &vts_ifo(SECTOR, 0, 1));
s.file("VIDEO_TS/VTS_01_1.VOB", &pattern(1, SECTOR));
let mut img = DirImage::open(s.path()).unwrap();
let fs = udf::read_filesystem(&mut img).unwrap();
let ext = |img: &mut DirImage, p: &str| fs.file_extents(img, p).unwrap()[0];
let (ifo_lba, _) = ext(&mut img, "/VIDEO_TS/VTS_01_0.IFO");
let (vob_lba, _) = ext(&mut img, "/VIDEO_TS/VTS_01_1.VOB");
let mut buf = vec![0u8; SECTOR];
touch_later(&s.path().join("VIDEO_TS/VTS_01_1.VOB"));
img.read_sectors(vob_lba, 1, &mut buf, false)
.expect("size-only files ignore mtime");
touch_later(&s.path().join("VIDEO_TS/VTS_01_0.IFO"));
let err = img.read_sectors(ifo_lba, 1, &mut buf, false).unwrap_err();
assert_eq!(err.code(), crate::error::E_DIR_IMAGE_FILE_CHANGED);
}
// ── DVD placement ───────────────────────────────────────────────────────────
/// Build a `VTS_01_0.IFO` body whose VOB pointers are the given sector
@@ -541,7 +625,7 @@ fn scan_image_enumerates_a_bdmv_folder() {
/// A one-PlayItem MPLS long enough that `parse_playlist` keeps it (it drops
/// anything under 30 s as a menu stub).
fn one_item_mpls(clip_id: &[u8; 5]) -> Vec<u8> {
pub(crate) fn one_item_mpls(clip_id: &[u8; 5]) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(b"MPLS0200");
buf.extend_from_slice(&40u32.to_be_bytes()); // playlist_start
@@ -582,7 +666,7 @@ fn one_item_mpls(clip_id: &[u8; 5]) -> Vec<u8> {
/// A CLPI with only the fields `clpi::parse` needs: magic, zeroed section
/// starts, and the source packet count at 56.
fn minimal_clpi(source_packets: u32) -> Vec<u8> {
pub(crate) fn minimal_clpi(source_packets: u32) -> Vec<u8> {
let mut d = vec![0u8; 60];
d[0..4].copy_from_slice(b"HDMV");
d[4..8].copy_from_slice(b"0200");
@@ -592,7 +676,7 @@ fn minimal_clpi(source_packets: u32) -> Vec<u8> {
// A BD folder that enumerates a title, so the AACS content probe has an extent to sample;
// `scrambled` sets the CPI bits and withholds TS sync.
fn playable_bdmv(tag: &str, scrambled: bool) -> Scratch {
pub(crate) fn playable_bdmv(tag: &str, scrambled: bool) -> Scratch {
let s = Scratch::new(tag);
let packets = 4096u32;
let mut m2ts = vec![0x5Au8; packets as usize * 192];
@@ -644,19 +728,18 @@ fn an_aacs_directory_over_clear_content_is_treated_as_decrypted() {
assert!(disc.aacs_error.is_none(), "and no key is demanded");
}
/// The other verdict: a folder whose content units really are flagged and
/// scrambled is a raw encrypted copy, which `dir://` does not support. It must
/// be a typed error, not a rip that emits garbage.
/// The other verdict: a folder whose content units really are flagged and scrambled is
/// a raw encrypted copy. It keeps its AACS verdict, so it is keyed and decrypted like an
/// image of the same disc (every input passes the one decryption stage).
#[test]
fn an_aacs_folder_with_scrambled_content_is_rejected() {
fn an_aacs_folder_with_scrambled_content_scans_encrypted() {
let s = playable_bdmv("aacsenc", true);
s.file("AACS/Unit_Key_RO.inf", &[0u8; 64]);
s.file("AACS/MKB_RO.inf", &[0u8; 64]);
let err = match crate::session::scan_dir(s.path(), crate::disc::ScanOptions::default()) {
Ok(_) => panic!("a scrambled folder must not scan clean"),
Err(e) => e,
};
assert_eq!(err.code(), crate::error::E_DIR_IMAGE_ENCRYPTED);
let (disc, _reader) =
crate::session::scan_dir(s.path(), crate::disc::ScanOptions::default()).unwrap();
assert!(disc.encrypted, "scrambled content keeps the AACS verdict");
assert!(disc.aacs.is_some());
}
// ── The OTHER door: `dir://` through the PES input path ─────────────────────
@@ -667,6 +750,8 @@ fn an_aacs_folder_with_scrambled_content_is_rejected() {
/// not a bare is_ok(), so it cannot go vacuous.
#[test]
fn both_doors_agree_on_a_clear_folder_that_kept_its_aacs_directory() {
// Spawns the prefetch producer, a Drive holder.
let _serial = crate::sector::prefetched::holder_test_lock();
// `s` MUST outlive `stream`: the pipeline's producer thread is still
// reading the folder until the stream is dropped, and `Scratch::drop`
// removes the directory out from under it.
@@ -689,8 +774,12 @@ fn both_doors_agree_on_a_clear_folder_that_kept_its_aacs_directory() {
// Door 2 — the PES input path the CLI actually rips through.
let url = format!("dir://{}", s.path().display());
let stream = crate::input(&url, &crate::InputOptions::default())
.expect("dir:// input must open a clear folder, exactly as scan_dir does");
let stream = crate::input(
&url,
&crate::InputOptions::default(),
&crate::ctx::Ctx::default(),
)
.expect("dir:// input must open a clear folder, exactly as scan_dir does");
assert_eq!(
stream.info().extents,
scanned_extents,
@@ -699,23 +788,50 @@ fn both_doors_agree_on_a_clear_folder_that_kept_its_aacs_directory() {
drop(stream);
}
// The other verdict, same door: a truly scrambled folder is refused with the TYPED code, not
// muxed into garbage. Load-bearing half.
// HP3: the `Url` arm reports read progress like every other arm (it used to pass no events).
#[test]
fn a_scrambled_folder_is_refused_through_the_dir_url_door_too() {
fn a_dir_url_mux_reports_read_progress_to_the_ctx() {
let _serial = crate::sector::prefetched::holder_test_lock();
let s = playable_bdmv("direvents", false);
let read = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let seen = read.clone();
let ctx =
crate::Ctx::default().with_events(std::sync::Arc::new(move |e: &crate::Event<'_>| {
if let crate::Event::BytesRead { bytes, .. } = *e {
seen.store(bytes, std::sync::atomic::Ordering::Relaxed);
}
}));
let url = format!("dir://{}", s.path().display());
// The fixture's TS carries no PES, so the drain refuses as NoStreams after reading it all.
let _ = crate::mux_url(&url, None, "null://", &crate::MuxOptions::default(), &ctx);
assert_eq!(
read.load(std::sync::atomic::Ordering::Relaxed),
4096 * 192,
"every byte of the title's extent was reported read"
);
}
// The other verdict, same door: a truly scrambled folder with no key is refused before any
// output with the standard no-key code (E7022), never muxed as ciphertext.
#[test]
fn a_scrambled_folder_with_no_key_is_refused_through_the_dir_url_door() {
let s = playable_bdmv("dirdoorenc", true);
s.file("AACS/Unit_Key_RO.inf", &[0u8; 64]);
s.file("AACS/MKB_RO.inf", &[0u8; 64]);
let url = format!("dir://{}", s.path().display());
let err = match crate::input(&url, &crate::InputOptions::default()) {
let err = match crate::input(
&url,
&crate::InputOptions::default(),
&crate::ctx::Ctx::default(),
) {
Ok(_) => panic!("a scrambled folder must not open as a PES source"),
Err(e) => e,
};
assert_eq!(
crate::error::error_code(&err),
Some(crate::error::E_DIR_IMAGE_ENCRYPTED),
"expected the typed dir-source-encrypted code, got: {err}"
Some(crate::error::E_NO_DISC_KEY),
"expected the no-key code, got: {err}"
);
}
@@ -1070,3 +1186,443 @@ fn a_multi_clip_playlist_produces_feed_spans_that_tile_the_feed() {
"the spans must cover exactly the feed, or a byte offset lands in the wrong clip"
);
}
// ── Disc::read_structure_files (info --share) ───────────────────────────────
/// A Stop during the file reads is a cancellation, not "file missing".
#[test]
fn read_structure_files_propagates_halt() {
use crate::sector::SectorSource;
use std::collections::HashSet;
// Records every LBA read while parsing the filesystem; later reads of any other
// LBA (the file data) are answered with Halted.
struct HaltOnData<'a> {
inner: &'a mut DirImage,
seen: HashSet<u32>,
recording: bool,
}
impl SectorSource for HaltOnData<'_> {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> crate::error::Result<usize> {
if self.recording {
self.seen.extend(lba..lba + count as u32);
} else if !self.seen.contains(&lba) {
return Err(crate::error::Error::Halted);
}
self.inner.read_sectors(lba, count, buf, recovery)
}
}
let (s, _, _) = bdmv_scratch();
let mut img = DirImage::open(s.path()).unwrap();
let mut rec = HaltOnData {
inner: &mut img,
seen: HashSet::new(),
recording: true,
};
udf::read_filesystem(&mut rec).unwrap();
rec.recording = false;
let res = crate::disc::Disc::read_structure_files(&mut rec);
assert!(
matches!(res, Err(crate::error::Error::Halted)),
"got {:?}",
res.map(|f| f.len())
);
}
// ── Audit fixes: planning edge cases ────────────────────────────────────────
/// A symlink skipped from the image must not take part in the case-collision check.
#[cfg(unix)]
#[test]
fn a_skipped_symlink_does_not_collide_with_a_same_name_file() {
let (s, _, _) = bdmv_scratch();
// The reader trims the leading space, so both names read back as `A.M2TS`.
s.file("BDMV/STREAM/A.M2TS", b"x");
std::os::unix::fs::symlink("/nonexistent-target", s.path().join("BDMV/STREAM/ A.M2TS"))
.unwrap();
DirImage::open(s.path()).expect("dangling symlink is omitted, not a collision");
}
/// A tree whose metadata exceeds the in-memory ceiling is refused at plan time.
#[test]
fn an_oversized_metadata_footprint_is_refused_at_plan_time() {
let (s, _, _) = bdmv_scratch();
for dir in ["BDMV/STREAM", "BDMV/CLIPINF"] {
for i in 0..17_000 {
s.file(&format!("{dir}/{i:05}"), b"");
}
}
let err = DirImage::open(s.path()).unwrap_err();
assert_eq!(err.code(), crate::error::E_DIR_IMAGE_TOO_LARGE, "got {err}");
}
/// A symlink to a directory is omitted, and does not take part in the collision check.
#[cfg(unix)]
#[test]
fn a_directory_symlink_is_omitted_from_the_image() {
let (s, _, _) = bdmv_scratch();
s.file("BDMV/STREAM/A.M2TS", b"x");
s.dir("elsewhere");
std::os::unix::fs::symlink(
s.path().join("elsewhere"),
s.path().join("BDMV/STREAM/ A.M2TS"),
)
.unwrap();
let mut img = DirImage::open(s.path()).expect("a directory link is omitted, not a collision");
let fs = udf::read_filesystem(&mut img).unwrap();
let names: Vec<_> = fs
.find_dir("/BDMV/STREAM")
.unwrap()
.entries
.iter()
.map(|e| e.name.clone())
.collect();
assert_eq!(
names.iter().filter(|n| n.trim() == "A.M2TS").count(),
1,
"{names:?}"
);
}
/// A directory whose FID list exceeds the UDF reader's cap is refused at plan time, by name.
#[test]
fn an_over_wide_directory_is_refused_at_plan_time() {
let (s, _, _) = bdmv_scratch();
for i in 0..24_000 {
s.file(&format!("BDMV/STREAM/{i:05}"), b"");
}
let err = DirImage::open(s.path()).unwrap_err();
assert_eq!(err.code(), crate::error::E_DIR_IMAGE_FANOUT);
assert!(err.to_string().contains("/BDMV/STREAM"), "got {err}");
}
/// A truncated IFO is a typed error naming the file.
#[test]
fn a_truncated_ifo_names_the_file() {
let s = Scratch::new("dvdshort");
s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]);
s.file("VIDEO_TS/VTS_01_0.IFO", &[0u8; 8]);
let err = DirImage::open(s.path()).unwrap_err();
assert_eq!(err.code(), crate::error::E_DIR_IMAGE_PLACEMENT);
assert!(err.to_string().contains("VTS_01_0.IFO"), "got {err}");
}
/// A file that vanishes after planning names the file.
#[test]
fn a_file_removed_after_planning_names_the_file() {
let (s, _, _) = bdmv_scratch();
let mut img = DirImage::open(s.path()).unwrap();
let fs = udf::read_filesystem(&mut img).unwrap();
let (lba, sectors) = fs
.file_extents(&mut img, "/BDMV/STREAM/00000.m2ts")
.unwrap()[0];
std::fs::remove_file(s.path().join("BDMV/STREAM/00000.m2ts")).unwrap();
let mut buf = vec![0u8; sectors as usize * SECTOR];
let err = img
.read_sectors(lba, sectors as u16, &mut buf, false)
.unwrap_err();
assert_eq!(err.code(), crate::error::E_DIR_IMAGE_FILE_CHANGED);
assert!(err.to_string().contains("00000.m2ts"), "got {err}");
}
/// UDF reserves Unique IDs 1-15; only the root may be below 16.
#[test]
fn synthesized_unique_ids_avoid_the_reserved_range() {
let (s, _, _) = bdmv_scratch();
let plan = layout::plan(s.path()).unwrap();
fn check(d: &layout::DirNode, is_root: bool) {
if is_root {
assert_eq!(d.unique_id, 0);
} else {
assert!(d.unique_id >= 16, "dir uid {}", d.unique_id);
}
for f in &d.files {
assert!(f.unique_id >= 16, "file uid {}", f.unique_id);
}
for s in &d.dirs {
check(s, false);
}
}
check(&plan.root, true);
assert!(plan.next_unique_id >= 16);
}
/// A non-ASCII folder name must read back from the UDF exactly as `volume_id()` reports it.
#[test]
fn a_non_ascii_volume_id_matches_what_the_reader_sees() {
let base = Scratch::new("vol");
let name = "Am\u{e9}lie (2001) Blu-ray \u{1F600}\u{1F600}";
base.file(&format!("{name}/BDMV/index.bdmv"), &pattern(1, 16));
base.file(&format!("{name}/BDMV/STREAM/00000.m2ts"), &pattern(2, 4096));
let mut img = DirImage::open(&base.path().join(name)).unwrap();
let fs = udf::read_filesystem(&mut img).unwrap();
assert_eq!(fs.volume_id, img.volume_id());
}
/// A folder name with an emoji and a trailing space INSIDE the 15-unit cut still matches the reader.
#[test]
fn a_volume_id_cut_inside_emoji_or_space_matches_the_reader() {
for (tag, name) in [
("volemoji", "Film \u{1F600} x"),
("volspace", "Cr\u{e8}me br\u{fb}l\u{e9}e 1 x"),
] {
let base = Scratch::new(tag);
base.file(&format!("{name}/BDMV/index.bdmv"), &pattern(1, 16));
base.file(&format!("{name}/BDMV/STREAM/00000.m2ts"), &pattern(2, 4096));
let mut img = DirImage::open(&base.path().join(name)).unwrap();
let fs = udf::read_filesystem(&mut img).unwrap();
assert_eq!(fs.volume_id, img.volume_id(), "{name}");
}
}
/// An unreadable (not vanished) file keeps its IoError so engine retry logic still sees it.
#[cfg(unix)]
#[test]
fn an_unreadable_file_is_an_io_error_not_file_changed() {
use std::os::unix::fs::PermissionsExt;
let (s, _, _) = bdmv_scratch();
let mut img = DirImage::open(s.path()).unwrap();
let fs = udf::read_filesystem(&mut img).unwrap();
let (lba, sectors) = fs
.file_extents(&mut img, "/BDMV/STREAM/00000.m2ts")
.unwrap()[0];
let p = s.path().join("BDMV/STREAM/00000.m2ts");
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::File::open(&p).is_ok() {
return; // running as root: permissions not enforced
}
let mut buf = vec![0u8; sectors as usize * SECTOR];
let err = img
.read_sectors(lba, sectors as u16, &mut buf, false)
.unwrap_err();
assert!(
matches!(err, crate::error::Error::IoError { .. }),
"expected IoError, got {err:?}"
);
}
/// An unreadable IFO is an IoError; only a short one is a placement error.
#[cfg(unix)]
#[test]
fn an_unreadable_ifo_is_an_io_error_not_placement() {
use std::os::unix::fs::PermissionsExt;
let s = Scratch::new("dvdperm");
s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]);
s.file("VIDEO_TS/VTS_01_0.IFO", &vec![0u8; SECTOR]);
let p = s.path().join("VIDEO_TS/VTS_01_0.IFO");
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::File::open(&p).is_ok() {
return; // running as root: permissions not enforced
}
let err = DirImage::open(s.path()).unwrap_err();
assert!(
matches!(err, crate::error::Error::IoError { .. }),
"expected IoError, got {err:?}"
);
}
// Two IFOs that parse to the same title-set group (VTS_01_0 vs VTS_1_0) must be refused, or the
// second's constraint silently overwrites the first's.
#[test]
fn duplicate_group_ifos_are_refused() {
let s = Scratch::new("dvddup");
s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]);
s.file("VIDEO_TS/VTS_01_0.IFO", &vts_ifo(SECTOR, 0, 0));
s.file("VIDEO_TS/VTS_1_0.IFO", &vts_ifo(SECTOR, 0, 0));
let err = DirImage::open(s.path()).expect_err("duplicate title-set IFOs");
assert!(
matches!(err, crate::error::Error::DirNameCollision { .. }),
"expected DirNameCollision, got {err:?}"
);
}
// VIDEO_TS.IFO's 0xC0 points at the VMG menu VOB; it must land exactly there.
#[test]
fn vmg_menu_vob_lands_at_the_ifo_0xc0_offset() {
let s = Scratch::new("dvdvmg");
let mut vmg = vec![0u8; SECTOR];
vmg[0xC0..0xC4].copy_from_slice(&5u32.to_be_bytes());
s.file("VIDEO_TS/VIDEO_TS.IFO", &vmg);
s.file("VIDEO_TS/VIDEO_TS.VOB", &pattern(4, SECTOR));
let mut img = DirImage::open(s.path()).unwrap();
let fs = udf::read_filesystem(&mut img).unwrap();
let ifo = fs
.file_start_lba(&mut img, "/VIDEO_TS/VIDEO_TS.IFO")
.unwrap();
let vob = fs
.file_start_lba(&mut img, "/VIDEO_TS/VIDEO_TS.VOB")
.unwrap();
assert_eq!(vob, ifo + 5, "VMG menu VOB must sit at IFO + 0xC0");
}
fn ifo_folder(tag: &str) -> Scratch {
let s = Scratch::new(tag);
s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]);
s.file("VIDEO_TS/VTS_01_0.IFO", &vts_ifo(SECTOR, 0, 1));
s.file("VIDEO_TS/VTS_01_1.VOB", &pattern(1, SECTOR));
s
}
fn ifo_lba(img: &mut DirImage) -> u32 {
let fs = udf::read_filesystem(img).unwrap();
fs.file_start_lba(img, "/VIDEO_TS/VTS_01_0.IFO").unwrap()
}
// An IFO rewritten in place with the same size but a new mtime must be refused at read time.
#[test]
fn an_ifo_with_a_changed_mtime_is_refused_on_read() {
let s = ifo_folder("mtime");
let mut img = DirImage::open(s.path()).unwrap();
let lba = ifo_lba(&mut img);
let f = std::fs::OpenOptions::new()
.write(true)
.open(s.path().join("VIDEO_TS/VTS_01_0.IFO"))
.unwrap();
let t = std::time::SystemTime::now() + std::time::Duration::from_secs(3600);
f.set_modified(t).unwrap();
let mut buf = vec![0u8; SECTOR];
let err = img.read_sectors(lba, 1, &mut buf, false).unwrap_err();
assert!(
matches!(err, crate::error::Error::DirImageFileChanged { .. }),
"expected DirImageFileChanged, got {err:?}"
);
}
// A file that shrinks after its handle is open surfaces as DirImageFileChanged via fill's EOF arm.
#[test]
fn a_file_truncated_after_open_is_reported_as_changed() {
let s = ifo_folder("shrink");
let mut img = DirImage::open(s.path()).unwrap();
let fs = udf::read_filesystem(&mut img).unwrap();
let vob = fs
.file_start_lba(&mut img, "/VIDEO_TS/VTS_01_1.VOB")
.unwrap();
let mut buf = vec![0u8; SECTOR];
img.read_sectors(vob, 1, &mut buf, false).unwrap();
let f = std::fs::OpenOptions::new()
.write(true)
.open(s.path().join("VIDEO_TS/VTS_01_1.VOB"))
.unwrap();
f.set_len(10).unwrap();
let err = img.read_sectors(vob, 1, &mut buf, false).unwrap_err();
assert!(
matches!(err, crate::error::Error::DirImageFileChanged { .. }),
"expected DirImageFileChanged, got {err:?}"
);
}
// Independently verify every assembled descriptor's tag (checksum, CRC, CRC length, location),
// including the FIDs of a directory large enough to span several blocks.
#[test]
fn every_assembled_descriptor_has_a_valid_tag() {
let s = Scratch::new("tags");
s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]);
s.file("VIDEO_TS/VTS_01_0.IFO", &vts_ifo(SECTOR, 0, 1));
s.file("VIDEO_TS/VTS_01_1.VOB", &pattern(1, SECTOR));
for i in 0..150 {
s.file(&format!("EXTRA/a_rather_long_file_name_{i:04}.bin"), b"x");
}
let img = DirImage::open(s.path()).unwrap();
let check = |sec: &[u8], at: usize, len: usize, want_loc: u32, what: &str| {
let t = &sec[at..at + 16];
let sum = t
.iter()
.enumerate()
.filter(|(i, _)| *i != 4)
.fold(0u8, |a, (_, b)| a.wrapping_add(*b));
assert_eq!(t[4], sum, "{what}: checksum");
let crc_len = u16::from_le_bytes([t[10], t[11]]) as usize;
assert_eq!(crc_len, len - 16, "{what}: CRC length");
let crc = u16::from_le_bytes([t[8], t[9]]);
let mut c = 0u16;
for &b in &sec[at + 16..at + 16 + crc_len] {
c ^= (b as u16) << 8;
for _ in 0..8 {
c = if c & 0x8000 != 0 {
(c << 1) ^ 0x1021
} else {
c << 1
};
}
}
assert_eq!(crc, c, "{what}: CRC");
let loc = u32::from_le_bytes([t[12], t[13], t[14], t[15]]);
assert_eq!(loc, want_loc, "{what}: location");
};
// The File Set Descriptor sits at partition block 0, which fixes the partition start.
let part_start = *img
.meta
.iter()
.find(|(_, s)| u16::from_le_bytes([s[0], s[1]]) == 256)
.expect("file set descriptor")
.0;
let mut dirs_seen = 0;
let mut multi_block_dir = false;
for (&lba, sec) in &img.meta {
let sec: &[u8] = &sec[..];
let id = u16::from_le_bytes([sec[0], sec[1]]);
// Directory-data blocks can begin mid-descriptor; only descriptors are tagged here.
if lba > part_start && !matches!(id, 8 | 256 | 261) {
continue;
}
match id {
// The partition's own terminating descriptor is partition-relative.
8 if lba > part_start => check(sec, 0, 512, lba - part_start, "partition terminator"),
1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 => {
// Length is recoverable from the tag itself; volume-space tags are absolute.
let len = u16::from_le_bytes([sec[10], sec[11]]) as usize + 16;
check(sec, 0, len, lba, &format!("VDS tag {id} @ {lba}"));
}
256 => check(sec, 0, 512, lba - part_start, "file set"),
261 => {
let l_ad = u32::from_le_bytes([sec[172], sec[173], sec[174], sec[175]]) as usize;
check(sec, 0, 176 + l_ad, lba - part_start, "file entry");
if sec[27] != 4 {
continue;
}
dirs_seen += 1;
let len =
u32::from_le_bytes([sec[176], sec[177], sec[178], sec[179]]) & 0x3FFF_FFFF;
let blk = u32::from_le_bytes([sec[180], sec[181], sec[182], sec[183]]);
multi_block_dir |= len as usize > SECTOR;
let mut data = Vec::new();
for b in 0..(len as usize).div_ceil(SECTOR) {
data.extend_from_slice(&img.meta[&(part_start + blk + b as u32)][..]);
}
let mut pos = 0usize;
while pos < len as usize {
let l_fi = data[pos + 19] as usize;
let l_iu = u16::from_le_bytes([data[pos + 36], data[pos + 37]]) as usize;
let unpadded = 38 + l_iu + l_fi;
assert_eq!(u16::from_le_bytes([data[pos], data[pos + 1]]), 257);
check(
&data,
pos,
unpadded,
blk + (pos / SECTOR) as u32,
&format!("FID @ dir byte {pos}"),
);
pos += unpadded.div_ceil(4) * 4;
}
}
_ => {}
}
}
assert!(
dirs_seen >= 3,
"expected several directories, saw {dirs_seen}"
);
assert!(
multi_block_dir,
"fixture must include a multi-block directory"
);
}
+204 -2008
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+362 -1330
View File
File diff suppressed because it is too large Load Diff
+21 -413
View File
@@ -1,11 +1,10 @@
//! Physical AC-3 sub-stream probing for DVD audio routing.
//! Physical AC-3 sub-stream channel probe for DVD bug logs.
//!
//! On some discs the physical `private_stream_1` sub-stream order does not match the IFO's
//! declared audio stream order, so ordinal assignment (`ifo::assign_audio_sub_stream_ids`) can
//! mux the wrong physical track (e.g. a 2.0 down-mix labelled 5.1). This module probes each
//! physical AC-3 sub-stream's REAL channel count from the VOB and re-routes each declared
//! stream to the matching sub-stream, falling back to ordinal mapping when the probe yields
//! nothing.
//! Reads the head of a title's feature and reports each physical `private_stream_1` AC-3
//! sub-stream's REAL channel count under `--log-level 3`, so a log alone shows whether the
//! IFO's declared layout matches the VOB. It no longer re-routes anything: the scanner routes
//! audio by the PGC's AST_CTL, the map every player follows, and a channel-count heuristic
//! layered on top could only move a stream off the track the disc actually plays.
use crate::disc::Stream;
use crate::mux::codec::ac3;
@@ -75,84 +74,24 @@ fn max_substream_channels(data: &[u8]) -> Option<u8> {
best
}
/// Re-route the title's declared AC-3 audio streams onto the physical sub-stream ids whose REAL
/// channel counts match, using a probed `sub_id -> channels` map. For each declared AC-3 audio
/// stream (in IFO order), picks the physical `0x8x` sub-stream whose probed channel count
/// equals the declared count, never reusing a claimed sub-stream, and writes its PID (`0xBD00 |
/// sub_id`) back onto the `Stream::Audio`. Conservative: only reassigns when a better match
/// exists. Returns the number of streams whose PID was changed.
pub fn remap_audio_pids(streams: &mut [Stream], probed: &BTreeMap<u8, u8>) -> usize {
if probed.is_empty() {
return 0;
}
// Sub-streams already claimed by a remapped (or matching) earlier stream,
// so two declared streams never collide on one physical sub-stream.
let mut claimed: Vec<u8> = Vec::new();
let mut changed = 0usize;
for s in streams.iter_mut() {
let Stream::Audio(a) = s else { continue };
if a.codec != crate::disc::Codec::Ac3 {
continue;
}
let declared = a.channels.count();
// The sub-id this stream currently routes by (low byte of its PID).
let current_sub = (a.pid & 0x00FF) as u8;
// If the stream's current physical sub-stream already matches its
// declared channel count, keep it and claim it.
if probed.get(&current_sub) == Some(&declared) {
claimed.push(current_sub);
continue;
}
// Otherwise find an unclaimed physical sub-stream whose REAL channel
// count equals the declared count.
let pick = probed
.iter()
.find(|(sub, ch)| **ch == declared && !claimed.contains(*sub))
.map(|(sub, _)| *sub);
if let Some(sub) = pick {
let new_pid = 0xBD00 | sub as u16;
if new_pid != a.pid {
tracing::debug!(
target: "freemkv::scan",
old_pid = a.pid,
new_pid,
declared_channels = declared,
"dvd: re-routed AC-3 audio to physical sub-stream matching channel count"
);
a.pid = new_pid;
changed += 1;
}
claimed.push(sub);
} else {
// No physical match — leave the ordinal assignment, but claim its
// current sub so later streams don't steal a slot it may still use.
claimed.push(current_sub);
}
}
changed
}
/// Probe the first feature extent of a DVD title through a (decrypted) sector
/// source and re-route its AC-3 audio PIDs to the physically-correct
/// sub-streams. A bounded, best-effort scan: any read error or empty probe
/// leaves the ordinal assignment untouched.
/// Probe the first feature extent of a DVD title through a (decrypted) sector source and
/// log each physical AC-3 sub-stream's real channel count. Diagnostics only: reads nothing
/// unless `freemkv::diag` is enabled, and never changes `title` (the name predates L084b).
///
/// `reader` MUST yield PLAINTEXT VOB bytes (i.e. a `DecryptingSectorSource` on a
/// CSS disc) — probing scrambled sectors yields no AC-3 syncs and is a safe
/// no-op. Remaps `title`'s AC-3 streams in place; returns nothing.
/// `reader` MUST yield PLAINTEXT VOB bytes (a `DecryptingSectorSource` on a CSS disc);
/// scrambled sectors yield no AC-3 syncs and log `probed=0`.
pub fn probe_and_remap<S: SectorSource + ?Sized>(
reader: &mut S,
title: &mut crate::disc::DiscTitle,
) {
// Only DVD (MPEG-PS) titles carry private_stream_1 AC-3 sub-streams.
if title.content_format != crate::disc::ContentFormat::MpegPs {
if !tracing::enabled!(target: "freemkv::diag", tracing::Level::DEBUG) {
return;
}
// Nothing to disambiguate unless there is at least one AC-3 audio stream.
// Only DVD-Video titles (`DvdPs`) carry these private_stream_1 AC-3 sub-streams.
if title.content_format != crate::disc::ContentFormat::DvdPs {
return;
}
// Nothing to report unless there is at least one AC-3 audio stream.
let has_ac3 = title
.streams
.iter()
@@ -169,7 +108,8 @@ pub fn probe_and_remap<S: SectorSource + ?Sized>(
}
let mut buf = vec![0u8; count as usize * 2048];
// `recovery=false`: a single best-effort attempt — the probe must never
// stall the mux or hammer a marginal drive. On any error, bail to ordinal.
// stall the mux or hammer a marginal drive. On any error, log nothing.
reader.set_unit_base(ext.start_lba); // HD DVD AACS: anchor at the title head
let n = match reader.read_sectors(ext.start_lba, count, &mut buf, false) {
Ok(n) => n,
Err(_) => return,
@@ -177,340 +117,8 @@ pub fn probe_and_remap<S: SectorSource + ?Sized>(
buf.truncate(n);
let probed = probe_ac3_substream_channels(&buf);
crate::diag::dump_dvd_substream_probe(title.playlist_id, &probed);
remap_audio_pids(&mut title.streams, &probed);
}
#[cfg(test)]
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"
);
}
// Real-disc regression: IFO declares ONE 5.1 AC-3 stream ordinally mapped
// to 0x80, but physically 0x80 is the 2.0 down-mix and 5.1 lives at 0x81.
#[test]
fn remap_routes_declared_51_to_physical_51_substream() {
// Physical layout: 0x80 = 2.0, 0x81 = 5.1 (reversed vs ordinal).
let mut probed = BTreeMap::new();
probed.insert(0x80u8, 2u8);
probed.insert(0x81u8, 6u8);
// Declared: one 5.1 stream, ordinally assigned 0x80 (PID 0xBD80).
let mut streams = vec![ac3_stream(0xBD80, AudioChannels::Surround51)];
let changed = remap_audio_pids(&mut streams, &probed);
assert_eq!(changed, 1, "the one 5.1 stream must be re-routed");
let Stream::Audio(a) = &streams[0] else {
panic!("audio")
};
assert_eq!(
a.pid, 0xBD81,
"declared 5.1 must route to physical 0x81 (the real 5.1), not ordinal 0x80"
);
}
/// Conservative no-op: when the physical order already matches the IFO
/// order (0x80 = 5.1 as declared), remap changes nothing.
#[test]
fn remap_noop_when_physical_matches_ordinal() {
let mut probed = BTreeMap::new();
probed.insert(0x80u8, 6u8); // 0x80 really is the 5.1
let mut streams = vec![ac3_stream(0xBD80, AudioChannels::Surround51)];
let changed = remap_audio_pids(&mut streams, &probed);
assert_eq!(changed, 0, "matching physical order is a no-op");
let Stream::Audio(a) = &streams[0] else {
panic!()
};
assert_eq!(a.pid, 0xBD80);
}
/// Two declared streams (5.1 + 2.0) where the physical order is reversed:
/// 0x80=2.0, 0x81=5.1. The 5.1 declaration must claim 0x81 and the 2.0
/// declaration must claim 0x80 — no collision, both correct.
#[test]
fn remap_two_streams_no_collision() {
let mut probed = BTreeMap::new();
probed.insert(0x80u8, 2u8);
probed.insert(0x81u8, 6u8);
// Declared order: 5.1 first (ordinal 0x80), 2.0 second (ordinal 0x81).
let mut streams = vec![
ac3_stream(0xBD80, AudioChannels::Surround51),
ac3_stream(0xBD81, AudioChannels::Stereo),
];
remap_audio_pids(&mut streams, &probed);
let pids: Vec<u16> = streams
.iter()
.filter_map(|s| match s {
Stream::Audio(a) => Some(a.pid),
_ => None,
})
.collect();
assert_eq!(
pids,
vec![0xBD81, 0xBD80],
"5.1→0x81, 2.0→0x80, no collision"
);
}
/// Empty probe (unreadable / scrambled VOB) is a no-op — the ordinal
/// assignment survives so behaviour never regresses below today's.
#[test]
fn remap_empty_probe_is_noop() {
let probed = BTreeMap::new();
let mut streams = vec![ac3_stream(0xBD80, AudioChannels::Surround51)];
let changed = remap_audio_pids(&mut streams, &probed);
assert_eq!(changed, 0);
let Stream::Audio(a) = &streams[0] else {
panic!()
};
assert_eq!(a.pid, 0xBD80, "no probe data → keep ordinal");
}
// 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"
);
}
// Mutation guard: current sub-stream must be read via `pid & 0x00FF`, not `|`/`^`.
#[test]
fn remap_reads_current_substream_via_and_not_or_or_xor() {
let mut probed = BTreeMap::new();
probed.insert(0x80u8, 6u8);
probed.insert(0x81u8, 6u8); // ambiguous: two physical 6ch sub-streams
let mut streams = vec![ac3_stream(0xBD81, AudioChannels::Surround51)];
let changed = remap_audio_pids(&mut streams, &probed);
assert_eq!(
changed, 0,
"already sitting on a matching physical sub-stream (0x81) must be left alone"
);
let Stream::Audio(a) = &streams[0] else {
panic!()
};
assert_eq!(
a.pid, 0xBD81,
"must not be bumped to the other matching sub-stream (0x80)"
);
}
/// 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)
}
}
// End-to-end `probe_and_remap`: real-disc-shaped MpegPs title, 0x80=2.0 down-mix /
// 0x81=real 5.1, must re-route to 0xBD81.
#[test]
fn probe_and_remap_reroutes_swapped_substream_scenario_end_to_end() {
let mut bytes = ps_ac3(0x80, 2, false); // physical 0x80 = 2.0 down-mix
bytes.extend(ps_ac3(0x81, 7, true)); // physical 0x81 = 5.1 main mix
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(0xBD80, AudioChannels::Surround51)],
chapters: Vec::new(),
extents: vec![Extent {
start_lba: 0,
sector_count: 2,
}],
content_format: ContentFormat::MpegPs,
codec_privates: vec![None],
};
let mut source = FixedSource { data: bytes };
probe_and_remap(&mut source, &mut title);
let Stream::Audio(a) = &title.streams[0] else {
panic!("audio")
};
assert_eq!(
a.pid, 0xBD81,
"declared 5.1 stream must be re-routed to the physical 5.1 sub-stream 0x81"
);
}
}
#[path = "dvd_audio_probe_tests.rs"]
mod tests;
+233
View File
@@ -0,0 +1,233 @@
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
@@ -0,0 +1,401 @@
//! 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
@@ -0,0 +1,193 @@
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
+422 -1159
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+387 -1965
View File
File diff suppressed because it is too large Load Diff

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