mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
186
Commits
v1.6.9-rc3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6980b04e63 | ||
|
|
777cd6275c | ||
|
|
4d8cba6ff8 | ||
|
|
76ddaf5538 | ||
|
|
0672db23e7 | ||
|
|
6621987c8a | ||
|
|
76507e677d | ||
|
|
c3ceb62f5f | ||
|
|
d1e0a8a98c | ||
|
|
0b146ef784 | ||
|
|
a69a8d9bbd | ||
|
|
92847607b9 | ||
|
|
0a96b1ca81 | ||
|
|
561a757b60 | ||
|
|
f745a6f2e6 | ||
|
|
f855d604ae | ||
|
|
46ffac8690 | ||
|
|
81ecc8acce | ||
|
|
cf2171e11a | ||
|
|
81b7e3ab4e | ||
|
|
5b9dd0fbec | ||
|
|
4031513574 | ||
|
|
1d78420366 | ||
|
|
25fd393c04 | ||
|
|
fdab162bc4 | ||
|
|
9000c83c1b | ||
|
|
1baf9ba061 | ||
|
|
3617ccf733 | ||
|
|
cbd3db9d0e | ||
|
|
3207856c17 | ||
|
|
18982a7c0e | ||
|
|
503a80884e | ||
|
|
d6396005b8 | ||
|
|
8c707efbe1 | ||
|
|
4c5098ce75 | ||
|
|
aa85906a87 | ||
|
|
ee25d50c11 | ||
|
|
c8e67f16b4 | ||
|
|
efc9bd705d | ||
|
|
68d27be8f8 | ||
|
|
a42241eb15 | ||
|
|
5520890550 | ||
|
|
d0c9df91ef | ||
|
|
1363ba8510 | ||
|
|
3f156f1f03 | ||
|
|
df43373f41 | ||
|
|
a797c544f1 | ||
|
|
584cc6030b | ||
|
|
7257559153 | ||
|
|
8949b81dfc | ||
|
|
7bcfdeda32 | ||
|
|
89be65a3d4 | ||
|
|
f21ab37920 | ||
|
|
0c28e569a8 | ||
|
|
0d9565eac9 | ||
|
|
b52e3c8292 | ||
|
|
d4510392f6 | ||
|
|
49992a5c2d | ||
|
|
c1caac3a7e | ||
|
|
b69002694f | ||
|
|
11c869f8e9 | ||
|
|
8ce40edba3 | ||
|
|
bccc8eb9a7 | ||
|
|
0edbdfbb21 | ||
|
|
2499f1600d | ||
|
|
bb3b1920a9 | ||
|
|
551173fa46 | ||
|
|
26a9cb9e62 | ||
|
|
86d66d3113 | ||
|
|
6d20c854c0 | ||
|
|
24f29d83f0 | ||
|
|
471a441799 | ||
|
|
aeb6bf5cc1 | ||
|
|
0062d79d2c | ||
|
|
b0e297bd41 | ||
|
|
0cd967a5ac | ||
|
|
d7b6aca6e8 | ||
|
|
3a41e0eec7 | ||
|
|
0109db984f | ||
|
|
841328af0f | ||
|
|
a35f336c52 | ||
|
|
00f590f634 | ||
|
|
827eaf0452 | ||
|
|
f4ea6ea129 | ||
|
|
b47bccce7b | ||
|
|
13b93f803b | ||
|
|
1894fa1a49 | ||
|
|
5696175520 | ||
|
|
e99cb95bd4 | ||
|
|
1794f5c406 | ||
|
|
9379507c15 | ||
|
|
8457efb5da | ||
|
|
dbc135bee0 | ||
|
|
48fb9b2cf6 | ||
|
|
013f27eede | ||
|
|
9f0e10a524 | ||
|
|
b65124728c | ||
|
|
10d073286c | ||
|
|
89fd1a6e4d | ||
|
|
5c9e2fefe5 | ||
|
|
041ac5876f | ||
|
|
e0875b630c | ||
|
|
d5902ff9f9 | ||
|
|
01cd85f514 | ||
|
|
6de61484a3 | ||
|
|
764519de43 | ||
|
|
2fb7d78b29 | ||
|
|
18024ea5f5 | ||
|
|
2287bb4449 | ||
|
|
013cc3ce8d | ||
|
|
ab2416d07a | ||
|
|
b735fb3730 | ||
|
|
f1b7a73b93 | ||
|
|
fbe91895c4 | ||
|
|
679894e5ea | ||
|
|
b7914c5995 | ||
|
|
747aeba4a4 | ||
|
|
034a8b145b | ||
|
|
6720ccdf8b | ||
|
|
24ab408559 | ||
|
|
6e427e2eac | ||
|
|
e130528fea | ||
|
|
0de2e598ef | ||
|
|
d0a5a90261 | ||
|
|
be296b3901 | ||
|
|
b30b06e04e | ||
|
|
e6d86b3582 | ||
|
|
2a701a217a | ||
|
|
90e8e99152 | ||
|
|
1edacf294f | ||
|
|
7350577d91 | ||
|
|
b985618fb5 | ||
|
|
d3c4ce4a60 | ||
|
|
f4a48480df | ||
|
|
aad821c8a2 | ||
|
|
959bba919d | ||
|
|
ff3e46a560 | ||
|
|
14f97aacd6 | ||
|
|
5c911d1573 | ||
|
|
cdc1d17993 | ||
|
|
7b4a0b31d5 | ||
|
|
a85764da38 | ||
|
|
5af336776c | ||
|
|
86c2c3c501 | ||
|
|
c280478ef9 | ||
|
|
e7bcef0c45 | ||
|
|
695569d29a | ||
|
|
77e4bca234 | ||
|
|
ae8c20f1ca | ||
|
|
086547b78b | ||
|
|
b09ed299fc | ||
|
|
3f262ba6cb | ||
|
|
3c8fce3031 | ||
|
|
5578e42961 | ||
|
|
a7db00c0a1 | ||
|
|
09957e066b | ||
|
|
a88b7486ac | ||
|
|
6957c7251c | ||
|
|
6e14fc0c0c | ||
|
|
962aa5d852 | ||
|
|
719b63e376 | ||
|
|
3cf7922574 | ||
|
|
0c953e9340 | ||
|
|
2e90a7585d | ||
|
|
3e11225389 | ||
|
|
82511d84b6 | ||
|
|
20afbfdccb | ||
|
|
6d78a36d22 | ||
|
|
a9d41f53f8 | ||
|
|
c7c53989f7 | ||
|
|
7ec2dbfbcb | ||
|
|
16e5b5ee38 | ||
|
|
d40540ce9e | ||
|
|
ba14d66c18 | ||
|
|
f16abe1cf4 | ||
|
|
477c0da6dd | ||
|
|
41c5b8b785 | ||
|
|
9f43ae94d3 | ||
|
|
9ed6f86255 | ||
|
|
70ae7b6c10 | ||
|
|
3f03c6db06 | ||
|
|
562bb8bee5 | ||
|
|
7c13ed32b5 | ||
|
|
574aa2c506 | ||
|
|
3912762b52 | ||
|
|
028768e71b |
@@ -0,0 +1 @@
|
||||
github: MattJackson
|
||||
+31
-61
@@ -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*
|
||||
|
||||
+187
-176
@@ -2,25 +2,40 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
# dev -> qa -> main. `dev` is where work lands and is meant to be pushed
|
||||
# to often: these are the FAST checks, so a mistake surfaces in minutes.
|
||||
# `qa` is the release candidate — it runs these too, plus the expensive
|
||||
# suite in qa.yml. `main` only ever moves at release time, to a tagged
|
||||
# commit that was already green on qa.
|
||||
# dev -> qa -> main. `dev` is where work lands and is pushed to often: these
|
||||
# are the FAST checks. `qa` runs these plus the expensive suite in qa.yml.
|
||||
# `main` only moves at release time, to a commit already green on qa.
|
||||
branches: [main, dev, qa]
|
||||
pull_request:
|
||||
|
||||
# Rapid dev pushes supersede their own in-progress runs instead of stacking.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# CI owns the changelog: main may never carry a released version without a
|
||||
# CHANGELOG.md entry for it. The release orchestrator dates an existing
|
||||
# `## [X.Y.Z]` heading — it does NOT invent one — so a version bump that
|
||||
# forgot its changelog entry must fail here rather than ship a blank release.
|
||||
# Hard-fails on main (and PRs targeting main); warns elsewhere so an
|
||||
# in-progress dev/qa branch whose entry isn't written yet is not blocked.
|
||||
# Single source of truth for the MSRV: `rust-version` in Cargo.toml. Every job
|
||||
# that builds AT the MSRV consumes needs.resolve-msrv.outputs.v so the floor
|
||||
# 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- id: msrv
|
||||
shell: bash
|
||||
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# main may never carry a released version without a CHANGELOG.md entry. Hard-
|
||||
# fails on main (and PRs to main); warns elsewhere so an in-progress branch
|
||||
# whose entry isn't written yet is not blocked.
|
||||
changelog:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- name: Require a CHANGELOG entry for the crate version on main
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -37,190 +52,189 @@ jobs:
|
||||
fi
|
||||
echo "::warning::${msg} (allowed on ${GITHUB_REF_NAME:-unknown}; required before it can reach main)."
|
||||
|
||||
# fmt on the pinned MSRV; clippy on stable so new-lint debt is a blocking gate
|
||||
# the pinned version would hide. --all-targets lints test code too.
|
||||
lint:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
|
||||
# swaps it to a git tag only inside the tagged commit, then restores the
|
||||
# path dep). CI checks out one repo, so the branch tip has never been
|
||||
# buildable here — every green run you have ever seen was a tag build,
|
||||
# and Windows/Linux were first compiled at release time.
|
||||
#
|
||||
# Both repos go into subdirectories because actions/checkout refuses a
|
||||
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
|
||||
# With this layout the path dep resolves exactly as it does locally.
|
||||
- uses: actions/checkout@v7
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
self: libfreemkv
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
components: rustfmt
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo fmt --check
|
||||
working-directory: libfreemkv
|
||||
# libfreemkv is a library — Cargo.lock is gitignored. --locked
|
||||
# would always fail on a fresh runner because there's no committed
|
||||
# lockfile to lock against. The binary crates (freemkv, autorip,
|
||||
# bdemu) track Cargo.lock and DO use --locked.
|
||||
# --all-targets so TEST code is linted too. Without it this crate — the
|
||||
# reference implementation for the other seven — was the only one whose
|
||||
# tests had never been linted at all, and it was hiding 74 findings.
|
||||
- run: cargo clippy --all-targets -- -D warnings
|
||||
- run: cargo +stable clippy --all-targets -- -D warnings
|
||||
working-directory: libfreemkv
|
||||
|
||||
test:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
|
||||
# swaps it to a git tag only inside the tagged commit, then restores the
|
||||
# path dep). CI checks out one repo, so the branch tip has never been
|
||||
# buildable here — every green run you have ever seen was a tag build,
|
||||
# and Windows/Linux were first compiled at release time.
|
||||
#
|
||||
# Both repos go into subdirectories because actions/checkout refuses a
|
||||
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
|
||||
# With this layout the path dep resolves exactly as it does locally.
|
||||
- uses: actions/checkout@v7
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
self: libfreemkv
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- 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
|
||||
|
||||
check-macos:
|
||||
# dev is the FAST lane: this job still runs, but on the release-candidate
|
||||
# branches rather than on every push to dev. Nothing is deleted and no
|
||||
# platform stops being checked before a release -- qa.yml independently
|
||||
# covers macOS and Windows, and the jobs unique to this file (the Intel
|
||||
# macOS build, the Windows release build) run here on qa and main. A push
|
||||
# to dev is meant to be cheap and frequent; waiting on three runner pools
|
||||
# to agree is what a release candidate is for.
|
||||
#
|
||||
# `if` SKIPS the job (it does not queue). A queued job would be far worse
|
||||
# than a slow one: release.sh's CI gate refuses while any run for the
|
||||
# commit is still in progress, so a never-scheduled job blocks releases
|
||||
# silently -- see the note on real-media in qa.yml.
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: macos-latest
|
||||
# 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
|
||||
# checkout even with --no-default-features: features don't exempt an optional
|
||||
# path dep from manifest RESOLUTION.
|
||||
loom:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
RUSTFLAGS: --cfg loom
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
|
||||
# swaps it to a git tag only inside the tagged commit, then restores the
|
||||
# path dep). CI checks out one repo, so the branch tip has never been
|
||||
# buildable here — every green run you have ever seen was a tag build,
|
||||
# and Windows/Linux were first compiled at release time.
|
||||
#
|
||||
# Both repos go into subdirectories because actions/checkout refuses a
|
||||
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
|
||||
# With this layout the path dep resolves exactly as it does locally.
|
||||
- uses: actions/checkout@v7
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
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@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
|
||||
working-directory: libfreemkv
|
||||
|
||||
deny:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- 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
|
||||
- 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
|
||||
# can't block the release gate.
|
||||
check-macos:
|
||||
needs: resolve-msrv
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- 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@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo check
|
||||
working-directory: libfreemkv
|
||||
|
||||
check-windows:
|
||||
# dev is the FAST lane: this job still runs, but on the release-candidate
|
||||
# branches rather than on every push to dev. Nothing is deleted and no
|
||||
# platform stops being checked before a release -- qa.yml independently
|
||||
# covers macOS and Windows, and the jobs unique to this file (the Intel
|
||||
# macOS build, the Windows release build) run here on qa and main. A push
|
||||
# to dev is meant to be cheap and frequent; waiting on three runner pools
|
||||
# to agree is what a release candidate is for.
|
||||
#
|
||||
# `if` SKIPS the job (it does not queue). A queued job would be far worse
|
||||
# than a slow one: release.sh's CI gate refuses while any run for the
|
||||
# commit is still in progress, so a never-scheduled job blocks releases
|
||||
# silently -- see the note on real-media in qa.yml.
|
||||
needs: resolve-msrv
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
|
||||
# swaps it to a git tag only inside the tagged commit, then restores the
|
||||
# path dep). CI checks out one repo, so the branch tip has never been
|
||||
# buildable here — every green run you have ever seen was a tag build,
|
||||
# and Windows/Linux were first compiled at release time.
|
||||
#
|
||||
# Both repos go into subdirectories because actions/checkout refuses a
|
||||
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
|
||||
# With this layout the path dep resolves exactly as it does locally.
|
||||
- uses: actions/checkout@v7
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
self: libfreemkv
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
# Build the tests (not just `cargo check`): catches errors in test
|
||||
# code and forces full codegen of the Windows-only SPTI transport
|
||||
# (src/scsi/windows.rs), which never compiles on the Linux/macOS dev
|
||||
# hosts. We don't `cargo test` here — the suite needs no drive but the
|
||||
# extra build is the value; running tests is covered by the Linux job.
|
||||
# Build the tests: forces full codegen of the Windows-only SPTI transport
|
||||
# (src/scsi/windows.rs), which never compiles on Linux/macOS dev hosts.
|
||||
- run: cargo build --tests
|
||||
working-directory: libfreemkv
|
||||
|
||||
# ── Did this change break anything downstream? ──────────────────────────────
|
||||
#
|
||||
# Every job above proves libfreemkv builds. None proved its DEPENDENTS do,
|
||||
# and that gap is real: an engine signature change broke autorip today and
|
||||
# went unnoticed because consumer CI only fires on a push to that consumer.
|
||||
# libfreemkv sits below all five of them, so a break here is worth strictly
|
||||
# more than a break anywhere else in the project.
|
||||
#
|
||||
# `cargo check --all-targets` only — each dependent owns its own behaviour
|
||||
# and has its own suite. The question here is just "does everything built on
|
||||
# me still compile against this commit".
|
||||
# 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@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with: { path: libfreemkv }
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv-unlock, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-unlock }
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv-keysources, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-keysources }
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv-engine, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-engine }
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv-i18n, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-i18n }
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv }
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/autorip, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: autorip }
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/bdemu, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: bdemu }
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
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]
|
||||
@@ -228,67 +242,64 @@ jobs:
|
||||
freemkv-keysources = { path = "../freemkv-keysources" }
|
||||
freemkv-engine = { path = "../freemkv-engine" }
|
||||
freemkv-i18n = { path = "../freemkv-i18n" }
|
||||
freemkv-unlock = { path = "../freemkv-unlock" }
|
||||
EOF
|
||||
done
|
||||
# The consumers span a higher MSRV than libfreemkv itself (the bins are
|
||||
# 1.98, keysources 1.97), so build them with a toolchain that satisfies the
|
||||
# HIGHEST dependent floor — libfreemkv's own 1.94 cannot compile them.
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# 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
|
||||
sudo apt-get install -y libgtk-4-dev libadwaita-1-dev build-essential
|
||||
# Dependents track the highest supported Rust — build on stable so this job
|
||||
# satisfies the highest dependent floor (the library's own lower MSRV, which
|
||||
# the `test` job verifies, cannot compile them).
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
workspaces: |
|
||||
freemkv-keysources
|
||||
freemkv-engine
|
||||
freemkv
|
||||
autorip
|
||||
bdemu
|
||||
# `cargo check` alone only proves the dependents still COMPILE against
|
||||
# this commit. It cannot see a behavioural change — the library keeps its
|
||||
# signatures and a dependent's tests start failing. That is the shape of
|
||||
# every defect worth catching here, so run their suites too.
|
||||
- run: cargo test --tests
|
||||
working-directory: freemkv-keysources
|
||||
- run: cargo test --tests
|
||||
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
|
||||
|
||||
# ── Coverage (non-required, additive) ───────────────────────────────────────
|
||||
#
|
||||
# Reports line coverage to Codecov. Deliberately NOT wired into any release
|
||||
# gate: it is informational only (see codecov.yml) and no other job `needs:`
|
||||
# it. Mirrors the `test` job's layout exactly — same pinned 1.94 toolchain,
|
||||
# same dual checkout so the `../freemkv-unlock` path dep resolves, same cache
|
||||
# workspace and working-directory. cargo-llvm-cov comes as a prebuilt binary
|
||||
# from taiki-e/install-action, so it is toolchain-independent.
|
||||
#
|
||||
# No `--locked`: like the fmt/clippy/test jobs above, this crate's Cargo.lock
|
||||
# is gitignored, so there is nothing to lock against on a fresh runner.
|
||||
# Coverage: non-required, informational only (codecov.yml), no job needs: it.
|
||||
coverage:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
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@v5
|
||||
- uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: libfreemkv/lcov.info
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
name: comment-guard
|
||||
|
||||
# Thin caller → the canonical reusable guard in freemkv/.github (one definition
|
||||
# for every repo). Fork PRs still run it; same-repo pushes aren't double-run.
|
||||
on: [push, pull_request]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
comment-guard:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-comment.yml@v2
|
||||
@@ -0,0 +1,18 @@
|
||||
name: doc-links
|
||||
|
||||
# Thin caller → the canonical reusable doc-links gate in freemkv/.github.
|
||||
# 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 }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
doc-links:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-doclinks.yml@v1
|
||||
with:
|
||||
self: libfreemkv
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
@@ -0,0 +1,58 @@
|
||||
name: FFmpeg validation
|
||||
|
||||
# Called by qa.yml on pushes to qa, or manually for a development branch.
|
||||
# Pure Rust tests remain in dev CI; these tests require external codecs.
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
FREEMKV_FFMPEG_ARTIFACT_DIR: ${{ github.workspace }}/ffmpeg-artifacts
|
||||
steps:
|
||||
- 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
|
||||
run: echo "v=$(sed -n 's/^rust-version = "\(.*\)"/\1/p' Cargo.toml)" >> "$GITHUB_OUTPUT"
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ steps.msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- name: Install software encoders and decoders
|
||||
run: sudo apt-get update && sudo apt-get install -y ffmpeg
|
||||
- name: Record tool versions
|
||||
run: |
|
||||
mkdir -p "$FREEMKV_FFMPEG_ARTIFACT_DIR"
|
||||
ffmpeg -version > "$FREEMKV_FFMPEG_ARTIFACT_DIR/ffmpeg-version.txt"
|
||||
ffprobe -version > "$FREEMKV_FFMPEG_ARTIFACT_DIR/ffprobe-version.txt"
|
||||
rustc --version > "$FREEMKV_FFMPEG_ARTIFACT_DIR/rustc-version.txt"
|
||||
- name: Generate fixtures and validate libfreemkv output
|
||||
working-directory: libfreemkv
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ffmpeg-validation-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ env.FREEMKV_FFMPEG_ARTIFACT_DIR }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,14 @@
|
||||
name: identity-guard
|
||||
|
||||
# Thin caller → the canonical reusable guard in freemkv/.github.
|
||||
on:
|
||||
push:
|
||||
branches: [dev, qa, main]
|
||||
pull_request:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
identity:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-identity.yml@v1
|
||||
@@ -1,35 +1,11 @@
|
||||
name: leak-guard
|
||||
|
||||
# Self-contained public-repo leak gate. Public CI cannot reach the private
|
||||
# tooling, so this encodes only the generic net: internal-infra references,
|
||||
# tracked CLAUDE.md/.claude paths, and AI-attribution in commit messages.
|
||||
# No project-specific reverse-engineering vocabulary lives here.
|
||||
|
||||
# Thin caller → the canonical reusable guard in freemkv/.github.
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
leak-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Compute commit range
|
||||
id: range
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
head="${{ github.event.pull_request.head.sha }}"
|
||||
echo "range=$base..$head" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
before="${{ github.event.before }}"
|
||||
after="${{ github.sha }}"
|
||||
# New branch / first push: github.event.before is all-zeros.
|
||||
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "range=$after" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "range=$before..$after" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
- name: Run leak-guard
|
||||
run: bash ci/leak-guard.sh "${{ steps.range.outputs.range }}"
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-leak.yml@v1
|
||||
|
||||
+78
-31
@@ -15,15 +15,33 @@ name: qa
|
||||
# one being released, which is the exact failure this branch exists to prevent.
|
||||
#
|
||||
# What this gate CANNOT cover: `disc://` and real `iso://` need physical media,
|
||||
# and no hosted runner has an optical drive or the image hoard. Those run on a
|
||||
# self-hosted runner (see the media job at the end) and are the one leg that
|
||||
# stays on hardware.
|
||||
# and no hosted runner has an optical drive or the image hoard. That coverage
|
||||
# lives in freemkv's qa.yml (the real-media EC2 matrix), not in this file.
|
||||
on:
|
||||
push:
|
||||
branches: [qa]
|
||||
workflow_dispatch:
|
||||
|
||||
# Rapid dev pushes supersede their own in-progress runs instead of stacking.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Single source of truth for the MSRV — see ci.yml. Jobs that build/test AT
|
||||
# the MSRV read `needs.resolve-msrv.outputs.v` from Cargo.toml's rust-version;
|
||||
# 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- id: msrv
|
||||
shell: bash
|
||||
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── Name the candidate ────────────────────────────────────────────────
|
||||
#
|
||||
# Every push to `qa` is a release candidate, so every push gets a tag:
|
||||
@@ -39,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@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Stamp the next rc
|
||||
@@ -54,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:
|
||||
@@ -65,49 +92,65 @@ jobs:
|
||||
# changes what the optimiser can prove. A test that only passes in debug is
|
||||
# a test that never guarded the binary anyone actually ships.
|
||||
release-tests:
|
||||
needs: resolve-msrv
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ['ubuntu-latest', 'macos-latest']
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
self: libfreemkv
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- 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.
|
||||
ffmpeg-validation:
|
||||
uses: ./.github/workflows/ffmpeg-validation.yml
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# clippy's output is target-dependent: cfg-gated code only gets linted on
|
||||
# the target it compiles for. Linting solely on the dev machine's host
|
||||
# target is how a lint that CI rejects reaches a push.
|
||||
cross-lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
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
|
||||
@@ -115,18 +158,22 @@ jobs:
|
||||
# of the SCSI transport and platform layers compile on no other runner, so
|
||||
# without this they are first built at release time.
|
||||
windows-build:
|
||||
needs: resolve-msrv
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
self: libfreemkv
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo build --release --tests
|
||||
|
||||
@@ -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,20 +29,27 @@ 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
|
||||
# gated by the local precommit (same Rust 1.94, same commit). Binary consumers
|
||||
# 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: dtolnay/rust-toolchain@1.94.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# libfreemkv is a library — Cargo.lock isn't tracked, so --locked
|
||||
# would always fail (no lockfile to lock against on a fresh runner).
|
||||
- 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@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.
|
||||
- run: cargo test
|
||||
|
||||
# NOTE: there is no crates.io publish job. libfreemkv is git-tag-only
|
||||
@@ -52,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
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Scorecard
|
||||
on:
|
||||
branch_protection_rule:
|
||||
schedule:
|
||||
- cron: '22 9 * * 2'
|
||||
push:
|
||||
branches: ["dev"]
|
||||
permissions: read-all
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- 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@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -1,5 +1,4 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
+268
-10
@@ -1,6 +1,273 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
## [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
|
||||
|
||||
- Replace comment-overflow documentation with concise source contracts and README instructions; enforce the shared comment policy in CI.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Preserve PGS clear events so forced subtitles disappear and FFmpeg remuxing retains valid timestamps (#52).
|
||||
- Reassemble AAC/MPEG audio frames, recognize standard transport audio types, retain opening audio and preserve MKV decoder delay/padding.
|
||||
|
||||
### Added
|
||||
|
||||
- FFmpeg interoperability validation on QA, using generated fixtures and decoded-content/timing comparisons.
|
||||
|
||||
## [1.7.6] — 2026-09-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.7.6 for the unified release. No functional changes to this crate; the release is driven by the freemkv 1.7.6 Linux desktop shell (GTK4 + libadwaita) and the rip-finished desktop notification (issue #56).
|
||||
|
||||
## [1.7.5] — 2026-09-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.7.5 for the unified release. No functional changes to this crate; the release is driven by freemkv-unlock mirroring the freemkv-firmware 0.9.0 ABI (the drive's `Ake` and `Bus` levers retired into a single `Encryption` lever).
|
||||
|
||||
## [1.7.4] — 2026-09-21
|
||||
### Fixed
|
||||
|
||||
- BD-J / Java-menu main-title selection reworked (#45): a chapter-completeness failsafe, a BD-J menu-walk (parses the BDJO objects and jar manifests), and a stream-richness tiebreak now cooperate so discs driven by a Java menu pick the real main feature — keeping the Dolby Vision enhancement layer and all subtitle tracks — instead of a lower-id sibling playlist.
|
||||
- PGS subtitles now get a synthesized `BlockDuration` on every block, fixing the "Timestamps are unset" mux error, plus a defensive HEVC NAL length-prefix guard (#52).
|
||||
- keydb "no entry" is de-conflated into a true miss versus matched-but-no-usable-VID, and the disc hash is surfaced; adds `KeyStep.matched_entry` and `KeyStep.store_entries` (#46).
|
||||
|
||||
### Maintenance
|
||||
|
||||
- CI moved to the central reusable workflows.
|
||||
|
||||
## [1.7.3] — 2026-09-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- MPLS title enumeration: the STN (stream number) table of the first PlayItem is now located past the multi-angle *angle block* instead of at the fixed offset 32. A multi-angle first PlayItem (e.g. a seamless-branch UHD title) carries `number_of_angles` extra 10-byte angle entries before the STN table, so the old fixed offset read the wrong bytes and misparsed the primary title's streams — the root cause behind autorip/CLI picking the wrong (or no) main feature on some UHD discs. Validated against a 92-disc real-media corpus with zero regressions (issue #45).
|
||||
|
||||
### Added
|
||||
|
||||
- `disc::read_structure_files()` — captures a disc's **structure metadata** (`BDMV/index.bdmv`, `MovieObject.bdmv`, `PLAYLIST/*.mpls`, `CLIPINF/*.clpi`, `BDJO/*.bdjo`, `META/DL/*.xml`, DVD `VIDEO_TS/*.IFO`) over any `SectorSource` (drive, ISO, `dir://`). No audio/video essence and no AACS keys are read, so the result is safe to attach to a bug report (a few hundred KB). Powers `freemkv info … --share` so a reporter can reproduce a title-selection issue without shipping the full ISO.
|
||||
|
||||
## [1.7.2] — UNRELEASED
|
||||
|
||||
### Fixed
|
||||
|
||||
- ISO imaging: the READ CAPACITY (disc-size) query is now retried, with a UDF partition-size fallback, and `image_read_sectors()` hard-errors (`EmptyImage`) when the size is unavailable instead of writing a silent 0-byte ISO reported as success. Fixes an intermittent empty ISO caused by a transient capacity-query transport failure (shared CLI/autorip engine).
|
||||
|
||||
### Changed
|
||||
|
||||
- Unified release with freemkv-unlock 1.7.2 (firmware ABI v2).
|
||||
|
||||
## [1.7.1] — 2026-09-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Unified release with freemkv-unlock 1.7.1 (LibreDrive profile-match fix). No functional changes to this crate.
|
||||
|
||||
## [1.7.0] — 2026-09-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- `mux/mp4`: the capped child-box scan treated any `size < 8` as a stop, which dropped a 64-bit `largesize` child box (`size == 1`, real length in the following 8 bytes) and every sibling after it; a `size == 0` (run-to-end) box was mishandled too. Both are now honoured, so an MP4/MOV whose boxes use 64-bit sizes is walked fully instead of truncated.
|
||||
|
||||
- Linux: `SgIoTransport::raw_command` clamped an over-length CDB with `cdb.len().min(16)` instead of routing it through the shared `checked_cdb_len` guard that `execute()` uses. Under SPC-4 a CDB's length is fixed by its opcode, so truncating one does not shorten the command — it sends a descriptor the drive will read as something else. An empty CDB was likewise unguarded, and went to the sg driver as a zero-length command descriptor. Unreachable — `raw_command` is private and its one caller passes a fixed 6-byte ALLOW MEDIUM REMOVAL — but it was the one transport path that did the thing the guard exists to prevent.
|
||||
- Linux: the fd hand-off between a background recovery thread and `SgIoTransport::drop` had no release edge, so a recovery thread that published its fd after teardown had drained the slot was not guaranteed to observe `dead == true` and could leave the descriptor unclosed. `Drop`'s claiming `swap` was `Acquire` (a relaxed store, heading no release sequence) and the recovery thread's `compare_exchange` was `Release` (a relaxed load, acquiring nothing) — neither side alone was enough, and upgrading only one still leaks. Both are now `AcqRel`. Worst case was a leaked file descriptor on a transport already being torn down.
|
||||
|
||||
- Windows: `scsi::windows::list_drives` reached into the `rip`-gated `drive`/`identity` modules, so a scsi-only consumer (`default-features = false, features = ["scsi"]`) failed to compile — `--all-features` CI never exercised that combination. It now enumerates directly via `scsi::open` + `scsi::inquiry`, like the Linux and macOS backends. Surfaced by freemkv-firmware, which links libfreemkv scsi-only.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Linux recovery-thread fd hand-off moved to `scsi::fd_handoff`, where it is compiled and unit-tested on every platform (`linux.rs` is built on one host only) and model-checked under `loom`. No public API change.
|
||||
|
||||
### Security
|
||||
|
||||
- Fixed a soundness bug (GHSA-j8ww-f5fg-9pmh, low severity): `PrefetchedSectorSource::into_channels` handed out a `Sender<Vec<u8>>` recycle channel whose buffers the producer re-exposed with `unsafe { Vec::set_len }` guarded only by capacity, so a downstream caller recycling a `Vec::with_capacity(n)` (len 0) could drive `set_len` over uninitialized memory — undefined behaviour reachable from safe code. The producer now uses `Vec::resize`, removing the `unsafe`; buffer pooling (the real cross-thread alloc/free win) is unchanged. No in-tree caller triggered it.
|
||||
|
||||
## [1.6.14] — 2026-08-31
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.14 for the unified release.
|
||||
|
||||
## [1.6.13] — 2026-08-28
|
||||
|
||||
### Added
|
||||
|
||||
- Capture a few additional drive buffers during `info --share` collection on supported drives.
|
||||
|
||||
## [1.6.12] — 2026-08-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- macOS: hold a DiskArbitration claim (plus a mount-approval dissenter) for the SCSI transport's lifetime so `diskarbitrationd` can't remount the disc mid-rip — the cause of the E1000 "disc stolen mid-rip" failure on macOS.
|
||||
|
||||
### Changed
|
||||
|
||||
- Comment and documentation cleanup.
|
||||
|
||||
## [1.6.11] — 2026-08-26
|
||||
|
||||
### Added
|
||||
|
||||
- Navigation-driven main-feature selection: selection now follows the disc's
|
||||
own on-disc menu/navigation logic — a Blu-ray HDMV navigation VM and a DVD
|
||||
First-Play resolver — to reach the main feature, instead of guessing by
|
||||
title size alone.
|
||||
- Clip-set composite/decoy detection, so a title assembled from a composite
|
||||
or decoy clip set is recognized as such during selection.
|
||||
- A feature-payload floor, giving main-feature selection a minimum-size
|
||||
sanity check.
|
||||
- Normalized `DiscProfile` output.
|
||||
- Vendor navigation-label parsing.
|
||||
- Substantially expanded unit-test coverage across the navigation and
|
||||
clip-selection paths.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Main-feature selection no longer picks an obfuscated decoy playlist over
|
||||
the real feature.
|
||||
- Two rounds of code-audit hardening: navigation VM operand/field-offset
|
||||
conformance, navigation now abstains rather than guessing on an
|
||||
undecidable system-parameter branch, panic-safety fixes, log-escaping
|
||||
fixes, and expanded fuzz/test coverage.
|
||||
- Direct disc reads (`disc://…`): `DiscStream` now threads a cumulative feed
|
||||
base offset into the demuxer (`feed_at`) for both the TS and PS paths and
|
||||
forwards it onto each `PesPacket`, so demuxed frames carry byte-exact source
|
||||
provenance (`SourcePos`). Without it, `SeamPlan::place` fell back to a PTS
|
||||
heuristic that a B-frame PTS reorder dip could misread as a `stepped_back`
|
||||
clip transition, dropping all subsequent video frames in multi-clip playlists.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -10,15 +277,6 @@
|
||||
moved to match. (The crate's dependencies floor `cargo build` at 1.90, but
|
||||
clippy is only warning-clean from 1.94.)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Direct disc reads (`disc://…`): `DiscStream` now threads a cumulative feed
|
||||
base offset into the demuxer (`feed_at`) for both the TS and PS paths and
|
||||
forwards it onto each `PesPacket`, so demuxed frames carry byte-exact source
|
||||
provenance (`SourcePos`). Without it, `SeamPlan::place` fell back to a PTS
|
||||
heuristic that a B-frame PTS reorder dip could misread as a `stepped_back`
|
||||
clip transition, dropping all subsequent video frames in multi-clip playlists.
|
||||
|
||||
## [1.6.10] — 2026-08-23
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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
+863
@@ -0,0 +1,863 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"cpubits",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"pioneer-optical",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"tracing",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generator"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"log",
|
||||
"rustversion",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "libfreemkv"
|
||||
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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "loom"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"generator",
|
||||
"scoped-tls",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "8.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"typed-path",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
+104
-21
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.6.9"
|
||||
version = "1.8.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.94"
|
||||
rust-version = "1.88"
|
||||
license = "MIT"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
repository = "https://github.com/freemkv/libfreemkv"
|
||||
@@ -18,45 +18,93 @@ publish = false
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# ── Feature layout ───────────────────────────────────────────────────────────
|
||||
# `scsi` — the SCSI passthrough transport layer ONLY (`src/scsi/` + `error`):
|
||||
# ScsiTransport/execute, ScsiResult/ScsiSense/parse_sense, DriveInfo,
|
||||
# list_drives, drive_has_disc, CDB builders, the per-OS backends and
|
||||
# the raw-`cc` macOS shim (built by build.rs). Pulls only `tracing`
|
||||
# (transport instrumentation) plus the always-on `libc` target dep —
|
||||
# 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, 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.
|
||||
[features]
|
||||
default = ["rip", "scsi"]
|
||||
scsi = ["dep:tracing"]
|
||||
rip = [
|
||||
"scsi",
|
||||
"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"] }
|
||||
serde_json = "1"
|
||||
sha1 = "0.10"
|
||||
aes = "0.9"
|
||||
# Interim path dep for local cross-repo dev; the release script re-pins this to
|
||||
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
|
||||
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
||||
freemkv-unlock = { path = "../freemkv-unlock" }
|
||||
rand = "0.10"
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
base64 = "0.23"
|
||||
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 }
|
||||
# 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 = "0.20"
|
||||
roxmltree = { version = "0.21", optional = true }
|
||||
# Trace-level instrumentation for the read/transport path (SgIoTransport::execute
|
||||
# and friends). Permitted
|
||||
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
|
||||
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
||||
tracing = "0.1"
|
||||
# and friends). Part of the `scsi` feature — the transport layer traces here.
|
||||
# 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
|
||||
# polling — the 50 ms poll cadence of the previous mpsc-based impl capped mux
|
||||
# throughput at ~1 MB/s (0.21.7).
|
||||
crossbeam-channel = "0.5"
|
||||
crossbeam-channel = { version = "0.5", optional = true }
|
||||
# Persistent work-stealing thread pool for parallel AACS unit
|
||||
# decryption. Per-call std::thread::scope spawned fresh OS threads
|
||||
# and that overhead dominated for typical batch sizes (60 units).
|
||||
# rayon's global pool initialises once on first use.
|
||||
rayon = "1"
|
||||
rayon = { version = "1", optional = true }
|
||||
# SIMD-accelerated bytestring search. Drives the HEVC/H.264 start-code
|
||||
# scan in `mux::codec::h264::find_start_code` — naive byte-by-byte
|
||||
# walk is ~500 MB/s single-thread on x86_64; memchr's vectorised
|
||||
# `memmem::find` for the 3-byte `00 00 01` needle hits ~5 GB/s on
|
||||
# AVX2-capable hosts.
|
||||
memchr = "2"
|
||||
memchr = { version = "2", optional = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -66,8 +114,43 @@ 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
|
||||
# test cannot see a missing release edge there: the orderings compile to the
|
||||
# same instructions on x86_64, so the wrong ones pass every time. loom
|
||||
# enumerates the executions the C++20 model permits and reports the leaked
|
||||
# descriptor directly.
|
||||
#
|
||||
# Behind `cfg(loom)`, which nothing sets unless you ask for it, so this is out
|
||||
# of the graph for every ordinary build and for every CI job but the dedicated
|
||||
# `loom` one. Run it with:
|
||||
#
|
||||
# RUSTFLAGS="--cfg loom" cargo test --lib --no-default-features \
|
||||
# --features scsi fd_handoff
|
||||
#
|
||||
# `cargo test --lib` specifically: as a DEV-dependency loom is only in scope for
|
||||
# test targets, which is why `fd_handoff` gates its import on `all(loom, test)`
|
||||
# and falls back to std elsewhere. Without that, `--cfg loom` would break plain
|
||||
# `cargo build`/`clippy` with an unresolved-crate error.
|
||||
[target.'cfg(loom)'.dev-dependencies]
|
||||
loom = "0.7"
|
||||
|
||||
[[bench]]
|
||||
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.
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(loom)'] }
|
||||
|
||||
[lints.clippy]
|
||||
uninlined_format_args = "allow"
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,7 +1,7 @@
|
||||
# libfreemkv — local dev helper.
|
||||
# Mirrors the workspace-wide CI checks but scoped to this single crate.
|
||||
|
||||
.PHONY: test build check ci clean
|
||||
.PHONY: test build check loom ci clean
|
||||
|
||||
test:
|
||||
cargo test --tests
|
||||
@@ -13,7 +13,13 @@ check:
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
ci: check build test
|
||||
# Model-check the scsi::fd_handoff atomics. `cargo test` cannot fail on those
|
||||
# orderings — they compile to the same instructions on x86_64 — so this is the
|
||||
# only check on them. Mirrors the `loom` CI job.
|
||||
loom:
|
||||
RUSTFLAGS="--cfg loom" cargo test --lib --no-default-features --features scsi fd_handoff
|
||||
|
||||
ci: check build test loom
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
[](https://github.com/sponsors/MattJackson)
|
||||
|
||||
[](LICENSE)
|
||||
[](https://github.com/freemkv/libfreemkv/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/freemkv/libfreemkv)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/freemkv/libfreemkv)
|
||||
[](https://www.bestpractices.dev/projects/14745)
|
||||
[](https://api.reuse.software/info/github.com/freemkv/libfreemkv)
|
||||
[](#minimum-supported-rust-version)
|
||||
|
||||
# libfreemkv
|
||||
|
||||
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Drive-level unlocking is handled internally; consumers work with disc access and decryption only.
|
||||
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, and content reading in one crate. Drive-level unlocking is handled internally; consumers work with disc access and decryption only.
|
||||
|
||||
DVDs (CSS) decrypt out of the box. Blu-ray and UHD (AACS) require a `keydb.cfg` (default `~/.config/freemkv/keydb.cfg`) supplying disc-specific volume unique keys; no AACS key material is compiled in.
|
||||
DVDs (CSS) decrypt out of the box. Blu-ray and UHD (AACS) require disc-specific volume unique keys, supplied by the consumer (e.g. via [freemkv-keysources](https://github.com/freemkv/freemkv-keysources), which owns `keydb.cfg` lookup/download); libfreemkv itself never reads `keydb.cfg` or downloads keys, and no AACS key material is compiled in.
|
||||
|
||||
**12+ MB/s** sustained read speeds on BD. Drive prep (`init()`) handles unlocking internally via the `freemkv-unlock` crate — clients never see it; when no drive unlock applies, the library rips via the host-certificate AACS handshake.
|
||||
|
||||
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
|
||||
|
||||
**[Source & API](https://github.com/freemkv/libfreemkv)** · **[Technical Docs](docs/)**
|
||||
**[Source & API](https://github.com/freemkv/libfreemkv)** · **[Changelog](CHANGELOG.md)**
|
||||
|
||||
Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
@@ -46,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() {
|
||||
@@ -57,24 +64,22 @@ output.finish()?;
|
||||
|
||||
### Multi-pass recovery rip
|
||||
|
||||
Recovery moved OUT of this crate in 1.6.0. The sweep/patch strategy, the
|
||||
ddrescue mapfile, damage classification and the multipass loop now live in the
|
||||
The sweep/patch strategy, ddrescue mapfile, damage classification and
|
||||
multipass loop live in the
|
||||
`freemkv-engine` crate as `freemkv_engine::recovery::{copy, sweep, patch}`.
|
||||
|
||||
libfreemkv keeps the layers underneath: the raw single-shot read
|
||||
(`Drive::read`) and the SCSI-fact translation (`SenseFamily`) that the engine's
|
||||
strategy is built on. The dependency runs engine → libfreemkv, so this crate
|
||||
cannot call into it; front-ends get recovery from the engine directly. See
|
||||
[`docs/rip-recovery.md`](docs/rip-recovery.md) for what stayed here.
|
||||
cannot call into it; front-ends get recovery from the engine directly.
|
||||
|
||||
## What It Does
|
||||
|
||||
- **Drive access** — open, identify, internal unlock + prep, speed control, eject
|
||||
- **12+ MB/s reads** — auto-detects kernel transfer limits, sustained full speed
|
||||
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
|
||||
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||
- **Stream labels** — 7 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, DBP, Deluxe, Fox)
|
||||
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0 bus decryption)
|
||||
- **KEYDB updates** — download, verify, save from any HTTP URL (zero deps, raw TCP)
|
||||
- **Content reading** — adaptive batch reads with automatic decryption
|
||||
- **Stream I/O** — unified stream pipeline for reading and writing any format
|
||||
|
||||
@@ -82,21 +87,21 @@ cannot call into it; front-ends get recovery from the engine directly. See
|
||||
|
||||
| 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
|
||||
|
||||
DVDs (CSS) decrypt out of the box, with no external key file needed.
|
||||
|
||||
Blu-rays and UHD (AACS) require a `keydb.cfg` at `~/.config/freemkv/keydb.cfg` (or passed via `ScanOptions`). No AACS key material is compiled into the binary.
|
||||
Blu-rays and UHD (AACS) require Unit Keys passed in via `ScanOptions`/`KeySpec`. libfreemkv does not read `keydb.cfg` or fetch keys itself — that's the consumer's job (see [freemkv-keysources](https://github.com/freemkv/freemkv-keysources)). No AACS key material is compiled into the binary.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -111,15 +116,16 @@ Disc — scan titles, streams, AACS/CSS state
|
||||
├── MPLS parser — playlists → titles + clips + streams
|
||||
├── CLPI parser — clip info → EP map → sector extents
|
||||
├── IFO parser — DVD title sets, PGC chains, cell addresses
|
||||
├── Labels — 5 BD-J format parsers (detect + parse)
|
||||
├── Labels — 7 BD-J format parsers (detect + parse)
|
||||
├── AACS — key resolution + content decryption
|
||||
├── CSS — DVD CSS (bus auth → player-key disc crack → known-plaintext title-key attack)
|
||||
└── KEYDB — download + verify + save
|
||||
└── 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
|
||||
@@ -127,7 +133,8 @@ Streams — unified PES pipeline
|
||||
└── NullStream — discard sink
|
||||
```
|
||||
|
||||
See [docs/](docs/) for detailed technical documentation on each module.
|
||||
Build API documentation with `cargo doc --no-deps --open`. The public
|
||||
[FVI format specification](https://freemkv.org/docs/fvi-format/) describes exported video indexes.
|
||||
|
||||
## Error Codes
|
||||
|
||||
@@ -157,6 +164,53 @@ All errors are structured with numeric codes. No user-facing English text — ap
|
||||
|
||||
Run `freemkv info disc:// --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to capture your drive's identity for contribution. Drive-unlock profiles are maintained in the [freemkv-unlock](https://github.com/freemkv/freemkv-unlock) repository.
|
||||
|
||||
## What this is NOT
|
||||
|
||||
To keep expectations honest:
|
||||
|
||||
- **No bundled AACS or CSS keys.** No AACS key material is compiled into the
|
||||
crate. Blu-ray/UHD unit keys are supplied by the consumer at runtime (e.g. via
|
||||
[freemkv-keysources](https://github.com/freemkv/freemkv-keysources)'s
|
||||
`keydb.cfg` lookup); libfreemkv never reads `keydb.cfg` or downloads keys.
|
||||
- **Not published to crates.io.** The crate is `publish = false`. It is consumed
|
||||
by git tag or path only (see [Install](#install)) — there is no crates.io
|
||||
version, docs.rs page, or download count.
|
||||
- **No independent security audit.** No external or third-party security audit
|
||||
has been performed. [SECURITY.md](SECURITY.md) is an assurance case authored by
|
||||
the project, not an attestation by an outside auditor.
|
||||
- **CSS is legacy, weak crypto.** DVD CSS is handled solely for format
|
||||
interoperability with existing discs. It is a broken cipher by modern
|
||||
standards and is not, and should not be treated as, a security mechanism.
|
||||
|
||||
## Minimum Supported Rust Version
|
||||
|
||||
The minimum supported Rust version (MSRV) is **1.88**, declared as
|
||||
`rust-version` in [`Cargo.toml`](Cargo.toml) and enforced in CI on every change.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Validation
|
||||
|
||||
Run `cargo test --tests`, `cargo fmt --check`, and
|
||||
`cargo clippy --all-targets -- -D warnings` before pushing.
|
||||
|
||||
The FFmpeg interoperability workflow runs on `qa` pushes or manual dispatch.
|
||||
With FFmpeg/ffprobe installed, run:
|
||||
|
||||
```sh
|
||||
cargo test --release --lib ffmpeg_ -- --ignored --nocapture
|
||||
```
|
||||
|
||||
It generates synthetic PGS/video/audio fixtures and checks decoded content,
|
||||
timing and decoder warnings. Set `FREEMKV_FFMPEG_ARTIFACT_DIR` to retain local
|
||||
fixtures; CI retains fixtures and logs for 14 days. Real-disc QA is separate.
|
||||
|
||||
FFmpeg runs as an installed executable; we do not link or distribute its
|
||||
binaries. This use is permitted by its software licenses; codec patent rights
|
||||
are separate. See [FFmpeg legal guidance](https://ffmpeg.org/legal.html).
|
||||
|
||||
MKV remuxing preserves decoder delay and frame padding locally. Network/stdio
|
||||
PES serialization does not carry that metadata. Direct `PesFrame` constructors
|
||||
must set `discard_padding_ns` to zero unless preserving trimming.
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
version = 1
|
||||
|
||||
# REUSE (https://reuse.software) machine-readable licensing for the whole repo.
|
||||
#
|
||||
# Licensing is declared here in the manifest rather than as per-file inline
|
||||
# headers: the project's comment-guard lint caps stacked `//` comment blocks, so
|
||||
# a two-line SPDX header prepended above an existing top-of-file comment would
|
||||
# trip it. REUSE explicitly supports manifest-based annotation, so every file —
|
||||
# Rust sources, documentation, configuration, workflows, scripts, binary assets,
|
||||
# and the byte-exact `.golden` test fixtures — is covered here instead.
|
||||
#
|
||||
# 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 = [
|
||||
"**/*.rs",
|
||||
"**/*.md",
|
||||
"**/*.toml",
|
||||
"**/*.lock",
|
||||
"**/*.yml",
|
||||
"**/*.yaml",
|
||||
"**/*.json",
|
||||
"**/*.sh",
|
||||
"**/*.py",
|
||||
"**/*.ps1",
|
||||
"**/*.golden",
|
||||
"**/*.svg",
|
||||
"**/*.png",
|
||||
"**/*.ico",
|
||||
"**/*.plist",
|
||||
"**/*.manifest",
|
||||
"**/.gitignore",
|
||||
".gitignore",
|
||||
"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"
|
||||
+2
-2
@@ -139,7 +139,7 @@ If your machine has a free SATA port, use it.
|
||||
|
||||
### How freemkv Handles This
|
||||
|
||||
freemkv uses a three-layer recovery model. See [`docs/rip-recovery.md`](docs/rip-recovery.md) for full details.
|
||||
freemkv uses a three-layer recovery model.
|
||||
|
||||
- **Pass 1 (`freemkv_engine::recovery::sweep`):** Fast sweep with 64 KB reads. On failure, zero-fills the block and skips forward. Writes a ddrescue-format mapfile for later retry.
|
||||
- **Pass 2+ (`freemkv_engine::recovery::patch`):** Targeted re-reads of bad ranges with a long 60-second timeout per CDB. The drive firmware performs its own ECC and laser power retries within that window.
|
||||
@@ -164,7 +164,7 @@ Workarounds:
|
||||
- **Accept the partial ISO.** freemkv's skip-forward recovery will zero-fill the unreadable blocks and continue. The resulting ISO may be playable with minor glitches in the affected areas.
|
||||
- **Physical replug between retry passes.** If running multi-pass patch, replug the drive between passes to clear the taint state.
|
||||
|
||||
freemkv deliberately does not attempt inline SCSI resets or eject cycles to recover from this state, because those operations were found to make the problem worse on affected hardware (see the design rationale in [`docs/rip-recovery.md`](docs/rip-recovery.md)).
|
||||
freemkv deliberately does not attempt inline SCSI resets or eject cycles to recover from this state, because those operations were found to make the problem worse on affected hardware.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -5,6 +5,7 @@ fn main() {
|
||||
if target == "macos" {
|
||||
println!("cargo:rustc-link-lib=framework=IOKit");
|
||||
println!("cargo:rustc-link-lib=framework=CoreFoundation");
|
||||
println!("cargo:rustc-link-lib=framework=DiskArbitration");
|
||||
|
||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||
let obj = format!("{out_dir}/macos_shim.o");
|
||||
@@ -22,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",
|
||||
@@ -60,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() {
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# leak-guard.sh — self-contained public-repo leak gate.
|
||||
#
|
||||
# This is the LAST line of defense in CI. It is intentionally self-contained:
|
||||
# public CI cannot reach the private tooling, so this script encodes ONLY the
|
||||
# generic net — internal infrastructure references, agent-context files, and
|
||||
# AI-attribution in commit messages. It deliberately contains NO project-
|
||||
# specific reverse-engineering vocabulary (those words would themselves be a
|
||||
# leak). The richer private scanner stays private.
|
||||
#
|
||||
# Fails (exit 1) if any of the following appear in the repo:
|
||||
# 1. a tracked CLAUDE.md or .claude/ path (agent context — never public),
|
||||
# 2. tracked file content matching the internal-infra net,
|
||||
# 3. a commit message (in the given range) with AI attribution.
|
||||
#
|
||||
# Usage:
|
||||
# leak-guard.sh [<commit-range>]
|
||||
# <commit-range> optional git rev-list range to scan commit messages
|
||||
# (e.g. "abc..def"). If omitted, commit-message scan is
|
||||
# skipped (path + content checks always run).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Absolute path to this script, resolved before any cd, so we can exclude it
|
||||
# from the content scan (it necessarily contains the detection patterns).
|
||||
SELF_ABS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
|
||||
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO"
|
||||
|
||||
fail=0
|
||||
note() { printf ' ✗ %s\n' "$1"; fail=1; }
|
||||
|
||||
# Internal-infra net — GENERIC ONLY. This script ships in the public repo, so
|
||||
# the patterns themselves must not name any org-specific identifier (doing so
|
||||
# would itself leak the infra they guard). We catch the leak *class*:
|
||||
# - RFC1918 private IPv4 ranges (10/8, 172.16/12, 192.168/16),
|
||||
# - private/internal/non-routable TLDs (.internal/.local/.lan/.corp/.invalid),
|
||||
# - docker.internal.
|
||||
# The full org-specific net (literal hostnames, service names, repo paths,
|
||||
# vendor tooling, …) lives ONLY in the private scanner and never ships here.
|
||||
INFRA_RE='\b10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|\b172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3}|\b192\.168\.[0-9]{1,3}\.[0-9]{1,3}|\.internal\b|\.local\b|\.lan\b|\.corp\b|\.invalid\b|docker\.internal'
|
||||
# Home-path net — GENERIC ONLY. Catches an absolute developer home path
|
||||
# committed into a tracked file (a macOS /Users/<user>/… or Linux /home/<user>/…
|
||||
# path). This names NO specific user — it matches the leak *class* (any home
|
||||
# path), so the pattern itself reveals nothing org- or person-specific. A real
|
||||
# leak (e.g. /Users/alice/Developer/x slipping into a public RELEASE.md) trips
|
||||
# this regardless of whose machine it came from. The username segment is a
|
||||
# literal-username class ([A-Za-z0-9._-]) so dynamic/templated paths that build
|
||||
# the user at runtime — shell `/home/$USER/`, doc `/home/<rip>/`, Rust
|
||||
# `/home/{user}/` — do NOT false-positive; only a baked-in literal home leaks.
|
||||
HOMEPATH_RE='/Users/[A-Za-z0-9._-]+/|/home/[A-Za-z0-9._-]+/'
|
||||
# AI-attribution net (case-insensitive). "claude" matches only as a standalone
|
||||
# word — NOT preceded by a dot/slash/alnum and NOT followed by .md — so legit
|
||||
# mentions of CLAUDE.md / .claude/ in a commit message don't false-positive.
|
||||
ATTR_RE='co-authored-by|generated with|🤖|(?<![.\/A-Za-z0-9])claude(?!\.md)'
|
||||
|
||||
echo "── leak-guard: tracked agent-context paths ──"
|
||||
while IFS= read -r f; do
|
||||
case "$f" in
|
||||
CLAUDE.md|*/CLAUDE.md|.claude|.claude/*|*/.claude|*/.claude/*)
|
||||
note "tracked agent-context file: $f (CLAUDE.md/.claude must never be tracked in a public repo)" ;;
|
||||
esac
|
||||
done < <(git ls-files)
|
||||
|
||||
# Match a PCRE against a file, emitting "LINE: MATCH". The pattern is passed as
|
||||
# an argument (not interpolated into a //) so metacharacters like the "/" in a
|
||||
# path-style token can't break the regex. Reads raw bytes so non-UTF-8 blobs
|
||||
# don't abort the scan.
|
||||
pcre_matches() {
|
||||
perl -e '
|
||||
my ($file, $re) = @ARGV;
|
||||
open(my $fh, "<:raw", $file) or exit 0;
|
||||
my $rx; eval { $rx = qr/$re/i }; exit 0 if $@;
|
||||
while (my $l = <$fh>) { if ($l =~ /$rx/) { print "$.: $&\n"; } }
|
||||
' "$1" "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
# This script's own source necessarily contains the detection patterns (e.g.
|
||||
# the regex tokens in INFRA_RE), so scanning it would always self-flag. Skip it.
|
||||
SELF="$(git ls-files --full-name -- "$SELF_ABS" 2>/dev/null | head -1)"
|
||||
|
||||
echo "── leak-guard: internal-infra references in tracked files ──"
|
||||
while IFS= read -r f; do
|
||||
case "$f" in *.png|*.jpg|*.jpeg|*.ico|*.gif|*.bin|*.crate|*.gz|*.zip|*.pdf) continue ;; esac
|
||||
[ -n "$SELF" ] && [ "$f" = "$SELF" ] && continue
|
||||
[ -f "$f" ] || continue
|
||||
while IFS= read -r hit; do
|
||||
[ -z "$hit" ] && continue
|
||||
note "internal-infra reference: $f:$hit"
|
||||
done < <(pcre_matches "$f" "$INFRA_RE")
|
||||
while IFS= read -r hit; do
|
||||
[ -z "$hit" ] && continue
|
||||
note "[HOME-PATH] absolute home path: $f:$hit (no local home path may be committed to a public repo)"
|
||||
done < <(pcre_matches "$f" "$HOMEPATH_RE")
|
||||
done < <(git ls-files)
|
||||
|
||||
RANGE="${1:-}"
|
||||
if [ -n "$RANGE" ]; then
|
||||
echo "── leak-guard: AI-attribution in commit messages ($RANGE) ──"
|
||||
while IFS= read -r sha; do
|
||||
[ -z "$sha" ] && continue
|
||||
msg="$(git log -1 --format='%B' "$sha" 2>/dev/null || true)"
|
||||
# Pass the pattern as an argument (not interpolated into a //) so the
|
||||
# lookbehind char class and "/" don't break the regex.
|
||||
hit="$(printf '%s' "$msg" | perl -e '
|
||||
my $re = $ARGV[0]; my $rx = qr/$re/i;
|
||||
while (my $l = <STDIN>) { if ($l =~ /($rx)/) { print "$1\n"; last; } }
|
||||
' "$ATTR_RE" | head -1 || true)"
|
||||
[ -n "$hit" ] && note "commit ${sha:0:12}: message contains \"$hit\" (owner rule: zero AI attribution, ever)"
|
||||
done < <(git rev-list "$RANGE" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "✗ leak-guard: blocking finding(s) above — DO NOT MERGE/PUBLISH"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ leak-guard: clean"
|
||||
@@ -0,0 +1,42 @@
|
||||
# cargo-deny — gates the RESOLVED dependency graph (issue #15).
|
||||
# Runs in CI against the committed Cargo.lock: RUSTSEC advisories, a permissive
|
||||
# licence allow-list, and source/ban hygiene. Update the lock (cargo update)
|
||||
# to clear an advisory rather than adding an `ignore` entry without cause.
|
||||
|
||||
[advisories]
|
||||
yanked = "deny"
|
||||
# ignore = ["RUSTSEC-0000-0000"] # only with a written justification
|
||||
|
||||
[licenses]
|
||||
# The whole tree is permissive. r-efi's LGPL arm is satisfied by Apache-2.0
|
||||
# (its expression is "Apache-2.0 OR LGPL-2.1-or-later"), so no copyleft is
|
||||
# allowed here. Add a licence only when a real dependency introduces it.
|
||||
allow = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"0BSD",
|
||||
"Unicode-3.0",
|
||||
"Unlicense",
|
||||
"Zlib",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"CDLA-Permissive-2.0",
|
||||
]
|
||||
confidence-threshold = 0.9
|
||||
|
||||
[bans]
|
||||
multiple-versions = "warn" # transitive dupes (digest 0.10/0.11 etc.) are noise, not a gate
|
||||
wildcards = "deny"
|
||||
# The freemkv siblings resolve as path deps under CI's path-patch layout, which
|
||||
# reads as a wildcard version req. That is internal, not a supply-chain wildcard.
|
||||
allow-wildcard-paths = true
|
||||
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
# The freemkv siblings are first-party git-tag deps; allow git sources from
|
||||
# our own org only (CI resolves them as git, not the local path override).
|
||||
[sources.allow-org]
|
||||
github = ["freemkv"]
|
||||
@@ -1,302 +0,0 @@
|
||||
# FVI — Freemkv Video Index Format
|
||||
|
||||
**Specification version:** 1.0 (DRAFT)\
|
||||
**File extension:** `.fvi`\
|
||||
**Media type:** `application/vnd.freemkv.fvi+jsonl`\
|
||||
**Status:** Draft for review. This document is the normative reference for the FVI
|
||||
format; implementations and downstream tools cite it by section.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope and purpose
|
||||
|
||||
FVI is an open, codec-agnostic, byte-exact **index of the coded pictures** in a
|
||||
video bitstream, together with **provenance** back to the source medium.
|
||||
|
||||
An FVI document answers, for every picture in a stream, three questions:
|
||||
|
||||
1. **Where is it?** — the byte-exact offset of its first byte in the *source*
|
||||
(the disc/ISO/file), so a reader can extract or seek to any picture without
|
||||
re-parsing the whole bitstream.
|
||||
2. **What is it?** — coding type, random-access capability, GOP boundary, and
|
||||
(where the codec defines them) field/pulldown attributes.
|
||||
3. **When is it?** — decode and presentation timestamps on a declared timescale.
|
||||
|
||||
FVI is **not** a container, a codec, or a copy of the bitstream. It indexes; it
|
||||
never stores coded samples. It is the serialized form of an indexer's per-picture
|
||||
truth — carried from the demuxer, **never reconstructed** (§9).
|
||||
|
||||
## 2. Conformance
|
||||
|
||||
The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**,
|
||||
**SHOULD**, **SHOULD NOT**, **MAY**, and **OPTIONAL** are to be interpreted as
|
||||
described in BCP 14 (RFC 2119, RFC 8174) when, and only when, they appear in all
|
||||
capitals.
|
||||
|
||||
A **conformant writer** MUST emit a document that satisfies §4–§10. A
|
||||
**conformant reader** MUST accept any such document and MUST ignore unknown
|
||||
object members (§11) so that forward-compatible extensions do not break it.
|
||||
|
||||
## 3. Terminology
|
||||
|
||||
- **Picture** — one coded video frame (or pair of fields coded as a frame). The
|
||||
unit FVI indexes.
|
||||
- **Access unit (AU)** — the set of bitstream bytes that decode to exactly one
|
||||
picture (ISO/IEC 14496-10 §3; ISO/IEC 23008-2 §3).
|
||||
- **Coded order** — the order pictures appear in the bitstream. FVI records are
|
||||
emitted in coded order.
|
||||
- **GOP / coded video sequence** — a self-contained run beginning at a
|
||||
random-access point.
|
||||
- **Provenance** — the mapping from an AU back to the exact bytes of the physical
|
||||
source it was read from (§9).
|
||||
- **Source position (`src`)** — `{ file, sector, byte }`, the provenance anchor of
|
||||
an AU.
|
||||
|
||||
## 4. Encoding
|
||||
|
||||
An FVI document is a sequence of **UTF-8** text lines separated by a single LF
|
||||
(`U+000A`). Each non-empty line is exactly one JSON value (RFC 8259), forming a
|
||||
**JSON Lines / NDJSON** stream. A writer MUST NOT emit a UTF-8 BOM. A writer MUST
|
||||
NOT pretty-print: each JSON value occupies exactly one line.
|
||||
|
||||
The first line MUST be the **Header** object (§6). Each subsequent line is one
|
||||
**Picture record** (§7), in coded order.
|
||||
|
||||
Rationale: line-delimited JSON is streamable (a writer appends as it indexes; a
|
||||
reader processes without loading the whole file), line-addressable (picture *n*
|
||||
is near line *n+1*), append-safe, and parseable by every language without a
|
||||
custom grammar — while remaining a precisely specified format, not an ad-hoc dump.
|
||||
|
||||
A document MAY be concatenated for multiple elementary streams: each stream is its
|
||||
own header line followed by its records. Readers MUST treat a Header line as the
|
||||
start of a new stream section.
|
||||
|
||||
## 5. Document structure
|
||||
|
||||
```
|
||||
<header> line 1 (exactly one Header object)
|
||||
<record> line 2 .. N (one Picture record per picture, coded order)
|
||||
[<header> <record>…] (OPTIONAL further stream sections)
|
||||
```
|
||||
|
||||
## 6. Header object
|
||||
|
||||
| Member | JSON type | Req | Semantics / reference |
|
||||
|---|---|---|---|
|
||||
| `format` | string | MUST | Constant `"freemkv/video-index"`. Signature: a document begins with these bytes. |
|
||||
| `fvi_version` | integer | MUST | Document format version. This spec defines `1`. |
|
||||
| `generator` | string | SHOULD | Producing tool + version, e.g. `"freemkv/1.0.0-rc.6"`. |
|
||||
| `stream` | object | MUST | The indexed elementary stream (§6.1). |
|
||||
| `source` | object | MUST | Provenance root (§6.2). |
|
||||
| `timescale` | integer | MUST | Ticks per second for all `pts`/`dts` (§10). E.g. `90000`. |
|
||||
| `picture_count` | integer | MAY | Total pictures, if known at header time; OMITTED when streaming. |
|
||||
|
||||
### 6.1 `stream` object
|
||||
|
||||
| Member | JSON type | Req | Semantics / reference |
|
||||
|---|---|---|---|
|
||||
| `codec` | string | MUST | Registered codec id (Appendix B), e.g. `"mpeg2video"`, `"hevc"`. |
|
||||
| `width`,`height` | integer | MUST | Coded luma dimensions in pixels. |
|
||||
| `dar` | `[int,int]` | SHOULD | Display aspect ratio as `[num,den]`. |
|
||||
| `frame_rate` | `[int,int]` | SHOULD | Nominal rate as exact rational `[num,den]` (e.g. `[24000,1001]`). |
|
||||
| `scan` | string | MUST | `"progressive"`<br>`"interlaced"`<br>`"mbaff"` |
|
||||
| `colour` | object | SHOULD | CICP per ITU-T H.273: `primaries`, `transfer`, `matrix` (integer CICP codes or registered names)<br>`range`: `"limited"` \| `"full"`<br>HDR: `mastering_display`, `max_cll`, `max_fall` per ITU-T H.273 / SMPTE ST 2086. |
|
||||
| `language` | string | MAY | BCP 47 tag, if known. |
|
||||
|
||||
### 6.2 `source` object
|
||||
|
||||
| Member | JSON type | Req | Semantics / reference |
|
||||
|---|---|---|---|
|
||||
| `medium` | string | MUST | `"disc"`<br>`"iso"`<br>`"file"`<br>`"stream"` |
|
||||
| `path` | string | MAY | Source path/label. |
|
||||
| `title` | integer | MAY | Title/program number. |
|
||||
| `playlist` | string | MAY | Playlist/PGC identifier. |
|
||||
| `volume_id` | string | MAY | Disc volume identifier, if read. |
|
||||
| `sector_size` | integer | SHOULD | Bytes per `src.sector` unit (e.g. `2048`). Lets readers convert `src` to an absolute byte offset. |
|
||||
|
||||
## 7. Picture record
|
||||
|
||||
One JSON object per coded picture, in coded order.
|
||||
|
||||
| Member | JSON type | Req | Semantics / reference |
|
||||
|---|---|---|---|
|
||||
| `n` | integer | MUST | Coded-order index, 0-based, contiguous. |
|
||||
| `src` | object | MUST | Provenance: `{ "file": int?, "sector": uint, "byte": uint }` — the offset of this AU's **first byte** in the source (§9). MUST be carried from demux, never reconstructed. |
|
||||
| `type` | string | MUST | Coding type:<br>`"I"`<br>`"P"`<br>`"B"`<br>_ISO/IEC 13818-2 §6.3.9; H.264/H.265 slice types collapsed to frame type._ |
|
||||
| `key` | boolean | MUST | `true` iff this picture is an intra (I) picture / parser-flagged decode-restart point (IDR / IRAP / I-picture).<br>_MPEG-2 open-GOP clean-RAP precision (`closed_gop`) is not currently distinguished — see note below._ |
|
||||
| `gop` | boolean | SHOULD | `true` iff this picture begins a GOP / coded video sequence.<br>_Omitted when the implementation does not carry a distinct GOP-boundary signal._ |
|
||||
| `pts` | integer\|null | SHOULD | Presentation timestamp in `timescale` ticks; `null` if unknown. |
|
||||
| `dts` | integer\|null | MAY | Decode timestamp in `timescale` ticks. |
|
||||
| `size` | integer | MAY | AU length in bytes; enables byte-range extraction with `src`. |
|
||||
| `recovered` | boolean | MAY | `true` iff any byte of this AU came from a retried/marginal read (§9.1).<br>_Default `false`._ |
|
||||
| codec ext | object | MAY | Codec-specific members under the codec's namespace (§8). |
|
||||
|
||||
The `type` and `key` members are **codec-agnostic** and MUST be populated for
|
||||
every codec. `type` is the I/P/B coding type the parser decoded (collapsing
|
||||
H.264/H.265 slice types to a frame type); where no per-picture coding is carried
|
||||
(audio / synthetic frames), `type` is `"I"` for a key picture else `"P"`. `key`
|
||||
is the picture's random-access flag as the codec parser sets it (IDR / IRAP /
|
||||
I-picture). A writer MUST NOT emit a degraded record (`type:"?"` or `src:null`)
|
||||
merely because a codec lacks per-picture coding info — those fallbacks are
|
||||
reserved for a field that is genuinely unavailable (e.g. provenance absent on a
|
||||
synthetic source).
|
||||
|
||||
> **Limitation (honest random-access).** `key` is set from the picture's
|
||||
> intra / decode-restart flag. The per-picture coding model this index carries
|
||||
> does **not** distinguish MPEG-2 open-GOP clean random-access points
|
||||
> (`closed_gop`) from any other I-picture, so `key` is the parser-flagged
|
||||
> decode-restart point, not a verified clean-RAP claim. A future revision MAY
|
||||
> tighten `key` for codecs/profiles that carry that signal; readers MUST NOT
|
||||
> assume present `key` precision beyond "intra / decode-restart point".
|
||||
|
||||
### 7.1 Interlace / pulldown fields
|
||||
|
||||
Codec-agnostic interlace/pulldown attributes, derived through the indexer's
|
||||
per-picture coding accessors (MPEG-2: ISO/IEC 13818-2 §6.3.10). Emitted as
|
||||
top-level members of the record, and ONLY when the codec actually measured the
|
||||
signal — an OPTIONAL member that is omitted (not defaulted) when unknown:
|
||||
|
||||
| Member | JSON type | Req | Semantics / reference |
|
||||
|---|---|---|---|
|
||||
| `field_order` | string | MAY | Display field order:<br>`"tff"` — top field first<br>`"bff"` — bottom field first<br>`"progressive"` — no field order applies<br>_Omitted when the codec did not signal it._ |
|
||||
| `progressive` | boolean | MAY | `true` iff the picture is progressive.<br>_Omitted when the codec did not signal it._ |
|
||||
| `nb_fields` | integer | MAY | Number of displayed field periods this picture occupies (the soft-telecine / 2:3 pulldown basis):<br>`1` for a single field picture<br>`2` for a normal frame<br>`3`/`4`/`6` for `repeat_first_field` pulldown per §6.3.10 |
|
||||
|
||||
Codecs that carry only a coding type (e.g. H.264 / HEVC / VC-1 through this
|
||||
pipeline) omit `field_order` and `progressive` rather than guessing a default.
|
||||
|
||||
## 8. Codec model and extensibility
|
||||
|
||||
Core record members (§7) are codec-agnostic and present for every codec.
|
||||
Codec-specific data is either (a) promoted to top-level members for a small,
|
||||
registered set per codec profile (e.g. MPEG-2 §7.1), or (b) placed under an
|
||||
`ext` object keyed by codec id for richer/optional data:
|
||||
|
||||
```json
|
||||
{
|
||||
"n": 42,
|
||||
"type": "P",
|
||||
"key": false,
|
||||
"src": {
|
||||
"sector": 17,
|
||||
"byte": 924
|
||||
},
|
||||
"ext": {
|
||||
"hevc": {
|
||||
"temporal_id": 0,
|
||||
"nal_type": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
New codecs and members are added through Appendix B (codec registry) without a
|
||||
breaking version bump, provided readers continue to ignore unknown members (§11).
|
||||
|
||||
## 9. Provenance and recovery semantics
|
||||
|
||||
`src` is **byte-exact** to the source as read. `src.sector` counts in
|
||||
`source.sector_size`-byte units; `src.byte` is the offset within that sector of
|
||||
the AU's first byte. For multi-file sources, `src.file` indexes a writer-declared
|
||||
file list. Provenance MUST be the value observed at demux time; an implementation
|
||||
MUST NOT recompute `src` by re-parsing — the point of FVI is to *carry* the truth.
|
||||
|
||||
### 9.1 Recovery
|
||||
|
||||
Because FVI is provenance-native, it can record reliability. A record with
|
||||
`"recovered":true` indicates the AU's source bytes required retry/marginal-read
|
||||
recovery. This lets downstream tools surface or quarantine pictures whose bytes
|
||||
are not byte-identical to a clean read — a capability legacy index formats lack.
|
||||
|
||||
## 10. Time model
|
||||
|
||||
All `pts`/`dts` are integers in units of `1/timescale` seconds. `pts` is
|
||||
presentation (display) time; `dts` is decode time. Records are in **coded**
|
||||
(decode) order, so `pts` is not necessarily monotonic across records (B-pictures
|
||||
reorder); `dts` is non-decreasing. Readers needing display order sort by `pts`.
|
||||
|
||||
## 11. Versioning and forward compatibility
|
||||
|
||||
- `fvi_version` is the document version; this spec defines `1`.
|
||||
- **Additive** changes (new OPTIONAL members, new registered codecs) do NOT bump
|
||||
`fvi_version`. Readers MUST ignore members they do not recognize.
|
||||
- A change that alters the meaning of an existing member or makes a new member
|
||||
REQUIRED bumps `fvi_version`.
|
||||
- A reader encountering a higher `fvi_version` than it implements SHOULD process
|
||||
the members it understands and MUST NOT reject the document solely for the
|
||||
version being higher, unless a member it relies on is absent.
|
||||
|
||||
## 12. Conformance requirements (summary)
|
||||
|
||||
A conformant **writer** MUST: emit a Header first; emit records in coded order
|
||||
with contiguous `n`; populate `src` from demux; use named/registered codec ids;
|
||||
encode one JSON value per UTF-8 LF-terminated line.
|
||||
|
||||
A conformant **reader** MUST: accept any §4–§10 document; ignore unknown members;
|
||||
not assume `picture_count`, `pts`, or `size` are present unless required above.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — JSON Schema (informative)
|
||||
|
||||
Header:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"required": ["format", "fvi_version", "stream", "source", "timescale"],
|
||||
"properties": {
|
||||
"format": { "const": "freemkv/video-index" },
|
||||
"fvi_version": { "type": "integer", "minimum": 1 },
|
||||
"timescale": { "type": "integer", "minimum": 1 },
|
||||
"stream": { "type": "object", "required": ["codec", "width", "height", "scan"] },
|
||||
"source": { "type": "object", "required": ["medium"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Record:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"required": ["n", "src", "type", "key"],
|
||||
"properties": {
|
||||
"n": { "type": "integer", "minimum": 0 },
|
||||
"type": { "enum": ["I", "P", "B"] },
|
||||
"key": { "type": "boolean" },
|
||||
"src": {
|
||||
"type": "object",
|
||||
"required": ["sector", "byte"],
|
||||
"properties": {
|
||||
"file": { "type": "integer" },
|
||||
"sector": { "type": "integer", "minimum": 0 },
|
||||
"byte": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Appendix B — Registered codec identifiers
|
||||
|
||||
| `codec` | Bitstream | Field profile |
|
||||
|---|---|---|
|
||||
| `mpeg2video` | ISO/IEC 13818-2 | §7.1 (field_order/progressive/nb_fields) |
|
||||
| `mpeg1video` | ISO/IEC 11172-2 | §7.1 |
|
||||
| `h264` | ISO/IEC 14496-10 | core + `ext.h264` |
|
||||
| `hevc` | ISO/IEC 23008-2 | core + `ext.hevc` |
|
||||
| `vc1` | SMPTE 421M | core |
|
||||
|
||||
## Appendix C — Normative references
|
||||
|
||||
- RFC 2119, RFC 8174 — Requirement keywords (BCP 14).
|
||||
- RFC 8259 — JSON.
|
||||
- ISO/IEC 13818-2 — MPEG-2 video (picture coding, §6.3.9–6.3.10).
|
||||
- ISO/IEC 14496-10 — H.264/AVC. ISO/IEC 23008-2 — H.265/HEVC.
|
||||
- ITU-T H.273 — Coding-independent code points (colour primaries/transfer/matrix).
|
||||
- SMPTE ST 2086 — Mastering display colour volume (HDR).
|
||||
- BCP 47 — Language tags.
|
||||
- RFC 9559 — Matroska (alignment of colour/field-order semantics).
|
||||
@@ -1,34 +0,0 @@
|
||||
# libfreemkv Documentation
|
||||
|
||||
Technical documentation for [libfreemkv](https://github.com/freemkv/libfreemkv), the open source optical drive library.
|
||||
|
||||
## Start Here
|
||||
|
||||
**[Disc to Rip: End-to-End Flow](disc-to-rip.md)** — How the library goes from a disc in the drive to decrypted content. Read this first.
|
||||
|
||||
## Reference
|
||||
|
||||
| Document | What it covers |
|
||||
|----------|---------------|
|
||||
| [Architecture](architecture.md) | Module map, design principles, error codes, platform support |
|
||||
| [Drive Access](drive-access.md) | Drive, SCSI transport, profiles, unlock, why raw mode is needed |
|
||||
| [Rip Recovery](rip-recovery.md) | What this crate owns of the recovery model: single-shot Drive::read, SenseFamily, DiscStream batch halving (the strategy itself moved to freemkv-engine in 1.6.0) |
|
||||
| [AACS Encryption](aacs.md) | Key resolution (4 paths), content decryption, bus encryption, SCSI handshake |
|
||||
| [UDF Filesystem](udf.md) | UDF 2.50 with metadata partitions, pointer chain, how files are read from disc |
|
||||
| [MPLS Playlists](mpls.md) | Playlist format, play items, STN stream table, coding types |
|
||||
| [CLPI Clip Info](clpi.md) | EP map (coarse + fine entries), timestamp-to-sector mapping, extent calculation |
|
||||
| [API Design](api-design.md) | Stream API design, PES pipeline, input/output resolution |
|
||||
|
||||
## Reading Order
|
||||
|
||||
If you want to understand the whole library:
|
||||
|
||||
1. **[Disc to Rip](disc-to-rip.md)** — the big picture
|
||||
2. **[Architecture](architecture.md)** — how modules fit together
|
||||
3. **[Drive Access](drive-access.md)** — how we talk to hardware
|
||||
4. **[UDF](udf.md)** → **[MPLS](mpls.md)** → **[CLPI](clpi.md)** — how disc content is structured
|
||||
5. **[AACS](aacs.md)** — how encryption works and how we break it
|
||||
|
||||
## API Documentation
|
||||
|
||||
Generated API docs are on [docs.rs/libfreemkv](https://docs.rs/libfreemkv).
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
# AACS Encryption Support
|
||||
|
||||
## Overview
|
||||
|
||||
AACS (Advanced Access Content System) is the encryption layer used by Blu-ray
|
||||
and UHD 4K discs to protect content. libfreemkv implements AACS decryption so
|
||||
disc access is transparent to the application.
|
||||
|
||||
There are two major versions:
|
||||
|
||||
- **AACS 1.0** -- Used by standard Blu-ray discs.
|
||||
- **AACS 2.0 / 2.1** -- Used by UHD 4K Blu-ray discs. Adds a per-sector bus
|
||||
encryption layer on top of the standard content encryption. UHD drives accept
|
||||
AACS 1.0 host credentials for backward compatibility.
|
||||
|
||||
All versions use AES-128 for content decryption. The library reads the keys it
|
||||
needs from `keydb.cfg`, walks the disc's Media Key Block (MKB) to resolve the
|
||||
disc's key, and decrypts the content stream. AACS-encrypted discs therefore
|
||||
require a `keydb.cfg`; CSS-protected DVDs do not (see the CSS notes in the
|
||||
library docs).
|
||||
|
||||
## How it works (feature level)
|
||||
|
||||
When a disc is scanned, the library:
|
||||
|
||||
1. Reads the disc's AACS key-input files from the `/AACS/` directory.
|
||||
2. Resolves the disc's key from `keydb.cfg` — either directly from a per-disc
|
||||
entry, or by walking the MKB with the keys present in the keydb.
|
||||
3. Performs the drive-level SCSI authentication handshake needed to obtain the
|
||||
Volume ID and, for UHD, the bus-decryption key.
|
||||
4. Decrypts the content stream as titles are read.
|
||||
|
||||
A resolved key is verified against actual disc content before it is applied, so
|
||||
a stale or wrong key fails loudly rather than producing silent garbage. If no
|
||||
usable key is available for an AACS-encrypted disc, the library surfaces a
|
||||
specific error (the E70xx family) describing which part of the chain was
|
||||
missing, and a missing `keydb.cfg` surfaces as `Error::KeydbLoad` with the
|
||||
sentinel path `<no keydb in search paths>`.
|
||||
|
||||
## API Usage
|
||||
|
||||
AACS decryption is transparent to the application. `Disc::scan()` handles
|
||||
everything automatically:
|
||||
|
||||
```rust
|
||||
use libfreemkv::{Drive, Disc};
|
||||
use libfreemkv::disc::ScanOptions;
|
||||
use std::path::Path;
|
||||
|
||||
let mut drive = Drive::open(Path::new("/dev/sg4")).unwrap();
|
||||
drive.wait_ready().unwrap();
|
||||
drive.init().unwrap();
|
||||
let disc = Disc::scan(&mut drive, &ScanOptions::default()).unwrap();
|
||||
|
||||
// Check encryption state
|
||||
if disc.encrypted {
|
||||
if let Some(ref aacs) = disc.aacs {
|
||||
println!("AACS {}.0", aacs.version);
|
||||
println!("Key source: {}", aacs.key_source.name());
|
||||
if let Some(mkb_ver) = aacs.mkb_version {
|
||||
println!("MKB version: {}", mkb_ver);
|
||||
}
|
||||
} else {
|
||||
println!("Encrypted but keys not available");
|
||||
}
|
||||
}
|
||||
|
||||
// Read content -- decryption is applied on read by the DiscStream decorator.
|
||||
// Live disc does NOT go through the URL resolver: `input("disc://...")` returns
|
||||
// Error::DiscUrlNotDirect by design.
|
||||
let keys = disc.decrypt_keys();
|
||||
let mut stream = DiscStream::new(
|
||||
Box::new(drive),
|
||||
disc.titles[0].clone(),
|
||||
keys,
|
||||
batch_sectors,
|
||||
disc.titles[0].content_format,
|
||||
false, // raw: false → decrypt on read
|
||||
None, // halt
|
||||
)?;
|
||||
while let Ok(Some(frame)) = stream.read() {
|
||||
// decrypted PES frames
|
||||
}
|
||||
```
|
||||
|
||||
The application never calls decryption functions and never manages the
|
||||
drive-level handshake. It DOES own key resolution — see below.
|
||||
|
||||
### Key resolution is the caller's job
|
||||
|
||||
`libfreemkv` is **lookup-free: it resolves no keys and reads no keydb.** There is
|
||||
no `ScanOptions::with_keydb`, and `ScanOptions` has no keydb field — its only
|
||||
scan input is the optional drive credentials for the live-drive authenticated
|
||||
handshake.
|
||||
|
||||
The caller resolves a key out-of-band through a key source and applies it with
|
||||
[`Disc::decrypt_with`]. `freemkv-keysources` is the crate that implements the
|
||||
keydb and key-server sources; `ScanOptions::key_sources` takes them as
|
||||
`Box<dyn KeySource>`.
|
||||
|
||||
### AacsState
|
||||
|
||||
After a successful scan, `disc.aacs` contains an `AacsState`:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `version` | `u8` | AACS version (1 or 2) |
|
||||
| `bus_encryption` | `bool` | Whether bus encryption is active |
|
||||
| `mkb_version` | `Option<u32>` | MKB version from disc |
|
||||
| `disc_hash` | `String` | Identifier for the disc's key-input files |
|
||||
| `key_source` | `KeyOrigin` | How the disc's key was resolved |
|
||||
|
||||
## keydb.cfg
|
||||
|
||||
`keydb.cfg` is the single source of AACS key material. It is a text file (lines
|
||||
starting with `;` or `#` are comments) holding the host credentials and per-disc
|
||||
entries the library uses to resolve a disc. autorip can auto-download and
|
||||
refresh it from a configured URL. The library does not ship any AACS keys
|
||||
compiled into the binary.
|
||||
@@ -1,237 +0,0 @@
|
||||
# libfreemkv API Design
|
||||
|
||||
## Principles
|
||||
|
||||
1. Lib provides building blocks. App composes them.
|
||||
2. No English text in lib. Error codes only. App handles i18n.
|
||||
3. No display logic in lib. App decides what to show.
|
||||
4. Streams are the pipeline. Each stage wraps the next.
|
||||
5. Lib fires events. App listens.
|
||||
|
||||
## Core API
|
||||
|
||||
```rust
|
||||
// Open drive — explicit steps, app prints between them
|
||||
let mut drive = Drive::open(path)?;
|
||||
drive.wait_ready()?;
|
||||
drive.init()?;
|
||||
drive.probe_disc()?;
|
||||
|
||||
// Scan disc
|
||||
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
|
||||
|
||||
// Browse
|
||||
disc.titles // Vec<DiscTitle>
|
||||
disc.format // BD / UHD / DVD
|
||||
disc.capacity_gb()
|
||||
```
|
||||
|
||||
## PES Pipeline (primary API)
|
||||
|
||||
The PES pipeline is the main way to move content. All streams produce/consume
|
||||
PES frames. The pipeline just reads frames and writes frames.
|
||||
|
||||
```rust
|
||||
// URL-based — any source to any destination
|
||||
let opts = InputOptions::default();
|
||||
let mut input = libfreemkv::input("disc:///dev/sg4", &opts)?;
|
||||
let title = input.info().clone();
|
||||
let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?;
|
||||
|
||||
while let Ok(Some(frame)) = input.read() {
|
||||
output.write(&frame)?;
|
||||
}
|
||||
output.finish()?;
|
||||
```
|
||||
|
||||
The `FrameSource` and `FrameSink` traits — direction is type-checked, so
|
||||
calling `read()` on a write-only sink (or `write()` on a read-only source)
|
||||
is a compile error rather than a runtime fault:
|
||||
|
||||
```rust
|
||||
pub trait FrameSource: Send {
|
||||
fn read(&mut self) -> Result<Option<PesFrame>, Error>;
|
||||
fn info(&self) -> &DiscTitle;
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> { None }
|
||||
fn headers_ready(&self) -> bool { true }
|
||||
}
|
||||
|
||||
pub trait FrameSink: Send {
|
||||
fn write(&mut self, frame: &PesFrame) -> Result<(), Error>;
|
||||
fn finish(self: Box<Self>) -> Result<(), Error>;
|
||||
fn info(&self) -> &DiscTitle;
|
||||
}
|
||||
```
|
||||
|
||||
## Streams
|
||||
|
||||
All streams implement `FrameSource` (read) and/or `FrameSink` (write); the
|
||||
directional split prevents runtime "wrong-direction" errors. URL-based
|
||||
resolvers open any stream by string.
|
||||
|
||||
| Stream | Input | Output | URL | Transport |
|
||||
|--------|-------|--------|-----|-----------|
|
||||
| DiscStream | Yes | -- | `disc://` `disc:///dev/sg4` | Optical drive via SCSI |
|
||||
| IsoStream | Yes | Yes | `iso://path.iso` | Blu-ray ISO image |
|
||||
| MkvStream | Yes | Yes | `mkv://path` | Matroska container |
|
||||
| M2tsStream | Yes | Yes | `m2ts://path` | BD-TS with FMKV metadata header |
|
||||
| NetworkStream | Yes (listen) | Yes (connect) | `network://host:port` | TCP with FMKV metadata header |
|
||||
| StdioStream | Yes (stdin) | Yes (stdout) | `stdio://` | Raw byte pipe |
|
||||
| NullStream | -- | Yes | `null://` | Discard sink (byte counter) |
|
||||
|
||||
All URLs require a `scheme://path` format. Bare paths are rejected.
|
||||
|
||||
```rust
|
||||
// PES pipeline (frame-level) — input() returns Box<dyn FrameSource>,
|
||||
// output() returns Box<dyn FrameSink>.
|
||||
let input = libfreemkv::input("disc:///dev/sg4", &opts)?; // DiscStream
|
||||
let input = libfreemkv::input("iso://Movie.iso", &opts)?; // IsoStream
|
||||
let output = libfreemkv::output("mkv://Movie.mkv", &title)?; // MkvOutputStream
|
||||
let output = libfreemkv::output("m2ts://Movie.m2ts", &title)?; // M2tsOutputStream
|
||||
let output = libfreemkv::output("network://192.0.2.10:9000", &title)?; // NetworkOutputStream
|
||||
let output = libfreemkv::output("null://", &title)?; // NullOutputStream
|
||||
```
|
||||
|
||||
### FMKV Metadata Header
|
||||
|
||||
M2tsStream and NetworkStream embed a JSON metadata header before the BD-TS data:
|
||||
|
||||
```
|
||||
[8B magic "FMKV\0\0\0\0"][4B JSON length][JSON metadata][padding to 192B boundary][BD-TS data...]
|
||||
```
|
||||
|
||||
The header carries title name, duration, codec_privates, and full stream layout
|
||||
(PIDs, codecs, languages, labels). This allows the receiving end to set up
|
||||
demuxing and track metadata without scanning the TS.
|
||||
|
||||
## Events
|
||||
|
||||
Lib fires events during operations. App provides a callback. No display, no text.
|
||||
|
||||
```rust
|
||||
pub struct Event {
|
||||
pub kind: EventKind,
|
||||
}
|
||||
|
||||
pub enum EventKind {
|
||||
// Init / scan
|
||||
DriveOpened { device: String },
|
||||
DriveReady,
|
||||
InitComplete { success: bool },
|
||||
ProbeComplete { success: bool },
|
||||
ScanComplete { titles: usize },
|
||||
|
||||
// Read pipeline
|
||||
BytesRead { bytes: u64, total: u64 },
|
||||
ReadError { sector: u64, error: Error },
|
||||
SpeedChange { speed_kbs: u16 },
|
||||
ExtentStart { index: usize, start_sector: u64, sector_count: u64 },
|
||||
SectorSkipped { sector: u64 },
|
||||
BatchSizeChanged { new_size: u16, reason: BatchSizeReason },
|
||||
Complete { bytes: u64, errors: u32 },
|
||||
|
||||
// Kept for forward-compat; not emitted in 0.13.6+
|
||||
Retry { attempt: u32 },
|
||||
SectorRecovered { sector: u64 },
|
||||
}
|
||||
```
|
||||
|
||||
Emission notes:
|
||||
|
||||
- `BytesRead { bytes, total }` is emitted from `DiscStream::fill_extents`
|
||||
after each successful sector read. `bytes` is the cumulative running
|
||||
total; `total` is the precomputed extent sum (0 if unknown).
|
||||
- `SpeedChange` is emitted from the public `Drive::set_speed` API path.
|
||||
It is no longer emitted from a recovery hot loop (recovery loop removed
|
||||
in 0.13.6).
|
||||
- `BatchSizeChanged` fires from the `DiscStream` adaptive sizer on shrink
|
||||
(read failed at a larger size) and on probe-up (clean-read streak hit
|
||||
the threshold). Consumers use it to display a "recovering" state
|
||||
distinct from "ripping normally".
|
||||
- `Retry` and `SectorRecovered` are NOT emitted in 0.13.6+. They were
|
||||
tied to the inline `Drive::read` recovery phases that were removed; the
|
||||
variants are kept for forward compatibility so consumers' match arms
|
||||
don't need conditional compilation.
|
||||
|
||||
Events report what happened. App decides what to do. GUI shows a dialog. CLI
|
||||
prints a line. Server logs to file.
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
libfreemkv/src/
|
||||
├── lib.rs Public exports
|
||||
├── error.rs Error codes (no English)
|
||||
├── event.rs Event types for callbacks
|
||||
├── halt.rs Halt cancellation token (Arc<AtomicBool> wrapper)
|
||||
├── io/ Pipeline + WritebackFile primitives
|
||||
│ ├── mod.rs Re-exports WritebackFile, Pipeline, Sink, Flow
|
||||
│ ├── pipeline.rs Generic Pipeline<I, R> + Sink trait
|
||||
│ ├── writeback_file.rs WritebackFile (was crate::io::Writer)
|
||||
│ └── writeback.rs sync_file_range pipeline
|
||||
├── drive/ Drive (open, init, single-shot read)
|
||||
│ ├── mod.rs Drive struct, init, read (single-shot), eject
|
||||
│ ├── capture.rs Raw drive SCSI capture (INQUIRY/GET_CONFIG) for contribution
|
||||
│ ├── linux.rs Linux drive discovery
|
||||
│ ├── macos.rs macOS drive discovery
|
||||
│ └── windows.rs Windows drive discovery
|
||||
├── disc/ Disc (scan, titles, AACS setup, per-format parsing)
|
||||
│ ├── mod.rs Disc struct, scan, titles, formats
|
||||
│ ├── bluray.rs Blu-ray / UHD scanning (MPLS/CLPI-driven)
|
||||
│ ├── dvd.rs DVD-Video scanning (IFO-driven)
|
||||
│ ├── hddvd.rs HD-DVD scanning
|
||||
│ ├── extract.rs Per-extent content extraction
|
||||
│ ├── encrypt.rs Encrypted-range mapping for content reads
|
||||
│ ├── dvd_audio_probe.rs DVD audio-stream probing
|
||||
│ └── pgs_forced_probe.rs PGS forced-subtitle probing
|
||||
├── scsi/ SCSI transport (Linux SG_IO, macOS IOKit, Windows SPTI)
|
||||
├── unlock.rs Unlocker trait + registry (pluggable unlock seam)
|
||||
├── aacs/ AACS decryption (handshake, keys, keydb, decrypt)
|
||||
├── css/ DVD CSS cipher
|
||||
├── decrypt.rs Unified decrypt dispatcher (AACS/CSS/None)
|
||||
├── pes.rs PES frame types, FrameSource / FrameSink traits
|
||||
├── sector/ Sector I/O
|
||||
│ ├── mod.rs SectorSource, SectorSink traits
|
||||
│ ├── file.rs FileSectorSource, FileSectorSink (ISO-backed)
|
||||
│ └── decrypting.rs DecryptingSectorSource decorator
|
||||
├── udf.rs UDF 2.50 filesystem parser
|
||||
├── mpls.rs MPLS playlist parser
|
||||
├── clpi.rs CLPI clip info parser
|
||||
├── ifo.rs DVD IFO parser
|
||||
├── labels/ BD-J label extraction (5 format parsers)
|
||||
├── keydb.rs KEYDB download, parse, save
|
||||
├── identity.rs DriveId from INQUIRY
|
||||
├── speed.rs DriveSpeed enum
|
||||
├── mux/
|
||||
│ ├── mod.rs Public mux exports
|
||||
│ ├── resolve.rs URL parser + input/output (Box<dyn FrameSource/Sink>)
|
||||
│ ├── meta.rs FMKV header format
|
||||
│ ├── disc.rs DiscStream (optical drive → PES)
|
||||
│ ├── iso.rs IsoStream (ISO image read)
|
||||
│ ├── isowriter.rs ISO image writer (UDF, AVDP, multi-extent)
|
||||
│ ├── mkvstream.rs MkvStream (bidirectional Matroska)
|
||||
│ ├── mkvout.rs MkvOutputStream (PES → MKV)
|
||||
│ ├── m2ts.rs M2tsStream (BD-TS)
|
||||
│ ├── pesout.rs PES output streams (M2ts, Network, Stdio, Null)
|
||||
│ ├── network.rs NetworkStream (TCP + FMKV header)
|
||||
│ ├── stdio.rs StdioStream (stdin/stdout pipe)
|
||||
│ ├── null.rs NullStream (discard + byte counter)
|
||||
│ ├── lookahead.rs LookaheadBuffer (codec header scanning)
|
||||
│ ├── ts.rs BD-TS demuxer + PAT/PMT scanner
|
||||
│ ├── tsreader.rs TS reader utilities
|
||||
│ ├── tsmux.rs TS muxer (PES → BD-TS packets)
|
||||
│ ├── ps.rs MPEG-2 PS demuxer (DVD)
|
||||
│ ├── ebml.rs EBML read/write primitives
|
||||
│ ├── mkv.rs MKV muxer (tracks, clusters, cues)
|
||||
│ └── codec/ Frame parsers (H.264, HEVC, MPEG-2, VC-1, AC3, EAC3, DTS, TrueHD, LPCM, PGS)
|
||||
└── ...
|
||||
|
||||
freemkv/src/
|
||||
├── main.rs CLI dispatcher (URL routing)
|
||||
├── pipe.rs PES pipeline — source → dest copy
|
||||
├── disc_info.rs Disc/file info display
|
||||
├── info.rs Drive info + profile submission
|
||||
├── strings.rs i18n string table
|
||||
├── output.rs Verbosity-filtered output
|
||||
└── build.rs Bundled locale code generation
|
||||
```
|
||||
@@ -1,222 +0,0 @@
|
||||
# libfreemkv Architecture
|
||||
|
||||
Open source optical drive access library for 4K UHD Blu-ray, Blu-ray, and DVD.
|
||||
Rust library with profiles bundled and all SCSI communication handled in-process.
|
||||
AACS decryption requires an external `keydb.cfg` (default
|
||||
`~/.config/freemkv/keydb.cfg`) — the derivation math is internal, but no AACS key
|
||||
material is compiled in; DVD CSS player keys are the only compiled-in keys.
|
||||
|
||||
**Repository:** <https://github.com/freemkv/libfreemkv>
|
||||
**License:** MIT
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **CLI is dumb.** All drive communication, disc parsing, AACS decryption, and
|
||||
format handling live in the library. CLI binaries are thin wrappers that call
|
||||
`Drive::open()` and `Disc::scan()`.
|
||||
|
||||
2. **Firmware-clean core.** libfreemkv ships no firmware, no unlock CDBs, and no
|
||||
drive profiles. Drive-unlock logic is plugged in by an external crate through
|
||||
the `Unlocker` trait + registry (`register_unlocker`); without one the library
|
||||
still rips via the host-certificate AACS handshake.
|
||||
|
||||
3. **Transparent AACS.** The `ContentReader` decrypts on the fly when keys are
|
||||
available. Callers read cleartext sectors without knowing whether the disc
|
||||
was encrypted.
|
||||
|
||||
4. **Structured errors, no English.** Every error has a numeric code (E1000-E8000).
|
||||
The library never formats user-facing messages -- applications do that.
|
||||
|
||||
5. **Library-agnostic.** No concept of "supported" vs "unsupported" drives at a
|
||||
policy level. If a profile exists, the library uses it.
|
||||
|
||||
6. **Streams are dumb pipes.** Streams read/write PES frames. They don't know
|
||||
about encryption, transport format, or source type. Decrypt is a stream-internal
|
||||
concern; the pipeline just moves frames.
|
||||
|
||||
---
|
||||
|
||||
## Module Map
|
||||
|
||||
```
|
||||
libfreemkv (lib.rs)
|
||||
│
|
||||
├── Drive Access
|
||||
│ ├── drive Drive — open, identify, init, single-shot read
|
||||
│ ├── scsi ScsiTransport trait + platform backends (sg async, IOKit, SPTI)
|
||||
│ ├── unlock Unlocker trait + registry — the pluggable unlock seam
|
||||
│ ├── identity DriveId from INQUIRY + GET_CONFIG 010C
|
||||
│ ├── speed DriveSpeed enum, SET CD SPEED CDB builder
|
||||
│ └── event Event system for drive status callbacks
|
||||
│
|
||||
├── Disc Scanning
|
||||
│ ├── disc Disc::scan() — titles, streams, extents, AACS setup
|
||||
│ ├── udf UDF 2.50 filesystem reader (metadata partitions)
|
||||
│ ├── mpls MPLS playlist parser — clips, streams, STN table
|
||||
│ ├── clpi CLPI clip info parser — EP map, sector extents
|
||||
│ ├── ifo DVD IFO parser — title sets, PGC chains, cell addresses
|
||||
│ └── labels/ BD-J label extraction (5 formats: Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||
│
|
||||
├── Encryption
|
||||
│ ├── aacs/ AACS handshake, KEYDB, VUK lookup, MKB, unit decryption
|
||||
│ ├── css DVD CSS cipher — table-driven, no external keys needed
|
||||
│ └── decrypt decrypt_sectors() — unified AACS/CSS/None dispatcher
|
||||
│
|
||||
├── Streaming
|
||||
│ ├── mux/ Stream implementations (Disc, ISO, MKV, M2TS, Network, Stdio, Null)
|
||||
│ ├── pes PES frame types; the unified pes::Stream (PesStream) read/write trait
|
||||
│ └── sector/ SectorSource / SectorSink traits, FileSector{Source,Sink}, DecryptingSectorSource
|
||||
│
|
||||
├── I/O Primitives
|
||||
│ ├── halt Halt cancellation token (one Arc<AtomicBool>, cloneable)
|
||||
│ └── io/ Pipeline<I, R> + Sink trait + WritebackFile (bounded-cache writer)
|
||||
│
|
||||
├── Support
|
||||
│ ├── keydb KEYDB.cfg download, parse, verify, save
|
||||
│ └── error Error enum with numeric codes E1000-E8000
|
||||
│
|
||||
└── lib.rs Public API re-exports
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Drive Access Flow
|
||||
|
||||
```
|
||||
Drive::open(Path::new("/dev/sg4"))
|
||||
│
|
||||
├─ scsi::open() Open /dev/sg4 (async write/poll/read)
|
||||
├─ DriveId::from_drive() INQUIRY + GET_CONFIG 010C
|
||||
└─ Drive ready for init/read
|
||||
```
|
||||
|
||||
After open:
|
||||
- `init()` -- routes to the matching registered unlocker (if any); otherwise
|
||||
a no-op and the cert handshake carries the disc
|
||||
- `probe_disc()` -- probe disc surface for optimal speeds
|
||||
- `read(lba, count, buf, recovery)` -- single-shot read; `recovery` only selects the per-CDB timeout (`READ_TIMEOUT_MS` 10 s vs. `READ_RECOVERY_TIMEOUT_MS` 60 s)
|
||||
- `wait_ready()` -- wait for disc insertion
|
||||
- `eject()` -- eject tray
|
||||
|
||||
Recovery is layered above `Drive::read`, not inside it. Layer 1
|
||||
(`freemkv_engine::recovery::patch`, in the engine crate) handles bad-range
|
||||
retry by replaying the ddrescue mapfile.
|
||||
Layer 3 (`DiscStream::fill_extents` adaptive batch sizer) handles in-loop
|
||||
request-size adaptation. Inline recovery (gentle retry → SCSI reset → retry)
|
||||
was removed in 0.13.6 — see [`rip-recovery.md`](rip-recovery.md) and
|
||||
the stop-wedge postmortem (2026-04-25).
|
||||
|
||||
---
|
||||
|
||||
## Disc Scanning Flow
|
||||
|
||||
```
|
||||
Disc::scan(&mut drive, &ScanOptions)
|
||||
│
|
||||
├─ READ CAPACITY Get disc size in sectors
|
||||
├─ udf::read_filesystem() Parse UDF 2.50 (AVDP → VDS → metadata → FSD → root)
|
||||
├─ For each BDMV/PLAYLIST/*.mpls:
|
||||
│ ├─ mpls::parse() Extract play items, STN streams
|
||||
│ └─ For each clip:
|
||||
│ └─ clpi::parse() EP map → sector extents for the clip's time range
|
||||
├─ labels::detect() Parse BD-J JARs for stream labels
|
||||
├─ Detect AACS Check for /AACS directory on disc
|
||||
└─ Disc::setup_aacs() Handshake + KEYDB → VUK → unit keys (if encrypted)
|
||||
```
|
||||
|
||||
For DVD:
|
||||
```
|
||||
Disc::scan_dvd(&mut drive, &ScanOptions)
|
||||
│
|
||||
├─ ifo::parse() Parse VIDEO_TS.IFO — title sets, PGC chains
|
||||
├─ CSS detection Check disc structure flag
|
||||
└─ CSS key cracking Table-driven, no KEYDB needed
|
||||
```
|
||||
|
||||
The result is a `Disc` with:
|
||||
- `titles: Vec<DiscTitle>` -- sorted by duration, each with streams, sector extents, codec_privates
|
||||
- `decrypt_keys()` -- DecryptKeys for content decryption
|
||||
- `encrypted: bool` -- whether the disc uses AACS/CSS
|
||||
|
||||
---
|
||||
|
||||
## AACS Decryption
|
||||
|
||||
Four key resolution paths, tried in order:
|
||||
|
||||
| Path | Method | Speed |
|
||||
|------|--------|-------|
|
||||
| 1 | VUK lookup by disc hash in KEYDB.cfg | Instant |
|
||||
| 2 | Media Key + Volume ID from KEYDB → derive VUK | Fast |
|
||||
| 3 | Processing Keys + MKB → Media Key → VUK | Medium |
|
||||
| 4 | Device Keys + MKB subset-difference tree → VUK | Slow |
|
||||
|
||||
The AACS handshake (`aacs/handshake`) performs ECDH key agreement over the
|
||||
AACS 1.0 160-bit elliptic curve to obtain:
|
||||
- **Volume ID** -- needed for VUK derivation (paths 2-4)
|
||||
- **Read Data Key** -- needed for AACS 2.0 (UHD) bus decryption
|
||||
|
||||
Content decryption uses AES-128-CBC on 6144-byte aligned units. The
|
||||
`ContentReader` handles this transparently. Streams that read sectors
|
||||
(DiscStream, IsoStream) decrypt internally — the pipeline sees clean bytes.
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
|
||||
All errors carry a numeric code for programmatic handling. No user-facing text
|
||||
is baked into the library.
|
||||
|
||||
| Range | Category | Examples |
|
||||
|-------|----------|----------|
|
||||
| E1xxx | Device errors | `DeviceNotFound`, `DevicePermission` |
|
||||
| E2xxx | Profile errors | `UnsupportedDrive`, `ProfileNotFound`, `ProfileParse` |
|
||||
| E3xxx | Unlock errors | `UnlockFailed`, `SignatureMismatch`, `NotUnlocked`, `NotCalibrated` |
|
||||
| E4xxx | SCSI errors | `ScsiError`, `ScsiTimeout` |
|
||||
| E5xxx | I/O errors | `IoError` (wraps `std::io::Error`) |
|
||||
| E6xxx | Disc format errors | `DiscError` (UDF, MPLS, CLPI parse failures) |
|
||||
| E7xxx | AACS errors | `AacsError` (key resolution, handshake, decryption) |
|
||||
| E8xxx | KEYDB errors | `KeydbError` (download, parse, save) |
|
||||
|
||||
---
|
||||
|
||||
## Platform Support
|
||||
|
||||
| Platform | Transport | Status |
|
||||
|----------|-----------|--------|
|
||||
| Linux | async sg write/poll/read on `/dev/sg*` | Supported |
|
||||
| macOS | IOKit SCSITask | Supported |
|
||||
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Supported |
|
||||
|
||||
The `ScsiTransport` trait abstracts the platform. Adding a new platform requires
|
||||
implementing `execute()` for that OS and wiring it into `scsi::open()`.
|
||||
|
||||
---
|
||||
|
||||
## Drive Unlock
|
||||
|
||||
libfreemkv carries no drive-unlock mechanism. The `Unlocker` trait + registry
|
||||
(`src/unlock.rs`) is the seam: an external crate implements `Unlocker` and
|
||||
registers it once via `register_unlocker(...)`. At drive-prep the registry is
|
||||
walked in order and the first unlocker whose `matches()` is true is asked to
|
||||
`unlock_drive()` over the raw `ScsiTransport`. If none match, the drive is left
|
||||
untouched and the host-certificate AACS handshake carries the disc.
|
||||
|
||||
The implementor owns everything firmware-specific — drive profiles, vendor CDBs,
|
||||
variant logic. Concrete unlockers live in the separate
|
||||
**[freemkv-unlock](https://github.com/freemkv/freemkv-unlock)** repository, never
|
||||
in libfreemkv. See [`drive-access.md`](drive-access.md#drive-unlock-seam) for the
|
||||
trait definition and routing.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Produces a Rust library crate. The `libc` dependency is unix-only (gated).
|
||||
All three platforms build and pass CI.
|
||||
-207
@@ -1,207 +0,0 @@
|
||||
# CLPI Clip Information Format
|
||||
|
||||
## What is CLPI?
|
||||
|
||||
CLPI (Clip Information) files describe the structure of individual M2TS transport streams on a Blu-ray disc. Each `.clpi` file in `BDMV/CLIPINF/` corresponds to one `.m2ts` file in `BDMV/STREAM/` with the same numeric name (e.g. `00001.clpi` describes `00001.m2ts`).
|
||||
|
||||
The most important data in a CLPI file is the **EP (Entry Point) map**, which provides timestamp-to-sector mapping. This is essential for seeking and for extracting the specific sector ranges that correspond to a playlist's in/out time window.
|
||||
|
||||
## File Structure
|
||||
|
||||
CLPI files use big-endian byte order. The header:
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 4 Magic: "HDMV"
|
||||
4 4 Version: "0200" (BD) or "0300" (UHD BD)
|
||||
8 4 SequenceInfo start offset
|
||||
12 4 ProgramInfo start offset
|
||||
16 4 CPI (Characteristic Point Information) start offset
|
||||
20 4 ClipMark start offset
|
||||
24 4 ExtensionData start offset
|
||||
```
|
||||
|
||||
### ClipInfo Section (offset 40)
|
||||
|
||||
Contains basic clip metadata. The source packet count (total number of 192-byte source packets in the M2TS file) is at offset 56:
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
40 4 ClipInfo length
|
||||
44 2 Reserved
|
||||
46 1 Stream type
|
||||
47 1 Application type
|
||||
48 4 Reserved
|
||||
52 4 TS recording rate
|
||||
56 4 Source packet count
|
||||
```
|
||||
|
||||
The source packet count multiplied by 192 gives the total byte size of the M2TS file.
|
||||
|
||||
## EP Map
|
||||
|
||||
The EP map lives inside the CPI section and provides random access into the transport stream. It maps PTS timestamps to Source Packet Numbers (SPN), enabling precise seeking without scanning the stream.
|
||||
|
||||
### CPI Section Layout
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 4 CPI length
|
||||
4 2 Reserved / CPI type
|
||||
6 ... EP map
|
||||
```
|
||||
|
||||
### EP Map Header
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 1 Reserved
|
||||
1 1 Number of stream PID entries
|
||||
2 ... Stream PID entry headers (one per stream)
|
||||
```
|
||||
|
||||
Each stream PID entry header (14 bytes):
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
2 2 stream_PID (byte-aligned)
|
||||
4 10 Bit-packed block, 80 bits total (see below)
|
||||
```
|
||||
|
||||
The stream PID entry is **not** byte-aligned past `stream_PID`. Bytes 4..14 are one
|
||||
80-bit packed field, read as a `u64` plus a trailing `u16`:
|
||||
|
||||
Bits Width Field
|
||||
---- ----- -----
|
||||
0-9 10 reserved
|
||||
10-13 4 EP_stream_type
|
||||
14-29 16 num_EP_coarse
|
||||
30-47 18 num_EP_fine
|
||||
48-79 32 EP_map_start_address (relative to the EP map start)
|
||||
|
||||
Note `num_EP_fine` is **18 bits**, not 32. See `parse_cpi` in `src/clpi.rs`.
|
||||
|
||||
libfreemkv parses only the first stream (primary video), which is sufficient for sector-level seeking.
|
||||
|
||||
### Two-Level Index
|
||||
|
||||
The EP map uses a two-level structure to compress what would otherwise be a very large lookup table:
|
||||
|
||||
- **Coarse entries**: low-resolution index covering large time/sector ranges
|
||||
- **Fine entries**: high-resolution entries within each coarse range
|
||||
|
||||
Each coarse entry points to a range of fine entries via `ref_to_fine_id`.
|
||||
|
||||
### Coarse Entries (8 bytes each)
|
||||
|
||||
Located immediately after the fine table start offset (4 bytes) in the per-stream EP map:
|
||||
|
||||
```
|
||||
Bits Field
|
||||
------ -----
|
||||
[31:14] ref_to_fine_id (18 bits) -- index of first fine entry in this range
|
||||
[13:0] pts_coarse (14 bits) -- upper bits of PTS
|
||||
```
|
||||
|
||||
Second dword:
|
||||
|
||||
```
|
||||
Bits Field
|
||||
------ -----
|
||||
[31:0] spn_coarse (32 bits) -- upper bits of SPN
|
||||
```
|
||||
|
||||
### Fine Entries (4 bytes each)
|
||||
|
||||
Located at the fine table start offset within the per-stream EP map:
|
||||
|
||||
```
|
||||
Bits Field
|
||||
------ -----
|
||||
[31] is_angle_change_point (1 bit)
|
||||
[30:28] I_end_position_offset (3 bits)
|
||||
[27:17] pts_fine (11 bits) -- lower bits of PTS
|
||||
[16:0] spn_fine (17 bits) -- lower bits of SPN
|
||||
```
|
||||
|
||||
## Reconstructing Full PTS and SPN
|
||||
|
||||
The full timestamp and packet number are assembled by combining the coarse and fine components:
|
||||
|
||||
### Full PTS
|
||||
|
||||
```
|
||||
full_pts = (pts_coarse << 19) + (pts_fine << 8)
|
||||
```
|
||||
|
||||
The coarse component provides the upper bits, shifted left by 19. The fine component provides mid-range bits, shifted left by 8. The resulting PTS is in 45kHz ticks, matching the MPLS timestamp format.
|
||||
|
||||
### Full SPN
|
||||
|
||||
```
|
||||
full_spn = (spn_coarse & 0xFFFE0000) + spn_fine
|
||||
```
|
||||
|
||||
The coarse SPN provides the upper 15 bits (masked to clear the lower 17). The fine SPN provides the lower 17 bits. The result is a Source Packet Number -- each source packet is 192 bytes in the M2TS file.
|
||||
|
||||
### Resolved EP Map
|
||||
|
||||
The `resolved_ep_map()` method iterates through all coarse entries and their associated fine entries to produce a flat list of `(PTS, SPN)` pairs. For each coarse entry at index `ci`:
|
||||
|
||||
- Fine entries range from `coarse[ci].ref_to_fine_id` to `coarse[ci+1].ref_to_fine_id` (or end of fine table for the last coarse entry).
|
||||
- Each fine entry is combined with its parent coarse entry to produce one full `(PTS, SPN)` pair.
|
||||
|
||||
## Deriving Sector Extents for Ripping
|
||||
|
||||
Given a playlist's in_time and out_time (from MPLS), the CLPI EP map provides the sector ranges to read from disc. The `get_extents()` method does this in three steps:
|
||||
|
||||
### Step 1: PTS to SPN
|
||||
|
||||
Binary search the resolved EP map for the in_time and out_time:
|
||||
|
||||
- **start_spn**: the SPN at or before in_time (seek backward to the nearest I-frame)
|
||||
- **end_spn**: the SPN at or after out_time (include the full GOP)
|
||||
|
||||
### Step 2: SPN to Byte Offset
|
||||
|
||||
Each Source Packet is 192 bytes (188 bytes MPEG-TS payload + 4 bytes M2TS header):
|
||||
|
||||
```
|
||||
byte_offset = spn * 192
|
||||
```
|
||||
|
||||
### Step 3: Byte Offset to Sector
|
||||
|
||||
M2TS files are stored contiguously on disc. Sectors are 2048 bytes:
|
||||
|
||||
```
|
||||
start_sector = start_byte / 2048
|
||||
end_sector = (end_byte + 2047) / 2048
|
||||
sector_count = end_sector - start_sector
|
||||
```
|
||||
|
||||
The resulting `Extent` contains `start_lba` (relative to the M2TS file's starting sector on disc) and `sector_count`. The caller adds the file's absolute starting LBA from UDF to get disc-absolute sector numbers.
|
||||
|
||||
### Alignment Note
|
||||
|
||||
The 192-byte source packet size and 2048-byte sector size share no common factor beyond 1. A single sector contains roughly 10.67 source packets. The conversion rounds start down and end up to ensure complete coverage.
|
||||
|
||||
## Putting It Together
|
||||
|
||||
The full ripping pipeline chains three parsers:
|
||||
|
||||
1. **MPLS** provides clip IDs and in/out timestamps.
|
||||
2. **CLPI** converts those timestamps to SPN ranges, then to sector extents.
|
||||
3. **UDF** provides the file's starting LBA on disc for absolute sector addressing.
|
||||
|
||||
The `Disc::scan()` method in `src/disc/mod.rs` orchestrates this: for each play item in each playlist, it loads the corresponding CLPI, calls `get_extents()` with the play item's in/out times, and collects the resulting sector ranges into the title's extent list.
|
||||
|
||||
## References
|
||||
|
||||
- BD-ROM Part 3, Section 5.5: Clip Information file format
|
||||
- https://github.com/lw/BluRay/wiki/CLPI
|
||||
@@ -1,135 +0,0 @@
|
||||
# Disc to Rip: End-to-End Flow
|
||||
|
||||
How libfreemkv goes from a disc in the drive to decrypted content ready for backup.
|
||||
This is the starting point for understanding the library.
|
||||
|
||||
## The Pipeline
|
||||
|
||||
```
|
||||
Insert disc
|
||||
│
|
||||
▼
|
||||
1. Open drive (drive/mod.rs)
|
||||
│ INQUIRY → identify drive (DriveId)
|
||||
│
|
||||
▼
|
||||
2. Init drive (drive/mod.rs → unlock seam)
|
||||
│ Walk the registered-unlocker registry; first match unlocks the drive
|
||||
│ (firmware/vendor handshakes are the unlocker's own business)
|
||||
│ No match → drive untouched; host-cert AACS handshake carries the disc
|
||||
│ Speed control → probe_disc()
|
||||
│
|
||||
▼
|
||||
3. AACS handshake (aacs/handshake.rs) — optional
|
||||
│ Allocate AGID
|
||||
│ Exchange certificates + nonces (ECDH)
|
||||
│ Derive bus key
|
||||
│ Read Volume ID + read_data_key
|
||||
│ (fails gracefully if drive doesn't support AACS for this disc)
|
||||
│
|
||||
▼
|
||||
4. Read UDF filesystem (udf.rs)
|
||||
│ Sector 256: AVDP → find Volume Descriptor Sequence
|
||||
│ VDS: Partition Descriptor (physical start) + Logical Volume (metadata start)
|
||||
│ Metadata partition → File Set Descriptor → Root directory
|
||||
│ Walk directory tree: BDMV/, AACS/, CERTIFICATE/
|
||||
│ → docs/udf.md
|
||||
│
|
||||
▼
|
||||
5. Read AACS files from disc (aacs/mod.rs)
|
||||
│ AACS/Unit_Key_RO.inf → SHA1 = disc hash
|
||||
│ AACS/Content000.cer → AACS version (1.0 or 2.0), bus encryption flag
|
||||
│ MKB via SCSI → for key derivation fallback
|
||||
│
|
||||
▼
|
||||
6. Resolve encryption keys (decrypt.rs → resolve_encryption)
|
||||
│ BD AACS:
|
||||
│ Path 1: disc hash → KEYDB.cfg → VUK (fast, 99% of discs)
|
||||
│ Path 2: KEYDB media key + Volume ID → VUK
|
||||
│ Path 3: MKB + processing keys → media key → VUK
|
||||
│ Path 4: MKB + device keys → subset-difference tree → VUK
|
||||
│ VUK → decrypt unit keys from Unit_Key_RO.inf
|
||||
│ DVD CSS:
|
||||
│ Table-driven cipher — no KEYDB needed
|
||||
│ → docs/aacs.md
|
||||
│
|
||||
▼
|
||||
7. Parse playlists (mpls.rs) — BD/UHD only
|
||||
│ BDMV/PLAYLIST/*.mpls → titles with play items
|
||||
│ Each play item: clip ID, in/out timestamps
|
||||
│ STN table: video, audio, subtitle streams with codec + language
|
||||
│ → docs/mpls.md
|
||||
│
|
||||
▼
|
||||
8. Parse clip info (clpi.rs) — BD/UHD only
|
||||
│ BDMV/CLIPINF/*.clpi → EP map (timestamp → sector mapping)
|
||||
│ Coarse + fine entries → full PTS and SPN
|
||||
│ SPN → byte offset → sector extents for reading
|
||||
│ → docs/clpi.md
|
||||
│
|
||||
▼
|
||||
9. Parse BD-J labels (labels/) — optional
|
||||
│ BDMV/JAR/*.jar → Java class constant pool strings
|
||||
│ 5 format parsers: Paramount, Criterion, Pixelogic, CTRM, Deluxe
|
||||
│ Audio track labels: "English Descriptive Audio", "French 5.1", etc.
|
||||
│
|
||||
▼
|
||||
10. Stream content (mux/disc.rs → DiscStream)
|
||||
│ Read sectors → decrypt → TS demux → PES frames
|
||||
│ Or: read sectors → decrypt → raw bytes (for ISO output)
|
||||
│ Drive::read() is single-shot. DiscStream::fill_extents adapts the
|
||||
│ batch size on failure (halve / probe-up). Bad-range retry is layer
|
||||
│ 1 above this — freemkv_engine::recovery::patch re-runs against the mapfile.
|
||||
│
|
||||
▼
|
||||
PES frames → output stream (MKV, M2TS, network, etc.)
|
||||
```
|
||||
|
||||
## API Summary
|
||||
|
||||
```rust
|
||||
// Open + init drive
|
||||
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||
drive.wait_ready()?;
|
||||
drive.init()?;
|
||||
drive.probe_disc()?;
|
||||
|
||||
// Scan disc (UDF + playlists + AACS — all automatic)
|
||||
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
|
||||
|
||||
// Stream pipeline — PES frames from any source to any output.
|
||||
// input() returns Box<dyn FrameSource>, output() returns Box<dyn FrameSink>;
|
||||
// direction is type-checked, so calling .write() on an input is a compile error.
|
||||
let opts = InputOptions::default();
|
||||
let mut input = libfreemkv::input("disc:///dev/sg4", &opts)?;
|
||||
let title = input.info().clone();
|
||||
let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?;
|
||||
while let Ok(Some(frame)) = input.read() {
|
||||
output.write(&frame)?;
|
||||
}
|
||||
output.finish()?;
|
||||
```
|
||||
|
||||
## Module Reference
|
||||
|
||||
| Module | Doc | Purpose |
|
||||
|--------|-----|---------|
|
||||
| drive/ | [drive-access.md](drive-access.md) | Open, identify, init, unlock, single-shot read |
|
||||
| scsi/ | [drive-access.md](drive-access.md) | Platform SCSI transport (Linux, macOS, Windows) |
|
||||
| udf.rs | [udf.md](udf.md) | UDF 2.50 filesystem |
|
||||
| mpls.rs | [mpls.md](mpls.md) | MPLS playlists + STN streams |
|
||||
| clpi.rs | [clpi.md](clpi.md) | CLPI clip info + EP map |
|
||||
| ifo.rs | -- | DVD IFO parser |
|
||||
| aacs/ | [aacs.md](aacs.md) | Key resolution + content decrypt + bus handshake |
|
||||
| css/ | -- | DVD CSS cipher |
|
||||
| decrypt.rs | -- | Unified decrypt dispatcher (AACS/CSS/None) |
|
||||
| disc/ | [rip-recovery.md](rip-recovery.md) | Disc::scan (sweep/patch/mapfile moved to freemkv-engine in 1.6.0) |
|
||||
| labels/ | -- | BD-J stream labels (5 format parsers) |
|
||||
| mux/ | -- | Stream implementations (7 stream types) |
|
||||
| pes.rs | -- | PES frame types + FrameSource / FrameSink traits |
|
||||
| sector/ | -- | SectorSource / SectorSink + DecryptingSectorSource decorator |
|
||||
| io/ | -- | Pipeline<I, R> + Sink trait + WritebackFile |
|
||||
| halt.rs | -- | Halt cancellation token |
|
||||
| keydb.rs | -- | KEYDB download, parse, save |
|
||||
| error.rs | -- | Error codes (E1xxx-E8xxx) |
|
||||
| event.rs | -- | Drive event system |
|
||||
@@ -1,243 +0,0 @@
|
||||
# Drive Access and Unlock
|
||||
|
||||
Technical reference for how libfreemkv opens, identifies, unlocks, and reads
|
||||
optical drives.
|
||||
|
||||
---
|
||||
|
||||
## Drive
|
||||
|
||||
`Drive` is the primary API. It owns the SCSI transport and the drive
|
||||
identity (`DriveId`); any drive-specific unlock logic lives behind the
|
||||
pluggable [unlock seam](#drive-unlock-seam), not in `Drive` itself.
|
||||
|
||||
### Opening a Drive
|
||||
|
||||
```rust
|
||||
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||
```
|
||||
|
||||
`open()` performs: open device → send INQUIRY → build `DriveId`. The drive
|
||||
is ready for `wait_ready()` and `init()` (which routes through the unlock
|
||||
seam).
|
||||
|
||||
### Drive Operations
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `wait_ready()` | Wait for disc insertion (30s timeout, TUR polling) |
|
||||
| `init()` | Route to the matching registered unlocker (if any), then prepare for reads |
|
||||
| `probe_disc()` | Probe disc surface for optimal speeds |
|
||||
| `read(lba, count, buf, recovery)` | Read sectors. Single-shot — no inline retries or reset. |
|
||||
| `reset()` | Eject-cycle escape hatch. Caller-invoked only; not on the read path. |
|
||||
| `lock_tray()` | Prevent tray ejection during rip |
|
||||
| `unlock_tray()` | Allow tray ejection (also runs on Drop) |
|
||||
| `eject()` | Eject disc tray |
|
||||
| `drive_status()` | Query physical state (disc present, tray open, etc.) |
|
||||
| `has_profile()` | Whether a registered unlocker matches this drive |
|
||||
| `close()` | Consume Drive, cleanup (also runs via Drop) |
|
||||
|
||||
### init() Sequence
|
||||
|
||||
`init()` routes drive preparation through the unlock seam:
|
||||
|
||||
1. Walk the registered-unlocker registry; the first whose `matches()` is true
|
||||
is asked to `unlock_drive()` over the raw transport.
|
||||
2. Whatever that unlocker needs (firmware upload, vendor handshakes, retries)
|
||||
is the unlocker's own business — libfreemkv only forwards the transport.
|
||||
3. If no unlocker matches, the drive is left untouched and the library uses
|
||||
the host-certificate AACS handshake.
|
||||
|
||||
See [Drive Unlock Seam](#drive-unlock-seam) for the trait and registry.
|
||||
|
||||
### read() — single-shot
|
||||
|
||||
`Drive::read(lba, count, buf, recovery)` is the single read method. It issues
|
||||
exactly one READ(10) CDB and returns the result. The `recovery` parameter only
|
||||
selects the per-CDB timeout:
|
||||
|
||||
| `recovery` | Timeout | Used by |
|
||||
|------------|----------|------------------------------------------|
|
||||
| `false` | 10 s | `freemkv_engine::recovery::sweep` fast skip-forward pass, `DiscStream::fill_extents` |
|
||||
| `true` | 60 s | `freemkv_engine::recovery::patch` retry pass over the mapfile |
|
||||
|
||||
On any SCSI failure or timeout, `read` returns `Err(DiscRead)` immediately.
|
||||
There are no inline retries, no SCSI reset, no Phase 1/2/3 escalation.
|
||||
|
||||
Recovery is layered above `Drive::read`:
|
||||
|
||||
- **Layer 1 — `freemkv_engine::recovery::patch`** (in the engine crate, not
|
||||
here) loops over the ddrescue mapfile and re-issues
|
||||
`read(.., recovery=true)` against each non-`+` range.
|
||||
- **Layer 3 — `DiscStream::fill_extents`** halves the request size on
|
||||
failure, retries at the same LBA, and probes back up on a clean-read
|
||||
streak.
|
||||
|
||||
Inline recovery (5× gentle retry → close + reset + reopen → 5× more) was
|
||||
removed in 0.13.6. See the stop-wedge postmortem (2026-04-25)
|
||||
for rationale: the inline reset wedged drive firmware on the LG BU40N (Initio
|
||||
USB-SATA bridge) without ever recovering a sector. See
|
||||
[`rip-recovery.md`](rip-recovery.md) for the full three-layer model.
|
||||
|
||||
---
|
||||
|
||||
## SCSI Transport
|
||||
|
||||
### Trait
|
||||
|
||||
```rust
|
||||
pub trait ScsiTransport: Send {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult>;
|
||||
}
|
||||
```
|
||||
|
||||
All drive communication goes through this trait. The library never opens file
|
||||
descriptors or calls ioctls outside of a `ScsiTransport` implementation.
|
||||
|
||||
### Platform Backends
|
||||
|
||||
| Platform | Implementation | Device |
|
||||
|----------|---------------|--------|
|
||||
| Linux | `SgIoTransport` — async `write`/`poll`/`read` on `/dev/sg*` | `/dev/sg*` |
|
||||
| macOS | `MacScsiTransport` — IOKit SCSITask | IOKit service |
|
||||
|
||||
The Linux backend uses the sg driver's asynchronous interface: `write()` submits
|
||||
the command, `poll()` waits with an enforceable wall-clock timeout, `read()`
|
||||
retrieves the result. If `poll()` times out, the fd is abandoned (closed in a
|
||||
background thread) and a fresh fd opened — the kernel's USB error recovery
|
||||
cannot block us. Opens with `O_RDWR | O_NONBLOCK`.
|
||||
|
||||
The macOS backend uses a C shim (`macos_shim.c`) for IOKit exclusive access.
|
||||
The shim handles:
|
||||
1. `shim_open_exclusive(bsd_name)` — unmounts the target device via `diskutil`,
|
||||
then walks the IOKit registry to find the `IOBDServices` matching the
|
||||
requested BSD name (IOBDServices → IOBDBlockStorageDriver → IOMedia → "BSD Name"),
|
||||
then creates MMCDeviceInterface → SCSITaskDeviceInterface → ObtainExclusiveAccess.
|
||||
2. `shim_list_drives()` — registry-based enumeration with zero SCSI, zero exclusive
|
||||
access, zero unmounts. Reads IOBDServices "Device Characteristics" for
|
||||
vendor/model/firmware and child IOMedia "BSD Name" for the device path.
|
||||
3. `shim_execute()` / `shim_close()` — raw CDB dispatch and cleanup.
|
||||
|
||||
On non-zero SCSI status, the transport parses sense key from the sense buffer
|
||||
and returns `Error::ScsiError`.
|
||||
|
||||
`SgIoTransport::reset` (Linux) does pure userspace state cleanup: an open +
|
||||
close pair to make the kernel cancel any SG_IO commands queued against a
|
||||
previous fd, a 2 s sleep to let the kernel finish that cancellation, then a
|
||||
fresh fd to send ALLOW MEDIUM REMOVAL to clear any stale tray lock. It does
|
||||
NOT issue `SG_SCSI_RESET` or escalate via STOP+START UNIT. Both were tried
|
||||
in 0.13.0–0.13.5 against the LG BU40N (Initio USB-SATA bridge); both failed
|
||||
to recover wedged drives and made the wedge worse. The macOS reset (which
|
||||
had been a no-op) was removed entirely in 0.13.6, and the top-level
|
||||
`scsi::reset()` / `reset_with_timeout()` / `reset_blocking()` wrappers were
|
||||
removed at the same time (no callers).
|
||||
|
||||
### CDB Builders
|
||||
|
||||
The `scsi` module provides platform-agnostic CDB constructors:
|
||||
|
||||
| Function | CDB | Use |
|
||||
|----------|-----|-----|
|
||||
| `inquiry()` | INQUIRY (0x12) | Drive identification |
|
||||
| `get_config_010c()` | GET CONFIGURATION (0x46) | Feature 010C firmware date |
|
||||
| `build_read_buffer()` | READ BUFFER (0x3C) | All platform commands |
|
||||
| `build_set_cd_speed()` | SET CD SPEED (0xBB) | Speed control |
|
||||
| `build_read10_raw()` | READ(10) (0x28) with flag 0x08 | Raw sector reads |
|
||||
|
||||
---
|
||||
|
||||
## Drive Identification
|
||||
|
||||
`DriveId::from_drive()` sends two standard SCSI commands and extracts identity
|
||||
fields:
|
||||
|
||||
| Field | Source | SCSI Reference |
|
||||
|-------|--------|----------------|
|
||||
| `vendor_id` | INQUIRY bytes [8:16] | SPC-4 section 6.4.2 |
|
||||
| `product_id` | INQUIRY bytes [16:32] | SPC-4 section 6.4.2 |
|
||||
| `product_revision` | INQUIRY bytes [32:36] | SPC-4 section 6.4.2 |
|
||||
| `vendor_specific` | INQUIRY bytes [36:43] | SPC-4 section 6.4.2 |
|
||||
| `firmware_date` | GET CONFIGURATION Feature 010C | MMC-6 section 5.3.10 |
|
||||
|
||||
The match key is `"VENDOR|PRODUCT|REVISION|VENDOR_SPECIFIC"`. Profile matching
|
||||
tries all four fields first, then falls back to matching without the firmware
|
||||
date for drives where Feature 010C is unavailable.
|
||||
|
||||
---
|
||||
|
||||
## Drive Unlock Seam
|
||||
|
||||
libfreemkv ships **no firmware, no unlock CDBs, and no drive profiles.** It
|
||||
knows only the *seam*, never the *mechanism*. The seam is the `Unlocker`
|
||||
trait plus a small process-wide registry (`src/unlock.rs`):
|
||||
|
||||
```rust
|
||||
pub trait Unlocker: Send + Sync {
|
||||
/// Stable, language-neutral identifier (logged).
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// True if this unlocker handles the given drive.
|
||||
fn matches(&self, id: &DriveId) -> bool;
|
||||
|
||||
/// Put the drive into extended-access mode. The one required capability.
|
||||
fn unlock_drive(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<()>;
|
||||
|
||||
/// Read the disc Volume ID via the drive's OEM path. Default: no-op.
|
||||
fn read_volume_id(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId)
|
||||
-> Result<Option<[u8; 16]>> { Ok(None) }
|
||||
|
||||
/// Raise the drive to its maximum read speed. Default: no-op.
|
||||
fn set_max_read_speed(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId)
|
||||
-> Result<()> { Ok(()) }
|
||||
}
|
||||
```
|
||||
|
||||
An unlocker is supplied by an **external crate** and registered once at
|
||||
process start:
|
||||
|
||||
```rust
|
||||
libfreemkv::register_unlocker(Box::new(some_unlocker::Plugin::new()));
|
||||
```
|
||||
|
||||
The implementor owns everything about *how* a particular drive family is
|
||||
driven — drive identification against its own profile database, firmware
|
||||
upload, vendor CDBs, variant logic. libfreemkv only hands over the raw
|
||||
`ScsiTransport` and the `DriveId`.
|
||||
|
||||
### Routing
|
||||
|
||||
At drive-prep the registry is walked in registration order; the first
|
||||
unlocker whose `matches()` returns true is asked to `unlock_drive()` (and,
|
||||
when needed, `read_volume_id()` / `set_max_read_speed()`). If no unlocker
|
||||
matches, the drive is left untouched and the library falls back to the
|
||||
standard host-certificate AACS handshake (the "OEM route"). The
|
||||
`register_unlocker(...)` line is the entire plug: drop it (and the unlocker
|
||||
crate) and libfreemkv still compiles and rips via the cert handshake.
|
||||
|
||||
Concrete unlockers — including the firmware-unlock profile databases,
|
||||
variant logic, and vendor CDBs that used to live in-tree — are maintained
|
||||
in the separate **[freemkv-unlock](https://github.com/freemkv/freemkv-unlock)**
|
||||
repository, never here.
|
||||
|
||||
---
|
||||
|
||||
## Speed Control
|
||||
|
||||
A matching unlocker may raise the drive to its maximum read speed via
|
||||
`set_max_read_speed()` (a no-op when no unlocker matches or the unlocker
|
||||
declines). The library issues SET CD SPEED (0xBB) through the generic CDB
|
||||
builder; the concrete speed policy lives in the unlocker.
|
||||
|
||||
Available speeds:
|
||||
|
||||
| Format | Speeds |
|
||||
|--------|--------|
|
||||
| Blu-ray | 1x (4,500 KB/s) through 12x (54,000 KB/s) |
|
||||
| DVD | 1x (1,385 KB/s) through 16x (22,160 KB/s) |
|
||||
| Max | 0xFFFF (drive decides) |
|
||||
-217
@@ -1,217 +0,0 @@
|
||||
# MPLS Playlist Format
|
||||
|
||||
## What is MPLS?
|
||||
|
||||
MPLS (Movie PlayList) files define playback titles on a Blu-ray disc. Each `.mpls` file in `BDMV/PLAYLIST/` describes one title -- a sequence of clips with precise in/out timestamps and stream information. The main movie, bonus features, trailers, and menus each have their own MPLS file.
|
||||
|
||||
A disc may contain dozens of MPLS files. Most are short (menus, logos, transitions). The main movie is typically the longest playlist. libfreemkv filters out playlists shorter than 30 seconds.
|
||||
|
||||
## File Structure
|
||||
|
||||
MPLS files use big-endian byte order throughout. The file has three main sections:
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 4 Magic: "MPLS"
|
||||
4 4 Version: "0200" (BD) or "0300" (UHD BD)
|
||||
8 4 PlayList start offset (absolute from file start)
|
||||
12 4 PlayListMark start offset
|
||||
16 4 ExtensionData start offset
|
||||
```
|
||||
|
||||
### PlayList Section
|
||||
|
||||
Located at the PlayList start offset. Contains all play items and sub-path entries:
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 4 PlayList length
|
||||
4 2 Reserved
|
||||
6 2 Number of PlayItems
|
||||
8 2 Number of SubPaths
|
||||
10 ... PlayItem entries (variable length)
|
||||
```
|
||||
|
||||
### Play Items
|
||||
|
||||
Each play item references one clip and specifies what portion to play:
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 2 PlayItem length (bytes after this field)
|
||||
2 5 Clip ID (ASCII, e.g. "00001")
|
||||
7 4 Codec ID ("M2TS")
|
||||
11 1 Connection condition (lower 4 bits)
|
||||
12 1 Ref to STC_id
|
||||
14 4 IN_time (45kHz PTS ticks)
|
||||
18 4 OUT_time (45kHz PTS ticks)
|
||||
22 8 UO_mask_table
|
||||
30 1 Misc flags
|
||||
31 1 still_mode
|
||||
32 ... STN_table (first play item only is parsed)
|
||||
```
|
||||
|
||||
**Connection condition** values:
|
||||
- `1` = seamless connection (no gap between clips)
|
||||
- `5`, `6` = non-seamless connection
|
||||
|
||||
**Timestamps** use 45kHz PTS (Presentation Time Stamp) ticks, the same timebase as MPEG transport streams. Duration of a play item = `OUT_time - IN_time`. To convert to seconds: divide by 45000.
|
||||
|
||||
A playlist's total duration is the sum of all play item durations.
|
||||
|
||||
### Clip ID Mapping
|
||||
|
||||
The Clip ID (e.g. "00001") maps to:
|
||||
- `BDMV/STREAM/00001.m2ts` -- the transport stream
|
||||
- `BDMV/CLIPINF/00001.clpi` -- the clip info (EP map, stream details)
|
||||
|
||||
## STN Table
|
||||
|
||||
The Stream Number Table describes all elementary streams available in the clip. It is parsed from the first play item (which defines the title's stream layout).
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 2 STN table length
|
||||
2 2 Reserved
|
||||
4 1 Number of primary video streams
|
||||
5 1 Number of primary audio streams
|
||||
6 1 Number of PG (subtitle) streams
|
||||
7 1 Number of IG (interactive graphics) streams
|
||||
8 ... Stream entries
|
||||
```
|
||||
|
||||
### Stream Entries
|
||||
|
||||
Each stream entry has two parts: a stream reference and a stream attributes block.
|
||||
|
||||
**Stream reference:**
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 1 Entry length
|
||||
1 1 Stream type (1=PlayItem, 2=SubPath, 3=InMux)
|
||||
2 2 PID (MPEG-TS packet ID, big-endian)
|
||||
```
|
||||
|
||||
**Stream attributes** (immediately follows the reference):
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 1 Attributes length
|
||||
1 1 Coding type
|
||||
2+ ... Type-specific fields
|
||||
```
|
||||
|
||||
The layout of type-specific fields depends on the stream category:
|
||||
|
||||
**Video streams:**
|
||||
|
||||
| Offset | Size | Field |
|
||||
|--------|------|-------|
|
||||
| 1 | 1 | Coding type |
|
||||
| 2 | 1 | Format (upper 4 bits) + frame rate (lower 4 bits) |
|
||||
|
||||
**Audio streams:**
|
||||
|
||||
| Offset | Size | Field |
|
||||
|--------|------|-------|
|
||||
| 1 | 1 | Coding type |
|
||||
| 2 | 1 | Format (upper 4 bits) + sample rate (lower 4 bits) |
|
||||
| 3 | 3 | Language code (ISO 639-2, e.g. "eng") |
|
||||
|
||||
**PG subtitle and IG streams:**
|
||||
|
||||
| Offset | Size | Field |
|
||||
|--------|------|-------|
|
||||
| 1 | 1 | Coding type |
|
||||
| 2 | 3 | Language code |
|
||||
|
||||
## Coding Types
|
||||
|
||||
The coding type byte identifies the codec:
|
||||
|
||||
| Value | Codec | Category |
|
||||
|-------|-------|----------|
|
||||
| `0x02` | MPEG-2 | Video |
|
||||
| `0x1B` | H.264 / AVC | Video |
|
||||
| `0x24` | HEVC / H.265 | Video |
|
||||
| `0xEA` | VC-1 | Video |
|
||||
| `0x80` | LPCM | Audio |
|
||||
| `0x81` | AC-3 (Dolby Digital) | Audio |
|
||||
| `0x82` | DTS | Audio |
|
||||
| `0x83` | TrueHD (Dolby TrueHD) | Audio |
|
||||
| `0x84` | AC-3 Plus (E-AC-3) | Audio |
|
||||
| `0x85` | DTS-HD HR | Audio |
|
||||
| `0x86` | DTS-HD MA | Audio |
|
||||
| `0xA1` | AC-3 Plus (secondary) | Audio |
|
||||
| `0xA2` | DTS-HD HR (secondary) | Audio |
|
||||
| `0x90` | PGS (Presentation Graphics) | Subtitle |
|
||||
| `0x91` | PGS (Interactive Graphics) | Subtitle |
|
||||
|
||||
## Video Format Codes
|
||||
|
||||
| Value | Resolution |
|
||||
|-------|-----------|
|
||||
| 1 | 480i |
|
||||
| 2 | 576i |
|
||||
| 3 | 480p |
|
||||
| 4 | 1080i |
|
||||
| 5 | 720p |
|
||||
| 6 | 1080p |
|
||||
| 7 | 576p |
|
||||
| 8 | 2160p |
|
||||
|
||||
## Video Frame Rate Codes
|
||||
|
||||
| Value | Frame Rate |
|
||||
|-------|-----------|
|
||||
| 1 | 23.976 fps |
|
||||
| 2 | 24 fps |
|
||||
| 3 | 25 fps |
|
||||
| 4 | 29.97 fps |
|
||||
| 6 | 50 fps |
|
||||
| 7 | 59.94 fps |
|
||||
|
||||
## Audio Format Codes
|
||||
|
||||
| Value | Channels |
|
||||
|-------|----------|
|
||||
| 1 | Mono |
|
||||
| 3 | Stereo |
|
||||
| 6 | 5.1 surround |
|
||||
| 12 | 7.1 surround |
|
||||
|
||||
## Audio Sample Rate Codes
|
||||
|
||||
| Value | Rate |
|
||||
|-------|------|
|
||||
| 1 | 48 kHz |
|
||||
| 4 | 96 kHz |
|
||||
| 5 | 192 kHz |
|
||||
| 12 | 48/192 kHz (combo) |
|
||||
| 14 | 48/96 kHz (combo) |
|
||||
|
||||
## How Playlists Map to Titles
|
||||
|
||||
libfreemkv's `Disc::scan()` reads every MPLS file from the disc and builds a `Title` for each:
|
||||
|
||||
1. Read all `.mpls` files from `BDMV/PLAYLIST/` via UDF.
|
||||
2. Parse each with `mpls::parse()`.
|
||||
3. Calculate duration by summing `(OUT_time - IN_time)` across all play items.
|
||||
4. Discard playlists shorter than 30 seconds.
|
||||
5. For each play item, load the corresponding CLPI file to get EP map data and compute sector extents (see [clpi.md](clpi.md)).
|
||||
6. Extract stream info from the STN table of the first play item.
|
||||
7. Sort titles by duration, longest first.
|
||||
|
||||
The resulting `Title` struct contains everything needed to rip: streams, duration, byte size, and the sector extents to read from disc.
|
||||
|
||||
## References
|
||||
|
||||
- BD-ROM Part 3, Section 5.3: PlayList file format
|
||||
- https://github.com/lw/BluRay/wiki/MPLS
|
||||
@@ -1,98 +0,0 @@
|
||||
# Rip recovery — what libfreemkv owns
|
||||
|
||||
**Recovery strategy moved OUT of this crate in 1.6.0.** The forward sweep, the
|
||||
targeted retry pass, the ddrescue mapfile, damage classification and the
|
||||
multipass loop now live in the **`freemkv-engine`** crate as
|
||||
`freemkv_engine::recovery::{copy, sweep, patch}`. The dependency runs
|
||||
engine → libfreemkv, so this crate cannot call into the engine; front-ends
|
||||
(`freemkv` CLI, autorip) get recovery from the engine directly.
|
||||
|
||||
What stayed here are the two layers underneath the strategy: the single-shot
|
||||
read primitive, and the in-stream request-size adaptation that sits in front of
|
||||
it. This document covers those, plus the design constraints they encode — the
|
||||
constraints are the reason the strategy above them looks the way it does, so
|
||||
they belong with the code that enforces them.
|
||||
|
||||
For the strategy itself — damage-jump thresholds, pass ordering, mapfile status
|
||||
state machine, wedge detection — read `freemkv-engine/src/recovery/`.
|
||||
|
||||
| Layer | Where it lives | What it does |
|
||||
|-------|---------------|--------------|
|
||||
| 1 — Bad-range retry | **`freemkv-engine`** (`recovery::patch`) | Re-reads non-`+` ranges with the long timeout. Idempotent; caller invokes N times. |
|
||||
| 2 — Single-shot primitive | `Drive::read` in `src/drive/mod.rs` | One CDB, one timeout, one result. No inline retries, no SCSI reset. |
|
||||
| 3 — In-loop request adaptation | `DiscStream::fill_extents` in `src/mux/disc.rs` | Halves the batch on failure, retries at the same LBA, walks back up on a clean-read streak. |
|
||||
|
||||
Layer 2 also translates drive facts: [`SenseFamily`](../src/scsi/mod.rs)
|
||||
classifies SCSI sense data into the categories the engine's strategy routes on
|
||||
(marginal vs. hardware vs. not-ready). Getting that classification wrong
|
||||
silently misroutes recovery, which is why it lives next to the transport rather
|
||||
than in the strategy.
|
||||
|
||||
Layer 3 runs inside any consumer of `DiscStream` — direct PES pipeline, ISO
|
||||
playback — without caller involvement, and applies whether or not the engine's
|
||||
recovery is in play.
|
||||
|
||||
## In-stream — adaptive batch halving (`DiscStream::fill_extents`)
|
||||
|
||||
When a consumer reads a `DiscStream` directly (no ISO intermediate),
|
||||
`fill_extents` runs an adaptive sizer in front of `Drive::read`:
|
||||
|
||||
1. Try the current preferred batch size (e.g. 32 sectors, one BD ECC block).
|
||||
2. On failure: halve the batch and retry at the same LBA. Emit
|
||||
`EventKind::BatchSizeChanged { reason: Shrunk }`.
|
||||
3. On a clean-read streak: probe back up toward the preferred size. Emit
|
||||
`EventKind::BatchSizeChanged { reason: Probed }`.
|
||||
4. If a single-sector read fails: skip (zero-fill, emit
|
||||
`EventKind::SectorSkipped`) when `skip_errors` is set, otherwise return
|
||||
`Err(DiscRead)`.
|
||||
|
||||
This exists so a transient single-sector glitch inside a 32-sector batch can be
|
||||
isolated and read individually without the caller implementing retry logic. See
|
||||
[`src/event.rs`](../src/event.rs) for the emitted events.
|
||||
|
||||
## Design choices
|
||||
|
||||
These are constraints on the read path, enforced here and relied on by the
|
||||
engine's strategy.
|
||||
|
||||
**`Drive::read` is single-shot.** No inline retry phases, no SCSI reset, no
|
||||
eject cycle. The `recovery` flag controls only the per-CDB timeout (10 s vs.
|
||||
60 s); on any failure it returns `Err(DiscRead)` immediately. Inline recovery
|
||||
(5× gentle retry → close + SCSI reset + reopen → 5× more) was removed in
|
||||
0.13.6. See the stop-wedge postmortem (2026-04-25) for rationale: the inline
|
||||
reset on the LG BU40N (Initio USB-SATA bridge) wedged drive firmware below the
|
||||
bridge without ever recovering a sector, and the gentle-retry phase produced
|
||||
long stretches of 0 KB/s with nothing to show for it. Recovery responsibility
|
||||
is layered instead: layer 1 handles ranges, layer 3 handles request size,
|
||||
neither touches the wedge-prone reset path.
|
||||
|
||||
**No `MODE SELECT` to disable drive retries.** Neither ddrescue nor any
|
||||
consumer ripper does this. Drive firmware has access to raw analog signal,
|
||||
laser power control and drive-specific ECC tuning that userspace cannot
|
||||
replicate — disabling it throws away recovery headroom on marginal sectors. The
|
||||
fast pass fails quickly via short SG_IO timeouts and lets the firmware work the
|
||||
long timeout during retry.
|
||||
|
||||
**No SCSI reset from any read path.** There is no reset escape hatch on
|
||||
`Drive` at all: the `SG_SCSI_RESET` ioctl and STOP/START UNIT escalation went in
|
||||
0.13.6, the macOS reset (always a no-op) was removed entirely, and the
|
||||
top-level `scsi::reset()` wrappers went with their last callers. The only
|
||||
remaining reset is a Windows-specific device-level helper in
|
||||
[`src/scsi/windows.rs`](../src/scsi/windows.rs), never reached from a read.
|
||||
|
||||
**ISO intermediate, even for single-pass.** The engine's Pass 1 always writes
|
||||
an ISO, and the mux stage reads it back via `FileSectorSource`. For a
|
||||
no-retry rip this costs a few minutes but buys resumability across crashes,
|
||||
re-muxability without re-ripping, and a persistent forensic artifact. Callers
|
||||
who need pure speed can bypass it with `DiscStream::new(Box::new(drive), …)` —
|
||||
nothing forbids it, and layer 3 still applies there.
|
||||
|
||||
## References
|
||||
|
||||
- [ddrescue manual, Algorithm chapter](https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html)
|
||||
- [ddrescue optical media notes](https://www.electric-spoon.com/doc/gddrescue/html/Optical-media.html)
|
||||
- Recovery strategy and mapfile: `freemkv-engine/src/recovery/`
|
||||
- In this crate: [`src/drive/mod.rs`](../src/drive/mod.rs) (`Drive::read`),
|
||||
[`src/scsi/mod.rs`](../src/scsi/mod.rs) (`SenseFamily`),
|
||||
[`src/mux/disc.rs`](../src/mux/disc.rs) (`DiscStream::fill_extents`),
|
||||
[`src/event.rs`](../src/event.rs) (progress events).
|
||||
-177
@@ -1,177 +0,0 @@
|
||||
# UDF 2.50 Filesystem Parser
|
||||
|
||||
## What is UDF?
|
||||
|
||||
UDF (Universal Disc Format) is the filesystem standard for optical media. It is defined by ECMA-167 with extensions from the OSTA (Optical Storage Technology Association). BD-ROM discs use UDF revision 2.50, which introduces the **metadata partition** -- a critical feature that separates file metadata from file content on disc.
|
||||
|
||||
## Why UDF 2.50 for Blu-ray?
|
||||
|
||||
Older UDF revisions (1.02, 1.50) scatter ICBs (file metadata) and file data across the same partition. On a high-capacity Blu-ray disc (25-100 GB), this creates excessive seeking when the drive needs to read a directory listing or locate a file. UDF 2.50 solves this by placing all metadata into a contiguous region near the beginning of the disc. The drive reads metadata from one compact area and streams file data from another -- no interleaved seeks.
|
||||
|
||||
BD-ROM Part 3 of the Blu-ray specification mandates UDF 2.50.
|
||||
|
||||
## Metadata Partitions
|
||||
|
||||
A UDF 2.50 BD-ROM has two logical partitions:
|
||||
|
||||
- **Partition 0 (Type 1)** -- the physical partition. Contains actual file data (m2ts streams, playlist files, etc.). Mapped directly to disc sectors starting at the Partition Descriptor's `partitionStartingLocation`.
|
||||
|
||||
- **Partition 1 (Type 2)** -- the metadata partition. Contains all ICBs (Inode-like structures), directory data, and the File Set Descriptor. The metadata partition is itself stored as a file within the physical partition. Its location is found by reading an Extended File Entry at LBA 0 of the physical partition.
|
||||
|
||||
The key rule: **ICBs and directory data live in the metadata partition. File content lives in the physical partition.** When an ICB's allocation descriptor gives an LBA, the partition it refers to depends on what the LBA describes -- metadata-relative for directory entries, physical-partition-relative for file data extents.
|
||||
|
||||
## Pointer Chain
|
||||
|
||||
Reading a UDF 2.50 filesystem follows a fixed chain of pointers. Each step reads one or two 2048-byte sectors:
|
||||
|
||||
```
|
||||
Sector 256: AVDP (Anchor Volume Descriptor Pointer, tag 2)
|
||||
|
|
||||
v
|
||||
Sectors 32-63: VDS (Volume Descriptor Sequence)
|
||||
|-- Partition Descriptor (tag 5) --> partition_start (physical sector)
|
||||
|-- Logical Volume Descriptor (tag 6) --> partition maps, FSD location
|
||||
|
|
||||
v
|
||||
Partition Maps in LVD (offset 440):
|
||||
|-- Map 0: Type 1 (physical partition)
|
||||
|-- Map 1: Type 2 (metadata partition, identified by "*UDF Metadata Partition")
|
||||
|
|
||||
v
|
||||
Metadata file ICB at partition_start + 0 (Extended File Entry, tag 266)
|
||||
|-- Allocation descriptor --> metadata content location
|
||||
|
|
||||
v
|
||||
metadata_start = partition_start + allocation_position
|
||||
|
|
||||
v
|
||||
FSD at metadata_start + 0 (File Set Descriptor, tag 256)
|
||||
|-- Root Directory ICB: long_ad at offset 400 --> root_lba (metadata-relative)
|
||||
|
|
||||
v
|
||||
Root Directory ICB at metadata_start + root_lba (Extended File Entry, tag 266)
|
||||
|-- Allocation descriptor --> directory data location (metadata-relative)
|
||||
|
|
||||
v
|
||||
Directory data: File Identifier Descriptors (tag 257)
|
||||
|-- Each FID names a file/subdirectory and points to its ICB
|
||||
|-- Recurse into subdirectories to build the full file tree
|
||||
```
|
||||
|
||||
### AVDP (Sector 256)
|
||||
|
||||
The Anchor Volume Descriptor Pointer is always at sector 256 (ECMA-167 section 10.2). It points to the Main Volume Descriptor Sequence. Tag identifier = 2.
|
||||
|
||||
### VDS (Sectors 32+)
|
||||
|
||||
The Volume Descriptor Sequence contains:
|
||||
|
||||
- **Partition Descriptor (tag 5)**: byte offset 188 holds `partitionStartingLocation` -- the absolute sector where the physical partition begins.
|
||||
- **Logical Volume Descriptor (tag 6)**: byte offset 268 holds the number of partition maps. The partition maps themselves start at offset 440. For BD-ROM, map 0 is Type 1 (physical) and map 1 is Type 2 (metadata).
|
||||
- **Terminating Descriptor (tag 8)**: signals the end of the VDS.
|
||||
|
||||
### Metadata File
|
||||
|
||||
When two partition maps exist and the second is Type 2, the metadata partition content is located by reading the Extended File Entry at the first sector of the physical partition (partition_start + 0). This ICB's allocation descriptor gives the offset and length of the metadata content within the physical partition.
|
||||
|
||||
### File Set Descriptor
|
||||
|
||||
The FSD (tag 256) sits at metadata-relative LBA 0 (the first sector of the metadata content). It contains a long allocation descriptor at offset 400 pointing to the root directory ICB. The LBA in this long_ad is at bytes 404-407.
|
||||
|
||||
### Directory Traversal
|
||||
|
||||
Each directory is an ICB (Extended File Entry, tag 266, or File Entry, tag 261) whose allocation extent points to directory data. The directory data is a sequence of File Identifier Descriptors (FIDs, tag 257):
|
||||
|
||||
| FID Field | Offset | Size | Description |
|
||||
|-----------|--------|------|-------------|
|
||||
| Tag | 0 | 2 | Always 257 |
|
||||
| File characteristics | 18 | 1 | Bit 1 = directory, bit 3 = parent |
|
||||
| L_FI (name length) | 19 | 1 | Length of filename |
|
||||
| ICB (long_ad) | 20 | 16 | Points to the entry's ICB |
|
||||
| L_IU | 36 | 2 | Implementation use length |
|
||||
| Filename | 38 + L_IU | L_FI | UDF-encoded filename |
|
||||
|
||||
FIDs are 4-byte aligned. The parser advances by `(38 + L_IU + L_FI + 3) & !3` bytes per entry.
|
||||
|
||||
### ICB Layout
|
||||
|
||||
Both File Entry (tag 261) and Extended File Entry (tag 266) share the same info_length field:
|
||||
|
||||
| Field | Tag 261 Offset | Tag 266 Offset |
|
||||
|-------|---------------|---------------|
|
||||
| info_length (u64) | 56 | 56 |
|
||||
| L_EA (u32) | 168 | 208 |
|
||||
| L_AD (u32) | 172 | 212 |
|
||||
| Allocation descriptors | 176 + L_EA | 216 + L_EA |
|
||||
|
||||
Allocation descriptors use the Short Allocation Descriptor format: 4 bytes extent length (upper 2 bits = type), 4 bytes extent position (LBA).
|
||||
|
||||
## How read_filesystem() Works
|
||||
|
||||
The `read_filesystem()` function in `src/udf.rs` follows the pointer chain above:
|
||||
|
||||
1. Reads sector 256, validates AVDP (tag 2).
|
||||
2. Scans sectors 32-63 for the Partition Descriptor and Logical Volume Descriptor.
|
||||
3. If two partition maps exist and the second is Type 2, reads the metadata file ICB at partition_start to find metadata_start.
|
||||
4. Reads the FSD at metadata_start, extracts the root directory ICB LBA.
|
||||
5. Calls `read_directory()` recursively (max depth `MAX_DIR_DEPTH` = 8) to build the full file tree.
|
||||
|
||||
Each directory read involves two sector reads: one for the ICB, then one or more for the directory data. File sizes are read from info_length in each file's ICB.
|
||||
|
||||
`read_file()` reads a file by navigating the directory tree, reading the file's ICB to get its data extent, then reading the data sector by sector from the **physical partition** (partition_start + LBA, not metadata_start).
|
||||
|
||||
## Buffered Sector Reads
|
||||
|
||||
USB optical drives have ~500ms round-trip latency per SCSI command. Since `read_filesystem()` and `read_file()` issue one SCSI READ per sector, a full disc scan can require hundreds of commands -- taking 10+ minutes on USB.
|
||||
|
||||
`Disc::scan()` wraps the drive in a `BufferedSectorReader` before reading. On a single-sector read, the buffer prefetches a batch of sectors (sized from the kernel's `max_hw_sectors_kb` for the device) and caches them. Subsequent reads to nearby LBAs return from cache with zero SCSI overhead. After parsing the UDF directory structure, the entire metadata partition is pre-read into the cache, so all ICB lookups during title scanning and encryption resolution are instant.
|
||||
|
||||
The buffer is transparent -- `read_filesystem()`, `read_file()`, and all downstream code still call `read_sectors(lba, 1, buf)` as before. The batching happens inside the `SectorSource` implementation.
|
||||
|
||||
### UDF Filename Encoding
|
||||
|
||||
UDF filenames use a compression ID as the first byte:
|
||||
- `8` = 8-bit characters (ASCII)
|
||||
- `16` = 16-bit big-endian Unicode (UTF-16BE)
|
||||
|
||||
The parser handles both encodings. All path lookups are case-insensitive.
|
||||
|
||||
## BD-ROM Directory Structure
|
||||
|
||||
A typical Blu-ray disc has this directory layout:
|
||||
|
||||
```
|
||||
/
|
||||
+-- BDMV/
|
||||
| +-- index.bdmv Disc index (title list, first play)
|
||||
| +-- MovieObject.bdmv Movie objects (navigation commands)
|
||||
| +-- PLAYLIST/
|
||||
| | +-- 00000.mpls Main movie playlist
|
||||
| | +-- 00001.mpls Director's commentary
|
||||
| | +-- ...
|
||||
| +-- CLIPINF/
|
||||
| | +-- 00001.clpi Clip info for 00001.m2ts
|
||||
| | +-- 00002.clpi
|
||||
| | +-- ...
|
||||
| +-- STREAM/
|
||||
| | +-- 00001.m2ts Transport stream (video/audio/subtitle data)
|
||||
| | +-- 00002.m2ts
|
||||
| | +-- ...
|
||||
| +-- BACKUP/ Duplicate of index, MovieObject, playlists, clip info
|
||||
|
|
||||
+-- AACS/ AACS encryption data (encrypted discs only)
|
||||
| +-- Unit_Key_RO.inf Unit key file (encrypted)
|
||||
| +-- MKB_RW.inf Media Key Block
|
||||
| +-- Content000.cer Content certificate
|
||||
| +-- DUPLICATE/ Backup copies
|
||||
|
|
||||
+-- CERTIFICATE/ BD+ certificate data (some discs)
|
||||
```
|
||||
|
||||
The parser reads from `BDMV/PLAYLIST/` and `BDMV/CLIPINF/` to discover titles and their sector layouts. The `BDMV/STREAM/` directory contains the actual transport streams but is not parsed by the UDF layer -- stream data is read by LBA directly using extents computed from CLPI EP maps.
|
||||
|
||||
## References
|
||||
|
||||
- ECMA-167: Volume and File Structure of Write-Once and Rewritable Media
|
||||
- UDF 2.50 (OSTA): Universal Disk Format Specification
|
||||
- BD-ROM Part 3: Blu-ray Disc Read-Only Format, File System Specifications
|
||||
@@ -0,0 +1,179 @@
|
||||
// Corpus title-shape dump for issue #45 fallback tuning.
|
||||
//
|
||||
// Runs the REAL offline scan (Disc::scan_image → sort_titles_by_main_feature)
|
||||
// over a directory of decrypted ISOs and emits one NDJSON line per disc:
|
||||
// every title's duration/size/clip-shape plus the rank flags the selector
|
||||
// computed (nav/authoring/composite) and which title it picked (titles[0]).
|
||||
//
|
||||
// The point: measure, across many real discs, how cleanly any candidate
|
||||
// discriminator separates the true main feature from play-all wrappers and
|
||||
// seamless-branch decoys — so the #45 fallback is derived from data, not a
|
||||
// guessed 0.85/0.90 constant.
|
||||
//
|
||||
// Usage: corpus_titledump <iso_dir> [<iso_dir>...] > corpus.ndjson
|
||||
|
||||
use libfreemkv::disc::{Disc, ScanOptions};
|
||||
use libfreemkv::io::file_sector_source::FileSectorSource;
|
||||
use libfreemkv::sector::SectorSource;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// scan_image wants the disc capacity in sectors; FileSectorSource knows it.
|
||||
|
||||
fn jstr(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len() + 2);
|
||||
o.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => o.push_str("\\\""),
|
||||
'\\' => o.push_str("\\\\"),
|
||||
'\n' => o.push_str("\\n"),
|
||||
'\r' => o.push_str("\\r"),
|
||||
'\t' => o.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => o.push(c),
|
||||
}
|
||||
}
|
||||
o.push('"');
|
||||
o
|
||||
}
|
||||
|
||||
fn dump_one(path: &Path) -> String {
|
||||
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");
|
||||
let mut reader = match FileSectorSource::open(path) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return format!(
|
||||
"{{\"iso\":{},\"error\":{}}}",
|
||||
jstr(name),
|
||||
jstr(&format!("open: {e}"))
|
||||
);
|
||||
}
|
||||
};
|
||||
let cap = reader.capacity_sectors();
|
||||
let opts = ScanOptions::default();
|
||||
let disc = match Disc::scan_image(&mut reader, cap, &opts) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
return format!(
|
||||
"{{\"iso\":{},\"error\":{}}}",
|
||||
jstr(name),
|
||||
jstr(&format!("scan: {e}"))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Recompute the rank flags the selector used so we can see WHY it ordered
|
||||
// things the way it did. nav/authoring hint aren't available offline here
|
||||
// (scan_with already applied them into titles[0]); pass None/None so the
|
||||
// flags reflect the pure composite/video/size classification, which is the
|
||||
// fallback we are tuning.
|
||||
let ranks = Disc::rank_titles(&disc.titles, None, None);
|
||||
|
||||
// pick = titles[0] (post-sort main feature). Also compute the naive
|
||||
// "longest video title" as a ground-truth proxy for single-movie discs.
|
||||
let pick_pl = disc.titles.first().map(|t| t.playlist_id).unwrap_or(0);
|
||||
let longest_video_pl = disc
|
||||
.titles
|
||||
.iter()
|
||||
.filter(|t| t.has_probable_video())
|
||||
.max_by(|a, b| a.duration_secs.partial_cmp(&b.duration_secs).unwrap())
|
||||
.map(|t| t.playlist_id);
|
||||
|
||||
let mut titles_json = String::from("[");
|
||||
for (i, t) in disc.titles.iter().enumerate() {
|
||||
if i > 0 {
|
||||
titles_json.push(',');
|
||||
}
|
||||
let r = &ranks[i];
|
||||
// Sum of the FULL durations of this title's distinct clips (each clip
|
||||
// counted once at its clip-level duration). A play-all concat wrapper's
|
||||
// title duration ≈ this sum; a seamless-branch feature's is dominated by
|
||||
// one clip. This is the structural discriminator to evaluate.
|
||||
let mut seen = std::collections::BTreeMap::<&str, f64>::new();
|
||||
for c in &t.clips {
|
||||
seen.entry(c.clip_id.as_str()).or_insert(c.duration_secs);
|
||||
}
|
||||
let sum_distinct_clip_dur: f64 = seen.values().sum();
|
||||
let max_distinct_clip_dur: f64 = seen.values().cloned().fold(0.0, f64::max);
|
||||
let n_distinct_clips = seen.len();
|
||||
let clip_ids: String = {
|
||||
let mut s = String::from("[");
|
||||
for (k, id) in seen.keys().enumerate() {
|
||||
if k > 0 {
|
||||
s.push(',');
|
||||
}
|
||||
s.push_str(&jstr(id));
|
||||
}
|
||||
s.push(']');
|
||||
s
|
||||
};
|
||||
titles_json.push_str(&format!(
|
||||
"{{\"pl\":{},\"dur\":{:.1},\"size\":{},\"clips\":{},\"distinct_clips\":{},\
|
||||
\"sum_distinct_clip_dur\":{:.1},\"max_distinct_clip_dur\":{:.1},\
|
||||
\"has_video\":{},\"has_probable_video\":{},\
|
||||
\"nav\":{},\"authoring\":{},\"composite\":{},\"clip_ids\":{}}}",
|
||||
t.playlist_id,
|
||||
t.duration_secs,
|
||||
t.size_bytes,
|
||||
t.clips.len(),
|
||||
n_distinct_clips,
|
||||
sum_distinct_clip_dur,
|
||||
max_distinct_clip_dur,
|
||||
t.has_video(),
|
||||
t.has_probable_video(),
|
||||
r.nav,
|
||||
r.authoring,
|
||||
r.composite,
|
||||
clip_ids,
|
||||
));
|
||||
}
|
||||
titles_json.push(']');
|
||||
|
||||
format!(
|
||||
"{{\"iso\":{},\"format\":\"{:?}\",\"cap\":{},\"n_titles\":{},\
|
||||
\"pick_pl\":{},\"longest_video_pl\":{},\"pick_is_longest_video\":{},\"titles\":{}}}",
|
||||
jstr(name),
|
||||
disc.format,
|
||||
cap,
|
||||
disc.titles.len(),
|
||||
pick_pl,
|
||||
longest_video_pl
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_else(|| "null".into()),
|
||||
longest_video_pl.map(|p| p == pick_pl).unwrap_or(false),
|
||||
titles_json,
|
||||
)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let dirs: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
|
||||
if dirs.is_empty() {
|
||||
eprintln!("usage: corpus_titledump <iso_dir> [<iso_dir>...]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let mut isos: Vec<PathBuf> = Vec::new();
|
||||
for d in &dirs {
|
||||
if d.is_file() {
|
||||
isos.push(d.clone());
|
||||
continue;
|
||||
}
|
||||
if let Ok(rd) = std::fs::read_dir(d) {
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.eq_ignore_ascii_case("iso"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
isos.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
isos.sort();
|
||||
eprintln!("scanning {} ISOs", isos.len());
|
||||
for (i, p) in isos.iter().enumerate() {
|
||||
eprintln!("[{}/{}] {}", i + 1, isos.len(), p.display());
|
||||
println!("{}", dump_one(p));
|
||||
}
|
||||
}
|
||||
+135
-1471
File diff suppressed because it is too large
Load Diff
@@ -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
+74
-158
@@ -1,4 +1,4 @@
|
||||
//! AACS common cryptographic primitives — [C] Chapter 2 / §3.2.2.
|
||||
//! AACS common cryptographic primitives — `[C]` Chapter 2 / §3.2.2.
|
||||
//!
|
||||
//! Source: `[C]` = AACS Introduction and Common Cryptographic Elements Book,
|
||||
//! Rev 0.953. The shared low-level building blocks — AES-128 ECB E/D, AES-G,
|
||||
@@ -10,39 +10,37 @@
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{Array, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
|
||||
|
||||
/// Fixed IV used by AACS for all AES-CBC operations. [C] §2.1.2 (default CBC IV, `iv0`).
|
||||
/// Fixed IV used by AACS for all AES-CBC operations. `[C]` §2.1.2 (default CBC IV, `iv0`).
|
||||
pub(crate) const AACS_IV: [u8; 16] = [
|
||||
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
|
||||
];
|
||||
|
||||
// Per-thread count of AES-128 key schedules built through `new_cipher`.
|
||||
// Test-only instrumentation: an AES-128 key expansion is 10 round-key
|
||||
// derivations, and the CBC helpers here run on the per-aligned-unit decrypt hot
|
||||
// path of a whole disc read, so "how many times was the schedule built for one
|
||||
// loop-invariant key" is a property worth asserting rather than reasoning about.
|
||||
// THREAD-LOCAL, not a global atomic: `cargo test` runs tests concurrently, so a
|
||||
// shared counter would see every other test's expansions. See
|
||||
// `content::tests::decrypt_bus_expands_the_read_data_key_once_per_unit`.
|
||||
// Test-only: lets tests assert the CBC decrypt hot path builds one schedule
|
||||
// per loop-invariant key. Thread-local, not atomic, since `cargo test` runs concurrently.
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
pub(crate) static KEY_EXPANSIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// 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));
|
||||
Aes128::new(&(*key).into())
|
||||
}
|
||||
|
||||
/// AES-128-ECB encrypt a single 16-byte block. [C] §2.1.1 (`AES-128E`).
|
||||
/// AES-128-ECB encrypt a single 16-byte block. `[C]` §2.1.1 (`AES-128E`).
|
||||
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(&(*key).into());
|
||||
let mut block: Array<u8, _> = (*data).into();
|
||||
@@ -52,9 +50,16 @@ pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
out
|
||||
}
|
||||
|
||||
/// AES-128-ECB decrypt a single 16-byte block. [C] §2.1.1 (`AES-128D`).
|
||||
/// 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];
|
||||
@@ -62,17 +67,8 @@ pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
out
|
||||
}
|
||||
|
||||
/// AES-128-CBC ENCRYPT in place under the fixed [`AACS_IV`] — the forward
|
||||
/// direction of [`aes_cbc_decrypt`], and its exact inverse. [C] §2.1.2
|
||||
/// (`AES-128CBCE`).
|
||||
///
|
||||
/// Precondition: `data.len()` is a multiple of 16; the assert
|
||||
/// documents/enforces that contract.
|
||||
///
|
||||
/// Constructs the cipher ONCE for the whole slice. Driving this from the
|
||||
/// single-block [`aes_ecb_encrypt`] instead rebuilds the AES key schedule per
|
||||
/// 16-byte block, which for a 6144-byte aligned unit is 383 redundant key
|
||||
/// expansions.
|
||||
// AES-128-CBC encrypt in place under AACS_IV. Forward direction / exact inverse of
|
||||
// aes_cbc_decrypt (`[C]` §2.1.2, AES-128CBCE).
|
||||
pub(crate) fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
debug_assert!(
|
||||
data.len().is_multiple_of(16),
|
||||
@@ -95,21 +91,7 @@ pub(crate) fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// AES-128-CBC DECRYPT in-place with the fixed AACS IV. [C] §2.1.2
|
||||
/// (`AES-128CBCD`).
|
||||
///
|
||||
/// Precondition: `data.len()` is a multiple of 16. Any trailing partial
|
||||
/// block is silently ignored; all callers pass aligned regions (6128 and
|
||||
/// 2032 bytes), and the assert documents/enforces that contract.
|
||||
///
|
||||
/// (This doc block was orphaned onto `aes_cbc_encrypt` above when that function
|
||||
/// was inserted directly after it with no separating blank line, so rustdoc
|
||||
/// rendered the crate's only forward-direction AACS primitive as "decrypt" and
|
||||
/// cited the spec's DECRYPT clause for it, while this function had no doc at
|
||||
/// all. `encrypt_unit_is_the_exact_inverse_of_decrypt_unit` in `content.rs` pins
|
||||
/// the directions behaviourally so a maintainer 'fixing' the contradiction by
|
||||
/// swapping the two bodies fails the suite instead of shipping a second
|
||||
/// decryptor behind an already-set encrypted flag.)
|
||||
// AES-128-CBC decrypt in-place under AACS_IV (`[C]` §2.1.2, AES-128CBCD).
|
||||
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
debug_assert!(
|
||||
data.len().is_multiple_of(16),
|
||||
@@ -118,46 +100,57 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
cbc_decrypt_blocks(&new_cipher(key), data);
|
||||
}
|
||||
|
||||
/// AES-128-CBC decrypt in place under the fixed [`AACS_IV`] with an ALREADY
|
||||
/// EXPANDED key schedule.
|
||||
///
|
||||
/// Split out of [`aes_cbc_decrypt`] so a caller that decrypts several regions
|
||||
/// under one loop-invariant key expands the schedule once. `decrypt_bus`
|
||||
/// ([`super::content::decrypt_bus`]) is that caller: bus encryption
|
||||
/// ([C] §4.2 / the AACS 2.0 Read Data Key) covers bytes 16..2048 of EVERY
|
||||
/// 2048-byte sector, so a 6144-byte aligned unit is three regions under one
|
||||
/// `read_data_key` — three key schedules where one suffices, on the per-unit
|
||||
/// decrypt hot path of a whole 90 GB read.
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2. [C] §2.1.3 (note: uses AES-128**D**).
|
||||
///
|
||||
/// The Media Key Variant chain uses AES-G to derive both the variant
|
||||
/// number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key
|
||||
/// (`Kvu = AES-G(Km, VID)`). See [`super::derive::derive_vuk`] for the
|
||||
/// classical VUK form — the math is identical, this exposes it as a
|
||||
/// neutral primitive for the variant chain.
|
||||
// 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];
|
||||
@@ -165,17 +158,13 @@ pub(crate) fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
|
||||
out
|
||||
}
|
||||
|
||||
/// AACS-G3 seed constant (`s0`). [C] §3.2.2.
|
||||
/// AACS-G3 seed constant (`s0`). `[C]` §3.2.2.
|
||||
pub(crate) const AESG3_SEED: [u8; 16] = [
|
||||
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
|
||||
];
|
||||
|
||||
/// AACS-G3: derive a subkey from a parent key. [C] §3.2.2 (Triple AES Generator:
|
||||
/// left=`D(k,s0)⊕s0` inc 0, pk=`D(k,s0+1)⊕(s0+1)` inc 1, right=`D(k,s0+2)⊕(s0+2)` inc 2).
|
||||
/// seed[15] += inc, then AES-DEC(key, seed) XOR seed.
|
||||
///
|
||||
/// Shared with [`super::variant`] (its variant chain runs the same SD
|
||||
/// tree); a single definition keeps the two walks byte-identical.
|
||||
// AACS-G3: derive a subkey from a parent key (`[C]` §3.2.2, Triple AES Generator). Shared with
|
||||
// super::variant so both SD-tree walks stay byte-identical.
|
||||
pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
|
||||
let mut seed = AESG3_SEED;
|
||||
seed[15] = seed[15].wrapping_add(inc);
|
||||
@@ -187,82 +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;
|
||||
|
||||
/// The AACS-G3 seed `s0`, transcribed independently from [C] §3.2.2 rather
|
||||
/// than read from [`AESG3_SEED`] — a test that sourced the seed from the
|
||||
/// production constant would assert that constant against itself and would
|
||||
/// still pass if it were edited.
|
||||
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 node function of the AACS subset-difference tree: every
|
||||
/// Processing Key the DK walk produces (`aesg3(node_key, 1)`) and every
|
||||
/// descent step (`aesg3(., 0)` / `aesg3(., 2)`) is one call. A body that
|
||||
/// returned a fixed block would make every device key in the crate derive
|
||||
/// the SAME Processing Key, and a `^` that became `|` or `&` would derive a
|
||||
/// wrong-but-plausible one — in both cases the MKB walk simply stops
|
||||
/// finding Media Keys, with no error to say why.
|
||||
///
|
||||
/// Pinned through the spec relation rather than a re-implementation:
|
||||
/// [C] §3.2.2 defines `AES-G3` as `AES-128D(k, s) XOR s` for
|
||||
/// `s = s0 + inc` (added into the last seed byte), so applying the
|
||||
/// FORWARD primitive [`aes_ecb_encrypt`] — a different function from the
|
||||
/// one under test — to `aesg3(k, inc) XOR s` must reproduce `s` exactly.
|
||||
#[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 ([C] §3.2.2: left = inc 0, the
|
||||
/// Processing Key = inc 1, right = inc 2) are the two child node keys and
|
||||
/// the Processing Key of ONE tree node. They must be three different keys —
|
||||
/// if `inc` were ignored, a descent would revisit its own parent and the
|
||||
/// walk would derive the same key at every level of the tree.
|
||||
#[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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+194
-1321
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
+10
-133
@@ -1,17 +1,10 @@
|
||||
//! FMTS index selection — the pure decode-time decision for a 2.1 disc.
|
||||
//!
|
||||
//! A 2.1 disc resolves to exactly one forensic index (1..=32) for a given
|
||||
//! rip. `IndividualSegment.tbl` tags each forensic segment with an index (see
|
||||
//! [`super::segment`]); the decode keeps the segments matching our index,
|
||||
//! drops the other 31, and treats everything outside a segment as ordinary
|
||||
//! (index-0) content. This module owns that classification and nothing else —
|
||||
//! no I/O, no keys, no cipher — so it is fully testable in isolation. The
|
||||
//! decrypt pipeline consumes the [`UnitDisposition`] it returns.
|
||||
//!
|
||||
//! Where the resolved index comes from is a separate concern
|
||||
//! ([`resolve_disc_index`]): today it is read off the index keys the key
|
||||
//! source handed us; when Processing Keys are available it will come from the
|
||||
//! VK derivation instead. Either way the disposition logic below is identical.
|
||||
//! A 2.1 disc resolves to exactly one forensic index (1..=32); `IndividualSegment.tbl`
|
||||
//! 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.
|
||||
//! 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;
|
||||
@@ -63,11 +56,9 @@ pub fn unit_disposition(
|
||||
None => UnitDisposition::Default,
|
||||
// In a forensic segment → decide by whether it is our index.
|
||||
Some(seg) => {
|
||||
// `seg.index` is an untrusted u16 from IndividualSegment.tbl; a real
|
||||
// forensic index is 1..=32. Compare in u16 space so a corrupt/crafted
|
||||
// index above 255 can't truncate into a valid u8 and alias our index.
|
||||
// The disposition carries a u8 for diagnostics (saturated — an
|
||||
// out-of-range index is never ours anyway).
|
||||
// `seg.index` is untrusted (real range 1..=32); compare in u16 space so a
|
||||
// crafted index above 255 can't truncate into a valid u8 and alias ours.
|
||||
// Diagnostics carry a saturated u8 since out-of-range is never ours.
|
||||
let seg_index = seg.index;
|
||||
let diag = seg_index.min(u8::MAX as u16) as u8;
|
||||
match disc_index {
|
||||
@@ -80,119 +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() {
|
||||
// A crafted/corrupt segment index of 288 (0x0120) truncates to 32 in a
|
||||
// u8. With our disc index resolved as 32, the old `seg.index as u8`
|
||||
// compare would alias it to OUR index and decrypt with the wrong key.
|
||||
// The u16 compare must instead classify it as foreign.
|
||||
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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
+94
-563
@@ -22,17 +22,11 @@ pub struct UnitKeyFile {
|
||||
pub title_cps_unit: Vec<u16>,
|
||||
}
|
||||
|
||||
/// Redacting `Debug`, per the policy `aacs::types` documents: this struct holds
|
||||
/// the disc's ENCRYPTED CPS unit keys — exactly the material a keydb entry stores
|
||||
/// — plus the disc hash they are looked up by. A derived `Debug` printed every key
|
||||
/// byte verbatim, so any `{:?}` (a downstream crate, an `assert_eq!` failure
|
||||
/// message, a future `tracing::debug!` in this module) leaked them. Only
|
||||
/// non-secret shape is printed. Guarded by `unit_key_file_debug_is_redacted`.
|
||||
// Hand-written Debug that redacts the ENCRYPTED CPS unit keys a derive would print verbatim.
|
||||
impl std::fmt::Debug for UnitKeyFile {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("UnitKeyFile")
|
||||
// The disc hash is the public keydb lookup key, printed as hex the
|
||||
// same way `DiscEntry` prints its own — never as raw bytes.
|
||||
// Disc hash is the public keydb lookup key, printed as hex.
|
||||
.field("disc_hash", &disc_hash_hex(&self.disc_hash))
|
||||
.field("app_type", &self.app_type)
|
||||
.field("num_bdmv_dir", &self.num_bdmv_dir)
|
||||
@@ -67,6 +61,7 @@ pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
|
||||
/// Parse Unit_Key_RO.inf from raw bytes.
|
||||
///
|
||||
/// Format (from AACS spec):
|
||||
/// ```text
|
||||
/// [0..4] BE32: offset to key storage area (uk_pos)
|
||||
/// [16] app_type (1 = BD-ROM)
|
||||
/// [17] num_bdmv_dir
|
||||
@@ -81,6 +76,7 @@ pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
|
||||
/// [uk_pos+48..] encrypted keys, 16 bytes each
|
||||
/// AACS 1.0: 48-byte stride
|
||||
/// AACS 2.0 / 2.1: 64-byte stride (48 + 16 extra)
|
||||
/// ```
|
||||
pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
|
||||
if data.len() < 20 {
|
||||
return None;
|
||||
@@ -95,7 +91,7 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
|
||||
|
||||
// Key storage offset
|
||||
let uk_pos = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||
if uk_pos + 2 > data.len() {
|
||||
if uk_pos.checked_add(2)? > data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -117,13 +113,13 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
|
||||
let stride = version.unit_key_stride();
|
||||
|
||||
// Validate size
|
||||
let keys_start = uk_pos + 48; // first key at uk_pos + 48
|
||||
if keys_start + 16 > data.len() {
|
||||
let keys_start = uk_pos.checked_add(48)?; // first key at uk_pos + 48
|
||||
if keys_start.checked_add(16)? > data.len() {
|
||||
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() {
|
||||
@@ -135,18 +131,16 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
|
||||
pos += stride;
|
||||
}
|
||||
|
||||
// The loop above `break`s if the buffer runs out mid-key. A short list
|
||||
// means the .inf is malformed/truncated — reject it rather than silently
|
||||
// accepting fewer keys than the header declared, which would later map
|
||||
// title CPS units to nonexistent keys.
|
||||
// The loop `break`s if the buffer runs out mid-key; a short list means the .inf
|
||||
// is malformed/truncated — reject rather than silently mapping title CPS units
|
||||
// to fewer keys than the header declared.
|
||||
if encrypted_keys.len() != num_uk {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Title → CPS unit mapping (AACS Unit_Key_RO format): each on-disc CPS
|
||||
// value is in `1..=num_uk` (else zeroes it) and converts the 1-based on-disc
|
||||
// index to a 0-based key index. We mirror that so the stored value is a safe,
|
||||
// ready-to-use key index rather than a raw 1-based number.
|
||||
// Title → CPS unit mapping (AACS Unit_Key_RO format): validates the on-disc
|
||||
// CPS value is in `1..=num_uk` (else zeroes it) and converts it from 1-based
|
||||
// to a safe, ready-to-use 0-based key index.
|
||||
let to_key_idx = |cps: u16| -> u16 {
|
||||
if cps >= 1 && cps as usize <= num_uk {
|
||||
cps - 1
|
||||
@@ -165,10 +159,14 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
|
||||
|
||||
for i in 0..num_titles {
|
||||
let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit
|
||||
if off + 2 <= data.len() {
|
||||
let cps = u16::from_be_bytes([data[off], data[off + 1]]);
|
||||
title_cps_unit.push(to_key_idx(cps));
|
||||
// A declared title whose entry runs past the buffer means the .inf
|
||||
// is truncated — reject rather than silently returning a short
|
||||
// title→CPS map (mirrors the key-list truncation check above).
|
||||
if off + 2 > data.len() {
|
||||
return None;
|
||||
}
|
||||
let cps = u16::from_be_bytes([data[off], data[off + 1]]);
|
||||
title_cps_unit.push(to_key_idx(cps));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,13 +199,10 @@ const VTKF_AV_FLG: u8 = 0x80;
|
||||
|
||||
/// Parse an HD DVD `VTKF%%%.AACS` into the SAME [`UnitKeyFile`] a BD/UHD
|
||||
/// `Unit_Key_RO.inf` yields — so the shared AACS crypto (`derive_unit_keys` →
|
||||
/// `decrypt_unit_key(vuk, …)`) unwraps HD DVD title keys with no change. Only
|
||||
/// the on-disc CONTAINER differs between BD and HD DVD; the title-key unwrap is
|
||||
/// the identical AES-128 VUK step (`Kt = AES-128D(Kvu, Kte)`).
|
||||
/// `decrypt_unit_key(vuk, …)`) unwraps HD DVD title keys with no change.
|
||||
///
|
||||
/// Layout — AACS "HD DVD and DVD Pre-recorded Book" Table 3-8, a fixed
|
||||
/// 2480-byte file, verified byte-exact against real discs (Freedom `VTKF090`,
|
||||
/// Dukes of Hazzard `VTKF000`):
|
||||
/// 2480-byte file:
|
||||
/// ```text
|
||||
/// [0x00..0x0C] magic "DVD_HD_V_TKF"
|
||||
/// [0x0C..0x10] BE32 HD_VTKF_SIZE (2480)
|
||||
@@ -219,18 +214,12 @@ const VTKF_AV_FLG: u8 = 0x80;
|
||||
/// (pre-recorded discs fill the binding MAC with 0xFF)
|
||||
/// [0x9A0..2480] 16-byte TKF MAC (CMAC keyed by Kvu — NOT a key)
|
||||
/// ```
|
||||
/// The slot index (1-based) is the CPS unit number, so an absent slot is
|
||||
/// SKIPPED (not a terminator) — collapsing gaps would renumber later keys and
|
||||
/// hand the wrong title key to CPS unit N+1. The title→CPS mapping is
|
||||
/// playlist-driven (`VPLST%%%.XPL`) and owned by the HD DVD enumerator, so
|
||||
/// `title_cps_unit` is left empty here.
|
||||
///
|
||||
/// The prior parser used a 32-byte stride (a 12-byte pad instead of the 16-byte
|
||||
/// binding MAC). That reads entry #1 correctly but drifts +4 bytes per entry
|
||||
/// after it, so it only decrypted single-CPS-unit discs; every multi-key VTKF
|
||||
/// (Freedom, Harry Potter) yielded garbage keys for CPS unit ≥2.
|
||||
/// 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
|
||||
@@ -241,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 {
|
||||
@@ -288,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,
|
||||
@@ -309,53 +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];
|
||||
if session
|
||||
.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
|
||||
.is_ok()
|
||||
{
|
||||
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
||||
if len > 2 && len - 2 <= 32768 {
|
||||
mkb.extend_from_slice(&buf[4..4 + len - 2]);
|
||||
}
|
||||
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)
|
||||
@@ -370,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,
|
||||
}
|
||||
@@ -382,14 +373,13 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Content Certificate layout (per the AACS content-cert format):
|
||||
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
|
||||
// [1] bit7 bus_encryption_enabled_flag (`p[1] >> 7`)
|
||||
// [14..20] cc_id (6 bytes) (`p + 14`)
|
||||
let version = if data[0] == 0x00 {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
AacsVersion::V20
|
||||
// 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
|
||||
@@ -406,476 +396,17 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod vtkf_tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a synthetic `VTKF%%%.AACS` matching the real on-disc layout (AACS
|
||||
/// HD DVD Book Table 3-8, verified against Freedom `VTKF090` and Dukes
|
||||
/// `VTKF000`): magic, BE32 size, playlist name, reserved to 0x80, then 64
|
||||
/// entry slots of 36 bytes (the first `keys.len()` present with `AV_FLG`
|
||||
/// set, the rest empty), a reserved gap, and the 16-byte trailing TKF MAC.
|
||||
fn synth_vtkf(keys: &[[u8; 16]]) -> Vec<u8> {
|
||||
const FILE_LEN: usize = 2480;
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(VTKF_MAGIC); // 0x00
|
||||
v.extend_from_slice(&(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 — the empty slots and the trailing
|
||||
// 16-byte TKF MAC are NOT mistaken for keys. Critically, k2/k3 are read
|
||||
// at the 36-byte stride (offsets 0xA4, 0xC8); the old 32-byte stride
|
||||
// misread them from inside the previous entry's binding MAC.
|
||||
assert_eq!(ukf.encrypted_keys.len(), 3);
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys[0],
|
||||
(1, k1),
|
||||
"CPS units = 1-based slot index"
|
||||
);
|
||||
assert_eq!(ukf.encrypted_keys[1], (2, k2));
|
||||
assert_eq!(ukf.encrypted_keys[2], (3, k3));
|
||||
assert_eq!(ukf.version, AacsVersion::V10, "HD DVD is AACS 1.0");
|
||||
// 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));
|
||||
}
|
||||
|
||||
/// `UnitKeyFile` holds the disc's ENCRYPTED CPS unit keys. A derived `Debug`
|
||||
/// printed every byte; the hand-written impl must not. Sentinel key byte
|
||||
/// 0xD5 = decimal 213 (a derived `Debug` renders `[u8; 16]` in decimal), the
|
||||
/// same probe `aacs::types::redaction_tests` uses. Mutation guard: putting
|
||||
/// `#[derive(Debug)]` back fails this.
|
||||
#[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};
|
||||
#[path = "inf_read_mkb_tests.rs"]
|
||||
mod read_mkb_tests;
|
||||
|
||||
/// 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>>,
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[path = "inf_unit_key_ro_tests.rs"]
|
||||
mod unit_key_ro_tests;
|
||||
|
||||
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],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `read_mkb_from_drive` is the in-drive MKB source: every AACS derivation
|
||||
/// downstream (`mkb_find_mk_dv`, the subset-difference walk, the whole
|
||||
/// Media Key ladder) consumes exactly what it returns. An empty return is
|
||||
/// not a benign "no MKB" — it is a total read failure reported as success,
|
||||
/// and every derivation then fails with a key-not-found code that points
|
||||
/// the operator at their keydb rather than at the drive.
|
||||
///
|
||||
/// This pins the CONTENT: the concatenated payload of all packs, 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The CDB is what the drive actually acts on, and every byte of it is
|
||||
/// load-bearing: a wrong format code returns a different disc structure
|
||||
/// entirely, and a wrong allocation length truncates the pack. The existing
|
||||
/// test above pins the opcode, the format code and the pack number; this
|
||||
/// pins the WHOLE 12-byte CDB, so no field can drift unnoticed.
|
||||
///
|
||||
/// Expected layout (MMC-6 READ DISC STRUCTURE, AACS MKB format):
|
||||
/// `[0]` opcode, `[1]` media type 0x01, `[2..6]` address = pack number
|
||||
/// (BE32), `[6]` layer 0, `[7]` format 0x83, `[8..10]` allocation length
|
||||
/// BE16 = 32772 = `0x80 0x04`, `[10..12]` reserved/control.
|
||||
#[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 pack payload filling the FULL 32768-byte window must come back whole.
|
||||
/// The `len > 0 && len <= 32768` bound is what stands between a maximal
|
||||
/// pack and a silently dropped one, and the small payloads used elsewhere
|
||||
/// in this module never reach it.
|
||||
#[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 that DECLARES more payload than it returned must not be
|
||||
/// believed. The BE16 length in the response header is drive-supplied data:
|
||||
/// a firmware bug, a short transfer, or a hostile device can put a value in
|
||||
/// it that runs past the 32772-byte buffer. Copying `len` bytes on that word
|
||||
/// alone panics the rip thread mid-scan.
|
||||
///
|
||||
/// Both the first-pack read and the per-pack loop carry the same bound, so
|
||||
/// both are exercised here: the over-declared pack contributes nothing and
|
||||
/// the honest pack still comes through.
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[path = "inf_content_cert_tests.rs"]
|
||||
mod content_cert_tests;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
+52
-232
@@ -1,4 +1,4 @@
|
||||
//! AACS Media Key Block — [C] Chapter 3.
|
||||
//! AACS Media Key Block — `[C]` Chapter 3.
|
||||
//!
|
||||
//! The MKB record format (framing walker, the `MkbRecord` view, record-body
|
||||
//! finders), the MKBType / AACS-generation classification, and MKB-file
|
||||
@@ -7,19 +7,18 @@
|
||||
//! still live side by side pending a follow-up that collapses them.
|
||||
|
||||
// ── MKB record types ([C] Chapter 3) ──────────────────────────────────────
|
||||
// The ONE canonical set. Every record-type comparison in the `aacs` module
|
||||
// references these, so a type byte is never a bare literal scattered across
|
||||
// files (the `0x0c` variant-data record in particular used to appear in several
|
||||
// hand-rolled forms).
|
||||
// The ONE canonical set; every record-type comparison in `aacs` references these
|
||||
// so a type byte is never a bare literal scattered across files.
|
||||
|
||||
/// 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;
|
||||
@@ -62,14 +61,8 @@ pub fn walk_mkb(mkb: &[u8]) -> Vec<MkbRecord> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// THE single MKB record-framing walker: yields `(offset, rec_type, rec_len)`
|
||||
/// for each record — a 4-byte header (type byte + big-endian 24-bit length)
|
||||
/// then the body — stopping at the `00 000000` end marker or a
|
||||
/// malformed/out-of-bounds length. Lazy (no body clone), so a find-one-record
|
||||
/// caller never materialises the multi-MB cvalue table. [`walk_mkb`] and every
|
||||
/// MKB record walk in `aacs::resolve`/`aacs::derive` are built on this, so the framing rules — and
|
||||
/// any future fix to them — live in exactly one place (they had drifted across
|
||||
/// six hand-rolled copies).
|
||||
// THE single MKB record-framing walker: 4-byte header (type + BE24 length) then body, stopping
|
||||
// at the `00 000000` end marker. `walk_mkb` and every other MKB walk are built on this.
|
||||
pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)> + '_ {
|
||||
let mut pos = 0usize;
|
||||
std::iter::from_fn(move || {
|
||||
@@ -101,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.
|
||||
@@ -125,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 {
|
||||
@@ -156,8 +148,31 @@ 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).
|
||||
/// 0x81: `[C]` §3.2.5.1.4. 0x86 (AACS 2.x): `[RE]` — not in the public spec (from real 2.x MKBs).
|
||||
pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
// Verify-Media-Key record (0x81 for AACS 1.0, 0x86 for AACS 2.x): mk_dv is
|
||||
// the 16 bytes at record offset 4 (body offset 0). Needs rec_len >= 20.
|
||||
@@ -189,41 +204,30 @@ 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.
|
||||
/// 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.
|
||||
///
|
||||
/// The cvalue table is record type `0x05` (Media Key Data) on BOTH AACS
|
||||
/// 1.0 and AACS 2.x MKBs — its 16-byte cvalue entries are 1:1 with the
|
||||
/// 5-byte Subset-Difference index entries in record `0x04` — the standard AACS
|
||||
/// MKB layout (`0x05` cvalues 1:1 with the `0x04` subset-difference index).
|
||||
///
|
||||
/// On AACS 2.x in-drive UHD MKBs the `0x05` table is large (the full
|
||||
/// subset-difference cvalue set: ~181k entries on a retail MKB, 1:1 with
|
||||
/// the giant `0x04` index), while record `0x07` (Explicit
|
||||
/// Subset-Difference Record) is a much smaller structure (~96 entries) and
|
||||
/// is NOT the cvalue table. An earlier version of this function preferred
|
||||
/// `0x07`, which under-tested the Subset-Difference walk on UHD discs and
|
||||
/// prevented the DK→walk path from ever finding the matching uv. The
|
||||
/// selection MUST therefore be `0x05`-first; `0x07` is only a fallback for
|
||||
/// malformed/legacy MKBs that somehow lack a `0x05` record.
|
||||
// Find the Media Key Data Record (cvalues table) in an MKB. `[C]` §3.2.4 / §3.2.5.1.7. 0x05 is
|
||||
// the pre-recorded / Category C table (libaacs `mkb_cvalues`); a Class II MKB carries 0x0c
|
||||
// instead (see `check_mkb_class`). 0x07 is the Subset-Difference Index.
|
||||
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
|
||||
@@ -331,7 +335,7 @@ impl MkbType {
|
||||
}
|
||||
|
||||
/// The raw 32-bit MKBType field from the Type-and-Version record (0x10), bytes
|
||||
/// 4-7. `None` if no 0x10 record is present. [C] §3.2.5.1.1 Table 3-2.
|
||||
/// 4-7. `None` if no 0x10 record is present. `[C]` §3.2.5.1.1 Table 3-2.
|
||||
pub fn mkb_type_raw(mkb: &[u8]) -> Option<u32> {
|
||||
// Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body
|
||||
// offset 0). Needs rec_len >= 8 (4 header + 4 type).
|
||||
@@ -352,189 +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);
|
||||
}
|
||||
|
||||
#[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 ────────────────────────────────
|
||||
|
||||
/// The record length is a big-endian **24-bit** field, so the high byte
|
||||
/// carries lengths of 64 KiB and up. The MKB records that matter most are
|
||||
/// exactly that size — a real UHD cvalue table is `46_101 * 16` bytes and a
|
||||
/// `0x2d` variant record is ~92 KiB — so a walker that dropped the high
|
||||
/// byte would mis-frame every record of a real MKB from the first big one
|
||||
/// onward, and every downstream key lookup would read the wrong bytes.
|
||||
///
|
||||
/// (The pre-existing high-byte test used total length `0x0110`, whose high
|
||||
/// byte is ZERO — it exercised the middle byte only. This one puts a
|
||||
/// non-zero value in the high byte.)
|
||||
#[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 (the minimum the
|
||||
/// walker accepts), including one sitting at the very end of the buffer
|
||||
/// with no bytes after it. Rejecting either — the `pos + 4` bound or the
|
||||
/// `rec_len < 4` floor being off by one — silently drops the MKB's last
|
||||
/// record, and "the record isn't there" is indistinguishable from "the disc
|
||||
/// doesn't carry it".
|
||||
#[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 record whose TYPE
|
||||
/// happens to be `0x00` but which declares a real length is a record, not
|
||||
/// the end of the MKB — stopping there would truncate everything after it,
|
||||
/// including the cvalue and verify records the key derivation needs.
|
||||
#[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 ([C] §3.2.5.1.1
|
||||
/// Table 3-2), including a value this build does not recognise — the caller
|
||||
/// uses it to tell "unknown MKB generation" from "no Type record at all".
|
||||
/// All four bytes must come from the record body; reading any of them from
|
||||
/// the wrong offset yields a type that silently classifies as a different
|
||||
/// AACS generation.
|
||||
///
|
||||
/// The recognised constants all share bytes with the `0x10` record-type
|
||||
/// header byte (e.g. `MKB_21_CATEGORY_C` is `48 15 10 03`), so this uses a
|
||||
/// value with four distinct bytes, none of them `0x10`.
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
#[path = "mkb_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+56
-315
@@ -1,68 +1,34 @@
|
||||
//! AACS decryption — Volume Unique Key lookup and title key derivation.
|
||||
//!
|
||||
//! Two paths:
|
||||
//! 1. VUK lookup: disc_hash → KEYDB.cfg → VUK (fast, 99% of discs)
|
||||
//! 2. Full handshake: device_keys + MKB → Media Key → + Volume ID → VUK (fallback)
|
||||
//!
|
||||
//! KEYDB.cfg format:
|
||||
//! | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...
|
||||
//! | PK | 0x...
|
||||
//! | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
|
||||
//! | HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
|
||||
//! 0x<disc_hash> = <title> | D | <date> | M | 0x<media_key> | I | 0x<disc_id> | V | 0x<vuk> | U | <unit_keys>
|
||||
//!
|
||||
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
|
||||
//! Title keys decrypt m2ts stream content (AES-128-CBC).
|
||||
//!
|
||||
//! ## Spec provenance
|
||||
//!
|
||||
//! The crypto below carries `[TAG] §x.y` citations back to the published AACS
|
||||
//! specification (Final Rev 0.953), so each primitive links to the section it
|
||||
//! implements:
|
||||
//! - `[C]` — AACS Introduction and Common Cryptographic Elements Book (primitives, MKB/key-management).
|
||||
//! - `[PR]` — AACS Pre-recorded Video Book (Volume/Title Key layer).
|
||||
//! - `[BD]` — AACS Blu-ray Disc Pre-recorded Book (CPS Unit Key, Aligned Unit, Block Key).
|
||||
//! - `[RE]` — reverse-engineered from real discs, cited only where the public
|
||||
//! spec is silent (the `0x86` verify record and the Category-C MKB type values).
|
||||
//! 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.
|
||||
///
|
||||
/// BD and UHD keep their key material under a fixed `/AACS/…` tree, so those
|
||||
/// paths are constants. HD DVD keeps the equivalents in a reserved root
|
||||
/// directory whose NAME is authoring-house-specific — observed `ANY!` (Dukes
|
||||
/// of Hazzard) and `AAC!` (Freedom / Memory-Tech), each with a `<name>!_BAK`
|
||||
/// mirror — and whose title-key file is NOT always `VTKF000.AACS` (Freedom
|
||||
/// ships `VTKF090.AACS` + `VTKF100.AACS`). So the HD DVD files are DISCOVERED
|
||||
/// from the parsed UDF tree ([`find_hddvd_aacs_dir`] + [`role_paths`]), never
|
||||
/// hardcoded.
|
||||
///
|
||||
/// Each key ROLE ([`AacsRole`]) resolves to an ordered candidate list — the
|
||||
/// BD/UHD constants first, then whatever the HD DVD directory actually holds —
|
||||
/// which every reader walks with [`read_first`], first-that-reads. No reader
|
||||
/// ever branches on disc type: a BD/UHD disc has the `/AACS/` files so those
|
||||
/// win; an HD DVD has none of them, so it falls through to the discovered
|
||||
/// entries. Centralised so `resolve_vid_only`, `read_aacs_inputs`,
|
||||
/// `read_mkb_content`, and `read_aacs_version` can never silently diverge the
|
||||
/// disc_hash / MKB / VID that another reader feeds a key service.
|
||||
// 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";
|
||||
@@ -74,42 +40,38 @@ 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, NOT by a hardcoded name: the root child directory
|
||||
/// whose name ends in `!` (so the `<name>!_BAK` backup mirror, which also ends
|
||||
/// in a non-`!` char, is not mistaken for it) and which contains `MKBROM.AACS`.
|
||||
/// Observed real names: `ANY!` (Dukes of Hazzard), `AAC!` (Freedom). A BD/UHD
|
||||
/// disc has no such directory → `None`.
|
||||
// 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 [`AacsRole`]: the fixed BD/UHD
|
||||
/// `/AACS/…` paths first, then the actual HD DVD files discovered in the disc's
|
||||
/// AACS directory (see [`find_hddvd_aacs_dir`]). A disc has only one family, so
|
||||
/// the other family's entries simply never read.
|
||||
///
|
||||
/// For [`AacsRole::UnitKey`] every `VTKF*.AACS` in the directory is appended in
|
||||
/// sorted name order — a disc may carry more than one variant (Freedom:
|
||||
/// `VTKF090` + `VTKF100`), not just `VTKF000`.
|
||||
// Ordered candidate paths for an AACS key role: fixed BD/UHD `/AACS/…` paths
|
||||
// first, then discovered HD DVD files (see `find_hddvd_aacs_dir`). UnitKey
|
||||
// appends every VTKF*.AACS found, sorted, since a disc may ship >1 variant.
|
||||
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()
|
||||
@@ -122,18 +84,9 @@ pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec<String>
|
||||
AacsRole::Mkb => v.push(format!("/{d}/MKBROM.AACS")),
|
||||
AacsRole::ContentCert => v.push(format!("/{d}/CONTENT_CERT.AACS")),
|
||||
AacsRole::UnitKey => {
|
||||
// Glob VTKF*.AACS — the title-key filename is not fixed at
|
||||
// VTKF000 (Freedom ships VTKF090 + VTKF100). Sorted for a
|
||||
// deterministic try order.
|
||||
//
|
||||
// Each VTKF%%%.AACS is bound to ONE playlist (VPLST%%%.XPL): the
|
||||
// TKF's 12-byte PLAYLIST_NAME field (bytes 0x10..0x1C) names the
|
||||
// playlist whose Title Keys it carries, and keys from a TKF whose
|
||||
// name does not match the title's playlist must not be used. The
|
||||
// caller resolves this by trying candidates in sorted order and
|
||||
// decrypting with the one whose keys verify — correct for a
|
||||
// single-playlist disc; a name-matched selection keyed on the
|
||||
// active playlist is the precise form for multi-playlist discs.
|
||||
// Glob VTKF*.AACS (not fixed at VTKF000; Freedom ships VTKF090 +
|
||||
// VTKF100), sorted for a deterministic try order. Each is bound to
|
||||
// ONE playlist; caller tries in order, uses whichever's keys verify.
|
||||
let mut names: Vec<&str> = dir
|
||||
.entries
|
||||
.iter()
|
||||
@@ -152,254 +105,42 @@ pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec<String>
|
||||
v
|
||||
}
|
||||
|
||||
/// Walk an AACS role's candidate paths (from [`role_paths`]) and return the
|
||||
/// first that reads.
|
||||
/// Walk an AACS role's candidate paths, returning the first that reads.
|
||||
///
|
||||
/// `read` performs the actual per-path read (full file or bounded prefix), so
|
||||
/// callers share the same first-present walk regardless of read style. Returns
|
||||
/// [`Error::AacsNoKeys`] if no candidate is present. Generic over the path
|
||||
/// element (`&str` or owned `String`) so it accepts the `Vec<String>` that
|
||||
/// [`role_paths`] builds from the discovered HD DVD directory.
|
||||
/// AACS ships a `/AACS/DUPLICATE/` copy of every managed file so a bad primary
|
||||
/// read can fall through to the backup. A missing candidate (`UdfNotFound`) and a
|
||||
/// failed read (`DiscRead`) are both retriable: the walk moves on rather than
|
||||
/// aborting. After all candidates are tried, a remembered `DiscRead` propagates
|
||||
/// (more informative than `AacsNoKeys`); if every candidate was merely absent, the
|
||||
/// result is `AacsNoKeys`. Any other error is a hard failure (parse, corruption)
|
||||
/// and propagates immediately.
|
||||
pub(crate) fn read_first<S, F>(candidates: &[S], mut read: F) -> crate::error::Result<Vec<u8>>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
F: FnMut(&str) -> crate::error::Result<Vec<u8>>,
|
||||
{
|
||||
let mut deferred: Option<crate::error::Error> = None;
|
||||
for path in candidates {
|
||||
if let Ok(buf) = read(path.as_ref()) {
|
||||
return Ok(buf);
|
||||
match read(path.as_ref()) {
|
||||
Ok(buf) => return Ok(buf),
|
||||
Err(crate::error::Error::UdfNotFound { .. }) => continue,
|
||||
Err(e @ crate::error::Error::DiscRead { .. }) => {
|
||||
deferred = Some(e);
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(crate::error::Error::AacsNoKeys)
|
||||
Err(deferred.unwrap_or(crate::error::Error::AacsNoKeys))
|
||||
}
|
||||
|
||||
// The module structure IS the public API — consumers import from the owning
|
||||
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
|
||||
// `aacs::derive::{derive_vuk, resolve_candidate}`, `aacs::resolve::resolve_keys_v2`).
|
||||
// The `derive::probe` reproduction harness stays reachable via its module path.
|
||||
//
|
||||
// A small set of flat re-exports is kept for the typed key primitives and the
|
||||
// content-decrypt entry points that downstream key-source crates import through
|
||||
// the `aacs::` path. These are the stable, load-bearing names; keeping them here
|
||||
// lets those crates track the module refactor without a lockstep re-pin.
|
||||
// The module structure IS the public API — consumers import from the owning module
|
||||
// (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.
|
||||
// Re-exported from decrypt; pin the value here so the public constant
|
||||
// and the spec stay in lockstep.
|
||||
assert_eq!(ALIGNED_UNIT_LEN, 6144);
|
||||
assert_eq!(ALIGNED_UNIT_LEN, 3 * 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_strides_are_reexported_and_distinct() {
|
||||
// The three AACS generations are part of the public surface, and the
|
||||
// V10 (48) vs V20/V21 (64) stride distinction is the load-bearing
|
||||
// difference. Confirm the enum re-export is usable and the variants
|
||||
// are distinct values.
|
||||
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 ────────────────────────
|
||||
//
|
||||
// The HD DVD AACS dir name and title-key filename are authoring-specific
|
||||
// and were previously hardcoded to `/ANY!/VTKF000.AACS`. These verify the
|
||||
// discovery replacement against both real-disc shapes: Freedom (`AAC!` +
|
||||
// `VTKF090`/`VTKF100`) and a BD/UHD disc (no HD DVD dir).
|
||||
|
||||
#[test]
|
||||
fn role_paths_discovers_hddvd_dir_and_globs_all_vtkf_variants() {
|
||||
use crate::udf::fixture::*;
|
||||
// Freedom-shaped: an `AAC!` dir (NOT `ANY!`) holding MKBROM + two VTKF
|
||||
// variants (090/100, NOT 000) + a VTUF usage file (must be excluded),
|
||||
// plus the `AAC!_BAK` mirror (must NOT be picked as the AACS dir).
|
||||
let mut disc = MemDisc::new();
|
||||
let aacs_files = vec![
|
||||
file("MKBROM.AACS", 100, 5000, 4096, true),
|
||||
file("CONTENT_CERT.AACS", 101, 5100, 2048, true),
|
||||
file("VTKF100.AACS", 102, 5200, 2048, true),
|
||||
file("VTKF090.AACS", 103, 5300, 2048, true),
|
||||
file("VTUF090.AACS", 104, 5400, 2048, true),
|
||||
];
|
||||
let bak_files = vec![file("MKBROM.AACS", 110, 6000, 4096, true)];
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![
|
||||
DirSpec {
|
||||
name: "AAC!".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: aacs_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
name: "AAC!_BAK".to_string(),
|
||||
icb_lba: 30,
|
||||
dir_data_lba: 31,
|
||||
files: bak_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
// Discovered structurally (ends in '!', holds MKBROM.AACS) — the real
|
||||
// AACS dir, never the `_BAK` mirror.
|
||||
let dir = super::find_hddvd_aacs_dir(&udf).expect("aacs dir");
|
||||
assert_eq!(dir.name, "AAC!");
|
||||
|
||||
// UnitKey: BD/UHD paths first, then EVERY VTKF*.AACS in sorted order
|
||||
// (090 before 100) — NOT hardcoded VTKF000; VTUF (usage) excluded.
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
"/AAC!/VTKF090.AACS".to_string(),
|
||||
"/AAC!/VTKF100.AACS".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::Mkb)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/MKBROM.AACS"
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::ContentCert)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/CONTENT_CERT.AACS"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `!`-suffix and the `MKBROM.AACS` presence test are BOTH required —
|
||||
/// the discovery is a conjunction, not a disjunction.
|
||||
///
|
||||
/// The existing fixtures only ever present a directory that satisfies both
|
||||
/// (`AAC!` with `MKBROM.AACS`) alongside one that satisfies neither
|
||||
/// (`AAC!_BAK` — which contains `MKBROM.AACS` but is ALSO reached only after
|
||||
/// the real dir), so either half of the conjunction could be dropped and the
|
||||
/// same directory would still be found. Here a directory satisfies the name
|
||||
/// half and NOT the contents half: it must not be picked.
|
||||
///
|
||||
/// If it were, the HD DVD path would resolve `MKBROM.AACS`,
|
||||
/// `CONTENT_CERT.AACS` and the title-key file under a directory that holds
|
||||
/// none of them — the disc reports "no AACS key files" and never rips.
|
||||
#[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;
|
||||
|
||||
@@ -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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1,456 +0,0 @@
|
||||
//! Key source abstraction for the AACS resolve chain.
|
||||
//!
|
||||
//! libfreemkv keeps all crypto (AES-G primitives, SD-tree walking,
|
||||
//! validation, MK/VUK/TK derivation) but accepts key material from
|
||||
//! arbitrary backends via [`KeyProvider`].
|
||||
//!
|
||||
//! Methods come in two flavors:
|
||||
//!
|
||||
//! - **Bulk material** ([`device_keys`], [`processing_keys`],
|
||||
//! [`media_keys`]) — the resolver unions (and dedups) results
|
||||
//! across all providers and tries each candidate.
|
||||
//! - **Disc-keyed lookup** ([`lookup_disc_by_hash`],
|
||||
//! [`lookup_disc_by_vid`]) — the resolver short-circuits on the
|
||||
//! first hit, so providers are queried in array order with
|
||||
//! fastest/closest first.
|
||||
//!
|
||||
//! [`host_certs`] is a sixth method but is NOT consumed by the
|
||||
//! resolver chain: the SCSI handshake reads host certs directly from
|
||||
//! the caller-supplied credentials, not from the provider array. A
|
||||
//! provider that overrides `host_certs` today has no effect on the
|
||||
//! handshake; the method is retained as a forward-looking extension
|
||||
//! point only.
|
||||
//!
|
||||
//! Default impls return empty / `None` so backends only override
|
||||
//! the methods they actually support — an external key service might
|
||||
//! implement only `lookup_disc_by_hash`, while a local file might
|
||||
//! implement all six.
|
||||
//!
|
||||
//! Calls may block (disk I/O, network round-trips). The resolver
|
||||
//! invokes each method at most a handful of times per scan; for
|
||||
//! per-disc memoization, implementations should cache internally.
|
||||
//!
|
||||
//! [`device_keys`]: KeyProvider::device_keys
|
||||
//! [`processing_keys`]: KeyProvider::processing_keys
|
||||
//! [`media_keys`]: KeyProvider::media_keys
|
||||
//! [`host_certs`]: KeyProvider::host_certs
|
||||
//! [`lookup_disc_by_hash`]: KeyProvider::lookup_disc_by_hash
|
||||
//! [`lookup_disc_by_vid`]: KeyProvider::lookup_disc_by_vid
|
||||
|
||||
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 helpers that aggregate across a provider array.
|
||||
///
|
||||
/// The resolver wraps `ctx.providers` (`&[&dyn KeyProvider]`) in this
|
||||
/// struct; these helpers apply the union-vs-short-circuit policy per
|
||||
/// method. The bulk unions dedup so overlapping providers don't make
|
||||
/// the resolver re-walk/re-validate identical material.
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`KeyProvider`] backed by a single caller-supplied key's raw material —
|
||||
/// the bridge for [`crate::disc::Disc::decrypt_with`].
|
||||
///
|
||||
/// The application's key source did the lookup and handed in material at one
|
||||
/// level (DK / PK / MK / VUK). This exposes exactly that material to the
|
||||
/// version-dispatched resolver, which owns ALL derivation — so a source never
|
||||
/// derives, and the lib remains the single home for the AACS chain across
|
||||
/// 1.0 / 2.0 / 2.1 / 2.x.
|
||||
///
|
||||
/// Each level fills only its own field; the rest stay empty, so the resolver
|
||||
/// naturally runs the matching path (DK→…, PK→…, MK-pool brute, or a
|
||||
/// disc-keyed VUK hit). `decrypt_with` already knows the disc, so the
|
||||
/// `lookup_disc_by_*` hash/VID arguments are irrelevant — a present
|
||||
/// `disc_entry` is returned for any query.
|
||||
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");
|
||||
}
|
||||
|
||||
/// `Providers::host_certs` is the union across the provider array. It is not
|
||||
/// wired into the handshake today (see the module docs), so nothing else in
|
||||
/// the crate would notice a body that dropped every cert on the floor — and
|
||||
/// the day it IS wired in, a silently-empty cert list means the drive AACS
|
||||
/// authentication finds no host certificate to present and every disc fails
|
||||
/// to open, with no indication that the caller's certs were discarded.
|
||||
///
|
||||
/// Unlike the bulk key unions this one does NOT dedup (HostCert is not
|
||||
/// Ord/Hash), so the assertion is on the full concatenation in array order.
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
-2276
File diff suppressed because it is too large
Load Diff
+28
-290
@@ -1,21 +1,9 @@
|
||||
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
|
||||
//!
|
||||
//! An FMTS main feature interleaves short forensic **segments** — the sequence-key
|
||||
//! / forensic-watermark mechanism. Each segment carries an **index** (1..32): a
|
||||
//! tag in `IndividualSegment.tbl` that selects which of the 32 forensic **index
|
||||
//! keys** decrypts that segment's units, in place of the ordinary CPS Unit Key.
|
||||
//!
|
||||
//! Terminology (see the project AACS reference): the **index** here is NOT the
|
||||
//! AACS 2.1 *Media Key Variant* — that is the 65536-value device selector in the
|
||||
//! MKB that decides *which set* of index keys a device receives, a layer this
|
||||
//! module does not deal with. All the index keys belong to one variant, whose
|
||||
//! number is unknown and irrelevant to the segment map. Decrypting a segment with
|
||||
//! the Unit Key yields garbage — broken HEVC reference frames (empirically:
|
||||
//! `Could not find ref with POC …` on a plain unit-key rip).
|
||||
//!
|
||||
//! This table says WHERE the segments live and which index each carries, so a
|
||||
//! decoder can decrypt them with the matching index key instead of muxing
|
||||
//! unit-key garbage.
|
||||
//! An FMTS main feature interleaves short forensic **segments**, each with an
|
||||
//! **index** (1..32) selecting one of 32 forensic index keys to decrypt that
|
||||
//! segment's units in place of the ordinary CPS Unit Key. This table says
|
||||
//! WHERE the segments live and which index each carries.
|
||||
//!
|
||||
//! Format (validated against a retail AACS 2.1 disc):
|
||||
//! ```text
|
||||
@@ -24,22 +12,11 @@
|
||||
//! u32 marker (= 0x01000000) | u16 index | u16 flag (= 1)
|
||||
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
|
||||
//! ```
|
||||
//! `index` is the 1..32 forensic index tag, NOT a sequential segment id: measured
|
||||
//! on a retail 2.1 disc it cycles 1,2,…,32,1,2,… across records in
|
||||
//! file order — 24 full cycles of 32 plus a final partial cycle of 24 = 792
|
||||
//! records. Source-packet numbers are the 192-byte BDAV packet index: byte offset
|
||||
//! = `spn * 192`. Each segment is ~2560 packets (~480 KB) = 80 aligned units,
|
||||
//! spread across the entire 54 GB feature (one roughly every 67 MB). Inside a
|
||||
//! segment the 80 units interleave in two stride-2 halves: applying the segment's
|
||||
//! index key decrypts ~40 of them to clean TS and garbles the other ~40 (a second
|
||||
//! interleaved half, unidentified), which the demux then drops — leaving one
|
||||
//! coherent stream. Confirmed by decoding a retail disc with a full set of 32
|
||||
//! index keys.
|
||||
|
||||
/// Fixed size of one `IndividualSegment.tbl` record.
|
||||
pub const SEGMENT_RECORD_LEN: usize = 16;
|
||||
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
|
||||
pub const SOURCE_PACKET_LEN: u64 = 192;
|
||||
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.
|
||||
@@ -96,24 +73,17 @@ 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
|
||||
/// unit's clip-relative byte offset.
|
||||
///
|
||||
/// This is the routing decision behind a 2.1 decrypt-miss: a unit that
|
||||
/// overlaps a forensic segment must be opened with that segment's **index key**
|
||||
/// (selected by the segment's `index`), not the CPS Unit Key. Opening it with
|
||||
/// the Unit Key is exactly what yields the broken-reference-frame garbage a
|
||||
/// plain unit-key rip produces. A unit outside every segment is ordinary
|
||||
/// content and a miss on it is a Unit-Key miss, so this returns `None` and the
|
||||
/// caller falls back to the normal unit-key fetch.
|
||||
/// A unit overlapping a segment must be opened with that segment's index key (selected by
|
||||
/// `index`), not the CPS Unit Key.
|
||||
///
|
||||
/// The unit is tested as a packet *span* (`[off/192, (off+6144-1)/192]`) so a
|
||||
/// unit that only partly overlaps a segment edge is still classified as
|
||||
/// forensic; on the observed disc segments are unit-aligned, but the span test
|
||||
/// does not rely on that.
|
||||
/// Tested as a packet *span* (`[off/192, (off+6144-1)/192]`) so a unit that
|
||||
/// only partly overlaps a segment edge still counts as forensic.
|
||||
pub fn segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> {
|
||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
|
||||
let first = (unit_offset / SOURCE_PACKET_LEN) as u32;
|
||||
@@ -174,18 +144,14 @@ pub fn clip_byte_to_lba(extents: &[crate::disc::Extent], clip_byte: u64) -> Opti
|
||||
|
||||
/// Build the `[start_lba, end_lba) → key_idx` ranges for an FMTS forensic key map.
|
||||
///
|
||||
/// Each forensic segment's clip-relative source-packet span becomes an absolute
|
||||
/// LBA range tagged with the key its `index` selects (via `index_to_key_idx`,
|
||||
/// e.g. `|i| i as usize` when the pool is `[base, idx1, idx2, …]`). Applying that
|
||||
/// one key across the whole segment decodes the ~40 units of its interleave half
|
||||
/// to clean TS and garbles the other ~40 (the second interleaved half), which the
|
||||
/// demux then drops — yielding one coherent stream. Ranges outside every segment
|
||||
/// are left for the map's default (the ordinary Unit Key). A segment that straddles
|
||||
/// a UDF extent boundary is emitted as one range per whole-sector slice it covers.
|
||||
/// Each forensic segment's clip-relative source-packet span becomes an absolute LBA range
|
||||
/// tagged with the key its `index` selects (via `index_to_key_idx`). Ranges outside every
|
||||
/// 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.
|
||||
///
|
||||
/// The result feeds [`AacsKeyMap::from_ranges`](crate::decrypt::AacsKeyMap::from_ranges)
|
||||
/// with the Unit-Key index as the default — the same structure the CPS map uses,
|
||||
/// only finer-grained.
|
||||
/// 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],
|
||||
@@ -200,21 +166,21 @@ pub fn fmts_key_ranges(
|
||||
}
|
||||
let start_byte = s.start_spn as u64 * SOURCE_PACKET_LEN;
|
||||
let end_byte = (s.end_spn as u64 + 1) * SOURCE_PACKET_LEN; // exclusive
|
||||
// A segment is unit-aligned and contiguous in clip bytes; map its first
|
||||
// and last sector to LBAs. Segments are ~480 KB and extents are GB-sized,
|
||||
// so a segment almost never crosses an extent boundary — but if the two
|
||||
// ends land in different extents (non-contiguous LBAs), skip rather than
|
||||
// emit a wrong span; the units there fall to the Unit Key (garble+drop),
|
||||
// never a mis-decrypt.
|
||||
// Map first/last sector to LBAs. Segments (~480 KB) rarely cross the
|
||||
// GB-sized extent boundary, but if the two ends land in different
|
||||
// extents, skip rather than emit a wrong span (falls back to Unit Key).
|
||||
let (Some(a), Some(b)) = (
|
||||
clip_byte_to_lba(extents, start_byte),
|
||||
clip_byte_to_lba(extents, end_byte - 1),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if b >= a
|
||||
&& (b - a) as u64 == (end_byte - 1 - start_byte) / crate::consts::SECTOR_BYTES as u64
|
||||
{
|
||||
// start_byte = start_spn*192 is generally NOT 2048-aligned, so the LBA carry
|
||||
// is floor((end_byte-1)/2048) - floor(start_byte/2048); the naive (end_byte-1
|
||||
// -start_byte)/2048 matches `b-a` only when sector-aligned, else wrongly drops the non-aligned segment back to the Unit Key.
|
||||
let sector = crate::consts::SECTOR_BYTES as u64;
|
||||
let expected_delta = (end_byte - 1) / sector - start_byte / sector;
|
||||
if b >= a && (b - a) as u64 == expected_delta {
|
||||
ranges.push((a, b + 1, index_to_key_idx(s.index)));
|
||||
}
|
||||
}
|
||||
@@ -222,233 +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_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;
|
||||
|
||||
+8
-78
@@ -1,29 +1,16 @@
|
||||
//! AACS 2.1 FMTS forensic segment keys, `AACS/SegmentKeyNNNNN.tbl`.
|
||||
//!
|
||||
//! One file per CPS unit (`SegmentKey00001.tbl`, ...). It is the on-disc key
|
||||
//! store for the forensic variant segments mapped by [`super::segment`]. A
|
||||
//! device does not read a segment key directly. It derives a **16-bit variant
|
||||
//! selector** from the Media Key Variant chain (see [`super::variant`]) and uses
|
||||
//! that selector to index this table, which is how the device's position in the
|
||||
//! key tree decides which variant it can decrypt (the traitor-tracing link).
|
||||
//! One file per CPS unit. On-disc key store for the forensic variant segments
|
||||
//! mapped by [`super::segment`]; a device derives a 16-bit variant selector
|
||||
//! from the Media Key Variant chain (see [`super::variant`]) and uses it to
|
||||
//! index this table.
|
||||
//!
|
||||
//! Container format (confirmed against a retail AACS 2.1 disc):
|
||||
//! Container format:
|
||||
//! ```text
|
||||
//! header (8 bytes): u32 tag | u16 index_space | u16 record_size
|
||||
//! record[index_space] (record_size bytes each)
|
||||
//! ```
|
||||
//! On the reference disc: `index_space` = `0xffff` (the full 16-bit selector
|
||||
//! space, 65536 records), `record_size` = `0x0218` = 536. Total
|
||||
//! `8 + 65536 * 536 = 35,127,304` bytes, which matches the file exactly. Each
|
||||
//! record begins with an 8-byte sub-header, then 528 bytes of encrypted key
|
||||
//! material.
|
||||
//!
|
||||
//! **Not yet reversed:** the internal layout of a record's 528-byte payload, and
|
||||
//! how it maps onto the segments of [`super::segment`]. One numeric coincidence
|
||||
//! worth noting for whoever cracks it: the reference disc has 792 segments and
|
||||
//! `528 = 33 * 16`, with `792 = 24 * 33`, so `33` appears on both sides. Until
|
||||
//! the mapping and the key derivation are pinned, this module exposes only the
|
||||
//! confirmed container: locate the record for a given 16-bit selector.
|
||||
//! Record layout beyond an 8-byte sub-header is not yet reversed.
|
||||
|
||||
/// Bytes of the fixed file header.
|
||||
pub const HEADER_LEN: usize = 8;
|
||||
@@ -104,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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+71
-43
@@ -1,15 +1,8 @@
|
||||
//! Structured resolution trace — what the unlock + key-resolution attempt did.
|
||||
//!
|
||||
//! No user-facing English. Every step's STATE is a typed enum variant;
|
||||
//! applications RENDER these into localized text (the library never does). This
|
||||
//! module only DEFINES the shape and is wired through the resolve/handshake
|
||||
//! return path far enough to compile.
|
||||
//!
|
||||
//! The `who` of each step is the source's `label()` / unlocker's `name()` — a
|
||||
//! stable identifier string (a NAME, like a codec id, NOT user-facing prose),
|
||||
//! carried verbatim so an app renderer never has to match an enum back to a name
|
||||
//! it already has. Only the OUTCOME / path enums are structured states the app
|
||||
//! maps to i18n English.
|
||||
//! No user-facing English: every step's STATE is a typed enum variant that applications render
|
||||
//! into localized text. The `who` field of each step is a stable identifier (e.g. a
|
||||
//! `label()`/`name()`), never prose, carried verbatim.
|
||||
|
||||
/// The full trace of a resolution attempt: the unlock phase, then the
|
||||
/// key-resolution phase.
|
||||
@@ -67,6 +60,41 @@ pub struct KeyStep {
|
||||
pub who: String,
|
||||
pub path: Vec<KeyNode>,
|
||||
pub outcome: KeyOutcome,
|
||||
/// Shape of the source's MATCHED entry, when it matched this disc — a
|
||||
/// booleans-and-lengths summary (no key material) that de-conflates WHY a
|
||||
/// matched disc produced no key. `None` when the source did not match (a
|
||||
/// true miss) or the source kind carries no such shape. An application MAY
|
||||
/// log it verbatim and render it into a matched-but-no-key verdict.
|
||||
pub matched_entry: Option<MatchedEntry>,
|
||||
/// Number of per-disc entries loaded in the source's store, when known — so
|
||||
/// a true-miss verdict can name the store size (`… not in keydb (N entries
|
||||
/// loaded)`) and a reporter can confirm a wrong-pressing at a glance.
|
||||
/// `None` for a source that carries no such count. No secret.
|
||||
pub store_entries: Option<usize>,
|
||||
}
|
||||
|
||||
/// A shape summary of a key source's MATCHED entry — booleans and lengths only,
|
||||
/// never key MATERIAL — so an application can log WHY a disc that WAS found in
|
||||
/// the store still produced no usable key (the classic "matched but needs a VID
|
||||
/// this path can't supply"). Every field is safe to print at any log level.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct MatchedEntry {
|
||||
/// The entry carries a Volume Unique Key (title keys can boil directly).
|
||||
pub has_vuk: bool,
|
||||
/// The entry carries one or more pre-decrypted terminal Unit Keys.
|
||||
pub has_unit_keys: bool,
|
||||
/// Count of pre-decrypted terminal Unit Keys the entry carries.
|
||||
pub unit_keys_len: usize,
|
||||
/// The entry carries a Media Key (a VID is still needed to reach the VUK).
|
||||
pub has_media_key: bool,
|
||||
/// The entry carries its own Volume ID (the keydb `I` token).
|
||||
pub has_keydb_vid: bool,
|
||||
/// Count of the disc's encrypted title keys available to boil (from
|
||||
/// `Unit_Key_RO.inf`); `0` when none were captured.
|
||||
pub enc_title_keys_len: usize,
|
||||
/// A Volume ID is available on this resolve path (from the drive handshake
|
||||
/// or the entry) — the gate the `MK → VUK` step needs.
|
||||
pub vid_available: bool,
|
||||
}
|
||||
|
||||
/// A node on the derivation path a source walked. Ordered as encountered; not
|
||||
@@ -77,6 +105,10 @@ pub enum KeyNode {
|
||||
MatchedDisc,
|
||||
/// The source had no entry for this disc.
|
||||
NoEntry,
|
||||
/// The source matched this disc but could not derive any usable key from the
|
||||
/// matched entry (e.g. no derivation material for the path taken). Distinct
|
||||
/// from [`NoEntry`](Self::NoEntry): the disc WAS found, the key was not.
|
||||
NoDerivableKey,
|
||||
/// Pre-decrypted unit keys were found.
|
||||
FoundUnitKeys,
|
||||
/// A VUK was found.
|
||||
@@ -97,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 {
|
||||
@@ -108,37 +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,
|
||||
}],
|
||||
};
|
||||
// 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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
+9
-151
@@ -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]>,
|
||||
@@ -111,17 +111,9 @@ pub struct DiscEntry {
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
|
||||
// ── Redacting `Debug` impls ──────────────────────────────────────────────────
|
||||
//
|
||||
// Every type above carries AACS secret material (device keys, host PRIVATE keys,
|
||||
// media/volume/processing/unit keys). `#[derive(Debug)]` would print those bytes
|
||||
// verbatim, so a stray `debug!("{:?}", …)` or a panic message would leak the
|
||||
// keys. These hand-written impls print only NON-secret shape (presence, lengths,
|
||||
// tree coordinates, indices) — never key bytes. `decrypt::DecryptKeys` follows
|
||||
// the same policy by omitting `Debug` entirely; here we keep `Debug` because
|
||||
// these are `PartialEq`/`Eq` value types used in `assert_eq!` and nested inside
|
||||
// other `#[derive(Debug)]` structs, so the trait must exist — just not leak.
|
||||
// Guarded by `redaction_tests` below.
|
||||
// ── Redacting `Debug` impls: types above carry AACS secrets; derived `Debug`
|
||||
// would leak key bytes via `debug!`/panics. These print only non-secret shape,
|
||||
// kept (not omitted) since used in `assert_eq!`. See `redaction_tests`.
|
||||
|
||||
impl std::fmt::Debug for DeviceKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -196,143 +188,9 @@ impl std::fmt::Debug for DiscEntry {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unit_key_tests {
|
||||
use super::*;
|
||||
|
||||
/// `is_default_index` is the public predicate that separates ordinary
|
||||
/// (index-0) content keys from FMTS forensic index keys ([`UnitKey`] docs;
|
||||
/// AACS 2.1 `IndividualSegment.tbl` tagging). A body answering `true` for
|
||||
/// everything would present a forensic index key as an ordinary content
|
||||
/// key — the caller would decrypt the bulk of the title with a key that
|
||||
/// only opens 1/32nd of it; answering `false` for everything would hide
|
||||
/// every ordinary key.
|
||||
///
|
||||
/// Pinned against the two NAMED constructors, which are the contract:
|
||||
/// [`UnitKey::new`] builds the ordinary key, [`UnitKey::forensic`] builds
|
||||
/// an index key for `1..=32`.
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The predicate must agree with the one consumer of `index_number` in the
|
||||
/// crate: [`crate::aacs::index_select::resolve_disc_index`] resolves the
|
||||
/// disc's forensic index from exactly the keys that are NOT default. If
|
||||
/// the two disagree, a disc resolves an index whose key the rest of the
|
||||
/// pipeline treats as ordinary (or vice versa).
|
||||
#[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 key byte 0xD5 = decimal 213. A derived `Debug` prints `[u8;N]`
|
||||
// as decimal, so a leaked key surfaces the substring "213"; the redacting
|
||||
// impls must not. No non-secret field below is 213, so "213" appearing means
|
||||
// key bytes leaked. Each type must also carry a "redacted" marker (or omit
|
||||
// the secret entirely) so re-adding `#[derive(Debug)]` fails this test.
|
||||
const S: u8 = 0xD5;
|
||||
|
||||
fn assert_redacted(what: &str, dbg: &str) {
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"{what}: Debug leaked key bytes (found decimal 213): {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"{what}: Debug missing redaction marker: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_key_debug_is_redacted() {
|
||||
let d = DeviceKey {
|
||||
key: [S; 16],
|
||||
node: 1,
|
||||
uv: 2,
|
||||
u_mask_shift: 3,
|
||||
};
|
||||
assert_redacted("DeviceKey", &format!("{d:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_cert_debug_is_redacted() {
|
||||
let h = HostCert {
|
||||
private_key: [S; 20],
|
||||
certificate: vec![0u8; 92],
|
||||
private_key_v2: Some([S; 32]),
|
||||
certificate_v2: None,
|
||||
};
|
||||
assert_redacted("HostCert", &format!("{h:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newtype_keys_debug_is_redacted() {
|
||||
assert_redacted("Vid", &format!("{:?}", Vid([S; 16])));
|
||||
assert_redacted("MediaKey", &format!("{:?}", MediaKey([S; 16])));
|
||||
assert_redacted("Vuk", &format!("{:?}", Vuk([S; 16])));
|
||||
assert_redacted("ProcessingKey", &format!("{:?}", ProcessingKey([S; 16])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_key_debug_is_redacted() {
|
||||
assert_redacted("UnitKey", &format!("{:?}", UnitKey::new(0, [S; 16])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_entry_debug_is_redacted() {
|
||||
let e = DiscEntry {
|
||||
disc_hash: "0xAA".into(),
|
||||
title: "T".into(),
|
||||
media_key: Some([S; 16]),
|
||||
disc_id: Some([S; 16]),
|
||||
vuk: Some([S; 16]),
|
||||
unit_keys: vec![(1, [S; 16])],
|
||||
};
|
||||
assert_redacted("DiscEntry", &format!("{e:?}"));
|
||||
}
|
||||
}
|
||||
#[path = "types_redaction_tests.rs"]
|
||||
mod redaction_tests;
|
||||
|
||||
@@ -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:?}"));
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
+177
-1804
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,231 @@
|
||||
//! Minimal, read-only parser for `/BDMV/BDJO/*.bdjo` — the BD-J Object files.
|
||||
//!
|
||||
//! Only the Application Management Table (AMT) is decoded, and only the four
|
||||
//! fields the issue #45 Tier-2 menu-walk needs from each application record:
|
||||
//! its `application_control_code` (1 = AUTOSTART), `base_directory`,
|
||||
//! `classpath_extension`, and `initial_class` (the Xlet's fully-qualified class
|
||||
//! name). Everything else in the file is skipped by width.
|
||||
//!
|
||||
//! Layout follows the published BD-J Object (BDJO) file format: a fixed 48-byte
|
||||
//! header (8-byte magic+version, then a 40-byte section-address table that real
|
||||
//! players skip), followed by the terminal-info, app-cache-info,
|
||||
//! accessible-playlists, and application-management-table sections in sequence.
|
||||
//!
|
||||
//! This is a DOCUMENTED BINARY FORMAT read as data, never executed. Every field
|
||||
//! is bounds-checked and any malformed input yields `None` — panic-free like
|
||||
//! [`crate::bdnav::index`]. Tier-2 falls back to scanning every jar when this
|
||||
//! returns `None`.
|
||||
|
||||
/// One application record from a BDJO Application Management Table — only the
|
||||
/// fields the menu-walk consumes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct BdjoApp {
|
||||
/// `application_control_code`: 1 = AUTOSTART, 2 = PRESENT.
|
||||
pub control_code: u8,
|
||||
/// `base_directory` — the primary jar id (e.g. "00000"), naming
|
||||
/// `/BDMV/JAR/<base_directory>.jar`.
|
||||
pub base_directory: String,
|
||||
/// `classpath_extension` — additional `;`-separated jar ids on the
|
||||
/// classpath, or empty.
|
||||
pub classpath_extension: String,
|
||||
/// `initial_class` — the autostart Xlet's fully-qualified class name
|
||||
/// (e.g. "com.foxbd.StandardMenuXlet").
|
||||
pub initial_class: String,
|
||||
}
|
||||
|
||||
impl BdjoApp {
|
||||
/// Whether this application autostarts (the one whose Xlet drives the disc).
|
||||
pub fn is_autostart(&self) -> bool {
|
||||
self.control_code == AUTOSTART
|
||||
}
|
||||
|
||||
/// The jar ids that make up this app's classpath: `base_directory` first,
|
||||
/// then each `;`-separated `classpath_extension` entry. Empty tokens are
|
||||
/// dropped. These name `/BDMV/JAR/<id>.jar`.
|
||||
pub fn jar_ids(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let base = self.base_directory.trim();
|
||||
if !base.is_empty() {
|
||||
out.push(base.to_string());
|
||||
}
|
||||
for part in self.classpath_extension.split(';') {
|
||||
let part = part.trim();
|
||||
if !part.is_empty() && !out.iter().any(|e| e == part) {
|
||||
out.push(part.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
const AUTOSTART: u8 = 1;
|
||||
|
||||
// "BDJO" magic. The 4-byte version that follows ("0100"/"0200"/"0240"/"0300")
|
||||
// is read but not validated — a future revision must still let Tier-2 fall back
|
||||
// gracefully rather than mis-reject a readable table.
|
||||
const MAGIC: u32 = u32::from_be_bytes(*b"BDJO");
|
||||
|
||||
/// 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>> {
|
||||
let mut r = BitReader::new(data);
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────────────────
|
||||
if r.read(32)? as u32 != MAGIC {
|
||||
return None;
|
||||
}
|
||||
r.skip(32)?; // version_number (not load-bearing for resolution)
|
||||
r.skip(40 * 8)?; // section-address table (players read sections sequentially)
|
||||
|
||||
// ── TerminalInfo ────────────────────────────────────────────────────────
|
||||
r.skip(32)?; // length
|
||||
r.skip(5 * 8)?; // default_font (5 bytes)
|
||||
r.skip(4 + 1 + 1)?; // initial_havi_config_id + menu_call_mask + title_search_mask
|
||||
r.skip(34)?; // padding
|
||||
|
||||
// ── AppCacheInfo ──────────────────────────────────────────────────────────
|
||||
r.skip(32)?; // length
|
||||
let num_item = r.read(8)? as usize;
|
||||
r.skip(8)?; // padding
|
||||
for _ in 0..num_item {
|
||||
r.skip(12 * 8)?; // each item: type(1) + ref_to_name(5) + lang_code(3) + pad(3)
|
||||
}
|
||||
|
||||
// ── AccessiblePlaylists ───────────────────────────────────────────────────
|
||||
r.skip(32)?; // length
|
||||
let num_pl = r.read(11)? as usize;
|
||||
r.skip(1 + 1)?; // access_to_all_flag + autostart_first_playlist_flag
|
||||
r.skip(19)?; // padding
|
||||
for _ in 0..num_pl {
|
||||
r.skip(6 * 8)?; // each: name(5) + pad(1)
|
||||
}
|
||||
|
||||
// ── ApplicationManagementTable ────────────────────────────────────────────
|
||||
r.skip(32)?; // length
|
||||
let num_app = r.read(8)? as usize;
|
||||
r.skip(8)?; // padding
|
||||
// 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)?);
|
||||
}
|
||||
Some(apps)
|
||||
}
|
||||
|
||||
// Parse one Application() record from the Application Management Table.
|
||||
fn parse_app(r: &mut BitReader<'_>) -> Option<BdjoApp> {
|
||||
let control_code = r.read(8)? as u8;
|
||||
r.skip(4)?; // application_type
|
||||
r.skip(4)?; // reserved
|
||||
r.skip(32)?; // organization_id
|
||||
r.skip(16)?; // application_id
|
||||
r.skip(80)?; // descriptor tag + length (10 bytes)
|
||||
|
||||
// application descriptor
|
||||
let num_profile = r.read(4)? as usize;
|
||||
r.skip(12)?; // padding
|
||||
for _ in 0..num_profile {
|
||||
r.skip(6 * 8)?; // profile_number(2) + major/minor/micro(3) + pad(1)
|
||||
}
|
||||
|
||||
r.skip(8)?; // application_priority
|
||||
r.skip(2 + 2 + 4)?; // binding + visibility + reserved
|
||||
|
||||
// application_name section: u16 data_length, then that many bytes, word-aligned.
|
||||
let names_len = r.read(16)? as usize;
|
||||
r.skip(names_len * 8)?;
|
||||
if !names_len.is_multiple_of(2) {
|
||||
r.skip(8)?;
|
||||
}
|
||||
|
||||
// icon_locator (word-aligned string), then icon_flags(16).
|
||||
read_app_string(r)?;
|
||||
r.skip(16)?;
|
||||
|
||||
let base_directory = read_app_string(r)?;
|
||||
let classpath_extension = read_app_string(r)?;
|
||||
let initial_class = read_app_string(r)?;
|
||||
|
||||
// application_parameters: u8 data_length, that many bytes, word-aligned.
|
||||
let params_len = r.read(8)? as usize;
|
||||
r.skip(params_len * 8)?;
|
||||
if params_len.is_multiple_of(2) {
|
||||
r.skip(8)?;
|
||||
}
|
||||
|
||||
Some(BdjoApp {
|
||||
control_code,
|
||||
base_directory,
|
||||
classpath_extension,
|
||||
initial_class,
|
||||
})
|
||||
}
|
||||
|
||||
// A word-aligned length-prefixed string (`_read_app_string`): u8 length, then
|
||||
// `length` bytes, then one pad byte when `length` is EVEN. Bytes are Latin-1
|
||||
// decoded (FQCNs/jar ids are ASCII); trailing NULs are trimmed.
|
||||
fn read_app_string(r: &mut BitReader<'_>) -> Option<String> {
|
||||
let len = r.read(8)? as usize;
|
||||
let mut s = String::with_capacity(len);
|
||||
for _ in 0..len {
|
||||
s.push(r.read(8)? as u8 as char);
|
||||
}
|
||||
if len.is_multiple_of(2) {
|
||||
r.skip(8)?;
|
||||
}
|
||||
Some(s.trim_end_matches('\0').to_string())
|
||||
}
|
||||
|
||||
// ── MSB-first bit reader ─────────────────────────────────────────────────────
|
||||
// BDJO integers are bit-packed and consumed most-significant-first.
|
||||
// Every read/skip is bounds-checked and returns `None` past the end.
|
||||
struct BitReader<'a> {
|
||||
data: &'a [u8],
|
||||
bit_pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> BitReader<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
BitReader { data, bit_pos: 0 }
|
||||
}
|
||||
|
||||
fn total_bits(&self) -> usize {
|
||||
self.data.len() * 8
|
||||
}
|
||||
|
||||
// Read up to 64 bits, MSB-first. `None` if fewer than `n` bits remain.
|
||||
fn read(&mut self, n: u32) -> Option<u64> {
|
||||
if n == 0 {
|
||||
return Some(0);
|
||||
}
|
||||
if n > 64 {
|
||||
return None;
|
||||
}
|
||||
let end = self.bit_pos.checked_add(n as usize)?;
|
||||
if end > self.total_bits() {
|
||||
return None;
|
||||
}
|
||||
let mut v: u64 = 0;
|
||||
for _ in 0..n {
|
||||
let byte = self.data[self.bit_pos >> 3];
|
||||
let bit = (byte >> (7 - (self.bit_pos & 7))) & 1;
|
||||
v = (v << 1) | bit as u64;
|
||||
self.bit_pos += 1;
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
// Advance `n` bits. `None` if that would run past the end.
|
||||
fn skip(&mut self, n: usize) -> Option<()> {
|
||||
let end = self.bit_pos.checked_add(n)?;
|
||||
if end > self.total_bits() {
|
||||
return None;
|
||||
}
|
||||
self.bit_pos = end;
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "bdjo_tests.rs"]
|
||||
mod tests;
|
||||
@@ -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
-61
@@ -2,12 +2,8 @@
|
||||
//! and the title table), per its documented binary layout. This is read as a
|
||||
//! documented binary format, never executed: every field is bounds-checked and
|
||||
//! any malformed input yields `None` (the nav resolver then abstains).
|
||||
//!
|
||||
//! Layout: `"INDX"` + version(4) + `indexes_start`(u32 @8) + … ; at
|
||||
//! `indexes_start`: `index_len`(u32), First-Play object(12), Top-Menu object(12),
|
||||
//! `num_titles`(u16), then `num_titles` title objects(12 each). Every object's
|
||||
//! `object_type` is the top two bits of its first byte; for an HDMV object the
|
||||
//! `id_ref` into `MovieObject.bdmv` is a big-endian u16 at object offset 6.
|
||||
|
||||
use super::be_u16;
|
||||
|
||||
/// One playback/title object in `index.bdmv`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -35,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)?,
|
||||
@@ -67,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)?;
|
||||
@@ -96,54 +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());
|
||||
}
|
||||
}
|
||||
#[path = "index_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+10
-65
@@ -7,10 +7,9 @@
|
||||
//! byte 50. Each object is `flags`(1) + reserved(1) + `num_cmds`(u16) followed
|
||||
//! by `num_cmds` 12-byte navigation commands.
|
||||
|
||||
/// One decoded 12-byte navigation command. Field layout (big-endian): byte 0 =
|
||||
/// `op_cnt(3) grp(2) sub_grp(3)`; byte 1 = `imm_op1(1) imm_op2(1) branch_opt(4)`
|
||||
/// (low nibble); byte 2 low nibble = `cmp_opt`; byte 3 low 5 bits = `set_opt`;
|
||||
/// then `dst`(u32) and `src`(u32).
|
||||
use super::be_u16;
|
||||
|
||||
// One decoded 12-byte navigation command.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct Cmd {
|
||||
pub op_cnt: u8,
|
||||
@@ -32,14 +31,12 @@ pub(crate) struct MovieObject {
|
||||
}
|
||||
|
||||
const CMD_LEN: usize = 12;
|
||||
/// Sanity caps (real discs are far under these; the Sony dispatcher is ~2253
|
||||
/// commands in one object).
|
||||
/// Sanity cap on object count (real discs are far under this; some densely-
|
||||
/// branched dispatchers run several thousand commands in one object). There is
|
||||
/// deliberately NO command-count cap: `num_cmds` is a u16 (≤ 65535) and each
|
||||
/// object's command span is bounded against the input length below, so a
|
||||
/// separate cap would be dead code.
|
||||
const MAX_OBJECTS: usize = 4096;
|
||||
const MAX_CMDS: usize = 200_000;
|
||||
|
||||
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 {
|
||||
@@ -74,9 +71,6 @@ pub(crate) fn parse(d: &[u8]) -> Option<Vec<MovieObject>> {
|
||||
// Object header: flags(1) + reserved(1) + num_cmds(u16).
|
||||
let num_cmds = be_u16(d, off + 2)? as usize;
|
||||
off = off.checked_add(4)?;
|
||||
if num_cmds > MAX_CMDS {
|
||||
return None;
|
||||
}
|
||||
let span = num_cmds.checked_mul(CMD_LEN)?;
|
||||
let end = off.checked_add(span)?;
|
||||
if end > d.len() {
|
||||
@@ -94,54 +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());
|
||||
}
|
||||
}
|
||||
#[path = "mobj_tests.rs"]
|
||||
pub(crate) mod tests;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+14
-13
@@ -1,16 +1,12 @@
|
||||
//! BD/UHD HDMV navigation resolver — "mimic a real player" for main-feature
|
||||
//! title selection. It reads `/BDMV/index.bdmv` + `/BDMV/MovieObject.bdmv` and
|
||||
//! runs a faithful, bounded HDMV navigation VM (libbluray semantics) to find the
|
||||
//! playlist the disc's own First-Play navigation plays as the feature.
|
||||
//! runs a faithful, bounded HDMV navigation VM to find the playlist the disc's
|
||||
//! own First-Play navigation plays as the feature.
|
||||
//!
|
||||
//! There is NO vendor special-casing: one correctly-booted VM resolves the
|
||||
//! obfuscated Sony dispatcher and every other HDMV disc alike. The honest
|
||||
//! boundary is BD-J (the feature is chosen by a Java Xlet, not by the HDMV VM);
|
||||
//! there — and on any malformed data or non-convergence — the resolver ABSTAINS
|
||||
//! (`None`) and selection falls back to the structural/heuristic order.
|
||||
//!
|
||||
//! Contract: read-only, bounded, and never panics or hard-fails.
|
||||
|
||||
pub(crate) mod bdjo;
|
||||
pub(crate) mod index;
|
||||
pub(crate) mod mobj;
|
||||
pub(crate) mod vm;
|
||||
@@ -18,12 +14,13 @@ pub(crate) mod vm;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
/// Resolve the playlist id the disc's First-Play navigation plays as the
|
||||
/// feature, restricted to ids the caller marks as feature candidates
|
||||
/// (`is_feature_candidate` — typically video-bearing, non-trivial titles, so a
|
||||
/// short logo/pre-roll `PlayPlayList` is skipped). Returns `None` for BD-J discs,
|
||||
/// missing/malformed nav data, or when navigation does not converge on a
|
||||
/// candidate.
|
||||
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.
|
||||
pub(crate) fn resolve_feature(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf: &UdfFs,
|
||||
@@ -48,3 +45,7 @@ fn resolve_inner(
|
||||
let mobjs = mobj::parse(&udf.read_file(reader, "/BDMV/MovieObject.bdmv").ok()?)?;
|
||||
vm::resolve(&index, &mobjs, is_feature_candidate)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+70
-253
@@ -1,29 +1,26 @@
|
||||
//! The HDMV navigation VM — a faithful, bounded re-implementation of the subset
|
||||
//! of libbluray's `hdmv_vm.c` needed to follow First-Play to the feature
|
||||
//! `PlayPlayList`. It "mimics a real player": correct power-on register state
|
||||
//! (libbluray `register.c` `bd_psr_init`) is what makes an obfuscated dispatcher
|
||||
//! (e.g. Sony's) converge rather than loop. Never panics; hard step/switch caps.
|
||||
//! The HDMV navigation VM — a bounded re-implementation of the subset of HDMV
|
||||
//! navigation semantics needed to follow First-Play to the feature
|
||||
//! `PlayPlayList`. Never panics; hard step/switch caps.
|
||||
//!
|
||||
//! It resolves generically — there is no vendor special-casing. When First-Play
|
||||
//! is a BD-J title (the feature is chosen by a Java Xlet), or the program does
|
||||
//! not reach a `PlayPL` on a caller-approved feature candidate, it abstains
|
||||
//! (`None`) and selection falls back to the structural/heuristic order.
|
||||
//! It resolves generically — there is no per-disc special-casing. When
|
||||
//! First-Play is a BD-J title (the feature is chosen by a Java Xlet), or the
|
||||
//! program does not reach a `PlayPL` on a caller-approved feature candidate, it
|
||||
//! abstains (`None`) and selection falls back to the structural/heuristic order.
|
||||
|
||||
use super::index::{Index, PlaybackObj};
|
||||
use super::mobj::MovieObject;
|
||||
|
||||
/// Top bit of an operand selects PSR (else GPR).
|
||||
const PSR_FLAG: u32 = 0x8000_0000;
|
||||
/// Mirror of libbluray `MAX_LOOP`.
|
||||
/// Upper bound on VM steps before declaring non-convergence.
|
||||
const MAX_STEPS: usize = 1_000_000;
|
||||
/// Cap on object/title switches — the obfuscated dispatcher runs in one object;
|
||||
/// this only bounds pathological jump chains.
|
||||
/// Cap on object/title switches — a dispatcher runs in one object; this only
|
||||
/// bounds pathological jump chains.
|
||||
const MAX_SWITCHES: usize = 1024;
|
||||
|
||||
/// libbluray `bd_psr_init` power-on defaults (indices 0..=61); unlisted = 0. The
|
||||
/// load-bearing values are PSR4/5 = `0xffff` ("no title/chapter selected") and
|
||||
/// PSR6/7/8 = 0, which steer the dispatcher's compares; the rest are copied for
|
||||
/// fidelity.
|
||||
// BD player power-on register defaults (indices 0..=61); unlisted = 0.
|
||||
// Load-bearing: PSR4/5 = 0xffff ("no title/chapter selected"), PSR6/7/8 = 0 —
|
||||
// these steer dispatcher compares; rest set for fidelity with real player.
|
||||
fn psr_init() -> [u32; 128] {
|
||||
let mut p = [0u32; 128];
|
||||
let vals: &[(usize, u32)] = &[
|
||||
@@ -65,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]
|
||||
@@ -76,9 +93,9 @@ impl Vm<'_> {
|
||||
}
|
||||
}
|
||||
fn wr(&mut self, val: u32, x: u32) {
|
||||
// A store to a PSR-tagged register is refused (libbluray `_store_reg`
|
||||
// returns -1); only GPR writes take effect. PSR values come from the
|
||||
// system (power-on `psr_init`), never from a nav SET/SWAP operand.
|
||||
// Per the HDMV nav spec, a store to a PSR-tagged register is refused;
|
||||
// only GPR writes take effect. PSR values come only from power-on init,
|
||||
// never from a nav SET/SWAP operand.
|
||||
if val & PSR_FLAG == 0 {
|
||||
self.gpr[(val & 0xfff) as usize] = x;
|
||||
}
|
||||
@@ -87,9 +104,8 @@ impl Vm<'_> {
|
||||
if imm { raw } else { self.rd(raw) }
|
||||
}
|
||||
/// Resolve a JumpTitle target: title 0 = Top Menu, `1..=N` = `titles[i-1]`,
|
||||
/// `0xFFFF` = First Play (BD-ROM title numbering, per libbluray
|
||||
/// `_is_valid_title`). Any other value is an invalid title reference and
|
||||
/// abstains (`None`).
|
||||
/// `0xFFFF` = First Play (BD-ROM title numbering). Any other value is an
|
||||
/// invalid title reference and abstains (`None`).
|
||||
fn title_obj(&self, title: u32) -> Option<PlaybackObj> {
|
||||
let n = self.index.titles.len() as u32;
|
||||
if title == 0 {
|
||||
@@ -112,21 +128,14 @@ pub(crate) fn resolve(
|
||||
mobjs: &[MovieObject],
|
||||
is_feature: &dyn Fn(u16) -> bool,
|
||||
) -> Option<u16> {
|
||||
// Enter at First-Play when it is an HDMV object that exists; otherwise
|
||||
// abstain (BD-J boundary, or a `0xffff` "no object"). We deliberately do not
|
||||
// chase the Top-Menu program here — its "Play Movie" target usually lives in
|
||||
// the IG-stream button navigation commands inside the menu `.m2ts`, not in
|
||||
// `MovieObject.bdmv` (future work).
|
||||
// Enter at First-Play when it is an HDMV object that exists; otherwise abstain
|
||||
// (BD-J boundary, or `0xffff` "no object"). We don't chase Top-Menu's "Play
|
||||
// Movie" target here — it lives in menu .m2ts IG button commands (future work).
|
||||
let start = match index.first_play {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -153,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 {
|
||||
@@ -199,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);
|
||||
}
|
||||
}
|
||||
@@ -209,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,
|
||||
@@ -229,36 +246,40 @@ fn run(vm: &mut Vm, mut obj_id: usize, is_feature: &dyn Fn(u16) -> bool) -> Opti
|
||||
0x01 => Some(src), // MOVE
|
||||
0x02 => {
|
||||
// SWAP exchanges the two operands; a store to an operand
|
||||
// flagged immediate is refused (libbluray `_store_result`
|
||||
// honours imm_op1/imm_op2), and `wr` already drops PSR
|
||||
// stores.
|
||||
// flagged immediate is refused, and `wr` already drops
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -268,209 +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 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 abstains_when_first_play_jumps_to_bdj_title() {
|
||||
// SM3 shape: First-Play HDMV 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 (libbluray `_is_valid_title`): 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() {
|
||||
// A SET MOVE to PSR6 must be a no-op (libbluray `_store_reg` refuses PSR
|
||||
// stores). Program: MOVE PSR6 <- 5; CMP PSR6 == 5; PlayPL 100 else 200.
|
||||
// With the write refused, PSR6 stays at its power-on 0, so the compare is
|
||||
// false and control skips to PlayPL 200. If the illegal PSR write took
|
||||
// effect, the compare would be true and PlayPL 100 would win.
|
||||
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 swap_immediate_operand_is_not_written_back() {
|
||||
// Seed GPR[5]=77, then SWAP dst=GPR0 (register) with src=IMMEDIATE 5
|
||||
// (imm_op1=0, imm_op2=1). The imm gate must refuse using the immediate
|
||||
// as a write target, so GPR[5] — the register the literal 5 would alias
|
||||
// — keeps 77; only GPR0 receives the immediate. Dropping either
|
||||
// `if !c.imm_op1` / `if !c.imm_op2` guard makes an 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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
+26
-426
@@ -1,10 +1,11 @@
|
||||
//! CLPI clip info parser — maps clips to sector ranges on disc.
|
||||
//! CLPI clip info parser (BD-ROM Clip Information).
|
||||
//!
|
||||
//! Each .clpi file in BDMV/CLIPINF/ describes one M2TS clip.
|
||||
//! The EP (Entry Point) map provides timestamp → SPN mapping.
|
||||
//! SPN × 192 = byte offset in the m2ts file.
|
||||
//!
|
||||
//! Reference: https://github.com/lw/BluRay/wiki/CLPI
|
||||
//! Each .clpi file in BDMV/CLIPINF/ describes one M2TS clip. This parser reads
|
||||
//! two things from it: `source_packet_count` (the clip's total 192-byte packet
|
||||
//! count) and the ProgramInfo stream table (PID/coding-type/language), which
|
||||
//! cross-validates the MPLS STN view (see `labels/clpi_audit.rs`). The CPI/EP
|
||||
//! map is NOT parsed — reading is whole-clip via UDF and trimming is done at the
|
||||
//! playlist/timeline (PTS) level, not by EP-map SPN→sector seeking.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
@@ -33,32 +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 gives an
|
||||
// empty streams list. EP map is unaffected — sector-range lookups
|
||||
// continue to work.
|
||||
// Parse ProgramInfo (per-stream language + codec), best-effort: malformed
|
||||
// program_info doesn't fail the parse, just yields an empty streams list.
|
||||
let streams = if prog_info_start > 0 && prog_info_start + 6 < data.len() {
|
||||
parse_program_info(&data[prog_info_start..])
|
||||
} else {
|
||||
@@ -71,31 +66,8 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the ProgramInfo section: per-stream (pid, coding_type,
|
||||
/// language, codec sub-fields). Layout per the Blu-ray Disc Read-Only Format
|
||||
/// Part 3 CLIPINF (CLPI) specification:
|
||||
///
|
||||
/// ```text
|
||||
/// ProgramInfo:
|
||||
/// length: 4 bytes
|
||||
/// reserved: 1 byte
|
||||
/// num_programs: 1 byte
|
||||
/// for each program:
|
||||
/// spn_program_sequence_start: 4 bytes
|
||||
/// program_map_pid: 2 bytes
|
||||
/// num_streams: 1 byte
|
||||
/// num_groups: 1 byte
|
||||
/// for each stream:
|
||||
/// pid: 2 bytes
|
||||
/// stream_coding_info_length: 1 byte
|
||||
/// stream_coding_info: (varies by coding_type)
|
||||
/// coding_type: 1 byte
|
||||
/// per-type bytes (see match arms below)
|
||||
/// ```
|
||||
///
|
||||
/// Returns `Vec::new()` on any structural mismatch — we don't propagate
|
||||
/// errors because the EP map is the primary CLPI output, and a corrupt
|
||||
/// program_info shouldn't break sector-range lookups.
|
||||
// Parse the ProgramInfo section: per-stream (pid, coding_type, language, codec sub-fields).
|
||||
// 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();
|
||||
@@ -135,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 => {
|
||||
@@ -166,376 +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> {
|
||||
// We need at least 60 bytes for the header area.
|
||||
// Offsets:
|
||||
// 0..4: "HDMV"
|
||||
// 4..8: "0200"
|
||||
// 8..12: seq_info_start (unused, set to 0)
|
||||
// 12..16: prog_info_start (unused, set to 0)
|
||||
// 16..20: cpi_start
|
||||
// 20..40: reserved/padding
|
||||
// 40..56: ClipInfo section area (length + stuff before source_packet_count)
|
||||
// 56..60: 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
|
||||
// (https://github.com/lw/BluRay/wiki/CLPI).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a ProgramInfo section. `streams` = Vec<(pid, sci_bytes)>.
|
||||
/// Layout per source doc: length(4)+reserved(1)+num_programs(1)+
|
||||
/// per program [spn(4)+pmt_pid(2)+num_streams(1)+num_groups(1)] then
|
||||
/// per stream [pid(2)+sci_len(1)+sci].
|
||||
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");
|
||||
}
|
||||
|
||||
/// parse_program_info is best-effort: a stream whose declared sci_len
|
||||
/// runs past the section (`sci_end > data.len()`) makes it return the
|
||||
/// streams collected so far (here: none), never panic. Source returns
|
||||
/// `out` early on the 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`.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A prog_info_start of 0 means "no ProgramInfo section" — the CLPI
|
||||
/// header bytes at offset 0 must NOT be reinterpreted as a ProgramInfo
|
||||
/// table. The fixture is crafted so that parsing from offset 0 WOULD
|
||||
/// yield a stream (num_programs at [5], a second program header whose
|
||||
/// num_streams byte at [20] is 1, then a well-formed stream record), so
|
||||
/// the empty result can only come from the `prog_info_start > 0` gate.
|
||||
#[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 AC-3+ secondary, 0xA2 DTS-HD secondary) has the
|
||||
/// same stream_coding_info layout as primary audio: sci[1] carries
|
||||
/// audio_presentation_type in the high nibble and sampling_frequency in
|
||||
/// the low nibble, sci[2..5] the ISO 639-2 language. Both sub-fields and
|
||||
/// the language 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 stream_coding_info of exactly 1 byte (coding_type only) is the
|
||||
/// minimum the parser accepts: the stream is recorded with its PID and
|
||||
/// coding_type, and every sub-field that needs more bytes stays empty.
|
||||
/// Notably 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;
|
||||
|
||||
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
+11
-14
@@ -49,18 +49,11 @@ pub const BD_SOURCE_PACKET_BYTES: usize = TS_PACKET_BYTES + BD_TIMESTAMP_PREFIX_
|
||||
/// Elementary-stream coding-type codes — the single source of truth for the
|
||||
/// byte that identifies a stream's codec.
|
||||
///
|
||||
/// This is one registry used in two places that share the same value space:
|
||||
/// the MPEG-TS PMT `stream_type` (ISO/IEC 13818-1 Table 2-34) and the Blu-ray
|
||||
/// STN/CLPI `stream_coding_type` (BD-ROM Part 3). The standardized video codes
|
||||
/// (`0x02`, `0x1B`, `0x24`) are ISO assignments (ISO/IEC 13818-1 Table 2-34);
|
||||
/// `0xEA` (VC-1) is a BD-ROM convention in the ISO user-private range. The
|
||||
/// `0x80..=0xA2` audio/graphics codes also sit in the user-private range and follow the
|
||||
/// Blu-ray Disc Association / ATSC A/52 convention. Because every consumer
|
||||
/// reads or writes this single byte, the family is unprefixed — the scope is
|
||||
/// "any elementary stream freemkv parses or muxes".
|
||||
///
|
||||
/// Each constant is `u8`: the spec defines an 8-bit field and the code compares
|
||||
/// it directly against a byte read from the buffer, so no casts are needed.
|
||||
/// Shared registry for the MPEG-TS PMT `stream_type` and the Blu-ray STN/CLPI
|
||||
/// `stream_coding_type`, which use the same value space. Unprefixed because
|
||||
/// every consumer of either spec reads or writes this one byte. Each constant
|
||||
/// is `u8`: the spec defines an 8-bit field, so the code compares it directly
|
||||
/// against a buffer byte with no casts.
|
||||
pub mod coding_type {
|
||||
/// MPEG-2 video (ISO/IEC 13818-1 Table 2-34).
|
||||
pub const MPEG2_VIDEO: u8 = 0x02;
|
||||
@@ -112,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.
|
||||
@@ -125,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:
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! CSS title-key recovery — a divide-and-conquer known-plaintext attack,
|
||||
//! implemented from the openly published CSS cryptanalysis literature. It
|
||||
//! recovers the 5-byte CSS title key from a single scrambled DVD sector with
|
||||
//! no player keys and no disc-key crack, using only known plaintext.
|
||||
//! Implemented from that public description; nothing here is copied or
|
||||
//! translated from any particular CSS software.
|
||||
|
||||
use super::lfsr::descramble_sector;
|
||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
const ENCRYPTED_START: usize = 0x80; // byte 128
|
||||
const SEED_OFFSET: usize = 0x54; // sector seed at bytes 0x54-0x58
|
||||
const FLAG_BYTE: usize = 0x14;
|
||||
|
||||
/// LFSR0's output tap: the 24-bit register folded down to the byte it feeds
|
||||
/// into TAB4. Used both to validate a candidate against the keystream and to
|
||||
/// re-derive the byte shifted in during the backward-clocking search.
|
||||
fn lfsr0_output_tap(x: u32) -> u32 {
|
||||
(((((((x >> 3) ^ x) >> 1) ^ x) >> 8) ^ x) >> 5) & 0xff
|
||||
}
|
||||
|
||||
// Recover the title key from cipher + known plaintext. `seed` is sector[0x54..0x59].
|
||||
fn recover_title_key_from_plain(
|
||||
crypted: &[u8],
|
||||
decrypted: &[u8],
|
||||
seed: &[u8; 5],
|
||||
) -> Option<[u8; 5]> {
|
||||
if crypted.len() < 10 || decrypted.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// buf[i] = TAB1[cipher[i]] ^ plain[i] — the per-byte content keystream.
|
||||
let mut buffer = [0u8; 10];
|
||||
for (i, b) in buffer.iter_mut().enumerate() {
|
||||
*b = TAB1[crypted[i] as usize] ^ decrypted[i];
|
||||
}
|
||||
|
||||
let mut key = [0u8; 5];
|
||||
let mut found = false;
|
||||
|
||||
for i_try in 0u32..0x1_0000 {
|
||||
let mut i_t1 = (i_try >> 8) | 0x100;
|
||||
let mut i_t2 = i_try & 0xff;
|
||||
let mut i_t3: u32 = 0; // not needed yet
|
||||
let mut i_t5: u32 = 0;
|
||||
|
||||
// Iterate the cipher 4 times to reconstruct LFSR0 (i_t3).
|
||||
for &b in buffer.iter().take(4) {
|
||||
let i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||
i_t2 = i_t1 >> 1;
|
||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||
let i_t4 = TAB5[i_t4 as usize] as u32;
|
||||
|
||||
// Deduce i_t6 (LFSR0 output, pre-TAB4) and the carry.
|
||||
let mut i_t6 = b as u32;
|
||||
if i_t5 != 0 {
|
||||
i_t6 = (i_t6 + 0xff) & 0xff;
|
||||
}
|
||||
if i_t6 < i_t4 {
|
||||
i_t6 += 0x100;
|
||||
}
|
||||
i_t6 -= i_t4;
|
||||
i_t5 += i_t6 + i_t4;
|
||||
let i_t6 = TAB4[i_t6 as usize] as u32;
|
||||
|
||||
i_t3 = (i_t3 << 8) | i_t6;
|
||||
i_t5 >>= 8;
|
||||
}
|
||||
|
||||
let i_candidate = i_t3;
|
||||
|
||||
// Iterate 6 more times to validate the candidate.
|
||||
let mut i = 4usize;
|
||||
while i < 10 {
|
||||
let i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||
i_t2 = i_t1 >> 1;
|
||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||
let i_t4 = TAB5[i_t4 as usize] as u32;
|
||||
let mut i_t6 = lfsr0_output_tap(i_t3);
|
||||
i_t3 = (i_t3 << 8) | i_t6;
|
||||
i_t6 = TAB4[i_t6 as usize] as u32;
|
||||
i_t5 += i_t6 + i_t4;
|
||||
if (i_t5 & 0xff) as u8 != buffer[i] {
|
||||
break;
|
||||
}
|
||||
i_t5 >>= 8;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if i != 10 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Four backward steps of iterating i_t3 to deduce the initial state.
|
||||
i_t3 = i_candidate;
|
||||
for _ in 0..4 {
|
||||
let i_t1_byte = i_t3 & 0xff;
|
||||
i_t3 >>= 8;
|
||||
// Brute-force the byte shifted in (top byte of the 24-bit reg).
|
||||
for j in 0u32..256 {
|
||||
i_t3 = (i_t3 & 0x1_ffff) | (j << 17);
|
||||
let i_t6 = lfsr0_output_tap(i_t3);
|
||||
if i_t6 == i_t1_byte {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Undo `i_t3 = i_t3*2 + 8 - (i_t3 & 7)` to recover key[2..5].
|
||||
let i_t4 = (i_t3 >> 1).wrapping_sub(4);
|
||||
for i_t5 in 0u32..8 {
|
||||
let val = i_t4.wrapping_add(i_t5);
|
||||
if val.wrapping_mul(2).wrapping_add(8).wrapping_sub(val & 7) == i_t3 {
|
||||
key[0] = (i_try >> 8) as u8;
|
||||
key[1] = (i_try & 0xff) as u8;
|
||||
key[2] = (val & 0xff) as u8;
|
||||
key[3] = ((val >> 8) & 0xff) as u8;
|
||||
key[4] = ((val >> 16) & 0xff) as u8;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// First fully-validated candidate wins. The 48-bit keystream constraint
|
||||
// makes a second match cryptographically negligible on real sectors, but
|
||||
// continuing would let a later spurious match overwrite a correct key.
|
||||
if found {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
for (k, &s) in key.iter_mut().zip(seed.iter()) {
|
||||
*k ^= s;
|
||||
}
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover the CSS title key from a scrambled sector via known plaintext.
|
||||
///
|
||||
/// `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: 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;
|
||||
}
|
||||
if sector[FLAG_BYTE] & 0x30 == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let seed = sector_seed(sector);
|
||||
|
||||
let crypted = §or[ENCRYPTED_START..ENCRYPTED_START + 10];
|
||||
let key = recover_title_key_from_plain(crypted, plain, &seed)?;
|
||||
|
||||
if descramble_matches(sector, &key, plain) {
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn sector_seed(sector: &[u8]) -> [u8; 5] {
|
||||
let mut seed = [0u8; 5];
|
||||
seed.copy_from_slice(§or[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 {
|
||||
let mut test = sector.to_vec();
|
||||
test[FLAG_BYTE] |= 0x10; // ensure scramble flag set for the descrambler
|
||||
descramble_sector(title, &mut test);
|
||||
let n = plain.len().min(SECTOR_BYTES - ENCRYPTED_START);
|
||||
test[ENCRYPTED_START..ENCRYPTED_START + n] == plain[..n]
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
if sector[FLAG_BYTE] & 0x30 == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Runaway guard: this crack is a bounded 2^16 LFSR search, done well under 1s
|
||||
// on any modern CPU. If it ever exceeds ~2s, something pathological is
|
||||
// happening — log it so a hang is never silent.
|
||||
let crack_t0 = std::time::Instant::now();
|
||||
|
||||
let result = crack_title_key_inner(sector);
|
||||
|
||||
let elapsed = crack_t0.elapsed();
|
||||
if elapsed.as_secs_f64() > 2.0 {
|
||||
tracing::warn!(
|
||||
target: "freemkv::css",
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
found = result.is_some(),
|
||||
"css crack: single-sector recovery exceeded 2s (runaway guard)"
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// Crib: the predicted 10-byte plaintext at byte 0x80, from the longest periodic run in the
|
||||
// clear header. Also the decrypt path's cached-key oracle.
|
||||
pub(crate) fn attack_crib(sector: &[u8]) -> Option<[u8; 10]> {
|
||||
if sector.len() < SECTOR_BYTES || sector[FLAG_BYTE] & 0x30 == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut best_plen: usize = 0;
|
||||
let mut best_p: usize = 0;
|
||||
|
||||
// For all cycle lengths from 2 to 0x2F.
|
||||
for i in 2usize..0x30 {
|
||||
// Count bytes that repeat with cycle length i, scanning backward from
|
||||
// 0x7F. `sec[0x7F - (j % i)] == sec[0x7F - j]`.
|
||||
let mut j = i + 1;
|
||||
while j < 0x80 && sector[0x7f - (j % i)] == sector[0x7f - j] {
|
||||
if j > best_plen {
|
||||
best_plen = j;
|
||||
best_p = i;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Need at least a few repeated bytes and at least one full cycle.
|
||||
if best_plen > 3 && best_p > 0 && best_plen / best_p >= 2 {
|
||||
// Crib starts at `0x80 - cycles*best_p`; bytes at/after 0x80 are
|
||||
// predicted by the run repeating with period best_p.
|
||||
let cycles = best_plen / best_p;
|
||||
let plain_start = 0x80 - cycles * best_p;
|
||||
|
||||
// Must wrap within the period (not read `&sec[plain_start..+10]`
|
||||
// directly), since past 0x80 the raw byte is ciphertext, not plaintext.
|
||||
let mut plain = [0u8; 10];
|
||||
for (i, p) in plain.iter_mut().enumerate() {
|
||||
*p = sector[plain_start + (i % best_p)];
|
||||
}
|
||||
Some(plain)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn crack_title_key_inner(sector: &[u8]) -> Option<[u8; 5]> {
|
||||
let plain = attack_crib(sector)?;
|
||||
let seed = sector_seed(sector);
|
||||
let crypted = §or[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) {
|
||||
return Some(key);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "keyless_tests.rs"]
|
||||
mod tests;
|
||||
@@ -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(§or).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crack_too_short_returns_none() {
|
||||
let sector = vec![0u8; 100];
|
||||
assert!(crack_title_key(§or).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_needs_min_plain() {
|
||||
let sector = vec![0u8; SECTOR_BYTES];
|
||||
let short_plain = [0u8; 4];
|
||||
assert!(recover_title_key(§or, &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(§or, &PES).expect("recover_title_key returned None");
|
||||
descramble_sector(&recovered, &mut sector);
|
||||
assert_eq!(
|
||||
§or[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(§or, &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(§or, &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(§or, &title_key, &PES));
|
||||
let mut wrong = title_key;
|
||||
wrong[0] ^= 0x01;
|
||||
assert!(!descramble_matches(§or, &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(§or)
|
||||
.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(§or, &PES).expect("recover_title_key returned None");
|
||||
descramble_sector(&recovered, &mut sector);
|
||||
assert_eq!(
|
||||
§or[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(§or, &PES).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_rejects_unscrambled_sector() {
|
||||
let sector = vec![0x00u8; SECTOR_BYTES];
|
||||
assert!(recover_title_key(§or, &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(§or, &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(§or).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(§or).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(§or);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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(§or, &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(§or, &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(§or, &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(§or, &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(§or, &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(§or, &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(§or, &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(§or, &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(§or),
|
||||
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(§or).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(§or_with_trailing_run(8, 9)), None);
|
||||
// period 0x18, run of 0x19 bytes: one cycle.
|
||||
assert_eq!(attack_crib(§or_with_trailing_run(0x18, 0x19)), None);
|
||||
// ...and one more byte of run does not conjure a second cycle either.
|
||||
assert_eq!(attack_crib(§or_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(§or), None);
|
||||
// And the cracker built on it reports no key rather than guessing.
|
||||
assert_eq!(crack_title_key(§or), 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(§or),
|
||||
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(§or),
|
||||
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(§or[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 = §or[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-433
@@ -1,51 +1,24 @@
|
||||
//! CSS content cipher — an independent implementation of the publicly
|
||||
//! documented Content Scramble System stream cipher.
|
||||
//! CSS content cipher (constants in [`super::tables`]). Two table-driven
|
||||
//! linear-feedback circuits:
|
||||
//! - **LFSR1** — 17-bit register seeded from `key[0..2] XOR seed[0..2]`,
|
||||
//! stepped through `TAB2`/`TAB3`/`TAB5`.
|
||||
//! - **LFSR0** — 24-bit register seeded from `key[2..5] XOR seed[2..5]`,
|
||||
//! stepped through a feedback polynomial and `TAB4`.
|
||||
//!
|
||||
//! The algorithm is the one recovered and published in Frank A. Stevenson's
|
||||
//! 1999 cryptanalysis ("Cryptanalysis of Contents Scrambling System") and
|
||||
//! described in the open CSS literature. It is implemented here from that public
|
||||
//! description; its constants (see [`super::tables`]) are the cipher's own
|
||||
//! defined values. Nothing in this file is copied or translated from any
|
||||
//! particular CSS software.
|
||||
//!
|
||||
//! The cipher uses two table-driven linear-feedback circuits:
|
||||
//! - **LFSR1** — a 17-bit register (a 9-bit and an 8-bit half seeded from
|
||||
//! `key[0..2] XOR seed[0..2]`), stepped through `TAB2`/`TAB3`/`TAB5`.
|
||||
//! - **LFSR0** — a 24-bit feedback register (seeded from `key[2..5] XOR
|
||||
//! seed[2..5]`), stepped through a feedback polynomial and `TAB4`.
|
||||
//!
|
||||
//! Each output byte is the sum-with-carry of the two register outputs. A body
|
||||
//! byte is recovered as `plain = TAB1[cipher] ^ keystream` — a `TAB1`
|
||||
//! substitution of the ciphertext byte followed by an XOR with the keystream
|
||||
//! (so the cipher is deliberately not its own inverse).
|
||||
//! Output byte = sum-with-carry of both registers; body byte recovered as `plain = TAB1[cipher]
|
||||
//! ^ keystream`.
|
||||
|
||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
|
||||
/// Descramble a CSS-encrypted DVD sector in place.
|
||||
///
|
||||
/// The two feedback registers are seeded **directly** from
|
||||
/// `title_key XOR sector_seed` (bytes `0x54..0x59`) — there is no title-key
|
||||
/// mangling on the content path (that belongs to the disc/title-key hierarchy,
|
||||
/// not the sector cipher). Only the body, bytes `0x80..0x800`, is transformed:
|
||||
/// `body[i] = TAB1[body[i]] ^ (keystream & 0xff)`.
|
||||
/// 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.
|
||||
///
|
||||
/// The scramble flag at byte `0x14` (bits 4-5) marks an encrypted sector. This
|
||||
/// routine CLEARS that flag after unscrambling, so a descrambled sector reads as
|
||||
/// `sector[0x14] & 0x30 == 0`; callers and tests use that to tell it from
|
||||
/// ciphertext, and re-running descramble on an already-cleared sector is a no-op
|
||||
/// (the flag guard below skips it). Clearing does not affect the recovered body.
|
||||
///
|
||||
/// No-op (returns without modifying `sector`) in two cases:
|
||||
/// - `sector.len() < 2048`: the encrypted region (`0x80..0x800`) is not fully
|
||||
/// present. Callers chunk by 2048, so a trailing partial chunk is left
|
||||
/// untouched. The `debug_assert!` flags this misuse in debug/test builds; a
|
||||
/// DVD sector is always exactly 2048 bytes.
|
||||
/// - scramble flags are zero: the sector is not CSS-encrypted.
|
||||
/// 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;
|
||||
}
|
||||
@@ -96,18 +69,7 @@ pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
sector[0x14] &= 0xCF;
|
||||
}
|
||||
|
||||
/// Exact inverse of [`descramble_sector`]: turn a plaintext sector body into
|
||||
/// CSS ciphertext under `title_key`.
|
||||
///
|
||||
/// Descramble computes `plain = TAB1[cipher] ^ (keystream & 0xff)`, so the
|
||||
/// inverse is `cipher = TAB1_INV[plain ^ (keystream & 0xff)]` with the identical
|
||||
/// keystream. The keystream derivation is the same as [`descramble_sector`];
|
||||
/// only the final substitution differs. Bytes `0x80..0x800` are rewritten in
|
||||
/// place; the scramble flag is set to `0x10` so a subsequent descramble runs.
|
||||
///
|
||||
/// Not on any production read path — it exists so the key-recovery tests (and
|
||||
/// any caller that needs a known CSS-encrypted sector) can build genuine
|
||||
/// ciphertext rather than approximating it.
|
||||
// Exact inverse of descramble_sector, for test use only (builds known ciphertext).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn scramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
if sector.len() < 2048 {
|
||||
@@ -157,384 +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: the deterministic output of the CSS content cipher for
|
||||
/// a fixed key/seed/body. The value is generated by this implementation and
|
||||
/// is self-consistent with the scramble/descramble round-trip below — any
|
||||
/// correct CSS descrambler yields the same bytes, since the cipher is
|
||||
/// deterministic. Pins the implementation against accidental change.
|
||||
///
|
||||
/// key = 42 13 37 BE EF, seed (0x54..0x59) = DE AD BE EF 42, body = 0xAA.
|
||||
#[test]
|
||||
fn descramble_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!(
|
||||
§or[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!(
|
||||
§or[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!(§or[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);
|
||||
}
|
||||
|
||||
/// Test 2: descramble inverts scramble over the body.
|
||||
///
|
||||
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to the
|
||||
/// ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
|
||||
/// [`scramble_sector`]. Scrambling a plaintext body and then descrambling
|
||||
/// with the same key must reproduce the original body exactly.
|
||||
#[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!(§or[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!(
|
||||
§or[128..2048],
|
||||
&original[128..2048],
|
||||
"descramble(scramble(body)) did not restore the body"
|
||||
);
|
||||
}
|
||||
|
||||
/// css_tab1_relationship
|
||||
///
|
||||
/// Verify the structure of TAB1: it is a substitution table used in key
|
||||
/// mangling. Check that no two inputs map to the same output (TAB1 is a
|
||||
/// permutation of 0..255).
|
||||
#[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 of byte 0x14 are the CSS scramble flag: the code reads
|
||||
/// `sector[0x14] & 0x30 == 0` (bits 6-7, i.e. 0x40/0x80, are masked out by
|
||||
/// 0x30). A sector with 0x14 == 0x40 or 0x80 must therefore be treated as
|
||||
/// UNSCRAMBLED and left byte-for-byte unchanged. This guards against a
|
||||
/// too-wide mask silently "descrambling" (and thus corrupting) clear data.
|
||||
///
|
||||
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live in
|
||||
/// bits 4-5; the masked value 0 means not scrambled.
|
||||
/// Mutation: widen the mask `0x30` to `0x70`/`0xF0` -> 0x40/0x80 would be
|
||||
/// seen as scrambled and the body would change.
|
||||
#[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 individual scramble bit (4 and 5) independently marks the sector as
|
||||
/// encrypted: 0x10 and 0x20 must both trigger descrambling.
|
||||
///
|
||||
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both nonzero.
|
||||
/// Mutation: change `!= 0` early-return condition to `== 3` -> a sector
|
||||
/// flagged only 0x10 or 0x20 would be skipped and left scrambled.
|
||||
#[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!(
|
||||
§or[128..256],
|
||||
&original[128..256],
|
||||
"flag {flag:#04x} (bits 4-5 nonzero) must descramble the body"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// After descrambling, ONLY the two scramble bits are cleared (`& 0xCF`);
|
||||
/// bits 6 and 7 of byte 0x14 must be preserved. A sector with 0x14 == 0xF0
|
||||
/// becomes 0xC0 (bits 6,7 kept, bits 4,5 cleared), NOT 0x00.
|
||||
///
|
||||
/// Grounding: code does `sector[0x14] &= 0xCF`; 0xF0 & 0xCF == 0xC0.
|
||||
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved high
|
||||
/// bits assert fails.
|
||||
#[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) ───────────
|
||||
|
||||
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80 (the
|
||||
/// header) must NOT be modified by the keystream — except byte 0x14 whose
|
||||
/// flag is cleared. In particular the sector-seed bytes 0x54..0x59 (which
|
||||
/// live inside the header) must survive untouched, since the descrambler
|
||||
/// reads them but never writes them.
|
||||
///
|
||||
/// Grounding: loop is `sector.iter_mut().take(2048).skip(128)` -> indices
|
||||
/// 128..2048 only.
|
||||
/// Mutation: change `.skip(128)` to `.skip(0)` -> header bytes (incl. the
|
||||
/// seed) get XORed and this fails.
|
||||
#[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!(§or[0x54..0x59], &seed, "sector seed must survive");
|
||||
}
|
||||
|
||||
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a prefix.
|
||||
/// With a constant body and constant key, the keystream is non-degenerate
|
||||
/// enough that the very last sector byte (index 2047) is altered. This guards
|
||||
/// the loop bound `.take(2048)` against an off-by-one that would leave the
|
||||
/// final byte(s) scrambled.
|
||||
///
|
||||
/// Grounding: encrypted region end is 0x800 == 2048 (exclusive).
|
||||
/// Mutation: change `.take(2048)` to `.take(2047)` -> last byte unchanged,
|
||||
/// assert fires (this body is all-zero so any keystream XOR shows).
|
||||
#[test]
|
||||
fn descramble_covers_final_body_byte() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
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!(
|
||||
§or[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` is a
|
||||
/// no-op below one sector, and processes the FIRST sector of anything at
|
||||
/// least that long (the loop is `.take(2048)`). `css::descramble_sector` is
|
||||
/// a public entry taking `&mut [u8]` of any length, so a caller handing it a
|
||||
/// multi-sector buffer must get its first sector descrambled — a guard that
|
||||
/// rejected over-long buffers would hand that caller its ciphertext back
|
||||
/// unchanged, with the scramble flag cleared as if it had worked.
|
||||
#[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
|
||||
/// produce two different bodies for the same scrambled input. A cipher that
|
||||
/// ignored the title key (or mixed it in wrongly) would yield identical
|
||||
/// output — silent wrong-key decryption.
|
||||
///
|
||||
/// Grounding: per-sector key = title_key[i] ^ sector[0x54+i].
|
||||
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both keys
|
||||
/// give the same body, assert fires.
|
||||
#[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. Pins that bytes 0x54..0x59 actually feed the
|
||||
/// keystream (not just the per-sector XOR key).
|
||||
///
|
||||
/// Mutation: replace `seed` array reads with a constant -> both seeds give
|
||||
/// the same body, assert fires.
|
||||
#[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;
|
||||
|
||||
@@ -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!(
|
||||
§or[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!(
|
||||
§or[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!(§or[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!(§or[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!(
|
||||
§or[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!(
|
||||
§or[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!(§or[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!(
|
||||
§or[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"
|
||||
);
|
||||
}
|
||||
+281
-1589
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+6
-153
@@ -4,7 +4,8 @@
|
||||
//! Content Scramble System. They are mathematical constants derived from
|
||||
//! the CSS specification, published in academic literature since 1999.
|
||||
|
||||
/// Table 1: byte substitution used in key mangling and sector seed processing.
|
||||
/// Table 1: content-cipher substitution table, `plain = TAB1[cipher] ^ keystream`
|
||||
/// (used by descramble/recover in lfsr.rs and keyless.rs).
|
||||
pub const TAB1: [u8; 256] = [
|
||||
0x33, 0x73, 0x3b, 0x26, 0x63, 0x23, 0x6b, 0x76, 0x3e, 0x7e, 0x36, 0x2b, 0x6e, 0x2e, 0x66, 0x7b,
|
||||
0xd3, 0x93, 0xdb, 0x06, 0x43, 0x03, 0x4b, 0x96, 0xde, 0x9e, 0xd6, 0x0b, 0x4e, 0x0e, 0x46, 0x9b,
|
||||
@@ -88,7 +89,7 @@ pub const TAB3: [u8; 512] = [
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
];
|
||||
|
||||
/// Table 4: LFSR0 byte permutation (used in initialization and output).
|
||||
/// Table 4: permutes the LFSR0 output byte (`o0 = TAB4[o0]`).
|
||||
pub const TAB4: [u8; 256] = [
|
||||
0x00, 0x80, 0x40, 0xc0, 0x20, 0xa0, 0x60, 0xe0, 0x10, 0x90, 0x50, 0xd0, 0x30, 0xb0, 0x70, 0xf0,
|
||||
0x08, 0x88, 0x48, 0xc8, 0x28, 0xa8, 0x68, 0xe8, 0x18, 0x98, 0x58, 0xd8, 0x38, 0xb8, 0x78, 0xf8,
|
||||
@@ -111,7 +112,7 @@ pub const TAB4: [u8; 256] = [
|
||||
/// Table 5: LFSR1 output permutation used in the keystream combiner.
|
||||
/// `TAB5[i] == TAB4[i] ^ 0xFF` (bitwise complement of the TAB4 bit-reversal
|
||||
/// table). Applied on the normal descramble/recrypt path (lfsr.rs) as well as
|
||||
/// in the key-recovery fallback (crack.rs).
|
||||
/// in the key-recovery fallback ([`super::keyless`]).
|
||||
pub const TAB5: [u8; 256] = [
|
||||
0xff, 0x7f, 0xbf, 0x3f, 0xdf, 0x5f, 0x9f, 0x1f, 0xef, 0x6f, 0xaf, 0x2f, 0xcf, 0x4f, 0x8f, 0x0f,
|
||||
0xf7, 0x77, 0xb7, 0x37, 0xd7, 0x57, 0x97, 0x17, 0xe7, 0x67, 0xa7, 0x27, 0xc7, 0x47, 0x87, 0x07,
|
||||
@@ -132,153 +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's chained-XOR rounds; if two inputs
|
||||
/// collided, the key mangling would not be invertible.
|
||||
///
|
||||
/// Mutation: duplicate any value (e.g. set TAB1[1] = TAB1[0]) -> the
|
||||
/// "maps two inputs" assert fires.
|
||||
#[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 from the CSS spec table:
|
||||
/// TAB1[0x00] == 0x33 and the inverse TAB1[0x33] == 0x00. These two
|
||||
/// entries are the canonical first-row / inverse-lookup landmarks of the
|
||||
/// published CSS TAB1 and pin the table's orientation.
|
||||
///
|
||||
/// Grounding: CSS specification TAB1, row 0 col 0 = 0x33; index 0x33
|
||||
/// (row 3 col 3) = 0x00.
|
||||
/// Mutation: change the first literal `0x33` in TAB1 -> first assert fails.
|
||||
#[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 (it is the LFSR1 high-byte feedback
|
||||
/// substitution). A non-bijective TAB2 would bias the LFSR1 keystream.
|
||||
///
|
||||
/// Mutation: set TAB2[8] = 0x00 (collides with TAB2[0]) -> assert fires.
|
||||
#[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 the CSS LFSR1 low-word table: the 8-value feedback block
|
||||
/// BASE = [0x00,0x24,0x49,0x6d,0x92,0xb6,0xdb,0xff]
|
||||
/// repeated 64 times — `TAB3[i] == BASE[i & 7]`. The high bits of the
|
||||
/// 9-bit index do not affect the output (the LFSR1 step indexes with the
|
||||
/// full 9-bit low register but only `& 7` matters). This pins all 512
|
||||
/// entries to the published cipher's table.
|
||||
///
|
||||
/// Mutation: flip any single byte in the TAB3 literal -> the formula
|
||||
/// check fails at that index.
|
||||
#[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 uses it to permute
|
||||
/// LFSR0 bytes on seed and output). TAB4[b] reverses b's 8 bits MSB<->LSB.
|
||||
/// Therefore it is also an involution: TAB4[TAB4[b]] == b.
|
||||
///
|
||||
/// Grounding: TAB4[0x01]=0x80, TAB4[0x80]=0x01, TAB4[0x00]=0x00,
|
||||
/// TAB4[0xFF]=0xFF.
|
||||
/// Mutation: set TAB4[1] = 0x40 (not the reversal 0x80) -> bit-reversal
|
||||
/// check fails at index 1.
|
||||
#[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: a table that is "reversal except two swapped entries"
|
||||
/// would still be a permutation, and a table that is "reversal except one
|
||||
/// duplicated entry" would fail this but might pass a sampled reversal
|
||||
/// check — the two tests pin different failure modes.
|
||||
///
|
||||
/// Mutation: set TAB4[2] = TAB4[1] -> permutation assert fires.
|
||||
#[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 also a permutation (complement of a bijection is a bijection)
|
||||
/// and its own self-consistency landmark: TAB5[0x00] == 0xFF (TAB4[0]^0xFF)
|
||||
/// and TAB5[0xFF] == 0x00 (TAB4[0xFF]^0xFF). Pins orientation independent
|
||||
/// of the complement-loop test.
|
||||
///
|
||||
/// Mutation: change the first TAB5 literal 0xff -> 0xfe -> the landmark
|
||||
/// and permutation checks both catch it.
|
||||
#[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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
+517
-1545
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
Reference in New Issue
Block a user