mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f03c6db06 | ||
|
|
562bb8bee5 | ||
|
|
7c13ed32b5 | ||
|
|
574aa2c506 | ||
|
|
3912762b52 | ||
|
|
028768e71b | ||
|
|
bf4e5ac395 | ||
|
|
11948d513b | ||
|
|
7d5c0a5c41 | ||
|
|
1ede962aec | ||
|
|
014ea870a4 | ||
|
|
bdd2876d78 | ||
|
|
44de7a8d12 | ||
|
|
552076a943 | ||
|
|
3ecbeb7eba | ||
|
|
b405154fd0 | ||
|
|
8a630c8839 | ||
|
|
6c00090308 | ||
|
|
7f9f5a9d3e | ||
|
|
b3c23c19ee | ||
|
|
f3923cf587 | ||
|
|
002912abb7 | ||
|
|
b5fd193d54 | ||
|
|
49011d577f | ||
|
|
f8e39abe54 | ||
|
|
65d719ee5d | ||
|
|
c3bdbdcfc9 | ||
|
|
3f4e69f7b8 | ||
|
|
89b03610e7 | ||
|
|
a7cbb3afcd | ||
|
|
f75766b4d5 | ||
|
|
1c313fe882 | ||
|
|
f7d38caf0b | ||
|
|
e98e98c8fd | ||
|
|
7c085db133 | ||
|
|
587e26353c | ||
|
|
3eb3e649b1 | ||
|
|
b01505983c | ||
|
|
87bc07dad8 | ||
|
|
743c5830b6 | ||
|
|
80c8e4ca24 | ||
|
|
f49e79c198 | ||
|
|
231cbf3d76 | ||
|
|
21c7b12e2b | ||
|
|
799039df21 | ||
|
|
497b7726dc | ||
|
|
94b549a9d8 | ||
|
|
fc2d35a19c | ||
|
|
3c51190c32 | ||
|
|
dfe0390445 | ||
|
|
8678a2bf94 | ||
|
|
d953b46023 | ||
|
|
443ac6bcb1 | ||
|
|
794393f24e | ||
|
|
c6e2614c4c | ||
|
|
b02c402dae | ||
|
|
128e3bb120 | ||
|
|
dc100f1c80 | ||
|
|
286b5d4527 | ||
|
|
d9f3f82c65 | ||
|
|
e30209091e | ||
|
|
ac46e2382f | ||
|
|
862c1460c3 | ||
|
|
fea64d07e2 | ||
|
|
6e8da15c34 | ||
|
|
8898351cb1 | ||
|
|
7a03be5496 | ||
|
|
de94e12864 | ||
|
|
21ec1d3f80 | ||
|
|
6658a80819 | ||
|
|
3db11a192c | ||
|
|
a425a71cd5 | ||
|
|
f79745d815 | ||
|
|
5d8f77e049 | ||
|
|
79a16a9279 | ||
|
|
219a77b581 | ||
|
|
be378cbc34 | ||
|
|
63200a1d1f | ||
|
|
526dd3552b | ||
|
|
8d17904459 | ||
|
|
1bdb8add2f | ||
|
|
c9562c07a3 | ||
|
|
7f9dcf1870 | ||
|
|
3c5c681399 | ||
|
|
43d9fb583a | ||
|
|
60ed45c047 | ||
|
|
07cc9fa5f9 | ||
|
|
1e6bb8babd | ||
|
|
64f894c9f3 | ||
|
|
3820348d2f | ||
|
|
102ae82933 | ||
|
|
9d05f54ddb | ||
|
|
47abd1270b | ||
|
|
96c23ffb87 | ||
|
|
f2b0e00bea | ||
|
|
bca886a9c3 | ||
|
|
7cda914f29 | ||
|
|
11538981de | ||
|
|
8edb53942c | ||
|
|
c718cd9e9e | ||
|
|
e32f874387 | ||
|
|
b1f9adc82b | ||
|
|
16a894f835 | ||
|
|
9bcd752216 | ||
|
|
c1cd2e4b06 | ||
|
|
cbafe71769 | ||
|
|
3c1375a245 | ||
|
|
651bfaaa2e | ||
|
|
cce4e5effe | ||
|
|
8e6f1511ea | ||
|
|
d68d73e68d | ||
|
|
648bcce5b6 | ||
|
|
45af2152a8 | ||
|
|
abbc725acf | ||
|
|
2e3ba374f3 | ||
|
|
84b4a6ddae | ||
|
|
3f4bf3f290 | ||
|
|
edb6f0035b | ||
|
|
5fc28056c3 | ||
|
|
51de027d15 | ||
|
|
96282ecda7 | ||
|
|
6e2ee9e002 | ||
|
|
0f8035b15a | ||
|
|
f67fd92b60 | ||
|
|
7ced0d9b61 | ||
|
|
c2c00a874c | ||
|
|
87463ed3ee | ||
|
|
0c5e419b07 | ||
|
|
218cd1f6f8 | ||
|
|
2c4851e6c1 | ||
|
|
7511adb64e | ||
|
|
84a6c0d373 | ||
|
|
7cc5b0c93d | ||
|
|
f4fc730d83 | ||
|
|
db0ca3367f | ||
|
|
cc870a4614 | ||
|
|
211d293aee | ||
|
|
abf47137ef | ||
|
|
00332e4dba | ||
|
|
6152028271 | ||
|
|
abe464d799 | ||
|
|
862c4e90ab | ||
|
|
7cfeadbcd3 | ||
|
|
9742fe00fd | ||
|
|
e814310a3e | ||
|
|
480c873be1 | ||
|
|
5043baaebe | ||
|
|
4d63a11d63 | ||
|
|
2310e65f44 | ||
|
|
4bd1c9210c | ||
|
|
bfce73e73c | ||
|
|
ca4e6f5a13 | ||
|
|
0158112c35 | ||
|
|
b55884675c | ||
|
|
455d953144 | ||
|
|
c8eafad5ef | ||
|
|
fe795fb472 | ||
|
|
5b095c56b6 | ||
|
|
383adacec3 | ||
|
|
5115eb14da | ||
|
|
4da6d3ab70 | ||
|
|
5f0cd2e3fe | ||
|
|
308063196a | ||
|
|
b14497dfba | ||
|
|
f6e167ff2d | ||
|
|
f982ce1046 | ||
|
|
f21cbdef80 | ||
|
|
7ad5cdaf41 | ||
|
|
af64089978 | ||
|
|
a2e6debac4 | ||
|
|
fd1918ef08 | ||
|
|
8636497505 | ||
|
|
e78c8e9802 | ||
|
|
d6542eda10 | ||
|
|
08bdd51588 | ||
|
|
9d19781b8a | ||
|
|
030f7c5f34 | ||
|
|
4dd0848d62 | ||
|
|
49d5ca2a67 | ||
|
|
f39762e09b | ||
|
|
126765ca19 | ||
|
|
bbc07ba437 | ||
|
|
b97b419f76 | ||
|
|
eac0040b95 | ||
|
|
a939b5b2e1 | ||
|
|
2ccbb9d417 | ||
|
|
2d06639963 | ||
|
|
56921af7c9 | ||
|
|
322381c429 | ||
|
|
309997f09f | ||
|
|
dc65517f55 | ||
|
|
20fd5c3f63 | ||
|
|
284c19f15a | ||
|
|
1f1a17d23f | ||
|
|
1ca47ce406 | ||
|
|
6889e6a032 | ||
|
|
794e757889 | ||
|
|
ddb36e2c15 | ||
|
|
530b4a1925 | ||
|
|
6153690a90 | ||
|
|
65dd9ce9dc | ||
|
|
f72a3d59a3 | ||
|
|
efae55fa2f | ||
|
|
ef934ed308 | ||
|
|
2c3e372e9b | ||
|
|
00abead473 | ||
|
|
f655befb51 | ||
|
|
a6585df923 | ||
|
|
823ec82872 | ||
|
|
5049e6515f | ||
|
|
ba3ce1e6a5 | ||
|
|
94f3d106f9 | ||
|
|
a24bf3b96d | ||
|
|
cc35b7af66 | ||
|
|
1faf2ce51b | ||
|
|
d0ca927a9e | ||
|
|
093c90481b | ||
|
|
cb8cd21b21 | ||
|
|
1fd612954b | ||
|
|
139abb721b | ||
|
|
2d9b648486 | ||
|
|
08da31883b | ||
|
|
871471edcf | ||
|
|
0890d84d4d | ||
|
|
f1c2b2c158 | ||
|
|
a8e7d46400 | ||
|
|
94f087d5ab | ||
|
|
d706d4b49e | ||
|
|
d7bf829b01 | ||
|
|
cb5a918daa | ||
|
|
66a3506df3 | ||
|
|
8e70f2a973 | ||
|
|
fc1f4db3a2 | ||
|
|
41e0982ec9 | ||
|
|
0469845c2d | ||
|
|
d72084e077 | ||
|
|
f2ca0e48b0 | ||
|
|
b79015f822 | ||
|
|
c7e43b2bc4 | ||
|
|
5b81c33c9a | ||
|
|
3ccee19101 | ||
|
|
337b57e9dc | ||
|
|
0e50b74211 | ||
|
|
c56b1d0b85 | ||
|
|
20e3aa26ca | ||
|
|
7e4c22e36c | ||
|
|
d628e24eaa | ||
|
|
9bea6ab52b | ||
|
|
0a88b61421 | ||
|
|
ef0cf028e4 | ||
|
|
766fb68e04 | ||
|
|
45fbaa8ee2 | ||
|
|
40ac369fb1 | ||
|
|
f5e65cf2da | ||
|
|
ade13a35de | ||
|
|
816a41a01f | ||
|
|
ae33563587 | ||
|
|
9a88efcc84 | ||
|
|
719a9077f3 | ||
|
|
de97c5a607 | ||
|
|
424b0fa32e | ||
|
|
81596e7735 | ||
|
|
dce6d7abdf | ||
|
|
6e9a53e151 | ||
|
|
75bcc71f34 | ||
|
|
de46268785 | ||
|
|
22f71bb243 | ||
|
|
c73d6c6291 | ||
|
|
41102bf023 | ||
|
|
07cb9ec62c | ||
|
|
455763587c | ||
|
|
4b58d3db98 | ||
|
|
d451d00f72 | ||
|
|
f6ba4823d1 | ||
|
|
85f2246b0f | ||
|
|
61f98535d8 | ||
|
|
36d0801f14 | ||
|
|
656c6f36d7 | ||
|
|
7d861f5287 | ||
|
|
032994be40 | ||
|
|
a61503502e | ||
|
|
e5e3b863fd | ||
|
|
36c1469026 | ||
|
|
4aff402e9d | ||
|
|
fae5282eb9 | ||
|
|
2398a57979 | ||
|
|
5ef391f029 | ||
|
|
ef36213331 | ||
|
|
4b13fc0a5c | ||
|
|
b2abff9792 | ||
|
|
77b8e520f8 | ||
|
|
5a4631a9e1 | ||
|
|
69d96e3f02 | ||
|
|
230fc0b322 | ||
|
|
d79cb0d6eb | ||
|
|
77d2198499 | ||
|
|
95c05d97c9 | ||
|
|
f40e01de4b | ||
|
|
5172803566 | ||
|
|
0c2dbdba6e | ||
|
|
1994f42ff8 | ||
|
|
4993f5df79 | ||
|
|
a10f43841f | ||
|
|
98fe02746b | ||
|
|
99ac4268b1 | ||
|
|
8737c5bb65 | ||
|
|
a124be8a76 | ||
|
|
8cc868ca54 | ||
|
|
e768d1666b | ||
|
|
1f643cf91a | ||
|
|
b299e5d8db | ||
|
|
ffc7f93aeb | ||
|
|
3c53eff257 | ||
|
|
f0dca35a91 | ||
|
|
4e0c0efbc3 | ||
|
|
ac51108e95 | ||
|
|
8c0eb665b3 | ||
|
|
5e33fa4771 | ||
|
|
a7b16e3a1e | ||
|
|
298262f6b3 | ||
|
|
2999daf1c9 | ||
|
|
31a87ae994 | ||
|
|
6be489a10a | ||
|
|
922a9d4403 | ||
|
|
b70da49f7f | ||
|
|
c52240b81f | ||
|
|
16663841d0 | ||
|
|
5670e77def | ||
|
|
0b44020d08 | ||
|
|
4c73256bdd | ||
|
|
925f4d56f6 | ||
|
|
4ba2c728b2 | ||
|
|
6f2e9a74bd | ||
|
|
071b65a64e | ||
|
|
b943f7511c | ||
|
|
8d730f3d27 | ||
|
|
6e15dbaec7 | ||
|
|
2cf42ec9f3 | ||
|
|
63334dcc8b | ||
|
|
07c0a5300a | ||
|
|
8cac6111a8 | ||
|
|
9c144d1980 | ||
|
|
b2ebae8f81 | ||
|
|
b2f35a8afc | ||
|
|
a6a4a5d7d2 | ||
|
|
0de6b82a37 | ||
|
|
b9b0a87328 | ||
|
|
d6cef9f665 | ||
|
|
b9b9185633 | ||
|
|
66951a32ed | ||
|
|
505a78efd1 | ||
|
|
9b80084220 | ||
|
|
b246169e5b | ||
|
|
2d96a3e87d | ||
|
|
bae5a9ea07 | ||
|
|
86ed27508d | ||
|
|
f28d79b51b | ||
|
|
5b27b9f8a5 | ||
|
|
405e9d32c3 | ||
|
|
21b362c6e8 | ||
|
|
9f8df95513 | ||
|
|
2f465f4cfe | ||
|
|
3fdd463794 | ||
|
|
6a7392ac8c | ||
|
|
28bdecd26b | ||
|
|
d29d3d71b9 | ||
|
|
999898a438 | ||
|
|
b4470ccd81 | ||
|
|
668dd95237 | ||
|
|
777d9a04a7 | ||
|
|
b717044ae5 | ||
|
|
379767071c | ||
|
|
6a06443dd8 | ||
|
|
42df399156 | ||
|
|
9ede4b9afd | ||
|
|
67e3e39499 | ||
|
|
42be09f628 | ||
|
|
8bbbe43724 | ||
|
|
95888f27a4 | ||
|
|
96dec65040 | ||
|
|
378fb226b7 | ||
|
|
42caa2d90e | ||
|
|
41a2493429 | ||
|
|
0e73650440 | ||
|
|
bcfd5eb213 | ||
|
|
8ce0a66bee | ||
|
|
7d4d9a3e84 | ||
|
|
4dec71a867 | ||
|
|
a9b29c7060 | ||
|
|
0cb525517b | ||
|
|
5b4a3f32dc | ||
|
|
66b0bc385c | ||
|
|
aaccd83750 | ||
|
|
40f5b01a12 | ||
|
|
82d42b1930 | ||
|
|
913b01b79f | ||
|
|
af7ef59f3e | ||
|
|
ef8e11b6f3 | ||
|
|
e1f897fb32 |
@@ -0,0 +1,63 @@
|
||||
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
|
||||
+255
-10
@@ -2,50 +2,295 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# 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.
|
||||
branches: [main, dev, qa]
|
||||
pull_request:
|
||||
|
||||
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.
|
||||
changelog:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Require a CHANGELOG entry for the crate version on main
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
if grep -qE "^## \[${version}\]" CHANGELOG.md; then
|
||||
echo "CHANGELOG.md has an entry for ${version}."
|
||||
exit 0
|
||||
fi
|
||||
msg="CHANGELOG.md has no '## [${version}]' entry"
|
||||
if [ "${GITHUB_REF_NAME:-}" = "main" ] || [ "${GITHUB_BASE_REF:-}" = "main" ]; then
|
||||
echo "::error::${msg} — main requires a changelog entry for the released version."
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::${msg} (allowed on ${GITHUB_REF_NAME:-unknown}; required before it can reach main)."
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: actions/checkout@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
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@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.
|
||||
- run: cargo clippy -- -D warnings
|
||||
# --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
|
||||
working-directory: libfreemkv
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: actions/checkout@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
|
||||
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
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo test --tests
|
||||
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
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: actions/checkout@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
|
||||
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
|
||||
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.
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: actions/checkout@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
|
||||
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
|
||||
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.
|
||||
- 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".
|
||||
consumers:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@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 }
|
||||
- name: Point every dependent at THIS libfreemkv commit
|
||||
shell: bash
|
||||
run: |
|
||||
for c in freemkv-keysources freemkv-engine freemkv autorip bdemu; do
|
||||
mkdir -p "$c/.cargo"
|
||||
cat > "$c/.cargo/config.toml" <<'EOF'
|
||||
[patch.crates-io]
|
||||
libfreemkv = { path = "../libfreemkv" }
|
||||
freemkv-keysources = { path = "../freemkv-keysources" }
|
||||
freemkv-engine = { path = "../freemkv-engine" }
|
||||
freemkv-i18n = { path = "../freemkv-i18n" }
|
||||
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
|
||||
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
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- uses: taiki-e/install-action@cargo-llvm-cov
|
||||
- run: cargo llvm-cov --workspace --ignore-run-fail --lcov --output-path lcov.info
|
||||
working-directory: libfreemkv
|
||||
- uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: libfreemkv/lcov.info
|
||||
slug: freemkv/libfreemkv
|
||||
fail_ci_if_error: false
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
leak-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Compute commit range
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
name: qa
|
||||
|
||||
# ── The qa gate: "is this production worth?" ────────────────────────────────
|
||||
#
|
||||
# dev -> qa -> main.
|
||||
#
|
||||
# `dev` is for committing often. ci.yml answers "is it green" in minutes with
|
||||
# fmt, clippy and the unit suite, so a mistake surfaces while it is still cheap
|
||||
# to fix. `qa` is the release-candidate branch, and THIS workflow is the claim
|
||||
# that a commit is production worth: everything expensive that can run without
|
||||
# physical media. `main` only ever receives a qa that went green here.
|
||||
#
|
||||
# Sibling repos are checked out at `qa`, NOT `dev`. A qa run that resolved its
|
||||
# dependencies from dev tips would be validating a combination that is not the
|
||||
# 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.
|
||||
on:
|
||||
push:
|
||||
branches: [qa]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# ── Name the candidate ────────────────────────────────────────────────
|
||||
#
|
||||
# Every push to `qa` is a release candidate, so every push gets a tag:
|
||||
# v<version>-rc<N>, N incrementing. That is the answer to "which build is on
|
||||
# qa right now, and is it the one I tested?" — a question that otherwise gets
|
||||
# answered from memory.
|
||||
#
|
||||
# This runs FIRST and does not depend on the gates, deliberately. A red
|
||||
# candidate needs a name more than a green one does: "rc3 failed
|
||||
# release-tests on windows" is a sentence you can act on; "qa is red" is not.
|
||||
# Red on qa is a working gate, not an incident — it is the branch saying this
|
||||
# is not production worth yet. Fix on dev, get dev green, push qa again.
|
||||
#
|
||||
# release.yml excludes v*-rc* so a candidate never publishes a release.
|
||||
rc-tag:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Stamp the next rc
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
[ -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"
|
||||
echo "### Candidate \`$tag\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The debug suite runs on every dev push. Release is a DIFFERENT build:
|
||||
# overflow checks are off, debug_assert! is compiled out, and inlining
|
||||
# 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:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ['ubuntu-latest', 'macos-latest']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo test --release --tests
|
||||
working-directory: libfreemkv
|
||||
|
||||
# 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
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@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
|
||||
working-directory: libfreemkv
|
||||
|
||||
# Windows compiles the tests but does not run them, matching the policy
|
||||
# ci.yml already set. The value here is codegen: the #[cfg(windows)] halves
|
||||
# of the SCSI transport and platform layers compile on no other runner, so
|
||||
# without this they are first built at release time.
|
||||
windows-build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo build --release --tests
|
||||
working-directory: libfreemkv
|
||||
@@ -4,6 +4,11 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
# NOT the release-candidate tags. Every push to `qa` stamps a
|
||||
# v<version>-rc<N> so a run can be named, and 'v*' matches those too —
|
||||
# which would have this workflow build and PUBLISH a GitHub release for
|
||||
# every candidate, including the red ones.
|
||||
- '!v*-rc*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -12,7 +17,7 @@ jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- name: Verify Cargo.toml version matches tag
|
||||
run: |
|
||||
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
|
||||
@@ -24,7 +29,7 @@ jobs:
|
||||
|
||||
# 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.86, same commit). Binary consumers
|
||||
# gated by the local precommit (same Rust 1.94, 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.
|
||||
@@ -32,8 +37,8 @@ jobs:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- 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).
|
||||
@@ -51,8 +56,8 @@ jobs:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Update README version
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.ORG_DISPATCH_TOKEN }}
|
||||
|
||||
- name: Update version in README
|
||||
run: |
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
FILE="README.md"
|
||||
|
||||
# Update cargo dependency version (e.g. "0.2" -> "0.3")
|
||||
MAJOR_MINOR="${VERSION#v}"
|
||||
MAJOR_MINOR="${MAJOR_MINOR%.*}"
|
||||
sed -i "s|libfreemkv = \"[0-9]*\.[0-9]*\"|libfreemkv = \"${MAJOR_MINOR}\"|" "$FILE"
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add README.md
|
||||
git diff --cached --quiet || git commit -m "Update to libfreemkv ${VERSION}"
|
||||
git push
|
||||
@@ -14,3 +14,12 @@ scratch/
|
||||
# internal agent context — never publish (path AND dir; leak-guard blocks both)
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
# Nightly harness output. Written into the repo it audits, and it embeds
|
||||
# absolute paths from the machine that ran it — which must never reach a public
|
||||
# repo. Ignored rather than relocated so a run from any working copy is safe.
|
||||
.nightly/
|
||||
|
||||
# cargo-mutants working output: large, machine-specific, never committed
|
||||
mutants.out/
|
||||
mutants.out.old/
|
||||
|
||||
+881
-728
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -36,7 +36,7 @@ This Code of Conduct applies within all community spaces, and also applies when
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at owner@example.invalid. All complaints will be reviewed and investigated promptly and fairly.
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported privately to the project maintainer via GitHub at https://github.com/MattJackson. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
|
||||
+9
-14
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.4.4"
|
||||
version = "1.6.11"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
rust-version = "1.94"
|
||||
license = "MIT"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
repository = "https://github.com/freemkv/libfreemkv"
|
||||
@@ -22,27 +22,22 @@ codegen-units = 1
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
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 = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.4.4" }
|
||||
num-bigint = "0.4"
|
||||
num-traits = "0.2"
|
||||
num-integer = "0.1"
|
||||
rand = "0.8"
|
||||
cmac = "0.7"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
base64 = "0.22.1"
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.6.11" }
|
||||
rand = "0.10"
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
base64 = "0.23"
|
||||
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
|
||||
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
|
||||
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
|
||||
# parser, not a hand-rolled scanner: the XPL is genuine XML (comments, varied
|
||||
# attribute order, self-closing tags).
|
||||
roxmltree = "0.20"
|
||||
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
|
||||
# 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"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
[](LICENSE)
|
||||
[](https://github.com/freemkv/libfreemkv/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/freemkv/libfreemkv)
|
||||
|
||||
# libfreemkv
|
||||
|
||||
@@ -55,48 +57,15 @@ output.finish()?;
|
||||
|
||||
### Multi-pass recovery rip
|
||||
|
||||
For damaged discs the library exposes two flat verbs — `Disc::sweep` for the
|
||||
forward Pass 1 and `Disc::patch` for retrying bad ranges. The library never
|
||||
loops; the multipass policy is the caller's job. See
|
||||
[`docs/rip-recovery.md`](docs/rip-recovery.md).
|
||||
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
|
||||
`freemkv-engine` crate as `freemkv_engine::recovery::{copy, sweep, patch}`.
|
||||
|
||||
```rust
|
||||
use libfreemkv::{SweepOptions, PatchOptions};
|
||||
use libfreemkv::disc::{mapfile, mapfile_path_for};
|
||||
use std::path::Path;
|
||||
|
||||
let iso = Path::new("disc.iso");
|
||||
|
||||
// Pass 1: disc → ISO. Skip-on-error, zero-fill, write the sidecar mapfile.
|
||||
disc.sweep(&mut drive, iso, &SweepOptions {
|
||||
decrypt: true,
|
||||
resume: false,
|
||||
batch_sectors: None,
|
||||
skip_on_error: true,
|
||||
progress: None,
|
||||
halt: None,
|
||||
})?;
|
||||
|
||||
// Pass 2..N: retry every non-finished range. Idempotent.
|
||||
loop {
|
||||
let map = mapfile::Mapfile::load(&mapfile_path_for(iso))?;
|
||||
let stats = map.stats();
|
||||
if stats.bytes_pending + stats.bytes_unreadable == 0 { break; }
|
||||
|
||||
let outcome = disc.patch(&mut drive, iso, &PatchOptions {
|
||||
decrypt: true,
|
||||
block_sectors: None,
|
||||
full_recovery: true,
|
||||
reverse: true,
|
||||
wedged_threshold: 50,
|
||||
progress: None,
|
||||
halt: None,
|
||||
})?;
|
||||
if outcome.bytes_recovered_this_pass == 0 { break; }
|
||||
}
|
||||
|
||||
// Mux from the ISO via the normal stream pipeline (no drive involvement).
|
||||
```
|
||||
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.
|
||||
|
||||
## What It Does
|
||||
|
||||
@@ -114,7 +83,7 @@ loop {
|
||||
| Stream | Input | Output | Transport |
|
||||
|--------|-------|--------|-----------|
|
||||
| DiscStream | Yes | -- | Optical drive via SCSI |
|
||||
| IsoStream | Yes | -- | Blu-ray ISO image file (read via stream pipeline; written via `Disc::sweep()`) |
|
||||
| IsoStream | Yes | -- | Blu-ray ISO image file (read via stream pipeline; 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 |
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 1.6.x | Yes |
|
||||
| < 1.6 | No |
|
||||
|
||||
Only the current 1.6.x line receives security fixes.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Report vulnerabilities privately through GitHub Security Advisories:
|
||||
https://github.com/freemkv/libfreemkv/security/advisories/new
|
||||
|
||||
Do not open a public issue for a security report. Include the affected
|
||||
version, steps to reproduce, and the impact you believe the issue has.
|
||||
|
||||
## Response time
|
||||
|
||||
You will get an initial response within 7 days.
|
||||
+3
-3
@@ -141,8 +141,8 @@ If your machine has a free SATA port, use it.
|
||||
|
||||
freemkv uses a three-layer recovery model. See [`docs/rip-recovery.md`](docs/rip-recovery.md) for full details.
|
||||
|
||||
- **Pass 1 (Disc::copy):** 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+ (Disc::patch):** Targeted re-reads of bad ranges with a long 30-second timeout per CDB. The drive firmware performs its own ECC and laser power retries within that window.
|
||||
- **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.
|
||||
- **In-stream (DiscStream):** Adaptive batch halving -- reduces request size on failure to isolate bad sectors within a larger block.
|
||||
|
||||
This means a disc with some bad sectors will still produce a usable ISO. The damaged areas are zero-filled in pass 1 and retried in subsequent passes. Structure-protected sectors (deliberate unreadable regions from copy protection) will never yield, which is expected.
|
||||
@@ -302,7 +302,7 @@ Many external drive enclosures (Vantec NexStar, Sabrent, OWC, etc.) do not adver
|
||||
When something goes wrong during a rip, gather this information before filing an issue:
|
||||
|
||||
1. **freemkv version:** `freemkv --version` or the crate version in `Cargo.toml`.
|
||||
2. **Drive model:** from the drive label, or from `freemkv info`.
|
||||
2. **Drive model:** from the drive label, or from `freemkv info disc://`.
|
||||
3. **Connection type:** USB (with bridge chipset if known) or direct SATA.
|
||||
4. **Operating system and kernel:** `uname -a`.
|
||||
5. **Kernel messages during the failure:** `dmesg | tail -50` immediately after the crash.
|
||||
|
||||
@@ -22,7 +22,7 @@ fn main() {
|
||||
&target_arch // x86_64 → x86_64
|
||||
};
|
||||
|
||||
std::process::Command::new("cc")
|
||||
let cc_status = std::process::Command::new("cc")
|
||||
.args([
|
||||
"-arch",
|
||||
clang_arch,
|
||||
@@ -38,12 +38,19 @@ fn main() {
|
||||
"-O2",
|
||||
])
|
||||
.status()
|
||||
.expect("failed to compile macos_shim.c");
|
||||
.expect("failed to spawn cc for macos_shim.c");
|
||||
// `.status()` succeeding only means the process RAN. A real compile error
|
||||
// exits non-zero, and ignoring that left no object file, which surfaced
|
||||
// much later as an unexplained link failure against a missing symbol. The
|
||||
// shim is macOS-only and is neither linted nor compiled on the other two
|
||||
// platforms, so a mistake in it has exactly one chance to be noticed.
|
||||
assert!(cc_status.success(), "cc failed to compile macos_shim.c");
|
||||
|
||||
std::process::Command::new("ar")
|
||||
let ar_status = std::process::Command::new("ar")
|
||||
.args(["rcs", &lib, &obj])
|
||||
.status()
|
||||
.expect("failed to create static lib");
|
||||
.expect("failed to spawn ar");
|
||||
assert!(ar_status.success(), "ar failed to create the static lib");
|
||||
|
||||
println!("cargo:rustc-link-search=native={out_dir}");
|
||||
println!("cargo:rustc-link-lib=static=macos_scsi");
|
||||
@@ -77,10 +84,10 @@ fn emit_git_suffix() {
|
||||
// Re-run when HEAD (or the branch it points at) moves so the stamp stays
|
||||
// current without a clean rebuild.
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
if let Ok(head) = std::fs::read_to_string(".git/HEAD") {
|
||||
if let Some(ref_path) = head.strip_prefix("ref: ") {
|
||||
println!("cargo:rerun-if-changed=.git/{}", ref_path.trim());
|
||||
}
|
||||
if let Ok(head) = std::fs::read_to_string(".git/HEAD")
|
||||
&& let Some(ref_path) = head.strip_prefix("ref: ")
|
||||
{
|
||||
println!("cargo:rerun-if-changed=.git/{}", ref_path.trim());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
# Coverage is informational only: it never fails CI or blocks a merge/release.
|
||||
# The `coverage` job in .github/workflows/ci.yml is non-required and no gate
|
||||
# depends on it.
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
informational: true
|
||||
patch:
|
||||
default:
|
||||
informational: true
|
||||
|
||||
comment: false
|
||||
|
||||
ignore:
|
||||
- "tests"
|
||||
- "examples"
|
||||
- "benches"
|
||||
+1
-1
@@ -12,7 +12,7 @@ Technical documentation for [libfreemkv](https://github.com/freemkv/libfreemkv),
|
||||
|----------|---------------|
|
||||
| [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) | Three-layer recovery model: Disc::patch, single-shot Drive::read, DiscStream batch halving |
|
||||
| [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 |
|
||||
|
||||
+27
-16
@@ -65,27 +65,38 @@ if disc.encrypted {
|
||||
}
|
||||
}
|
||||
|
||||
// Read content -- decryption is automatic
|
||||
let mut reader = disc.open_title(&mut session, 0).unwrap();
|
||||
while let Some(unit) = reader.read_unit().unwrap() {
|
||||
// decrypted content
|
||||
// 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 touches keys, never calls decryption functions, and never
|
||||
manages handshakes. All of that is internal to `Disc::scan()` and the content
|
||||
reader.
|
||||
The application never calls decryption functions and never manages the
|
||||
drive-level handshake. It DOES own key resolution — see below.
|
||||
|
||||
### KEYDB Location
|
||||
### Key resolution is the caller's job
|
||||
|
||||
`ScanOptions` controls where the keydb is loaded from. If no explicit path is
|
||||
set, the library checks the standard config locations. To specify an explicit
|
||||
path:
|
||||
`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.
|
||||
|
||||
```rust
|
||||
let opts = ScanOptions::with_keydb("/path/to/keydb.cfg");
|
||||
let disc = Disc::scan(&mut session, &opts).unwrap();
|
||||
```
|
||||
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
|
||||
|
||||
@@ -97,7 +108,7 @@ After a successful scan, `disc.aacs` contains an `AacsState`:
|
||||
| `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` | `KeySource` | How the disc's key was resolved |
|
||||
| `key_source` | `KeyOrigin` | How the disc's key was resolved |
|
||||
|
||||
## keydb.cfg
|
||||
|
||||
|
||||
+10
-7
@@ -170,17 +170,20 @@ libfreemkv/src/
|
||||
│ ├── 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), reset, eject
|
||||
│ ├── 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, sweep, patch)
|
||||
│ ├── mod.rs Disc struct, scan, titles, formats; Disc::copy + Disc::sweep (Pass 1)
|
||||
│ ├── sweep.rs Pass 1 internal helpers (pub(super))
|
||||
│ ├── patch.rs Disc::patch (Pass N retry over mapfile)
|
||||
│ ├── mapfile.rs ddrescue-format mapfile
|
||||
│ └── read_error.rs ReadCtx / ReadAction state machine
|
||||
├── 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)
|
||||
|
||||
@@ -96,12 +96,13 @@ 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 (1.5 s vs. 30 s)
|
||||
- `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
|
||||
(`Disc::patch`) handles bad-range retry by replaying the ddrescue mapfile.
|
||||
(`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
|
||||
|
||||
+15
-5
@@ -69,13 +69,23 @@ Each stream PID entry header (14 bytes):
|
||||
```
|
||||
Offset Size Field
|
||||
------ ---- -----
|
||||
0 2 Stream PID
|
||||
2 2 Reserved + EP stream type
|
||||
4 2 Number of coarse entries
|
||||
6 4 Number of fine entries (note: 32-bit, can be large)
|
||||
10 4 EP map start offset (relative to EP map start)
|
||||
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
|
||||
|
||||
+2
-2
@@ -79,7 +79,7 @@ Insert disc
|
||||
│ 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 — Disc::patch re-runs against the mapfile.
|
||||
│ 1 above this — freemkv_engine::recovery::patch re-runs against the mapfile.
|
||||
│
|
||||
▼
|
||||
PES frames → output stream (MKV, M2TS, network, etc.)
|
||||
@@ -123,7 +123,7 @@ output.finish()?;
|
||||
| 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 + Disc::sweep + Disc::patch + mapfile |
|
||||
| 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 |
|
||||
|
||||
@@ -58,15 +58,16 @@ selects the per-CDB timeout:
|
||||
|
||||
| `recovery` | Timeout | Used by |
|
||||
|------------|----------|------------------------------------------|
|
||||
| `false` | 1.5 s | `Disc::sweep` fast skip-forward pass, `DiscStream::fill_extents` |
|
||||
| `true` | 30 s | `Disc::patch` retry pass over the mapfile |
|
||||
| `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 — `Disc::patch`** loops over the ddrescue mapfile and re-issues
|
||||
- **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
|
||||
|
||||
+64
-167
@@ -1,135 +1,38 @@
|
||||
# Rip recovery — three-layer architecture
|
||||
# Rip recovery — what libfreemkv owns
|
||||
|
||||
`libfreemkv` supports a multi-stage rip model for damaged or protection-bearing
|
||||
discs: a fast forward sweep that tolerates read failures, in-loop request-size
|
||||
adaptation that survives transient drive trouble without bailing, and targeted
|
||||
retry passes against a persistent bad-range map. The stream pipeline
|
||||
(`DiscStream` + `input`/`output`) operates against the resulting ISO image, so
|
||||
the mux stage never touches the drive.
|
||||
**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.
|
||||
|
||||
Recovery is layered cleanly. Each layer has one responsibility and does not
|
||||
reach into the others.
|
||||
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 | `Disc::patch` (one pass over the mapfile per call) | Re-reads non-`+` ranges with the long timeout. Idempotent; caller invokes N times. |
|
||||
| 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` adaptive batch sizer | Halves the batch on failure, retries at the same LBA, walks back up on a clean-read streak. |
|
||||
| 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. |
|
||||
|
||||
The library exposes flat verbs; the caller drives the multipass loop. Autorip
|
||||
runs `Disc::sweep` once, then loops `Disc::patch` until either the mapfile is
|
||||
clean or the configured retry budget is exhausted, then hands the ISO off to
|
||||
the mux pipeline. The `freemkv` CLI does the same shape with a
|
||||
terminal-output progress sink. Layer 3 runs inside any consumer of
|
||||
`DiscStream` (direct PES pipeline, ISO playback, etc.) without caller
|
||||
involvement.
|
||||
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.
|
||||
|
||||
Three primitives compose the disc-side flow:
|
||||
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.
|
||||
|
||||
| Primitive | What it does |
|
||||
|---------------------------|-----------------------------------------------------------------------|
|
||||
| `Disc::sweep` | disc → ISO, one forward pass. Writes a sidecar `.mapfile`. Opt-in skip-on-error. |
|
||||
| `Disc::patch` | Re-reads bad ranges from the drive. One pass per call; caller invokes N times. |
|
||||
| `DiscStream` (ISO source) | Reads sectors from the ISO, feeds decrypt → demux → codec → mux. |
|
||||
|
||||
## Data model
|
||||
|
||||
### Mapfile
|
||||
|
||||
Format: [ddrescue](https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html)-compatible
|
||||
plain text, greppable, tool-interoperable. Flushed to disk on every `record()`
|
||||
so a crashed rip loses at most one block.
|
||||
|
||||
```
|
||||
# Rescue Logfile. Created by libfreemkv v0.13.6
|
||||
# Current pos / status / pass / pass_time
|
||||
0x000000000 ? 1 0
|
||||
# pos size status
|
||||
0x000000000 0x12a35d000 +
|
||||
0x12a35d000 0x000003000 -
|
||||
0x12a360000 0x009c4a000 +
|
||||
0x12d00a000 0x000064000 *
|
||||
```
|
||||
|
||||
Status characters match ddrescue:
|
||||
|
||||
| Char | Meaning |
|
||||
|------|----------------------------------------------------|
|
||||
| `?` | Not yet attempted |
|
||||
| `*` | Fast-pass failed; needs edge-trim |
|
||||
| `/` | Trimmed; interior needs sector scrape |
|
||||
| `-` | Unreadable this session |
|
||||
| `+` | Finished (good) |
|
||||
|
||||
Position and size are hex byte offsets into the ISO.
|
||||
|
||||
### `SweepOptions` and `PatchOptions`
|
||||
|
||||
The library no longer dispatches between sweep and patch internally — the
|
||||
caller picks the verb explicitly per pass. The two option structs are flat
|
||||
and have no overlap:
|
||||
|
||||
```rust
|
||||
SweepOptions {
|
||||
decrypt: true,
|
||||
resume: false,
|
||||
batch_sectors: None,
|
||||
skip_on_error: true, // damage-jump + zero-fill on read failure
|
||||
progress: Some(&reporter),
|
||||
halt: Some(flag),
|
||||
}
|
||||
|
||||
PatchOptions {
|
||||
decrypt: true,
|
||||
block_sectors: None,
|
||||
full_recovery: true,
|
||||
reverse: true, // walk bad ranges high → low LBA
|
||||
wedged_threshold: 50,
|
||||
progress: Some(&reporter),
|
||||
halt: Some(flag),
|
||||
}
|
||||
```
|
||||
|
||||
Caller-orchestrated dispatch (the policy `Disc::copy` used to embed):
|
||||
|
||||
- No mapfile → `sweep` (fresh Pass 1).
|
||||
- Mapfile with `?` ranges → `sweep` with `resume: true`.
|
||||
- Mapfile covers full disc, only `*` / `/` / `-` ranges → `patch`.
|
||||
- Mapfile clean → done; no further pass needed.
|
||||
|
||||
Each consumer (autorip, `freemkv` CLI) implements the loop in roughly five
|
||||
lines of `Mapfile::stats()` checks.
|
||||
|
||||
## Algorithm
|
||||
|
||||
### Pass 1 — fast sweep (`Disc::sweep`)
|
||||
|
||||
1. Read one ECC block (32 sectors for UHD, 16 for BD/DVD) at the current LBA.
|
||||
2. On success: write data to ISO, mark `+`, advance.
|
||||
3. On failure (with `multipass`): zero-fill, mark `*`, advance.
|
||||
4. Track a sliding window of the last 16 ECC block results. When ≥12% are failures
|
||||
→ **damage-jump**: skip ahead by `1024×batch×multiplier` sectors (64 MB base for
|
||||
UHD). Double the multiplier on each jump (64→128→256→512 MB...). Zero-fill the gap as `*`.
|
||||
5. On 16 consecutive good reads: reset jump multiplier to 1, restore max read speed.
|
||||
6. Speed control: damage zone entry → minimum speed, exit → maximum speed.
|
||||
7. Only transport failures (USB bridge crash) abort the pass.
|
||||
|
||||
Pass 1 completes when every byte has been visited (either `+` or `*`).
|
||||
|
||||
### Pass 2+ — patch (`Disc::patch`)
|
||||
|
||||
`Disc::patch` reads the mapfile and iterates every non-`+` range. Default: **reverse** mode
|
||||
(walks ranges from highest LBA to lowest, within each range from end to start).
|
||||
|
||||
1. Issue a single-sector read with 60 s timeout (`recovery=true`). Drive firmware
|
||||
does its own ECC recovery inside that window.
|
||||
2. On success: write the good bytes into the ISO, mark `+`.
|
||||
3. On failure with non-marginal SCSI sense: bail immediately (drive won't produce data).
|
||||
4. On failure with marginal sense: mark `-`, continue.
|
||||
5. Update the mapfile after every block — crash-safe resume.
|
||||
6. Wedged-drive exit: 50 consecutive failures with zero recovery → bail this pass.
|
||||
|
||||
### In-stream — adaptive batch halving (`DiscStream::fill_extents`)
|
||||
## 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`:
|
||||
@@ -143,59 +46,53 @@ When a consumer reads a `DiscStream` directly (no ISO intermediate),
|
||||
`EventKind::SectorSkipped`) when `skip_errors` is set, otherwise return
|
||||
`Err(DiscRead)`.
|
||||
|
||||
This is layer 3. It exists so a transient single-sector glitch in a 32-sector
|
||||
batch can be isolated and read individually without the caller needing to
|
||||
implement retry logic.
|
||||
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
|
||||
|
||||
**`Drive::read` is single-shot.** No inline retry phases, no SCSI reset,
|
||||
no eject cycle. The `recovery` flag controls only the per-CDB timeout
|
||||
(1.5 s vs. 30 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 no
|
||||
recoveries to show for it. Recovery responsibility is now layered: layer 1
|
||||
handles ranges, layer 3 handles request size, neither touches the
|
||||
wedge-prone reset path.
|
||||
These are constraints on the read path, enforced here and relied on by the
|
||||
engine's strategy.
|
||||
|
||||
**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 can't replicate —
|
||||
disabling it throws away recovery headroom on marginal sectors. We fail fast
|
||||
via short SG_IO timeouts in pass 1 and let the firmware work the long timeout
|
||||
in pass 2 / patch.
|
||||
**`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 SCSI reset from any retry path.** `SgIoTransport::reset` (Linux) is
|
||||
trimmed to a kernel SG_IO state flush plus ALLOW MEDIUM REMOVAL — the
|
||||
`SG_SCSI_RESET` ioctl and STOP/START UNIT escalation were removed in 0.13.6.
|
||||
The macOS reset (which had been a no-op) was removed entirely. The top-level
|
||||
`scsi::reset()` / `reset_with_timeout()` / `reset_blocking()` wrappers were
|
||||
also removed (no callers). The remaining `Drive::reset()` is only invoked
|
||||
explicitly by callers that need an eject-cycle escape hatch — it is never
|
||||
reached from a read 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.
|
||||
|
||||
**ISO intermediate, even for single-pass.** Pass 1 always writes an ISO. The
|
||||
mux stage reads the ISO via `FileSectorSource`. For single-pass (no retries),
|
||||
this adds ~2-3 min (local disk mux) but gains resumability across crashes,
|
||||
**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 and use `DiscStream::new(Box::new(drive), …)`
|
||||
directly — the lib doesn't forbid it, and layer 3 (adaptive batch halving)
|
||||
still applies there.
|
||||
|
||||
**Mapfile in ddrescue format.** Plain text so users can `less` it, `diff` it,
|
||||
or feed it to ddrescue's own tooling. Crash-safe (flush-per-record). Entries
|
||||
coalesce on adjacent same-status ranges so files stay small.
|
||||
|
||||
**Patches target `-`, `*`, `/`, and `?` alike.** The status state machine is
|
||||
ddrescue's but `patch` collapses the distinction — it just tries every
|
||||
non-finished range with the long timeout. Future work can specialize (trim vs.
|
||||
scrape vs. retry with direction reversal) if there's measured benefit.
|
||||
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)
|
||||
- Source: [`src/disc/mapfile.rs`](../src/disc/mapfile.rs), [`src/disc/mod.rs`](../src/disc/mod.rs) (`Disc::sweep`), [`src/disc/patch.rs`](../src/disc/patch.rs) (`Disc::patch`), [`src/drive/mod.rs`](../src/drive/mod.rs) (`Drive::read`), [`src/mux/disc.rs`](../src/mux/disc.rs) (`DiscStream::fill_extents`).
|
||||
- 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).
|
||||
|
||||
+1
-1
@@ -114,7 +114,7 @@ The `read_filesystem()` function in `src/udf.rs` follows the pointer chain above
|
||||
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 3) to build the full file tree.
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
// Minimal ISO dumper — find exact stall point
|
||||
use libfreemkv::Drive;
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 3 {
|
||||
eprintln!("Usage: iso_dump <device> <output>");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let mut drive = Drive::open(Path::new(&args[1])).unwrap();
|
||||
drive.wait_ready().unwrap();
|
||||
let _ = drive.init();
|
||||
let _ = drive.probe_disc();
|
||||
|
||||
// AACS handshake — required to read past the protected area
|
||||
eprint!("Scanning disc... ");
|
||||
let _ = libfreemkv::Disc::scan(&mut drive, &libfreemkv::ScanOptions::default());
|
||||
eprintln!("OK");
|
||||
|
||||
let cap = drive.read_capacity().unwrap();
|
||||
let batch = libfreemkv::disc::detect_max_batch_sectors(drive.device_path());
|
||||
|
||||
eprintln!("Device: {} | {} sectors | batch {}", args[1], cap, batch);
|
||||
|
||||
let file = std::fs::File::create(&args[2]).unwrap();
|
||||
let mut w = BufWriter::with_capacity(4 * 1024 * 1024, file);
|
||||
let mut buf = vec![0u8; batch as usize * 2048];
|
||||
let mut lba: u32 = 0;
|
||||
let start = Instant::now();
|
||||
let mut last = Instant::now();
|
||||
let mut bytes: u64 = 0;
|
||||
let mut last_bytes: u64 = 0;
|
||||
|
||||
while lba < cap {
|
||||
let count = ((cap - lba) as u16).min(batch);
|
||||
let n = count as usize * 2048;
|
||||
|
||||
// Tiny yield between reads — test if pacing prevents firmware throttle
|
||||
std::thread::yield_now();
|
||||
let t0 = Instant::now();
|
||||
let ok = drive.read(lba, count, &mut buf[..n], true).is_ok();
|
||||
let read_ms = t0.elapsed().as_millis();
|
||||
|
||||
// Flag slow reads
|
||||
if read_ms > 2000 {
|
||||
eprintln!("\n SLOW READ: LBA {} took {}ms (ok={})", lba, read_ms, ok);
|
||||
}
|
||||
|
||||
if !ok {
|
||||
buf[..n].fill(0);
|
||||
}
|
||||
w.write_all(&buf[..n]).unwrap();
|
||||
lba += count as u32;
|
||||
bytes += n as u64;
|
||||
|
||||
if last.elapsed().as_millis() >= 1000 {
|
||||
let delta = bytes - last_bytes;
|
||||
let speed = delta as f64 / last.elapsed().as_secs_f64() / 1_048_576.0;
|
||||
let avg = bytes as f64 / start.elapsed().as_secs_f64() / 1_048_576.0;
|
||||
let pct = bytes as f64 / (cap as f64 * 2048.0) * 100.0;
|
||||
eprint!(
|
||||
"\r {:.1}% LBA {} | {:.0} MB/s (avg {:.0}) | {:.1} GB ",
|
||||
pct,
|
||||
lba,
|
||||
speed,
|
||||
avg,
|
||||
bytes as f64 / 1e9
|
||||
);
|
||||
last_bytes = bytes;
|
||||
last = Instant::now();
|
||||
}
|
||||
}
|
||||
w.flush().unwrap();
|
||||
eprintln!(
|
||||
"\nDone: {:.1} GB in {:.0}s",
|
||||
bytes as f64 / 1e9,
|
||||
start.elapsed().as_secs_f64()
|
||||
);
|
||||
}
|
||||
+406
-71
@@ -4,11 +4,11 @@
|
||||
#[cfg(test)]
|
||||
use aes::Aes128;
|
||||
#[cfg(test)]
|
||||
use aes::cipher::{KeyInit, generic_array::GenericArray};
|
||||
use aes::cipher::{Array, KeyInit};
|
||||
|
||||
use super::crypto::{aes_cbc_decrypt, aes_ecb_encrypt};
|
||||
// Available at module scope for this module's test fixtures (they reference
|
||||
// `super::AACS_IV` when building CBC ciphertext directly); test-only.
|
||||
use super::crypto::{aes_cbc_decrypt, aes_cbc_encrypt, aes_ecb_encrypt};
|
||||
// Only this module's test fixtures build CBC ciphertext by hand now — the
|
||||
// production paths get the IV from `aes_cbc_encrypt` / `aes_cbc_decrypt`.
|
||||
#[cfg(test)]
|
||||
use super::crypto::AACS_IV;
|
||||
|
||||
@@ -41,7 +41,8 @@ pub const ALIGNED_UNIT_SECTORS: u32 = (ALIGNED_UNIT_LEN / SECTOR_BYTES) as u32;
|
||||
/// underflow wraps to ~2^32 and, because `2^32 ≡ 1 (mod 3)`, mis-reports the
|
||||
/// alignment (e.g. `lba == unit_base - 1` would falsely read as aligned).
|
||||
pub fn is_unit_aligned(lba: u32, unit_base: u32) -> bool {
|
||||
lba.saturating_sub(unit_base) % ALIGNED_UNIT_SECTORS == 0
|
||||
lba.saturating_sub(unit_base)
|
||||
.is_multiple_of(ALIGNED_UNIT_SECTORS)
|
||||
}
|
||||
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
@@ -106,6 +107,23 @@ pub fn aacs_unit_encrypted(unit: &[u8], format: crate::disc::ContentFormat) -> b
|
||||
}
|
||||
}
|
||||
|
||||
/// The AACS encrypted flag from an aligned unit's CLEAR seed, readable even on a
|
||||
/// trailing PARTIAL unit (unlike [`aacs_unit_encrypted`], which requires a whole
|
||||
/// 6144-byte unit). The flag lives at a fixed low offset in the clear header, so a
|
||||
/// fragment that still contains that byte can be classified. Used to catch an
|
||||
/// encrypted unit truncated across a buffer/extent boundary — a fragment we cannot
|
||||
/// CBC-decrypt and must not emit as clear. `false` for a slice too short to hold
|
||||
/// the flag byte. Same clip-anchored-read caveat as [`aacs_unit_encrypted`].
|
||||
pub fn aacs_unit_seed_encrypted(unit: &[u8], format: crate::disc::ContentFormat) -> bool {
|
||||
use crate::disc::ContentFormat;
|
||||
match format {
|
||||
ContentFormat::BdTs => unit.first().is_some_and(|b| b & 0xC0 != 0),
|
||||
ContentFormat::MpegPs => unit
|
||||
.get(PS_SCRAMBLE_OFF)
|
||||
.is_some_and(|b| b & PS_SCRAMBLE_MASK != 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when an aligned unit is flagged encrypted AND still looks scrambled
|
||||
/// (structure not yet restored) — i.e. genuine encrypted content NOT yet decrypted.
|
||||
///
|
||||
@@ -129,8 +147,14 @@ pub fn aacs_unit_needs_decrypt(unit: &[u8], format: crate::disc::ContentFormat)
|
||||
}
|
||||
|
||||
/// Minimum synced content packets that PROVE a key opened a unit. Four `0x47`
|
||||
/// syncs ≈ 32 bits of MPEG-TS structure ≈ 1-in-4-billion that a wrong key (uniform
|
||||
/// AES noise, `0x47` at 1/256 per packet) fakes it. It is an ABSOLUTE proof floor,
|
||||
/// syncs are 32 bits of MPEG-TS structure, but the per-UNIT false-pass risk is
|
||||
/// NOT 2^-32: `is_clean_ts` accepts ANY four of the ~31 encrypted packets in a
|
||||
/// 6144-byte aligned unit, so for a wrong key (uniform AES noise, `0x47` at 1/256
|
||||
/// per packet) it is ≈ C(31,4)·256^-4 ≈ 7e-6, i.e. ~1e-5 — the figure
|
||||
/// [`is_clean_ts`]'s own doc below states. 1-in-4-billion is the probability for
|
||||
/// four SPECIFIC packets and overstates the margin by ~4000x; at
|
||||
/// `KEY_PROOF_PACKETS = 3` the per-unit rate is ≈ C(31,3)·256^-3 ≈ 2.6e-4, so do
|
||||
/// NOT lower it on the strength of slack that is not there. It is an ABSOLUTE proof floor,
|
||||
/// NOT a proportion — a unit the key opened but whose content is bad-encoded
|
||||
/// (many non-conforming packets) is proven by ANY four good packets, not rejected
|
||||
/// for the bad ones.
|
||||
@@ -154,8 +178,16 @@ pub fn is_clean(unit: &[u8], format: crate::disc::ContentFormat) -> bool {
|
||||
/// Structural "does this unit carry enough valid MPEG-TS to prove a key opened
|
||||
/// it?" — the Transport-Stream arm of [`is_clean`]. It is
|
||||
/// NOT a decryption verdict: [`decrypt_unit`] applies a key (that is
|
||||
/// "decrypt"); whether the plaintext is clean TS is this SEPARATE question. The
|
||||
/// mux never calls this — TS validity is a muxer concern, never a decrypt result.
|
||||
/// "decrypt"); whether the plaintext is clean TS is this SEPARATE question.
|
||||
///
|
||||
/// The mux is its PRINCIPAL consumer for `BdTs` discs, reaching it through
|
||||
/// [`is_clean`]: `mux::resolve`'s multi-CPS `pick` closure selects a unit key by
|
||||
/// it, `probe_index_phase` reports each FMTS index's interleave parity by it, and
|
||||
/// `decrypt::decrypt_sectors_mapped` uses it as the forensic-range verify net.
|
||||
/// (The doc used to say "the mux never calls this", which invited a maintainer to
|
||||
/// tighten or loosen the proof rule below believing only whole-disc read
|
||||
/// verification was affected — while it in fact changes which unit key a
|
||||
/// multi-CPS disc muxes with and which phase an FMTS index is muxed at.)
|
||||
///
|
||||
/// Rule — evidence is ABSOLUTE, scaled to the packets that exist. Over the
|
||||
/// ENCRYPTED packets (skip packet 0: its `0x47` sits in the clear 16-byte seed, so
|
||||
@@ -226,7 +258,7 @@ pub fn ts_packet_total(unit: &[u8]) -> usize {
|
||||
/// the freebie `is_clean_ts` skips at packet 0); packs 1 and 2 (offsets 2048 and
|
||||
/// 4096) are in the encrypted region, so a wrong key garbles them and this returns
|
||||
/// false (64 bits of discrimination). Validated against real decrypted HD-DVD
|
||||
/// `.evo` (ANCHORMAN / SHAUN_OF_THE_DEAD): pack starts are exactly 2048-aligned,
|
||||
/// `.evo` (two real retail titles): pack starts are exactly 2048-aligned,
|
||||
/// three per aligned unit, at offsets 0 / 2048 / 4096.
|
||||
///
|
||||
/// UNVERIFIED-HDDVD-DECRYPT (2 of 2): the pack STRUCTURE here is confirmed on
|
||||
@@ -309,16 +341,74 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt one AACS aligned unit (6144 bytes) IN PLACE — the exact inverse of
|
||||
/// [`decrypt_unit`], for authoring an encrypted disc image (and for building
|
||||
/// genuinely-encrypted read-path fixtures).
|
||||
///
|
||||
/// PURE, on the same terms as `decrypt_unit`: it applies the key and nothing else.
|
||||
/// It does NOT set the encrypted flag, because where that flag lives is
|
||||
/// container-specific (CPI bits in byte 0 for BD-TS, elsewhere for HD-DVD-PS) and
|
||||
/// keeping it out is what lets this stay container-agnostic. The only guard is the
|
||||
/// length check, since the crypto is defined only over a whole 6144-byte unit.
|
||||
///
|
||||
/// **Set the encrypted flag BEFORE calling, never after.** Bytes 0..16 are the key
|
||||
/// seed and are left in plaintext, so the Block Key derives from them: mutating any
|
||||
/// header byte after encrypting changes the seed a decryptor will derive from and
|
||||
/// silently yields garbage. The caller's order must be flag, then encrypt.
|
||||
///
|
||||
/// Block Key = AES-128E(Kcu, seed) ⊕ seed, then AES-128-CBC **encrypt** bytes
|
||||
/// 16..6144 under the AACS IV — the forward direction of the same construction
|
||||
/// `decrypt_unit` documents, sharing its module-scope primitives so the two cannot
|
||||
/// drift apart.
|
||||
///
|
||||
/// Note one deliberate asymmetry: `decrypt_unit` restores all-zero-on-disc source
|
||||
/// padding packets to zero. This does not, and need not — an all-zero plaintext
|
||||
/// packet encrypts to ciphertext that is not all-zero, so it is not mistaken for
|
||||
/// padding on the way back and the round trip is still exact. Authoring that wants
|
||||
/// true source-zero padding leaves those packets unencrypted instead.
|
||||
///
|
||||
/// Returns `false` — encrypting nothing — when `unit` is shorter than
|
||||
/// [`ALIGNED_UNIT_LEN`]. That case MUST be checked: the caller has already set the
|
||||
/// container's encrypted flag by then (this function's contract requires it), so
|
||||
/// ignoring the result leaves a unit marked encrypted while still carrying
|
||||
/// plaintext, which is the worst possible outcome for an authoring tool.
|
||||
#[must_use = "returns false when the slice is too short to encrypt, leaving \
|
||||
plaintext behind a flag that already says 'encrypted'"]
|
||||
pub fn encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
if unit.len() < ALIGNED_UNIT_LEN {
|
||||
return false;
|
||||
}
|
||||
let mut header = [0u8; 16];
|
||||
header.copy_from_slice(&unit[..16]);
|
||||
let derived = aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
k[i] = derived[i] ^ header[i];
|
||||
}
|
||||
// CBC-encrypt bytes 16.. under the fixed AACS IV — the exact forward of the
|
||||
// `aes_cbc_decrypt` call in `decrypt_unit`, and one key expansion for the whole
|
||||
// unit rather than one per 16-byte block.
|
||||
aes_cbc_encrypt(&k, &mut unit[16..ALIGNED_UNIT_LEN]);
|
||||
true
|
||||
}
|
||||
|
||||
/// Remove bus encryption from an aligned unit (AACS 2.0 / UHD).
|
||||
/// Bus encryption uses read_data_key, decrypting bytes 16..2048 of each 2048-byte sector.
|
||||
pub(crate) fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
|
||||
// Expand the key schedule ONCE for the whole unit. `read_data_key` is
|
||||
// loop-invariant here (and constant for the entire disc), but calling
|
||||
// `aes_cbc_decrypt` per sector rebuilt the AES-128 schedule per sector — three
|
||||
// expansions per 6144-byte aligned unit, i.e. ~29 million redundant expansions
|
||||
// over a 90 GB read on a stock drive, on the per-unit decrypt hot path.
|
||||
// Measured by `decrypt_bus_expands_the_read_data_key_once_per_unit`.
|
||||
let cipher = crate::aacs::crypto::new_cipher_for(read_data_key);
|
||||
for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
||||
if sector_start + SECTOR_BYTES > unit.len() {
|
||||
break;
|
||||
}
|
||||
// First 16 bytes of each sector are plaintext
|
||||
aes_cbc_decrypt(
|
||||
read_data_key,
|
||||
crate::aacs::crypto::cbc_decrypt_blocks(
|
||||
&cipher,
|
||||
&mut unit[sector_start + 16..sector_start + SECTOR_BYTES],
|
||||
);
|
||||
}
|
||||
@@ -328,7 +418,105 @@ pub(crate) fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
|
||||
mod tests {
|
||||
use super::super::crypto::aes_ecb_decrypt;
|
||||
use super::*;
|
||||
use aes::cipher::BlockEncrypt; // test fixtures build ciphertext directly
|
||||
use aes::cipher::BlockCipherEncrypt; // test fixtures build ciphertext directly
|
||||
|
||||
/// [`encrypt_unit`] is the exact inverse of [`decrypt_unit`]: whatever an
|
||||
/// authoring caller encrypts, the read path must recover byte-for-byte.
|
||||
///
|
||||
/// Mutation: drop the trailing `⊕ header` from either function's Block Key
|
||||
/// derivation, or swap `AACS_IV` for zeroes in one of them, and the two stop
|
||||
/// agreeing -> this fails.
|
||||
#[test]
|
||||
fn encrypt_unit_is_the_exact_inverse_of_decrypt_unit() {
|
||||
let key = [0x3Cu8; 16];
|
||||
// Content with no all-zero packets: every byte position exercised.
|
||||
let mut clear: Vec<u8> = (0..ALIGNED_UNIT_LEN)
|
||||
.map(|i| (i * 7 % 251 + 1) as u8)
|
||||
.collect();
|
||||
// The encrypted flag belongs to the caller and must be set BEFORE the
|
||||
// crypto, since bytes 0..16 are the key seed.
|
||||
clear[0] |= 0xC0;
|
||||
|
||||
let mut unit = clear.clone();
|
||||
assert!(
|
||||
encrypt_unit(&mut unit, &key),
|
||||
"a full-length unit must encrypt"
|
||||
);
|
||||
assert_ne!(
|
||||
unit[16..],
|
||||
clear[16..],
|
||||
"the payload must actually be enciphered"
|
||||
);
|
||||
assert_eq!(
|
||||
unit[..16],
|
||||
clear[..16],
|
||||
"the 16-byte seed stays plaintext on disc"
|
||||
);
|
||||
|
||||
decrypt_unit(&mut unit, &key);
|
||||
assert_eq!(unit, clear, "round trip must be byte-exact");
|
||||
}
|
||||
|
||||
/// The documented padding asymmetry actually holds: `decrypt_unit` restores
|
||||
/// all-zero-ON-DISC packets to zero, but an all-zero PLAINTEXT packet enciphers
|
||||
/// to non-zero bytes, so it is not mistaken for padding and still round-trips.
|
||||
/// This is the one place the two functions are deliberately not symmetric, so
|
||||
/// the claim is worth pinning rather than asserting in prose alone.
|
||||
/// A slice too short to encrypt must SAY so. The caller has already set the
|
||||
/// container's encrypted flag by the time it calls this (the contract requires
|
||||
/// flag-before-crypto, since the header is the key seed), so a silent no-op
|
||||
/// leaves a unit advertised as encrypted while still carrying plaintext.
|
||||
#[test]
|
||||
fn encrypt_unit_reports_a_slice_too_short_to_encrypt() {
|
||||
let key = [0x11u8; 16];
|
||||
let mut short = vec![0u8; ALIGNED_UNIT_LEN - 1];
|
||||
short[0] |= 0xC0; // the caller already flagged it encrypted
|
||||
let before = short.clone();
|
||||
assert!(
|
||||
!encrypt_unit(&mut short, &key),
|
||||
"a short slice must report false, not silently succeed"
|
||||
);
|
||||
assert_eq!(
|
||||
short, before,
|
||||
"a refused encrypt must leave the buffer untouched"
|
||||
);
|
||||
|
||||
// Exactly ALIGNED_UNIT_LEN is the boundary and must succeed.
|
||||
let mut exact = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
exact[0] |= 0xC0;
|
||||
assert!(encrypt_unit(&mut exact, &key), "a full unit must encrypt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_unit_round_trips_all_zero_plaintext_packets() {
|
||||
let key = [0xA5u8; 16];
|
||||
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
clear[0] |= 0xC0; // flag before crypto
|
||||
// Give packet 0 some content; leave every later packet entirely zero.
|
||||
for (i, b) in clear[16..192].iter_mut().enumerate() {
|
||||
*b = (i % 255 + 1) as u8;
|
||||
}
|
||||
|
||||
let mut unit = clear.clone();
|
||||
assert!(
|
||||
encrypt_unit(&mut unit, &key),
|
||||
"a full-length unit must encrypt"
|
||||
);
|
||||
// No later packet may encipher to all-zero, or decrypt would treat it as
|
||||
// source padding and the asymmetry would bite.
|
||||
for p in 1..ALIGNED_UNIT_LEN / BD_SOURCE_PACKET_BYTES {
|
||||
let off = p * BD_SOURCE_PACKET_BYTES;
|
||||
assert!(
|
||||
unit[off..off + BD_SOURCE_PACKET_BYTES]
|
||||
.iter()
|
||||
.any(|&b| b != 0),
|
||||
"packet {p} enciphered to all-zero, which decrypt reads as padding"
|
||||
);
|
||||
}
|
||||
|
||||
decrypt_unit(&mut unit, &key);
|
||||
assert_eq!(unit, clear, "zero-payload packets must round trip exactly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aes_ecb_roundtrip() {
|
||||
@@ -498,23 +686,11 @@ mod tests {
|
||||
let original = vec![0x42u8; 128]; // 8 blocks
|
||||
let mut data = original.clone();
|
||||
|
||||
// Encrypt with CBC manually (forward direction)
|
||||
fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut prev = super::AACS_IV;
|
||||
let num_blocks = data.len() / 16;
|
||||
for i in 0..num_blocks {
|
||||
let offset = i * 16;
|
||||
for j in 0..16 {
|
||||
data[offset + j] ^= prev[j];
|
||||
}
|
||||
let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
|
||||
cipher.encrypt_block(&mut block);
|
||||
data[offset..offset + 16].copy_from_slice(&block);
|
||||
prev.copy_from_slice(&data[offset..offset + 16]);
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt with the REAL production primitive. This test previously
|
||||
// defined a local `fn aes_cbc_encrypt` that SHADOWED it, so it round-
|
||||
// tripped a copy of the algorithm against itself and never exercised
|
||||
// `crypto::aes_cbc_encrypt` at all — a mutation to the shipped function
|
||||
// could not fail it.
|
||||
aes_cbc_encrypt(&key, &mut data);
|
||||
assert_ne!(data, original); // should be different after encrypt
|
||||
|
||||
@@ -549,7 +725,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// CBC encrypt bytes 16..6143
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&encrypt_key));
|
||||
let cipher = Aes128::new(&encrypt_key.into());
|
||||
let mut prev = AACS_IV;
|
||||
let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
|
||||
for i in 0..num_blocks {
|
||||
@@ -557,7 +733,9 @@ mod tests {
|
||||
for j in 0..16 {
|
||||
plain[off + j] ^= prev[j];
|
||||
}
|
||||
let mut block = GenericArray::clone_from_slice(&plain[off..off + 16]);
|
||||
let mut chunk = [0u8; 16];
|
||||
chunk.copy_from_slice(&plain[off..off + 16]);
|
||||
let mut block: Array<u8, _> = chunk.into();
|
||||
cipher.encrypt_block(&mut block);
|
||||
plain[off..off + 16].copy_from_slice(&block);
|
||||
prev.copy_from_slice(&plain[off..off + 16]);
|
||||
@@ -598,29 +776,13 @@ mod tests {
|
||||
/// header) XOR header`, then CBC-encrypt bytes 16..6144 under the
|
||||
/// fixed AACS IV.
|
||||
fn aacs_encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
|
||||
// Set the CPI bits (top 2 of byte 0) so the unit reads as encrypted under
|
||||
// `aacs_unit_encrypted` — done BEFORE key derivation so the plaintext
|
||||
// header the real decrypt recovers matches what we encrypt under.
|
||||
// Delegate to the module-scope `pub(crate)` helper (the single encrypt
|
||||
// implementation, shared with the mux `driver.rs` decrypt test).
|
||||
unit[0] |= 0xC0;
|
||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let derived = aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
k[i] = derived[i] ^ header[i];
|
||||
}
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&k));
|
||||
let mut prev = AACS_IV;
|
||||
let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
|
||||
for i in 0..num_blocks {
|
||||
let off = 16 + i * 16;
|
||||
for j in 0..16 {
|
||||
unit[off + j] ^= prev[j];
|
||||
}
|
||||
let mut block = GenericArray::clone_from_slice(&unit[off..off + 16]);
|
||||
cipher.encrypt_block(&mut block);
|
||||
unit[off..off + 16].copy_from_slice(&block);
|
||||
prev.copy_from_slice(&unit[off..off + 16]);
|
||||
}
|
||||
assert!(
|
||||
super::encrypt_unit(unit, unit_key),
|
||||
"a full-length unit must encrypt"
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a clear aligned unit with TS sync bytes at offset 4 + k*192.
|
||||
@@ -637,7 +799,7 @@ mod tests {
|
||||
// ── Padding-aware IsDecryptable (fragment-tail recovery) ───────────────
|
||||
//
|
||||
// A content fragment can end mid-unit, with the disc zero-padding the rest
|
||||
// of the aligned unit to the next fragment (proven on Dunkirk: ~11 real
|
||||
// of the aligned unit to the next fragment (proven on a real disc: ~11 real
|
||||
// video packets + source-zero pad). `decrypt_unit` must accept such a unit
|
||||
// — its real packets decrypt; the source-zero tail is padding, not content
|
||||
// — while still REJECTING a unit whose undecryptable tail is non-zero (a
|
||||
@@ -671,7 +833,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn fragment_tail_with_source_zero_pad_is_decryptable() {
|
||||
// 11 real content packets, then source-zero padding (the Dunkirk shape).
|
||||
// 11 real content packets, then source-zero padding (a real-disc fragment shape).
|
||||
let key = [0x5Au8; 16];
|
||||
let mut unit = tail_filled_unit(&key, 11, 0x00);
|
||||
decrypt_unit(&mut unit, &key);
|
||||
@@ -715,7 +877,7 @@ mod tests {
|
||||
|
||||
// ── Defect-tolerant "did a key OPEN this unit?" verdict ─────────────────
|
||||
//
|
||||
// The Bourne-UHD bug: a commercial disc carries the odd authored-bad TS
|
||||
// The authored-bad-packet bug: a commercial disc carries the odd bad TS
|
||||
// packet (a pressing/encoding defect, or an AACS 2.1 forensic-variant frame)
|
||||
// — one non-conforming packet inside an otherwise perfectly-decrypted 6144
|
||||
// unit. The OLD strict per-packet acceptance rejected the WHOLE unit over
|
||||
@@ -838,7 +1000,7 @@ mod tests {
|
||||
use crate::disc::ContentFormat;
|
||||
// Clean HD-DVD `.evo` Program-Stream unit: pack_start_code `00 00 01 BA`
|
||||
// at each 2048-byte pack boundary (0/2048/4096) — the layout validated
|
||||
// against real decrypted ANCHORMAN / SHAUN_OF_THE_DEAD `.evo`. Offset 0 is
|
||||
// against real decrypted retail HD-DVD `.evo`. Offset 0 is
|
||||
// the clear-seed freebie; the packs at 2048/4096 are encrypted and are
|
||||
// what actually discriminate a key.
|
||||
let mut ps = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
@@ -1043,21 +1205,100 @@ mod tests {
|
||||
assert_eq!(aes_ecb_decrypt(&key, &expected), pt);
|
||||
}
|
||||
|
||||
// ── decrypt_bus: one key schedule per unit, not one per sector ─────────
|
||||
|
||||
/// MEASURED, not reasoned: `decrypt_bus` called `aes_cbc_decrypt` once per
|
||||
/// 2048-byte sector, and each call built its own AES-128 key schedule, so a
|
||||
/// 6144-byte aligned unit performed THREE key expansions under the same
|
||||
/// loop-invariant `read_data_key`. On a 90 GB UHD read on a stock (non-
|
||||
/// firmware-unlocked) drive — ~14.6 million aligned units — that is ~29 million
|
||||
/// redundant expansions on the per-unit decrypt hot path, for a key that is
|
||||
/// constant for the whole disc. The counter is incremented inside
|
||||
/// `crypto::new_cipher`, the single construction site.
|
||||
#[test]
|
||||
fn decrypt_bus_expands_the_read_data_key_once_per_unit() {
|
||||
use crate::aacs::crypto::KEY_EXPANSIONS;
|
||||
let mut unit = clear_unit();
|
||||
let rdk = [0x4Eu8; 16];
|
||||
KEY_EXPANSIONS.with(|c| c.set(0));
|
||||
decrypt_bus(&mut unit, &rdk);
|
||||
let n = KEY_EXPANSIONS.with(|c| c.get());
|
||||
assert_eq!(
|
||||
n, 1,
|
||||
"one aligned unit under one read_data_key must expand the schedule \
|
||||
exactly once, not once per 2048-byte sector"
|
||||
);
|
||||
}
|
||||
|
||||
/// The single-expansion refactor must be byte-identical: bus encryption
|
||||
/// ([C] §4.2) covers bytes 16..2048 of every 2048-byte sector, so a
|
||||
/// three-sector aligned unit round-trips through the forward direction
|
||||
/// sector by sector and `decrypt_bus` must recover it exactly.
|
||||
#[test]
|
||||
fn decrypt_bus_roundtrips_every_sector_region() {
|
||||
let rdk = [0x91u8; 16];
|
||||
let original = clear_unit();
|
||||
let mut unit = original.clone();
|
||||
// Forward direction, region by region — the inverse of decrypt_bus.
|
||||
for start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
||||
crate::aacs::crypto::aes_cbc_encrypt(&rdk, &mut unit[start + 16..start + SECTOR_BYTES]);
|
||||
}
|
||||
assert_ne!(
|
||||
&unit[16..64],
|
||||
&original[16..64],
|
||||
"the forward direction must have changed the bytes"
|
||||
);
|
||||
decrypt_bus(&mut unit, &rdk);
|
||||
assert_eq!(
|
||||
unit.as_slice(),
|
||||
original.as_slice(),
|
||||
"decrypt_bus must invert the per-sector bus encryption exactly"
|
||||
);
|
||||
}
|
||||
|
||||
// ── CBC decrypt: first-block uses fixed AACS IV ────────────────────────
|
||||
|
||||
/// The published `iv0` bytes, INDEPENDENT of the production constant.
|
||||
///
|
||||
/// [C] §2.1.2 fixes one default CBC IV for every AACS AES-CBC operation.
|
||||
/// Both IV tests below used to compute their expected value from
|
||||
/// `crypto::AACS_IV` itself, so the constant was asserted against itself and
|
||||
/// NOTHING in the suite pinned its bytes: swapping `AACS_IV` for `[0u8; 16]`
|
||||
/// left both tests passing (one builds its ciphertext with the same value and
|
||||
/// the other cancels the change in a triple XOR) while every real AACS disc
|
||||
/// decrypted to noise — block 0 of every 6128-byte aligned unit and of every
|
||||
/// bus-encrypted sector XORed with the wrong IV. This literal is the
|
||||
/// independent witness the tests assert against.
|
||||
const IV0_PUBLISHED: [u8; 16] = [
|
||||
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F,
|
||||
0x78,
|
||||
];
|
||||
|
||||
/// Pins the fixed AACS CBC IV ([C] §2.1.2 `iv0`) against a literal, so a
|
||||
/// change to `crypto::AACS_IV` fails HERE rather than silently shipping.
|
||||
#[test]
|
||||
fn aacs_iv_matches_published_iv0() {
|
||||
assert_eq!(
|
||||
AACS_IV, IV0_PUBLISHED,
|
||||
"the fixed AACS CBC IV must be the published iv0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbc_decrypt_first_block_xors_aacs_iv() {
|
||||
// CBC: P[0] = AES-D(K, C[0]) XOR IV, and the IV is the fixed AACS
|
||||
// constant (not zero). Encrypt a single block forward with IV, then
|
||||
// confirm aes_cbc_decrypt recovers it — proving the IV used on block
|
||||
// 0 is exactly AACS_IV. A mutation that swaps AACS_IV for [0u8;16]
|
||||
// makes the recovered block wrong.
|
||||
// constant (not zero). Encrypt a single block forward with the PUBLISHED
|
||||
// iv0 literal, then confirm aes_cbc_decrypt recovers it — proving the IV
|
||||
// the production code uses on block 0 is exactly that value. Building the
|
||||
// fixture from `IV0_PUBLISHED` rather than from `AACS_IV` is what makes
|
||||
// the claim real: a mutation that swaps AACS_IV for [0u8;16] now makes the
|
||||
// recovered block wrong.
|
||||
let key = [0x24u8; 16];
|
||||
let plain = [0x5Au8; 16];
|
||||
// Forward CBC for one block: C = AES-E(K, P XOR IV).
|
||||
let mut x = plain;
|
||||
for j in 0..16 {
|
||||
x[j] ^= AACS_IV[j];
|
||||
x[j] ^= IV0_PUBLISHED[j];
|
||||
}
|
||||
let ct = aes_ecb_encrypt(&key, &x);
|
||||
let mut buf = ct;
|
||||
@@ -1086,10 +1327,13 @@ mod tests {
|
||||
// * Blocks 1..=3 are independent of the IV — they MUST equal the NIST
|
||||
// plaintext byte-for-byte (P[i] = AES-D(K, C[i]) XOR C[i-1]). This
|
||||
// pins the real reverse-order CBC chaining against a published KAT.
|
||||
// * Block 0 = AES-D(K, C[0]) XOR AACS_IV = NIST_PT[0] XOR NIST_IV
|
||||
// XOR AACS_IV — the documented IV substitution. Asserting this exact
|
||||
// relation pins both the AES decrypt of C[0] AND that block 0 uses
|
||||
// AACS_IV (a swap to [0u8;16] or a chaining bug fails it).
|
||||
// * Block 0 = AES-D(K, C[0]) XOR iv0 = NIST_PT[0] XOR NIST_IV XOR iv0 —
|
||||
// the documented IV substitution. Asserting this exact relation pins
|
||||
// both the AES decrypt of C[0] AND that block 0 uses iv0. The expected
|
||||
// value is built from the `IV0_PUBLISHED` literal, NOT from
|
||||
// `crypto::AACS_IV`: computing it from the production constant made
|
||||
// the change cancel out of the triple XOR, so a swap to [0u8;16] still
|
||||
// passed. It now fails.
|
||||
let key = [
|
||||
0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF,
|
||||
0x4F, 0x3C,
|
||||
@@ -1129,7 +1373,7 @@ mod tests {
|
||||
// Block 0: NIST_PT[0] XOR NIST_IV XOR AACS_IV (the fixed-IV substitution).
|
||||
let mut expected_block0 = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
expected_block0[i] = nist_plaintext[i] ^ nist_iv[i] ^ AACS_IV[i];
|
||||
expected_block0[i] = nist_plaintext[i] ^ nist_iv[i] ^ IV0_PUBLISHED[i];
|
||||
}
|
||||
assert_eq!(
|
||||
&buf[0..16],
|
||||
@@ -1254,7 +1498,7 @@ mod tests {
|
||||
let plain = unit.clone();
|
||||
|
||||
// Forward: CBC-encrypt unit[s+16 .. s+2048] per sector under AACS IV.
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&rdk));
|
||||
let cipher = Aes128::new(&rdk.into());
|
||||
for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
||||
let mut prev = AACS_IV;
|
||||
let body = s + 16;
|
||||
@@ -1265,7 +1509,9 @@ mod tests {
|
||||
for j in 0..16 {
|
||||
unit[off + j] ^= prev[j];
|
||||
}
|
||||
let mut blk = GenericArray::clone_from_slice(&unit[off..off + 16]);
|
||||
let mut chunk = [0u8; 16];
|
||||
chunk.copy_from_slice(&unit[off..off + 16]);
|
||||
let mut blk: Array<u8, _> = chunk.into();
|
||||
cipher.encrypt_block(&mut blk);
|
||||
unit[off..off + 16].copy_from_slice(&blk);
|
||||
prev.copy_from_slice(&unit[off..off + 16]);
|
||||
@@ -1284,7 +1530,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── ts_sync_destroyed / ts_sync_count edge cases ───────────────────────
|
||||
// ── is_clean / ts_sync_count edge cases ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn ts_sync_destroyed_false_for_sub_unit_length() {
|
||||
@@ -1322,6 +1568,95 @@ mod tests {
|
||||
assert_eq!(ts_sync_count(&unit), 1);
|
||||
}
|
||||
|
||||
// ── the encrypted-flag readers ────────────────────────────────────────
|
||||
|
||||
/// `aacs_unit_seed_encrypted` is the flag reader for a PARTIAL unit — the
|
||||
/// guard that stops a truncated encrypted fragment from being emitted as
|
||||
/// clear content. It reads ONLY the two Copy Permission Indicator bits
|
||||
/// ([BD] §3.10.2, byte 0 bits 6-7); the remaining six bits are
|
||||
/// `TP_extra_header` arrival-timestamp bits and carry no encryption
|
||||
/// meaning.
|
||||
///
|
||||
/// Both failure directions are damaging and silent: a reader that answers
|
||||
/// "encrypted" for a clear fragment discards good content, and one that
|
||||
/// answers "clear" for an encrypted fragment writes ciphertext into the
|
||||
/// output as if it were video.
|
||||
#[test]
|
||||
fn aacs_unit_seed_encrypted_reads_only_the_two_cpi_bits() {
|
||||
use crate::disc::ContentFormat::BdTs;
|
||||
|
||||
// CPI bits clear → NOT encrypted, whatever the ATS bits say.
|
||||
for ats in 0u8..=0x3F {
|
||||
assert!(
|
||||
!aacs_unit_seed_encrypted(&[ats], BdTs),
|
||||
"byte0={ats:#04x} has both CPI bits clear → not encrypted"
|
||||
);
|
||||
}
|
||||
|
||||
// Either CPI bit set → encrypted, whatever the ATS bits say.
|
||||
for &cpi in &[0x40u8, 0x80, 0xC0] {
|
||||
assert!(
|
||||
aacs_unit_seed_encrypted(&[cpi], BdTs),
|
||||
"byte0={cpi:#04x} has a CPI bit set → encrypted"
|
||||
);
|
||||
assert!(
|
||||
aacs_unit_seed_encrypted(&[cpi | 0x3F], BdTs),
|
||||
"ATS bits must not change the answer"
|
||||
);
|
||||
}
|
||||
|
||||
// Too short to hold the flag → false rather than a panic.
|
||||
assert!(!aacs_unit_seed_encrypted(&[], BdTs));
|
||||
}
|
||||
|
||||
/// The MpegPs (HD-DVD `.evo`) side reads `PES_scrambling_control` at its own
|
||||
/// fixed offset, and a fragment shorter than that offset must be reported
|
||||
/// clear rather than panic.
|
||||
#[test]
|
||||
fn aacs_unit_seed_encrypted_reads_the_ps_scramble_flag_or_says_clear() {
|
||||
use crate::disc::ContentFormat::MpegPs;
|
||||
|
||||
let mut frag = vec![0u8; PS_SCRAMBLE_OFF + 1];
|
||||
assert!(!aacs_unit_seed_encrypted(&frag, MpegPs), "flag byte zero");
|
||||
frag[PS_SCRAMBLE_OFF] = PS_SCRAMBLE_MASK;
|
||||
assert!(aacs_unit_seed_encrypted(&frag, MpegPs), "flag byte set");
|
||||
// Bits outside the mask are not the scrambling control.
|
||||
frag[PS_SCRAMBLE_OFF] = !PS_SCRAMBLE_MASK;
|
||||
assert!(!aacs_unit_seed_encrypted(&frag, MpegPs), "outside the mask");
|
||||
// A fragment that stops short of the flag byte is not classifiable.
|
||||
assert!(!aacs_unit_seed_encrypted(&frag[..PS_SCRAMBLE_OFF], MpegPs));
|
||||
}
|
||||
|
||||
/// `aacs_unit_encrypted` is the AUTHORITATIVE gate and requires a WHOLE
|
||||
/// 6144-byte aligned unit: on anything shorter the flag byte is not
|
||||
/// guaranteed to be the unit's, so it must answer `false` and leave the
|
||||
/// partial-unit case to `aacs_unit_seed_encrypted`. A reversed length guard
|
||||
/// would both classify fragments off arbitrary mid-stream bytes and, on an
|
||||
/// empty slice, index out of bounds.
|
||||
#[test]
|
||||
fn aacs_unit_encrypted_requires_a_whole_aligned_unit() {
|
||||
use crate::disc::ContentFormat::BdTs;
|
||||
|
||||
// A short buffer whose byte 0 has the CPI bits set is still NOT a unit.
|
||||
let mut short = vec![0u8; ALIGNED_UNIT_LEN - 1];
|
||||
short[0] = 0xC0;
|
||||
assert!(
|
||||
!aacs_unit_encrypted(&short, BdTs),
|
||||
"a sub-unit buffer must not be classified"
|
||||
);
|
||||
assert!(!aacs_unit_encrypted(&[], BdTs), "empty must not index");
|
||||
|
||||
// Exactly one aligned unit IS classified.
|
||||
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
unit[0] = 0xC0;
|
||||
assert!(
|
||||
aacs_unit_encrypted(&unit, BdTs),
|
||||
"a full unit with CPI set is encrypted"
|
||||
);
|
||||
unit[0] = 0x00;
|
||||
assert!(!aacs_unit_encrypted(&unit, BdTs), "CPI clear is not");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ts_packet_total_for_various_lengths() {
|
||||
// total = len / 192 (BD-TS packet size). Pin a few lengths.
|
||||
|
||||
+175
-9
@@ -8,17 +8,44 @@
|
||||
//! content / keys / variant modules.
|
||||
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
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`).
|
||||
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`.
|
||||
#[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.
|
||||
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.
|
||||
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`).
|
||||
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
let cipher = Aes128::new(&(*key).into());
|
||||
let mut block: Array<u8, _> = (*data).into();
|
||||
cipher.encrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
@@ -27,25 +54,81 @@ pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
|
||||
/// AES-128-ECB decrypt a single 16-byte block. [C] §2.1.1 (`AES-128D`).
|
||||
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
let cipher = Aes128::new(&(*key).into());
|
||||
let mut block: Array<u8, _> = (*data).into();
|
||||
cipher.decrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
out
|
||||
}
|
||||
|
||||
/// AES-128-CBC decrypt in-place with the fixed AACS IV. [C] §2.1.2 (`AES-128CBCD`).
|
||||
/// 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.
|
||||
pub(crate) fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
debug_assert!(
|
||||
data.len().is_multiple_of(16),
|
||||
"aes_cbc_encrypt requires a block-aligned slice"
|
||||
);
|
||||
let cipher = new_cipher(key);
|
||||
let num_blocks = data.len() / 16;
|
||||
let mut prev = AACS_IV;
|
||||
// Forward order: each block is XORed with the PRECEDING ciphertext block.
|
||||
for i in 0..num_blocks {
|
||||
let offset = i * 16;
|
||||
let mut block = [0u8; 16];
|
||||
for j in 0..16 {
|
||||
block[j] = data[offset + j] ^ prev[j];
|
||||
}
|
||||
let mut ga: Array<u8, _> = block.into();
|
||||
cipher.encrypt_block(&mut ga);
|
||||
data[offset..offset + 16].copy_from_slice(&ga);
|
||||
prev.copy_from_slice(&ga);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.)
|
||||
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
debug_assert!(
|
||||
data.len() % 16 == 0,
|
||||
data.len().is_multiple_of(16),
|
||||
"aes_cbc_decrypt requires a block-aligned slice"
|
||||
);
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
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.
|
||||
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() {
|
||||
@@ -57,7 +140,9 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
p.copy_from_slice(&data[(i - 1) * 16..i * 16]);
|
||||
p
|
||||
};
|
||||
let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
|
||||
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];
|
||||
@@ -100,3 +185,84 @@ pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
|
||||
+1082
-1
File diff suppressed because it is too large
Load Diff
@@ -63,11 +63,17 @@ pub fn unit_disposition(
|
||||
None => UnitDisposition::Default,
|
||||
// In a forensic segment → decide by whether it is our index.
|
||||
Some(seg) => {
|
||||
let seg_index = seg.index as u8;
|
||||
// `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).
|
||||
let seg_index = seg.index;
|
||||
let diag = seg_index.min(u8::MAX as u16) as u8;
|
||||
match disc_index {
|
||||
Some(v) if v == seg_index => UnitDisposition::Index(v),
|
||||
Some(_) => UnitDisposition::DropForeignIndex(seg_index),
|
||||
None => UnitDisposition::ForensicNoKey(seg_index),
|
||||
Some(v) if u16::from(v) == seg_index => UnitDisposition::Index(v),
|
||||
Some(_) => UnitDisposition::DropForeignIndex(diag),
|
||||
None => UnitDisposition::ForensicNoKey(diag),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,6 +167,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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
|
||||
@@ -169,7 +189,7 @@ mod tests {
|
||||
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
|
||||
// Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
assert!(80 + unit_packets - 1 >= 100, "sanity: unit tails into seg");
|
||||
assert!(80 + unit_packets > 100, "sanity: unit tails into seg");
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(5)),
|
||||
UnitDisposition::Index(5)
|
||||
|
||||
+474
-50
@@ -5,7 +5,6 @@
|
||||
use super::mkb::*;
|
||||
|
||||
/// Parsed Unit_Key_RO.inf file.
|
||||
#[derive(Debug)]
|
||||
pub struct UnitKeyFile {
|
||||
/// Disc hash (SHA1 of the entire file) — used as KEYDB lookup key
|
||||
pub disc_hash: [u8; 20],
|
||||
@@ -23,6 +22,29 @@ 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`.
|
||||
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.
|
||||
.field("disc_hash", &disc_hash_hex(&self.disc_hash))
|
||||
.field("app_type", &self.app_type)
|
||||
.field("num_bdmv_dir", &self.num_bdmv_dir)
|
||||
.field("use_skb_mkb", &self.use_skb_mkb)
|
||||
.field("version", &self.version)
|
||||
.field("encrypted_keys", &"<redacted>")
|
||||
.field("encrypted_keys_len", &self.encrypted_keys.len())
|
||||
.field("title_cps_unit", &self.title_cps_unit)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute disc hash (SHA1 of Unit_Key_RO.inf content).
|
||||
pub fn disc_hash(data: &[u8]) -> [u8; 20] {
|
||||
use sha1::{Digest, Sha1};
|
||||
@@ -161,33 +183,52 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
|
||||
})
|
||||
}
|
||||
|
||||
/// HD DVD Video Title Key File (`VTKF000.AACS`) magic — "DVD HD Video TKF".
|
||||
/// HD DVD Video Title Key File (`VTKF%%%.AACS`) magic — "DVD_HD_V_TKF".
|
||||
pub const VTKF_MAGIC: &[u8; 12] = b"DVD_HD_V_TKF";
|
||||
/// Fixed header length before the first title-key entry.
|
||||
/// Fixed header length before the first Title Key Entry (AACS HD DVD Book,
|
||||
/// Table 3-8).
|
||||
const VTKF_HEADER_LEN: usize = 0x80;
|
||||
/// Each title-key entry: BE32 flag + 16-byte encrypted key + 12-byte 0xFF pad.
|
||||
const VTKF_ENTRY_LEN: usize = 0x20;
|
||||
/// Title Key Entry stride (Table 3-8): 1-byte `BIFO` + 3 reserved + 16-byte
|
||||
/// encrypted title key + 16-byte binding MAC = 36 bytes.
|
||||
const VTKF_ENTRY_LEN: usize = 0x24;
|
||||
/// Byte offset of the encrypted title key within an entry (after `BIFO` + 3
|
||||
/// reserved).
|
||||
const VTKF_KEY_OFF: usize = 4;
|
||||
/// Number of Title Key Entry slots in a VTKF (Table 3-8): a fixed 64.
|
||||
const VTKF_MAX_ENTRIES: usize = 64;
|
||||
/// `BIFO` bit 7 (`AV_FLG`): set = this slot carries an available title key.
|
||||
const VTKF_AV_FLG: u8 = 0x80;
|
||||
|
||||
/// Parse an HD DVD `VTKF000.AACS` into the SAME [`UnitKeyFile`] a BD/UHD
|
||||
/// Parse an HD DVD `VTKF%%%.AACS` into the SAME [`UnitKeyFile`] a BD/UHD
|
||||
/// `Unit_Key_RO.inf` yields — so the shared AACS crypto (`derive_unit_keys` →
|
||||
/// `decrypt_unit_key(vuk, …)`) unwraps HD DVD title keys with no change. Only
|
||||
/// the on-disc CONTAINER differs between BD and HD DVD; the title-key unwrap is
|
||||
/// the identical AES-128 VUK step (`Kt = AES-128D(Kvu, Kte)`).
|
||||
///
|
||||
/// Layout (grounded in real discs — Shaun of the Dead, Anchorman, Harry Potter):
|
||||
/// Layout — AACS "HD DVD and DVD Pre-recorded Book" Table 3-8, a fixed
|
||||
/// 2480-byte file, verified byte-exact against real discs (Freedom `VTKF090`,
|
||||
/// Dukes of Hazzard `VTKF000`):
|
||||
/// ```text
|
||||
/// [0x00..0x0C] magic "DVD_HD_V_TKF"
|
||||
/// [0x0C..0x10] BE32 total file length
|
||||
/// [0x10..0x1C] associated playlist name ("VPLST000.XPL")
|
||||
/// [0x1C..0x80] reserved (zero)
|
||||
/// [0x80..] 32-byte entries: BE32 flag | 16-byte ENCRYPTED title key | 12-byte 0xFF pad
|
||||
/// flag bit 31 (0x8000_0000) set = present; a cleared flag ends the table
|
||||
/// [tail] 16-byte signature/MAC (never a key — the cleared-flag stop guards it)
|
||||
/// [0x0C..0x10] BE32 HD_VTKF_SIZE (2480)
|
||||
/// [0x10..0x1C] associated playlist name ("VPLST%%%.XPL")
|
||||
/// [0x1C..0x80] reserved
|
||||
/// [0x80..] 64 entries × 36 bytes:
|
||||
/// BIFO (1) | reserved (3) | ENCRYPTED title key (16) | binding MAC (16)
|
||||
/// BIFO bit 7 (AV_FLG) set = this slot holds a title key
|
||||
/// (pre-recorded discs fill the binding MAC with 0xFF)
|
||||
/// [0x9A0..2480] 16-byte TKF MAC (CMAC keyed by Kvu — NOT a key)
|
||||
/// ```
|
||||
/// Entries number 1..=N as CPS units, matching `Unit_Key_RO`'s 1-based CPS
|
||||
/// numbering, so a title's CPS unit indexes this list identically. The
|
||||
/// title→CPS mapping itself is playlist-driven (`VPLST000.XPL`) and owned by the
|
||||
/// HD DVD enumerator, so `title_cps_unit` is left empty here.
|
||||
/// The slot index (1-based) is the CPS unit number, so an absent slot is
|
||||
/// SKIPPED (not a terminator) — collapsing gaps would renumber later keys and
|
||||
/// hand the wrong title key to CPS unit N+1. The title→CPS mapping is
|
||||
/// playlist-driven (`VPLST%%%.XPL`) and owned by the HD DVD enumerator, so
|
||||
/// `title_cps_unit` is left empty here.
|
||||
///
|
||||
/// The prior parser used a 32-byte stride (a 12-byte pad instead of the 16-byte
|
||||
/// binding MAC). That reads entry #1 correctly but drifts +4 bytes per entry
|
||||
/// after it, so it only decrypted single-CPS-unit discs; every multi-key VTKF
|
||||
/// (real multi-CPS-unit HD-DVD titles) yielded garbage keys for CPS unit ≥2.
|
||||
pub fn parse_vtkf(data: &[u8]) -> Option<UnitKeyFile> {
|
||||
if data.len() < VTKF_HEADER_LEN || &data[..12] != VTKF_MAGIC {
|
||||
return None;
|
||||
@@ -198,20 +239,19 @@ pub fn parse_vtkf(data: &[u8]) -> Option<UnitKeyFile> {
|
||||
let hash = disc_hash(data);
|
||||
|
||||
let mut encrypted_keys = Vec::new();
|
||||
let mut pos = VTKF_HEADER_LEN;
|
||||
let mut cps: u32 = 1;
|
||||
while pos + VTKF_ENTRY_LEN <= data.len() {
|
||||
let flag = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
|
||||
// A cleared present-bit terminates the key table. The file's trailing
|
||||
// 16-byte signature then follows and must NOT be read as a key.
|
||||
if flag & 0x8000_0000 == 0 {
|
||||
for n in 0..VTKF_MAX_ENTRIES {
|
||||
let pos = VTKF_HEADER_LEN + n * VTKF_ENTRY_LEN;
|
||||
if pos + VTKF_ENTRY_LEN > data.len() {
|
||||
break;
|
||||
}
|
||||
// AV_FLG clear = empty slot: skip it, but keep the slot index as the CPS
|
||||
// number (do NOT break — a gap must not renumber the keys that follow).
|
||||
if data[pos] & VTKF_AV_FLG == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut key = [0u8; 16];
|
||||
key.copy_from_slice(&data[pos + 4..pos + 20]);
|
||||
encrypted_keys.push((cps, key));
|
||||
cps += 1;
|
||||
pos += VTKF_ENTRY_LEN;
|
||||
key.copy_from_slice(&data[pos + VTKF_KEY_OFF..pos + VTKF_KEY_OFF + 16]);
|
||||
encrypted_keys.push((n as u32 + 1, key));
|
||||
}
|
||||
if encrypted_keys.is_empty() {
|
||||
return None;
|
||||
@@ -369,42 +409,51 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
mod vtkf_tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a synthetic `VTKF000.AACS` matching the real on-disc layout
|
||||
/// (Shaun of the Dead / Anchorman): magic, BE32 size, playlist name,
|
||||
/// reserved to 0x80, then 32-byte present-flagged entries, a cleared-flag
|
||||
/// terminator, and a 16-byte trailer.
|
||||
/// Build a synthetic `VTKF%%%.AACS` matching the real on-disc layout (AACS
|
||||
/// HD DVD Book Table 3-8, verified against Freedom `VTKF090` and Dukes
|
||||
/// `VTKF000`): magic, BE32 size, playlist name, reserved to 0x80, then 64
|
||||
/// entry slots of 36 bytes (the first `keys.len()` present with `AV_FLG`
|
||||
/// set, the rest empty), a reserved gap, and the 16-byte trailing TKF MAC.
|
||||
fn synth_vtkf(keys: &[[u8; 16]]) -> Vec<u8> {
|
||||
const FILE_LEN: usize = 2480;
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(VTKF_MAGIC); // 0x00
|
||||
v.extend_from_slice(&0u32.to_be_bytes()); // 0x0C size (patched below)
|
||||
v.extend_from_slice(b"VPLST000.XPL"); // 0x10
|
||||
v.resize(0x80, 0); // reserve to first entry
|
||||
for k in keys {
|
||||
v.extend_from_slice(&0x8000_0000u32.to_be_bytes()); // present flag
|
||||
v.extend_from_slice(k); // 16-byte encrypted title key
|
||||
v.extend_from_slice(&[0xFFu8; 12]); // 0xFF pad → 32-byte entry
|
||||
v.extend_from_slice(&(FILE_LEN as u32).to_be_bytes()); // 0x0C HD_VTKF_SIZE
|
||||
v.extend_from_slice(b"VPLST000.XPL"); // 0x10 playlist name
|
||||
v.resize(VTKF_HEADER_LEN, 0); // reserve to first entry (0x80)
|
||||
for n in 0..VTKF_MAX_ENTRIES {
|
||||
if let Some(k) = keys.get(n) {
|
||||
v.push(VTKF_AV_FLG); // BIFO: AV_FLG set (present)
|
||||
v.extend_from_slice(&[0, 0, 0]); // reserved
|
||||
v.extend_from_slice(k); // 16-byte encrypted title key
|
||||
v.extend_from_slice(&[0xFFu8; 16]); // binding MAC (0xFF, pre-recorded)
|
||||
} else {
|
||||
v.extend_from_slice(&[0u8; VTKF_ENTRY_LEN]); // empty slot (AV_FLG clear)
|
||||
}
|
||||
}
|
||||
// Cleared-flag terminator entry (must NOT be read as a key).
|
||||
v.extend_from_slice(&[0u8; VTKF_ENTRY_LEN]);
|
||||
// 16-byte trailing signature (must NOT be read as a key).
|
||||
v.extend_from_slice(&[0xABu8; 16]);
|
||||
let len = v.len() as u32;
|
||||
v[0x0C..0x10].copy_from_slice(&len.to_be_bytes());
|
||||
v.resize(FILE_LEN - 16, 0); // reserved gap before the trailer
|
||||
v.extend_from_slice(&[0xABu8; 16]); // TKF MAC (must NOT be read as a key)
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_extracts_present_entries_and_stops_at_terminator() {
|
||||
fn parse_vtkf_reads_present_entries_skips_empty_ignores_mac() {
|
||||
let k1 = [0x11u8; 16];
|
||||
let k2 = [0x22u8; 16];
|
||||
let k3 = [0x33u8; 16];
|
||||
let data = synth_vtkf(&[k1, k2, k3]);
|
||||
|
||||
let ukf = parse_vtkf(&data).expect("valid VTKF must parse");
|
||||
// Exactly the three present entries — the cleared-flag terminator and
|
||||
// the 16-byte trailer are NOT mistaken for keys.
|
||||
assert_eq!(ukf.encrypted_keys.len(), 3, "must stop at the cleared flag");
|
||||
assert_eq!(ukf.encrypted_keys[0], (1, k1), "CPS units number 1..=N");
|
||||
// Exactly the three present entries — the empty slots and the trailing
|
||||
// 16-byte TKF MAC are NOT mistaken for keys. Critically, k2/k3 are read
|
||||
// at the 36-byte stride (offsets 0xA4, 0xC8); the old 32-byte stride
|
||||
// misread them from inside the previous entry's binding MAC.
|
||||
assert_eq!(ukf.encrypted_keys.len(), 3);
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys[0],
|
||||
(1, k1),
|
||||
"CPS units = 1-based slot index"
|
||||
);
|
||||
assert_eq!(ukf.encrypted_keys[1], (2, k2));
|
||||
assert_eq!(ukf.encrypted_keys[2], (3, k3));
|
||||
assert_eq!(ukf.version, AacsVersion::V10, "HD DVD is AACS 1.0");
|
||||
@@ -412,6 +461,22 @@ mod vtkf_tests {
|
||||
assert_eq!(ukf.disc_hash, disc_hash(&data));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_reads_a_full_64_entry_file() {
|
||||
// Real discs (Freedom, Dukes) carry all 64 slots present. Every key must
|
||||
// come back, none dropped and none drifted — the regression the 32-byte
|
||||
// stride failed.
|
||||
let keys: Vec<[u8; 16]> = (0..VTKF_MAX_ENTRIES).map(|n| [n as u8; 16]).collect();
|
||||
let ukf = parse_vtkf(&synth_vtkf(&keys)).expect("64-entry VTKF");
|
||||
assert_eq!(ukf.encrypted_keys.len(), 64);
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys[63],
|
||||
(64, [63u8; 16]),
|
||||
"entry 64 at 0x{:x}",
|
||||
VTKF_HEADER_LEN + 63 * VTKF_ENTRY_LEN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_rejects_non_magic() {
|
||||
let mut data = synth_vtkf(&[[0x11u8; 16]]);
|
||||
@@ -454,4 +519,363 @@ mod vtkf_tests {
|
||||
// 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}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod read_mkb_tests {
|
||||
use super::*;
|
||||
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE, ScsiResult, ScsiTransport};
|
||||
|
||||
/// A drive that answers READ DISC STRUCTURE format 0x83 from a scripted set
|
||||
/// of packs and records every CDB it was handed.
|
||||
struct MkbDrive {
|
||||
/// One entry per pack: the pack's MKB payload bytes.
|
||||
packs: Vec<Vec<u8>>,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ScsiTransport for MkbDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
// Pack number is carried in the CDB address field (bytes 2..6),
|
||||
// MMC-6 READ DISC STRUCTURE.
|
||||
let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]) as usize;
|
||||
let body = self.packs.get(pack).cloned().unwrap_or_default();
|
||||
// Header: BE16 data length (counts the 2 header bytes that follow
|
||||
// it plus the payload), reserved byte, pack count, then payload.
|
||||
let data_len = body.len() + 2;
|
||||
data[0..2].copy_from_slice(&(data_len as u16).to_be_bytes());
|
||||
data[2] = 0x00;
|
||||
data[3] = self.packs.len() as u8;
|
||||
data[4..4 + body.len()].copy_from_slice(&body);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4 + body.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+105
@@ -432,4 +432,109 @@ mod tests {
|
||||
"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));
|
||||
}
|
||||
}
|
||||
|
||||
+277
-36
@@ -40,17 +40,24 @@ pub mod trace;
|
||||
pub mod types;
|
||||
pub mod variant;
|
||||
|
||||
/// On-disc UDF paths to the AACS key-input files.
|
||||
/// On-disc UDF paths to the AACS key-input files, plus HD DVD AACS-directory
|
||||
/// discovery.
|
||||
///
|
||||
/// BD and UHD keep their key material under `/AACS/…`; HD DVD keeps the
|
||||
/// equivalents under `/ANY!/…` with different names (`VTKF000.AACS` is the
|
||||
/// title-key file — magic `DVD_HD_V_TKF`; `MKBROM.AACS` is the MKB). The
|
||||
/// container difference is expressed here purely as DATA: each ROLE
|
||||
/// ([`UNIT_KEY_RO_PATHS`], [`MKB_PATHS`], [`CONTENT_CERT_PATHS`]) is an ordered
|
||||
/// candidate list, and every reader walks it with [`read_first`] taking the
|
||||
/// first that reads. No reader ever branches on disc type — a BD/UHD disc has
|
||||
/// the `/AACS/` files so those win; an HD DVD has neither, so it falls through
|
||||
/// to the `/ANY!/` entry. Centralised so `resolve_vid_only`, `read_aacs_inputs`,
|
||||
/// BD and UHD keep their key material under a fixed `/AACS/…` tree, so those
|
||||
/// paths are constants. HD DVD keeps the equivalents in a reserved root
|
||||
/// directory whose NAME is authoring-house-specific — observed `ANY!` (Dukes
|
||||
/// of Hazzard) and `AAC!` (Freedom / Memory-Tech), each with a `<name>!_BAK`
|
||||
/// mirror — and whose title-key file is NOT always `VTKF000.AACS` (Freedom
|
||||
/// ships `VTKF090.AACS` + `VTKF100.AACS`). So the HD DVD files are DISCOVERED
|
||||
/// from the parsed UDF tree ([`find_hddvd_aacs_dir`] + [`role_paths`]), never
|
||||
/// hardcoded.
|
||||
///
|
||||
/// Each key ROLE ([`AacsRole`]) resolves to an ordered candidate list — the
|
||||
/// BD/UHD constants first, then whatever the HD DVD directory actually holds —
|
||||
/// which every reader walks with [`read_first`], first-that-reads. No reader
|
||||
/// ever branches on disc type: a BD/UHD disc has the `/AACS/` files so those
|
||||
/// win; an HD DVD has none of them, so it falls through to the discovered
|
||||
/// entries. Centralised so `resolve_vid_only`, `read_aacs_inputs`,
|
||||
/// `read_mkb_content`, and `read_aacs_version` can never silently diverge the
|
||||
/// disc_hash / MKB / VID that another reader feeds a key service.
|
||||
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
|
||||
@@ -59,41 +66,107 @@ pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf";
|
||||
pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
|
||||
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
|
||||
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
||||
/// HD DVD title-key file (`/ANY!/`), forwarded as `inf_b64`; the key service
|
||||
/// recognises it by its `DVD_HD_V_TKF` magic.
|
||||
pub const PATH_VTKF_HDDVD: &str = "/ANY!/VTKF000.AACS";
|
||||
/// HD DVD Media Key Block (`/ANY!/`), forwarded as `mkb_b64`.
|
||||
pub const PATH_MKBROM_HDDVD: &str = "/ANY!/MKBROM.AACS";
|
||||
/// HD DVD content certificate (`/ANY!/`); byte 0 gives the AACS major (0x00 → V10).
|
||||
pub const PATH_CONTENT_CERT_HDDVD: &str = "/ANY!/CONTENT_CERT.AACS";
|
||||
|
||||
/// Title-key / `Unit_Key_RO.inf` role, in resolution order (BD/UHD, then HD DVD).
|
||||
pub const UNIT_KEY_RO_PATHS: &[&str] = &[
|
||||
PATH_UNIT_KEY_RO,
|
||||
PATH_UNIT_KEY_RO_DUPLICATE,
|
||||
PATH_VTKF_HDDVD,
|
||||
];
|
||||
/// MKB role, in resolution order (BD/UHD RO then RW, then HD DVD).
|
||||
pub const MKB_PATHS: &[&str] = &[PATH_MKB_RO, PATH_MKB_RW, PATH_MKBROM_HDDVD];
|
||||
/// Content-certificate role, in resolution order (BD/UHD, then HD DVD).
|
||||
pub const CONTENT_CERT_PATHS: &[&str] = &[
|
||||
PATH_CONTENT_CERT,
|
||||
PATH_CONTENT_CERT_ALT,
|
||||
PATH_CONTENT_CERT_HDDVD,
|
||||
];
|
||||
/// An AACS key-input role. [`role_paths`] maps it to an ordered candidate path
|
||||
/// list (BD/UHD constants, then the discovered HD DVD files).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AacsRole {
|
||||
/// Title-key file: BD/UHD `Unit_Key_RO.inf`, HD DVD `VTKF*.AACS`
|
||||
/// (magic `DVD_HD_V_TKF`). The disc_hash is `SHA1` of this file.
|
||||
UnitKey,
|
||||
/// Media Key Block: BD/UHD `MKB_RO/RW.inf`, HD DVD `MKBROM.AACS`.
|
||||
Mkb,
|
||||
/// Content certificate: BD/UHD `Content000/001.cer`, HD DVD
|
||||
/// `CONTENT_CERT.AACS` (byte 0 gives the AACS major).
|
||||
ContentCert,
|
||||
}
|
||||
|
||||
/// Walk an AACS role's candidate paths and return the first that reads.
|
||||
/// The HD DVD AACS directory in a parsed UDF tree, if present.
|
||||
///
|
||||
/// Identified structurally, NOT by a hardcoded name: the root child directory
|
||||
/// whose name ends in `!` (so the `<name>!_BAK` backup mirror, which also ends
|
||||
/// in a non-`!` char, is not mistaken for it) and which contains `MKBROM.AACS`.
|
||||
/// Observed real names: `ANY!` (Dukes of Hazzard), `AAC!` (Freedom). A BD/UHD
|
||||
/// disc has no such directory → `None`.
|
||||
pub(crate) fn find_hddvd_aacs_dir(udf: &crate::udf::UdfFs) -> Option<&crate::udf::DirEntry> {
|
||||
udf.root.entries.iter().find(|e| {
|
||||
e.is_dir
|
||||
&& e.name.ends_with('!')
|
||||
&& e.entries
|
||||
.iter()
|
||||
.any(|c| !c.is_dir && c.name.eq_ignore_ascii_case("MKBROM.AACS"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Ordered candidate paths for an AACS key [`AacsRole`]: the fixed BD/UHD
|
||||
/// `/AACS/…` paths first, then the actual HD DVD files discovered in the disc's
|
||||
/// AACS directory (see [`find_hddvd_aacs_dir`]). A disc has only one family, so
|
||||
/// the other family's entries simply never read.
|
||||
///
|
||||
/// For [`AacsRole::UnitKey`] every `VTKF*.AACS` in the directory is appended in
|
||||
/// sorted name order — a disc may carry more than one variant (Freedom:
|
||||
/// `VTKF090` + `VTKF100`), not just `VTKF000`.
|
||||
pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec<String> {
|
||||
let mut v: Vec<String> = match role {
|
||||
AacsRole::UnitKey => vec![PATH_UNIT_KEY_RO, PATH_UNIT_KEY_RO_DUPLICATE],
|
||||
AacsRole::Mkb => vec![PATH_MKB_RO, PATH_MKB_RW],
|
||||
AacsRole::ContentCert => vec![PATH_CONTENT_CERT, PATH_CONTENT_CERT_ALT],
|
||||
}
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
if let Some(dir) = find_hddvd_aacs_dir(udf) {
|
||||
let d = &dir.name;
|
||||
match role {
|
||||
AacsRole::Mkb => v.push(format!("/{d}/MKBROM.AACS")),
|
||||
AacsRole::ContentCert => v.push(format!("/{d}/CONTENT_CERT.AACS")),
|
||||
AacsRole::UnitKey => {
|
||||
// Glob VTKF*.AACS — the title-key filename is not fixed at
|
||||
// VTKF000 (Freedom ships VTKF090 + VTKF100). Sorted for a
|
||||
// deterministic try order.
|
||||
//
|
||||
// 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.
|
||||
let mut names: Vec<&str> = dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir)
|
||||
.filter(|e| {
|
||||
let u = e.name.to_ascii_uppercase();
|
||||
u.starts_with("VTKF") && u.ends_with(".AACS")
|
||||
})
|
||||
.map(|e| e.name.as_str())
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
v.extend(names.into_iter().map(|n| format!("/{d}/{n}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Walk an AACS role's candidate paths (from [`role_paths`]) and return the
|
||||
/// first that reads.
|
||||
///
|
||||
/// `read` performs the actual per-path read (full file or bounded prefix), so
|
||||
/// callers share the same first-present walk regardless of read style. Returns
|
||||
/// [`Error::AacsNoKeys`] if no candidate is present. This is the single place
|
||||
/// the `/AACS/` (BD/UHD) vs `/ANY!/` (HD DVD) layout difference is resolved.
|
||||
pub(crate) fn read_first<F>(candidates: &[&str], mut read: F) -> crate::error::Result<Vec<u8>>
|
||||
/// [`Error::AacsNoKeys`] if no candidate is present. Generic over the path
|
||||
/// element (`&str` or owned `String`) so it accepts the `Vec<String>` that
|
||||
/// [`role_paths`] builds from the discovered HD DVD directory.
|
||||
pub(crate) fn read_first<S, F>(candidates: &[S], mut read: F) -> crate::error::Result<Vec<u8>>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
F: FnMut(&str) -> crate::error::Result<Vec<u8>>,
|
||||
{
|
||||
for path in candidates {
|
||||
if let Ok(buf) = read(path) {
|
||||
if let Ok(buf) = read(path.as_ref()) {
|
||||
return Ok(buf);
|
||||
}
|
||||
}
|
||||
@@ -161,4 +234,172 @@ mod tests {
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
// ── HD DVD AACS directory / filename discovery ────────────────────────
|
||||
//
|
||||
// The HD DVD AACS dir name and title-key filename are authoring-specific
|
||||
// and were previously hardcoded to `/ANY!/VTKF000.AACS`. These verify the
|
||||
// discovery replacement against both real-disc shapes: Freedom (`AAC!` +
|
||||
// `VTKF090`/`VTKF100`) and a BD/UHD disc (no HD DVD dir).
|
||||
|
||||
#[test]
|
||||
fn role_paths_discovers_hddvd_dir_and_globs_all_vtkf_variants() {
|
||||
use crate::udf::fixture::*;
|
||||
// Freedom-shaped: an `AAC!` dir (NOT `ANY!`) holding MKBROM + two VTKF
|
||||
// variants (090/100, NOT 000) + a VTUF usage file (must be excluded),
|
||||
// plus the `AAC!_BAK` mirror (must NOT be picked as the AACS dir).
|
||||
let mut disc = MemDisc::new();
|
||||
let aacs_files = vec![
|
||||
file("MKBROM.AACS", 100, 5000, 4096, true),
|
||||
file("CONTENT_CERT.AACS", 101, 5100, 2048, true),
|
||||
file("VTKF100.AACS", 102, 5200, 2048, true),
|
||||
file("VTKF090.AACS", 103, 5300, 2048, true),
|
||||
file("VTUF090.AACS", 104, 5400, 2048, true),
|
||||
];
|
||||
let bak_files = vec![file("MKBROM.AACS", 110, 6000, 4096, true)];
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![
|
||||
DirSpec {
|
||||
name: "AAC!".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: aacs_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
name: "AAC!_BAK".to_string(),
|
||||
icb_lba: 30,
|
||||
dir_data_lba: 31,
|
||||
files: bak_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
// Discovered structurally (ends in '!', holds MKBROM.AACS) — the real
|
||||
// AACS dir, never the `_BAK` mirror.
|
||||
let dir = super::find_hddvd_aacs_dir(&udf).expect("aacs dir");
|
||||
assert_eq!(dir.name, "AAC!");
|
||||
|
||||
// UnitKey: BD/UHD paths first, then EVERY VTKF*.AACS in sorted order
|
||||
// (090 before 100) — NOT hardcoded VTKF000; VTUF (usage) excluded.
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
"/AAC!/VTKF090.AACS".to_string(),
|
||||
"/AAC!/VTKF100.AACS".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::Mkb)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/MKBROM.AACS"
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::ContentCert)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/CONTENT_CERT.AACS"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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],
|
||||
@@ -357,6 +368,42 @@ mod tests {
|
||||
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] = &[];
|
||||
|
||||
+209
-17
@@ -13,7 +13,6 @@ use super::mkb::*;
|
||||
// ── Full VUK resolution chain ───────────────────────────────────────────────
|
||||
|
||||
/// Result of resolving a disc's VUK.
|
||||
#[derive(Debug)]
|
||||
pub struct ResolvedKeys {
|
||||
/// Disc hash (SHA1 of Unit_Key_RO.inf)
|
||||
pub disc_hash: [u8; 20],
|
||||
@@ -34,6 +33,23 @@ pub struct ResolvedKeys {
|
||||
pub key_source: u8,
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `vuk` and `unit_keys` are raw key bytes, never printed.
|
||||
// `disc_hash` is the public per-disc identifier (SHA-1 of the .inf), not secret.
|
||||
// Guarded by `resolved_keys_debug_is_redacted`.
|
||||
impl std::fmt::Debug for ResolvedKeys {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ResolvedKeys")
|
||||
.field("disc_hash", &self.disc_hash)
|
||||
.field("vuk", &self.vuk.map(|_| "<redacted>"))
|
||||
.field("unit_keys_len", &self.unit_keys.len())
|
||||
.field("title_cps_unit", &self.title_cps_unit)
|
||||
.field("version", &self.version)
|
||||
.field("bus_encryption", &self.bus_encryption)
|
||||
.field("key_source", &self.key_source)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Inputs shared by every classical-path resolver. References only —
|
||||
/// callers retain ownership of all buffers.
|
||||
pub struct ResolveContext<'a> {
|
||||
@@ -363,26 +379,18 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
|
||||
// One AES-D + magic check per candidate (cheap). mk_dv is hoisted
|
||||
// out of the loop so the MKB is not re-walked per candidate.
|
||||
let mks = providers.media_keys();
|
||||
let mut mk_hits: Vec<[u8; 16]> = Vec::new();
|
||||
if let Some(mk_dv) = mkb_find_mk_dv(mkb) {
|
||||
for mk in &mks {
|
||||
let verifies = aes_ecb_decrypt(mk, &mk_dv)[..8]
|
||||
== [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||
if verifies && !mk_hits.contains(mk) {
|
||||
mk_hits.push(*mk);
|
||||
if mk_hits.len() > 1 {
|
||||
break; // ambiguous — bail to avoid a wrong key
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if mk_hits.len() == 1 {
|
||||
let vuk = derive_vuk(&mk_hits[0], ctx.volume_id);
|
||||
let chosen_mk = mkb_find_mk_dv(mkb).and_then(|mk_dv| {
|
||||
unique_verifying_mk(&mks, |mk| {
|
||||
aes_ecb_decrypt(mk, &mk_dv)[..8] == MK_VERIFY_MAGIC
|
||||
})
|
||||
});
|
||||
if let Some(mk) = chosen_mk {
|
||||
let vuk = derive_vuk(&mk, ctx.volume_id);
|
||||
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path2_5_hit", mk_pool = mks.len(), "media key from keydb MK-pool brute (km_verifies)");
|
||||
// Same class as path 3 (KEYDB MK → derived VUK).
|
||||
return Some(build(Some(vuk), derive_uks(&vuk), 3));
|
||||
}
|
||||
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path2_5_miss", mk_pool = mks.len(), mk_hits = mk_hits.len(), "MK-pool brute: no unique verifying MK");
|
||||
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path2_5_miss", mk_pool = mks.len(), "MK-pool brute: no unique verifying MK");
|
||||
} else {
|
||||
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB; paths 1/2 skipped");
|
||||
}
|
||||
@@ -434,6 +442,42 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
|
||||
None
|
||||
}
|
||||
|
||||
/// First 8 bytes of the plaintext behind an MKB Verify Media Key record — the
|
||||
/// AACS "this is the right Km" sentinel (`0123456789ABCDEF`). A candidate MK
|
||||
/// verifies when AES-128-ECB-D(mk, mk_dv) starts with it.
|
||||
const MK_VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||
|
||||
/// The MK-pool selection rule of path 2.5, split out of [`resolve_keys_v1`] so
|
||||
/// the ambiguity guard has a reachable test.
|
||||
///
|
||||
/// `verifies` is the MKB check — in production
|
||||
/// `AES-D(mk, mk_dv)[..8] == MK_VERIFY_MAGIC`. Returns a Media Key only when
|
||||
/// EXACTLY ONE DISTINCT candidate passes. Duplicates of the same key are one
|
||||
/// candidate (a pool aggregated across providers routinely repeats a key), but
|
||||
/// two DIFFERENT keys that both verify mean the pool cannot say which is this
|
||||
/// disc's Km: picking either derives a wrong VUK, and a wrong VUK decrypts to
|
||||
/// plausible-looking garbage rather than failing loudly. Bail and let the
|
||||
/// later hash/VID paths answer instead.
|
||||
///
|
||||
/// The predicate is a parameter rather than the inlined AES check because a
|
||||
/// genuine two-key multi-hit cannot be synthesised: it needs one ciphertext
|
||||
/// that decrypts under two distinct AES-128 keys to plaintexts sharing a
|
||||
/// 64-bit prefix — a 2^64 search. Injecting the verifier is the only way the
|
||||
/// ambiguity branch is reachable from a test at all.
|
||||
fn unique_verifying_mk(mks: &[[u8; 16]], verifies: impl Fn(&[u8; 16]) -> bool) -> Option<[u8; 16]> {
|
||||
let mut hits: Vec<[u8; 16]> = Vec::new();
|
||||
for mk in mks {
|
||||
if verifies(mk) && !hits.contains(mk) {
|
||||
hits.push(*mk);
|
||||
if hits.len() > 1 {
|
||||
// Ambiguous — bail rather than pick a Media Key.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.first().copied()
|
||||
}
|
||||
|
||||
/// For path 5: cross-reference the disc's `Unit_Key_RO.inf` CPS-unit
|
||||
/// numbering against the KEYDB entry's pre-decrypted unit keys. Every
|
||||
/// CPS unit the disc declares must have a matching entry in KEYDB;
|
||||
@@ -467,6 +511,27 @@ mod tests {
|
||||
use super::super::types::*;
|
||||
use super::*;
|
||||
|
||||
/// `ResolvedKeys` carries the disc's VUK and unit keys raw; `Debug` must not
|
||||
/// leak them. Sentinel 213 (0xD5); non-secret fields are not 213.
|
||||
#[test]
|
||||
fn resolved_keys_debug_is_redacted() {
|
||||
let rk = ResolvedKeys {
|
||||
disc_hash: [0u8; 20],
|
||||
vuk: Some([0xD5; 16]),
|
||||
unit_keys: vec![(1, [0xD5; 16])],
|
||||
title_cps_unit: vec![0],
|
||||
version: AacsVersion::V21,
|
||||
bus_encryption: true,
|
||||
key_source: 1,
|
||||
};
|
||||
let dbg = format!("{rk:?}");
|
||||
assert!(!dbg.contains("213"), "ResolvedKeys leaked keys: {dbg}");
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"ResolvedKeys missing marker: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Audit #5: the `major` / `from_major` mapping is load-bearing for the
|
||||
/// Unit_Key_RO stride, so pin it as a table. V10 ↔ BD; V20/V21 → UHD; any
|
||||
/// non-BD major selects the V20/V21 64-byte stride (V10 is the only 48-byte).
|
||||
@@ -1263,6 +1328,62 @@ mod tests {
|
||||
"VUK must derive from the verified Km + this disc's VID"
|
||||
);
|
||||
}
|
||||
|
||||
/// Path 2.5's ambiguity guard: when MORE THAN ONE DISTINCT pooled Media Key
|
||||
/// verifies against the MKB, the resolver must return no key at all rather
|
||||
/// than pick one. A wrong Km derives a wrong VUK, and a wrong VUK does not
|
||||
/// fail loudly — it decrypts the title to garbage that muxes and plays as a
|
||||
/// corrupt rip.
|
||||
///
|
||||
/// The real MKB check cannot be forced into a multi-hit: two distinct
|
||||
/// AES-128 keys decrypting one `mk_dv` to plaintexts that share the 64-bit
|
||||
/// verify magic is a 2^64 search, not a fixture. So the rule is tested
|
||||
/// through `unique_verifying_mk`, whose verifier is a parameter — the same
|
||||
/// function `resolve_keys_v1` calls, with the same pool semantics.
|
||||
#[test]
|
||||
fn mk_pool_ambiguity_bails_rather_than_picking_a_media_key() {
|
||||
let a = [0xAAu8; 16];
|
||||
let b = [0xBBu8; 16];
|
||||
let c = [0xCCu8; 16];
|
||||
|
||||
// One verifying candidate → that key.
|
||||
assert_eq!(
|
||||
unique_verifying_mk(&[a, b, c], |mk| *mk == b),
|
||||
Some(b),
|
||||
"a single verifying MK resolves"
|
||||
);
|
||||
|
||||
// The SAME key repeated across providers is one candidate, not an
|
||||
// ambiguity — the dedup (`!hits.contains`) must keep this resolvable.
|
||||
assert_eq!(
|
||||
unique_verifying_mk(&[b, b, b], |mk| *mk == b),
|
||||
Some(b),
|
||||
"duplicates of one key are not ambiguity"
|
||||
);
|
||||
|
||||
// TWO DISTINCT verifying candidates → bail, no key.
|
||||
assert_eq!(
|
||||
unique_verifying_mk(&[a, b], |mk| *mk == a || *mk == b),
|
||||
None,
|
||||
"two distinct verifying MKs must yield NO key, not the first one"
|
||||
);
|
||||
|
||||
// Ambiguity must still be detected when the second hit is last in the
|
||||
// pool, i.e. the scan may not stop at the first hit.
|
||||
assert_eq!(
|
||||
unique_verifying_mk(&[a, c, [0u8; 16], b], |mk| *mk == a || *mk == b),
|
||||
None,
|
||||
"a late second hit is still ambiguous"
|
||||
);
|
||||
|
||||
// Every candidate verifying is the degenerate ambiguous case.
|
||||
assert_eq!(unique_verifying_mk(&[a, b, c], |_| true), None);
|
||||
|
||||
// No candidate verifies → no key (and no panic on an empty pool).
|
||||
assert_eq!(unique_verifying_mk(&[a, b, c], |_| false), None);
|
||||
assert_eq!(unique_verifying_mk(&[], |_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_cert_parse() {
|
||||
// AACS 1.0 cert, bus encryption OFF. Content-cert layout: flag in
|
||||
@@ -1785,6 +1906,77 @@ mod tests {
|
||||
assert_eq!(r.vuk, Some(derive_vuk(&mk, &vid)));
|
||||
}
|
||||
|
||||
/// `resolve_keys_v21` gates paths 1 and 3 on `has_vid`, and an all-zero
|
||||
/// Volume ID is the crate's "the VID was never read" sentinel — the SCSI
|
||||
/// handshake leaves the buffer zeroed when it does not run or fails.
|
||||
///
|
||||
/// Both directions matter and both fail silently:
|
||||
/// - treating the zero sentinel as a real VID runs path 3 and derives
|
||||
/// `Kvu = AES-G(Km, 0…0)`, a perfectly well-formed but WRONG VUK. It
|
||||
/// unwraps the title keys to garbage, and nothing downstream errors —
|
||||
/// the rip just decodes to noise.
|
||||
/// - treating a real VID as absent skips paths 1 and 3 entirely, so a
|
||||
/// disc that could have been resolved from its Media Key reports no key.
|
||||
///
|
||||
/// Asserted through the final VUK, not through the flag.
|
||||
#[test]
|
||||
fn resolve_keys_v21_treats_the_all_zero_volume_id_as_no_vid() {
|
||||
let uk_ro = minimal_unit_key_ro();
|
||||
let vid = [0x42u8; 16];
|
||||
let mk = [0x24u8; 16];
|
||||
// A VID-keyed entry carrying an MK and nothing else: no VUK and no unit
|
||||
// keys, so paths 4 and 5 cannot fire and ONLY the VID-gated path 3 can
|
||||
// produce a result.
|
||||
let entry = DiscEntry {
|
||||
disc_hash: "not-this-disc".to_string(),
|
||||
title: "sibling".to_string(),
|
||||
media_key: Some(mk),
|
||||
disc_id: Some(vid),
|
||||
vuk: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
let keydb = SuppliedKey {
|
||||
device_keys: Vec::new(),
|
||||
processing_keys: Vec::new(),
|
||||
media_keys: Vec::new(),
|
||||
disc_entry: Some(entry),
|
||||
};
|
||||
let providers: &[&dyn super::super::provider::KeyProvider] = &[&keydb];
|
||||
|
||||
// A real VID → path 3 fires and the VUK derives from Km + THIS VID.
|
||||
let with_vid = ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &vid,
|
||||
providers,
|
||||
mkb: None,
|
||||
};
|
||||
let r = resolve_keys_v21(&with_vid).expect("a real VID must reach path 3");
|
||||
assert_eq!(r.key_source, 3);
|
||||
assert_eq!(
|
||||
r.vuk,
|
||||
Some(derive_vuk(&mk, &vid)),
|
||||
"VUK must derive from the Media Key and the disc's own VID"
|
||||
);
|
||||
|
||||
// The all-zero sentinel → paths 1 and 3 are skipped entirely; with no
|
||||
// VUK and no unit keys on the entry, nothing resolves.
|
||||
let no_vid = ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &[0u8; 16],
|
||||
providers,
|
||||
mkb: None,
|
||||
};
|
||||
let got = resolve_keys_v21(&no_vid);
|
||||
assert!(
|
||||
got.is_none(),
|
||||
"a zero VID must not be used to derive a VUK; got key_source {:?} vuk {:?}",
|
||||
got.as_ref().map(|r| r.key_source),
|
||||
got.as_ref().map(|r| r.vuk.is_some())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_keys_returns_none_when_no_provider_has_anything() {
|
||||
// Empty provider array + VID present + no MKB → all paths miss → None.
|
||||
|
||||
+41
-25
@@ -25,7 +25,7 @@
|
||||
//! 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 (Zombieland) it cycles 1,2,…,32,1,2,… across records in
|
||||
//! 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,
|
||||
@@ -41,21 +41,6 @@ pub const SEGMENT_RECORD_LEN: usize = 16;
|
||||
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
|
||||
pub const SOURCE_PACKET_LEN: u64 = 192;
|
||||
|
||||
/// Whether a 2.1 (FMTS) disc may rip WITHOUT the forensic index keys.
|
||||
///
|
||||
/// `true` (today): the forensic segments are skipped as expected loss
|
||||
/// and the bulk of the title decodes with the unit key, so a 2.1 disc rips
|
||||
/// mostly-complete. A unit key (VUK) is still required, exactly as for any AACS
|
||||
/// disc. `false`: the absence of a segment-key source is a hard, UPFRONT failure
|
||||
/// ([`Error::FmtsKeyMissing`]) — the same policy as a missing unit key, so a
|
||||
/// forensic-holed rip is refused rather than produced. No segment-key source
|
||||
/// exists yet, so `true` is the only value under which a 2.1 disc rips at all;
|
||||
/// flip to `false` once segment keys can be sourced and a partial rip should be
|
||||
/// refused. Hardcoded on purpose — not a user setting.
|
||||
///
|
||||
/// [`Error::FmtsKeyMissing`]: crate::error::Error::FmtsKeyMissing
|
||||
pub const BYPASS_FMTS_KEY: bool = false;
|
||||
|
||||
/// One forensic segment: the inclusive source-packet range it occupies in the
|
||||
/// FMTS clip.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -208,6 +193,11 @@ pub fn fmts_key_ranges(
|
||||
) -> Vec<(u32, u32, usize)> {
|
||||
let mut ranges = Vec::new();
|
||||
for s in segments {
|
||||
// SPNs are untrusted (from IndividualSegment.tbl); an inverted record
|
||||
// (start_spn > end_spn) would underflow `end_byte - 1 - start_byte` below.
|
||||
if s.start_spn > s.end_spn {
|
||||
continue;
|
||||
}
|
||||
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
|
||||
@@ -284,18 +274,44 @@ mod tests {
|
||||
// sectors 937..=946 → LBA 1937..1947, key index 7.
|
||||
assert_eq!(ranges[1], (1937, 1947, 7));
|
||||
|
||||
// The ranges drive an AacsKeyMap with the Unit Key (index 0) as default.
|
||||
let map = crate::decrypt::AacsKeyMap::from_ranges(ranges, 0);
|
||||
assert_eq!(map.key_idx_for(500), 0, "outside any segment → Unit Key");
|
||||
assert_eq!(map.key_idx_for(1012), 5, "inside index-5 segment → key 5");
|
||||
assert_eq!(map.key_idx_for(1940), 7, "inside index-7 segment → key 7");
|
||||
// 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),
|
||||
0,
|
||||
"segment end is exclusive → Unit Key"
|
||||
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;
|
||||
@@ -318,7 +334,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_real_disc_layout() {
|
||||
// First three records observed on retail 2.1 (Zombieland): the variant
|
||||
// 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(&[
|
||||
@@ -380,7 +396,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn index_field_cycles_one_to_thirty_two() {
|
||||
// Reality on Zombieland: field@4 is the index, cycling 1..=32 in file
|
||||
// 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;
|
||||
|
||||
+234
-8
@@ -6,7 +6,7 @@
|
||||
//! owns only the crypto and these value types that flow through it.
|
||||
|
||||
/// A device key for MKB subset-difference tree processing.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceKey {
|
||||
pub key: [u8; 16],
|
||||
pub node: u16,
|
||||
@@ -15,7 +15,7 @@ pub struct DeviceKey {
|
||||
}
|
||||
|
||||
/// Host certificate + private key for AACS SCSI authentication.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct HostCert {
|
||||
/// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes.
|
||||
pub private_key: [u8; 20],
|
||||
@@ -28,22 +28,22 @@ pub struct HostCert {
|
||||
}
|
||||
|
||||
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vid(pub [u8; 16]);
|
||||
|
||||
/// Media Key (Km, 16 bytes) — the MKB-scoped key derived from device keys.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MediaKey(pub [u8; 16]);
|
||||
|
||||
/// Volume Unique Key (VUK / Kvu, 16 bytes) — derived from `MediaKey` + `Vid`,
|
||||
/// decrypts the per-disc encrypted title keys in `Unit_Key_RO.inf`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vuk(pub [u8; 16]);
|
||||
|
||||
/// Processing Key (Kp, 16 bytes) — an MKB Subset-Difference key that yields the
|
||||
/// Media Key. A leaked/precomputed PK in the keydb, or the intermediate PK a
|
||||
/// device-key walk derives at its matching SD node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProcessingKey(pub [u8; 16]);
|
||||
|
||||
/// One decrypted per-CPS-unit AACS title key.
|
||||
@@ -53,7 +53,7 @@ pub struct ProcessingKey(pub [u8; 16]);
|
||||
/// area). The CPS-unit *number* association is a higher-level concern owned by
|
||||
/// [`super::inf::parse_unit_key_ro`], which pairs each positional key with its
|
||||
/// declared CPS unit; this primitive only does the AES, so it surfaces position.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UnitKey {
|
||||
pub idx: u32,
|
||||
pub key: [u8; 16],
|
||||
@@ -95,7 +95,7 @@ impl UnitKey {
|
||||
}
|
||||
|
||||
/// A per-disc entry from the key database.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct DiscEntry {
|
||||
/// Disc hash (20 bytes, hex)
|
||||
pub disc_hash: String,
|
||||
@@ -110,3 +110,229 @@ pub struct DiscEntry {
|
||||
/// Unit keys (title keys) indexed by CPS unit number
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
|
||||
// ── Redacting `Debug` impls ──────────────────────────────────────────────────
|
||||
//
|
||||
// Every type above carries AACS secret material (device keys, host PRIVATE keys,
|
||||
// media/volume/processing/unit keys). `#[derive(Debug)]` would print those bytes
|
||||
// verbatim, so a stray `debug!("{:?}", …)` or a panic message would leak the
|
||||
// keys. These hand-written impls print only NON-secret shape (presence, lengths,
|
||||
// tree coordinates, indices) — never key bytes. `decrypt::DecryptKeys` follows
|
||||
// the same policy by omitting `Debug` entirely; here we keep `Debug` because
|
||||
// these are `PartialEq`/`Eq` value types used in `assert_eq!` and nested inside
|
||||
// other `#[derive(Debug)]` structs, so the trait must exist — just not leak.
|
||||
// Guarded by `redaction_tests` below.
|
||||
|
||||
impl std::fmt::Debug for DeviceKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DeviceKey")
|
||||
.field("key", &"<redacted>")
|
||||
.field("node", &self.node)
|
||||
.field("uv", &self.uv)
|
||||
.field("u_mask_shift", &self.u_mask_shift)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HostCert {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("HostCert")
|
||||
.field("private_key", &"<redacted>")
|
||||
.field("certificate_len", &self.certificate.len())
|
||||
.field("private_key_v2", &self.private_key_v2.map(|_| "<redacted>"))
|
||||
.field(
|
||||
"certificate_v2_len",
|
||||
&self.certificate_v2.as_ref().map(|c| c.len()),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Vid {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Vid(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MediaKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("MediaKey(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Vuk {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Vuk(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProcessingKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("ProcessingKey(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for UnitKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("UnitKey")
|
||||
.field("idx", &self.idx)
|
||||
.field("key", &"<redacted>")
|
||||
.field("index_number", &self.index_number)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DiscEntry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DiscEntry")
|
||||
.field("disc_hash", &self.disc_hash)
|
||||
.field("title", &self.title)
|
||||
.field("media_key", &self.media_key.map(|_| "<redacted>"))
|
||||
.field("disc_id", &self.disc_id.map(|_| "<redacted>"))
|
||||
.field("vuk", &self.vuk.map(|_| "<redacted>"))
|
||||
.field("unit_keys_len", &self.unit_keys.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod 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()));
|
||||
}
|
||||
}
|
||||
|
||||
#[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:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
+973
-9
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
||||
//! Parse `/BDMV/index.bdmv` — the BD navigation index (First Play, Top Menu,
|
||||
//! 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.
|
||||
|
||||
/// One playback/title object in `index.bdmv`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PlaybackObj {
|
||||
/// HDMV title. `id_ref` indexes `MovieObject.bdmv`; `0xffff` means "no
|
||||
/// object".
|
||||
Hdmv { id_ref: u16 },
|
||||
/// BD-J title (a Java Xlet chooses what plays — the nav VM cannot resolve
|
||||
/// it and must abstain).
|
||||
BdJ,
|
||||
/// Unrecognised object type.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// The parsed index: the two entry objects plus the title table (title numbers
|
||||
/// `1..=titles.len()` map to `titles[i-1]`; title 0 is the Top Menu).
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Index {
|
||||
pub first_play: PlaybackObj,
|
||||
pub top_menu: PlaybackObj,
|
||||
pub titles: Vec<PlaybackObj>,
|
||||
}
|
||||
|
||||
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)?,
|
||||
*d.get(o + 1)?,
|
||||
*d.get(o + 2)?,
|
||||
*d.get(o + 3)?,
|
||||
]))
|
||||
}
|
||||
|
||||
/// Parse one 12-byte object starting at `o`. `object_type` is the top two bits
|
||||
/// of byte 0; the object body starts at byte 4, and an HDMV `id_ref` is the
|
||||
/// big-endian u16 at body offset +2 (object offset +6).
|
||||
fn parse_obj(d: &[u8], o: usize) -> Option<PlaybackObj> {
|
||||
let b0 = *d.get(o)?;
|
||||
d.get(o + OBJ_LEN - 1)?; // require the whole 12-byte record
|
||||
Some(match (b0 >> 6) & 0x3 {
|
||||
1 => PlaybackObj::Hdmv {
|
||||
id_ref: be_u16(d, o + 6)?,
|
||||
},
|
||||
2 => PlaybackObj::BdJ,
|
||||
_ => PlaybackObj::Unknown,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse `index.bdmv`. Returns `None` on any structural problem.
|
||||
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.
|
||||
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)?;
|
||||
let first_play = parse_obj(d, o)?;
|
||||
o = o.checked_add(OBJ_LEN)?;
|
||||
let top_menu = parse_obj(d, o)?;
|
||||
o = o.checked_add(OBJ_LEN)?;
|
||||
let num_titles = be_u16(d, o)? as usize;
|
||||
o = o.checked_add(2)?;
|
||||
if num_titles == 0 || num_titles > MAX_TITLES {
|
||||
return None;
|
||||
}
|
||||
let span = num_titles.checked_mul(OBJ_LEN)?;
|
||||
if o.checked_add(span)? > d.len() {
|
||||
return None;
|
||||
}
|
||||
let mut titles = Vec::with_capacity(num_titles);
|
||||
for i in 0..num_titles {
|
||||
titles.push(parse_obj(d, o + i * OBJ_LEN)?);
|
||||
}
|
||||
Some(Index {
|
||||
first_play,
|
||||
top_menu,
|
||||
titles,
|
||||
})
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Parse `/BDMV/MovieObject.bdmv` — the HDMV navigation programs, per their
|
||||
//! documented binary layout. Read as a documented format, never executed here:
|
||||
//! bounds-checked, never panics.
|
||||
//!
|
||||
//! Layout: `"MOBJ"` + version(4) + reserved… ; `MovieObjects()` at byte 40 is
|
||||
//! `length`(u32) + reserved(u32) + `num_objects`(u16 @48), then objects from
|
||||
//! 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).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct Cmd {
|
||||
pub op_cnt: u8,
|
||||
pub grp: u8,
|
||||
pub sub_grp: u8,
|
||||
pub imm_op1: bool,
|
||||
pub imm_op2: bool,
|
||||
pub branch_opt: u8,
|
||||
pub cmp_opt: u8,
|
||||
pub set_opt: u8,
|
||||
pub dst: u32,
|
||||
pub src: u32,
|
||||
}
|
||||
|
||||
/// One HDMV navigation program.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct MovieObject {
|
||||
pub cmds: Vec<Cmd>,
|
||||
}
|
||||
|
||||
const CMD_LEN: usize = 12;
|
||||
/// Sanity caps (real discs are far under these; some densely-branched
|
||||
/// dispatchers run several thousand commands in one object).
|
||||
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 {
|
||||
let (b0, b1, b2, b3) = (b[0], b[1], b[2], b[3]);
|
||||
Cmd {
|
||||
op_cnt: (b0 >> 5) & 0x7,
|
||||
grp: (b0 >> 3) & 0x3,
|
||||
sub_grp: b0 & 0x7,
|
||||
imm_op1: (b1 >> 7) & 1 == 1,
|
||||
imm_op2: (b1 >> 6) & 1 == 1,
|
||||
branch_opt: b1 & 0x0f,
|
||||
cmp_opt: b2 & 0x0f,
|
||||
set_opt: b3 & 0x1f,
|
||||
dst: u32::from_be_bytes([b[4], b[5], b[6], b[7]]),
|
||||
src: u32::from_be_bytes([b[8], b[9], b[10], b[11]]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `MovieObject.bdmv` into its programs. Returns `None` on any structural
|
||||
/// problem.
|
||||
pub(crate) fn parse(d: &[u8]) -> Option<Vec<MovieObject>> {
|
||||
if d.get(0..4)? != b"MOBJ" {
|
||||
return None;
|
||||
}
|
||||
let num = be_u16(d, 48)? as usize;
|
||||
if num > MAX_OBJECTS {
|
||||
return None;
|
||||
}
|
||||
let mut off = 50usize;
|
||||
let mut objs = Vec::with_capacity(num);
|
||||
for _ in 0..num {
|
||||
// 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() {
|
||||
return None;
|
||||
}
|
||||
let mut cmds = Vec::with_capacity(num_cmds);
|
||||
for i in 0..num_cmds {
|
||||
let s = off + i * CMD_LEN;
|
||||
cmds.push(decode_cmd(&d[s..s + CMD_LEN]));
|
||||
}
|
||||
off = end;
|
||||
objs.push(MovieObject { cmds });
|
||||
}
|
||||
Some(objs)
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! 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 to find the playlist the disc's
|
||||
//! own First-Play navigation plays as the feature.
|
||||
//!
|
||||
//! There is no per-disc special-casing: one correctly-booted VM resolves
|
||||
//! densely-branched dispatchers as well as simple ones. 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 index;
|
||||
pub(crate) mod mobj;
|
||||
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(crate) fn resolve_feature(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf: &UdfFs,
|
||||
is_feature_candidate: impl Fn(u16) -> bool,
|
||||
) -> Option<u16> {
|
||||
// Belt-and-suspenders: the parsers and VM are written panic-free and bounded,
|
||||
// but a navigation resolver must NEVER take down a scan — swallow any
|
||||
// unexpected panic and abstain.
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
resolve_inner(reader, udf, &is_feature_candidate)
|
||||
}))
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn resolve_inner(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf: &UdfFs,
|
||||
is_feature_candidate: &dyn Fn(u16) -> bool,
|
||||
) -> Option<u16> {
|
||||
let index = index::parse(&udf.read_file(reader, "/BDMV/index.bdmv").ok()?)?;
|
||||
let mobjs = mobj::parse(&udf.read_file(reader, "/BDMV/MovieObject.bdmv").ok()?)?;
|
||||
vm::resolve(&index, &mobjs, is_feature_candidate)
|
||||
}
|
||||
+474
@@ -0,0 +1,474 @@
|
||||
//! The HDMV navigation VM — a faithful, bounded re-implementation of the subset
|
||||
//! of HDMV navigation semantics needed to follow First-Play to the feature
|
||||
//! `PlayPlayList`. It "mimics a real player": correct power-on register state is
|
||||
//! what lets a densely-branched dispatcher converge on the feature rather than
|
||||
//! loop. Never panics; hard step/switch caps.
|
||||
//!
|
||||
//! 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;
|
||||
/// Upper bound on VM steps before declaring non-convergence.
|
||||
const MAX_STEPS: usize = 1_000_000;
|
||||
/// Cap on object/title switches — a dispatcher runs in one object; this only
|
||||
/// bounds pathological jump chains.
|
||||
const MAX_SWITCHES: usize = 1024;
|
||||
|
||||
/// BD player power-on register 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 dispatcher compares; the rest are set for fidelity
|
||||
/// with real player behavior.
|
||||
fn psr_init() -> [u32; 128] {
|
||||
let mut p = [0u32; 128];
|
||||
let vals: &[(usize, u32)] = &[
|
||||
(0, 1),
|
||||
(1, 0xff),
|
||||
(2, 0x0fff_0fff),
|
||||
(3, 1),
|
||||
(4, 0xffff),
|
||||
(5, 0xffff),
|
||||
(10, 0xffff),
|
||||
(12, 0xff),
|
||||
(13, 0xff),
|
||||
(14, 0xffff),
|
||||
(15, 0xAAAA),
|
||||
(16, 0x00ff_ffff),
|
||||
(17, 0x00ff_ffff),
|
||||
(18, 0x00ff_ffff),
|
||||
(19, 0xffff),
|
||||
(20, 2),
|
||||
(29, 0x0000_0003),
|
||||
(30, 0x0001_ffff),
|
||||
(31, 0x0003_0200),
|
||||
(36, 0xffff),
|
||||
(37, 0xffff),
|
||||
(42, 0xffff),
|
||||
(44, 0xff),
|
||||
];
|
||||
for &(i, v) in vals {
|
||||
p[i] = v;
|
||||
}
|
||||
for slot in p.iter_mut().take(62).skip(48) {
|
||||
*slot = 0xffff_ffff;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
struct Vm<'a> {
|
||||
mobjs: &'a [MovieObject],
|
||||
index: &'a Index,
|
||||
gpr: [u32; 4096],
|
||||
psr: [u32; 128],
|
||||
}
|
||||
|
||||
impl Vm<'_> {
|
||||
fn rd(&self, val: u32) -> u32 {
|
||||
if val & PSR_FLAG != 0 {
|
||||
self.psr[(val & 0x7f) as usize]
|
||||
} else {
|
||||
self.gpr[(val & 0xfff) as usize]
|
||||
}
|
||||
}
|
||||
fn wr(&mut self, val: u32, x: u32) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
fn fetch(&self, imm: bool, raw: u32) -> u32 {
|
||||
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). 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 {
|
||||
Some(self.index.top_menu)
|
||||
} else if title <= n {
|
||||
self.index.titles.get((title - 1) as usize).copied()
|
||||
} else if title == 0xFFFF {
|
||||
Some(self.index.first_play)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run First-Play and return the first `PlayPL`/`PlayPL_PM`/`PlayPL_PI` whose
|
||||
/// playlist id passes `is_feature`. `None` = BD-J boundary, non-convergence, or
|
||||
/// no approved feature playlist reached.
|
||||
pub(crate) fn resolve(
|
||||
index: &Index,
|
||||
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).
|
||||
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(),
|
||||
};
|
||||
run(&mut vm, start, is_feature)
|
||||
}
|
||||
|
||||
fn run(vm: &mut Vm, mut obj_id: usize, is_feature: &dyn Fn(u16) -> bool) -> Option<u16> {
|
||||
let mut pc = 0usize;
|
||||
let mut steps = 0usize;
|
||||
let mut switches = 0usize;
|
||||
loop {
|
||||
steps += 1;
|
||||
if steps > MAX_STEPS {
|
||||
return None;
|
||||
}
|
||||
let obj = vm.mobjs.get(obj_id)?;
|
||||
// Ran off the end without a feature PlayPL → abstain.
|
||||
let c = *obj.cmds.get(pc)?;
|
||||
let mut npc = pc + 1;
|
||||
let dst = if c.op_cnt > 0 {
|
||||
vm.fetch(c.imm_op1, c.dst)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let src = if c.op_cnt > 1 {
|
||||
vm.fetch(c.imm_op2, c.src)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
match c.grp {
|
||||
// BRANCH
|
||||
0 => match c.sub_grp {
|
||||
// GOTO
|
||||
0 => match c.branch_opt {
|
||||
0x01 => npc = dst as usize, // GOTO
|
||||
0x02 => return None, // BREAK — terminate
|
||||
_ => {} // NOP / other
|
||||
},
|
||||
// JUMP
|
||||
1 => match c.branch_opt {
|
||||
0x00 | 0x02 => {
|
||||
// JumpObject / CallObject (return address not modelled —
|
||||
// for feature resolution we only follow the play path).
|
||||
switches += 1;
|
||||
if switches > MAX_SWITCHES || dst as usize >= vm.mobjs.len() {
|
||||
return None;
|
||||
}
|
||||
obj_id = dst as usize;
|
||||
pc = 0;
|
||||
continue;
|
||||
}
|
||||
0x01 | 0x03 => {
|
||||
// JumpTitle / CallTitle — resolve through the index table.
|
||||
switches += 1;
|
||||
if switches > MAX_SWITCHES {
|
||||
return None;
|
||||
}
|
||||
match vm.title_obj(dst) {
|
||||
Some(PlaybackObj::Hdmv { id_ref })
|
||||
if (id_ref as usize) < vm.mobjs.len() =>
|
||||
{
|
||||
obj_id = id_ref as usize;
|
||||
pc = 0;
|
||||
continue;
|
||||
}
|
||||
// BD-J / unknown / invalid title → abstain.
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
_ => {} // RESUME / other — fall through
|
||||
},
|
||||
// PLAY: PlayPL / PlayPL_PI / PlayPL_PM emit a playlist id (the
|
||||
// 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) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// CMP — skip the next command when the compare is false.
|
||||
1 => {
|
||||
let truth = match c.cmp_opt {
|
||||
0x01 => (dst & !src) == 0,
|
||||
0x02 => dst == src,
|
||||
0x03 => dst != src,
|
||||
0x04 => dst >= src,
|
||||
0x05 => dst > src,
|
||||
0x06 => dst <= src,
|
||||
0x07 => dst < src,
|
||||
_ => true,
|
||||
};
|
||||
if !truth {
|
||||
npc = pc + 2;
|
||||
}
|
||||
}
|
||||
// SET (sub_grp 0). SETSYSTEM (sub_grp 1) only mutates system PSRs
|
||||
// whose values the feature path does not branch on — skip it.
|
||||
2 if c.sub_grp == 0 => {
|
||||
let r: Option<u32> = match c.set_opt {
|
||||
0x01 => Some(src), // MOVE
|
||||
0x02 => {
|
||||
// SWAP exchanges the two operands; a store to an operand
|
||||
// flagged immediate is refused, and `wr` already drops
|
||||
// PSR stores.
|
||||
if !c.imm_op1 {
|
||||
vm.wr(c.dst, src);
|
||||
}
|
||||
if !c.imm_op2 {
|
||||
vm.wr(c.src, dst);
|
||||
}
|
||||
None
|
||||
}
|
||||
0x03 => Some(dst.wrapping_add(src)),
|
||||
0x04 => Some(dst.saturating_sub(src)),
|
||||
0x05 => Some(dst.wrapping_mul(src)),
|
||||
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
|
||||
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)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(r) = r
|
||||
&& !c.imm_op1
|
||||
{
|
||||
vm.wr(c.dst, r);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
pc = npc;
|
||||
}
|
||||
}
|
||||
|
||||
#[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() {
|
||||
// Computed-title-number shape: First-Play computes a title number in a
|
||||
// GPR and JumpTitles to it; the target title is BD-J → abstain (None).
|
||||
let d = build(&[&[set_move_gpr(0xFEB & 0xfff, 2), {
|
||||
// JumpTitle with dst = GPR[0xFEB].
|
||||
cmd((1 << 5) | 1, 0x01, 0, 0, 0x0000_0FEB, 0)
|
||||
}]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
// title 2 → titles[1] = BD-J.
|
||||
let index = idx(
|
||||
PlaybackObj::Hdmv { id_ref: 0 },
|
||||
vec![PlaybackObj::Hdmv { id_ref: 5 }, PlaybackObj::BdJ],
|
||||
);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abstains_when_first_play_is_bdj() {
|
||||
let d = build(&[&[play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::BdJ, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jumptitle_first_play_is_0xffff_not_n_plus_1() {
|
||||
// BD-ROM title numbering: 0 = Top Menu, 0xFFFF = First Play,
|
||||
// 1..=N = titles. `N+1` is an INVALID title ref.
|
||||
let index = idx(
|
||||
PlaybackObj::Hdmv { id_ref: 7 },
|
||||
vec![PlaybackObj::Hdmv { id_ref: 1 }],
|
||||
);
|
||||
let vm = Vm {
|
||||
mobjs: &[],
|
||||
index: &index,
|
||||
gpr: [0; 4096],
|
||||
psr: psr_init(),
|
||||
};
|
||||
assert_eq!(vm.title_obj(0), Some(index.top_menu), "title 0 = Top Menu");
|
||||
assert_eq!(
|
||||
vm.title_obj(1),
|
||||
Some(PlaybackObj::Hdmv { id_ref: 1 }),
|
||||
"title 1 = titles[0]"
|
||||
);
|
||||
assert_eq!(
|
||||
vm.title_obj(0xFFFF),
|
||||
Some(PlaybackObj::Hdmv { id_ref: 7 }),
|
||||
"0xFFFF = First Play (abstained wrongly before the fix)"
|
||||
);
|
||||
assert_eq!(
|
||||
vm.title_obj(2),
|
||||
None,
|
||||
"N+1 is an invalid title ref, not First Play (resolved wrongly before the fix)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_to_psr_is_refused() {
|
||||
// A 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.
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
+103
-843
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -99,7 +99,11 @@ pub mod coding_type {
|
||||
|
||||
/// Secondary Dolby Digital Plus audio (BD-ROM convention).
|
||||
pub const AC3_PLUS_SECONDARY: u8 = 0xA1;
|
||||
/// Secondary DTS-HD audio (lossless MA, not lossy HR) (BD-ROM convention).
|
||||
/// Secondary DTS-HD audio — DTS Express / DTS-HD LBR, a LOSSY low-bitrate
|
||||
/// stream for picture-in-picture and BD-J mixing (BD-ROM Part 3
|
||||
/// `stream_coding_type` table). The lossless primary is [`DTS_HD_MA`]
|
||||
/// (0x86); this code is its lossy secondary counterpart, parallel to
|
||||
/// [`AC3_PLUS_SECONDARY`] (0xA1) on the Dolby side.
|
||||
pub const DTS_HD_SECONDARY: u8 = 0xA2;
|
||||
}
|
||||
|
||||
@@ -110,6 +114,10 @@ pub mod coding_type {
|
||||
pub mod pes_stream_id {
|
||||
/// Video stream (`110x xxxx`; freemkv emits the base id `0xE0`).
|
||||
pub const VIDEO: u8 = 0xE0;
|
||||
/// 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;
|
||||
/// 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.
|
||||
|
||||
+1140
File diff suppressed because it is too large
Load Diff
+45
-3
@@ -1,9 +1,8 @@
|
||||
//! CSS content cipher — an independent implementation of the publicly
|
||||
//! documented Content Scramble System stream cipher.
|
||||
//!
|
||||
//! 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
|
||||
//! The algorithm is the publicly documented CSS stream cipher, described in the
|
||||
//! open CSS cryptanalysis 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.
|
||||
@@ -443,6 +442,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
+996
-105
File diff suppressed because it is too large
Load Diff
@@ -1,574 +0,0 @@
|
||||
//! CSS title-key recovery — Frank A. Stevenson's divide-and-conquer attack
|
||||
//! (1999), implemented from his published cryptanalysis ("Cryptanalysis of
|
||||
//! Contents Scrambling System"). It recovers the 5-byte CSS title key from a
|
||||
//! single scrambled DVD sector with no player keys and no disc-key crack, using
|
||||
//! only known plaintext. Implemented from that public description; nothing here
|
||||
//! is copied or translated from any particular CSS software.
|
||||
//!
|
||||
//! # The cipher this attacks
|
||||
//!
|
||||
//! The content descrambler ([`super::lfsr::descramble_sector`]) seeds its two
|
||||
//! LFSRs **directly** from `key = title_key XOR sector_seed` (seed =
|
||||
//! `sector[0x54..0x59]`): LFSR1 from key/seed bytes 0-1, LFSR0 (24-bit) from
|
||||
//! bytes 2-4 with the pre-conditioning `r0 = r0*2 + 8 - (r0 & 7)`, and each body
|
||||
//! byte recovered as `plain = TAB1[cipher] ^ (keystream & 0xff)`. There is no
|
||||
//! title-key mangling on the content path, so the recovery is a single inversion
|
||||
//! of the sector cipher.
|
||||
//!
|
||||
//! # The attack
|
||||
//!
|
||||
//! 1. **Known plaintext → keystream.** Because descramble applies TAB1 to the
|
||||
//! ciphertext, the per-byte keystream is `TAB1[cipher[i]] ^ plain[i]`.
|
||||
//! 2. **Brute the 16-bit LFSR1 seed.** For each of 2^16 seeds, run LFSR1
|
||||
//! forward; for the first four steps deduce the LFSR0 output bytes from the
|
||||
//! keystream (carry-tracked), reconstructing LFSR0's state. For the next six
|
||||
//! steps clock LFSR0 normally and check it reproduces the keystream — a wrong
|
||||
//! LFSR1 seed fails fast.
|
||||
//! 3. **Back-clock LFSR0.** Run four backward steps (each a 256-way search for
|
||||
//! the byte shifted in) to reach the initial state, then undo the
|
||||
//! `r0*2 + 8 - (r0 & 7)` pre-conditioning to recover key[2..5].
|
||||
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]`.
|
||||
//!
|
||||
//! Known plaintext for step 1 comes from the longest periodic run in the
|
||||
//! cleartext `sec[0x00..0x80]`, assumed to continue into the encrypted region at
|
||||
//! 0x80.
|
||||
|
||||
use super::lfsr::descramble_sector;
|
||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
|
||||
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;
|
||||
|
||||
/// Recover the title key from cipher + known plaintext (the core of Stevenson's
|
||||
/// attack). `crypted` is the ciphertext starting at sector byte 0x80;
|
||||
/// `decrypted` is the matching known plaintext; `seed` is `sector[0x54..0x59]`.
|
||||
/// On success returns the recovered 5-byte title key; `None` if no LFSR seed
|
||||
/// reproduces the keystream.
|
||||
///
|
||||
/// At least 10 bytes of `crypted`/`decrypted` are required (the cipher is
|
||||
/// iterated 10 times: 4 to reconstruct LFSR0, 6 to validate).
|
||||
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 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xff;
|
||||
i_t3 = (i_t3 << 8) | i_t6;
|
||||
i_t6 = TAB4[i_t6 as usize] as u32;
|
||||
i_t5 += i_t6 + i_t4;
|
||||
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 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xff;
|
||||
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 using a known plaintext
|
||||
/// for the encrypted region.
|
||||
///
|
||||
/// `plain` is the expected plaintext at byte 0x80 (at least 10 bytes).
|
||||
/// Returns the recovered key only if it actually descrambles the sector back
|
||||
/// to `plain` — guarding against the rare spurious LFSR-seed match.
|
||||
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: [u8; 5] = [
|
||||
sector[SEED_OFFSET],
|
||||
sector[SEED_OFFSET + 1],
|
||||
sector[SEED_OFFSET + 2],
|
||||
sector[SEED_OFFSET + 3],
|
||||
sector[SEED_OFFSET + 4],
|
||||
];
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
}
|
||||
|
||||
/// Find a repeating pattern just before the encrypted region and assume the
|
||||
/// plaintext at 0x80 continues it — the known-plaintext step of Stevenson's
|
||||
/// attack. Scans cleartext `sec[0x00..0x80]` for the longest run that repeats
|
||||
/// with a cycle length in 2..0x2F. If the run is long enough (`plen > 3` and at
|
||||
/// least two full cycles), the known plaintext at 0x80 is taken to be the
|
||||
/// periodic run continuing forward, and [`recover_title_key_from_plain`] is
|
||||
/// applied.
|
||||
pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
||||
if sector.len() < SECTOR_BYTES {
|
||||
return None;
|
||||
}
|
||||
if sector[FLAG_BYTE] & 0x30 == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Runaway guard: a single sector's crack is a bounded 2^16 LFSR search and
|
||||
// should finish in well under a second on any modern CPU. If it ever
|
||||
// exceeds ~2s wall-clock, 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.
|
||||
///
|
||||
/// Scans the clear header `sec[0x00..0x80]` (never scrambled) for the longest
|
||||
/// run that repeats with a cycle length in 2..0x2F. If the run is long enough
|
||||
/// (`plen > 3` and at least two full cycles), the plaintext at 0x80 is taken to
|
||||
/// be that periodic run continuing forward. Returns `None` for an unscrambled
|
||||
/// sector or one with no usable run — such a sector can be neither cracked nor
|
||||
/// key-validated, only descrambled with an externally-cached key.
|
||||
///
|
||||
/// The header is untouched by `descramble_sector`, so the crib is identical
|
||||
/// before and after descramble: the decrypt path uses it as a per-sector
|
||||
/// "did the cached key descramble correctly?" oracle (the predicted plaintext
|
||||
/// must reappear at 0x80), and the cracker uses it as its known plaintext.
|
||||
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 {
|
||||
// The known plaintext is the periodic run continuing past 0x80. The
|
||||
// crib starts at `0x80 - (best_plen/best_p)*best_p` and continues
|
||||
// through the encrypted region; the bytes at and after 0x80 are the
|
||||
// predicted plaintext (the pattern repeats with period best_p).
|
||||
let cycles = best_plen / best_p;
|
||||
let plain_start = 0x80 - cycles * best_p;
|
||||
|
||||
// Each predicted byte is the run sample one or more periods back:
|
||||
// `sec[plain_start + (i % best_p)]`. For in-run offsets
|
||||
// (`plain_start + i < 0x80`) the run is exactly periodic, so this
|
||||
// equals `sec[plain_start + i]`; for offsets at/after 0x80 the raw
|
||||
// byte is ciphertext, so we MUST wrap within the period rather than
|
||||
// read it. (Reading `&sec[plain_start..+10]` directly — as before —
|
||||
// pulled ciphertext into the crib whenever the run covered fewer than
|
||||
// 10 bytes before 0x80, producing false-negative key recovery.)
|
||||
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: [u8; 5] = [
|
||||
sector[SEED_OFFSET],
|
||||
sector[SEED_OFFSET + 1],
|
||||
sector[SEED_OFFSET + 2],
|
||||
sector[SEED_OFFSET + 3],
|
||||
sector[SEED_OFFSET + 4],
|
||||
];
|
||||
let crypted = §or[0x80..0x80 + 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)]
|
||||
mod tests {
|
||||
use super::super::lfsr::scramble_sector;
|
||||
use super::*;
|
||||
|
||||
/// Build a synthetic scrambled sector for a given title key and seed,
|
||||
/// with `plain` placed as the plaintext at byte 0x80, scrambled with
|
||||
/// EXACTLY the cipher `descramble_sector` inverts. Returns
|
||||
/// (scrambled_sector, full_plaintext_body).
|
||||
fn synth_sector(title_key: &[u8; 5], seed: &[u8; 5], plain: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut plaintext = vec![0u8; SECTOR_BYTES];
|
||||
plaintext[0..4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
|
||||
plaintext[FLAG_BYTE] = 0x10;
|
||||
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
||||
plaintext[ENCRYPTED_START..ENCRYPTED_START + plain.len()].copy_from_slice(plain);
|
||||
|
||||
let body = plaintext.clone();
|
||||
|
||||
// scramble_sector turns the plaintext body into ciphertext and sets
|
||||
// the scramble flag.
|
||||
scramble_sector(title_key, &mut plaintext);
|
||||
(plaintext, body)
|
||||
}
|
||||
|
||||
/// Build a synthetic scrambled sector whose CLEARTEXT (0x00..0x80) ends
|
||||
/// in a periodic run that continues into the encrypted region — the case
|
||||
/// `crack_title_key` is designed to crack.
|
||||
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;
|
||||
|
||||
// A clean periodic run occupying the tail of the cleartext header
|
||||
// (RUN_START..0x80) and continuing into the encrypted region. This
|
||||
// mirrors a real VOB: a periodic data run just before the scrambled
|
||||
// part. The run must NOT overlap the seed bytes (0x54..0x59), or the
|
||||
// the crib detector would break mid-run. The phase is anchored to
|
||||
// offset 0 so the run is consistent across the 0x80 boundary.
|
||||
// Just above the seed (0x54..0x59); gives a 39-byte run (0x59..0x80)
|
||||
// — enough for >=2 cycles of every tested period (<=19).
|
||||
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): synthesize a scrambled sector for a
|
||||
/// known (title_key, seed), then assert recover_title_key returns a key
|
||||
/// that descrambles the body back to plaintext. CSS title-key recovery is
|
||||
/// well-defined up to keys that scramble identically; we assert the full
|
||||
/// body round-trips (the true correctness property), and additionally
|
||||
/// that the EXACT key is returned for the common case.
|
||||
#[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?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1119
-890
File diff suppressed because it is too large
Load Diff
+127
-47
@@ -22,10 +22,7 @@
|
||||
//! `Disc`-level dump ([`dump_disc`]) covers everything that survives
|
||||
//! lowering: titles, streams, the picked main feature, and AACS state.
|
||||
|
||||
use crate::disc::{
|
||||
AudioChannels, ColorSpace, Disc, DiscTitle, FrameRate, HdrFormat, Resolution, SampleRate,
|
||||
Stream,
|
||||
};
|
||||
use crate::disc::{ColorSpace, Disc, DiscTitle, FrameRate, HdrFormat, Resolution, Stream};
|
||||
use crate::ifo::{CellCategory, DvdTitle};
|
||||
|
||||
const DIAG: &str = "freemkv::diag";
|
||||
@@ -95,36 +92,12 @@ pub fn hdr_str(h: HdrFormat) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel count from an [`AudioChannels`] layout (what lands in the MKV
|
||||
/// `Channels` element).
|
||||
pub fn channel_count(ch: AudioChannels) -> u8 {
|
||||
match ch {
|
||||
AudioChannels::Mono => 1,
|
||||
AudioChannels::Stereo => 2,
|
||||
AudioChannels::Stereo21 => 3,
|
||||
AudioChannels::Quad => 4,
|
||||
AudioChannels::Surround50 => 5,
|
||||
AudioChannels::Surround51 => 6,
|
||||
AudioChannels::Surround61 => 7,
|
||||
AudioChannels::Surround71 => 8,
|
||||
AudioChannels::Unknown => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample-rate in Hz for a [`SampleRate`].
|
||||
pub fn sample_rate_hz(s: SampleRate) -> u32 {
|
||||
match s {
|
||||
SampleRate::S44_1 => 44100,
|
||||
SampleRate::S48 => 48000,
|
||||
SampleRate::S88_2 => 88200,
|
||||
SampleRate::S96 => 96000,
|
||||
SampleRate::S176_4 => 176400,
|
||||
SampleRate::S192 => 192000,
|
||||
SampleRate::S48_96 => 96000,
|
||||
SampleRate::S48_192 => 192000,
|
||||
SampleRate::Unknown => 0,
|
||||
}
|
||||
}
|
||||
// `channel_count` and `sample_rate_hz` lived here as a third copy of the
|
||||
// AudioChannels/SampleRate mappings. They were the only HONEST copy — returning
|
||||
// 0 for Unknown where the canonical accessors fabricated 6 channels at 48 kHz —
|
||||
// and their only caller was the trace line below, in this same file. The
|
||||
// canonical accessors are honest now, so the duplicates are gone rather than
|
||||
// left to drift a fourth time.
|
||||
|
||||
// ── DVD cell-category dump (from the IFO scan, pre-lowering) ─────────────────
|
||||
|
||||
@@ -336,7 +309,7 @@ fn frame_record(track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> V
|
||||
/// `track_number` is the 1-based MKV track number; `track` is the built
|
||||
/// [`crate::mux::mkv::MkvTrack`] whose fields map one-to-one onto the emitted
|
||||
/// elements (see `MkvMuxer::new`). No-op unless the diag target is on.
|
||||
pub fn dump_mkv_track(track_number: u64, track: &crate::mux::mkv::MkvTrack) {
|
||||
pub(crate) fn dump_mkv_track(track_number: u64, track: &crate::mux::mkv::MkvTrack) {
|
||||
if !diag_enabled() {
|
||||
return;
|
||||
}
|
||||
@@ -499,13 +472,50 @@ pub fn dump_disc(disc: &Disc) {
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=decision pick=main_feature title_idx=0 playlist={:?} dur={:.1}s \
|
||||
size={}B clips={} reason=canonical_title_order(fits-disc, fewest-clips, longest, richest-audio)",
|
||||
size={}B clips={} video={} reason={}",
|
||||
main.playlist,
|
||||
main.duration_secs,
|
||||
main.size_bytes,
|
||||
main.clips.len(),
|
||||
main.video_streams().count(),
|
||||
main_feature_reason(),
|
||||
);
|
||||
}
|
||||
|
||||
// Titles disqualified from main-feature candidacy for carrying no video —
|
||||
// the streamless obfuscation decoys the `has-video` gate demotes. Logged so
|
||||
// a field bug report shows WHY a long/large playlist was not picked (the
|
||||
// gap that let an undiagnosable E6008 loop on a real UHD title's decoy
|
||||
// playlist `00245`).
|
||||
for (ti, t) in disc.titles.iter().enumerate() {
|
||||
if !t.has_video() {
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=decision.demoted title_idx={} playlist={:?} dur={:.1}s size={}B reason=no-video",
|
||||
ti,
|
||||
t.playlist,
|
||||
t.duration_secs,
|
||||
t.size_bytes,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `reason=` token on the main-feature decision row.
|
||||
///
|
||||
/// DERIVED from [`Disc::CANONICAL_TITLE_ORDER_KEYS`], which lives beside the
|
||||
/// comparator that actually implements them — never restated here. The previous
|
||||
/// hand-written copy drifted (it advertised a `fewest-clips` key the comparator
|
||||
/// had replaced with largest-physical-size), which made the self-diagnosing log
|
||||
/// explain the pick with a rule the code does not apply. A diagnostic that
|
||||
/// disagrees with the decision it documents is worse than no diagnostic.
|
||||
fn main_feature_reason() -> String {
|
||||
let keys: Vec<&str> = Disc::MAIN_FEATURE_ORDER_KEYS
|
||||
.iter()
|
||||
.chain(Disc::CANONICAL_TITLE_ORDER_KEYS.iter())
|
||||
.copied()
|
||||
.collect();
|
||||
format!("main_feature_order({})", keys.join(", "))
|
||||
}
|
||||
|
||||
fn dump_aacs(disc: &Disc) {
|
||||
@@ -530,7 +540,7 @@ fn dump_aacs(disc: &Disc) {
|
||||
a.bus_encryption,
|
||||
a.mkb_version,
|
||||
a.disc_hash,
|
||||
a.key_source.name(),
|
||||
a.key_source,
|
||||
a.vuk.is_some(),
|
||||
a.unit_keys.len(),
|
||||
a.uk_ro.len(),
|
||||
@@ -610,8 +620,8 @@ fn dump_title(ti: usize, title: &DiscTitle) {
|
||||
a.pid,
|
||||
a.codec,
|
||||
a.channels,
|
||||
channel_count(a.channels),
|
||||
sample_rate_hz(a.sample_rate),
|
||||
a.channels.count(),
|
||||
a.sample_rate.hz(),
|
||||
a.language,
|
||||
a.secondary,
|
||||
),
|
||||
@@ -631,6 +641,61 @@ fn dump_title(ti: usize, title: &DiscTitle) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// Needed only by the tests: the production code in this file no longer names
|
||||
// these types directly, since the local channel/sample-rate duplicates were
|
||||
// deleted in favour of the canonical accessors.
|
||||
use crate::disc::{AudioChannels, SampleRate};
|
||||
|
||||
/// The main-feature decision row must NAME `canonical_title_order`'s sort
|
||||
/// keys, not restate them from memory. The restated copy had drifted: it
|
||||
/// still advertised a "fewest-clips" key long after the comparator replaced
|
||||
/// clip-count with largest-physical-size, so a bug report read at
|
||||
/// `--log-level 3` explained the pick with a rule the code does not apply.
|
||||
///
|
||||
/// The behavioural half is asserted first — against the comparator itself,
|
||||
/// with literals — so the key names are checked against what the code
|
||||
/// actually does, not against the string that names them.
|
||||
#[test]
|
||||
fn main_feature_reason_names_the_comparators_real_keys() {
|
||||
use crate::disc::{Clip, Disc, DiscTitle};
|
||||
|
||||
let sized = |size_bytes: u64, n_clips: usize| DiscTitle {
|
||||
size_bytes,
|
||||
clips: (0..n_clips)
|
||||
.map(|i| Clip {
|
||||
feed_span: None,
|
||||
clip_id: format!("{i:05}"),
|
||||
in_time: 0,
|
||||
out_time: 0,
|
||||
duration_secs: 0.0,
|
||||
source_packets: 0,
|
||||
})
|
||||
.collect(),
|
||||
..DiscTitle::empty()
|
||||
};
|
||||
// A 40-clip 8 GB title beats a 1-clip 1 GB title: the comparator's
|
||||
// primary key among disc-fitting titles is LARGEST SIZE. "fewest clips"
|
||||
// would predict the opposite, so the drifted string described a rule
|
||||
// the comparator does not implement.
|
||||
let many_clips_big = sized(8_000_000_000, 40);
|
||||
let one_clip_small = sized(1_000_000_000, 1);
|
||||
assert_eq!(
|
||||
Disc::canonical_title_order(&many_clips_big, &one_clip_small, 25_000_000_000),
|
||||
std::cmp::Ordering::Less,
|
||||
"largest size wins regardless of clip count"
|
||||
);
|
||||
|
||||
let reason = main_feature_reason();
|
||||
assert!(
|
||||
!reason.contains("clips"),
|
||||
"the reason must not advertise a clip-count key the comparator dropped: {reason}"
|
||||
);
|
||||
assert_eq!(
|
||||
reason,
|
||||
"main_feature_order(nav-feature, authoring-feature, standalone, has-video, fits-disc, largest-size, longest, richest-audio)",
|
||||
"the reason must name the selection keys (authoring, standalone-over-composite, and has-video gates, then the physical keys) in priority order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_str_keeps_interlace_marker() {
|
||||
@@ -656,18 +721,33 @@ mod tests {
|
||||
assert_eq!(hdr_str(HdrFormat::Sdr), "SDR");
|
||||
}
|
||||
|
||||
/// Moved from the deleted local duplicates onto the canonical accessors,
|
||||
/// with the Unknown case added — which is the whole point of the change.
|
||||
#[test]
|
||||
fn channel_count_matches_layout() {
|
||||
assert_eq!(channel_count(AudioChannels::Mono), 1);
|
||||
assert_eq!(channel_count(AudioChannels::Stereo), 2);
|
||||
assert_eq!(channel_count(AudioChannels::Surround51), 6);
|
||||
assert_eq!(channel_count(AudioChannels::Surround71), 8);
|
||||
fn channel_count_matches_layout_and_is_zero_when_unknown() {
|
||||
assert_eq!(AudioChannels::Mono.count(), 1);
|
||||
assert_eq!(AudioChannels::Stereo.count(), 2);
|
||||
assert_eq!(AudioChannels::Surround51.count(), 6);
|
||||
assert_eq!(AudioChannels::Surround71.count(), 8);
|
||||
// The one that matters. This used to return 6, which is indistinguishable
|
||||
// from a real 5.1 track and left every caller responsible for checking
|
||||
// the variant first.
|
||||
assert_eq!(
|
||||
AudioChannels::Unknown.count(),
|
||||
0,
|
||||
"an unknown layout must not report a plausible channel count"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_rate_hz_values() {
|
||||
assert_eq!(sample_rate_hz(SampleRate::S48), 48000);
|
||||
assert_eq!(sample_rate_hz(SampleRate::S96), 96000);
|
||||
fn sample_rate_hz_values_and_zero_when_unknown() {
|
||||
assert_eq!(SampleRate::S48.hz(), 48000.0);
|
||||
assert_eq!(SampleRate::S96.hz(), 96000.0);
|
||||
assert_eq!(
|
||||
SampleRate::Unknown.hz(),
|
||||
0.0,
|
||||
"an unknown sample rate must not report a plausible 48 kHz"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
//! ECMA-167 / UDF 1.02 descriptor encoder.
|
||||
//!
|
||||
//! Turns a [`Layout`](super::layout::Layout) — a directory tree with every
|
||||
//! ICB, directory-data and file-data block already assigned — into the set of
|
||||
//! metadata sectors a real UDF volume would carry. Nothing here touches the
|
||||
//! filesystem: it is a pure function from layout to sectors, which is what
|
||||
//! makes it testable against the production parser in `udf.rs`.
|
||||
//!
|
||||
//! What is emitted, in volume order:
|
||||
//!
|
||||
//! | sector | descriptor |
|
||||
//! |---|---|
|
||||
//! | 16, 17, 18 | Volume Recognition Sequence — `BEA01`, `NSR02`, `TEA01` (ECMA-167 2/9.1) |
|
||||
//! | 32… | Main Volume Descriptor Sequence — PVD, IUVD, PD, LVD, USD, TD |
|
||||
//! | 48… | Reserve VDS (byte-identical but for the tag locations) |
|
||||
//! | 64, 65 | Logical Volume Integrity Sequence — LVID, TD |
|
||||
//! | 256 | Anchor Volume Descriptor Pointer |
|
||||
//! | `part_start` + 0, +1 | File Set Descriptor, TD |
|
||||
//! | `part_start` + … | File Entries (ICBs) and directory data (FIDs) |
|
||||
//! | last sector | Anchor Volume Descriptor Pointer (copy) |
|
||||
//!
|
||||
//! UDF revision 1.02 with a single Type-1 partition map is deliberate: it is
|
||||
//! the DVD-Video profile, it is the shape `read_filesystem` takes when
|
||||
//! `num_partition_maps < 2`, and it avoids the UDF 2.50 Metadata Partition
|
||||
//! entirely. That also means a synthetic image never exercises the Metadata
|
||||
//! Partition path in `udf.rs` (`:946-991`) — see the module docs on `dirimage`.
|
||||
|
||||
use super::layout::{DirNode, Layout};
|
||||
use crate::error::{Error, Result};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Logical block / sector size. Fixed for every optical profile this crate
|
||||
/// reads, and the same quantity as [`crate::consts::SECTOR_BYTES`] — aliased
|
||||
/// rather than re-declared so the two cannot drift apart. The short name is
|
||||
/// kept because it appears in ~25 extent and offset expressions across
|
||||
/// `dirimage`, where the longer one would bury the arithmetic.
|
||||
pub(super) use crate::consts::SECTOR_BYTES as SECTOR;
|
||||
|
||||
/// Descriptor version recorded in every tag. 2 = ECMA-167 2nd edition, which
|
||||
/// is what UDF revisions up to and including 2.00 require.
|
||||
const DESC_VERSION: u16 = 2;
|
||||
|
||||
/// UDF revision recorded in the domain EntityID suffix (1.02, BCD-ish u16).
|
||||
const UDF_REVISION: u16 = 0x0102;
|
||||
|
||||
/// A fixed recording timestamp, so an image synthesized from the same folder
|
||||
/// twice is byte-identical. Real mtimes would make every test golden-file
|
||||
/// comparison and every `dir:// -> iso://` re-run differ for no benefit.
|
||||
const FIXED_TIME: Timestamp = Timestamp {
|
||||
year: 2000,
|
||||
month: 1,
|
||||
day: 1,
|
||||
};
|
||||
|
||||
struct Timestamp {
|
||||
year: i16,
|
||||
month: u8,
|
||||
day: u8,
|
||||
}
|
||||
|
||||
/// The synthesized metadata: absolute LBA → sector contents. Data sectors are
|
||||
/// NOT here; they are served from the backing files.
|
||||
pub(super) type MetaSectors = BTreeMap<u32, Box<[u8; SECTOR]>>;
|
||||
|
||||
/// The descriptor-tag CRC of ECMA-167 7.2.4: polynomial 0x1021, initial value
|
||||
/// ZERO, no reflection, no final XOR — the variant catalogued as CRC-16/XMODEM
|
||||
/// (check value 0x31C3), NOT CCITT-FALSE, which seeds at 0xFFFF and would make
|
||||
/// every descriptor this crate writes fail a conformant driver's validation.
|
||||
fn crc16(data: &[u8]) -> u16 {
|
||||
let mut crc: u16 = 0;
|
||||
for &b in data {
|
||||
crc ^= (b as u16) << 8;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x8000 != 0 {
|
||||
(crc << 1) ^ 0x1021
|
||||
} else {
|
||||
crc << 1
|
||||
};
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// Write an ECMA-167 3/7.2 descriptor tag over `buf[0..16]`.
|
||||
///
|
||||
/// `tag_loc` is the block number of the sector holding the descriptor —
|
||||
/// ABSOLUTE for the volume-space descriptors (AVDP, VDS, LVID) and
|
||||
/// PARTITION-RELATIVE for everything inside the partition (FSD, File Entries).
|
||||
/// Getting that wrong is the classic reason a hand-built volume mounts nowhere:
|
||||
/// a driver that validates the tag location rejects the descriptor outright.
|
||||
///
|
||||
/// `desc_len` is the descriptor's total length including the tag; the CRC
|
||||
/// covers `buf[16..desc_len]`.
|
||||
fn finish_tag(buf: &mut [u8], tag_id: u16, tag_loc: u32, desc_len: usize) {
|
||||
buf[0..2].copy_from_slice(&tag_id.to_le_bytes());
|
||||
buf[2..4].copy_from_slice(&DESC_VERSION.to_le_bytes());
|
||||
buf[4] = 0; // checksum, filled below
|
||||
buf[5] = 0; // reserved
|
||||
buf[6..8].copy_from_slice(&0u16.to_le_bytes()); // tag serial number
|
||||
let crc_len = desc_len - 16;
|
||||
let crc = crc16(&buf[16..desc_len]);
|
||||
buf[8..10].copy_from_slice(&crc.to_le_bytes());
|
||||
buf[10..12].copy_from_slice(&(crc_len as u16).to_le_bytes());
|
||||
buf[12..16].copy_from_slice(&tag_loc.to_le_bytes());
|
||||
// ECMA-167 3/7.2.3: sum of bytes 0..16 EXCLUDING byte 4, modulo 256.
|
||||
let sum: u32 = buf[0..16]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != 4)
|
||||
.map(|(_, b)| *b as u32)
|
||||
.sum();
|
||||
buf[4] = (sum % 256) as u8;
|
||||
}
|
||||
|
||||
/// ECMA-167 1/7.2.1 charspec: type 0 (CS0) + "OSTA Compressed Unicode".
|
||||
fn put_charspec(buf: &mut [u8]) {
|
||||
buf[0] = 0;
|
||||
let id = b"OSTA Compressed Unicode";
|
||||
buf[1..1 + id.len()].copy_from_slice(id);
|
||||
}
|
||||
|
||||
/// ECMA-167 1/7.4 EntityID: flags byte, 23 identifier bytes, 8 suffix bytes.
|
||||
fn put_entity_id(buf: &mut [u8], id: &[u8], suffix: &[u8]) {
|
||||
buf[0] = 0;
|
||||
let n = id.len().min(23);
|
||||
buf[1..1 + n].copy_from_slice(&id[..n]);
|
||||
let m = suffix.len().min(8);
|
||||
buf[24..24 + m].copy_from_slice(&suffix[..m]);
|
||||
}
|
||||
|
||||
/// The `*OSTA UDF Compliant` domain EntityID suffix: UDF revision, domain
|
||||
/// flags (0 = neither hard nor soft write-protected), reserved.
|
||||
fn domain_suffix() -> [u8; 8] {
|
||||
let mut s = [0u8; 8];
|
||||
s[0..2].copy_from_slice(&UDF_REVISION.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// This crate's implementation EntityID suffix: OS class / OS identifier
|
||||
/// (0 = undefined, deliberately — the image is not OS-specific) + 6 free bytes.
|
||||
fn impl_suffix() -> [u8; 8] {
|
||||
[0u8; 8]
|
||||
}
|
||||
|
||||
fn put_impl_id(buf: &mut [u8]) {
|
||||
put_entity_id(buf, b"*freemkv", &impl_suffix());
|
||||
}
|
||||
|
||||
fn put_domain_id(buf: &mut [u8]) {
|
||||
put_entity_id(buf, b"*OSTA UDF Compliant", &domain_suffix());
|
||||
}
|
||||
|
||||
/// OSTA CS0 d-string: a compression-ID byte, the characters, then the used
|
||||
/// length in the FIELD'S LAST byte (ECMA-167 1/7.2.12 + UDF 2.1.3). An
|
||||
/// all-zero field is the empty string.
|
||||
fn put_dstring(buf: &mut [u8], s: &str) {
|
||||
if s.is_empty() {
|
||||
return;
|
||||
}
|
||||
let encoded = encode_cs0(s);
|
||||
// Leave room for the trailing length byte.
|
||||
let room = buf.len() - 1;
|
||||
let n = encoded.len().min(room);
|
||||
buf[..n].copy_from_slice(&encoded[..n]);
|
||||
buf[buf.len() - 1] = n as u8;
|
||||
}
|
||||
|
||||
/// OSTA CS0: compression ID 8 (one byte per character) when every character
|
||||
/// is ASCII, otherwise compression ID 16 (UTF-16BE).
|
||||
///
|
||||
/// ASCII rather than Latin-1 for the 8-bit form on purpose: `parse_udf_name`
|
||||
/// (`udf.rs:1467`) decodes a compression-8 name with `from_utf8_lossy`, so a
|
||||
/// 0x80-0xFF byte — legal CS0 — would come back as U+FFFD. Every character
|
||||
/// above 0x7F therefore takes the 16-bit form, which that parser decodes
|
||||
/// correctly.
|
||||
pub(super) fn encode_cs0(s: &str) -> Vec<u8> {
|
||||
if s.is_ascii() {
|
||||
let mut v = Vec::with_capacity(1 + s.len());
|
||||
v.push(8u8);
|
||||
v.extend_from_slice(s.as_bytes());
|
||||
v
|
||||
} else {
|
||||
let mut v = vec![16u8];
|
||||
for u in s.encode_utf16() {
|
||||
v.extend_from_slice(&u.to_be_bytes());
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// ECMA-167 1/7.3 timestamp, 12 bytes. Type 1 (local time) with a zero
|
||||
/// offset, i.e. UTC.
|
||||
fn put_timestamp(buf: &mut [u8]) {
|
||||
buf[0..2].copy_from_slice(&0x1000u16.to_le_bytes());
|
||||
buf[2..4].copy_from_slice(&FIXED_TIME.year.to_le_bytes());
|
||||
buf[4] = FIXED_TIME.month;
|
||||
buf[5] = FIXED_TIME.day;
|
||||
}
|
||||
|
||||
/// ECMA-167 3/7.1 extent_ad: length in BYTES, then location.
|
||||
fn put_extent_ad(buf: &mut [u8], len_bytes: u32, lba: u32) {
|
||||
buf[0..4].copy_from_slice(&len_bytes.to_le_bytes());
|
||||
buf[4..8].copy_from_slice(&lba.to_le_bytes());
|
||||
}
|
||||
|
||||
/// ECMA-167 4/14.14.2 long_ad: length+type, then lb_addr (block, partition
|
||||
/// reference), then 6 implementation-use bytes.
|
||||
fn put_long_ad(buf: &mut [u8], len_bytes: u32, lba: u32) {
|
||||
buf[0..4].copy_from_slice(&len_bytes.to_le_bytes());
|
||||
buf[4..8].copy_from_slice(&lba.to_le_bytes());
|
||||
buf[8..10].copy_from_slice(&0u16.to_le_bytes()); // partition reference 0
|
||||
}
|
||||
|
||||
/// ECMA-167 4/14.14.1 short_ad. The top two bits of the length word are the
|
||||
/// extent TYPE (0 = recorded and allocated), which is exactly why `udf.rs`
|
||||
/// masks with `0x3FFF_FFFF` when it reads one back — the mask is the field
|
||||
/// boundary, not a truncation bug.
|
||||
fn put_short_ad(buf: &mut [u8], len_bytes: u32, lba: u32) {
|
||||
debug_assert!(len_bytes <= 0x3FFF_FFFF, "AD length must fit 30 bits");
|
||||
buf[0..4].copy_from_slice(&len_bytes.to_le_bytes());
|
||||
buf[4..8].copy_from_slice(&lba.to_le_bytes());
|
||||
}
|
||||
|
||||
fn blank() -> Box<[u8; SECTOR]> {
|
||||
Box::new([0u8; SECTOR])
|
||||
}
|
||||
|
||||
// ── Volume-space descriptors ────────────────────────────────────────────────
|
||||
|
||||
/// ECMA-167 2/9.1 Volume Structure Descriptor: the three-sector recognition
|
||||
/// sequence an OS looks for before it will even consider the volume UDF.
|
||||
fn volume_recognition(id: &[u8; 5]) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
s[0] = 0; // structure type
|
||||
s[1..6].copy_from_slice(id);
|
||||
s[6] = 1; // structure version
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 3/10.1 Primary Volume Descriptor.
|
||||
fn primary_volume(volume_id: &str, lba: u32, seq: u32) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
s[16..20].copy_from_slice(&seq.to_le_bytes());
|
||||
s[20..24].copy_from_slice(&0u32.to_le_bytes()); // PVD number
|
||||
put_dstring(&mut s[24..56], volume_id);
|
||||
s[56..58].copy_from_slice(&1u16.to_le_bytes()); // volume sequence number
|
||||
s[58..60].copy_from_slice(&1u16.to_le_bytes()); // max volume sequence number
|
||||
s[60..62].copy_from_slice(&2u16.to_le_bytes()); // interchange level
|
||||
s[62..64].copy_from_slice(&2u16.to_le_bytes()); // max interchange level
|
||||
s[64..68].copy_from_slice(&1u32.to_le_bytes()); // character set list
|
||||
s[68..72].copy_from_slice(&1u32.to_le_bytes()); // max character set list
|
||||
// UDF 2.2.2.5: the first 8 characters of the volume set identifier must be
|
||||
// unique. A fixed hex prefix plus the volume id is sufficient here — the
|
||||
// image is single-volume and never joins a real volume set.
|
||||
put_dstring(&mut s[72..200], &format!("46524D4B{volume_id}"));
|
||||
put_charspec(&mut s[200..264]); // descriptor character set
|
||||
put_charspec(&mut s[264..328]); // explanatory character set
|
||||
put_timestamp(&mut s[376..388]);
|
||||
put_impl_id(&mut s[388..420]);
|
||||
finish_tag(&mut s[..], 1, lba, 512);
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 3/10.4 + UDF 2.2.7 Implementation Use Volume Descriptor
|
||||
/// (`*UDF LV Info`). Not read by `udf.rs`, required by the spec.
|
||||
fn impl_use_volume(volume_id: &str, lba: u32, seq: u32) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
s[16..20].copy_from_slice(&seq.to_le_bytes());
|
||||
put_entity_id(&mut s[20..52], b"*UDF LV Info", &domain_suffix());
|
||||
put_charspec(&mut s[52..116]); // LVI charset
|
||||
put_dstring(&mut s[116..244], volume_id); // logical volume identifier
|
||||
put_impl_id(&mut s[352..384]);
|
||||
finish_tag(&mut s[..], 4, lba, 512);
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 3/10.5 Partition Descriptor — the descriptor `read_filesystem`
|
||||
/// takes `partition_start` from (offset 188).
|
||||
fn partition(part_start: u32, part_sectors: u32, lba: u32, seq: u32) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
s[16..20].copy_from_slice(&seq.to_le_bytes());
|
||||
s[20..22].copy_from_slice(&1u16.to_le_bytes()); // partition flags: allocated
|
||||
s[22..24].copy_from_slice(&0u16.to_le_bytes()); // partition number
|
||||
put_entity_id(&mut s[24..56], b"+NSR02", &[]);
|
||||
// s[56..184] partition contents use = Partition Header Descriptor. All
|
||||
// zero: a read-only partition records no unallocated/freed space tables.
|
||||
s[184..188].copy_from_slice(&1u32.to_le_bytes()); // access type: read only
|
||||
s[188..192].copy_from_slice(&part_start.to_le_bytes());
|
||||
s[192..196].copy_from_slice(&part_sectors.to_le_bytes());
|
||||
put_impl_id(&mut s[196..228]);
|
||||
finish_tag(&mut s[..], 5, lba, 512);
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 3/10.6 Logical Volume Descriptor. Carries the FSD long_ad and the
|
||||
/// partition map table; `read_filesystem` reads `num_partition_maps` at 268
|
||||
/// and takes the single-partition path when it is 1.
|
||||
fn logical_volume(
|
||||
volume_id: &str,
|
||||
fsd_lba: u32,
|
||||
integrity_lba: u32,
|
||||
integrity_sectors: u32,
|
||||
lba: u32,
|
||||
seq: u32,
|
||||
) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
s[16..20].copy_from_slice(&seq.to_le_bytes());
|
||||
put_charspec(&mut s[20..84]);
|
||||
put_dstring(&mut s[84..212], volume_id);
|
||||
s[212..216].copy_from_slice(&(SECTOR as u32).to_le_bytes()); // logical block size
|
||||
put_domain_id(&mut s[216..248]);
|
||||
// Logical volume contents use = long_ad of the File Set Descriptor,
|
||||
// partition-relative. One sector.
|
||||
put_long_ad(&mut s[248..264], SECTOR as u32, fsd_lba);
|
||||
s[264..268].copy_from_slice(&6u32.to_le_bytes()); // map table length
|
||||
s[268..272].copy_from_slice(&1u32.to_le_bytes()); // number of partition maps
|
||||
put_impl_id(&mut s[272..304]);
|
||||
put_extent_ad(
|
||||
&mut s[432..440],
|
||||
integrity_sectors * SECTOR as u32,
|
||||
integrity_lba,
|
||||
);
|
||||
// ECMA-167 3/10.7.2 Type 1 partition map.
|
||||
s[440] = 1; // map type
|
||||
s[441] = 6; // map length
|
||||
s[442..444].copy_from_slice(&1u16.to_le_bytes()); // volume sequence number
|
||||
s[444..446].copy_from_slice(&0u16.to_le_bytes()); // partition number
|
||||
finish_tag(&mut s[..], 6, lba, 446);
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 3/10.8 Unallocated Space Descriptor with zero extents — the whole
|
||||
/// volume is accounted for by the partition.
|
||||
fn unallocated_space(lba: u32, seq: u32) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
s[16..20].copy_from_slice(&seq.to_le_bytes());
|
||||
s[20..24].copy_from_slice(&0u32.to_le_bytes());
|
||||
finish_tag(&mut s[..], 7, lba, 24);
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 3/10.9 Terminating Descriptor.
|
||||
fn terminating(lba: u32) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
finish_tag(&mut s[..], 8, lba, 512);
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 3/10.10 + UDF 2.2.6 Logical Volume Integrity Descriptor, closed.
|
||||
fn integrity(
|
||||
part_sectors: u32,
|
||||
files: u32,
|
||||
dirs: u32,
|
||||
next_uid: u64,
|
||||
lba: u32,
|
||||
) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
put_timestamp(&mut s[16..28]);
|
||||
s[28..32].copy_from_slice(&1u32.to_le_bytes()); // integrity type: close
|
||||
// s[32..40] next integrity extent: none.
|
||||
s[40..48].copy_from_slice(&next_uid.to_le_bytes()); // logical volume contents use: next unique id
|
||||
s[72..76].copy_from_slice(&1u32.to_le_bytes()); // number of partitions
|
||||
s[76..80].copy_from_slice(&46u32.to_le_bytes()); // length of implementation use
|
||||
s[80..84].copy_from_slice(&0u32.to_le_bytes()); // free space: none (read-only)
|
||||
s[84..88].copy_from_slice(&part_sectors.to_le_bytes()); // size table
|
||||
put_impl_id(&mut s[88..120]);
|
||||
s[120..124].copy_from_slice(&files.to_le_bytes());
|
||||
s[124..128].copy_from_slice(&dirs.to_le_bytes());
|
||||
s[128..130].copy_from_slice(&UDF_REVISION.to_le_bytes()); // min read revision
|
||||
s[130..132].copy_from_slice(&UDF_REVISION.to_le_bytes()); // min write revision
|
||||
s[132..134].copy_from_slice(&UDF_REVISION.to_le_bytes()); // max write revision
|
||||
finish_tag(&mut s[..], 9, lba, 134);
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 3/10.2 Anchor Volume Descriptor Pointer. `read_filesystem` reads
|
||||
/// the main VDS extent from offsets 16..24 and sweeps it.
|
||||
fn anchor(main_lba: u32, reserve_lba: u32, vds_sectors: u32, lba: u32) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
put_extent_ad(&mut s[16..24], vds_sectors * SECTOR as u32, main_lba);
|
||||
put_extent_ad(&mut s[24..32], vds_sectors * SECTOR as u32, reserve_lba);
|
||||
finish_tag(&mut s[..], 2, lba, 512);
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 4/14.1 File Set Descriptor. `read_filesystem` requires tag 256 at
|
||||
/// the first block of the (metadata =) partition and reads the root ICB block
|
||||
/// from offset 404.
|
||||
fn file_set(volume_id: &str, root_icb: u32, lba: u32) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
put_timestamp(&mut s[16..28]);
|
||||
s[28..30].copy_from_slice(&3u16.to_le_bytes()); // interchange level
|
||||
s[30..32].copy_from_slice(&3u16.to_le_bytes()); // max interchange level
|
||||
s[32..36].copy_from_slice(&1u32.to_le_bytes()); // character set list
|
||||
s[36..40].copy_from_slice(&1u32.to_le_bytes()); // max character set list
|
||||
s[40..44].copy_from_slice(&0u32.to_le_bytes()); // file set number
|
||||
s[44..48].copy_from_slice(&0u32.to_le_bytes()); // file set descriptor number
|
||||
put_charspec(&mut s[48..112]);
|
||||
put_dstring(&mut s[112..240], volume_id);
|
||||
put_charspec(&mut s[240..304]);
|
||||
put_dstring(&mut s[304..336], volume_id);
|
||||
put_long_ad(&mut s[400..416], SECTOR as u32, root_icb);
|
||||
put_domain_id(&mut s[416..448]);
|
||||
finish_tag(&mut s[..], 256, lba, 512);
|
||||
s
|
||||
}
|
||||
|
||||
// ── Partition-space descriptors ─────────────────────────────────────────────
|
||||
|
||||
/// UDF permission word: read + execute for owner, group and other. No write
|
||||
/// bit anywhere — the volume is read-only.
|
||||
const PERM_R_X: u32 = 0x0000_1000 | 0x0000_0400 | 0x0000_0080 | 0x0000_0020 | 0x4 | 0x1;
|
||||
|
||||
/// ECMA-167 4/14.9 File Entry (tag 261).
|
||||
///
|
||||
/// Tag 261 rather than the Extended File Entry (266) real BD-ROMs use: an EFE
|
||||
/// requires UDF 2.00+, and this image declares 1.02. `udf.rs` reads both — the
|
||||
/// 261 field offsets it uses (l_ea 168, l_ad 172, ADs at 176 + l_ea) are the
|
||||
/// ones written here.
|
||||
///
|
||||
/// `extents` are partition-relative (block, byte-length) pairs, already split
|
||||
/// so no single one exceeds the 30-bit AD length field.
|
||||
fn file_entry(
|
||||
is_dir: bool,
|
||||
info_len: u64,
|
||||
extents: &[(u32, u32)],
|
||||
link_count: u16,
|
||||
unique_id: u64,
|
||||
lba: u32,
|
||||
) -> Result<Box<[u8; SECTOR]>> {
|
||||
let mut s = blank();
|
||||
// ICB tag (ECMA-167 4/14.6) at offset 16.
|
||||
s[16..20].copy_from_slice(&0u32.to_le_bytes()); // prior recorded direct entries
|
||||
s[20..22].copy_from_slice(&4u16.to_le_bytes()); // strategy type 4
|
||||
s[24..26].copy_from_slice(&1u16.to_le_bytes()); // max number of entries
|
||||
s[27] = if is_dir { 4 } else { 5 }; // file type: directory / byte sequence
|
||||
// s[28..34] parent ICB location: not recorded (permitted).
|
||||
// s[34..36] ICB flags: 0 => short allocation descriptors. `udf.rs:601`
|
||||
// reads exactly this word to pick its AD stride.
|
||||
s[34..36].copy_from_slice(&0u16.to_le_bytes());
|
||||
// UDF's sentinel for "not specified" is 0xFFFFFFFF, not 0 — 0 is a real
|
||||
// uid/gid (root). A synthesized image has no meaningful owner, and a driver
|
||||
// that maps these through would otherwise report every file as root-owned.
|
||||
s[36..40].copy_from_slice(&u32::MAX.to_le_bytes()); // uid: not specified
|
||||
s[40..44].copy_from_slice(&u32::MAX.to_le_bytes()); // gid: not specified
|
||||
s[44..48].copy_from_slice(&PERM_R_X.to_le_bytes());
|
||||
s[48..50].copy_from_slice(&link_count.to_le_bytes());
|
||||
s[56..64].copy_from_slice(&info_len.to_le_bytes());
|
||||
let blocks: u64 = extents
|
||||
.iter()
|
||||
.map(|(_, len)| (*len as u64).div_ceil(SECTOR as u64))
|
||||
.sum();
|
||||
s[64..72].copy_from_slice(&blocks.to_le_bytes()); // logical blocks recorded
|
||||
put_timestamp(&mut s[72..84]); // access
|
||||
put_timestamp(&mut s[84..96]); // modification
|
||||
put_timestamp(&mut s[96..108]); // attribute
|
||||
s[108..112].copy_from_slice(&1u32.to_le_bytes()); // checkpoint
|
||||
put_impl_id(&mut s[128..160]);
|
||||
s[160..168].copy_from_slice(&unique_id.to_le_bytes());
|
||||
s[168..172].copy_from_slice(&0u32.to_le_bytes()); // length of EAs
|
||||
let l_ad = extents.len() * 8;
|
||||
// A short AD is 8 bytes and the entry has 2048 - 176 = 1872 bytes for
|
||||
// them, i.e. 234 extents — over 200 GiB at the per-AD ceiling. Beyond
|
||||
// that an Allocation Extent Descriptor chain would be required; refuse
|
||||
// rather than write a truncated list.
|
||||
if 176 + l_ad > SECTOR {
|
||||
return Err(Error::DirImageTooLarge);
|
||||
}
|
||||
s[172..176].copy_from_slice(&(l_ad as u32).to_le_bytes());
|
||||
for (i, (elba, len)) in extents.iter().enumerate() {
|
||||
let off = 176 + i * 8;
|
||||
put_short_ad(&mut s[off..off + 8], *len, *elba);
|
||||
}
|
||||
finish_tag(&mut s[..], 261, lba, 176 + l_ad);
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// ECMA-167 4/14.4 File Identifier Descriptor, appended to `buf`.
|
||||
///
|
||||
/// FIDs are packed with no inter-descriptor padding beyond the 4-byte
|
||||
/// alignment the spec mandates, and they are allowed to span logical blocks —
|
||||
/// which is also what `read_directory` (`udf.rs:1312`) assumes: it walks the
|
||||
/// directory extent as one flat byte run and STOPS at the first non-257 tag,
|
||||
/// so any block-alignment gap would truncate the directory.
|
||||
fn push_fid(buf: &mut Vec<u8>, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) {
|
||||
let start = buf.len();
|
||||
let name_field: Vec<u8> = if is_parent {
|
||||
Vec::new()
|
||||
} else {
|
||||
encode_cs0(name)
|
||||
};
|
||||
let l_fi = name_field.len();
|
||||
let mut fid = vec![0u8; 38];
|
||||
fid[16..18].copy_from_slice(&1u16.to_le_bytes()); // file version number
|
||||
let mut chars = 0u8;
|
||||
if is_dir {
|
||||
chars |= 0x02;
|
||||
}
|
||||
if is_parent {
|
||||
chars |= 0x08;
|
||||
}
|
||||
fid[18] = chars;
|
||||
// The planner refuses any name whose encoding exceeds what this byte can
|
||||
// hold (`layout::MAX_CS0_NAME_BYTES`), so this cannot wrap in practice. The
|
||||
// assert states the invariant where it is relied on rather than trusting a
|
||||
// check three files away; a wrap here would desynchronise the directory.
|
||||
debug_assert!(
|
||||
l_fi <= u8::MAX as usize,
|
||||
"FID name length must fit one byte"
|
||||
);
|
||||
fid[19] = l_fi as u8;
|
||||
put_long_ad(&mut fid[20..36], SECTOR as u32, icb_lba);
|
||||
fid[36..38].copy_from_slice(&0u16.to_le_bytes()); // length of implementation use
|
||||
buf.extend_from_slice(&fid);
|
||||
buf.extend_from_slice(&name_field);
|
||||
let unpadded = buf.len() - start;
|
||||
let padded = unpadded.div_ceil(4) * 4;
|
||||
buf.resize(start + padded, 0);
|
||||
// The tag is written last: its CRC covers the descriptor body, which the
|
||||
// padding is not part of (ECMA-167 4/14.4.9 counts padding outside the
|
||||
// CRC'd length).
|
||||
let tag_loc_placeholder = 0;
|
||||
finish_tag(
|
||||
&mut buf[start..start + unpadded],
|
||||
257,
|
||||
tag_loc_placeholder,
|
||||
unpadded,
|
||||
);
|
||||
}
|
||||
|
||||
/// Serialize one directory's FID list (parent entry first, then children).
|
||||
pub(super) fn dir_fids(dir: &DirNode) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
push_fid(&mut buf, "", dir.parent_icb_lba, true, true);
|
||||
for sub in &dir.dirs {
|
||||
push_fid(&mut buf, &sub.name, sub.icb_lba, true, false);
|
||||
}
|
||||
for f in &dir.files {
|
||||
push_fid(&mut buf, &f.name, f.icb_lba, false, false);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Patch every FID's tag location to the block it actually lands in. ECMA-167
|
||||
/// 3/7.2.2 makes the tag location the block of the descriptor, and a FID that
|
||||
/// spans two blocks records the block it STARTS in.
|
||||
fn fix_fid_tag_locations(buf: &mut [u8], first_block: u32) {
|
||||
let mut pos = 0usize;
|
||||
while pos + 38 <= buf.len() {
|
||||
let l_fi = buf[pos + 19] as usize;
|
||||
let l_iu = u16::from_le_bytes([buf[pos + 36], buf[pos + 37]]) as usize;
|
||||
let unpadded = 38 + l_iu + l_fi;
|
||||
if pos + unpadded > buf.len() {
|
||||
break;
|
||||
}
|
||||
let block = first_block + (pos / SECTOR) as u32;
|
||||
finish_tag(&mut buf[pos..pos + unpadded], 257, block, unpadded);
|
||||
pos += unpadded.div_ceil(4) * 4;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Whole-image assembly ────────────────────────────────────────────────────
|
||||
|
||||
/// Volume-space block of the Volume Recognition Sequence.
|
||||
const VRS_START: u32 = 16;
|
||||
/// Volume-space block of the Main Volume Descriptor Sequence.
|
||||
pub(super) const MAIN_VDS_START: u32 = 32;
|
||||
/// Volume-space block of the Reserve Volume Descriptor Sequence.
|
||||
pub(super) const RESERVE_VDS_START: u32 = 48;
|
||||
/// Sectors reserved for each VDS. ECMA-167 3/10.2.1 requires an anchor to
|
||||
/// record at least 16.
|
||||
pub(super) const VDS_SECTORS: u32 = 16;
|
||||
/// Volume-space block of the Logical Volume Integrity Sequence.
|
||||
pub(super) const LVID_START: u32 = 64;
|
||||
/// Sectors reserved for the integrity sequence (LVID + TD).
|
||||
pub(super) const LVID_SECTORS: u32 = 2;
|
||||
/// The mandatory anchor block (ECMA-167 3/10.2).
|
||||
pub(super) const ANCHOR_LBA: u32 = 256;
|
||||
/// First block a partition may start at. Everything above is volume space.
|
||||
pub(super) const MIN_PART_START: u32 = 320;
|
||||
|
||||
/// Emit the six-descriptor Volume Descriptor Sequence at `start`.
|
||||
fn write_vds(out: &mut MetaSectors, layout: &Layout, start: u32) {
|
||||
let vid = &layout.volume_id;
|
||||
out.insert(start, primary_volume(vid, start, 1));
|
||||
out.insert(start + 1, impl_use_volume(vid, start + 1, 2));
|
||||
out.insert(
|
||||
start + 2,
|
||||
partition(layout.part_start, layout.part_sectors, start + 2, 3),
|
||||
);
|
||||
out.insert(
|
||||
start + 3,
|
||||
logical_volume(vid, 0, LVID_START, LVID_SECTORS, start + 3, 4),
|
||||
);
|
||||
out.insert(start + 4, unallocated_space(start + 4, 5));
|
||||
out.insert(start + 5, terminating(start + 5));
|
||||
}
|
||||
|
||||
/// Recursively emit one directory's File Entry and FID list, then its
|
||||
/// children's.
|
||||
fn write_dir(out: &mut MetaSectors, layout: &Layout, dir: &DirNode) -> Result<()> {
|
||||
let mut fids = dir_fids(dir);
|
||||
fix_fid_tag_locations(&mut fids, dir.data_lba);
|
||||
debug_assert_eq!(fids.len(), dir.data_bytes as usize);
|
||||
|
||||
// A directory's link count is 1 (its own FID in the parent) plus one for
|
||||
// each child directory's parent FID pointing back at it.
|
||||
// The planner caps subdirectory fan-out (`layout::MAX_SUBDIRS`) so this
|
||||
// cannot overflow; saturating rather than wrapping keeps a future change to
|
||||
// that cap from silently producing a wrong count.
|
||||
let link_count = (dir.dirs.len() as u16).saturating_add(1);
|
||||
let fe = file_entry(
|
||||
true,
|
||||
fids.len() as u64,
|
||||
&[(dir.data_lba, fids.len() as u32)],
|
||||
link_count,
|
||||
dir.unique_id,
|
||||
dir.icb_lba,
|
||||
)?;
|
||||
out.insert(layout.part_start + dir.icb_lba, fe);
|
||||
|
||||
for (i, chunk) in fids.chunks(SECTOR).enumerate() {
|
||||
let mut s = blank();
|
||||
s[..chunk.len()].copy_from_slice(chunk);
|
||||
out.insert(layout.part_start + dir.data_lba + i as u32, s);
|
||||
}
|
||||
|
||||
for f in &dir.files {
|
||||
let extents: Vec<(u32, u32)> = f.extents.iter().map(|e| (e.lba, e.bytes)).collect();
|
||||
let fe = file_entry(false, f.size, &extents, 1, f.unique_id, f.icb_lba)?;
|
||||
out.insert(layout.part_start + f.icb_lba, fe);
|
||||
}
|
||||
|
||||
for sub in &dir.dirs {
|
||||
write_dir(out, layout, sub)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build every metadata sector of the synthesized volume.
|
||||
pub(super) fn encode(layout: &Layout) -> Result<MetaSectors> {
|
||||
let mut out = MetaSectors::new();
|
||||
|
||||
out.insert(VRS_START, volume_recognition(b"BEA01"));
|
||||
out.insert(VRS_START + 1, volume_recognition(b"NSR02"));
|
||||
out.insert(VRS_START + 2, volume_recognition(b"TEA01"));
|
||||
|
||||
write_vds(&mut out, layout, MAIN_VDS_START);
|
||||
write_vds(&mut out, layout, RESERVE_VDS_START);
|
||||
|
||||
out.insert(
|
||||
LVID_START,
|
||||
integrity(
|
||||
layout.part_sectors,
|
||||
layout.file_count,
|
||||
layout.dir_count,
|
||||
layout.next_unique_id,
|
||||
LVID_START,
|
||||
),
|
||||
);
|
||||
out.insert(LVID_START + 1, terminating(LVID_START + 1));
|
||||
|
||||
let avdp = anchor(MAIN_VDS_START, RESERVE_VDS_START, VDS_SECTORS, ANCHOR_LBA);
|
||||
out.insert(ANCHOR_LBA, avdp);
|
||||
let last = layout.total_sectors - 1;
|
||||
out.insert(
|
||||
last,
|
||||
anchor(MAIN_VDS_START, RESERVE_VDS_START, VDS_SECTORS, last),
|
||||
);
|
||||
|
||||
// Partition block 0 must hold the File Set Descriptor: `read_filesystem`
|
||||
// reads exactly `metadata_start` (== partition start on a single-partition
|
||||
// volume) and rejects the volume outright if the tag there is not 256.
|
||||
out.insert(
|
||||
layout.part_start,
|
||||
file_set(&layout.volume_id, layout.root.icb_lba, 0),
|
||||
);
|
||||
out.insert(layout.part_start + 1, terminating(1));
|
||||
|
||||
write_dir(&mut out, layout, &layout.root)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The reference check value for CRC-16/XMODEM — poly 0x1021 seeded at 0,
|
||||
/// which is what ECMA-167 7.2.4 specifies: "123456789" → 0x31C3. Seeding
|
||||
/// at 0xFFFF instead (CCITT-FALSE) yields 0x29B1, and that mutant is
|
||||
/// invisible to `udf.rs`, which never verifies a tag CRC — it would only
|
||||
/// show up as a volume no operating system will mount.
|
||||
#[test]
|
||||
fn crc16_matches_the_ecma167_check_value() {
|
||||
assert_eq!(crc16(b"123456789"), 0x31C3);
|
||||
assert_ne!(crc16(b"123456789"), 0x29B1, "not the 0xFFFF-seeded variant");
|
||||
}
|
||||
|
||||
/// ECMA-167 3/7.2.3: the checksum is the sum of the tag's first 16 bytes
|
||||
/// EXCLUDING the checksum byte itself, modulo 256.
|
||||
#[test]
|
||||
fn tag_checksum_excludes_its_own_byte() {
|
||||
let mut buf = [0u8; 512];
|
||||
buf[16..24].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
finish_tag(&mut buf, 261, 0x1234, 512);
|
||||
let sum: u32 = buf[0..16]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != 4)
|
||||
.map(|(_, b)| *b as u32)
|
||||
.sum();
|
||||
assert_eq!(buf[4] as u32, sum % 256);
|
||||
// And the recorded CRC covers the body, not the tag.
|
||||
let crc = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
assert_eq!(crc, crc16(&buf[16..512]));
|
||||
assert_eq!(u16::from_le_bytes([buf[10], buf[11]]), 496);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]),
|
||||
0x1234
|
||||
);
|
||||
}
|
||||
|
||||
/// ASCII takes compression ID 8; anything above takes 16 (UTF-16BE),
|
||||
/// because `parse_udf_name` decodes compression-8 bytes as UTF-8.
|
||||
#[test]
|
||||
fn cs0_picks_the_encoding_the_parser_can_decode() {
|
||||
assert_eq!(encode_cs0("AB"), vec![8, b'A', b'B']);
|
||||
let e = encode_cs0("Ä");
|
||||
assert_eq!(e[0], 16);
|
||||
assert_eq!(&e[1..], &[0x00, 0xC4]);
|
||||
assert_eq!(crate::udf::parse_udf_name(&e), "Ä");
|
||||
}
|
||||
|
||||
/// A d-string records its used length in the field's LAST byte, and the
|
||||
/// production parser must read the same string back.
|
||||
#[test]
|
||||
fn dstring_round_trips_through_the_production_parser() {
|
||||
let mut field = [0u8; 32];
|
||||
put_dstring(&mut field, "FREEMKV");
|
||||
assert_eq!(field[31], 8, "compid byte + 7 characters");
|
||||
assert_eq!(crate::udf::parse_dstring_for_test(&field), "FREEMKV");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
||||
//! `dir://` as an image-level SOURCE: a synthetic UDF volume over a folder.
|
||||
//!
|
||||
//! A user's extracted disc — a DVD `VIDEO_TS/` or a Blu-ray `BDMV/`, typically
|
||||
//! a MakeMKV-style backup — has files but no sectors, and everything above the
|
||||
//! sector layer in this crate wants sectors: `Disc::scan_image`, `UdfFs`,
|
||||
//! `ifo.rs`, `mpls.rs`, `clpi.rs` and the mux all read through a
|
||||
//! [`SectorSource`]. [`DirImage`] supplies one.
|
||||
//!
|
||||
//! The trick is that nothing is emulated. A real, minimal, valid UDF 1.02
|
||||
//! volume is synthesized over the folder:
|
||||
//!
|
||||
//! * **Metadata sectors** (anchors, the volume descriptor sequences, the File
|
||||
//! Set Descriptor, every File Entry, every directory's FID list) are encoded
|
||||
//! into RAM by [`encode`] — a few MiB even for a large Blu-ray.
|
||||
//! * **Data sectors** are not materialized at all. Each one maps to a byte
|
||||
//! range of a real file, read on demand.
|
||||
//!
|
||||
//! So `udf::read_filesystem` parses this image by exactly the same code path it
|
||||
//! parses a real disc with, and every consumer above it is unchanged. The cost
|
||||
//! is that a single-partition synthetic volume never exercises the UDF 2.50
|
||||
//! Metadata Partition path (`udf.rs:946-991`) that every real BD-ROM uses —
|
||||
//! this module's tests do not cover that block and must not be read as if they
|
||||
//! did.
|
||||
//!
|
||||
//! What this module deliberately does NOT do:
|
||||
//!
|
||||
//! * **3D / SSIF** — rejected up front ([`Error::DirImageSsifUnsupported`]).
|
||||
//! An SSIF aliases the same sectors as its base and dependent `.m2ts`; the
|
||||
//! planner allocates disjoint extents, so a 3D folder would produce silently
|
||||
//! wrong output.
|
||||
//! * **HD-DVD `HVDVD_TS/`** — no title enumerator constraint is modelled.
|
||||
//! * **Encrypted folders** — a folder whose content is still AACS-scrambled is
|
||||
//! rejected by the caller-side probe, not decrypted here.
|
||||
|
||||
mod encode;
|
||||
mod layout;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::io::file_sector_source::linux::drop_window;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::io::file_sector_source::macos::drop_window;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
use crate::io::file_sector_source::other::drop_window;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::io::file_sector_source::windows::drop_window;
|
||||
use crate::sector::SectorSource;
|
||||
use encode::{MetaSectors, SECTOR};
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// How many host files may be held open at once.
|
||||
///
|
||||
/// A Blu-ray `BDMV/` can exceed a thousand files while macOS `RLIMIT_NOFILE`
|
||||
/// defaults to 256, so "open every file up front" is not available. Reads are
|
||||
/// overwhelmingly sequential through one large stream file at a time, so a
|
||||
/// small LRU keeps the hit rate near 1 while bounding descriptors.
|
||||
const HANDLE_CACHE: usize = 16;
|
||||
|
||||
/// One file's bytes at one place in the image.
|
||||
#[derive(Debug, Clone)]
|
||||
struct DataRange {
|
||||
/// Absolute first block.
|
||||
start_lba: u32,
|
||||
/// Blocks covered (the last one may be partially used, and is zero-padded).
|
||||
sectors: u32,
|
||||
/// Index into [`DirImage::files`].
|
||||
file: usize,
|
||||
/// Byte offset within the file at which this range's bytes begin.
|
||||
offset: u64,
|
||||
/// Byte length of the range.
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
/// A file the image reads through.
|
||||
#[derive(Debug)]
|
||||
struct FileRef {
|
||||
host: PathBuf,
|
||||
disc_path: String,
|
||||
size: u64,
|
||||
/// Host mtime at plan time — see `layout::FileNode::mtime` for why size
|
||||
/// alone is not enough.
|
||||
mtime: Option<std::time::SystemTime>,
|
||||
}
|
||||
|
||||
/// A synthesized UDF disc image over a host directory.
|
||||
///
|
||||
/// Owns everything it reads through (`PathBuf`s and its own file handles), so
|
||||
/// it is `Send + 'static` and can be moved into `build_iso_pipeline`, which
|
||||
/// hands it to `PrefetchedSectorSource`'s producer thread.
|
||||
pub struct DirImage {
|
||||
meta: MetaSectors,
|
||||
/// Sorted by `start_lba`, non-overlapping.
|
||||
ranges: Vec<DataRange>,
|
||||
files: Vec<FileRef>,
|
||||
open: Vec<(usize, File)>,
|
||||
total_sectors: u32,
|
||||
volume_id: String,
|
||||
data_bytes: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DirImage {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DirImage")
|
||||
.field("volume_id", &self.volume_id)
|
||||
.field("total_sectors", &self.total_sectors)
|
||||
.field("files", &self.files.len())
|
||||
.field("meta_sectors", &self.meta.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl DirImage {
|
||||
/// Plan and encode an image over `root`.
|
||||
///
|
||||
/// Every error is decided here, at plan time, where it can name the file
|
||||
/// responsible — the read path is deliberately left with nothing to decide
|
||||
/// except "this file changed underneath me".
|
||||
pub fn open(root: &Path) -> Result<Self> {
|
||||
let plan = layout::plan(root)?;
|
||||
let meta = encode::encode(&plan)?;
|
||||
|
||||
let mut nodes = Vec::new();
|
||||
layout::flatten(&plan.root, &mut nodes);
|
||||
|
||||
let mut files = Vec::with_capacity(nodes.len());
|
||||
let mut ranges = Vec::new();
|
||||
for (idx, node) in nodes.iter().enumerate() {
|
||||
// Carry the plan-time mtime ONLY for files whose CONTENT the plan
|
||||
// read — the DVD IFOs, whose bytes 0xC0/0xC4 decide where every VOB
|
||||
// is placed (`layout::place_video_ts` -> `read_head`).
|
||||
//
|
||||
// For every other file the plan depends on the SIZE alone, and size
|
||||
// is already checked. Comparing mtime on those buys nothing and
|
||||
// costs real false positives: disc backups commonly live on
|
||||
// exFAT/FAT32, which stores local time, so a long rip spanning a
|
||||
// DST transition sees a whole-hour shift on a file nobody touched
|
||||
// and would abort hours in, blaming a change that did not happen.
|
||||
// The multi-gigabyte VOBs are exactly the files a long rip re-opens
|
||||
// after the handle cache evicts them.
|
||||
let content_sensitive = node
|
||||
.disc_path
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("IFO"));
|
||||
files.push(FileRef {
|
||||
host: node.host.clone(),
|
||||
disc_path: node.disc_path.clone(),
|
||||
size: node.size,
|
||||
mtime: content_sensitive.then_some(node.mtime).flatten(),
|
||||
});
|
||||
let mut offset = 0u64;
|
||||
for e in &node.extents {
|
||||
ranges.push(DataRange {
|
||||
start_lba: plan.part_start + e.lba,
|
||||
sectors: (e.bytes as u64).div_ceil(SECTOR as u64) as u32,
|
||||
file: idx,
|
||||
offset,
|
||||
bytes: e.bytes as u64,
|
||||
});
|
||||
offset += e.bytes as u64;
|
||||
}
|
||||
}
|
||||
ranges.sort_by_key(|r| r.start_lba);
|
||||
debug_assert!(
|
||||
ranges
|
||||
.windows(2)
|
||||
.all(|w| w[0].start_lba + w[0].sectors <= w[1].start_lba),
|
||||
"planned data ranges must not overlap"
|
||||
);
|
||||
|
||||
let data_bytes = layout::total_data_bytes(&plan.root);
|
||||
tracing::info!(
|
||||
target: "freemkv::dirimage",
|
||||
volume_id = %plan.volume_id,
|
||||
files = files.len(),
|
||||
dirs = plan.dir_count,
|
||||
meta_blocks = layout::metadata_block_count(&plan.root),
|
||||
total_sectors = plan.total_sectors,
|
||||
"synthesized UDF image over directory"
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
meta,
|
||||
ranges,
|
||||
files,
|
||||
open: Vec::new(),
|
||||
total_sectors: plan.total_sectors,
|
||||
volume_id: plan.volume_id,
|
||||
data_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// UDF volume identifier the image declares (the folder's own name).
|
||||
pub fn volume_id(&self) -> &str {
|
||||
&self.volume_id
|
||||
}
|
||||
|
||||
/// Total bytes of real file content the image carries — the folder's size,
|
||||
/// not the image's (which also counts metadata and inter-file gaps).
|
||||
pub fn data_bytes(&self) -> u64 {
|
||||
self.data_bytes
|
||||
}
|
||||
|
||||
/// The range covering `lba`, if any.
|
||||
fn range_at(&self, lba: u32) -> Option<&DataRange> {
|
||||
let i = self.ranges.partition_point(|r| r.start_lba <= lba);
|
||||
let r = self.ranges.get(i.checked_sub(1)?)?;
|
||||
(lba < r.start_lba + r.sectors).then_some(r)
|
||||
}
|
||||
|
||||
/// Borrow an open handle for `file`, opening it (and evicting the
|
||||
/// least-recently-used handle) if necessary.
|
||||
///
|
||||
/// Opening is also where the plan is revalidated. A folder is not a disc:
|
||||
/// a file can be shortened or replaced between planning and reading, and
|
||||
/// zero-filling the difference would turn "the user deleted something"
|
||||
/// into corrupt output at exit 0. The size is re-checked here, and a
|
||||
/// truncation that happens while the handle is already open is caught by
|
||||
/// the short read in [`Self::fill`].
|
||||
fn handle(&mut self, file: usize) -> Result<&mut File> {
|
||||
if let Some(pos) = self.open.iter().position(|(i, _)| *i == file) {
|
||||
// `open` is ordered most-recently-used first.
|
||||
let entry = self.open.remove(pos);
|
||||
self.open.insert(0, entry);
|
||||
return Ok(&mut self.open[0].1);
|
||||
}
|
||||
let f = File::open(&self.files[file].host).map_err(Error::from)?;
|
||||
let md = f.metadata().map_err(Error::from)?;
|
||||
// Size AND mtime. Size alone is content-blind, and this plan depends on
|
||||
// content: a DVD's VOB placement comes from bytes 0xC0/0xC4 of its IFO,
|
||||
// and an IFO rewritten in place keeps its length because IFOs occupy a
|
||||
// whole number of sectors. The size check would pass while every title
|
||||
// extent pointed at the wrong sectors — corrupt video behind an intact
|
||||
// structure, reported complete at exit 0.
|
||||
//
|
||||
// Only compared when both sides have a timestamp; a platform or
|
||||
// filesystem that reports none simply falls back to the size check
|
||||
// rather than failing every read.
|
||||
let changed_size = md.len() != self.files[file].size;
|
||||
let changed_mtime = match (self.files[file].mtime, md.modified().ok()) {
|
||||
(Some(planned), Some(live)) => planned != live,
|
||||
_ => false,
|
||||
};
|
||||
if changed_size || changed_mtime {
|
||||
return Err(Error::DirImageFileChanged {
|
||||
path: self.files[file].disc_path.clone(),
|
||||
});
|
||||
}
|
||||
if self.open.len() >= HANDLE_CACHE {
|
||||
self.open.pop();
|
||||
}
|
||||
self.open.insert(0, (file, f));
|
||||
Ok(&mut self.open[0].1)
|
||||
}
|
||||
|
||||
/// Fill `out` (a whole number of sectors) from one data range, starting at
|
||||
/// `lba`. `out` is already zeroed, so a file's tail sector comes back
|
||||
/// zero-padded — which is exactly what `file_extents`' `div_ceil(2048)`
|
||||
/// (`udf.rs:816`) makes every consumer expect.
|
||||
fn fill(&mut self, r: &DataRange, lba: u32, out: &mut [u8]) -> Result<()> {
|
||||
let within = (lba - r.start_lba) as u64 * SECTOR as u64;
|
||||
let want = (r.bytes.saturating_sub(within)).min(out.len() as u64) as usize;
|
||||
if want == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let at = r.offset + within;
|
||||
let file = r.file;
|
||||
let h = self.handle(file)?;
|
||||
h.seek(SeekFrom::Start(at)).map_err(Error::from)?;
|
||||
let res = h.read_exact(&mut out[..want]);
|
||||
if res.is_ok() {
|
||||
// Release the window just read, every time.
|
||||
//
|
||||
// The ISO source accumulates and drops in chunks because it reads
|
||||
// one file linearly, so a running start offset always names the
|
||||
// bytes it has consumed. Reads here jump between files, so there is
|
||||
// no single cursor to accumulate against — an accumulated byte
|
||||
// count paired with one read's offset names 1/Nth of what was
|
||||
// actually consumed and leaves the rest pinned, which is how the
|
||||
// first version of this got it wrong.
|
||||
//
|
||||
// Dropping per read costs one advisory syscall per batch (4-16 MiB),
|
||||
// which is nothing against the read itself, and it is correct
|
||||
// regardless of how reads interleave across files.
|
||||
if let Some((_, fh)) = self.open.iter().find(|(i, _)| *i == file) {
|
||||
drop_window(fh, at, want as u64);
|
||||
}
|
||||
}
|
||||
match res {
|
||||
Ok(()) => Ok(()),
|
||||
// The file shrank while the handle was open. Same verdict as the
|
||||
// size check in `handle`, reached the other way.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
Err(Error::DirImageFileChanged {
|
||||
path: self.files[file].disc_path.clone(),
|
||||
})
|
||||
}
|
||||
Err(e) => Err(Error::from(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorSource for DirImage {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.total_sectors
|
||||
}
|
||||
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
let need = count as usize * SECTOR;
|
||||
if buf.len() < need {
|
||||
return Err(Error::UdfBufferTooSmall);
|
||||
}
|
||||
buf[..need].fill(0);
|
||||
// Walk the request in RUNS, not sector by sector. A mux batch is 8192
|
||||
// sectors and almost always lands entirely inside one stream file's
|
||||
// extent; per-sector seek+read would issue 8192 syscalls for what is
|
||||
// one 16 MiB sequential read.
|
||||
let mut i = 0u32;
|
||||
while i < count as u32 {
|
||||
// Checked: callers saturate their LBAs (`disc/dvd.rs` builds a cell
|
||||
// start as `vob_start_sector.saturating_add(cell.first_sector)`, and
|
||||
// the prefetcher adds an offset the same way), so a crafted IFO can
|
||||
// present a request at the very top of the address space. Wrapping
|
||||
// here would fold `at` back to a LOW sector and hand the muxer a
|
||||
// different file's bytes with nothing reported.
|
||||
let Some(at) = lba.checked_add(i) else {
|
||||
break;
|
||||
};
|
||||
let off = i as usize * SECTOR;
|
||||
if let Some(s) = self.meta.get(&at) {
|
||||
buf[off..off + SECTOR].copy_from_slice(&s[..]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Metadata blocks all sit below the data floor, so a data range is
|
||||
// never interrupted by one.
|
||||
match self.range_at(at).cloned() {
|
||||
Some(r) => {
|
||||
let run = (r.start_lba + r.sectors - at).min(count as u32 - i);
|
||||
let end = off + run as usize * SECTOR;
|
||||
self.fill(&r, at, &mut buf[off..end])?;
|
||||
i += run;
|
||||
}
|
||||
// A gap between planned extents. Reads as zeros, exactly as an
|
||||
// unrecorded sector of a real image does.
|
||||
None => i += 1,
|
||||
}
|
||||
}
|
||||
Ok(need)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
+1380
-123
File diff suppressed because it is too large
Load Diff
+350
-26
@@ -7,15 +7,50 @@ use crate::udf;
|
||||
|
||||
impl Disc {
|
||||
/// Scan DVD titles from IFO files (VIDEO_TS.IFO + VTS_XX_0.IFO).
|
||||
///
|
||||
/// Cancellation: `halt` is polled before the IFO tree is read, and an IFO
|
||||
/// read that fails with [`Error::Halted`] — how a live drive reports a
|
||||
/// Stop, since `Drive::checked_exec` fails EVERY command once its flag is
|
||||
/// set — is propagated rather than swallowed. Every other IFO failure
|
||||
/// keeps its best-effort `Ok(vec![])`.
|
||||
///
|
||||
/// It has to be an error and not an empty title list, for the same reason
|
||||
/// spelled out on [`Disc::scan_hddvd_titles`]: a cancelled enumeration
|
||||
/// that returned `Ok` would be indistinguishable from a disc that
|
||||
/// genuinely holds fewer titles. This one was the worst of the three
|
||||
/// enumerators — a bare `Err(_) => return Vec::new()` turned an operator
|
||||
/// Stop into ZERO titles at rc=0, a disc reported as carrying no video at
|
||||
/// all.
|
||||
///
|
||||
/// Returns the scanned titles plus, when the disc's own First-Play
|
||||
/// navigation deterministically dispatches to a title, that title's
|
||||
/// `playlist_id` — the nav-resolved main feature. The caller feeds it into
|
||||
/// `sort_titles_by_main_feature` as the DVD `nav_feature`, the same seam the
|
||||
/// BD path uses. It is `None` (→ existing ranking heuristics stand) whenever
|
||||
/// the navigation is menu-only, malformed, or does not converge.
|
||||
pub(super) fn scan_dvd_titles(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
) -> Vec<DiscTitle> {
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> Result<(Vec<DiscTitle>, Option<u16>)> {
|
||||
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
let dvd_info = match ifo::parse_vmg(reader, udf_fs) {
|
||||
Ok(info) => info,
|
||||
Err(_) => return Vec::new(),
|
||||
Err(Error::Halted) => return Err(Error::Halted),
|
||||
Err(_) => return Ok((Vec::new(), None)),
|
||||
};
|
||||
|
||||
// Follow the disc's First-Play navigation the way a player would, to see
|
||||
// which title it selects as the feature (issue #40). Read-only, and any
|
||||
// parse error / interactive-menu entry / non-convergence returns `None`
|
||||
// — a strict improvement: it never removes a title, only re-ranks toward
|
||||
// the nav-chosen one. The `(vtsn, vts_ttn)` it yields is matched to the
|
||||
// scanned title below to recover its `playlist_id`.
|
||||
let nav_target = crate::dvdnav::resolve_main_title(reader, udf_fs);
|
||||
let mut nav_feature: Option<u16> = None;
|
||||
|
||||
let mut titles = Vec::new();
|
||||
let mut title_number: u16 = 0;
|
||||
|
||||
@@ -109,6 +144,17 @@ impl Disc {
|
||||
for (vts_title_idx, dvd_title) in ts.titles.iter().enumerate() {
|
||||
title_number += 1;
|
||||
|
||||
// If the nav executor picked this title (identified by its VTS
|
||||
// number and 1-based position within the set — the same
|
||||
// `vts_ttn` convention used below), remember its global
|
||||
// `playlist_id` so the caller can promote it in ranking.
|
||||
if let Some(rt) = nav_target
|
||||
&& rt.vtsn == ts.vts_number
|
||||
&& rt.vts_ttn as usize == vts_title_idx + 1
|
||||
{
|
||||
nav_feature = Some(title_number);
|
||||
}
|
||||
|
||||
// Diagnostic dump (--log-level 3): per-cell category table +
|
||||
// chapter map for this title, BEFORE lowering drops the
|
||||
// per-cell IFO detail. No-op unless freemkv::diag is enabled.
|
||||
@@ -116,7 +162,7 @@ impl Disc {
|
||||
|
||||
// Feature start cell. Prefer the DVD nav-VM resolver, which
|
||||
// PARKED (#40, menu-at-start playback). The "menu at the start"
|
||||
// symptom (e.g. SOTL) was a sector-mapping fault — the absolute
|
||||
// symptom (seen on a real disc) was a sector-mapping fault — the absolute
|
||||
// VOB rebase in `ifo::parse_vts` (`vob_start_sector =
|
||||
// file_start_lba + vtstt_vobs`) — NOT a navigation problem, so
|
||||
// feature-start resolution is unnecessary for correct rips. The
|
||||
@@ -179,7 +225,10 @@ impl Disc {
|
||||
// the coded video frame the subpicture was authored against
|
||||
// (720x480 NTSC / 720x576 PAL) so players place and scale the
|
||||
// bitmap correctly.
|
||||
let (vid_w, vid_h) = ts.video.resolution.pixels();
|
||||
// format_palette guards on (0, 0) and omits its `size:` line,
|
||||
// so an unresolved resolution degrades to a palette-only .idx
|
||||
// rather than one claiming a 0x0 frame.
|
||||
let (vid_w, vid_h) = ts.video.resolution.pixels().unwrap_or((0, 0));
|
||||
let codec_data = dvd_title
|
||||
.palette
|
||||
.as_ref()
|
||||
@@ -240,7 +289,14 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
titles
|
||||
// Polled again AFTER the loop: a cancel raised during the IFO reads
|
||||
// that `parse_vmg` performs per title set has nothing left to poll,
|
||||
// so without this a partially enumerated disc could still be handed
|
||||
// back as success.
|
||||
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
Ok((titles, nav_feature))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,6 +581,119 @@ mod tests {
|
||||
// Tests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// A `SectorSource` that fails every read at or above `halt_at` with
|
||||
/// [`Error::Halted`] — how a LIVE DRIVE behaves once the operator presses
|
||||
/// Stop: `Drive::checked_exec` fails every SCSI command with `Halted` from
|
||||
/// then on, and `Drive::read` deliberately preserves the variant. Reads
|
||||
/// below the threshold still succeed, so the scan gets far enough to have
|
||||
/// something to truncate.
|
||||
struct HaltingReader<'a> {
|
||||
inner: &'a mut MemDisc,
|
||||
halt_at: u32,
|
||||
}
|
||||
impl SectorSource for HaltingReader<'_> {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
if lba >= self.halt_at {
|
||||
return Err(crate::error::Error::Halted);
|
||||
}
|
||||
self.inner.read_sectors(lba, count, buf, recovery)
|
||||
}
|
||||
}
|
||||
|
||||
/// A Stop on a LIVE DRIVE never touches `ScanOptions::halt`: `Drive` has
|
||||
/// its own flag and `checked_exec` fails every SCSI command with
|
||||
/// [`Error::Halted`] once it is set. The DVD enumerator must not swallow
|
||||
/// that into a successful scan.
|
||||
///
|
||||
/// This was the worst of the three enumerators. RED BEFORE GREEN, two
|
||||
/// distinct swallows, both measured with the fix reverted:
|
||||
/// * `ifo::parse_vmg` treats a failed title set as a placeholder entry
|
||||
/// and continues, so a cancel landing on VTS_02's IFO returned
|
||||
/// `Ok([VTS_01_1.VOB])` — one title from a two-title disc.
|
||||
/// * `scan_dvd_titles`'s `Err(_) => return Vec::new()` turned a cancel
|
||||
/// landing on VIDEO_TS.IFO itself into ZERO titles at rc=0 — a disc
|
||||
/// reported as holding no video at all.
|
||||
///
|
||||
/// Both are indistinguishable from a real disc, and both are now
|
||||
/// `Err(Error::Halted)`.
|
||||
#[test]
|
||||
fn halted_ifo_read_is_not_reported_as_a_shorter_disc() {
|
||||
// Two title sets: VTS_01's IFO data at PART_START+6000, VTS_02's at
|
||||
// PART_START+7000. Both ICBs sit far below, so the filesystem
|
||||
// metadata resolves and only the second title set's CONTENT is
|
||||
// cancelled — the truncation case.
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(1, 1, 1), (1, 2, 1)]);
|
||||
let vts1 = build_vts(100, 0x00, &[], &[], &[(0, 9)], false);
|
||||
let vts2 = build_vts(200, 0x00, &[], &[], &[(0, 19)], false);
|
||||
let udf = build_video_ts_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
FileSpec {
|
||||
name: "VIDEO_TS.IFO".into(),
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vmg,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_01_0.IFO".into(),
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: vts1,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_02_0.IFO".into(),
|
||||
icb_lba: 64,
|
||||
data_lba: 7000,
|
||||
contents: vts2,
|
||||
},
|
||||
],
|
||||
);
|
||||
// Sanity: both title sets enumerate when nothing is cancelled, so a
|
||||
// short list below can only be the cancel.
|
||||
assert_eq!(
|
||||
Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0
|
||||
.len(),
|
||||
2,
|
||||
"fixture must offer two title sets"
|
||||
);
|
||||
|
||||
let mut reader = HaltingReader {
|
||||
inner: &mut disc,
|
||||
halt_at: PART_START + 7000, // VTS_02_0.IFO's data extent
|
||||
};
|
||||
let res = Disc::scan_dvd_titles(&mut reader, &udf, None);
|
||||
assert!(
|
||||
matches!(res, Err(crate::error::Error::Halted)),
|
||||
"a cancelled title-set read must surface as a cancelled scan, not \
|
||||
as a disc with fewer titles; got {:?}",
|
||||
res.map(|(ts, _)| ts.iter().map(|t| t.playlist.clone()).collect::<Vec<_>>())
|
||||
);
|
||||
|
||||
// The same cancel one level up: VIDEO_TS.IFO itself. This is the
|
||||
// `Err(_) => Vec::new()` path — a cancel that used to report a DVD as
|
||||
// carrying no titles whatsoever.
|
||||
let mut reader = HaltingReader {
|
||||
inner: &mut disc,
|
||||
halt_at: PART_START + 5000,
|
||||
};
|
||||
let res = Disc::scan_dvd_titles(&mut reader, &udf, None);
|
||||
assert!(
|
||||
matches!(res, Err(crate::error::Error::Halted)),
|
||||
"a cancelled VMG read must surface as a cancelled scan, not as an \
|
||||
empty disc; got {:?}",
|
||||
res.map(|(ts, _)| ts.len())
|
||||
);
|
||||
}
|
||||
|
||||
/// scan_dvd_titles returns empty when VIDEO_TS.IFO can't be parsed
|
||||
/// (dvd.rs: `parse_vmg(...) Err → return Vec::new()`). Never panics.
|
||||
#[test]
|
||||
@@ -532,7 +701,12 @@ mod tests {
|
||||
let mut disc = MemDisc::new();
|
||||
// VIDEO_TS exists but VIDEO_TS.IFO is missing.
|
||||
let udf = build_video_ts_fs(&mut disc, &[]);
|
||||
assert!(Disc::scan_dvd_titles(&mut disc, &udf).is_empty());
|
||||
assert!(
|
||||
Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
/// Single VTS, single title, one cell. Extent absolute LBA =
|
||||
@@ -568,7 +742,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0;
|
||||
assert_eq!(titles.len(), 1);
|
||||
let t = &titles[0];
|
||||
assert_eq!(t.extents.len(), 1);
|
||||
@@ -594,7 +770,7 @@ mod tests {
|
||||
/// before the title VOB. Cell `first_sector` values are relative to
|
||||
/// `vtstt_vobs`; reading 0xC0 prepended the menu and shifted every extent
|
||||
/// back by `vtstt_vobs - vtsm_vobs`, so the rip opened on the parental
|
||||
/// prompt instead of the movie (Greenland NTSC R1: vtsm=44, vtstt=3640).
|
||||
/// prompt instead of the movie (one real NTSC R1 disc: vtsm=44, vtstt=3640).
|
||||
///
|
||||
/// Here `build_vts` stamps `vtstt_vobs = 3640` (0xC4); we additionally stamp
|
||||
/// a *different* `vtsm_vobs = 44` (0xC0). The extent must resolve from 3640.
|
||||
@@ -624,7 +800,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0;
|
||||
assert_eq!(titles.len(), 1);
|
||||
let t = &titles[0];
|
||||
assert_eq!(t.extents.len(), 1);
|
||||
@@ -644,7 +822,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// ABSOLUTE-REBASE regression (THESILENCEOFTHELAMBS / Greenland fix):
|
||||
/// ABSOLUTE-REBASE regression (real-disc menu-at-start fix):
|
||||
/// `ifo::parse_vts` now sets `vob_start_sector = file_start_lba(IFO) +
|
||||
/// vtstt_vobs`, so an extent's `start_lba` must equal the sum of THREE
|
||||
/// independent terms — the IFO file's absolute on-disc LBA, the
|
||||
@@ -682,7 +860,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
assert_eq!(t.extents.len(), 1);
|
||||
let got = t.extents[0].start_lba;
|
||||
// The one correct answer: all three terms summed (9000 + 700 + 33).
|
||||
@@ -740,7 +920,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
assert_eq!(t.extents.len(), 2);
|
||||
assert_eq!(t.extents[0].start_lba, 9500); // ifo_lba(9000) + 500 + 0
|
||||
assert_eq!(t.extents[0].sector_count, 100);
|
||||
@@ -781,7 +963,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
let v = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -836,7 +1020,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
let v = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -897,7 +1083,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
let audios: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -908,7 +1096,7 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(audios.len(), 2);
|
||||
assert_eq!(audios[0].codec, Codec::Ac3);
|
||||
assert_eq!(audios[0].language, "en");
|
||||
assert_eq!(audios[0].language, "eng");
|
||||
assert_eq!(audios[1].codec, Codec::Dts);
|
||||
// Real channel layouts survive the scan (not a 1ch placeholder): the
|
||||
// AC-3 is 5.1 (6ch), the DTS is 2.0 (2ch).
|
||||
@@ -967,7 +1155,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
let audios: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1024,7 +1214,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
let subs: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1037,7 +1229,7 @@ mod tests {
|
||||
// Languages preserved in order.
|
||||
assert_eq!(
|
||||
subs.iter().map(|s| s.language.as_str()).collect::<Vec<_>>(),
|
||||
vec!["en", "fr", "de"]
|
||||
vec!["eng", "fra", "deu"]
|
||||
);
|
||||
// PIDs are 0x20 + ordinal, all distinct.
|
||||
let pids: Vec<u16> = subs.iter().map(|s| s.pid).collect();
|
||||
@@ -1084,7 +1276,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
let sub = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1094,7 +1288,7 @@ mod tests {
|
||||
})
|
||||
.expect("subtitle stream");
|
||||
assert_eq!(sub.codec, Codec::DvdSub);
|
||||
assert_eq!(sub.language, "en");
|
||||
assert_eq!(sub.language, "eng");
|
||||
assert!(
|
||||
sub.codec_data.is_some(),
|
||||
"non-zero palette must yield codec_data"
|
||||
@@ -1138,7 +1332,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0;
|
||||
assert_eq!(titles.len(), 2);
|
||||
// title_number is a running counter across all title sets.
|
||||
assert_eq!(titles[0].playlist_id, 1);
|
||||
@@ -1151,6 +1347,79 @@ mod tests {
|
||||
assert_eq!(titles[1].extents[0].start_lba, 10200); // 10000 + 200
|
||||
}
|
||||
|
||||
/// Stamp a First-Play PGC into a VMG whose pre-command list is a single
|
||||
/// unconditional `JumpTT ttn`, so `dvdnav::resolve_main_title` resolves that
|
||||
/// title through TT_SRPT. Placed in sector 0, clear of the magic / FP_PGC /
|
||||
/// TT_SRPT pointers `build_vmg` already writes.
|
||||
fn stamp_first_play_jumptt(vmg: &mut [u8], ttn: u8) {
|
||||
const FP_PGC_PTR: usize = 0x84; // u32 byte offset of the First-Play PGC
|
||||
const CMD_TBL_PTR: usize = 0xE4; // u16 cmd-table offset (rel to PGC)
|
||||
let fp_pgc: u32 = 0x200;
|
||||
let cmd_tbl_rel: u16 = 0x100;
|
||||
vmg[FP_PGC_PTR..FP_PGC_PTR + 4].copy_from_slice(&fp_pgc.to_be_bytes());
|
||||
let pgc = fp_pgc as usize;
|
||||
vmg[pgc + CMD_TBL_PTR..pgc + CMD_TBL_PTR + 2].copy_from_slice(&cmd_tbl_rel.to_be_bytes());
|
||||
let tbl = pgc + cmd_tbl_rel as usize;
|
||||
vmg[tbl..tbl + 2].copy_from_slice(&1u16.to_be_bytes()); // nr_of_pre = 1
|
||||
// JumpTT command (type 1, direct=1, cmd=2): ttn in byte 5.
|
||||
vmg[tbl + 8..tbl + 16].copy_from_slice(&[0x30, 0x02, 0, 0, 0, ttn, 0, 0]);
|
||||
}
|
||||
|
||||
/// The DVD First-Play nav promotion branch (issue #40): when
|
||||
/// `resolve_main_title` returns a `(vtsn, vts_ttn)` target, `scan_dvd_titles`
|
||||
/// must map it — via the 1-based `vts_ttn == vts_title_idx + 1` join within
|
||||
/// the matching `vts_number` — to the running `title_number`, and surface it
|
||||
/// as the `nav_feature`. TT_SRPT here maps title 2 → (VTS 2, title-in-set 1);
|
||||
/// the First-Play unconditionally `JumpTT 2`, so the promoted feature must be
|
||||
/// the SECOND scanned title (VTS_02 → title_number 2). An off-by-one on the
|
||||
/// join or a vtsn/vts_ttn field swap would promote the wrong title or none.
|
||||
#[test]
|
||||
fn scan_dvd_titles_nav_promotes_first_play_target() {
|
||||
let mut disc = MemDisc::new();
|
||||
// TT_SRPT: title 1 → VTS 1 (in-set title 1); title 2 → VTS 2 (in-set 1).
|
||||
let mut vmg = build_vmg(&[(1, 1, 1), (1, 2, 1)]);
|
||||
stamp_first_play_jumptt(&mut vmg, 2); // First-Play → JumpTT title 2
|
||||
let vts1 = build_vts(100, 0x00, &[], &[], &[(0, 9)], false);
|
||||
let vts2 = build_vts(200, 0x00, &[], &[], &[(0, 19)], false);
|
||||
let udf = build_video_ts_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
FileSpec {
|
||||
name: "VIDEO_TS.IFO".into(),
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vmg,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_01_0.IFO".into(),
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: vts1,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_02_0.IFO".into(),
|
||||
icb_lba: 64,
|
||||
data_lba: 7000,
|
||||
contents: vts2,
|
||||
},
|
||||
],
|
||||
);
|
||||
let (titles, nav_feature) = Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan");
|
||||
assert_eq!(titles.len(), 2);
|
||||
// Sanity: the resolver reached the branch with the VTS-2 target.
|
||||
assert_eq!(
|
||||
crate::dvdnav::resolve_main_title(&mut disc, &udf),
|
||||
Some(crate::dvdnav::nav::ResolvedTitle {
|
||||
title: 2,
|
||||
vtsn: 2,
|
||||
vts_ttn: 1,
|
||||
})
|
||||
);
|
||||
// The promotion mapped that target to the second scanned title.
|
||||
assert_eq!(nav_feature, Some(2));
|
||||
assert_eq!(titles[1].playlist_id, 2);
|
||||
}
|
||||
|
||||
/// chapter_times from the IFO become Chapter entries with ordinal
|
||||
/// names (dvd.rs maps chapter_times → Chapter{time_secs, chapter_name}).
|
||||
#[test]
|
||||
@@ -1175,7 +1444,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
// One program in the program map → one chapter time (0.0 for the
|
||||
// first program). Name is the ordinal from chapter_name(0).
|
||||
assert_eq!(t.chapters.len(), 1);
|
||||
@@ -1260,7 +1531,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
// The leading 0x90 cell is dropped: 2 feature extents, not 3.
|
||||
assert_eq!(t.extents.len(), 2, "leading angle sub-block cell dropped");
|
||||
// First extent starts at the feature cell (vob 1000 + 100), not at 1000+0.
|
||||
@@ -1284,7 +1557,7 @@ mod tests {
|
||||
|
||||
/// Conservative guard end-to-end: a normal feature (every cell category
|
||||
/// 0x00) is muxed in full — the filter drops nothing and chapters are
|
||||
/// unshifted. This is the "Silence of the Lambs" case.
|
||||
/// unshifted. This is the plain-single-angle-feature case.
|
||||
#[test]
|
||||
fn scan_dvd_titles_plain_feature_untouched() {
|
||||
let mut disc = MemDisc::new();
|
||||
@@ -1312,7 +1585,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
// Nothing dropped: both cells become extents, starting at the very head.
|
||||
assert_eq!(t.extents.len(), 2);
|
||||
assert_eq!(t.extents[0].start_lba, 9000 + 1000); // ifo_lba + vtstt + 0, head intact
|
||||
@@ -1320,4 +1595,53 @@ mod tests {
|
||||
// Chapter 0 stays at 0.0 (no shift).
|
||||
assert!((t.chapters[0].time_secs - 0.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// Audio PID fallback (dvd.rs `Disc::scan_dvd_titles`): when an audio
|
||||
/// stream has no on-wire private_stream_1 sub-stream id — MP1/MP2 audio,
|
||||
/// per `ifo::assign_audio_sub_stream_ids` — the PID falls back to
|
||||
/// `0xBD00 + i` where `i` is the stream's positional index in the IFO
|
||||
/// audio-attribute table. Two MPEG-audio (coding_mode 2) streams must
|
||||
/// land on two DISTINCT, correctly-offset PIDs: 0xBD00 and 0xBD01. This
|
||||
/// pins the `+` (not `-`/`*`) so the second stream doesn't collide with,
|
||||
/// or wrap under, the first.
|
||||
#[test]
|
||||
fn scan_dvd_titles_mp2_audio_pid_fallback_is_additive() {
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(1, 1, 1)]);
|
||||
// coding_mode bits are b0>>5 & 0x7; mode 2 = MPEG-1 Layer II (Mp2),
|
||||
// which `assign_audio_sub_stream_ids` leaves at `sub_stream_id: None`.
|
||||
// b0 = 0b010_00000 = 0x40. b1 = 0 (mono, sample rate 48k).
|
||||
let audio = [(0x40u8, 0x00u8, [0u8, 0u8]), (0x40u8, 0x00u8, [0u8, 0u8])];
|
||||
let vts = build_vts(1000, 0x00, &audio, &[], &[(10, 109)], false);
|
||||
let udf = build_video_ts_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
FileSpec {
|
||||
name: "VIDEO_TS.IFO".into(),
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vmg,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_01_0.IFO".into(),
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: vts,
|
||||
},
|
||||
],
|
||||
);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0;
|
||||
let t = &titles[0];
|
||||
let audio_pids: Vec<u16> = t
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Stream::Audio(a) => Some(a.pid),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(audio_pids, vec![0xBD00u16, 0xBD01u16]);
|
||||
}
|
||||
}
|
||||
|
||||
+178
-10
@@ -1,6 +1,6 @@
|
||||
//! Physical AC-3 sub-stream probing for DVD audio routing.
|
||||
//!
|
||||
//! ## Why this exists (Silence-of-the-Lambs wrong-substream bug)
|
||||
//! ## Why this exists (a real-disc wrong-substream bug)
|
||||
//!
|
||||
//! A DVD VTS IFO declares its audio streams in a fixed table, and freemkv's
|
||||
//! scan assigns each declared stream a `private_stream_1` sub-stream id purely
|
||||
@@ -8,7 +8,7 @@
|
||||
//! `0x81`, and so on (`ifo::assign_audio_sub_stream_ids`). That assumes the
|
||||
//! physical sub-stream order on the wire matches the IFO declaration order.
|
||||
//!
|
||||
//! On some discs it does NOT. The R2 PAL "The Silence of the Lambs" feature
|
||||
//! On some discs it does NOT. A real R2 PAL DVD release's feature
|
||||
//! declares ONE AC-3 audio stream the IFO nibble marks as 5.1 (6 channels), but
|
||||
//! the physical VOB carries the 5.1 main mix and a 2.0 down-mix on DIFFERENT
|
||||
//! `0x8x` sub-stream ids, and the 2.0 is the one that happens to land at the
|
||||
@@ -34,7 +34,7 @@ use std::collections::BTreeMap;
|
||||
/// a DVD feature opens with logos/warnings whose audio is frequently a thin 2.0
|
||||
/// bed on the FIRST sub-stream only — the other physical `0x8x` sub-streams and
|
||||
/// the main 5.1 mix do not appear until a sector or two further in. 512 sectors
|
||||
/// (1 MiB) was too short: on Greenland it saw ONLY `0x80`, and only its opening
|
||||
/// (1 MiB) was too short: on one real disc it saw ONLY `0x80`, and only its opening
|
||||
/// 2.0 frames. 1024 sectors (2 MiB) reliably contains at least one frame of
|
||||
/// every physical AC-3 sub-stream AND enough of `0x80` to reach its 5.1 frames.
|
||||
/// Still bounded so a live drive is never hammered (see the project "don't
|
||||
@@ -54,7 +54,7 @@ const PROBE_SECTORS: u16 = 1024;
|
||||
/// The first frame of a sub-stream at the head of a feature is NOT
|
||||
/// representative. A DVD opens with logos/warnings, and the main `0x80`
|
||||
/// sub-stream there frequently carries a thin 2.0 bed before transitioning to
|
||||
/// its real 5.1 main mix a fraction of a second later (observed on Greenland:
|
||||
/// its real 5.1 main mix a fraction of a second later (observed on a real disc:
|
||||
/// `0x80`'s first frames are acmod=2 → 2 channels, then it becomes acmod=7+lfe →
|
||||
/// 6 channels within the same 2 MiB window). Recording only the FIRST frame read
|
||||
/// `0x80=2` and missed the 5.1 entirely, defeating the channel-match routing.
|
||||
@@ -104,10 +104,10 @@ fn max_substream_channels(data: &[u8]) -> Option<u8> {
|
||||
};
|
||||
let start = pos + rel;
|
||||
let frame = &data[start..];
|
||||
if let Some(ch) = ac3::acmod_channels(frame) {
|
||||
if ch > 0 {
|
||||
best = Some(best.map_or(ch, |b| b.max(ch)));
|
||||
}
|
||||
if let Some(ch) = ac3::acmod_channels(frame)
|
||||
&& ch > 0
|
||||
{
|
||||
best = Some(best.map_or(ch, |b| b.max(ch)));
|
||||
}
|
||||
// Advance past this frame by its declared size when that is mappable;
|
||||
// otherwise step 2 bytes past the sync and re-scan for the next one.
|
||||
@@ -242,7 +242,11 @@ pub fn probe_and_remap<S: SectorSource + ?Sized>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::{AudioChannels, AudioStream, Codec, LabelPurpose, SampleRate};
|
||||
use crate::disc::{
|
||||
AudioChannels, AudioStream, Codec, ContentFormat, DiscTitle, Extent, LabelPurpose,
|
||||
SampleRate,
|
||||
};
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
/// Build a single, correctly-SIZED AC-3 frame whose `acmod`/`lfeon` encode a
|
||||
/// known channel count. `byte4` is `fscod=0 | frmsizecod=0`, so
|
||||
@@ -343,7 +347,7 @@ mod tests {
|
||||
assert_eq!(probed.get(&0x81), Some(&6), "0x81 is the 5.1 main mix");
|
||||
}
|
||||
|
||||
/// GREENLAND regression — the probe must read each sub-stream's TRUE
|
||||
/// Real-disc regression — the probe must read each sub-stream's TRUE
|
||||
/// (max-mix) channel count, not be poisoned by an unrepresentative head
|
||||
/// frame, and must NOT cross-contaminate between sub-streams.
|
||||
///
|
||||
@@ -463,4 +467,168 @@ mod tests {
|
||||
};
|
||||
assert_eq!(a.pid, 0xBD80, "no probe data → keep ordinal");
|
||||
}
|
||||
|
||||
/// `max_substream_channels` must locate the sync at its true ABSOLUTE
|
||||
/// position (`pos + rel`) when it is preceded by non-sync bytes, not just
|
||||
/// when the sync sits at offset 0. Regression guard for a hand-checked
|
||||
/// mutation (`+` → `-` at the `pos + rel` offset computation): with `pos`
|
||||
/// starting at 0 and the first sync found 3 bytes in, `pos - rel` would
|
||||
/// underflow a `usize` and panic, or (if it somehow didn't) index the
|
||||
/// wrong start entirely. `pos + rel` is the only computation that is
|
||||
/// always in-bounds, since `rel` is itself bounded by the length of the
|
||||
/// slice searched from `pos`.
|
||||
#[test]
|
||||
fn max_substream_channels_locates_sync_after_leading_non_sync_bytes() {
|
||||
let mut data = vec![0xAA, 0xAA, 0xAA]; // no 0x0B77 pattern in here
|
||||
data.extend(ac3_frame(2, false)); // real 2.0 frame, sync at absolute offset 3
|
||||
assert_eq!(
|
||||
max_substream_channels(&data),
|
||||
Some(2),
|
||||
"must find and decode the frame whose sync is NOT at offset 0"
|
||||
);
|
||||
}
|
||||
|
||||
/// When an AC-3 header's `fscod`/`frmsizecod` is unmappable (reserved
|
||||
/// `fscod == 3`), `max_substream_channels` must fall back to stepping
|
||||
/// `start + 2` bytes past the sync to re-lock onto the next genuine sync,
|
||||
/// and must keep making forward progress doing so (never revisit the same
|
||||
/// sync, which would loop forever, and never jump so far that it skips
|
||||
/// the very next real frame). This lays a bogus-sized header at absolute
|
||||
/// offset 4 (so `start == 4`, `start + 2 == 6`) immediately followed, at
|
||||
/// offset 6, by a real, fully decodable 2.0 frame — the position the
|
||||
/// `+ 2` fallback must land on exactly.
|
||||
#[test]
|
||||
fn max_substream_channels_unmappable_size_steps_forward_by_two() {
|
||||
let mut real = ac3_frame(2, false);
|
||||
// Overwrite the (unchecked) CRC bytes of the real frame — these double
|
||||
// as byte4/byte5 of the bogus header 2 bytes earlier, at absolute
|
||||
// offset 4: byte4 = 0xC0 (fscod=3 reserved -> ac3_frame_size == 0,
|
||||
// unmappable), byte5 = 0xF8 (bsid=31 >= 11 -> acmod_channels == None,
|
||||
// so the bogus header itself never contributes a spurious channel
|
||||
// count).
|
||||
real[2] = 0xC0;
|
||||
real[3] = 0xF8;
|
||||
let mut data = vec![0xAA, 0xAA, 0xAA, 0xAA]; // offsets 0..4, no sync
|
||||
data.push(0x0B); // offset 4: bogus header sync byte 0
|
||||
data.push(0x77); // offset 5: bogus header sync byte 1
|
||||
data.extend(real); // offset 6..: the real frame (also serves as the
|
||||
// bogus header's byte4/byte5 at offsets 8/9)
|
||||
assert_eq!(
|
||||
max_substream_channels(&data),
|
||||
Some(2),
|
||||
"must recover the real frame 2 bytes after the unmappable-size sync, not lose it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Same fallback as above, but with the unmappable-size sync at absolute
|
||||
/// offset 0 (`start == 0`) so that stepping backward instead of forward
|
||||
/// (`start - 2`) would underflow rather than merely land on the wrong
|
||||
/// byte. Also proves the real frame is still found 6 bytes further in,
|
||||
/// confirming forward progress past the bogus header.
|
||||
#[test]
|
||||
fn max_substream_channels_unmappable_size_at_start_steps_forward_not_back() {
|
||||
let mut data = vec![0x0B, 0x77, 0x00, 0x00, 0xC0, 0xF8]; // bogus header, offsets 0..6
|
||||
data.extend(ac3_frame(2, false)); // real 2.0 frame at offset 6
|
||||
assert_eq!(
|
||||
max_substream_channels(&data),
|
||||
Some(2),
|
||||
"must step forward past the bogus header at offset 0 and find the real frame at offset 6"
|
||||
);
|
||||
}
|
||||
|
||||
/// `remap_audio_pids` must read a stream's CURRENT physical sub-stream id
|
||||
/// from the low byte of its PID via `pid & 0x00FF` — not `|` or `^` with
|
||||
/// `0x00FF`, both of which force the low byte to `0xFF` regardless of the
|
||||
/// real PID and so always miss the "already matches" shortcut. That
|
||||
/// matters observably when TWO physical sub-streams share the same probed
|
||||
/// channel count: with a correct read, a stream already sitting on a
|
||||
/// matching sub-stream is left alone (conservative, per the module's
|
||||
/// documented behaviour); with the low byte forced to `0xFF`,
|
||||
/// `probed.get(&0xFF)` is always `None`, so the code falls through to the
|
||||
/// "find any unclaimed match" path and picks the FIRST (lowest-keyed,
|
||||
/// BTreeMap-ordered) matching physical sub-stream instead — which here is
|
||||
/// a *different* sub-stream (0x80) than the one the PID already correctly
|
||||
/// names (0x81), producing a spurious PID change.
|
||||
#[test]
|
||||
fn remap_reads_current_substream_via_and_not_or_or_xor() {
|
||||
let mut probed = BTreeMap::new();
|
||||
probed.insert(0x80u8, 6u8);
|
||||
probed.insert(0x81u8, 6u8); // ambiguous: two physical 6ch sub-streams
|
||||
let mut streams = vec![ac3_stream(0xBD81, AudioChannels::Surround51)];
|
||||
let changed = remap_audio_pids(&mut streams, &probed);
|
||||
assert_eq!(
|
||||
changed, 0,
|
||||
"already sitting on a matching physical sub-stream (0x81) must be left alone"
|
||||
);
|
||||
let Stream::Audio(a) = &streams[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
a.pid, 0xBD81,
|
||||
"must not be bumped to the other matching sub-stream (0x80)"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `SectorSource` stub that hands back fixed bytes regardless of the
|
||||
/// requested LBA/count, for exercising `probe_and_remap`'s end-to-end
|
||||
/// wiring (format/AC-3/extent/count guards -> read -> probe -> remap).
|
||||
struct FixedSource {
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl SectorSource for FixedSource {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
_count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
let n = self.data.len().min(buf.len());
|
||||
buf[..n].copy_from_slice(&self.data[..n]);
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end `probe_and_remap`: a real-disc-shaped MpegPs
|
||||
/// title (one declared 5.1 AC-3 stream ordinally assigned 0x80) whose
|
||||
/// physical VOB bytes carry the 2.0 down-mix on 0x80 and the real 5.1 on
|
||||
/// 0x81. This must reach the `remap_audio_pids` call and re-route the
|
||||
/// stream to 0xBD81. It also, by construction, proves each of the guards
|
||||
/// along the way lets a real, positive case through: the content-format
|
||||
/// check must NOT bail on `MpegPs` (only on non-`MpegPs`), the AC-3
|
||||
/// presence check must NOT bail when AC-3 IS present, and the
|
||||
/// sector-count check must NOT bail when the count is nonzero — any one
|
||||
/// of those inverted would skip the probe entirely and leave the PID at
|
||||
/// its untouched ordinal value (0xBD80), which the assertion below would
|
||||
/// catch.
|
||||
#[test]
|
||||
fn probe_and_remap_reroutes_swapped_substream_scenario_end_to_end() {
|
||||
let mut bytes = ps_ac3(0x80, 2, false); // physical 0x80 = 2.0 down-mix
|
||||
bytes.extend(ps_ac3(0x81, 7, true)); // physical 0x81 = 5.1 main mix
|
||||
let mut title = DiscTitle {
|
||||
playlist: "00001.ifo".into(),
|
||||
playlist_id: 1,
|
||||
duration_secs: 60.0,
|
||||
size_bytes: bytes.len() as u64,
|
||||
clips: Vec::new(),
|
||||
streams: vec![ac3_stream(0xBD80, AudioChannels::Surround51)],
|
||||
chapters: Vec::new(),
|
||||
extents: vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 2,
|
||||
}],
|
||||
content_format: ContentFormat::MpegPs,
|
||||
codec_privates: vec![None],
|
||||
};
|
||||
let mut source = FixedSource { data: bytes };
|
||||
probe_and_remap(&mut source, &mut title);
|
||||
let Stream::Audio(a) = &title.streams[0] else {
|
||||
panic!("audio")
|
||||
};
|
||||
assert_eq!(
|
||||
a.pid, 0xBD81,
|
||||
"declared 5.1 stream must be re-routed to the physical 5.1 sub-stream 0x81"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+110
-41
@@ -7,7 +7,6 @@ use crate::udf;
|
||||
|
||||
/// Result of SCSI AACS handshake (ECDH authentication).
|
||||
/// Only available when scanning from a real drive, not ISO images.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct HandshakeResult {
|
||||
pub volume_id: [u8; 16],
|
||||
pub read_data_key: Option<[u8; 16]>,
|
||||
@@ -29,6 +28,19 @@ pub(super) struct HandshakeResult {
|
||||
pub drive_unlocked: bool,
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `volume_id` and `read_data_key` (the AACS 2.0 bus key) are
|
||||
// secret; print only shape. Guarded by `handshake_result_debug_is_redacted`.
|
||||
impl std::fmt::Debug for HandshakeResult {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("HandshakeResult")
|
||||
.field("volume_id", &"<redacted>")
|
||||
.field("read_data_key", &self.read_data_key.map(|_| "<redacted>"))
|
||||
.field("read_data_key_err", &self.read_data_key_err)
|
||||
.field("drive_unlocked", &self.drive_unlocked)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Single source of truth for "is AACS bus encryption gone for this scan?". The
|
||||
/// gate asks ONLY this — `if !removed { error }` — never enumerating cases. Bus
|
||||
/// encryption is gone when ANY of these holds:
|
||||
@@ -169,6 +181,24 @@ fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::trace::UnlockOutco
|
||||
}
|
||||
}
|
||||
|
||||
/// Did the cert handshake actually carry a Volume ID?
|
||||
///
|
||||
/// Extracted so it can be tested as a VALUE. It only ever reaches an operator
|
||||
/// as the `has_volume_id` field of the `bus_key_unavailable` warn, and
|
||||
/// asserting on a `tracing` field means installing a capturing subscriber —
|
||||
/// which is thread-local, while `tracing`'s callsite-interest cache is global.
|
||||
/// Those two facts race: the test failed roughly one run in ten under the full
|
||||
/// parallel suite while passing every time in isolation, and serialising the
|
||||
/// captures was not enough because the cache can be re-evaluated against the
|
||||
/// process default rather than the thread-local dispatch.
|
||||
///
|
||||
/// A predicate this small does not need a subscriber to verify. The polarity is
|
||||
/// the whole point: an `==` here would tell an operator a VID was absent on
|
||||
/// exactly the discs where one was present.
|
||||
fn handshake_has_volume_id(h: &HandshakeResult) -> bool {
|
||||
h.volume_id != [0u8; 16]
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
/// SCSI handshake — drives the VID-acquisition flow and returns
|
||||
/// a structured `HandshakeResult` for downstream key resolution.
|
||||
@@ -312,13 +342,18 @@ impl Disc {
|
||||
use crate::aacs;
|
||||
|
||||
let uk_ro_data =
|
||||
aacs::read_first(aacs::UNIT_KEY_RO_PATHS, |p| udf_fs.read_file(reader, p))?;
|
||||
aacs::read_first(&aacs::role_paths(udf_fs, aacs::AacsRole::UnitKey), |p| {
|
||||
udf_fs.read_file(reader, p)
|
||||
})?;
|
||||
let dh = aacs::inf::disc_hash(&uk_ro_data);
|
||||
|
||||
let cc = aacs::read_first(aacs::CONTENT_CERT_PATHS, |p| udf_fs.read_file(reader, p))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::inf::parse_content_cert);
|
||||
let cc = aacs::read_first(
|
||||
&aacs::role_paths(udf_fs, aacs::AacsRole::ContentCert),
|
||||
|p| udf_fs.read_file(reader, p),
|
||||
)
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::inf::parse_content_cert);
|
||||
let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false);
|
||||
// No-cert default = UHD (V20 stride), matching `read_aacs_version` so the
|
||||
// scanned `AacsState.version` and the out-of-band fetch agree. A wrong
|
||||
@@ -352,7 +387,7 @@ impl Disc {
|
||||
// file/ISO, drive unlock, cert bus key). The gate enumerates nothing.
|
||||
if !bus_encryption_removed(bus_encryption, handshake) {
|
||||
let (rdk_err, has_vid) = handshake
|
||||
.map(|h| (h.read_data_key_err, h.volume_id != [0u8; 16]))
|
||||
.map(|h| (h.read_data_key_err, handshake_has_volume_id(h)))
|
||||
.unwrap_or((None, false));
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
@@ -428,6 +463,27 @@ mod tests {
|
||||
use crate::sector::SectorSource;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `HandshakeResult` carries the Volume ID and the AACS 2.0 bus (read-data)
|
||||
/// key; `Debug` must redact both. Sentinel 213 (0xD5).
|
||||
#[test]
|
||||
fn handshake_result_debug_is_redacted() {
|
||||
let hs = HandshakeResult {
|
||||
volume_id: [0xD5; 16],
|
||||
read_data_key: Some([0xD5; 16]),
|
||||
read_data_key_err: None,
|
||||
drive_unlocked: false,
|
||||
};
|
||||
let d = format!("{hs:?}");
|
||||
assert!(
|
||||
!d.contains("213"),
|
||||
"HandshakeResult leaked VID/bus key: {d}"
|
||||
);
|
||||
assert!(
|
||||
d.contains("redacted"),
|
||||
"HandshakeResult missing marker: {d}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// In-memory disc + minimal UDF image with a single physical
|
||||
// partition (metadata_start == partition_start). Offsets cited
|
||||
@@ -911,41 +967,54 @@ mod tests {
|
||||
|
||||
/// Unit_Key_RO.inf is read from /AACS/DUPLICATE when the primary copy
|
||||
/// is absent (encrypt.rs `.or_else(|_| read_file(DUPLICATE/...))`).
|
||||
/// This is the damaged-primary recovery path real discs rely on.
|
||||
#[test]
|
||||
fn resolve_vid_only_falls_back_to_duplicate_unit_key_ro() {
|
||||
let mut disc = MemDisc::new();
|
||||
// Build AACS dir with a DUPLICATE subdir holding Unit_Key_RO.inf.
|
||||
let uk = vec![0x55u8; 48];
|
||||
let mut dup_fids = Vec::new();
|
||||
push_fid(&mut dup_fids, "", 70, true, true);
|
||||
push_fid(&mut dup_fids, "Unit_Key_RO.inf", 72, false, false);
|
||||
disc.put(PART_START + 72, build_file_icb(uk.len() as u32, 9000));
|
||||
disc.put_bytes(PART_START + 9000, &uk);
|
||||
disc.put(PART_START + 70, build_file_icb(dup_fids.len() as u32, 71));
|
||||
disc.put_bytes(PART_START + 71, &dup_fids);
|
||||
// AACS dir: only a DUPLICATE subdir (no primary Unit_Key_RO.inf).
|
||||
let mut aacs_fids = Vec::new();
|
||||
push_fid(&mut aacs_fids, "", 50, true, true);
|
||||
push_fid(&mut aacs_fids, "DUPLICATE", 70, true, false);
|
||||
disc.put(PART_START + 50, build_file_icb(aacs_fids.len() as u32, 51));
|
||||
disc.put_bytes(PART_START + 51, &aacs_fids);
|
||||
let mut root_fids = Vec::new();
|
||||
push_fid(&mut root_fids, "", 10, true, true);
|
||||
push_fid(&mut root_fids, "AACS", 50, true, false);
|
||||
disc.put(PART_START + 10, build_file_icb(root_fids.len() as u32, 11));
|
||||
disc.put_bytes(PART_START + 11, &root_fids);
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
let udf = udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("DUPLICATE fallback");
|
||||
// disc_hash must be computed over the DUPLICATE bytes.
|
||||
assert_eq!(
|
||||
st.disc_hash,
|
||||
aacs::inf::disc_hash_hex(&aacs::inf::disc_hash(&uk)),
|
||||
"fallback must hash the DUPLICATE Unit_Key_RO.inf"
|
||||
fn handshake_has_volume_id_reports_presence_not_absence() {
|
||||
let with_vid = HandshakeResult {
|
||||
volume_id: [0x11u8; 16],
|
||||
read_data_key: None,
|
||||
read_data_key_err: None,
|
||||
drive_unlocked: false,
|
||||
};
|
||||
assert!(
|
||||
super::handshake_has_volume_id(&with_vid),
|
||||
"a non-zero Volume ID must report as PRESENT"
|
||||
);
|
||||
assert_eq!(st.uk_ro, uk);
|
||||
|
||||
let without = HandshakeResult {
|
||||
volume_id: [0u8; 16],
|
||||
..with_vid
|
||||
};
|
||||
assert!(
|
||||
!super::handshake_has_volume_id(&without),
|
||||
"an all-zero Volume ID is the absent case"
|
||||
);
|
||||
|
||||
// One bit of difference is still a VID: the check is != all-zero, not a
|
||||
// heuristic about how much of it looks populated.
|
||||
let mut barely = [0u8; 16];
|
||||
barely[15] = 1;
|
||||
assert!(
|
||||
super::handshake_has_volume_id(&HandshakeResult {
|
||||
volume_id: barely,
|
||||
..with_vid
|
||||
}),
|
||||
"any non-zero byte makes a Volume ID present"
|
||||
);
|
||||
}
|
||||
|
||||
/// The gate itself still hard-errors — the property the log line annotates.
|
||||
#[test]
|
||||
fn resolve_vid_only_bus_key_gate_hard_errors_without_a_read_data_key() {
|
||||
let (mut disc, udf) = disc_with_cert(0x01, true);
|
||||
let hs = HandshakeResult {
|
||||
volume_id: [0x11u8; 16],
|
||||
read_data_key: None,
|
||||
read_data_key_err: None,
|
||||
drive_unlocked: false,
|
||||
};
|
||||
let err = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs))
|
||||
.expect_err("bus-encrypted, no read_data_key must still hard-error");
|
||||
assert!(matches!(err, Error::AacsBusKeyUnavailable));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
@@ -976,7 +1045,7 @@ mod tests {
|
||||
/// A minimal in-test KeySource that yields no keys but a fixed cert list.
|
||||
struct CertSource(Vec<aacs::types::HostCert>);
|
||||
impl crate::KeySource for CertSource {
|
||||
fn get_uk(
|
||||
fn get_unit_keys(
|
||||
&self,
|
||||
_ctx: &dyn crate::keysource::ResolveCtx,
|
||||
) -> Result<Vec<crate::aacs::types::UnitKey>> {
|
||||
|
||||
+1009
-86
File diff suppressed because it is too large
Load Diff
+2313
-69
File diff suppressed because it is too large
Load Diff
-1670
File diff suppressed because it is too large
Load Diff
+4198
-2797
File diff suppressed because it is too large
Load Diff
-1657
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,505 @@
|
||||
//! Normalized, format-agnostic disc profile — a thin typed VIEW over the scanned
|
||||
//! [`Disc`] model.
|
||||
//!
|
||||
//! The scan already normalizes every source (DVD/BD/UHD/HD-DVD) into the same
|
||||
//! [`Disc`] / [`DiscTitle`] / [`Stream`] shapes, carrying language / forced /
|
||||
//! qualifier / purpose / secondary / label per stream. This module hoists those
|
||||
//! into a flat, serde-friendly surface so a consumer reads
|
||||
//! `profile.titles[i].subtitles[j].forced` (and the analogous audio/video flags)
|
||||
//! for ANY disc with no per-format conditionals.
|
||||
//!
|
||||
//! Everything here is DERIVED from the model at construction: the enum `Stream`
|
||||
//! is split into three typed vectors, the `qualifier` / `purpose` enums are
|
||||
//! decomposed into booleans, and the "default track" selection is precomputed so
|
||||
//! downstream never recomputes it. Every field is always populated — a sensible
|
||||
//! default (`"und"` language, empty `name`, `false` flag) stands in where the
|
||||
//! model has nothing, so consumers never handle a bare `Option`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AudioStream, Disc, DiscFormat, DiscTitle, LabelPurpose, LabelQualifier, Stream, SubtitleStream,
|
||||
VideoStream,
|
||||
};
|
||||
|
||||
/// A disc's complete normalized profile: identity plus every title's typed
|
||||
/// track breakdown, with the main-feature selection hoisted to `main_title`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DiscProfile {
|
||||
/// Disc container family (`"bluray"`, `"uhd"`, `"dvd"`, `"hddvd"`, …).
|
||||
pub format: String,
|
||||
/// Best available display name (disc metadata title, else volume id).
|
||||
pub disc_name: String,
|
||||
/// Stable disc identifier (the UDF volume identifier).
|
||||
pub disc_id: String,
|
||||
/// Index into `titles` of the selected main feature. The scan pre-sorts
|
||||
/// titles so `titles[0]` is the main feature, so this is `0` whenever any
|
||||
/// title exists.
|
||||
pub main_title: usize,
|
||||
/// Every title, in the scan's main-feature order.
|
||||
pub titles: Vec<TitleProfile>,
|
||||
}
|
||||
|
||||
/// One title's normalized profile: identity, duration/size, chapter count, the
|
||||
/// main-feature flag, and its streams split into typed per-kind vectors.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TitleProfile {
|
||||
/// Position of this title within [`DiscProfile::titles`].
|
||||
pub index: usize,
|
||||
/// Playlist / program identifier (e.g. `"00800.mpls"`).
|
||||
pub playlist: String,
|
||||
/// Duration in seconds.
|
||||
pub duration_secs: f64,
|
||||
/// Total size in bytes.
|
||||
pub size_bytes: u64,
|
||||
/// Number of chapter points.
|
||||
pub chapters: usize,
|
||||
/// Whether this is the disc's main feature (`index == main_title`).
|
||||
pub is_main: bool,
|
||||
/// Video tracks, in declared order.
|
||||
pub video: Vec<VideoTrack>,
|
||||
/// Audio tracks, in declared order.
|
||||
pub audio: Vec<AudioTrack>,
|
||||
/// Subtitle tracks, in declared order.
|
||||
pub subtitles: Vec<SubtitleTrack>,
|
||||
}
|
||||
|
||||
/// A normalized video track.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VideoTrack {
|
||||
/// Compact codec id (e.g. `"hevc"`, `"h264"`, `"vc1"`, `"mpeg2"`).
|
||||
pub codec: String,
|
||||
/// Resolution label (e.g. `"2160p"`, `"1080p"`, `"576i"`).
|
||||
pub resolution: String,
|
||||
/// HDR format id (e.g. `"sdr"`, `"hdr10"`, `"hdr10+"`, `"dv"`, `"hlg"`).
|
||||
pub hdr: String,
|
||||
/// Frame-rate label (e.g. `"23.976"`, `"25"`).
|
||||
pub frame_rate: String,
|
||||
/// Whether this is the title's default video track (first non-secondary).
|
||||
pub default: bool,
|
||||
/// Whether this is a secondary stream (PiP / dependent view).
|
||||
pub secondary: bool,
|
||||
}
|
||||
|
||||
/// A normalized audio track.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AudioTrack {
|
||||
/// ISO 639-2 language code, `"und"` when the source stated none.
|
||||
pub language: String,
|
||||
/// Compact codec id (e.g. `"truehd"`, `"dtshd_ma"`, `"ac3"`).
|
||||
pub codec: String,
|
||||
/// Channel layout label (e.g. `"stereo"`, `"5.1"`, `"unknown"`).
|
||||
pub channels: String,
|
||||
/// Whether this is the title's default audio track (first non-secondary).
|
||||
pub default: bool,
|
||||
/// Editorial purpose flag: commentary track.
|
||||
pub commentary: bool,
|
||||
/// Editorial purpose flag: descriptive / audio-description track.
|
||||
pub descriptive: bool,
|
||||
/// Codec / variant text label; empty when the source stated none.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// A normalized subtitle track.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SubtitleTrack {
|
||||
/// ISO 639-2 language code, `"und"` when the source stated none.
|
||||
pub language: String,
|
||||
/// Compact codec id (e.g. `"pgs"`, `"dvdsub"`).
|
||||
pub codec: String,
|
||||
/// Forced-narrative flag.
|
||||
pub forced: bool,
|
||||
/// SDH (subtitles for the deaf / hard-of-hearing) flag.
|
||||
pub sdh: bool,
|
||||
/// Whether this is the title's default subtitle track. Always `false`: the
|
||||
/// mux never marks a subtitle default, and the normalized view mirrors that.
|
||||
pub default: bool,
|
||||
/// Track name; empty (the subtitle model carries no free-text label).
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Container family as a compact, stable id. Kept here (not on [`DiscFormat`])
|
||||
/// because it is a serialization concern of this view.
|
||||
fn format_id(format: DiscFormat) -> &'static str {
|
||||
match format {
|
||||
DiscFormat::Uhd => "uhd",
|
||||
DiscFormat::Fmts => "uhd_fmts",
|
||||
DiscFormat::BluRay => "bluray",
|
||||
DiscFormat::HdDvd => "hddvd",
|
||||
DiscFormat::Dvd => "dvd",
|
||||
DiscFormat::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// `"und"` for an empty language code, else the code unchanged. Mirrors the
|
||||
/// muxer's `language_or_und`: the one place a source with no language table has
|
||||
/// to fall back to the ISO 639-2 "undetermined" code.
|
||||
fn language_or_und(lang: &str) -> String {
|
||||
if lang.is_empty() {
|
||||
"und".to_string()
|
||||
} else {
|
||||
lang.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoTrack {
|
||||
fn from_stream(v: &VideoStream, default: bool) -> Self {
|
||||
Self {
|
||||
codec: v.codec.id().to_string(),
|
||||
resolution: v.resolution.to_string(),
|
||||
hdr: v.hdr.id().to_string(),
|
||||
frame_rate: v.frame_rate.to_string(),
|
||||
default,
|
||||
secondary: v.secondary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioTrack {
|
||||
fn from_stream(a: &AudioStream, default: bool) -> Self {
|
||||
Self {
|
||||
language: language_or_und(&a.language),
|
||||
codec: a.codec.id().to_string(),
|
||||
channels: a.channels.to_string(),
|
||||
default,
|
||||
commentary: a.purpose == LabelPurpose::Commentary,
|
||||
descriptive: a.purpose == LabelPurpose::Descriptive,
|
||||
name: a.label.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SubtitleTrack {
|
||||
fn from_stream(s: &SubtitleStream) -> Self {
|
||||
Self {
|
||||
language: language_or_und(&s.language),
|
||||
codec: s.codec.id().to_string(),
|
||||
// The authoritative forced flag (`forced`, set by the content probe /
|
||||
// STN) OR the label-derived qualifier — either is sufficient.
|
||||
forced: s.forced || s.qualifier == LabelQualifier::Forced,
|
||||
sdh: s.qualifier == LabelQualifier::Sdh,
|
||||
// The mux never defaults a subtitle track; mirror that here.
|
||||
default: false,
|
||||
name: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TitleProfile {
|
||||
/// Build a title's profile. `index` / `is_main` are disc-level facts the
|
||||
/// caller supplies (see [`DiscProfile::from_disc`]); the streams are split
|
||||
/// and the per-kind default track is hoisted here.
|
||||
pub fn from_title(title: &DiscTitle, index: usize, is_main: bool) -> Self {
|
||||
let mut video = Vec::new();
|
||||
let mut audio = Vec::new();
|
||||
let mut subtitles = Vec::new();
|
||||
// "First non-secondary is the default; keep only the first" — the same
|
||||
// rule the Matroska path applies (`is_default = !secondary`, then only
|
||||
// the first video and first audio survive as default).
|
||||
let mut video_default_taken = false;
|
||||
let mut audio_default_taken = false;
|
||||
for s in &title.streams {
|
||||
match s {
|
||||
Stream::Video(v) => {
|
||||
let default = !v.secondary && !video_default_taken;
|
||||
video_default_taken |= default;
|
||||
video.push(VideoTrack::from_stream(v, default));
|
||||
}
|
||||
Stream::Audio(a) => {
|
||||
let default = !a.secondary && !audio_default_taken;
|
||||
audio_default_taken |= default;
|
||||
audio.push(AudioTrack::from_stream(a, default));
|
||||
}
|
||||
Stream::Subtitle(t) => subtitles.push(SubtitleTrack::from_stream(t)),
|
||||
}
|
||||
}
|
||||
Self {
|
||||
index,
|
||||
playlist: title.playlist.clone(),
|
||||
duration_secs: title.duration_secs,
|
||||
size_bytes: title.size_bytes,
|
||||
chapters: title.chapters.len(),
|
||||
is_main,
|
||||
video,
|
||||
audio,
|
||||
subtitles,
|
||||
}
|
||||
}
|
||||
|
||||
/// The title's video tracks.
|
||||
pub fn video(&self) -> &[VideoTrack] {
|
||||
&self.video
|
||||
}
|
||||
|
||||
/// The title's audio tracks.
|
||||
pub fn audio(&self) -> &[AudioTrack] {
|
||||
&self.audio
|
||||
}
|
||||
|
||||
/// The title's subtitle tracks.
|
||||
pub fn subtitles(&self) -> &[SubtitleTrack] {
|
||||
&self.subtitles
|
||||
}
|
||||
}
|
||||
|
||||
impl DiscProfile {
|
||||
/// Build the normalized profile from a scanned [`Disc`].
|
||||
pub fn from_disc(disc: &Disc) -> Self {
|
||||
// The scan pre-sorts titles so `titles[0]` is the selected main feature
|
||||
// (`sort_titles_by_main_feature`), so the main title is index 0 whenever
|
||||
// any title exists.
|
||||
let main_title = 0;
|
||||
let has_titles = !disc.titles.is_empty();
|
||||
let titles = disc
|
||||
.titles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| TitleProfile::from_title(t, i, has_titles && i == main_title))
|
||||
.collect();
|
||||
Self {
|
||||
format: format_id(disc.format).to_string(),
|
||||
disc_name: disc
|
||||
.meta_title
|
||||
.clone()
|
||||
.unwrap_or_else(|| disc.volume_id.clone()),
|
||||
disc_id: disc.volume_id.clone(),
|
||||
main_title,
|
||||
titles,
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected main-feature title, or `None` when the disc scanned to zero
|
||||
/// titles (a data-only image with no /BDMV, /HVDVD_TS or /VIDEO_TS). Never
|
||||
/// panics — mirrors the `Option`-returning main-feature accessors elsewhere
|
||||
/// (`titles.first()`, `dvdnav::resolve_main_title`).
|
||||
pub fn main_title(&self) -> Option<&TitleProfile> {
|
||||
self.titles.get(self.main_title)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Disc> for DiscProfile {
|
||||
fn from(disc: &Disc) -> Self {
|
||||
DiscProfile::from_disc(disc)
|
||||
}
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
/// This disc's normalized, format-agnostic [`DiscProfile`].
|
||||
pub fn profile(&self) -> DiscProfile {
|
||||
DiscProfile::from_disc(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::{
|
||||
AudioChannels, AudioStream, Codec, ColorSpace, ContentFormat, DiscRegion, DiscTitle,
|
||||
FrameRate, HdrFormat, Resolution, SampleRate, Stream, SubtitleStream, VideoStream,
|
||||
};
|
||||
|
||||
/// A minimal titleless [`Disc`] for the disc-level tests.
|
||||
fn test_disc() -> Disc {
|
||||
Disc {
|
||||
volume_id: String::new(),
|
||||
meta_title: None,
|
||||
format: DiscFormat::BluRay,
|
||||
capacity_sectors: 0,
|
||||
capacity_bytes: 0,
|
||||
layers: 1,
|
||||
titles: Vec::new(),
|
||||
region: DiscRegion::Free,
|
||||
aacs: None,
|
||||
css: None,
|
||||
encrypted: false,
|
||||
aacs_error: None,
|
||||
css_error: None,
|
||||
content_format: ContentFormat::BdTs,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_title_is_none_on_empty_disc() {
|
||||
// A data-only image (no /BDMV, /HVDVD_TS, /VIDEO_TS) scans to zero
|
||||
// titles; main_title() must return None, not panic on titles[0].
|
||||
let profile = DiscProfile::from_disc(&test_disc());
|
||||
assert!(profile.titles.is_empty());
|
||||
assert!(profile.main_title().is_none());
|
||||
}
|
||||
|
||||
fn video(codec: Codec, res: Resolution, secondary: bool) -> Stream {
|
||||
Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec,
|
||||
resolution: res,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Hdr10,
|
||||
color_space: ColorSpace::Bt2020,
|
||||
display_aspect: None,
|
||||
secondary,
|
||||
label: String::new(),
|
||||
measured_cicp: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn audio(lang: &str, secondary: bool, purpose: LabelPurpose, label: &str) -> Stream {
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::TrueHd,
|
||||
channels: AudioChannels::Stereo,
|
||||
language: lang.into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary,
|
||||
purpose,
|
||||
label: label.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn subtitle(lang: &str, forced: bool, qualifier: LabelQualifier) -> Stream {
|
||||
Stream::Subtitle(SubtitleStream {
|
||||
pid: 0x1200,
|
||||
codec: Codec::Pgs,
|
||||
language: lang.into(),
|
||||
forced,
|
||||
qualifier,
|
||||
codec_data: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A BD transport-stream title with a mix of streams: two audio tracks
|
||||
/// (main + commentary), two subtitles (forced + SDH), a video track.
|
||||
fn bdts_title() -> DiscTitle {
|
||||
let mut t = DiscTitle::empty();
|
||||
t.content_format = ContentFormat::BdTs;
|
||||
t.playlist = "00800.mpls".into();
|
||||
t.duration_secs = 7200.0;
|
||||
t.size_bytes = 30_000_000_000;
|
||||
t.streams = vec![
|
||||
video(Codec::Hevc, Resolution::R2160p, false),
|
||||
audio("eng", false, LabelPurpose::Normal, "Dolby TrueHD 5.1"),
|
||||
audio("eng", true, LabelPurpose::Commentary, "Director"),
|
||||
subtitle("eng", true, LabelQualifier::Forced),
|
||||
subtitle("eng", false, LabelQualifier::Sdh),
|
||||
];
|
||||
t
|
||||
}
|
||||
|
||||
/// A DVD (MPEG program stream) title: two audio tracks (both non-secondary,
|
||||
/// second is descriptive), one language-less subtitle, one video track.
|
||||
fn dvd_title() -> DiscTitle {
|
||||
let mut t = DiscTitle::empty();
|
||||
t.content_format = ContentFormat::MpegPs;
|
||||
t.playlist = "VTS_01".into();
|
||||
t.duration_secs = 5400.0;
|
||||
t.size_bytes = 4_000_000_000;
|
||||
t.streams = vec![
|
||||
video(Codec::Mpeg2, Resolution::R576i, false),
|
||||
audio("eng", false, LabelPurpose::Normal, ""),
|
||||
audio("eng", false, LabelPurpose::Descriptive, ""),
|
||||
subtitle("", false, LabelQualifier::None),
|
||||
];
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_streams_into_typed_vectors_across_formats() {
|
||||
for title in [bdts_title(), dvd_title()] {
|
||||
let p = TitleProfile::from_title(&title, 0, true);
|
||||
assert_eq!(p.video.len(), 1, "one video track");
|
||||
assert_eq!(p.audio.len(), 2, "two audio tracks");
|
||||
assert_eq!(p.subtitles.len(), 1.max(p.subtitles.len()));
|
||||
// Accessors return the same vectors.
|
||||
assert_eq!(p.video(), p.video.as_slice());
|
||||
assert_eq!(p.audio(), p.audio.as_slice());
|
||||
assert_eq!(p.subtitles(), p.subtitles.as_slice());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hoists_default_flags_first_non_secondary() {
|
||||
let p = TitleProfile::from_title(&bdts_title(), 0, true);
|
||||
// First (non-secondary) video/audio is the default.
|
||||
assert!(p.video[0].default, "first video defaults");
|
||||
assert!(p.audio[0].default, "first non-secondary audio defaults");
|
||||
// The commentary track is secondary → never default.
|
||||
assert!(
|
||||
!p.audio[1].default,
|
||||
"secondary/commentary audio not default"
|
||||
);
|
||||
// Subtitles are never default.
|
||||
assert!(p.subtitles.iter().all(|s| !s.default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_default_cleared_when_two_non_secondary_audio() {
|
||||
// DVD title: BOTH audio tracks are non-secondary. Only the first keeps
|
||||
// the default flag (mirrors the muxer's "keep only first default").
|
||||
let p = TitleProfile::from_title(&dvd_title(), 0, true);
|
||||
assert!(p.audio[0].default, "first audio default");
|
||||
assert!(
|
||||
!p.audio[1].default,
|
||||
"second non-secondary audio default cleared"
|
||||
);
|
||||
assert!(p.audio[1].descriptive, "second audio is descriptive");
|
||||
assert!(!p.audio[1].commentary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_qualifier_and_purpose_and_language_defaults() {
|
||||
let p = TitleProfile::from_title(&bdts_title(), 0, true);
|
||||
// Audio purpose → commentary/descriptive booleans.
|
||||
assert!(!p.audio[0].commentary && !p.audio[0].descriptive);
|
||||
assert!(p.audio[1].commentary);
|
||||
assert_eq!(p.audio[1].name, "Director", "label maps to name");
|
||||
// Subtitle qualifier → forced/sdh booleans.
|
||||
assert!(p.subtitles[0].forced && !p.subtitles[0].sdh);
|
||||
assert!(p.subtitles[1].sdh && !p.subtitles[1].forced);
|
||||
// Language default: DVD title's blank subtitle language → "und".
|
||||
let dvd = TitleProfile::from_title(&dvd_title(), 0, true);
|
||||
assert_eq!(dvd.subtitles[0].language, "und");
|
||||
assert_eq!(dvd.audio[0].language, "eng");
|
||||
// Codec/resolution/hdr flattened as compact ids/labels.
|
||||
assert_eq!(p.video[0].codec, "hevc");
|
||||
assert_eq!(p.video[0].resolution, "2160p");
|
||||
assert_eq!(p.video[0].hdr, "hdr10");
|
||||
assert_eq!(dvd.video[0].codec, "mpeg2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_disc_hoists_main_and_is_main() {
|
||||
let mut disc = test_disc();
|
||||
disc.format = DiscFormat::BluRay;
|
||||
disc.meta_title = Some("SOME MOVIE".into());
|
||||
disc.volume_id = "VOL_ID".into();
|
||||
disc.titles = vec![bdts_title(), dvd_title()];
|
||||
let profile = disc.profile();
|
||||
assert_eq!(profile.format, "bluray");
|
||||
assert_eq!(profile.disc_name, "SOME MOVIE");
|
||||
assert_eq!(profile.disc_id, "VOL_ID");
|
||||
assert_eq!(profile.main_title, 0);
|
||||
assert!(profile.titles[0].is_main, "titles[0] is the main feature");
|
||||
assert!(!profile.titles[1].is_main);
|
||||
assert_eq!(profile.titles[1].index, 1);
|
||||
assert_eq!(profile.main_title().unwrap().playlist, "00800.mpls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_name_falls_back_to_volume_id() {
|
||||
let mut disc = test_disc();
|
||||
disc.meta_title = None;
|
||||
disc.volume_id = "PLAIN_VOLUME".into();
|
||||
assert_eq!(disc.profile().disc_name, "PLAIN_VOLUME");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip() {
|
||||
let mut disc = test_disc();
|
||||
disc.format = DiscFormat::Dvd;
|
||||
disc.volume_id = "RT".into();
|
||||
disc.titles = vec![bdts_title(), dvd_title()];
|
||||
let profile = disc.profile();
|
||||
let json = serde_json::to_string(&profile).expect("serialize");
|
||||
let back: DiscProfile = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(profile, back, "profile round-trips through serde");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,218 +0,0 @@
|
||||
//! `Disc::sweep`'s consumer-side `Sink<WorkItem>`.
|
||||
//!
|
||||
//! Background: the original sweep loop runs strictly serialised —
|
||||
//! SCSI read → decrypt → seek + write → mapfile.record → next iter.
|
||||
//! On a healthy disc the SCSI read costs ~5-12 ms per 64 KB batch and
|
||||
//! the post-read work (decrypt 1-3 ms + file write + mapfile fsync
|
||||
//! 5-15 ms) adds another batch's worth of latency. The drive idles
|
||||
//! during the post-read work; throughput tops out at the *sum* of
|
||||
//! both costs.
|
||||
//!
|
||||
//! A producer/consumer split overlaps the two stages on the generic
|
||||
//! [`crate::io::Pipeline`] + [`crate::io::Sink`] primitive. This module
|
||||
//! is the sweep-specific `Sink` impl; the producer-side state machine
|
||||
//! (read_error context, decrypt, set_speed, halt) stays in
|
||||
//! `Disc::sweep` in `disc/mod.rs`.
|
||||
//!
|
||||
//! Correctness invariants preserved:
|
||||
//! - Mapfile is single-writer (consumer-only). No locking.
|
||||
//! - All `read_error::ReadCtx` state stays on the producer thread.
|
||||
//! - `set_speed` calls happen on the producer thread (same thread that
|
||||
//! owns the `SectorSource`). No new SCSI concurrency.
|
||||
//! - Per-iteration ordering of file-write → mapfile-record is kept
|
||||
//! intact in the consumer (write before record), so the on-disk
|
||||
//! invariant "mapfile only marks Finished what the file has
|
||||
//! received" survives a crash mid-pass.
|
||||
//! - Only one SCSI command is in flight at a time; error-path timing
|
||||
//! is identical and no new retry logic is introduced.
|
||||
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::io::{Flow, Sink};
|
||||
|
||||
use super::mapfile::{MapStats, Mapfile, SectorStatus};
|
||||
|
||||
/// Reusable zero buffer for SkipFill / GapFill / BisectBad. 64 KB
|
||||
/// matches the existing zero_gap chunk size used by the pre-split
|
||||
/// sweep loop.
|
||||
const ZERO_CHUNK: usize = 64 * 1024;
|
||||
|
||||
/// Producer → Consumer messages. The consumer applies these in FIFO
|
||||
/// order; ordering of file writes and mapfile records across items is
|
||||
/// preserved.
|
||||
pub(super) enum WorkItem {
|
||||
/// Successful batch read. Producer has already decrypted `buf` if
|
||||
/// `opts.decrypt` was set. Consumer writes `buf` at `pos` and
|
||||
/// records the range as `Finished`.
|
||||
Good { pos: u64, buf: Vec<u8> },
|
||||
|
||||
/// Bisect inner-loop good single sector (already decrypted by the
|
||||
/// producer). 2048 bytes.
|
||||
BisectGood { pos: u64, buf: Box<[u8; 2048]> },
|
||||
|
||||
/// Bisect inner-loop bad single sector. Consumer writes 2048
|
||||
/// zeros at `pos` and records the sector as `NonTrimmed`.
|
||||
BisectBad { pos: u64 },
|
||||
|
||||
/// Whole-batch zero-fill (failed batch on `SkipBlock`, or the
|
||||
/// failed batch portion of `JumpAhead`). Consumer streams zeros
|
||||
/// across `[pos, pos+len)` and records the range as `NonTrimmed`.
|
||||
SkipFill { pos: u64, len: u64 },
|
||||
|
||||
/// Gap fill following a `JumpAhead`. Same effect as `SkipFill`;
|
||||
/// distinguished only so future logging / instrumentation can
|
||||
/// tell them apart without parsing a flag.
|
||||
GapFill { pos: u64, len: u64 },
|
||||
|
||||
/// Producer wants the latest mapfile stats for the progress
|
||||
/// callback. Consumer responds on `prog_tx` with a fresh
|
||||
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
|
||||
/// drained the previous snapshot, the new one is silently
|
||||
/// dropped — the producer's local cache stays current enough.
|
||||
StatsRequest,
|
||||
}
|
||||
|
||||
/// Snapshot the consumer sends back to the producer for the progress
|
||||
/// callback.
|
||||
pub(super) struct ProgressSnapshot {
|
||||
pub stats: MapStats,
|
||||
pub bad_ranges: Vec<(u64, u64)>,
|
||||
}
|
||||
|
||||
/// Final summary returned by the consumer thread on shutdown — what
|
||||
/// `SweepSink::close` produces, surfaced to the producer via
|
||||
/// `Pipeline::finish`.
|
||||
pub(super) struct ConsumerSummary {
|
||||
pub stats: MapStats,
|
||||
}
|
||||
|
||||
/// Drain any pending progress snapshots from the consumer. Returns
|
||||
/// the most recent one, if any. The producer caches it and uses it
|
||||
/// for subsequent progress callbacks until a fresh one arrives.
|
||||
pub(super) fn try_recv_progress(rx: &Receiver<ProgressSnapshot>) -> Option<ProgressSnapshot> {
|
||||
let mut latest = None;
|
||||
while let Ok(snap) = rx.try_recv() {
|
||||
latest = Some(snap);
|
||||
}
|
||||
latest
|
||||
}
|
||||
|
||||
/// `Sink<WorkItem>` for sweep. Owns the writeback file + mapfile +
|
||||
/// progress back-channel. `apply` carries the file-write +
|
||||
/// mapfile.record per item; `close` drains the writeback pipeline,
|
||||
/// fsyncs the ISO, and flushes the mapfile.
|
||||
pub(super) struct SweepSink {
|
||||
file: crate::io::WritebackFile,
|
||||
map: Mapfile,
|
||||
/// `sync_all`-on-failure-is-an-error iff the output is a regular
|
||||
/// file. `/dev/null` and pipes always fail `sync_all`; that's not
|
||||
/// a real error.
|
||||
is_regular: bool,
|
||||
/// Back-channel for `StatsRequest` responses. The producer caches
|
||||
/// the latest snapshot and uses it for the progress callback;
|
||||
/// dropped sends on a full channel are by design.
|
||||
prog_tx: SyncSender<ProgressSnapshot>,
|
||||
/// Reusable zero buffer for SkipFill / GapFill / BisectBad. Held
|
||||
/// in the sink so each apply call doesn't reallocate.
|
||||
zero: Box<[u8; ZERO_CHUNK]>,
|
||||
}
|
||||
|
||||
impl SweepSink {
|
||||
/// Construct a new `SweepSink` plus the matching progress
|
||||
/// receiver. Channel depth on the back-channel is `1` — the
|
||||
/// producer's cache is the source of truth between snapshots.
|
||||
pub(super) fn new(
|
||||
file: crate::io::WritebackFile,
|
||||
map: Mapfile,
|
||||
is_regular: bool,
|
||||
) -> (Self, Receiver<ProgressSnapshot>) {
|
||||
let (prog_tx, prog_rx) = sync_channel::<ProgressSnapshot>(1);
|
||||
let sink = SweepSink {
|
||||
file,
|
||||
map,
|
||||
is_regular,
|
||||
prog_tx,
|
||||
zero: Box::new([0u8; ZERO_CHUNK]),
|
||||
};
|
||||
(sink, prog_rx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sink<WorkItem> for SweepSink {
|
||||
type Output = ConsumerSummary;
|
||||
|
||||
fn apply(&mut self, item: WorkItem) -> Result<Flow, Error> {
|
||||
match item {
|
||||
WorkItem::Good { pos, buf } => {
|
||||
// Decrypt is on the producer; consumer assumes plaintext.
|
||||
let len = buf.len() as u64;
|
||||
self.file.seek(SeekFrom::Start(pos))?;
|
||||
self.file.write_all(&buf)?;
|
||||
self.map.record(pos, len, SectorStatus::Finished)?;
|
||||
}
|
||||
WorkItem::BisectGood { pos, buf } => {
|
||||
self.file.seek(SeekFrom::Start(pos))?;
|
||||
self.file.write_all(&buf[..])?;
|
||||
self.map.record(pos, 2048, SectorStatus::Finished)?;
|
||||
}
|
||||
WorkItem::BisectBad { pos } => {
|
||||
self.file.seek(SeekFrom::Start(pos))?;
|
||||
self.file.write_all(&self.zero[..2048])?;
|
||||
self.map.record(pos, 2048, SectorStatus::NonTrimmed)?;
|
||||
}
|
||||
WorkItem::SkipFill { pos, len } | WorkItem::GapFill { pos, len } => {
|
||||
self.file.seek(SeekFrom::Start(pos))?;
|
||||
// Subsequent writes are sequential; `WritebackFile`'s
|
||||
// seek-elision keeps them on the writeback pipeline path.
|
||||
let mut filled = 0u64;
|
||||
while filled < len {
|
||||
let chunk = (len - filled).min(self.zero.len() as u64) as usize;
|
||||
self.file.write_all(&self.zero[..chunk])?;
|
||||
filled += chunk as u64;
|
||||
}
|
||||
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
|
||||
}
|
||||
WorkItem::StatsRequest => {
|
||||
let stats = self.map.stats();
|
||||
// DAMAGE only — NOT NonTried. NonTried is the unread remainder
|
||||
// ahead of the sweep head, not damage; including it made the live
|
||||
// located drilldown (at-risk movie time + range count) treat the
|
||||
// whole unread disc as confirmed damage, so at sweep start it
|
||||
// showed ~full-movie at-risk and melted to 0 as the sweep
|
||||
// progressed. Matches the one-shot progress path, which already
|
||||
// excludes NonTried.
|
||||
let bad_ranges = self.map.ranges_with(&[
|
||||
SectorStatus::NonTrimmed,
|
||||
SectorStatus::Unreadable,
|
||||
SectorStatus::NonScraped,
|
||||
]);
|
||||
// Best-effort: drop on backpressure; producer's cache
|
||||
// stays current enough.
|
||||
let _ = self
|
||||
.prog_tx
|
||||
.try_send(ProgressSnapshot { stats, bad_ranges });
|
||||
}
|
||||
}
|
||||
Ok(Flow::Continue)
|
||||
}
|
||||
|
||||
fn close(mut self) -> Result<Self::Output, Error> {
|
||||
// Drain the writeback pipeline + fsync the ISO, then persist
|
||||
// any pending mapfile state. Same finalisation order as the
|
||||
// pre-Pipeline consumer loop.
|
||||
if let Err(e) = self.file.sync_all() {
|
||||
if self.is_regular {
|
||||
return Err(Error::IoError { source: e });
|
||||
}
|
||||
// Non-regular outputs (/dev/null, pipes) always fail
|
||||
// sync_all; that's not a real error.
|
||||
}
|
||||
self.map.flush()?;
|
||||
|
||||
Ok(ConsumerSummary {
|
||||
stats: self.map.stats(),
|
||||
})
|
||||
}
|
||||
}
|
||||
+6
-8
@@ -23,14 +23,12 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
if !id.raw_inquiry.is_empty()
|
||||
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||
{
|
||||
drives.push((path, id));
|
||||
}
|
||||
}
|
||||
if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path))
|
||||
&& let Ok(id) = DriveId::from_drive(transport.as_mut())
|
||||
&& !id.raw_inquiry.is_empty()
|
||||
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||
{
|
||||
drives.push((path, id));
|
||||
}
|
||||
}
|
||||
drives
|
||||
|
||||
+35
-6
@@ -25,12 +25,11 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let path = std::path::Path::new(&info.path);
|
||||
match crate::scsi::open(path) {
|
||||
Ok(mut transport) => {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
if !id.raw_inquiry.is_empty()
|
||||
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||
{
|
||||
drives.push((info.path.clone(), id));
|
||||
}
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut())
|
||||
&& !id.raw_inquiry.is_empty()
|
||||
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||
{
|
||||
drives.push((info.path.clone(), id));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -53,3 +52,33 @@ pub fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> {
|
||||
}
|
||||
Ok((path.to_string(), DeviceResolution::Direct))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod resolve_device_tests {
|
||||
use super::*;
|
||||
|
||||
/// An existing path resolves unchanged as `Direct` — macOS has no
|
||||
/// `sr`->`sg` substitution, so the returned path must be byte-identical
|
||||
/// to the input, not some canonicalised/mutated form.
|
||||
#[test]
|
||||
fn existing_path_resolves_direct_unchanged() {
|
||||
// Use the test binary's own executable path: guaranteed to exist,
|
||||
// no fixture file needed.
|
||||
let exe = std::env::current_exe().unwrap();
|
||||
let path = exe.to_str().unwrap();
|
||||
let (resolved, kind) = resolve_device(path).expect("existing path must resolve");
|
||||
assert_eq!(resolved, path, "path must be returned unchanged");
|
||||
assert_eq!(kind, DeviceResolution::Direct);
|
||||
}
|
||||
|
||||
/// A path that does not exist must error with `DeviceNotFound` carrying
|
||||
/// the original path, never silently succeed.
|
||||
#[test]
|
||||
fn missing_path_is_device_not_found() {
|
||||
let path = "/dev/freemkv-definitely-does-not-exist-0xdead";
|
||||
match resolve_device(path) {
|
||||
Err(Error::DeviceNotFound { path: p }) => assert_eq!(p, path),
|
||||
other => panic!("expected DeviceNotFound, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1263
-137
File diff suppressed because it is too large
Load Diff
+7
-4
@@ -8,11 +8,14 @@
|
||||
//! PGC/cell tables, PCI/HLI button packets); the VM command decoder is
|
||||
//! verified against real discs.
|
||||
//!
|
||||
//! Current contents: [`vmcmd`] — the VM command decoder (proven against the
|
||||
//! SOTL/Greenland test discs). The IFO/PCI parsing and the navigation executor
|
||||
//! that resolves the start cell build on top of this.
|
||||
//! Current contents: [`vmcmd`] — the VM command decoder; [`nav`] — the
|
||||
//! First-Play navigation executor that resolves which title the disc's own
|
||||
//! navigation selects as the feature.
|
||||
|
||||
pub mod vmcmd;
|
||||
pub(crate) mod nav;
|
||||
pub(crate) mod vmcmd;
|
||||
|
||||
pub(crate) use nav::resolve_main_title;
|
||||
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
//! DVD-Video navigation executor — the DVD arm of "mimic a real player" title
|
||||
//! selection (issue #40).
|
||||
//!
|
||||
//! A player enters a disc at the **First-Play PGC** (FP_PGC): a short program of
|
||||
//! VM commands (parsed by [`super::vmcmd`]) that runs before any menu draws and
|
||||
//! typically dispatches straight to the feature title. This module reads that
|
||||
//! program out of `VIDEO_TS.IFO` (VMGI), executes its pre-command list on a
|
||||
//! minimal register machine, and — when the program deterministically reaches a
|
||||
//! title dispatch (`JumpTT`) — maps the selected VMG title through the title
|
||||
//! search pointer table (`TT_SRPT`) to its `(VTS number, title-in-set)`
|
||||
//! coordinates. That is the title a player would land on, so DVD title
|
||||
//! selection can prefer it over the structural leading-cell heuristic.
|
||||
//!
|
||||
//! Contract: this is a strict improvement over the fallback. Any parse error,
|
||||
//! interactive/menu-only entry, or non-convergence yields `None`, and the caller
|
||||
//! keeps today's behaviour. Every table count and offset is attacker-controlled,
|
||||
//! so all arithmetic is checked and all reads are bounds-guarded — the entry
|
||||
//! point [`resolve_from_vmg`] never panics on any input (exercised by the
|
||||
//! never-panic harness).
|
||||
|
||||
use super::vmcmd::{self, Compare, Instr};
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
// ── VMGI management-table byte offsets into VIDEO_TS.IFO (DVD-Video spec) ─────
|
||||
//
|
||||
// The VMGI_MAT header carries these fixed offsets. The values they hold are
|
||||
// per-disc; the offsets are constant.
|
||||
const VMGI_MAGIC: &[u8; 12] = b"DVDVIDEO-VMG";
|
||||
/// u32 **byte** offset (from the start of VIDEO_TS.IFO) of the First-Play PGC.
|
||||
const VMGI_FP_PGC_PTR: usize = 0x84;
|
||||
/// u32 **sector** offset (from the start of VIDEO_TS.IFO) of TT_SRPT.
|
||||
const VMGI_TT_SRPT_PTR: usize = 0xC4;
|
||||
|
||||
/// Within a PGC, the command-table pointer is a u16 at `PGC + 0xE4` giving the
|
||||
/// command table's byte offset relative to the PGC start (DVD-Video PGC layout;
|
||||
/// the same layout `ifo::parse_pgc` reads for cell/program tables).
|
||||
const PGC_CMD_TBL_PTR: usize = 0xE4;
|
||||
|
||||
/// Upper bound on commands executed before declaring non-convergence. A
|
||||
/// conformant First-Play routine reaches a title in a handful of steps; the cap
|
||||
/// stops a crafted command list (self-`Goto`, mutual jumps) from spinning.
|
||||
const STEP_BUDGET: usize = 1024;
|
||||
|
||||
/// Maximum commands honoured in one PGC command list. The DVD-Video VM caps a
|
||||
/// list at 128 pre / 128 post / 128 cell commands; the on-disc count is an
|
||||
/// untrusted u16, so it is clamped to this format maximum.
|
||||
const MAX_CMDS: usize = 128;
|
||||
|
||||
/// Maximum TT_SRPT entries honoured — the DVD-Video 99-title format maximum
|
||||
/// (mirrors `ifo::MAX_TT_SRPT_TITLES`; the on-disc count is an untrusted u16).
|
||||
const MAX_TT_SRPT_TITLES: usize = 99;
|
||||
|
||||
/// The title the First-Play navigation selects, in VMG coordinates.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ResolvedTitle {
|
||||
/// 1-based VMG title number (the TT_SRPT index the `JumpTT` named).
|
||||
pub title: u8,
|
||||
/// 1-based VTS (title set) number the title lives in.
|
||||
pub vtsn: u8,
|
||||
/// 1-based title-within-set number (TT_SRPT `VTS_TTN`).
|
||||
pub vts_ttn: u8,
|
||||
}
|
||||
|
||||
// ── Bounds-guarded big-endian reads (return None past the end) ────────────────
|
||||
|
||||
#[inline]
|
||||
fn u16_at(b: &[u8], o: usize) -> Option<u16> {
|
||||
Some(u16::from_be_bytes([*b.get(o)?, *b.get(o.checked_add(1)?)?]))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn u32_at(b: &[u8], o: usize) -> Option<u32> {
|
||||
Some(u32::from_be_bytes([
|
||||
*b.get(o)?,
|
||||
*b.get(o.checked_add(1)?)?,
|
||||
*b.get(o.checked_add(2)?)?,
|
||||
*b.get(o.checked_add(3)?)?,
|
||||
]))
|
||||
}
|
||||
|
||||
/// A register operand index names a system parameter (SPRM) when it carries the
|
||||
/// `0x80` bias — matching [`Vm::reg`]'s split of GPRM (`< 128`) vs SPRM
|
||||
/// (`>= 128`). Only SPRM reads are session-specific and therefore taint a
|
||||
/// compare; GPRM reads do not.
|
||||
#[inline]
|
||||
fn is_sprm(idx: u8) -> bool {
|
||||
idx >= 128
|
||||
}
|
||||
|
||||
// ── Minimal navigation register machine ──────────────────────────────────────
|
||||
|
||||
/// The subset of the DVD-Video VM register file the First-Play resolver needs:
|
||||
/// 16 general parameters (GPRMs) and the system parameters (SPRMs). Execution
|
||||
/// starts from a cold machine (all zero) — the same starting point a player has
|
||||
/// before any user interaction. The SPRMs a First-Play routine might branch on
|
||||
/// (region, parental level, language) are player-/session-specific and unknown
|
||||
/// at scan time; leaving them zero means the resolver only commits to a title
|
||||
/// when the program reaches one *without* depending on them (an unconditional
|
||||
/// dispatch, or a conditional whose zero-register path a player also takes).
|
||||
/// When a branch genuinely depends on an unknown SPRM the routine is treated as
|
||||
/// undecidable and the caller falls back — see the module contract.
|
||||
struct Vm {
|
||||
gprm: [u16; 16],
|
||||
sprm: [u16; 24],
|
||||
/// Per-GPRM taint: set when the register holds a value derived from a system
|
||||
/// parameter (SPRM). An SPRM read taints a compare directly ([`is_sprm`]);
|
||||
/// this bit tracks an SPRM *laundered through* a GPRM store so a later
|
||||
/// GPRM-only compare that reads it is still recognised as undecidable.
|
||||
gprm_tainted: [bool; 16],
|
||||
}
|
||||
|
||||
impl Vm {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
gprm: [0; 16],
|
||||
sprm: [0; 24],
|
||||
gprm_tainted: [false; 16],
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a register *operand read* is session-specific: an SPRM read is
|
||||
/// always undecidable at scan time; a GPRM read is undecidable only when the
|
||||
/// register currently holds an SPRM-derived (tainted) value.
|
||||
fn reg_tainted(&self, idx: u8) -> bool {
|
||||
if is_sprm(idx) {
|
||||
true
|
||||
} else {
|
||||
self.gprm_tainted[(idx & 0x0F) as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a GPRM's post-state as unknown. Used when a store to it was guarded
|
||||
/// by an undecidable (tainted) predicate: whether the write happened is
|
||||
/// itself unknown, so the destination is conservatively tainted.
|
||||
fn taint_gprm(&mut self, reg: u8) {
|
||||
self.gprm_tainted[(reg & 0x0F) as usize] = true;
|
||||
}
|
||||
|
||||
/// Read a register operand. Per the VM command model, indices `< 16` are
|
||||
/// GPRMs and `>= 128` are SPRMs (0-23 after the `0x80` bias); anything out
|
||||
/// of range reads as 0 rather than panicking.
|
||||
fn reg(&self, idx: u8) -> u16 {
|
||||
if idx >= 128 {
|
||||
self.sprm.get((idx - 128) as usize).copied().unwrap_or(0)
|
||||
} else {
|
||||
self.gprm.get((idx & 0x0F) as usize).copied().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a compare predicate (op codes per [`Compare`]). Returns the
|
||||
/// predicate result together with a *taint* flag that is set when either
|
||||
/// operand read a system parameter (SPRM). An SPRM's value is
|
||||
/// player-/session-specific (region, parental level, language) and unknown
|
||||
/// at scan time, so a branch decision that depends on one is undecidable —
|
||||
/// the caller abstains rather than commit to the cold-power-on arm. A GPRM
|
||||
/// read taints only when that register was itself loaded from an SPRM
|
||||
/// (tracked by [`Vm::gprm_tainted`]); a GPRM holding a program-established
|
||||
/// value does not taint.
|
||||
fn eval(&self, c: &Compare) -> (bool, bool) {
|
||||
let mut tainted = self.reg_tainted(c.lhs_reg);
|
||||
let l = self.reg(c.lhs_reg);
|
||||
let r = if c.immediate {
|
||||
c.imm
|
||||
} else {
|
||||
tainted |= self.reg_tainted(c.rhs_reg);
|
||||
self.reg(c.rhs_reg)
|
||||
};
|
||||
let result = match c.op {
|
||||
1 => (l & r) != 0,
|
||||
2 => l == r,
|
||||
3 => l != r,
|
||||
4 => l >= r,
|
||||
5 => l > r,
|
||||
6 => l <= r,
|
||||
7 => l < r,
|
||||
_ => false,
|
||||
};
|
||||
(result, tainted)
|
||||
}
|
||||
|
||||
/// Apply a `SetGPRM`. Only the arithmetic/logic set-ops that a First-Play
|
||||
/// routine uses to prepare a dispatch are modelled; unmodelled ops
|
||||
/// (swap/rnd) leave the register unchanged (best-effort, never panic).
|
||||
fn set(&mut self, reg: u8, op: u8, immediate: bool, imm: u16, src: u8) {
|
||||
let idx = (reg & 0x0F) as usize;
|
||||
let v = if immediate { imm } else { self.reg(src) };
|
||||
// The value's taint follows its inputs: an immediate is concrete
|
||||
// (untainted); a register source carries its own taint ([`reg_tainted`]
|
||||
// covers both a direct SPRM read and a laundered GPRM).
|
||||
let src_tainted = !immediate && self.reg_tainted(src);
|
||||
let cur = self.gprm[idx];
|
||||
let cur_tainted = self.gprm_tainted[idx];
|
||||
// Each arm sets (new value, new taint). `mov` overwrites, so its taint is
|
||||
// the source's alone — this is what CLEARS a prior taint on a concrete
|
||||
// store. The accumulating ops fold the source into the current value, so
|
||||
// their taint is the union. Unmodelled ops leave value and taint intact.
|
||||
let (nv, nt) = match op {
|
||||
1 => (v, src_tainted), // mov
|
||||
3 => (cur.wrapping_add(v), cur_tainted || src_tainted), // add
|
||||
4 => (cur.wrapping_sub(v), cur_tainted || src_tainted), // sub
|
||||
5 => (cur.wrapping_mul(v), cur_tainted || src_tainted), // mul
|
||||
6 => (
|
||||
cur.checked_div(v).unwrap_or(cur),
|
||||
cur_tainted || src_tainted,
|
||||
), // div (÷0 → unchanged)
|
||||
7 => (
|
||||
cur.checked_rem(v).unwrap_or(cur),
|
||||
cur_tainted || src_tainted,
|
||||
), // mod (÷0 → unchanged)
|
||||
9 => (cur & v, cur_tainted || src_tainted), // and
|
||||
10 => (cur | v, cur_tainted || src_tainted), // or
|
||||
11 => (cur ^ v, cur_tainted || src_tainted), // xor
|
||||
_ => (cur, cur_tainted), // swap/rnd/unmodelled
|
||||
};
|
||||
self.gprm[idx] = nv;
|
||||
self.gprm_tainted[idx] = nt;
|
||||
}
|
||||
}
|
||||
|
||||
// ── VMGI structure readers ───────────────────────────────────────────────────
|
||||
|
||||
/// Extract the First-Play PGC pre-command list from VMGI bytes. Returns `None`
|
||||
/// when there is no First-Play PGC, no command table, or the table is out of
|
||||
/// bounds — all of which mean "nothing to execute" → fall back.
|
||||
fn fp_pre_commands(vmg: &[u8]) -> Option<Vec<[u8; 8]>> {
|
||||
let pgc = u32_at(vmg, VMGI_FP_PGC_PTR)? as usize;
|
||||
if pgc == 0 {
|
||||
return None; // no First-Play PGC on this disc
|
||||
}
|
||||
let cmd_ptr = u16_at(vmg, pgc.checked_add(PGC_CMD_TBL_PTR)?)? as usize;
|
||||
if cmd_ptr == 0 {
|
||||
return None; // First-Play PGC carries no command table
|
||||
}
|
||||
let tbl = pgc.checked_add(cmd_ptr)?;
|
||||
// Command table header: nr_of_pre (u16) at +0, then pre-commands at +8.
|
||||
let nr_pre = (u16_at(vmg, tbl)? as usize).min(MAX_CMDS);
|
||||
let base = tbl.checked_add(8)?;
|
||||
let mut cmds = Vec::with_capacity(nr_pre);
|
||||
for i in 0..nr_pre {
|
||||
let o = base.checked_add(i.checked_mul(8)?)?;
|
||||
if o.checked_add(8)? > vmg.len() {
|
||||
break; // truncated table — execute what parsed
|
||||
}
|
||||
let mut c = [0u8; 8];
|
||||
c.copy_from_slice(&vmg[o..o + 8]);
|
||||
cmds.push(c);
|
||||
}
|
||||
Some(cmds)
|
||||
}
|
||||
|
||||
/// Parse TT_SRPT into a per-title `(vtsn, vts_ttn)` list indexed by 1-based VMG
|
||||
/// title number. This is the mapping `JumpTT` needs: a title number → the VTS
|
||||
/// title-set coordinates that identify the corresponding scanned title.
|
||||
fn tt_srpt_titles(vmg: &[u8]) -> Option<Vec<(u8, u8)>> {
|
||||
let sector = u32_at(vmg, VMGI_TT_SRPT_PTR)? as usize;
|
||||
let base = sector.checked_mul(SECTOR_BYTES)?;
|
||||
let n = (u16_at(vmg, base)? as usize).min(MAX_TT_SRPT_TITLES);
|
||||
let entries = base.checked_add(8)?;
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
// Title entries are 12 bytes: VTSN at +6, VTS_TTN at +7.
|
||||
let e = entries.checked_add(i.checked_mul(12)?)?;
|
||||
if e.checked_add(12)? > vmg.len() {
|
||||
break; // truncated — map what parsed
|
||||
}
|
||||
out.push((vmg[e + 6], vmg[e + 7]));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
// ── First-Play executor ──────────────────────────────────────────────────────
|
||||
|
||||
/// Execute the First-Play pre-command list and return the VMG title number it
|
||||
/// dispatches to, if it reaches one deterministically. Returns `None` for a
|
||||
/// program that ends in a menu/sub-domain, links within the menu domain, exits,
|
||||
/// or does not converge inside the step budget — every case the caller falls
|
||||
/// back on.
|
||||
fn run_first_play(cmds: &[[u8; 8]]) -> Option<u8> {
|
||||
let mut vm = Vm::new();
|
||||
let mut pc = 0usize;
|
||||
let mut budget = STEP_BUDGET;
|
||||
|
||||
while pc < cmds.len() {
|
||||
if budget == 0 {
|
||||
return None; // non-convergence guard
|
||||
}
|
||||
budget -= 1;
|
||||
|
||||
let cmd = vmcmd::decode(&cmds[pc]);
|
||||
|
||||
// A guarded command's predicate decides whether its instruction runs.
|
||||
// `taken` is the predicate result; `tainted` marks it undecidable at scan
|
||||
// time (it read a system parameter, directly or laundered through a
|
||||
// GPRM). A command with no predicate always runs and is never tainted.
|
||||
let (taken, tainted) = match cmd.compare {
|
||||
Some(cmp) => vm.eval(&cmp),
|
||||
None => (true, false),
|
||||
};
|
||||
|
||||
// A decidable false predicate skips this line — no effect, taint
|
||||
// unchanged. When the predicate is tainted we cannot decide `taken`, so
|
||||
// we do not skip; the per-instruction handling below is conservative.
|
||||
if !tainted && !taken {
|
||||
pc += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
match cmd.instr {
|
||||
// Title dispatch — a control transfer, the answer we are looking for.
|
||||
// Abstain only when an undecidable predicate gates it: committing
|
||||
// would pick the cold-power-on arm of an SPRM-dependent decision.
|
||||
Instr::JumpTt { ttn } => {
|
||||
if tainted {
|
||||
return None;
|
||||
}
|
||||
return Some(ttn);
|
||||
}
|
||||
|
||||
// Control flow within the pre list (Goto lines are 1-based) — also a
|
||||
// control transfer, so an undecidable guard abstains.
|
||||
Instr::Goto { line } => {
|
||||
if tainted {
|
||||
return None;
|
||||
}
|
||||
let l = line as usize;
|
||||
if l == 0 || l > cmds.len() {
|
||||
return None;
|
||||
}
|
||||
pc = l - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Register preparation before a dispatch — a store, not a control
|
||||
// transfer, so a tainted guard does NOT abstain. If the guard is
|
||||
// undecidable we do not know whether the store ran, so the
|
||||
// destination's post-state is unknown → taint it. Otherwise apply the
|
||||
// store (which itself propagates or clears the destination's taint).
|
||||
Instr::SetGprm {
|
||||
reg,
|
||||
op,
|
||||
immediate,
|
||||
imm,
|
||||
src,
|
||||
} => {
|
||||
if tainted {
|
||||
vm.taint_gprm(reg);
|
||||
} else {
|
||||
vm.set(reg, op, immediate, imm, src);
|
||||
}
|
||||
}
|
||||
|
||||
// No-effect (for resolution) instructions: keep executing. SetSystem
|
||||
// writes an SPRM (never a GPRM in this decoder), and SPRM reads are
|
||||
// caught at read time, so no GPRM taint is needed here.
|
||||
Instr::Nop | Instr::SetSystem => {}
|
||||
|
||||
// Everything else leaves the deterministically-followable path:
|
||||
// Break/Exit end the pre list with no title selected; the JumpSS
|
||||
// sub-domain jumps and the intra-domain Link ops land in a menu or
|
||||
// depend on an interactive button selection that cannot be resolved
|
||||
// statically. Abstain → caller falls back.
|
||||
_ => return None,
|
||||
}
|
||||
|
||||
pc += 1;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// ── Public entry points ──────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve the main-feature title from raw VMGI (`VIDEO_TS.IFO`) bytes.
|
||||
///
|
||||
/// Pure and total: any malformed/hostile input returns `None`, never panics.
|
||||
/// This is the harness entry point.
|
||||
pub fn resolve_from_vmg(vmg: &[u8]) -> Option<ResolvedTitle> {
|
||||
if vmg.len() < 0xC8 || &vmg[0..12] != VMGI_MAGIC {
|
||||
return None;
|
||||
}
|
||||
let cmds = fp_pre_commands(vmg)?;
|
||||
let ttn = run_first_play(&cmds)?;
|
||||
if ttn == 0 {
|
||||
return None; // title 0 is not addressable
|
||||
}
|
||||
let titles = tt_srpt_titles(vmg)?;
|
||||
let (vtsn, vts_ttn) = titles.get((ttn as usize) - 1).copied()?;
|
||||
if vtsn == 0 {
|
||||
return None; // invalid TT_SRPT entry
|
||||
}
|
||||
Some(ResolvedTitle {
|
||||
title: ttn,
|
||||
vtsn,
|
||||
vts_ttn,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the main-feature title by reading the disc's VMGI and following its
|
||||
/// First-Play navigation. Returns `None` (→ caller keeps the heuristic) when the
|
||||
/// IFO cannot be read or the navigation does not deterministically reach a
|
||||
/// title.
|
||||
pub fn resolve_main_title(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ResolvedTitle> {
|
||||
let vmg = udf.read_file(reader, "/VIDEO_TS/VIDEO_TS.IFO").ok()?;
|
||||
let resolved = resolve_from_vmg(&vmg)?;
|
||||
tracing::debug!(
|
||||
target: "freemkv::dvdnav",
|
||||
title = resolved.title,
|
||||
vtsn = resolved.vtsn,
|
||||
vts_ttn = resolved.vts_ttn,
|
||||
"nav resolved main-feature title from First-Play program"
|
||||
);
|
||||
Some(resolved)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Fixture builders ────────────────────────────────────────────────────
|
||||
|
||||
/// Assemble a VMGI (`VIDEO_TS.IFO`) image with a First-Play PGC whose
|
||||
/// pre-command list is `pre`, and a TT_SRPT at sector `tt_srpt_sector`
|
||||
/// mapping each title to `(vtsn, vts_ttn)`.
|
||||
///
|
||||
/// Layout: the fixed VMGI header (magic + the FP_PGC and TT_SRPT pointers),
|
||||
/// then the FP_PGC (with a command table holding `pre`), then the TT_SRPT at
|
||||
/// its sector. Kept minimal but spec-shaped so the same code path a real
|
||||
/// disc takes is exercised.
|
||||
fn build_vmgi(pre: &[[u8; 8]], tt_srpt_sector: u32, titles: &[(u8, u8)]) -> Vec<u8> {
|
||||
// Place the FP_PGC at a fixed byte offset past the header.
|
||||
let fp_pgc_off: u32 = 0x400;
|
||||
// Command table sits 0x100 bytes into the PGC.
|
||||
let cmd_tbl_rel: u16 = 0x100;
|
||||
|
||||
let mut v = vec![0u8; 0x800];
|
||||
v[0..12].copy_from_slice(VMGI_MAGIC);
|
||||
v[VMGI_FP_PGC_PTR..VMGI_FP_PGC_PTR + 4].copy_from_slice(&fp_pgc_off.to_be_bytes());
|
||||
v[VMGI_TT_SRPT_PTR..VMGI_TT_SRPT_PTR + 4].copy_from_slice(&tt_srpt_sector.to_be_bytes());
|
||||
|
||||
// FP_PGC: command-table pointer at +0xE4.
|
||||
let pgc = fp_pgc_off as usize;
|
||||
v[pgc + PGC_CMD_TBL_PTR..pgc + PGC_CMD_TBL_PTR + 2]
|
||||
.copy_from_slice(&cmd_tbl_rel.to_be_bytes());
|
||||
|
||||
// Command table: nr_of_pre at +0, pre-commands from +8.
|
||||
let tbl = pgc + cmd_tbl_rel as usize;
|
||||
v[tbl..tbl + 2].copy_from_slice(&(pre.len() as u16).to_be_bytes());
|
||||
for (i, c) in pre.iter().enumerate() {
|
||||
let o = tbl + 8 + i * 8;
|
||||
v[o..o + 8].copy_from_slice(c);
|
||||
}
|
||||
|
||||
// TT_SRPT at its sector: count at +0, 12-byte entries from +8.
|
||||
let base = tt_srpt_sector as usize * SECTOR_BYTES;
|
||||
let need = base + 8 + titles.len() * 12;
|
||||
if v.len() < need {
|
||||
v.resize(need, 0);
|
||||
}
|
||||
v[base..base + 2].copy_from_slice(&(titles.len() as u16).to_be_bytes());
|
||||
for (i, &(vtsn, vts_ttn)) in titles.iter().enumerate() {
|
||||
let e = base + 8 + i * 12;
|
||||
v[e + 6] = vtsn;
|
||||
v[e + 7] = vts_ttn;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn h(s: &str) -> [u8; 8] {
|
||||
let v: Vec<u8> = (0..8)
|
||||
.map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap())
|
||||
.collect();
|
||||
v.try_into().unwrap()
|
||||
}
|
||||
|
||||
// ── Executor: convergence to a title ────────────────────────────────────
|
||||
|
||||
/// Unconditional-dispatch shape: First-Play is an unconditional `JumpTT 1`,
|
||||
/// and TT_SRPT maps title 1 to the feature title set (here VTS_02, title 1).
|
||||
/// The resolver must return that title.
|
||||
#[test]
|
||||
fn first_play_jumptt_1_resolves_to_its_title_set() {
|
||||
// 3002...0001 = JumpTT ttn=1.
|
||||
let vmgi = build_vmgi(&[h("3002000000010000")], 1, &[(2, 1), (3, 1)]);
|
||||
assert_eq!(
|
||||
resolve_from_vmg(&vmgi),
|
||||
Some(ResolvedTitle {
|
||||
title: 1,
|
||||
vtsn: 2,
|
||||
vts_ttn: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// A First-Play that sets up a register and then unconditionally dispatches
|
||||
/// still resolves (the SetGPRM is a no-op for an unconditional JumpTT).
|
||||
#[test]
|
||||
fn setgprm_then_jumptt_resolves() {
|
||||
let vmgi = build_vmgi(
|
||||
&[h("7100000603e80000"), h("3002000000020000")],
|
||||
1,
|
||||
&[(1, 1), (4, 1)],
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_from_vmg(&vmgi),
|
||||
Some(ResolvedTitle {
|
||||
title: 2,
|
||||
vtsn: 4,
|
||||
vts_ttn: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// A conditional dispatch whose predicate is true on the cold (zero)
|
||||
/// machine is taken. `if g0 == 0 -> JumpTT 1` with g0 defaulting to 0.
|
||||
#[test]
|
||||
fn conditional_jumptt_true_on_cold_machine_is_taken() {
|
||||
// 30 22: jump with EQ compare (op=2), operands are registers b6/b7 both
|
||||
// 0 (g0 == g0) → true; JumpTT ttn=b5=1.
|
||||
let vmgi = build_vmgi(&[h("3022000000010000")], 1, &[(5, 1)]);
|
||||
assert_eq!(
|
||||
resolve_from_vmg(&vmgi),
|
||||
Some(ResolvedTitle {
|
||||
title: 1,
|
||||
vtsn: 5,
|
||||
vts_ttn: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// A conditional dispatch that is false falls through to the next line's
|
||||
/// unconditional dispatch.
|
||||
#[test]
|
||||
fn false_conditional_falls_through_to_next_dispatch() {
|
||||
// line 1: 30 32 with GT compare (op=3 = !=) g0(b6=1?) ... build a clearly
|
||||
// false compare: if g0 != g0 -> JumpTT 9 (false, skipped);
|
||||
// line 2: unconditional JumpTT 1.
|
||||
let vmgi = build_vmgi(
|
||||
&[h("3032000000090000"), h("3002000000010000")],
|
||||
1,
|
||||
&[(7, 1)],
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_from_vmg(&vmgi),
|
||||
Some(ResolvedTitle {
|
||||
title: 1,
|
||||
vtsn: 7,
|
||||
vts_ttn: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ── Executor: abstention (→ caller falls back) ──────────────────────────
|
||||
|
||||
/// Menu-entry shape: First-Play jumps into a VTS menu (`JumpSS VTSM root`).
|
||||
/// That is an interactive menu, not a static dispatch — the resolver must
|
||||
/// abstain so the caller keeps the leading-cell heuristic.
|
||||
#[test]
|
||||
fn first_play_into_menu_abstains() {
|
||||
// 3006...0183 = JumpSS VTSM root.
|
||||
let vmgi = build_vmgi(&[h("3006000101830000")], 1, &[(3, 1)]);
|
||||
assert_eq!(resolve_from_vmg(&vmgi), None);
|
||||
}
|
||||
|
||||
/// A dispatch gated on a system parameter (SPRM) is undecidable at scan
|
||||
/// time — the resolver must abstain rather than commit to the cold-power-on
|
||||
/// arm. Here `if SPRM0 == g0 -> JumpTT 1` is *true* on the cold machine
|
||||
/// (both read 0), so without SPRM taint-tracking the resolver would wrongly
|
||||
/// return title 1; with it, the SPRM read taints the branch and it abstains.
|
||||
#[test]
|
||||
fn sprm_gated_dispatch_abstains() {
|
||||
// 30 22: jump with EQ compare (op=2); if_v2 operands are registers
|
||||
// b6/b7. b6=0x80 = SPRM0 (session-specific), b7=0x00 = g0. ttn=b5=1.
|
||||
let vmgi = build_vmgi(&[h("3022000000018000")], 1, &[(2, 1)]);
|
||||
assert_eq!(resolve_from_vmg(&vmgi), None);
|
||||
}
|
||||
|
||||
/// The mirror of the SPRM case: a compare that reads only GPRMs is decidable
|
||||
/// and must NOT over-abstain. `if g0 == g1 -> JumpTT 1` is true on the cold
|
||||
/// machine and resolves to title 1 (no SPRM operand → no taint).
|
||||
#[test]
|
||||
fn gprm_only_compare_does_not_taint() {
|
||||
// 30 22: jump EQ; b6=0x00 = g0, b7=0x01 = g1 (both 0 on the cold
|
||||
// machine → equal). ttn=b5=1.
|
||||
let vmgi = build_vmgi(&[h("3022000000010001")], 1, &[(6, 1)]);
|
||||
assert_eq!(
|
||||
resolve_from_vmg(&vmgi),
|
||||
Some(ResolvedTitle {
|
||||
title: 1,
|
||||
vtsn: 6,
|
||||
vts_ttn: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// Defect-1 regression (over-abstention): a store guarded by an SPRM-tainted
|
||||
/// compare does NOT gate a control transfer, so it must not abstain. Here
|
||||
/// `if SPRM0 == g0 -> g0 = 5` (a guarded store) is followed by an
|
||||
/// UNCONDITIONAL `JumpTT 1`. The tainted predicate only decides whether the
|
||||
/// store runs; the dispatch that follows is SPRM-independent, so the resolver
|
||||
/// must resolve to title 1.
|
||||
#[test]
|
||||
fn sprm_guarded_store_then_unconditional_dispatch_resolves() {
|
||||
// line 0: 71 20 | lhs=b2=0x80 (SPRM0), cmp EQ(op=2, register), rhs=b7=0
|
||||
// (g0) → tainted guard; SetGPRM g0 = imm(bytes4-5)=5 (mov).
|
||||
// line 1: unconditional JumpTT 1.
|
||||
let vmgi = build_vmgi(
|
||||
&[h("7120800000050000"), h("3002000000010000")],
|
||||
1,
|
||||
&[(2, 1)],
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_from_vmg(&vmgi),
|
||||
Some(ResolvedTitle {
|
||||
title: 1,
|
||||
vtsn: 2,
|
||||
vts_ttn: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// Defect-2 regression (under-abstention): an SPRM laundered through a GPRM
|
||||
/// must still taint a later branch. `SetGPRM g0 = SPRM20` (register source,
|
||||
/// index >= 128) followed by `if g0 == g1 -> JumpTT 1` — the compare reads
|
||||
/// only GPRMs, but g0 now holds an SPRM-derived value, so the decision is
|
||||
/// undecidable and the resolver must abstain.
|
||||
#[test]
|
||||
fn sprm_laundered_through_gprm_abstains() {
|
||||
// line 0: 61 00 | SetGPRM g0 = SPRM20 (mov, register src=b5=0x94).
|
||||
// line 1: 30 22 | if g0 == g1 -> JumpTT 1 (both 0 on cold machine).
|
||||
let vmgi = build_vmgi(
|
||||
&[h("6100000000940000"), h("3022000000010001")],
|
||||
1,
|
||||
&[(2, 1)],
|
||||
);
|
||||
assert_eq!(resolve_from_vmg(&vmgi), None);
|
||||
}
|
||||
|
||||
/// Guard: an unconditional immediate store CLEARS a prior taint (no sticky
|
||||
/// latch). Taint g0 from an SPRM, overwrite it with an immediate, then branch
|
||||
/// on g0 — the branch is now decidable and must resolve, not abstain.
|
||||
#[test]
|
||||
fn unconditional_immediate_store_clears_taint() {
|
||||
// line 0: SetGPRM g0 = SPRM20 (taints g0).
|
||||
// line 1: SetGPRM g0 = imm 0 (mov, immediate) → clears taint, g0 = 0.
|
||||
// line 2: if g0 == g1 -> JumpTT 1 (both 0) → resolves.
|
||||
let vmgi = build_vmgi(
|
||||
&[
|
||||
h("6100000000940000"),
|
||||
h("7100000000000000"),
|
||||
h("3022000000010001"),
|
||||
],
|
||||
1,
|
||||
&[(2, 1)],
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_from_vmg(&vmgi),
|
||||
Some(ResolvedTitle {
|
||||
title: 1,
|
||||
vtsn: 2,
|
||||
vts_ttn: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// Guard: copying an UNtainted GPRM does not taint the destination. `g0 = g1`
|
||||
/// (register source, g1 untainted) then `if g0 == g2 -> JumpTT 1` must
|
||||
/// resolve (no SPRM ever entered g0).
|
||||
#[test]
|
||||
fn gprm_copy_of_untainted_gprm_does_not_taint() {
|
||||
// line 0: 61 00 | SetGPRM g0 = g1 (mov, register src=b5=1).
|
||||
// line 1: 30 22 | if g0 == g2 -> JumpTT 1 (both 0) → resolves.
|
||||
let vmgi = build_vmgi(
|
||||
&[h("6100000000010000"), h("3022000000010002")],
|
||||
1,
|
||||
&[(2, 1)],
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_from_vmg(&vmgi),
|
||||
Some(ResolvedTitle {
|
||||
title: 1,
|
||||
vtsn: 2,
|
||||
vts_ttn: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty First-Play pre list selects no title.
|
||||
#[test]
|
||||
fn empty_first_play_abstains() {
|
||||
let vmgi = build_vmgi(&[], 1, &[(2, 1)]);
|
||||
assert_eq!(resolve_from_vmg(&vmgi), None);
|
||||
}
|
||||
|
||||
/// A JumpTT whose title number is past the TT_SRPT table resolves to no
|
||||
/// coordinates (→ abstain) rather than indexing out of bounds.
|
||||
#[test]
|
||||
fn jumptt_past_tt_srpt_abstains() {
|
||||
// JumpTT ttn=9 but TT_SRPT declares only 1 title.
|
||||
let vmgi = build_vmgi(&[h("3002000000090000")], 1, &[(2, 1)]);
|
||||
assert_eq!(resolve_from_vmg(&vmgi), None);
|
||||
}
|
||||
|
||||
/// A self-referential `Goto` cannot spin forever — the step budget stops it
|
||||
/// and the resolver abstains.
|
||||
#[test]
|
||||
fn self_goto_hits_budget_and_abstains() {
|
||||
// Goto line 1 (0-special sub 1), byte7 = 01.
|
||||
let vmgi = build_vmgi(&[h("0001000000000001")], 1, &[(2, 1)]);
|
||||
assert_eq!(resolve_from_vmg(&vmgi), None);
|
||||
}
|
||||
|
||||
// ── Robustness ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn bad_magic_abstains() {
|
||||
let mut vmgi = build_vmgi(&[h("3002000000010000")], 1, &[(2, 1)]);
|
||||
vmgi[0] = b'X';
|
||||
assert_eq!(resolve_from_vmg(&vmgi), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_short_inputs_never_panic() {
|
||||
// Below the header minimum, and a valid header truncated at every length.
|
||||
assert_eq!(resolve_from_vmg(&[]), None);
|
||||
assert_eq!(resolve_from_vmg(VMGI_MAGIC), None);
|
||||
let full = build_vmgi(&[h("3002000000010000")], 1, &[(2, 1)]);
|
||||
for len in 0..full.len() {
|
||||
let _ = resolve_from_vmg(&full[..len]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A TT_SRPT pointer that would overflow when scaled to a byte offset must
|
||||
/// be rejected, not wrap. Build a normal fixture, then overwrite just the
|
||||
/// TT_SRPT sector pointer with the extreme value (building a real table at
|
||||
/// that sector would allocate terabytes in the fixture, not the resolver).
|
||||
#[test]
|
||||
fn overflowing_tt_srpt_sector_abstains() {
|
||||
let mut vmgi = build_vmgi(&[h("3002000000010000")], 1, &[(2, 1)]);
|
||||
vmgi[VMGI_TT_SRPT_PTR..VMGI_TT_SRPT_PTR + 4].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(resolve_from_vmg(&vmgi), None);
|
||||
}
|
||||
}
|
||||
+36
-17
@@ -163,9 +163,11 @@ fn be16(b: &[u8; 8], o: usize) -> u16 {
|
||||
// nibble is always `byte1` bits 6-4; the immediate flag is `byte1` bit 7. The
|
||||
// operand *offsets* differ by command family.
|
||||
//
|
||||
// v1 (special + link): lhs reg = b[3]; rhs imm = bytes4-5 / rhs reg = b[4].
|
||||
// v1 (special + link): lhs reg = b[3]; rhs imm = bytes4-5 / rhs reg = b[5]
|
||||
// (the register operand is the low byte of the reg-or-immediate field).
|
||||
// v2 (jump + system-set): lhs reg = b[6]; rhs reg = b[7] (registers only).
|
||||
// v3 (set-GPRM): lhs reg = b[2]; rhs imm = bytes6-7 / rhs reg = b[6].
|
||||
// v3 (set-GPRM): lhs reg = b[2]; rhs imm = bytes6-7 / rhs reg = b[7]
|
||||
// (the register operand is the low byte of the reg-or-immediate field).
|
||||
fn if_v1(b: &[u8; 8]) -> Option<Compare> {
|
||||
let op = (b[1] >> 4) & 7;
|
||||
(op != 0).then(|| Compare {
|
||||
@@ -173,7 +175,7 @@ fn if_v1(b: &[u8; 8]) -> Option<Compare> {
|
||||
lhs_reg: b[3],
|
||||
immediate: b[1] >> 7 != 0,
|
||||
imm: be16(b, 4),
|
||||
rhs_reg: b[4],
|
||||
rhs_reg: b[5],
|
||||
})
|
||||
}
|
||||
fn if_v2(b: &[u8; 8]) -> Option<Compare> {
|
||||
@@ -193,7 +195,7 @@ fn if_v3(b: &[u8; 8]) -> Option<Compare> {
|
||||
lhs_reg: b[2],
|
||||
immediate: b[1] >> 7 != 0,
|
||||
imm: be16(b, 6),
|
||||
rhs_reg: b[6],
|
||||
rhs_reg: b[7],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -295,16 +297,16 @@ mod tests {
|
||||
v.try_into().unwrap()
|
||||
}
|
||||
|
||||
// KATs taken from the real SOTL / Greenland discs (decoded in the PoC).
|
||||
// KATs taken from real-world First-Play command streams.
|
||||
#[test]
|
||||
fn greenland_first_play_is_jumptt_1() {
|
||||
fn first_play_jumptt_1_decodes() {
|
||||
let c = decode(&h("3002000000010000"));
|
||||
assert_eq!(c.instr, Instr::JumpTt { ttn: 1 });
|
||||
assert!(c.compare.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sotl_first_play_is_jumpss_vtsm_root() {
|
||||
fn first_play_jumpss_vtsm_root_decodes() {
|
||||
// 30 06 ... byte5=0x83 -> sub 2 (VTSM), vts=byte4=1, menu=byte5&0xF=3 (root)
|
||||
let c = decode(&h("3006000101830000"));
|
||||
assert_eq!(
|
||||
@@ -318,8 +320,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sotl_title_dispatch_is_conditional_linkpgn_2() {
|
||||
// 20 a6 ... CmpLink: if GPRM0 == 2 -> LinkPGN 2 (cell 2 = the 5:02 start)
|
||||
fn title_dispatch_is_conditional_linkpgn_2() {
|
||||
// 20 a6 ... CmpLink: if GPRM0 == 2 -> LinkPGN 2 (the cell where the
|
||||
// feature begins).
|
||||
let c = decode(&h("20a6000000020002"));
|
||||
assert_eq!(c.instr, Instr::LinkPgn { pgn: 2 });
|
||||
let cmp = c.compare.expect("conditional");
|
||||
@@ -330,7 +333,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sotl_root_button_is_linkpgcn_37() {
|
||||
fn root_button_is_linkpgcn_37() {
|
||||
assert_eq!(
|
||||
decode(&h("2004000000000025")).instr,
|
||||
Instr::LinkPgcn { pgcn: 37 }
|
||||
@@ -338,7 +341,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn greenland_scene_button_is_linkpgn() {
|
||||
fn scene_button_is_linkpgn() {
|
||||
assert_eq!(
|
||||
decode(&h("2006000000001401")).instr,
|
||||
Instr::LinkPgn { pgn: 1 }
|
||||
@@ -354,7 +357,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn setgprm_immediate_mov() {
|
||||
// SOTL First-Play pre[0]: 71 00 | reg=byte3=6 | imm(bytes4-5)=0x03e8 -> g6 = 1000
|
||||
// First-Play pre[0]: 71 00 | reg=byte3=6 | imm(bytes4-5)=0x03e8 -> g6 = 1000
|
||||
match decode(&h("7100000603e80000")).instr {
|
||||
Instr::SetGprm {
|
||||
reg,
|
||||
@@ -382,16 +385,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// if_version_1 register compare: rhs register is byte4 (not byte5).
|
||||
// if_version_1 register compare: the register operand is the LOW byte of the
|
||||
// reg-or-immediate field (byte5), NOT the high byte (byte4). Distinct values
|
||||
// in byte4 (0xAA) vs byte5 (0x05) pin the field down.
|
||||
#[test]
|
||||
fn link_register_compare_rhs_is_byte4() {
|
||||
// 20 26: link, cmp=EQ(2), dircmp=0(register) ; cmd=6 LinkPGN
|
||||
let c = decode(&h("2026000304000002"));
|
||||
fn link_register_compare_rhs_is_low_byte() {
|
||||
// 20 26: link, cmp=EQ(2), dircmp=0(register) ; cmd=6 LinkPGN (pgn=byte7=2)
|
||||
let c = decode(&h("20260003aa050002"));
|
||||
assert_eq!(c.instr, Instr::LinkPgn { pgn: 2 });
|
||||
let cmp = c.compare.expect("conditional");
|
||||
assert!(!cmp.immediate);
|
||||
assert_eq!(cmp.lhs_reg, 3);
|
||||
assert_eq!(cmp.rhs_reg, 4);
|
||||
assert_eq!(cmp.rhs_reg, 5); // low byte of bytes4-5, not the 0xAA high byte
|
||||
}
|
||||
|
||||
// if_version_3 (set-GPRM) register compare: the register operand is the LOW
|
||||
// byte of the reg-or-immediate field (byte7), NOT the high byte (byte6).
|
||||
// Distinct values in byte6 (0xBB) vs byte7 (0x07) pin the field down.
|
||||
#[test]
|
||||
fn setgprm_register_compare_rhs_is_low_byte() {
|
||||
// 71 20: set-GPRM (type 3), cmp=EQ(2), dircmp=0(register); lhs=byte2=2.
|
||||
let c = decode(&h("712002000000bb07"));
|
||||
let cmp = c.compare.expect("conditional");
|
||||
assert_eq!(cmp.op, 2);
|
||||
assert!(!cmp.immediate);
|
||||
assert_eq!(cmp.lhs_reg, 2);
|
||||
assert_eq!(cmp.rhs_reg, 7); // low byte of bytes6-7, not the 0xBB high byte
|
||||
}
|
||||
|
||||
// if_version_2 jump compare: both operands are registers in byte6 / byte7.
|
||||
|
||||
+896
-108
File diff suppressed because it is too large
Load Diff
+387
@@ -0,0 +1,387 @@
|
||||
//! Seeded robustness harness for the untrusted-input parsers.
|
||||
//!
|
||||
//! Every parser reached from here takes bytes that came off a disc, and this
|
||||
//! crate's primary boundary is that the disc is untrusted: a malformed, damaged
|
||||
//! or hostile image must never crash the library. These tests assert exactly
|
||||
//! that one property — **the parser returns `Ok` or `Err`, and never panics.**
|
||||
//!
|
||||
//! # Why this exists rather than `cargo-fuzz`
|
||||
//!
|
||||
//! `cargo-fuzz` needs a nightly toolchain (`-Zsanitizer` plus SanitizerCoverage
|
||||
//! for libFuzzer's coverage feedback) and this project pins stable. So the
|
||||
//! generator lives here instead. It gives up coverage-guided mutation — the real
|
||||
//! loss — and keeps everything else: millions of cases, structure-aware input,
|
||||
//! and a crash corpus. It also gains determinism, which a fuzzer does not have:
|
||||
//! the same seed replays the same cases on any machine.
|
||||
//!
|
||||
//! # Why no `proptest` or `arbitrary`
|
||||
//!
|
||||
//! This crate has exactly one dev-dependency. That is a deliberate posture, and
|
||||
//! a randomness crate is not worth ten transitive dependencies when the parsers
|
||||
//! take plain `&[u8]` and a good enough generator is forty lines.
|
||||
//!
|
||||
//! # Budget
|
||||
//!
|
||||
//! `FREEMKV_HARNESS_CASES` sets cases per generator per target (default 256, low
|
||||
//! enough that the per-commit gate stays under a second). The overnight run sets
|
||||
//! it to millions. `FREEMKV_HARNESS_SEED` overrides the seed; the default is
|
||||
//! fixed so a failure in CI reproduces locally verbatim.
|
||||
//!
|
||||
//! # On failure
|
||||
//!
|
||||
//! The panic message carries the seed, generator and case index. Re-run with
|
||||
//! `FREEMKV_HARNESS_SEED=<seed>` to reproduce, then write the offending bytes
|
||||
//! into `tests/corpus/` as a permanent regression fixture — discovery happens
|
||||
//! here, defence happens there.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
/// Marsaglia xorshift64. Not cryptographic and does not need to be: the job is
|
||||
/// a reproducible spread of bytes, and a named algorithm beats an ad-hoc LCG
|
||||
/// whose period nobody has checked.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn new(seed: u64) -> Self {
|
||||
// A zero seed is a fixed point of xorshift — it would emit zeros forever
|
||||
// and every generated case would be identical.
|
||||
Self(if seed == 0 {
|
||||
0x2545_F491_4F6C_DD1D
|
||||
} else {
|
||||
seed
|
||||
})
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
|
||||
fn byte(&mut self) -> u8 {
|
||||
(self.next() >> 24) as u8
|
||||
}
|
||||
|
||||
/// Uniform-ish in `0..n`. The modulo bias is irrelevant at these magnitudes.
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
if n == 0 {
|
||||
0
|
||||
} else {
|
||||
(self.next() % n as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
fn fill(&mut self, len: usize) -> Vec<u8> {
|
||||
(0..len).map(|_| self.byte()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Budget per generator per target.
|
||||
fn cases() -> usize {
|
||||
std::env::var("FREEMKV_HARNESS_CASES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(256)
|
||||
}
|
||||
|
||||
fn seed() -> u64 {
|
||||
std::env::var("FREEMKV_HARNESS_SEED")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0x5EED_1234_ABCD_0001)
|
||||
}
|
||||
|
||||
/// Largest generated input. Big enough to carry a plausible header plus a body,
|
||||
/// small enough that millions of cases stay quick.
|
||||
const MAX_LEN: usize = 4096;
|
||||
|
||||
/// Drive `f` over three generators and report which case broke it.
|
||||
///
|
||||
/// A panic inside `f` fails the test on its own — nothing is caught here,
|
||||
/// because catching would risk reporting a pass on an input that aborted. The
|
||||
/// wrapper exists to make the failing case *identifiable*: the harness prints
|
||||
/// the seed, generator and index before each call, so the last line before a
|
||||
/// panic names the exact case to reproduce.
|
||||
fn sweep<F: FnMut(&[u8])>(target: &str, magic: &[u8], f: F) {
|
||||
sweep_n(target, magic, cases(), f)
|
||||
}
|
||||
|
||||
/// `sweep` with an explicit budget. The budget is a PARAMETER rather than read
|
||||
/// from the environment inside the loop: the meta-tests below need a small,
|
||||
/// fixed count, and `std::env::set_var` is unsound once the test harness runs
|
||||
/// tests in parallel — two tests setting the same variable race, which is
|
||||
/// exactly what happened on the first run of this file.
|
||||
fn sweep_n<F: FnMut(&[u8])>(target: &str, magic: &[u8], n: usize, mut f: F) {
|
||||
let s = seed();
|
||||
|
||||
// 1. Pure random bytes. Cheap, and almost always rejected at the magic
|
||||
// number — it exercises the entry guards and little else. Kept because
|
||||
// the entry guards are themselves worth exercising.
|
||||
let mut rng = Rng::new(s);
|
||||
for i in 0..n {
|
||||
let len = rng.below(MAX_LEN);
|
||||
let buf = rng.fill(len);
|
||||
run(target, "random", s, i, &buf, &mut f);
|
||||
}
|
||||
|
||||
// 2. Valid magic, random body. THE generator that matters: pure random
|
||||
// input dies at the magic check and never reaches the parser body, so
|
||||
// without this the sweep only ever tests the first few lines.
|
||||
let mut rng = Rng::new(s ^ 0xA5A5_A5A5_A5A5_A5A5);
|
||||
for i in 0..n {
|
||||
let mut buf = magic.to_vec();
|
||||
let tail = rng.below(MAX_LEN.saturating_sub(magic.len()));
|
||||
buf.extend(rng.fill(tail));
|
||||
run(target, "magic+noise", s, i, &buf, &mut f);
|
||||
}
|
||||
|
||||
// 3. Structured mutation of a plausible record: a valid magic, then mostly
|
||||
// zeroes, with a handful of bytes corrupted and a truncation. Length and
|
||||
// offset fields live in those early bytes, so this is what reaches the
|
||||
// arithmetic — the offsets, counts and sizes a hostile image would lie
|
||||
// about.
|
||||
let mut rng = Rng::new(s ^ 0x1234_5678_9ABC_DEF0);
|
||||
for i in 0..n {
|
||||
let mut buf = vec![0u8; 512];
|
||||
buf[..magic.len().min(512)].copy_from_slice(&magic[..magic.len().min(512)]);
|
||||
for _ in 0..rng.below(24) + 1 {
|
||||
let at = rng.below(buf.len());
|
||||
buf[at] = rng.byte();
|
||||
}
|
||||
buf.truncate(rng.below(buf.len()) + 1);
|
||||
run(target, "mutate", s, i, &buf, &mut f);
|
||||
}
|
||||
}
|
||||
|
||||
fn run<F: FnMut(&[u8])>(target: &str, generator: &str, seed: u64, i: usize, buf: &[u8], f: &mut F) {
|
||||
// Printed, not asserted: `cargo test` swallows stdout for passing tests and
|
||||
// shows it for failing ones, so this line is invisible until it is the last
|
||||
// thing before a panic — at which point it is exactly what is needed.
|
||||
println!(
|
||||
"harness {target}/{generator} seed={seed:#x} case={i} len={} :: \
|
||||
FREEMKV_HARNESS_SEED={seed} to reproduce",
|
||||
buf.len()
|
||||
);
|
||||
f(buf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpls_parse_never_panics() {
|
||||
sweep("mpls", b"MPLS", |b| {
|
||||
let _ = crate::mpls::parse(b);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clpi_parse_never_panics() {
|
||||
sweep("clpi", b"HDMV", |b| {
|
||||
let _ = crate::clpi::parse(b);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bdnav_index_parse_never_panics() {
|
||||
sweep("bdnav_index", b"INDX", |b| {
|
||||
let _ = crate::bdnav::index::parse(b);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bdnav_mobj_parse_never_panics() {
|
||||
sweep("bdnav_mobj", b"MOBJ", |b| {
|
||||
let _ = crate::bdnav::mobj::parse(b);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dvd_nav_resolve_never_panics() {
|
||||
// The DVD First-Play navigation resolver parses VMGI (`VIDEO_TS.IFO`) —
|
||||
// FP_PGC command table + TT_SRPT, all attacker-controlled offsets/counts —
|
||||
// and executes an untrusted command list on a bounded VM. Magic is the VMGI
|
||||
// signature so the sweep reaches past the header guard into the executor.
|
||||
sweep("dvd_nav", b"DVDVIDEO-VMG", |b| {
|
||||
let _ = crate::dvdnav::nav::resolve_from_vmg(b);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udf_name_parse_never_panics() {
|
||||
// No magic: the compression ID is the first byte and every value is legal
|
||||
// input to reject, so the "magic" is a byte the sweep will mutate anyway.
|
||||
sweep("udf_name", &[8], |b| {
|
||||
let _ = crate::udf::parse_udf_name(b);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_reader_parse_never_panics() {
|
||||
// Java `.class` files are a documented format we READ (never execute) to
|
||||
// recover BD-J stream labels — a peer of mpls/clpi here. Every count,
|
||||
// length and constant-pool index in the file is attacker-controlled disc
|
||||
// metadata, so the parser must reject malformed input with `Err`, never
|
||||
// panic. Magic is the class-file magic `0xCAFEBABE`.
|
||||
sweep("class_reader", &[0xCA, 0xFE, 0xBA, 0xBE], |b| {
|
||||
if let Ok(class) = crate::labels::class_reader::ClassFile::parse(b) {
|
||||
// Exercise the accessors a label parser drives, so their indexing
|
||||
// is fuzzed too, not just the structural parse.
|
||||
let _ = class.this_class_name();
|
||||
let _ = class.super_class_name();
|
||||
for m in &class.methods {
|
||||
let _ = class.member_name(m);
|
||||
if let Some(code) = m.code(&class.constant_pool) {
|
||||
for insn in code.instructions() {
|
||||
let _ = insn.cp_index();
|
||||
if let Some(idx) = insn.cp_index() {
|
||||
let _ = class.constant_pool.get(idx);
|
||||
let _ = class.constant_pool.member_ref(idx);
|
||||
let _ = class.constant_pool.load_constant_display(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jar_class_walk_never_panics() {
|
||||
// The jar layer opens a `/BDMV/JAR/<x>.jar` (a zip) and walks every
|
||||
// `.class` inside it, handing each to the class reader. Feed the sweep's
|
||||
// bytes as a zip archive; when they parse as one, walk the classes exactly
|
||||
// as `deluxe`/`dbp` do. Neither the zip open nor the class walk may panic.
|
||||
// Magic is the zip local-file-header signature `PK\x03\x04`.
|
||||
sweep("jar", &[0x50, 0x4B, 0x03, 0x04], |b| {
|
||||
if let Ok(mut archive) = zip::ZipArchive::new(std::io::Cursor::new(b.to_vec())) {
|
||||
crate::labels::jar::for_each_class(&mut archive, |_name, _class| {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deluxe_bytecode_decode_never_panics() {
|
||||
// The Deluxe BD-J label parser runs a symbolic stack machine over `<clinit>`
|
||||
// bytecode (`decode_binding_class`). Fuzzing it THROUGH a zip is vacuous —
|
||||
// `zip::ZipArchive::new` validates the end-of-central-directory record at the
|
||||
// buffer's end, which the generators (front magic + noise) cannot synthesize,
|
||||
// so the archive open always fails and the decoder is never reached. Drive
|
||||
// the decoder DIRECTLY instead: parse the sweep bytes as a `.class` (they also
|
||||
// exercise the class parser) and run the bytecode decoder over it with an
|
||||
// empty master-enum table. None may panic on crafted/corrupt bytecode.
|
||||
let master = crate::labels::deluxe::MasterEnumTable::from(&[] as &[(
|
||||
&'static str,
|
||||
crate::labels::deluxe::MasterEnum,
|
||||
)]);
|
||||
sweep("deluxe_decode", &[0xCA, 0xFE, 0xBA, 0xBE], |b| {
|
||||
if let Ok(class) = crate::labels::class_reader::ClassFile::parse(b) {
|
||||
let _ = crate::labels::deluxe::decode_binding_class(&class, &master);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fox_dcx_labels_never_panics() {
|
||||
// Fox's `dcx.xml` is untrusted disc-authored XML; `labels_from_dcx` scans it
|
||||
// with byte-offset XML helpers documented as UTF-8-boundary-prone. No magic —
|
||||
// feed lossy UTF-8 so multi-byte boundaries inside tags/attributes are hit.
|
||||
sweep("fox_dcx", b"<", |b| {
|
||||
let text = String::from_utf8_lossy(b);
|
||||
let _ = crate::labels::fox::labels_from_dcx(&text);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bdnav_vm_resolve_never_panics() {
|
||||
// The HDMV navigation VM executes an untrusted `MovieObject.bdmv` command
|
||||
// list on a bounded VM. Parse the sweep bytes as a MovieObject set and run
|
||||
// the VM from a First-Play entry — the combined parse+execute path a real
|
||||
// disc drives, which the per-parser index/mobj sweeps never reach.
|
||||
use crate::bdnav::index::{Index, PlaybackObj};
|
||||
sweep("bdnav_vm", b"MOBJ", |b| {
|
||||
if let Some(mobjs) = crate::bdnav::mobj::parse(b) {
|
||||
let index = Index {
|
||||
first_play: PlaybackObj::Hdmv { id_ref: 0 },
|
||||
top_menu: PlaybackObj::Hdmv { id_ref: 0 },
|
||||
titles: Vec::new(),
|
||||
};
|
||||
let _ = crate::bdnav::vm::resolve(&index, &mobjs, &|_| false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ps_demuxer_feed_never_panics() {
|
||||
// Stateful, unlike the others: the demuxer carries a buffer across feeds, so
|
||||
// each case is fed to a FRESH demuxer and then a shared one. The shared pass
|
||||
// is what exercises cross-feed state — a start code split over a boundary,
|
||||
// a held PES completed by later bytes, the carry-over cap.
|
||||
let mut shared = crate::mux::ps::PsDemuxer::new();
|
||||
sweep("ps_demux", &[0x00, 0x00, 0x01, 0xBA], |b| {
|
||||
let mut fresh = crate::mux::ps::PsDemuxer::new();
|
||||
let _ = fresh.feed(b);
|
||||
let _ = shared.feed(b);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkv_lacing_split_never_panics() {
|
||||
// All four lacing modes, including the reserved bit pattern. A degenerate
|
||||
// fixed lace was a real defect found by audit round 5.
|
||||
sweep("mkv_lacing", &[0x00], |b| {
|
||||
for lacing in 0u8..=3 {
|
||||
let _ = crate::mux::mkvstream::split_lacing(lacing, b);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The generators must actually differ, or the sweep is one generator run three
|
||||
/// times and the coverage claim is false.
|
||||
#[test]
|
||||
fn the_three_generators_produce_different_inputs() {
|
||||
let mut seen: Vec<Vec<u8>> = Vec::new();
|
||||
sweep_n("probe", b"MPLS", 1, |b| seen.push(b.to_vec()));
|
||||
assert_eq!(seen.len(), 3, "one case per generator");
|
||||
assert_ne!(seen[0], seen[1], "random and magic+noise must differ");
|
||||
assert_ne!(seen[1], seen[2], "magic+noise and mutate must differ");
|
||||
assert!(
|
||||
seen[1].starts_with(b"MPLS"),
|
||||
"the magic+noise generator must actually carry the magic, or it never \
|
||||
reaches the parser body"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same seed must replay the same bytes, or a reported failure cannot be
|
||||
/// reproduced and the harness is worthless as a regression tool.
|
||||
#[test]
|
||||
fn a_seed_replays_identically() {
|
||||
let mut a = Vec::new();
|
||||
let mut b = Vec::new();
|
||||
sweep_n("probe", b"MPLS", 4, |x| a.push(x.to_vec()));
|
||||
sweep_n("probe", b"MPLS", 4, |x| b.push(x.to_vec()));
|
||||
assert_eq!(a, b, "the same seed must produce the same cases");
|
||||
}
|
||||
|
||||
/// The harness is worthless if its cases die at the entry guards, so this
|
||||
/// MEASURES how deep they actually reach instead of assuming. A generator that
|
||||
/// never gets past a length or magic check exercises the first ten lines and
|
||||
/// nothing else — the fuzzing equivalent of a test that cannot fail.
|
||||
#[test]
|
||||
fn the_generators_actually_reach_the_parser_bodies() {
|
||||
// mpls::parse rejects at: len < 40, bad magic, then playlist_start + 10 >
|
||||
// len. Anything that returns Ok got all the way through the play-item loop.
|
||||
let mut ok = 0usize;
|
||||
let mut total = 0usize;
|
||||
sweep_n("reach", b"MPLS", 20000, |b| {
|
||||
total += 1;
|
||||
if crate::mpls::parse(b).is_ok() {
|
||||
ok += 1;
|
||||
}
|
||||
});
|
||||
assert!(
|
||||
ok > 0,
|
||||
"not one of {total} generated cases parsed successfully — the generators \
|
||||
are all being rejected at the entry guards, so this harness is testing \
|
||||
the guards and nothing behind them"
|
||||
);
|
||||
println!("mpls reach: {ok}/{total} cases parsed to completion");
|
||||
}
|
||||
+47
-5
@@ -16,11 +16,11 @@
|
||||
/// (case-insensitive), then requires an even run of ASCII hex digits. Any
|
||||
/// non-hex byte, or an odd length, yields `None`.
|
||||
pub fn parse_hex_bytes(s: &str) -> Option<Vec<u8>> {
|
||||
let body = strip_prefix(s.trim());
|
||||
let body = strip_hex_prefix(s.trim());
|
||||
let bytes = body.as_bytes();
|
||||
// Empty → empty Vec (a legitimately-empty variable-length field); odd length
|
||||
// is malformed. (`parse_hex_fixed` enforces a concrete length separately.)
|
||||
if bytes.len() % 2 != 0 {
|
||||
if !bytes.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(bytes.len() / 2);
|
||||
@@ -34,7 +34,7 @@ pub fn parse_hex_bytes(s: &str) -> Option<Vec<u8>> {
|
||||
/// prefix; requires EXACTLY `2*N` ASCII hex digits after it. `None` on any
|
||||
/// non-hex byte or a length mismatch.
|
||||
pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
|
||||
let body = strip_prefix(s.trim());
|
||||
let body = strip_hex_prefix(s.trim());
|
||||
let bytes = body.as_bytes();
|
||||
if bytes.len() != 2 * N {
|
||||
return None;
|
||||
@@ -46,8 +46,33 @@ pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Strip a single leading `0x` / `0X` if present (case-insensitive).
|
||||
fn strip_prefix(s: &str) -> &str {
|
||||
/// Parse a hex string into a `u16`. Accepts an optional `0x`/`0X` prefix
|
||||
/// (case-insensitive) via the same [`strip_hex_prefix`] the byte parsers use.
|
||||
/// `None` on any non-hex content or overflow.
|
||||
///
|
||||
/// Exists so callers never hand-roll `from_str_radix(s.trim_start_matches("0x"), 16)`
|
||||
/// — a **case-sensitive** strip that silently dropped an uppercase-`0X` value.
|
||||
/// (That reintroduced-in-keydb bug is exactly what this module was built to kill;
|
||||
/// the integer fields now share the one prefix rule.)
|
||||
pub fn parse_hex_u16(s: &str) -> Option<u16> {
|
||||
u16::from_str_radix(strip_hex_prefix(s.trim()), 16).ok()
|
||||
}
|
||||
|
||||
/// Parse a hex string into a `u32`. See [`parse_hex_u16`].
|
||||
pub fn parse_hex_u32(s: &str) -> Option<u32> {
|
||||
u32::from_str_radix(strip_hex_prefix(s.trim()), 16).ok()
|
||||
}
|
||||
|
||||
/// Parse a hex string into a `u8`. See [`parse_hex_u16`].
|
||||
pub fn parse_hex_u8(s: &str) -> Option<u8> {
|
||||
u8::from_str_radix(strip_hex_prefix(s.trim()), 16).ok()
|
||||
}
|
||||
|
||||
/// Strip a single leading `0x` / `0X` if present (case-insensitive). Public so
|
||||
/// callers that only need the prefix rule (e.g. normalizing a disc hash) reuse
|
||||
/// the one definition instead of hand-rolling a case-sensitive
|
||||
/// `trim_start_matches("0x")`.
|
||||
pub fn strip_hex_prefix(s: &str) -> &str {
|
||||
s.strip_prefix("0x")
|
||||
.or_else(|| s.strip_prefix("0X"))
|
||||
.unwrap_or(s)
|
||||
@@ -95,6 +120,23 @@ mod tests {
|
||||
assert_eq!(parse_hex_fixed::<16>(&s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_ints_accept_both_prefix_cases_and_bare() {
|
||||
// The regression the keydb device-key bug hit: uppercase `0X` must parse
|
||||
// identically to `0x` and to a bare value.
|
||||
assert_eq!(parse_hex_u16("0x0001"), Some(1));
|
||||
assert_eq!(parse_hex_u16("0X0001"), Some(1));
|
||||
assert_eq!(parse_hex_u16("0001"), Some(1));
|
||||
assert_eq!(parse_hex_u16(" 0XABCD "), Some(0xABCD));
|
||||
assert_eq!(parse_hex_u32("0X00000002"), Some(2));
|
||||
assert_eq!(parse_hex_u32("deadbeef"), Some(0xDEAD_BEEF));
|
||||
assert_eq!(parse_hex_u8("0X03"), Some(3));
|
||||
assert_eq!(parse_hex_u8("ff"), Some(0xFF));
|
||||
// Overflow / non-hex → None.
|
||||
assert_eq!(parse_hex_u8("0x1FF"), None);
|
||||
assert_eq!(parse_hex_u16("0xzz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_variable_length_and_odd_rejected() {
|
||||
assert_eq!(parse_hex_bytes("0xAABBCC"), Some(vec![0xAA, 0xBB, 0xCC]));
|
||||
|
||||
+193
-1
@@ -49,13 +49,30 @@ pub struct DriveId {
|
||||
pub raw_gc_010c: Vec<u8>,
|
||||
}
|
||||
|
||||
/// SPC-4 standard INQUIRY data: 36 bytes through `product_revision`. Anything
|
||||
/// shorter cannot populate the identity fields this type promises.
|
||||
const INQUIRY_STANDARD_LEN: usize = 36;
|
||||
|
||||
impl DriveId {
|
||||
/// Probe a real drive via SCSI and build its identity.
|
||||
pub fn from_drive(transport: &mut dyn ScsiTransport) -> Result<Self> {
|
||||
// INQUIRY — SPC-4 §6.4
|
||||
let mut inquiry = vec![0u8; 96];
|
||||
let cdb_inq = [0x12, 0x00, 0x00, 0x00, 0x60, 0x00];
|
||||
transport.execute(&cdb_inq, DataDirection::FromDevice, &mut inquiry, 5000)?;
|
||||
let inq = transport.execute(&cdb_inq, DataDirection::FromDevice, &mut inquiry, 5000)?;
|
||||
// `bytes_transferred` is device-reported and untrusted — the same rule
|
||||
// the two GET CONFIGURATION calls below already apply. It was ignored
|
||||
// here, and the buffer is pre-zeroed, so a drive answering GOOD with a
|
||||
// short or empty data phase (a USB-SATA bridge mid-wedge does exactly
|
||||
// this) decoded to blank identity strings and a byte 0 of 0x00. Every
|
||||
// platform enumerator gates on `raw_inquiry[0] & 0x1F == OPTICAL`, so
|
||||
// 0x00 reads as DIRECT ACCESS and the drive silently disappears from
|
||||
// the device list instead of reporting a failed probe.
|
||||
if inq.bytes_transferred < INQUIRY_STANDARD_LEN {
|
||||
return Err(crate::error::Error::DriveInquiryShort);
|
||||
}
|
||||
// Never decode past what the drive actually sent.
|
||||
inquiry.truncate(inq.bytes_transferred.min(inquiry.len()));
|
||||
|
||||
// GET CONFIGURATION Feature 010Ch — MMC-6 §6.6.
|
||||
// Best-effort: 010Ch (Firmware Information) is an optional feature.
|
||||
@@ -262,6 +279,54 @@ mod tests {
|
||||
/// Spec: SPC-4 §6.4.2 — bytes[8:16] are vendor ID; a truncated buffer
|
||||
/// (e.g. a device that reports fewer than 8 bytes) must not panic.
|
||||
/// Mutation: removing the `data.len() > start` guard makes it panic on short inputs.
|
||||
/// A drive that answers INQUIRY with GOOD status but a short or empty
|
||||
/// data phase must fail the probe, not present as a blank drive.
|
||||
///
|
||||
/// The buffer is pre-zeroed, so decoding it unconditionally yielded empty
|
||||
/// vendor/product/revision strings and a byte 0 of 0x00. Every platform
|
||||
/// enumerator gates on `raw_inquiry[0] & 0x1F == SCSI_PERIPHERAL_TYPE_OPTICAL`,
|
||||
/// and 0x00 is DIRECT ACCESS — so the drive silently vanished from the
|
||||
/// device list rather than reporting that its identity probe failed. A
|
||||
/// USB-SATA bridge mid-wedge does exactly this.
|
||||
///
|
||||
/// The two GET CONFIGURATION calls in the same function already clamped on
|
||||
/// `bytes_transferred`, with a comment calling it untrusted; INQUIRY, three
|
||||
/// lines above them, discarded it.
|
||||
#[test]
|
||||
fn inquiry_with_a_short_data_phase_fails_instead_of_reporting_a_blank_drive() {
|
||||
/// GOOD status, no sense, and only `n` bytes written.
|
||||
struct ShortInquiry(usize);
|
||||
impl ScsiTransport for ShortInquiry {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_buf: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
sense: [0u8; 32],
|
||||
bytes_transferred: self.0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Empty data phase — the case that made a real drive disappear.
|
||||
assert!(matches!(
|
||||
DriveId::from_drive(&mut ShortInquiry(0)),
|
||||
Err(crate::error::Error::DriveInquiryShort)
|
||||
));
|
||||
// One byte short of the SPC-4 standard 36-byte header.
|
||||
assert!(matches!(
|
||||
DriveId::from_drive(&mut ShortInquiry(35)),
|
||||
Err(crate::error::Error::DriveInquiryShort)
|
||||
));
|
||||
// Exactly the standard length is acceptable: the optional
|
||||
// vendor-specific tail past byte 36 is allowed to be absent.
|
||||
assert!(DriveId::from_drive(&mut ShortInquiry(36)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_field_short_buffer_returns_empty() {
|
||||
// Buffer of length 5: start=8 is beyond the end → empty string.
|
||||
@@ -339,9 +404,136 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `ascii_field`'s guard is `data.len() > start` (strictly greater), not
|
||||
/// `>=`: a buffer whose length is exactly `start` has NO byte at that
|
||||
/// offset, so it must still yield empty, not attempt to slice.
|
||||
/// Mutation: `>` -> `>=` would try to slice `data[start..]` when
|
||||
/// `data.len() == start`, which panics (empty range at the very end is
|
||||
/// fine, but the guard's job is the `< start` case below it — pinning the
|
||||
/// exact boundary catches an off-by-one either direction).
|
||||
#[test]
|
||||
fn ascii_field_boundary_len_equals_start_is_empty() {
|
||||
let buf = vec![0u8; 8];
|
||||
assert_eq!(ascii_field(&buf, 8, 16), "");
|
||||
}
|
||||
|
||||
/// One byte past the boundary: `data.len() == start + 1` must extract
|
||||
/// that single byte (clamped to `end`), proving the guard is `>` and not
|
||||
/// off by one in the other direction.
|
||||
#[test]
|
||||
fn ascii_field_boundary_len_one_past_start_extracts_one_byte() {
|
||||
let mut buf = vec![0u8; 9];
|
||||
buf[8] = b'X';
|
||||
assert_eq!(ascii_field(&buf, 8, 16), "X");
|
||||
}
|
||||
|
||||
/// `Display` renders the four trimmed identity fields space-separated —
|
||||
/// the human-readable counterpart of `match_key`'s pipe-separated form.
|
||||
/// Not exercised anywhere else in this test module.
|
||||
/// Mutation: replacing the `fmt` body with `Ok(Default::default())`
|
||||
/// writes nothing at all, so formatting any `DriveId` yields "".
|
||||
#[test]
|
||||
fn display_formats_trimmed_fields_space_separated() {
|
||||
let mut inquiry = vec![0u8; 96];
|
||||
inquiry[8..16].copy_from_slice(b"PIONEER ");
|
||||
inquiry[16..32].copy_from_slice(b"BD-RW BDR-S09 ");
|
||||
inquiry[32..36].copy_from_slice(b"1.34");
|
||||
inquiry[36..43].copy_from_slice(b" 16/04/");
|
||||
let id = DriveId::from_inquiry(&inquiry, "201604250000");
|
||||
assert_eq!(id.to_string(), "PIONEER BD-RW BDR-S09 1.34 16/04/");
|
||||
}
|
||||
|
||||
/// GET CONFIGURATION failure (transport error) must not abort the
|
||||
/// identity probe — firmware_date is empty, raw_gc_010c is empty.
|
||||
/// Mutation: propagating the GET_CONFIGURATION error with `?` aborts from_drive.
|
||||
/// Transport whose GET CONFIGURATION responses report an exact,
|
||||
/// caller-chosen `bytes_transferred` for each of the two GC features
|
||||
/// (010Ch firmware date / 0108h serial), so the `end > 12` / `> 12`
|
||||
/// boundary guards can be pinned precisely. INQUIRY always succeeds.
|
||||
struct FixedGcCountTransport {
|
||||
firmware_bytes: usize,
|
||||
serial_bytes: usize,
|
||||
}
|
||||
|
||||
impl ScsiTransport for FixedGcCountTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
buf: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
for b in buf.iter_mut() {
|
||||
*b = b'Z';
|
||||
}
|
||||
let bytes_transferred = match cdb.first() {
|
||||
Some(&0x12) => buf.len(),
|
||||
Some(&0x46) if cdb[3] == 0x0C => self.firmware_bytes,
|
||||
Some(&0x46) if cdb[3] == 0x08 => self.serial_bytes,
|
||||
_ => buf.len(),
|
||||
};
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `end > 12` in the firmware-date branch (`from_drive`) is a strict
|
||||
/// inequality: `bytes_transferred == 12` reports the field absent
|
||||
/// (offset 12 is the first byte of the 12-char date; a count of exactly
|
||||
/// 12 covers bytes 0..12, none of which is the date), so `firmware_date`
|
||||
/// must be empty, not the mutant's off-by-one read.
|
||||
/// Mutation: `>` -> `>=` would try `gc[12..12]` at the boundary — an
|
||||
/// empty but non-panicking slice — silently reporting "present" data
|
||||
/// that is actually all outside the transferred count.
|
||||
#[test]
|
||||
fn from_drive_firmware_date_boundary_exactly_12_is_empty() {
|
||||
let mut t = FixedGcCountTransport {
|
||||
firmware_bytes: 12,
|
||||
serial_bytes: 0,
|
||||
};
|
||||
let id = DriveId::from_drive(&mut t).unwrap();
|
||||
assert_eq!(id.firmware_date, "");
|
||||
}
|
||||
|
||||
/// One byte past the boundary (`bytes_transferred == 13`) must extract
|
||||
/// exactly the one available date byte (offset 12), proving the guard
|
||||
/// is `>` and the slice end is clamped to `end`, not always to 24.
|
||||
#[test]
|
||||
fn from_drive_firmware_date_boundary_13_extracts_one_byte() {
|
||||
let mut t = FixedGcCountTransport {
|
||||
firmware_bytes: 13,
|
||||
serial_bytes: 0,
|
||||
};
|
||||
let id = DriveId::from_drive(&mut t).unwrap();
|
||||
assert_eq!(id.firmware_date, "Z");
|
||||
}
|
||||
|
||||
/// Same `> 12` boundary for the serial-number branch: exactly 12
|
||||
/// transferred bytes must yield an empty serial.
|
||||
#[test]
|
||||
fn from_drive_serial_boundary_exactly_12_is_empty() {
|
||||
let mut t = FixedGcCountTransport {
|
||||
firmware_bytes: 0,
|
||||
serial_bytes: 12,
|
||||
};
|
||||
let id = DriveId::from_drive(&mut t).unwrap();
|
||||
assert_eq!(id.serial_number, "");
|
||||
}
|
||||
|
||||
/// One byte past the serial boundary extracts exactly that byte.
|
||||
#[test]
|
||||
fn from_drive_serial_boundary_13_extracts_one_byte() {
|
||||
let mut t = FixedGcCountTransport {
|
||||
firmware_bytes: 0,
|
||||
serial_bytes: 13,
|
||||
};
|
||||
let id = DriveId::from_drive(&mut t).unwrap();
|
||||
assert_eq!(id.serial_number, "Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_drive_gc_failure_yields_empty_firmware_date() {
|
||||
struct GcFailTransport;
|
||||
|
||||
+1172
-111
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -133,10 +133,10 @@ where
|
||||
match rx.recv_timeout(slice) {
|
||||
Ok(v) => return Ok(v),
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if let Some(h) = halt {
|
||||
if h.is_cancelled() {
|
||||
return Err(BoundedError::Halted);
|
||||
}
|
||||
if let Some(h) = halt
|
||||
&& h.is_cancelled()
|
||||
{
|
||||
return Err(BoundedError::Halted);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(BoundedError::Timeout);
|
||||
|
||||
@@ -249,6 +249,27 @@ impl Drop for BytePrefetcher {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `RECYCLE_DEPTH` must be one MORE than `FORWARD_DEPTH` per its own
|
||||
/// doc comment: the producer needs at least one buffer to fill while
|
||||
/// the consumer holds the other `FORWARD_DEPTH`-worth in flight. A
|
||||
/// `+` -> `*`/`-` mutation on `FORWARD_DEPTH + 1` would under-size the
|
||||
/// recycle channel (e.g. `FORWARD_DEPTH * 1 == FORWARD_DEPTH`, one
|
||||
/// short), which starves the producer of a spare buffer.
|
||||
#[test]
|
||||
fn recycle_depth_is_forward_depth_plus_one() {
|
||||
assert_eq!(RECYCLE_DEPTH, FORWARD_DEPTH + 1);
|
||||
assert_eq!(RECYCLE_DEPTH, 3, "FORWARD_DEPTH is 2, so recycle must be 3");
|
||||
}
|
||||
|
||||
/// `DEFAULT_CHUNK_BYTES` is documented as 16 MiB. Pins the literal so a
|
||||
/// `*` -> `+`/`/` mutation on either factor (16 * 1024 * 1024) is
|
||||
/// caught by a concrete, spec-derived expected value rather than by
|
||||
/// recomputing the same expression.
|
||||
#[test]
|
||||
fn default_chunk_bytes_is_16_mib() {
|
||||
assert_eq!(DEFAULT_CHUNK_BYTES, 16_777_216, "documented as 16 MiB");
|
||||
}
|
||||
|
||||
/// Endless reader: every `read` fills the whole buffer and never
|
||||
/// hits EOF, so the producer keeps trying to push batches forward
|
||||
/// until the forward channel disconnects. Exactly the shape that
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
use std::fs::File;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
pub(super) fn hint_sequential(file: &File, _len_bytes: u64) {
|
||||
pub(crate) fn hint_sequential(file: &File, _len_bytes: u64) {
|
||||
// Best-effort: return value ignored. A fadvise failure has no
|
||||
// user-observable consequence.
|
||||
unsafe {
|
||||
@@ -34,7 +34,7 @@ pub(super) fn hint_sequential(file: &File, _len_bytes: u64) {
|
||||
/// Drop pages in the half-open byte range `[start, start+len)` from
|
||||
/// the page cache. Called periodically by `read_sectors` to bound the
|
||||
/// read-side page cache pressure.
|
||||
pub(super) fn drop_window(file: &File, start: u64, len: u64) {
|
||||
pub(crate) fn drop_window(file: &File, start: u64, len: u64) {
|
||||
unsafe {
|
||||
libc::posix_fadvise(
|
||||
file.as_raw_fd(),
|
||||
@@ -58,7 +58,7 @@ pub(super) fn drop_window(file: &File, start: u64, len: u64) {
|
||||
/// can only pre-stage a tiny slice of the next batch. An explicit
|
||||
/// `readahead()` of the same size as the current batch tells the
|
||||
/// kernel to queue the full next-batch read now.
|
||||
pub(super) fn prefetch(file: &File, offset: u64, len: u64) {
|
||||
pub(crate) fn prefetch(file: &File, offset: u64, len: u64) {
|
||||
unsafe {
|
||||
libc::readahead(file.as_raw_fd(), offset as i64, len as usize);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::os::unix::io::AsRawFd;
|
||||
/// pipeline depth.
|
||||
const RDADVISE_MAX_BYTES: i64 = 64 * 1024 * 1024;
|
||||
|
||||
pub(super) fn hint_sequential(file: &File, len_bytes: u64) {
|
||||
pub(crate) fn hint_sequential(file: &File, len_bytes: u64) {
|
||||
let bytes = (len_bytes as i64).min(RDADVISE_MAX_BYTES);
|
||||
let mut ra = libc::radvisory {
|
||||
ra_offset: 0,
|
||||
@@ -33,14 +33,14 @@ pub(super) fn hint_sequential(file: &File, len_bytes: u64) {
|
||||
/// approximation: no-op. macOS's unified buffer cache is generally
|
||||
/// less prone to the pin-everything pathology that triggers the
|
||||
/// regression on Linux NFS clients.
|
||||
pub(super) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||
pub(crate) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||
|
||||
/// Async-prefetch the byte range `[offset, offset+len)`. macOS uses
|
||||
/// the same `fcntl(F_RDADVISE, &radvisory)` primitive as the open-
|
||||
/// time sequential hint, just targeted at a moving window instead of
|
||||
/// the whole file. The kernel queues I/O for the requested range and
|
||||
/// returns immediately.
|
||||
pub(super) fn prefetch(file: &File, offset: u64, len: u64) {
|
||||
pub(crate) fn prefetch(file: &File, offset: u64, len: u64) {
|
||||
let bytes = (len as i64).min(RDADVISE_MAX_BYTES);
|
||||
let mut ra = libc::radvisory {
|
||||
ra_offset: offset as libc::off_t,
|
||||
|
||||
@@ -48,22 +48,25 @@
|
||||
//! far smaller than our 16 MiB app-level batch.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
pub(crate) mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
pub(crate) mod macos;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
mod other;
|
||||
pub(crate) mod other;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
pub(crate) mod windows;
|
||||
|
||||
// The page-cache hints are shared with any other file-backed sector source:
|
||||
// `dirimage` reads host files the same way and needs the same eviction, or a
|
||||
// large rip pins every byte it has read (see this module's DONTNEED note).
|
||||
#[cfg(target_os = "linux")]
|
||||
use linux as platform;
|
||||
pub(crate) use linux as platform;
|
||||
#[cfg(target_os = "macos")]
|
||||
use macos as platform;
|
||||
pub(crate) use macos as platform;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
use other as platform;
|
||||
pub(crate) use other as platform;
|
||||
#[cfg(target_os = "windows")]
|
||||
use windows as platform;
|
||||
pub(crate) use windows as platform;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
@@ -85,11 +88,27 @@ use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
|
||||
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
|
||||
const READ_DROP_CHUNK_BYTES_DEFAULT: u64 = 32 * 1024 * 1024;
|
||||
|
||||
/// Upper bound (in MiB) accepted from `FREEMKV_READ_DROP_CHUNK_MIB`. 64 GiB —
|
||||
/// generous for any real medium, and small enough that `n * 1024 * 1024` cannot
|
||||
/// wrap `u64`. Mirrors `WRITEBACK_CHUNK_MIB_MAX`, whose identical multiply is
|
||||
/// bounded for exactly this reason: without the bound, a value above 2^44
|
||||
/// overflows — a panic on the first ISO open in an overflow-checked build, and in
|
||||
/// release a wrap to a near-zero window that fires `drop_window` on every read.
|
||||
/// Out-of-range values fall back to the default.
|
||||
const READ_DROP_CHUNK_MIB_MAX: u64 = 64 * 1024;
|
||||
|
||||
fn read_drop_chunk_bytes() -> u64 {
|
||||
std::env::var("FREEMKV_READ_DROP_CHUNK_MIB")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.filter(|&n| n > 0)
|
||||
resolve_read_drop_chunk(
|
||||
std::env::var("FREEMKV_READ_DROP_CHUNK_MIB")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok()),
|
||||
)
|
||||
}
|
||||
|
||||
/// The pure part of [`read_drop_chunk_bytes`], split out so the bound is
|
||||
/// testable without mutating process environment.
|
||||
fn resolve_read_drop_chunk(mib: Option<u64>) -> u64 {
|
||||
mib.filter(|&n| n > 0 && n <= READ_DROP_CHUNK_MIB_MAX)
|
||||
.map(|n| n * 1024 * 1024)
|
||||
.unwrap_or(READ_DROP_CHUNK_BYTES_DEFAULT)
|
||||
}
|
||||
@@ -171,12 +190,20 @@ impl SectorSource for FileSectorSource {
|
||||
) -> Result<usize> {
|
||||
let count = count as u32;
|
||||
let bytes = count as usize * SECTOR_BYTES;
|
||||
debug_assert!(
|
||||
out.len() >= bytes,
|
||||
"FileSectorSource::read_sectors: out len {} < requested {}",
|
||||
out.len(),
|
||||
bytes
|
||||
);
|
||||
// A real check, not a debug_assert: this is a public `SectorSource` impl,
|
||||
// so an undersized `out` is caller input, and `out[..bytes]` below would
|
||||
// panic with 'range end index out of range' in release where the assert is
|
||||
// compiled away. `Drive::read_fua` already carries exactly this guard, with
|
||||
// a comment recording the same panic being fixed there — this impl was
|
||||
// simply never given it, and `PrefetchedSectorSource` has a regression test
|
||||
// for the case that this one lacked.
|
||||
if out.len() < bytes {
|
||||
return Err(Error::DiscRead {
|
||||
sector: lba as u64,
|
||||
status: None,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
if count == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -219,6 +246,40 @@ mod tests {
|
||||
use std::io::Write;
|
||||
use tempfile::tempdir;
|
||||
|
||||
/// An undersized output buffer must return an error, never panic. This is a
|
||||
/// public `SectorSource` impl, so buffer length is caller input, and the guard
|
||||
/// used to be a `debug_assert!` — compiled out in release, where the
|
||||
/// `out[..bytes]` slice then panicked with 'range end index out of range'.
|
||||
///
|
||||
/// `Drive::read_fua` already carries this exact guard with a comment recording
|
||||
/// the same panic being fixed there, and `PrefetchedSectorSource` has
|
||||
/// `direct_read_too_small_buffer_errors` for the same case; this impl had
|
||||
/// neither.
|
||||
#[test]
|
||||
fn read_sectors_with_an_undersized_buffer_errors_rather_than_panicking() {
|
||||
let dir = tempdir().unwrap();
|
||||
let iso = dir.path().join("t.iso");
|
||||
make_iso(&iso, 8);
|
||||
let mut src = FileSectorSource::open(&iso).expect("iso opens");
|
||||
|
||||
// Ask for four sectors but supply room for barely more than one.
|
||||
let mut out = vec![0u8; SECTOR_BYTES + 1];
|
||||
let err = src
|
||||
.read_sectors(0, 4, &mut out, false)
|
||||
.expect_err("an undersized buffer must be an error, not a panic");
|
||||
assert!(
|
||||
matches!(err, Error::DiscRead { .. }),
|
||||
"expected DiscRead, got {err:?}"
|
||||
);
|
||||
|
||||
// Exactly-sized still works, so the guard is not off by one.
|
||||
let mut out = vec![0u8; 4 * SECTOR_BYTES];
|
||||
assert_eq!(
|
||||
src.read_sectors(0, 4, &mut out, false).unwrap(),
|
||||
4 * SECTOR_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a deterministic ISO of `sectors` sectors where sector `n`
|
||||
/// is filled with the byte pattern `((n & 0xff) as u8)`. Lets us
|
||||
/// verify any sector by content alone.
|
||||
@@ -376,19 +437,36 @@ mod tests {
|
||||
// Additional coverage.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// `count == 0` must short-circuit to Ok(0) WITHOUT seeking or
|
||||
/// reading, even at an out-of-range LBA — the early-return guard
|
||||
/// runs before any I/O. Grounding: `if count == 0 { return Ok(0) }`.
|
||||
/// `count == 0` must short-circuit to Ok(0) WITHOUT seeking or reading,
|
||||
/// even at an out-of-range LBA. Grounding: `if count == 0 { return Ok(0) }`.
|
||||
///
|
||||
/// The `Ok(0)` return alone proves nothing: with the guard deleted, a seek
|
||||
/// past EOF succeeds (POSIX permits seeking beyond the end of a file) and a
|
||||
/// zero-length `read_exact` returns `Ok(())` immediately, so the call still
|
||||
/// returns `Ok(0)`. The observable difference is the file's cursor — the
|
||||
/// seek MOVES it to `lba * 2048`. Assert on that, so the guard is what the
|
||||
/// test is actually measuring.
|
||||
#[test]
|
||||
fn zero_count_returns_zero_no_io() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("zc.iso");
|
||||
make_iso(&path, 4);
|
||||
let mut src = FileSectorSource::open(&path).unwrap();
|
||||
let before = src.file.stream_position().expect("cursor readable");
|
||||
assert_eq!(before, 0, "a freshly opened file starts at offset 0");
|
||||
// LBA far past EOF — must not matter because count==0 returns early.
|
||||
let mut buf = [0u8; 1];
|
||||
let n = src.read_sectors(1_000_000, 0, &mut buf, false).unwrap();
|
||||
assert_eq!(n, 0);
|
||||
assert_eq!(
|
||||
src.file.stream_position().expect("cursor readable"),
|
||||
before,
|
||||
"count == 0 must return before the seek — an unmoved cursor is the \
|
||||
only observable proof that no I/O was issued"
|
||||
);
|
||||
// And the drop-window accounting must not have advanced either.
|
||||
assert_eq!(src.bytes_read_since_drop, 0);
|
||||
assert_eq!(src.drop_window_start, 0);
|
||||
}
|
||||
|
||||
/// Reading past EOF must ERROR (read_exact's UnexpectedEof), never
|
||||
@@ -518,4 +596,36 @@ mod tests {
|
||||
lba += batch as u32;
|
||||
}
|
||||
}
|
||||
|
||||
/// `FREEMKV_READ_DROP_CHUNK_MIB` must be BOUNDED before the MiB→byte
|
||||
/// multiply, exactly as its writeback twin bounds the identical multiply.
|
||||
/// Unbounded, any value above 2^44 overflowed `n * 1024 * 1024`: a panic on
|
||||
/// the first ISO open in an overflow-checked build, and in release a wrap to
|
||||
/// a near-zero window that fires `drop_window` on essentially every read.
|
||||
#[test]
|
||||
fn read_drop_chunk_env_is_bounded_before_the_multiply() {
|
||||
// Default when unset / zero / out of range.
|
||||
assert_eq!(resolve_read_drop_chunk(None), READ_DROP_CHUNK_BYTES_DEFAULT);
|
||||
assert_eq!(
|
||||
resolve_read_drop_chunk(Some(0)),
|
||||
READ_DROP_CHUNK_BYTES_DEFAULT
|
||||
);
|
||||
// The overflow value: `u64::MAX * 1024 * 1024` panicked here.
|
||||
assert_eq!(
|
||||
resolve_read_drop_chunk(Some(u64::MAX)),
|
||||
READ_DROP_CHUNK_BYTES_DEFAULT
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_read_drop_chunk(Some(READ_DROP_CHUNK_MIB_MAX + 1)),
|
||||
READ_DROP_CHUNK_BYTES_DEFAULT
|
||||
);
|
||||
// In-range values convert MiB→bytes. Mutation: `* 1024` breaks this.
|
||||
assert_eq!(resolve_read_drop_chunk(Some(1)), 1024 * 1024);
|
||||
assert_eq!(
|
||||
resolve_read_drop_chunk(Some(READ_DROP_CHUNK_MIB_MAX)),
|
||||
READ_DROP_CHUNK_MIB_MAX * 1024 * 1024
|
||||
);
|
||||
// And the bound itself keeps the multiply inside u64.
|
||||
assert!((READ_DROP_CHUNK_MIB_MAX as u128) * 1024 * 1024 <= u64::MAX as u128);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
use std::fs::File;
|
||||
|
||||
pub(super) fn hint_sequential(_file: &File, _len_bytes: u64) {}
|
||||
pub(crate) fn hint_sequential(_file: &File, _len_bytes: u64) {}
|
||||
|
||||
pub(super) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||
pub(crate) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||
|
||||
pub(super) fn prefetch(_file: &File, _offset: u64, _len: u64) {}
|
||||
pub(crate) fn prefetch(_file: &File, _offset: u64, _len: u64) {}
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::fs::File;
|
||||
/// No-op stub. `FILE_FLAG_SEQUENTIAL_SCAN` can only be set at
|
||||
/// `CreateFile` open time, which the plain `File::open` path does not
|
||||
/// do, so there is no post-open hint to issue here.
|
||||
pub(super) fn hint_sequential(_file: &File, _len_bytes: u64) {
|
||||
pub(crate) fn hint_sequential(_file: &File, _len_bytes: u64) {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"FileSectorSource hint_sequential: windows no-op stub"
|
||||
@@ -19,10 +19,10 @@ pub(super) fn hint_sequential(_file: &File, _len_bytes: u64) {
|
||||
/// Windows page-cache eviction is not exposed via a posix_fadvise
|
||||
/// equivalent. The kernel does its own working-set management. No-op
|
||||
/// for now.
|
||||
pub(super) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||
pub(crate) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||
|
||||
/// Windows async-prefetch hint. With FILE_FLAG_SEQUENTIAL_SCAN at
|
||||
/// open the kernel already prefetches aggressively, so there's no
|
||||
/// per-range hint we'd add on top. No-op stub for parity with the
|
||||
/// posix platforms.
|
||||
pub(super) fn prefetch(_file: &File, _offset: u64, _len: u64) {}
|
||||
pub(crate) fn prefetch(_file: &File, _offset: u64, _len: u64) {}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! `write_image` — write an image-level source out as a sector image.
|
||||
//!
|
||||
//! This is the plain image writer: sectors in from any [`SectorSource`], bytes
|
||||
//! out to a file, in order, once. It is what an `iso://` DESTINATION means when
|
||||
//! the source is not a physical drive.
|
||||
//!
|
||||
//! # Why this is not `freemkv_engine::copy`
|
||||
//!
|
||||
//! The engine's `copy` is the RECOVERY path — mapfile sidecar, `--multipass`
|
||||
//! sweep/patch, damage-jump, ECC-aware batching, auto-resume. Every one of those
|
||||
//! exists because an optical drive returns read errors on marginal media. A
|
||||
//! file-backed or synthesized source has no marginal media: a read either
|
||||
//! succeeds or the underlying file is broken, and retrying it is pointless.
|
||||
//!
|
||||
//! Routing a non-drive source through the recovery path is not merely wasteful,
|
||||
//! it is wrong. Its mapfile identity check compares AACS unit keys and the VID,
|
||||
//! both of which are empty for an already-decrypted source, so identity passes
|
||||
//! for ANY such source: a second run with a different input to the same output
|
||||
//! path would resume over the previous image and produce wrong content at exit
|
||||
//! zero. Keeping the two paths separate makes that unrepresentable.
|
||||
//!
|
||||
//! So: drive sources get `freemkv_engine::copy`. Everything else gets this.
|
||||
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::halt::Halt;
|
||||
use crate::sector::SectorSource;
|
||||
use std::fs::File;
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::Path;
|
||||
|
||||
/// Sectors per read/write batch. 4 MiB — large enough that per-call overhead
|
||||
/// disappears against a file-backed source, small enough that the buffer is not
|
||||
/// a notable allocation and cancellation stays responsive.
|
||||
const BATCH_SECTORS: u32 = 2048;
|
||||
|
||||
/// Write `total_sectors` sectors from `reader` to `dest`.
|
||||
///
|
||||
/// Reads sequentially from LBA 0 and writes in order, so the output is a faithful
|
||||
/// image of whatever the source presents — decrypted if the caller wrapped the
|
||||
/// source in a [`DecryptingSectorSource`](crate::sector::decrypting::DecryptingSectorSource),
|
||||
/// ciphertext if it did not. This function performs no decryption itself and makes
|
||||
/// no decryption decision; that belongs to the caller, which knows whether the run
|
||||
/// is `--raw`.
|
||||
///
|
||||
/// `on_progress` is called after each batch with the cumulative byte count, for
|
||||
/// front-end progress reporting. It must not block.
|
||||
///
|
||||
/// `halt` is checked once per batch. On cancellation the partial file is left in
|
||||
/// place — the caller decides whether a partial image is worth keeping, and
|
||||
/// deleting a multi-gigabyte file the user may want to inspect is not this
|
||||
/// function's call to make.
|
||||
///
|
||||
/// Returns the number of bytes written.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`Error::Halted`] if `halt` was cancelled.
|
||||
/// - [`Error::IoError`] if the destination cannot be created or written.
|
||||
/// - Whatever the source's `read_sectors` returns. A short read is an error, not
|
||||
/// a zero-fill: silently padding a truncated source produces an image that
|
||||
/// looks complete and is not.
|
||||
pub fn write_image(
|
||||
reader: &mut dyn SectorSource,
|
||||
dest: &Path,
|
||||
total_sectors: u32,
|
||||
halt: &Halt,
|
||||
mut on_progress: impl FnMut(u64),
|
||||
) -> Result<u64> {
|
||||
if total_sectors == 0 {
|
||||
return Err(Error::EmptyImage);
|
||||
}
|
||||
|
||||
let file = File::create(dest).map_err(|source| Error::IoError { source })?;
|
||||
let mut out = BufWriter::with_capacity(BATCH_SECTORS as usize * SECTOR_BYTES, file);
|
||||
|
||||
let mut buf = vec![0u8; BATCH_SECTORS as usize * SECTOR_BYTES];
|
||||
let mut written: u64 = 0;
|
||||
let mut lba: u32 = 0;
|
||||
|
||||
while lba < total_sectors {
|
||||
if halt.is_cancelled() {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
let count = BATCH_SECTORS.min(total_sectors - lba);
|
||||
let want = count as usize * SECTOR_BYTES;
|
||||
// `recovery = false`: a file-backed source ignores the flag, and a
|
||||
// retry loop over a local file would only re-read the same bytes.
|
||||
let got = reader.read_sectors(lba, count as u16, &mut buf[..want], false)?;
|
||||
if got != want {
|
||||
return Err(Error::ShortImageRead {
|
||||
lba,
|
||||
expected: want as u32,
|
||||
got: got as u32,
|
||||
});
|
||||
}
|
||||
out.write_all(&buf[..want])
|
||||
.map_err(|source| Error::IoError { source })?;
|
||||
written += want as u64;
|
||||
lba += count;
|
||||
on_progress(written);
|
||||
}
|
||||
|
||||
// flush() only pushes the BufWriter's bytes into the kernel via write(2).
|
||||
// It makes no durability promise at all, so returning Ok here would report
|
||||
// a finished image while up to several gigabytes of it still sit in the
|
||||
// page cache. A crash, a power loss, or yanking the removable/network
|
||||
// volume the image was written to then leaves a truncated or empty file
|
||||
// that the caller was told was complete.
|
||||
//
|
||||
// For a 6-90 GB image that is exactly the failure this crate treats as
|
||||
// worst: success reported over wrong output. `into_inner` is used rather
|
||||
// than `flush` so a buffered-write error is surfaced instead of being
|
||||
// dropped on the floor by BufWriter's Drop.
|
||||
let file = out.into_inner().map_err(|e| Error::IoError {
|
||||
source: e.into_error(),
|
||||
})?;
|
||||
file.sync_all()
|
||||
.map_err(|source| Error::IoError { source })?;
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Result as FmResult;
|
||||
|
||||
/// A source that yields a deterministic byte per sector, so the written
|
||||
/// image can be checked positionally rather than just by length.
|
||||
struct PatternSource {
|
||||
sectors: u32,
|
||||
/// Sectors after which `read_sectors` reports a short read.
|
||||
short_after: Option<u32>,
|
||||
}
|
||||
|
||||
impl SectorSource for PatternSource {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.sectors
|
||||
}
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> FmResult<usize> {
|
||||
let want = count as usize * SECTOR_BYTES;
|
||||
if self.short_after.is_some_and(|after| lba >= after) {
|
||||
return Ok(want - 1);
|
||||
}
|
||||
for s in 0..count as usize {
|
||||
let byte = ((lba as usize + s) % 251) as u8;
|
||||
buf[s * SECTOR_BYTES..(s + 1) * SECTOR_BYTES].fill(byte);
|
||||
}
|
||||
Ok(want)
|
||||
}
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("fmkv-image-writer-{name}-{}", std::process::id()));
|
||||
p
|
||||
}
|
||||
|
||||
/// The written image is byte-for-byte what the source presented, at the
|
||||
/// right offsets — not merely the right length.
|
||||
#[test]
|
||||
fn writes_every_sector_in_order() {
|
||||
let dest = tmp("order");
|
||||
let mut src = PatternSource {
|
||||
sectors: 5000,
|
||||
short_after: None,
|
||||
};
|
||||
let n = write_image(&mut src, &dest, 5000, &Halt::new(), |_| {}).expect("write");
|
||||
assert_eq!(n, 5000 * SECTOR_BYTES as u64);
|
||||
|
||||
let data = std::fs::read(&dest).expect("read back");
|
||||
assert_eq!(data.len(), 5000 * SECTOR_BYTES);
|
||||
// Spot-check across batch boundaries (BATCH_SECTORS = 2048): the last
|
||||
// sector of batch 0, the first of batch 1, and the final sector.
|
||||
for lba in [0usize, 2047, 2048, 4095, 4096, 4999] {
|
||||
let want = (lba % 251) as u8;
|
||||
assert_eq!(
|
||||
data[lba * SECTOR_BYTES],
|
||||
want,
|
||||
"sector {lba} head byte wrong — batching lost or duplicated a sector"
|
||||
);
|
||||
assert_eq!(
|
||||
data[(lba + 1) * SECTOR_BYTES - 1],
|
||||
want,
|
||||
"sector {lba} tail"
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
|
||||
/// A tail shorter than a full batch must still be written whole — the
|
||||
/// classic off-by-one when `total_sectors` is not a batch multiple.
|
||||
#[test]
|
||||
fn writes_a_partial_final_batch() {
|
||||
let dest = tmp("tail");
|
||||
let mut src = PatternSource {
|
||||
sectors: 2049,
|
||||
short_after: None,
|
||||
};
|
||||
let n = write_image(&mut src, &dest, 2049, &Halt::new(), |_| {}).expect("write");
|
||||
assert_eq!(n, 2049 * SECTOR_BYTES as u64);
|
||||
assert_eq!(
|
||||
std::fs::metadata(&dest).expect("stat").len(),
|
||||
2049 * SECTOR_BYTES as u64
|
||||
);
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
|
||||
/// A short read is an error. Zero-filling would yield an image that looks
|
||||
/// complete and is not — the single worst outcome for an archival copy.
|
||||
#[test]
|
||||
fn short_read_is_an_error_not_a_zero_fill() {
|
||||
let dest = tmp("short");
|
||||
let mut src = PatternSource {
|
||||
sectors: 4096,
|
||||
short_after: Some(2048),
|
||||
};
|
||||
let err = write_image(&mut src, &dest, 4096, &Halt::new(), |_| {}).expect_err("must fail");
|
||||
assert!(
|
||||
matches!(err, Error::ShortImageRead { lba: 2048, .. }),
|
||||
"got {err:?}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
|
||||
/// Cancellation stops the run and reports it, rather than finishing quietly
|
||||
/// or reporting success on a partial image.
|
||||
#[test]
|
||||
fn cancellation_halts_and_reports() {
|
||||
let dest = tmp("halt");
|
||||
let mut src = PatternSource {
|
||||
sectors: 100_000,
|
||||
short_after: None,
|
||||
};
|
||||
let halt = Halt::new();
|
||||
halt.cancel();
|
||||
let err = write_image(&mut src, &dest, 100_000, &halt, |_| {}).expect_err("must halt");
|
||||
assert!(matches!(err, Error::Halted), "got {err:?}");
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
|
||||
/// Progress is cumulative and monotonic, and its final value equals the
|
||||
/// returned byte count — a front-end that trusts the callback must not end
|
||||
/// up disagreeing with the return value.
|
||||
#[test]
|
||||
fn progress_is_cumulative_and_ends_at_the_total() {
|
||||
let dest = tmp("progress");
|
||||
let mut src = PatternSource {
|
||||
sectors: 5000,
|
||||
short_after: None,
|
||||
};
|
||||
let mut seen: Vec<u64> = Vec::new();
|
||||
let n = write_image(&mut src, &dest, 5000, &Halt::new(), |b| seen.push(b)).expect("write");
|
||||
assert!(
|
||||
seen.windows(2).all(|w| w[1] > w[0]),
|
||||
"not monotonic: {seen:?}"
|
||||
);
|
||||
assert_eq!(*seen.last().expect("at least one callback"), n);
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
|
||||
/// A zero-sector source is a caller error, not a zero-byte image: an empty
|
||||
/// ISO is never what anyone wanted, and failing here names the problem.
|
||||
#[test]
|
||||
fn zero_sectors_is_an_error() {
|
||||
let dest = tmp("empty");
|
||||
let mut src = PatternSource {
|
||||
sectors: 0,
|
||||
short_after: None,
|
||||
};
|
||||
let err = write_image(&mut src, &dest, 0, &Halt::new(), |_| {}).expect_err("must fail");
|
||||
assert!(matches!(err, Error::EmptyImage), "got {err:?}");
|
||||
// The destination must not have been created — a failed run leaves no
|
||||
// stub for a later run to mistake for output.
|
||||
assert!(!dest.exists(), "empty run created a file");
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -31,6 +31,7 @@ pub(crate) mod bounded;
|
||||
pub mod byte_prefetcher;
|
||||
pub mod file_sector_source;
|
||||
pub mod fsync;
|
||||
pub mod image_writer;
|
||||
pub mod sink;
|
||||
mod writeback;
|
||||
mod writeback_file;
|
||||
@@ -40,9 +41,8 @@ pub(crate) mod platform_macos;
|
||||
|
||||
pub mod pipeline;
|
||||
|
||||
pub(crate) use writeback_file::WritebackFile;
|
||||
pub use writeback_file::WritebackFile;
|
||||
|
||||
pub use pipeline::{
|
||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
|
||||
WRITE_THROUGH_DEPTH,
|
||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink, WRITE_PIPELINE_DEPTH, WRITE_THROUGH_DEPTH,
|
||||
};
|
||||
|
||||
+352
-65
@@ -33,7 +33,7 @@
|
||||
//! consumer lag detection). This is critical for diagnosing stalls.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -117,8 +117,32 @@ fn consumer_panicked(payload: Box<dyn std::any::Any + Send>) -> Error {
|
||||
Error::PipelineConsumerPanicked
|
||||
}
|
||||
|
||||
/// Consumer lifecycle state, shared between the caller and the consumer thread.
|
||||
///
|
||||
/// A plain `AtomicBool` could not make "the caller abandons" and "the consumer
|
||||
/// commits to finalising" mutually exclusive: the consumer loaded the flag, the
|
||||
/// caller stored it, and the consumer then finalised the container anyway — the
|
||||
/// caller reporting the rip as interrupted while a fully finalised MKV (Cues
|
||||
/// written, Segment size patched) landed on disk, indistinguishable from a
|
||||
/// complete one. The two transitions are therefore a single compare-exchange each,
|
||||
/// out of [`state::RUNNING`]: whoever wins decides, and the loser observes the
|
||||
/// winner. (`ST_RUNNING` does not exist anywhere in the crate — the constants are
|
||||
/// `state::RUNNING` / `state::ABANDONED` / `state::CLOSING` below, and both
|
||||
/// compare-exchange sites that must stay in step with this argument name them.)
|
||||
mod state {
|
||||
/// Consumer is running; neither side has committed yet.
|
||||
pub const RUNNING: u8 = 0;
|
||||
/// The caller gave up on the consumer and will report failure — the consumer
|
||||
/// must NOT finalise the output.
|
||||
pub const ABANDONED: u8 = 1;
|
||||
/// The consumer has committed to `close()` (finalising the output). The caller
|
||||
/// can no longer abandon it; it must wait for the result it is about to
|
||||
/// produce.
|
||||
pub const CLOSING: u8 = 2;
|
||||
}
|
||||
|
||||
/// After a halt or deadline fires, spin-poll `handle.is_finished()` for
|
||||
/// [`FINISH_GRACE_SECS`] before accepting the thread leak. This converts
|
||||
/// `grace` before accepting the thread leak. This converts
|
||||
/// the common "nearly-done" consumer (whose own bounded_syscall just
|
||||
/// returned and is about to drop its output file) into a clean join,
|
||||
/// releasing the file handle without waiting the full grace period.
|
||||
@@ -132,11 +156,12 @@ fn consumer_panicked(payload: Box<dyn std::any::Any + Send>) -> Error {
|
||||
/// syscall itself; that still returns on its own (or at process exit).
|
||||
fn finish_with_grace<R: Send + 'static>(
|
||||
handle: thread::JoinHandle<Result<R, Error>>,
|
||||
abandoned: &Arc<AtomicBool>,
|
||||
state: &Arc<AtomicU8>,
|
||||
grace: Duration,
|
||||
leak_err: Error,
|
||||
) -> Result<R, Error> {
|
||||
let grace = Instant::now() + Duration::from_secs(FINISH_GRACE_SECS);
|
||||
while Instant::now() < grace {
|
||||
let deadline = Instant::now() + grace;
|
||||
while Instant::now() < deadline {
|
||||
if handle.is_finished() {
|
||||
return match handle.join() {
|
||||
Ok(result) => result,
|
||||
@@ -145,16 +170,50 @@ fn finish_with_grace<R: Send + 'static>(
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
// Grace expired. Signal abandonment, then log and leak. Setting the
|
||||
// flag BEFORE dropping the handle guarantees the leaked consumer
|
||||
// observes it the moment its wedged syscall returns: it then skips
|
||||
// any further `apply` and skips `close()`, rather than running on to
|
||||
// finalise the abandoned output file.
|
||||
// `Release` here pairs with the `Acquire` loads in the consumer loop so
|
||||
// the leaked consumer reliably observes the flag the moment its wedged
|
||||
// syscall returns, even on weak memory models (ARM64/POWER) where
|
||||
// `Relaxed` gives no cross-thread visibility guarantee.
|
||||
abandoned.store(true, Ordering::Release);
|
||||
// Grace expired. CLAIM abandonment, then log and leak. Claiming BEFORE
|
||||
// dropping the handle guarantees the leaked consumer observes it the moment
|
||||
// its wedged syscall returns: it then skips any further `apply` and skips
|
||||
// `close()`, rather than running on to finalise the abandoned output file.
|
||||
//
|
||||
// A compare-exchange, not a store, because the consumer may have committed to
|
||||
// `close()` in the instant between our last `is_finished()` poll and now. It
|
||||
// then cannot be stopped — the finalise IS happening — so abandoning it would
|
||||
// report the rip as interrupted while a valid, fully finalised container
|
||||
// lands on disk. Losing the race means waiting for the result the consumer is
|
||||
// already producing instead. `AcqRel` pairs with the consumer's own
|
||||
// compare-exchange and with the `Acquire` loads in its drain loop, so the flag
|
||||
// is reliably observed even on weak memory models (ARM64/POWER).
|
||||
if state
|
||||
.compare_exchange(
|
||||
state::RUNNING,
|
||||
state::ABANDONED,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "freemkv::pipeline",
|
||||
phase = "finish_with_halt_close_in_flight",
|
||||
"pipeline consumer had already committed to finalising the output; \
|
||||
waiting for it rather than reporting an unfinalised output"
|
||||
);
|
||||
let close_deadline = Instant::now() + grace;
|
||||
while Instant::now() < close_deadline {
|
||||
if handle.is_finished() {
|
||||
return match handle.join() {
|
||||
Ok(result) => result,
|
||||
Err(payload) => Err(consumer_panicked(payload)),
|
||||
};
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
// Still finalising after a second grace window: leak and report the wedge.
|
||||
// The output may end up finalised by the leaked thread — but that is now a
|
||||
// wedged-`close()` case, not the check-then-finalise race.
|
||||
drop(handle);
|
||||
return Err(leak_err);
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "freemkv::pipeline",
|
||||
phase = "finish_with_halt_grace_expired",
|
||||
@@ -173,14 +232,9 @@ fn finish_with_grace<R: Send + 'static>(
|
||||
|
||||
/// Default channel depth for callers without a specific reason to
|
||||
/// pick another value. Kept conservative (4) — most callers should
|
||||
/// use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead.
|
||||
/// use WRITE_PIPELINE_DEPTH instead.
|
||||
pub const DEFAULT_PIPELINE_DEPTH: usize = 4;
|
||||
|
||||
/// Read pipeline depth. Larger buffer compensates for drive variability
|
||||
/// and NFS sync_file_range stalls; keeps ISO reader thread fed even when
|
||||
/// consumer blocks on write.
|
||||
pub const READ_PIPELINE_DEPTH: usize = 32;
|
||||
|
||||
/// Write pipeline depth. Smaller buffer reduces backpressure risk when
|
||||
/// sync_file_range blocks; prevents producer from accumulating too much
|
||||
/// work while consumer waits for NFS to drain.
|
||||
@@ -189,7 +243,8 @@ pub const WRITE_PIPELINE_DEPTH: usize = 16;
|
||||
/// Channel depth for write-through pipelines. Each `send` fully
|
||||
/// drains before the next can enqueue. Use this when the producer
|
||||
/// must observe consumer side-effects (e.g. mapfile state) before
|
||||
/// emitting the next item. Currently used by `disc::patch`.
|
||||
/// emitting the next item. Used by `freemkv_engine::recovery::patch` — the
|
||||
/// recovery strategy moved to that crate in 1.6.0, so there is no `patch` here.
|
||||
pub const WRITE_THROUGH_DEPTH: usize = 1;
|
||||
|
||||
/// Outcome of [`Sink::apply`]: either keep feeding items
|
||||
@@ -247,7 +302,21 @@ pub struct Pipeline<I: Send + 'static, R: Send + 'static> {
|
||||
/// a syscall the consumer is currently wedged in, but it does bound
|
||||
/// the damage to "whatever write is already in flight" once that
|
||||
/// syscall returns, instead of running on to a clean finalise.
|
||||
abandoned: Arc<AtomicBool>,
|
||||
///
|
||||
/// One of [`state::RUNNING`] / [`state::ABANDONED`] / [`state::CLOSING`];
|
||||
/// both transitions are compare-exchanges so abandoning and finalising are
|
||||
/// mutually exclusive rather than racing.
|
||||
state: Arc<AtomicU8>,
|
||||
/// Set by the consumer the moment an `apply` returns `Err`. The consumer keeps
|
||||
/// draining the channel after that (so the producer never blocks on a dead
|
||||
/// receiver) — which means a producer watching only `send`'s return value
|
||||
/// cannot tell the difference between "being consumed" and "being discarded
|
||||
/// after a fatal write error", and would go on reading the whole remaining
|
||||
/// disc before `finish()` finally surfaced the error. This flag is that
|
||||
/// missing edge: [`Pipeline::send_with_halt`] fails fast on it, and
|
||||
/// [`Pipeline::consumer_failed`] exposes it to producers that use plain
|
||||
/// [`Pipeline::send`].
|
||||
failed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
@@ -256,17 +325,21 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
///
|
||||
/// The thread is named `freemkv-pipeline-consumer` so it shows up
|
||||
/// distinctly in stack traces and `top -H`. Callers that want a
|
||||
/// more specific name (e.g. `freemkv-sweep-consumer`) should use
|
||||
/// [`Pipeline::spawn_named`] instead. Returns an `Error::IoError`
|
||||
/// if the OS refuses the thread spawn (resource exhaustion);
|
||||
/// callers already operate in fallible context, so this is
|
||||
/// propagated rather than panicked.
|
||||
/// more specific name should use [`Pipeline::spawn_named`] instead.
|
||||
/// Returns an `Error::IoError` if the OS refuses the thread spawn
|
||||
/// (resource exhaustion); callers already operate in fallible context, so
|
||||
/// this is propagated rather than panicked.
|
||||
///
|
||||
/// Sweep uses [`Pipeline::spawn_named`] directly so the consumer
|
||||
/// thread shows up as `freemkv-sweep-consumer`; mux uses
|
||||
/// `freemkv-mux-consumer`. `Pipeline::spawn` (this function, with
|
||||
/// the default name) is used by `disc::patch` and by the unit
|
||||
/// tests in this module.
|
||||
/// Inside this crate the only [`Pipeline::spawn_named`] caller is the mux
|
||||
/// driver, which names its thread `freemkv-mux-consumer`. `Pipeline::spawn`
|
||||
/// (this function, with the default name) is used only by the unit tests in
|
||||
/// this module.
|
||||
///
|
||||
/// This paragraph twice named a caller that had left the crate: first
|
||||
/// `disc::patch`, then Sweep and its `freemkv-sweep-consumer` thread. Both
|
||||
/// went to freemkv-engine with the recovery passes in 1.6.0, and each in
|
||||
/// turn sent readers hunting a component that is not here. Name callers
|
||||
/// that live in THIS crate, or none.
|
||||
pub fn spawn<S: Sink<I, Output = R>>(depth: usize, sink: S) -> Result<Self, Error> {
|
||||
Self::spawn_named("freemkv-pipeline-consumer", depth, sink)
|
||||
}
|
||||
@@ -274,15 +347,17 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
/// Like [`Pipeline::spawn`] but lets the caller supply the
|
||||
/// consumer thread's name. Useful when several pipelines run in
|
||||
/// the same process and stack traces / `top -H` need to tell them
|
||||
/// apart (e.g. `freemkv-sweep-consumer`, `freemkv-mux-consumer`).
|
||||
/// apart (e.g. `freemkv-mux-consumer`).
|
||||
pub fn spawn_named<S: Sink<I, Output = R>>(
|
||||
name: &str,
|
||||
depth: usize,
|
||||
sink: S,
|
||||
) -> Result<Self, Error> {
|
||||
let (tx, rx) = bounded::<I>(depth);
|
||||
let abandoned = Arc::new(AtomicBool::new(false));
|
||||
let abandoned_consumer = abandoned.clone();
|
||||
let state = Arc::new(AtomicU8::new(state::RUNNING));
|
||||
let state_consumer = state.clone();
|
||||
let failed = Arc::new(AtomicBool::new(false));
|
||||
let failed_consumer = failed.clone();
|
||||
let handle = thread::Builder::new()
|
||||
.name(name.into())
|
||||
.spawn(move || -> Result<R, Error> {
|
||||
@@ -313,7 +388,7 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
// dead receiver, but we touch the output no further. The
|
||||
// final post-loop abandonment check returns the error
|
||||
// and skips `close()`.
|
||||
if abandoned_consumer.load(Ordering::Acquire) {
|
||||
if state_consumer.load(Ordering::Acquire) == state::ABANDONED {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -342,6 +417,12 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
tracing::debug!("Pipeline: apply error, stopping, err={:?}", e);
|
||||
}
|
||||
first_err = Some(e);
|
||||
// Publish the failure so the producer can stop
|
||||
// FEEDING a dead write side instead of only learning
|
||||
// about it at `finish()` — by which time it has read
|
||||
// the rest of the disc. `Release` pairs with the
|
||||
// `Acquire` load in `send_with_halt`.
|
||||
failed_consumer.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,13 +483,38 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
// MKV Cues + patching the segment header) on a file the
|
||||
// caller already reported as failed is exactly the
|
||||
// write race we must not run.
|
||||
if abandoned_consumer.load(Ordering::Acquire) {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
|
||||
match first_err {
|
||||
Some(e) => Err(e),
|
||||
None => sink.close(),
|
||||
// No `close()` on this path, so there is nothing to claim —
|
||||
// just report, unless the caller has already given up on us.
|
||||
Some(e) => {
|
||||
if state_consumer.load(Ordering::Acquire) == state::ABANDONED {
|
||||
Err(Error::Halted)
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
// CLAIM the finalise. A plain load here left a window in which
|
||||
// the caller stored `abandoned` AFTER we read it as clear, so
|
||||
// `close()` ran anyway and finalised (Cues + Segment-size
|
||||
// patch) an output the caller had already reported as
|
||||
// interrupted — a truncated rip indistinguishable from a
|
||||
// complete one. The compare-exchange closes that window: if the
|
||||
// caller got there first we skip `close()`, and if we get there
|
||||
// first the caller waits for us instead of abandoning.
|
||||
None => {
|
||||
if state_consumer
|
||||
.compare_exchange(
|
||||
state::RUNNING,
|
||||
state::CLOSING,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
sink.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
@@ -416,10 +522,24 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
Ok(Pipeline {
|
||||
tx,
|
||||
handle,
|
||||
abandoned,
|
||||
state,
|
||||
failed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the consumer's `apply` has already failed fatally.
|
||||
///
|
||||
/// The consumer keeps draining the channel after an `apply` error (so the
|
||||
/// producer never blocks on a dead receiver), which means `send` keeps
|
||||
/// succeeding and a producer has no other way to tell that everything it feeds
|
||||
/// is being discarded. A long-running producer — the mux frame pump reading a
|
||||
/// 60 GB title off an optical drive — should check this and unwind instead of
|
||||
/// reading the rest of the disc for a write that has already failed.
|
||||
/// [`Pipeline::send_with_halt`] checks it automatically.
|
||||
pub fn consumer_failed(&self) -> bool {
|
||||
self.failed.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Push one item. Blocks if the channel is full — that's the
|
||||
/// back-pressure the whole primitive exists to provide. Returns
|
||||
/// the item back if the consumer thread is gone (panicked or
|
||||
@@ -512,11 +632,34 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
/// wedged inside an unkillable syscall, the producer can still
|
||||
/// observe `/api/stop` and unwind within
|
||||
/// [`SEND_HALT_CHECK_INTERVAL`].
|
||||
/// NOT a `foo_with_X` variant of [`Pipeline::send`], despite the name.
|
||||
/// The two encode OPPOSITE policies on the same event, each with its own
|
||||
/// test: after the consumer's `apply` has failed, `send` still succeeds
|
||||
/// (the consumer keeps draining, so the channel accepts the item), while
|
||||
/// this one hands the item straight back — so a producer does not read an
|
||||
/// hour of disc for a write that died on the first frame. Collapsing them
|
||||
/// into one Option-parameterised method deletes one of those behaviours;
|
||||
/// it was tried and `apply_error_drains_then_propagates` caught it.
|
||||
pub fn send_with_halt(&self, item: I, halt: &Halt, deadline: Duration) -> Result<(), I> {
|
||||
use crossbeam_channel::SendTimeoutError;
|
||||
let end = Instant::now() + deadline;
|
||||
let mut pending = item;
|
||||
loop {
|
||||
// The consumer's `apply` has failed fatally: everything sent from here
|
||||
// is drained and discarded, so hand the item back at once. Without this
|
||||
// the producer saw every send succeed (the channel is always being
|
||||
// drained) and went on reading the whole remaining title — an hour of
|
||||
// drive time on a UHD — for a write that died on the first frame, only
|
||||
// learning about it at `finish()`.
|
||||
if self.consumer_failed() {
|
||||
if debug_enabled() {
|
||||
tracing::debug!(
|
||||
"Pipeline send_with_halt: consumer apply failed, returning item={}",
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
}
|
||||
return Err(pending);
|
||||
}
|
||||
// Pre-check the cheap exit conditions before parking.
|
||||
if halt.is_cancelled() {
|
||||
if debug_enabled() {
|
||||
@@ -571,7 +714,8 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
let Pipeline {
|
||||
tx,
|
||||
handle,
|
||||
abandoned: _,
|
||||
state: _,
|
||||
failed: _,
|
||||
} = self;
|
||||
// Explicit drop, although the destructure already drops `tx`
|
||||
// at end-of-scope. Being explicit keeps the intent obvious.
|
||||
@@ -607,11 +751,18 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
/// Plain [`Pipeline::finish`] is preserved for callers without a
|
||||
/// halt-token plumbed through; that path still blocks indefinitely
|
||||
/// on `join()`, matching pre-0.20.8 behaviour.
|
||||
/// Also not a `foo_with_X` variant: [`Pipeline::finish`] joins and waits
|
||||
/// however long the consumer needs, while this one gives up after
|
||||
/// `JOIN_TIMEOUT_SECS` and reports halted. Which is right depends on
|
||||
/// whether the caller has a user waiting to cancel — the mux driver does
|
||||
/// and uses this; the unit tests do not and use the plain join. Merging
|
||||
/// them means picking one of those policies for both.
|
||||
pub fn finish_with_halt(self, halt: Option<&Halt>) -> Result<R, Error> {
|
||||
let Pipeline {
|
||||
tx,
|
||||
handle,
|
||||
abandoned,
|
||||
state,
|
||||
failed: _,
|
||||
} = self;
|
||||
drop(tx);
|
||||
let deadline = Instant::now() + Duration::from_secs(JOIN_TIMEOUT_SECS);
|
||||
@@ -622,13 +773,23 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
Err(payload) => Err(consumer_panicked(payload)),
|
||||
};
|
||||
}
|
||||
if let Some(h) = halt {
|
||||
if h.is_cancelled() {
|
||||
return finish_with_grace(handle, &abandoned, Error::Halted);
|
||||
}
|
||||
if let Some(h) = halt
|
||||
&& h.is_cancelled()
|
||||
{
|
||||
return finish_with_grace(
|
||||
handle,
|
||||
&state,
|
||||
Duration::from_secs(FINISH_GRACE_SECS),
|
||||
Error::Halted,
|
||||
);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return finish_with_grace(handle, &abandoned, Error::PipelineJoinTimeout);
|
||||
return finish_with_grace(
|
||||
handle,
|
||||
&state,
|
||||
Duration::from_secs(FINISH_GRACE_SECS),
|
||||
Error::PipelineJoinTimeout,
|
||||
);
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
@@ -1080,20 +1241,6 @@ mod tests {
|
||||
/// A sink that records the exact order of items it receives, so we
|
||||
/// can prove the channel is FIFO (no reordering). `close` returns
|
||||
/// the recorded vector.
|
||||
struct OrderSink {
|
||||
seen: Vec<u64>,
|
||||
}
|
||||
impl Sink<u64> for OrderSink {
|
||||
type Output = Vec<u64>;
|
||||
fn apply(&mut self, item: u64) -> Result<Flow, Error> {
|
||||
self.seen.push(item);
|
||||
Ok(Flow::Continue)
|
||||
}
|
||||
fn close(self) -> Result<Vec<u64>, Error> {
|
||||
Ok(self.seen)
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero items sent: closing the pipeline immediately must still
|
||||
/// call `close()` exactly once and return its Output. The consumer
|
||||
/// loop's `while let Ok = rx.recv()` exits on the dropped tx with
|
||||
@@ -1598,4 +1745,144 @@ mod tests {
|
||||
let res = pipe.finish_with_halt(None);
|
||||
assert!(matches!(res, Ok(190)), "expected Ok(190), got {res:?}");
|
||||
}
|
||||
|
||||
/// A fatal `apply` error must become visible to the PRODUCER, not only to
|
||||
/// `finish()`. The consumer keeps draining after the error (so the producer
|
||||
/// never blocks on a dead receiver), which meant every `send_with_halt`
|
||||
/// returned `Ok` for the rest of the run: on a 60 GB mkv:// mux that hit
|
||||
/// ENOSPC on the first frame, the mux driver read the entire remaining title —
|
||||
/// an hour of optical-drive time — before learning the write had died.
|
||||
#[test]
|
||||
fn send_with_halt_fails_fast_once_apply_has_failed() {
|
||||
struct FailFirst {
|
||||
failed: Arc<AtomicUsize>,
|
||||
}
|
||||
impl Sink<u64> for FailFirst {
|
||||
type Output = ();
|
||||
fn apply(&mut self, _item: u64) -> Result<Flow, Error> {
|
||||
self.failed.fetch_add(1, Ordering::SeqCst);
|
||||
Err(Error::DecryptFailed)
|
||||
}
|
||||
fn close(self) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let applied = Arc::new(AtomicUsize::new(0));
|
||||
let pipe = Pipeline::spawn(
|
||||
DEFAULT_PIPELINE_DEPTH,
|
||||
FailFirst {
|
||||
failed: applied.clone(),
|
||||
},
|
||||
)
|
||||
.expect("spawn");
|
||||
let halt = crate::halt::Halt::new();
|
||||
let deadline = Duration::from_secs(5);
|
||||
|
||||
// Feed one item and wait until the consumer has actually applied (and
|
||||
// failed on) it, so the check below is deterministic rather than racy.
|
||||
pipe.send_with_halt(0u64, &halt, deadline)
|
||||
.expect("the first send lands");
|
||||
let until = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < until && applied.load(Ordering::SeqCst) == 0 {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(applied.load(Ordering::SeqCst), 1, "apply ran and failed");
|
||||
|
||||
assert!(pipe.consumer_failed(), "the failure must be observable");
|
||||
// The very next send must hand the item straight back — the producer's
|
||||
// signal to stop reading the disc.
|
||||
assert_eq!(
|
||||
pipe.send_with_halt(1u64, &halt, deadline),
|
||||
Err(1u64),
|
||||
"send_with_halt must fail fast once the consumer's apply has failed"
|
||||
);
|
||||
// The halt was never fired, so this is not a cancellation: the real error
|
||||
// still comes out of finish().
|
||||
assert!(matches!(pipe.finish(), Err(Error::DecryptFailed)));
|
||||
assert_eq!(
|
||||
applied.load(Ordering::SeqCst),
|
||||
1,
|
||||
"no further item was applied"
|
||||
);
|
||||
}
|
||||
|
||||
/// The abandon/finalise race. A consumer that has ALREADY committed to
|
||||
/// `close()` when the grace period expires cannot be stopped — the finalise is
|
||||
/// happening — so the caller must wait for its result instead of reporting the
|
||||
/// output as un-finalised. With a plain flag the consumer read it as clear, the
|
||||
/// caller then stored it, and the caller returned `Err(Halted)`
|
||||
/// (`completed = false`) while a fully finalised MKV (Cues written, Segment
|
||||
/// size patched) landed on disk — a truncated rip indistinguishable from a
|
||||
/// complete one.
|
||||
#[test]
|
||||
fn abandon_loses_to_a_close_already_committed() {
|
||||
let state = Arc::new(AtomicU8::new(state::RUNNING));
|
||||
let release = Arc::new(AtomicBool::new(false));
|
||||
let in_close = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let (st, rel, inc) = (state.clone(), release.clone(), in_close.clone());
|
||||
let handle = thread::Builder::new()
|
||||
.name("test-consumer".into())
|
||||
.spawn(move || -> Result<u64, Error> {
|
||||
// Exactly what the consumer does before finalising: claim the
|
||||
// right to close.
|
||||
assert!(
|
||||
st.compare_exchange(
|
||||
state::RUNNING,
|
||||
state::CLOSING,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire
|
||||
)
|
||||
.is_ok(),
|
||||
"the consumer claims the finalise first"
|
||||
);
|
||||
inc.store(true, Ordering::SeqCst);
|
||||
// Inside `close()`, finalising the container.
|
||||
while !rel.load(Ordering::SeqCst) {
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
Ok(42)
|
||||
})
|
||||
.expect("spawn");
|
||||
|
||||
let until = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < until && !in_close.load(Ordering::SeqCst) {
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(in_close.load(Ordering::SeqCst), "consumer reached close()");
|
||||
|
||||
// Finish the close only AFTER the first grace window has expired, so the
|
||||
// caller genuinely reaches the abandon decision with a close in flight.
|
||||
let rel = release.clone();
|
||||
thread::spawn(move || {
|
||||
// Past the first grace window (and past the 250 ms poll cadence that
|
||||
// bounds when the window is actually observed), inside the second.
|
||||
//
|
||||
// These intervals used to be 600 ms against a 300 ms grace, which
|
||||
// left NO margin: two 300 ms windows end at 600 ms, and the 250 ms
|
||||
// poll cadence can push the observation later still, so on a loaded
|
||||
// runner the second window expired first and the caller abandoned —
|
||||
// failing with Err(Halted) against a race, not a defect.
|
||||
//
|
||||
// Scaled up so the jitter is small relative to the intervals: the
|
||||
// first window ends at ~1.0-1.25 s and the second at ~2.0-2.25 s,
|
||||
// so releasing at 1.6 s sits well inside the second with roughly
|
||||
// 350 ms of slack on either side. The ordering under test is
|
||||
// unchanged; only the margin is.
|
||||
thread::sleep(Duration::from_millis(1600));
|
||||
rel.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
let grace = Duration::from_secs(1);
|
||||
let res = finish_with_grace(handle, &state, grace, Error::Halted);
|
||||
assert!(
|
||||
matches!(res, Ok(42)),
|
||||
"a finalise already in flight must be waited for, not abandoned: {res:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
state.load(Ordering::SeqCst),
|
||||
state::CLOSING,
|
||||
"the caller must not have overwritten the consumer's claim"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ impl WritebackPipeline {
|
||||
self.chunk_bytes,
|
||||
self.skip_wait(),
|
||||
);
|
||||
if self.chunk_count % SIZE_LOG_INTERVAL == 0 {
|
||||
if self.chunk_count.is_multiple_of(SIZE_LOG_INTERVAL) {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"WritebackPipeline chunk_bytes={} after {} chunks is_nfs={} degraded={}",
|
||||
|
||||
@@ -30,14 +30,15 @@ pub(super) fn preallocate(file: &File, size_bytes: u64) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Run `fsync` on `file` with a 60 s deadline. On timeout — and
|
||||
/// likewise on halt or a lost worker — we log and return `Ok(())`: the
|
||||
/// kernel will still flush on close, so the data is best-effort durable.
|
||||
/// The alternative (trap the thread for the rest of the rip, or return
|
||||
/// an error that aborts an otherwise-complete mux) is worse, so all
|
||||
/// three fallbacks return `Ok(())`. `Ok(())` from these paths is NOT a
|
||||
/// durability barrier — the durable flush did not complete; only the
|
||||
/// hang is bounded.
|
||||
/// Run `fsync` on `file` with a 60 s deadline. On timeout, halt or a lost
|
||||
/// worker we log and return `Err` — matching macOS. POSIX gives `fsync`
|
||||
/// exactly one way to say "the data is on stable storage" and that is a zero
|
||||
/// return; a call that never reached the device has not earned it, so `Ok(())`
|
||||
/// from here means the flush completed and nothing else.
|
||||
///
|
||||
/// The kernel will still flush on close, so the data is usually durable
|
||||
/// anyway — but that is a probability, not a barrier, and a caller that needs
|
||||
/// crash-consistency has to be able to tell the difference.
|
||||
///
|
||||
/// ## fd-reuse safety
|
||||
///
|
||||
@@ -78,27 +79,7 @@ pub(super) fn durable_sync(file: &File) -> io::Result<()> {
|
||||
},
|
||||
) {
|
||||
Ok(inner) => inner,
|
||||
Err(crate::io::bounded::BoundedError::Timeout) => {
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fsync timed out after 60s; kernel will flush on close (best-effort)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(crate::io::bounded::BoundedError::Halted) => {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fsync skipped (halt requested); data not durably flushed, kernel will flush on close"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(crate::io::bounded::BoundedError::WorkerLost) => {
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fsync worker lost before completion; data not durably flushed, kernel will flush on close"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => bounded_failure_to_result(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,3 +124,77 @@ mod tests {
|
||||
durable_sync(f.as_file()).expect("durable_sync must return Ok on a local tempfile");
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a [`crate::io::bounded::BoundedError`] from the bounded `fsync` onto the
|
||||
/// `io::Error` `durable_sync` returns.
|
||||
///
|
||||
/// Every arm means the same thing: **no sync observably ran**. All three used to
|
||||
/// return `Ok(())`, so `WritebackFile::sync_all` reported success for a
|
||||
/// durability barrier that never happened. POSIX gives `fsync` one way to say
|
||||
/// "the data is on stable storage" — a zero return — and a call that never
|
||||
/// reached the device has not earned it.
|
||||
///
|
||||
/// This mirrors the macOS `F_FULLFSYNC` mapping exactly. The two were found
|
||||
/// carrying the identical defect, and a platform disagreeing with its sibling
|
||||
/// about whether a failed sync is an error is the "works on my platform" class
|
||||
/// this crate has been bitten by before — most recently an over-length SCSI CDB
|
||||
/// that macOS rejected and the other two silently truncated.
|
||||
///
|
||||
/// No message text (this crate ships no user-facing English): the kind, and
|
||||
/// `EIO` for the worker-lost case, are the signal; `tracing` carries the detail.
|
||||
fn bounded_failure_to_result(e: crate::io::bounded::BoundedError) -> io::Result<()> {
|
||||
match e {
|
||||
crate::io::bounded::BoundedError::Timeout => {
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fsync timed out after 60s; data NOT durably flushed, kernel will flush on close"
|
||||
);
|
||||
Err(crate::error::Error::SyncTimeout.into())
|
||||
}
|
||||
crate::io::bounded::BoundedError::Halted => {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fsync skipped (halt requested); data NOT durably flushed, kernel will flush on close"
|
||||
);
|
||||
Err(crate::error::Error::Halted.into())
|
||||
}
|
||||
crate::io::bounded::BoundedError::WorkerLost => {
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fsync worker lost before completion; data NOT durably flushed, kernel will flush on close"
|
||||
);
|
||||
// EIO, matching the macOS sibling: a consumer distinguishing these
|
||||
// three failures does so on the same value on every platform.
|
||||
// ErrorKind::Other carries nothing a caller can branch on.
|
||||
Err(crate::error::Error::SyncWorkerLost.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bounded_failure_tests {
|
||||
use super::*;
|
||||
use crate::io::bounded::BoundedError;
|
||||
|
||||
/// Every bounded-fsync failure must be an error. Asserted per variant rather
|
||||
/// than as a loop so a new variant defaulting to Ok cannot slip through.
|
||||
#[test]
|
||||
fn no_bounded_fsync_failure_maps_to_ok() {
|
||||
assert_eq!(
|
||||
bounded_failure_to_result(BoundedError::Timeout)
|
||||
.expect_err("a timed-out fsync must be an error")
|
||||
.kind(),
|
||||
io::ErrorKind::TimedOut
|
||||
);
|
||||
assert_eq!(
|
||||
bounded_failure_to_result(BoundedError::Halted)
|
||||
.expect_err("a halted fsync must be an error")
|
||||
.kind(),
|
||||
io::ErrorKind::Interrupted
|
||||
);
|
||||
assert!(
|
||||
bounded_failure_to_result(BoundedError::WorkerLost).is_err(),
|
||||
"a lost fsync worker must be an error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,15 +105,46 @@ pub(super) fn durable_sync(file: &File) -> io::Result<()> {
|
||||
},
|
||||
) {
|
||||
Ok(inner) => inner,
|
||||
Err(crate::io::bounded::BoundedError::Timeout) => {
|
||||
Err(e) => bounded_failure_to_result(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a [`crate::io::bounded::BoundedError`] from the bounded `F_FULLFSYNC`
|
||||
/// onto the `io::Error` `durable_sync` returns.
|
||||
///
|
||||
/// Every arm here means the same thing: **no sync observably ran**. All three
|
||||
/// previously returned `Ok(())`, so `WritebackFile::sync_all` reported success
|
||||
/// for a durability barrier that never happened — a total failure exiting 0,
|
||||
/// with only a log line to distinguish it. POSIX gives `fsync` exactly one way
|
||||
/// to say "the data is on stable storage" and that is a zero return; a call
|
||||
/// that never reached the device has not earned it.
|
||||
///
|
||||
/// The errors carry no message text (this crate ships no user-facing English):
|
||||
/// the kind, and `EIO` for the worker-lost case, are the whole signal, and the
|
||||
/// `tracing` lines above/below carry the operator detail.
|
||||
fn bounded_failure_to_result(e: crate::io::bounded::BoundedError) -> io::Result<()> {
|
||||
match e {
|
||||
crate::io::bounded::BoundedError::Timeout => {
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all F_FULLFSYNC timed out after 60s; kernel will flush on close (best-effort)"
|
||||
"WritebackFile::sync_all F_FULLFSYNC timed out after 60s; data NOT durably flushed, kernel will flush on close"
|
||||
);
|
||||
Ok(())
|
||||
Err(crate::error::Error::SyncTimeout.into())
|
||||
}
|
||||
crate::io::bounded::BoundedError::Halted => {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all F_FULLFSYNC skipped (halt requested); data NOT durably flushed, kernel will flush on close"
|
||||
);
|
||||
Err(crate::error::Error::Halted.into())
|
||||
}
|
||||
crate::io::bounded::BoundedError::WorkerLost => {
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all F_FULLFSYNC worker lost before completion; data NOT durably flushed, kernel will flush on close"
|
||||
);
|
||||
Err(crate::error::Error::SyncWorkerLost.into())
|
||||
}
|
||||
Err(crate::io::bounded::BoundedError::Halted) => Ok(()),
|
||||
Err(crate::io::bounded::BoundedError::WorkerLost) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,4 +187,75 @@ mod tests {
|
||||
// durable_sync must complete without error on the local tempfile.
|
||||
durable_sync(f.as_file()).expect("durable_sync must return Ok on a local tempfile");
|
||||
}
|
||||
|
||||
/// Every `BoundedError` arm of the bounded `F_FULLFSYNC` means no sync
|
||||
/// observably ran. All three returned `Ok(())`, so `sync_all` reported a
|
||||
/// durability barrier that never happened — the caller could not tell a
|
||||
/// completed flush from a skipped one by any means except reading a log.
|
||||
///
|
||||
/// Asserted on the concrete `ErrorKind` / `errno` each arm must produce,
|
||||
/// so a future arm that quietly reverts to `Ok(())` fails here.
|
||||
#[test]
|
||||
fn every_bounded_failure_is_reported_as_an_error() {
|
||||
use crate::io::bounded::BoundedError;
|
||||
|
||||
let timeout = bounded_failure_to_result(BoundedError::Timeout)
|
||||
.expect_err("a timed-out F_FULLFSYNC must be an error");
|
||||
assert_eq!(
|
||||
timeout.kind(),
|
||||
io::ErrorKind::TimedOut,
|
||||
"a timed-out F_FULLFSYNC must not be reported as a completed sync"
|
||||
);
|
||||
|
||||
let halted = bounded_failure_to_result(BoundedError::Halted)
|
||||
.expect_err("a halted F_FULLFSYNC must be an error");
|
||||
assert_eq!(
|
||||
halted.kind(),
|
||||
io::ErrorKind::Interrupted,
|
||||
"a halted F_FULLFSYNC must not be reported as a completed sync"
|
||||
);
|
||||
|
||||
// The three arms must be DISTINGUISHABLE, not merely non-Ok. Each
|
||||
// carries its own numeric code through the "E<code>" prefix that
|
||||
// `From<Error> for io::Error` mints — the only shape `error_code`
|
||||
// recognises. A bare `ErrorKind` cannot be classified, which is how a
|
||||
// user cancel here used to read as a hard I/O failure.
|
||||
let lost = bounded_failure_to_result(BoundedError::WorkerLost)
|
||||
.expect_err("a lost F_FULLFSYNC worker must be an error");
|
||||
assert!(
|
||||
lost.to_string()
|
||||
.starts_with(&format!("E{}", crate::error::E_SYNC_WORKER_LOST)),
|
||||
"a lost worker must be identifiable, got {lost}"
|
||||
);
|
||||
assert!(
|
||||
timeout
|
||||
.to_string()
|
||||
.starts_with(&format!("E{}", crate::error::E_SYNC_TIMEOUT)),
|
||||
"a timeout must be distinguishable from a lost worker, got {timeout}"
|
||||
);
|
||||
assert!(
|
||||
crate::error::is_halt(&halted),
|
||||
"a halt must satisfy the crate's own is_halt(), or the CLI reports a \
|
||||
user cancel as a failure; got {halted}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The failure path must be reachable through the public surface: a
|
||||
/// `WritebackFile::sync_all` that hits any of these arms must surface an
|
||||
/// `Err`, not a silent `Ok`. Pinned at the mapping boundary because the
|
||||
/// timeout itself is not deterministically inducible in a unit test.
|
||||
#[test]
|
||||
fn bounded_failures_are_never_mapped_to_ok() {
|
||||
use crate::io::bounded::BoundedError;
|
||||
for e in [
|
||||
BoundedError::Timeout,
|
||||
BoundedError::Halted,
|
||||
BoundedError::WorkerLost,
|
||||
] {
|
||||
assert!(
|
||||
bounded_failure_to_result(e).is_err(),
|
||||
"a bounded F_FULLFSYNC failure must never map to Ok"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ fn writeback_chunk_bytes() -> u64 {
|
||||
.unwrap_or(WRITEBACK_CHUNK_BYTES_DEFAULT)
|
||||
}
|
||||
|
||||
pub(crate) struct WritebackFile {
|
||||
pub struct WritebackFile {
|
||||
file: File,
|
||||
pipeline: WritebackPipeline,
|
||||
pos: u64,
|
||||
@@ -115,7 +115,7 @@ impl WritebackFile {
|
||||
/// once so the pipeline starts tracking from wherever the file
|
||||
/// already is (typically 0 for fresh files; non-zero for resumed
|
||||
/// or appended files).
|
||||
pub(crate) fn new(mut file: File) -> io::Result<Self> {
|
||||
pub fn new(mut file: File) -> io::Result<Self> {
|
||||
let pos = file.stream_position()?;
|
||||
let pipeline = WritebackPipeline::new(&file, pos, writeback_chunk_bytes());
|
||||
Ok(Self {
|
||||
@@ -136,7 +136,7 @@ impl WritebackFile {
|
||||
/// [`Self::create_with_size_hint`] so the kernel can pre-reserve
|
||||
/// extents.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn create(path: &Path) -> io::Result<Self> {
|
||||
pub fn create(path: &Path) -> io::Result<Self> {
|
||||
let file = File::create(path)?;
|
||||
Self::new(file)
|
||||
}
|
||||
@@ -153,7 +153,7 @@ impl WritebackFile {
|
||||
/// On platforms without an extent-preallocation primitive this is
|
||||
/// equivalent to `create` — the size hint is dropped after a debug
|
||||
/// log.
|
||||
pub(crate) fn create_with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
|
||||
pub fn create_with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
|
||||
let file = File::create(path)?;
|
||||
platform::preallocate(&file, size_bytes);
|
||||
Self::new(file)
|
||||
@@ -163,7 +163,7 @@ impl WritebackFile {
|
||||
/// wrap it. Mirrors `File::open` semantics for the writable case
|
||||
/// — used by patch / resume paths that mutate an existing ISO in
|
||||
/// place.
|
||||
pub(crate) fn open(path: &Path) -> io::Result<Self> {
|
||||
pub fn open(path: &Path) -> io::Result<Self> {
|
||||
let file = OpenOptions::new().write(true).open(path)?;
|
||||
Self::new(file)
|
||||
}
|
||||
@@ -178,13 +178,20 @@ impl WritebackFile {
|
||||
/// is left to the kernel's normal flush-on-close path — best
|
||||
/// effort, but bounded.
|
||||
///
|
||||
/// IMPORTANT: on Linux/macOS a successful `Ok(())` does NOT
|
||||
/// guarantee the data is durable if the bounded fsync timed out or
|
||||
/// was halted — only the hang is bounded, the fsync may not have
|
||||
/// completed. Callers needing crash-consistency (e.g. mux-finish
|
||||
/// then external commit/DB update) must not treat `Ok(())` as a
|
||||
/// durability barrier.
|
||||
pub(crate) fn sync_all(&mut self) -> io::Result<()> {
|
||||
/// A bounded-fsync failure is returned as an `Err` on BOTH platforms, so
|
||||
/// `Ok(())` means the flush completed and a caller needing
|
||||
/// crash-consistency can treat it as a durability barrier.
|
||||
///
|
||||
/// The three causes are DISTINGUISHABLE by numeric code, because a caller
|
||||
/// should not retry a lost worker the way it retries a timeout, and must
|
||||
/// not report a user cancel as a failure:
|
||||
///
|
||||
/// * [`E_SYNC_TIMEOUT`](crate::error::E_SYNC_TIMEOUT) — deadline expired
|
||||
/// * [`E_HALTED`](crate::error::E_HALTED) — cancelled;
|
||||
/// [`is_halt`](crate::error::is_halt) recognises it
|
||||
/// * [`E_SYNC_WORKER_LOST`](crate::error::E_SYNC_WORKER_LOST) — the worker
|
||||
/// thread died before reporting
|
||||
pub fn sync_all(&mut self) -> io::Result<()> {
|
||||
if self.seek_count > 0 {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
@@ -256,9 +263,8 @@ impl super::sink::SequentialSink for WritebackFile {
|
||||
/// the same work [`Self::sync_all`] does. Implemented explicitly (no
|
||||
/// blanket impl) so a `dyn SequentialSink` / `dyn RandomAccessSink`
|
||||
/// `finish()` actually finalises + fsyncs instead of hitting a no-op
|
||||
/// default. Note the bounded-fsync caveat from [`Self::sync_all`]
|
||||
/// applies: `Ok(())` is not a durability barrier if the fsync timed
|
||||
/// out or was halted.
|
||||
/// default. A bounded-fsync failure surfaces as an `Err` here, on every
|
||||
/// platform, exactly as it does from [`Self::sync_all`].
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
self.sync_all()
|
||||
}
|
||||
|
||||
+584
-86
@@ -53,9 +53,30 @@ pub const MIN_SAMPLE_UNITS: usize = 8;
|
||||
/// units it yields); the *requested* count is a caller-side compile-time constant that
|
||||
/// callers pin to `MIN_SAMPLE_UNITS` (see e.g. autorip's `SAMPLE_UNITS`). Together the
|
||||
/// two make under-sampling unrepresentable at the request boundary.
|
||||
#[derive(Debug, Clone)]
|
||||
///
|
||||
/// The wrapped samples are on-disc AACS ciphertext — the same bytes the sibling
|
||||
/// [`DiscInputs::samples`] redacts as key MATERIAL — so [`Debug`] is hand-written
|
||||
/// and redacting; see the impl below.
|
||||
#[derive(Clone)]
|
||||
pub struct DecodeSampleSet(Vec<Vec<u8>>);
|
||||
|
||||
impl std::fmt::Debug for DecodeSampleSet {
|
||||
/// Prints the SHAPE only. A derived `Debug` dumped every wrapped sample
|
||||
/// verbatim: a `DecodeSampleSet` carries at least [`MIN_SAMPLE_UNITS`]
|
||||
/// 6144-byte aligned units (≥ 49 KiB, in practice multi-MB) of AACS
|
||||
/// ciphertext plus each unit's clear 16-byte derivation seed, so one
|
||||
/// `tracing::debug!("{set:?}")` on a failed `/decode` request — or an
|
||||
/// `assert_eq!` whose panic message formats it — wrote all of it to the log
|
||||
/// that gets attached to a bug report. Same policy and same shape as
|
||||
/// [`DiscInputs`]'s impl below.
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DecodeSampleSet")
|
||||
.field("units", &"<redacted>")
|
||||
.field("units_len", &self.0.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl DecodeSampleSet {
|
||||
/// Wrap `units` iff it carries at least [`MIN_SAMPLE_UNITS`] samples; `None`
|
||||
/// otherwise (the caller then skips the online source rather than sending an
|
||||
@@ -82,9 +103,12 @@ impl DecodeSampleSet {
|
||||
}
|
||||
|
||||
/// The public AACS inputs a key source needs to look a disc up. Captured at
|
||||
/// scan; contains no secrets — only the disc identity and the on-disc AACS
|
||||
/// structures a source or key server may key on.
|
||||
#[derive(Debug, Clone)]
|
||||
/// scan; carries no DERIVED secrets (no media key, VUK or plaintext unit key) —
|
||||
/// only the disc identity and the on-disc AACS structures a source or key server
|
||||
/// may key on. The on-disc structures are nonetheless key MATERIAL (the encrypted
|
||||
/// title keys live in `unit_key_ro`), so [`Debug`] is hand-written and redacting;
|
||||
/// see the impl below.
|
||||
#[derive(Clone)]
|
||||
pub struct DiscInputs {
|
||||
/// SHA-1 of `Unit_Key_RO.inf`, `0x`-prefixed hex. The value a keydb keys
|
||||
/// its per-disc entries by, and a key server identifies the disc with.
|
||||
@@ -115,7 +139,31 @@ pub struct DiscInputs {
|
||||
pub volume_label: Option<String>,
|
||||
}
|
||||
|
||||
/// A lazy view of a disc's AACS material, handed to [`KeySource::get_uk`] so a
|
||||
/// Redacting `Debug`, per the policy `aacs::types` documents (and which
|
||||
/// `aacs::types::Vid` already applies to this very Volume ID). `DiscInputs` is
|
||||
/// public and returned by [`crate::Disc::inputs`], so a consumer's
|
||||
/// `tracing::debug!("{inputs:?}")` used to print the Volume ID, the whole
|
||||
/// `Unit_Key_RO.inf` (the encrypted title keys), the entire MKB and every
|
||||
/// ciphertext sample verbatim into a log that ends up attached to a bug report.
|
||||
/// Only non-secret identity and shape (presence, lengths) is printed.
|
||||
impl std::fmt::Debug for DiscInputs {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DiscInputs")
|
||||
.field("disc_hash", &self.disc_hash)
|
||||
.field("volume_id", &"<redacted>")
|
||||
.field("version", &self.version)
|
||||
.field("mkb", &"<redacted>")
|
||||
.field("mkb_len", &self.mkb.len())
|
||||
.field("unit_key_ro", &"<redacted>")
|
||||
.field("unit_key_ro_len", &self.unit_key_ro.len())
|
||||
.field("samples", &"<redacted>")
|
||||
.field("samples_len", &self.samples.len())
|
||||
.field("volume_label", &self.volume_label)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A lazy view of a disc's AACS material, handed to [`KeySource::get_unit_keys`] so a
|
||||
/// source can drive the derivation chain without holding the disc reader.
|
||||
///
|
||||
/// "Lazy" by contract: each accessor returns only what the source asks for, so a
|
||||
@@ -233,10 +281,34 @@ impl ResolveCtx for DiscInputsCtx<'_> {
|
||||
/// ([`resolve_and_apply`]) tries each source in order and validates the returned
|
||||
/// keys against real ciphertext before committing them, so a wrong key from one
|
||||
/// source transparently falls through to the next.
|
||||
///
|
||||
/// Two explicit resolve operations, one per key kind — never one overloaded call
|
||||
/// whose meaning depends on how many keys came back:
|
||||
/// * [`get_unit_keys`](Self::get_unit_keys) — the disc's base per-CPS-unit Unit
|
||||
/// Keys (index space = CPS-unit number). The common path for every disc.
|
||||
/// * [`get_fmts_indexes`](Self::get_fmts_indexes) — the AACS 2.1 forensic index
|
||||
/// keys (index space = forensic index 1..N). Defaults to empty: a source with
|
||||
/// no forensic material opts out, and only an FMTS disc ever asks.
|
||||
///
|
||||
/// What each source must do to answer is the source's own business: a keydb keys
|
||||
/// on `disc_hash` and reads no samples; the online source submits the ctx's
|
||||
/// content samples (a base batch for `get_unit_keys`, an index-1 anchor batch for
|
||||
/// `get_fmts_indexes`) to the key service.
|
||||
pub trait KeySource {
|
||||
/// Resolve this disc's terminal Unit Keys from this source. An empty `Vec`
|
||||
/// is a genuine "no key here"; `Err` is a source failure.
|
||||
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error>;
|
||||
/// Resolve this disc's base per-CPS-unit Unit Keys from this source. An empty
|
||||
/// `Vec` is a genuine "no key here"; `Err` is a source failure.
|
||||
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error>;
|
||||
|
||||
/// Resolve this disc's AACS 2.1 forensic index keys — the per-index keys the
|
||||
/// base Unit Key cannot open (see [`crate::aacs::segment`]) — ordered by
|
||||
/// forensic index (element `i` carries `UnitKey.idx == i`, forensic index
|
||||
/// `i + 1`). The source hands back the COMPLETE set it holds; the caller
|
||||
/// trusts any non-empty result as all of them and never assumes a fixed count.
|
||||
/// Defaults to empty: a source with no forensic material (a plain keydb, the
|
||||
/// mapfile) opts out, and only an FMTS disc's mux ever calls this.
|
||||
fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// The AACS host certificate(s) this source can supply for the live-drive
|
||||
/// SCSI mutual-auth handshake (the OEM/AACS baseline route). `mkb` is the
|
||||
@@ -273,7 +345,7 @@ pub fn resolve_and_apply(
|
||||
/// [`crate::aacs::trace::ResolutionTrace`] recording, per source, what happened — for
|
||||
/// applications to render. ZERO English; the trace is typed enums only.
|
||||
///
|
||||
/// One-shot per source: each source's [`KeySource::get_uk`] is called exactly
|
||||
/// One-shot per source: each source's [`KeySource::get_unit_keys`] is called exactly
|
||||
/// once with a [`DiscInputsCtx`] over `inputs`. Non-empty Unit Keys are mapped
|
||||
/// to terminal [`Key::Unit`]s and applied via [`crate::Disc::decrypt_with`],
|
||||
/// which validates them against `inputs.samples` and only mutates the disc on
|
||||
@@ -283,8 +355,23 @@ pub fn resolve_and_apply(
|
||||
/// from [`crate::aacs::derive::decrypt_unit_key`]; the library's canonical CPS-unit number is
|
||||
/// `position + 1` (matching [`crate::aacs::inf::parse_unit_key_ro`]'s `(i + 1)`), so
|
||||
/// the committed `AacsState.unit_keys` is byte-identical to the library-resolved
|
||||
/// path. The number is cosmetic for descramble (the decrypt path strips it and
|
||||
/// tries every key) but is kept faithful to the resolver's convention.
|
||||
/// path.
|
||||
///
|
||||
/// The NUMBER itself is not what descramble indexes by — but the ORDER is
|
||||
/// load-bearing, so a source must return its keys in CPS-unit order. Trial
|
||||
/// decrypt-and-check was deliberately deleted (see
|
||||
/// [`crate::decrypt::AacsKeyMap`]: decryption is driven by the disc's CPS-unit /
|
||||
/// FMTS-segment structure, "never by trial-decrypt-and-check per unit"), and
|
||||
/// `decrypt_sectors_mapped` indexes the committed pool POSITIONALLY —
|
||||
/// `unit_keys[key_idx].1`, where `key_idx` is a POSITION in the Vec a source
|
||||
/// returned, recorded by `resolve_mux_key_map_cached` / `resolve_fmts_key_map`.
|
||||
/// Return the same keys in a different order and every `AacsKeyMap` points at the
|
||||
/// wrong key: the whole title decrypts under a neighbour's key, or a forensic
|
||||
/// range trips the `is_clean` net into `DecryptFailed`. (The doc used to say the
|
||||
/// number "is cosmetic for descramble (the decrypt path strips it and tries every
|
||||
/// key)", which is what the DELETED trial-decrypt path did; the only place that
|
||||
/// still tries every key is `Disc::decrypt_with`'s sample VALIDATION, which does
|
||||
/// not descramble content.)
|
||||
pub fn resolve_and_apply_traced(
|
||||
sources: &[Box<dyn KeySource>],
|
||||
inputs: &DiscInputs,
|
||||
@@ -294,6 +381,14 @@ pub fn resolve_and_apply_traced(
|
||||
|
||||
let mut trace = crate::aacs::trace::ResolutionTrace::new();
|
||||
|
||||
// The FIRST source failure seen, if any. A source that returns `Err` did not
|
||||
// answer "no key for this disc" — it could not answer at all — and that
|
||||
// reason is stamped onto `disc.aacs_error` below so the decrypt gate reports
|
||||
// THAT instead of the generic `NoDiscKey`. First-wins (not last) so the
|
||||
// ordered sources' most-preferred failure is the one the operator is told
|
||||
// about, matching the first-valid-wins rule for successes.
|
||||
let mut source_failure: Option<crate::error::Error> = None;
|
||||
|
||||
// The ctx parses Unit_Key_RO.inf at the stride for `inputs.version` (the
|
||||
// disc's own AACS major), so the stride is the disc's single source of truth.
|
||||
let ctx = DiscInputsCtx::new(inputs);
|
||||
@@ -301,7 +396,7 @@ pub fn resolve_and_apply_traced(
|
||||
for source in sources {
|
||||
// `who` is the source's own stable identifier — no enum to map back to.
|
||||
let who = source.label().to_string();
|
||||
match source.get_uk(&ctx) {
|
||||
match source.get_unit_keys(&ctx) {
|
||||
Ok(uks) if !uks.is_empty() => {
|
||||
// Positional index → canonical CPS-unit number (position + 1).
|
||||
let unit_keys: Vec<(u32, [u8; 16])> = uks
|
||||
@@ -326,17 +421,56 @@ pub fn resolve_and_apply_traced(
|
||||
outcome: KeyOutcome::NoKey,
|
||||
});
|
||||
}
|
||||
// Empty (no key here) or a source failure — both are "no key from
|
||||
// this source"; move on to the next.
|
||||
Ok(_) | Err(_) => {
|
||||
// The source ANSWERED and holds nothing for this disc. This — and
|
||||
// only this — is `NoEntry`: the claim "I looked, it is not there".
|
||||
Ok(_) => {
|
||||
trace.keys.push(KeyStep {
|
||||
who,
|
||||
path: vec![KeyNode::NoEntry],
|
||||
outcome: KeyOutcome::NoKey,
|
||||
});
|
||||
}
|
||||
// The source could NOT answer — it was unreachable, it errored, or it
|
||||
// refused. Nothing is known about whether a key exists, so the path
|
||||
// is EMPTY: recording `NoEntry` here is exactly the conflation that
|
||||
// made a seven-hour run of HTTP 502s render as
|
||||
// `key: online > no entry > NO KEY` + `E7022 No key source has a
|
||||
// decryption key for this disc`, and sent operators hunting for a VUK
|
||||
// that was never missing.
|
||||
//
|
||||
// The reason itself rides out on `disc.aacs_error` (below), the
|
||||
// channel `Disc::ensure_decryptable_keys` already reads for the
|
||||
// E7017-vs-E7022 split — so the decrypt gate raises the SOURCE's code
|
||||
// (`KeyServiceUnavailable` / `KeyServiceUnauthorized` /
|
||||
// `KeyServiceRateLimited`) instead of the generic `NoDiscKey`.
|
||||
//
|
||||
// `KeyOutcome` deliberately gains no variant: it is matched
|
||||
// exhaustively by every front-end's trace renderer (freemkv's
|
||||
// `pipe::render_resolution_trace`, autorip's
|
||||
// `keysource::render_resolution_trace`), and this fix must not turn
|
||||
// into a breaking change across four repos to say something the error
|
||||
// code already says precisely.
|
||||
Err(e) => {
|
||||
if source_failure.is_none() {
|
||||
source_failure = Some(e);
|
||||
}
|
||||
trace.keys.push(KeyStep {
|
||||
who,
|
||||
path: Vec::new(),
|
||||
outcome: KeyOutcome::NoKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Nothing resolved. If a source FAILED rather than answered, stamp that
|
||||
// reason onto the disc so the decrypt gate can report it — but never clobber
|
||||
// a reason the scan already captured (e.g. `AacsVidUnavailable`), which is
|
||||
// closer to the disc itself than a source outage is.
|
||||
if let Some(e) = source_failure
|
||||
&& disc.aacs_error.is_none()
|
||||
{
|
||||
disc.aacs_error = Some(e);
|
||||
}
|
||||
(false, trace)
|
||||
}
|
||||
|
||||
@@ -351,71 +485,156 @@ pub fn resolve_and_apply_traced(
|
||||
/// [`resolve_and_apply`] this does not validate/commit to a disc — the read's
|
||||
/// decorator re-decrypts with the returned keys, which is the validation.
|
||||
pub fn fetch_unit_keys(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
|
||||
for source in sources {
|
||||
if let Ok(uks) = source.get_uk(ctx) {
|
||||
if !uks.is_empty() {
|
||||
return uks;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
drive_unit_keys(sources, ctx).keys
|
||||
}
|
||||
|
||||
/// Build the read-time key-fetch closure from the disc's public AACS inputs and
|
||||
/// a way to (re)build the application's key sources. The decorator calls it with
|
||||
/// the still-scrambled unit ciphertext when no held key opens that unit; it runs
|
||||
/// [`fetch_unit_keys`] with those bytes as `samples` and returns any keys.
|
||||
/// Whether a driver run resolved keys, and — when it did NOT — whether the miss
|
||||
/// was a genuine "no source holds this key" (`errored == false`) or at least one
|
||||
/// source FAILED (`errored == true`, e.g. a network source was unreachable). The
|
||||
/// distinction gates negative-result memoization: an empty-because-absent result
|
||||
/// is safe to cache, an empty-because-a-source-was-down result is transient and
|
||||
/// must NOT be cached (the key may resolve once the source recovers).
|
||||
struct FetchOutcome {
|
||||
keys: Vec<UnitKey>,
|
||||
errored: bool,
|
||||
}
|
||||
|
||||
/// [`fetch_unit_keys`] plus the error signal: drive `sources` in order, return the
|
||||
/// first source's non-empty Unit Keys, and flag whether any source that failed to
|
||||
/// answer did so with an `Err` (a source failure) rather than an empty `Ok`
|
||||
/// (genuine absence — see [`KeySource::get_unit_keys`]).
|
||||
fn drive_unit_keys(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> FetchOutcome {
|
||||
let mut errored = false;
|
||||
for source in sources {
|
||||
match source.get_unit_keys(ctx) {
|
||||
Ok(uks) if !uks.is_empty() => {
|
||||
return FetchOutcome {
|
||||
keys: uks,
|
||||
errored: false,
|
||||
};
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => errored = true,
|
||||
}
|
||||
}
|
||||
FetchOutcome {
|
||||
keys: Vec::new(),
|
||||
errored,
|
||||
}
|
||||
}
|
||||
|
||||
/// The forensic counterpart to [`fetch_unit_keys`]: drive `sources` in order and
|
||||
/// return the first source's non-empty AACS 2.1 forensic index set. `ctx` carries
|
||||
/// the index-1 anchor batch (the mux, which owns disc geometry, gathers it and
|
||||
/// injects it as the ctx's samples); a source that needs no samples (a keydb
|
||||
/// keying on `disc_hash`) ignores them. Whatever the winning source returns —
|
||||
/// ≥ 1 key — is trusted as the COMPLETE ordered set; no fixed count is assumed.
|
||||
pub fn fetch_fmts_indexes(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
|
||||
drive_fmts_indexes(sources, ctx).keys
|
||||
}
|
||||
|
||||
/// [`fetch_fmts_indexes`] plus the error signal (see [`drive_unit_keys`]): the
|
||||
/// forensic counterpart that flags whether any source `Err`ed during the miss.
|
||||
fn drive_fmts_indexes(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> FetchOutcome {
|
||||
let mut errored = false;
|
||||
for source in sources {
|
||||
match source.get_fmts_indexes(ctx) {
|
||||
Ok(uks) if !uks.is_empty() => {
|
||||
return FetchOutcome {
|
||||
keys: uks,
|
||||
errored: false,
|
||||
};
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => errored = true,
|
||||
}
|
||||
}
|
||||
FetchOutcome {
|
||||
keys: Vec::new(),
|
||||
errored,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the read-time [`crate::sector::KeyFetch`] from the disc's public AACS
|
||||
/// inputs and a way to (re)build the application's key sources. The returned
|
||||
/// resolver has the two explicit operations the mux and recovery decorator call:
|
||||
/// [`unit_keys`](crate::sector::KeyFetch::unit_keys) drives [`fetch_unit_keys`]
|
||||
/// (base per-CPS-unit keys), [`fmts_indexes`](crate::sector::KeyFetch::fmts_indexes)
|
||||
/// drives [`fetch_fmts_indexes`] (the AACS 2.1 forensic set). Each is handed the
|
||||
/// caller's sample batch as the ctx's `samples`, so a source pulls whatever
|
||||
/// material it needs.
|
||||
///
|
||||
/// One builder, used by every read path (sweep / patch / mux) and by every
|
||||
/// consumer (CLI, autorip) — neither application contains the fetch logic, only
|
||||
/// its key-source config. Returns a **shared, stateless** [`crate::sector::KeyFetch`]
|
||||
/// (`Arc<Fn>`): build it once, clone it into each read path. `make_sources` is
|
||||
/// invoked per fetch (the cold path, ~once per CPS unit) so the closure stays
|
||||
/// One builder, used by every read path (sweep / patch / mux) and every consumer
|
||||
/// (CLI, autorip) — neither application contains the fetch logic, only its
|
||||
/// key-source config. Cheap to clone; build once, clone into each read path.
|
||||
/// `make_sources` is invoked per fetch (the cold path) so the resolver stays
|
||||
/// `Send + Sync` without requiring `KeySource: Send`.
|
||||
pub fn key_fetch(
|
||||
inputs: DiscInputs,
|
||||
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
|
||||
) -> crate::sector::KeyFetch {
|
||||
// Memoize by the fingerprint of the sample batch. The resolved keys are
|
||||
// disc-level (the same clip's index / CPS keys are identical for every title
|
||||
// that references it), and this one closure is shared across every title's mux
|
||||
// — so the first title resolves a given batch over the network and every later
|
||||
// title (or repeated batch) is answered from the cache with no request. Empty
|
||||
// replies are cached too: a key the service does not have for a batch will not
|
||||
// appear on a re-ask, so re-hitting the network buys nothing.
|
||||
let cache: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<u64, Vec<[u8; 16]>>>> =
|
||||
std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
|
||||
std::sync::Arc::new(move |samples: &[Vec<u8>]| -> Vec<[u8; 16]> {
|
||||
let fp = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
samples.len().hash(&mut h);
|
||||
for s in samples {
|
||||
s.hash(&mut h);
|
||||
// One driver behind both operations: rebuild the sources, inject `samples`
|
||||
// as the ctx's content samples, run `drive` (the per-kind fetch), map the
|
||||
// resolved UnitKeys to raw keys. Memoized by the fingerprint of the sample
|
||||
// batch: the resolved keys are disc-level (a clip's index / CPS keys are
|
||||
// identical for every title that references it), so the first batch resolves
|
||||
// over the network and every repeat is answered from the cache with no
|
||||
// request. A GENUINELY-empty reply (every source ran and none held the key)
|
||||
// is cached too — the key the service lacks for a batch won't appear on a
|
||||
// re-ask, so re-hitting the network buys nothing. But an empty reply caused
|
||||
// by a source FAILURE (network down, source unreachable) is NOT cached: that
|
||||
// is a transient miss, and caching it would permanently drop a unit that
|
||||
// could be recovered once the source recovers — the `errored` flag on
|
||||
// `FetchOutcome` draws exactly that line. Each operation gets its OWN cache:
|
||||
// a base batch and a forensic anchor never collide, and the same bytes could
|
||||
// legitimately resolve differently per op.
|
||||
// The per-kind driver: `drive_unit_keys` or `drive_fmts_indexes`.
|
||||
type FetchDriver = fn(&[Box<dyn KeySource>], &dyn ResolveCtx) -> FetchOutcome;
|
||||
fn make_op(
|
||||
inputs: DiscInputs,
|
||||
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
|
||||
drive: FetchDriver,
|
||||
) -> crate::sector::KeyFetchFn {
|
||||
let cache: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<u64, Vec<[u8; 16]>>>> =
|
||||
std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
|
||||
std::sync::Arc::new(move |samples: &[Vec<u8>]| -> Vec<[u8; 16]> {
|
||||
let fp = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
samples.len().hash(&mut h);
|
||||
for s in samples {
|
||||
s.hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
};
|
||||
if let Some(hit) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&fp) {
|
||||
return hit.clone();
|
||||
}
|
||||
h.finish()
|
||||
};
|
||||
if let Some(hit) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&fp) {
|
||||
return hit.clone();
|
||||
}
|
||||
let sources = make_sources();
|
||||
let mut di = inputs.clone();
|
||||
di.samples = samples.to_vec();
|
||||
// Parse Unit_Key_RO.inf at the disc's OWN stride (carried on `inputs`):
|
||||
// an online /decode reply that returns a VUK (not a terminal UK) then
|
||||
// derives unit keys from `enc_title_keys`, which a V10 disc parses at the
|
||||
// 48-byte stride — hardcoding the V20 stride here corrupted them.
|
||||
let ctx = DiscInputsCtx::new(&di);
|
||||
let keys: Vec<[u8; 16]> = fetch_unit_keys(&sources, &ctx)
|
||||
.into_iter()
|
||||
.map(|u| u.key)
|
||||
.collect();
|
||||
cache
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(fp, keys.clone());
|
||||
keys
|
||||
})
|
||||
let sources = make_sources();
|
||||
let mut di = inputs.clone();
|
||||
di.samples = samples.to_vec();
|
||||
// Parse Unit_Key_RO.inf at the disc's OWN stride (carried on `inputs`):
|
||||
// an online /decode reply that returns a VUK (not a terminal UK) then
|
||||
// derives unit keys from `enc_title_keys`, which a V10 disc parses at
|
||||
// the 48-byte stride — hardcoding the V20 stride here corrupted them.
|
||||
let ctx = DiscInputsCtx::new(&di);
|
||||
let outcome = drive(&sources, &ctx);
|
||||
let keys: Vec<[u8; 16]> = outcome.keys.into_iter().map(|u| u.key).collect();
|
||||
// Memoize a positive result always; memoize a NEGATIVE (empty) result
|
||||
// only when it is a genuine absence, never when a source errored — a
|
||||
// transient outage must not permanently poison this fingerprint.
|
||||
if !keys.is_empty() || !outcome.errored {
|
||||
cache
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(fp, keys.clone());
|
||||
}
|
||||
keys
|
||||
})
|
||||
}
|
||||
let unit = make_op(inputs.clone(), make_sources.clone(), drive_unit_keys);
|
||||
let fmts = make_op(inputs, make_sources, drive_fmts_indexes);
|
||||
crate::sector::KeyFetch::new(unit, fmts)
|
||||
}
|
||||
|
||||
/// Read up to `n` ENCRYPTED 6144-byte aligned units from `title`'s body, raw (no
|
||||
@@ -430,9 +649,9 @@ pub fn key_fetch(
|
||||
///
|
||||
/// "Encrypted" is decided by [`crate::aacs::content::aacs_unit_encrypted`] — the
|
||||
/// AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the
|
||||
/// spec-correct signal (`buf[0] & 0xc0`). NOT the `ts_sync_destroyed`
|
||||
/// sync heuristic: destroyed TS syncs do not imply encryption (an FMTS variant
|
||||
/// frame or an odd clear unit can lack syncs yet be unencrypted), and a clear
|
||||
/// spec-correct signal (`buf[0] & 0xc0`). NOT the `is_clean` TS-sync
|
||||
/// heuristic: a unit lacking clean TS syncs does not imply encryption (an FMTS
|
||||
/// variant frame or an odd clear unit can lack syncs yet be unencrypted), and a clear
|
||||
/// unit sent to a key server yields nothing to validate against — the "0
|
||||
/// encrypted units" rejection. A clip opens with clear navigation units (PAT/PMT,
|
||||
/// menus) whose CPI is clear; only CPI-flagged content units are collected —
|
||||
@@ -560,7 +779,7 @@ mod tests {
|
||||
fn key_source_host_certs_defaults_to_empty() {
|
||||
struct MinimalSource;
|
||||
impl KeySource for MinimalSource {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
@@ -617,7 +836,7 @@ mod tests {
|
||||
fn trace_who_is_the_source_label_verbatim() {
|
||||
struct LabeledSource(&'static str);
|
||||
impl KeySource for LabeledSource {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn label(&self) -> &'static str {
|
||||
@@ -674,19 +893,19 @@ mod tests {
|
||||
|
||||
struct EmptySource;
|
||||
impl KeySource for EmptySource {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
struct ErroringSource;
|
||||
impl KeySource for ErroringSource {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Err(Error::AacsNoKeys)
|
||||
}
|
||||
}
|
||||
struct HasKey([u8; 16]);
|
||||
impl KeySource for HasKey {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey::new(0, self.0)])
|
||||
}
|
||||
}
|
||||
@@ -729,7 +948,7 @@ mod tests {
|
||||
seen: Arc<Mutex<Vec<Vec<u8>>>>,
|
||||
}
|
||||
impl KeySource for Probe {
|
||||
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
if let Ok(s) = ctx.samples(8) {
|
||||
self.seen.lock().unwrap().extend(s);
|
||||
}
|
||||
@@ -749,7 +968,7 @@ mod tests {
|
||||
|
||||
let cb = key_fetch(empty_inputs(), make);
|
||||
let samples = vec![vec![0xEEu8; crate::aacs::content::ALIGNED_UNIT_LEN]];
|
||||
let got = cb(&samples);
|
||||
let got = cb.unit_keys(&samples);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![key],
|
||||
@@ -763,6 +982,225 @@ mod tests {
|
||||
assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch");
|
||||
}
|
||||
|
||||
/// `key_fetch` memoizes each operation by the fingerprint of the sample batch:
|
||||
/// identical samples reuse the cached keys (no rebuild), different samples miss,
|
||||
/// the two operations keep independent caches, and even an empty reply is cached.
|
||||
#[test]
|
||||
fn key_fetch_memoizes_per_op_by_sample_fingerprint() {
|
||||
let builds = Arc::new(Mutex::new(0usize));
|
||||
let builds_c = Arc::clone(&builds);
|
||||
let key = [0x11u8; 16];
|
||||
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> = Arc::new(move || {
|
||||
*builds_c.lock().unwrap() += 1;
|
||||
vec![Box::new(HasKey(key)) as Box<dyn KeySource>]
|
||||
});
|
||||
let cb = key_fetch(empty_inputs(), make);
|
||||
let a = vec![vec![0xAAu8; 8]];
|
||||
let b = vec![vec![0xBBu8; 8]];
|
||||
|
||||
// First resolve for `a` builds sources; the identical repeat is cached.
|
||||
assert_eq!(cb.unit_keys(&a), vec![key]);
|
||||
assert_eq!(cb.unit_keys(&a), vec![key]);
|
||||
assert_eq!(
|
||||
*builds.lock().unwrap(),
|
||||
1,
|
||||
"identical samples reuse the cache"
|
||||
);
|
||||
|
||||
// A different sample batch is a cache miss → one more build.
|
||||
assert_eq!(cb.unit_keys(&b), vec![key]);
|
||||
assert_eq!(
|
||||
*builds.lock().unwrap(),
|
||||
2,
|
||||
"different samples miss the cache"
|
||||
);
|
||||
|
||||
// The forensic op has its OWN cache (HasKey has no forensic keys → empty),
|
||||
// so `a` builds once more here; its empty reply is then cached too.
|
||||
assert!(cb.fmts_indexes(&a).is_empty());
|
||||
assert_eq!(
|
||||
*builds.lock().unwrap(),
|
||||
3,
|
||||
"unit/fmts caches are independent"
|
||||
);
|
||||
assert!(cb.fmts_indexes(&a).is_empty());
|
||||
assert_eq!(
|
||||
*builds.lock().unwrap(),
|
||||
3,
|
||||
"an empty reply is cached, not re-asked"
|
||||
);
|
||||
}
|
||||
|
||||
/// A transient source outage must NOT be memoized as a permanent "no key":
|
||||
/// a fingerprint whose first fetch failed because the source errored must be
|
||||
/// re-asked, and once the source recovers the key resolves. Regression guard
|
||||
/// for the negative-result memoization fix — caching the errored empty would
|
||||
/// permanently drop a recoverable unit for the rest of the op.
|
||||
#[test]
|
||||
fn errored_empty_is_not_cached_and_retries_when_source_recovers() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let key = [0x77u8; 16];
|
||||
// Shared across every `make_sources()` rebuild: call 0 errors (source
|
||||
// down), every later call succeeds (source recovered).
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
struct Flaky {
|
||||
calls: Arc<AtomicUsize>,
|
||||
key: [u8; 16],
|
||||
}
|
||||
impl KeySource for Flaky {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
Err(Error::AacsNoKeys) // first attempt: source unreachable
|
||||
} else {
|
||||
Ok(vec![UnitKey::new(0, self.key)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let calls_c = Arc::clone(&calls);
|
||||
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> = Arc::new(move || {
|
||||
vec![Box::new(Flaky {
|
||||
calls: Arc::clone(&calls_c),
|
||||
key,
|
||||
}) as Box<dyn KeySource>]
|
||||
});
|
||||
|
||||
let cb = key_fetch(empty_inputs(), make);
|
||||
let samples = vec![vec![0xCDu8; 8]];
|
||||
|
||||
// First fetch: the source errors → empty, but the miss must NOT be cached.
|
||||
assert!(
|
||||
cb.unit_keys(&samples).is_empty(),
|
||||
"source down → empty this time"
|
||||
);
|
||||
// Second fetch, SAME samples: not blocked by a cached empty → the now-
|
||||
// recovered source resolves the key.
|
||||
assert_eq!(
|
||||
cb.unit_keys(&samples),
|
||||
vec![key],
|
||||
"recovered source resolves — errored empty was not memoized"
|
||||
);
|
||||
}
|
||||
|
||||
/// A GENUINE absence (a source that runs and returns an empty `Ok`) is still
|
||||
/// memoized — the benefit the fix preserves. A source counting its calls must
|
||||
/// be asked exactly once for a fingerprint whose first (clean) reply was empty.
|
||||
#[test]
|
||||
fn genuine_empty_is_still_memoized() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
struct AlwaysEmpty {
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
impl KeySource for AlwaysEmpty {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Vec::new()) // ran fine, genuinely holds no key
|
||||
}
|
||||
}
|
||||
|
||||
let calls_c = Arc::clone(&calls);
|
||||
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> = Arc::new(move || {
|
||||
vec![Box::new(AlwaysEmpty {
|
||||
calls: Arc::clone(&calls_c),
|
||||
}) as Box<dyn KeySource>]
|
||||
});
|
||||
|
||||
let cb = key_fetch(empty_inputs(), make);
|
||||
let samples = vec![vec![0xEFu8; 8]];
|
||||
|
||||
assert!(cb.unit_keys(&samples).is_empty());
|
||||
assert!(cb.unit_keys(&samples).is_empty());
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"a clean empty reply is cached — the source is asked only once"
|
||||
);
|
||||
}
|
||||
|
||||
/// The two `KeyFetch` operations route to the two DISTINCT trait methods:
|
||||
/// `unit_keys` drives `get_unit_keys`, `fmts_indexes` drives
|
||||
/// `get_fmts_indexes`. A source that returns different keys per method proves
|
||||
/// the seam no longer collapses "1 base key" and "the forensic set" into one
|
||||
/// overloaded call — the operation, not the return length, decides which.
|
||||
#[test]
|
||||
fn key_fetch_routes_unit_and_fmts_to_distinct_source_methods() {
|
||||
const BASE: [u8; 16] = [0xB0; 16];
|
||||
const F1: [u8; 16] = [0xF1; 16];
|
||||
const F2: [u8; 16] = [0xF2; 16];
|
||||
|
||||
struct TwoOp;
|
||||
impl KeySource for TwoOp {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey::new(0, BASE)])
|
||||
}
|
||||
fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey::new(0, F1), UnitKey::new(1, F2)])
|
||||
}
|
||||
}
|
||||
|
||||
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> =
|
||||
Arc::new(|| vec![Box::new(TwoOp) as Box<dyn KeySource>]);
|
||||
let cb = key_fetch(empty_inputs(), make);
|
||||
let samples = vec![vec![0x01u8; 4]];
|
||||
|
||||
assert_eq!(
|
||||
cb.unit_keys(&samples),
|
||||
vec![BASE],
|
||||
"unit_keys resolves the base Unit Key via get_unit_keys"
|
||||
);
|
||||
assert_eq!(
|
||||
cb.fmts_indexes(&samples),
|
||||
vec![F1, F2],
|
||||
"fmts_indexes resolves the forensic set (any length) via get_fmts_indexes"
|
||||
);
|
||||
}
|
||||
|
||||
/// `KeyFetch::unit_only` serves base keys but NEVER a forensic set — the
|
||||
/// contract the sweep/patch recovery decorator relies on (it resolves CPS
|
||||
/// units only). Its `fmts_indexes` is unconditionally empty.
|
||||
#[test]
|
||||
fn key_fetch_unit_only_never_serves_forensic() {
|
||||
let f = crate::sector::KeyFetch::unit_only(std::sync::Arc::new(|_| vec![[0xAA; 16]]));
|
||||
assert_eq!(f.unit_keys(&[vec![0u8; 4]]), vec![[0xAA; 16]]);
|
||||
assert!(
|
||||
f.fmts_indexes(&[vec![0u8; 4]]).is_empty(),
|
||||
"unit_only resolver yields no forensic keys"
|
||||
);
|
||||
}
|
||||
|
||||
/// `get_fmts_indexes` defaults to empty, so a base-only source (a keydb) opts
|
||||
/// out of the forensic path without implementing it. `fetch_fmts_indexes` then
|
||||
/// falls through to the next source, exactly like the unit-key driver.
|
||||
#[test]
|
||||
fn fetch_fmts_indexes_skips_default_optout_source() {
|
||||
struct BaseOnly; // uses the default (empty) get_fmts_indexes
|
||||
impl KeySource for BaseOnly {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey::new(0, [0x11; 16])])
|
||||
}
|
||||
}
|
||||
struct Forensic;
|
||||
impl KeySource for Forensic {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey::new(0, [0x77; 16])])
|
||||
}
|
||||
}
|
||||
let inputs = empty_inputs();
|
||||
let ctx = DiscInputsCtx::new(&inputs);
|
||||
let sources: Vec<Box<dyn KeySource>> = vec![Box::new(BaseOnly), Box::new(Forensic)];
|
||||
let got = fetch_fmts_indexes(&sources, &ctx);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].key, [0x77; 16], "the base-only source is skipped");
|
||||
}
|
||||
|
||||
/// #4 regression: encrypted content NOT at the extent midpoint (a late-
|
||||
/// starting feature, or a midpoint landing in clear nav) must still be
|
||||
/// sampled — empty samples make `decrypt_with` skip wrong-key validation.
|
||||
@@ -848,12 +1286,12 @@ mod tests {
|
||||
}
|
||||
|
||||
/// DISCRIMINATING: selection is by the AACS CPI (byte 0), NOT the
|
||||
/// `ts_sync_destroyed` heuristic. Half the units are sync-destroyed but
|
||||
/// TS-sync clarity heuristic. Half the units lack TS syncs but are
|
||||
/// CPI-CLEAR (`byte0 & 0xC0 == 0`) — genuinely UNencrypted units that merely
|
||||
/// lack TS syncs; the old sampler collected these and the key server rejected
|
||||
/// the POST as "0 encrypted units". `read_encrypted_units` must skip them and
|
||||
/// return ONLY CPI-flagged units. A regression to `ts_sync_destroyed` would
|
||||
/// collect the CPI-clear units too and fail the `& 0xC0` assertion.
|
||||
/// return ONLY CPI-flagged units. A regression to selecting by TS-sync clarity
|
||||
/// would collect the CPI-clear units too and fail the `& 0xC0` assertion.
|
||||
#[test]
|
||||
fn read_encrypted_units_selects_by_cpi_not_ts_sync() {
|
||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
|
||||
@@ -862,7 +1300,8 @@ mod tests {
|
||||
|
||||
// Even units: CPI-clear (byte0 & 0xC0 == 0) AND sync-destroyed (no 0x47).
|
||||
// Odd units: CPI-set (byte0 = 0xC0) with a scrambled body.
|
||||
// `ts_sync_destroyed` is TRUE for BOTH; `aacs_unit_encrypted` only odd.
|
||||
// Neither has clean TS syncs, so `is_clean` is FALSE for BOTH;
|
||||
// `aacs_unit_encrypted` flags only the odd units.
|
||||
struct MixSource {
|
||||
ext_start: u32,
|
||||
total_units: u32,
|
||||
@@ -884,7 +1323,7 @@ mod tests {
|
||||
break;
|
||||
}
|
||||
let abs = (lba - self.ext_start) / ALIGNED_UNIT_SECTORS + i as u32;
|
||||
if abs % 2 == 0 {
|
||||
if abs.is_multiple_of(2) {
|
||||
chunk.fill(0x11); // CPI-clear (0x11 & 0xC0 == 0), no TS sync
|
||||
} else {
|
||||
chunk.fill(0xAB); // scrambled body (no TS sync)
|
||||
@@ -982,4 +1421,63 @@ mod tests {
|
||||
assert_eq!(k10[1], [0x10; 16], "V10 reads the 2nd key at +48");
|
||||
assert_ne!(k20[1], k10[1], "the parse stride follows inputs.version");
|
||||
}
|
||||
|
||||
/// `DiscInputs` is public and returned by `Disc::inputs`, so any consumer's
|
||||
/// `tracing::debug!("{inputs:?}")` prints it. A derived `Debug` printed the
|
||||
/// Volume ID (the value `aacs::types::Vid` deliberately renders as
|
||||
/// `Vid(<redacted>)`), the whole `Unit_Key_RO.inf` (the encrypted title keys),
|
||||
/// the entire MKB and every ciphertext sample verbatim. Sentinel byte
|
||||
/// 0xD5 = decimal 213, matching `aacs::types::redaction_tests`. Mutation
|
||||
/// guard: restoring `#[derive(Debug)]` fails this.
|
||||
#[test]
|
||||
fn disc_inputs_debug_is_redacted() {
|
||||
let inputs = DiscInputs {
|
||||
disc_hash: "0xAA".into(),
|
||||
volume_id: [0xD5; 16],
|
||||
version: 2,
|
||||
mkb: vec![0xD5; 64],
|
||||
unit_key_ro: vec![0xD5; 48],
|
||||
samples: vec![vec![0xD5; 6144]],
|
||||
volume_label: Some("TITLE_2024".into()),
|
||||
};
|
||||
let dbg = format!("{inputs:?}");
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"DiscInputs Debug leaked key material (decimal 213): {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"DiscInputs Debug missing redaction marker: {dbg}"
|
||||
);
|
||||
// Non-secret identity and shape stay printable for diagnostics.
|
||||
assert!(dbg.contains("0xAA"), "{dbg}");
|
||||
assert!(dbg.contains("mkb_len: 64"), "{dbg}");
|
||||
assert!(dbg.contains("unit_key_ro_len: 48"), "{dbg}");
|
||||
assert!(dbg.contains("samples_len: 1"), "{dbg}");
|
||||
assert!(dbg.contains("TITLE_2024"), "{dbg}");
|
||||
}
|
||||
|
||||
/// `DecodeSampleSet` is public and wraps the SAME on-disc ciphertext the
|
||||
/// sibling `DiscInputs` redacts, so a derived `Debug` dumped ≥ MIN_SAMPLE_UNITS
|
||||
/// × 6144 bytes of verbatim AACS ciphertext (plus every unit's clear 16-byte
|
||||
/// derivation seed) into any log that formatted it. Sentinel byte 0xD5 =
|
||||
/// decimal 213, matching `aacs::types::redaction_tests` and the
|
||||
/// `DiscInputs` test above. Mutation guard: restoring `#[derive(Debug)]`
|
||||
/// fails this.
|
||||
#[test]
|
||||
fn decode_sample_set_debug_is_redacted() {
|
||||
let set = DecodeSampleSet::new(vec![vec![0xD5; 6144]; MIN_SAMPLE_UNITS])
|
||||
.expect("MIN_SAMPLE_UNITS units is a valid set");
|
||||
let dbg = format!("{set:?}");
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"DecodeSampleSet Debug leaked ciphertext (decimal 213): {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"DecodeSampleSet Debug missing redaction marker: {dbg}"
|
||||
);
|
||||
// Non-secret shape stays printable for diagnostics.
|
||||
assert!(dbg.contains("units_len: 8"), "{dbg}");
|
||||
}
|
||||
}
|
||||
|
||||
+45
-16
@@ -100,10 +100,10 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<DiscMetadata>
|
||||
// Disc-set position is disc-global; first one we successfully
|
||||
// read wins. (All bdmt_*.xml on a given disc carry the same
|
||||
// value in practice.)
|
||||
if out.disc_number.is_none() {
|
||||
if let Some(ds) = disc_set {
|
||||
out.disc_number = Some(ds);
|
||||
}
|
||||
if out.disc_number.is_none()
|
||||
&& let Some(ds) = disc_set
|
||||
{
|
||||
out.disc_number = Some(ds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,20 +184,20 @@ fn extract_title(xml_text: &str) -> Option<String> {
|
||||
// xml::text already trims its result, so an empty string after
|
||||
// extraction means a genuinely empty element.
|
||||
for tag in ["name", "title"] {
|
||||
if let Some(s) = xml::text(xml_text, tag) {
|
||||
if !s.is_empty() {
|
||||
return Some(s);
|
||||
}
|
||||
if let Some(s) = xml::text(xml_text, tag)
|
||||
&& !s.is_empty()
|
||||
{
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
// tableOfContents/titleName: search inside the toc block so we
|
||||
// don't accidentally pick a stray <titleName> from elsewhere.
|
||||
if let Some((s, e)) = xml::find_element(xml_text, "tableOfContents", 0) {
|
||||
let block = &xml_text[s..e];
|
||||
if let Some(t) = xml::text(block, "titleName") {
|
||||
if !t.is_empty() {
|
||||
return Some(t);
|
||||
}
|
||||
if let Some(t) = xml::text(block, "titleName")
|
||||
&& !t.is_empty()
|
||||
{
|
||||
return Some(t);
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -370,10 +370,10 @@ mod tests {
|
||||
if let Some(d) = desc {
|
||||
meta.descriptions.insert(lang.to_string(), d);
|
||||
}
|
||||
if meta.disc_number.is_none() {
|
||||
if let Some(d) = ds {
|
||||
meta.disc_number = Some(d);
|
||||
}
|
||||
if meta.disc_number.is_none()
|
||||
&& let Some(d) = ds
|
||||
{
|
||||
meta.disc_number = Some(d);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,4 +566,33 @@ mod tests {
|
||||
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(title, "Real Title");
|
||||
}
|
||||
|
||||
/// `is_bdmt_filename` must recognize the `bdmt_<lang>.xml` convention
|
||||
/// and reject everything else — it drives `detect`'s directory scan.
|
||||
/// Mutation: stub the return to a constant `true`/`false` → every
|
||||
/// directory listing (or none) would match regardless of filename.
|
||||
#[test]
|
||||
fn is_bdmt_filename_matches_convention_only() {
|
||||
assert!(is_bdmt_filename("bdmt_eng.xml"));
|
||||
assert!(is_bdmt_filename("BDMT_FRA.XML"));
|
||||
assert!(!is_bdmt_filename("bdmt_engl.xml"));
|
||||
assert!(!is_bdmt_filename("index.bdmv"));
|
||||
assert!(!is_bdmt_filename("foo.xml"));
|
||||
}
|
||||
|
||||
/// Spec: "Disc 1 of 1" (a single-disc release whose bdmt XML still
|
||||
/// carries `<di:numSets>1</di:numSets>`) is a valid, non-nonsensical
|
||||
/// pair — `total < 1` must reject only `total == 0`, not `total == 1`.
|
||||
/// Mutation: `total < 1` -> `total == 1` or `total <= 1` would reject
|
||||
/// this legitimate (1, 1) pair as if it were malformed.
|
||||
#[test]
|
||||
fn disc_set_allows_single_disc_release() {
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Film</di:name>
|
||||
<di:discNumber>1</di:discNumber>
|
||||
<di:numSets>1</di:numSets>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(set, Some((1, 1)));
|
||||
}
|
||||
}
|
||||
|
||||
+458
-1
@@ -989,7 +989,18 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
|
||||
fn slice(&mut self, n: usize, needed: &'static str) -> Result<&'a [u8]> {
|
||||
if self.pos + n > self.data.len() {
|
||||
// `n` is attacker-supplied: it comes from a JVMS `u4` attribute_length
|
||||
// / code_length (§4.7, §4.7.3) or a `u2` Utf8 length (§4.4.7). Unlike
|
||||
// the fixed-width readers above, whose `self.pos + k` cannot leave the
|
||||
// buffer's own address range, `self.pos + n` can wrap — on a 32-bit
|
||||
// target a `u4` length near 0xFFFF_FFFF plus a non-zero `pos` panics
|
||||
// in debug and in release wraps to a SMALL end offset that passes the
|
||||
// bounds check, after which the slice index itself panics. Checked, so
|
||||
// an out-of-range length is the EOF error it always should have been.
|
||||
let Some(end) = self.pos.checked_add(n) else {
|
||||
return Err(Error::UnexpectedEof { needed });
|
||||
};
|
||||
if end > self.data.len() {
|
||||
return Err(Error::UnexpectedEof { needed });
|
||||
}
|
||||
let s = &self.data[self.pos..self.pos + n];
|
||||
@@ -1006,6 +1017,46 @@ impl<'a> Reader<'a> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `Reader::slice` takes an attacker-supplied length: a JVMS `u4`
|
||||
/// `attribute_length` / `code_length` (§4.7, §4.7.3) or a `u2` Utf8
|
||||
/// length (§4.4.7). Adding it to `pos` without a wrap check panics on
|
||||
/// overflow in debug and, in release, wraps to a small end offset that
|
||||
/// slips past the bounds check and then panics inside the slice index.
|
||||
/// Both are panics escaping a parser whose whole input is untrusted disc
|
||||
/// bytes; the contract is an EOF error.
|
||||
#[test]
|
||||
fn slice_rejects_a_length_that_would_wrap_pos() {
|
||||
let data = [0u8; 16];
|
||||
let mut r = Reader::new(&data);
|
||||
r.u64("advance pos").expect("8 bytes available");
|
||||
// pos is now 8; usize::MAX would wrap the end offset to 7.
|
||||
match r.slice(usize::MAX, "wrapping length") {
|
||||
Err(Error::UnexpectedEof { .. }) => {}
|
||||
Err(other) => panic!("expected UnexpectedEof, got {other:?}"),
|
||||
Ok(s) => panic!("expected UnexpectedEof, got a {}-byte slice", s.len()),
|
||||
}
|
||||
// The reader must not have consumed anything.
|
||||
match r.slice(8, "remaining bytes") {
|
||||
Ok(s) => assert_eq!(s.len(), 8, "pos moved on the rejected slice"),
|
||||
Err(e) => panic!("the remaining 8 bytes must still be readable: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The ordinary out-of-range case (no wrap) must keep returning EOF, and
|
||||
/// an exactly-fitting length must still succeed — the check is `>`, not
|
||||
/// `>=`.
|
||||
#[test]
|
||||
fn slice_boundary_is_inclusive_of_the_final_byte() {
|
||||
let data = [0u8; 16];
|
||||
let mut r = Reader::new(&data);
|
||||
assert_eq!(r.slice(16, "whole buffer").expect("exact fit").len(), 16);
|
||||
let mut r = Reader::new(&data);
|
||||
assert!(matches!(
|
||||
r.slice(17, "one past"),
|
||||
Err(Error::UnexpectedEof { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_class_bytes() {
|
||||
match ClassFile::parse(b"\x00\x01\x02\x03DEAD") {
|
||||
@@ -1337,4 +1388,410 @@ mod tests {
|
||||
let _ = decode_modified_utf8(&buf);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// ConstantPool / ClassFile accessor correctness
|
||||
//
|
||||
// These exercise plain data accessors on an already-parsed pool
|
||||
// (built via the test-only `from_entries` constructor) — not the
|
||||
// untrusted-bytes parsing path, just "does the right variant map to
|
||||
// the right Option value."
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
fn sample_pool() -> ConstantPool {
|
||||
// index: 0=Empty (reserved), 1=Utf8("Hello"), 2=Integer(42),
|
||||
// 3=String{string_index:1}, 4=Class{name_index:1}, 5=Float(1.5),
|
||||
// 6=Long(9), 7=Empty (2-slot tail), 8=Double(2.5), 9=Empty (tail).
|
||||
ConstantPool::from_entries(vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("Hello".to_string()),
|
||||
CpInfo::Integer(42),
|
||||
CpInfo::String { string_index: 1 },
|
||||
CpInfo::Class { name_index: 1 },
|
||||
CpInfo::Float(1.5),
|
||||
CpInfo::Long(9),
|
||||
CpInfo::Empty,
|
||||
CpInfo::Double(2.5),
|
||||
CpInfo::Empty,
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_pool_string_resolves_through_string_index() {
|
||||
let pool = sample_pool();
|
||||
// index 3 is CpInfo::String{string_index: 1} -> utf8(1) = "Hello".
|
||||
assert_eq!(pool.string(3), Some("Hello"));
|
||||
// Wrong variant (Integer at index 2) must not resolve as a string.
|
||||
assert_eq!(pool.string(2), None);
|
||||
// Out of range index.
|
||||
assert_eq!(pool.string(999), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_pool_integer_resolves_only_integer_entries() {
|
||||
let pool = sample_pool();
|
||||
assert_eq!(pool.integer(2), Some(42));
|
||||
// Wrong variant (Utf8 at index 1) must not resolve as an integer.
|
||||
assert_eq!(pool.integer(1), None);
|
||||
assert_eq!(pool.integer(999), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_pool_load_constant_display_covers_ldc_operand_kinds() {
|
||||
let pool = sample_pool();
|
||||
assert_eq!(
|
||||
pool.load_constant_display(1),
|
||||
Some("utf8:\"Hello\"".to_string())
|
||||
);
|
||||
assert_eq!(pool.load_constant_display(2), Some("int:42".to_string()));
|
||||
assert_eq!(
|
||||
pool.load_constant_display(3),
|
||||
Some("str:\"Hello\"".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
pool.load_constant_display(4),
|
||||
Some("class:\"Hello\"".to_string())
|
||||
);
|
||||
assert_eq!(pool.load_constant_display(5), Some("float:1.5".to_string()));
|
||||
assert_eq!(pool.load_constant_display(6), Some("long:9".to_string()));
|
||||
assert_eq!(
|
||||
pool.load_constant_display(8),
|
||||
Some("double:2.5".to_string())
|
||||
);
|
||||
// A variant with no display arm (e.g. reserved Empty slot) -> None.
|
||||
assert_eq!(pool.load_constant_display(0), None);
|
||||
assert_eq!(pool.load_constant_display(999), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_pool_len_and_is_empty() {
|
||||
let pool = sample_pool();
|
||||
assert_eq!(pool.len(), 10);
|
||||
assert!(!pool.is_empty());
|
||||
|
||||
let empty = ConstantPool::from_entries(vec![]);
|
||||
assert_eq!(empty.len(), 0);
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_pool_iter_yields_index_and_entry_pairs() {
|
||||
let pool = ConstantPool::from_entries(vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("A".to_string()),
|
||||
CpInfo::Integer(7),
|
||||
]);
|
||||
let indices: Vec<u16> = pool.iter().map(|(i, _)| i).collect();
|
||||
assert_eq!(indices, vec![0, 1, 2]);
|
||||
// Confirm the entries themselves come through, not an empty iterator.
|
||||
let utf8_at_1 = pool.iter().find(|(i, _)| *i == 1).map(|(_, e)| match e {
|
||||
CpInfo::Utf8(s) => s.as_str(),
|
||||
_ => "?",
|
||||
});
|
||||
assert_eq!(utf8_at_1, Some("A"));
|
||||
}
|
||||
|
||||
fn class_file_with(this_class: u16, super_class: u16, pool: ConstantPool) -> ClassFile {
|
||||
ClassFile {
|
||||
minor_version: 0,
|
||||
major_version: 0,
|
||||
constant_pool: pool,
|
||||
access_flags: 0,
|
||||
this_class,
|
||||
super_class,
|
||||
interfaces: Vec::new(),
|
||||
fields: Vec::new(),
|
||||
methods: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_class_name_and_super_class_name_resolve_distinct_indices() {
|
||||
let pool = ConstantPool::from_entries(vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("com/example/Foo".to_string()),
|
||||
CpInfo::Utf8("com/example/Bar".to_string()),
|
||||
CpInfo::Class { name_index: 1 },
|
||||
CpInfo::Class { name_index: 2 },
|
||||
]);
|
||||
let cf = class_file_with(3, 4, pool);
|
||||
assert_eq!(cf.this_class_name(), Some("com/example/Foo"));
|
||||
assert_eq!(cf.super_class_name(), Some("com/example/Bar"));
|
||||
|
||||
// this_class index pointing at a non-Class entry must not resolve.
|
||||
let pool2 = ConstantPool::from_entries(vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("not a class ref".to_string()),
|
||||
]);
|
||||
let cf2 = class_file_with(1, 1, pool2);
|
||||
assert_eq!(cf2.this_class_name(), None);
|
||||
assert_eq!(cf2.super_class_name(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_descriptor_resolves_the_descriptor_not_the_name() {
|
||||
let pool = ConstantPool::from_entries(vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("doStuff".to_string()), // index 1: name
|
||||
CpInfo::Utf8("()V".to_string()), // index 2: descriptor
|
||||
]);
|
||||
let cf = class_file_with(0, 0, pool);
|
||||
let m = Member {
|
||||
access_flags: 0,
|
||||
name_index: 1,
|
||||
descriptor_index: 2,
|
||||
attributes: Vec::new(),
|
||||
};
|
||||
assert_eq!(cf.member_descriptor(&m), Some("()V"));
|
||||
assert_ne!(cf.member_descriptor(&m), Some("doStuff"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Reader::u16/u32/u64 boundary + value correctness
|
||||
//
|
||||
// Mirrors `slice_boundary_is_inclusive_of_the_final_byte`: an
|
||||
// exact-fit read must succeed, one byte short must fail. Plus
|
||||
// positive-value tests so a scrambled byte assembly (not just an
|
||||
// out-of-bounds read) would be caught.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn u16_boundary_is_inclusive_of_the_final_byte() {
|
||||
let data = [0xAB, 0xCD];
|
||||
let mut r = Reader::new(&data);
|
||||
assert_eq!(r.u16("exact fit").expect("2 bytes available"), 0xABCD);
|
||||
|
||||
let data = [0xAB];
|
||||
let mut r = Reader::new(&data);
|
||||
assert!(matches!(
|
||||
r.u16("one byte short"),
|
||||
Err(Error::UnexpectedEof { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u16_decodes_big_endian_value() {
|
||||
let data = [0x01, 0x02];
|
||||
let mut r = Reader::new(&data);
|
||||
assert_eq!(r.u16("value").unwrap(), 0x0102);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u32_boundary_is_inclusive_of_the_final_byte() {
|
||||
let data = [0x00, 0x00, 0x00, 0x2A];
|
||||
let mut r = Reader::new(&data);
|
||||
assert_eq!(r.u32("exact fit").expect("4 bytes available"), 42);
|
||||
|
||||
let data = [0x00, 0x00, 0x00];
|
||||
let mut r = Reader::new(&data);
|
||||
assert!(matches!(
|
||||
r.u32("one byte short"),
|
||||
Err(Error::UnexpectedEof { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u32_decodes_big_endian_value() {
|
||||
let data = [0x00, 0x00, 0x05, 0x39]; // 1337
|
||||
let mut r = Reader::new(&data);
|
||||
assert_eq!(r.u32("value").unwrap(), 1337);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u64_boundary_is_inclusive_of_the_final_byte() {
|
||||
// pos == 0, buffer exactly 8 bytes: must succeed.
|
||||
let data = [0, 0, 0, 0, 0, 0, 0, 0x7B]; // 123
|
||||
let mut r = Reader::new(&data);
|
||||
assert_eq!(r.u64("exact fit").expect("8 bytes available"), 123);
|
||||
|
||||
// pos == 0, buffer one byte short of 8: must fail cleanly, not
|
||||
// panic on the internal self.data[self.pos + 7] index.
|
||||
let data = [0u8; 7];
|
||||
let mut r = Reader::new(&data);
|
||||
assert!(matches!(
|
||||
r.u64("one byte short"),
|
||||
Err(Error::UnexpectedEof { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u64_decodes_big_endian_value() {
|
||||
let data = [0, 0, 0, 0, 0, 0, 0x05, 0x39]; // 1337
|
||||
let mut r = Reader::new(&data);
|
||||
assert_eq!(r.u64("value").unwrap(), 1337);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// decode_modified_utf8: 3-byte (BMP) decode path
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn modified_utf8_three_byte_cjk() {
|
||||
// U+3042 (hiragana あ) in modified UTF-8: 1110xxxx 10xxxxxx 10xxxxxx
|
||||
// = 0xE3 0x81 0x82.
|
||||
let s = decode_modified_utf8(&[0xE3, 0x81, 0x82]).unwrap();
|
||||
assert_eq!(s, "\u{3042}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_utf8_three_byte_rejects_bad_first_continuation() {
|
||||
// Valid lead byte (0xE3), but the first continuation byte is not
|
||||
// 10xxxxxx (0x01 instead) — must be rejected, proving the first
|
||||
// `& 0xC0 != 0x80` check is live.
|
||||
assert!(decode_modified_utf8(&[0xE3, 0x01, 0x82]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_utf8_three_byte_rejects_bad_second_continuation() {
|
||||
// Valid lead + first continuation, but the second continuation
|
||||
// byte is not 10xxxxxx — proves the second check is independently
|
||||
// live (not short-circuited by the first).
|
||||
assert!(decode_modified_utf8(&[0xE3, 0x81, 0x01]).is_err());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// read_constant_pool: Long/Double two-slot skip, real byte parsing
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn constant_pool_long_entry_occupies_two_slots_via_real_parse() {
|
||||
// Real class-file bytes (not the `from_entries` synthetic ctor):
|
||||
// magic + minor/major + cp_count=4 + tag=5 (Long, 8-byte payload
|
||||
// at index 1, reserved slot at index 2) + tag=1 (Utf8 at index 3)
|
||||
// + empty access_flags/this/super/interfaces/fields/methods/attrs.
|
||||
let mut buf = vec![
|
||||
0xCA, 0xFE, 0xBA, 0xBE, // magic
|
||||
0x00, 0x00, // minor
|
||||
0x00, 0x34, // major
|
||||
0x00, 0x04, // cp_count = 4 (0=Empty,1=Long,2=Empty tail,3=Utf8)
|
||||
5, // Long tag
|
||||
];
|
||||
buf.extend_from_slice(&0x1122_3344_5566_7788u64.to_be_bytes()); // 8-byte payload
|
||||
buf.push(1); // Utf8 tag
|
||||
let name = b"marker";
|
||||
buf.extend_from_slice(&(name.len() as u16).to_be_bytes());
|
||||
buf.extend_from_slice(name);
|
||||
// access_flags, this_class, super_class, interfaces_count
|
||||
buf.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
// fields_count, methods_count, attributes_count
|
||||
buf.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
|
||||
|
||||
let cf = ClassFile::parse(&buf).expect("well-formed synthetic class file");
|
||||
assert_eq!(cf.constant_pool.len(), 4);
|
||||
// The Long occupies indices 1 AND 2 (its reserved tail slot).
|
||||
// The Utf8 must resolve at index 3 = long_index(1) + 2, NOT +1.
|
||||
assert_eq!(cf.constant_pool.utf8(3), Some("marker"));
|
||||
// Index 2 is the reserved tail slot: not a Utf8, must not
|
||||
// resolve as one (guards against the Utf8 landing one slot early).
|
||||
assert_eq!(cf.constant_pool.utf8(2), None);
|
||||
match cf.constant_pool.get(1) {
|
||||
Some(CpInfo::Long(v)) => assert_eq!(*v, 0x1122_3344_5566_7788u64 as i64),
|
||||
other => panic!("expected Long at index 1, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// instruction_size: tableswitch/lookupswitch with non-degenerate
|
||||
// low/high/npairs (the existing tests only cover low==high==0 and
|
||||
// npairs==0, which can't distinguish `-` from `+` in the entry-count
|
||||
// arithmetic).
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn instruction_size_tableswitch_non_degenerate_range() {
|
||||
// low=1, high=4 -> 4 entries (high-low+1 = 4). A `-`->`+` mutation
|
||||
// on that arithmetic would instead compute high+low+1 = 6.
|
||||
let mut code = vec![TABLESWITCH];
|
||||
code.extend_from_slice(&[0, 0, 0]); // padding
|
||||
code.extend_from_slice(&[0, 0, 0, 0]); // default offset
|
||||
code.extend_from_slice(&1i32.to_be_bytes()); // low = 1
|
||||
code.extend_from_slice(&4i32.to_be_bytes()); // high = 4
|
||||
code.extend_from_slice(&[0; 16]); // 4 jump entries * 4 bytes
|
||||
// total = 1 (opcode) + 3 (pad) + 12 (default/low/high) + 16 (entries) = 32
|
||||
assert_eq!(instruction_size(&code, 0), Some(32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_size_lookupswitch_non_degenerate_npairs() {
|
||||
// npairs = 3 -> 3 * 8 = 24 bytes of pairs.
|
||||
let mut code = vec![LOOKUPSWITCH];
|
||||
code.extend_from_slice(&[0, 0, 0]); // padding
|
||||
code.extend_from_slice(&[0, 0, 0, 0]); // default
|
||||
code.extend_from_slice(&3i32.to_be_bytes()); // npairs = 3
|
||||
code.extend_from_slice(&[0; 24]); // 3 pairs
|
||||
// total = 1 + 3 + 8 (default/npairs) + 24 = 36
|
||||
assert_eq!(instruction_size(&code, 0), Some(36));
|
||||
}
|
||||
|
||||
// ── Malformed-input hardening: every count/length/index is attacker- ──────
|
||||
// controlled, so an oversized or lying field must yield Err/None, never a
|
||||
// panic or an unbounded allocation.
|
||||
|
||||
/// A `constant_pool_count` far larger than the data behind it must fail
|
||||
/// cleanly (running out of bytes), not pre-allocate gigabytes or panic. The
|
||||
/// count is a u16, so `Vec::with_capacity` is bounded at 65535 regardless.
|
||||
#[test]
|
||||
fn parse_rejects_oversized_constant_pool_count() {
|
||||
let bytes = [
|
||||
0xCA, 0xFE, 0xBA, 0xBE, // magic
|
||||
0x00, 0x00, 0x00, 0x00, // minor / major
|
||||
0xFF, 0xFF, // constant_pool_count = 65535, but no entries follow
|
||||
];
|
||||
assert!(matches!(
|
||||
ClassFile::parse(&bytes),
|
||||
Err(Error::UnexpectedEof { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// An oversized `interfaces_count` / `fields_count` with no data behind it
|
||||
/// must fail on the first missing element, not panic.
|
||||
#[test]
|
||||
fn parse_rejects_oversized_interface_count() {
|
||||
let bytes = [
|
||||
0xCA, 0xFE, 0xBA, 0xBE, // magic
|
||||
0x00, 0x00, 0x00, 0x00, // minor / major
|
||||
0x00, 0x01, // constant_pool_count = 1 (no entries)
|
||||
0x00, 0x00, // access_flags
|
||||
0x00, 0x00, // this_class
|
||||
0x00, 0x00, // super_class
|
||||
0xFF, 0xFF, // interfaces_count = 65535, none follow
|
||||
];
|
||||
assert!(matches!(
|
||||
ClassFile::parse(&bytes),
|
||||
Err(Error::UnexpectedEof { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// Out-of-range constant-pool indices resolve to `None` from every
|
||||
/// accessor a label parser drives — no slice panic.
|
||||
#[test]
|
||||
fn constant_pool_out_of_range_index_is_none_everywhere() {
|
||||
let pool = sample_pool();
|
||||
let oob = 9999u16;
|
||||
assert!(pool.get(oob).is_none());
|
||||
assert!(pool.utf8(oob).is_none());
|
||||
assert!(pool.class_name(oob).is_none());
|
||||
assert!(pool.string(oob).is_none());
|
||||
assert!(pool.integer(oob).is_none());
|
||||
assert!(pool.member_ref(oob).is_none());
|
||||
assert!(pool.load_constant_display(oob).is_none());
|
||||
}
|
||||
|
||||
/// A `Code` attribute whose declared `code_length` runs past the attribute
|
||||
/// body must be rejected, not read out of bounds.
|
||||
#[test]
|
||||
fn code_attribute_rejects_length_past_its_body() {
|
||||
// max_stack(2) + max_locals(2) + code_length(4) then a code_length that
|
||||
// exceeds the remaining bytes.
|
||||
let mut info = Vec::new();
|
||||
info.extend_from_slice(&0u16.to_be_bytes()); // max_stack
|
||||
info.extend_from_slice(&0u16.to_be_bytes()); // max_locals
|
||||
info.extend_from_slice(&0xFFFF_FFFFu32.to_be_bytes()); // code_length: absurd
|
||||
info.extend_from_slice(&[0x00, 0x01]); // only 2 code bytes present
|
||||
assert!(matches!(
|
||||
parse_code_attribute(&info),
|
||||
Err(Error::UnexpectedEof { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+208
-36
@@ -36,17 +36,18 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
|
||||
// Stream number mapping from playbackconfig.xml
|
||||
let mut stream_map: HashMap<String, u16> = HashMap::new();
|
||||
if let Some(pc_data) = super::read_jar_file(reader, udf, "playbackconfig.xml") {
|
||||
if let Ok(pc_text) = std::str::from_utf8(&pc_data) {
|
||||
parse_playback_config(pc_text, &mut stream_map);
|
||||
}
|
||||
if let Some(pc_data) = super::read_jar_file(reader, udf, "playbackconfig.xml")
|
||||
&& let Ok(pc_text) = std::str::from_utf8(&pc_data)
|
||||
{
|
||||
parse_playback_config(pc_text, &mut stream_map);
|
||||
}
|
||||
|
||||
let stream_nums = assign_stream_numbers(&stream_infos, &stream_map);
|
||||
let stream_nums = assign_stream_numbers(&stream_infos, &stream_map)?;
|
||||
|
||||
let mut labels = Vec::new();
|
||||
for (info, &stream_num) in stream_infos.iter().zip(stream_nums.iter()) {
|
||||
labels.push(StreamLabel {
|
||||
stream_id: None,
|
||||
stream_number: stream_num,
|
||||
stream_type: info.stream_type,
|
||||
language: info.language.clone(),
|
||||
@@ -76,7 +77,29 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
/// map-assigned one. (Both numbering domains are 1-based per type, and
|
||||
/// `apply_labels` matches on `(type, stream_number)`, so a collision
|
||||
/// would mislabel tracks.)
|
||||
fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap<String, u16>) -> Vec<u16> {
|
||||
///
|
||||
/// Returns `None` when the 1-based stream-number space is exhausted — every
|
||||
/// number in `1..=u16::MAX` for that type is either already claimed by the map
|
||||
/// or already synthesized. That is unreachable on real media: the BD STN_table
|
||||
/// carries at most 32 primary audio and 32 PG streams per playlist, so the
|
||||
/// 65535-wide space leaves >2000x headroom. It IS reachable from a crafted
|
||||
/// `streamproperties.xml` listing >65535 stream entries, and the only correct
|
||||
/// answers there are "fail the parse" or "emit colliding numbers"; we fail.
|
||||
///
|
||||
/// The skip search is bounded by the numbering space itself: a `u16`
|
||||
/// `saturating_add` here parked the counter at `u16::MAX` forever whenever the
|
||||
/// map also claimed `u16::MAX`, turning an overflow guard into a hang that
|
||||
/// `apply()`'s `catch_unwind` cannot interrupt. The counters are therefore
|
||||
/// widened to `u32` so the skip loop strictly increases toward a fixed ceiling
|
||||
/// (guaranteeing termination) and exhaustion is reported rather than absorbed.
|
||||
fn assign_stream_numbers(
|
||||
infos: &[StreamInfo],
|
||||
stream_map: &HashMap<String, u16>,
|
||||
) -> Option<Vec<u16>> {
|
||||
/// One past the last assignable stream number, as a `u32` so the
|
||||
/// counters can step off the end of the `u16` domain without wrapping.
|
||||
const NUMBER_SPACE_END: u32 = u16::MAX as u32 + 1;
|
||||
|
||||
// Numbers already claimed by the map, per type. A map value of 0 is NOT a
|
||||
// claim: apply_labels binds on 1-based stream numbers, so 0 is unmatchable.
|
||||
// Treat 0 as "unmapped" here (defense in depth — parse_playback_config also
|
||||
@@ -96,8 +119,8 @@ fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap<String, u16>
|
||||
}
|
||||
}
|
||||
|
||||
let mut audio_idx: u16 = 1;
|
||||
let mut sub_idx: u16 = 1;
|
||||
let mut audio_idx: u32 = 1;
|
||||
let mut sub_idx: u32 = 1;
|
||||
let mut out = Vec::with_capacity(infos.len());
|
||||
for info in infos {
|
||||
let n = match stream_map.get(&info.id).copied() {
|
||||
@@ -107,21 +130,31 @@ fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap<String, u16>
|
||||
StreamLabelType::Audio => (&mut audio_idx, &taken_audio),
|
||||
StreamLabelType::Subtitle => (&mut sub_idx, &taken_sub),
|
||||
};
|
||||
// Advance past any number already claimed via the map.
|
||||
// saturating: a crafted XML with >65k stream entries must
|
||||
// not overflow (panic in debug, wrap-to-0 in release) on
|
||||
// untrusted disc bytes.
|
||||
while taken.contains(idx) {
|
||||
*idx = idx.saturating_add(1);
|
||||
// Advance past any number already claimed via the map. The
|
||||
// counter strictly increases and NUMBER_SPACE_END is fixed, so
|
||||
// this terminates in at most 65535 steps for any input.
|
||||
while *idx < NUMBER_SPACE_END && taken.contains(&(*idx as u16)) {
|
||||
*idx += 1;
|
||||
}
|
||||
let n = *idx;
|
||||
*idx = idx.saturating_add(1);
|
||||
if *idx >= NUMBER_SPACE_END {
|
||||
// Numbering space exhausted. Emitting anything here would
|
||||
// either wrap to 0 (unmatchable) or duplicate a number
|
||||
// already bound to a different stream, so the parse fails.
|
||||
tracing::warn!(
|
||||
streams = infos.len(),
|
||||
"criterion: 1-based u16 stream-number space exhausted; \
|
||||
refusing to synthesize a colliding stream number"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let n = *idx as u16;
|
||||
*idx += 1;
|
||||
n
|
||||
}
|
||||
};
|
||||
out.push(n);
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
|
||||
struct StreamInfo {
|
||||
@@ -189,14 +222,13 @@ fn parse_playback_config(text: &str, map: &mut HashMap<String, u16>) {
|
||||
if let (Some(stream_id_str), Some(info_id)) = (
|
||||
xml::text(block, "StreamID"),
|
||||
xml::text(block, "StreamInfo_ID"),
|
||||
) {
|
||||
if let Ok(stream_num) = stream_id_str.parse::<u16>() {
|
||||
// Stream numbers are 1-based per the apply_labels
|
||||
// contract; a mapped 0 is unmatchable and silently
|
||||
// drops the label. Skip it rather than store it.
|
||||
if stream_num != 0 {
|
||||
map.insert(info_id, stream_num);
|
||||
}
|
||||
) && let Ok(stream_num) = stream_id_str.parse::<u16>()
|
||||
{
|
||||
// Stream numbers are 1-based per the apply_labels
|
||||
// contract; a mapped 0 is unmatchable and silently
|
||||
// drops the label. Skip it rather than store it.
|
||||
if stream_num != 0 {
|
||||
map.insert(info_id, stream_num);
|
||||
}
|
||||
}
|
||||
from = end;
|
||||
@@ -226,11 +258,89 @@ mod tests {
|
||||
info("a1", StreamLabelType::Audio),
|
||||
info("s0", StreamLabelType::Subtitle),
|
||||
];
|
||||
let nums = assign_stream_numbers(&infos, &HashMap::new());
|
||||
let nums =
|
||||
assign_stream_numbers(&infos, &HashMap::new()).expect("numbering space not exhausted");
|
||||
// Per-type 1-based: audio 1,2 ; subtitle 1.
|
||||
assert_eq!(nums, vec![1, 2, 1]);
|
||||
}
|
||||
|
||||
/// Immunity pin. `parse_stream_infos` emits one `StreamInfo` per
|
||||
/// `*StreamInfos` element unconditionally — no filter, no `continue` — so
|
||||
/// an element whose fields are missing or unrecognized still occupies its
|
||||
/// position, and `assign_stream_numbers` still spends a number on it.
|
||||
///
|
||||
/// That is the property that keeps this parser out of the failure mode
|
||||
/// where a skipped entry pulls every later label one stream forward. It
|
||||
/// is load-bearing for the fallback path specifically: with no
|
||||
/// `playbackconfig.xml` the numbers come purely from position in this
|
||||
/// list, so dropping an element there would shift the rest.
|
||||
///
|
||||
/// Mutation: skip elements with an empty `ID`/`LangInfoID` → the two
|
||||
/// real audio streams renumber to 1 and 2.
|
||||
/// Immunity pin, section-boundary half. Each stream here is one closed XML
|
||||
/// element, and every field is read out of `&text[start..end]` — the range
|
||||
/// `xml::find_element` returned — so one element can never absorb the next
|
||||
/// one's fields, however the document is malformed around it. Contrast the
|
||||
/// flat-string walk in pixelogic, where a section whose end marker is
|
||||
/// missing keeps consuming entries as STN slots.
|
||||
///
|
||||
/// The missing-boundary case fails closed. An element with no close tag of
|
||||
/// its own ends at the NEXT close tag, so it absorbs the element behind it
|
||||
/// — the list comes back SHORTER. It cannot come back longer: nothing
|
||||
/// outside a returned range is ever read as a stream, and `find_element`
|
||||
/// yields `None` rather than a range running to EOF when no close tag
|
||||
/// exists at all. A malformed document can cost this parser a slot; it can
|
||||
/// never invent one.
|
||||
///
|
||||
/// Mutation: read fields from the document rather than the element's
|
||||
/// range, or let a close-less element run to EOF → the trailing elements
|
||||
/// re-enter the list as extra streams.
|
||||
#[test]
|
||||
fn an_unterminated_stream_element_shortens_the_list_it_cannot_extend_it() {
|
||||
let sp = concat!(
|
||||
"<AudioStreamInfos><ID>a0</ID><LangInfoID>ENG</LangInfoID></AudioStreamInfos>",
|
||||
// No `</AudioStreamInfos>` for this one.
|
||||
"<AudioStreamInfos><ID>a1</ID><LangInfoID>FRA</LangInfoID>",
|
||||
"<AudioStreamInfos><ID>a2</ID><LangInfoID>DEU</LangInfoID></AudioStreamInfos>",
|
||||
);
|
||||
let infos = parse_stream_infos(sp);
|
||||
assert_eq!(
|
||||
infos.iter().map(|i| i.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["a0", "a1"],
|
||||
"the close-less element absorbs the one behind it — two slots, not \
|
||||
three, and never four"
|
||||
);
|
||||
assert_eq!(infos[1].language, "fra", "and keeps its own leading fields");
|
||||
|
||||
// With no close tag anywhere behind it, the element is not returned at
|
||||
// all and the walk ends — the tail of the document never becomes a
|
||||
// stream list.
|
||||
let no_close = "<AudioStreamInfos><ID>a0</ID><LangInfoID>ENG</LangInfoID>";
|
||||
assert!(parse_stream_infos(no_close).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_stream_element_still_occupies_its_position() {
|
||||
let sp = r#"
|
||||
<AudioStreamInfos><ID>a0</ID><LangInfoID>ENG_US</LangInfoID></AudioStreamInfos>
|
||||
<AudioStreamInfos></AudioStreamInfos>
|
||||
<AudioStreamInfos><ID>a2</ID><LangInfoID>FRA</LangInfoID><Content>COMMENTARY</Content></AudioStreamInfos>
|
||||
<SubtitleStreamInfos><ID>s0</ID><LangInfoID></LangInfoID><Qualifier>WAT</Qualifier></SubtitleStreamInfos>
|
||||
<SubtitleStreamInfos><ID>s1</ID><LangInfoID>ENG</LangInfoID><Qualifier>SDH</Qualifier></SubtitleStreamInfos>
|
||||
"#;
|
||||
let infos = parse_stream_infos(sp);
|
||||
assert_eq!(infos.len(), 5, "every element yields a StreamInfo");
|
||||
let nums =
|
||||
assign_stream_numbers(&infos, &HashMap::new()).expect("numbering space not exhausted");
|
||||
assert_eq!(
|
||||
nums,
|
||||
vec![1, 2, 3, 1, 2],
|
||||
"the blank element owns audio slot 2, so the commentary is slot 3"
|
||||
);
|
||||
assert_eq!(infos[2].purpose, LabelPurpose::Commentary);
|
||||
assert_eq!(infos[4].qualifier, LabelQualifier::Sdh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_does_not_collide_with_partial_map() {
|
||||
// Map claims audio "a1" -> 1. The unmapped audio "a0" must NOT
|
||||
@@ -242,7 +352,7 @@ mod tests {
|
||||
info("a1", StreamLabelType::Audio), // mapped → 1
|
||||
info("a2", StreamLabelType::Audio), // unmapped → fallback
|
||||
];
|
||||
let nums = assign_stream_numbers(&infos, &map);
|
||||
let nums = assign_stream_numbers(&infos, &map).expect("numbering space not exhausted");
|
||||
// a0 skips the taken 1 → 2; a1 keeps 1; a2 → 3. All distinct.
|
||||
assert_eq!(nums, vec![2, 1, 3]);
|
||||
let mut sorted = nums.clone();
|
||||
@@ -260,7 +370,10 @@ mod tests {
|
||||
info("a0", StreamLabelType::Audio),
|
||||
info("a1", StreamLabelType::Audio),
|
||||
];
|
||||
assert_eq!(assign_stream_numbers(&infos, &map), vec![5, 9]);
|
||||
assert_eq!(
|
||||
assign_stream_numbers(&infos, &map).expect("numbering space not exhausted"),
|
||||
vec![5, 9]
|
||||
);
|
||||
}
|
||||
|
||||
// ── Additional hardening tests ─────────────────────────────────────────
|
||||
@@ -276,7 +389,8 @@ mod tests {
|
||||
info("a1", StreamLabelType::Audio),
|
||||
info("s1", StreamLabelType::Subtitle),
|
||||
];
|
||||
let nums = assign_stream_numbers(&infos, &HashMap::new());
|
||||
let nums =
|
||||
assign_stream_numbers(&infos, &HashMap::new()).expect("numbering space not exhausted");
|
||||
// Audio: 1, 2; Subtitle: 1, 2 — each counter resets at 1 per type.
|
||||
assert_eq!(nums[0], 1); // audio 1
|
||||
assert_eq!(nums[1], 1); // subtitle 1
|
||||
@@ -293,7 +407,7 @@ mod tests {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("a0".to_string(), 0u16); // 0 must not be treated as a claim
|
||||
let infos = vec![info("a0", StreamLabelType::Audio)];
|
||||
let nums = assign_stream_numbers(&infos, &map);
|
||||
let nums = assign_stream_numbers(&infos, &map).expect("numbering space not exhausted");
|
||||
// 0 is treated as unmapped → the fallback counter assigns 1.
|
||||
assert_eq!(nums[0], 1);
|
||||
}
|
||||
@@ -309,7 +423,7 @@ mod tests {
|
||||
info("real", StreamLabelType::Audio),
|
||||
info("bad", StreamLabelType::Audio),
|
||||
];
|
||||
let nums = assign_stream_numbers(&infos, &map);
|
||||
let nums = assign_stream_numbers(&infos, &map).expect("numbering space not exhausted");
|
||||
assert_eq!(nums[0], 1); // the genuinely-mapped stream keeps 1
|
||||
assert_eq!(nums[1], 2); // the 0-stream is synthesized to the next free slot
|
||||
}
|
||||
@@ -326,16 +440,74 @@ mod tests {
|
||||
info("a0", StreamLabelType::Audio), // fallback
|
||||
info("s0", StreamLabelType::Subtitle), // mapped → 2
|
||||
];
|
||||
let nums = assign_stream_numbers(&infos, &map);
|
||||
let nums = assign_stream_numbers(&infos, &map).expect("numbering space not exhausted");
|
||||
// Audio fallback for a0 → 1 (subtitle's taken-2 doesn't block it).
|
||||
assert_eq!(nums[0], 1);
|
||||
assert_eq!(nums[1], 2);
|
||||
}
|
||||
|
||||
/// Spec: saturating_add prevents overflow when many streams are listed.
|
||||
/// Mutation: use wrapping_add → counter wraps to 0 and collides.
|
||||
/// A crafted `streamproperties.xml` can drive the fallback counter to the
|
||||
/// top of the 1-based u16 stream-number space and then present one more
|
||||
/// unmapped stream whose successor number is also claimed by the map.
|
||||
///
|
||||
/// This must TERMINATE. The bound is the numbering space itself, so the
|
||||
/// assertion is on the spec-derived exhaustion behaviour (`None`), not on
|
||||
/// any tunable constant. Run on a worker thread with a deadline so a
|
||||
/// non-terminating loop fails the test in 20 s instead of hanging CI.
|
||||
#[test]
|
||||
fn assign_stream_numbers_saturation_on_overflow() {
|
||||
fn exhausted_numbering_terminates_instead_of_looping() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
// One mapped audio stream claims the last number in the space.
|
||||
let mut map = HashMap::new();
|
||||
map.insert("claims_max".to_string(), u16::MAX);
|
||||
let mut infos = vec![info("claims_max", StreamLabelType::Audio)];
|
||||
// Enough unmapped audio streams to walk the counter to the top.
|
||||
for i in 0..=(u16::MAX as u32) {
|
||||
infos.push(info(&format!("u{i}"), StreamLabelType::Audio));
|
||||
}
|
||||
let _ = tx.send(assign_stream_numbers(&infos, &map));
|
||||
});
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(20)) {
|
||||
Ok(result) => {
|
||||
worker.join().expect("worker panicked");
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"an exhausted 1-based u16 numbering space must fail the parse, \
|
||||
not emit colliding or wrapped stream numbers"
|
||||
);
|
||||
}
|
||||
Err(_) => panic!(
|
||||
"assign_stream_numbers did not terminate within 20s — \
|
||||
non-terminating skip loop on crafted stream_map"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole 1-based u16 space must remain usable: 65535 unmapped audio
|
||||
/// streams get 65535 distinct numbers with no panic and no wrap. The
|
||||
/// literals here are the JVMS-independent, spec-derived size of a u16
|
||||
/// 1-based numbering domain, not a tunable cap.
|
||||
#[test]
|
||||
fn full_u16_numbering_space_is_usable_and_unique() {
|
||||
let infos: Vec<StreamInfo> = (0..65_535u32)
|
||||
.map(|i| info(&format!("a{i}"), StreamLabelType::Audio))
|
||||
.collect();
|
||||
let nums = assign_stream_numbers(&infos, &HashMap::new()).expect("space is not exhausted");
|
||||
assert_eq!(nums.len(), 65_535);
|
||||
assert_eq!(nums[0], 1);
|
||||
assert_eq!(nums[65_534], 65_535);
|
||||
let mut sorted = nums.clone();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
assert_eq!(sorted.len(), 65_535, "stream numbers must all be distinct");
|
||||
}
|
||||
|
||||
/// Spec: a partially-mapped playlist with many claimed numbers must still
|
||||
/// synthesize past every claim without panicking or colliding.
|
||||
/// Mutation: drop the skip loop → the fallback reuses a claimed number.
|
||||
#[test]
|
||||
fn fallback_skips_a_dense_block_of_claimed_numbers() {
|
||||
// Force the counter past u16::MAX by pre-taking all values 1..=u16::MAX.
|
||||
// Doing that for real would be slow; instead inject u16::MAX into taken.
|
||||
let mut map = HashMap::new();
|
||||
@@ -362,7 +534,7 @@ mod tests {
|
||||
qualifier: LabelQualifier::None,
|
||||
});
|
||||
// This must not panic.
|
||||
let nums = assign_stream_numbers(&infos, &map);
|
||||
let nums = assign_stream_numbers(&infos, &map).expect("numbering space not exhausted");
|
||||
assert_eq!(nums.len(), 501);
|
||||
// The last (unmapped) entry's number must be > 500 (skipped all taken).
|
||||
assert!(nums[500] > 500);
|
||||
|
||||
+276
-119
@@ -50,10 +50,10 @@ fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> {
|
||||
if let Some(mb_match) = mb
|
||||
.iter()
|
||||
.find(|m| m.stream_type == label.stream_type && m.stream_number == label.stream_number)
|
||||
&& label.name.is_empty()
|
||||
&& !mb_match.name.is_empty()
|
||||
{
|
||||
if label.name.is_empty() && !mb_match.name.is_empty() {
|
||||
label.name = mb_match.name.clone();
|
||||
}
|
||||
label.name = mb_match.name.clone();
|
||||
}
|
||||
}
|
||||
// Append any menu_base-only stream (present in mb but not in ls by
|
||||
@@ -87,7 +87,21 @@ fn prefix_is_commentary(prefix: &str) -> bool {
|
||||
fn parse_language_streams(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(reader, udf, "language_streams.txt")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
let labels = parse_language_streams_text(text);
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
/// Parse the body of a `language_streams.txt` file into stream labels.
|
||||
///
|
||||
/// This is the shipping parser: [`parse_language_streams`] does the UDF read
|
||||
/// and UTF-8 decode and then delegates here. It is split out — rather than
|
||||
/// duplicated under `#[cfg(test)]`, which is what it used to be — so the unit
|
||||
/// tests below exercise production code. A test that re-implements the
|
||||
/// function it guards cannot fail when the real function breaks.
|
||||
fn parse_language_streams_text(text: &str) -> Vec<StreamLabel> {
|
||||
let mut labels = Vec::new();
|
||||
|
||||
for line in text.lines() {
|
||||
@@ -193,122 +207,7 @@ fn parse_language_streams(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<
|
||||
}
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_number: stream_num,
|
||||
stream_type,
|
||||
language,
|
||||
name: String::new(),
|
||||
purpose: final_purpose,
|
||||
qualifier,
|
||||
codec_hint,
|
||||
variant: variant_code,
|
||||
});
|
||||
}
|
||||
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
/// Parse the body of a `language_streams.txt` file into stream labels. Split
|
||||
/// out from [`parse_language_streams`] so unit tests exercise the real parsing
|
||||
/// logic without needing a SectorSource / UdfFs.
|
||||
#[cfg(test)]
|
||||
fn parse_language_streams_text(text: &str) -> Vec<StreamLabel> {
|
||||
let mut labels = Vec::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
|
||||
if parts.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let type_str = parts[1];
|
||||
let stream_num: u16 = match parts[2].parse() {
|
||||
Ok(n) if n > 0 => n,
|
||||
_ => continue,
|
||||
};
|
||||
let language = parts[3].to_string();
|
||||
let variant = if parts.len() > 4 {
|
||||
parts[4].to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let (stream_type, purpose, qualifier) = match type_str {
|
||||
"audio_production" => (
|
||||
StreamLabelType::Audio,
|
||||
LabelPurpose::Normal,
|
||||
LabelQualifier::None,
|
||||
),
|
||||
"audio_commentary" => (
|
||||
StreamLabelType::Audio,
|
||||
LabelPurpose::Commentary,
|
||||
LabelQualifier::None,
|
||||
),
|
||||
"audio_ime" => (
|
||||
StreamLabelType::Audio,
|
||||
LabelPurpose::Ime,
|
||||
LabelQualifier::None,
|
||||
),
|
||||
"subtitle_production" => (
|
||||
StreamLabelType::Subtitle,
|
||||
LabelPurpose::Normal,
|
||||
LabelQualifier::None,
|
||||
),
|
||||
"subtitle_commentary" => (
|
||||
StreamLabelType::Subtitle,
|
||||
LabelPurpose::Commentary,
|
||||
LabelQualifier::None,
|
||||
),
|
||||
"subtitle_narrative" => (
|
||||
StreamLabelType::Subtitle,
|
||||
LabelPurpose::Normal,
|
||||
LabelQualifier::Forced,
|
||||
),
|
||||
"subtitle_dual" => (
|
||||
StreamLabelType::Subtitle,
|
||||
LabelPurpose::Normal,
|
||||
LabelQualifier::None,
|
||||
),
|
||||
"subtitle_bonus" => (
|
||||
StreamLabelType::Subtitle,
|
||||
LabelPurpose::Normal,
|
||||
LabelQualifier::None,
|
||||
),
|
||||
"subtitle_ime" => (
|
||||
StreamLabelType::Subtitle,
|
||||
LabelPurpose::Ime,
|
||||
LabelQualifier::None,
|
||||
),
|
||||
"subtitle_ime_narrative" => (
|
||||
StreamLabelType::Subtitle,
|
||||
LabelPurpose::Ime,
|
||||
LabelQualifier::Forced,
|
||||
),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let mut codec_hint = String::new();
|
||||
let mut variant_code = String::new();
|
||||
let mut final_purpose = purpose;
|
||||
|
||||
if !variant.is_empty() {
|
||||
match variant.as_str() {
|
||||
"eda" => final_purpose = LabelPurpose::Descriptive,
|
||||
"csp" | "cs" | "lsp" | "ls" | "cf" | "pf" | "bp" | "pp" => {
|
||||
variant_code = variant.clone();
|
||||
}
|
||||
_ => codec_hint = vocab::codec(&variant).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_id: None,
|
||||
stream_number: stream_num,
|
||||
stream_type,
|
||||
language,
|
||||
@@ -426,6 +325,68 @@ mod tests {
|
||||
assert_eq!(labels[0].qualifier, LabelQualifier::None);
|
||||
}
|
||||
|
||||
/// Spec: `menu_base.prop` lines are skipped when `is_empty() ||
|
||||
/// starts_with('#')` — either alone is sufficient. A commented-out
|
||||
/// key=value line must never be parsed into an entry.
|
||||
/// Mutation: `||` -> `&&` requires both, which a non-empty comment
|
||||
/// line can't satisfy, so it falls through to `line.find('=')` and
|
||||
/// gets parsed as a real property.
|
||||
#[test]
|
||||
fn menu_base_comment_line_with_equals_is_still_skipped() {
|
||||
let labels = parse_props(
|
||||
"#audio_1.class=AudioButton\n\
|
||||
#audio_1.streamNumber=9\n\
|
||||
#audio_1.name=Should Not Appear\n\
|
||||
audio_2.class=AudioButton\n\
|
||||
audio_2.streamNumber=1\n\
|
||||
audio_2.name=Real Track\n",
|
||||
);
|
||||
assert_eq!(labels.len(), 1, "commented-out entry must not be parsed");
|
||||
assert_eq!(labels[0].name, "Real Track");
|
||||
}
|
||||
|
||||
/// Spec: `menu_base.prop` streamNumber (or audioStream/subtitleStream)
|
||||
/// must be strictly positive — `0` means "no STN entry" and must be
|
||||
/// skipped, matching the `n > 0` guard on the language_streams side.
|
||||
/// Mutation: `n > 0` -> `n >= 0` (or the guard deleted) would let a
|
||||
/// stream_num of 0 through, emitting a dead label apply_labels can
|
||||
/// never match (its counter starts at 1).
|
||||
#[test]
|
||||
fn menu_base_zero_stream_number_skipped() {
|
||||
let labels = parse_props(
|
||||
"audio_1.class=AudioButton\n\
|
||||
audio_1.streamNumber=0\n\
|
||||
audio_1.name=Disabled Slot\n",
|
||||
);
|
||||
assert!(
|
||||
labels.is_empty(),
|
||||
"streamNumber=0 must be skipped, got {labels:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Spec: `is_subtitle` is `class.contains("SubtitleButton") ||
|
||||
/// prefix.starts_with("subtitle_")` — EITHER signal alone is
|
||||
/// sufficient to classify (and keep) a subtitle entry whose prefix
|
||||
/// doesn't follow the `subtitle_` naming convention.
|
||||
/// Mutation: `||` -> `&&` would require BOTH signals; an entry whose
|
||||
/// class says SubtitleButton but whose prefix is something else
|
||||
/// (e.g. a vendor-specific button id) would then satisfy neither
|
||||
/// `is_audio` nor `is_subtitle` and get dropped entirely.
|
||||
#[test]
|
||||
fn menu_base_subtitle_class_alone_is_sufficient() {
|
||||
let labels = parse_props(
|
||||
"menuBtn7.class=SubtitleButton\n\
|
||||
menuBtn7.streamNumber=1\n\
|
||||
menuBtn7.name=English SDH\n",
|
||||
);
|
||||
assert_eq!(
|
||||
labels.len(),
|
||||
1,
|
||||
"class=SubtitleButton alone must classify as subtitle, not be dropped"
|
||||
);
|
||||
assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_commentary_segment_match_not_substring() {
|
||||
// Genuine commentary group segments match.
|
||||
@@ -441,6 +402,7 @@ mod tests {
|
||||
|
||||
fn lbl(t: StreamLabelType, n: u16, name: &str) -> StreamLabel {
|
||||
StreamLabel {
|
||||
stream_id: None,
|
||||
stream_number: n,
|
||||
stream_type: t,
|
||||
language: String::new(),
|
||||
@@ -452,6 +414,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spec: `merge`'s `mb.iter().find(...)` must match an mb entry by
|
||||
/// (stream_type AND stream_number) TOGETHER — either alone is not a
|
||||
/// unique key (there can be an audio #1 and a subtitle #1, or two
|
||||
/// different audio streams).
|
||||
/// Mutation: `&&` -> `||` inside the closure would match on type OR
|
||||
/// number alone, so `.find` (which returns the FIRST match) can pick
|
||||
/// an mb entry with the right type but the WRONG stream number.
|
||||
#[test]
|
||||
fn merge_matches_mb_entry_by_type_and_number_together() {
|
||||
// ls wants audio #2 (empty name, so it will borrow from mb).
|
||||
let ls = vec![lbl(StreamLabelType::Audio, 2, "")];
|
||||
// mb's FIRST audio entry is #1 (wrong number); its #2 entry (the
|
||||
// real match) comes second.
|
||||
let mb = vec![
|
||||
lbl(StreamLabelType::Audio, 1, "Wrong Number Match"),
|
||||
lbl(StreamLabelType::Audio, 2, "Correct Match"),
|
||||
];
|
||||
let merged = merge(ls, mb);
|
||||
assert_eq!(
|
||||
merged.len(),
|
||||
2,
|
||||
"mb's own audio #1 must also survive as its own entry"
|
||||
);
|
||||
let a2 = merged
|
||||
.iter()
|
||||
.find(|l| l.stream_type == StreamLabelType::Audio && l.stream_number == 2)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
a2.name, "Correct Match",
|
||||
"must match mb by (type AND number), not type or number alone"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_preserves_menu_base_only_streams() {
|
||||
// language_streams covers audio 1; menu_base has audio 1 (name)
|
||||
@@ -521,6 +516,47 @@ mod tests {
|
||||
assert_eq!(labels[0].qualifier, LabelQualifier::Forced);
|
||||
}
|
||||
|
||||
/// Immunity pin against the defect measured in the `paramount` parser,
|
||||
/// where a vendor `forced_sub` cell hung off a FULL dialogue track's own
|
||||
/// slot to say "this track also contains forced signs", and reading that
|
||||
/// cell as "this track is forced" flagged 30 MB dialogue tracks forced.
|
||||
///
|
||||
/// This format cannot express that. The forced signal is not a flag beside
|
||||
/// a track's entry — it IS the entry's stream-kind token, drawn from a
|
||||
/// closed vocabulary in which `subtitle_production` (the full dialogue
|
||||
/// track) and `subtitle_narrative` (the forced-narrative track) are
|
||||
/// mutually exclusive alternatives in the same position. A row is one or
|
||||
/// the other; there is no cell a full track can carry to acquire the
|
||||
/// qualifier, so the paramount failure mode has no encoding here.
|
||||
///
|
||||
/// Mutation: give `subtitle_production` a `Forced` qualifier, or add a
|
||||
/// forced side-flag that both kinds may carry.
|
||||
#[test]
|
||||
fn a_full_subtitle_track_kind_can_never_carry_the_forced_qualifier() {
|
||||
// Every subtitle kind in the vocabulary, one row each.
|
||||
let text = "id1,subtitle_production,1,eng\n\
|
||||
id2,subtitle_commentary,2,eng\n\
|
||||
id3,subtitle_dual,3,eng\n\
|
||||
id4,subtitle_bonus,4,eng\n\
|
||||
id5,subtitle_ime,5,kor\n\
|
||||
id6,subtitle_narrative,6,eng\n\
|
||||
id7,subtitle_ime_narrative,7,kor\n";
|
||||
let labels = parse_language_streams_text(text);
|
||||
let forced: Vec<&str> = labels
|
||||
.iter()
|
||||
.filter(|l| l.qualifier == LabelQualifier::Forced)
|
||||
.map(|l| l.language.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
forced.len(),
|
||||
2,
|
||||
"only the two narrative kinds are forced, got {forced:?}"
|
||||
);
|
||||
// The full dialogue kind specifically.
|
||||
let production = parse_language_streams_text("id,subtitle_production,1,eng\n");
|
||||
assert_eq!(production[0].qualifier, LabelQualifier::None);
|
||||
}
|
||||
|
||||
/// Spec: `subtitle_commentary` → Subtitle / Commentary.
|
||||
/// Mutation: treat as Normal → subtitle commentary not flagged.
|
||||
#[test]
|
||||
@@ -564,6 +600,72 @@ mod tests {
|
||||
assert!(labels.is_empty());
|
||||
}
|
||||
|
||||
/// Immunity pin. `language_streams.txt` states each stream's number in
|
||||
/// field 3, so a row the parser cannot use is simply dropped — it can
|
||||
/// never renumber the rows behind it. This is the property that keeps
|
||||
/// this parser out of the STN-slot-shifting failure mode that bites
|
||||
/// parsers which count positionally: there, a skipped entry silently
|
||||
/// pulls every later label one stream forward.
|
||||
///
|
||||
/// Mutation: replace `parts[2]` with a running per-type counter → the
|
||||
/// three unusable rows here collapse the survivors onto 1/2 and 1.
|
||||
#[test]
|
||||
fn ls_stream_numbers_come_from_the_row_not_a_counter() {
|
||||
let labels = parse_language_streams_text(
|
||||
"id,audio_production,4,eng\n\
|
||||
id,audio_bonus_extended,5,eng\n\
|
||||
id,audio_production,0,fra\n\
|
||||
id,audio_production,7,fra\n\
|
||||
id,subtitle_production\n\
|
||||
id,subtitle_narrative,9,deu\n",
|
||||
);
|
||||
let nums: Vec<(StreamLabelType, u16)> = labels
|
||||
.iter()
|
||||
.map(|l| (l.stream_type, l.stream_number))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
nums,
|
||||
vec![
|
||||
(StreamLabelType::Audio, 4),
|
||||
(StreamLabelType::Audio, 7),
|
||||
(StreamLabelType::Subtitle, 9),
|
||||
],
|
||||
"an unusable row drops out without shifting the numbering"
|
||||
);
|
||||
assert_eq!(labels[2].qualifier, LabelQualifier::Forced);
|
||||
}
|
||||
|
||||
/// Immunity pin, `menu_base.prop` side: the number comes from the
|
||||
/// entry's own `streamNumber` property, so a skipped entry (commented
|
||||
/// out, `streamNumber=0`, neither audio nor subtitle) leaves the
|
||||
/// surviving entries on their authored slots.
|
||||
///
|
||||
/// Mutation: number by iteration order → the survivors collapse to 1/2.
|
||||
#[test]
|
||||
fn menu_base_stream_numbers_come_from_the_entry_not_a_counter() {
|
||||
let labels = parse_props(
|
||||
"#audio_0.class=AudioButton\n\
|
||||
#audio_0.streamNumber=1\n\
|
||||
audio_1.class=AudioButton\n\
|
||||
audio_1.streamNumber=0\n\
|
||||
audio_2.class=AudioButton\n\
|
||||
audio_2.streamNumber=6\n\
|
||||
other_1.class=SomeOtherButton\n\
|
||||
other_1.streamNumber=2\n\
|
||||
subtitle_1.class=SubtitleButton\n\
|
||||
subtitle_1.streamNumber=11\n",
|
||||
);
|
||||
let nums: Vec<(StreamLabelType, u16)> = labels
|
||||
.iter()
|
||||
.map(|l| (l.stream_type, l.stream_number))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
nums,
|
||||
vec![(StreamLabelType::Audio, 6), (StreamLabelType::Subtitle, 11),],
|
||||
"skipped entries must not renumber the ones that survive"
|
||||
);
|
||||
}
|
||||
|
||||
/// Spec: `eda` variant → `Descriptive` purpose.
|
||||
/// Mutation: miss the `eda` branch → purpose stays Normal.
|
||||
#[test]
|
||||
@@ -608,6 +710,60 @@ mod tests {
|
||||
assert_eq!(labels[0].language, "eng");
|
||||
}
|
||||
|
||||
/// Spec: the skip test is `is_empty() || starts_with('#')` — EITHER
|
||||
/// condition alone must skip the line. A commented-out line that
|
||||
/// happens to look like valid CSV (a real authoring pattern for
|
||||
/// disabling a stream entry) must never produce a label.
|
||||
/// Mutation: `||` -> `&&` requires BOTH conditions, which a non-empty
|
||||
/// comment line can never satisfy, so it would fall through to the
|
||||
/// CSV parser and (since it has >= 4 comma fields) emit a spurious
|
||||
/// label instead of being skipped.
|
||||
#[test]
|
||||
fn ls_comment_line_with_csv_shape_is_still_skipped() {
|
||||
let labels =
|
||||
parse_language_streams_text("#id,audio_production,1,eng\nid2,audio_production,2,fra\n");
|
||||
assert_eq!(
|
||||
labels.len(),
|
||||
1,
|
||||
"the commented-out CSV-shaped line must not parse"
|
||||
);
|
||||
assert_eq!(labels[0].language, "fra");
|
||||
}
|
||||
|
||||
/// Spec: `subtitle_dual` is a recognized subtitle type (Normal/no
|
||||
/// qualifier). Mutation: delete this match arm → falls to the
|
||||
/// catch-all `_ => continue`, silently dropping the stream.
|
||||
#[test]
|
||||
fn ls_subtitle_dual_parsed() {
|
||||
let labels = parse_language_streams_text("id,subtitle_dual,1,eng\n");
|
||||
assert_eq!(labels.len(), 1, "subtitle_dual must produce a label");
|
||||
assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle);
|
||||
assert_eq!(labels[0].purpose, LabelPurpose::Normal);
|
||||
assert_eq!(labels[0].qualifier, LabelQualifier::None);
|
||||
}
|
||||
|
||||
/// Spec: `subtitle_bonus` is a recognized subtitle type (Normal/no
|
||||
/// qualifier). Mutation: delete this match arm → dropped as unknown.
|
||||
#[test]
|
||||
fn ls_subtitle_bonus_parsed() {
|
||||
let labels = parse_language_streams_text("id,subtitle_bonus,2,eng\n");
|
||||
assert_eq!(labels.len(), 1, "subtitle_bonus must produce a label");
|
||||
assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle);
|
||||
assert_eq!(labels[0].purpose, LabelPurpose::Normal);
|
||||
}
|
||||
|
||||
/// Spec: `subtitle_ime` maps to Subtitle/Ime (no Forced qualifier,
|
||||
/// unlike `subtitle_ime_narrative`).
|
||||
/// Mutation: delete this match arm → dropped as unknown.
|
||||
#[test]
|
||||
fn ls_subtitle_ime_parsed() {
|
||||
let labels = parse_language_streams_text("id,subtitle_ime,3,jpn\n");
|
||||
assert_eq!(labels.len(), 1, "subtitle_ime must produce a label");
|
||||
assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle);
|
||||
assert_eq!(labels[0].purpose, LabelPurpose::Ime);
|
||||
assert_eq!(labels[0].qualifier, LabelQualifier::None);
|
||||
}
|
||||
|
||||
/// Spec: multiple valid lines produce multiple labels.
|
||||
/// Mutation: stop after first label → only 1 label returned.
|
||||
#[test]
|
||||
@@ -756,6 +912,7 @@ fn parse_menu_base_text(text: &str) -> Vec<StreamLabel> {
|
||||
.unwrap_or_default();
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_id: None,
|
||||
stream_number: stream_num,
|
||||
stream_type,
|
||||
language,
|
||||
|
||||
+265
-8
@@ -93,6 +93,45 @@ fn scan_jar(archive: &mut jar::Jar) -> Vec<StreamLabel> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Cap on the bytes retained for one stream label.
|
||||
///
|
||||
/// The label is an owned copy of a slice of a `CONSTANT_Utf8_info` entry,
|
||||
/// whose `length` field is a `u16` (JVMS §4.4.7) — so a single crafted
|
||||
/// constant contributes up to 65535 bytes, and the `u16` stream-number
|
||||
/// keyspace admits 65536 of them per type.
|
||||
///
|
||||
/// Headroom: real dbp menu labels are short display names — "English Dolby
|
||||
/// Atmos" (19 bytes), "Spanish 5.1 Dolby Digital" (25). The longest plausible
|
||||
/// retail string ("Portuguese (Brazilian) 5.1 Dolby Digital Plus") is 45
|
||||
/// bytes. 256 leaves >5x headroom over that, and any string past it is menu
|
||||
/// geometry or padding, never a language name — `vocab::lang` would not
|
||||
/// resolve it anyway.
|
||||
const MAX_LABEL_BYTES: usize = 256;
|
||||
|
||||
/// Cap on retained stream slots per type.
|
||||
///
|
||||
/// The keys come from `parse::<u16>()` on disc bytes, so all 65536 slots per
|
||||
/// type are reachable; paired with [`MAX_LABEL_BYTES`] this bounds the whole
|
||||
/// scan at 2 x 512 x 256 bytes.
|
||||
///
|
||||
/// Headroom: the BD STN_table admits at most 32 primary audio and 32 PG
|
||||
/// streams per playlist, and dbp emits one menu TextField per stream. 512
|
||||
/// leaves 16x headroom over the spec maximum.
|
||||
const MAX_LABELS_PER_TYPE: usize = 512;
|
||||
|
||||
/// Record `label` for stream `n`, honouring the retention caps. Existing
|
||||
/// slots are still overwritten at the cap so the documented last-write-wins
|
||||
/// behaviour is preserved; only NEW slots are refused.
|
||||
fn retain_label(map: &mut BTreeMap<u16, String>, n: u16, label: &str) {
|
||||
if label.len() > MAX_LABEL_BYTES {
|
||||
return;
|
||||
}
|
||||
if map.len() >= MAX_LABELS_PER_TYPE && !map.contains_key(&n) {
|
||||
return;
|
||||
}
|
||||
map.insert(n, label.to_string());
|
||||
}
|
||||
|
||||
fn collect_textfield(
|
||||
s: &str,
|
||||
audios: &mut BTreeMap<u16, String>,
|
||||
@@ -112,15 +151,15 @@ fn collect_textfield(
|
||||
}
|
||||
if let Some(rest) = kind_n.strip_prefix("Audio") {
|
||||
if let Ok(n) = rest.parse::<u16>() {
|
||||
audios.insert(n, label.to_string());
|
||||
retain_label(audios, n, label);
|
||||
}
|
||||
} else if let Some(rest) = kind_n.strip_prefix("Subtitle") {
|
||||
if let Ok(n) = rest.parse::<u16>() {
|
||||
// Subtitle0 is conventionally the "None / Off" disable
|
||||
// button, not an actual subtitle stream.
|
||||
if n > 0 {
|
||||
subs.insert(n, label.to_string());
|
||||
}
|
||||
} else if let Some(rest) = kind_n.strip_prefix("Subtitle")
|
||||
&& let Ok(n) = rest.parse::<u16>()
|
||||
{
|
||||
// Subtitle0 is conventionally the "None / Off" disable
|
||||
// button, not an actual subtitle stream.
|
||||
if n > 0 {
|
||||
retain_label(subs, n, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +171,7 @@ fn make_label(num: u16, label: String, stream_type: StreamLabelType) -> StreamLa
|
||||
let qualifier = vocab::qualifier(&label);
|
||||
let purpose = vocab::purpose(&label);
|
||||
StreamLabel {
|
||||
stream_id: None,
|
||||
stream_number: num,
|
||||
stream_type,
|
||||
language,
|
||||
@@ -147,6 +187,223 @@ fn make_label(num: u16, label: String, stream_type: StreamLabelType) -> StreamLa
|
||||
mod tests {
|
||||
use super::super::{LabelPurpose, LabelQualifier};
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write as _};
|
||||
|
||||
/// Build a minimal, structurally valid `.class` file (JVMS §4.1) whose
|
||||
/// constant pool holds exactly the given `Utf8` strings (indices 1..=N,
|
||||
/// no long/double slot padding needed for plain strings). No fields,
|
||||
/// methods, interfaces, or attributes — `scan_jar`'s only interest is
|
||||
/// the constant pool.
|
||||
fn build_class(utf8_entries: &[&str]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&0xCAFEBABEu32.to_be_bytes()); // magic
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // minor_version
|
||||
out.extend_from_slice(&52u16.to_be_bytes()); // major_version (Java 8)
|
||||
out.extend_from_slice(&((utf8_entries.len() + 1) as u16).to_be_bytes()); // cp_count
|
||||
for s in utf8_entries {
|
||||
out.push(1); // CONSTANT_Utf8 tag
|
||||
out.extend_from_slice(&(s.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
}
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // access_flags
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // this_class
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // super_class
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // interfaces_count
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // fields_count
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // methods_count
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // attributes_count
|
||||
out
|
||||
}
|
||||
|
||||
/// Zip `entries` (name -> bytes) into an in-memory, Stored (uncompressed)
|
||||
/// `jar::Jar` via the `zip` crate's own writer — a real archive, not a
|
||||
/// hand-rolled central directory.
|
||||
fn build_jar(entries: &[(&str, Vec<u8>)]) -> jar::Jar {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(&mut buf));
|
||||
let opts = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in entries {
|
||||
writer.start_file(*name, opts).expect("start_file");
|
||||
writer.write_all(&data[..]).expect("write class bytes");
|
||||
}
|
||||
writer.finish().expect("finish zip");
|
||||
}
|
||||
zip::ZipArchive::new(Cursor::new(buf)).expect("valid zip")
|
||||
}
|
||||
|
||||
/// `scan_jar` wires together `for_each_class`, constant-pool iteration,
|
||||
/// `collect_textfield`, and `make_label` into the actual per-jar scan
|
||||
/// used by `parse`. The pure `collect_textfield`/`make_label` unit
|
||||
/// tests above don't exercise this wiring at all.
|
||||
///
|
||||
/// Mutation: replace the whole function body with `vec![]` — every
|
||||
/// dbp disc would silently lose all its stream labels regardless of
|
||||
/// what's in the jar.
|
||||
#[test]
|
||||
fn scan_jar_extracts_labels_from_real_class_entries() {
|
||||
let class_bytes = build_class(&[
|
||||
"com/dbp/Whatever", // unrelated string — must be ignored
|
||||
"LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,296,763",
|
||||
"HTextField,Subtitle1,English SDH,Fontstrip_Composite,1312,763",
|
||||
"ATextField,Subtitle0,None,Fontstrip_Composite,1312,843", // disable button, skipped
|
||||
]);
|
||||
let mut archive = build_jar(&[("com/dbp/Menu.class", class_bytes)]);
|
||||
|
||||
let labels = scan_jar(&mut archive);
|
||||
|
||||
assert_eq!(
|
||||
labels.len(),
|
||||
2,
|
||||
"expected one audio + one real subtitle label"
|
||||
);
|
||||
let audio = labels
|
||||
.iter()
|
||||
.find(|l| l.stream_type == StreamLabelType::Audio)
|
||||
.expect("audio label present");
|
||||
assert_eq!(audio.stream_number, 1);
|
||||
assert_eq!(audio.language, "eng");
|
||||
|
||||
let sub = labels
|
||||
.iter()
|
||||
.find(|l| l.stream_type == StreamLabelType::Subtitle)
|
||||
.expect("subtitle label present");
|
||||
assert_eq!(sub.stream_number, 1);
|
||||
assert_eq!(sub.qualifier, LabelQualifier::Sdh);
|
||||
}
|
||||
|
||||
/// Immunity pin. Every dbp label states its own slot in the `AudioN` /
|
||||
/// `SubtitleN` token, so the numbering survives gaps and skipped entries
|
||||
/// intact. Nothing here counts positionally, which is what keeps this
|
||||
/// parser out of the failure mode where a skipped entry pulls every later
|
||||
/// label one stream forward.
|
||||
///
|
||||
/// Mutation: number by iteration order → `Audio4` becomes 2 and
|
||||
/// `Subtitle3` becomes 1, silently rebinding both to other streams.
|
||||
#[test]
|
||||
fn stream_numbers_come_from_the_token_not_iteration_order() {
|
||||
let class_bytes = build_class(&[
|
||||
"LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,296,763",
|
||||
// Slots 2 and 3 have no menu TextField authored.
|
||||
"LTextField,Audio4,French 5.1 Dolby Digital,Fontstrip_Composite,296,803",
|
||||
// Not a stream: the disable-subtitles button.
|
||||
"ATextField,Subtitle0,None,Fontstrip_Composite,1312,843",
|
||||
// Unparseable slot token — dropped, and must shift nothing.
|
||||
"HTextField,SubtitleX,German,Fontstrip_Composite,1312,883",
|
||||
"HTextField,Subtitle3,English SDH,Fontstrip_Composite,1312,763",
|
||||
]);
|
||||
let mut archive = build_jar(&[("com/dbp/Menu.class", class_bytes)]);
|
||||
|
||||
let labels = scan_jar(&mut archive);
|
||||
let nums: Vec<(StreamLabelType, u16)> = labels
|
||||
.iter()
|
||||
.map(|l| (l.stream_type, l.stream_number))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
nums,
|
||||
vec![
|
||||
(StreamLabelType::Audio, 1),
|
||||
(StreamLabelType::Audio, 4),
|
||||
(StreamLabelType::Subtitle, 3),
|
||||
],
|
||||
"unlabelled and unusable slots leave the authored numbers alone"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `CONSTANT_Utf8_info` carries a `u16` length (JVMS §4.4.7), so one
|
||||
/// crafted constant contributes up to 65535 bytes and the `u16` stream
|
||||
/// keyspace admits 65536 slots per type — ~4 GiB of retained `String` per
|
||||
/// map from a jar that is orders of magnitude smaller.
|
||||
///
|
||||
/// Boundary literals, not the constant: a 256-byte label is kept, 257 and
|
||||
/// the JVMS maximum 65535 are refused.
|
||||
#[test]
|
||||
fn oversized_labels_are_not_retained() {
|
||||
let mut audios = BTreeMap::new();
|
||||
let mut subs = BTreeMap::new();
|
||||
|
||||
collect_textfield(
|
||||
&format!("XTextField,Audio1,{},rest", "A".repeat(256)),
|
||||
&mut audios,
|
||||
&mut subs,
|
||||
);
|
||||
assert_eq!(
|
||||
audios.get(&1).map(String::len),
|
||||
Some(256),
|
||||
"a 256-byte label must still be retained"
|
||||
);
|
||||
|
||||
collect_textfield(
|
||||
&format!("XTextField,Audio2,{},rest", "A".repeat(257)),
|
||||
&mut audios,
|
||||
&mut subs,
|
||||
);
|
||||
assert!(!audios.contains_key(&2), "a 257-byte label must be refused");
|
||||
|
||||
collect_textfield(
|
||||
&format!("XTextField,Subtitle1,{},rest", "B".repeat(65_535)),
|
||||
&mut audios,
|
||||
&mut subs,
|
||||
);
|
||||
assert!(
|
||||
!subs.contains_key(&1),
|
||||
"a JVMS-maximum 65535-byte Utf8 label must be refused"
|
||||
);
|
||||
}
|
||||
|
||||
/// The stream-slot keyspace is the full `u16` on both maps. Offer 600
|
||||
/// distinct audio slots; exactly 512 are retained.
|
||||
#[test]
|
||||
fn retained_stream_slots_are_capped_per_type() {
|
||||
let mut audios = BTreeMap::new();
|
||||
let mut subs = BTreeMap::new();
|
||||
for n in 1..=600u16 {
|
||||
collect_textfield(
|
||||
&format!("XTextField,Audio{n},English,rest"),
|
||||
&mut audios,
|
||||
&mut subs,
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
audios.len(),
|
||||
512,
|
||||
"600 audio slots offered, {} retained — the slot count is unbounded",
|
||||
audios.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Reaching the slot cap must not break the documented last-write-wins
|
||||
/// behaviour for slots already held.
|
||||
#[test]
|
||||
fn existing_slot_is_still_overwritten_at_the_cap() {
|
||||
let mut audios = BTreeMap::new();
|
||||
let mut subs = BTreeMap::new();
|
||||
for n in 1..=600u16 {
|
||||
collect_textfield(
|
||||
&format!("XTextField,Audio{n},English,rest"),
|
||||
&mut audios,
|
||||
&mut subs,
|
||||
);
|
||||
}
|
||||
collect_textfield("XTextField,Audio1,Spanish,rest", &mut audios, &mut subs);
|
||||
assert_eq!(audios.get(&1).map(String::as_str), Some("Spanish"));
|
||||
}
|
||||
|
||||
/// Headroom: the longest plausible retail label must survive untouched.
|
||||
#[test]
|
||||
fn longest_realistic_label_survives_the_cap() {
|
||||
let mut audios = BTreeMap::new();
|
||||
let mut subs = BTreeMap::new();
|
||||
let real = "Portuguese (Brazilian) 5.1 Dolby Digital Plus";
|
||||
assert_eq!(real.len(), 45, "fixture length changed");
|
||||
collect_textfield(
|
||||
&format!("XTextField,Audio1,{real},Fontstrip_Composite,296,763"),
|
||||
&mut audios,
|
||||
&mut subs,
|
||||
);
|
||||
assert_eq!(audios.get(&1).map(String::as_str), Some(real));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_extracts_audio_and_subtitle_indices() {
|
||||
|
||||
+2027
-64
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,537 @@
|
||||
//! Fox — loose `/BDMV/JAR/<id>/dcx.xml` plain-XML manifest.
|
||||
//!
|
||||
//! Older Fox authoring (e.g. a real Fox release, `/BDMV/JAR/05001/dcx.xml`) ships a
|
||||
//! human-readable XML manifest alongside the BD-J jar. Its root is `<dcx>`, and
|
||||
//! under `<disc>` it lists every playlist the disc plays. The main-feature
|
||||
//! playlists carry nested per-stream `<audio>`/`<subtitle>` elements naming
|
||||
//! language, editorial purpose and forced/SDH state outright — everything a
|
||||
//! label parser wants, in attributes, with no bytecode to walk.
|
||||
//!
|
||||
//! NOT A SPECIFICATION. `/BDMV/JAR/` is application-defined space, so this file
|
||||
//! is one authoring house's internal metadata that happens to press onto the
|
||||
//! disc. Every field meaning below was read off a real disc; treat an
|
||||
//! unfamiliar value as unknown rather than guessing.
|
||||
//!
|
||||
//! Confirmed schema (a real Fox release, `/BDMV/JAR/05001/dcx.xml`):
|
||||
//!
|
||||
//! ```xml
|
||||
//! <dcx>
|
||||
//! <disc>
|
||||
//! <playlist id="00001" lang="eng" name="topmenu"/>
|
||||
//! ...
|
||||
//! <playlist id="00800" lang="eng" name="feature" vers="1" durs="7628">
|
||||
//! <audio id="01" lang="eng" type="feature"/>
|
||||
//! <audio id="02" lang="eng" type="rnib"/>
|
||||
//! <audio id="03" lang="spa" dial="lat" type="feature"/>
|
||||
//! ...
|
||||
//! <subtitle id="01" lang="eng" type="feature" form="sdh"/>
|
||||
//! <subtitle id="02" lang="spa" dial="lat" type="embed"/>
|
||||
//! ...
|
||||
//! <subtitle id="11" lang="eng" type="text"/>
|
||||
//! <properties> ...chapter marks... </properties>
|
||||
//! </playlist>
|
||||
//! <playlist id="00801" lang="jpn" name="feature" vers="1" durs="7628"> ... </playlist>
|
||||
//! </disc>
|
||||
//! </dcx>
|
||||
//! ```
|
||||
//!
|
||||
//! Field meanings (read off the disc, not documented):
|
||||
//! * `<audio type>`: `feature` = a normal program track; `rnib` = a
|
||||
//! descriptive/narration track ("Royal National Institute of Blind People"
|
||||
//! described-video), mapped to [`LabelPurpose::Descriptive`]. A `type`
|
||||
//! containing "comment" maps to [`LabelPurpose::Commentary`].
|
||||
//! * `<subtitle form>`: `sdh` marks a subtitles-for-the-deaf-and-hard-of-
|
||||
//! hearing track ([`LabelQualifier::Sdh`]).
|
||||
//! * `<subtitle type>`: `embed` marks a dedicated forced/embedded-subtitle
|
||||
//! track ([`LabelQualifier::Forced`]); `feature`/`text` are full tracks.
|
||||
//! * `id` on a nested stream is its 1-based STN slot WITHIN ITS TYPE
|
||||
//! (audio ids 01..N, subtitle ids 01..N independently), matching the vendor
|
||||
//! `stream_number` convention the ordinal binder in [`super::apply_labels`]
|
||||
//! reads.
|
||||
//!
|
||||
//! The `<audio>`/`<subtitle>` elements are NESTED inside one feature playlist,
|
||||
//! so extraction is scoped to a single `<playlist name="feature">` element —
|
||||
//! never a document-wide `<audio>` scan, which would merge the regional
|
||||
//! `00800` (eng) and `00801` (jpn) tables into one and collide their slots.
|
||||
|
||||
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, xml};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
/// Detect a Fox disc.
|
||||
///
|
||||
/// Primary, cheap signal: a loose `dcx.xml` under some `/BDMV/JAR/<id>/`
|
||||
/// subdirectory — the manifest [`parse`] reads. Checked first because it is a
|
||||
/// directory walk with no sector reads.
|
||||
///
|
||||
/// Secondary signal: a `com/foxbd/` prefix in a top-level BD-J jar, which newer
|
||||
/// Fox discs (later franchise sequels) carry instead of a loose `dcx.xml`. Recognising
|
||||
/// it attributes the disc to Fox rather than the generic deluxe stub even
|
||||
/// though [`parse`] cannot decode that bytecode form yet (see the Phase 2 note
|
||||
/// at the bottom of this file); such a disc detects here but yields no labels,
|
||||
/// so a lower-confidence parser still wins.
|
||||
pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||
if super::jar_file_exists(udf, "dcx.xml") {
|
||||
return true;
|
||||
}
|
||||
super::jar::for_each_jar(reader, udf, |_, jar| {
|
||||
super::jar::has_path_prefix(jar, "com/foxbd/").then_some(())
|
||||
})
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
let data = super::read_jar_file(reader, udf, "dcx.xml")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
let labels = labels_from_dcx(text);
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// High confidence: the manifest is fully structured and every field
|
||||
// extracted here has its meaning fixed by real discs.
|
||||
let mut result = ParseResult::high(labels);
|
||||
// Surface the feature playlist's authoring id (e.g. "00800") so title
|
||||
// selection can prefer the disc's own feature over a size-inflated decoy —
|
||||
// the same signal `paramount::parse` provides.
|
||||
result.feature_playlist = feature_playlist_id(text).map(|id| super::FeaturePlaylistHint {
|
||||
playlist_id: id.parse::<u16>().ok(),
|
||||
filename: Some(format!("{id}.mpls")),
|
||||
});
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// Build the stream labels from a `dcx.xml` document. Split out from [`parse`]
|
||||
/// so the schema mapping is unit-testable without a `SectorSource`/`UdfFs`.
|
||||
///
|
||||
/// Scoped to ONE feature playlist element (see the module note): the richest
|
||||
/// `<playlist name="feature">`. Its nested `<audio>`/`<subtitle>` children are
|
||||
/// the vendor's STN table for that title.
|
||||
pub(crate) fn labels_from_dcx(text: &str) -> Vec<StreamLabel> {
|
||||
let Some(feature) = select_feature_playlist(text) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut labels = Vec::new();
|
||||
|
||||
// Audio streams.
|
||||
let mut from = 0;
|
||||
while let Some((s, e)) = xml::find_element(feature, "audio", from) {
|
||||
let el = &feature[s..e];
|
||||
from = e;
|
||||
let Some(stream_number) = stream_number_from_id(&xml::attr(el, "id")) else {
|
||||
continue;
|
||||
};
|
||||
let Some(language) = xml::attr(el, "lang") else {
|
||||
continue;
|
||||
};
|
||||
let ty = xml::attr(el, "type")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
labels.push(StreamLabel {
|
||||
stream_id: None,
|
||||
stream_number,
|
||||
stream_type: StreamLabelType::Audio,
|
||||
language: normalize_language(&language),
|
||||
name: String::new(),
|
||||
purpose: audio_purpose(&ty),
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint: String::new(),
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Subtitle streams.
|
||||
let mut from = 0;
|
||||
while let Some((s, e)) = xml::find_element(feature, "subtitle", from) {
|
||||
let el = &feature[s..e];
|
||||
from = e;
|
||||
let Some(stream_number) = stream_number_from_id(&xml::attr(el, "id")) else {
|
||||
continue;
|
||||
};
|
||||
let Some(language) = xml::attr(el, "lang") else {
|
||||
continue;
|
||||
};
|
||||
let ty = xml::attr(el, "type")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let form = xml::attr(el, "form")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
labels.push(StreamLabel {
|
||||
stream_id: None,
|
||||
stream_number,
|
||||
stream_type: StreamLabelType::Subtitle,
|
||||
language: normalize_language(&language),
|
||||
name: String::new(),
|
||||
purpose: LabelPurpose::Normal,
|
||||
qualifier: subtitle_qualifier(&ty, &form),
|
||||
codec_hint: String::new(),
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
labels
|
||||
}
|
||||
|
||||
/// Pick the disc's primary feature playlist element (the full `<playlist ...>
|
||||
/// ... </playlist>` text). Among all `<playlist name="feature">` elements —
|
||||
/// Fox presses one per regional variant (`00800` eng, `00801` jpn) — the one
|
||||
/// carrying the most `<audio>`/`<subtitle>` streams wins, ties to the first.
|
||||
///
|
||||
/// Returning ONE element is the whole point: each feature playlist is its own
|
||||
/// STN table with its own 1-based slot ids, so merging two would put two
|
||||
/// different streams on slot `01`. The richest table is the fullest label set
|
||||
/// and anchors to its matching title by language sequence in
|
||||
/// [`super::apply_labels`].
|
||||
fn select_feature_playlist(text: &str) -> Option<&str> {
|
||||
let mut best: Option<&str> = None;
|
||||
let mut best_streams = 0usize;
|
||||
let mut from = 0;
|
||||
while let Some((s, e)) = xml::find_element(text, "playlist", from) {
|
||||
let element = &text[s..e];
|
||||
from = e;
|
||||
// `name` is read from the element; the opening tag's `name="feature"`
|
||||
// is the first `name=` in document order, ahead of any nested child
|
||||
// (nested `<audio>`/`<subtitle>` carry no `name`).
|
||||
let is_feature =
|
||||
xml::attr(element, "name").is_some_and(|n| n.eq_ignore_ascii_case("feature"));
|
||||
if !is_feature {
|
||||
continue;
|
||||
}
|
||||
let streams = count_elements(element, "audio") + count_elements(element, "subtitle");
|
||||
// First feature wins the tie (matches the registry's first-wins rule);
|
||||
// a strictly richer table displaces it. `best.is_none()` so a feature
|
||||
// with zero nested streams is still selected — its id feeds the hint.
|
||||
if best.is_none() || streams > best_streams {
|
||||
best_streams = streams;
|
||||
best = Some(element);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// The authoring id (digits only, e.g. "00800") of the selected feature
|
||||
/// playlist, or `None` when no feature playlist / no id is present.
|
||||
fn feature_playlist_id(text: &str) -> Option<String> {
|
||||
let feature = select_feature_playlist(text)?;
|
||||
let id = xml::attr(feature, "id")?;
|
||||
let digits: String = id.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
if digits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(digits)
|
||||
}
|
||||
|
||||
/// Count `<tag>` elements inside `element`.
|
||||
fn count_elements(element: &str, tag: &str) -> usize {
|
||||
let mut n = 0;
|
||||
let mut from = 0;
|
||||
while let Some((_, e)) = xml::find_element(element, tag, from) {
|
||||
n += 1;
|
||||
from = e;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Parse a nested stream `id` (e.g. "01", "11") into its 1-based STN slot.
|
||||
/// Digits only, so a stray quote/space cannot poison it; `None` (skip the
|
||||
/// stream) when it names no slot — 0 is the module's `NO_STN_SLOT` sentinel and
|
||||
/// cannot be bound by the ordinal path.
|
||||
fn stream_number_from_id(id: &Option<String>) -> Option<u16> {
|
||||
let id = id.as_deref()?;
|
||||
let digits: String = id.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
digits.parse::<u16>().ok().filter(|&n| n != 0)
|
||||
}
|
||||
|
||||
/// Map an `<audio type>` value to a [`LabelPurpose`]. `rnib` is described-video
|
||||
/// (descriptive/narration); a `comment*` value is commentary; everything else
|
||||
/// (`feature`, unknown) is a normal program track.
|
||||
fn audio_purpose(ty: &str) -> LabelPurpose {
|
||||
if ty == "rnib" {
|
||||
LabelPurpose::Descriptive
|
||||
} else if ty.contains("comment") {
|
||||
LabelPurpose::Commentary
|
||||
} else {
|
||||
LabelPurpose::Normal
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a `<subtitle>`'s `form`/`type` to a [`LabelQualifier`]. `form="sdh"`
|
||||
/// takes precedence (an SDH track is the editorially meaningful flag);
|
||||
/// otherwise `type="embed"` is a dedicated forced/embedded track. `feature`
|
||||
/// and `text` full tracks carry no qualifier.
|
||||
fn subtitle_qualifier(ty: &str, form: &str) -> LabelQualifier {
|
||||
if form == "sdh" {
|
||||
LabelQualifier::Sdh
|
||||
} else if ty == "embed" {
|
||||
LabelQualifier::Forced
|
||||
} else {
|
||||
LabelQualifier::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim + lowercase the raw ISO 639-2 code (`"eng"`, `"fra"`, ...).
|
||||
fn normalize_language(raw: &str) -> String {
|
||||
raw.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
// ── Phase 2 (design only, not implemented here) ─────────────────────────────
|
||||
//
|
||||
// Newer Fox discs (later franchise sequels, surveyed as 11 discs currently mis-owned by
|
||||
// the deluxe stub) ship NO loose `dcx.xml`. They wrap the same per-stream data
|
||||
// inside `com/foxbd` BD-J `.class` bytecode. The follow-on parser would reuse
|
||||
// `super::class_reader` the way `dbp`/`deluxe` already do:
|
||||
//
|
||||
// * `detect` already recognises the `com/foxbd/` central-directory prefix
|
||||
// (above), so Phase 2 only adds a parse path.
|
||||
// * Iterate the jar's classes via `super::jar::for_each_class` and read the
|
||||
// constant pool (`ClassFile`), which holds the authored strings verbatim:
|
||||
// - `FeatureIntroPlaylist.play: PLAYLIST ID:` → the feature playlist id
|
||||
// (→ the same `FeaturePlaylistHint` the dcx path should wire up),
|
||||
// - `RNIB` → a descriptive/narration audio track (→ Descriptive),
|
||||
// - `primaryAudioId=` and the parallel subtitle-id constants → the STN
|
||||
// slot numbering, mirroring the `id="NN"` mapping here.
|
||||
// * Emit the identical `StreamLabel` shape at High confidence so the two Fox
|
||||
// forms are indistinguishable downstream.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A real Fox-release manifest, reduced only by truncating the giant
|
||||
/// chapter-mark `<properties>` blocks (which carry no stream labels). Every
|
||||
/// `<audio>`/`<subtitle>` element and both regional feature playlists are
|
||||
/// verbatim from a real disc's `/BDMV/JAR/05001/dcx.xml`, so this fixture exercises the
|
||||
/// exact bytes production sees: feature selection across two `name="feature"`
|
||||
/// playlists, the nested-scope rule, and the rnib/sdh/embed flags.
|
||||
const FOX_DCX_SAMPLE: &str = r#"<dcx>
|
||||
<disc>
|
||||
<properties region="ABC" regioncheckon="false" parentallevel="PG" hdronlydisc="true" bootstrap.bdjo="88888"
|
||||
vstaskenabled="false" defaultversion="1" topmenushowmarkid="0" topmenuloopmarkid="0"/>
|
||||
|
||||
<playlist id="00001" lang="eng" name="topmenu"/>
|
||||
<playlist id="00100" lang="eng" name="foxlogo"/>
|
||||
<playlist id="00300" lang="eng" name="copyright"/> <!-- English -->
|
||||
<playlist id="00600" name="black" lang="eng"/>
|
||||
|
||||
<playlist id="00800" lang="eng" name="feature" vers="1" durs="7628">
|
||||
<audio id="01" lang="eng" type="feature"/>
|
||||
<audio id="02" lang="eng" type="rnib"/>
|
||||
<audio id="03" lang="spa" dial="lat" type="feature"/>
|
||||
<audio id="04" lang="fra" dial="par" type="feature"/>
|
||||
<audio id="05" lang="dan" type="feature"/>
|
||||
<audio id="06" lang="nld" type="feature"/>
|
||||
<audio id="07" lang="fin" type="feature"/>
|
||||
<audio id="08" lang="deu" type="feature"/>
|
||||
<audio id="09" lang="ita" type="feature"/>
|
||||
<audio id="10" lang="nor" type="feature"/>
|
||||
<audio id="11" lang="swe" type="feature"/>
|
||||
<subtitle id="01" lang="eng" type="feature" form="sdh"/>
|
||||
<subtitle id="02" lang="spa" dial="lat" type="embed"/>
|
||||
<subtitle id="03" lang="fra" dial="par" type="embed"/>
|
||||
<subtitle id="04" lang="dan" type="embed"/>
|
||||
<subtitle id="05" lang="nld" type="embed"/>
|
||||
<subtitle id="06" lang="fin" type="feature"/>
|
||||
<subtitle id="07" lang="deu" type="embed"/>
|
||||
<subtitle id="08" lang="ita" type="embed"/>
|
||||
<subtitle id="09" lang="nor" type="embed"/>
|
||||
<subtitle id="10" lang="swe" type="embed"/>
|
||||
<subtitle id="11" lang="eng" type="text"/>
|
||||
<properties>
|
||||
<entry.marks ids="0,2,4,6,8,10"/>
|
||||
<playlist.marks timecodes="00:00:00:00,00:03:58:18"/>
|
||||
</properties>
|
||||
</playlist>
|
||||
|
||||
<playlist id="00801" lang="jpn" name="feature" vers="1" durs="7628">
|
||||
<audio id="01" lang="eng" type="feature"/>
|
||||
<audio id="02" lang="jpn" type="feature"/>
|
||||
<subtitle id="01" lang="jpn" type="feature"/>
|
||||
<subtitle id="02" lang="eng" type="feature" form="sdh"/>
|
||||
<subtitle id="03" lang="jpn" type="text"/>
|
||||
<subtitle id="04" lang="eng" type="text"/>
|
||||
<properties>
|
||||
<entry.marks ids="0,2,4"/>
|
||||
</properties>
|
||||
</playlist>
|
||||
</disc>
|
||||
</dcx>"#;
|
||||
|
||||
fn audio(labels: &[StreamLabel]) -> Vec<&StreamLabel> {
|
||||
labels
|
||||
.iter()
|
||||
.filter(|l| l.stream_type == StreamLabelType::Audio)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn subs(labels: &[StreamLabel]) -> Vec<&StreamLabel> {
|
||||
labels
|
||||
.iter()
|
||||
.filter(|l| l.stream_type == StreamLabelType::Subtitle)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The primary feature is the richer `00800` (eng) table, not `00801`
|
||||
/// (jpn). Both are `name="feature"`; selection must pick by stream count,
|
||||
/// and the id we surface for the (future) FeaturePlaylistHint is `00800`.
|
||||
#[test]
|
||||
fn selects_richest_feature_playlist_and_its_id() {
|
||||
assert_eq!(
|
||||
feature_playlist_id(FOX_DCX_SAMPLE),
|
||||
Some("00800".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
/// Full real-disc parse: the 00800 audio table. Eleven tracks, id order =
|
||||
/// STN slot, and slot 2 (`eng rnib`) is the descriptive/narration track.
|
||||
#[test]
|
||||
fn fox_dcx_audio_labels() {
|
||||
let labels = labels_from_dcx(FOX_DCX_SAMPLE);
|
||||
let a = audio(&labels);
|
||||
assert_eq!(
|
||||
a.len(),
|
||||
11,
|
||||
"00800 has eleven audio tracks, 00801 not merged"
|
||||
);
|
||||
|
||||
// Slots and languages, in id order.
|
||||
let got: Vec<(u16, &str, LabelPurpose)> = a
|
||||
.iter()
|
||||
.map(|l| (l.stream_number, l.language.as_str(), l.purpose))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
(1, "eng", LabelPurpose::Normal),
|
||||
(2, "eng", LabelPurpose::Descriptive), // rnib = described video
|
||||
(3, "spa", LabelPurpose::Normal),
|
||||
(4, "fra", LabelPurpose::Normal),
|
||||
(5, "dan", LabelPurpose::Normal),
|
||||
(6, "nld", LabelPurpose::Normal),
|
||||
(7, "fin", LabelPurpose::Normal),
|
||||
(8, "deu", LabelPurpose::Normal),
|
||||
(9, "ita", LabelPurpose::Normal),
|
||||
(10, "nor", LabelPurpose::Normal),
|
||||
(11, "swe", LabelPurpose::Normal),
|
||||
]
|
||||
);
|
||||
// Vendor labels: no StreamId, they bind by ordinal STN slot.
|
||||
assert!(a.iter().all(|l| l.stream_id.is_none()));
|
||||
}
|
||||
|
||||
/// Full real-disc parse: the 00800 subtitle table. `form="sdh"` → Sdh,
|
||||
/// `type="embed"` → Forced, `feature`/`text` → no qualifier.
|
||||
#[test]
|
||||
fn fox_dcx_subtitle_labels() {
|
||||
let labels = labels_from_dcx(FOX_DCX_SAMPLE);
|
||||
let s = subs(&labels);
|
||||
assert_eq!(s.len(), 11, "00800 has eleven subtitle tracks");
|
||||
|
||||
let got: Vec<(u16, &str, LabelQualifier)> = s
|
||||
.iter()
|
||||
.map(|l| (l.stream_number, l.language.as_str(), l.qualifier))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
(1, "eng", LabelQualifier::Sdh), // feature + form=sdh
|
||||
(2, "spa", LabelQualifier::Forced), // embed
|
||||
(3, "fra", LabelQualifier::Forced),
|
||||
(4, "dan", LabelQualifier::Forced),
|
||||
(5, "nld", LabelQualifier::Forced),
|
||||
(6, "fin", LabelQualifier::None), // feature
|
||||
(7, "deu", LabelQualifier::Forced),
|
||||
(8, "ita", LabelQualifier::Forced),
|
||||
(9, "nor", LabelQualifier::Forced),
|
||||
(10, "swe", LabelQualifier::Forced),
|
||||
(11, "eng", LabelQualifier::None), // text
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The nested-scope rule: audio/subtitle come from ONE feature playlist,
|
||||
/// never a document-wide scan that would merge 00800 and 00801 and collide
|
||||
/// their `id="01"` slots. If scoping regressed, slot 1 audio would appear
|
||||
/// twice and the count would jump past 11.
|
||||
#[test]
|
||||
fn does_not_merge_regional_feature_playlists() {
|
||||
let labels = labels_from_dcx(FOX_DCX_SAMPLE);
|
||||
let a = audio(&labels);
|
||||
// Exactly one audio label per STN slot 1..=11.
|
||||
let slot1: Vec<_> = a.iter().filter(|l| l.stream_number == 1).collect();
|
||||
assert_eq!(slot1.len(), 1, "one stream on slot 1, not one per playlist");
|
||||
assert_eq!(audio(&labels).len() + subs(&labels).len(), 22);
|
||||
}
|
||||
|
||||
/// The `rnib` described-video mapping in isolation, plus the commentary
|
||||
/// path (no track in the sample manifest uses it, so it is pinned synthetically).
|
||||
#[test]
|
||||
fn audio_purpose_mapping() {
|
||||
assert_eq!(audio_purpose("feature"), LabelPurpose::Normal);
|
||||
assert_eq!(audio_purpose("rnib"), LabelPurpose::Descriptive);
|
||||
assert_eq!(audio_purpose("commentary"), LabelPurpose::Commentary);
|
||||
assert_eq!(
|
||||
audio_purpose("director-commentary"),
|
||||
LabelPurpose::Commentary
|
||||
);
|
||||
assert_eq!(audio_purpose("unknown"), LabelPurpose::Normal);
|
||||
}
|
||||
|
||||
/// `form="sdh"` outranks `type`; `embed` is forced; full tracks are None.
|
||||
#[test]
|
||||
fn subtitle_qualifier_mapping() {
|
||||
assert_eq!(subtitle_qualifier("feature", "sdh"), LabelQualifier::Sdh);
|
||||
assert_eq!(subtitle_qualifier("embed", ""), LabelQualifier::Forced);
|
||||
assert_eq!(subtitle_qualifier("feature", ""), LabelQualifier::None);
|
||||
assert_eq!(subtitle_qualifier("text", ""), LabelQualifier::None);
|
||||
// An SDH embedded track (hypothetical) still flags SDH, the richer fact.
|
||||
assert_eq!(subtitle_qualifier("embed", "sdh"), LabelQualifier::Sdh);
|
||||
}
|
||||
|
||||
/// `id="NN"` becomes the 1-based STN slot; a missing/zero id names no slot.
|
||||
#[test]
|
||||
fn stream_number_from_id_digits_only() {
|
||||
assert_eq!(stream_number_from_id(&Some("01".into())), Some(1));
|
||||
assert_eq!(stream_number_from_id(&Some("11".into())), Some(11));
|
||||
assert_eq!(stream_number_from_id(&Some(" 07 ".into())), Some(7));
|
||||
assert_eq!(stream_number_from_id(&Some("00".into())), None);
|
||||
assert_eq!(stream_number_from_id(&None), None);
|
||||
assert_eq!(stream_number_from_id(&Some("".into())), None);
|
||||
}
|
||||
|
||||
// ── Negative detection ─────────────────────────────────────────────────
|
||||
|
||||
/// A document that is not a Fox feature manifest yields no labels. Three
|
||||
/// shapes: empty, a `<dcx>` whose playlists are all non-feature menu/logo
|
||||
/// clips (no `name="feature"`), and unrelated XML. None must be mistaken
|
||||
/// for a Fox feature table.
|
||||
#[test]
|
||||
fn non_feature_manifest_yields_no_labels() {
|
||||
assert!(labels_from_dcx("").is_empty());
|
||||
assert!(labels_from_dcx("<not-dcx><playlist name=\"x\"/></not-dcx>").is_empty());
|
||||
let menus_only = r#"<dcx><disc>
|
||||
<playlist id="00001" lang="eng" name="topmenu"/>
|
||||
<playlist id="00100" lang="eng" name="foxlogo"/>
|
||||
<playlist id="00600" name="black" lang="eng"/>
|
||||
</disc></dcx>"#;
|
||||
assert!(
|
||||
labels_from_dcx(menus_only).is_empty(),
|
||||
"no <playlist name=\"feature\"> → nothing to label"
|
||||
);
|
||||
assert_eq!(feature_playlist_id(menus_only), None);
|
||||
}
|
||||
|
||||
/// A feature playlist with no nested streams (an authoring edge) produces
|
||||
/// no labels rather than a spurious empty-slot entry.
|
||||
#[test]
|
||||
fn feature_without_streams_yields_no_labels() {
|
||||
let doc = r#"<dcx><disc>
|
||||
<playlist id="00800" lang="eng" name="feature" durs="7628"/>
|
||||
</disc></dcx>"#;
|
||||
assert!(labels_from_dcx(doc).is_empty());
|
||||
// ...but the id is still recoverable for the feature hint.
|
||||
assert_eq!(feature_playlist_id(doc), Some("00800".to_string()));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user