mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
962aa5d852 | ||
|
|
719b63e376 | ||
|
|
3cf7922574 | ||
|
|
0c953e9340 | ||
|
|
2e90a7585d | ||
|
|
3e11225389 | ||
|
|
82511d84b6 | ||
|
|
20afbfdccb | ||
|
|
6d78a36d22 | ||
|
|
a9d41f53f8 | ||
|
|
c7c53989f7 | ||
|
|
7ec2dbfbcb | ||
|
|
16e5b5ee38 | ||
|
|
d40540ce9e | ||
|
|
ba14d66c18 | ||
|
|
f16abe1cf4 | ||
|
|
477c0da6dd | ||
|
|
41c5b8b785 | ||
|
|
9f43ae94d3 | ||
|
|
9ed6f86255 | ||
|
|
70ae7b6c10 | ||
|
|
3f03c6db06 | ||
|
|
562bb8bee5 | ||
|
|
7c13ed32b5 | ||
|
|
574aa2c506 | ||
|
|
3912762b52 | ||
|
|
028768e71b | ||
|
|
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 | ||
|
|
602e44fde9 | ||
|
|
622008650c | ||
|
|
c508484451 | ||
|
|
cf16f66e07 | ||
|
|
adcab75409 | ||
|
|
61e4095727 | ||
|
|
540f84c7d0 |
@@ -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.88.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.88.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.88.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.88.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.88.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@v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: libfreemkv/lcov.info
|
||||
slug: freemkv/libfreemkv
|
||||
fail_ci_if_error: false
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
name: comment-guard
|
||||
|
||||
# Fails if any inline `//` comment block runs longer than 3 lines. Rustdoc
|
||||
# (`///`, `//!`) is exempt. Keeps explanatory comments terse; long rationale
|
||||
# belongs in a doc comment or a design note, not an inline wall.
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
comment-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run comment-guard
|
||||
run: python3 ci/comment-guard.py
|
||||
@@ -0,0 +1,32 @@
|
||||
name: doc-links
|
||||
|
||||
# Fails on broken intra-doc links in `///`/`//!` doc comments (e.g. a `[Type]`
|
||||
# link to a renamed/removed item). Catches doc drift that `cargo build` misses.
|
||||
#
|
||||
# libfreemkv path-deps ../freemkv-unlock on the branch tip, so — like ci.yml —
|
||||
# both repos are checked out into sibling subdirectories for the path dep to
|
||||
# resolve.
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
doc-links:
|
||||
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.98.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- name: cargo doc (deny broken intra-doc links)
|
||||
working-directory: libfreemkv
|
||||
env:
|
||||
RUSTDOCFLAGS: "-D rustdoc::broken_intra_doc_links"
|
||||
run: cargo doc --no-deps --document-private-items --lib --bins
|
||||
@@ -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.88.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.88.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.88.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/
|
||||
|
||||
+901
-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.
|
||||
|
||||
|
||||
+54
-22
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.4.2"
|
||||
version = "1.6.14"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
rust-version = "1.88"
|
||||
license = "MIT"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
repository = "https://github.com/freemkv/libfreemkv"
|
||||
@@ -18,50 +18,79 @@ publish = false
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# ── Feature layout ───────────────────────────────────────────────────────────
|
||||
# `scsi` — the SCSI passthrough transport layer ONLY (`src/scsi/` + `error`):
|
||||
# ScsiTransport/execute, ScsiResult/ScsiSense/parse_sense, DriveInfo,
|
||||
# list_drives, drive_has_disc, CDB builders, the per-OS backends and
|
||||
# the raw-`cc` macOS shim (built by build.rs). Pulls only `tracing`
|
||||
# (transport instrumentation) plus the always-on `libc` target dep —
|
||||
# none of the ripping dependency tree.
|
||||
# `rip` — the full ripping tree (drive lifecycle, disc/UDF/MPLS/CLPI parsing,
|
||||
# AACS/CSS decrypt, PES/mux pipeline, keydb, freemkv-unlock seam). All
|
||||
# heavy deps (serde, zip, rayon, roxmltree, rand, crossbeam, memchr,
|
||||
# sha1, aes, base64, freemkv-unlock, serde_json) live here.
|
||||
# `default = ["rip", "scsi"]` preserves today's full build byte-for-byte;
|
||||
# consumers wanting transport only use `default-features = false, features =
|
||||
# ["scsi"]`. `rip` implies `scsi` because the ripping tree reads through it.
|
||||
[features]
|
||||
default = ["rip", "scsi"]
|
||||
scsi = ["dep:tracing"]
|
||||
rip = [
|
||||
"scsi",
|
||||
"dep:serde",
|
||||
"dep:serde_json",
|
||||
"dep:sha1",
|
||||
"dep:aes",
|
||||
"dep:freemkv-unlock",
|
||||
"dep:rand",
|
||||
"dep:zip",
|
||||
"dep:base64",
|
||||
"dep:roxmltree",
|
||||
"dep:crossbeam-channel",
|
||||
"dep:rayon",
|
||||
"dep:memchr",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
serde = { version = "1", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1", optional = true }
|
||||
sha1 = { version = "0.10", optional = true }
|
||||
aes = { version = "0.9", optional = true }
|
||||
# Interim path dep for local cross-repo dev; the release script re-pins this to
|
||||
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
|
||||
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.4.2" }
|
||||
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.14", optional = true }
|
||||
rand = { version = "0.10", optional = true }
|
||||
zip = { version = "8", default-features = false, features = ["deflate"], optional = true }
|
||||
base64 = { version = "0.23", optional = true }
|
||||
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
|
||||
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
|
||||
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
|
||||
# parser, not a hand-rolled scanner: the XPL is genuine XML (comments, varied
|
||||
# attribute order, self-closing tags).
|
||||
roxmltree = "0.20"
|
||||
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
|
||||
roxmltree = { version = "0.20", optional = true }
|
||||
# Trace-level instrumentation for the read/transport path (SgIoTransport::execute
|
||||
# and friends). Part of the `scsi` feature — the transport layer traces here.
|
||||
# Permitted
|
||||
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
|
||||
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
||||
tracing = "0.1"
|
||||
tracing = { version = "0.1", optional = true }
|
||||
# Bounded MPSC channel with kernel-wakeup send_timeout. Used by `io::pipeline`
|
||||
# so the halt-aware send/finish loops can BLOCK on consumer drain instead of
|
||||
# polling — the 50 ms poll cadence of the previous mpsc-based impl capped mux
|
||||
# throughput at ~1 MB/s (0.21.7).
|
||||
crossbeam-channel = "0.5"
|
||||
crossbeam-channel = { version = "0.5", optional = true }
|
||||
# Persistent work-stealing thread pool for parallel AACS unit
|
||||
# decryption. Per-call std::thread::scope spawned fresh OS threads
|
||||
# and that overhead dominated for typical batch sizes (60 units).
|
||||
# rayon's global pool initialises once on first use.
|
||||
rayon = "1"
|
||||
rayon = { version = "1", optional = true }
|
||||
# SIMD-accelerated bytestring search. Drives the HEVC/H.264 start-code
|
||||
# scan in `mux::codec::h264::find_start_code` — naive byte-by-byte
|
||||
# walk is ~500 MB/s single-thread on x86_64; memchr's vectorised
|
||||
# `memmem::find` for the 3-byte `00 00 01` needle hits ~5 GB/s on
|
||||
# AVX2-capable hosts.
|
||||
memchr = "2"
|
||||
memchr = { version = "2", optional = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -76,3 +105,6 @@ tempfile = "3"
|
||||
name = "sgio_read"
|
||||
harness = false
|
||||
|
||||
|
||||
[lints.clippy]
|
||||
uninlined_format_args = "allow"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,6 +5,7 @@ fn main() {
|
||||
if target == "macos" {
|
||||
println!("cargo:rustc-link-lib=framework=IOKit");
|
||||
println!("cargo:rustc-link-lib=framework=CoreFoundation");
|
||||
println!("cargo:rustc-link-lib=framework=DiskArbitration");
|
||||
|
||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||
let obj = format!("{out_dir}/macos_shim.o");
|
||||
@@ -22,7 +23,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 +39,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 +85,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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail if any inline `//` comment block runs longer than MAX_LINES.
|
||||
|
||||
Rustdoc (`///`, `//!`) is exempt — long API docs are fine. Only consecutive
|
||||
non-doc `//` lines are counted; a blank line, code, or a doc line ends the run.
|
||||
Block comments (`/* */`) are not inspected. Scans src/ and tests/ under the cwd.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MAX_LINES = 3
|
||||
|
||||
|
||||
def is_inline_comment(line: str) -> bool:
|
||||
s = line.lstrip()
|
||||
return s.startswith("//") and not s.startswith("///") and not s.startswith("//!")
|
||||
|
||||
|
||||
def violations(path: Path):
|
||||
"""Yield (start_line, length) for each inline run longer than MAX_LINES."""
|
||||
out = []
|
||||
run_start = None
|
||||
run_len = 0
|
||||
for i, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
|
||||
if is_inline_comment(line):
|
||||
if run_start is None:
|
||||
run_start = i
|
||||
run_len += 1
|
||||
else:
|
||||
if run_len > MAX_LINES:
|
||||
out.append((run_start, run_len))
|
||||
run_start, run_len = None, 0
|
||||
if run_len > MAX_LINES:
|
||||
out.append((run_start, run_len))
|
||||
return out
|
||||
|
||||
|
||||
def main(argv):
|
||||
root = Path(argv[1]) if len(argv) > 1 else Path.cwd()
|
||||
files = sorted(p for d in ("src", "tests") for p in (root / d).rglob("*.rs"))
|
||||
total = 0
|
||||
for f in files:
|
||||
for start, length in violations(f):
|
||||
rel = f.relative_to(root)
|
||||
print(f"{rel}:{start}: inline comment block is {length} lines (max {MAX_LINES})")
|
||||
total += 1
|
||||
if total:
|
||||
print(f"\ncomment-guard: {total} inline comment block(s) exceed {MAX_LINES} lines.")
|
||||
print("Trim to the essential why, or split the code so the comment isn't needed.")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
+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()
|
||||
);
|
||||
}
|
||||
+506
-229
File diff suppressed because it is too large
Load Diff
+178
-17
@@ -1,4 +1,4 @@
|
||||
//! AACS common cryptographic primitives — [C] Chapter 2 / §3.2.2.
|
||||
//! AACS common cryptographic primitives — `[C]` Chapter 2 / §3.2.2.
|
||||
//!
|
||||
//! Source: `[C]` = AACS Introduction and Common Cryptographic Elements Book,
|
||||
//! Rev 0.953. The shared low-level building blocks — AES-128 ECB E/D, AES-G,
|
||||
@@ -8,44 +8,122 @@
|
||||
//! 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`).
|
||||
/// 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,
|
||||
];
|
||||
|
||||
/// AES-128-ECB encrypt a single 16-byte block. [C] §2.1.1 (`AES-128E`).
|
||||
// Per-thread count of AES-128 key schedules built through `new_cipher`.
|
||||
// Test-only: lets tests assert the CBC decrypt hot path builds one schedule
|
||||
// per loop-invariant key. Thread-local, not atomic, since `cargo test` runs concurrently.
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
pub(crate) static KEY_EXPANSIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Build an AES-128 key schedule for a caller that will drive
|
||||
/// [`cbc_decrypt_blocks`] over several regions under one key.
|
||||
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);
|
||||
out
|
||||
}
|
||||
|
||||
/// AES-128-ECB decrypt a single 16-byte block. [C] §2.1.1 (`AES-128D`).
|
||||
/// AES-128-ECB decrypt a single 16-byte block. `[C]` §2.1.1 (`AES-128D`).
|
||||
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(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 +135,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];
|
||||
@@ -65,7 +145,7 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2. [C] §2.1.3 (note: uses AES-128**D**).
|
||||
/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2. `[C]` §2.1.3 (note: uses AES-128**D**).
|
||||
///
|
||||
/// The Media Key Variant chain uses AES-G to derive both the variant
|
||||
/// number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key
|
||||
@@ -80,14 +160,14 @@ pub(crate) fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
|
||||
out
|
||||
}
|
||||
|
||||
/// AACS-G3 seed constant (`s0`). [C] §3.2.2.
|
||||
/// AACS-G3 seed constant (`s0`). `[C]` §3.2.2.
|
||||
pub(crate) const AESG3_SEED: [u8; 16] = [
|
||||
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
|
||||
];
|
||||
|
||||
/// AACS-G3: derive a subkey from a parent key. [C] §3.2.2 (Triple AES Generator:
|
||||
/// AACS-G3: derive a subkey from a parent key. `[C]` §3.2.2 (Triple AES Generator:
|
||||
/// left=`D(k,s0)⊕s0` inc 0, pk=`D(k,s0+1)⊕(s0+1)` inc 1, right=`D(k,s0+2)⊕(s0+2)` inc 2).
|
||||
/// seed[15] += inc, then AES-DEC(key, seed) XOR seed.
|
||||
/// `seed[15] += inc`, then AES-DEC(key, seed) XOR seed.
|
||||
///
|
||||
/// Shared with [`super::variant`] (its variant chain runs the same SD
|
||||
/// tree); a single definition keeps the two walks byte-identical.
|
||||
@@ -100,3 +180,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));
|
||||
}
|
||||
}
|
||||
|
||||
+1066
-40
File diff suppressed because it is too large
Load Diff
@@ -1,73 +1,77 @@
|
||||
//! FMTS variant selection — the pure decode-time decision for a 2.1 disc.
|
||||
//! FMTS index selection — the pure decode-time decision for a 2.1 disc.
|
||||
//!
|
||||
//! A 2.1 disc resolves to exactly one forensic variant (1..=32) for a given
|
||||
//! rip. `IndividualSegment.tbl` tags each forensic segment with a variant (see
|
||||
//! [`super::segment`]); the decode keeps the segments matching our variant,
|
||||
//! A 2.1 disc resolves to exactly one forensic index (1..=32) for a given
|
||||
//! rip. `IndividualSegment.tbl` tags each forensic segment with an index (see
|
||||
//! [`super::segment`]); the decode keeps the segments matching our index,
|
||||
//! drops the other 31, and treats everything outside a segment as ordinary
|
||||
//! (variant-0) content. This module owns that classification and nothing else —
|
||||
//! (index-0) content. This module owns that classification and nothing else —
|
||||
//! no I/O, no keys, no cipher — so it is fully testable in isolation. The
|
||||
//! decrypt pipeline consumes the [`UnitDisposition`] it returns.
|
||||
//!
|
||||
//! Where the resolved variant comes from is a separate concern
|
||||
//! ([`resolve_disc_variant`]): today it is read off the variant keys the key
|
||||
//! Where the resolved index comes from is a separate concern
|
||||
//! ([`resolve_disc_index`]): today it is read off the index keys the key
|
||||
//! source handed us; when Processing Keys are available it will come from the
|
||||
//! VK derivation instead. Either way the disposition logic below is identical.
|
||||
|
||||
use super::segment::{Segment, variant_segment_for_unit};
|
||||
use super::segment::{Segment, segment_for_unit};
|
||||
use super::types::UnitKey;
|
||||
|
||||
/// What the decode should do with one AACS aligned unit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UnitDisposition {
|
||||
/// Outside every forensic segment: ordinary content, decrypt with the
|
||||
/// default (variant-0) unit key.
|
||||
/// default (index-0) unit key.
|
||||
Default,
|
||||
/// Inside a forensic segment tagged with OUR resolved variant: decrypt with
|
||||
/// that variant's key.
|
||||
Variant(u8),
|
||||
/// Inside a forensic segment tagged with a DIFFERENT variant: not our
|
||||
/// Inside a forensic segment tagged with OUR resolved index: decrypt with
|
||||
/// that index's key.
|
||||
Index(u8),
|
||||
/// Inside a forensic segment tagged with a DIFFERENT index: not our
|
||||
/// watermark, so it is not part of our output — drop it.
|
||||
DropForeignVariant(u8),
|
||||
/// Inside a forensic segment but no variant key is held (the disc's variant
|
||||
DropForeignIndex(u8),
|
||||
/// Inside a forensic segment but no index key is held (the disc's index
|
||||
/// was never resolved): the segment cannot be decoded, so it is concealed
|
||||
/// as loss. Carries the segment's variant for diagnostics.
|
||||
/// as loss. Carries the segment's index for diagnostics.
|
||||
ForensicNoKey(u8),
|
||||
}
|
||||
|
||||
/// Resolve the disc's single forensic variant from the keys we hold.
|
||||
/// Resolve the disc's single forensic index from the keys we hold.
|
||||
///
|
||||
/// Scans for a variant key (`variant_number` in `1..=32`) and returns its
|
||||
/// variant. `None` when only default (variant-0) keys are held — i.e. no
|
||||
/// variant source answered, so forensic segments are not decodable. A disc has
|
||||
/// exactly one variant, so the first non-zero key decides; if several distinct
|
||||
/// variant keys were somehow supplied the lowest wins (deterministic), which is
|
||||
/// Scans for an index key (`index_number` in `1..=32`) and returns its
|
||||
/// index. `None` when only default (index-0) keys are held — i.e. no
|
||||
/// index source answered, so forensic segments are not decodable. A disc has
|
||||
/// exactly one index, so the first non-zero key decides; if several distinct
|
||||
/// index keys were somehow supplied the lowest wins (deterministic), which is
|
||||
/// only a defensive tiebreak — the probe/derivation yields one.
|
||||
pub fn resolve_disc_variant(unit_keys: &[UnitKey]) -> Option<u8> {
|
||||
pub fn resolve_disc_index(unit_keys: &[UnitKey]) -> Option<u8> {
|
||||
unit_keys
|
||||
.iter()
|
||||
.map(|k| k.variant_number)
|
||||
.map(|k| k.index_number)
|
||||
.filter(|&v| v != 0)
|
||||
.min()
|
||||
}
|
||||
|
||||
/// Classify the AACS aligned unit at `unit_offset` (clip-relative bytes) given
|
||||
/// the forensic segment map and the disc's resolved variant (`None` if no
|
||||
/// variant key is held).
|
||||
/// the forensic segment map and the disc's resolved index (`None` if no
|
||||
/// index key is held).
|
||||
pub fn unit_disposition(
|
||||
unit_offset: u64,
|
||||
segments: &[Segment],
|
||||
disc_variant: Option<u8>,
|
||||
disc_index: Option<u8>,
|
||||
) -> UnitDisposition {
|
||||
match variant_segment_for_unit(segments, unit_offset) {
|
||||
match segment_for_unit(segments, unit_offset) {
|
||||
// Not in any forensic segment → ordinary content.
|
||||
None => UnitDisposition::Default,
|
||||
// In a forensic segment → decide by whether it is our variant.
|
||||
// In a forensic segment → decide by whether it is our index.
|
||||
Some(seg) => {
|
||||
let seg_variant = seg.variant as u8;
|
||||
match disc_variant {
|
||||
Some(v) if v == seg_variant => UnitDisposition::Variant(v),
|
||||
Some(_) => UnitDisposition::DropForeignVariant(seg_variant),
|
||||
None => UnitDisposition::ForensicNoKey(seg_variant),
|
||||
// `seg.index` is untrusted (real range 1..=32); compare in u16 space so a
|
||||
// crafted index above 255 can't truncate into a valid u8 and alias ours.
|
||||
// Diagnostics carry a saturated u8 since out-of-range is never ours.
|
||||
let seg_index = seg.index;
|
||||
let diag = seg_index.min(u8::MAX as u16) as u8;
|
||||
match disc_index {
|
||||
Some(v) if u16::from(v) == seg_index => UnitDisposition::Index(v),
|
||||
Some(_) => UnitDisposition::DropForeignIndex(diag),
|
||||
None => UnitDisposition::ForensicNoKey(diag),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,7 +83,7 @@ mod tests {
|
||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
|
||||
|
||||
/// Build a one-record segment table (variant, start_spn, end_spn).
|
||||
/// Build a one-record segment table (index, start_spn, end_spn).
|
||||
fn tbl(recs: &[(u16, u32, u32)]) -> Vec<Segment> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
||||
@@ -95,23 +99,23 @@ mod tests {
|
||||
parse_individual_segments(&v).expect("parse")
|
||||
}
|
||||
|
||||
fn uk(idx: u32, variant: u8) -> UnitKey {
|
||||
if variant == 0 {
|
||||
fn uk(idx: u32, index: u8) -> UnitKey {
|
||||
if index == 0 {
|
||||
UnitKey::new(idx, [0u8; 16])
|
||||
} else {
|
||||
UnitKey::variant(idx, [variant; 16], variant)
|
||||
UnitKey::forensic(idx, [index; 16], index)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_picks_the_single_variant_key() {
|
||||
// Default keys only → no variant resolved.
|
||||
assert_eq!(resolve_disc_variant(&[uk(0, 0)]), None);
|
||||
assert_eq!(resolve_disc_variant(&[]), None);
|
||||
// One variant key among defaults → that variant.
|
||||
assert_eq!(resolve_disc_variant(&[uk(0, 0), uk(1, 7)]), Some(7));
|
||||
// Defensive: lowest of several distinct variants (deterministic).
|
||||
assert_eq!(resolve_disc_variant(&[uk(0, 9), uk(1, 3)]), Some(3));
|
||||
fn resolve_picks_the_single_index_key() {
|
||||
// Default keys only → no index resolved.
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 0)]), None);
|
||||
assert_eq!(resolve_disc_index(&[]), None);
|
||||
// One index key among defaults → that index.
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 0), uk(1, 7)]), Some(7));
|
||||
// Defensive: lowest of several distinct indexes (deterministic).
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 9), uk(1, 3)]), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -130,29 +134,29 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_in_our_variant_decrypts() {
|
||||
fn unit_in_our_index_decrypts() {
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(7)),
|
||||
UnitDisposition::Variant(7)
|
||||
UnitDisposition::Index(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_in_foreign_variant_drops() {
|
||||
// Segment tagged variant 7, but our disc variant is 3 → drop it.
|
||||
fn unit_in_foreign_index_drops() {
|
||||
// Segment tagged index 7, but our disc index is 3 → drop it.
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(3)),
|
||||
UnitDisposition::DropForeignVariant(7)
|
||||
UnitDisposition::DropForeignIndex(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forensic_unit_with_no_key_is_concealed() {
|
||||
// A forensic segment but we never resolved a variant → conceal as loss.
|
||||
// A forensic segment but we never resolved an index → conceal as loss.
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
@@ -161,18 +165,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_index_does_not_truncate_into_ours() {
|
||||
// Index 288 (0x0120) truncates to 32 in a u8, which would alias our
|
||||
// resolved index 32 under the old `as u8` compare; u16 compare must
|
||||
// classify it as foreign instead.
|
||||
let segs = tbl(&[(288, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(32)),
|
||||
UnitDisposition::DropForeignIndex(255)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straddling_unit_still_classified_as_its_segment() {
|
||||
// A unit whose 32-packet span only tails into the segment still routes
|
||||
// to the segment (matches variant_segment_for_unit's span test).
|
||||
// to the segment (matches segment_for_unit's span test).
|
||||
let segs = tbl(&[(5, 100, 200)]);
|
||||
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
|
||||
// Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100.
|
||||
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::Variant(5)
|
||||
UnitDisposition::Index(5)
|
||||
);
|
||||
}
|
||||
}
|
||||
+483
-62
@@ -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};
|
||||
@@ -45,6 +67,7 @@ pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
|
||||
/// Parse Unit_Key_RO.inf from raw bytes.
|
||||
///
|
||||
/// Format (from AACS spec):
|
||||
/// ```text
|
||||
/// [0..4] BE32: offset to key storage area (uk_pos)
|
||||
/// [16] app_type (1 = BD-ROM)
|
||||
/// [17] num_bdmv_dir
|
||||
@@ -59,6 +82,7 @@ pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
|
||||
/// [uk_pos+48..] encrypted keys, 16 bytes each
|
||||
/// AACS 1.0: 48-byte stride
|
||||
/// AACS 2.0 / 2.1: 64-byte stride (48 + 16 extra)
|
||||
/// ```
|
||||
pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
|
||||
if data.len() < 20 {
|
||||
return None;
|
||||
@@ -113,18 +137,16 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
|
||||
pos += stride;
|
||||
}
|
||||
|
||||
// The loop above `break`s if the buffer runs out mid-key. A short list
|
||||
// means the .inf is malformed/truncated — reject it rather than silently
|
||||
// accepting fewer keys than the header declared, which would later map
|
||||
// title CPS units to nonexistent keys.
|
||||
// The loop `break`s if the buffer runs out mid-key; a short list means the .inf
|
||||
// is malformed/truncated — reject rather than silently mapping title CPS units
|
||||
// to fewer keys than the header declared.
|
||||
if encrypted_keys.len() != num_uk {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Title → CPS unit mapping (AACS Unit_Key_RO format): each on-disc CPS
|
||||
// value is in `1..=num_uk` (else zeroes it) and converts the 1-based on-disc
|
||||
// index to a 0-based key index. We mirror that so the stored value is a safe,
|
||||
// ready-to-use key index rather than a raw 1-based number.
|
||||
// Title → CPS unit mapping (AACS Unit_Key_RO format): validates the on-disc
|
||||
// CPS value is in `1..=num_uk` (else zeroes it) and converts it from 1-based
|
||||
// to a safe, ready-to-use 0-based key index.
|
||||
let to_key_idx = |cps: u16| -> u16 {
|
||||
if cps >= 1 && cps as usize <= num_uk {
|
||||
cps - 1
|
||||
@@ -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;
|
||||
@@ -342,10 +382,8 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Content Certificate layout (per the AACS content-cert format):
|
||||
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
|
||||
// [1] bit7 bus_encryption_enabled_flag (`p[1] >> 7`)
|
||||
// [14..20] cc_id (6 bytes) (`p + 14`)
|
||||
// Content Certificate layout: [0] cert type (0x00 AACS1/0x10 AACS2),
|
||||
// [1] bit7 bus_encryption_enabled_flag, [14..20] cc_id (6 bytes).
|
||||
let version = if data[0] == 0x00 {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
@@ -369,42 +407,50 @@ 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 — empty slots and the trailing 16-byte
|
||||
// TKF MAC are not mistaken for keys. k2/k3 are read at the 36-byte stride
|
||||
// (0xA4, 0xC8); the old 32-byte stride misread them from the prior MAC.
|
||||
assert_eq!(ukf.encrypted_keys.len(), 3);
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys[0],
|
||||
(1, k1),
|
||||
"CPS units = 1-based slot index"
|
||||
);
|
||||
assert_eq!(ukf.encrypted_keys[1], (2, k2));
|
||||
assert_eq!(ukf.encrypted_keys[2], (3, k3));
|
||||
assert_eq!(ukf.version, AacsVersion::V10, "HD DVD is AACS 1.0");
|
||||
@@ -412,6 +458,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 +516,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+112
-9
@@ -1,4 +1,4 @@
|
||||
//! AACS Media Key Block — [C] Chapter 3.
|
||||
//! AACS Media Key Block — `[C]` Chapter 3.
|
||||
//!
|
||||
//! The MKB record format (framing walker, the `MkbRecord` view, record-body
|
||||
//! finders), the MKBType / AACS-generation classification, and MKB-file
|
||||
@@ -7,10 +7,8 @@
|
||||
//! still live side by side pending a follow-up that collapses them.
|
||||
|
||||
// ── MKB record types ([C] Chapter 3) ──────────────────────────────────────
|
||||
// The ONE canonical set. Every record-type comparison in the `aacs` module
|
||||
// references these, so a type byte is never a bare literal scattered across
|
||||
// files (the `0x0c` variant-data record in particular used to appear in several
|
||||
// hand-rolled forms).
|
||||
// The ONE canonical set; every record-type comparison in `aacs` references these
|
||||
// so a type byte is never a bare literal scattered across files.
|
||||
|
||||
/// Type-and-Version — carries the 32-bit MKBType / AACS generation.
|
||||
pub(crate) const REC_TYPE_AND_VERSION: u8 = 0x10;
|
||||
@@ -157,7 +155,7 @@ impl AacsVersion {
|
||||
}
|
||||
|
||||
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
|
||||
/// 0x81: [C] §3.2.5.1.4. 0x86 (AACS 2.x): [RE] — not in the public spec (from real 2.x MKBs).
|
||||
/// 0x81: `[C]` §3.2.5.1.4. 0x86 (AACS 2.x): `[RE]` — not in the public spec (from real 2.x MKBs).
|
||||
pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
// Verify-Media-Key record (0x81 for AACS 1.0, 0x86 for AACS 2.x): mk_dv is
|
||||
// the 16 bytes at record offset 4 (body offset 0). Needs rec_len >= 20.
|
||||
@@ -189,12 +187,12 @@ pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find Subset-Difference records (type 0x04) in MKB. [C] §3.2.5.1.5.
|
||||
/// Find Subset-Difference records (type 0x04) in MKB. `[C]` §3.2.5.1.5.
|
||||
pub(crate) fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
find_record_body(mkb, 0x04)
|
||||
}
|
||||
|
||||
/// Find the Media Key Data Record (cvalues table) in an MKB. [C] §3.2.4 / §3.2.5.1.7.
|
||||
/// Find the Media Key Data Record (cvalues table) in an MKB. `[C]` §3.2.4 / §3.2.5.1.7.
|
||||
///
|
||||
/// The cvalue table is record type `0x05` (Media Key Data) on BOTH AACS
|
||||
/// 1.0 and AACS 2.x MKBs — its 16-byte cvalue entries are 1:1 with the
|
||||
@@ -331,7 +329,7 @@ impl MkbType {
|
||||
}
|
||||
|
||||
/// The raw 32-bit MKBType field from the Type-and-Version record (0x10), bytes
|
||||
/// 4-7. `None` if no 0x10 record is present. [C] §3.2.5.1.1 Table 3-2.
|
||||
/// 4-7. `None` if no 0x10 record is present. `[C]` §3.2.5.1.1 Table 3-2.
|
||||
pub fn mkb_type_raw(mkb: &[u8]) -> Option<u32> {
|
||||
// Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body
|
||||
// offset 0). Needs rec_len >= 8 (4 header + 4 type).
|
||||
@@ -432,4 +430,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));
|
||||
}
|
||||
}
|
||||
|
||||
+278
-55
@@ -29,6 +29,7 @@ pub mod content;
|
||||
pub mod crypto;
|
||||
pub mod derive;
|
||||
pub mod host_certs;
|
||||
pub mod index_select;
|
||||
pub mod inf;
|
||||
pub mod mkb;
|
||||
pub mod provider;
|
||||
@@ -38,19 +39,25 @@ pub mod segment_key;
|
||||
pub mod trace;
|
||||
pub mod types;
|
||||
pub mod variant;
|
||||
pub mod variant_select;
|
||||
|
||||
/// On-disc UDF paths to the AACS key-input files.
|
||||
/// On-disc UDF paths to the AACS key-input files, plus HD DVD AACS-directory
|
||||
/// discovery.
|
||||
///
|
||||
/// BD and UHD keep their key material under `/AACS/…`; HD DVD keeps the
|
||||
/// equivalents under `/ANY!/…` with different names (`VTKF000.AACS` is the
|
||||
/// title-key file — magic `DVD_HD_V_TKF`; `MKBROM.AACS` is the MKB). The
|
||||
/// container difference is expressed here purely as DATA: each ROLE
|
||||
/// ([`UNIT_KEY_RO_PATHS`], [`MKB_PATHS`], [`CONTENT_CERT_PATHS`]) is an ordered
|
||||
/// candidate list, and every reader walks it with [`read_first`] taking the
|
||||
/// first that reads. No reader ever branches on disc type — a BD/UHD disc has
|
||||
/// the `/AACS/` files so those win; an HD DVD has neither, so it falls through
|
||||
/// to the `/ANY!/` entry. Centralised so `resolve_vid_only`, `read_aacs_inputs`,
|
||||
/// BD and UHD keep their key material under a fixed `/AACS/…` tree, so those
|
||||
/// paths are constants. HD DVD keeps the equivalents in a reserved root
|
||||
/// directory whose NAME is authoring-house-specific — observed `ANY!` (Dukes
|
||||
/// of Hazzard) and `AAC!` (Freedom / Memory-Tech), each with a `<name>!_BAK`
|
||||
/// mirror — and whose title-key file is NOT always `VTKF000.AACS` (Freedom
|
||||
/// ships `VTKF090.AACS` + `VTKF100.AACS`). So the HD DVD files are DISCOVERED
|
||||
/// from the parsed UDF tree ([`find_hddvd_aacs_dir`] + [`role_paths`]), never
|
||||
/// hardcoded.
|
||||
///
|
||||
/// Each key ROLE ([`AacsRole`]) resolves to an ordered candidate list — the
|
||||
/// BD/UHD constants first, then whatever the HD DVD directory actually holds —
|
||||
/// which every reader walks with [`read_first`], first-that-reads. No reader
|
||||
/// ever branches on disc type: a BD/UHD disc has the `/AACS/` files so those
|
||||
/// win; an HD DVD has none of them, so it falls through to the discovered
|
||||
/// entries. Centralised so `resolve_vid_only`, `read_aacs_inputs`,
|
||||
/// `read_mkb_content`, and `read_aacs_version` can never silently diverge the
|
||||
/// disc_hash / MKB / VID that another reader feeds a key service.
|
||||
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
|
||||
@@ -59,56 +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 (not fixed at VTKF000; Freedom ships VTKF090 +
|
||||
// VTKF100), sorted for a deterministic try order. Each is bound to
|
||||
// ONE playlist; caller tries in order, uses whichever's keys verify.
|
||||
let mut names: Vec<&str> = dir
|
||||
.entries
|
||||
.iter()
|
||||
.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`](crate::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);
|
||||
}
|
||||
}
|
||||
Err(crate::error::Error::AacsNoKeys)
|
||||
}
|
||||
|
||||
// The module structure IS the public API — consumers import from the owning
|
||||
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
|
||||
// `aacs::derive::{derive_vuk, resolve_candidate}`, `aacs::resolve::resolve_keys_v2`).
|
||||
// The `derive::probe` reproduction harness stays reachable via its module path.
|
||||
//
|
||||
// A small set of flat re-exports is kept for the typed key primitives and the
|
||||
// content-decrypt entry points that downstream key-source crates import through
|
||||
// the `aacs::` path. These are the stable, load-bearing names; keeping them here
|
||||
// lets those crates track the module refactor without a lockstep re-pin.
|
||||
// The module structure IS the public API — consumers import from the owning module
|
||||
// (e.g. `aacs::content::decrypt_unit`). A small set of flat re-exports below is kept
|
||||
// for typed key primitives and content-decrypt entry points that downstream crates rely on.
|
||||
pub use content::ALIGNED_UNIT_LEN;
|
||||
pub use derive::derive_vuk;
|
||||
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
|
||||
@@ -119,26 +177,23 @@ mod tests {
|
||||
//! Touching one representative item per module keeps these as a
|
||||
//! compile-time contract that the module paths stay stable.
|
||||
|
||||
use super::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
||||
use super::content::ALIGNED_UNIT_LEN;
|
||||
use super::inf::{disc_hash, disc_hash_hex};
|
||||
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
||||
use super::variant::is_variant_mkb;
|
||||
|
||||
#[test]
|
||||
fn aligned_unit_len_is_three_2048_byte_sectors() {
|
||||
// ALIGNED_UNIT_LEN is the AACS aligned-unit size: 3 × 2048 = 6144.
|
||||
// Re-exported from decrypt; pin the value here so the public constant
|
||||
// and the spec stay in lockstep.
|
||||
// ALIGNED_UNIT_LEN is the AACS aligned-unit size (3 × 2048 = 6144);
|
||||
// pin it here so the public constant tracks the spec.
|
||||
assert_eq!(ALIGNED_UNIT_LEN, 6144);
|
||||
assert_eq!(ALIGNED_UNIT_LEN, 3 * 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_strides_are_reexported_and_distinct() {
|
||||
// The three AACS generations are part of the public surface, and the
|
||||
// V10 (48) vs V20/V21 (64) stride distinction is the load-bearing
|
||||
// difference. Confirm the enum re-export is usable and the variants
|
||||
// are distinct values.
|
||||
// V10 (48) vs V20/V21 (64) stride is the load-bearing distinction;
|
||||
// confirm the enum re-export is usable and variants are distinct.
|
||||
assert_ne!(AacsVersion::V10, AacsVersion::V20);
|
||||
assert_ne!(AacsVersion::V20, AacsVersion::V21);
|
||||
}
|
||||
@@ -147,7 +202,10 @@ mod tests {
|
||||
fn public_helpers_are_callable_by_module_path() {
|
||||
// Touch a representative function from each module so a dropped/renamed
|
||||
// item fails to compile. Smoke calls, not behavioural assertions.
|
||||
let _ = ts_sync_destroyed(&[0u8; ALIGNED_UNIT_LEN]);
|
||||
let _ = !crate::aacs::content::is_clean(
|
||||
&[0u8; ALIGNED_UNIT_LEN],
|
||||
crate::disc::ContentFormat::BdTs,
|
||||
);
|
||||
let _ = mkb_content_len(&[]);
|
||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||
@@ -158,4 +216,169 @@ mod tests {
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
// ── HD DVD AACS directory / filename discovery ────────────────────────
|
||||
// Dir/filename are authoring-specific (previously hardcoded to `/ANY!/VTKF000.AACS`);
|
||||
// verify against Freedom (`AAC!` + `VTKF090`/`VTKF100`) and a BD/UHD disc (no HD DVD dir).
|
||||
|
||||
#[test]
|
||||
fn role_paths_discovers_hddvd_dir_and_globs_all_vtkf_variants() {
|
||||
use crate::udf::fixture::*;
|
||||
// Freedom-shaped: an `AAC!` dir (NOT `ANY!`) holding MKBROM + two VTKF
|
||||
// variants (090/100, NOT 000) + a VTUF usage file (must be excluded),
|
||||
// plus the `AAC!_BAK` mirror (must NOT be picked as the AACS dir).
|
||||
let mut disc = MemDisc::new();
|
||||
let aacs_files = vec![
|
||||
file("MKBROM.AACS", 100, 5000, 4096, true),
|
||||
file("CONTENT_CERT.AACS", 101, 5100, 2048, true),
|
||||
file("VTKF100.AACS", 102, 5200, 2048, true),
|
||||
file("VTKF090.AACS", 103, 5300, 2048, true),
|
||||
file("VTUF090.AACS", 104, 5400, 2048, true),
|
||||
];
|
||||
let bak_files = vec![file("MKBROM.AACS", 110, 6000, 4096, true)];
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![
|
||||
DirSpec {
|
||||
name: "AAC!".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: aacs_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
name: "AAC!_BAK".to_string(),
|
||||
icb_lba: 30,
|
||||
dir_data_lba: 31,
|
||||
files: bak_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
// Discovered structurally (ends in '!', holds MKBROM.AACS) — the real
|
||||
// AACS dir, never the `_BAK` mirror.
|
||||
let dir = super::find_hddvd_aacs_dir(&udf).expect("aacs dir");
|
||||
assert_eq!(dir.name, "AAC!");
|
||||
|
||||
// UnitKey: BD/UHD paths first, then EVERY VTKF*.AACS in sorted order
|
||||
// (090 before 100) — NOT hardcoded VTKF000; VTUF (usage) excluded.
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
"/AAC!/VTKF090.AACS".to_string(),
|
||||
"/AAC!/VTKF100.AACS".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::Mkb)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/MKBROM.AACS"
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::ContentCert)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/CONTENT_CERT.AACS"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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] = &[];
|
||||
|
||||
+279
-151
@@ -5,15 +5,13 @@ use super::derive::*;
|
||||
use super::inf::*;
|
||||
use super::mkb::*;
|
||||
|
||||
//
|
||||
// Canonical form is `<category>1003` (low 16 bits `0x1003` is a fixed marker).
|
||||
// Types 3/4/10 are from the AACS Common Cryptographic Elements spec (0.953,
|
||||
// §3.2.5.1.1); the Category-C 2.0/2.1 values are the standard MKB type constants.
|
||||
// §3.2.5.1.1); Category-C 2.0/2.1 values are the standard MKB type constants.
|
||||
|
||||
// ── 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 +32,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> {
|
||||
@@ -120,13 +135,13 @@ pub(crate) fn classify_resolve_failure(ctx: &ResolveContext<'_>) -> ResolveFailu
|
||||
}
|
||||
|
||||
/// AACS 1.0 key resolution. Parses `Unit_Key_RO.inf` with 48-byte
|
||||
/// stride. Tries paths 1 → 4 in order.
|
||||
/// stride. Tries paths 1 → 5 in order (via `resolve_keys_classical`).
|
||||
pub fn resolve_keys_v1(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
resolve_keys_classical(ctx, AacsVersion::V10)
|
||||
}
|
||||
|
||||
/// AACS 2.0 key resolution. Parses `Unit_Key_RO.inf` with 64-byte
|
||||
/// stride. Tries paths 1 → 4 in order. When paths 3/4 succeed against
|
||||
/// stride. Tries paths 1 → 5 in order. When paths 3/4 succeed against
|
||||
/// an MKB carrying Variant records (`0x2d` / `0x2f`), the result's
|
||||
/// `version` is upgraded to [`AacsVersion::V21`] — derivation still
|
||||
/// runs through the classical V2 path; the V21-specific Variant chain
|
||||
@@ -194,9 +209,8 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
|
||||
if has_vid {
|
||||
// Path 1: Variant chain (V21's analogue of classical Path 1's DK
|
||||
// derivation). Derive the Processing Key from device keys first (DK → PK
|
||||
// via the variant walk), then run the PK → Km variant primitive and
|
||||
// derive the per-disc VUK from Km + VID.
|
||||
// derivation): DK → PK via variant walk, then PK → Km, then VUK
|
||||
// from Km + VID.
|
||||
if let Some(mkb) = ctx.mkb {
|
||||
let recs = super::mkb::walk_mkb(mkb);
|
||||
let all_dks = providers.device_keys();
|
||||
@@ -226,10 +240,9 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
// Path 3: pre-computed MK + matching VID → derived VUK.
|
||||
// Short-circuit: first provider with a matching VID wins.
|
||||
if let Some(entry) = providers.lookup_disc_by_vid(ctx.volume_id) {
|
||||
// The entry already matched by VID and derive_vuk needs only mk +
|
||||
// ctx.volume_id, so a provider that matches by VID without
|
||||
// populating disc_id (e.g. a webservice) must not have its MK
|
||||
// dropped — gate on the MK alone.
|
||||
// Already matched by VID and derive_vuk needs only mk + volume_id, so a
|
||||
// provider matching by VID without disc_id (e.g. a webservice) must not
|
||||
// lose its MK — gate on the MK alone.
|
||||
if let Some(mk) = entry.media_key {
|
||||
let vuk = derive_vuk(&mk, ctx.volume_id);
|
||||
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_v21_path3_hit", "MK+VID entry matched volume_id");
|
||||
@@ -355,34 +368,22 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
|
||||
}
|
||||
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", pk_count = all_pks.len(), "PK derivation failed");
|
||||
|
||||
// Path 2.5: MK-pool brute. keydb stores Media Keys per-disc, but an
|
||||
// MK is MKB-scoped (shared across a pressing/MKB-family). A disc
|
||||
// whose own hash/VID isn't keyed can still resolve if ANY stored MK
|
||||
// verifies against its MKB. Try every distinct MK via km_verifies;
|
||||
// a UNIQUE pass is this disc's Km → derive VUK (needs VID) → UK.
|
||||
// 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.
|
||||
// Path 2.5: MK-pool brute. An MK is MKB-scoped, so a disc whose own
|
||||
// hash/VID isn't keyed can still resolve if ANY stored MK verifies (a
|
||||
// UNIQUE pass via km_verifies is this disc's Km); mk_dv is hoisted out.
|
||||
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");
|
||||
}
|
||||
@@ -390,10 +391,9 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
|
||||
// Path 3: pre-computed MK + matching VID → derived VUK.
|
||||
// Short-circuit: first provider with a matching VID wins.
|
||||
if let Some(entry) = providers.lookup_disc_by_vid(ctx.volume_id) {
|
||||
// The entry already matched by VID and derive_vuk needs only mk +
|
||||
// ctx.volume_id, so a provider that matches by VID without
|
||||
// populating disc_id (e.g. a webservice) must not have its MK
|
||||
// dropped — gate on the MK alone.
|
||||
// Already matched by VID and derive_vuk needs only mk + volume_id, so a
|
||||
// provider matching by VID without disc_id (e.g. a webservice) must not
|
||||
// lose its MK — gate on the MK alone.
|
||||
if let Some(mk) = entry.media_key {
|
||||
let vuk = derive_vuk(&mk, ctx.volume_id);
|
||||
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "MK+VID entry matched volume_id");
|
||||
@@ -434,6 +434,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 +503,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).
|
||||
@@ -521,11 +578,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn derive_media_key_from_dk_survives_out_of_range_u_mask_shift() {
|
||||
// Regression: a crafted/corrupt MKB with a Subset-Difference
|
||||
// u_mask_shift of 32..=63 (passes the 0xC0 revoked-marker check but
|
||||
// overflows `0xFFFF_FFFF << shift`) used to panic in debug / compute a
|
||||
// wrong mask in release. The walk must now skip the bad slot and
|
||||
// return cleanly (no panic) on disc-controlled bytes.
|
||||
// Regression: a u_mask_shift of 32..=63 (passes the 0xC0 revoked-marker
|
||||
// check but overflows `0xFFFF_FFFF << shift`) used to panic in debug /
|
||||
// compute a wrong mask in release. The walk must skip the bad slot.
|
||||
let mut mkb: Vec<u8> = Vec::new();
|
||||
// 0x81 record: 4-byte header + 16-byte mk_dv body (rec_len = 20).
|
||||
mkb.extend_from_slice(&[0x81, 0x00, 0x00, 0x14]);
|
||||
@@ -551,14 +606,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_unit_key_from_vuk() {
|
||||
// VUK → encrypted unit key → unit key roundtrip. The keydb-sourced
|
||||
// variant of this test (which scanned a real KEYDB for VUK + unit
|
||||
// keys) moved to freemkv-keysources; this rebuilt version exercises
|
||||
// the same AES-G primitive (decrypt_unit_key ∘ aes_ecb_encrypt under a
|
||||
// VUK) with directly-constructed material, so it needs no parser and
|
||||
// keeps the crypto covered in libfreemkv. `aes_ecb_encrypt` is
|
||||
// pub(crate), reachable here but not from keysources — the reason this
|
||||
// half stays.
|
||||
// VUK → encrypted unit key → unit key roundtrip. The keydb-sourced variant
|
||||
// moved to freemkv-keysources; this exercises the same AES-G primitive
|
||||
// with directly-constructed material — `aes_ecb_encrypt` is pub(crate).
|
||||
use super::super::crypto::aes_ecb_encrypt;
|
||||
let vuk = [0x5Au8; 16];
|
||||
// A few representative "decrypted" unit keys.
|
||||
@@ -678,11 +728,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn trim_mkb_never_zeroes_an_unrecognised_mkb() {
|
||||
// Regression: the 0.31.0 read_aacs_inputs path truncated the MKB to
|
||||
// mkb_content_len() unconditionally. For an MKB whose first record the
|
||||
// parser can't read, mkb_content_len() returns 0 → an unconditional
|
||||
// truncate zeroed the MKB, so autorip sent an EMPTY MKB to the key
|
||||
// service (or skipped the request). trim_mkb must leave it intact.
|
||||
// Regression: 0.31.0 truncated the MKB to mkb_content_len() unconditionally.
|
||||
// For an unparseable first record, mkb_content_len() returns 0, so autorip
|
||||
// sent an EMPTY MKB to the key service. trim_mkb must leave it intact.
|
||||
let unrecognised = vec![0xFFu8; 4096]; // first "rec_type" 0xFF, rec_len huge → content_len 0
|
||||
assert_eq!(
|
||||
mkb_content_len(&unrecognised),
|
||||
@@ -744,11 +792,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn probe_try_pk_against_tables_accepts_planted_pk_rejects_corrupt() {
|
||||
// Lock in the terminal PK scan (`try_pk_against_tables`) used by the
|
||||
// production PK path (`derive_media_key_from_pk`). Plant a terminal PK
|
||||
// whose derived Media Key satisfies a synthetic verify record; confirm
|
||||
// the scan ACCEPTS it against caller-supplied SD/cvalue tables and
|
||||
// REJECTS a 1-byte corruption.
|
||||
// Lock in the terminal PK scan (`try_pk_against_tables`) used by
|
||||
// `derive_media_key_from_pk`: a planted PK whose derived MK satisfies a
|
||||
// synthetic verify record must be ACCEPTED; a 1-byte corruption REJECTED.
|
||||
use super::super::crypto::aes_ecb_encrypt as enc;
|
||||
|
||||
let pk: [u8; 16] = [
|
||||
@@ -790,11 +836,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn validate_processing_key_round_trip_with_nonzero_uv() {
|
||||
// Synthesise a (pk, uv, mk, cvalue, mk_dv) tuple that satisfies the
|
||||
// AACS PK-validation relation, then confirm validate_processing_key
|
||||
// recovers mk. Catches the bugs that landed pre-fix:
|
||||
// * uv XOR step was missing → mk wrong whenever uv != 0
|
||||
// * AES-128E + 12-zero check instead of AES-128D + magic
|
||||
// Synthesise a (pk, uv, mk, cvalue, mk_dv) tuple satisfying the AACS
|
||||
// PK-validation relation; confirms validate_processing_key recovers mk.
|
||||
// Catches pre-fix bugs: missing uv XOR, and AES-128E+zero-check vs 128D+magic.
|
||||
use super::super::crypto::{aes_ecb_decrypt as dec, aes_ecb_encrypt as enc};
|
||||
|
||||
let pk: [u8; 16] = [
|
||||
@@ -807,10 +851,8 @@ mod tests {
|
||||
];
|
||||
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
|
||||
|
||||
// cvalue is what AES-128E(pk, mk') gives, where mk' = mk with the
|
||||
// last-4-bytes-uv XOR pre-undone:
|
||||
// mk_raw[12..16] = mk[12..16] XOR uv (so the validate step XORs
|
||||
// uv back in and recovers mk).
|
||||
// cvalue is AES-128E(pk, mk') where mk' = mk with last-4-bytes-uv XOR
|
||||
// pre-undone, so the validate step XORs uv back in and recovers mk.
|
||||
let mut mk_raw = mk;
|
||||
for a in 0..4 {
|
||||
mk_raw[12 + a] ^= uv[a];
|
||||
@@ -842,16 +884,9 @@ mod tests {
|
||||
assert!(validate_processing_key(&pk, &cvalue, &wrong_uv, &mk_dv).is_none());
|
||||
}
|
||||
|
||||
// ── MKB cvalue-record selection (issue #259 / #281) ─────────────────
|
||||
//
|
||||
// The cvalue (Media Key Data) table is record 0x05; the
|
||||
// Subset-Difference index is record 0x04 (the standard AACS MKB layout:
|
||||
// 0x05 = cvalues, 0x04 = subset-difference index). Record 0x07
|
||||
// (Explicit Subset-Difference Record) is NOT the cvalue table. On real
|
||||
// in-drive AACS 2.x UHD MKBs 0x07 is small (~96 entries) while the 0x05
|
||||
// table is large (181270 entries, 1:1 with 0x04). An earlier
|
||||
// `mkb_find_cvalues` preferred 0x07, which under-tested the SD walk and
|
||||
// broke the DK→walk path. The selector must prefer 0x05.
|
||||
// ── MKB cvalue-record selection (issue #259 / #281): cvalue (Media Key Data)
|
||||
// is record 0x05, not 0x07 (Explicit SD, small ~96 entries vs 0x05's 181270).
|
||||
// An earlier `mkb_find_cvalues` wrongly preferred 0x07, breaking DK→walk.
|
||||
|
||||
/// Build a 4-byte MKB record header (type + 3-byte big-endian total
|
||||
/// length, header included) and append `body`.
|
||||
@@ -947,12 +982,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn real_aacs2_samples_select_large_0x05_not_small_0x07() {
|
||||
// Real in-drive AACS 2.x UHD MKBs carry BOTH a small 0x07
|
||||
// Explicit-Subset-Difference record (96 16-byte entries) AND the
|
||||
// large 0x05 Media Key Data / cvalue table (181270 entries, 1:1
|
||||
// with the 0x04 index). The production selector must return the
|
||||
// LARGE 0x05 body, not the small 0x07 one. This is the exact
|
||||
// regression #259 found. Skips when no sample dir is present.
|
||||
// Real AACS 2.x UHD MKBs carry BOTH a small 0x07 record (96 entries) AND
|
||||
// the large 0x05 cvalue table (181270 entries); selector must return the
|
||||
// LARGE 0x05 body — the exact regression #259. Skips if no sample dir.
|
||||
let samples = [
|
||||
"sample-a/MKB_RO.inf",
|
||||
"sample-b/MKB_RO.inf",
|
||||
@@ -1051,19 +1083,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_keys_skips_paths_2_through_4_when_vid_is_zero() {
|
||||
// No VID -> paths 2/3/4 cannot succeed. The function must
|
||||
// return None WITHOUT touching the MKB / device keys, so we
|
||||
// can pass an MKB that would otherwise cause expensive
|
||||
// derivation work — it must not be consumed.
|
||||
// No VID -> paths 2/3/4 cannot succeed. Must return None WITHOUT touching
|
||||
// the MKB/device keys, so an MKB that would cause expensive derivation
|
||||
// work can be passed here and must not be consumed.
|
||||
let uk_ro = minimal_unit_key_ro();
|
||||
let zero_vid = [0u8; 16];
|
||||
|
||||
// A provider carrying a dummy processing key but NO disc entry that
|
||||
// matches this disc. `disc_entry: None` preserves the negative-miss
|
||||
// the test asserts: with VID=0, paths 1/2/3 are skipped and the
|
||||
// path-4/5 hash lookup must MISS (a SuppliedKey returns its
|
||||
// disc_entry unconditionally, so the planted entry would WRONGLY hit
|
||||
// path 4 — None keeps the miss).
|
||||
// Dummy processing key, NO matching disc entry: `disc_entry: None` keeps
|
||||
// the path-4/5 hash lookup a miss (SuppliedKey returns disc_entry
|
||||
// unconditionally, so a planted entry would WRONGLY hit path 4).
|
||||
let keydb = SuppliedKey {
|
||||
device_keys: Vec::new(),
|
||||
processing_keys: vec![[0u8; 16]],
|
||||
@@ -1088,10 +1116,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_keys_path4_still_runs_when_vid_is_zero() {
|
||||
// Path 4 (disc-hash → VUK) doesn't need VID. Confirm the
|
||||
// short-circuit doesn't block it: install a keydb entry whose
|
||||
// disc_hash matches the fixture's hash, with a known VUK, and
|
||||
// verify resolve_keys returns it with key_source = 4.
|
||||
// Path 4 (disc-hash → VUK) doesn't need VID; confirm the short-circuit
|
||||
// doesn't block it: install a keydb entry whose disc_hash matches the
|
||||
// fixture with a known VUK, verify resolve_keys returns key_source = 4.
|
||||
let uk_ro = minimal_unit_key_ro();
|
||||
let hash = disc_hash(&uk_ro);
|
||||
// `find_disc` lowercases the incoming hash; the entry map is
|
||||
@@ -1130,10 +1157,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_keys_path5_uses_keydb_unit_keys_when_vuk_absent() {
|
||||
// Path 5: an entry with no VUK but with pre-decrypted unit
|
||||
// keys matching the disc's CPS-unit numbering decrypts the
|
||||
// disc directly. Covers the ~4,572 U-only KEYDB entries
|
||||
// (mostly MKBv76+ UHDs) that the resolver previously ignored.
|
||||
// Path 5: an entry with no VUK but pre-decrypted unit keys matching the
|
||||
// disc's CPS-unit numbering decrypts directly. Covers the ~4,572 U-only
|
||||
// KEYDB entries (mostly MKBv76+ UHDs) the resolver previously ignored.
|
||||
let uk_ro = minimal_unit_key_ro();
|
||||
let hash = disc_hash(&uk_ro);
|
||||
let hash_hex = disc_hash_hex(&hash).to_lowercase();
|
||||
@@ -1172,11 +1198,9 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn resolve_keys_path5_rejects_partial_unit_key_coverage() {
|
||||
// If the disc declares a CPS unit that's not in the KEYDB
|
||||
// entry's unit_keys, path 5 must NOT half-decrypt the disc.
|
||||
// The match function returns None and the resolver falls
|
||||
// through to None overall (no other paths available in this
|
||||
// setup).
|
||||
// If the disc declares a CPS unit not in the KEYDB entry's unit_keys, path
|
||||
// 5 must NOT half-decrypt: match returns None and the resolver falls
|
||||
// through to None overall (no other paths available here).
|
||||
let uk_ro = minimal_unit_key_ro();
|
||||
let hash = disc_hash(&uk_ro);
|
||||
let hash_hex = disc_hash_hex(&hash).to_lowercase();
|
||||
@@ -1230,13 +1254,9 @@ mod tests {
|
||||
);
|
||||
// This disc's inf (its hash will NOT be in keydb).
|
||||
let uk_ro = minimal_unit_key_ro();
|
||||
// The sibling's MK is lifted directly into the MK pool: a KeyDb
|
||||
// aggregated per-disc media_keys into media_keys(), but SuppliedKey
|
||||
// does NOT harvest its disc_entry's media_key — it has an explicit
|
||||
// media_keys field. `disc_entry: None` preserves the miss on this
|
||||
// disc's own hash/VID (the sibling matches neither), so ONLY the
|
||||
// MK-pool brute (km_verifies) can resolve it — exactly the path under
|
||||
// test.
|
||||
// The sibling's MK is lifted into the MK pool via SuppliedKey's explicit
|
||||
// media_keys field; `disc_entry: None` keeps this disc's own hash/VID a
|
||||
// miss, so ONLY the MK-pool brute (km_verifies) can resolve it.
|
||||
let keydb = SuppliedKey {
|
||||
device_keys: Vec::new(),
|
||||
processing_keys: Vec::new(),
|
||||
@@ -1263,6 +1283,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
|
||||
@@ -1286,9 +1362,7 @@ mod tests {
|
||||
data[1] = 0x01;
|
||||
assert!(!parse_content_cert(&data).unwrap().bus_encryption);
|
||||
}
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Hardening additions
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// ════════════════ Hardening additions ════════════════
|
||||
// ── VUK derivation: spec relation VUK = AES-D(MK, VID) XOR VID ─────────
|
||||
#[test]
|
||||
fn derive_vuk_matches_spec_relation_explicitly() {
|
||||
@@ -1346,11 +1420,9 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn stride_v10_is_48_v20_is_64_and_picks_distinct_keys() {
|
||||
// AACS 1.0 stride = 48, AACS 2.0/2.1 stride = 64 (aacs/inf.rs).
|
||||
// Lay keys at 64-byte stride. Parsing at V20 stride must pick exactly
|
||||
// those keys; parsing the SAME bytes at V10 (48) stride would read the
|
||||
// wrong (intermediate) bytes for key 2 onward — proving the stride
|
||||
// selector matters.
|
||||
// AACS 1.0 stride = 48, 2.0/2.1 = 64. Keys laid at 64-byte stride: V20
|
||||
// parsing must pick exactly those keys, while V10 (48) parsing the SAME
|
||||
// bytes would read wrong intermediate bytes for key 2 onward.
|
||||
let data = build_unit_key_ro(2, 64);
|
||||
let v20 = parse_unit_key_ro(&data, AacsVersion::V20).unwrap();
|
||||
assert_eq!(v20.encrypted_keys.len(), 2);
|
||||
@@ -1412,11 +1484,9 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn parse_unit_key_ro_rejects_when_keys_run_off_end() {
|
||||
// Finding #5: 3 keys declared but the buffer holds only 2 strides plus
|
||||
// 8 trailing bytes (not a full 3rd 16-byte key). The extraction loop
|
||||
// breaks at the buffer end (never reading OOB), and the post-loop
|
||||
// length check rejects the short list with None — a truncated/malformed
|
||||
// .inf must NOT be silently accepted with fewer keys than declared.
|
||||
// Finding #5: 3 keys declared but buffer holds only 2 strides + 8 spare
|
||||
// bytes. Extraction loop breaks at buffer end (never OOB), and the
|
||||
// post-loop length check rejects the short list rather than accepting fewer.
|
||||
let uk_pos = 0x60usize;
|
||||
let stride = 48usize;
|
||||
// Room for keys at uk_pos+48 and uk_pos+48+48, then only 8 spare bytes
|
||||
@@ -1458,10 +1528,9 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn parse_unit_key_ro_title_cps_mapping_first_play_top_menu_then_titles() {
|
||||
// [20..22] first_play, [22..24] top_menu, [24..26] num_titles, then
|
||||
// per-title 2-byte pad + 2-byte CPS unit at 26 + i*4 + 2. Each on-disc
|
||||
// 1-based CPS number in `1..=num_uk` is validated and converted to a
|
||||
// 0-based key index (per the AACS Unit_Key_RO format); an out-of-range number → 0.
|
||||
// [20..22] first_play, [22..24] top_menu, [24..26] num_titles, then per-title
|
||||
// 2-byte pad + 2-byte CPS unit at 26 + i*4 + 2. Each on-disc 1-based CPS
|
||||
// number in `1..=num_uk` converts to a 0-based key index; out-of-range → 0.
|
||||
let mut data = build_unit_key_ro(4, 64); // num_uk = 4 → CPS 1..=4 valid
|
||||
data[20..22].copy_from_slice(&1u16.to_be_bytes()); // first_play CPS 1
|
||||
data[22..24].copy_from_slice(&2u16.to_be_bytes()); // top_menu CPS 2
|
||||
@@ -1785,6 +1854,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.
|
||||
@@ -1854,17 +1994,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn derive_media_key_and_pk_from_dk_returns_intermediate_pk() {
|
||||
// Regression: a classical DK boil must yield the intermediate
|
||||
// Processing Key, not just the Media Key. The key service banks the
|
||||
// PK lineage (DK·PK·MK·VUK·UK); before the `_and_pk_` form existed it
|
||||
// recovered the MK here but lost the PK silently.
|
||||
//
|
||||
// Build a minimal classical MKB (no 0x82/0x83) with:
|
||||
// - 0x04 Subset-Difference: u_mask_shift=3, uv=0x00000002
|
||||
// - 0x05 cvalues: one cvalue C planted so AES-D(Kp, C) XOR uv == mk
|
||||
// - 0x86 Verify Media Key: mk_dv = AES-E(mk, magic || pad)
|
||||
// and a DK with node=4, uv=2, u_mask_shift=3 so dev_key_v_mask ==
|
||||
// v_mask: the calc_pk_from_dk loop is a no-op and Kp == aesg3(dk, 1).
|
||||
// Regression: a classical DK boil must yield the intermediate PK too
|
||||
// (key service banks DK·PK·MK·VUK·UK; before `_and_pk_` it lost the PK).
|
||||
// Builds a minimal MKB with a DK whose calc_pk_from_dk walk is a no-op.
|
||||
use super::super::crypto::aes_ecb_encrypt as enc;
|
||||
|
||||
let dk_bytes: [u8; 16] = [
|
||||
@@ -1934,12 +2066,8 @@ mod tests {
|
||||
}
|
||||
|
||||
// ── resolve_keys_with_reason / classify_resolve_failure ────────────────
|
||||
//
|
||||
// The rc.6 E7017/E7022 split is also exercised end-to-end through the
|
||||
// `ensure_decryptable` gate in `disc/mod.rs`. These tests pin the
|
||||
// *classifier* directly at the aacs::resolve seam and cover the branches the
|
||||
// gate test does not: VID-present (must never be VidUnavailable), the
|
||||
// processing-keys-only material path, and the version dispatch / Ok path.
|
||||
// Also exercised end-to-end via `ensure_decryptable` in `disc/mod.rs`; these
|
||||
// pin the *classifier* directly, covering branches that gate test doesn't.
|
||||
|
||||
/// A `SuppliedKey` provider with the given derivation material and no
|
||||
/// disc-keyed entry. Mirrors the construction the gate test uses, lifted to
|
||||
|
||||
+220
-71
@@ -1,61 +1,55 @@
|
||||
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
|
||||
//!
|
||||
//! An FMTS main feature interleaves N "variant" segments — the sequence-key /
|
||||
//! forensic-watermark mechanism. The same frames are authored as several
|
||||
//! slightly different variants; each variant is encrypted under its own SEGMENT
|
||||
//! key (from `SegmentKeyNNNNN.tbl`), NOT the CPS Unit Key. A player with the
|
||||
//! right device keys can decrypt exactly one variant per segment, and which one
|
||||
//! silently identifies the player (traitor tracing). Decrypting a variant
|
||||
//! segment with the Unit Key yields garbage — broken HEVC reference frames
|
||||
//! (empirically: `Could not find ref with POC …` on a plain unit-key rip).
|
||||
//! An FMTS main feature interleaves short forensic **segments** — the sequence-key
|
||||
//! / forensic-watermark mechanism. Each segment carries an **index** (1..32): a
|
||||
//! tag in `IndividualSegment.tbl` that selects which of the 32 forensic **index
|
||||
//! keys** decrypts that segment's units, in place of the ordinary CPS Unit Key.
|
||||
//!
|
||||
//! This table says WHERE the variant segments live so a decoder can decrypt
|
||||
//! them with segment keys and select one coherent variant instead of muxing
|
||||
//! Terminology (see the project AACS reference): the **index** here is NOT the
|
||||
//! AACS 2.1 *Media Key Variant* — that is the 65536-value device selector in the
|
||||
//! MKB that decides *which set* of index keys a device receives, a layer this
|
||||
//! module does not deal with. All the index keys belong to one variant, whose
|
||||
//! number is unknown and irrelevant to the segment map. Decrypting a segment with
|
||||
//! the Unit Key yields garbage — broken HEVC reference frames (empirically:
|
||||
//! `Could not find ref with POC …` on a plain unit-key rip).
|
||||
//!
|
||||
//! This table says WHERE the segments live and which index each carries, so a
|
||||
//! decoder can decrypt them with the matching index key instead of muxing
|
||||
//! unit-key garbage.
|
||||
//!
|
||||
//! Format (validated against a retail AACS 2.1 disc):
|
||||
//! ```text
|
||||
//! header (8 bytes): u32 type | u16 count | u16 record_size (= 16)
|
||||
//! record[count] (16 bytes each):
|
||||
//! u32 marker (= 0x01000000) | u16 variant | u16 flag (= 1)
|
||||
//! u32 marker (= 0x01000000) | u16 index | u16 flag (= 1)
|
||||
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
|
||||
//! ```
|
||||
//! `variant` is the 1..32 forensic-variant tag, NOT a sequential segment id:
|
||||
//! measured on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across
|
||||
//! records in file order — 24 full cycles of 32 plus a final partial cycle of
|
||||
//! 24 = 792 records. Source-packet numbers are the 192-byte BDAV packet index:
|
||||
//! byte offset = `spn * 192`. Each segment is ~2560 packets (~480 KB), spread
|
||||
//! across the entire 54 GB feature (one roughly every 67 MB).
|
||||
//! `index` is the 1..32 forensic index tag, NOT a sequential segment id: measured
|
||||
//! on a retail 2.1 disc it cycles 1,2,…,32,1,2,… across records in
|
||||
//! file order — 24 full cycles of 32 plus a final partial cycle of 24 = 792
|
||||
//! records. Source-packet numbers are the 192-byte BDAV packet index: byte offset
|
||||
//! = `spn * 192`. Each segment is ~2560 packets (~480 KB) = 80 aligned units,
|
||||
//! spread across the entire 54 GB feature (one roughly every 67 MB). Inside a
|
||||
//! segment the 80 units interleave in two stride-2 halves: applying the segment's
|
||||
//! index key decrypts ~40 of them to clean TS and garbles the other ~40 (a second
|
||||
//! interleaved half, unidentified), which the demux then drops — leaving one
|
||||
//! coherent stream. Confirmed by decoding a retail disc with a full set of 32
|
||||
//! index keys.
|
||||
|
||||
/// Fixed size of one `IndividualSegment.tbl` record.
|
||||
pub const SEGMENT_RECORD_LEN: usize = 16;
|
||||
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
|
||||
pub const SOURCE_PACKET_LEN: u64 = 192;
|
||||
|
||||
/// Whether a 2.1 (FMTS) disc may rip WITHOUT segment (variant) keys.
|
||||
///
|
||||
/// `true` (today): the forensic variant segments are skipped as expected loss
|
||||
/// and the bulk of the title decodes with the unit key, so a 2.1 disc rips
|
||||
/// mostly-complete. A unit key (VUK) is still required, exactly as for any AACS
|
||||
/// disc. `false`: the absence of a segment-key source is a hard, UPFRONT failure
|
||||
/// ([`Error::FmtsKeyMissing`]) — the same policy as a missing unit key, so a
|
||||
/// forensic-holed rip is refused rather than produced. No segment-key source
|
||||
/// exists yet, so `true` is the only value under which a 2.1 disc rips at all;
|
||||
/// flip to `false` once segment keys can be sourced and a partial rip should be
|
||||
/// refused. Hardcoded on purpose — not a user setting.
|
||||
///
|
||||
/// [`Error::FmtsKeyMissing`]: crate::error::Error::FmtsKeyMissing
|
||||
pub const BYPASS_FMTS_KEY: bool = true;
|
||||
|
||||
/// One forensic variant segment: the inclusive source-packet range it occupies
|
||||
/// in the FMTS clip.
|
||||
/// One forensic segment: the inclusive source-packet range it occupies in the
|
||||
/// FMTS clip.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Segment {
|
||||
/// Forensic variant tag, 1..=32 (field@4 of the record). Cycles across the
|
||||
/// table rather than counting up — it selects WHICH variant this range is,
|
||||
/// which is what a variant-keyed decode routes on. (`0` is not used here;
|
||||
/// the default/non-forensic content carries no segment record at all.)
|
||||
pub variant: u16,
|
||||
/// Forensic index tag, 1..=32 (field@4 of the record). Cycles across the
|
||||
/// table rather than counting up — it selects WHICH of the 32 index keys
|
||||
/// decrypts this range. (`0` is not used here; the default/non-forensic
|
||||
/// content carries no segment record at all.)
|
||||
pub index: u16,
|
||||
/// First source packet of the segment (inclusive).
|
||||
pub start_spn: u32,
|
||||
/// Last source packet of the segment (inclusive).
|
||||
@@ -109,8 +103,8 @@ pub fn lba_byte_offset(lba: u32) -> u64 {
|
||||
/// unit's clip-relative byte offset.
|
||||
///
|
||||
/// This is the routing decision behind a 2.1 decrypt-miss: a unit that
|
||||
/// overlaps a forensic segment must be opened with that segment's **variant
|
||||
/// key** (from `SegmentKeyNNNNN.tbl`), not the CPS Unit Key. Opening it with
|
||||
/// overlaps a forensic segment must be opened with that segment's **index key**
|
||||
/// (selected by the segment's `index`), not the CPS Unit Key. Opening it with
|
||||
/// the Unit Key is exactly what yields the broken-reference-frame garbage a
|
||||
/// plain unit-key rip produces. A unit outside every segment is ordinary
|
||||
/// content and a miss on it is a Unit-Key miss, so this returns `None` and the
|
||||
@@ -118,16 +112,16 @@ pub fn lba_byte_offset(lba: u32) -> u64 {
|
||||
///
|
||||
/// The unit is tested as a packet *span* (`[off/192, (off+6144-1)/192]`) so a
|
||||
/// unit that only partly overlaps a segment edge is still classified as
|
||||
/// variant; on the observed disc segments are unit-aligned, but the span test
|
||||
/// forensic; on the observed disc segments are unit-aligned, but the span test
|
||||
/// does not rely on that.
|
||||
pub fn variant_segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> {
|
||||
pub fn segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> {
|
||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
|
||||
let first = (unit_offset / SOURCE_PACKET_LEN) as u32;
|
||||
let last = ((unit_offset + unit_len - 1) / SOURCE_PACKET_LEN) as u32;
|
||||
segments.iter().find(|s| s.overlaps_spn(first, last))
|
||||
}
|
||||
|
||||
/// Parse `IndividualSegment.tbl` into its forensic variant segments, in table
|
||||
/// Parse `IndividualSegment.tbl` into its forensic segments, in table
|
||||
/// order. Returns `None` when the header is malformed, the record size is not
|
||||
/// [`SEGMENT_RECORD_LEN`], or the declared record count overruns the buffer —
|
||||
/// so a truncated / foreign table degrades to "no segment map" rather than
|
||||
@@ -147,12 +141,12 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
||||
let mut segments = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let o = 8 + i * record_size;
|
||||
// o+4..o+8 = variant (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
|
||||
let variant = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
|
||||
// o+4..o+8 = index (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
|
||||
let index = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
|
||||
let start_spn = u32::from_be_bytes([tbl[o + 8], tbl[o + 9], tbl[o + 10], tbl[o + 11]]);
|
||||
let end_spn = u32::from_be_bytes([tbl[o + 12], tbl[o + 13], tbl[o + 14], tbl[o + 15]]);
|
||||
segments.push(Segment {
|
||||
variant,
|
||||
index,
|
||||
start_spn,
|
||||
end_spn,
|
||||
});
|
||||
@@ -160,12 +154,77 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
||||
Some(segments)
|
||||
}
|
||||
|
||||
/// Map a clip-relative byte offset to the absolute LBA that holds it, by walking
|
||||
/// the title's extents (the `.fmts` clip's sectors in file order). Segment
|
||||
/// offsets in [`Segment`] are clip-relative source-packet numbers, so this is how
|
||||
/// a segment's `spn` range becomes disc LBAs. `None` if the offset is past the
|
||||
/// clip.
|
||||
pub fn clip_byte_to_lba(extents: &[crate::disc::Extent], clip_byte: u64) -> Option<u32> {
|
||||
let mut cum = 0u64;
|
||||
for e in extents {
|
||||
let len = e.sector_count as u64 * crate::consts::SECTOR_BYTES as u64;
|
||||
if clip_byte < cum + len {
|
||||
let sector_in_ext = ((clip_byte - cum) / crate::consts::SECTOR_BYTES as u64) as u32;
|
||||
return Some(e.start_lba.saturating_add(sector_in_ext));
|
||||
}
|
||||
cum += len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build the `[start_lba, end_lba) → key_idx` ranges for an FMTS forensic key map.
|
||||
///
|
||||
/// Each forensic segment's clip-relative source-packet span becomes an absolute
|
||||
/// LBA range tagged with the key its `index` selects (via `index_to_key_idx`,
|
||||
/// e.g. `|i| i as usize` when the pool is `[base, idx1, idx2, …]`). Applying that
|
||||
/// one key across the whole segment decodes the ~40 units of its interleave half
|
||||
/// to clean TS and garbles the other ~40 (the second interleaved half), which the
|
||||
/// demux then drops — yielding one coherent stream. Ranges outside every segment
|
||||
/// are left for the map's default (the ordinary Unit Key). A segment that straddles
|
||||
/// a UDF extent boundary is skipped entirely, falling back to the Unit Key rather
|
||||
/// than emitting a wrong span.
|
||||
///
|
||||
/// The result feeds [`AacsKeyMap::from_ranges`](crate::decrypt::AacsKeyMap::from_ranges)
|
||||
/// with the Unit-Key index as the default — the same structure the CPS map uses,
|
||||
/// only finer-grained.
|
||||
pub fn fmts_key_ranges(
|
||||
segments: &[Segment],
|
||||
extents: &[crate::disc::Extent],
|
||||
index_to_key_idx: &dyn Fn(u16) -> usize,
|
||||
) -> Vec<(u32, u32, usize)> {
|
||||
let mut ranges = Vec::new();
|
||||
for s in segments {
|
||||
// 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
|
||||
// Map first/last sector to LBAs. Segments (~480 KB) rarely cross the
|
||||
// GB-sized extent boundary, but if the two ends land in different
|
||||
// extents, skip rather than emit a wrong span (falls back to Unit Key).
|
||||
let (Some(a), Some(b)) = (
|
||||
clip_byte_to_lba(extents, start_byte),
|
||||
clip_byte_to_lba(extents, end_byte - 1),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if b >= a
|
||||
&& (b - a) as u64 == (end_byte - 1 - start_byte) / crate::consts::SECTOR_BYTES as u64
|
||||
{
|
||||
ranges.push((a, b + 1, index_to_key_idx(s.index)));
|
||||
}
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
|
||||
/// records. `recs` are `(variant, start_spn, end_spn)`.
|
||||
/// records. `recs` are `(index, start_spn, end_spn)`.
|
||||
fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
|
||||
@@ -181,11 +240,101 @@ mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmts_key_ranges_maps_segments_to_lba_by_index() {
|
||||
use crate::disc::Extent;
|
||||
// One big clip extent starting at LBA 1000. Clip byte B lives at
|
||||
// LBA 1000 + B/2048.
|
||||
let extents = vec![Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 1_000_000,
|
||||
}];
|
||||
// Two segments, indexes 5 and 7 (spn ranges as on a real disc).
|
||||
let segs = vec![
|
||||
Segment {
|
||||
index: 5,
|
||||
start_spn: 100,
|
||||
end_spn: 199,
|
||||
},
|
||||
Segment {
|
||||
index: 7,
|
||||
start_spn: 10_000,
|
||||
end_spn: 10_099,
|
||||
},
|
||||
];
|
||||
// Pool layout [base, idx1, idx2, …] → index N uses key slot N.
|
||||
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||
assert_eq!(ranges.len(), 2, "one LBA range per segment");
|
||||
// Segment 0: spn 100..=199 → clip bytes [19200, 38400) → sectors 9..=18
|
||||
// → LBA 1009..1019, key index 5.
|
||||
assert_eq!(ranges[0], (1009, 1019, 5));
|
||||
// Segment 1: spn 10000..=10099 → bytes [1_920_000, 1_939_200) →
|
||||
// sectors 937..=946 → LBA 1937..1947, key index 7.
|
||||
assert_eq!(ranges[1], (1937, 1947, 7));
|
||||
|
||||
// The ranges drive a positive AacsKeyMap: an LBA in no range has no key.
|
||||
let map = crate::decrypt::AacsKeyMap::from_ranges(ranges);
|
||||
assert_eq!(map.key_idx_for(500), None, "outside any segment → no key");
|
||||
assert_eq!(
|
||||
map.key_idx_for(1012),
|
||||
Some(5),
|
||||
"inside index-5 segment → key 5"
|
||||
);
|
||||
assert_eq!(
|
||||
map.key_idx_for(1940),
|
||||
Some(7),
|
||||
"inside index-7 segment → key 7"
|
||||
);
|
||||
assert_eq!(
|
||||
map.key_idx_for(1019),
|
||||
None,
|
||||
"segment end is exclusive → no key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmts_key_ranges_skips_inverted_segment_without_underflow() {
|
||||
use crate::disc::Extent;
|
||||
let extents = vec![Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 1_000_000,
|
||||
}];
|
||||
// start_spn == end_spn + 1: `end_byte - 1 - start_byte` would underflow.
|
||||
// The record must be skipped rather than panic (debug) / wrap (release).
|
||||
let segs = vec![Segment {
|
||||
index: 5,
|
||||
start_spn: 200,
|
||||
end_spn: 199,
|
||||
}];
|
||||
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||
assert!(ranges.is_empty(), "inverted segment yields no range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_byte_to_lba_walks_extents() {
|
||||
use crate::disc::Extent;
|
||||
let extents = vec![
|
||||
Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 10,
|
||||
}, // clip bytes [0, 20480)
|
||||
Extent {
|
||||
start_lba: 500,
|
||||
sector_count: 10,
|
||||
}, // clip bytes [20480, 40960)
|
||||
];
|
||||
assert_eq!(clip_byte_to_lba(&extents, 0), Some(100));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 2048), Some(101));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 20480), Some(500)); // second extent
|
||||
assert_eq!(clip_byte_to_lba(&extents, 22528), Some(501));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 40960), None); // past the clip
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_real_disc_layout() {
|
||||
// First three records observed on retail 2.1 (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
|
||||
// `variant_field_cycles_one_to_thirty_two`), segments are 2560 packets.
|
||||
// `index_field_cycles_one_to_thirty_two`), segments are 2560 packets.
|
||||
let tbl = build_tbl(&[
|
||||
(1, 343680, 346239),
|
||||
(2, 695616, 698175),
|
||||
@@ -193,9 +342,9 @@ mod tests {
|
||||
]);
|
||||
let segs = parse_individual_segments(&tbl).expect("parse");
|
||||
assert_eq!(segs.len(), 3);
|
||||
assert_eq!(segs[0].variant, 1);
|
||||
assert_eq!(segs[1].variant, 2);
|
||||
assert_eq!(segs[2].variant, 3);
|
||||
assert_eq!(segs[0].index, 1);
|
||||
assert_eq!(segs[1].index, 2);
|
||||
assert_eq!(segs[2].index, 3);
|
||||
assert_eq!(segs[0].start_spn, 343680);
|
||||
assert_eq!(segs[0].end_spn, 346239);
|
||||
assert_eq!(segs[0].packet_count(), 2560);
|
||||
@@ -234,18 +383,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_inside_segment_routes_to_variant() {
|
||||
fn unit_inside_segment_routes_to_index() {
|
||||
// A real first-record segment: packets [343680, 346239].
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
|
||||
let off = 344000u64 * SOURCE_PACKET_LEN;
|
||||
let hit = variant_segment_for_unit(&segs, off).expect("inside the segment");
|
||||
assert_eq!(hit.variant, 1);
|
||||
let hit = segment_for_unit(&segs, off).expect("inside the segment");
|
||||
assert_eq!(hit.index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_field_cycles_one_to_thirty_two() {
|
||||
// Reality on Zombieland: field@4 is the variant, cycling 1..=32 in file
|
||||
fn index_field_cycles_one_to_thirty_two() {
|
||||
// Reality on 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;
|
||||
@@ -258,9 +407,9 @@ mod tests {
|
||||
}
|
||||
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
|
||||
assert_eq!(segs.len(), 64);
|
||||
assert_eq!(segs[31].variant, 32); // end of first cycle
|
||||
assert_eq!(segs[32].variant, 1); // wraps, does not become 33
|
||||
assert!(segs.iter().all(|s| (1..=32).contains(&s.variant)));
|
||||
assert_eq!(segs[31].index, 32); // end of first cycle
|
||||
assert_eq!(segs[32].index, 1); // wraps, does not become 33
|
||||
assert!(segs.iter().all(|s| (1..=32).contains(&s.index)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -268,29 +417,29 @@ mod tests {
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit well before the segment is ordinary content → None (unit-key path).
|
||||
let off = 1000u64 * SOURCE_PACKET_LEN;
|
||||
assert!(variant_segment_for_unit(&segs, off).is_none());
|
||||
assert!(segment_for_unit(&segs, off).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_straddling_a_segment_edge_counts_as_variant() {
|
||||
fn unit_straddling_a_segment_edge_counts_as_forensic() {
|
||||
// Segment starts at packet 100. A unit that ENDS just inside it (its 32
|
||||
// packets straddle the boundary) must still route to the variant key,
|
||||
// because part of its ciphertext is variant-encrypted.
|
||||
// packets straddle the boundary) must still route to the index key,
|
||||
// because part of its ciphertext is forensic-encrypted.
|
||||
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
|
||||
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
let hit = variant_segment_for_unit(&segs, off).expect("straddles the start edge");
|
||||
assert_eq!(hit.variant, 7);
|
||||
let hit = segment_for_unit(&segs, off).expect("straddles the start edge");
|
||||
assert_eq!(hit.index, 7);
|
||||
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
|
||||
let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99]
|
||||
assert!(variant_segment_for_unit(&segs, before).is_none());
|
||||
assert!(segment_for_unit(&segs, before).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_segments_never_routes_to_variant() {
|
||||
fn no_segments_never_routes_to_index() {
|
||||
// The 1.0 / 2.0 case: no forensic map, so every miss is a unit-key miss.
|
||||
assert!(variant_segment_for_unit(&[], lba_byte_offset(0)).is_none());
|
||||
assert!(variant_segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
|
||||
assert!(segment_for_unit(&[], lba_byte_offset(0)).is_none());
|
||||
assert!(segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+239
-24
@@ -6,7 +6,7 @@
|
||||
//! owns only the crypto and these value types that flow through it.
|
||||
|
||||
/// A device key for MKB subset-difference tree processing.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceKey {
|
||||
pub key: [u8; 16],
|
||||
pub node: u16,
|
||||
@@ -15,7 +15,7 @@ pub struct DeviceKey {
|
||||
}
|
||||
|
||||
/// Host certificate + private key for AACS SCSI authentication.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct HostCert {
|
||||
/// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes.
|
||||
pub private_key: [u8; 20],
|
||||
@@ -28,22 +28,22 @@ pub struct HostCert {
|
||||
}
|
||||
|
||||
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vid(pub [u8; 16]);
|
||||
|
||||
/// Media Key (Km, 16 bytes) — the MKB-scoped key derived from device keys.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MediaKey(pub [u8; 16]);
|
||||
|
||||
/// Volume Unique Key (VUK / Kvu, 16 bytes) — derived from `MediaKey` + `Vid`,
|
||||
/// decrypts the per-disc encrypted title keys in `Unit_Key_RO.inf`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vuk(pub [u8; 16]);
|
||||
|
||||
/// Processing Key (Kp, 16 bytes) — an MKB Subset-Difference key that yields the
|
||||
/// Media Key. A leaked/precomputed PK in the keydb, or the intermediate PK a
|
||||
/// device-key walk derives at its matching SD node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProcessingKey(pub [u8; 16]);
|
||||
|
||||
/// One decrypted per-CPS-unit AACS title key.
|
||||
@@ -53,50 +53,49 @@ pub struct ProcessingKey(pub [u8; 16]);
|
||||
/// area). The CPS-unit *number* association is a higher-level concern owned by
|
||||
/// [`super::inf::parse_unit_key_ro`], which pairs each positional key with its
|
||||
/// declared CPS unit; this primitive only does the AES, so it surfaces position.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UnitKey {
|
||||
pub idx: u32,
|
||||
pub key: [u8; 16],
|
||||
/// AACS 2.1 (FMTS) forensic-variant tag.
|
||||
/// AACS 2.1 (FMTS) forensic **index** tag (see [`crate::aacs::segment`]).
|
||||
///
|
||||
/// `0` = ordinary (non-forensic) content — the value for every 1.0 / 2.0
|
||||
/// key and for the bulk of a 2.1 title. `1..=32` = a variant key that
|
||||
/// decrypts the forensic segments tagged with that same variant in
|
||||
/// `IndividualSegment.tbl`. A disc resolves to exactly one variant, so at
|
||||
/// most one non-zero value is ever in play for a given rip; the decode
|
||||
/// selects the segments matching it and drops the other variants.
|
||||
pub variant_number: u8,
|
||||
/// key and for the bulk of a 2.1 title. `1..=32` = a forensic index key that
|
||||
/// decrypts the `IndividualSegment.tbl` segments tagged with that same index.
|
||||
/// This is the per-segment index (1..32), NOT the AACS 2.1 Media Key Variant
|
||||
/// (the 65536-value device selector), which is a separate MKB-layer concern.
|
||||
pub index_number: u8,
|
||||
}
|
||||
|
||||
impl UnitKey {
|
||||
/// An ordinary (non-forensic) unit key: `variant_number == 0`. The value
|
||||
/// An ordinary (non-forensic) unit key: `index_number == 0`. The value
|
||||
/// for every AACS 1.0 / 2.0 key and the bulk of a 2.1 title.
|
||||
pub const fn new(idx: u32, key: [u8; 16]) -> Self {
|
||||
Self {
|
||||
idx,
|
||||
key,
|
||||
variant_number: 0,
|
||||
index_number: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A forensic-variant key: `variant_number` in `1..=32`, decrypting the
|
||||
/// `IndividualSegment.tbl` segments tagged with that variant.
|
||||
pub const fn variant(idx: u32, key: [u8; 16], variant_number: u8) -> Self {
|
||||
/// A forensic index key: `index_number` in `1..=32`, decrypting the
|
||||
/// `IndividualSegment.tbl` segments tagged with that index.
|
||||
pub const fn forensic(idx: u32, key: [u8; 16], index_number: u8) -> Self {
|
||||
Self {
|
||||
idx,
|
||||
key,
|
||||
variant_number,
|
||||
index_number,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this key decrypts ordinary (non-forensic) content.
|
||||
pub const fn is_default_variant(&self) -> bool {
|
||||
self.variant_number == 0
|
||||
/// Whether this key decrypts ordinary (non-forensic) content (index 0).
|
||||
pub const fn is_default_index(&self) -> bool {
|
||||
self.index_number == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-disc entry from the key database.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct DiscEntry {
|
||||
/// Disc hash (20 bytes, hex)
|
||||
pub disc_hash: String,
|
||||
@@ -111,3 +110,219 @@ pub struct DiscEntry {
|
||||
/// Unit keys (title keys) indexed by CPS unit number
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
|
||||
// ── Redacting `Debug` impls: types above carry AACS secrets; derived `Debug`
|
||||
// would leak key bytes via `debug!`/panics. These print only non-secret shape,
|
||||
// kept (not omitted) since used in `assert_eq!`. See `redaction_tests`.
|
||||
|
||||
impl std::fmt::Debug for DeviceKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
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 byte 0xD5 = decimal 213: a derived `Debug` prints `[u8;N]` as
|
||||
// decimal, so a leak surfaces "213" (no non-secret field is 213). Each type
|
||||
// must also carry a "redacted" marker so re-adding `#[derive(Debug)]` fails.
|
||||
const S: u8 = 0xD5;
|
||||
|
||||
fn assert_redacted(what: &str, dbg: &str) {
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"{what}: Debug leaked key bytes (found decimal 213): {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"{what}: Debug missing redaction marker: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_key_debug_is_redacted() {
|
||||
let d = DeviceKey {
|
||||
key: [S; 16],
|
||||
node: 1,
|
||||
uv: 2,
|
||||
u_mask_shift: 3,
|
||||
};
|
||||
assert_redacted("DeviceKey", &format!("{d:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_cert_debug_is_redacted() {
|
||||
let h = HostCert {
|
||||
private_key: [S; 20],
|
||||
certificate: vec![0u8; 92],
|
||||
private_key_v2: Some([S; 32]),
|
||||
certificate_v2: None,
|
||||
};
|
||||
assert_redacted("HostCert", &format!("{h:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newtype_keys_debug_is_redacted() {
|
||||
assert_redacted("Vid", &format!("{:?}", Vid([S; 16])));
|
||||
assert_redacted("MediaKey", &format!("{:?}", MediaKey([S; 16])));
|
||||
assert_redacted("Vuk", &format!("{:?}", Vuk([S; 16])));
|
||||
assert_redacted("ProcessingKey", &format!("{:?}", ProcessingKey([S; 16])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_key_debug_is_redacted() {
|
||||
assert_redacted("UnitKey", &format!("{:?}", UnitKey::new(0, [S; 16])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_entry_debug_is_redacted() {
|
||||
let e = DiscEntry {
|
||||
disc_hash: "0xAA".into(),
|
||||
title: "T".into(),
|
||||
media_key: Some([S; 16]),
|
||||
disc_id: Some([S; 16]),
|
||||
vuk: Some([S; 16]),
|
||||
unit_keys: vec![(1, [S; 16])],
|
||||
};
|
||||
assert_redacted("DiscEntry", &format!("{e:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
+980
-73
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,162 @@
|
||||
//! 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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_title_count_over_the_cap() {
|
||||
let mut d = build(hdmv_obj(0), bdj_obj(), &[bdj_obj()]);
|
||||
// num_titles (u16) is at 48 (indexes_start) + 4 (index_len) + 12 + 12 = 76.
|
||||
// Overwrite it with MAX_TITLES + 1: the cap must reject before any record
|
||||
// read, so an attacker-huge count can't drive a 4097-entry parse/alloc.
|
||||
d[76..78].copy_from_slice(&((MAX_TITLES as u16) + 1).to_be_bytes());
|
||||
assert!(
|
||||
parse(&d).is_none(),
|
||||
"title count over MAX_TITLES must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! 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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_object_count_over_the_cap() {
|
||||
// A real (non-truncated) buffer declaring MAX_OBJECTS + 1 empty objects:
|
||||
// if the cap were removed, this would parse fine (nothing to truncate on),
|
||||
// so only the cap itself can reject it.
|
||||
let empty: Vec<&[[u8; 12]]> = vec![&[]; MAX_OBJECTS + 1];
|
||||
let d = build(&empty);
|
||||
assert!(
|
||||
parse(&d).is_none(),
|
||||
"object count over MAX_OBJECTS must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: no "rejects_cmd_count_over_the_cap" test — `num_cmds` is a u16
|
||||
// field (max 65535), always < MAX_CMDS, so that branch is unreachable via
|
||||
// honest bytes; a test for it would be tautological. Flagged, not faked.
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! 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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::mobj::tests::{build as build_mobj, cmd};
|
||||
use super::*;
|
||||
use crate::udf::fixture::{DirSpec, MemDisc, build_udf_skeleton, file_with, lay_dir};
|
||||
|
||||
/// Encode one 12-byte `index.bdmv` playback object: `object_type` in the
|
||||
/// top two bits of byte 0 (1 = HDMV, `id_ref` big-endian @6; 2 = BD-J).
|
||||
/// Mirrors `index.rs`'s own test builder (kept local — that one is
|
||||
/// private to `index.rs`'s test module).
|
||||
fn hdmv_obj(id_ref: u16) -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = 1 << 6;
|
||||
b[6..8].copy_from_slice(&id_ref.to_be_bytes());
|
||||
b
|
||||
}
|
||||
fn bdj_obj() -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = 2 << 6;
|
||||
b
|
||||
}
|
||||
|
||||
/// Build a minimal, valid `index.bdmv`: First-Play = HDMV object 0,
|
||||
/// Top Menu = BD-J, one (unused) title. Layout per `index::parse`.
|
||||
fn build_index(first_play: [u8; 12]) -> Vec<u8> {
|
||||
let indexes_start = 48u32;
|
||||
let mut d = vec![0u8; indexes_start as usize];
|
||||
d[0..4].copy_from_slice(b"INDX");
|
||||
d[4..8].copy_from_slice(b"0300");
|
||||
d[8..12].copy_from_slice(&indexes_start.to_be_bytes());
|
||||
d.extend_from_slice(&0u32.to_be_bytes()); // index_len (unused by parser)
|
||||
d.extend_from_slice(&first_play);
|
||||
d.extend_from_slice(&bdj_obj()); // top_menu
|
||||
d.extend_from_slice(&1u16.to_be_bytes()); // num_titles
|
||||
d.extend_from_slice(&bdj_obj()); // titles[0] (not exercised here)
|
||||
d
|
||||
}
|
||||
|
||||
/// Smoke/e2e test of the public resolver: a minimal valid
|
||||
/// `/BDMV/index.bdmv` + `/BDMV/MovieObject.bdmv` on an in-memory UDF disc,
|
||||
/// resolved end-to-end through `read_file` + `index::parse` +
|
||||
/// `mobj::parse` + `vm::resolve`. First-Play HDMV object 0 unconditionally
|
||||
/// `PlayPL`s playlist 11, which the caller marks as the feature candidate.
|
||||
#[test]
|
||||
fn resolve_feature_end_to_end_resolves_playlist() {
|
||||
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
|
||||
let play_pl_11 = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
|
||||
let mobj_bytes = build_mobj(&[&[play_pl_11]]);
|
||||
let index_bytes = build_index(hdmv_obj(0));
|
||||
|
||||
let mut disc = MemDisc::new();
|
||||
let bdmv = DirSpec {
|
||||
name: "BDMV".to_string(),
|
||||
icb_lba: 12,
|
||||
dir_data_lba: 13,
|
||||
files: vec![
|
||||
file_with("index.bdmv", 14, 500, index_bytes, true),
|
||||
file_with("MovieObject.bdmv", 15, 600, mobj_bytes, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
};
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![bdmv],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert_eq!(
|
||||
resolve_feature(&mut disc, &udf, |id| id == 11),
|
||||
Some(11),
|
||||
"First-Play's unconditional PlayPL 11 must resolve as the feature"
|
||||
);
|
||||
}
|
||||
|
||||
/// A malformed index (`index.bdmv` truncated to just its magic) must make
|
||||
/// the whole resolver abstain, not panic — proving `resolve_feature`
|
||||
/// really is wired through `index::parse`'s failure path end-to-end.
|
||||
#[test]
|
||||
fn resolve_feature_abstains_on_malformed_index() {
|
||||
let play_pl_11 = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
|
||||
let mobj_bytes = build_mobj(&[&[play_pl_11]]);
|
||||
|
||||
let mut disc = MemDisc::new();
|
||||
let bdmv = DirSpec {
|
||||
name: "BDMV".to_string(),
|
||||
icb_lba: 12,
|
||||
dir_data_lba: 13,
|
||||
files: vec![
|
||||
file_with("index.bdmv", 14, 500, b"INDX".to_vec(), true),
|
||||
file_with("MovieObject.bdmv", 15, 600, mobj_bytes, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
};
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![bdmv],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert_eq!(resolve_feature(&mut disc, &udf, |id| id == 11), None);
|
||||
}
|
||||
}
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
//! 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 `0xffff` "no object"). We don't chase Top-Menu's "Play
|
||||
// Movie" target here — it lives in menu .m2ts IG button commands (future work).
|
||||
let start = match index.first_play {
|
||||
PlaybackObj::Hdmv { id_ref } if (id_ref as usize) < mobjs.len() => id_ref as usize,
|
||||
_ => return None,
|
||||
};
|
||||
let mut vm = Vm {
|
||||
mobjs,
|
||||
index,
|
||||
gpr: [0; 4096],
|
||||
psr: psr_init(),
|
||||
};
|
||||
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() {
|
||||
// SET MOVE to PSR6 must be a no-op (PSR stores are refused by spec).
|
||||
// Program: MOVE PSR6<-5; CMP PSR6==5; PlayPL 100 else 200. Refused write
|
||||
// keeps PSR6 at 0 -> PlayPL 200; an illegal write would wrongly pick 100.
|
||||
let set_psr6 = cmd((2 << 5) | (2 << 3), 0x40, 0, 0x01, 0x8000_0006, 5);
|
||||
let cmp_psr6_eq_5 = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0x8000_0006, 5);
|
||||
let d = build(&[&[set_psr6, cmp_psr6_eq_5, play_pl(100), play_pl(200)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(
|
||||
resolve(&index, &mobjs, &|id| id == 100 || id == 200),
|
||||
Some(200),
|
||||
"PSR store must be refused, so the compare is false and 200 is reached"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_fold_and_cmp_skip_reach_playpl() {
|
||||
// SET GPR[3]=7; CMP GPR[3]==7 (true → do NOT skip); PlayPL GPR[3].
|
||||
// grp=CMP(1), op_cnt=2, imm_op2=1 (src=7 immediate), cmp_opt=EQ(2); dst=GPR3.
|
||||
let cmp_eq_reg = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 3, 0x0000_0007);
|
||||
let d = build(&[&[set_move_gpr(3, 7), cmp_eq_reg, play_pl_reg(3), play_pl(999)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
// If the compare wrongly skipped, we'd hit PlayPL 999 instead of 7.
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 7 || id == 999), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jumptitle_namespace_differs_from_jumpobject() {
|
||||
// JumpTitle 1 → titles[0] = HDMV obj 1 (which PlayPLs 42). Proves title
|
||||
// numbers resolve through the index, not as object indices.
|
||||
let d = build(&[&[jump_title(1)], &[play_pl(42)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(
|
||||
PlaybackObj::Hdmv { id_ref: 0 },
|
||||
vec![PlaybackObj::Hdmv { id_ref: 1 }],
|
||||
);
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 42), Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_exchanges_two_registers() {
|
||||
// MOVE g0=11; MOVE g1=22; SWAP g0<->g1; PlayPL GPR[0]. SWAP: grp=SET(2),
|
||||
// sub_grp=SET(0), set_opt=SWAP(2), both operands registers (imm_op*=0).
|
||||
let swap01 = cmd((2 << 5) | (2 << 3), 0x00, 0, 0x02, 0, 1);
|
||||
let d = build(&[&[
|
||||
set_move_gpr(0, 11),
|
||||
set_move_gpr(1, 22),
|
||||
swap01,
|
||||
play_pl_reg(0),
|
||||
]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm {
|
||||
mobjs: &mobjs,
|
||||
index: &index,
|
||||
gpr: [0; 4096],
|
||||
psr: psr_init(),
|
||||
};
|
||||
// After the swap GPR0 holds the old GPR1 (22) and PlayPL plays it.
|
||||
assert_eq!(run(&mut vm, 0, &|id| id == 22), Some(22));
|
||||
// Full exchange: GPR1 now holds the old GPR0 (11).
|
||||
assert_eq!(vm.gpr[1], 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_convergent_self_goto_bails_via_max_steps() {
|
||||
// An unconditional GOTO to its own line never reaches a PlayPL: the
|
||||
// MAX_STEPS guard must bail (None) rather than loop forever. If the
|
||||
// guard were removed this would hang instead of returning.
|
||||
let self_goto = cmd(1 << 5, 0x81, 0, 0, 0, 0);
|
||||
let d = build(&[&[self_goto]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_immediate_operand_is_not_written_back() {
|
||||
// SWAP dst=GPR0 with src=IMMEDIATE 5 (imm_op1=0, imm_op2=1) must refuse
|
||||
// writing to the aliased register: GPR[5] stays 77, only GPR0 gets 5.
|
||||
// Dropping the `imm_op1`/`imm_op2` guard makes this assertion fail.
|
||||
let swap_imm = cmd((2 << 5) | (2 << 3), 0x40, 0, 0x02, 0, 5);
|
||||
let d = build(&[&[set_move_gpr(5, 77), swap_imm]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm {
|
||||
mobjs: &mobjs,
|
||||
index: &index,
|
||||
gpr: [0; 4096],
|
||||
psr: psr_init(),
|
||||
};
|
||||
// No PlayPL → program runs off the end (None); inspect registers after.
|
||||
assert_eq!(run(&mut vm, 0, &|_| false), None);
|
||||
assert_eq!(
|
||||
vm.gpr[5], 77,
|
||||
"immediate operand must not be a write target"
|
||||
);
|
||||
assert_eq!(vm.gpr[0], 5, "register operand receives the immediate");
|
||||
}
|
||||
}
|
||||
+115
-862
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.
|
||||
|
||||
+1122
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
|
||||
|
||||
+930
-132
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -4,7 +4,8 @@
|
||||
//! Content Scramble System. They are mathematical constants derived from
|
||||
//! the CSS specification, published in academic literature since 1999.
|
||||
|
||||
/// Table 1: byte substitution used in key mangling and sector seed processing.
|
||||
/// Table 1: content-cipher substitution table, `plain = TAB1[cipher] ^ keystream`
|
||||
/// (used by descramble/recover in lfsr.rs and keyless.rs).
|
||||
pub const TAB1: [u8; 256] = [
|
||||
0x33, 0x73, 0x3b, 0x26, 0x63, 0x23, 0x6b, 0x76, 0x3e, 0x7e, 0x36, 0x2b, 0x6e, 0x2e, 0x66, 0x7b,
|
||||
0xd3, 0x93, 0xdb, 0x06, 0x43, 0x03, 0x4b, 0x96, 0xde, 0x9e, 0xd6, 0x0b, 0x4e, 0x0e, 0x46, 0x9b,
|
||||
@@ -88,7 +89,7 @@ pub const TAB3: [u8; 512] = [
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
];
|
||||
|
||||
/// Table 4: LFSR0 byte permutation (used in initialization and output).
|
||||
/// Table 4: permutes the LFSR0 output byte (`o0 = TAB4[o0]`).
|
||||
pub const TAB4: [u8; 256] = [
|
||||
0x00, 0x80, 0x40, 0xc0, 0x20, 0xa0, 0x60, 0xe0, 0x10, 0x90, 0x50, 0xd0, 0x30, 0xb0, 0x70, 0xf0,
|
||||
0x08, 0x88, 0x48, 0xc8, 0x28, 0xa8, 0x68, 0xe8, 0x18, 0x98, 0x58, 0xd8, 0x38, 0xb8, 0x78, 0xf8,
|
||||
@@ -111,7 +112,7 @@ pub const TAB4: [u8; 256] = [
|
||||
/// Table 5: LFSR1 output permutation used in the keystream combiner.
|
||||
/// `TAB5[i] == TAB4[i] ^ 0xFF` (bitwise complement of the TAB4 bit-reversal
|
||||
/// table). Applied on the normal descramble/recrypt path (lfsr.rs) as well as
|
||||
/// in the key-recovery fallback (crack.rs).
|
||||
/// in the key-recovery fallback ([`super::keyless`]).
|
||||
pub const TAB5: [u8; 256] = [
|
||||
0xff, 0x7f, 0xbf, 0x3f, 0xdf, 0x5f, 0x9f, 0x1f, 0xef, 0x6f, 0xaf, 0x2f, 0xcf, 0x4f, 0x8f, 0x0f,
|
||||
0xf7, 0x77, 0xb7, 0x37, 0xd7, 0x57, 0x97, 0x17, 0xe7, 0x67, 0xa7, 0x27, 0xc7, 0x47, 0x87, 0x07,
|
||||
|
||||
+1205
-846
File diff suppressed because it is too large
Load Diff
+126
-50
@@ -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,9 @@ 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`/`sample_rate_hz` used to live here as a third, HONEST copy of
|
||||
// the AudioChannels/SampleRate mappings (returning 0 for Unknown where the
|
||||
// canonical accessors fabricated 6ch/48kHz). Canonical accessors are honest now.
|
||||
|
||||
// ── DVD cell-category dump (from the IFO scan, pre-lowering) ─────────────────
|
||||
|
||||
@@ -137,9 +107,8 @@ pub fn sample_rate_hz(s: SampleRate) -> u32 {
|
||||
pub fn dvd_cell_row(idx: usize, cell: &crate::ifo::DvdCell, dropped: bool) -> String {
|
||||
let c = CellCategory::decode(cell.category);
|
||||
// Per-cell keep/skip REASON (self-sufficient bug log): a dropped cell is a
|
||||
// leading secondary angle/interleave block piece; a kept cell is either the
|
||||
// first feature cell or genuine feature content. This makes the
|
||||
// leading-cell-filter decision auditable from the log without the disc.
|
||||
// leading secondary angle/interleave piece; a kept cell is the first feature
|
||||
// cell or genuine content. Makes the leading-cell-filter decision auditable.
|
||||
let verdict = if dropped {
|
||||
"DROP(leading-secondary-block-piece)"
|
||||
} else if c.is_secondary_block_piece() {
|
||||
@@ -336,7 +305,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 +468,51 @@ 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 playlist wasn't picked (the gap behind E6008 on `00245`).
|
||||
for (ti, t) in disc.titles.iter().enumerate() {
|
||||
// Match the ACTUAL has-video gate used by `main_feature_order`
|
||||
// (`has_probable_video`, deliberately more permissive than `has_video`),
|
||||
// so the diagnostic doesn't report a title as demoted that was in fact kept.
|
||||
if !t.has_probable_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 +537,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 +617,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 +638,60 @@ 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, not "fewest clips" (the
|
||||
// drifted string described a rule the comparator does not implement).
|
||||
let many_clips_big = sized(8_000_000_000, 40);
|
||||
let one_clip_small = sized(1_000_000_000, 1);
|
||||
assert_eq!(
|
||||
Disc::canonical_title_order(&many_clips_big, &one_clip_small, 25_000_000_000),
|
||||
std::cmp::Ordering::Less,
|
||||
"largest size wins regardless of clip count"
|
||||
);
|
||||
|
||||
let reason = main_feature_reason();
|
||||
assert!(
|
||||
!reason.contains("clips"),
|
||||
"the reason must not advertise a clip-count key the comparator dropped: {reason}"
|
||||
);
|
||||
assert_eq!(
|
||||
reason,
|
||||
"main_feature_order(nav-feature, authoring-feature, standalone, has-video, fits-disc, largest-size, longest, richest-audio)",
|
||||
"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 +717,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,739 @@
|
||||
//! 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, leaving room for 234 extents (~200 GiB) before an
|
||||
// Allocation Extent Descriptor chain would be needed; 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;
|
||||
// Planner enforces `layout::MAX_CS0_NAME_BYTES` so this can't wrap in
|
||||
// practice; the assert states that invariant where it's relied on
|
||||
// instead of trusting a check three files away.
|
||||
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);
|
||||
|
||||
// Link count is 1 (own FID in parent) plus one per child dir's parent FID.
|
||||
// `layout::MAX_SUBDIRS` bounds fan-out so this can't overflow; saturating
|
||||
// guards against a future change to that cap silently miscounting.
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,961 @@
|
||||
//! Layout planner: a host directory tree in, a full block assignment out.
|
||||
//!
|
||||
//! Two phases, kept apart because they fail for different reasons:
|
||||
//!
|
||||
//! 1. **Walk** the folder into a tree of names and sizes. Rejects what the
|
||||
//! image model cannot represent (3D SSIF, an unrecognized tree, a
|
||||
//! case-collision inside one directory).
|
||||
//! 2. **Assign** blocks. Metadata first (File Set Descriptor, one File Entry
|
||||
//! per node, then the directory FID lists), then file data.
|
||||
//!
|
||||
//! Data placement is the part with real constraints. For a Blu-ray there are
|
||||
//! none — `.mpls`/`.clpi` address clips by name, never by LBA — so files are
|
||||
//! packed sequentially. For a DVD the IFOs record VOB positions as sector
|
||||
//! offsets from the IFO's own start, and `ifo.rs` re-derives every title extent
|
||||
//! from them, so the placement must reproduce those offsets exactly or the rip
|
||||
//! reads the wrong sectors. See [`place_video_ts`].
|
||||
|
||||
use super::encode::{ANCHOR_LBA, MIN_PART_START, SECTOR};
|
||||
use crate::error::{Error, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// First block any FILE DATA may occupy, absolute.
|
||||
///
|
||||
/// Well clear of the volume-space descriptors, and — the load-bearing part —
|
||||
/// far from zero: `disc/bluray.rs:137` drops any clip extent whose LBA is 0,
|
||||
/// so a file that landed at block 0 would vanish from the title with no error.
|
||||
const DATA_FLOOR: u32 = 4096;
|
||||
|
||||
/// Largest byte length a single allocation descriptor may record.
|
||||
///
|
||||
/// The AD length field is 30 bits (the top two are the ECMA-167 4/14.14.1.1
|
||||
/// extent type), so 0x3FFF_FFFF is the arithmetic ceiling — but a non-final
|
||||
/// extent must be a whole number of blocks, so the usable ceiling is the
|
||||
/// largest multiple of 2048 below it. Files larger than this are split across
|
||||
/// several ADs; `udf.rs` reads them back as a multi-extent file, which is the
|
||||
/// same shape a dual-layer disc produces.
|
||||
pub(super) const MAX_AD_BYTES: u64 = 0x3FFF_F800;
|
||||
|
||||
/// Deepest directory nesting represented. Matches `udf.rs`'s `MAX_DIR_DEPTH`:
|
||||
/// anything deeper would be recorded but never descended into, so the files
|
||||
/// under it would be invisible to every consumer. Refuse instead of silently
|
||||
/// dropping.
|
||||
const MAX_DEPTH: u32 = 8;
|
||||
|
||||
/// Upper bound on entries in the synthesized tree, mirroring `udf.rs`'s
|
||||
/// `MAX_TOTAL_DIR_ENTRIES`.
|
||||
const MAX_ENTRIES: usize = 100_000;
|
||||
|
||||
/// Longest OSTA CS0 encoding a File Identifier Descriptor can describe.
|
||||
///
|
||||
/// The FID's name-length field is one byte, so 255 is the ceiling; 254 leaves
|
||||
/// the encoder no way to produce a value that wraps to zero.
|
||||
const MAX_CS0_NAME_BYTES: usize = 254;
|
||||
|
||||
/// Most subdirectories one directory may hold.
|
||||
///
|
||||
/// A directory File Entry's link count is `u16` and counts one per child
|
||||
/// directory plus one for its own entry in the parent, so the last usable
|
||||
/// value is `u16::MAX - 1`.
|
||||
/// Lowered under `cfg(test)` ONLY so the guard can actually be executed.
|
||||
/// Building a folder with 65534 subdirectories to reach the real cap is not a
|
||||
/// test anyone can run, so the previous test asserted arithmetic about the
|
||||
/// constant instead and would have passed with the guard deleted. The
|
||||
/// production value is unchanged.
|
||||
#[cfg(not(test))]
|
||||
const MAX_SUBDIRS: usize = (u16::MAX - 1) as usize;
|
||||
#[cfg(test)]
|
||||
const MAX_SUBDIRS: usize = 4;
|
||||
|
||||
/// One entry per child plus the parent's own must still fit the 16-bit field.
|
||||
///
|
||||
/// At MODULE scope, and it has to be. This assertion previously sat inside
|
||||
/// `mod tests`, which is `#[cfg(test)]`, while carrying `#[cfg(not(test))]`
|
||||
/// itself — so it was compiled in NO configuration and could never fire, which
|
||||
/// made it exactly the dead gate the test above it was written to replace.
|
||||
#[cfg(not(test))]
|
||||
const _: () = assert!(MAX_SUBDIRS + 1 == u16::MAX as usize);
|
||||
|
||||
/// Largest image this planner will synthesize, in sectors (128 GiB).
|
||||
///
|
||||
/// A DVD title set records where its VOBS begins as an offset in its own IFO,
|
||||
/// and that offset is read verbatim out of a file in the folder. A regenerated
|
||||
/// `.BUP`, a tool that rewrote an IFO, or a hand-assembled folder can therefore
|
||||
/// name an offset far beyond the content — and the planner honours it, because
|
||||
/// honouring it is what makes a real backup readable. Without a ceiling the
|
||||
/// image grows to wherever that offset points: a `u32` sector count reaches
|
||||
/// ~8.8 TB, and writing one to an `iso://` destination would fill a disk with
|
||||
/// zeros before anything noticed.
|
||||
///
|
||||
/// 128 GiB clears the largest real medium (BD-100) with room to spare, so a
|
||||
/// genuine disc folder never meets it.
|
||||
const MAX_IMAGE_SECTORS: u32 = (128u64 * 1024 * 1024 * 1024 / SECTOR as u64) as u32;
|
||||
|
||||
/// Ceiling on the in-memory metadata region (64 MiB).
|
||||
///
|
||||
/// Every node costs a 2 KiB File Entry sector held for the life of the image,
|
||||
/// so the entry cap alone permits ~205 MB of metadata for content of no size at
|
||||
/// all — and the mux holds two of these at once while probing. The module
|
||||
/// documents a budget of "a few MiB even for a large Blu-ray"; this is what
|
||||
/// enforces it rather than merely asserting it.
|
||||
const MAX_META_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
/// The fan-out cap must bite before the global entry cap, or it never fires.
|
||||
const _: () = assert!(MAX_SUBDIRS < MAX_ENTRIES);
|
||||
|
||||
/// One contiguous run of a file's bytes at a partition-relative block.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct Extent {
|
||||
pub(super) lba: u32,
|
||||
pub(super) bytes: u32,
|
||||
}
|
||||
|
||||
/// A planned file: where its bytes come from on the host, and where they live
|
||||
/// in the synthesized image.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct FileNode {
|
||||
pub(super) name: String,
|
||||
/// Disc path (`/BDMV/STREAM/00000.m2ts`) — used only for error reporting.
|
||||
pub(super) disc_path: String,
|
||||
pub(super) host: PathBuf,
|
||||
pub(super) size: u64,
|
||||
/// Host mtime at PLAN time, when the platform reports one.
|
||||
///
|
||||
/// Size alone is content-blind, and the plan depends on CONTENT: a DVD's
|
||||
/// VOB placement is derived from bytes 0xC0/0xC4 of its IFO (`read_head`).
|
||||
/// A re-authoring tool rewriting an IFO in place keeps the length — IFOs
|
||||
/// are a whole number of sectors — so a size check passes while the
|
||||
/// placement the image was built around is stale, and every cell of the
|
||||
/// title then resolves to the wrong sectors at exit 0.
|
||||
pub(super) mtime: Option<std::time::SystemTime>,
|
||||
pub(super) icb_lba: u32,
|
||||
pub(super) unique_id: u64,
|
||||
pub(super) extents: Vec<Extent>,
|
||||
}
|
||||
|
||||
/// A planned directory.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct DirNode {
|
||||
pub(super) name: String,
|
||||
pub(super) icb_lba: u32,
|
||||
pub(super) parent_icb_lba: u32,
|
||||
pub(super) data_lba: u32,
|
||||
pub(super) data_bytes: u32,
|
||||
pub(super) unique_id: u64,
|
||||
pub(super) dirs: Vec<DirNode>,
|
||||
pub(super) files: Vec<FileNode>,
|
||||
}
|
||||
|
||||
/// A complete image plan.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Layout {
|
||||
pub(super) part_start: u32,
|
||||
pub(super) part_sectors: u32,
|
||||
pub(super) total_sectors: u32,
|
||||
pub(super) volume_id: String,
|
||||
pub(super) file_count: u32,
|
||||
pub(super) dir_count: u32,
|
||||
pub(super) next_unique_id: u64,
|
||||
pub(super) root: DirNode,
|
||||
}
|
||||
|
||||
/// Serialized length of one File Identifier Descriptor, before its 4-byte
|
||||
/// alignment padding. Shared with the encoder so the planner's directory size
|
||||
/// and the encoder's output cannot drift apart.
|
||||
fn fid_len(name: &str, is_parent: bool) -> usize {
|
||||
let l_fi = if is_parent {
|
||||
0
|
||||
} else {
|
||||
crate::dirimage::encode::encode_cs0(name).len()
|
||||
};
|
||||
(38 + l_fi).div_ceil(4) * 4
|
||||
}
|
||||
|
||||
/// Total FID bytes a directory's data extent must hold.
|
||||
fn dir_bytes(dirs: &[DirNode], files: &[FileNode]) -> usize {
|
||||
let mut n = fid_len("", true);
|
||||
for d in dirs {
|
||||
n += fid_len(&d.name, false);
|
||||
}
|
||||
for f in files {
|
||||
n += fid_len(&f.name, false);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
// ── Phase 1: walk ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Whether a host directory entry belongs in the synthesized image.
|
||||
///
|
||||
/// Dot-files are host artefacts, never disc content: macOS sprays `.DS_Store`
|
||||
/// and `._*` resource forks through any folder a Finder window has touched,
|
||||
/// and including them would put files on the "disc" that were never on the
|
||||
/// disc. `.partial` is freemkv's own in-flight extraction suffix (see
|
||||
/// `disc/extract.rs`) — picking one up would mean planning an extent over a
|
||||
/// file that is still being written.
|
||||
fn is_excluded(name: &str) -> bool {
|
||||
name.starts_with('.') || name.ends_with(".partial")
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result<DirNode> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(Error::DirImageTooLarge);
|
||||
}
|
||||
let mut dirs = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
|
||||
let mut read: Vec<_> = std::fs::read_dir(dir)
|
||||
.map_err(Error::from)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Error::from)?;
|
||||
// Deterministic order: the same folder must always produce the same image
|
||||
// (readdir order is filesystem- and even mount-dependent).
|
||||
read.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in read {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if is_excluded(&name) {
|
||||
continue;
|
||||
}
|
||||
// `file_type()` does NOT follow symlinks; `metadata()` does. A symlink to
|
||||
// a file is materialized as that file; a symlink to a directory is
|
||||
// skipped — following one can loop, and UDF has no link this maps onto.
|
||||
let ft = entry.file_type().map_err(Error::from)?;
|
||||
let child_path = format!("{}/{}", disc_path.trim_end_matches('/'), name);
|
||||
// A FID records the encoded name length in ONE byte; a longer name would
|
||||
// wrap it and desynchronise the directory, so refuse while planning. 255
|
||||
// ASCII bytes (legal on ext4/APFS/NTFS) already exceeds it with CS0 added.
|
||||
if crate::dirimage::encode::encode_cs0(&name).len() > MAX_CS0_NAME_BYTES {
|
||||
return Err(Error::DirNameTooLong { path: child_path });
|
||||
}
|
||||
*entries += 1;
|
||||
if *entries > MAX_ENTRIES {
|
||||
return Err(Error::DirImageTooLarge);
|
||||
}
|
||||
// Key uniqueness on the name AS THE READER WILL SEE IT (round-trip through
|
||||
// the same encoder/parser it uses): `parse_udf_name` trims/drops chars, so
|
||||
// distinct hosts (" A.M2TS" vs "A.M2TS") can collapse and `find` mismatches.
|
||||
let as_read = crate::udf::parse_udf_name(&crate::dirimage::encode::encode_cs0(&name));
|
||||
// Nothing left after the reader drops it (e.g. a name of a single emoji):
|
||||
// it would exist in the image but be addressable by nothing. SKIP rather
|
||||
// than fail the plan (1.6.0 handled such extras fine); not pushed to `names`.
|
||||
if as_read.is_empty() {
|
||||
tracing::warn!(
|
||||
target: "freemkv::dirimage",
|
||||
path = %child_path,
|
||||
"name is unrepresentable in UDF after encoding; entry omitted from the image"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
names.push(as_read.to_ascii_uppercase());
|
||||
if ft.is_dir() {
|
||||
// Link count (child dirs + 1) is stored in 16 bits; the global entry
|
||||
// cap alone permits a single directory holding more than that, which
|
||||
// would wrap the count.
|
||||
if dirs.len() >= MAX_SUBDIRS {
|
||||
return Err(Error::DirImageFanout {
|
||||
path: disc_path.to_string(),
|
||||
});
|
||||
}
|
||||
dirs.push(walk(&entry.path(), &child_path, depth + 1, entries)?);
|
||||
} else {
|
||||
let meta = match std::fs::metadata(entry.path()) {
|
||||
Ok(m) => m,
|
||||
// A broken symlink or a file gone between readdir and stat: skip it
|
||||
// rather than plan an unreadable extent. Anything else (e.g. perm
|
||||
// denied) is NOT a missing file, so skipping it would drop it silently.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(e) => return Err(Error::from(e)),
|
||||
};
|
||||
if !meta.is_file() {
|
||||
// Not a plain file after following the link: commonly a SYMLINK TO A
|
||||
// DIRECTORY, silently vanishing before (clips → no extents, near-empty
|
||||
// MKV at exit 0). Still skipped, not followed, but now SAID.
|
||||
tracing::warn!(
|
||||
target: "freemkv::dirimage",
|
||||
path = %child_path,
|
||||
kind = if meta.is_dir() { "directory link" } else { "special file" },
|
||||
"entry is not a plain file; omitted from the image"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
files.push(FileNode {
|
||||
name,
|
||||
disc_path: child_path,
|
||||
host: entry.path(),
|
||||
size: meta.len(),
|
||||
mtime: meta.modified().ok(),
|
||||
icb_lba: 0,
|
||||
unique_id: 0,
|
||||
extents: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// `find_dir`/`read_file` match path components case-insensitively, so two
|
||||
// entries differing only in case are indistinguishable to every consumer —
|
||||
// the second would silently shadow the first. Only on a case-sensitive host.
|
||||
names.sort();
|
||||
for pair in names.windows(2) {
|
||||
if pair[0] == pair[1] {
|
||||
return Err(Error::DirNameCollision {
|
||||
host: format!("{disc_path}/{}", pair[0]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DirNode {
|
||||
name: dir
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
icb_lba: 0,
|
||||
parent_icb_lba: 0,
|
||||
data_lba: 0,
|
||||
data_bytes: 0,
|
||||
unique_id: 0,
|
||||
dirs,
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
/// Find a child directory by ASCII-case-insensitive name.
|
||||
fn child_dir<'a>(dir: &'a DirNode, name: &str) -> Option<&'a DirNode> {
|
||||
dir.dirs.iter().find(|d| d.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
// ── Phase 2: block assignment ───────────────────────────────────────────────
|
||||
|
||||
/// Assign metadata blocks depth-first: this node's File Entry, then its
|
||||
/// children's, then the directory data extents. Returns the next free block.
|
||||
fn assign_metadata(dir: &mut DirNode, parent_icb: u32, next: &mut u32, uid: &mut u64) {
|
||||
dir.icb_lba = *next;
|
||||
*next += 1;
|
||||
dir.parent_icb_lba = parent_icb;
|
||||
dir.unique_id = *uid;
|
||||
*uid += 1;
|
||||
for f in &mut dir.files {
|
||||
f.icb_lba = *next;
|
||||
*next += 1;
|
||||
f.unique_id = *uid;
|
||||
*uid += 1;
|
||||
}
|
||||
let icb = dir.icb_lba;
|
||||
for sub in &mut dir.dirs {
|
||||
assign_metadata(sub, icb, next, uid);
|
||||
}
|
||||
// Directory data after every File Entry of this subtree, so a directory's
|
||||
// FIDs can name ICBs that were assigned after it.
|
||||
let bytes = dir_bytes(&dir.dirs, &dir.files);
|
||||
dir.data_bytes = bytes as u32;
|
||||
dir.data_lba = *next;
|
||||
*next += bytes.div_ceil(SECTOR) as u32;
|
||||
}
|
||||
|
||||
/// Split a file into allocation descriptors and place them starting at `lba`.
|
||||
/// Returns the first free block after the file.
|
||||
fn place_file(f: &mut FileNode, lba: u32) -> Result<u32> {
|
||||
f.extents.clear();
|
||||
if f.size == 0 {
|
||||
return Ok(lba);
|
||||
}
|
||||
let mut remaining = f.size;
|
||||
let mut cur = lba;
|
||||
while remaining > 0 {
|
||||
let chunk = remaining.min(MAX_AD_BYTES);
|
||||
f.extents.push(Extent {
|
||||
lba: cur,
|
||||
bytes: chunk as u32,
|
||||
});
|
||||
let blocks = chunk.div_ceil(SECTOR as u64);
|
||||
cur = u32::try_from(cur as u64 + blocks).map_err(|_| Error::DirImageTooLarge)?;
|
||||
remaining -= chunk;
|
||||
}
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
/// Big-endian u32 at `off`, as every DVD-Video structure field is stored.
|
||||
fn be_u32(buf: &[u8], off: usize) -> Option<u32> {
|
||||
let b = buf.get(off..off + 4)?;
|
||||
Some(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
|
||||
}
|
||||
|
||||
/// Read the first `n` bytes of a host file.
|
||||
/// Read the first `n` bytes of an IFO so its placement offsets can be resolved.
|
||||
///
|
||||
/// Errors propagate. Returning an empty buffer instead would send every offset
|
||||
/// through `unwrap_or(0)`, recording NO placement constraint — and a VOB placed
|
||||
/// without its constraint yields an image that reads at the wrong offset with
|
||||
/// nothing reported. An IFO that cannot be read is a folder that cannot be
|
||||
/// planned.
|
||||
fn read_head(path: &Path, n: usize) -> Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let mut buf = vec![0u8; n];
|
||||
// `read_exact`, not `read`: a single `read` may legally return fewer bytes on
|
||||
// a network/FUSE mount (a NAS-hosted backup is normal here). A short buffer
|
||||
// records NO constraint, so the rip reads the wrong sectors at exit 0.
|
||||
let mut f = std::fs::File::open(path).map_err(Error::from)?;
|
||||
f.read_exact(&mut buf).map_err(Error::from)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Placement order and constraints for a `VIDEO_TS` folder.
|
||||
///
|
||||
/// DVD-Video records VOB positions INSIDE the IFOs, as sector offsets from the
|
||||
/// IFO file's own first sector:
|
||||
///
|
||||
/// * `VIDEO_TS.IFO` + 0xC0 (`VMGM_VOBS`) → `VIDEO_TS.VOB`
|
||||
/// * `VTS_nn_0.IFO` + 0xC0 (`vtsm_vobs`) → `VTS_nn_0.VOB` (the VTS menu)
|
||||
/// * `VTS_nn_0.IFO` + 0xC4 (`vtstt_vobs`) → `VTS_nn_1.VOB` (the title stream)
|
||||
///
|
||||
/// `ifo.rs:554-556` re-derives every title extent as
|
||||
/// `file_start_lba(IFO) + vtstt_vobs + cell.first_sector`, so only the third
|
||||
/// of those is load-bearing for freemkv itself — but the other two are
|
||||
/// load-bearing for DVD PLAYERS, which read the menus freemkv ignores. All
|
||||
/// three are honoured.
|
||||
///
|
||||
/// `VTS_nn_1.VOB … VTS_nn_9.VOB` are one logical stream split at the 1 GB
|
||||
/// file-size limit, and the cell sector addresses run continuously across the
|
||||
/// split, so they are placed back-to-back with no gap. Laying the group out in
|
||||
/// its canonical on-disc order gives exactly that for free; the constraint is
|
||||
/// then a CHECK, not a search.
|
||||
///
|
||||
/// The check can fail — a regenerated `.BUP`, a tool that rewrote an IFO, a
|
||||
/// folder assembled by hand — and when it does the required position lies
|
||||
/// below the end of the file that must precede it. There is no placement that
|
||||
/// satisfies it, so it is a typed error naming the file rather than a silent
|
||||
/// misplacement.
|
||||
fn place_video_ts(vts: &mut DirNode, start: u32) -> Result<u32> {
|
||||
let mut order: Vec<usize> = (0..vts.files.len()).collect();
|
||||
// Canonical on-disc order. Files the naming scheme does not cover (stray
|
||||
// extras) sort last and are placed unconstrained.
|
||||
order.sort_by_key(|&i| {
|
||||
let r = classify(&vts.files[i].name);
|
||||
(
|
||||
r.is_none(),
|
||||
r.map(|c| (c.group, c.order))
|
||||
.unwrap_or((u32::MAX, u32::MAX)),
|
||||
i,
|
||||
)
|
||||
});
|
||||
|
||||
// Required start blocks, resolved as each IFO is placed.
|
||||
let mut menu_req: std::collections::HashMap<u32, u32> = std::collections::HashMap::new();
|
||||
let mut title_req: std::collections::HashMap<u32, u32> = std::collections::HashMap::new();
|
||||
// Groups whose IFO has already been placed, kept separately from the
|
||||
// constraint maps: an IFO declaring no offsets inserts into neither, so
|
||||
// keying the duplicate check on those maps would miss the collision.
|
||||
let mut seen_ifo: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
|
||||
let mut cursor = start;
|
||||
for &i in &order {
|
||||
let class = classify(&vts.files[i].name);
|
||||
let required = match class.map(|c| (c.group, c.role)) {
|
||||
Some((g, Role::MenuVob)) => menu_req.get(&g).copied(),
|
||||
Some((g, Role::TitleVob)) => title_req.get(&g).copied(),
|
||||
_ => None,
|
||||
};
|
||||
let lba = match required {
|
||||
// No placement satisfies this: the VOB must begin below the end of
|
||||
// the file that precedes it. Naming the file is the whole point —
|
||||
// the alternative is an image freemkv reads at the wrong offset.
|
||||
Some(req) if req < cursor => {
|
||||
return Err(Error::DirImagePlacement {
|
||||
path: vts.files[i].disc_path.clone(),
|
||||
});
|
||||
}
|
||||
Some(req) => req,
|
||||
None => cursor,
|
||||
};
|
||||
cursor = place_file(&mut vts.files[i], lba)?;
|
||||
|
||||
// An IFO just landed: resolve the offsets it declares. Both are
|
||||
// relative to the IFO's own first sector.
|
||||
if let Some(c) = class
|
||||
&& c.role == Role::Ifo
|
||||
{
|
||||
let head = read_head(&vts.files[i].host, 0xC8)?;
|
||||
let menu = be_u32(&head, 0xC0).unwrap_or(0);
|
||||
// One group per title set: `VTS_01_0.IFO` and `VTS_1_0.IFO` parse to
|
||||
// the same group, and a second insert would overwrite the first's
|
||||
// constraint, placing a VOB where the reader's IFO doesn't point.
|
||||
if !seen_ifo.insert(c.group) {
|
||||
return Err(Error::DirNameCollision {
|
||||
host: vts.files[i].disc_path.clone(),
|
||||
});
|
||||
}
|
||||
if menu != 0 {
|
||||
menu_req.insert(c.group, lba.saturating_add(menu));
|
||||
}
|
||||
// Only a VTS IFO carries a title VOBS pointer at 0xC4; in the VMG that
|
||||
// offset is TT_SRPT (a sector offset INSIDE the IFO, not a file
|
||||
// pointer). `ifo.rs::parse_vmg` reads the same field; must not drift.
|
||||
if c.group > 0 {
|
||||
let title = be_u32(&head, 0xC4).unwrap_or(0);
|
||||
if title != 0 {
|
||||
title_req.insert(c.group, lba.saturating_add(title));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(cursor)
|
||||
}
|
||||
|
||||
/// What a DVD-Video filename is, for placement purposes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Role {
|
||||
/// `VIDEO_TS.IFO` / `VTS_nn_0.IFO` — the file the offsets are relative to.
|
||||
Ifo,
|
||||
/// `VIDEO_TS.VOB` / `VTS_nn_0.VOB` — constrained by the 0xC0 offset.
|
||||
MenuVob,
|
||||
/// `VTS_nn_1.VOB` — constrained by the 0xC4 offset. `_2 … _9` follow it
|
||||
/// with no gap by virtue of sorting immediately after.
|
||||
TitleVob,
|
||||
/// Backups and continuation VOBs: placed sequentially, no constraint.
|
||||
Sequential,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Class {
|
||||
/// 0 = the Video Manager (`VIDEO_TS.*`), n = title set n.
|
||||
group: u32,
|
||||
/// Sort key within the group.
|
||||
order: u32,
|
||||
role: Role,
|
||||
}
|
||||
|
||||
fn classify(name: &str) -> Option<Class> {
|
||||
let up = name.to_ascii_uppercase();
|
||||
if let Some(ext) = up.strip_prefix("VIDEO_TS.") {
|
||||
let (order, role) = match ext {
|
||||
"IFO" => (0, Role::Ifo),
|
||||
"VOB" => (1, Role::MenuVob),
|
||||
"BUP" => (2, Role::Sequential),
|
||||
_ => return None,
|
||||
};
|
||||
return Some(Class {
|
||||
group: 0,
|
||||
order,
|
||||
role,
|
||||
});
|
||||
}
|
||||
let rest = up.strip_prefix("VTS_")?;
|
||||
let (num, rest) = rest.split_once('_')?;
|
||||
let set: u32 = num.parse().ok()?;
|
||||
let (part, ext) = rest.split_once('.')?;
|
||||
let part: u32 = part.parse().ok()?;
|
||||
let (order, role) = match (ext, part) {
|
||||
("IFO", 0) => (0, Role::Ifo),
|
||||
("VOB", 0) => (1, Role::MenuVob),
|
||||
("VOB", 1) => (2, Role::TitleVob),
|
||||
// Checked: `n` is parsed verbatim out of a filename, so a folder may
|
||||
// legitimately contain `VTS_01_4294967295.VOB`. Wrapping would sort a
|
||||
// stray VOB ahead of its own title set's IFO.
|
||||
("VOB", n) => (n.checked_add(1)?, Role::Sequential),
|
||||
("BUP", 0) => (100, Role::Sequential),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Class {
|
||||
// Checked for the same reason: a wrap here lands on group 0, which is
|
||||
// the Video Manager's, so a stray file would contend for the VMG's
|
||||
// placement slot.
|
||||
group: set.checked_add(1)?,
|
||||
order,
|
||||
role,
|
||||
})
|
||||
}
|
||||
|
||||
/// Place every file's data, depth-first, packed with no gaps.
|
||||
fn place_generic(dir: &mut DirNode, cursor: &mut u32) -> Result<()> {
|
||||
for f in &mut dir.files {
|
||||
*cursor = place_file(f, *cursor)?;
|
||||
}
|
||||
for sub in &mut dir.dirs {
|
||||
place_generic(sub, cursor)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn count_nodes(dir: &DirNode, dirs: &mut u32, files: &mut u32) {
|
||||
*dirs += 1;
|
||||
*files += dir.files.len() as u32;
|
||||
for sub in &dir.dirs {
|
||||
count_nodes(sub, dirs, files);
|
||||
}
|
||||
}
|
||||
|
||||
/// Total blocks the metadata region needs: File Set Descriptor, its
|
||||
/// Terminating Descriptor, one File Entry per node, and each directory's FID
|
||||
/// list.
|
||||
fn metadata_blocks(dir: &DirNode) -> u64 {
|
||||
let mut n = 1 + dir.files.len() as u64;
|
||||
n += dir_bytes(&dir.dirs, &dir.files).div_ceil(SECTOR) as u64;
|
||||
for sub in &dir.dirs {
|
||||
n += metadata_blocks(sub);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Reject a Blu-ray 3D tree.
|
||||
///
|
||||
/// `disc/bluray.rs:124-130` probes `/BDMV/STREAM/SSIF/{clip}.ssif` and sets
|
||||
/// `is_3d` the moment one resolves — unconditionally, before any capability
|
||||
/// check. On a real 3D disc the `.ssif` and the base/dependent `.m2ts` files
|
||||
/// ALIAS the same sectors (the SSIF interleave IS the two m2ts streams), which
|
||||
/// this planner cannot express: it would allocate three disjoint copies, so
|
||||
/// the clip's extents and the m2ts's extents would disagree and the rip would
|
||||
/// read the wrong bytes at exit 0. Reject the folder instead.
|
||||
fn reject_ssif(root: &DirNode) -> Result<()> {
|
||||
let Some(bdmv) = child_dir(root, "BDMV") else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(stream) = child_dir(bdmv, "STREAM") else {
|
||||
return Ok(());
|
||||
};
|
||||
if child_dir(stream, "SSIF").is_some() {
|
||||
return Err(Error::DirImageSsifUnsupported);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Plan an image over `root`.
|
||||
///
|
||||
/// # Capacity, and what it changes
|
||||
///
|
||||
/// `total_sectors` becomes the `Disc`'s `capacity_sectors` / `capacity_bytes`,
|
||||
/// and `capacity_bytes` is not inert: `canonical_title_order`'s oversize gate
|
||||
/// (`disc/mod.rs:2039-2040`, body `:2209-2210`) demotes any title whose
|
||||
/// `size_bytes` exceeds it, which decides which title sorts first and therefore
|
||||
/// what `-t 1` selects.
|
||||
///
|
||||
/// The capacity reported here is **the synthesized image's own size** — what an
|
||||
/// ISO built from this folder would report — and nothing else. Specifically it
|
||||
/// is NOT padded up to a media tier (BD25/BD50/…), which was considered and
|
||||
/// does not work: a folder does not record the tier of the disc it came from,
|
||||
/// so a 22 GB folder off a BD50 would pad to BD25 and diverge anyway, and the
|
||||
/// padding would inflate any pre-sized output written from the image.
|
||||
///
|
||||
/// The consequence, stated plainly: **for a folder that is missing files the
|
||||
/// source disc had — a selective MakeMKV backup being the normal case — the
|
||||
/// capacity is smaller than the source disc's, the oversize gate is therefore
|
||||
/// stricter, and a borderline "play-all" composite title that the ISO kept can
|
||||
/// be demoted here. `-t 1` can select a different title from `dir://FOLDER`
|
||||
/// than from an `iso://` of the same disc.** For a complete folder the two
|
||||
/// agree, because the capacities agree.
|
||||
///
|
||||
/// This is chosen over the alternatives because it is the only one that is
|
||||
/// self-consistent: `dir://X` and an `iso://` built from `X` describe the same
|
||||
/// image and must answer the same way.
|
||||
pub(super) fn plan(root: &Path) -> Result<Layout> {
|
||||
let mut entries = 0usize;
|
||||
let mut tree = walk(root, "", 0, &mut entries)?;
|
||||
tree.name = String::new();
|
||||
|
||||
if child_dir(&tree, "BDMV").is_none() && child_dir(&tree, "VIDEO_TS").is_none() {
|
||||
return Err(Error::DirImageUnsupportedTree);
|
||||
}
|
||||
reject_ssif(&tree)?;
|
||||
|
||||
// Metadata: block 0 is the FSD, block 1 its Terminating Descriptor.
|
||||
let mut next = 2u32;
|
||||
let mut uid = 0u64;
|
||||
assign_metadata(&mut tree, 0, &mut next, &mut uid);
|
||||
tree.parent_icb_lba = tree.icb_lba; // root's parent FID points at itself
|
||||
|
||||
let part_start = MIN_PART_START;
|
||||
debug_assert!(part_start > ANCHOR_LBA);
|
||||
// Data starts after the metadata region AND above the floor, so nothing
|
||||
// lands at a low LBA a consumer treats as "no extent".
|
||||
let meta_end = part_start as u64 + next as u64;
|
||||
let data_start_abs = meta_end.max(DATA_FLOOR as u64);
|
||||
let mut cursor =
|
||||
u32::try_from(data_start_abs - part_start as u64).map_err(|_| Error::DirImageTooLarge)?;
|
||||
|
||||
if let Some(idx) = tree
|
||||
.dirs
|
||||
.iter()
|
||||
.position(|d| d.name.eq_ignore_ascii_case("VIDEO_TS"))
|
||||
{
|
||||
cursor = place_video_ts(&mut tree.dirs[idx], cursor)?;
|
||||
// `place_video_ts` places only the FILES directly inside VIDEO_TS (the ones
|
||||
// with IFO-relative constraints); subdirectories still need placing, or
|
||||
// their File Entry has real size but no extents and reads as nothing.
|
||||
for sub in tree.dirs[idx].dirs.iter_mut() {
|
||||
place_generic(sub, &mut cursor)?;
|
||||
}
|
||||
for f in &mut tree.files {
|
||||
cursor = place_file(f, cursor)?;
|
||||
}
|
||||
for (i, sub) in tree.dirs.iter_mut().enumerate() {
|
||||
if i != idx {
|
||||
place_generic(sub, &mut cursor)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
place_generic(&mut tree, &mut cursor)?;
|
||||
}
|
||||
|
||||
let mut dir_count = 0;
|
||||
let mut file_count = 0;
|
||||
count_nodes(&tree, &mut dir_count, &mut file_count);
|
||||
|
||||
let part_sectors = cursor;
|
||||
let total = part_start as u64 + part_sectors as u64 + 1; // + trailing anchor
|
||||
let total_sectors = u32::try_from(total).map_err(|_| Error::DirImageTooLarge)?;
|
||||
if total_sectors > MAX_IMAGE_SECTORS {
|
||||
return Err(Error::DirImageTooLarge);
|
||||
}
|
||||
// Metadata is materialized up front and held for the life of the image, so
|
||||
// its size is bounded here rather than discovered when memory runs out.
|
||||
let meta_bytes = (dir_count as u64 + file_count as u64).saturating_mul(SECTOR as u64);
|
||||
if meta_bytes > MAX_META_BYTES {
|
||||
return Err(Error::DirImageTooLarge);
|
||||
}
|
||||
|
||||
let volume_id = root
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "FREEMKV".to_string());
|
||||
// UDF volume identifiers are a 32-byte d-string: one compression byte, the
|
||||
// characters, and a trailing length byte. Trim rather than let
|
||||
// `put_dstring` cut a multi-byte character in half.
|
||||
let volume_id: String = volume_id.chars().take(30).collect();
|
||||
|
||||
Ok(Layout {
|
||||
part_start,
|
||||
part_sectors,
|
||||
total_sectors,
|
||||
volume_id,
|
||||
file_count,
|
||||
dir_count,
|
||||
next_unique_id: uid,
|
||||
root: tree,
|
||||
})
|
||||
}
|
||||
|
||||
/// Total bytes of file data the plan covers — the honest "how big is this
|
||||
/// folder" number, used for progress and for the capacity the scan sees.
|
||||
pub(super) fn total_data_bytes(dir: &DirNode) -> u64 {
|
||||
let mut n: u64 = dir.files.iter().map(|f| f.size).sum();
|
||||
for sub in &dir.dirs {
|
||||
n += total_data_bytes(sub);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Every file in the tree, depth-first, paired with its extents. The read path
|
||||
/// turns this into a sorted LBA → (file, offset) map.
|
||||
pub(super) fn flatten<'a>(dir: &'a DirNode, out: &mut Vec<&'a FileNode>) {
|
||||
for f in &dir.files {
|
||||
out.push(f);
|
||||
}
|
||||
for sub in &dir.dirs {
|
||||
flatten(sub, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata footprint in blocks, for diagnostics.
|
||||
pub(super) fn metadata_block_count(root: &DirNode) -> u64 {
|
||||
2 + metadata_blocks(root)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A file above the 30-bit AD ceiling must split into MULTIPLE
|
||||
/// descriptors, and every non-final one must be a whole number of blocks —
|
||||
/// otherwise the next extent's bytes start mid-sector and the file
|
||||
/// reassembles wrong. Kills a mutant that emits one oversized AD (whose
|
||||
/// length would collide with the extent-TYPE bits `udf.rs:642` reads).
|
||||
#[test]
|
||||
fn a_file_past_the_ad_ceiling_splits_on_a_block_boundary() {
|
||||
let mut f = FileNode {
|
||||
name: "BIG.M2TS".into(),
|
||||
disc_path: "/BIG.M2TS".into(),
|
||||
host: PathBuf::new(),
|
||||
size: MAX_AD_BYTES + 4096,
|
||||
mtime: None,
|
||||
icb_lba: 0,
|
||||
unique_id: 0,
|
||||
extents: Vec::new(),
|
||||
};
|
||||
let end = place_file(&mut f, 1000).unwrap();
|
||||
assert_eq!(f.extents.len(), 2);
|
||||
assert_eq!(f.extents[0].bytes as u64, MAX_AD_BYTES);
|
||||
assert_eq!(f.extents[0].bytes % SECTOR as u32, 0, "block multiple");
|
||||
assert!(f.extents[0].bytes <= 0x3FFF_FFFF);
|
||||
assert_eq!(f.extents[1].bytes, 4096);
|
||||
assert_eq!(
|
||||
f.extents[1].lba,
|
||||
1000 + (MAX_AD_BYTES / SECTOR as u64) as u32,
|
||||
"the second extent starts where the first ends"
|
||||
);
|
||||
assert_eq!(end, f.extents[1].lba + 2);
|
||||
assert_eq!(
|
||||
f.extents.iter().map(|e| e.bytes as u64).sum::<u64>(),
|
||||
f.size,
|
||||
"no bytes lost or invented"
|
||||
);
|
||||
}
|
||||
|
||||
/// A zero-byte file records no allocation descriptors at all and consumes
|
||||
/// no blocks.
|
||||
#[test]
|
||||
fn an_empty_file_gets_no_extents() {
|
||||
let mut f = FileNode {
|
||||
name: "EMPTY".into(),
|
||||
disc_path: "/EMPTY".into(),
|
||||
host: PathBuf::new(),
|
||||
size: 0,
|
||||
mtime: None,
|
||||
icb_lba: 0,
|
||||
unique_id: 0,
|
||||
extents: Vec::new(),
|
||||
};
|
||||
assert_eq!(place_file(&mut f, 500).unwrap(), 500);
|
||||
assert!(f.extents.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_artefacts_are_not_disc_content() {
|
||||
assert!(is_excluded(".DS_Store"));
|
||||
assert!(is_excluded("._00000.m2ts"));
|
||||
assert!(is_excluded("00000.m2ts.partial"));
|
||||
assert!(!is_excluded("00000.m2ts"));
|
||||
assert!(!is_excluded("VTS_01_1.VOB"));
|
||||
}
|
||||
|
||||
/// Two host names that the READER collapses into one must be refused by
|
||||
/// the planner, not written into the image.
|
||||
///
|
||||
/// Audit finding: the uniqueness check compared raw host names, while
|
||||
/// `parse_udf_name` trims whitespace and drops code units `char::from_u32`
|
||||
/// rejects. So " 00000.m2ts" and "00000.m2ts" were two distinct entries at
|
||||
/// plan time and ONE name at read time; `find`/`read_file` take the first
|
||||
/// match, so a title resolved to the wrong file's extents and muxed the
|
||||
/// wrong bytes at exit 0.
|
||||
///
|
||||
/// This calls `plan` on a real folder. It fails if the round-trip key is
|
||||
/// reverted to comparing host names.
|
||||
#[test]
|
||||
fn two_names_the_reader_cannot_tell_apart_are_refused() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"fmkv-shadow-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let stream = dir.join("BDMV/STREAM");
|
||||
std::fs::create_dir_all(&stream).expect("mkdir");
|
||||
std::fs::write(stream.join("00000.m2ts"), b"a").expect("write");
|
||||
// Same name to the reader: it trims the leading space.
|
||||
std::fs::write(stream.join(" 00000.m2ts"), b"b").expect("write");
|
||||
|
||||
// Precondition — if this stops holding the fixture is wrong, not the code.
|
||||
assert_eq!(
|
||||
crate::udf::parse_udf_name(&crate::dirimage::encode::encode_cs0(" 00000.m2ts")),
|
||||
crate::udf::parse_udf_name(&crate::dirimage::encode::encode_cs0("00000.m2ts")),
|
||||
"fixture: these two host names must read back identically"
|
||||
);
|
||||
|
||||
let got = plan(&dir);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(
|
||||
matches!(got, Err(Error::DirNameCollision { .. })),
|
||||
"a name the reader cannot distinguish must be refused, got {got:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A name too long for the FID's one-byte length field is refused by the
|
||||
/// PLANNER, on a real folder.
|
||||
///
|
||||
/// Audit finding: the length was narrowed with `as u8`, so a 255-byte ASCII
|
||||
/// name — POSIX NAME_MAX, legal on ext4/APFS/NTFS — encoded to 256 bytes
|
||||
/// with the CS0 compression byte and wrote a length of ZERO, making every
|
||||
/// later entry in that directory read from the wrong offset.
|
||||
///
|
||||
/// An earlier version of this test asserted arithmetic about the constants
|
||||
/// and never called `plan`, so it would have passed with the guard deleted.
|
||||
#[test]
|
||||
fn an_over_long_name_is_refused_by_the_planner() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"fmkv-longname-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(dir.join("BDMV/STREAM")).expect("mkdir");
|
||||
// 255 bytes: the exact length that used to narrow to zero.
|
||||
let name = "a".repeat(255);
|
||||
assert_eq!(
|
||||
crate::dirimage::encode::encode_cs0(&name).len(),
|
||||
256,
|
||||
"fixture: NAME_MAX encodes to 256 bytes with the compression byte"
|
||||
);
|
||||
std::fs::write(dir.join("BDMV/STREAM").join(&name), b"x").expect("write");
|
||||
|
||||
let err = plan(&dir).expect_err("the planner must refuse this folder");
|
||||
assert!(
|
||||
matches!(err, Error::DirNameTooLong { .. }),
|
||||
"expected DirNameTooLong, got {err:?}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A name at the cap is accepted, so the guard rejects only what it must.
|
||||
#[test]
|
||||
fn a_name_at_the_cap_is_accepted() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"fmkv-okname-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(dir.join("BDMV/STREAM")).expect("mkdir");
|
||||
// One byte of name shorter, so the encoding lands exactly on the cap.
|
||||
let name = "a".repeat(MAX_CS0_NAME_BYTES - 1);
|
||||
assert_eq!(
|
||||
crate::dirimage::encode::encode_cs0(&name).len(),
|
||||
MAX_CS0_NAME_BYTES
|
||||
);
|
||||
std::fs::write(dir.join("BDMV/STREAM").join(&name), b"x").expect("write");
|
||||
assert!(
|
||||
plan(&dir).is_ok(),
|
||||
"a name whose encoding equals the cap must be accepted"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The subdirectory cap must keep a directory's 16-bit link count
|
||||
/// representable. Creating 65,535 directories in a test is not reasonable,
|
||||
/// so this pins the arithmetic relationship the guard relies on — and says
|
||||
/// plainly that it does NOT exercise `walk`.
|
||||
#[test]
|
||||
fn the_subdir_cap_refuses_a_folder_with_too_many_subdirectories() {
|
||||
// Executes the guard rather than restating the constant: link count (child
|
||||
// dirs + 1) is 16 bits, so exceeding it wraps and the image lies about
|
||||
// its own directory structure.
|
||||
let dir = std::env::temp_dir().join(format!("fmkv-fanout-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for i in 0..=MAX_SUBDIRS {
|
||||
std::fs::create_dir_all(dir.join(format!("d{i}"))).unwrap();
|
||||
}
|
||||
|
||||
let err = plan(&dir).expect_err("more subdirectories than the link count can represent");
|
||||
assert!(
|
||||
matches!(err, Error::DirImageFanout { .. }),
|
||||
"expected DirImageFanout, got {err:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
//! `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 plan-time mtime ONLY for files whose CONTENT the plan read (DVD
|
||||
// IFOs, whose 0xC0/0xC4 bytes place every VOB); others depend only on
|
||||
// SIZE, and comparing mtime there false-positives on exFAT/FAT32 + DST.
|
||||
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 VOB placement depends on
|
||||
// IFO bytes 0xC0/0xC4 — an IFO rewritten in place keeps its sector-aligned
|
||||
// length, so size alone would miss it. mtime only compared if both present.
|
||||
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 (unlike the ISO source's
|
||||
// accumulate-and-drop, which relies on one linear cursor — reads here
|
||||
// jump between files, so an accumulated count would leave bytes pinned).
|
||||
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 (8192
|
||||
// sectors) almost always lands in one extent, so per-sector seek+read
|
||||
// would cost 8192 syscalls for what is one 16 MiB sequential read.
|
||||
let mut i = 0u32;
|
||||
while i < count as u32 {
|
||||
// Checked: callers saturate their LBAs, so a crafted IFO can present a
|
||||
// request at the top of the address space. Wrapping here would fold
|
||||
// `at` to a LOW sector, handing the muxer a different file's bytes.
|
||||
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
+1283
-185
File diff suppressed because it is too large
Load Diff
+382
-104
@@ -7,14 +7,54 @@ 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> {
|
||||
let dvd_info = match ifo::parse_vmg(reader, udf_fs) {
|
||||
Ok(info) => info,
|
||||
Err(_) => return Vec::new(),
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> Result<(Vec<DiscTitle>, Option<u16>)> {
|
||||
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
// Read VIDEO_TS.IFO once and reuse for both the VMG parse and the nav
|
||||
// resolver below; otherwise the VTS reads in parse_vmg evict the single
|
||||
// sector-cache window, forcing a duplicate physical read of the same file.
|
||||
let vmg_bytes = match udf_fs.read_file(reader, "/VIDEO_TS/VIDEO_TS.IFO") {
|
||||
Ok(bytes) => bytes,
|
||||
Err(Error::Halted) => return Err(Error::Halted),
|
||||
Err(_) => return Ok((Vec::new(), None)),
|
||||
};
|
||||
let dvd_info = match ifo::parse_vmg_with(reader, udf_fs, Some(&vmg_bytes)) {
|
||||
Ok(info) => info,
|
||||
Err(Error::Halted) => return Err(Error::Halted),
|
||||
Err(_) => return Ok((Vec::new(), None)),
|
||||
};
|
||||
|
||||
// Follow the disc's First-Play navigation like a player would, to find the
|
||||
// nav-chosen feature title (issue #40). Read-only; any failure/non-convergence
|
||||
// yields `None`, so this only re-ranks, never removes, a title.
|
||||
let nav_target = crate::dvdnav::resolve_main_title(reader, udf_fs, Some(&vmg_bytes));
|
||||
let mut nav_feature: Option<u16> = None;
|
||||
|
||||
let mut titles = Vec::new();
|
||||
let mut title_number: u16 = 0;
|
||||
@@ -39,50 +79,27 @@ impl Disc {
|
||||
crate::ifo::TvSystem::Pal => ColorSpace::Bt470bg,
|
||||
crate::ifo::TvSystem::Ntsc => ColorSpace::Smpte170m,
|
||||
},
|
||||
// DVD pixels are anamorphic 720x480/576; the real display shape
|
||||
// is the IFO aspect flag, not the pixel grid. Carry it so the
|
||||
// MKV muxer writes a correct 16:9 / 4:3 DisplayWidth/Height
|
||||
// instead of the square-pixel 3:2 / 5:4 it would otherwise emit.
|
||||
// DVD pixels are anamorphic 720x480/576; real display shape comes from the
|
||||
// IFO aspect flag, not the pixel grid. Carry it so the MKV muxer emits correct
|
||||
// 16:9/4:3 DisplayWidth/Height instead of the wrong square-pixel 3:2/5:4.
|
||||
display_aspect: Some(match ts.video.aspect {
|
||||
crate::ifo::DvdAspect::R16x9 => (16, 9),
|
||||
crate::ifo::DvdAspect::R4x3 => (4, 3),
|
||||
}),
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
// TODO(spec): populate from the MPEG-2 picture coding extension
|
||||
// once the codec parser surfaces it. `Mpeg2Parser` already reads
|
||||
// `top_field_first` (mux/codec/mpeg2.rs `picture_nb_fields`) but
|
||||
// tracks are built from the IFO scan BEFORE any frame is parsed;
|
||||
// wiring it requires a CodecParser accessor surfaced through
|
||||
// PipelinedPesStream/DiscStream into the output title (mirroring
|
||||
// the existing `codec_private` handshake). Until then `None`
|
||||
// means the muxer falls back to TFF (correct for ~all DVDs).
|
||||
// TODO(spec): DVD MPEG-2 carries no VUI; the colour signalling is
|
||||
// the sequence_display_extension colour_description when present.
|
||||
// Surface it from `Mpeg2Parser` (same handshake as above) and set
|
||||
// this so a disc that states e.g. BT.601-625 colour overrides the
|
||||
// PAL/NTSC guess in `color_space`. `None` uses the enum fallback.
|
||||
// TODO(spec): populate top_field_first + measured colour from Mpeg2Parser once
|
||||
// tracks are built after frame parsing -- needs a CodecParser->title channel
|
||||
// like `codec_private`. Until wired, `None` falls back to TFF / PAL-NTSC guess.
|
||||
measured_cicp: None,
|
||||
});
|
||||
// TODO(spec): DefaultDuration is derived from the declared IFO
|
||||
// frame_rate (25 / 29.97). A soft-telecined 23.976-in-29.97 DVD then
|
||||
// reports 29.97 fps instead of the true 23.976 film rate. The pulldown
|
||||
// cadence is detectable from the parser's per-picture `nb_fields`
|
||||
// (repeat_first_field) — when most frames are 3-field-then-2-field
|
||||
// 2:3 pulldown the film rate is frame_rate × 4/5. Emitting the film
|
||||
// DefaultDuration needs the parser to report the measured cadence
|
||||
// through the same parser→title channel as `top_field_first` above;
|
||||
// left as a follow-up to avoid a speculative rate change here.
|
||||
// TODO(spec): DefaultDuration comes from IFO frame_rate, so a soft-telecined
|
||||
// 23.976-in-29.97 DVD reports 29.97 instead of true film rate. Cadence is
|
||||
// detectable via nb_fields (2:3 pulldown); needs same channel as top_field_first above.
|
||||
|
||||
// Map DvdAudioAttr to Stream::Audio. The PID is derived from the
|
||||
// stream's REAL on-wire private_stream_1 sub-stream id (assigned
|
||||
// by per-codec ordinal in the IFO scan) via the same
|
||||
// `dvd_audio_pid` table the demuxer's `PsPacket::dvd_pid` uses,
|
||||
// so a mixed-codec title (AC-3 + DTS + LPCM) routes correctly
|
||||
// instead of colliding on 0xBD00. Streams carried as a regular
|
||||
// MPEG-audio PES (MP1/MP2, no sub-id) fall back to a distinct
|
||||
// 0xBD00+ordinal PID — disjoint from the 0xBD80+ canonical audio
|
||||
// space — though they are not routed via `dvd_pid` today.
|
||||
// Map DvdAudioAttr to Stream::Audio. PID derives from the on-wire private_stream_1
|
||||
// sub-id via the same `dvd_audio_pid` table the demuxer uses, so mixed-codec
|
||||
// titles route correctly instead of colliding; MP1/MP2 (no sub-id) use 0xBD00+ordinal.
|
||||
let audio_streams: Vec<Stream> = ts
|
||||
.audio_streams
|
||||
.iter()
|
||||
@@ -109,24 +126,24 @@ impl Disc {
|
||||
for (vts_title_idx, dvd_title) in ts.titles.iter().enumerate() {
|
||||
title_number += 1;
|
||||
|
||||
// Match the nav target by VTS number + the REAL vts_title_num, not
|
||||
// by position (a dropped sibling PGC or non-monotonic TT_SRPT
|
||||
// desyncs position from vts_ttn); remember its playlist for ranking.
|
||||
if let Some(rt) = nav_target
|
||||
&& rt.vtsn == ts.vts_number
|
||||
&& rt.vts_ttn == dvd_title.vts_title_num
|
||||
{
|
||||
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.
|
||||
crate::diag::dump_dvd_cells(ts.vts_number, title_number, dvd_title);
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// nav resolver + verified VM decoder (`dvdnav`) are kept compiled
|
||||
// but deliberately bypassed; flip `USE_NAV_RESOLVER` to re-enable
|
||||
// once the nav executor is finished. The fallback is the
|
||||
// structural leading-cell filter (`feature_start_cell`), which
|
||||
// drops leading scene-index / interleaved-angle sub-block cells
|
||||
// and is a no-op for a normal feature (category 0x00 on cell 0).
|
||||
// See `dvdnav::resolve_feature_start`.
|
||||
// Feature-start nav resolution is PARKED (#40): the "menu at start" symptom
|
||||
// was actually a sector-mapping fault in `ifo::parse_vts`'s VOB rebase, not
|
||||
// navigation. Bypassed via `USE_NAV_RESOLVER = false`; fallback is the filter.
|
||||
const USE_NAV_RESOLVER: bool = false;
|
||||
let feature_start = if USE_NAV_RESOLVER {
|
||||
crate::dvdnav::resolve_feature_start(
|
||||
@@ -174,12 +191,10 @@ impl Disc {
|
||||
|
||||
let size_bytes: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
|
||||
|
||||
// Build pre-formatted VobSub `.idx` codec_data (size: + palette:
|
||||
// lines) for VobSub subtitle streams. The `size:` line carries
|
||||
// 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();
|
||||
// Build pre-formatted VobSub `.idx` codec_data. The `size:` line carries the
|
||||
// coded video frame (720x480 NTSC / 720x576 PAL) so players place/scale the
|
||||
// subpicture correctly; format_palette omits it on unresolved (0,0).
|
||||
let (vid_w, vid_h) = ts.video.resolution.pixels().unwrap_or((0, 0));
|
||||
let codec_data = dvd_title
|
||||
.palette
|
||||
.as_ref()
|
||||
@@ -211,10 +226,9 @@ impl Disc {
|
||||
streams.extend(audio_streams.iter().cloned());
|
||||
streams.extend(subtitle_streams);
|
||||
|
||||
// Chapter times are absolute from the PGC start. When leading
|
||||
// cells are dropped the muxed video shifts earlier by exactly
|
||||
// their total duration, so shift the chapter marks too (clamping
|
||||
// any that fell inside the dropped head to 0).
|
||||
// Chapter times are absolute from the PGC start. When leading cells are
|
||||
// dropped, the muxed video shifts earlier by their total duration, so shift
|
||||
// chapter marks too (clamping any that fell inside the dropped head to 0).
|
||||
let chapters: Vec<Chapter> = dvd_title
|
||||
.chapter_times
|
||||
.iter()
|
||||
@@ -240,7 +254,13 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
titles
|
||||
// Polled again AFTER the loop: a cancel raised during per-title-set IFO reads in
|
||||
// `parse_vmg` has nothing left to poll otherwise, 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,11 +270,9 @@ mod tests {
|
||||
use crate::sector::SectorSource;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// In-memory disc + minimal UDF image (single physical partition,
|
||||
// metadata_start == partition_start). Offsets cited against
|
||||
// udf.rs::read_filesystem / ECMA-167.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const PART_START: u32 = 3000;
|
||||
|
||||
@@ -525,6 +543,118 @@ 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 at PART_START+6000, VTS_02's at PART_START+7000.
|
||||
// Both ICBs sit far below, so filesystem metadata resolves fine and only the
|
||||
// second title set's CONTENT read 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 +662,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 +703,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 +731,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 +761,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 +783,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 +821,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 +881,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 +924,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 +981,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()
|
||||
@@ -866,12 +1013,9 @@ mod tests {
|
||||
fn scan_dvd_titles_mixed_audio_codecs_distinct_pids() {
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(1, 1, 1)]);
|
||||
// audio b0: coding_mode is (b0 >> 5) & 7. AC-3 = 0 → b0=0x00.
|
||||
// DTS = 6 → b0 = 6<<5 = 0xC0. b1 channels nibble = (channels-1) in bits
|
||||
// 2-0: a REAL 5.1 layout is 5 (0x05), a REAL 2.0 is 1 (0x01). The old
|
||||
// fixture used 0x10/0x50 (both decode to 1 channel) — a placeholder that
|
||||
// would pass even against code mishandling channel counts. Pin real
|
||||
// layouts: AC-3 5.1 eng, DTS 2.0 fra.
|
||||
// audio b0: coding_mode = (b0>>5)&7 (AC-3=0->0x00, DTS=6->0xC0). b1 channels nibble
|
||||
// = (channels-1) in bits 2-0 (5.1=0x05, 2.0=0x01). Old fixture used 0x10/0x50
|
||||
// (both -> 1ch), a placeholder that passed even with broken channel handling.
|
||||
let vts = build_vts(
|
||||
0,
|
||||
0x00,
|
||||
@@ -897,7 +1041,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 +1054,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).
|
||||
@@ -922,10 +1068,9 @@ mod tests {
|
||||
2,
|
||||
"DTS 2.0 nibble must decode to 2 channels"
|
||||
);
|
||||
// PIDs route via the positional sub-id table: AC-3 @ pos 0 → 0x80 →
|
||||
// 0xBD80, DTS @ pos 1 → 0x89 → 0xBD89 (the shared audio-stream number
|
||||
// in the low nibble, NOT a per-codec ordinal). Distinct AND the exact
|
||||
// canonical wire PIDs the demux routes on.
|
||||
// PIDs route via the positional sub-id table: AC-3 @ pos 0 -> 0x80 -> 0xBD80, DTS @
|
||||
// pos 1 -> 0x89 -> 0xBD89 (shared audio-stream number in the low nibble, NOT a
|
||||
// per-codec ordinal). Distinct AND the exact canonical wire PIDs the demux routes on.
|
||||
assert_eq!(audios[0].pid, 0xBD80, "AC-3 @ pos 0 → 0xBD80");
|
||||
assert_eq!(audios[1].pid, 0xBD89, "DTS @ pos 1 → 0xBD89");
|
||||
assert_ne!(audios[0].pid, audios[1].pid);
|
||||
@@ -967,7 +1112,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 +1171,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 +1186,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 +1233,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 +1245,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"
|
||||
@@ -1107,11 +1258,9 @@ mod tests {
|
||||
#[test]
|
||||
fn scan_dvd_titles_numbering_increments_per_title() {
|
||||
let mut disc = MemDisc::new();
|
||||
// Two titles in VTS 1 (title nums 1 and 2). num_pgcs must cover
|
||||
// pgc_index = vts_title - 1, so we need >=2 PGC entries; our
|
||||
// build_vts only emits 1 PGC. So the second title's PGC index (1)
|
||||
// exceeds num_pgcs (1) and is skipped. To exercise numbering we use
|
||||
// two separate VTS sets instead.
|
||||
// Two titles in VTS 1 (nums 1,2): pgc_index = vts_title-1 needs >=2 PGC entries, but
|
||||
// build_vts only emits 1, so the second title's PGC index (1) exceeds num_pgcs (1)
|
||||
// and is skipped. Use two separate VTS sets instead to exercise numbering.
|
||||
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);
|
||||
@@ -1138,7 +1287,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 +1302,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, None),
|
||||
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 +1399,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);
|
||||
@@ -1230,9 +1456,8 @@ mod tests {
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(2, 1, 1)]);
|
||||
// Cell 0: leading scene-index/angle sub-block (cat 0x90), 5s, sectors 0..9.
|
||||
// Cell 1: feature start (cat 0x00), 59s, sectors 100..199.
|
||||
// Cell 2: feature (cat 0x00), 59s, sectors 300..399.
|
||||
// Programs: prog0 → cell 1 (feature start), prog1 → cell 3.
|
||||
// Cell 1: feature start (cat 0x00), 59s, 100..199. Cell 2: feature, 59s, 300..399.
|
||||
// Programs: prog0 -> cell 1 (feature start), prog1 -> cell 3.
|
||||
let vts = build_vts_cells(
|
||||
1000,
|
||||
0x00,
|
||||
@@ -1260,7 +1485,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 +1511,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 +1539,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 +1549,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]);
|
||||
}
|
||||
}
|
||||
|
||||
+179
-16
@@ -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.
|
||||
@@ -75,11 +75,9 @@ pub fn probe_ac3_substream_channels(ps_bytes: &[u8]) -> BTreeMap<u8, u8> {
|
||||
if !(0x80..=0x87).contains(&sub) {
|
||||
continue;
|
||||
}
|
||||
// The PS demux strips the 4-byte AC-3 sub-header but does not align to a
|
||||
// frame. Walk EVERY 0x0B77 sync in this sub-stream's payload, decode
|
||||
// each frame's channel count, and keep the largest — the sub-stream's
|
||||
// real (main-mix) channel capability. See the doc comment above for why
|
||||
// the first frame alone is unreliable.
|
||||
// The PS demux strips the AC-3 sub-header but doesn't align to a frame, so
|
||||
// walk every 0x0B77 sync in the payload and keep the largest decoded
|
||||
// channel count — the sub-stream's real main-mix capability (see doc above).
|
||||
if let Some(ch) = max_substream_channels(&p.data) {
|
||||
let slot = found.entry(sub).or_insert(0);
|
||||
*slot = (*slot).max(ch);
|
||||
@@ -104,10 +102,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.
|
||||
@@ -202,7 +200,7 @@ pub fn remap_audio_pids(streams: &mut [Stream], probed: &BTreeMap<u8, u8>) -> us
|
||||
///
|
||||
/// `reader` MUST yield PLAINTEXT VOB bytes (i.e. a `DecryptingSectorSource` on a
|
||||
/// CSS disc) — probing scrambled sectors yields no AC-3 syncs and is a safe
|
||||
/// no-op. Returns the number of audio streams whose PID changed.
|
||||
/// no-op. Remaps `title`'s AC-3 streams in place; returns nothing.
|
||||
pub fn probe_and_remap<S: SectorSource + ?Sized>(
|
||||
reader: &mut S,
|
||||
title: &mut crate::disc::DiscTitle,
|
||||
@@ -242,7 +240,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 +345,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 +465,165 @@ 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 real frame's (unchecked) CRC bytes, which double as byte4/5
|
||||
// of the bogus header at offset 4: 0xC0 (fscod=3 -> ac3_frame_size == 0,
|
||||
// unmappable) and 0xF8 (bsid=31 -> acmod_channels == None, no spurious count).
|
||||
real[2] = 0xC0;
|
||||
real[3] = 0xF8;
|
||||
let mut data = vec![0xAA, 0xAA, 0xAA, 0xAA]; // offsets 0..4, no sync
|
||||
data.push(0x0B); // offset 4: bogus header sync byte 0
|
||||
data.push(0x77); // offset 5: bogus header sync byte 1
|
||||
data.extend(real); // offset 6..: the real frame (also serves as the
|
||||
// bogus header's byte4/byte5 at offsets 8/9)
|
||||
assert_eq!(
|
||||
max_substream_channels(&data),
|
||||
Some(2),
|
||||
"must recover the real frame 2 bytes after the unmappable-size sync, not lose it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Same fallback 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+162
-150
@@ -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:
|
||||
@@ -90,11 +102,9 @@ impl AacsCertUnlocker<'_> {
|
||||
.ok()
|
||||
.and_then(|m| aacs::mkb::mkb_version(&m));
|
||||
|
||||
// Host certs are keysource-served, never compiled in — unioned from the
|
||||
// explicit `DriveCredentials` and the key-source layer. With ZERO certs
|
||||
// the cert route cannot run: NoHostCert (folded to AacsNoHostCert by the
|
||||
// caller, preserving the graceful path-1 disc-hash → VUK fallback). This
|
||||
// is detected here, where the MKB generation is still in hand.
|
||||
// Host certs are keysource-served, never compiled in — unioned from
|
||||
// `DriveCredentials` and the key-source layer. With ZERO certs, NoHostCert
|
||||
// (folded to AacsNoHostCert) preserves the graceful disc-hash → VUK fallback.
|
||||
let host_certs = Disc::collect_host_certs(self.opts, mkb_gen);
|
||||
if host_certs.is_empty() {
|
||||
tracing::warn!(
|
||||
@@ -105,10 +115,9 @@ impl AacsCertUnlocker<'_> {
|
||||
return Err(CertUnlockFailure::NoHostCert { mkb: mkb_gen });
|
||||
}
|
||||
|
||||
// Hand the collected certs to the AACS unlocker. The cert-route bus
|
||||
// removal depends on the read_data_key, NOT a drive unlock. The
|
||||
// borrow checker can't split `session` across `scsi_mut()` + `&drive_id`
|
||||
// through method calls, so clone the (cheap) identity first.
|
||||
// Hand the collected certs to the AACS unlocker; the cert-route bus removal
|
||||
// depends on read_data_key, NOT a drive unlock. Borrow checker can't split
|
||||
// `session` across `scsi_mut()` + `&drive_id`, so clone the cheap identity.
|
||||
let drive_id = session.drive_id.clone();
|
||||
let fu_certs = crate::unlock_bridge::map_host_certs(&host_certs);
|
||||
let (_, unlock_res) = crate::unlock_bridge::run_bus(
|
||||
@@ -169,6 +178,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.
|
||||
@@ -221,35 +248,13 @@ impl Disc {
|
||||
/// read_data_key for bus decryption must still use the cert path, so an
|
||||
/// unlocker with no OEM VID capability returns `None` and we fall through
|
||||
/// to cert auth unchanged.
|
||||
/// Collect every AACS host cert the caller carries, from BOTH the explicit
|
||||
/// [`DriveCredentials`] and the key-source layer
|
||||
/// ([`crate::KeySource::host_certs`] across each source), unioned. Host certs
|
||||
/// are keysource-served, never compiled in; this is the one place the OEM
|
||||
/// cert route gathers them. An empty result is the graceful no-cert signal
|
||||
/// (the caller turns it into [`Error::AacsNoHostCert`]).
|
||||
/// `mkb` is the disc's MKB generation when known, forwarded to each source's
|
||||
/// [`crate::KeySource::host_certs`] so a source MAY return only
|
||||
/// generation-appropriate certs (the default ignores it).
|
||||
fn collect_host_certs(
|
||||
opts: &ScanOptions,
|
||||
mkb: Option<u32>,
|
||||
) -> Vec<crate::aacs::types::HostCert> {
|
||||
// Delegates to the shared cert primitive (the external freemkv-unlock-aacs
|
||||
// plugin uses the same one). Kept as a thin Disc method so the existing
|
||||
// collect_host_certs_* unit tests and call sites are unchanged.
|
||||
crate::aacs::host_certs::collect_host_certs(opts, mkb)
|
||||
}
|
||||
|
||||
fn do_handshake_cert(
|
||||
session: &mut crate::drive::Drive,
|
||||
opts: &ScanOptions,
|
||||
) -> (Option<HandshakeResult>, Option<Error>) {
|
||||
// OEM VID shortcut: a matching unlocker stashed the disc's Volume ID at
|
||||
// drive `init()` (the new `unlock()` folds in the old `read_volume_id`).
|
||||
// Use it and SKIP the cert handshake — the OEM path
|
||||
// decouples the VID from the host cert + HRL. It yields no
|
||||
// `read_data_key`; a bus-encrypted disc that needs the bus key is caught
|
||||
// by the bus-key gate in `resolve_vid_only`.
|
||||
// drive `init()`; use it and SKIP the cert handshake. It yields no
|
||||
// `read_data_key`, caught by the bus-key gate in `resolve_vid_only`.
|
||||
if let Some(volume_id) = session.oem_vid() {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
@@ -277,12 +282,9 @@ impl Disc {
|
||||
"No drive-unlocker Volume ID; running the in-tree AACS host-certificate handshake (AacsCertUnlocker)."
|
||||
);
|
||||
|
||||
// Cert path: the in-tree `AacsCertUnlocker` peer absorbs the host-cert
|
||||
// mutual-auth. It collects host certs from the key sources + credentials,
|
||||
// runs `aacs_authenticate` per cert (wedge-guarded), and on success reads
|
||||
// the VID + read_data_key. Its `UnlockError` is folded back to the same
|
||||
// `Error` variants this function has always surfaced, so `scan_with`'s
|
||||
// error rendering and the path-1 disc-hash → VUK fallback are unchanged.
|
||||
// Cert path: `AacsCertUnlocker` collects host certs, runs
|
||||
// `aacs_authenticate` per cert, and on success reads VID + read_data_key.
|
||||
// Its `UnlockError` folds back to this function's usual `Error` variants.
|
||||
let unlocker = AacsCertUnlocker { opts };
|
||||
match unlocker.authenticate(session) {
|
||||
Ok(hs) => (Some(hs), None),
|
||||
@@ -298,6 +300,25 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect every AACS host cert the caller carries, from BOTH the explicit
|
||||
/// [`DriveCredentials`] and the key-source layer
|
||||
/// ([`crate::KeySource::host_certs`] across each source), unioned. Host certs
|
||||
/// are keysource-served, never compiled in; this is the one place the OEM
|
||||
/// cert route gathers them. An empty result is the graceful no-cert signal
|
||||
/// (the caller turns it into [`Error::AacsNoHostCert`]).
|
||||
/// `mkb` is the disc's MKB generation when known, forwarded to each source's
|
||||
/// [`crate::KeySource::host_certs`] so a source MAY return only
|
||||
/// generation-appropriate certs (the default ignores it).
|
||||
fn collect_host_certs(
|
||||
opts: &ScanOptions,
|
||||
mkb: Option<u32>,
|
||||
) -> Vec<crate::aacs::types::HostCert> {
|
||||
// Delegates to the shared cert primitive (the external freemkv-unlock-aacs
|
||||
// plugin uses the same one). Kept as a thin Disc method so the existing
|
||||
// collect_host_certs_* unit tests and call sites are unchanged.
|
||||
crate::aacs::host_certs::collect_host_certs(opts, mkb)
|
||||
}
|
||||
|
||||
/// Build a keys-free AACS state that carries only the Volume ID (+ version
|
||||
/// metadata), for callers that resolve Unit Keys out-of-band and have
|
||||
/// disabled the local keydb. The VID is on-disc content read during the
|
||||
@@ -312,47 +333,33 @@ 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
|
||||
// stride on the main resolve path fails loudly (sample validation) rather
|
||||
// than silently, so the conservative V20 default is safe here too.
|
||||
// No-cert default = UHD (V20 stride), matching `read_aacs_version` so
|
||||
// scanned/out-of-band versions agree; the main path fails loudly on a
|
||||
// wrong stride, so this conservative default is safe here too.
|
||||
let version = cc
|
||||
.as_ref()
|
||||
.map(|c| c.version.major())
|
||||
.unwrap_or(aacs::mkb::AACS_MAJOR_UHD);
|
||||
|
||||
// Bus-encryption gate (wrong-keys guard). A bus-encrypted disc (Content
|
||||
// Certificate bus-encryption bit set) carries bus encryption on its
|
||||
// sectors, which MUST be removed before any AACS key can decrypt them.
|
||||
// There are TWO ways it gets removed, and bus encryption is unremovable
|
||||
// only when NEITHER succeeded:
|
||||
// 1. An unlocker unlocked the drive → it serves CLEAR content
|
||||
// (`drive_unlocked`). This is the common live-drive case and yields
|
||||
// no `read_data_key` — it doesn't need one.
|
||||
// 2. The AACS host-certificate cert-auth handshake produced the bus key
|
||||
// (`read_data_key`).
|
||||
// The old gate credited ONLY (2), so a SUCCESSFUL drive unlock (VID
|
||||
// present, `read_data_key: None`, `drive_unlocked: true`) tripped it and
|
||||
// blocked ALL key resolution — including the online source — even though
|
||||
// the drive was serving clear content. That was the bug.
|
||||
//
|
||||
// Also skipped when `handshake = None` (file-backed/ISO scans — bus
|
||||
// encryption already removed at read time) and when `bus_encryption` is
|
||||
// false (AACS 1.0 BD is not bus-encrypted).
|
||||
// ONE question — "is AACS bus encryption gone?" — asked of the single
|
||||
// `bus_encryption_removed` predicate, which OWNS every case (never had it,
|
||||
// file/ISO, drive unlock, cert bus key). The gate enumerates nothing.
|
||||
// Bus-encryption gate: removed by a drive unlock (`drive_unlocked`) or a
|
||||
// cert handshake `read_data_key`. The old gate credited only the latter,
|
||||
// wrongly blocking key resolution after a successful drive unlock.
|
||||
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",
|
||||
@@ -365,20 +372,9 @@ impl Disc {
|
||||
);
|
||||
return Err(Error::AacsBusKeyUnavailable);
|
||||
}
|
||||
// Read the MKB record stream via the SAME bounded reader the
|
||||
// out-of-band `read_aacs_inputs` uses (`read_mkb_content`: a prefix-grow
|
||||
// read + trim), NOT a full `read_file`. MKB_RO/RW is allocated to a
|
||||
// fixed ~128 MiB of zero padding, and a full `read_file` of it FAILS on
|
||||
// file-backed / large readers — which left `a.mkb` empty here, silently
|
||||
// breaking online key resolution: `Disc::inputs()` shipped `mkb=0` to
|
||||
// the decode service and it 404'd, while autorip's separate
|
||||
// `read_aacs_inputs` path (this same helper) worked. One reader now, so
|
||||
// `Disc::inputs()` is the single complete source of AACS inputs.
|
||||
// A read ERROR is surfaced (logged), not silently emptied: an empty MKB
|
||||
// here is invisible until an online key service rejects the request, so
|
||||
// a transient I/O hiccup must not masquerade as "no MKB". We still
|
||||
// continue with an empty MKB (disc-hash-keyed keydb lookups don't need
|
||||
// it), but the cause is now on the log.
|
||||
// Use the SAME bounded reader as `read_aacs_inputs` (`read_mkb_content`),
|
||||
// NOT `read_file` (fails on the ~128 MiB zero-padded MKB_RO/RW, silently
|
||||
// emptying `a.mkb`). A read ERROR is logged; we still continue with an empty MKB.
|
||||
let mkb_bytes = match Self::read_mkb_content(reader, udf_fs) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
@@ -428,11 +424,29 @@ mod tests {
|
||||
use crate::sector::SectorSource;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// In-memory disc + minimal UDF image with a single physical
|
||||
// partition (metadata_start == partition_start). Offsets cited
|
||||
// against udf.rs::read_filesystem / ECMA-167.
|
||||
// ---------------------------------------------------------------
|
||||
/// `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), per udf.rs::read_filesystem / ECMA-167.
|
||||
|
||||
const PART_START: u32 = 4000;
|
||||
|
||||
@@ -800,12 +814,9 @@ mod tests {
|
||||
assert_eq!(st.read_data_key, Some(rdk));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// OEM bus-key gate: a bus-encrypted disc scanned on a LIVE drive
|
||||
// (handshake present) with no read_data_key must HARD-ERROR
|
||||
// (AacsBusKeyUnavailable) rather than silently yield garbage. The
|
||||
// three non-regressing cases must still succeed.
|
||||
// ---------------------------------------------------------------
|
||||
// OEM bus-key gate: a bus-encrypted disc on a LIVE drive with no
|
||||
// read_data_key must HARD-ERROR (AacsBusKeyUnavailable); three
|
||||
// non-regressing cases must still succeed.
|
||||
|
||||
fn disc_with_cert(cert_type: u8, bus_encryption: bool) -> (MemDisc, udf::UdfFs) {
|
||||
let mut disc = MemDisc::new();
|
||||
@@ -909,60 +920,64 @@ mod tests {
|
||||
assert_eq!(st.read_data_key, None);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// `handshake_has_volume_id` treats an all-zero Volume ID as absent and
|
||||
/// any non-zero Volume ID as present.
|
||||
#[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");
|
||||
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"
|
||||
);
|
||||
|
||||
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"
|
||||
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"
|
||||
);
|
||||
assert_eq!(st.uk_ro, uk);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Tests: read_vid_oem (response parsing). The OEM path issues a
|
||||
// READ_BUFFER CDB and parses a 36-byte response; we can't easily
|
||||
// fixture a real Drive, but the response-shape contract (3-byte
|
||||
// signature 00 22 00, VID at [4..20]) is documented and worth a
|
||||
// direct guard via a fake transport. Skipped here because Drive
|
||||
// construction requires a live transport; the parsing branches are
|
||||
// exercised through `read_vid_oem`'s callers in integration.
|
||||
// ---------------------------------------------------------------
|
||||
/// 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));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Tests: collect_host_certs — the OEM cert route's cert-gathering.
|
||||
// Unions DriveCredentials with the key-source layer; empty means
|
||||
// the route fails gracefully (AacsNoHostCert), never panics.
|
||||
// ---------------------------------------------------------------
|
||||
// Tests: read_vid_oem (response parsing) are skipped here since Drive
|
||||
// construction requires a live transport; parsing branches are
|
||||
// exercised through `read_vid_oem`'s callers in integration.
|
||||
|
||||
// Tests: collect_host_certs — unions DriveCredentials with the key-source
|
||||
// layer; empty means the route fails gracefully (AacsNoHostCert).
|
||||
|
||||
fn fake_cert(tag: u8) -> aacs::types::HostCert {
|
||||
aacs::types::HostCert {
|
||||
@@ -976,7 +991,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>> {
|
||||
@@ -1040,11 +1055,8 @@ mod tests {
|
||||
assert_eq!(tags, vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// AacsCertUnlocker outcome mapping: UnlockError → Error (preserving
|
||||
// the legacy do_handshake_cert surface) and → UnlockOutcome (the
|
||||
// structured trace step). No English in either.
|
||||
// ---------------------------------------------------------------
|
||||
// AacsCertUnlocker outcome mapping: UnlockError → Error (legacy surface)
|
||||
// and → UnlockOutcome (structured trace step). No English in either.
|
||||
|
||||
#[test]
|
||||
fn unlock_error_maps_to_legacy_error_variants() {
|
||||
|
||||
+930
-143
File diff suppressed because it is too large
Load Diff
+2251
-122
File diff suppressed because it is too large
Load Diff
-1670
File diff suppressed because it is too large
Load Diff
+4157
-3009
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,559 @@
|
||||
//! 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>,
|
||||
/// Whether the source disc is encrypted (AACS or CSS). A clear disc and a
|
||||
/// disc whose key resolution FAILED would otherwise serialize identically;
|
||||
/// this and [`Self::key_error`] disambiguate them.
|
||||
#[serde(default)]
|
||||
pub encrypted: bool,
|
||||
/// Numeric code of the key-resolution failure, or `None` when keys resolved
|
||||
/// (or the disc is unencrypted): the `aacs_error` code if present, else the
|
||||
/// `css_error` code. Numeric per the project's no-English error convention
|
||||
/// (see [`crate::error::Error::code`]).
|
||||
#[serde(default)]
|
||||
pub key_error: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
encrypted: disc.encrypted,
|
||||
// AACS takes precedence over CSS: a disc is one format or the other,
|
||||
// and `from_disc` mirrors the same aacs-then-css order callers use.
|
||||
key_error: disc
|
||||
.aacs_error
|
||||
.as_ref()
|
||||
.or(disc.css_error.as_ref())
|
||||
.map(|e| u32::from(e.code())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, expect_subs) in [(bdts_title(), 2), (dvd_title(), 1)] {
|
||||
let p = TitleProfile::from_title(&title, 0, true);
|
||||
assert_eq!(p.video.len(), 1, "one video track");
|
||||
assert_eq!(p.audio.len(), 2, "two audio tracks");
|
||||
assert_eq!(p.subtitles.len(), expect_subs, "subtitle count per format");
|
||||
// Accessors return the same vectors.
|
||||
assert_eq!(p.video(), p.video.as_slice());
|
||||
assert_eq!(p.audio(), p.audio.as_slice());
|
||||
assert_eq!(p.subtitles(), p.subtitles.as_slice());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hoists_default_flags_first_non_secondary() {
|
||||
let p = TitleProfile::from_title(&bdts_title(), 0, true);
|
||||
// First (non-secondary) video/audio is the default.
|
||||
assert!(p.video[0].default, "first video defaults");
|
||||
assert!(p.audio[0].default, "first non-secondary audio defaults");
|
||||
// The commentary track is secondary → never default.
|
||||
assert!(
|
||||
!p.audio[1].default,
|
||||
"secondary/commentary audio not default"
|
||||
);
|
||||
// Subtitles are never default.
|
||||
assert!(p.subtitles.iter().all(|s| !s.default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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 encryption_status_and_key_error_surface_numerically() {
|
||||
// Encrypted disc whose key resolution FAILED: the encrypted flag is set
|
||||
// and the numeric aacs_error code surfaces (never English text), so it
|
||||
// no longer serializes identically to a rippable disc.
|
||||
let mut disc = test_disc();
|
||||
disc.encrypted = true;
|
||||
let err = crate::error::Error::AacsVidUnavailable;
|
||||
let aacs_code = u32::from(err.code());
|
||||
disc.aacs_error = Some(err);
|
||||
let p = disc.profile();
|
||||
assert!(p.encrypted);
|
||||
assert_eq!(p.key_error, Some(aacs_code));
|
||||
|
||||
// AACS takes precedence: with BOTH errors set, the aacs code wins.
|
||||
let mut both = test_disc();
|
||||
both.encrypted = true;
|
||||
both.aacs_error = Some(crate::error::Error::AacsVidUnavailable);
|
||||
both.css_error = Some(crate::error::Error::CssKeyMissing);
|
||||
assert_eq!(both.profile().key_error, Some(aacs_code));
|
||||
|
||||
// A CSS-only failure surfaces the css code.
|
||||
let mut css = test_disc();
|
||||
css.encrypted = true;
|
||||
let css_err = crate::error::Error::CssKeyMissing;
|
||||
let css_code = u32::from(css_err.code());
|
||||
css.css_error = Some(css_err);
|
||||
assert_eq!(css.profile().key_error, Some(css_code));
|
||||
|
||||
// A clean, rippable disc: not encrypted, no key error.
|
||||
let clean = test_disc().profile();
|
||||
assert!(!clean.encrypted);
|
||||
assert_eq!(clean.key_error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip() {
|
||||
let mut disc = test_disc();
|
||||
disc.format = DiscFormat::Dvd;
|
||||
disc.volume_id = "RT".into();
|
||||
disc.titles = vec![bdts_title(), dvd_title()];
|
||||
let profile = disc.profile();
|
||||
let json = serde_json::to_string(&profile).expect("serialize");
|
||||
let back: DiscProfile = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(profile, back, "profile round-trips through serde");
|
||||
}
|
||||
}
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
//! Drive data capture — read hardware information via SCSI.
|
||||
|
||||
use crate::drive::Drive;
|
||||
use crate::error::Result;
|
||||
|
||||
/// Raw data captured from a drive's SCSI responses.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DriveCapture {
|
||||
/// Raw INQUIRY response (96 bytes)
|
||||
pub inquiry: Vec<u8>,
|
||||
/// Raw GET_CONFIG 010C response
|
||||
pub gc_010c: Vec<u8>,
|
||||
/// GET_CONFIG feature responses: (feature_code, feature_name, data)
|
||||
pub features: Vec<CapturedFeature>,
|
||||
/// REPORT_KEY RPC state
|
||||
pub rpc_state: Option<Vec<u8>>,
|
||||
/// MODE SENSE page 2A (capabilities)
|
||||
pub mode_2a: Option<Vec<u8>>,
|
||||
/// READ_BUFFER 0xF1 (Pioneer vendor data)
|
||||
pub rb_f1: Option<Vec<u8>>,
|
||||
/// READ_BUFFER mode 6 (MTK vendor data)
|
||||
pub rb_mode6: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A single GET CONFIGURATION feature response from the drive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CapturedFeature {
|
||||
/// MMC-6 GET CONFIGURATION feature code (e.g. `0x010D` = AACS).
|
||||
pub code: u16,
|
||||
/// Static human-readable label from the internal `FEATURES` table —
|
||||
/// not a device-reported string.
|
||||
pub name: &'static str,
|
||||
/// Raw feature-descriptor payload bytes, with the 8-byte GET
|
||||
/// CONFIGURATION header stripped (i.e. `buf[8..]`). Unlike
|
||||
/// [`DriveCapture::gc_010c`], which retains the full header.
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Feature codes to capture.
|
||||
const FEATURES: &[(u16, &str)] = &[
|
||||
(0x0000, "Profile List"),
|
||||
(0x0001, "Core"),
|
||||
(0x0003, "Removable Medium"),
|
||||
(0x0010, "Random Readable"),
|
||||
(0x001D, "Multi-Read"),
|
||||
(0x001E, "CD Read"),
|
||||
(0x001F, "DVD Read"),
|
||||
(0x0040, "BD Read"),
|
||||
(0x0041, "BD Write"),
|
||||
(0x0100, "Power Management"),
|
||||
(0x0102, "Embedded Changer"),
|
||||
(0x0107, "Real Time Streaming"),
|
||||
(0x0108, "Serial Number"),
|
||||
(0x010C, "Firmware Information"),
|
||||
(0x010D, "AACS"),
|
||||
];
|
||||
|
||||
/// Capture all available drive data via SCSI commands.
|
||||
/// Returns raw responses — no formatting, no zipping, no presentation.
|
||||
pub fn capture_drive_data(session: &mut Drive) -> Result<DriveCapture> {
|
||||
let id = &session.drive_id;
|
||||
|
||||
// Already have INQUIRY from drive open
|
||||
let inquiry = id.raw_inquiry.clone();
|
||||
let gc_010c = id.raw_gc_010c.clone();
|
||||
|
||||
// Capture GET_CONFIG features using Drive's query methods
|
||||
let mut features = Vec::new();
|
||||
for &(code, name) in FEATURES {
|
||||
if let Some(data) = session.get_config_feature(code) {
|
||||
features.push(CapturedFeature { code, name, data });
|
||||
}
|
||||
}
|
||||
|
||||
// Vendor-specific READ_BUFFER queries
|
||||
let rb_f1 = session.read_buffer(0x02, 0xF1, 48); // Pioneer
|
||||
let rb_mode6 = session.read_buffer(0x06, 0x00, 32); // MTK
|
||||
|
||||
// Standard queries
|
||||
let rpc_state = session.report_key_rpc_state();
|
||||
let mode_2a = session.mode_sense_page(0x2A);
|
||||
|
||||
Ok(DriveCapture {
|
||||
inquiry,
|
||||
gc_010c,
|
||||
features,
|
||||
rpc_state,
|
||||
mode_2a,
|
||||
rb_f1,
|
||||
rb_mode6,
|
||||
})
|
||||
}
|
||||
|
||||
/// Mask a string for privacy (letters->A, digits->0).
|
||||
pub fn mask_string(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphabetic() {
|
||||
'A'
|
||||
} else if c.is_ascii_digit() {
|
||||
'0'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Mask bytes for privacy.
|
||||
pub fn mask_bytes(data: &[u8]) -> Vec<u8> {
|
||||
data.iter()
|
||||
.map(|&b| {
|
||||
if b.is_ascii_alphabetic() {
|
||||
b'A'
|
||||
} else if b.is_ascii_digit() {
|
||||
b'0'
|
||||
} else {
|
||||
b
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Privacy-masking + capture-orchestration tests.
|
||||
//!
|
||||
//! `mask_string` / `mask_bytes` redact identifying characters before
|
||||
//! a drive capture leaves the machine: every ASCII letter → 'A',
|
||||
//! every ASCII digit → '0', everything else (punctuation, spaces,
|
||||
//! control bytes, non-ASCII) is preserved verbatim so structural
|
||||
//! framing (offsets, separators) survives for diffing.
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mask_string_letters_become_a_digits_become_zero() {
|
||||
// Mixed case letters all collapse to 'A'; digits to '0'.
|
||||
assert_eq!(mask_string("HL-DT-ST"), "AA-AA-AA");
|
||||
assert_eq!(mask_string("BU40N"), "AA00A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_string_preserves_non_alnum_punctuation_and_space() {
|
||||
// Separators and spaces must be preserved so the masked output
|
||||
// keeps the same shape as the original (the whole point of a
|
||||
// structure-preserving redaction).
|
||||
assert_eq!(mask_string("1.04"), "0.00");
|
||||
assert_eq!(mask_string("a b-c.d_e"), "A A-A.A_A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_string_preserves_non_ascii_chars() {
|
||||
// is_ascii_alphabetic/is_ascii_digit are false for non-ASCII, so
|
||||
// multibyte chars pass through unchanged (no mojibake, no panic).
|
||||
// 'c','a','f' are ASCII letters → 'A'; 'é' is non-ASCII →
|
||||
// preserved; '9' → '0'.
|
||||
assert_eq!(mask_string("café9"), "AAAé0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_bytes_matches_string_masking_for_ascii() {
|
||||
// mask_bytes is the byte-wise analogue: letters→b'A', digits→b'0'.
|
||||
assert_eq!(mask_bytes(b"HL-DT-ST"), b"AA-AA-AA".to_vec());
|
||||
assert_eq!(mask_bytes(b"1.04"), b"0.00".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_bytes_preserves_non_alnum_and_high_bytes() {
|
||||
// Control bytes (0x00), high bytes (0xFF), and punctuation are
|
||||
// not ASCII alnum and must survive verbatim — INQUIRY payloads
|
||||
// are space-padded binary and the framing must be diffable.
|
||||
let input = [0x00u8, b'A', 0x20, b'7', 0xFF, b'-'];
|
||||
assert_eq!(mask_bytes(&input), vec![0x00, b'A', 0x20, b'0', 0xFF, b'-']);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_table_has_no_duplicate_codes() {
|
||||
// capture_drive_data iterates FEATURES once per code; a duplicate
|
||||
// code would silently capture the same feature twice (and bloat
|
||||
// the report). Each MMC-6 feature code must be unique.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for &(code, _name) in FEATURES {
|
||||
assert!(seen.insert(code), "duplicate feature code {code:#06x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_table_includes_aacs_010d() {
|
||||
// AACS (0x010D) is the feature that gates UHD decryption capture;
|
||||
// it must be in the table or AACS drives capture incompletely.
|
||||
assert!(
|
||||
FEATURES.iter().any(|&(c, _)| c == 0x010D),
|
||||
"AACS feature 0x010D must be captured"
|
||||
);
|
||||
}
|
||||
}
|
||||
+9
-16
@@ -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
|
||||
@@ -86,14 +84,9 @@ pub fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> {
|
||||
let sr_id = DriveId::from_drive(sr_transport.as_mut())?;
|
||||
drop(sr_transport);
|
||||
for (sg_path, sg_id) in find_drives() {
|
||||
// Require a non-empty serial before treating vendor/product/
|
||||
// serial as a unique match. serial_number falls back to an
|
||||
// empty string when GET CONFIGURATION 0108h is unavailable
|
||||
// (common on OEM drives); two same-model drives would then
|
||||
// both compare equal and the first in enumeration order would
|
||||
// win silently, resolving sr1 to sr0's sg node. An empty
|
||||
// serial can't disambiguate, so fall through to the no-match
|
||||
// path instead.
|
||||
// Require a non-empty serial before treating vendor/product/serial as a unique
|
||||
// match: serial_number falls back to "" when GET CONFIGURATION 0108h is
|
||||
// unavailable (OEM drives), which would let same-model drives collide silently.
|
||||
if !sr_id.serial_number.is_empty()
|
||||
&& sg_id.vendor_id == sr_id.vendor_id
|
||||
&& sg_id.product_id == sr_id.product_id
|
||||
|
||||
+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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1238
-209
File diff suppressed because it is too large
Load Diff
+13
-8
@@ -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;
|
||||
|
||||
@@ -26,10 +29,12 @@ use crate::sector::SectorSource;
|
||||
/// Returns `None` when navigation cannot be resolved, so the caller falls back
|
||||
/// to the structural leading-cell filter (today's behaviour, ≈ cell 0 / 0:00).
|
||||
///
|
||||
/// TODO(#40): the IFO/PCI parsing + nav executor (built on [`vmcmd`]) land
|
||||
/// incrementally. Until the executor is complete this returns `None`, so wiring
|
||||
/// it in is behaviour-neutral; improvements to the resolver take effect here
|
||||
/// without touching the call site.
|
||||
/// PARKED (issue #40, `USE_NAV_RESOLVER=false`): this is intentionally a stub,
|
||||
/// not an accidental dead one. It unconditionally returns `None` and ignores
|
||||
/// `reader`/`udf` on purpose — the IFO/PCI parsing + nav executor (built on
|
||||
/// [`vmcmd`]) land incrementally behind that flag. Until wired in, calling this
|
||||
/// is behaviour-neutral (the caller always falls back); improvements to the
|
||||
/// resolver take effect here without touching the call site. Do not delete.
|
||||
pub fn resolve_feature_start(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf: &crate::udf::UdfFs,
|
||||
|
||||
@@ -0,0 +1,750 @@
|
||||
//! 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: per-disc values live at
|
||||
// disc-constant offsets.
|
||||
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
|
||||
/// (the on-disc count is an untrusted u16). Shared with the IFO parser so the
|
||||
/// two can never diverge.
|
||||
use crate::ifo::MAX_TT_SRPT_TITLES;
|
||||
|
||||
/// 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 with the source's
|
||||
// taint alone (clearing prior taint); accumulating ops union current and
|
||||
// source taint. 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 (read a system
|
||||
// parameter, directly or via GPRM). No predicate = always runs, 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 prep before dispatch is a store, not a control transfer, so a
|
||||
// tainted guard does NOT abstain. Undecidable guard -> unknown post-state
|
||||
// -> taint the destination; otherwise apply the store normally.
|
||||
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; JumpSS/Link land in a menu or depend on
|
||||
// an interactive button selection that can't be resolved statically.
|
||||
_ => 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,
|
||||
vmg_bytes: Option<&[u8]>,
|
||||
) -> Option<ResolvedTitle> {
|
||||
// Reuse caller-supplied VIDEO_TS.IFO bytes when present, else read them.
|
||||
let read;
|
||||
let vmg: &[u8] = match vmg_bytes {
|
||||
Some(bytes) => bytes,
|
||||
None => {
|
||||
read = udf.read_file(reader, "/VIDEO_TS/VIDEO_TS.IFO").ok()?;
|
||||
&read
|
||||
}
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
+39
-26
@@ -16,7 +16,7 @@
|
||||
/// A decoded navigation instruction. Only the variants freemkv's start-point
|
||||
/// resolver needs are modelled explicitly; everything else is [`Instr::Other`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Instr {
|
||||
pub(crate) enum Instr {
|
||||
Nop,
|
||||
/// Stop executing the current command list (resume cell playback).
|
||||
Break,
|
||||
@@ -96,7 +96,7 @@ pub enum Instr {
|
||||
|
||||
/// A compare predicate carried by a command (`byte1` bits 6-4). `None` = always.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Compare {
|
||||
pub(crate) struct Compare {
|
||||
/// Compare op: 1=&,2===,3=!=,4=>=,5=>,6=<=,7=<.
|
||||
pub op: u8,
|
||||
/// Left register index (GPRM 0-15, SPRM 128+).
|
||||
@@ -109,7 +109,7 @@ pub struct Compare {
|
||||
|
||||
/// A fully decoded command: its predicate (if any) and the instruction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Command {
|
||||
pub(crate) struct Command {
|
||||
pub compare: Option<Compare>,
|
||||
pub instr: Instr,
|
||||
}
|
||||
@@ -159,13 +159,9 @@ fn be16(b: &[u8; 8], o: usize) -> u16 {
|
||||
((b[o] as u16) << 8) | b[o + 1] as u16
|
||||
}
|
||||
|
||||
// Compare-operand layouts ("if_version"s) per the DVD-Video VM. The op
|
||||
// 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].
|
||||
// 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].
|
||||
// Compare-operand layouts ("if_version"s): op nibble = byte1 bits 6-4, imm
|
||||
// flag = byte1 bit 7; offsets differ by family: v1(special/link) lhs=b[3],
|
||||
// rhs=b[4:6]/b[5]; v2(jump/sys-set) lhs=b[6],rhs=b[7]; v3(set-GPRM) lhs=b[2],rhs=b[6:8]/b[7].
|
||||
fn if_v1(b: &[u8; 8]) -> Option<Compare> {
|
||||
let op = (b[1] >> 4) & 7;
|
||||
(op != 0).then(|| Compare {
|
||||
@@ -173,7 +169,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,12 +189,12 @@ 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],
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode an 8-byte VM command.
|
||||
pub fn decode(b: &[u8; 8]) -> Command {
|
||||
pub(crate) fn decode(b: &[u8; 8]) -> Command {
|
||||
let typ = b[0] >> 5;
|
||||
let direct = (b[0] >> 4) & 1;
|
||||
let setop = b[0] & 0x0F;
|
||||
@@ -295,16 +291,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 +314,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 +327,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 +335,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 +351,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 +379,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.
|
||||
|
||||
+884
-124
File diff suppressed because it is too large
Load Diff
+373
@@ -0,0 +1,373 @@
|
||||
//! 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: valid magic, mostly zeroes, a handful of bytes
|
||||
// corrupted plus truncation. Length/offset fields live in those early
|
||||
// bytes, so this reaches the arithmetic 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 resolver parses VMGI (FP_PGC + TT_SRPT, all
|
||||
// attacker-controlled) and executes an untrusted command list on a bounded
|
||||
// VM. Magic is the VMGI signature, so the sweep reaches 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 READ (never executed) to recover BD-J stream
|
||||
// labels. Every count/length/constant-pool index is attacker-controlled
|
||||
// disc metadata, so the parser must reject malformed input, never panic.
|
||||
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` zip and walks every `.class`
|
||||
// inside, handing each to the class reader (as `deluxe`/`dbp` do). Neither
|
||||
// the zip open nor the class walk may panic. Magic: `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() {
|
||||
// Deluxe's BD-J parser runs a symbolic stack machine over `<clinit>`
|
||||
// bytecode. Fuzzing THROUGH a zip is vacuous (generators can't fake a valid
|
||||
// end-of-central-directory), so drive the decoder DIRECTLY on a `.class`.
|
||||
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 nav VM executes an untrusted MovieObject.bdmv command list.
|
||||
// Parse sweep bytes as a MovieObject set and run the VM from First-Play —
|
||||
// the combined parse+execute path the per-parser index/mobj sweeps miss.
|
||||
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.
|
||||
// Each case is fed to a FRESH demuxer, then a shared one — the shared pass
|
||||
// exercises cross-feed state: split start codes, held PES, the carry 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]));
|
||||
|
||||
+198
-16
@@ -17,19 +17,19 @@ use crate::scsi::{DataDirection, ScsiTransport};
|
||||
/// - MMC-6 §5.3.10 for Firmware Information
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DriveId {
|
||||
/// T10 VENDOR IDENTIFICATION — INQUIRY bytes [8:16]
|
||||
/// T10 VENDOR IDENTIFICATION — INQUIRY bytes `[8:16]`
|
||||
/// SPC-4 §6.4.2
|
||||
pub vendor_id: String,
|
||||
|
||||
/// PRODUCT IDENTIFICATION — INQUIRY bytes [16:32]
|
||||
/// PRODUCT IDENTIFICATION — INQUIRY bytes `[16:32]`
|
||||
/// SPC-4 §6.4.2
|
||||
pub product_id: String,
|
||||
|
||||
/// PRODUCT REVISION LEVEL — INQUIRY bytes [32:36]
|
||||
/// PRODUCT REVISION LEVEL — INQUIRY bytes `[32:36]`
|
||||
/// SPC-4 §6.4.2
|
||||
pub product_revision: String,
|
||||
|
||||
/// VENDOR SPECIFIC — INQUIRY bytes [36:43]
|
||||
/// VENDOR SPECIFIC — INQUIRY bytes `[36:43]`
|
||||
/// SPC-4 §6.4.2
|
||||
/// Content varies by vendor: firmware type code (MTK), date (Pioneer), etc.
|
||||
pub vendor_specific: String,
|
||||
@@ -49,20 +49,29 @@ 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. Unchecked, a
|
||||
// GOOD status with a short/empty data phase decoded to blank identity +
|
||||
// byte0 0x00 — which reads as DIRECT ACCESS, so the drive vanishes silently.
|
||||
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.
|
||||
// A drive that lacks it may CHECK CONDITION rather than return an
|
||||
// empty descriptor, so a failure here is treated as feature-absent
|
||||
// (empty firmware date + empty raw bytes) instead of aborting the
|
||||
// whole identity probe.
|
||||
// GET CONFIGURATION Feature 010Ch — MMC-6 §6.6. Best-effort: an optional
|
||||
// feature a drive may lack (CHECK CONDITION), so failure here is
|
||||
// feature-absent (empty firmware date + raw bytes), not a probe abort.
|
||||
let mut gc = vec![0u8; 256];
|
||||
let cdb_gc = [0x46, 0x02, 0x01, 0x0C, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00];
|
||||
// `bytes_transferred` is device-reported and untrusted; clamp every
|
||||
@@ -83,11 +92,9 @@ impl DriveId {
|
||||
Err(_) => (String::new(), Vec::new()),
|
||||
};
|
||||
|
||||
// GET CONFIGURATION Feature 0108h — Serial Number.
|
||||
// Best-effort, like 010Ch above: the serial-number feature is
|
||||
// optional, so a drive that lacks it (CHECK CONDITION) or reports
|
||||
// too few bytes deliberately yields an empty serial rather than
|
||||
// failing the identity probe.
|
||||
// GET CONFIGURATION Feature 0108h — Serial Number. Best-effort like
|
||||
// 010Ch: optional feature, so lacking it (CHECK CONDITION) or too few
|
||||
// bytes deliberately yields an empty serial rather than failing.
|
||||
let mut gc_serial = vec![0u8; 256];
|
||||
let cdb_serial = [0x46, 0x02, 0x01, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00];
|
||||
let serial_number = if let Ok(r) =
|
||||
@@ -262,6 +269,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 +394,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;
|
||||
|
||||
+1190
-173
File diff suppressed because it is too large
Load Diff
+13
-22
@@ -109,19 +109,14 @@ where
|
||||
return Err(BoundedError::Halted);
|
||||
}
|
||||
|
||||
// Rendezvous channel: the worker sends exactly one value (the
|
||||
// op's return) and then exits. Capacity-0 means the send blocks
|
||||
// until we receive — fine on the happy path; on the timeout /
|
||||
// halt path the receiver is dropped and the worker's send
|
||||
// returns Err, which the worker ignores.
|
||||
// Rendezvous channel: worker sends one value then exits. On timeout/halt
|
||||
// the receiver is dropped, so the worker's send returns Err (ignored).
|
||||
let (tx, rx) = sync_channel::<R>(0);
|
||||
let _ = thread::Builder::new()
|
||||
.name("freemkv-bounded-syscall".into())
|
||||
.spawn(move || {
|
||||
// Ignore the send error: if we time out (or get halted)
|
||||
// before the worker finishes, the receiver is dropped
|
||||
// and `tx.send` returns Err. Either way, the worker has
|
||||
// nothing more to do.
|
||||
// Ignore send error: if we time out/halt before the worker finishes,
|
||||
// the receiver is dropped and `tx.send` returns Err — nothing more to do.
|
||||
let _ = tx.send(op());
|
||||
});
|
||||
|
||||
@@ -133,10 +128,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);
|
||||
@@ -179,10 +174,8 @@ mod tests {
|
||||
fn halt_fires_during_wait() {
|
||||
let halt = Halt::new();
|
||||
let halt2 = halt.clone();
|
||||
// Flip the halt from a side thread after ~300 ms — long
|
||||
// enough that the receive loop has rolled at least one
|
||||
// 250 ms slice and is sitting in `recv_timeout` again when
|
||||
// the bit flips.
|
||||
// Flip halt after ~300ms, long enough that the receive loop has rolled
|
||||
// at least one 250ms slice and is back in `recv_timeout`.
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
halt2.cancel();
|
||||
@@ -196,11 +189,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn worker_panics() {
|
||||
// Worker panics → sender drops without sending → recv sees
|
||||
// Disconnected → WorkerLost. We use an explicit panic in the
|
||||
// op closure rather than `panic!()` from inside the channel
|
||||
// machinery; the spawned thread's panic is contained (no
|
||||
// process abort) because we don't `.join()` it.
|
||||
// Worker panics → sender drops without sending → recv sees Disconnected →
|
||||
// WorkerLost. Panic is in the op closure, not the channel machinery; it's
|
||||
// contained (no process abort) because we don't `.join()` the thread.
|
||||
let r = bounded_syscall(None, Duration::from_secs(2), || -> u32 {
|
||||
panic!("intentional test panic");
|
||||
});
|
||||
|
||||
+45
-57
@@ -75,18 +75,15 @@ impl BytePrefetcher {
|
||||
chunk_bytes: usize,
|
||||
halt: Option<Halt>,
|
||||
) -> std::io::Result<Self> {
|
||||
// A zero-length chunk makes every recycled buffer an empty
|
||||
// slice; `reader.read(&mut [])` returns Ok(0), which the loop
|
||||
// below treats as EOF — the consumer would see a clean,
|
||||
// silent zero-byte stream. Callers pass the downstream
|
||||
// demuxer's batch size, which is always > 0.
|
||||
// A zero-length chunk makes every recycled buffer empty; `reader.read(&mut [])`
|
||||
// returns Ok(0), which the loop below treats as EOF, producing a silent
|
||||
// zero-byte stream. Callers must pass the downstream demuxer's batch size (> 0).
|
||||
debug_assert!(chunk_bytes > 0, "BytePrefetcher chunk_bytes must be > 0");
|
||||
let (tx, rx) = bounded::<Batch>(FORWARD_DEPTH);
|
||||
let (recycle_tx, recycle_rx) = bounded::<Vec<u8>>(RECYCLE_DEPTH);
|
||||
|
||||
// Seed the recycle pool. Without these the first
|
||||
// `recycle_rx.recv()` would block forever (no consumer has
|
||||
// returned a buffer yet).
|
||||
// Seed the recycle pool; otherwise the first `recycle_rx.recv()` blocks
|
||||
// forever since no consumer has returned a buffer yet.
|
||||
for _ in 0..RECYCLE_DEPTH {
|
||||
let _ = recycle_tx.send(vec![0u8; chunk_bytes]);
|
||||
}
|
||||
@@ -94,21 +91,13 @@ impl BytePrefetcher {
|
||||
let producer = std::thread::Builder::new()
|
||||
.name("freemkv-byte-prefetch".into())
|
||||
.spawn(move || {
|
||||
// Wrap the feed loop in catch_unwind so a panic in the inner
|
||||
// `reader.read` (e.g. a decrypt-on-read slice/arith bug) is NOT
|
||||
// indistinguishable from a clean finish at the demux boundary. A
|
||||
// clean exit (EOF, halt, consumer disconnect) returns and drops
|
||||
// `tx` → the demux loop reads RecvError as EOF (correct). A PANIC
|
||||
// sends an explicit error sentinel first so the demux loop's
|
||||
// `Ok(Err(_))` arm fires and propagates a typed error instead of
|
||||
// converting the dropped channel into a clean `DemuxBatch::Eof`
|
||||
// that would finalize a TRUNCATED mux while reporting success.
|
||||
// catch_unwind: a clean exit drops `tx` (demux reads RecvError as EOF),
|
||||
// but a panic sends an error sentinel first so demux gets a typed error
|
||||
// instead of finalizing a truncated mux as success.
|
||||
let body = std::panic::AssertUnwindSafe(|| {
|
||||
let cancelled = || halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false);
|
||||
// Liveness heartbeat: the producer blocks on the recycle and
|
||||
// forward channels; a stalled consumer or a wedged reader shows
|
||||
// up as the beat going silent. Total is unknown, so `pos` is
|
||||
// cumulative bytes read.
|
||||
// Liveness heartbeat: a stalled consumer or wedged reader shows up
|
||||
// as the beat going silent. Total is unknown, so `pos` is cumulative.
|
||||
let mut hb = crate::progress::Heartbeat::new("byte_prefetch");
|
||||
let mut produced_bytes: u64 = 0;
|
||||
loop {
|
||||
@@ -116,10 +105,9 @@ impl BytePrefetcher {
|
||||
if cancelled() {
|
||||
return;
|
||||
}
|
||||
// Park on the recycle channel, but re-poll halt
|
||||
// every POLL_INTERVAL: a pure-AtomicBool Halt does
|
||||
// not disconnect the channel, so a blocking recv()
|
||||
// would never re-reach the cancel check.
|
||||
// Re-poll halt every POLL_INTERVAL: a pure-AtomicBool Halt does
|
||||
// not disconnect the channel, so a blocking recv() would never
|
||||
// re-reach the cancel check.
|
||||
let mut buf = loop {
|
||||
match recycle_rx.recv_timeout(POLL_INTERVAL) {
|
||||
Ok(b) => break b,
|
||||
@@ -132,11 +120,9 @@ impl BytePrefetcher {
|
||||
Err(RecvTimeoutError::Disconnected) => return,
|
||||
}
|
||||
};
|
||||
// Re-expose the full extent. After a short read the
|
||||
// prior iteration truncated to n < chunk_bytes, so
|
||||
// this regrows the length back to chunk_bytes
|
||||
// without reallocating (capacity was fixed at
|
||||
// construction and never shrinks).
|
||||
// Regrow to chunk_bytes: a prior short read truncated len to
|
||||
// n < chunk_bytes. No realloc — capacity was fixed at
|
||||
// construction and never shrinks.
|
||||
if buf.len() < chunk_bytes {
|
||||
buf.resize(chunk_bytes, 0);
|
||||
} else {
|
||||
@@ -196,27 +182,12 @@ impl BytePrefetcher {
|
||||
/// drains `rx`, runs the demuxer in place on each filled buffer,
|
||||
/// and recycles back through `recycle_tx`.
|
||||
pub fn into_channels(self) -> (Receiver<Batch>, Sender<Vec<u8>>, PrefetchShell) {
|
||||
// MOVE the three fields out cleanly — never clone. Each of
|
||||
// `rx` and `recycle_tx` ends up with exactly ONE live copy:
|
||||
// the one in the returned tuple. The pre-1.0.0 implementation
|
||||
// cloned both and then `mem::forget`-ed `self`, leaking the
|
||||
// originals so an extra live receiver + sender survived
|
||||
// forever. That defeated the channel-disconnection shutdown:
|
||||
// when the demux consumer exited early (halt, or a `tx.send`
|
||||
// error in `demux_thread`), the producer's `recycle_rx.recv()`
|
||||
// and `tx.send()` never saw all-peers-dropped, so the producer
|
||||
// never returned and `PrefetchShell::drop`'s `join()` hung.
|
||||
//
|
||||
// `ManuallyDrop` + `ptr::read` reads each field out by value
|
||||
// and suppresses `self`'s own `Drop` (which would otherwise
|
||||
// double-`join`), leaving NO extra live endpoint behind. This
|
||||
// is the panic-free equivalent of the `Option::take` approach
|
||||
// and mirrors `sector::prefetched::into_channels`.
|
||||
// MOVE the fields out, never clone: the pre-1.0.0 impl cloned + `mem::forget`-ed
|
||||
// `self`, leaking endpoints that defeated disconnection shutdown (producer join
|
||||
// hung forever). `ManuallyDrop` + `ptr::read` moves fields out cleanly instead.
|
||||
let me = std::mem::ManuallyDrop::new(self);
|
||||
// SAFETY: `me` is `ManuallyDrop`, so none of these fields will
|
||||
// be dropped by `me`. Each `ptr::read` performs exactly one
|
||||
// bitwise move out; every field is read exactly once and never
|
||||
// touched again, so there are no double-frees and no aliasing.
|
||||
// SAFETY: `me` is `ManuallyDrop`, so none of these fields are dropped by `me`.
|
||||
// Each `ptr::read` is one bitwise move, read exactly once — no double-frees.
|
||||
let producer = unsafe { std::ptr::read(&me.producer) };
|
||||
// SAFETY: `rx` and `recycle_tx` are always `Some` here —
|
||||
// `into_channels` is the only way to consume a live
|
||||
@@ -230,13 +201,9 @@ impl BytePrefetcher {
|
||||
|
||||
impl Drop for BytePrefetcher {
|
||||
fn drop(&mut self) {
|
||||
// Drop channel endpoints BEFORE joining the producer so the
|
||||
// producer observes SendTimeoutError::Disconnected (forward tx)
|
||||
// or RecvTimeoutError::Disconnected (recycle rx) and exits
|
||||
// promptly. Without this, a non-EOF source fills the depth-2
|
||||
// forward channel and then spins in send_timeout(POLL_INTERVAL)
|
||||
// forever because rx is never drained, causing join() to
|
||||
// deadlock.
|
||||
// Drop channel endpoints BEFORE joining so the producer observes Disconnected
|
||||
// (send/recv) and exits promptly. Otherwise a non-EOF source fills the forward
|
||||
// channel and spins in send_timeout forever since rx is never drained.
|
||||
drop(self.rx.take());
|
||||
drop(self.recycle_tx.take());
|
||||
if let Some(h) = self.producer.take() {
|
||||
@@ -249,6 +216,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,
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
//!
|
||||
//! On `open()` each platform issues its "sequential access expected"
|
||||
//! hint so OS-level readahead widens. The hint and the DONTNEED call
|
||||
//! live in per-OS sibling modules ([`linux::hint_sequential`] et al.)
|
||||
//! live in per-OS sibling modules (`linux::hint_sequential` et al.)
|
||||
//! — no inline `#[cfg]` in this file.
|
||||
//!
|
||||
//! ## Read-ahead prefetch
|
||||
@@ -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,16 @@ 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 debug_assert: `out` is caller input to a public
|
||||
// `SectorSource` impl, and `out[..bytes]` below would panic in release
|
||||
// where the assert is compiled away. `Drive::read_fua` carries this guard.
|
||||
if out.len() < bytes {
|
||||
return Err(Error::DiscRead {
|
||||
sector: lba as u64,
|
||||
status: None,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
if count == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -188,18 +211,14 @@ impl SectorSource for FileSectorSource {
|
||||
.read_exact(&mut out[..bytes])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
|
||||
// Queue the next batch's read with the kernel before the
|
||||
// caller starts processing what we just returned. readahead()
|
||||
// is non-blocking — it queues I/O and returns, so the kernel
|
||||
// pulls those pages into cache while the consumer (decrypt +
|
||||
// demux + mux) runs. Next read_sectors call hits a warm cache.
|
||||
// Queue the next batch's read before the caller processes what we returned.
|
||||
// readahead() is non-blocking; the kernel pulls pages into cache while the
|
||||
// consumer runs, so the next read_sectors call hits a warm cache.
|
||||
platform::prefetch(&self.file, offset + bytes as u64, bytes as u64);
|
||||
|
||||
// Periodic page-cache eviction on the read side. Without
|
||||
// this, an 85 GB streaming ISO read pins the entire file in
|
||||
// the kernel page cache, which starves concurrent writes and
|
||||
// collapses mux throughput. Mirrors the write-side
|
||||
// WritebackPipeline's DONTNEED policy.
|
||||
// Periodic page-cache eviction on the read side: an 85 GB streaming ISO
|
||||
// read would otherwise pin the whole file in cache, starving concurrent
|
||||
// writes. Mirrors WritebackPipeline's DONTNEED policy on the write side.
|
||||
self.bytes_read_since_drop += bytes as u64;
|
||||
if self.bytes_read_since_drop >= self.drop_chunk_bytes {
|
||||
let drop_start = self.drop_window_start;
|
||||
@@ -219,6 +238,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.
|
||||
@@ -346,12 +399,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn drop_chunk_size_env_override() {
|
||||
// Explicit 8 MiB via env var.
|
||||
// SAFETY: tests in this crate are single-threaded per the
|
||||
// default cargo test harness, but std::env::set_var is
|
||||
// declared `unsafe` since Rust 2024 (it can race with other
|
||||
// threads / TLS). For a test that runs in-process before any
|
||||
// FileSectorSource construction this is safe in practice.
|
||||
// Explicit 8 MiB via env var. SAFETY: set_var is `unsafe` since Rust 2024
|
||||
// (can race with other threads/TLS), but this test runs single-threaded
|
||||
// and in-process before any FileSectorSource construction.
|
||||
unsafe {
|
||||
std::env::set_var("FREEMKV_READ_DROP_CHUNK_MIB", "8");
|
||||
}
|
||||
@@ -376,19 +426,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 +585,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,275 @@
|
||||
//! `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 bytes into the kernel via write(2), no durability promise —
|
||||
// a crash or yanked volume could leave a truncated file reported as complete.
|
||||
// `into_inner` (not `flush`) also surfaces buffered-write errors, not silently drop them.
|
||||
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,
|
||||
};
|
||||
|
||||
+353
-130
@@ -1,13 +1,12 @@
|
||||
//! Generic bounded producer/consumer pipeline.
|
||||
//!
|
||||
//! `Pipeline<I, R>` spawns a single consumer thread, hands it items
|
||||
//! through a bounded `mpsc::sync_channel`, and joins it on `finish()`.
|
||||
//! through a bounded `crossbeam_channel`, and joins it on `finish()`.
|
||||
//! The consumer's behaviour is supplied by a [`Sink`] implementation:
|
||||
//! `apply` is called once per item, `close` is called once at the end.
|
||||
//!
|
||||
//! Call sites in libfreemkv that want a producer/consumer split —
|
||||
//! sweep (`disc/sweep.rs::SweepSink`) and the file-backed mux highway
|
||||
//! — are built on this primitive.
|
||||
//! The file-backed mux highway is built on this primitive. (An earlier
|
||||
//! sweep sink also used it; sweep moved to freemkv-engine in 1.6.0.)
|
||||
//!
|
||||
//! ## Cancellation and error semantics
|
||||
//!
|
||||
@@ -33,7 +32,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 +116,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 +155,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 +169,40 @@ 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 before dropping the handle so the leaked consumer
|
||||
// skips further `apply`/`close()` once its wedged syscall returns. Compare-exchange,
|
||||
// not a store: if it already committed to `close()`, wait for its result instead.
|
||||
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",
|
||||
@@ -162,25 +210,18 @@ fn finish_with_grace<R: Send + 'static>(
|
||||
(output will not be finalised)",
|
||||
FINISH_GRACE_SECS
|
||||
);
|
||||
// Dropping `handle` without joining detaches from the thread — the
|
||||
// consumer keeps running until its kernel call returns or the process
|
||||
// exits. This is the intentional "leak" documented in
|
||||
// `finish_with_halt`'s contract; the `abandoned` flag bounds what the
|
||||
// leaked thread is allowed to do to the output before it exits.
|
||||
// Dropping `handle` without joining detaches the thread; the consumer keeps
|
||||
// running until its kernel call returns or the process exits. This is the
|
||||
// intentional "leak" from `finish_with_halt`'s contract, bounded by `abandoned`.
|
||||
drop(handle);
|
||||
Err(leak_err)
|
||||
}
|
||||
|
||||
/// 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 +230,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 +289,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 +312,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 +334,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> {
|
||||
@@ -290,11 +352,9 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
let mut first_err: Option<Error> = None;
|
||||
let mut stopped = false;
|
||||
|
||||
// Rolling apply-throughput summary. The per-item "apply: OK"
|
||||
// line was 99% of the mux log; collapse it into a periodic
|
||||
// summary (count, avg ms, items/s) emitted ~every 5 s while
|
||||
// debug tracing is on. The individual slow-apply ("took … s")
|
||||
// STALL events below stay visible — those are signal, not noise.
|
||||
// Rolling apply-throughput summary: the per-item "apply: OK" line was
|
||||
// 99% of the mux log, so collapse it into a periodic summary (count,
|
||||
// avg ms, items/s) every ~5s. Slow-apply STALL events stay visible below.
|
||||
let mut summary_count: u64 = 0;
|
||||
let mut summary_nanos: u128 = 0;
|
||||
let mut summary_since = Instant::now();
|
||||
@@ -306,14 +366,10 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
tracing::debug!("Pipeline receive: item={}", std::any::type_name::<I>());
|
||||
}
|
||||
|
||||
// If the caller has abandoned us (grace period expired
|
||||
// after a halt/timeout and the JoinHandle was dropped),
|
||||
// stop applying items. We keep draining so the producer
|
||||
// — if it is somehow still alive — never blocks on a
|
||||
// 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) {
|
||||
// Abandoned (grace expired, JoinHandle dropped): keep draining so a
|
||||
// still-alive producer never blocks on a dead receiver, but touch
|
||||
// the output no further. Post-loop check returns error, skips close().
|
||||
if state_consumer.load(Ordering::Acquire) == state::ABANDONED {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -342,6 +398,10 @@ 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 stops feeding a dead
|
||||
// write side instead of learning at `finish()`, after reading
|
||||
// the rest of the disc. `Release` pairs with `send_with_halt`.
|
||||
failed_consumer.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,23 +452,36 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
);
|
||||
}
|
||||
|
||||
// Final abandonment check: the common leak case is a
|
||||
// consumer wedged inside `apply` (a blocking write
|
||||
// syscall). When that syscall finally returns, the
|
||||
// producer has long since dropped `tx`, so `recv`
|
||||
// yields `Err` and we fall through to here. If the
|
||||
// caller abandoned us in the meantime, skip `close()`
|
||||
// entirely — finalising the output (e.g. writing the
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Final abandonment check: a consumer wedged in a blocking `apply` write
|
||||
// can outlive the producer dropping `tx`, landing here via `recv -> Err`.
|
||||
// Skip `close()` if abandoned meanwhile — it would race the write.
|
||||
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 could race the caller marking
|
||||
// `abandoned`, letting `close()` finalise output already reported
|
||||
// interrupted. Compare-exchange: loser skips `close()`, waits for winner.
|
||||
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 +489,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 +599,31 @@ 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` failed fatally: hand the item back now, since
|
||||
// otherwise sends keep succeeding and the producer reads a whole UHD
|
||||
// title before learning at `finish()` the write died on frame one.
|
||||
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() {
|
||||
@@ -537,9 +644,8 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
}
|
||||
return Err(pending);
|
||||
}
|
||||
// Wait for space-available or halt-check tick, whichever
|
||||
// is sooner. Crossbeam's send_timeout is kernel-wakeup
|
||||
// based: the consumer's recv on a saturated channel
|
||||
// Wait for space-available or halt-check tick, whichever is sooner.
|
||||
// send_timeout is kernel-wakeup based: recv on a saturated channel
|
||||
// signals this thread the moment a slot opens up.
|
||||
let slice = SEND_HALT_CHECK_INTERVAL.min(end.saturating_duration_since(now));
|
||||
match self.tx.send_timeout(pending, slice) {
|
||||
@@ -571,7 +677,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 +714,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 +736,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);
|
||||
}
|
||||
@@ -696,14 +820,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn back_pressure_blocks_sender() {
|
||||
// depth=2 + 5 sends + 50ms/apply: with the consumer pinned at
|
||||
// 50 ms per item, the producer can buffer 2 (channel cap) +
|
||||
// 1 (consumer in flight) = 3 items before sends 4 and 5 must
|
||||
// block on consumer progress. Wall-clock floor across all 5
|
||||
// sends is therefore ~2 * 50ms = 100ms (sends 4 and 5 each
|
||||
// wait roughly one apply-cycle). Use 80 ms as the assertion
|
||||
// floor to stay above the 50ms-per-item progress floor while
|
||||
// tolerating CI jitter — it still proves blocking is real.
|
||||
// depth=2 + 5 sends + 50ms/apply: producer buffers 3 items (2 channel cap +
|
||||
// 1 in flight) before sends 4 and 5 must block, giving a ~100ms wall-clock
|
||||
// floor. Assert 80ms to tolerate CI jitter while still proving blocking.
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let sink = SlowSink {
|
||||
delay: Duration::from_millis(50),
|
||||
@@ -824,10 +943,9 @@ mod tests {
|
||||
)
|
||||
.expect("spawn should succeed");
|
||||
|
||||
// Send 10 items. After Stop, subsequent sends may either
|
||||
// succeed (already buffered) or fail with Err(I) (channel
|
||||
// closed). Both are valid — we don't assert on the send
|
||||
// results.
|
||||
// Send 10 items. After Stop, subsequent sends may either succeed (already
|
||||
// buffered) or fail with Err(I) (channel closed); both are valid, so we
|
||||
// don't assert on the send results.
|
||||
for i in 0..10u64 {
|
||||
let _ = pipe.send(i);
|
||||
}
|
||||
@@ -926,14 +1044,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn send_with_halt_returns_item_on_deadline() {
|
||||
// depth=1 + consumer wedged in apply on the first item, AND
|
||||
// the channel buffer already loaded with a second item, means
|
||||
// any further `try_send` sees Full; with a 200 ms deadline and
|
||||
// no halt fired, send_with_halt must return `Err(item)` within
|
||||
// roughly the deadline. Synchronising on `started` ensures the
|
||||
// consumer has actually started its wedged apply BEFORE we
|
||||
// load the channel-buffer slot — without that, the consumer
|
||||
// could still drain in a race window.
|
||||
// depth=1 + wedged consumer + a loaded buffer slot means further `try_send`
|
||||
// sees Full, so send_with_halt must return `Err(item)` around the 200ms
|
||||
// deadline. Sync on `started` first so the consumer is wedged before we load the slot.
|
||||
let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let pipe = Pipeline::spawn(
|
||||
@@ -1047,13 +1160,9 @@ mod tests {
|
||||
matches!(res, Err(Error::Halted)),
|
||||
"expected Err(Halted), got {res:?}"
|
||||
);
|
||||
// Bailed out within the grace period plus a healthy margin.
|
||||
// The grace spin-poll adds up to FINISH_GRACE_SECS (5s) of
|
||||
// extra wait for a truly wedged consumer; the test's consumer
|
||||
// is deliberately never released before this assert so we
|
||||
// exercise the "grace expires → leak" path. 15s is well under
|
||||
// the 10-minute JOIN_TIMEOUT backstop and proves the new code
|
||||
// doesn't block forever.
|
||||
// Bailed within the grace period plus margin: grace spin-poll adds up to
|
||||
// FINISH_GRACE_SECS (5s) for the deliberately-unreleased, wedged consumer.
|
||||
// 15s stays well under the 10-minute JOIN_TIMEOUT, proving it doesn't block forever.
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(15),
|
||||
"halt observation took too long: {elapsed:?}"
|
||||
@@ -1080,20 +1189,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
|
||||
@@ -1424,10 +1519,9 @@ mod tests {
|
||||
halt.cancel();
|
||||
|
||||
let start = Instant::now();
|
||||
// finish_with_halt drops tx (signalling EOF), then observes the
|
||||
// pre-cancelled halt and enters the grace spin. The consumer
|
||||
// finishes close() within 500ms, so finish_with_halt must join
|
||||
// cleanly and return Ok with the correct total.
|
||||
// finish_with_halt drops tx (EOF), observes the pre-cancelled halt, and
|
||||
// enters the grace spin; the consumer finishes close() within 500ms, so
|
||||
// it must join cleanly and return Ok with the correct total.
|
||||
let res = pipe.finish_with_halt(Some(&halt));
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
@@ -1598,4 +1692,133 @@ 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 || {
|
||||
// Release inside the second grace window. Old 600ms/300ms-grace intervals
|
||||
// left no margin and flaked on loaded runners; scaled up so windows end
|
||||
// at ~1.0-1.25s and ~2.0-2.25s, releasing at 1.6s for ~350ms slack.
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! The `libc` crate doesn't expose these symbols across all macOS SDK
|
||||
//! versions, so we define them locally with values from
|
||||
//! `/usr/include/sys/fcntl.h`. Two call sites (
|
||||
//! [`crate::io::writeback_file`] and [`crate::io::sink::preallocate`])
|
||||
//! [`crate::io::writeback_file`] and `crate::io::sink::preallocate`)
|
||||
//! need the same constants and `fstore_t` layout — keeping a single
|
||||
//! source of truth here prevents the two copies from drifting.
|
||||
//!
|
||||
@@ -16,8 +16,9 @@ pub(crate) const F_PREALLOCATE: libc::c_int = 42;
|
||||
/// Anchor preallocation at the current physical EOF.
|
||||
pub(crate) const F_PEOFPOSMODE: libc::c_int = 3;
|
||||
|
||||
/// Prefer a contiguous allocation. Try this first; on `EINVAL` (no
|
||||
/// contiguous run of that size), fall back to `F_ALLOCATEALL`.
|
||||
/// Prefer a contiguous allocation. Call sites OR this together with
|
||||
/// `F_ALLOCATEALL` on the first attempt; on failure they fall back to
|
||||
/// `F_ALLOCATEALL` alone.
|
||||
pub(crate) const F_ALLOCATECONTIG: libc::c_uint = 0x0000_0002;
|
||||
|
||||
/// Allow non-contiguous allocation. Stronger guarantee than just
|
||||
|
||||
+3
-5
@@ -94,11 +94,9 @@ pub(crate) fn open_for_mkv(
|
||||
return Ok(Box::new(wf));
|
||||
}
|
||||
}
|
||||
// Only Linux differentiates the sink by filesystem type (NFS gets
|
||||
// the WritebackFile machinery); every other OS always uses
|
||||
// `LocalFileSink`. Reference `detect` as a value (no call, no
|
||||
// `statfs` syscall) so it isn't flagged dead on non-Linux while
|
||||
// still avoiding the wasted probe whose result we'd discard.
|
||||
// Only Linux differentiates the sink by filesystem type; other OSes always use
|
||||
// `LocalFileSink`. Reference `detect` as a value (no call/syscall) so it isn't
|
||||
// flagged dead on non-Linux while avoiding a wasted probe.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = crate::platform::fs_type::detect;
|
||||
|
||||
|
||||
@@ -18,11 +18,9 @@ pub(super) fn preallocate_impl(file: &File, size_bytes: u64) {
|
||||
// would wrap a >= 2^63 size to a negative length.
|
||||
let len = i64::try_from(size_bytes).unwrap_or(i64::MAX) as libc::off_t;
|
||||
let mut store = Fstore {
|
||||
// Prefer a contiguous run but accept scattered extents to
|
||||
// satisfy the full length. Without F_ALLOCATEALL the first
|
||||
// attempt is best-effort and can return rc=0 with a partial
|
||||
// allocation, so the fallback below would never fire. Matches
|
||||
// writeback_file/macos.rs.
|
||||
// Prefer contiguous but accept scattered extents. Without F_ALLOCATEALL
|
||||
// the first attempt is best-effort and can return rc=0 with a partial
|
||||
// allocation, so the fallback below would never fire. Matches writeback_file/macos.rs.
|
||||
fst_flags: F_ALLOCATECONTIG | F_ALLOCATEALL,
|
||||
fst_posmode: F_PEOFPOSMODE,
|
||||
fst_offset: 0,
|
||||
|
||||
+15
-25
@@ -56,12 +56,9 @@ impl SocketSink {
|
||||
/// `("host", 1234)`, a `SocketAddr`, etc.
|
||||
pub fn connect<A: ToSocketAddrs>(addr: A, sndbuf_bytes: Option<usize>) -> io::Result<Self> {
|
||||
let stream = TcpStream::connect(addr)?;
|
||||
// `set_nodelay(true)` keeps small writes (TS packet trains, fMP4
|
||||
// moof headers) from sitting in Nagle's algorithm until the buffer
|
||||
// fills. The BufWriter already absorbs syscall overhead; Nagle
|
||||
// would just add latency without coalescing more. It is a latency
|
||||
// hint, not a correctness requirement, so a platform that rejects
|
||||
// TCP_NODELAY must not fail the connect — demote the error.
|
||||
// `set_nodelay(true)` keeps small writes (TS packet trains, fMP4 moof headers)
|
||||
// from sitting in Nagle's algorithm; BufWriter already absorbs syscall overhead.
|
||||
// A latency hint, not a correctness requirement — a rejecting platform must not fail connect.
|
||||
let _ = stream.set_nodelay(true);
|
||||
if let Some(n) = sndbuf_bytes {
|
||||
set_send_buffer(&stream, n)?;
|
||||
@@ -119,10 +116,9 @@ impl UdpSocketSink {
|
||||
///
|
||||
/// `sndbuf_bytes`, when present, is a hint to `SO_SNDBUF`.
|
||||
pub fn connect<A: ToSocketAddrs>(peer: A, sndbuf_bytes: Option<usize>) -> io::Result<Self> {
|
||||
// Resolve the peer first so the local bind matches its address
|
||||
// family. Binding `0.0.0.0:0` (IPv4) and then connecting to an
|
||||
// IPv6 peer fails with EAFNOSUPPORT, so pick the wildcard that
|
||||
// matches the resolved family.
|
||||
// Resolve the peer first so the local bind matches its address family:
|
||||
// binding `0.0.0.0:0` (IPv4) then connecting to an IPv6 peer fails
|
||||
// with EAFNOSUPPORT.
|
||||
let peer_addr = peer
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
@@ -164,12 +160,9 @@ impl SequentialSink for UdpSocketSink {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Platform `SO_SNDBUF` tuning ────────────────────────────────────────────
|
||||
//
|
||||
// std's `TcpStream` / `UdpSocket` don't expose `SO_SNDBUF`. We drop to
|
||||
// libc on Linux + macOS (the libc-dep targets in Cargo.toml). On other
|
||||
// targets the hint is silently ignored — the socket still works, the
|
||||
// kernel just picks its own send-buffer size.
|
||||
// ── Platform `SO_SNDBUF` tuning ─────────────────────────────────────────
|
||||
// std doesn't expose `SO_SNDBUF`; drop to libc on Linux + macOS. Other
|
||||
// targets silently ignore the hint — socket still works, kernel picks its own size.
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
fn set_send_buffer(stream: &TcpStream, bytes: usize) -> io::Result<()> {
|
||||
@@ -185,10 +178,9 @@ fn set_udp_send_buffer(socket: &UdpSocket, bytes: usize) -> io::Result<()> {
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
fn set_send_buffer(_stream: &TcpStream, _bytes: usize) -> io::Result<()> {
|
||||
// Non-Linux-non-macOS targets aren't in Cargo.toml's libc dep list;
|
||||
// silently ignore the hint rather than failing the connect. Callers
|
||||
// can detect via the lack of an explicit "sndbuf applied" signal
|
||||
// (not provided, intentionally — this is a hint, not a guarantee).
|
||||
// Non-Linux-non-macOS targets aren't in Cargo.toml's libc dep list; silently
|
||||
// ignore the hint rather than failing the connect (intentionally no
|
||||
// "sndbuf applied" signal — this is a hint, not a guarantee).
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -265,11 +257,9 @@ mod tests {
|
||||
});
|
||||
let mut sink = SocketSink::connect(addr, None).unwrap();
|
||||
_assert_seq(&mut sink);
|
||||
// The negative is harder to assert directly (no `is_not<T>`),
|
||||
// but `SocketSink` does not impl `Seek`, so it can't unify with
|
||||
// `RandomAccessSink`'s super-bound. The Phase 2 blanket impl
|
||||
// `impl<T: SequentialSink + Seek> RandomAccessSink for T {}` thus
|
||||
// excludes it by construction.
|
||||
// No `is_not<T>` to assert the negative directly, but `SocketSink` doesn't
|
||||
// impl `Seek`, so it can't unify with `RandomAccessSink`'s super-bound —
|
||||
// the blanket `impl<T: SequentialSink + Seek> RandomAccessSink for T {}` excludes it.
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
//! queue bounded. On macOS and Windows, a no-op stub.
|
||||
//!
|
||||
//! The platform decision lives entirely in this file (the cfg-gated
|
||||
//! `pub use` below). Callers — and `DiskWriter` itself — are
|
||||
//! `pub use` below). Callers — and `WritebackFile` itself — are
|
||||
//! platform-independent.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
+23
-35
@@ -179,18 +179,16 @@ impl WritebackPipeline {
|
||||
if pos < self.last_flush_pos.saturating_add(self.chunk_bytes) {
|
||||
return;
|
||||
}
|
||||
// Byte offsets are unsigned throughout; the signed cast happens
|
||||
// only at the libc call boundary where the kernel ABI requires
|
||||
// `i64`. `saturating_sub` documents and hardens the line-above
|
||||
// guard that `pos >= last_flush_pos`.
|
||||
// Byte offsets are unsigned throughout; the signed cast happens only at the
|
||||
// libc call boundary where the kernel ABI requires `i64`. `saturating_sub`
|
||||
// hardens the line-above guard that `pos >= last_flush_pos`.
|
||||
let chunk_off: u64 = self.last_flush_pos;
|
||||
let chunk_len: u64 = pos.saturating_sub(self.last_flush_pos);
|
||||
let mut wait_ms: u64 = 0;
|
||||
let mut fadvise_ms: u64 = 0;
|
||||
// Async kickoff for the just-completed chunk runs on every
|
||||
// path (NFS, degraded, normal) — it's nominally non-blocking
|
||||
// by spec and gives the kernel an early hint that this range
|
||||
// is ready to flush.
|
||||
// Async kickoff for the just-completed chunk runs on every path (NFS,
|
||||
// degraded, normal) — non-blocking by spec, an early hint that this
|
||||
// range is ready to flush.
|
||||
let kickoff_rc = unsafe {
|
||||
libc::sync_file_range(
|
||||
self.fd,
|
||||
@@ -211,16 +209,13 @@ impl WritebackPipeline {
|
||||
}
|
||||
if let Some((prev_off, prev_len)) = self.pending.take() {
|
||||
if self.skip_wait() {
|
||||
// NFS branch (or degraded fallback after a prior
|
||||
// timeout): the WAIT_AFTER + DONTNEED dance is what
|
||||
// hangs on NFS — skip it. We still advance `pending`
|
||||
// so the next call has a stable cycle.
|
||||
// NFS branch (or degraded fallback after a prior timeout): the
|
||||
// WAIT_AFTER + DONTNEED dance hangs on NFS — skip it, but still
|
||||
// advance `pending` so the next call has a stable cycle.
|
||||
} else {
|
||||
// Normal local-storage branch with belt-and-braces
|
||||
// timeout. If WAIT_AFTER hangs > WAIT_AFTER_TIMEOUT
|
||||
// we mark the pipeline degraded, log a loud error,
|
||||
// and fall through to the skip path on subsequent
|
||||
// calls.
|
||||
// Normal local-storage branch with belt-and-braces timeout: if
|
||||
// WAIT_AFTER hangs > WAIT_AFTER_TIMEOUT, mark degraded, log loudly,
|
||||
// and fall through to the skip path on subsequent calls.
|
||||
match wait_after_with_timeout(self.clone_for_worker(), self.fd, prev_off, prev_len)
|
||||
{
|
||||
Some(ms) => {
|
||||
@@ -238,17 +233,13 @@ impl WritebackPipeline {
|
||||
self.record_wait(wait_ms);
|
||||
}
|
||||
None => {
|
||||
// Timeout branch: switch to NFS-style skip
|
||||
// for the rest of the pipeline's life. Do
|
||||
// NOT call DONTNEED — if WAIT_AFTER hasn't
|
||||
// Timeout branch: switch to NFS-style skip for the rest of the
|
||||
// pipeline's life. Do NOT call DONTNEED — if WAIT_AFTER hasn't
|
||||
// returned, the pages aren't safely flushed.
|
||||
self.degraded.store(true, Ordering::Relaxed);
|
||||
// Once degraded we skip DONTNEED, so every subsequent
|
||||
// chunk's pages stay resident until close — the same
|
||||
// page-cache exposure profile as NFS. Shrink to the
|
||||
// floor so that exposure window is as small as the NFS
|
||||
// path keeps it, instead of whatever the adaptive sizing
|
||||
// had grown chunk_bytes to (up to 256 MiB).
|
||||
// Skipping DONTNEED leaves pages resident until close (same
|
||||
// exposure as NFS), so shrink chunk_bytes to the floor rather
|
||||
// than whatever adaptive sizing had grown it to (up to 256 MiB).
|
||||
self.chunk_bytes = CHUNK_BYTES_MIN;
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
@@ -272,7 +263,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={}",
|
||||
@@ -294,10 +285,9 @@ impl WritebackPipeline {
|
||||
if self.wait_after_window.len() < ADAPTIVE_WINDOW {
|
||||
return;
|
||||
}
|
||||
// p95 index, derived from the window size so it stays valid if
|
||||
// ADAPTIVE_WINDOW changes (a hard-coded `[14]` would panic OOB
|
||||
// for a window <= 14). For the default 16 this is index 15
|
||||
// (ceil(16 * 95 / 100) - 1 = 15), i.e. the top sample.
|
||||
// p95 index, derived from window size so it stays valid if ADAPTIVE_WINDOW
|
||||
// changes (a hard-coded `[14]` would panic OOB for a window <= 14). For the
|
||||
// default 16 this is index 15, i.e. the top sample.
|
||||
let mut sorted: Vec<u64> = self.wait_after_window.iter().copied().collect();
|
||||
sorted.sort_unstable();
|
||||
let p95_idx = (ADAPTIVE_WINDOW * 95).div_ceil(100).min(ADAPTIVE_WINDOW) - 1;
|
||||
@@ -442,10 +432,8 @@ fn wait_after_with_timeout(
|
||||
Err(crate::io::bounded::BoundedError::Timeout)
|
||||
| Err(crate::io::bounded::BoundedError::Halted) => None,
|
||||
Err(crate::io::bounded::BoundedError::WorkerLost) => {
|
||||
// Worker thread spawn failed or panicked before sending.
|
||||
// Treat as a benign success (no syscall ran) rather than
|
||||
// a degrade trigger — falling through with elapsed_ms=0
|
||||
// matches the no-op behaviour.
|
||||
// Worker thread spawn failed or panicked before sending. Treat as benign
|
||||
// success (no syscall ran), not a degrade trigger — elapsed_ms=0 matches no-op.
|
||||
Some(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,9 @@ use std::time::Duration;
|
||||
/// Best-effort: a non-zero rc is logged but not propagated, since the
|
||||
/// caller would just continue with the unreserved file anyway.
|
||||
pub(super) fn preallocate(file: &File, size_bytes: u64) {
|
||||
// FALLOC_FL_KEEP_SIZE = 0x01 — keep the reported file size at 0
|
||||
// (writes grow it normally) while still pre-reserving the extents.
|
||||
// Clamp to the signed `off_t` range; an unchecked `as i64` cast
|
||||
// would wrap a >= 2^63 size to a negative length (EINVAL no-op).
|
||||
// FALLOC_FL_KEEP_SIZE keeps the reported file size at 0 (writes grow it normally)
|
||||
// while pre-reserving extents. Clamp to `off_t` range; an unchecked `as i64`
|
||||
// cast would wrap a >= 2^63 size to a negative length (EINVAL no-op).
|
||||
let len = i64::try_from(size_bytes).unwrap_or(i64::MAX);
|
||||
let rc = unsafe { libc::fallocate(file.as_raw_fd(), libc::FALLOC_FL_KEEP_SIZE, 0, len) };
|
||||
tracing::debug!(
|
||||
@@ -30,14 +29,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 +78,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 +123,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user