mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f03c6db06 | ||
|
|
562bb8bee5 | ||
|
|
7c13ed32b5 | ||
|
|
574aa2c506 | ||
|
|
3912762b52 | ||
|
|
028768e71b | ||
|
|
bf4e5ac395 | ||
|
|
11948d513b | ||
|
|
7d5c0a5c41 | ||
|
|
1ede962aec | ||
|
|
014ea870a4 | ||
|
|
bdd2876d78 | ||
|
|
44de7a8d12 | ||
|
|
552076a943 | ||
|
|
3ecbeb7eba | ||
|
|
b405154fd0 | ||
|
|
8a630c8839 | ||
|
|
6c00090308 | ||
|
|
7f9f5a9d3e | ||
|
|
b3c23c19ee | ||
|
|
f3923cf587 | ||
|
|
002912abb7 | ||
|
|
b5fd193d54 | ||
|
|
49011d577f | ||
|
|
f8e39abe54 | ||
|
|
65d719ee5d | ||
|
|
c3bdbdcfc9 | ||
|
|
3f4e69f7b8 | ||
|
|
89b03610e7 | ||
|
|
a7cbb3afcd | ||
|
|
f75766b4d5 | ||
|
|
1c313fe882 | ||
|
|
f7d38caf0b | ||
|
|
e98e98c8fd | ||
|
|
7c085db133 | ||
|
|
587e26353c | ||
|
|
3eb3e649b1 | ||
|
|
b01505983c | ||
|
|
87bc07dad8 | ||
|
|
743c5830b6 | ||
|
|
80c8e4ca24 | ||
|
|
f49e79c198 | ||
|
|
231cbf3d76 | ||
|
|
21c7b12e2b | ||
|
|
799039df21 | ||
|
|
497b7726dc | ||
|
|
94b549a9d8 | ||
|
|
fc2d35a19c | ||
|
|
3c51190c32 | ||
|
|
dfe0390445 | ||
|
|
8678a2bf94 | ||
|
|
d953b46023 | ||
|
|
443ac6bcb1 | ||
|
|
794393f24e | ||
|
|
c6e2614c4c | ||
|
|
b02c402dae | ||
|
|
128e3bb120 | ||
|
|
dc100f1c80 | ||
|
|
286b5d4527 | ||
|
|
d9f3f82c65 | ||
|
|
e30209091e | ||
|
|
ac46e2382f | ||
|
|
862c1460c3 | ||
|
|
fea64d07e2 | ||
|
|
6e8da15c34 | ||
|
|
8898351cb1 | ||
|
|
7a03be5496 | ||
|
|
de94e12864 | ||
|
|
21ec1d3f80 | ||
|
|
6658a80819 | ||
|
|
3db11a192c | ||
|
|
a425a71cd5 | ||
|
|
f79745d815 | ||
|
|
5d8f77e049 | ||
|
|
79a16a9279 | ||
|
|
219a77b581 | ||
|
|
be378cbc34 | ||
|
|
63200a1d1f | ||
|
|
526dd3552b | ||
|
|
8d17904459 | ||
|
|
1bdb8add2f | ||
|
|
c9562c07a3 | ||
|
|
7f9dcf1870 | ||
|
|
3c5c681399 | ||
|
|
43d9fb583a | ||
|
|
60ed45c047 | ||
|
|
07cc9fa5f9 | ||
|
|
1e6bb8babd | ||
|
|
64f894c9f3 | ||
|
|
3820348d2f | ||
|
|
102ae82933 | ||
|
|
9d05f54ddb | ||
|
|
47abd1270b | ||
|
|
96c23ffb87 | ||
|
|
f2b0e00bea | ||
|
|
bca886a9c3 | ||
|
|
7cda914f29 | ||
|
|
11538981de | ||
|
|
8edb53942c | ||
|
|
c718cd9e9e | ||
|
|
e32f874387 | ||
|
|
b1f9adc82b | ||
|
|
16a894f835 | ||
|
|
9bcd752216 | ||
|
|
c1cd2e4b06 | ||
|
|
cbafe71769 | ||
|
|
3c1375a245 | ||
|
|
651bfaaa2e | ||
|
|
cce4e5effe | ||
|
|
8e6f1511ea | ||
|
|
d68d73e68d | ||
|
|
648bcce5b6 | ||
|
|
45af2152a8 | ||
|
|
abbc725acf | ||
|
|
2e3ba374f3 | ||
|
|
84b4a6ddae | ||
|
|
3f4bf3f290 | ||
|
|
edb6f0035b | ||
|
|
5fc28056c3 | ||
|
|
51de027d15 | ||
|
|
96282ecda7 | ||
|
|
6e2ee9e002 | ||
|
|
0f8035b15a | ||
|
|
f67fd92b60 | ||
|
|
7ced0d9b61 | ||
|
|
c2c00a874c | ||
|
|
87463ed3ee | ||
|
|
0c5e419b07 | ||
|
|
218cd1f6f8 | ||
|
|
2c4851e6c1 | ||
|
|
7511adb64e | ||
|
|
84a6c0d373 | ||
|
|
7cc5b0c93d | ||
|
|
f4fc730d83 | ||
|
|
db0ca3367f | ||
|
|
cc870a4614 | ||
|
|
211d293aee | ||
|
|
abf47137ef | ||
|
|
00332e4dba | ||
|
|
6152028271 | ||
|
|
abe464d799 | ||
|
|
862c4e90ab | ||
|
|
7cfeadbcd3 | ||
|
|
9742fe00fd | ||
|
|
e814310a3e | ||
|
|
480c873be1 | ||
|
|
5043baaebe | ||
|
|
4d63a11d63 | ||
|
|
2310e65f44 | ||
|
|
4bd1c9210c | ||
|
|
bfce73e73c | ||
|
|
ca4e6f5a13 | ||
|
|
0158112c35 | ||
|
|
b55884675c | ||
|
|
455d953144 | ||
|
|
c8eafad5ef | ||
|
|
fe795fb472 | ||
|
|
5b095c56b6 | ||
|
|
383adacec3 | ||
|
|
5115eb14da | ||
|
|
4da6d3ab70 | ||
|
|
5f0cd2e3fe | ||
|
|
308063196a | ||
|
|
b14497dfba | ||
|
|
f6e167ff2d | ||
|
|
f982ce1046 | ||
|
|
f21cbdef80 | ||
|
|
7ad5cdaf41 | ||
|
|
af64089978 | ||
|
|
a2e6debac4 | ||
|
|
fd1918ef08 | ||
|
|
8636497505 | ||
|
|
e78c8e9802 | ||
|
|
d6542eda10 | ||
|
|
08bdd51588 | ||
|
|
9d19781b8a | ||
|
|
030f7c5f34 | ||
|
|
4dd0848d62 | ||
|
|
49d5ca2a67 | ||
|
|
f39762e09b | ||
|
|
126765ca19 | ||
|
|
bbc07ba437 | ||
|
|
b97b419f76 | ||
|
|
eac0040b95 | ||
|
|
a939b5b2e1 | ||
|
|
2ccbb9d417 | ||
|
|
2d06639963 | ||
|
|
56921af7c9 | ||
|
|
322381c429 | ||
|
|
309997f09f | ||
|
|
dc65517f55 | ||
|
|
20fd5c3f63 | ||
|
|
284c19f15a | ||
|
|
1f1a17d23f | ||
|
|
1ca47ce406 | ||
|
|
6889e6a032 | ||
|
|
794e757889 | ||
|
|
ddb36e2c15 | ||
|
|
530b4a1925 | ||
|
|
6153690a90 | ||
|
|
65dd9ce9dc | ||
|
|
f72a3d59a3 | ||
|
|
efae55fa2f | ||
|
|
ef934ed308 | ||
|
|
2c3e372e9b | ||
|
|
00abead473 | ||
|
|
f655befb51 | ||
|
|
a6585df923 | ||
|
|
823ec82872 | ||
|
|
5049e6515f | ||
|
|
ba3ce1e6a5 | ||
|
|
94f3d106f9 | ||
|
|
a24bf3b96d | ||
|
|
cc35b7af66 | ||
|
|
1faf2ce51b | ||
|
|
d0ca927a9e | ||
|
|
093c90481b | ||
|
|
cb8cd21b21 | ||
|
|
1fd612954b | ||
|
|
139abb721b | ||
|
|
2d9b648486 | ||
|
|
08da31883b | ||
|
|
871471edcf | ||
|
|
0890d84d4d | ||
|
|
f1c2b2c158 | ||
|
|
a8e7d46400 | ||
|
|
94f087d5ab | ||
|
|
d706d4b49e | ||
|
|
d7bf829b01 | ||
|
|
cb5a918daa | ||
|
|
66a3506df3 | ||
|
|
8e70f2a973 | ||
|
|
fc1f4db3a2 | ||
|
|
41e0982ec9 | ||
|
|
0469845c2d | ||
|
|
d72084e077 | ||
|
|
f2ca0e48b0 | ||
|
|
b79015f822 | ||
|
|
c7e43b2bc4 | ||
|
|
5b81c33c9a | ||
|
|
3ccee19101 | ||
|
|
337b57e9dc | ||
|
|
0e50b74211 | ||
|
|
c56b1d0b85 | ||
|
|
20e3aa26ca | ||
|
|
7e4c22e36c | ||
|
|
d628e24eaa | ||
|
|
9bea6ab52b | ||
|
|
0a88b61421 | ||
|
|
ef0cf028e4 | ||
|
|
766fb68e04 | ||
|
|
45fbaa8ee2 | ||
|
|
40ac369fb1 | ||
|
|
f5e65cf2da | ||
|
|
ade13a35de | ||
|
|
816a41a01f | ||
|
|
ae33563587 | ||
|
|
9a88efcc84 | ||
|
|
719a9077f3 | ||
|
|
de97c5a607 | ||
|
|
424b0fa32e | ||
|
|
81596e7735 | ||
|
|
dce6d7abdf | ||
|
|
6e9a53e151 | ||
|
|
75bcc71f34 | ||
|
|
de46268785 | ||
|
|
22f71bb243 | ||
|
|
c73d6c6291 | ||
|
|
41102bf023 | ||
|
|
07cb9ec62c | ||
|
|
455763587c | ||
|
|
4b58d3db98 | ||
|
|
d451d00f72 | ||
|
|
f6ba4823d1 | ||
|
|
85f2246b0f | ||
|
|
61f98535d8 | ||
|
|
36d0801f14 | ||
|
|
656c6f36d7 | ||
|
|
7d861f5287 | ||
|
|
032994be40 | ||
|
|
a61503502e | ||
|
|
e5e3b863fd | ||
|
|
36c1469026 | ||
|
|
4aff402e9d | ||
|
|
fae5282eb9 | ||
|
|
2398a57979 | ||
|
|
5ef391f029 | ||
|
|
ef36213331 | ||
|
|
4b13fc0a5c | ||
|
|
b2abff9792 | ||
|
|
77b8e520f8 | ||
|
|
5a4631a9e1 | ||
|
|
69d96e3f02 | ||
|
|
230fc0b322 | ||
|
|
d79cb0d6eb | ||
|
|
77d2198499 | ||
|
|
95c05d97c9 | ||
|
|
f40e01de4b | ||
|
|
5172803566 | ||
|
|
0c2dbdba6e | ||
|
|
1994f42ff8 | ||
|
|
4993f5df79 | ||
|
|
a10f43841f | ||
|
|
98fe02746b | ||
|
|
99ac4268b1 | ||
|
|
8737c5bb65 | ||
|
|
a124be8a76 | ||
|
|
8cc868ca54 | ||
|
|
e768d1666b | ||
|
|
1f643cf91a | ||
|
|
b299e5d8db | ||
|
|
ffc7f93aeb | ||
|
|
3c53eff257 | ||
|
|
f0dca35a91 | ||
|
|
4e0c0efbc3 | ||
|
|
ac51108e95 | ||
|
|
8c0eb665b3 | ||
|
|
5e33fa4771 | ||
|
|
a7b16e3a1e | ||
|
|
298262f6b3 | ||
|
|
2999daf1c9 | ||
|
|
31a87ae994 | ||
|
|
6be489a10a | ||
|
|
922a9d4403 | ||
|
|
b70da49f7f | ||
|
|
c52240b81f | ||
|
|
16663841d0 | ||
|
|
5670e77def | ||
|
|
0b44020d08 | ||
|
|
4c73256bdd | ||
|
|
925f4d56f6 | ||
|
|
4ba2c728b2 | ||
|
|
6f2e9a74bd | ||
|
|
071b65a64e | ||
|
|
b943f7511c | ||
|
|
8d730f3d27 | ||
|
|
6e15dbaec7 | ||
|
|
2cf42ec9f3 | ||
|
|
63334dcc8b | ||
|
|
07c0a5300a | ||
|
|
8cac6111a8 | ||
|
|
9c144d1980 | ||
|
|
b2ebae8f81 | ||
|
|
b2f35a8afc | ||
|
|
a6a4a5d7d2 | ||
|
|
0de6b82a37 | ||
|
|
b9b0a87328 | ||
|
|
d6cef9f665 | ||
|
|
b9b9185633 | ||
|
|
66951a32ed | ||
|
|
505a78efd1 | ||
|
|
9b80084220 | ||
|
|
b246169e5b | ||
|
|
2d96a3e87d | ||
|
|
bae5a9ea07 | ||
|
|
86ed27508d | ||
|
|
f28d79b51b | ||
|
|
5b27b9f8a5 | ||
|
|
405e9d32c3 | ||
|
|
21b362c6e8 | ||
|
|
9f8df95513 | ||
|
|
2f465f4cfe | ||
|
|
3fdd463794 | ||
|
|
6a7392ac8c | ||
|
|
28bdecd26b | ||
|
|
d29d3d71b9 | ||
|
|
999898a438 | ||
|
|
b4470ccd81 | ||
|
|
668dd95237 | ||
|
|
777d9a04a7 | ||
|
|
b717044ae5 | ||
|
|
379767071c | ||
|
|
6a06443dd8 | ||
|
|
42df399156 | ||
|
|
9ede4b9afd | ||
|
|
67e3e39499 | ||
|
|
42be09f628 | ||
|
|
8bbbe43724 | ||
|
|
95888f27a4 | ||
|
|
96dec65040 | ||
|
|
378fb226b7 | ||
|
|
42caa2d90e | ||
|
|
41a2493429 | ||
|
|
0e73650440 | ||
|
|
bcfd5eb213 | ||
|
|
8ce0a66bee | ||
|
|
7d4d9a3e84 | ||
|
|
4dec71a867 | ||
|
|
a9b29c7060 | ||
|
|
0cb525517b | ||
|
|
5b4a3f32dc | ||
|
|
66b0bc385c | ||
|
|
aaccd83750 | ||
|
|
40f5b01a12 | ||
|
|
82d42b1930 | ||
|
|
913b01b79f | ||
|
|
af7ef59f3e | ||
|
|
ef8e11b6f3 | ||
|
|
e1f897fb32 | ||
|
|
602e44fde9 | ||
|
|
622008650c | ||
|
|
c508484451 | ||
|
|
cf16f66e07 | ||
|
|
adcab75409 | ||
|
|
61e4095727 | ||
|
|
540f84c7d0 | ||
|
|
82c4e018d5 | ||
|
|
08e962694f | ||
|
|
37e563b8ef | ||
|
|
e659613ab8 | ||
|
|
eaa7ddc683 | ||
|
|
723c7edf45 | ||
|
|
d9881c57c0 | ||
|
|
61b4486ae9 | ||
|
|
72d39544ac | ||
|
|
1e394eaeb8 | ||
|
|
4d1aa6bbf7 | ||
|
|
9ce03b4d1d | ||
|
|
f099a4a31a | ||
|
|
e606b5e3a2 | ||
|
|
de809189ec | ||
|
|
253b0924e3 | ||
|
|
98a7892c34 | ||
|
|
b82d21756a | ||
|
|
02c584a3d9 | ||
|
|
564e66fd24 | ||
|
|
57d40e81a5 | ||
|
|
674819d937 | ||
|
|
c5e8421f9f | ||
|
|
7dcf9b5a0d | ||
|
|
f01c097784 | ||
|
|
c265fa349e | ||
|
|
6a59dcb7ba | ||
|
|
0eb1817ec7 | ||
|
|
21f3b6adbd | ||
|
|
b32d636c64 | ||
|
|
d877630c45 | ||
|
|
1895095f49 | ||
|
|
5f8c049444 | ||
|
|
7d427d0b17 | ||
|
|
0a47b22db5 | ||
|
|
2ebcb60059 | ||
|
|
f46d99a348 | ||
|
|
4d93d90561 | ||
|
|
4eeafd3378 | ||
|
|
a73ba98f82 | ||
|
|
7676f3f2f6 | ||
|
|
e1cfe59dc9 | ||
|
|
c43f23a759 | ||
|
|
770e0ab936 | ||
|
|
5557765a86 | ||
|
|
458b04ae63 | ||
|
|
c31ac6b66b | ||
|
|
3e84ecfe42 | ||
|
|
998f82dc07 | ||
|
|
b80af33775 | ||
|
|
054902cefe | ||
|
|
aa277224a9 | ||
|
|
26154b6977 | ||
|
|
bb90991e05 | ||
|
|
175f131008 | ||
|
|
af2f750629 | ||
|
|
f1517f7313 | ||
|
|
984774b068 | ||
|
|
ddc44e1105 | ||
|
|
b8a51ef1c7 | ||
|
|
08cb80aac1 | ||
|
|
60d0ebc8a1 | ||
|
|
5dee947007 | ||
|
|
a1796e8345 | ||
|
|
5cf4252e44 | ||
|
|
b5f9c4e426 | ||
|
|
82dca307ad | ||
|
|
a9a21d8139 | ||
|
|
8dc7075356 | ||
|
|
02218a6511 | ||
|
|
e32a3af898 | ||
|
|
1253db037f | ||
|
|
4b878dcc06 | ||
|
|
7511b0b808 | ||
|
|
707989bd68 | ||
|
|
57b2f0c4f9 | ||
|
|
47406a7e32 | ||
|
|
03cbda741a |
@@ -0,0 +1,63 @@
|
||||
version: 2
|
||||
|
||||
# Dependency updates land on `dev`, never on `main`.
|
||||
#
|
||||
# `main` here is a RELEASE POINTER that release.sh moves to each tag. A bot
|
||||
# commit on it would put work there that no tag contains, which is exactly the
|
||||
# state that aborted the 1.6.2 cascade at the last step -- so pointing
|
||||
# Dependabot at main would recreate that failure on a schedule.
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
# One PR per week for the routine bumps instead of one per crate. Eight
|
||||
# repos times a handful of crates is a volume nobody reads, and an
|
||||
# unread PR queue is indistinguishable from no updates at all.
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
ignore:
|
||||
# The freemkv crates depend on each other by GIT TAG, re-pinned by
|
||||
# release.sh as part of the release commit. Dependabot cannot see that
|
||||
# cascade, so a PR bumping one of these would fight the release process
|
||||
# and could pin a version whose tag does not exist yet.
|
||||
- dependency-name: freemkv-unlock
|
||||
- dependency-name: libfreemkv
|
||||
- dependency-name: freemkv-keysources
|
||||
- dependency-name: freemkv-i18n
|
||||
- dependency-name: freemkv-engine
|
||||
|
||||
# The workflows are now real infrastructure -- the release cascade, the
|
||||
# cross-platform hash matrix, the disc gate -- so their actions need the same
|
||||
# attention as the crates.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
actions:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
ignore:
|
||||
# NOT a dependency: `dtolnay/rust-toolchain` is versioned by the RUST
|
||||
# release it installs, and the tag we pin is the toolchain CI is pinned
|
||||
# to on purpose -- precommit.sh runs the same one locally so a lint that
|
||||
# passes on a developer's newer default cannot pass CI by accident.
|
||||
#
|
||||
# Dependabot reads those tags as semver and proposed 1.97.0 -> 1.100.0,
|
||||
# a Rust version that does not exist. Every such PR 404s on toolchain
|
||||
# download across all eight repos, and they regenerate weekly -- eight
|
||||
# permanently-red PRs that promote.yml then has to special-case when it
|
||||
# decides whether dev is green.
|
||||
#
|
||||
# Bumping the toolchain is a deliberate, all-eight-repos change, made by
|
||||
# hand together with precommit.sh. There is nothing here for a bot.
|
||||
- dependency-name: dtolnay/rust-toolchain
|
||||
+255
-10
@@ -2,50 +2,295 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# dev -> qa -> main. `dev` is where work lands and is meant to be pushed
|
||||
# to often: these are the FAST checks, so a mistake surfaces in minutes.
|
||||
# `qa` is the release candidate — it runs these too, plus the expensive
|
||||
# suite in qa.yml. `main` only ever moves at release time, to a tagged
|
||||
# commit that was already green on qa.
|
||||
branches: [main, dev, qa]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
# CI owns the changelog: main may never carry a released version without a
|
||||
# CHANGELOG.md entry for it. The release orchestrator dates an existing
|
||||
# `## [X.Y.Z]` heading — it does NOT invent one — so a version bump that
|
||||
# forgot its changelog entry must fail here rather than ship a blank release.
|
||||
# Hard-fails on main (and PRs targeting main); warns elsewhere so an
|
||||
# in-progress dev/qa branch whose entry isn't written yet is not blocked.
|
||||
changelog:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Require a CHANGELOG entry for the crate version on main
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
if grep -qE "^## \[${version}\]" CHANGELOG.md; then
|
||||
echo "CHANGELOG.md has an entry for ${version}."
|
||||
exit 0
|
||||
fi
|
||||
msg="CHANGELOG.md has no '## [${version}]' entry"
|
||||
if [ "${GITHUB_REF_NAME:-}" = "main" ] || [ "${GITHUB_BASE_REF:-}" = "main" ]; then
|
||||
echo "::error::${msg} — main requires a changelog entry for the released version."
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::${msg} (allowed on ${GITHUB_REF_NAME:-unknown}; required before it can reach main)."
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
|
||||
# swaps it to a git tag only inside the tagged commit, then restores the
|
||||
# path dep). CI checks out one repo, so the branch tip has never been
|
||||
# buildable here — every green run you have ever seen was a tag build,
|
||||
# and Windows/Linux were first compiled at release time.
|
||||
#
|
||||
# Both repos go into subdirectories because actions/checkout refuses a
|
||||
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
|
||||
# With this layout the path dep resolves exactly as it does locally.
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo fmt --check
|
||||
working-directory: libfreemkv
|
||||
# libfreemkv is a library — Cargo.lock is gitignored. --locked
|
||||
# would always fail on a fresh runner because there's no committed
|
||||
# lockfile to lock against. The binary crates (freemkv, autorip,
|
||||
# bdemu) track Cargo.lock and DO use --locked.
|
||||
- run: cargo clippy -- -D warnings
|
||||
# --all-targets so TEST code is linted too. Without it this crate — the
|
||||
# reference implementation for the other seven — was the only one whose
|
||||
# tests had never been linted at all, and it was hiding 74 findings.
|
||||
- run: cargo clippy --all-targets -- -D warnings
|
||||
working-directory: libfreemkv
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
|
||||
# swaps it to a git tag only inside the tagged commit, then restores the
|
||||
# path dep). CI checks out one repo, so the branch tip has never been
|
||||
# buildable here — every green run you have ever seen was a tag build,
|
||||
# and Windows/Linux were first compiled at release time.
|
||||
#
|
||||
# Both repos go into subdirectories because actions/checkout refuses a
|
||||
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
|
||||
# With this layout the path dep resolves exactly as it does locally.
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo test --tests
|
||||
working-directory: libfreemkv
|
||||
|
||||
check-macos:
|
||||
# dev is the FAST lane: this job still runs, but on the release-candidate
|
||||
# branches rather than on every push to dev. Nothing is deleted and no
|
||||
# platform stops being checked before a release -- qa.yml independently
|
||||
# covers macOS and Windows, and the jobs unique to this file (the Intel
|
||||
# macOS build, the Windows release build) run here on qa and main. A push
|
||||
# to dev is meant to be cheap and frequent; waiting on three runner pools
|
||||
# to agree is what a release candidate is for.
|
||||
#
|
||||
# `if` SKIPS the job (it does not queue). A queued job would be far worse
|
||||
# than a slow one: release.sh's CI gate refuses while any run for the
|
||||
# commit is still in progress, so a never-scheduled job blocks releases
|
||||
# silently -- see the note on real-media in qa.yml.
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
|
||||
# swaps it to a git tag only inside the tagged commit, then restores the
|
||||
# path dep). CI checks out one repo, so the branch tip has never been
|
||||
# buildable here — every green run you have ever seen was a tag build,
|
||||
# and Windows/Linux were first compiled at release time.
|
||||
#
|
||||
# Both repos go into subdirectories because actions/checkout refuses a
|
||||
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
|
||||
# With this layout the path dep resolves exactly as it does locally.
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo check
|
||||
working-directory: libfreemkv
|
||||
|
||||
check-windows:
|
||||
# dev is the FAST lane: this job still runs, but on the release-candidate
|
||||
# branches rather than on every push to dev. Nothing is deleted and no
|
||||
# platform stops being checked before a release -- qa.yml independently
|
||||
# covers macOS and Windows, and the jobs unique to this file (the Intel
|
||||
# macOS build, the Windows release build) run here on qa and main. A push
|
||||
# to dev is meant to be cheap and frequent; waiting on three runner pools
|
||||
# to agree is what a release candidate is for.
|
||||
#
|
||||
# `if` SKIPS the job (it does not queue). A queued job would be far worse
|
||||
# than a slow one: release.sh's CI gate refuses while any run for the
|
||||
# commit is still in progress, so a never-scheduled job blocks releases
|
||||
# silently -- see the note on real-media in qa.yml.
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
|
||||
# swaps it to a git tag only inside the tagged commit, then restores the
|
||||
# path dep). CI checks out one repo, so the branch tip has never been
|
||||
# buildable here — every green run you have ever seen was a tag build,
|
||||
# and Windows/Linux were first compiled at release time.
|
||||
#
|
||||
# Both repos go into subdirectories because actions/checkout refuses a
|
||||
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
|
||||
# With this layout the path dep resolves exactly as it does locally.
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
# Build the tests (not just `cargo check`): catches errors in test
|
||||
# code and forces full codegen of the Windows-only SPTI transport
|
||||
# (src/scsi/windows.rs), which never compiles on the Linux/macOS dev
|
||||
# hosts. We don't `cargo test` here — the suite needs no drive but the
|
||||
# extra build is the value; running tests is covered by the Linux job.
|
||||
- run: cargo build --tests
|
||||
working-directory: libfreemkv
|
||||
|
||||
# ── Did this change break anything downstream? ──────────────────────────────
|
||||
#
|
||||
# Every job above proves libfreemkv builds. None proved its DEPENDENTS do,
|
||||
# and that gap is real: an engine signature change broke autorip today and
|
||||
# went unnoticed because consumer CI only fires on a push to that consumer.
|
||||
# libfreemkv sits below all five of them, so a break here is worth strictly
|
||||
# more than a break anywhere else in the project.
|
||||
#
|
||||
# `cargo check --all-targets` only — each dependent owns its own behaviour
|
||||
# and has its own suite. The question here is just "does everything built on
|
||||
# me still compile against this commit".
|
||||
consumers:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with: { path: libfreemkv }
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv-unlock, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-unlock }
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv-keysources, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-keysources }
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv-engine, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-engine }
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv-i18n, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-i18n }
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv }
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/autorip, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: autorip }
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/bdemu, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: bdemu }
|
||||
- name: Point every dependent at THIS libfreemkv commit
|
||||
shell: bash
|
||||
run: |
|
||||
for c in freemkv-keysources freemkv-engine freemkv autorip bdemu; do
|
||||
mkdir -p "$c/.cargo"
|
||||
cat > "$c/.cargo/config.toml" <<'EOF'
|
||||
[patch.crates-io]
|
||||
libfreemkv = { path = "../libfreemkv" }
|
||||
freemkv-keysources = { path = "../freemkv-keysources" }
|
||||
freemkv-engine = { path = "../freemkv-engine" }
|
||||
freemkv-i18n = { path = "../freemkv-i18n" }
|
||||
EOF
|
||||
done
|
||||
# The consumers span a higher MSRV than libfreemkv itself (the bins are
|
||||
# 1.98, keysources 1.97), so build them with a toolchain that satisfies the
|
||||
# HIGHEST dependent floor — libfreemkv's own 1.94 cannot compile them.
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: |
|
||||
freemkv-keysources
|
||||
freemkv-engine
|
||||
freemkv
|
||||
autorip
|
||||
bdemu
|
||||
# `cargo check` alone only proves the dependents still COMPILE against
|
||||
# this commit. It cannot see a behavioural change — the library keeps its
|
||||
# signatures and a dependent's tests start failing. That is the shape of
|
||||
# every defect worth catching here, so run their suites too.
|
||||
- run: cargo test --tests
|
||||
working-directory: freemkv-keysources
|
||||
- run: cargo test --tests
|
||||
working-directory: freemkv-engine
|
||||
- run: cargo test --tests
|
||||
working-directory: freemkv
|
||||
- run: cargo test --tests
|
||||
working-directory: autorip
|
||||
- run: cargo test --tests
|
||||
working-directory: bdemu
|
||||
|
||||
# ── Coverage (non-required, additive) ───────────────────────────────────────
|
||||
#
|
||||
# Reports line coverage to Codecov. Deliberately NOT wired into any release
|
||||
# gate: it is informational only (see codecov.yml) and no other job `needs:`
|
||||
# it. Mirrors the `test` job's layout exactly — same pinned 1.94 toolchain,
|
||||
# same dual checkout so the `../freemkv-unlock` path dep resolves, same cache
|
||||
# workspace and working-directory. cargo-llvm-cov comes as a prebuilt binary
|
||||
# from taiki-e/install-action, so it is toolchain-independent.
|
||||
#
|
||||
# No `--locked`: like the fmt/clippy/test jobs above, this crate's Cargo.lock
|
||||
# is gitignored, so there is nothing to lock against on a fresh runner.
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- uses: taiki-e/install-action@cargo-llvm-cov
|
||||
- run: cargo llvm-cov --workspace --ignore-run-fail --lcov --output-path lcov.info
|
||||
working-directory: libfreemkv
|
||||
- uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: libfreemkv/lcov.info
|
||||
slug: freemkv/libfreemkv
|
||||
fail_ci_if_error: false
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
leak-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Compute commit range
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
name: qa
|
||||
|
||||
# ── The qa gate: "is this production worth?" ────────────────────────────────
|
||||
#
|
||||
# dev -> qa -> main.
|
||||
#
|
||||
# `dev` is for committing often. ci.yml answers "is it green" in minutes with
|
||||
# fmt, clippy and the unit suite, so a mistake surfaces while it is still cheap
|
||||
# to fix. `qa` is the release-candidate branch, and THIS workflow is the claim
|
||||
# that a commit is production worth: everything expensive that can run without
|
||||
# physical media. `main` only ever receives a qa that went green here.
|
||||
#
|
||||
# Sibling repos are checked out at `qa`, NOT `dev`. A qa run that resolved its
|
||||
# dependencies from dev tips would be validating a combination that is not the
|
||||
# one being released, which is the exact failure this branch exists to prevent.
|
||||
#
|
||||
# What this gate CANNOT cover: `disc://` and real `iso://` need physical media,
|
||||
# and no hosted runner has an optical drive or the image hoard. Those run on a
|
||||
# self-hosted runner (see the media job at the end) and are the one leg that
|
||||
# stays on hardware.
|
||||
on:
|
||||
push:
|
||||
branches: [qa]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# ── Name the candidate ────────────────────────────────────────────────
|
||||
#
|
||||
# Every push to `qa` is a release candidate, so every push gets a tag:
|
||||
# v<version>-rc<N>, N incrementing. That is the answer to "which build is on
|
||||
# qa right now, and is it the one I tested?" — a question that otherwise gets
|
||||
# answered from memory.
|
||||
#
|
||||
# This runs FIRST and does not depend on the gates, deliberately. A red
|
||||
# candidate needs a name more than a green one does: "rc3 failed
|
||||
# release-tests on windows" is a sentence you can act on; "qa is red" is not.
|
||||
# Red on qa is a working gate, not an incident — it is the branch saying this
|
||||
# is not production worth yet. Fix on dev, get dev green, push qa again.
|
||||
#
|
||||
# release.yml excludes v*-rc* so a candidate never publishes a release.
|
||||
rc-tag:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Stamp the next rc
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
[ -n "$v" ] || { echo "no version in Cargo.toml" >&2; exit 1; }
|
||||
# Numeric sort on the rc ordinal: -rc10 must beat -rc9, and a plain
|
||||
# lexical sort gets that backwards from the tenth candidate on.
|
||||
n=$(git tag -l "v$v-rc*" | sed "s|^v$v-rc||" | sort -n | tail -1)
|
||||
tag="v$v-rc$(( ${n:-0} + 1 ))"
|
||||
git tag "$tag"
|
||||
git push origin "$tag"
|
||||
echo "### Candidate \`$tag\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The debug suite runs on every dev push. Release is a DIFFERENT build:
|
||||
# overflow checks are off, debug_assert! is compiled out, and inlining
|
||||
# changes what the optimiser can prove. A test that only passes in debug is
|
||||
# a test that never guarded the binary anyone actually ships.
|
||||
release-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ['ubuntu-latest', 'macos-latest']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo test --release --tests
|
||||
working-directory: libfreemkv
|
||||
|
||||
# clippy's output is target-dependent: cfg-gated code only gets linted on
|
||||
# the target it compiles for. Linting solely on the dev machine's host
|
||||
# target is how a lint that CI rejects reaches a push.
|
||||
cross-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: rustup target add x86_64-unknown-linux-gnu
|
||||
- run: cargo clippy --all-targets --target x86_64-unknown-linux-gnu -- -D warnings
|
||||
working-directory: libfreemkv
|
||||
|
||||
# Windows compiles the tests but does not run them, matching the policy
|
||||
# ci.yml already set. The value here is codegen: the #[cfg(windows)] halves
|
||||
# of the SCSI transport and platform layers compile on no other runner, so
|
||||
# without this they are first built at release time.
|
||||
windows-build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.94.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo build --release --tests
|
||||
working-directory: libfreemkv
|
||||
@@ -4,6 +4,11 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
# NOT the release-candidate tags. Every push to `qa` stamps a
|
||||
# v<version>-rc<N> so a run can be named, and 'v*' matches those too —
|
||||
# which would have this workflow build and PUBLISH a GitHub release for
|
||||
# every candidate, including the red ones.
|
||||
- '!v*-rc*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -12,7 +17,7 @@ jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- name: Verify Cargo.toml version matches tag
|
||||
run: |
|
||||
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
|
||||
@@ -24,7 +29,7 @@ jobs:
|
||||
|
||||
# Tests run as a PARALLEL TRIPWIRE: they fail the run if they fail, but the
|
||||
# publish/release jobs do NOT `needs:` this job. The tag decision was already
|
||||
# gated by the local precommit (same Rust 1.86, same commit). Binary consumers
|
||||
# gated by the local precommit (same Rust 1.94, same commit). Binary consumers
|
||||
# (freemkv/autorip/bdemu) git-tag-pin libfreemkv and therefore start building
|
||||
# the instant this tag exists — so this test job and the crates.io publish
|
||||
# below must NOT sit on their critical path.
|
||||
@@ -32,36 +37,18 @@ 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).
|
||||
- run: cargo test
|
||||
|
||||
# crates.io publish is an INDEPENDENT job: it serves EXTERNAL consumers only.
|
||||
# The freemkv binaries no longer depend on it (they git-tag-pin libfreemkv via
|
||||
# a committed [patch.crates-io]), so this publish runs in parallel with their
|
||||
# release builds rather than gating them. It `needs: [verify, test]` so a
|
||||
# failing test suite still blocks publication to crates.io — external
|
||||
# consumers who `cargo add libfreemkv` must never receive a release whose
|
||||
# tests were failing. (The two upstream jobs run in parallel, so this gate
|
||||
# does not serialize publish behind test beyond their own completion.)
|
||||
publish:
|
||||
needs: [verify, test]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# --no-verify: CI already compiled this exact commit (in the `test` job
|
||||
# and on every push via ci.yml). cargo publish's default re-verify does a
|
||||
# full cold release build of the packaged tarball, which here is pure
|
||||
# redundant work (~a cold lib build). Skip it.
|
||||
- name: Publish to crates.io
|
||||
run: cargo publish --no-verify
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
# NOTE: there is no crates.io publish job. libfreemkv is git-tag-only
|
||||
# (`package.publish = false` — it git-deps the firmware crate freemkv-unlock,
|
||||
# which never ships to crates.io). Every consumer git-tag-pins libfreemkv via
|
||||
# a committed [patch.crates-io]; the git tag itself IS the release artifact.
|
||||
# A `cargo publish` here fails hard on `publish = false`, so it was removed.
|
||||
|
||||
release:
|
||||
# Only needs `verify`; the GitHub Release can be cut as soon as the version
|
||||
@@ -69,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/
|
||||
|
||||
+943
-535
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.
|
||||
|
||||
|
||||
+1
-1
@@ -24,4 +24,4 @@ cargo test
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree your code will be licensed under AGPL-3.0.
|
||||
By contributing, you agree your code will be licensed under MIT.
|
||||
|
||||
+16
-15
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.2.1"
|
||||
version = "1.6.11"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
rust-version = "1.94"
|
||||
license = "MIT"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
repository = "https://github.com/freemkv/libfreemkv"
|
||||
keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
|
||||
@@ -22,21 +22,22 @@ codegen-units = 1
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
aes = "0.9"
|
||||
# Interim path dep for local cross-repo dev; the release script re-pins this to
|
||||
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
|
||||
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.2.1" }
|
||||
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"
|
||||
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.6.11" }
|
||||
rand = "0.10"
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
base64 = "0.23"
|
||||
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
|
||||
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
|
||||
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
|
||||
# parser, not a hand-rolled scanner: the XPL is genuine XML (comments, varied
|
||||
# attribute order, self-closing tags).
|
||||
roxmltree = "0.20"
|
||||
# Trace-level instrumentation for the read/transport path (SgIoTransport::execute
|
||||
# and friends). Permitted
|
||||
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
|
||||
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2026 FreeMKV Contributors
|
||||
Copyright (c) 2026 Matthew Jackson & Contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, version 3 of the License.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
[](LICENSE)
|
||||
[](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 |
|
||||
@@ -190,4 +159,4 @@ Run `freemkv info disc:// --share` with the [freemkv CLI](https://github.com/fre
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-only
|
||||
MIT
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 1.6.x | Yes |
|
||||
| < 1.6 | No |
|
||||
|
||||
Only the current 1.6.x line receives security fixes.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Report vulnerabilities privately through GitHub Security Advisories:
|
||||
https://github.com/freemkv/libfreemkv/security/advisories/new
|
||||
|
||||
Do not open a public issue for a security report. Include the affected
|
||||
version, steps to reproduce, and the impact you believe the issue has.
|
||||
|
||||
## Response time
|
||||
|
||||
You will get an initial response within 7 days.
|
||||
+3
-3
@@ -141,8 +141,8 @@ If your machine has a free SATA port, use it.
|
||||
|
||||
freemkv uses a three-layer recovery model. See [`docs/rip-recovery.md`](docs/rip-recovery.md) for full details.
|
||||
|
||||
- **Pass 1 (Disc::copy):** Fast sweep with 64 KB reads. On failure, zero-fills the block and skips forward. Writes a ddrescue-format mapfile for later retry.
|
||||
- **Pass 2+ (Disc::patch):** Targeted re-reads of bad ranges with a long 30-second timeout per CDB. The drive firmware performs its own ECC and laser power retries within that window.
|
||||
- **Pass 1 (`freemkv_engine::recovery::sweep`):** Fast sweep with 64 KB reads. On failure, zero-fills the block and skips forward. Writes a ddrescue-format mapfile for later retry.
|
||||
- **Pass 2+ (`freemkv_engine::recovery::patch`):** Targeted re-reads of bad ranges with a long 60-second timeout per CDB. The drive firmware performs its own ECC and laser power retries within that window.
|
||||
- **In-stream (DiscStream):** Adaptive batch halving -- reduces request size on failure to isolate bad sectors within a larger block.
|
||||
|
||||
This means a disc with some bad sectors will still produce a usable ISO. The damaged areas are zero-filled in pass 1 and retried in subsequent passes. Structure-protected sectors (deliberate unreadable regions from copy protection) will never yield, which is expected.
|
||||
@@ -302,7 +302,7 @@ Many external drive enclosures (Vantec NexStar, Sabrent, OWC, etc.) do not adver
|
||||
When something goes wrong during a rip, gather this information before filing an issue:
|
||||
|
||||
1. **freemkv version:** `freemkv --version` or the crate version in `Cargo.toml`.
|
||||
2. **Drive model:** from the drive label, or from `freemkv info`.
|
||||
2. **Drive model:** from the drive label, or from `freemkv info disc://`.
|
||||
3. **Connection type:** USB (with bridge chipset if known) or direct SATA.
|
||||
4. **Operating system and kernel:** `uname -a`.
|
||||
5. **Kernel messages during the failure:** `dmesg | tail -50` immediately after the crash.
|
||||
|
||||
@@ -22,7 +22,7 @@ fn main() {
|
||||
&target_arch // x86_64 → x86_64
|
||||
};
|
||||
|
||||
std::process::Command::new("cc")
|
||||
let cc_status = std::process::Command::new("cc")
|
||||
.args([
|
||||
"-arch",
|
||||
clang_arch,
|
||||
@@ -38,12 +38,19 @@ fn main() {
|
||||
"-O2",
|
||||
])
|
||||
.status()
|
||||
.expect("failed to compile macos_shim.c");
|
||||
.expect("failed to spawn cc for macos_shim.c");
|
||||
// `.status()` succeeding only means the process RAN. A real compile error
|
||||
// exits non-zero, and ignoring that left no object file, which surfaced
|
||||
// much later as an unexplained link failure against a missing symbol. The
|
||||
// shim is macOS-only and is neither linted nor compiled on the other two
|
||||
// platforms, so a mistake in it has exactly one chance to be noticed.
|
||||
assert!(cc_status.success(), "cc failed to compile macos_shim.c");
|
||||
|
||||
std::process::Command::new("ar")
|
||||
let ar_status = std::process::Command::new("ar")
|
||||
.args(["rcs", &lib, &obj])
|
||||
.status()
|
||||
.expect("failed to create static lib");
|
||||
.expect("failed to spawn ar");
|
||||
assert!(ar_status.success(), "ar failed to create the static lib");
|
||||
|
||||
println!("cargo:rustc-link-search=native={out_dir}");
|
||||
println!("cargo:rustc-link-lib=static=macos_scsi");
|
||||
@@ -77,10 +84,10 @@ fn emit_git_suffix() {
|
||||
// Re-run when HEAD (or the branch it points at) moves so the stamp stays
|
||||
// current without a clean rebuild.
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
if let Ok(head) = std::fs::read_to_string(".git/HEAD") {
|
||||
if let Some(ref_path) = head.strip_prefix("ref: ") {
|
||||
println!("cargo:rerun-if-changed=.git/{}", ref_path.trim());
|
||||
}
|
||||
if let Ok(head) = std::fs::read_to_string(".git/HEAD")
|
||||
&& let Some(ref_path) = head.strip_prefix("ref: ")
|
||||
{
|
||||
println!("cargo:rerun-if-changed=.git/{}", ref_path.trim());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
# Coverage is informational only: it never fails CI or blocks a merge/release.
|
||||
# The `coverage` job in .github/workflows/ci.yml is non-required and no gate
|
||||
# depends on it.
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
informational: true
|
||||
patch:
|
||||
default:
|
||||
informational: true
|
||||
|
||||
comment: false
|
||||
|
||||
ignore:
|
||||
- "tests"
|
||||
- "examples"
|
||||
- "benches"
|
||||
+1
-1
@@ -12,7 +12,7 @@ Technical documentation for [libfreemkv](https://github.com/freemkv/libfreemkv),
|
||||
|----------|---------------|
|
||||
| [Architecture](architecture.md) | Module map, design principles, error codes, platform support |
|
||||
| [Drive Access](drive-access.md) | Drive, SCSI transport, profiles, unlock, why raw mode is needed |
|
||||
| [Rip Recovery](rip-recovery.md) | Three-layer recovery model: Disc::patch, single-shot Drive::read, DiscStream batch halving |
|
||||
| [Rip Recovery](rip-recovery.md) | What this crate owns of the recovery model: single-shot Drive::read, SenseFamily, DiscStream batch halving (the strategy itself moved to freemkv-engine in 1.6.0) |
|
||||
| [AACS Encryption](aacs.md) | Key resolution (4 paths), content decryption, bus encryption, SCSI handshake |
|
||||
| [UDF Filesystem](udf.md) | UDF 2.50 with metadata partitions, pointer chain, how files are read from disc |
|
||||
| [MPLS Playlists](mpls.md) | Playlist format, play items, STN stream table, coding types |
|
||||
|
||||
+27
-16
@@ -65,27 +65,38 @@ if disc.encrypted {
|
||||
}
|
||||
}
|
||||
|
||||
// Read content -- decryption is automatic
|
||||
let mut reader = disc.open_title(&mut session, 0).unwrap();
|
||||
while let Some(unit) = reader.read_unit().unwrap() {
|
||||
// decrypted content
|
||||
// Read content -- decryption is applied on read by the DiscStream decorator.
|
||||
// Live disc does NOT go through the URL resolver: `input("disc://...")` returns
|
||||
// Error::DiscUrlNotDirect by design.
|
||||
let keys = disc.decrypt_keys();
|
||||
let mut stream = DiscStream::new(
|
||||
Box::new(drive),
|
||||
disc.titles[0].clone(),
|
||||
keys,
|
||||
batch_sectors,
|
||||
disc.titles[0].content_format,
|
||||
false, // raw: false → decrypt on read
|
||||
None, // halt
|
||||
)?;
|
||||
while let Ok(Some(frame)) = stream.read() {
|
||||
// decrypted PES frames
|
||||
}
|
||||
```
|
||||
|
||||
The application never touches keys, never calls decryption functions, and never
|
||||
manages handshakes. All of that is internal to `Disc::scan()` and the content
|
||||
reader.
|
||||
The application never calls decryption functions and never manages the
|
||||
drive-level handshake. It DOES own key resolution — see below.
|
||||
|
||||
### KEYDB Location
|
||||
### Key resolution is the caller's job
|
||||
|
||||
`ScanOptions` controls where the keydb is loaded from. If no explicit path is
|
||||
set, the library checks the standard config locations. To specify an explicit
|
||||
path:
|
||||
`libfreemkv` is **lookup-free: it resolves no keys and reads no keydb.** There is
|
||||
no `ScanOptions::with_keydb`, and `ScanOptions` has no keydb field — its only
|
||||
scan input is the optional drive credentials for the live-drive authenticated
|
||||
handshake.
|
||||
|
||||
```rust
|
||||
let opts = ScanOptions::with_keydb("/path/to/keydb.cfg");
|
||||
let disc = Disc::scan(&mut session, &opts).unwrap();
|
||||
```
|
||||
The caller resolves a key out-of-band through a key source and applies it with
|
||||
[`Disc::decrypt_with`]. `freemkv-keysources` is the crate that implements the
|
||||
keydb and key-server sources; `ScanOptions::key_sources` takes them as
|
||||
`Box<dyn KeySource>`.
|
||||
|
||||
### AacsState
|
||||
|
||||
@@ -97,7 +108,7 @@ After a successful scan, `disc.aacs` contains an `AacsState`:
|
||||
| `bus_encryption` | `bool` | Whether bus encryption is active |
|
||||
| `mkb_version` | `Option<u32>` | MKB version from disc |
|
||||
| `disc_hash` | `String` | Identifier for the disc's key-input files |
|
||||
| `key_source` | `KeySource` | How the disc's key was resolved |
|
||||
| `key_source` | `KeyOrigin` | How the disc's key was resolved |
|
||||
|
||||
## keydb.cfg
|
||||
|
||||
|
||||
+10
-7
@@ -170,17 +170,20 @@ libfreemkv/src/
|
||||
│ ├── writeback_file.rs WritebackFile (was crate::io::Writer)
|
||||
│ └── writeback.rs sync_file_range pipeline
|
||||
├── drive/ Drive (open, init, single-shot read)
|
||||
│ ├── mod.rs Drive struct, init, read (single-shot), reset, eject
|
||||
│ ├── mod.rs Drive struct, init, read (single-shot), eject
|
||||
│ ├── capture.rs Raw drive SCSI capture (INQUIRY/GET_CONFIG) for contribution
|
||||
│ ├── linux.rs Linux drive discovery
|
||||
│ ├── macos.rs macOS drive discovery
|
||||
│ └── windows.rs Windows drive discovery
|
||||
├── disc/ Disc (scan, titles, AACS setup, sweep, patch)
|
||||
│ ├── mod.rs Disc struct, scan, titles, formats; Disc::copy + Disc::sweep (Pass 1)
|
||||
│ ├── sweep.rs Pass 1 internal helpers (pub(super))
|
||||
│ ├── patch.rs Disc::patch (Pass N retry over mapfile)
|
||||
│ ├── mapfile.rs ddrescue-format mapfile
|
||||
│ └── read_error.rs ReadCtx / ReadAction state machine
|
||||
├── disc/ Disc (scan, titles, AACS setup, per-format parsing)
|
||||
│ ├── mod.rs Disc struct, scan, titles, formats
|
||||
│ ├── bluray.rs Blu-ray / UHD scanning (MPLS/CLPI-driven)
|
||||
│ ├── dvd.rs DVD-Video scanning (IFO-driven)
|
||||
│ ├── hddvd.rs HD-DVD scanning
|
||||
│ ├── extract.rs Per-extent content extraction
|
||||
│ ├── encrypt.rs Encrypted-range mapping for content reads
|
||||
│ ├── dvd_audio_probe.rs DVD audio-stream probing
|
||||
│ └── pgs_forced_probe.rs PGS forced-subtitle probing
|
||||
├── scsi/ SCSI transport (Linux SG_IO, macOS IOKit, Windows SPTI)
|
||||
├── unlock.rs Unlocker trait + registry (pluggable unlock seam)
|
||||
├── aacs/ AACS decryption (handshake, keys, keydb, decrypt)
|
||||
|
||||
@@ -7,7 +7,7 @@ AACS decryption requires an external `keydb.cfg` (default
|
||||
material is compiled in; DVD CSS player keys are the only compiled-in keys.
|
||||
|
||||
**Repository:** <https://github.com/freemkv/libfreemkv>
|
||||
**License:** AGPL-3.0-only
|
||||
**License:** MIT
|
||||
|
||||
---
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
//! AACS derivation "boil-down" — one public home for the key chain.
|
||||
//!
|
||||
//! Thin newtypes at the API boundary and three wrapper functions over the
|
||||
//! existing crypto. Nothing here re-implements a primitive: every function
|
||||
//! delegates to the already-audited code in [`super::keys`] and
|
||||
//! [`super::variants`], so the boil-down cannot drift from production math.
|
||||
//!
|
||||
//! The newtypes wrap bare `[u8; 16]` ONLY at this boundary — the crypto
|
||||
//! internals continue to operate on raw arrays. They exist so a caller threads
|
||||
//! the chain `DK → MK → VUK → UK` without confusing one 16-byte secret for
|
||||
//! another, not to refactor the resolver.
|
||||
//!
|
||||
//! Chain (matches `aacs::keys::resolve_keys_classical` path 1 and
|
||||
//! `aacs::keys::resolve_keys_v21` path 1 byte-for-byte):
|
||||
//!
|
||||
//! ```text
|
||||
//! mk_from_dk(device_keys, mkb, vid) → MediaKey (Km)
|
||||
//! mk_from_pk(processing_keys, mkb) → MediaKey (Km)
|
||||
//! vuk_from_mk(MediaKey, Vid) → Vuk (= AES-G(Km, VID))
|
||||
//! uk_from_vuk(Vuk, enc_title_keys) → [UnitKey] (decrypt_unit_key each)
|
||||
//! ```
|
||||
//!
|
||||
//! `mk_from_dk` and `mk_from_pk` are two entry points to the SAME Media Key:
|
||||
//! the device-key path walks the MKB's Media-Key-Variant chain, the
|
||||
//! processing-key path walks the MKB's Subset-Difference cvalue tables. Neither
|
||||
//! needs a VID (the VID enters at `vuk_from_mk`).
|
||||
|
||||
use super::keys::{decrypt_unit_key, derive_media_key_from_pk, derive_vuk};
|
||||
use super::types::DeviceKey;
|
||||
use super::variants::{KEY_CORRECTION_DATA_PLACEHOLDER, derive_media_key_variant, walk_mkb};
|
||||
|
||||
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
|
||||
#[derive(Debug, 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)]
|
||||
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)]
|
||||
pub struct Vuk(pub [u8; 16]);
|
||||
|
||||
/// One decrypted per-CPS-unit AACS title key.
|
||||
///
|
||||
/// `idx` is the POSITIONAL index of the encrypted title key within the slice
|
||||
/// handed to [`uk_from_vuk`] (i.e. its order in `Unit_Key_RO.inf`'s key-storage
|
||||
/// area). The CPS-unit *number* association (the `u32` in
|
||||
/// `ResolvedKeys::unit_keys`) is a higher-level concern owned by
|
||||
/// [`super::keys::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)]
|
||||
pub struct UnitKey {
|
||||
pub idx: u32,
|
||||
pub key: [u8; 16],
|
||||
}
|
||||
|
||||
/// Derive the Volume Unique Key from a Media Key and Volume ID.
|
||||
///
|
||||
/// Wraps [`derive_vuk`] verbatim: `VUK = AES-128-ECB-DECRYPT(MK, VID) XOR VID`.
|
||||
/// This is byte-identical to the inline `derive_vuk(&mk, ctx.volume_id)` call in
|
||||
/// every classical resolver path AND to the `Kvu = AES-G(Km, VID)` step inside
|
||||
/// [`derive_media_key_variant`] (AES-G and `derive_vuk` are the same math), so
|
||||
/// `vuk_from_mk(mk_from_dk(..)?, vid)` reproduces the V21 variant VUK exactly.
|
||||
pub fn vuk_from_mk(mk: MediaKey, vid: Vid) -> Vuk {
|
||||
Vuk(derive_vuk(&mk.0, &vid.0))
|
||||
}
|
||||
|
||||
/// Decrypt the disc's encrypted title keys with a VUK.
|
||||
///
|
||||
/// Wraps [`decrypt_unit_key`] (AES-128-ECB-DECRYPT) per entry, mirroring the
|
||||
/// `derive_uks` closure in `resolve_keys_classical` / `resolve_keys_v21`. The
|
||||
/// returned `UnitKey::idx` is the slice position; pair with CPS-unit numbers via
|
||||
/// [`super::keys::parse_unit_key_ro`] when the numbering matters.
|
||||
pub fn uk_from_vuk(vuk: Vuk, enc_title_keys: &[[u8; 16]]) -> Vec<UnitKey> {
|
||||
enc_title_keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, enc)| UnitKey {
|
||||
idx: i as u32,
|
||||
key: decrypt_unit_key(&vuk.0, enc),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Derive the Media Key (Km) from device keys via the Media Key Variant chain.
|
||||
///
|
||||
/// Wraps [`walk_mkb`] + [`derive_media_key_variant`] with exactly the arguments
|
||||
/// `resolve_keys_v21` path 1 passes: the placeholder Key Correction Data and the
|
||||
/// disc Volume ID. Returns the FIRST tuple element `Km` (the Media Key) — the
|
||||
/// resolver treats `Km` as the media key and derives the VUK from it as
|
||||
/// `Kvu = AES-G(Km, VID)`, which equals [`vuk_from_mk`]`(MediaKey(km), vid)`. The
|
||||
/// variant fn's second element is that already-derived `Kvu`; returning `Km`
|
||||
/// keeps this primitive at the "media key" level so the chain composes.
|
||||
///
|
||||
/// Because the integrator KCD is unavailable in-tree (the placeholder is
|
||||
/// rejected by the variant chain), this returns `Err` for every real disc today
|
||||
/// — byte-for-byte identical to `resolve_keys_v21` path 1, which the resolver
|
||||
/// also leaves unreachable in production. All variant-chain failures collapse to
|
||||
/// [`Error::AacsMkUnavailable`] (E7018): no numeric distinction is load-bearing
|
||||
/// at this boundary, and the variant error carries no English to preserve.
|
||||
pub fn mk_from_dk(
|
||||
device_keys: &[DeviceKey],
|
||||
mkb: &[u8],
|
||||
vid: Vid,
|
||||
) -> Result<MediaKey, crate::error::Error> {
|
||||
let records = walk_mkb(mkb);
|
||||
match derive_media_key_variant(
|
||||
&records,
|
||||
device_keys,
|
||||
&KEY_CORRECTION_DATA_PLACEHOLDER,
|
||||
&vid.0,
|
||||
) {
|
||||
Ok((km, _kvu)) => Ok(MediaKey(km)),
|
||||
Err(_) => Err(crate::error::Error::AacsMkUnavailable),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the Media Key (Km) from one or more Processing Keys and the disc MKB.
|
||||
///
|
||||
/// Wraps [`derive_media_key_from_pk`] — the Subset-Difference PK→MK walk: each
|
||||
/// processing key is validated (and tree-walked) against the MKB's cvalue tables
|
||||
/// (records `0x04`/`0x05`) until one yields the Media Key whose verify record
|
||||
/// (`0x81`/`0x86`) matches. Unlike [`mk_from_dk`] this path is reachable for
|
||||
/// real discs — a leaked/precomputed AACS Processing Key in the keydb resolves
|
||||
/// the Media Key directly. No VID is involved at this step; the VID enters at
|
||||
/// [`vuk_from_mk`].
|
||||
///
|
||||
/// Returns [`Error::AacsMkUnavailable`] (E7018) when no processing key resolves
|
||||
/// the MKB — the same terminal error as [`mk_from_dk`]; no numeric distinction
|
||||
/// is load-bearing at this boundary.
|
||||
///
|
||||
/// [`Error::AacsMkUnavailable`]: crate::error::Error::AacsMkUnavailable
|
||||
pub fn mk_from_pk(
|
||||
processing_keys: &[[u8; 16]],
|
||||
mkb: &[u8],
|
||||
) -> Result<MediaKey, crate::error::Error> {
|
||||
match derive_media_key_from_pk(mkb, processing_keys) {
|
||||
Some(km) => Ok(MediaKey(km)),
|
||||
None => Err(crate::error::Error::AacsMkUnavailable),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs::decrypt::aes_ecb_encrypt;
|
||||
use crate::aacs::keys::{decrypt_unit_key, derive_vuk};
|
||||
|
||||
/// `vuk_from_mk` must equal the inline `derive_vuk` path bit-for-bit, for
|
||||
/// several known (MK, VID) vectors.
|
||||
#[test]
|
||||
fn vuk_from_mk_matches_inline_derive_vuk() {
|
||||
let cases: [([u8; 16], [u8; 16]); 3] = [
|
||||
([0x5A; 16], [0xA5; 16]),
|
||||
([0x11; 16], [0x22; 16]),
|
||||
(
|
||||
[
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
|
||||
0x0D, 0x0E, 0x0F,
|
||||
],
|
||||
[
|
||||
0xF0, 0xE1, 0xD2, 0xC3, 0xB4, 0xA5, 0x96, 0x87, 0x78, 0x69, 0x5A, 0x4B, 0x3C,
|
||||
0x2D, 0x1E, 0x0F,
|
||||
],
|
||||
),
|
||||
];
|
||||
for (mk, vid) in cases {
|
||||
let inline = derive_vuk(&mk, &vid);
|
||||
let boiled = vuk_from_mk(MediaKey(mk), Vid(vid));
|
||||
assert_eq!(boiled.0, inline, "vuk_from_mk must equal derive_vuk");
|
||||
}
|
||||
}
|
||||
|
||||
/// `uk_from_vuk` must equal the inline `decrypt_unit_key` path bit-for-bit
|
||||
/// and carry positional indices 0..n. Built by encrypting known plaintext
|
||||
/// title keys under the VUK (the same primitive the resolver inverts).
|
||||
#[test]
|
||||
fn uk_from_vuk_matches_inline_decrypt_unit_key() {
|
||||
let vuk = [0x5Au8; 16];
|
||||
let plain_keys = [[0x11u8; 16], [0x22u8; 16], [0xCDu8; 16]];
|
||||
let enc: Vec<[u8; 16]> = plain_keys
|
||||
.iter()
|
||||
.map(|k| aes_ecb_encrypt(&vuk, k))
|
||||
.collect();
|
||||
|
||||
let boiled = uk_from_vuk(Vuk(vuk), &enc);
|
||||
assert_eq!(boiled.len(), enc.len());
|
||||
for (i, uk) in boiled.iter().enumerate() {
|
||||
assert_eq!(uk.idx, i as u32, "idx must be the positional index");
|
||||
// Matches the inline derive_uks closure: decrypt_unit_key(vuk, enc).
|
||||
assert_eq!(uk.key, decrypt_unit_key(&vuk, &enc[i]));
|
||||
// And recovers the original plaintext title key.
|
||||
assert_eq!(
|
||||
uk.key, plain_keys[i],
|
||||
"VUK roundtrip recovers the title key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `uk_from_vuk` on an empty slice yields no keys (no panic, no phantom idx).
|
||||
#[test]
|
||||
fn uk_from_vuk_empty_is_empty() {
|
||||
assert!(uk_from_vuk(Vuk([0u8; 16]), &[]).is_empty());
|
||||
}
|
||||
|
||||
/// `mk_from_dk` returns `Err(AacsMkUnavailable)` for the placeholder-KCD
|
||||
/// path that production also leaves unreachable — never a wrong key, never a
|
||||
/// panic — on both an empty MKB and a non-variant MKB.
|
||||
#[test]
|
||||
fn mk_from_dk_errors_without_integrator_kcd() {
|
||||
let dk = DeviceKey {
|
||||
key: [0x11; 16],
|
||||
node: 1,
|
||||
uv: 1,
|
||||
u_mask_shift: 0,
|
||||
};
|
||||
// Empty MKB → not a variant MKB → Err.
|
||||
let e = mk_from_dk(std::slice::from_ref(&dk), &[], Vid([0x09; 16]));
|
||||
assert!(matches!(e, Err(crate::error::Error::AacsMkUnavailable)));
|
||||
|
||||
// A variant-looking MKB (0x82 record) still cannot complete without the
|
||||
// integrator KCD, so it also errors — never silently yields a key.
|
||||
let mut mkb: Vec<u8> = Vec::new();
|
||||
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]); // variant data record
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
let e2 = mk_from_dk(&[dk], &mkb, Vid([0x09; 16]));
|
||||
assert!(matches!(e2, Err(crate::error::Error::AacsMkUnavailable)));
|
||||
}
|
||||
|
||||
/// Build a 4-byte MKB record header (type + 3-byte big-endian total length,
|
||||
/// header included) and append `body`. Mirrors the MKB record framing the
|
||||
/// parser expects; no crypto.
|
||||
fn mkb_record(rec_type: u8, body: &[u8]) -> Vec<u8> {
|
||||
let total = 4 + body.len();
|
||||
let mut rec = Vec::with_capacity(total);
|
||||
rec.push(rec_type);
|
||||
rec.push(((total >> 16) & 0xFF) as u8);
|
||||
rec.push(((total >> 8) & 0xFF) as u8);
|
||||
rec.push((total & 0xFF) as u8);
|
||||
rec.extend_from_slice(body);
|
||||
rec
|
||||
}
|
||||
|
||||
/// `mk_from_pk` resolves a planted Processing Key against a synthetic MKB and
|
||||
/// drives the FULL boil chain PK → MK → VUK → UK. The MKB is built with the
|
||||
/// same (pk, cv, mk_dv, uv) construction the production SD walk validates, so
|
||||
/// this proves a PK entry yields real Unit Keys — not just an `Ok`.
|
||||
#[test]
|
||||
fn mk_from_pk_drives_full_chain_to_uks() {
|
||||
let pk: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
let mk: [u8; 16] = [
|
||||
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
||||
0xAE, 0xAF,
|
||||
];
|
||||
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
|
||||
|
||||
// cv = AES-E(pk, mk_raw), where mk_raw is mk with the 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];
|
||||
}
|
||||
let cv = aes_ecb_encrypt(&pk, &mk_raw);
|
||||
|
||||
// mk_dv = AES-E(mk, magic||pad): AES-D(mk, mk_dv) starts with the AACS
|
||||
// verify sentinel.
|
||||
let mut vd = [0x11u8; 16];
|
||||
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||
let mk_dv = aes_ecb_encrypt(&mk, &vd);
|
||||
|
||||
// Synthetic MKB: type/version (0x10), verify record (0x86 = mk_dv),
|
||||
// one-entry SD index (0x04 = [u_mask_shift=0][uv]), one-entry cvalue
|
||||
// table (0x05 = cv).
|
||||
let mut sd = vec![0u8];
|
||||
sd.extend_from_slice(&uv);
|
||||
let mut mkb = Vec::new();
|
||||
mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
|
||||
mkb.extend_from_slice(&mkb_record(0x86, &mk_dv));
|
||||
mkb.extend_from_slice(&mkb_record(0x04, &sd));
|
||||
mkb.extend_from_slice(&mkb_record(0x05, &cv));
|
||||
|
||||
// PK → MK.
|
||||
let got_mk = mk_from_pk(std::slice::from_ref(&pk), &mkb).expect("planted PK resolves MK");
|
||||
assert_eq!(got_mk, MediaKey(mk), "mk_from_pk recovers the planted MK");
|
||||
|
||||
// MK → VUK → UK over an encrypted title key.
|
||||
let vid = Vid([0x42u8; 16]);
|
||||
let plain_uk = [0x7Eu8; 16];
|
||||
let vuk = vuk_from_mk(got_mk, vid);
|
||||
let enc = aes_ecb_encrypt(&vuk.0, &plain_uk);
|
||||
let uks = uk_from_vuk(vuk, std::slice::from_ref(&enc));
|
||||
assert_eq!(uks.len(), 1);
|
||||
assert_eq!(uks[0].key, plain_uk, "PK chain recovers the title key");
|
||||
|
||||
// A corrupt PK resolves nothing.
|
||||
let mut bad = pk;
|
||||
bad[0] ^= 0xFF;
|
||||
assert!(matches!(
|
||||
mk_from_pk(std::slice::from_ref(&bad), &mkb),
|
||||
Err(crate::error::Error::AacsMkUnavailable)
|
||||
));
|
||||
}
|
||||
}
|
||||
+1669
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
//! 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,
|
||||
//! the AES-G3 Triple Generator, AES-CBC decrypt — and their fixed constants
|
||||
//! (`iv0`, `s0`). Used by every AACS generation; relocated here so the
|
||||
//! primitives live in one place instead of being scattered across the
|
||||
//! content / keys / variant modules.
|
||||
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{Array, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
|
||||
|
||||
/// Fixed IV used by AACS for all AES-CBC operations. [C] §2.1.2 (default CBC IV, `iv0`).
|
||||
pub(crate) const AACS_IV: [u8; 16] = [
|
||||
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
|
||||
];
|
||||
|
||||
// Per-thread count of AES-128 key schedules built through `new_cipher`.
|
||||
// Test-only instrumentation: an AES-128 key expansion is 10 round-key
|
||||
// derivations, and the CBC helpers here run on the per-aligned-unit decrypt hot
|
||||
// path of a whole disc read, so "how many times was the schedule built for one
|
||||
// loop-invariant key" is a property worth asserting rather than reasoning about.
|
||||
// THREAD-LOCAL, not a global atomic: `cargo test` runs tests concurrently, so a
|
||||
// shared counter would see every other test's expansions. See
|
||||
// `content::tests::decrypt_bus_expands_the_read_data_key_once_per_unit`.
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
pub(crate) static KEY_EXPANSIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Build an AES-128 key schedule for a caller that will drive
|
||||
/// [`cbc_decrypt_blocks`] over several regions under one key.
|
||||
pub(crate) fn new_cipher_for(key: &[u8; 16]) -> Aes128 {
|
||||
new_cipher(key)
|
||||
}
|
||||
|
||||
/// Build an AES-128 key schedule. The single construction site for the CBC
|
||||
/// helpers, so [`KEY_EXPANSIONS`] can count them under test.
|
||||
fn new_cipher(key: &[u8; 16]) -> Aes128 {
|
||||
#[cfg(test)]
|
||||
KEY_EXPANSIONS.with(|c| c.set(c.get() + 1));
|
||||
Aes128::new(&(*key).into())
|
||||
}
|
||||
|
||||
/// AES-128-ECB encrypt a single 16-byte block. [C] §2.1.1 (`AES-128E`).
|
||||
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(&(*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`).
|
||||
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
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 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().is_multiple_of(16),
|
||||
"aes_cbc_decrypt requires a block-aligned slice"
|
||||
);
|
||||
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() {
|
||||
let offset = i * 16;
|
||||
let prev = if i == 0 {
|
||||
AACS_IV
|
||||
} else {
|
||||
let mut p = [0u8; 16];
|
||||
p.copy_from_slice(&data[(i - 1) * 16..i * 16]);
|
||||
p
|
||||
};
|
||||
let mut chunk = [0u8; 16];
|
||||
chunk.copy_from_slice(&data[offset..offset + 16]);
|
||||
let mut block: Array<u8, _> = chunk.into();
|
||||
cipher.decrypt_block(&mut block);
|
||||
for j in 0..16 {
|
||||
data[offset + j] = block[j] ^ prev[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2. [C] §2.1.3 (note: uses AES-128**D**).
|
||||
///
|
||||
/// The Media Key Variant chain uses AES-G to derive both the variant
|
||||
/// number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key
|
||||
/// (`Kvu = AES-G(Km, VID)`). See [`super::derive::derive_vuk`] for the
|
||||
/// classical VUK form — the math is identical, this exposes it as a
|
||||
/// neutral primitive for the variant chain.
|
||||
pub(crate) fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
|
||||
let mut out = aes_ecb_decrypt(x1, x2);
|
||||
for i in 0..16 {
|
||||
out[i] ^= x2[i];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// AACS-G3 seed constant (`s0`). [C] §3.2.2.
|
||||
pub(crate) const AESG3_SEED: [u8; 16] = [
|
||||
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
|
||||
];
|
||||
|
||||
/// AACS-G3: derive a subkey from a parent key. [C] §3.2.2 (Triple AES Generator:
|
||||
/// left=`D(k,s0)⊕s0` inc 0, pk=`D(k,s0+1)⊕(s0+1)` inc 1, right=`D(k,s0+2)⊕(s0+2)` inc 2).
|
||||
/// seed[15] += inc, then AES-DEC(key, seed) XOR seed.
|
||||
///
|
||||
/// Shared with [`super::variant`] (its variant chain runs the same SD
|
||||
/// tree); a single definition keeps the two walks byte-identical.
|
||||
pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
|
||||
let mut seed = AESG3_SEED;
|
||||
seed[15] = seed[15].wrapping_add(inc);
|
||||
let mut out = aes_ecb_decrypt(key, &seed);
|
||||
for i in 0..16 {
|
||||
out[i] ^= seed[i];
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
-1501
File diff suppressed because it is too large
Load Diff
+1769
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,8 @@
|
||||
pub fn collect_host_certs(
|
||||
opts: &crate::disc::ScanOptions,
|
||||
mkb: Option<u32>,
|
||||
) -> Vec<crate::aacs::HostCert> {
|
||||
let mut host_certs: Vec<crate::aacs::HostCert> = Vec::new();
|
||||
) -> Vec<crate::aacs::types::HostCert> {
|
||||
let mut host_certs: Vec<crate::aacs::types::HostCert> = Vec::new();
|
||||
if let Some(c) = &opts.credentials {
|
||||
host_certs.extend(c.host_certs.iter().cloned());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! FMTS index selection — the pure decode-time decision for a 2.1 disc.
|
||||
//!
|
||||
//! A 2.1 disc resolves to exactly one forensic index (1..=32) for a given
|
||||
//! rip. `IndividualSegment.tbl` tags each forensic segment with an index (see
|
||||
//! [`super::segment`]); the decode keeps the segments matching our index,
|
||||
//! drops the other 31, and treats everything outside a segment as ordinary
|
||||
//! (index-0) content. This module owns that classification and nothing else —
|
||||
//! no I/O, no keys, no cipher — so it is fully testable in isolation. The
|
||||
//! decrypt pipeline consumes the [`UnitDisposition`] it returns.
|
||||
//!
|
||||
//! Where the resolved index comes from is a separate concern
|
||||
//! ([`resolve_disc_index`]): today it is read off the index keys the key
|
||||
//! source handed us; when Processing Keys are available it will come from the
|
||||
//! VK derivation instead. Either way the disposition logic below is identical.
|
||||
|
||||
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 (index-0) unit key.
|
||||
Default,
|
||||
/// 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.
|
||||
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 index for diagnostics.
|
||||
ForensicNoKey(u8),
|
||||
}
|
||||
|
||||
/// Resolve the disc's single forensic index from the keys we hold.
|
||||
///
|
||||
/// 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_index(unit_keys: &[UnitKey]) -> Option<u8> {
|
||||
unit_keys
|
||||
.iter()
|
||||
.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 index (`None` if no
|
||||
/// index key is held).
|
||||
pub fn unit_disposition(
|
||||
unit_offset: u64,
|
||||
segments: &[Segment],
|
||||
disc_index: Option<u8>,
|
||||
) -> UnitDisposition {
|
||||
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 index.
|
||||
Some(seg) => {
|
||||
// `seg.index` is an untrusted u16 from IndividualSegment.tbl; a real
|
||||
// forensic index is 1..=32. Compare in u16 space so a corrupt/crafted
|
||||
// index above 255 can't truncate into a valid u8 and alias our index.
|
||||
// The disposition carries a u8 for diagnostics (saturated — an
|
||||
// out-of-range index is never ours anyway).
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
|
||||
|
||||
/// Build a one-record segment table (index, start_spn, end_spn).
|
||||
fn tbl(recs: &[(u16, u32, u32)]) -> Vec<Segment> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
||||
v.extend_from_slice(&(recs.len() as u16).to_be_bytes());
|
||||
v.extend_from_slice(&16u16.to_be_bytes());
|
||||
for &(n, s, e) in recs {
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
||||
v.extend_from_slice(&n.to_be_bytes());
|
||||
v.extend_from_slice(&1u16.to_be_bytes());
|
||||
v.extend_from_slice(&s.to_be_bytes());
|
||||
v.extend_from_slice(&e.to_be_bytes());
|
||||
}
|
||||
parse_individual_segments(&v).expect("parse")
|
||||
}
|
||||
|
||||
fn uk(idx: u32, index: u8) -> UnitKey {
|
||||
if index == 0 {
|
||||
UnitKey::new(idx, [0u8; 16])
|
||||
} else {
|
||||
UnitKey::forensic(idx, [index; 16], index)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_picks_the_single_index_key() {
|
||||
// Default keys only → no index resolved.
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 0)]), None);
|
||||
assert_eq!(resolve_disc_index(&[]), None);
|
||||
// One index key among defaults → that index.
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 0), uk(1, 7)]), Some(7));
|
||||
// Defensive: lowest of several distinct indexes (deterministic).
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 9), uk(1, 3)]), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_outside_segments_is_default() {
|
||||
let segs = tbl(&[(1, 343680, 346239)]);
|
||||
let off = 1000u64 * SOURCE_PACKET_LEN; // well before the segment
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(1)),
|
||||
UnitDisposition::Default
|
||||
);
|
||||
// With no segments at all (1.0 / 2.0), everything is Default.
|
||||
assert_eq!(
|
||||
unit_disposition(off, &[], Some(1)),
|
||||
UnitDisposition::Default
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_in_our_index_decrypts() {
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(7)),
|
||||
UnitDisposition::Index(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_in_foreign_index_drops() {
|
||||
// Segment tagged index 7, but our disc index is 3 → drop it.
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(3)),
|
||||
UnitDisposition::DropForeignIndex(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forensic_unit_with_no_key_is_concealed() {
|
||||
// A forensic segment but we never resolved an index → conceal as loss.
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, None),
|
||||
UnitDisposition::ForensicNoKey(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_index_does_not_truncate_into_ours() {
|
||||
// A crafted/corrupt segment index of 288 (0x0120) truncates to 32 in a
|
||||
// u8. With our disc index resolved as 32, the old `seg.index as u8`
|
||||
// compare would alias it to OUR index and decrypt with the wrong key.
|
||||
// The u16 compare must instead classify it as foreign.
|
||||
let segs = tbl(&[(288, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(32)),
|
||||
UnitDisposition::DropForeignIndex(255)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straddling_unit_still_classified_as_its_segment() {
|
||||
// A unit whose 32-packet span only tails into the segment still routes
|
||||
// to the segment (matches segment_for_unit's span test).
|
||||
let segs = tbl(&[(5, 100, 200)]);
|
||||
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
|
||||
// Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
assert!(80 + unit_packets > 100, "sanity: unit tails into seg");
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(5)),
|
||||
UnitDisposition::Index(5)
|
||||
);
|
||||
}
|
||||
}
|
||||
+881
@@ -0,0 +1,881 @@
|
||||
//! AACS on-disc key-input files: `Unit_Key_RO.inf` parsing, the disc-hash
|
||||
//! keydb lookup key, the Content Certificate, and the in-drive MKB read.
|
||||
//! These turn raw disc files into the structures the key paths consume.
|
||||
|
||||
use super::mkb::*;
|
||||
|
||||
/// Parsed Unit_Key_RO.inf file.
|
||||
pub struct UnitKeyFile {
|
||||
/// Disc hash (SHA1 of the entire file) — used as KEYDB lookup key
|
||||
pub disc_hash: [u8; 20],
|
||||
/// Application type (1 = BD-ROM)
|
||||
pub app_type: u8,
|
||||
/// Number of BDMV directories
|
||||
pub num_bdmv_dir: u8,
|
||||
/// Whether SKB MKB is used
|
||||
pub use_skb_mkb: bool,
|
||||
/// AACS generation this file's stride matches
|
||||
pub version: AacsVersion,
|
||||
/// Encrypted unit keys (CPS unit number, encrypted key)
|
||||
pub encrypted_keys: Vec<(u32, [u8; 16])>,
|
||||
/// Title → CPS unit index mapping (title_idx → unit_key_idx)
|
||||
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};
|
||||
let hash = Sha1::digest(data);
|
||||
let mut out = [0u8; 20];
|
||||
out.copy_from_slice(&hash);
|
||||
out
|
||||
}
|
||||
|
||||
/// Format disc hash as hex string with 0x prefix (for KEYDB lookup).
|
||||
pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
|
||||
let mut s = String::with_capacity(42);
|
||||
s.push_str("0x");
|
||||
for b in hash {
|
||||
s.push_str(&format!("{b:02X}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Parse Unit_Key_RO.inf from raw bytes.
|
||||
///
|
||||
/// Format (from AACS spec):
|
||||
/// [0..4] BE32: offset to key storage area (uk_pos)
|
||||
/// [16] app_type (1 = BD-ROM)
|
||||
/// [17] num_bdmv_dir
|
||||
/// [18] bit 7: use_skb_mkb
|
||||
/// [20..22] BE16: first_play CPS unit
|
||||
/// [22..24] BE16: top_menu CPS unit
|
||||
/// [24..26] BE16: num_titles
|
||||
/// [26..] title entries: 2 bytes padding + 2 bytes CPS unit, × num_titles
|
||||
///
|
||||
/// Key storage at uk_pos:
|
||||
/// [uk_pos..uk_pos+2] BE16: num_unit_keys
|
||||
/// [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;
|
||||
}
|
||||
|
||||
let hash = disc_hash(data);
|
||||
|
||||
// Header
|
||||
let app_type = data[16];
|
||||
let num_bdmv_dir = data[17];
|
||||
let use_skb_mkb = (data[18] >> 7) & 1 == 1;
|
||||
|
||||
// Key storage offset
|
||||
let uk_pos = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||
if uk_pos + 2 > data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Number of unit keys
|
||||
let num_uk = u16::from_be_bytes([data[uk_pos], data[uk_pos + 1]]) as usize;
|
||||
if num_uk == 0 {
|
||||
return Some(UnitKeyFile {
|
||||
disc_hash: hash,
|
||||
app_type,
|
||||
num_bdmv_dir,
|
||||
use_skb_mkb,
|
||||
version,
|
||||
encrypted_keys: Vec::new(),
|
||||
title_cps_unit: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Stride between keys
|
||||
let stride = version.unit_key_stride();
|
||||
|
||||
// Validate size
|
||||
let keys_start = uk_pos + 48; // first key at uk_pos + 48
|
||||
if keys_start + 16 > data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract encrypted keys
|
||||
let mut encrypted_keys = Vec::with_capacity(num_uk);
|
||||
let mut pos = keys_start;
|
||||
for i in 0..num_uk {
|
||||
if pos + 16 > data.len() {
|
||||
break;
|
||||
}
|
||||
let mut key = [0u8; 16];
|
||||
key.copy_from_slice(&data[pos..pos + 16]);
|
||||
encrypted_keys.push(((i + 1) as u32, key));
|
||||
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.
|
||||
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.
|
||||
let to_key_idx = |cps: u16| -> u16 {
|
||||
if cps >= 1 && cps as usize <= num_uk {
|
||||
cps - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
let mut title_cps_unit = Vec::new();
|
||||
if data.len() >= 26 {
|
||||
let first_play = u16::from_be_bytes([data[20], data[21]]);
|
||||
let top_menu = u16::from_be_bytes([data[22], data[23]]);
|
||||
let num_titles = u16::from_be_bytes([data[24], data[25]]) as usize;
|
||||
|
||||
title_cps_unit.push(to_key_idx(first_play));
|
||||
title_cps_unit.push(to_key_idx(top_menu));
|
||||
|
||||
for i in 0..num_titles {
|
||||
let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit
|
||||
if off + 2 <= data.len() {
|
||||
let cps = u16::from_be_bytes([data[off], data[off + 1]]);
|
||||
title_cps_unit.push(to_key_idx(cps));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(UnitKeyFile {
|
||||
disc_hash: hash,
|
||||
app_type,
|
||||
num_bdmv_dir,
|
||||
use_skb_mkb,
|
||||
version,
|
||||
encrypted_keys,
|
||||
title_cps_unit,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 (AACS HD DVD Book,
|
||||
/// Table 3-8).
|
||||
const VTKF_HEADER_LEN: usize = 0x80;
|
||||
/// 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 `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 — 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 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)
|
||||
/// ```
|
||||
/// 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;
|
||||
}
|
||||
// SHA1 of the WHOLE file — the KEYDB lookup key. BackupHDDVD-family key
|
||||
// databases index an HD DVD disc by SHA1(VTKF000.AACS), the same role the
|
||||
// BD disc_hash plays for `Unit_Key_RO.inf`.
|
||||
let hash = disc_hash(data);
|
||||
|
||||
let mut encrypted_keys = Vec::new();
|
||||
for n in 0..VTKF_MAX_ENTRIES {
|
||||
let pos = VTKF_HEADER_LEN + n * VTKF_ENTRY_LEN;
|
||||
if pos + VTKF_ENTRY_LEN > data.len() {
|
||||
break;
|
||||
}
|
||||
// AV_FLG clear = empty slot: skip it, but keep the slot index as the CPS
|
||||
// number (do NOT break — a gap must not renumber the keys that follow).
|
||||
if data[pos] & VTKF_AV_FLG == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut key = [0u8; 16];
|
||||
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;
|
||||
}
|
||||
|
||||
Some(UnitKeyFile {
|
||||
disc_hash: hash,
|
||||
app_type: 0, // HD DVD VTKF carries no BD-ROM app_type
|
||||
num_bdmv_dir: 0, // BD-only concept
|
||||
use_skb_mkb: false,
|
||||
version: AacsVersion::V10, // HD DVD is always AACS 1.0
|
||||
encrypted_keys,
|
||||
title_cps_unit: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a disc's title-key file, dispatching on the self-describing magic:
|
||||
/// an HD DVD `VTKF000.AACS` (`DVD_HD_V_TKF`) → [`parse_vtkf`]; anything else is a
|
||||
/// BD/UHD `Unit_Key_RO.inf` → [`parse_unit_key_ro`]. Both return the same
|
||||
/// [`UnitKeyFile`], so every downstream AACS derivation stays container-agnostic
|
||||
/// — the single seam where BD-vs-HD-DVD key layout is resolved (mirrors the key
|
||||
/// service, which classifies HD DVD by the very same magic).
|
||||
pub fn parse_title_keys(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
|
||||
if data.len() >= 12 && &data[..12] == VTKF_MAGIC {
|
||||
parse_vtkf(data)
|
||||
} else {
|
||||
parse_unit_key_ro(data, version)
|
||||
}
|
||||
}
|
||||
|
||||
/// MKB disc structure format code.
|
||||
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
|
||||
|
||||
/// MKB pack buffer size.
|
||||
const MKB_PACK_SIZE: usize = 32772;
|
||||
|
||||
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
|
||||
/// Returns the concatenated MKB data from all packs.
|
||||
pub fn read_mkb_from_drive(
|
||||
session: &mut dyn crate::scsi::ScsiTransport,
|
||||
) -> crate::error::Result<Vec<u8>> {
|
||||
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
|
||||
|
||||
let cdb = [
|
||||
SCSI_READ_DISC_STRUCTURE,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
MKB_DISC_STRUCTURE_FORMAT,
|
||||
(MKB_PACK_SIZE >> 8) as u8,
|
||||
(MKB_PACK_SIZE & 0xFF) as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
let mut buf = vec![0u8; 32772];
|
||||
session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
|
||||
|
||||
let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
||||
if data_len < 2 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let len = data_len - 2;
|
||||
let num_packs = buf[3] as usize;
|
||||
|
||||
let mut mkb = Vec::with_capacity(32768 * num_packs.max(1));
|
||||
if len > 0 && len <= 32768 {
|
||||
mkb.extend_from_slice(&buf[4..4 + len]);
|
||||
}
|
||||
|
||||
// Read remaining packs
|
||||
for pack in 1..num_packs {
|
||||
let mut cdb = [
|
||||
SCSI_READ_DISC_STRUCTURE,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
MKB_DISC_STRUCTURE_FORMAT,
|
||||
(MKB_PACK_SIZE >> 8) as u8,
|
||||
(MKB_PACK_SIZE & 0xFF) as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
// Pack number goes in address field
|
||||
cdb[2] = ((pack >> 24) & 0xFF) as u8;
|
||||
cdb[3] = ((pack >> 16) & 0xFF) as u8;
|
||||
cdb[4] = ((pack >> 8) & 0xFF) as u8;
|
||||
cdb[5] = (pack & 0xFF) as u8;
|
||||
|
||||
let mut buf = vec![0u8; 32772];
|
||||
if session
|
||||
.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
|
||||
.is_ok()
|
||||
{
|
||||
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
||||
if len > 2 && len - 2 <= 32768 {
|
||||
mkb.extend_from_slice(&buf[4..4 + len - 2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(mkb)
|
||||
}
|
||||
|
||||
/// AACS Content Certificate — identifies disc AACS version and features.
|
||||
#[derive(Debug)]
|
||||
pub struct ContentCert {
|
||||
/// Bus encryption enabled flag
|
||||
pub bus_encryption: bool,
|
||||
/// Content Certificate ID (6 bytes)
|
||||
pub cc_id: [u8; 6],
|
||||
/// AACS generation indicated by the certificate type byte.
|
||||
///
|
||||
/// Cert type `0x00` → [`AacsVersion::V10`]; any other value →
|
||||
/// [`AacsVersion::V20`]. The certificate alone cannot distinguish
|
||||
/// V20 from V21 — Variant detection happens after the MKB walk.
|
||||
pub version: AacsVersion,
|
||||
}
|
||||
|
||||
/// Parse a Content Certificate (ContentXXX.cer) file.
|
||||
pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
if data.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Content Certificate layout (per the AACS content-cert format):
|
||||
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
|
||||
// [1] bit7 bus_encryption_enabled_flag (`p[1] >> 7`)
|
||||
// [14..20] cc_id (6 bytes) (`p + 14`)
|
||||
let version = if data[0] == 0x00 {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
AacsVersion::V20
|
||||
};
|
||||
// The flag is bit 7 of byte 1, NOT bit 0. Reading bit 0 (the prior bug) made
|
||||
// a bus-encrypted cert (byte1=0x80) read as `false`, defeating the
|
||||
// AacsBusKeyUnavailable fail-loud gate in disc/encrypt.rs.
|
||||
let bus_encryption = (data[1] >> 7) & 1 == 1;
|
||||
let mut cc_id = [0u8; 6];
|
||||
cc_id.copy_from_slice(&data[14..20]);
|
||||
|
||||
Some(ContentCert {
|
||||
bus_encryption,
|
||||
cc_id,
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod vtkf_tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a synthetic `VTKF%%%.AACS` matching the real on-disc layout (AACS
|
||||
/// HD DVD Book Table 3-8, verified against Freedom `VTKF090` and Dukes
|
||||
/// `VTKF000`): magic, BE32 size, playlist name, reserved to 0x80, then 64
|
||||
/// entry slots of 36 bytes (the first `keys.len()` present with `AV_FLG`
|
||||
/// set, the rest empty), a reserved gap, and the 16-byte trailing TKF MAC.
|
||||
fn synth_vtkf(keys: &[[u8; 16]]) -> Vec<u8> {
|
||||
const FILE_LEN: usize = 2480;
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(VTKF_MAGIC); // 0x00
|
||||
v.extend_from_slice(&(FILE_LEN as u32).to_be_bytes()); // 0x0C HD_VTKF_SIZE
|
||||
v.extend_from_slice(b"VPLST000.XPL"); // 0x10 playlist name
|
||||
v.resize(VTKF_HEADER_LEN, 0); // reserve to first entry (0x80)
|
||||
for n in 0..VTKF_MAX_ENTRIES {
|
||||
if let Some(k) = keys.get(n) {
|
||||
v.push(VTKF_AV_FLG); // BIFO: AV_FLG set (present)
|
||||
v.extend_from_slice(&[0, 0, 0]); // reserved
|
||||
v.extend_from_slice(k); // 16-byte encrypted title key
|
||||
v.extend_from_slice(&[0xFFu8; 16]); // binding MAC (0xFF, pre-recorded)
|
||||
} else {
|
||||
v.extend_from_slice(&[0u8; VTKF_ENTRY_LEN]); // empty slot (AV_FLG clear)
|
||||
}
|
||||
}
|
||||
v.resize(FILE_LEN - 16, 0); // reserved gap before the trailer
|
||||
v.extend_from_slice(&[0xABu8; 16]); // TKF MAC (must NOT be read as a key)
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_reads_present_entries_skips_empty_ignores_mac() {
|
||||
let k1 = [0x11u8; 16];
|
||||
let k2 = [0x22u8; 16];
|
||||
let k3 = [0x33u8; 16];
|
||||
let data = synth_vtkf(&[k1, k2, k3]);
|
||||
|
||||
let ukf = parse_vtkf(&data).expect("valid VTKF must parse");
|
||||
// Exactly the three present entries — the empty slots and the trailing
|
||||
// 16-byte TKF MAC are NOT mistaken for keys. Critically, k2/k3 are read
|
||||
// at the 36-byte stride (offsets 0xA4, 0xC8); the old 32-byte stride
|
||||
// misread them from inside the previous entry's binding MAC.
|
||||
assert_eq!(ukf.encrypted_keys.len(), 3);
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys[0],
|
||||
(1, k1),
|
||||
"CPS units = 1-based slot index"
|
||||
);
|
||||
assert_eq!(ukf.encrypted_keys[1], (2, k2));
|
||||
assert_eq!(ukf.encrypted_keys[2], (3, k3));
|
||||
assert_eq!(ukf.version, AacsVersion::V10, "HD DVD is AACS 1.0");
|
||||
// disc_hash is SHA1 of the whole file (the KEYDB lookup key).
|
||||
assert_eq!(ukf.disc_hash, disc_hash(&data));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_reads_a_full_64_entry_file() {
|
||||
// Real discs (Freedom, Dukes) carry all 64 slots present. Every key must
|
||||
// come back, none dropped and none drifted — the regression the 32-byte
|
||||
// stride failed.
|
||||
let keys: Vec<[u8; 16]> = (0..VTKF_MAX_ENTRIES).map(|n| [n as u8; 16]).collect();
|
||||
let ukf = parse_vtkf(&synth_vtkf(&keys)).expect("64-entry VTKF");
|
||||
assert_eq!(ukf.encrypted_keys.len(), 64);
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys[63],
|
||||
(64, [63u8; 16]),
|
||||
"entry 64 at 0x{:x}",
|
||||
VTKF_HEADER_LEN + 63 * VTKF_ENTRY_LEN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_rejects_non_magic() {
|
||||
let mut data = synth_vtkf(&[[0x11u8; 16]]);
|
||||
data[0] = b'X'; // corrupt magic
|
||||
assert!(
|
||||
parse_vtkf(&data).is_none(),
|
||||
"non-VTKF magic must be rejected"
|
||||
);
|
||||
assert!(
|
||||
parse_vtkf(&[0u8; 4]).is_none(),
|
||||
"too short must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_title_keys_dispatches_by_magic() {
|
||||
// VTKF magic → parse_vtkf.
|
||||
let data = synth_vtkf(&[[0x44u8; 16], [0x55u8; 16]]);
|
||||
let ukf = parse_title_keys(&data, AacsVersion::V10).expect("VTKF dispatch");
|
||||
assert_eq!(ukf.encrypted_keys.len(), 2);
|
||||
|
||||
// Non-VTKF → parse_unit_key_ro (a 2-byte buffer is not a valid inf, so
|
||||
// this proves it ROUTED to the BD parser rather than parse_vtkf).
|
||||
assert!(
|
||||
parse_title_keys(&[0x00, 0x00], AacsVersion::V10).is_none(),
|
||||
"non-magic input must route to parse_unit_key_ro"
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole point of the seam: a parsed VTKF feeds the SHARED VUK→title-key
|
||||
/// crypto (`decrypt_unit_key`) exactly like a BD `Unit_Key_RO.inf` would —
|
||||
/// no HD-DVD-specific crypto path.
|
||||
#[test]
|
||||
fn vtkf_encrypted_keys_feed_shared_vuk_unwrap() {
|
||||
let enc = [0x9Au8; 16];
|
||||
let data = synth_vtkf(&[enc]);
|
||||
let ukf = parse_vtkf(&data).unwrap();
|
||||
let vuk = [0x5Cu8; 16];
|
||||
let derived = super::super::derive::decrypt_unit_key(&vuk, &ukf.encrypted_keys[0].1);
|
||||
// Same as applying the shared unwrap directly to the stored enc key.
|
||||
assert_eq!(derived, super::super::derive::decrypt_unit_key(&vuk, &enc));
|
||||
}
|
||||
|
||||
/// `UnitKeyFile` holds the disc's ENCRYPTED CPS unit keys. A derived `Debug`
|
||||
/// printed every byte; the hand-written impl must not. Sentinel key byte
|
||||
/// 0xD5 = decimal 213 (a derived `Debug` renders `[u8; 16]` in decimal), the
|
||||
/// same probe `aacs::types::redaction_tests` uses. Mutation guard: putting
|
||||
/// `#[derive(Debug)]` back fails this.
|
||||
#[test]
|
||||
fn unit_key_file_debug_is_redacted() {
|
||||
let f = UnitKeyFile {
|
||||
disc_hash: [0xD5; 20],
|
||||
app_type: 1,
|
||||
num_bdmv_dir: 1,
|
||||
use_skb_mkb: false,
|
||||
version: AacsVersion::V20,
|
||||
encrypted_keys: vec![(0, [0xD5; 16]), (1, [0xD5; 16])],
|
||||
title_cps_unit: vec![0, 1],
|
||||
};
|
||||
let dbg = format!("{f:?}");
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"UnitKeyFile Debug leaked key bytes (decimal 213): {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"UnitKeyFile Debug missing redaction marker: {dbg}"
|
||||
);
|
||||
// Non-secret shape is still useful for diagnostics.
|
||||
assert!(dbg.contains("encrypted_keys_len: 2"), "{dbg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+540
@@ -0,0 +1,540 @@
|
||||
//! 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
|
||||
//! utilities (content length, trimming, version). Consolidated here so the one
|
||||
//! place that understands MKB bytes is `mkb`. Some duplicate record finders
|
||||
//! 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).
|
||||
|
||||
/// Type-and-Version — carries the 32-bit MKBType / AACS generation.
|
||||
pub(crate) const REC_TYPE_AND_VERSION: u8 = 0x10;
|
||||
/// Subset-Difference index — the per-slot `(u_mask_shift, uv)` table.
|
||||
pub(crate) const REC_SUBSET_DIFFERENCE: u8 = 0x04;
|
||||
/// Media Key Data — the classical (1.0 / 2.0) per-subset cvalue table.
|
||||
pub(crate) const REC_MEDIA_KEY_DATA: u8 = 0x05;
|
||||
/// Explicit Subset-Difference — the smaller cvalue table some MKBs use.
|
||||
pub(crate) const REC_EXPLICIT_SUBSET_DIFF: u8 = 0x07;
|
||||
/// Media Key Variant Data (AACS 2.1) — the per-subset-difference `C` table
|
||||
/// (one 16-byte C per slot); the `Kmp` step reads C from HERE, not `0x2d`.
|
||||
pub(crate) const REC_MEDIA_KEY_VARIANT_DATA: u8 = 0x0c;
|
||||
/// Variant Data + Nonce (AACS 2.1) — the `VARIANTS[uv]` table (leading bytes)
|
||||
/// with the 16-byte `Kvn` Nonce at the tail.
|
||||
pub(crate) const REC_VARIANT_DATA_AND_NONCE: u8 = 0x2d;
|
||||
/// Variant Key Data table (AACS 2.1) — 65,535×16, indexed by the resolved VKD index.
|
||||
pub(crate) const REC_VKD_TABLE: u8 = 0x2f;
|
||||
/// Verify-Media-Key — AACS 1.0.
|
||||
pub(crate) const REC_VERIFY_MEDIA_KEY_V1: u8 = 0x81;
|
||||
/// Verify-Media-Key — AACS 2.x.
|
||||
pub(crate) const REC_VERIFY_MEDIA_KEY_V2: u8 = 0x86;
|
||||
|
||||
/// A single MKB record produced by [`walk_mkb`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MkbRecord {
|
||||
/// Byte offset of the record within the MKB.
|
||||
pub offset: usize,
|
||||
/// Record type byte.
|
||||
pub rec_type: u8,
|
||||
/// Record length in bytes (includes the 4-byte header).
|
||||
pub rec_len: usize,
|
||||
/// Record body (the bytes after the 4-byte header).
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Walk an MKB into a flat list of records.
|
||||
///
|
||||
/// MKB record framing per AACS: 1 byte type, 3 bytes BE length
|
||||
/// INCLUDING the 4-byte header, followed by payload. The walker stops
|
||||
/// at the first `(type=0, len=0)` end marker or at end of buffer.
|
||||
pub fn walk_mkb(mkb: &[u8]) -> Vec<MkbRecord> {
|
||||
mkb_records(mkb)
|
||||
.map(|(offset, rec_type, rec_len)| MkbRecord {
|
||||
offset,
|
||||
rec_type,
|
||||
rec_len,
|
||||
body: mkb[offset + 4..offset + rec_len].to_vec(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// THE single MKB record-framing walker: yields `(offset, rec_type, rec_len)`
|
||||
/// for each record — a 4-byte header (type byte + big-endian 24-bit length)
|
||||
/// then the body — stopping at the `00 000000` end marker or a
|
||||
/// malformed/out-of-bounds length. Lazy (no body clone), so a find-one-record
|
||||
/// caller never materialises the multi-MB cvalue table. [`walk_mkb`] and every
|
||||
/// MKB record walk in `aacs::resolve`/`aacs::derive` are built on this, so the framing rules — and
|
||||
/// any future fix to them — live in exactly one place (they had drifted across
|
||||
/// six hand-rolled copies).
|
||||
pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)> + '_ {
|
||||
let mut pos = 0usize;
|
||||
std::iter::from_fn(move || {
|
||||
if pos + 4 > mkb.len() {
|
||||
return None;
|
||||
}
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = ((mkb[pos + 1] as usize) << 16)
|
||||
| ((mkb[pos + 2] as usize) << 8)
|
||||
| (mkb[pos + 3] as usize);
|
||||
if rec_type == 0 && rec_len == 0 {
|
||||
return None;
|
||||
}
|
||||
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
return None;
|
||||
}
|
||||
let here = pos;
|
||||
pos += rec_len;
|
||||
Some((here, rec_type, rec_len))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mkb_find_body(records: &[MkbRecord], rec_type: u8) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == rec_type && !r.body.is_empty())
|
||||
.map(|r| r.body.as_slice())
|
||||
}
|
||||
|
||||
/// AACS protection generation a disc carries.
|
||||
///
|
||||
/// The content cert byte distinguishes V10 (`0x00`) from V20 (`0x01`). V21
|
||||
/// cannot be detected from the cert alone — a V21 disc carries a V20 cert
|
||||
/// and is upgraded to `V21` only after the MKB walk turns up the real Variant
|
||||
/// records `0x2d` / `0x2f` (Encrypted Media Key Variant Data and the Variant
|
||||
/// Key Data table).
|
||||
///
|
||||
/// Key-storage stride in `Unit_Key_RO.inf` is 48 bytes for V10 and 64
|
||||
/// bytes for V20 / V21.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AacsVersion {
|
||||
/// AACS 1.0 — original BD-ROM.
|
||||
V10,
|
||||
/// AACS 2.0 — UHD-BD, classical Media Key derivation.
|
||||
V20,
|
||||
/// AACS 2.1 — UHD-BD with Media Key Variant chain on top of V20.
|
||||
V21,
|
||||
}
|
||||
|
||||
/// AACS major version as the small integer threaded through the scan / key
|
||||
/// paths (`AacsState.version`, `DiscInputs.version`, `DiscInputsCtx::new`):
|
||||
/// 1 = AACS 1.0 (BD), 2 = AACS 2.x (UHD). Centralised so the bare `1`/`2` — and
|
||||
/// the V10-vs-else stride choice it drives — lives in exactly one place.
|
||||
pub const AACS_MAJOR_BD: u8 = 1;
|
||||
|
||||
pub const AACS_MAJOR_UHD: u8 = 2;
|
||||
|
||||
impl AacsVersion {
|
||||
/// Stride (in bytes) between successive encrypted unit keys in
|
||||
/// `Unit_Key_RO.inf`.
|
||||
pub(crate) fn unit_key_stride(self) -> usize {
|
||||
match self {
|
||||
AacsVersion::V10 => 48,
|
||||
AacsVersion::V20 | AacsVersion::V21 => 64,
|
||||
}
|
||||
}
|
||||
|
||||
/// This version as the major integer ([`AACS_MAJOR_BD`] / [`AACS_MAJOR_UHD`]).
|
||||
pub fn major(self) -> u8 {
|
||||
match self {
|
||||
AacsVersion::V10 => AACS_MAJOR_BD,
|
||||
AacsVersion::V20 | AacsVersion::V21 => AACS_MAJOR_UHD,
|
||||
}
|
||||
}
|
||||
|
||||
/// The version a bare major integer selects for stride purposes: only the
|
||||
/// BD major is V10; every other value takes the V20/V21 64-byte stride.
|
||||
pub fn from_major(major: u8) -> Self {
|
||||
if major == AACS_MAJOR_BD {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
AacsVersion::V20
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
|
||||
/// 0x81: [C] §3.2.5.1.4. 0x86 (AACS 2.x): [RE] — not in the public spec (from real 2.x MKBs).
|
||||
pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
// 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.
|
||||
let found = mkb_records(mkb).find(|&(_, rt, len)| {
|
||||
(rt == REC_VERIFY_MEDIA_KEY_V1 || rt == REC_VERIFY_MEDIA_KEY_V2) && len >= 20
|
||||
});
|
||||
match found {
|
||||
Some((o, rec_type, rec_len)) => {
|
||||
let mut dv = [0u8; 16];
|
||||
dv.copy_from_slice(&mkb[o + 4..o + 20]);
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_found",
|
||||
rec_type,
|
||||
pos = o,
|
||||
rec_len,
|
||||
"mk_dv extracted from MKB"
|
||||
);
|
||||
Some(dv)
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_not_found",
|
||||
"no 0x81/0x86 record with rec_len>=20 found"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// The cvalue table is record type `0x05` (Media Key Data) on BOTH AACS
|
||||
/// 1.0 and AACS 2.x MKBs — its 16-byte cvalue entries are 1:1 with the
|
||||
/// 5-byte Subset-Difference index entries in record `0x04` — the standard AACS
|
||||
/// MKB layout (`0x05` cvalues 1:1 with the `0x04` subset-difference index).
|
||||
///
|
||||
/// On AACS 2.x in-drive UHD MKBs the `0x05` table is large (the full
|
||||
/// subset-difference cvalue set: ~181k entries on a retail MKB, 1:1 with
|
||||
/// the giant `0x04` index), while record `0x07` (Explicit
|
||||
/// Subset-Difference Record) is a much smaller structure (~96 entries) and
|
||||
/// is NOT the cvalue table. An earlier version of this function preferred
|
||||
/// `0x07`, which under-tested the Subset-Difference walk on UHD discs and
|
||||
/// prevented the DK→walk path from ever finding the matching uv. The
|
||||
/// selection MUST therefore be `0x05`-first; `0x07` is only a fallback for
|
||||
/// malformed/legacy MKBs that somehow lack a `0x05` record.
|
||||
pub(crate) fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
if let Some(body) = find_record_body(mkb, 0x05) {
|
||||
return Some(body);
|
||||
}
|
||||
find_record_body(mkb, 0x07)
|
||||
}
|
||||
|
||||
/// Walk an MKB and return the payload (header stripped) of the first
|
||||
/// record matching `rec_type`. Returns `None` if no such record exists or
|
||||
/// the record is empty.
|
||||
pub(crate) fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
|
||||
mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == rec_type_wanted && len > 4)
|
||||
.map(|(o, _, len)| mkb[o + 4..o + len].to_vec())
|
||||
}
|
||||
|
||||
/// Real content length of an MKB: the byte offset where the record stream
|
||||
/// ends. MKB files (especially `MKB_RW.inf`, but `MKB_RO.inf` too on some
|
||||
/// discs) are allocated to a fixed size — often ~128 MiB — with the records at
|
||||
/// the front and the rest zero padding. Walking records (type+len) and stopping
|
||||
/// at the first padding byte (`type == 0` / zero-length / overrun) gives the
|
||||
/// actual size so callers can trim off megabytes of zeros before sending or
|
||||
/// archiving. Returns `mkb.len()` only if the whole buffer parsed as records.
|
||||
pub fn mkb_content_len(mkb: &[u8]) -> usize {
|
||||
// End of the last framed record = where the fixed-region zero padding begins.
|
||||
// (The `00 000000` terminator / overrun stops the walk; real MKBs pad with
|
||||
// zeros, so this matches the prior "stop at the first padding byte".)
|
||||
mkb_records(mkb)
|
||||
.last()
|
||||
.map(|(o, _, len)| o + len)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Trim an MKB's trailing fixed-region padding to its real content length —
|
||||
/// but ONLY when [`mkb_content_len`] actually found one. It returns 0 for an
|
||||
/// MKB whose first record cannot be parsed; truncating to 0 in that case would
|
||||
/// hand downstream consumers (and the online key service) an EMPTY MKB that can
|
||||
/// never resolve. So a 0 (or a length that isn't strictly inside the buffer)
|
||||
/// leaves the MKB untouched. A 0.31.0 regression dropped this guard and
|
||||
/// `truncate`-d unconditionally, zeroing unrecognised MKBs.
|
||||
pub fn trim_mkb(mut mkb: Vec<u8>) -> Vec<u8> {
|
||||
let n = mkb_content_len(&mkb);
|
||||
if n > 0 && n < mkb.len() {
|
||||
mkb.truncate(n);
|
||||
}
|
||||
mkb
|
||||
}
|
||||
|
||||
/// Get MKB version from Type and Version Record (type 0x10).
|
||||
/// Layout: 4-byte record header at `pos` (type + BE24 length), then the
|
||||
/// record body starts at `pos + 4`. The body holds the BE u32 Type field at
|
||||
/// body offset 0 (`pos + 4`), then the BE u32 version at body offset 4
|
||||
/// (`pos + 8`).
|
||||
pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
|
||||
// Type-and-Version record (0x10): version is the BE u32 at body offset 4
|
||||
// (record offset 8). Needs rec_len >= 12 (4 header + 4 type + 4 version).
|
||||
mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == REC_TYPE_AND_VERSION && len >= 12)
|
||||
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 8], mkb[o + 9], mkb[o + 10], mkb[o + 11]]))
|
||||
}
|
||||
|
||||
/// `0x00031003` — recordable media MKB (Class I & II compute Km directly).
|
||||
pub const MKB_TYPE_3_RECORDABLE: u32 = 0x0003_1003;
|
||||
|
||||
/// `0x00041003` — AACS 1.0 pre-recorded content MKB (KCD-based). Standard BD.
|
||||
pub const MKB_TYPE_4_PRERECORDED: u32 = 0x0004_1003;
|
||||
|
||||
/// `0x000A1003` — Class II / Unified MKB (Sequence-Key-Block functionality).
|
||||
pub const MKB_TYPE_10_CLASS_II: u32 = 0x000A_1003;
|
||||
|
||||
/// `0x48141003` — AACS 2.0 Category C (UHD content) MKB type value.
|
||||
pub const MKB_20_CATEGORY_C: u32 = 0x4814_1003;
|
||||
|
||||
/// `0x48151003` — AACS 2.1 Category C (UHD content) MKB type value.
|
||||
pub const MKB_21_CATEGORY_C: u32 = 0x4815_1003;
|
||||
|
||||
/// The AACS MKB Type field, decoded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MkbType {
|
||||
/// Type 3 — recordable media.
|
||||
Recordable,
|
||||
/// Type 4 — AACS 1.0 pre-recorded content (KCD). Standard Blu-ray.
|
||||
Prerecorded,
|
||||
/// Type 10 — Class II / Unified (SKB).
|
||||
ClassII,
|
||||
/// AACS 2.0 Category C — UHD content.
|
||||
CategoryC20,
|
||||
/// AACS 2.1 Category C — UHD content.
|
||||
CategoryC21,
|
||||
/// Unrecognized MKBType value (raw field preserved).
|
||||
Other(u32),
|
||||
}
|
||||
|
||||
impl MkbType {
|
||||
pub(crate) fn from_raw(raw: u32) -> Self {
|
||||
match raw {
|
||||
MKB_TYPE_3_RECORDABLE => MkbType::Recordable,
|
||||
MKB_TYPE_4_PRERECORDED => MkbType::Prerecorded,
|
||||
MKB_TYPE_10_CLASS_II => MkbType::ClassII,
|
||||
MKB_20_CATEGORY_C => MkbType::CategoryC20,
|
||||
MKB_21_CATEGORY_C => MkbType::CategoryC21,
|
||||
other => MkbType::Other(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// AACS generation this MKB belongs to (Category C → 2.0/2.1, else 1.0).
|
||||
pub fn generation(self) -> AacsVersion {
|
||||
match self {
|
||||
MkbType::CategoryC21 => AacsVersion::V21,
|
||||
MkbType::CategoryC20 => AacsVersion::V20,
|
||||
_ => AacsVersion::V10,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` for UHD (AACS 2.x Category C); `false` for Blu-ray (AACS 1.x).
|
||||
pub fn is_uhd(self) -> bool {
|
||||
matches!(self, MkbType::CategoryC20 | MkbType::CategoryC21)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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).
|
||||
mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == REC_TYPE_AND_VERSION && len >= 8)
|
||||
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 4], mkb[o + 5], mkb[o + 6], mkb[o + 7]]))
|
||||
}
|
||||
|
||||
/// Decode an MKB's Type field. `None` if no Type-and-Version record is present.
|
||||
pub fn mkb_type(mkb: &[u8]) -> Option<MkbType> {
|
||||
mkb_type_raw(mkb).map(MkbType::from_raw)
|
||||
}
|
||||
|
||||
/// `Some(true)` if this MKB is a UHD (AACS 2.x Category C) block, `Some(false)`
|
||||
/// for Blu-ray (AACS 1.x), `None` if the Type record is absent.
|
||||
pub fn mkb_is_uhd(mkb: &[u8]) -> Option<bool> {
|
||||
mkb_type(mkb).map(MkbType::is_uhd)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One MKB record: 1 type byte + big-endian 24-bit total length + body.
|
||||
fn rec(rec_type: u8, body: &[u8]) -> Vec<u8> {
|
||||
let len = 4 + body.len();
|
||||
let mut v = vec![rec_type, (len >> 16) as u8, (len >> 8) as u8, len as u8];
|
||||
v.extend_from_slice(body);
|
||||
v
|
||||
}
|
||||
|
||||
/// Type-and-Version record (0x10): body = 4-byte MKBType + 4-byte version.
|
||||
fn type_and_version(mkb_type: u32, version: u32) -> Vec<u8> {
|
||||
let mut body = mkb_type.to_be_bytes().to_vec();
|
||||
body.extend_from_slice(&version.to_be_bytes());
|
||||
rec(REC_TYPE_AND_VERSION, &body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walker_frames_records_and_stops_at_end_marker() {
|
||||
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 77);
|
||||
mkb.extend(rec(REC_VKD_TABLE, &[0xAA; 16]));
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
|
||||
mkb.extend(rec(0x99, &[0xFF; 8])); // must NOT be walked (past the marker)
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 2, "walk stops at the 00 000000 end marker");
|
||||
assert_eq!(recs[0].rec_type, REC_TYPE_AND_VERSION);
|
||||
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
|
||||
assert_eq!(recs[1].body, vec![0xAA; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walker_stops_on_malformed_or_out_of_bounds_length() {
|
||||
// A record whose declared length runs past the buffer end must terminate
|
||||
// the walk rather than panic or read OOB.
|
||||
let mkb = vec![REC_VKD_TABLE, 0x00, 0xFF, 0xFF, 0x01, 0x02]; // len=0xFFFF, only 6 bytes
|
||||
assert!(
|
||||
walk_mkb(&mkb).is_empty(),
|
||||
"over-long record yields no records"
|
||||
);
|
||||
// A sub-4 length (shorter than the header itself) is also rejected.
|
||||
let short = vec![REC_VKD_TABLE, 0x00, 0x00, 0x02];
|
||||
assert!(walk_mkb(&short).is_empty(), "sub-4 length is rejected");
|
||||
// A truncated header (< 4 bytes) yields nothing.
|
||||
assert!(walk_mkb(&[0x10, 0x00]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_type_and_version_decode_from_the_type_record() {
|
||||
let mut mkb = type_and_version(MKB_21_CATEGORY_C, 100);
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]);
|
||||
assert_eq!(mkb_type_raw(&mkb), Some(MKB_21_CATEGORY_C));
|
||||
assert_eq!(mkb_version(&mkb), Some(100));
|
||||
assert_eq!(mkb_is_uhd(&mkb), Some(true), "2.1 Category C is UHD");
|
||||
|
||||
let bd = type_and_version(MKB_TYPE_4_PRERECORDED, 68);
|
||||
assert_eq!(
|
||||
mkb_is_uhd(&bd),
|
||||
Some(false),
|
||||
"AACS 1.0 prerecorded is not UHD"
|
||||
);
|
||||
// No Type record → None (not a panic, not a fabricated value).
|
||||
assert_eq!(mkb_version(&rec(REC_VKD_TABLE, &[0; 16])), None);
|
||||
assert_eq!(mkb_type_raw(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_mkb_keeps_only_the_framed_records() {
|
||||
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 1);
|
||||
let content_len = mkb.len(); // the single framed record, no end marker
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
|
||||
mkb.extend([0xDE; 4096]); // trailing padding past the end marker
|
||||
let trimmed = trim_mkb(mkb);
|
||||
assert_eq!(
|
||||
trimmed.len(),
|
||||
content_len,
|
||||
"trim keeps the framed records, dropping the end marker and padding"
|
||||
);
|
||||
}
|
||||
|
||||
// ── BE24 length field: all THREE bytes ────────────────────────────────
|
||||
|
||||
/// The record length is a big-endian **24-bit** field, so the high byte
|
||||
/// carries lengths of 64 KiB and up. The MKB records that matter most are
|
||||
/// exactly that size — a real UHD cvalue table is `46_101 * 16` bytes and a
|
||||
/// `0x2d` variant record is ~92 KiB — so a walker that dropped the high
|
||||
/// byte would mis-frame every record of a real MKB from the first big one
|
||||
/// onward, and every downstream key lookup would read the wrong bytes.
|
||||
///
|
||||
/// (The pre-existing high-byte test used total length `0x0110`, whose high
|
||||
/// byte is ZERO — it exercised the middle byte only. This one puts a
|
||||
/// non-zero value in the high byte.)
|
||||
#[test]
|
||||
fn mkb_records_honors_the_high_byte_of_the_be24_length() {
|
||||
const TOTAL: usize = 0x0001_0004; // 65_540 — high byte 0x01
|
||||
let mut mkb = vec![REC_VKD_TABLE, 0x01, 0x00, 0x04];
|
||||
mkb.resize(TOTAL, 0xAB);
|
||||
// A second record follows, so a walker that mis-read the length would
|
||||
// frame a different number of records rather than merely a short one.
|
||||
mkb.extend(rec(REC_TYPE_AND_VERSION, &[0x11; 8]));
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 2, "the big record must be framed as ONE record");
|
||||
assert_eq!(
|
||||
recs[0].rec_len, TOTAL,
|
||||
"rec_len must include the high BE24 byte"
|
||||
);
|
||||
assert_eq!(recs[0].body.len(), TOTAL - 4);
|
||||
assert_eq!(
|
||||
recs[1].rec_type, REC_TYPE_AND_VERSION,
|
||||
"the following record must start where the big one ends"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Header-only records and the exact end marker ──────────────────────
|
||||
|
||||
/// `rec_len == 4` is a well-formed HEADER-ONLY record (the minimum the
|
||||
/// walker accepts), including one sitting at the very end of the buffer
|
||||
/// with no bytes after it. Rejecting either — the `pos + 4` bound or the
|
||||
/// `rec_len < 4` floor being off by one — silently drops the MKB's last
|
||||
/// record, and "the record isn't there" is indistinguishable from "the disc
|
||||
/// doesn't carry it".
|
||||
#[test]
|
||||
fn mkb_records_yields_a_header_only_record_at_the_buffer_end() {
|
||||
let mut mkb = rec(REC_TYPE_AND_VERSION, &[0xAA, 0xBB]);
|
||||
mkb.extend([REC_VKD_TABLE, 0x00, 0x00, 0x04]); // 4-byte, empty body, at EOF
|
||||
assert_eq!(
|
||||
mkb.len(),
|
||||
10,
|
||||
"sanity: the last record ends at the buffer end"
|
||||
);
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 2, "the trailing header-only record is a record");
|
||||
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
|
||||
assert_eq!(recs[1].rec_len, 4);
|
||||
assert!(recs[1].body.is_empty());
|
||||
}
|
||||
|
||||
/// ONLY the exact `00 00 00 00` marker ends the walk. A record whose TYPE
|
||||
/// happens to be `0x00` but which declares a real length is a record, not
|
||||
/// the end of the MKB — stopping there would truncate everything after it,
|
||||
/// including the cvalue and verify records the key derivation needs.
|
||||
#[test]
|
||||
fn mkb_records_stops_only_on_the_all_zero_end_marker() {
|
||||
// A type-0 record of length 8, then a normal record, then the marker.
|
||||
let mut mkb = vec![0x00, 0x00, 0x00, 0x08, 1, 2, 3, 4];
|
||||
mkb.extend(rec(REC_VKD_TABLE, &[0x55; 16]));
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // the real end marker
|
||||
mkb.extend(rec(0x99, &[0xFF; 4])); // past the marker: not walked
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(
|
||||
recs.len(),
|
||||
2,
|
||||
"a type-0 record with a non-zero length is a record, not the end"
|
||||
);
|
||||
assert_eq!(recs[0].rec_type, 0x00);
|
||||
assert_eq!(recs[0].rec_len, 8);
|
||||
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
|
||||
assert_eq!(recs[1].body, vec![0x55; 16]);
|
||||
}
|
||||
|
||||
/// `mkb_type_raw` reports the 32-bit MKBType field verbatim ([C] §3.2.5.1.1
|
||||
/// Table 3-2), including a value this build does not recognise — the caller
|
||||
/// uses it to tell "unknown MKB generation" from "no Type record at all".
|
||||
/// All four bytes must come from the record body; reading any of them from
|
||||
/// the wrong offset yields a type that silently classifies as a different
|
||||
/// AACS generation.
|
||||
///
|
||||
/// The recognised constants all share bytes with the `0x10` record-type
|
||||
/// header byte (e.g. `MKB_21_CATEGORY_C` is `48 15 10 03`), so this uses a
|
||||
/// value with four distinct bytes, none of them `0x10`.
|
||||
#[test]
|
||||
fn mkb_type_raw_reads_all_four_body_bytes() {
|
||||
const RAW: u32 = 0xDEAD_BEEF;
|
||||
let mkb = type_and_version(RAW, 7);
|
||||
assert_eq!(
|
||||
mkb_type_raw(&mkb),
|
||||
Some(RAW),
|
||||
"every byte of the MKBType field must come from the record body"
|
||||
);
|
||||
assert_eq!(mkb_version(&mkb), Some(7));
|
||||
}
|
||||
}
|
||||
+345
-60
@@ -13,20 +13,52 @@
|
||||
//!
|
||||
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
|
||||
//! Title keys decrypt m2ts stream content (AES-128-CBC).
|
||||
//!
|
||||
//! ## Spec provenance
|
||||
//!
|
||||
//! The crypto below carries `[TAG] §x.y` citations back to the published AACS
|
||||
//! specification (Final Rev 0.953), so each primitive links to the section it
|
||||
//! implements:
|
||||
//! - `[C]` — AACS Introduction and Common Cryptographic Elements Book (primitives, MKB/key-management).
|
||||
//! - `[PR]` — AACS Pre-recorded Video Book (Volume/Title Key layer).
|
||||
//! - `[BD]` — AACS Blu-ray Disc Pre-recorded Book (CPS Unit Key, Aligned Unit, Block Key).
|
||||
//! - `[RE]` — reverse-engineered from real discs, cited only where the public
|
||||
//! spec is silent (the `0x86` verify record and the Category-C MKB type values).
|
||||
|
||||
pub mod boil;
|
||||
pub mod decrypt;
|
||||
pub mod content;
|
||||
pub mod crypto;
|
||||
pub mod derive;
|
||||
pub mod host_certs;
|
||||
pub mod keys;
|
||||
pub mod index_select;
|
||||
pub mod inf;
|
||||
pub mod mkb;
|
||||
pub mod provider;
|
||||
pub mod resolve;
|
||||
pub mod segment;
|
||||
pub mod segment_key;
|
||||
pub mod trace;
|
||||
pub mod types;
|
||||
pub mod variants;
|
||||
pub mod variant;
|
||||
|
||||
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
|
||||
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
|
||||
/// `read_mkb_content`, `read_aacs_version`) walks the exact same files — adding
|
||||
/// or changing a fallback in one place can then never silently diverge the
|
||||
/// On-disc UDF paths to the AACS key-input files, plus HD DVD AACS-directory
|
||||
/// discovery.
|
||||
///
|
||||
/// BD and UHD keep their key material under a fixed `/AACS/…` tree, so those
|
||||
/// paths are constants. HD DVD keeps the equivalents in a reserved root
|
||||
/// directory whose NAME is authoring-house-specific — observed `ANY!` (Dukes
|
||||
/// of Hazzard) and `AAC!` (Freedom / Memory-Tech), each with a `<name>!_BAK`
|
||||
/// mirror — and whose title-key file is NOT always `VTKF000.AACS` (Freedom
|
||||
/// ships `VTKF090.AACS` + `VTKF100.AACS`). So the HD DVD files are DISCOVERED
|
||||
/// from the parsed UDF tree ([`find_hddvd_aacs_dir`] + [`role_paths`]), never
|
||||
/// hardcoded.
|
||||
///
|
||||
/// Each key ROLE ([`AacsRole`]) resolves to an ordered candidate list — the
|
||||
/// BD/UHD constants first, then whatever the HD DVD directory actually holds —
|
||||
/// which every reader walks with [`read_first`], first-that-reads. No reader
|
||||
/// ever branches on disc type: a BD/UHD disc has the `/AACS/` files so those
|
||||
/// win; an HD DVD has none of them, so it falls through to the discovered
|
||||
/// entries. Centralised so `resolve_vid_only`, `read_aacs_inputs`,
|
||||
/// `read_mkb_content`, and `read_aacs_version` can never silently diverge the
|
||||
/// disc_hash / MKB / VID that another reader feeds a key service.
|
||||
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
|
||||
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
|
||||
@@ -35,50 +67,135 @@ 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";
|
||||
|
||||
// Boil-down derivation primitives (thin newtypes + wrappers over the crypto).
|
||||
pub use boil::{MediaKey, UnitKey, Vid, Vuk, mk_from_dk, mk_from_pk, uk_from_vuk, vuk_from_mk};
|
||||
// Structured, English-free resolution trace.
|
||||
pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, UnlockStep};
|
||||
/// 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,
|
||||
}
|
||||
|
||||
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
|
||||
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
|
||||
pub use decrypt::{
|
||||
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted,
|
||||
aacs_unit_needs_decrypt, aacs_unit_still_ciphertext, decrypt_bus, decrypt_unit,
|
||||
decrypt_unit_checked, decrypt_unit_full, decrypt_unit_try_keys, fill_null_ts_unit,
|
||||
is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed, unit_is_clean_ps,
|
||||
unit_is_clean_ts, unit_key_validates,
|
||||
};
|
||||
// `probe` is a reproduction-harness helper (see keys.rs), not part of the
|
||||
// documented 1.0 surface; keep it reachable but off the rendered docs so we
|
||||
// don't commit semver stability to test primitives.
|
||||
#[doc(hidden)]
|
||||
pub use keys::probe;
|
||||
pub use keys::{
|
||||
AACS_MAJOR_BD, AACS_MAJOR_UHD, AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C,
|
||||
MKB_TYPE_3_RECORDABLE, MKB_TYPE_4_PRERECORDED, MKB_TYPE_10_CLASS_II, MkbType, ResolveContext,
|
||||
ResolveFailure, ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_and_pk_from_dk,
|
||||
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
|
||||
mkb_content_len, mkb_is_uhd, mkb_type, mkb_type_raw, mkb_version, parse_content_cert,
|
||||
parse_unit_key_ro, read_mkb_from_drive, recover_dk_position, resolve_keys_v1, resolve_keys_v2,
|
||||
resolve_keys_v21, resolve_keys_with_reason, trim_mkb,
|
||||
};
|
||||
pub use provider::KeyProvider;
|
||||
pub use types::{DeviceKey, DiscEntry, HostCert};
|
||||
pub use variants::{
|
||||
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
|
||||
derive_media_key_variant, is_variant_mkb, variant_nonce, walk_mkb, walk_processing_key,
|
||||
};
|
||||
/// The HD DVD AACS directory in a parsed UDF tree, if present.
|
||||
///
|
||||
/// Identified structurally, NOT by a hardcoded name: the root child directory
|
||||
/// whose name ends in `!` (so the `<name>!_BAK` backup mirror, which also ends
|
||||
/// in a non-`!` char, is not mistaken for it) and which contains `MKBROM.AACS`.
|
||||
/// Observed real names: `ANY!` (Dukes of Hazzard), `AAC!` (Freedom). A BD/UHD
|
||||
/// disc has no such directory → `None`.
|
||||
pub(crate) fn find_hddvd_aacs_dir(udf: &crate::udf::UdfFs) -> Option<&crate::udf::DirEntry> {
|
||||
udf.root.entries.iter().find(|e| {
|
||||
e.is_dir
|
||||
&& e.name.ends_with('!')
|
||||
&& e.entries
|
||||
.iter()
|
||||
.any(|c| !c.is_dir && c.name.eq_ignore_ascii_case("MKBROM.AACS"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Ordered candidate paths for an AACS key [`AacsRole`]: the fixed BD/UHD
|
||||
/// `/AACS/…` paths first, then the actual HD DVD files discovered in the disc's
|
||||
/// AACS directory (see [`find_hddvd_aacs_dir`]). A disc has only one family, so
|
||||
/// the other family's entries simply never read.
|
||||
///
|
||||
/// For [`AacsRole::UnitKey`] every `VTKF*.AACS` in the directory is appended in
|
||||
/// sorted name order — a disc may carry more than one variant (Freedom:
|
||||
/// `VTKF090` + `VTKF100`), not just `VTKF000`.
|
||||
pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec<String> {
|
||||
let mut v: Vec<String> = match role {
|
||||
AacsRole::UnitKey => vec![PATH_UNIT_KEY_RO, PATH_UNIT_KEY_RO_DUPLICATE],
|
||||
AacsRole::Mkb => vec![PATH_MKB_RO, PATH_MKB_RW],
|
||||
AacsRole::ContentCert => vec![PATH_CONTENT_CERT, PATH_CONTENT_CERT_ALT],
|
||||
}
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
if let Some(dir) = find_hddvd_aacs_dir(udf) {
|
||||
let d = &dir.name;
|
||||
match role {
|
||||
AacsRole::Mkb => v.push(format!("/{d}/MKBROM.AACS")),
|
||||
AacsRole::ContentCert => v.push(format!("/{d}/CONTENT_CERT.AACS")),
|
||||
AacsRole::UnitKey => {
|
||||
// Glob VTKF*.AACS — the title-key filename is not fixed at
|
||||
// VTKF000 (Freedom ships VTKF090 + VTKF100). Sorted for a
|
||||
// deterministic try order.
|
||||
//
|
||||
// Each VTKF%%%.AACS is bound to ONE playlist (VPLST%%%.XPL): the
|
||||
// TKF's 12-byte PLAYLIST_NAME field (bytes 0x10..0x1C) names the
|
||||
// playlist whose Title Keys it carries, and keys from a TKF whose
|
||||
// name does not match the title's playlist must not be used. The
|
||||
// caller resolves this by trying candidates in sorted order and
|
||||
// decrypting with the one whose keys verify — correct for a
|
||||
// single-playlist disc; a name-matched selection keyed on the
|
||||
// active playlist is the precise form for multi-playlist discs.
|
||||
let mut names: Vec<&str> = dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir)
|
||||
.filter(|e| {
|
||||
let u = e.name.to_ascii_uppercase();
|
||||
u.starts_with("VTKF") && u.ends_with(".AACS")
|
||||
})
|
||||
.map(|e| e.name.as_str())
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
v.extend(names.into_iter().map(|n| format!("/{d}/{n}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Walk an AACS role's candidate paths (from [`role_paths`]) and return the
|
||||
/// first that reads.
|
||||
///
|
||||
/// `read` performs the actual per-path read (full file or bounded prefix), so
|
||||
/// callers share the same first-present walk regardless of read style. Returns
|
||||
/// [`Error::AacsNoKeys`] if no candidate is present. 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.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.
|
||||
pub use content::ALIGNED_UNIT_LEN;
|
||||
pub use derive::derive_vuk;
|
||||
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Re-export surface guards. The module's public API is the set of
|
||||
//! `pub use` items above. A regression that drops or renames an export
|
||||
//! (the class of bug that shipped in 0.31.0 by silently changing a
|
||||
//! surface) breaks compilation of these references, so they act as a
|
||||
//! compile-time contract for the crate's AACS surface.
|
||||
//! Surface guards. The public API is the module tree itself (no facade).
|
||||
//! Touching one representative item per module keeps these as a
|
||||
//! compile-time contract that the module paths stay stable.
|
||||
|
||||
use super::*;
|
||||
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() {
|
||||
@@ -100,21 +217,189 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_correction_data_placeholder_is_all_zero() {
|
||||
// The variant chain refuses to run against this all-zero placeholder
|
||||
// KCD; the public constant must therefore be exactly 16 zero bytes.
|
||||
assert_eq!(KEY_CORRECTION_DATA_PLACEHOLDER, [0u8; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_helpers_are_callable_through_the_facade() {
|
||||
// Touch a representative function from each re-export group so a
|
||||
// dropped/renamed export fails to compile. These are smoke calls, not
|
||||
// behavioural assertions (behaviour is covered in each module).
|
||||
let _ = ts_sync_destroyed(&[0u8; ALIGNED_UNIT_LEN]);
|
||||
fn public_helpers_are_callable_by_module_path() {
|
||||
// Touch a representative function from each module so a dropped/renamed
|
||||
// item fails to compile. Smoke calls, not behavioural assertions.
|
||||
let _ = !crate::aacs::content::is_clean(
|
||||
&[0u8; ALIGNED_UNIT_LEN],
|
||||
crate::disc::ContentFormat::BdTs,
|
||||
);
|
||||
let _ = mkb_content_len(&[]);
|
||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||
let _ = mk_from_pk(&[[0u8; 16]], &[]);
|
||||
let _ = super::derive::resolve_candidate(
|
||||
&super::derive::KeyCandidate::Uk(super::types::UnitKey::new(0, [0u8; 16])),
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
// ── HD DVD AACS directory / filename discovery ────────────────────────
|
||||
//
|
||||
// The HD DVD AACS dir name and title-key filename are authoring-specific
|
||||
// and were previously hardcoded to `/ANY!/VTKF000.AACS`. These verify the
|
||||
// discovery replacement against both real-disc shapes: Freedom (`AAC!` +
|
||||
// `VTKF090`/`VTKF100`) and a BD/UHD disc (no HD DVD dir).
|
||||
|
||||
#[test]
|
||||
fn role_paths_discovers_hddvd_dir_and_globs_all_vtkf_variants() {
|
||||
use crate::udf::fixture::*;
|
||||
// Freedom-shaped: an `AAC!` dir (NOT `ANY!`) holding MKBROM + two VTKF
|
||||
// variants (090/100, NOT 000) + a VTUF usage file (must be excluded),
|
||||
// plus the `AAC!_BAK` mirror (must NOT be picked as the AACS dir).
|
||||
let mut disc = MemDisc::new();
|
||||
let aacs_files = vec![
|
||||
file("MKBROM.AACS", 100, 5000, 4096, true),
|
||||
file("CONTENT_CERT.AACS", 101, 5100, 2048, true),
|
||||
file("VTKF100.AACS", 102, 5200, 2048, true),
|
||||
file("VTKF090.AACS", 103, 5300, 2048, true),
|
||||
file("VTUF090.AACS", 104, 5400, 2048, true),
|
||||
];
|
||||
let bak_files = vec![file("MKBROM.AACS", 110, 6000, 4096, true)];
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![
|
||||
DirSpec {
|
||||
name: "AAC!".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: aacs_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
name: "AAC!_BAK".to_string(),
|
||||
icb_lba: 30,
|
||||
dir_data_lba: 31,
|
||||
files: bak_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
// Discovered structurally (ends in '!', holds MKBROM.AACS) — the real
|
||||
// AACS dir, never the `_BAK` mirror.
|
||||
let dir = super::find_hddvd_aacs_dir(&udf).expect("aacs dir");
|
||||
assert_eq!(dir.name, "AAC!");
|
||||
|
||||
// UnitKey: BD/UHD paths first, then EVERY VTKF*.AACS in sorted order
|
||||
// (090 before 100) — NOT hardcoded VTKF000; VTUF (usage) excluded.
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
"/AAC!/VTKF090.AACS".to_string(),
|
||||
"/AAC!/VTKF100.AACS".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::Mkb)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/MKBROM.AACS"
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::ContentCert)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/CONTENT_CERT.AACS"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `!`-suffix and the `MKBROM.AACS` presence test are BOTH required —
|
||||
/// the discovery is a conjunction, not a disjunction.
|
||||
///
|
||||
/// The existing fixtures only ever present a directory that satisfies both
|
||||
/// (`AAC!` with `MKBROM.AACS`) alongside one that satisfies neither
|
||||
/// (`AAC!_BAK` — which contains `MKBROM.AACS` but is ALSO reached only after
|
||||
/// the real dir), so either half of the conjunction could be dropped and the
|
||||
/// same directory would still be found. Here a directory satisfies the name
|
||||
/// half and NOT the contents half: it must not be picked.
|
||||
///
|
||||
/// If it were, the HD DVD path would resolve `MKBROM.AACS`,
|
||||
/// `CONTENT_CERT.AACS` and the title-key file under a directory that holds
|
||||
/// none of them — the disc reports "no AACS key files" and never rips.
|
||||
#[test]
|
||||
fn a_bang_suffixed_directory_without_mkbrom_is_not_the_aacs_directory() {
|
||||
use crate::udf::fixture::*;
|
||||
let mut disc = MemDisc::new();
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
// Ends in '!' — but carries no MKBROM.AACS, so it is not the
|
||||
// HD DVD AACS directory.
|
||||
name: "AAC!".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![
|
||||
file("VTKF090.AACS", 102, 5200, 2048, true),
|
||||
file("CONTENT_CERT.AACS", 103, 5300, 2048, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert!(
|
||||
super::find_hddvd_aacs_dir(&udf).is_none(),
|
||||
"a '!' directory without MKBROM.AACS is not the AACS directory"
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
],
|
||||
"no HD DVD candidates may be appended from a directory that was \
|
||||
never identified as the AACS directory"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_paths_bd_uhd_disc_yields_no_hddvd_candidates() {
|
||||
use crate::udf::fixture::*;
|
||||
// A `/AACS/` tree (BD/UHD) has no '!' directory → discovery finds none
|
||||
// and the candidate list is exactly the static BD/UHD paths.
|
||||
let mut disc = MemDisc::new();
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
name: "AACS".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![
|
||||
file("Unit_Key_RO.inf", 100, 5000, 2048, true),
|
||||
file("MKB_RO.inf", 101, 5100, 2048, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert!(super::find_hddvd_aacs_dir(&udf).is_none());
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+48
-1
@@ -42,7 +42,7 @@ use super::types::{DeviceKey, DiscEntry, HostCert};
|
||||
/// Source of AACS key material.
|
||||
///
|
||||
/// Implementors return raw material only — the resolver in
|
||||
/// `aacs::keys` owns all the crypto (DK→PK walking, PK validation,
|
||||
/// `aacs::resolve` and `aacs::derive` own the crypto (DK→PK walking, PK validation,
|
||||
/// MK→VUK→TK derivation). See module docs for method semantics.
|
||||
pub trait KeyProvider: Send + Sync {
|
||||
/// Device keys (top-of-tree, walked by the resolver).
|
||||
@@ -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] = &[];
|
||||
|
||||
+304
-1154
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,454 @@
|
||||
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
|
||||
//!
|
||||
//! An FMTS main feature interleaves short forensic **segments** — the sequence-key
|
||||
//! / forensic-watermark mechanism. Each segment carries an **index** (1..32): a
|
||||
//! tag in `IndividualSegment.tbl` that selects which of the 32 forensic **index
|
||||
//! keys** decrypts that segment's units, in place of the ordinary CPS Unit Key.
|
||||
//!
|
||||
//! Terminology (see the project AACS reference): the **index** here is NOT the
|
||||
//! AACS 2.1 *Media Key Variant* — that is the 65536-value device selector in the
|
||||
//! MKB that decides *which set* of index keys a device receives, a layer this
|
||||
//! module does not deal with. All the index keys belong to one variant, whose
|
||||
//! number is unknown and irrelevant to the segment map. Decrypting a segment with
|
||||
//! the Unit Key yields garbage — broken HEVC reference frames (empirically:
|
||||
//! `Could not find ref with POC …` on a plain unit-key rip).
|
||||
//!
|
||||
//! This table says WHERE the segments live and which index each carries, so a
|
||||
//! decoder can decrypt them with the matching index key instead of muxing
|
||||
//! unit-key garbage.
|
||||
//!
|
||||
//! 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 index | u16 flag (= 1)
|
||||
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
|
||||
//! ```
|
||||
//! `index` is the 1..32 forensic index tag, NOT a sequential segment id: measured
|
||||
//! on a retail 2.1 disc it cycles 1,2,…,32,1,2,… across records in
|
||||
//! file order — 24 full cycles of 32 plus a final partial cycle of 24 = 792
|
||||
//! records. Source-packet numbers are the 192-byte BDAV packet index: byte offset
|
||||
//! = `spn * 192`. Each segment is ~2560 packets (~480 KB) = 80 aligned units,
|
||||
//! spread across the entire 54 GB feature (one roughly every 67 MB). Inside a
|
||||
//! segment the 80 units interleave in two stride-2 halves: applying the segment's
|
||||
//! index key decrypts ~40 of them to clean TS and garbles the other ~40 (a second
|
||||
//! interleaved half, unidentified), which the demux then drops — leaving one
|
||||
//! coherent stream. Confirmed by decoding a retail disc with a full set of 32
|
||||
//! index keys.
|
||||
|
||||
/// Fixed size of one `IndividualSegment.tbl` record.
|
||||
pub const SEGMENT_RECORD_LEN: usize = 16;
|
||||
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
|
||||
pub const SOURCE_PACKET_LEN: u64 = 192;
|
||||
|
||||
/// One forensic segment: the inclusive source-packet range it occupies in the
|
||||
/// FMTS clip.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Segment {
|
||||
/// 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).
|
||||
pub end_spn: u32,
|
||||
}
|
||||
|
||||
impl Segment {
|
||||
/// Source-packet count in this (inclusive) segment.
|
||||
pub fn packet_count(&self) -> u32 {
|
||||
self.end_spn
|
||||
.saturating_sub(self.start_spn)
|
||||
.saturating_add(1)
|
||||
}
|
||||
|
||||
/// Byte offset of the segment start within the clip (`start_spn * 192`).
|
||||
pub fn start_byte(&self) -> u64 {
|
||||
self.start_spn as u64 * SOURCE_PACKET_LEN
|
||||
}
|
||||
|
||||
/// Byte length of the segment (`packet_count * 192`).
|
||||
pub fn byte_len(&self) -> u64 {
|
||||
self.packet_count() as u64 * SOURCE_PACKET_LEN
|
||||
}
|
||||
|
||||
/// True when source packet `spn` falls inside this segment.
|
||||
pub fn contains_spn(&self, spn: u32) -> bool {
|
||||
spn >= self.start_spn && spn <= self.end_spn
|
||||
}
|
||||
|
||||
/// True when the inclusive source-packet span `[first, last]` overlaps this
|
||||
/// segment. Used to decide whether an aligned unit (which spans several
|
||||
/// packets) touches the segment at all, not just whether one packet does.
|
||||
pub fn overlaps_spn(&self, first: u32, last: u32) -> bool {
|
||||
first <= self.end_spn && last >= self.start_spn
|
||||
}
|
||||
}
|
||||
|
||||
/// Source packets spanned by one AACS aligned unit: `6144 / 192 = 32`.
|
||||
pub const PACKETS_PER_UNIT: u32 =
|
||||
(crate::aacs::content::ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32;
|
||||
|
||||
/// Byte offset within the clip of a clip-relative 2048-byte sector `lba`. The
|
||||
/// FMTS decode reads the clip file directly, so `lba` 0 is the clip's first
|
||||
/// byte and this offset lines up with the source-packet grid the segment map
|
||||
/// uses.
|
||||
pub fn lba_byte_offset(lba: u32) -> u64 {
|
||||
lba as u64 * 2048
|
||||
}
|
||||
|
||||
/// The forensic segment an AACS aligned unit belongs to, if any, given the
|
||||
/// unit's clip-relative byte offset.
|
||||
///
|
||||
/// This is the routing decision behind a 2.1 decrypt-miss: a unit that
|
||||
/// overlaps a forensic segment must be opened with that segment's **index key**
|
||||
/// (selected by the segment's `index`), not the CPS Unit Key. Opening it with
|
||||
/// the Unit Key is exactly what yields the broken-reference-frame garbage a
|
||||
/// plain unit-key rip produces. A unit outside every segment is ordinary
|
||||
/// content and a miss on it is a Unit-Key miss, so this returns `None` and the
|
||||
/// caller falls back to the normal unit-key fetch.
|
||||
///
|
||||
/// The unit is tested as a packet *span* (`[off/192, (off+6144-1)/192]`) so a
|
||||
/// unit that only partly overlaps a segment edge is still classified as
|
||||
/// forensic; on the observed disc segments are unit-aligned, but the span test
|
||||
/// does not rely on that.
|
||||
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 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
|
||||
/// yielding bogus ranges.
|
||||
pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
||||
if tbl.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let count = u16::from_be_bytes([tbl[4], tbl[5]]) as usize;
|
||||
let record_size = u16::from_be_bytes([tbl[6], tbl[7]]) as usize;
|
||||
if record_size != SEGMENT_RECORD_LEN {
|
||||
return None;
|
||||
}
|
||||
if 8usize.checked_add(count.checked_mul(record_size)?)? > tbl.len() {
|
||||
return None;
|
||||
}
|
||||
let mut segments = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let o = 8 + i * record_size;
|
||||
// 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 {
|
||||
index,
|
||||
start_spn,
|
||||
end_spn,
|
||||
});
|
||||
}
|
||||
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 emitted as one range per whole-sector slice it covers.
|
||||
///
|
||||
/// 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
|
||||
// A segment is unit-aligned and contiguous in clip bytes; map its first
|
||||
// and last sector to LBAs. Segments are ~480 KB and extents are GB-sized,
|
||||
// so a segment almost never crosses an extent boundary — but if the two
|
||||
// ends land in different extents (non-contiguous LBAs), skip rather than
|
||||
// emit a wrong span; the units there fall to the Unit Key (garble+drop),
|
||||
// never a mis-decrypt.
|
||||
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 `(index, start_spn, end_spn)`.
|
||||
fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
|
||||
v.extend_from_slice(&(recs.len() as u16).to_be_bytes()); // count
|
||||
v.extend_from_slice(&(SEGMENT_RECORD_LEN as u16).to_be_bytes()); // record_size
|
||||
for &(n, s, e) in recs {
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // marker
|
||||
v.extend_from_slice(&n.to_be_bytes());
|
||||
v.extend_from_slice(&1u16.to_be_bytes()); // flag
|
||||
v.extend_from_slice(&s.to_be_bytes());
|
||||
v.extend_from_slice(&e.to_be_bytes());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmts_key_ranges_maps_segments_to_lba_by_index() {
|
||||
use crate::disc::Extent;
|
||||
// One big clip extent starting at LBA 1000. Clip byte B lives at
|
||||
// LBA 1000 + B/2048.
|
||||
let extents = vec![Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 1_000_000,
|
||||
}];
|
||||
// Two segments, indexes 5 and 7 (spn ranges as on a real disc).
|
||||
let segs = vec![
|
||||
Segment {
|
||||
index: 5,
|
||||
start_spn: 100,
|
||||
end_spn: 199,
|
||||
},
|
||||
Segment {
|
||||
index: 7,
|
||||
start_spn: 10_000,
|
||||
end_spn: 10_099,
|
||||
},
|
||||
];
|
||||
// Pool layout [base, idx1, idx2, …] → index N uses key slot N.
|
||||
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||
assert_eq!(ranges.len(), 2, "one LBA range per segment");
|
||||
// Segment 0: spn 100..=199 → clip bytes [19200, 38400) → sectors 9..=18
|
||||
// → LBA 1009..1019, key index 5.
|
||||
assert_eq!(ranges[0], (1009, 1019, 5));
|
||||
// Segment 1: spn 10000..=10099 → bytes [1_920_000, 1_939_200) →
|
||||
// sectors 937..=946 → LBA 1937..1947, key index 7.
|
||||
assert_eq!(ranges[1], (1937, 1947, 7));
|
||||
|
||||
// The ranges drive a positive AacsKeyMap: an LBA in no range has no key.
|
||||
let map = crate::decrypt::AacsKeyMap::from_ranges(ranges);
|
||||
assert_eq!(map.key_idx_for(500), None, "outside any segment → no key");
|
||||
assert_eq!(
|
||||
map.key_idx_for(1012),
|
||||
Some(5),
|
||||
"inside index-5 segment → key 5"
|
||||
);
|
||||
assert_eq!(
|
||||
map.key_idx_for(1940),
|
||||
Some(7),
|
||||
"inside index-7 segment → key 7"
|
||||
);
|
||||
assert_eq!(
|
||||
map.key_idx_for(1019),
|
||||
None,
|
||||
"segment end is exclusive → no key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmts_key_ranges_skips_inverted_segment_without_underflow() {
|
||||
use crate::disc::Extent;
|
||||
let extents = vec![Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 1_000_000,
|
||||
}];
|
||||
// start_spn == end_spn + 1: `end_byte - 1 - start_byte` would underflow.
|
||||
// The record must be skipped rather than panic (debug) / wrap (release).
|
||||
let segs = vec![Segment {
|
||||
index: 5,
|
||||
start_spn: 200,
|
||||
end_spn: 199,
|
||||
}];
|
||||
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||
assert!(ranges.is_empty(), "inverted segment yields no range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_byte_to_lba_walks_extents() {
|
||||
use crate::disc::Extent;
|
||||
let extents = vec![
|
||||
Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 10,
|
||||
}, // clip bytes [0, 20480)
|
||||
Extent {
|
||||
start_lba: 500,
|
||||
sector_count: 10,
|
||||
}, // clip bytes [20480, 40960)
|
||||
];
|
||||
assert_eq!(clip_byte_to_lba(&extents, 0), Some(100));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 2048), Some(101));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 20480), Some(500)); // second extent
|
||||
assert_eq!(clip_byte_to_lba(&extents, 22528), Some(501));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 40960), None); // past the clip
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_real_disc_layout() {
|
||||
// First three records observed on retail 2.1: the variant
|
||||
// field counts 1,2,3,… (it wraps at 32 further into the table — see
|
||||
// `index_field_cycles_one_to_thirty_two`), segments are 2560 packets.
|
||||
let tbl = build_tbl(&[
|
||||
(1, 343680, 346239),
|
||||
(2, 695616, 698175),
|
||||
(3, 1051840, 1054399),
|
||||
]);
|
||||
let segs = parse_individual_segments(&tbl).expect("parse");
|
||||
assert_eq!(segs.len(), 3);
|
||||
assert_eq!(segs[0].index, 1);
|
||||
assert_eq!(segs[1].index, 2);
|
||||
assert_eq!(segs[2].index, 3);
|
||||
assert_eq!(segs[0].start_spn, 343680);
|
||||
assert_eq!(segs[0].end_spn, 346239);
|
||||
assert_eq!(segs[0].packet_count(), 2560);
|
||||
assert_eq!(segs[0].byte_len(), 2560 * 192);
|
||||
assert_eq!(segs[0].start_byte(), 343680 * 192);
|
||||
assert!(segs[0].contains_spn(345000));
|
||||
assert!(!segs[0].contains_spn(343679));
|
||||
assert!(!segs[0].contains_spn(346240));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_record_size() {
|
||||
let mut tbl = build_tbl(&[(1, 0, 10)]);
|
||||
tbl[6..8].copy_from_slice(&20u16.to_be_bytes()); // record_size != 16
|
||||
assert!(parse_individual_segments(&tbl).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_and_overrun() {
|
||||
assert!(parse_individual_segments(&[0u8; 4]).is_none()); // < header
|
||||
let mut tbl = build_tbl(&[(1, 0, 10)]);
|
||||
tbl[4..6].copy_from_slice(&99u16.to_be_bytes()); // claims 99 recs, has 1
|
||||
assert!(parse_individual_segments(&tbl).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_table_is_empty_not_none() {
|
||||
let tbl = build_tbl(&[]);
|
||||
assert_eq!(parse_individual_segments(&tbl), Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packets_per_unit_is_thirty_two() {
|
||||
// 6144-byte aligned unit / 192-byte source packet.
|
||||
assert_eq!(PACKETS_PER_UNIT, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_inside_segment_routes_to_index() {
|
||||
// A real first-record segment: packets [343680, 346239].
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
|
||||
let off = 344000u64 * SOURCE_PACKET_LEN;
|
||||
let hit = segment_for_unit(&segs, off).expect("inside the segment");
|
||||
assert_eq!(hit.index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_field_cycles_one_to_thirty_two() {
|
||||
// Reality on a retail 2.1 disc: field@4 is the index, cycling 1..=32 in file
|
||||
// order (NOT a sequential segment id). Reproduce one-and-a-bit cycles.
|
||||
let mut recs = Vec::new();
|
||||
let mut spn = 1000u32;
|
||||
for row in 0..2 {
|
||||
for v in 1..=32u16 {
|
||||
recs.push((v, spn, spn + 2559));
|
||||
spn += 50_000; // ~one segment every ~67 MB
|
||||
}
|
||||
let _ = row;
|
||||
}
|
||||
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
|
||||
assert_eq!(segs.len(), 64);
|
||||
assert_eq!(segs[31].index, 32); // end of first cycle
|
||||
assert_eq!(segs[32].index, 1); // wraps, does not become 33
|
||||
assert!(segs.iter().all(|s| (1..=32).contains(&s.index)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_outside_every_segment_is_unit_key_miss() {
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit well before the segment is ordinary content → None (unit-key path).
|
||||
let off = 1000u64 * SOURCE_PACKET_LEN;
|
||||
assert!(segment_for_unit(&segs, off).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_straddling_a_segment_edge_counts_as_forensic() {
|
||||
// Segment starts at packet 100. A unit that ENDS just inside it (its 32
|
||||
// packets straddle the boundary) must still route to the index key,
|
||||
// because part of its ciphertext is forensic-encrypted.
|
||||
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
|
||||
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
let hit = segment_for_unit(&segs, off).expect("straddles the start edge");
|
||||
assert_eq!(hit.index, 7);
|
||||
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
|
||||
let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99]
|
||||
assert!(segment_for_unit(&segs, before).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_segments_never_routes_to_index() {
|
||||
// The 1.0 / 2.0 case: no forensic map, so every miss is a unit-key miss.
|
||||
assert!(segment_for_unit(&[], lba_byte_offset(0)).is_none());
|
||||
assert!(segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lba_maps_to_the_packet_grid() {
|
||||
// A unit is 3 sectors (6144 bytes) = 32 packets. Clip-relative LBA 3 is
|
||||
// the second aligned unit, which starts at packet 32.
|
||||
let off = lba_byte_offset(3);
|
||||
assert_eq!(off / SOURCE_PACKET_LEN, 32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! AACS 2.1 FMTS forensic segment keys, `AACS/SegmentKeyNNNNN.tbl`.
|
||||
//!
|
||||
//! One file per CPS unit (`SegmentKey00001.tbl`, ...). It is the on-disc key
|
||||
//! store for the forensic variant segments mapped by [`super::segment`]. A
|
||||
//! device does not read a segment key directly. It derives a **16-bit variant
|
||||
//! selector** from the Media Key Variant chain (see [`super::variant`]) and uses
|
||||
//! that selector to index this table, which is how the device's position in the
|
||||
//! key tree decides which variant it can decrypt (the traitor-tracing link).
|
||||
//!
|
||||
//! Container format (confirmed against a retail AACS 2.1 disc):
|
||||
//! ```text
|
||||
//! header (8 bytes): u32 tag | u16 index_space | u16 record_size
|
||||
//! record[index_space] (record_size bytes each)
|
||||
//! ```
|
||||
//! On the reference disc: `index_space` = `0xffff` (the full 16-bit selector
|
||||
//! space, 65536 records), `record_size` = `0x0218` = 536. Total
|
||||
//! `8 + 65536 * 536 = 35,127,304` bytes, which matches the file exactly. Each
|
||||
//! record begins with an 8-byte sub-header, then 528 bytes of encrypted key
|
||||
//! material.
|
||||
//!
|
||||
//! **Not yet reversed:** the internal layout of a record's 528-byte payload, and
|
||||
//! how it maps onto the segments of [`super::segment`]. One numeric coincidence
|
||||
//! worth noting for whoever cracks it: the reference disc has 792 segments and
|
||||
//! `528 = 33 * 16`, with `792 = 24 * 33`, so `33` appears on both sides. Until
|
||||
//! the mapping and the key derivation are pinned, this module exposes only the
|
||||
//! confirmed container: locate the record for a given 16-bit selector.
|
||||
|
||||
/// Bytes of the fixed file header.
|
||||
pub const HEADER_LEN: usize = 8;
|
||||
|
||||
/// The on-disc segment-key table container. Borrows the file bytes; a record is
|
||||
/// looked up by the 16-bit variant selector.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SegmentKeyTable<'a> {
|
||||
data: &'a [u8],
|
||||
/// Number of records (the selector index space, e.g. 65536).
|
||||
count: usize,
|
||||
/// Bytes per record (e.g. 536).
|
||||
record_size: usize,
|
||||
}
|
||||
|
||||
impl<'a> SegmentKeyTable<'a> {
|
||||
/// Parse and validate the container header against the buffer length.
|
||||
///
|
||||
/// Returns `None` when the buffer is too small, or the declared
|
||||
/// `count * record_size` (plus header) does not match the buffer, so a
|
||||
/// truncated or foreign table degrades to "no segment keys" rather than
|
||||
/// handing back bogus records. `index_space` of `0xffff` is read as the full
|
||||
/// 65536-entry space (a device selector is a full 16-bit value).
|
||||
pub fn parse(data: &'a [u8]) -> Option<Self> {
|
||||
if data.len() < HEADER_LEN {
|
||||
return None;
|
||||
}
|
||||
let index_space = u16::from_be_bytes([data[4], data[5]]);
|
||||
let record_size = u16::from_be_bytes([data[6], data[7]]) as usize;
|
||||
// 0xffff means the full 16-bit selector space (65536 records).
|
||||
let count = if index_space == 0xffff {
|
||||
0x1_0000
|
||||
} else {
|
||||
index_space as usize
|
||||
};
|
||||
if record_size == 0 {
|
||||
return None;
|
||||
}
|
||||
let body = count.checked_mul(record_size)?;
|
||||
if HEADER_LEN.checked_add(body)? != data.len() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
data,
|
||||
count,
|
||||
record_size,
|
||||
})
|
||||
}
|
||||
|
||||
/// Number of records (the selector index space).
|
||||
pub fn record_count(&self) -> usize {
|
||||
self.count
|
||||
}
|
||||
|
||||
/// Bytes per record.
|
||||
pub fn record_size(&self) -> usize {
|
||||
self.record_size
|
||||
}
|
||||
|
||||
/// The raw record for a 16-bit variant `selector`, including its 8-byte
|
||||
/// sub-header. `None` if the selector is past the table (only possible when
|
||||
/// `index_space` was not the full 16-bit space).
|
||||
pub fn record(&self, selector: u16) -> Option<&'a [u8]> {
|
||||
let idx = selector as usize;
|
||||
if idx >= self.count {
|
||||
return None;
|
||||
}
|
||||
let start = HEADER_LEN + idx * self.record_size;
|
||||
self.data.get(start..start + self.record_size)
|
||||
}
|
||||
|
||||
/// The encrypted key payload for a selector: the record with its 8-byte
|
||||
/// sub-header stripped. The internal layout of these bytes is not yet
|
||||
/// reversed (see module docs).
|
||||
pub fn record_payload(&self, selector: u16) -> Option<&'a [u8]> {
|
||||
self.record(selector).and_then(|r| r.get(HEADER_LEN..))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a container with `record_size` and the given `index_space`, filling
|
||||
/// each record with a distinguishable byte so lookups can be checked.
|
||||
fn build(index_space: u16, record_size: u16) -> Vec<u8> {
|
||||
let count = if index_space == 0xffff {
|
||||
0x1_0000
|
||||
} else {
|
||||
index_space as usize
|
||||
};
|
||||
let mut v = Vec::with_capacity(HEADER_LEN + count * record_size as usize);
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // tag
|
||||
v.extend_from_slice(&index_space.to_be_bytes());
|
||||
v.extend_from_slice(&record_size.to_be_bytes());
|
||||
for i in 0..count {
|
||||
let mut rec = vec![(i & 0xff) as u8; record_size as usize];
|
||||
// sub-header, as seen on disc
|
||||
rec[..8].copy_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
|
||||
v.extend_from_slice(&rec);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_retail_container_geometry() {
|
||||
// The real disc: 0xffff index space, 536-byte records, 35,127,304 total.
|
||||
let data = build(0xffff, 536);
|
||||
assert_eq!(
|
||||
data.len(),
|
||||
35_127_304,
|
||||
"matches the retail file size exactly"
|
||||
);
|
||||
let t = SegmentKeyTable::parse(&data).expect("parse");
|
||||
assert_eq!(t.record_count(), 65_536);
|
||||
assert_eq!(t.record_size(), 536);
|
||||
let rec = t.record(0x1234).expect("record");
|
||||
assert_eq!(rec.len(), 536);
|
||||
assert_eq!(&rec[..8], &[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
|
||||
assert_eq!(t.record_payload(0x1234).unwrap().len(), 528);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_index_space_bounds_lookups() {
|
||||
let data = build(4, 32);
|
||||
let t = SegmentKeyTable::parse(&data).expect("parse");
|
||||
assert_eq!(t.record_count(), 4);
|
||||
assert!(t.record(3).is_some());
|
||||
assert!(t.record(4).is_none(), "selector past the table is None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_size_mismatch_and_truncation() {
|
||||
assert!(SegmentKeyTable::parse(&[0u8; 4]).is_none());
|
||||
let mut data = build(4, 32);
|
||||
data.truncate(data.len() - 1); // body no longer matches header
|
||||
assert!(SegmentKeyTable::parse(&data).is_none());
|
||||
}
|
||||
}
|
||||
+296
-3
@@ -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],
|
||||
@@ -27,8 +27,75 @@ pub struct HostCert {
|
||||
pub certificate_v2: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
|
||||
#[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(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(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(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProcessingKey(pub [u8; 16]);
|
||||
|
||||
/// One decrypted per-CPS-unit AACS title key.
|
||||
///
|
||||
/// `idx` is the POSITIONAL index of the encrypted title key within the slice
|
||||
/// handed to the VUK→UK step (i.e. its order in `Unit_Key_RO.inf`'s key-storage
|
||||
/// 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(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UnitKey {
|
||||
pub idx: u32,
|
||||
pub key: [u8; 16],
|
||||
/// 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 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: `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,
|
||||
index_number: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
index_number,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -43,3 +110,229 @@ pub struct DiscEntry {
|
||||
/// Unit keys (title keys) indexed by CPS unit number
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
|
||||
// ── Redacting `Debug` impls ──────────────────────────────────────────────────
|
||||
//
|
||||
// Every type above carries AACS secret material (device keys, host PRIVATE keys,
|
||||
// media/volume/processing/unit keys). `#[derive(Debug)]` would print those bytes
|
||||
// verbatim, so a stray `debug!("{:?}", …)` or a panic message would leak the
|
||||
// keys. These hand-written impls print only NON-secret shape (presence, lengths,
|
||||
// tree coordinates, indices) — never key bytes. `decrypt::DecryptKeys` follows
|
||||
// the same policy by omitting `Debug` entirely; here we keep `Debug` because
|
||||
// these are `PartialEq`/`Eq` value types used in `assert_eq!` and nested inside
|
||||
// other `#[derive(Debug)]` structs, so the trait must exist — just not leak.
|
||||
// Guarded by `redaction_tests` below.
|
||||
|
||||
impl std::fmt::Debug for DeviceKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DeviceKey")
|
||||
.field("key", &"<redacted>")
|
||||
.field("node", &self.node)
|
||||
.field("uv", &self.uv)
|
||||
.field("u_mask_shift", &self.u_mask_shift)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HostCert {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("HostCert")
|
||||
.field("private_key", &"<redacted>")
|
||||
.field("certificate_len", &self.certificate.len())
|
||||
.field("private_key_v2", &self.private_key_v2.map(|_| "<redacted>"))
|
||||
.field(
|
||||
"certificate_v2_len",
|
||||
&self.certificate_v2.as_ref().map(|c| c.len()),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Vid {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Vid(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MediaKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("MediaKey(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Vuk {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Vuk(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProcessingKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("ProcessingKey(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for UnitKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("UnitKey")
|
||||
.field("idx", &self.idx)
|
||||
.field("key", &"<redacted>")
|
||||
.field("index_number", &self.index_number)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DiscEntry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DiscEntry")
|
||||
.field("disc_hash", &self.disc_hash)
|
||||
.field("title", &self.title)
|
||||
.field("media_key", &self.media_key.map(|_| "<redacted>"))
|
||||
.field("disc_id", &self.disc_id.map(|_| "<redacted>"))
|
||||
.field("vuk", &self.vuk.map(|_| "<redacted>"))
|
||||
.field("unit_keys_len", &self.unit_keys.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unit_key_tests {
|
||||
use super::*;
|
||||
|
||||
/// `is_default_index` is the public predicate that separates ordinary
|
||||
/// (index-0) content keys from FMTS forensic index keys ([`UnitKey`] docs;
|
||||
/// AACS 2.1 `IndividualSegment.tbl` tagging). A body answering `true` for
|
||||
/// everything would present a forensic index key as an ordinary content
|
||||
/// key — the caller would decrypt the bulk of the title with a key that
|
||||
/// only opens 1/32nd of it; answering `false` for everything would hide
|
||||
/// every ordinary key.
|
||||
///
|
||||
/// Pinned against the two NAMED constructors, which are the contract:
|
||||
/// [`UnitKey::new`] builds the ordinary key, [`UnitKey::forensic`] builds
|
||||
/// an index key for `1..=32`.
|
||||
#[test]
|
||||
fn is_default_index_separates_the_two_constructors() {
|
||||
let ordinary = UnitKey::new(0, [0xAA; 16]);
|
||||
assert!(
|
||||
ordinary.is_default_index(),
|
||||
"UnitKey::new builds the ordinary (index-0) key"
|
||||
);
|
||||
|
||||
// Every forensic index the spec allows must be reported as NOT default.
|
||||
for n in 1u8..=32 {
|
||||
let k = UnitKey::forensic(0, [0xAA; 16], n);
|
||||
assert!(
|
||||
!k.is_default_index(),
|
||||
"UnitKey::forensic({n}) is an index key, not the default key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The predicate must agree with the one consumer of `index_number` in the
|
||||
/// crate: [`crate::aacs::index_select::resolve_disc_index`] resolves the
|
||||
/// disc's forensic index from exactly the keys that are NOT default. If
|
||||
/// the two disagree, a disc resolves an index whose key the rest of the
|
||||
/// pipeline treats as ordinary (or vice versa).
|
||||
#[test]
|
||||
fn is_default_index_agrees_with_the_forensic_index_resolver() {
|
||||
use crate::aacs::index_select::resolve_disc_index;
|
||||
|
||||
let keys = [
|
||||
UnitKey::new(0, [0x11; 16]),
|
||||
UnitKey::forensic(1, [0x22; 16], 7),
|
||||
];
|
||||
assert_eq!(
|
||||
resolve_disc_index(&keys),
|
||||
Some(7),
|
||||
"sanity: the resolver picks the forensic key's index"
|
||||
);
|
||||
|
||||
let non_default: Vec<u8> = keys
|
||||
.iter()
|
||||
.filter(|k| !k.is_default_index())
|
||||
.map(|k| k.index_number)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
non_default,
|
||||
vec![7],
|
||||
"exactly the key the resolver picked must be non-default"
|
||||
);
|
||||
|
||||
// An all-ordinary key set resolves no index, and every key must report
|
||||
// itself default.
|
||||
let plain = [UnitKey::new(0, [0x11; 16]), UnitKey::new(1, [0x22; 16])];
|
||||
assert_eq!(resolve_disc_index(&plain), None);
|
||||
assert!(plain.iter().all(|k| k.is_default_index()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod redaction_tests {
|
||||
use super::*;
|
||||
|
||||
// Sentinel key byte 0xD5 = decimal 213. A derived `Debug` prints `[u8;N]`
|
||||
// as decimal, so a leaked key surfaces the substring "213"; the redacting
|
||||
// impls must not. No non-secret field below is 213, so "213" appearing means
|
||||
// key bytes leaked. Each type must also carry a "redacted" marker (or omit
|
||||
// the secret entirely) so re-adding `#[derive(Debug)]` fails this test.
|
||||
const S: u8 = 0xD5;
|
||||
|
||||
fn assert_redacted(what: &str, dbg: &str) {
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"{what}: Debug leaked key bytes (found decimal 213): {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"{what}: Debug missing redaction marker: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_key_debug_is_redacted() {
|
||||
let d = DeviceKey {
|
||||
key: [S; 16],
|
||||
node: 1,
|
||||
uv: 2,
|
||||
u_mask_shift: 3,
|
||||
};
|
||||
assert_redacted("DeviceKey", &format!("{d:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_cert_debug_is_redacted() {
|
||||
let h = HostCert {
|
||||
private_key: [S; 20],
|
||||
certificate: vec![0u8; 92],
|
||||
private_key_v2: Some([S; 32]),
|
||||
certificate_v2: None,
|
||||
};
|
||||
assert_redacted("HostCert", &format!("{h:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newtype_keys_debug_is_redacted() {
|
||||
assert_redacted("Vid", &format!("{:?}", Vid([S; 16])));
|
||||
assert_redacted("MediaKey", &format!("{:?}", MediaKey([S; 16])));
|
||||
assert_redacted("Vuk", &format!("{:?}", Vuk([S; 16])));
|
||||
assert_redacted("ProcessingKey", &format!("{:?}", ProcessingKey([S; 16])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_key_debug_is_redacted() {
|
||||
assert_redacted("UnitKey", &format!("{:?}", UnitKey::new(0, [S; 16])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_entry_debug_is_redacted() {
|
||||
let e = DiscEntry {
|
||||
disc_hash: "0xAA".into(),
|
||||
title: "T".into(),
|
||||
media_key: Some([S; 16]),
|
||||
disc_id: Some([S; 16]),
|
||||
vuk: Some([S; 16]),
|
||||
unit_keys: vec![(1, [S; 16])],
|
||||
};
|
||||
assert_redacted("DiscEntry", &format!("{e:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
+2137
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
||||
//! Parse `/BDMV/index.bdmv` — the BD navigation index (First Play, Top Menu,
|
||||
//! and the title table), per its documented binary layout. This is read as a
|
||||
//! documented binary format, never executed: every field is bounds-checked and
|
||||
//! any malformed input yields `None` (the nav resolver then abstains).
|
||||
//!
|
||||
//! Layout: `"INDX"` + version(4) + `indexes_start`(u32 @8) + … ; at
|
||||
//! `indexes_start`: `index_len`(u32), First-Play object(12), Top-Menu object(12),
|
||||
//! `num_titles`(u16), then `num_titles` title objects(12 each). Every object's
|
||||
//! `object_type` is the top two bits of its first byte; for an HDMV object the
|
||||
//! `id_ref` into `MovieObject.bdmv` is a big-endian u16 at object offset 6.
|
||||
|
||||
/// One playback/title object in `index.bdmv`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PlaybackObj {
|
||||
/// HDMV title. `id_ref` indexes `MovieObject.bdmv`; `0xffff` means "no
|
||||
/// object".
|
||||
Hdmv { id_ref: u16 },
|
||||
/// BD-J title (a Java Xlet chooses what plays — the nav VM cannot resolve
|
||||
/// it and must abstain).
|
||||
BdJ,
|
||||
/// Unrecognised object type.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// The parsed index: the two entry objects plus the title table (title numbers
|
||||
/// `1..=titles.len()` map to `titles[i-1]`; title 0 is the Top Menu).
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Index {
|
||||
pub first_play: PlaybackObj,
|
||||
pub top_menu: PlaybackObj,
|
||||
pub titles: Vec<PlaybackObj>,
|
||||
}
|
||||
|
||||
const OBJ_LEN: usize = 12;
|
||||
/// Sanity cap on the title count (real discs have well under this).
|
||||
const MAX_TITLES: usize = 4096;
|
||||
|
||||
fn be_u16(d: &[u8], o: usize) -> Option<u16> {
|
||||
Some(u16::from_be_bytes([*d.get(o)?, *d.get(o + 1)?]))
|
||||
}
|
||||
fn be_u32(d: &[u8], o: usize) -> Option<u32> {
|
||||
Some(u32::from_be_bytes([
|
||||
*d.get(o)?,
|
||||
*d.get(o + 1)?,
|
||||
*d.get(o + 2)?,
|
||||
*d.get(o + 3)?,
|
||||
]))
|
||||
}
|
||||
|
||||
/// Parse one 12-byte object starting at `o`. `object_type` is the top two bits
|
||||
/// of byte 0; the object body starts at byte 4, and an HDMV `id_ref` is the
|
||||
/// big-endian u16 at body offset +2 (object offset +6).
|
||||
fn parse_obj(d: &[u8], o: usize) -> Option<PlaybackObj> {
|
||||
let b0 = *d.get(o)?;
|
||||
d.get(o + OBJ_LEN - 1)?; // require the whole 12-byte record
|
||||
Some(match (b0 >> 6) & 0x3 {
|
||||
1 => PlaybackObj::Hdmv {
|
||||
id_ref: be_u16(d, o + 6)?,
|
||||
},
|
||||
2 => PlaybackObj::BdJ,
|
||||
_ => PlaybackObj::Unknown,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse `index.bdmv`. Returns `None` on any structural problem.
|
||||
pub(crate) fn parse(d: &[u8]) -> Option<Index> {
|
||||
if d.get(0..4)? != b"INDX" {
|
||||
return None;
|
||||
}
|
||||
// version at 4..8 ("0100"/"0200"/"0300") is not load-bearing for resolution.
|
||||
let indexes_start = be_u32(d, 8)? as usize;
|
||||
// At indexes_start: u32 index_len, First-Play(12), Top-Menu(12), u16 titles.
|
||||
let mut o = indexes_start.checked_add(4)?;
|
||||
let first_play = parse_obj(d, o)?;
|
||||
o = o.checked_add(OBJ_LEN)?;
|
||||
let top_menu = parse_obj(d, o)?;
|
||||
o = o.checked_add(OBJ_LEN)?;
|
||||
let num_titles = be_u16(d, o)? as usize;
|
||||
o = o.checked_add(2)?;
|
||||
if num_titles == 0 || num_titles > MAX_TITLES {
|
||||
return None;
|
||||
}
|
||||
let span = num_titles.checked_mul(OBJ_LEN)?;
|
||||
if o.checked_add(span)? > d.len() {
|
||||
return None;
|
||||
}
|
||||
let mut titles = Vec::with_capacity(num_titles);
|
||||
for i in 0..num_titles {
|
||||
titles.push(parse_obj(d, o + i * OBJ_LEN)?);
|
||||
}
|
||||
Some(Index {
|
||||
first_play,
|
||||
top_menu,
|
||||
titles,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a 12-byte object record: `object_type` in the top two bits of
|
||||
/// byte 0, HDMV `id_ref` big-endian at offset 6.
|
||||
fn hdmv_obj(id_ref: u16) -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = 1 << 6;
|
||||
b[6..8].copy_from_slice(&id_ref.to_be_bytes());
|
||||
b
|
||||
}
|
||||
fn bdj_obj() -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = 2 << 6;
|
||||
b
|
||||
}
|
||||
|
||||
fn build(first: [u8; 12], top: [u8; 12], titles: &[[u8; 12]]) -> Vec<u8> {
|
||||
let indexes_start = 48u32;
|
||||
let mut d = vec![0u8; indexes_start as usize];
|
||||
d[0..4].copy_from_slice(b"INDX");
|
||||
d[4..8].copy_from_slice(b"0300");
|
||||
d[8..12].copy_from_slice(&indexes_start.to_be_bytes());
|
||||
d.extend_from_slice(&0u32.to_be_bytes()); // index_len (unused by parser)
|
||||
d.extend_from_slice(&first);
|
||||
d.extend_from_slice(&top);
|
||||
d.extend_from_slice(&(titles.len() as u16).to_be_bytes());
|
||||
for t in titles {
|
||||
d.extend_from_slice(t);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hdmv_first_play_and_titles() {
|
||||
let d = build(hdmv_obj(0), bdj_obj(), &[bdj_obj(), bdj_obj()]);
|
||||
let idx = parse(&d).expect("parses");
|
||||
assert_eq!(idx.first_play, PlaybackObj::Hdmv { id_ref: 0 });
|
||||
assert_eq!(idx.top_menu, PlaybackObj::BdJ);
|
||||
assert_eq!(idx.titles.len(), 2);
|
||||
assert_eq!(idx.titles[0], PlaybackObj::BdJ);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_magic_and_truncation() {
|
||||
assert!(parse(b"NOPE").is_none());
|
||||
let d = build(hdmv_obj(3), hdmv_obj(0), &[hdmv_obj(1)]);
|
||||
// Truncating below the declared title span must yield None, never panic.
|
||||
assert!(parse(&d[..d.len() - 4]).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Parse `/BDMV/MovieObject.bdmv` — the HDMV navigation programs, per their
|
||||
//! documented binary layout. Read as a documented format, never executed here:
|
||||
//! bounds-checked, never panics.
|
||||
//!
|
||||
//! Layout: `"MOBJ"` + version(4) + reserved… ; `MovieObjects()` at byte 40 is
|
||||
//! `length`(u32) + reserved(u32) + `num_objects`(u16 @48), then objects from
|
||||
//! byte 50. Each object is `flags`(1) + reserved(1) + `num_cmds`(u16) followed
|
||||
//! by `num_cmds` 12-byte navigation commands.
|
||||
|
||||
/// One decoded 12-byte navigation command. Field layout (big-endian): byte 0 =
|
||||
/// `op_cnt(3) grp(2) sub_grp(3)`; byte 1 = `imm_op1(1) imm_op2(1) branch_opt(4)`
|
||||
/// (low nibble); byte 2 low nibble = `cmp_opt`; byte 3 low 5 bits = `set_opt`;
|
||||
/// then `dst`(u32) and `src`(u32).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct Cmd {
|
||||
pub op_cnt: u8,
|
||||
pub grp: u8,
|
||||
pub sub_grp: u8,
|
||||
pub imm_op1: bool,
|
||||
pub imm_op2: bool,
|
||||
pub branch_opt: u8,
|
||||
pub cmp_opt: u8,
|
||||
pub set_opt: u8,
|
||||
pub dst: u32,
|
||||
pub src: u32,
|
||||
}
|
||||
|
||||
/// One HDMV navigation program.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct MovieObject {
|
||||
pub cmds: Vec<Cmd>,
|
||||
}
|
||||
|
||||
const CMD_LEN: usize = 12;
|
||||
/// Sanity caps (real discs are far under these; some densely-branched
|
||||
/// dispatchers run several thousand commands in one object).
|
||||
const MAX_OBJECTS: usize = 4096;
|
||||
const MAX_CMDS: usize = 200_000;
|
||||
|
||||
fn be_u16(d: &[u8], o: usize) -> Option<u16> {
|
||||
Some(u16::from_be_bytes([*d.get(o)?, *d.get(o + 1)?]))
|
||||
}
|
||||
|
||||
/// Decode one 12-byte command. Caller guarantees `b.len() == 12`.
|
||||
fn decode_cmd(b: &[u8]) -> Cmd {
|
||||
let (b0, b1, b2, b3) = (b[0], b[1], b[2], b[3]);
|
||||
Cmd {
|
||||
op_cnt: (b0 >> 5) & 0x7,
|
||||
grp: (b0 >> 3) & 0x3,
|
||||
sub_grp: b0 & 0x7,
|
||||
imm_op1: (b1 >> 7) & 1 == 1,
|
||||
imm_op2: (b1 >> 6) & 1 == 1,
|
||||
branch_opt: b1 & 0x0f,
|
||||
cmp_opt: b2 & 0x0f,
|
||||
set_opt: b3 & 0x1f,
|
||||
dst: u32::from_be_bytes([b[4], b[5], b[6], b[7]]),
|
||||
src: u32::from_be_bytes([b[8], b[9], b[10], b[11]]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `MovieObject.bdmv` into its programs. Returns `None` on any structural
|
||||
/// problem.
|
||||
pub(crate) fn parse(d: &[u8]) -> Option<Vec<MovieObject>> {
|
||||
if d.get(0..4)? != b"MOBJ" {
|
||||
return None;
|
||||
}
|
||||
let num = be_u16(d, 48)? as usize;
|
||||
if num > MAX_OBJECTS {
|
||||
return None;
|
||||
}
|
||||
let mut off = 50usize;
|
||||
let mut objs = Vec::with_capacity(num);
|
||||
for _ in 0..num {
|
||||
// Object header: flags(1) + reserved(1) + num_cmds(u16).
|
||||
let num_cmds = be_u16(d, off + 2)? as usize;
|
||||
off = off.checked_add(4)?;
|
||||
if num_cmds > MAX_CMDS {
|
||||
return None;
|
||||
}
|
||||
let span = num_cmds.checked_mul(CMD_LEN)?;
|
||||
let end = off.checked_add(span)?;
|
||||
if end > d.len() {
|
||||
return None;
|
||||
}
|
||||
let mut cmds = Vec::with_capacity(num_cmds);
|
||||
for i in 0..num_cmds {
|
||||
let s = off + i * CMD_LEN;
|
||||
cmds.push(decode_cmd(&d[s..s + CMD_LEN]));
|
||||
}
|
||||
off = end;
|
||||
objs.push(MovieObject { cmds });
|
||||
}
|
||||
Some(objs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Encode one command from its opcode bytes + operands.
|
||||
pub(crate) fn cmd(b0: u8, b1: u8, b2: u8, b3: u8, dst: u32, src: u32) -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = b0;
|
||||
b[1] = b1;
|
||||
b[2] = b2;
|
||||
b[3] = b3;
|
||||
b[4..8].copy_from_slice(&dst.to_be_bytes());
|
||||
b[8..12].copy_from_slice(&src.to_be_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub(crate) fn build(objects: &[&[[u8; 12]]]) -> Vec<u8> {
|
||||
let mut d = vec![0u8; 50];
|
||||
d[0..4].copy_from_slice(b"MOBJ");
|
||||
d[48..50].copy_from_slice(&(objects.len() as u16).to_be_bytes());
|
||||
for obj in objects {
|
||||
d.push(0); // flags
|
||||
d.push(0); // reserved
|
||||
d.extend_from_slice(&(obj.len() as u16).to_be_bytes());
|
||||
for c in *obj {
|
||||
d.extend_from_slice(c);
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_objects_and_commands() {
|
||||
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
|
||||
let b0 = (1 << 5) | 2;
|
||||
let play = cmd(b0, 0x80, 0, 0, 11, 0);
|
||||
let d = build(&[&[play]]);
|
||||
let objs = parse(&d).expect("parses");
|
||||
assert_eq!(objs.len(), 1);
|
||||
assert_eq!(objs[0].cmds.len(), 1);
|
||||
assert_eq!(objs[0].cmds[0].dst, 11);
|
||||
assert_eq!(objs[0].cmds[0].sub_grp, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncation() {
|
||||
let play = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
|
||||
let d = build(&[&[play]]);
|
||||
assert!(parse(&d[..d.len() - 3]).is_none());
|
||||
assert!(parse(b"NOPE").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! BD/UHD HDMV navigation resolver — "mimic a real player" for main-feature
|
||||
//! title selection. It reads `/BDMV/index.bdmv` + `/BDMV/MovieObject.bdmv` and
|
||||
//! runs a faithful, bounded HDMV navigation VM to find the playlist the disc's
|
||||
//! own First-Play navigation plays as the feature.
|
||||
//!
|
||||
//! There is no per-disc special-casing: one correctly-booted VM resolves
|
||||
//! densely-branched dispatchers as well as simple ones. The honest boundary is
|
||||
//! BD-J (the feature is chosen by a Java Xlet, not by the HDMV VM); there — and
|
||||
//! on any malformed data or non-convergence — the resolver ABSTAINS (`None`)
|
||||
//! and selection falls back to the structural/heuristic order.
|
||||
//!
|
||||
//! Contract: read-only, bounded, and never panics or hard-fails.
|
||||
|
||||
pub(crate) mod index;
|
||||
pub(crate) mod mobj;
|
||||
pub(crate) mod vm;
|
||||
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
/// Resolve the playlist id the disc's First-Play navigation plays as the
|
||||
/// feature, restricted to ids the caller marks as feature candidates
|
||||
/// (`is_feature_candidate` — typically video-bearing, non-trivial titles, so a
|
||||
/// short logo/pre-roll `PlayPlayList` is skipped). Returns `None` for BD-J discs,
|
||||
/// missing/malformed nav data, or when navigation does not converge on a
|
||||
/// candidate.
|
||||
pub(crate) fn resolve_feature(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf: &UdfFs,
|
||||
is_feature_candidate: impl Fn(u16) -> bool,
|
||||
) -> Option<u16> {
|
||||
// Belt-and-suspenders: the parsers and VM are written panic-free and bounded,
|
||||
// but a navigation resolver must NEVER take down a scan — swallow any
|
||||
// unexpected panic and abstain.
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
resolve_inner(reader, udf, &is_feature_candidate)
|
||||
}))
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn resolve_inner(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf: &UdfFs,
|
||||
is_feature_candidate: &dyn Fn(u16) -> bool,
|
||||
) -> Option<u16> {
|
||||
let index = index::parse(&udf.read_file(reader, "/BDMV/index.bdmv").ok()?)?;
|
||||
let mobjs = mobj::parse(&udf.read_file(reader, "/BDMV/MovieObject.bdmv").ok()?)?;
|
||||
vm::resolve(&index, &mobjs, is_feature_candidate)
|
||||
}
|
||||
+474
@@ -0,0 +1,474 @@
|
||||
//! The HDMV navigation VM — a faithful, bounded re-implementation of the subset
|
||||
//! of HDMV navigation semantics needed to follow First-Play to the feature
|
||||
//! `PlayPlayList`. It "mimics a real player": correct power-on register state is
|
||||
//! what lets a densely-branched dispatcher converge on the feature rather than
|
||||
//! loop. Never panics; hard step/switch caps.
|
||||
//!
|
||||
//! It resolves generically — there is no per-disc special-casing. When
|
||||
//! First-Play is a BD-J title (the feature is chosen by a Java Xlet), or the
|
||||
//! program does not reach a `PlayPL` on a caller-approved feature candidate, it
|
||||
//! abstains (`None`) and selection falls back to the structural/heuristic order.
|
||||
|
||||
use super::index::{Index, PlaybackObj};
|
||||
use super::mobj::MovieObject;
|
||||
|
||||
/// Top bit of an operand selects PSR (else GPR).
|
||||
const PSR_FLAG: u32 = 0x8000_0000;
|
||||
/// Upper bound on VM steps before declaring non-convergence.
|
||||
const MAX_STEPS: usize = 1_000_000;
|
||||
/// Cap on object/title switches — a dispatcher runs in one object; this only
|
||||
/// bounds pathological jump chains.
|
||||
const MAX_SWITCHES: usize = 1024;
|
||||
|
||||
/// BD player power-on register defaults (indices 0..=61); unlisted = 0. The
|
||||
/// load-bearing values are PSR4/5 = `0xffff` ("no title/chapter selected") and
|
||||
/// PSR6/7/8 = 0, which steer dispatcher compares; the rest are set for fidelity
|
||||
/// with real player behavior.
|
||||
fn psr_init() -> [u32; 128] {
|
||||
let mut p = [0u32; 128];
|
||||
let vals: &[(usize, u32)] = &[
|
||||
(0, 1),
|
||||
(1, 0xff),
|
||||
(2, 0x0fff_0fff),
|
||||
(3, 1),
|
||||
(4, 0xffff),
|
||||
(5, 0xffff),
|
||||
(10, 0xffff),
|
||||
(12, 0xff),
|
||||
(13, 0xff),
|
||||
(14, 0xffff),
|
||||
(15, 0xAAAA),
|
||||
(16, 0x00ff_ffff),
|
||||
(17, 0x00ff_ffff),
|
||||
(18, 0x00ff_ffff),
|
||||
(19, 0xffff),
|
||||
(20, 2),
|
||||
(29, 0x0000_0003),
|
||||
(30, 0x0001_ffff),
|
||||
(31, 0x0003_0200),
|
||||
(36, 0xffff),
|
||||
(37, 0xffff),
|
||||
(42, 0xffff),
|
||||
(44, 0xff),
|
||||
];
|
||||
for &(i, v) in vals {
|
||||
p[i] = v;
|
||||
}
|
||||
for slot in p.iter_mut().take(62).skip(48) {
|
||||
*slot = 0xffff_ffff;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
struct Vm<'a> {
|
||||
mobjs: &'a [MovieObject],
|
||||
index: &'a Index,
|
||||
gpr: [u32; 4096],
|
||||
psr: [u32; 128],
|
||||
}
|
||||
|
||||
impl Vm<'_> {
|
||||
fn rd(&self, val: u32) -> u32 {
|
||||
if val & PSR_FLAG != 0 {
|
||||
self.psr[(val & 0x7f) as usize]
|
||||
} else {
|
||||
self.gpr[(val & 0xfff) as usize]
|
||||
}
|
||||
}
|
||||
fn wr(&mut self, val: u32, x: u32) {
|
||||
// Per the HDMV nav spec, a store to a PSR-tagged register is refused;
|
||||
// only GPR writes take effect. PSR values come only from power-on init,
|
||||
// never from a nav SET/SWAP operand.
|
||||
if val & PSR_FLAG == 0 {
|
||||
self.gpr[(val & 0xfff) as usize] = x;
|
||||
}
|
||||
}
|
||||
fn fetch(&self, imm: bool, raw: u32) -> u32 {
|
||||
if imm { raw } else { self.rd(raw) }
|
||||
}
|
||||
/// Resolve a JumpTitle target: title 0 = Top Menu, `1..=N` = `titles[i-1]`,
|
||||
/// `0xFFFF` = First Play (BD-ROM title numbering). Any other value is an
|
||||
/// invalid title reference and abstains (`None`).
|
||||
fn title_obj(&self, title: u32) -> Option<PlaybackObj> {
|
||||
let n = self.index.titles.len() as u32;
|
||||
if title == 0 {
|
||||
Some(self.index.top_menu)
|
||||
} else if title <= n {
|
||||
self.index.titles.get((title - 1) as usize).copied()
|
||||
} else if title == 0xFFFF {
|
||||
Some(self.index.first_play)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run First-Play and return the first `PlayPL`/`PlayPL_PM`/`PlayPL_PI` whose
|
||||
/// playlist id passes `is_feature`. `None` = BD-J boundary, non-convergence, or
|
||||
/// no approved feature playlist reached.
|
||||
pub(crate) fn resolve(
|
||||
index: &Index,
|
||||
mobjs: &[MovieObject],
|
||||
is_feature: &dyn Fn(u16) -> bool,
|
||||
) -> Option<u16> {
|
||||
// Enter at First-Play when it is an HDMV object that exists; otherwise
|
||||
// abstain (BD-J boundary, or a `0xffff` "no object"). We deliberately do not
|
||||
// chase the Top-Menu program here — its "Play Movie" target usually lives in
|
||||
// the IG-stream button navigation commands inside the menu `.m2ts`, not in
|
||||
// `MovieObject.bdmv` (future work).
|
||||
let start = match index.first_play {
|
||||
PlaybackObj::Hdmv { id_ref } if (id_ref as usize) < mobjs.len() => id_ref as usize,
|
||||
_ => return None,
|
||||
};
|
||||
let mut vm = Vm {
|
||||
mobjs,
|
||||
index,
|
||||
gpr: [0; 4096],
|
||||
psr: psr_init(),
|
||||
};
|
||||
run(&mut vm, start, is_feature)
|
||||
}
|
||||
|
||||
fn run(vm: &mut Vm, mut obj_id: usize, is_feature: &dyn Fn(u16) -> bool) -> Option<u16> {
|
||||
let mut pc = 0usize;
|
||||
let mut steps = 0usize;
|
||||
let mut switches = 0usize;
|
||||
loop {
|
||||
steps += 1;
|
||||
if steps > MAX_STEPS {
|
||||
return None;
|
||||
}
|
||||
let obj = vm.mobjs.get(obj_id)?;
|
||||
// Ran off the end without a feature PlayPL → abstain.
|
||||
let c = *obj.cmds.get(pc)?;
|
||||
let mut npc = pc + 1;
|
||||
let dst = if c.op_cnt > 0 {
|
||||
vm.fetch(c.imm_op1, c.dst)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let src = if c.op_cnt > 1 {
|
||||
vm.fetch(c.imm_op2, c.src)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
match c.grp {
|
||||
// BRANCH
|
||||
0 => match c.sub_grp {
|
||||
// GOTO
|
||||
0 => match c.branch_opt {
|
||||
0x01 => npc = dst as usize, // GOTO
|
||||
0x02 => return None, // BREAK — terminate
|
||||
_ => {} // NOP / other
|
||||
},
|
||||
// JUMP
|
||||
1 => match c.branch_opt {
|
||||
0x00 | 0x02 => {
|
||||
// JumpObject / CallObject (return address not modelled —
|
||||
// for feature resolution we only follow the play path).
|
||||
switches += 1;
|
||||
if switches > MAX_SWITCHES || dst as usize >= vm.mobjs.len() {
|
||||
return None;
|
||||
}
|
||||
obj_id = dst as usize;
|
||||
pc = 0;
|
||||
continue;
|
||||
}
|
||||
0x01 | 0x03 => {
|
||||
// JumpTitle / CallTitle — resolve through the index table.
|
||||
switches += 1;
|
||||
if switches > MAX_SWITCHES {
|
||||
return None;
|
||||
}
|
||||
match vm.title_obj(dst) {
|
||||
Some(PlaybackObj::Hdmv { id_ref })
|
||||
if (id_ref as usize) < vm.mobjs.len() =>
|
||||
{
|
||||
obj_id = id_ref as usize;
|
||||
pc = 0;
|
||||
continue;
|
||||
}
|
||||
// BD-J / unknown / invalid title → abstain.
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
_ => {} // RESUME / other — fall through
|
||||
},
|
||||
// PLAY: PlayPL / PlayPL_PI / PlayPL_PM emit a playlist id (the
|
||||
// rest — Terminate / Link — fall through). A logo/pre-roll whose
|
||||
// id isn't a feature candidate lets autoplay resume at pc + 1.
|
||||
2 if matches!(c.branch_opt, 0x00..=0x02) => {
|
||||
let id = dst as u16;
|
||||
if is_feature(id) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// CMP — skip the next command when the compare is false.
|
||||
1 => {
|
||||
let truth = match c.cmp_opt {
|
||||
0x01 => (dst & !src) == 0,
|
||||
0x02 => dst == src,
|
||||
0x03 => dst != src,
|
||||
0x04 => dst >= src,
|
||||
0x05 => dst > src,
|
||||
0x06 => dst <= src,
|
||||
0x07 => dst < src,
|
||||
_ => true,
|
||||
};
|
||||
if !truth {
|
||||
npc = pc + 2;
|
||||
}
|
||||
}
|
||||
// SET (sub_grp 0). SETSYSTEM (sub_grp 1) only mutates system PSRs
|
||||
// whose values the feature path does not branch on — skip it.
|
||||
2 if c.sub_grp == 0 => {
|
||||
let r: Option<u32> = match c.set_opt {
|
||||
0x01 => Some(src), // MOVE
|
||||
0x02 => {
|
||||
// SWAP exchanges the two operands; a store to an operand
|
||||
// flagged immediate is refused, and `wr` already drops
|
||||
// PSR stores.
|
||||
if !c.imm_op1 {
|
||||
vm.wr(c.dst, src);
|
||||
}
|
||||
if !c.imm_op2 {
|
||||
vm.wr(c.src, dst);
|
||||
}
|
||||
None
|
||||
}
|
||||
0x03 => Some(dst.wrapping_add(src)),
|
||||
0x04 => Some(dst.saturating_sub(src)),
|
||||
0x05 => Some(dst.wrapping_mul(src)),
|
||||
0x06 => Some(dst.checked_div(src).unwrap_or(0xffff_ffff)),
|
||||
0x07 => Some(dst.checked_rem(src).unwrap_or(0xffff_ffff)),
|
||||
0x08 => Some(dst), // RND — deterministic stand-in
|
||||
0x09 => Some(dst & src),
|
||||
0x0a => Some(dst | src),
|
||||
0x0b => Some(dst ^ src),
|
||||
0x0c => Some(dst | (1u32 << (src & 31))),
|
||||
0x0d => Some(dst & !(1u32 << (src & 31))),
|
||||
0x0e => Some(dst.wrapping_shl(src & 31)),
|
||||
0x0f => Some(dst.wrapping_shr(src & 31)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(r) = r
|
||||
&& !c.imm_op1
|
||||
{
|
||||
vm.wr(c.dst, r);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
pc = npc;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::index::{Index, PlaybackObj};
|
||||
use super::super::mobj::{self, tests::build, tests::cmd};
|
||||
use super::*;
|
||||
|
||||
fn play_pl(id: u16) -> [u8; 12] {
|
||||
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
|
||||
cmd((1 << 5) | 2, 0x80, 0, 0, id as u32, 0)
|
||||
}
|
||||
fn jump_object(obj: u16) -> [u8; 12] {
|
||||
// grp=BRANCH(0), sub_grp=JUMP(1), branch_opt=JUMP_OBJECT(0), imm dst.
|
||||
cmd((1 << 5) | 1, 0x80, 0, 0, obj as u32, 0)
|
||||
}
|
||||
fn jump_title(title: u16) -> [u8; 12] {
|
||||
// sub_grp=JUMP(1), branch_opt=JUMP_TITLE(1), imm dst.
|
||||
cmd((1 << 5) | 1, 0x81, 0, 0, title as u32, 0)
|
||||
}
|
||||
fn set_move_gpr(reg: u16, imm: u16) -> [u8; 12] {
|
||||
// grp=SET(2), sub_grp=SET(0), set_opt=MOVE(1); dst=reg (GPR), imm src.
|
||||
// op_cnt=2, imm_op2=1 (src immediate), imm_op1=0 (dst is a register).
|
||||
cmd((2 << 5) | (2 << 3), 0x40, 0, 0x01, reg as u32, imm as u32)
|
||||
}
|
||||
fn play_pl_reg(reg: u16) -> [u8; 12] {
|
||||
// PLAY_PL with dst = GPR[reg] (op_cnt=1, imm_op1=0).
|
||||
cmd((1 << 5) | 2, 0x00, 0, 0, reg as u32, 0)
|
||||
}
|
||||
|
||||
fn idx(first: PlaybackObj, titles: Vec<PlaybackObj>) -> Index {
|
||||
Index {
|
||||
first_play: first,
|
||||
top_menu: PlaybackObj::BdJ,
|
||||
titles,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_immediate_playpl() {
|
||||
// First-Play HDMV obj0 → JumpObject 1 → PlayPL 11.
|
||||
let d = build(&[&[jump_object(1)], &[play_pl(11)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 11), Some(11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_feature_playpl_then_returns_feature() {
|
||||
// Autoplay chain: logo PlayPL 99 (not a candidate) → feature PlayPL 1.
|
||||
let d = build(&[&[play_pl(99), play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
// Only 1 is a feature candidate; 99 is a logo.
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 1), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abstains_when_first_play_jumps_to_bdj_title() {
|
||||
// Computed-title-number shape: First-Play computes a title number in a
|
||||
// GPR and JumpTitles to it; the target title is BD-J → abstain (None).
|
||||
let d = build(&[&[set_move_gpr(0xFEB & 0xfff, 2), {
|
||||
// JumpTitle with dst = GPR[0xFEB].
|
||||
cmd((1 << 5) | 1, 0x01, 0, 0, 0x0000_0FEB, 0)
|
||||
}]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
// title 2 → titles[1] = BD-J.
|
||||
let index = idx(
|
||||
PlaybackObj::Hdmv { id_ref: 0 },
|
||||
vec![PlaybackObj::Hdmv { id_ref: 5 }, PlaybackObj::BdJ],
|
||||
);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abstains_when_first_play_is_bdj() {
|
||||
let d = build(&[&[play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::BdJ, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jumptitle_first_play_is_0xffff_not_n_plus_1() {
|
||||
// BD-ROM title numbering: 0 = Top Menu, 0xFFFF = First Play,
|
||||
// 1..=N = titles. `N+1` is an INVALID title ref.
|
||||
let index = idx(
|
||||
PlaybackObj::Hdmv { id_ref: 7 },
|
||||
vec![PlaybackObj::Hdmv { id_ref: 1 }],
|
||||
);
|
||||
let vm = Vm {
|
||||
mobjs: &[],
|
||||
index: &index,
|
||||
gpr: [0; 4096],
|
||||
psr: psr_init(),
|
||||
};
|
||||
assert_eq!(vm.title_obj(0), Some(index.top_menu), "title 0 = Top Menu");
|
||||
assert_eq!(
|
||||
vm.title_obj(1),
|
||||
Some(PlaybackObj::Hdmv { id_ref: 1 }),
|
||||
"title 1 = titles[0]"
|
||||
);
|
||||
assert_eq!(
|
||||
vm.title_obj(0xFFFF),
|
||||
Some(PlaybackObj::Hdmv { id_ref: 7 }),
|
||||
"0xFFFF = First Play (abstained wrongly before the fix)"
|
||||
);
|
||||
assert_eq!(
|
||||
vm.title_obj(2),
|
||||
None,
|
||||
"N+1 is an invalid title ref, not First Play (resolved wrongly before the fix)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_to_psr_is_refused() {
|
||||
// A SET MOVE to PSR6 must be a no-op (PSR stores are refused by spec).
|
||||
// Program: MOVE PSR6 <- 5; CMP PSR6 == 5; PlayPL 100 else 200.
|
||||
// With the write refused, PSR6 stays at its power-on 0, so the compare is
|
||||
// false and control skips to PlayPL 200. If the illegal PSR write took
|
||||
// effect, the compare would be true and PlayPL 100 would win.
|
||||
let set_psr6 = cmd((2 << 5) | (2 << 3), 0x40, 0, 0x01, 0x8000_0006, 5);
|
||||
let cmp_psr6_eq_5 = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0x8000_0006, 5);
|
||||
let d = build(&[&[set_psr6, cmp_psr6_eq_5, play_pl(100), play_pl(200)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(
|
||||
resolve(&index, &mobjs, &|id| id == 100 || id == 200),
|
||||
Some(200),
|
||||
"PSR store must be refused, so the compare is false and 200 is reached"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_fold_and_cmp_skip_reach_playpl() {
|
||||
// SET GPR[3]=7; CMP GPR[3]==7 (true → do NOT skip); PlayPL GPR[3].
|
||||
// grp=CMP(1), op_cnt=2, imm_op2=1 (src=7 immediate), cmp_opt=EQ(2); dst=GPR3.
|
||||
let cmp_eq_reg = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 3, 0x0000_0007);
|
||||
let d = build(&[&[set_move_gpr(3, 7), cmp_eq_reg, play_pl_reg(3), play_pl(999)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
// If the compare wrongly skipped, we'd hit PlayPL 999 instead of 7.
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 7 || id == 999), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jumptitle_namespace_differs_from_jumpobject() {
|
||||
// JumpTitle 1 → titles[0] = HDMV obj 1 (which PlayPLs 42). Proves title
|
||||
// numbers resolve through the index, not as object indices.
|
||||
let d = build(&[&[jump_title(1)], &[play_pl(42)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(
|
||||
PlaybackObj::Hdmv { id_ref: 0 },
|
||||
vec![PlaybackObj::Hdmv { id_ref: 1 }],
|
||||
);
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 42), Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_exchanges_two_registers() {
|
||||
// MOVE g0=11; MOVE g1=22; SWAP g0<->g1; PlayPL GPR[0]. SWAP: grp=SET(2),
|
||||
// sub_grp=SET(0), set_opt=SWAP(2), both operands registers (imm_op*=0).
|
||||
let swap01 = cmd((2 << 5) | (2 << 3), 0x00, 0, 0x02, 0, 1);
|
||||
let d = build(&[&[
|
||||
set_move_gpr(0, 11),
|
||||
set_move_gpr(1, 22),
|
||||
swap01,
|
||||
play_pl_reg(0),
|
||||
]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm {
|
||||
mobjs: &mobjs,
|
||||
index: &index,
|
||||
gpr: [0; 4096],
|
||||
psr: psr_init(),
|
||||
};
|
||||
// After the swap GPR0 holds the old GPR1 (22) and PlayPL plays it.
|
||||
assert_eq!(run(&mut vm, 0, &|id| id == 22), Some(22));
|
||||
// Full exchange: GPR1 now holds the old GPR0 (11).
|
||||
assert_eq!(vm.gpr[1], 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_immediate_operand_is_not_written_back() {
|
||||
// Seed GPR[5]=77, then SWAP dst=GPR0 (register) with src=IMMEDIATE 5
|
||||
// (imm_op1=0, imm_op2=1). The imm gate must refuse using the immediate
|
||||
// as a write target, so GPR[5] — the register the literal 5 would alias
|
||||
// — keeps 77; only GPR0 receives the immediate. Dropping either
|
||||
// `if !c.imm_op1` / `if !c.imm_op2` guard makes an assertion fail.
|
||||
let swap_imm = cmd((2 << 5) | (2 << 3), 0x40, 0, 0x02, 0, 5);
|
||||
let d = build(&[&[set_move_gpr(5, 77), swap_imm]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm {
|
||||
mobjs: &mobjs,
|
||||
index: &index,
|
||||
gpr: [0; 4096],
|
||||
psr: psr_init(),
|
||||
};
|
||||
// No PlayPL → program runs off the end (None); inspect registers after.
|
||||
assert_eq!(run(&mut vm, 0, &|_| false), None);
|
||||
assert_eq!(
|
||||
vm.gpr[5], 77,
|
||||
"immediate operand must not be a write target"
|
||||
);
|
||||
assert_eq!(vm.gpr[0], 5, "register operand receives the immediate");
|
||||
}
|
||||
}
|
||||
+103
-843
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -66,6 +66,10 @@ pub mod coding_type {
|
||||
pub const MPEG2_VIDEO: u8 = 0x02;
|
||||
/// H.264 / AVC video (ISO/IEC 13818-1 Table 2-34).
|
||||
pub const H264: u8 = 0x1B;
|
||||
/// H.264 / MVC dependent view (Blu-ray 3D right-eye substream). Carried in
|
||||
/// the SSIF interleaved stream under its own PID; the base view is [`H264`].
|
||||
/// ISO/IEC 13818-1 stream_type 0x20 (MVC video sub-bitstream).
|
||||
pub const H264_MVC: u8 = 0x20;
|
||||
/// HEVC / H.265 video (ISO/IEC 13818-1 Table 2-34, 2015 amendment).
|
||||
pub const HEVC: u8 = 0x24;
|
||||
/// SMPTE VC-1 video (BD-ROM convention, ISO user-private range).
|
||||
@@ -95,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;
|
||||
}
|
||||
|
||||
@@ -106,6 +114,10 @@ pub mod coding_type {
|
||||
pub mod pes_stream_id {
|
||||
/// Video stream (`110x xxxx`; freemkv emits the base id `0xE0`).
|
||||
pub const VIDEO: u8 = 0xE0;
|
||||
/// system_header start code — the MPEG-PS `00 00 01 BB` structural header
|
||||
/// (rate/bound bounds), never an elementary stream. On a DVD NAV pack it
|
||||
/// follows the pack header, so it lands at sector offset 0x11.
|
||||
pub const SYSTEM_HEADER: u8 = 0xBB;
|
||||
/// private_stream_1 — AC-3 / DTS / LPCM / PGS subtitle payloads.
|
||||
pub const PRIVATE_STREAM_1: u8 = 0xBD;
|
||||
/// padding_stream — stuffing bytes only, no payload to demux.
|
||||
|
||||
+1140
File diff suppressed because it is too large
Load Diff
+162
-134
@@ -1,58 +1,45 @@
|
||||
//! CSS cipher implementation based on the Stevenson 1999 analysis.
|
||||
//! CSS content cipher — an independent implementation of the publicly
|
||||
//! documented Content Scramble System stream cipher.
|
||||
//!
|
||||
//! The CSS cipher uses two table-driven feedback circuits:
|
||||
//! - LFSR1: 17-bit state (9-bit lo + 8-bit hi register, seeded from
|
||||
//! key[0..2]), driven by TAB2/TAB3
|
||||
//! - LFSR0: 24-bit feedback register (seeded from key[2..5] XOR seed[2..5],
|
||||
//! masked to 0xFFFFFF), driven by a feedback polynomial through TAB4
|
||||
//! 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.
|
||||
//!
|
||||
//! The keystream is the bytewise sum (with carry) of both LFSR outputs.
|
||||
//! Content descrambling computes plain = TAB1[cipher] ^ keystream — a TAB1
|
||||
//! substitution of each ciphertext byte followed by an XOR with the keystream
|
||||
//! (NOT a plain XOR; the cipher is not its own inverse).
|
||||
//! The cipher uses two table-driven linear-feedback circuits:
|
||||
//! - **LFSR1** — a 17-bit register (a 9-bit and an 8-bit half seeded from
|
||||
//! `key[0..2] XOR seed[0..2]`), stepped through `TAB2`/`TAB3`/`TAB5`.
|
||||
//! - **LFSR0** — a 24-bit feedback register (seeded from `key[2..5] XOR
|
||||
//! seed[2..5]`), stepped through a feedback polynomial and `TAB4`.
|
||||
//!
|
||||
//! Algorithm: Frank A. Stevenson's divide-and-conquer attack (1999).
|
||||
//! Tables: CSS specification constants.
|
||||
//! Each output byte is the sum-with-carry of the two register outputs. A body
|
||||
//! byte is recovered as `plain = TAB1[cipher] ^ keystream` — a `TAB1`
|
||||
//! substitution of the ciphertext byte followed by an XOR with the keystream
|
||||
//! (so the cipher is deliberately not its own inverse).
|
||||
|
||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
|
||||
/// Descramble a CSS-encrypted DVD sector in place.
|
||||
///
|
||||
/// Exact port of libdvdcss `dvdcss_unscramble` (css.c). The two content
|
||||
/// LFSRs are seeded **directly** from `title_key XOR sector_seed` — there is
|
||||
/// no `decrypt_key` mangling on this path (that is the disc/title-key
|
||||
/// hierarchy, not the content cipher). Bytes 0x80..0x800 are recovered with
|
||||
/// `*p = TAB1[*p] ^ (i_t5 & 0xff)`.
|
||||
/// The two feedback registers are seeded **directly** from
|
||||
/// `title_key XOR sector_seed` (bytes `0x54..0x59`) — there is no title-key
|
||||
/// mangling on the content path (that belongs to the disc/title-key hierarchy,
|
||||
/// not the sector cipher). Only the body, bytes `0x80..0x800`, is transformed:
|
||||
/// `body[i] = TAB1[body[i]] ^ (keystream & 0xff)`.
|
||||
///
|
||||
/// The scramble flag at byte 0x14 (bits 4-5) indicates encryption. Like
|
||||
/// libdvdcss, the flag byte is NOT modified here — the caller treats a
|
||||
/// nonzero `sector[0x14] & 0x30` as "needs unscrambling" and the descramble
|
||||
/// is its own inverse, so re-running it on plaintext would re-scramble.
|
||||
/// (freemkv historically cleared the flag; we keep clearing it so callers
|
||||
/// and the existing tests can distinguish a descrambled sector. This does
|
||||
/// not affect the recovered body.)
|
||||
/// The scramble flag at byte `0x14` (bits 4-5) marks an encrypted sector. This
|
||||
/// routine CLEARS that flag after unscrambling, so a descrambled sector reads as
|
||||
/// `sector[0x14] & 0x30 == 0`; callers and tests use that to tell it from
|
||||
/// ciphertext, and re-running descramble on an already-cleared sector is a no-op
|
||||
/// (the flag guard below skips it). Clearing does not affect the recovered body.
|
||||
///
|
||||
/// No-op (returns without modifying `sector`) in two cases:
|
||||
/// - `sector.len() < 2048`: the encrypted region (0x80..0x800) is not
|
||||
/// fully present. Callers chunk by 2048, so a trailing partial chunk is
|
||||
/// left untouched. The `debug_assert!` flags this misuse in debug/test
|
||||
/// builds; a DVD sector is always exactly 2048 bytes.
|
||||
/// - `sector.len() < 2048`: the encrypted region (`0x80..0x800`) is not fully
|
||||
/// present. Callers chunk by 2048, so a trailing partial chunk is left
|
||||
/// untouched. The `debug_assert!` flags this misuse in debug/test builds; a
|
||||
/// DVD sector is always exactly 2048 bytes.
|
||||
/// - scramble flags are zero: the sector is not CSS-encrypted.
|
||||
///
|
||||
/// Design reference: libdvdcss `dvdcss_unscramble`. The combiner mirrors
|
||||
/// `css.c` line-for-line:
|
||||
/// ```text
|
||||
/// i_t1 = (key[0] ^ sec[0x54]) | 0x100;
|
||||
/// i_t2 = key[1] ^ sec[0x55];
|
||||
/// i_t3 = (key[2]|key[3]<<8|key[4]<<16) ^ (sec[0x56]|sec[0x57]<<8|sec[0x58]<<16);
|
||||
/// i_t4 = i_t3 & 7; i_t3 = i_t3*2 + 8 - i_t4;
|
||||
/// // per byte over 0x80..0x800:
|
||||
/// i_t4 = TAB2[i_t2] ^ TAB3[i_t1];
|
||||
/// i_t2 = i_t1 >> 1; i_t1 = ((i_t1 & 1) << 8) ^ i_t4; i_t4 = TAB5[i_t4];
|
||||
/// 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];
|
||||
/// i_t5 += i_t6 + i_t4; *p = TAB1[*p] ^ (i_t5 & 0xff); i_t5 >>= 8;
|
||||
/// ```
|
||||
pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
debug_assert!(
|
||||
sector.len() >= 2048,
|
||||
@@ -62,102 +49,103 @@ pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// libdvdcss: `if( !(p_sec[0x14] & 0x30) ) return;`
|
||||
// Not scrambled (flag bits 4-5 clear) → nothing to do.
|
||||
if sector[0x14] & 0x30 == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// LFSR1: seeded directly from (key ^ seed) — NO decrypt_key.
|
||||
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
// LFSR1 halves, seeded from (key ^ seed) bytes 0-1. The 9-bit half carries a
|
||||
// set bit 8 (`| 0x100`) as its running marker.
|
||||
let mut r1a: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut r1b: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
|
||||
// LFSR0 (i_t3): 24-bit feedback register seeded from the remaining three
|
||||
// key/seed bytes, then transformed `i_t3 = i_t3*2 + 8 - (i_t3 & 7)`.
|
||||
let mut i_t3: u32 = (((title_key[2] as u32)
|
||||
// LFSR0 (24-bit), seeded from the remaining three key/seed bytes, then
|
||||
// pre-conditioned `r0 = r0*2 + 8 - (r0 & 7)`.
|
||||
let mut r0: u32 = (((title_key[2] as u32)
|
||||
| ((title_key[3] as u32) << 8)
|
||||
| ((title_key[4] as u32) << 16))
|
||||
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
||||
& 0xFF_FFFF;
|
||||
let i_t4_seed = i_t3 & 7;
|
||||
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
|
||||
r0 = r0 * 2 + 8 - (r0 & 7);
|
||||
|
||||
let mut i_t5: u32 = 0;
|
||||
// Keystream accumulator; the low byte is the current keystream byte and the
|
||||
// high bits carry into the next iteration.
|
||||
let mut acc: u32 = 0;
|
||||
|
||||
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||
// Advance LFSR1.
|
||||
let mut 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;
|
||||
i_t4 = TAB5[i_t4 as usize] as u32;
|
||||
// Step LFSR1: its output byte `o1`.
|
||||
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
|
||||
r1b = r1a >> 1;
|
||||
r1a = ((r1a & 1) << 8) ^ o1;
|
||||
o1 = TAB5[o1 as usize] as u32;
|
||||
|
||||
// Advance LFSR0 (i_t3) and fold both outputs into i_t5.
|
||||
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;
|
||||
// Step LFSR0: its output byte `o0`.
|
||||
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
|
||||
r0 = (r0 << 8) | o0;
|
||||
o0 = TAB4[o0 as usize] as u32;
|
||||
|
||||
*byte = TAB1[*byte as usize] ^ (i_t5 & 0xFF) as u8;
|
||||
i_t5 >>= 8;
|
||||
// Combine (sum with carry) and recover the plaintext byte.
|
||||
acc += o0 + o1;
|
||||
*byte = TAB1[*byte as usize] ^ (acc & 0xFF) as u8;
|
||||
acc >>= 8;
|
||||
}
|
||||
|
||||
// libdvdcss leaves byte 0x14 untouched; freemkv clears the scramble bits
|
||||
// so downstream code and tests can tell a sector was descrambled.
|
||||
// Clear the scramble bits so downstream code and tests can tell a sector was
|
||||
// descrambled; bits 6-7 of byte 0x14 are preserved.
|
||||
sector[0x14] &= 0xCF;
|
||||
}
|
||||
|
||||
/// Exact inverse of [`descramble_sector`]: turn a plaintext sector body into
|
||||
/// CSS ciphertext under `title_key`.
|
||||
///
|
||||
/// Descramble computes `plain = TAB1[cipher] ^ (i_t5 & 0xff)`, so the
|
||||
/// inverse is `cipher = TAB1_INV[plain ^ (i_t5 & 0xff)]` with the identical
|
||||
/// LFSR keystream. The keystream derivation is byte-for-byte the same as
|
||||
/// `descramble_sector` (libdvdcss `dvdcss_unscramble`); only the final
|
||||
/// substitution differs. Bytes 0x80..0x800 are rewritten in place; the
|
||||
/// scramble flag is set to 0x10 so a subsequent descramble runs.
|
||||
/// Descramble computes `plain = TAB1[cipher] ^ (keystream & 0xff)`, so the
|
||||
/// inverse is `cipher = TAB1_INV[plain ^ (keystream & 0xff)]` with the identical
|
||||
/// keystream. The keystream derivation is the same as [`descramble_sector`];
|
||||
/// only the final substitution differs. Bytes `0x80..0x800` are rewritten in
|
||||
/// place; the scramble flag is set to `0x10` so a subsequent descramble runs.
|
||||
///
|
||||
/// Not on any production read path — it exists so the key-recovery tests
|
||||
/// (and any caller that needs to produce a known CSS-encrypted sector) can
|
||||
/// build genuine ciphertext rather than approximating it.
|
||||
/// Not on any production read path — it exists so the key-recovery tests (and
|
||||
/// any caller that needs a known CSS-encrypted sector) can build genuine
|
||||
/// ciphertext rather than approximating it.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn scramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
if sector.len() < 2048 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
let mut i_t3: u32 = (((title_key[2] as u32)
|
||||
let mut r1a: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut r1b: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
let mut r0: u32 = (((title_key[2] as u32)
|
||||
| ((title_key[3] as u32) << 8)
|
||||
| ((title_key[4] as u32) << 16))
|
||||
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
||||
& 0xFF_FFFF;
|
||||
let i_t4_seed = i_t3 & 7;
|
||||
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
|
||||
r0 = r0 * 2 + 8 - (r0 & 7);
|
||||
|
||||
let mut i_t5: u32 = 0;
|
||||
let mut acc: u32 = 0;
|
||||
|
||||
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||
let mut 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;
|
||||
i_t4 = TAB5[i_t4 as usize] as u32;
|
||||
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
|
||||
r1b = r1a >> 1;
|
||||
r1a = ((r1a & 1) << 8) ^ o1;
|
||||
o1 = TAB5[o1 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;
|
||||
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
|
||||
r0 = (r0 << 8) | o0;
|
||||
o0 = TAB4[o0 as usize] as u32;
|
||||
acc += o0 + o1;
|
||||
|
||||
// Inverse of `*p = TAB1[*p] ^ ks`: apply ks then TAB1's inverse.
|
||||
*byte = (*TAB1_INV)[(*byte ^ (i_t5 & 0xFF) as u8) as usize];
|
||||
i_t5 >>= 8;
|
||||
*byte = (*TAB1_INV)[(*byte ^ (acc & 0xFF) as u8) as usize];
|
||||
acc >>= 8;
|
||||
}
|
||||
|
||||
// Mark the sector scrambled so the descrambler will process it.
|
||||
sector[0x14] = (sector[0x14] & 0xCF) | 0x10;
|
||||
}
|
||||
|
||||
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a
|
||||
/// bijection on 0..256, so `TAB1_INV[TAB1[x]] == x`.
|
||||
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a bijection on
|
||||
/// `0..256`, so `TAB1_INV[TAB1[x]] == x`.
|
||||
#[cfg(test)]
|
||||
static TAB1_INV: std::sync::LazyLock<[u8; 256]> = std::sync::LazyLock::new(|| {
|
||||
let mut inv = [0u8; 256];
|
||||
@@ -181,14 +169,15 @@ mod tests {
|
||||
assert_eq!(sector, original);
|
||||
}
|
||||
|
||||
/// Cross-check `descramble_sector` against the EXACT output of libdvdcss
|
||||
/// `dvdcss_unscramble` (css.c) for a fixed sector, computed from the
|
||||
/// reference C semantics with the reference tables. Pins the content
|
||||
/// cipher to libdvdcss byte-for-byte.
|
||||
/// Regression vector: the deterministic output of the CSS content cipher for
|
||||
/// a fixed key/seed/body. The value is generated by this implementation and
|
||||
/// is self-consistent with the scramble/descramble round-trip below — any
|
||||
/// correct CSS descrambler yields the same bytes, since the cipher is
|
||||
/// deterministic. Pins the implementation against accidental change.
|
||||
///
|
||||
/// key = 42 13 37 BE EF, seed (0x54..0x59) = DE AD BE EF 42, body = 0xAA.
|
||||
#[test]
|
||||
fn descramble_matches_libdvdcss_unscramble_vector() {
|
||||
fn descramble_produces_the_reference_css_vector() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let mut sector = vec![0xAAu8; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
@@ -200,12 +189,12 @@ mod tests {
|
||||
0x81, 0x92, 0x24, 0xA2, 0x46, 0x70, 0x3C, 0x64, 0xA6, 0x91, 0x84, 0xF5, 0x1F, 0x98,
|
||||
0xA0, 0x31
|
||||
],
|
||||
"descramble body head must match libdvdcss dvdcss_unscramble"
|
||||
"descramble body head must match the reference CSS vector"
|
||||
);
|
||||
assert_eq!(
|
||||
§or[0x7F8..0x800],
|
||||
&[0x46, 0x94, 0x80, 0x0E, 0x67, 0x36, 0x65, 0xBC],
|
||||
"descramble body tail must match libdvdcss dvdcss_unscramble"
|
||||
"descramble body tail must match the reference CSS vector"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -241,8 +230,8 @@ mod tests {
|
||||
|
||||
/// Test 2: descramble inverts scramble over the body.
|
||||
///
|
||||
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to
|
||||
/// the ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
|
||||
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to the
|
||||
/// ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
|
||||
/// [`scramble_sector`]. Scrambling a plaintext body and then descrambling
|
||||
/// with the same key must reproduce the original body exactly.
|
||||
#[test]
|
||||
@@ -279,9 +268,9 @@ mod tests {
|
||||
|
||||
/// css_tab1_relationship
|
||||
///
|
||||
/// Verify the structure of TAB1: it is a substitution table used in
|
||||
/// key mangling. Check that no two inputs map to the same output
|
||||
/// (TAB1 is a permutation of 0..255).
|
||||
/// Verify the structure of TAB1: it is a substitution table used in key
|
||||
/// mangling. Check that no two inputs map to the same output (TAB1 is a
|
||||
/// permutation of 0..255).
|
||||
#[test]
|
||||
fn css_tab1_is_permutation() {
|
||||
let mut seen = [false; 256];
|
||||
@@ -332,8 +321,8 @@ mod tests {
|
||||
/// UNSCRAMBLED and left byte-for-byte unchanged. This guards against a
|
||||
/// too-wide mask silently "descrambling" (and thus corrupting) clear data.
|
||||
///
|
||||
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live
|
||||
/// in bits 4-5; the masked value 0 means not scrambled.
|
||||
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live in
|
||||
/// bits 4-5; the masked value 0 means not scrambled.
|
||||
/// Mutation: widen the mask `0x30` to `0x70`/`0xF0` -> 0x40/0x80 would be
|
||||
/// seen as scrambled and the body would change.
|
||||
#[test]
|
||||
@@ -352,11 +341,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each individual scramble bit (4 and 5) independently marks the sector
|
||||
/// as encrypted: 0x10 and 0x20 must both trigger descrambling.
|
||||
/// Each individual scramble bit (4 and 5) independently marks the sector as
|
||||
/// encrypted: 0x10 and 0x20 must both trigger descrambling.
|
||||
///
|
||||
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both
|
||||
/// nonzero.
|
||||
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both nonzero.
|
||||
/// Mutation: change `!= 0` early-return condition to `== 3` -> a sector
|
||||
/// flagged only 0x10 or 0x20 would be skipped and left scrambled.
|
||||
#[test]
|
||||
@@ -381,8 +369,8 @@ mod tests {
|
||||
/// becomes 0xC0 (bits 6,7 kept, bits 4,5 cleared), NOT 0x00.
|
||||
///
|
||||
/// Grounding: code does `sector[0x14] &= 0xCF`; 0xF0 & 0xCF == 0xC0.
|
||||
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved
|
||||
/// high bits assert fails.
|
||||
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved high
|
||||
/// bits assert fails.
|
||||
#[test]
|
||||
fn descramble_clear_preserves_high_bits_of_0x14() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
@@ -398,11 +386,11 @@ mod tests {
|
||||
|
||||
// ── header / body boundary (encrypted region is 0x80..0x800) ───────────
|
||||
|
||||
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80
|
||||
/// (the header) must NOT be modified by the keystream — except byte 0x14
|
||||
/// whose flag is cleared. In particular the sector-seed bytes 0x54..0x59
|
||||
/// (which live inside the header) must survive untouched, since the
|
||||
/// descrambler reads them but never writes them.
|
||||
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80 (the
|
||||
/// header) must NOT be modified by the keystream — except byte 0x14 whose
|
||||
/// flag is cleared. In particular the sector-seed bytes 0x54..0x59 (which
|
||||
/// live inside the header) must survive untouched, since the descrambler
|
||||
/// reads them but never writes them.
|
||||
///
|
||||
/// Grounding: loop is `sector.iter_mut().take(2048).skip(128)` -> indices
|
||||
/// 128..2048 only.
|
||||
@@ -429,16 +417,15 @@ mod tests {
|
||||
assert_eq!(§or[0x54..0x59], &seed, "sector seed must survive");
|
||||
}
|
||||
|
||||
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a
|
||||
/// prefix. With a constant body and constant key, the keystream is
|
||||
/// non-degenerate enough that the very last sector byte (index 2047) is
|
||||
/// altered. This guards the loop bound `.take(2048)` against an
|
||||
/// off-by-one that would leave the final byte(s) scrambled.
|
||||
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a prefix.
|
||||
/// With a constant body and constant key, the keystream is non-degenerate
|
||||
/// enough that the very last sector byte (index 2047) is altered. This guards
|
||||
/// the loop bound `.take(2048)` against an off-by-one that would leave the
|
||||
/// final byte(s) scrambled.
|
||||
///
|
||||
/// Grounding: encrypted region end is 0x800 == 2048 (exclusive).
|
||||
/// Mutation: change `.take(2048)` to `.take(2047)` -> last byte unchanged,
|
||||
/// assert fires (keystream byte for the last position is verified nonzero
|
||||
/// below by the round-trip, and this body is all-zero so any XOR shows).
|
||||
/// assert fires (this body is all-zero so any keystream XOR shows).
|
||||
#[test]
|
||||
fn descramble_covers_final_body_byte() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
@@ -447,9 +434,7 @@ mod tests {
|
||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
descramble_sector(&key, &mut sector);
|
||||
// Body was all zero; any nonzero in [0x80,0x800) is keystream. Confirm
|
||||
// the keystream reaches the final byte. (If the last keystream byte
|
||||
// happened to be 0 this could be a flaky test, so assert the run-end
|
||||
// region as a whole differs from zero.)
|
||||
// the keystream reaches the final byte.
|
||||
assert_ne!(
|
||||
§or[2040..2048],
|
||||
&[0u8; 8][..],
|
||||
@@ -457,14 +442,57 @@ 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 output — silent wrong-key decryption.
|
||||
/// produce two different bodies for the same scrambled input. A cipher that
|
||||
/// ignored the title key (or mixed it in wrongly) would yield identical
|
||||
/// output — silent wrong-key decryption.
|
||||
///
|
||||
/// Grounding: per-sector key = title_key[i] ^ sector[0x54+i].
|
||||
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both
|
||||
/// keys give the same body, assert fires.
|
||||
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both keys
|
||||
/// give the same body, assert fires.
|
||||
#[test]
|
||||
fn descramble_output_depends_on_title_key() {
|
||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||
|
||||
+1033
-96
File diff suppressed because it is too large
Load Diff
@@ -1,593 +0,0 @@
|
||||
//! CSS title-key recovery — Frank A. Stevenson's divide-and-conquer attack
|
||||
//! (1999), ported exactly from libdvdcss `RecoverTitleKey` + `AttackPattern`
|
||||
//! (css.c).
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! # The cipher this attacks
|
||||
//!
|
||||
//! The content descrambler ([`super::lfsr::descramble_sector`], = libdvdcss
|
||||
//! `dvdcss_unscramble`) seeds its two LFSRs **directly** from
|
||||
//! `key = title_key XOR sector_seed` (seed = `sector[0x54..0x59]`):
|
||||
//!
|
||||
//! ```text
|
||||
//! i_t1 = (key[0] ^ sec[0x54]) | 0x100; // LFSR1 low (9-bit)
|
||||
//! i_t2 = key[1] ^ sec[0x55]; // LFSR1 high
|
||||
//! i_t3 = (key[2]|key[3]<<8|key[4]<<16) ^ seed3; // LFSR0 (24-bit feedback)
|
||||
//! i_t3 = i_t3*2 + 8 - (i_t3 & 7);
|
||||
//! // per byte: *p = TAB1[*p] ^ (i_t5 & 0xff)
|
||||
//! ```
|
||||
//!
|
||||
//! There is NO `decrypt_key` mangling on the content path. So the recovery
|
||||
//! is a single inversion of `dvdcss_unscramble`, not the multi-stage
|
||||
//! working-key inversion the previous (non-CSS) implementation used.
|
||||
//!
|
||||
//! # The attack
|
||||
//!
|
||||
//! 1. **Known plaintext → keystream.** Because the descramble applies TAB1
|
||||
//! to the ciphertext, the per-byte keystream is
|
||||
//! `buf[i] = TAB1[cipher[i]] ^ plain[i]` (matching libdvdcss
|
||||
//! `RecoverTitleKey`'s `p_buffer`).
|
||||
//! 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 `i_t3`. 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 `i_t3` steps (each a 256-way
|
||||
//! search for the byte shifted in) to reach the initial state, then undo
|
||||
//! `i_t3 = i_t3*2 + 8 - (i_t3 & 7)` to recover key[2..5].
|
||||
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]` (plain XOR —
|
||||
//! the descramble seeds directly, so there is no inversion).
|
||||
//!
|
||||
//! `AttackPattern` finds known plaintext for step 1: 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;
|
||||
|
||||
/// RecoverTitleKey: recover the title key from cipher + known plaintext.
|
||||
///
|
||||
/// Exact port of libdvdcss `RecoverTitleKey` (css.c). `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]
|
||||
}
|
||||
|
||||
/// AttackPattern: find a repeating pattern just before the encrypted region
|
||||
/// and assume the plaintext at 0x80 continues it.
|
||||
///
|
||||
/// Functionally-equivalent port of libdvdcss `AttackPattern` (css.c) — finds the
|
||||
/// same periodic cribs on real DVD data, though its byte-comparison anchor
|
||||
/// differs from the C on phase-misaligned runs. 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
|
||||
}
|
||||
|
||||
/// Inner body of [`crack_title_key`] — the actual AttackPattern search. Split
|
||||
/// out so the public entry point can wall-clock the whole attempt for the
|
||||
/// runaway guard without threading a timer through every return path.
|
||||
/// AttackPattern 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
|
||||
/// `AttackPattern` (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
|
||||
// AttackPattern 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 AttackPattern 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 dvdcss_unscramble 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-12
@@ -24,9 +24,8 @@ pub const TAB1: [u8; 256] = [
|
||||
0xb7, 0xf7, 0xbf, 0xa2, 0xe7, 0xa7, 0xef, 0xf2, 0xba, 0xfa, 0xb2, 0xaf, 0xea, 0xaa, 0xe2, 0xff,
|
||||
];
|
||||
|
||||
/// Table 2: LFSR1 high-byte feedback permutation.
|
||||
///
|
||||
/// Byte-identical to libdvdcss `p_css_tab2` (csstables.h).
|
||||
/// Table 2: LFSR1 high-byte feedback permutation — a fixed constant of the CSS
|
||||
/// cipher (per the published algorithm).
|
||||
pub const TAB2: [u8; 256] = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x09, 0x08, 0x0b, 0x0a, 0x0d, 0x0c, 0x0f, 0x0e,
|
||||
0x12, 0x13, 0x10, 0x11, 0x16, 0x17, 0x14, 0x15, 0x1b, 0x1a, 0x19, 0x18, 0x1f, 0x1e, 0x1d, 0x1c,
|
||||
@@ -46,12 +45,12 @@ pub const TAB2: [u8; 256] = [
|
||||
0xed, 0xec, 0xef, 0xee, 0xe9, 0xe8, 0xeb, 0xea, 0xe4, 0xe5, 0xe6, 0xe7, 0xe0, 0xe1, 0xe2, 0xe3,
|
||||
];
|
||||
|
||||
/// Table 3: LFSR1 9-bit low-word feedback table (512 entries).
|
||||
/// Table 3: LFSR1 9-bit low-word feedback table (512 entries) — a fixed constant
|
||||
/// of the CSS cipher (per the published algorithm).
|
||||
///
|
||||
/// Byte-identical to libdvdcss `p_css_tab3` (csstables.h): the 8-value
|
||||
/// block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step indexes this
|
||||
/// table with the 9-bit low register (0x100..=0x1FF), but only the low 3
|
||||
/// bits select the output — the high bits are ignored, hence the constant
|
||||
/// It is the 8-value block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step
|
||||
/// indexes this table with the 9-bit low register (0x100..=0x1FF), but only the
|
||||
/// low 3 bits select the output — the high bits are ignored, hence the constant
|
||||
/// blocks. The 512-entry width simply lets the 9-bit index be used without
|
||||
/// masking.
|
||||
pub const TAB3: [u8; 512] = [
|
||||
@@ -197,12 +196,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// TAB3 is the libdvdcss `p_css_tab3`: the 8-value feedback block
|
||||
/// TAB3 is the CSS LFSR1 low-word table: the 8-value feedback block
|
||||
/// BASE = [0x00,0x24,0x49,0x6d,0x92,0xb6,0xdb,0xff]
|
||||
/// repeated 64 times — `TAB3[i] == BASE[i & 7]`. The high bits of the
|
||||
/// 9-bit index do not affect the output (libdvdcss's LFSR1 step indexes
|
||||
/// with the full 9-bit low register but only `& 7` matters). This pins
|
||||
/// all 512 entries to the published table.
|
||||
/// 9-bit index do not affect the output (the LFSR1 step indexes with the
|
||||
/// full 9-bit low register but only `& 7` matters). This pins all 512
|
||||
/// entries to the published cipher's table.
|
||||
///
|
||||
/// Mutation: flip any single byte in the TAB3 literal -> the formula
|
||||
/// check fails at that index.
|
||||
|
||||
+1333
-785
File diff suppressed because it is too large
Load Diff
+127
-47
@@ -22,10 +22,7 @@
|
||||
//! `Disc`-level dump ([`dump_disc`]) covers everything that survives
|
||||
//! lowering: titles, streams, the picked main feature, and AACS state.
|
||||
|
||||
use crate::disc::{
|
||||
AudioChannels, ColorSpace, Disc, DiscTitle, FrameRate, HdrFormat, Resolution, SampleRate,
|
||||
Stream,
|
||||
};
|
||||
use crate::disc::{ColorSpace, Disc, DiscTitle, FrameRate, HdrFormat, Resolution, Stream};
|
||||
use crate::ifo::{CellCategory, DvdTitle};
|
||||
|
||||
const DIAG: &str = "freemkv::diag";
|
||||
@@ -95,36 +92,12 @@ pub fn hdr_str(h: HdrFormat) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel count from an [`AudioChannels`] layout (what lands in the MKV
|
||||
/// `Channels` element).
|
||||
pub fn channel_count(ch: AudioChannels) -> u8 {
|
||||
match ch {
|
||||
AudioChannels::Mono => 1,
|
||||
AudioChannels::Stereo => 2,
|
||||
AudioChannels::Stereo21 => 3,
|
||||
AudioChannels::Quad => 4,
|
||||
AudioChannels::Surround50 => 5,
|
||||
AudioChannels::Surround51 => 6,
|
||||
AudioChannels::Surround61 => 7,
|
||||
AudioChannels::Surround71 => 8,
|
||||
AudioChannels::Unknown => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample-rate in Hz for a [`SampleRate`].
|
||||
pub fn sample_rate_hz(s: SampleRate) -> u32 {
|
||||
match s {
|
||||
SampleRate::S44_1 => 44100,
|
||||
SampleRate::S48 => 48000,
|
||||
SampleRate::S88_2 => 88200,
|
||||
SampleRate::S96 => 96000,
|
||||
SampleRate::S176_4 => 176400,
|
||||
SampleRate::S192 => 192000,
|
||||
SampleRate::S48_96 => 96000,
|
||||
SampleRate::S48_192 => 192000,
|
||||
SampleRate::Unknown => 0,
|
||||
}
|
||||
}
|
||||
// `channel_count` and `sample_rate_hz` lived here as a third copy of the
|
||||
// AudioChannels/SampleRate mappings. They were the only HONEST copy — returning
|
||||
// 0 for Unknown where the canonical accessors fabricated 6 channels at 48 kHz —
|
||||
// and their only caller was the trace line below, in this same file. The
|
||||
// canonical accessors are honest now, so the duplicates are gone rather than
|
||||
// left to drift a fourth time.
|
||||
|
||||
// ── DVD cell-category dump (from the IFO scan, pre-lowering) ─────────────────
|
||||
|
||||
@@ -336,7 +309,7 @@ fn frame_record(track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> V
|
||||
/// `track_number` is the 1-based MKV track number; `track` is the built
|
||||
/// [`crate::mux::mkv::MkvTrack`] whose fields map one-to-one onto the emitted
|
||||
/// elements (see `MkvMuxer::new`). No-op unless the diag target is on.
|
||||
pub fn dump_mkv_track(track_number: u64, track: &crate::mux::mkv::MkvTrack) {
|
||||
pub(crate) fn dump_mkv_track(track_number: u64, track: &crate::mux::mkv::MkvTrack) {
|
||||
if !diag_enabled() {
|
||||
return;
|
||||
}
|
||||
@@ -499,13 +472,50 @@ pub fn dump_disc(disc: &Disc) {
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=decision pick=main_feature title_idx=0 playlist={:?} dur={:.1}s \
|
||||
size={}B clips={} reason=canonical_title_order(fits-disc, fewest-clips, longest, richest-audio)",
|
||||
size={}B clips={} video={} reason={}",
|
||||
main.playlist,
|
||||
main.duration_secs,
|
||||
main.size_bytes,
|
||||
main.clips.len(),
|
||||
main.video_streams().count(),
|
||||
main_feature_reason(),
|
||||
);
|
||||
}
|
||||
|
||||
// Titles disqualified from main-feature candidacy for carrying no video —
|
||||
// the streamless obfuscation decoys the `has-video` gate demotes. Logged so
|
||||
// a field bug report shows WHY a long/large playlist was not picked (the
|
||||
// gap that let an undiagnosable E6008 loop on a real UHD title's decoy
|
||||
// playlist `00245`).
|
||||
for (ti, t) in disc.titles.iter().enumerate() {
|
||||
if !t.has_video() {
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=decision.demoted title_idx={} playlist={:?} dur={:.1}s size={}B reason=no-video",
|
||||
ti,
|
||||
t.playlist,
|
||||
t.duration_secs,
|
||||
t.size_bytes,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `reason=` token on the main-feature decision row.
|
||||
///
|
||||
/// DERIVED from [`Disc::CANONICAL_TITLE_ORDER_KEYS`], which lives beside the
|
||||
/// comparator that actually implements them — never restated here. The previous
|
||||
/// hand-written copy drifted (it advertised a `fewest-clips` key the comparator
|
||||
/// had replaced with largest-physical-size), which made the self-diagnosing log
|
||||
/// explain the pick with a rule the code does not apply. A diagnostic that
|
||||
/// disagrees with the decision it documents is worse than no diagnostic.
|
||||
fn main_feature_reason() -> String {
|
||||
let keys: Vec<&str> = Disc::MAIN_FEATURE_ORDER_KEYS
|
||||
.iter()
|
||||
.chain(Disc::CANONICAL_TITLE_ORDER_KEYS.iter())
|
||||
.copied()
|
||||
.collect();
|
||||
format!("main_feature_order({})", keys.join(", "))
|
||||
}
|
||||
|
||||
fn dump_aacs(disc: &Disc) {
|
||||
@@ -530,7 +540,7 @@ fn dump_aacs(disc: &Disc) {
|
||||
a.bus_encryption,
|
||||
a.mkb_version,
|
||||
a.disc_hash,
|
||||
a.key_source.name(),
|
||||
a.key_source,
|
||||
a.vuk.is_some(),
|
||||
a.unit_keys.len(),
|
||||
a.uk_ro.len(),
|
||||
@@ -610,8 +620,8 @@ fn dump_title(ti: usize, title: &DiscTitle) {
|
||||
a.pid,
|
||||
a.codec,
|
||||
a.channels,
|
||||
channel_count(a.channels),
|
||||
sample_rate_hz(a.sample_rate),
|
||||
a.channels.count(),
|
||||
a.sample_rate.hz(),
|
||||
a.language,
|
||||
a.secondary,
|
||||
),
|
||||
@@ -631,6 +641,61 @@ fn dump_title(ti: usize, title: &DiscTitle) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// Needed only by the tests: the production code in this file no longer names
|
||||
// these types directly, since the local channel/sample-rate duplicates were
|
||||
// deleted in favour of the canonical accessors.
|
||||
use crate::disc::{AudioChannels, SampleRate};
|
||||
|
||||
/// The main-feature decision row must NAME `canonical_title_order`'s sort
|
||||
/// keys, not restate them from memory. The restated copy had drifted: it
|
||||
/// still advertised a "fewest-clips" key long after the comparator replaced
|
||||
/// clip-count with largest-physical-size, so a bug report read at
|
||||
/// `--log-level 3` explained the pick with a rule the code does not apply.
|
||||
///
|
||||
/// The behavioural half is asserted first — against the comparator itself,
|
||||
/// with literals — so the key names are checked against what the code
|
||||
/// actually does, not against the string that names them.
|
||||
#[test]
|
||||
fn main_feature_reason_names_the_comparators_real_keys() {
|
||||
use crate::disc::{Clip, Disc, DiscTitle};
|
||||
|
||||
let sized = |size_bytes: u64, n_clips: usize| DiscTitle {
|
||||
size_bytes,
|
||||
clips: (0..n_clips)
|
||||
.map(|i| Clip {
|
||||
feed_span: None,
|
||||
clip_id: format!("{i:05}"),
|
||||
in_time: 0,
|
||||
out_time: 0,
|
||||
duration_secs: 0.0,
|
||||
source_packets: 0,
|
||||
})
|
||||
.collect(),
|
||||
..DiscTitle::empty()
|
||||
};
|
||||
// A 40-clip 8 GB title beats a 1-clip 1 GB title: the comparator's
|
||||
// primary key among disc-fitting titles is LARGEST SIZE. "fewest clips"
|
||||
// would predict the opposite, so the drifted string described a rule
|
||||
// the comparator does not implement.
|
||||
let many_clips_big = sized(8_000_000_000, 40);
|
||||
let one_clip_small = sized(1_000_000_000, 1);
|
||||
assert_eq!(
|
||||
Disc::canonical_title_order(&many_clips_big, &one_clip_small, 25_000_000_000),
|
||||
std::cmp::Ordering::Less,
|
||||
"largest size wins regardless of clip count"
|
||||
);
|
||||
|
||||
let reason = main_feature_reason();
|
||||
assert!(
|
||||
!reason.contains("clips"),
|
||||
"the reason must not advertise a clip-count key the comparator dropped: {reason}"
|
||||
);
|
||||
assert_eq!(
|
||||
reason,
|
||||
"main_feature_order(nav-feature, authoring-feature, standalone, has-video, fits-disc, largest-size, longest, richest-audio)",
|
||||
"the reason must name the selection keys (authoring, standalone-over-composite, and has-video gates, then the physical keys) in priority order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_str_keeps_interlace_marker() {
|
||||
@@ -656,18 +721,33 @@ mod tests {
|
||||
assert_eq!(hdr_str(HdrFormat::Sdr), "SDR");
|
||||
}
|
||||
|
||||
/// Moved from the deleted local duplicates onto the canonical accessors,
|
||||
/// with the Unknown case added — which is the whole point of the change.
|
||||
#[test]
|
||||
fn channel_count_matches_layout() {
|
||||
assert_eq!(channel_count(AudioChannels::Mono), 1);
|
||||
assert_eq!(channel_count(AudioChannels::Stereo), 2);
|
||||
assert_eq!(channel_count(AudioChannels::Surround51), 6);
|
||||
assert_eq!(channel_count(AudioChannels::Surround71), 8);
|
||||
fn channel_count_matches_layout_and_is_zero_when_unknown() {
|
||||
assert_eq!(AudioChannels::Mono.count(), 1);
|
||||
assert_eq!(AudioChannels::Stereo.count(), 2);
|
||||
assert_eq!(AudioChannels::Surround51.count(), 6);
|
||||
assert_eq!(AudioChannels::Surround71.count(), 8);
|
||||
// The one that matters. This used to return 6, which is indistinguishable
|
||||
// from a real 5.1 track and left every caller responsible for checking
|
||||
// the variant first.
|
||||
assert_eq!(
|
||||
AudioChannels::Unknown.count(),
|
||||
0,
|
||||
"an unknown layout must not report a plausible channel count"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_rate_hz_values() {
|
||||
assert_eq!(sample_rate_hz(SampleRate::S48), 48000);
|
||||
assert_eq!(sample_rate_hz(SampleRate::S96), 96000);
|
||||
fn sample_rate_hz_values_and_zero_when_unknown() {
|
||||
assert_eq!(SampleRate::S48.hz(), 48000.0);
|
||||
assert_eq!(SampleRate::S96.hz(), 96000.0);
|
||||
assert_eq!(
|
||||
SampleRate::Unknown.hz(),
|
||||
0.0,
|
||||
"an unknown sample rate must not report a plausible 48 kHz"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
//! ECMA-167 / UDF 1.02 descriptor encoder.
|
||||
//!
|
||||
//! Turns a [`Layout`](super::layout::Layout) — a directory tree with every
|
||||
//! ICB, directory-data and file-data block already assigned — into the set of
|
||||
//! metadata sectors a real UDF volume would carry. Nothing here touches the
|
||||
//! filesystem: it is a pure function from layout to sectors, which is what
|
||||
//! makes it testable against the production parser in `udf.rs`.
|
||||
//!
|
||||
//! What is emitted, in volume order:
|
||||
//!
|
||||
//! | sector | descriptor |
|
||||
//! |---|---|
|
||||
//! | 16, 17, 18 | Volume Recognition Sequence — `BEA01`, `NSR02`, `TEA01` (ECMA-167 2/9.1) |
|
||||
//! | 32… | Main Volume Descriptor Sequence — PVD, IUVD, PD, LVD, USD, TD |
|
||||
//! | 48… | Reserve VDS (byte-identical but for the tag locations) |
|
||||
//! | 64, 65 | Logical Volume Integrity Sequence — LVID, TD |
|
||||
//! | 256 | Anchor Volume Descriptor Pointer |
|
||||
//! | `part_start` + 0, +1 | File Set Descriptor, TD |
|
||||
//! | `part_start` + … | File Entries (ICBs) and directory data (FIDs) |
|
||||
//! | last sector | Anchor Volume Descriptor Pointer (copy) |
|
||||
//!
|
||||
//! UDF revision 1.02 with a single Type-1 partition map is deliberate: it is
|
||||
//! the DVD-Video profile, it is the shape `read_filesystem` takes when
|
||||
//! `num_partition_maps < 2`, and it avoids the UDF 2.50 Metadata Partition
|
||||
//! entirely. That also means a synthetic image never exercises the Metadata
|
||||
//! Partition path in `udf.rs` (`:946-991`) — see the module docs on `dirimage`.
|
||||
|
||||
use super::layout::{DirNode, Layout};
|
||||
use crate::error::{Error, Result};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Logical block / sector size. Fixed for every optical profile this crate
|
||||
/// reads, and the same quantity as [`crate::consts::SECTOR_BYTES`] — aliased
|
||||
/// rather than re-declared so the two cannot drift apart. The short name is
|
||||
/// kept because it appears in ~25 extent and offset expressions across
|
||||
/// `dirimage`, where the longer one would bury the arithmetic.
|
||||
pub(super) use crate::consts::SECTOR_BYTES as SECTOR;
|
||||
|
||||
/// Descriptor version recorded in every tag. 2 = ECMA-167 2nd edition, which
|
||||
/// is what UDF revisions up to and including 2.00 require.
|
||||
const DESC_VERSION: u16 = 2;
|
||||
|
||||
/// UDF revision recorded in the domain EntityID suffix (1.02, BCD-ish u16).
|
||||
const UDF_REVISION: u16 = 0x0102;
|
||||
|
||||
/// A fixed recording timestamp, so an image synthesized from the same folder
|
||||
/// twice is byte-identical. Real mtimes would make every test golden-file
|
||||
/// comparison and every `dir:// -> iso://` re-run differ for no benefit.
|
||||
const FIXED_TIME: Timestamp = Timestamp {
|
||||
year: 2000,
|
||||
month: 1,
|
||||
day: 1,
|
||||
};
|
||||
|
||||
struct Timestamp {
|
||||
year: i16,
|
||||
month: u8,
|
||||
day: u8,
|
||||
}
|
||||
|
||||
/// The synthesized metadata: absolute LBA → sector contents. Data sectors are
|
||||
/// NOT here; they are served from the backing files.
|
||||
pub(super) type MetaSectors = BTreeMap<u32, Box<[u8; SECTOR]>>;
|
||||
|
||||
/// The descriptor-tag CRC of ECMA-167 7.2.4: polynomial 0x1021, initial value
|
||||
/// ZERO, no reflection, no final XOR — the variant catalogued as CRC-16/XMODEM
|
||||
/// (check value 0x31C3), NOT CCITT-FALSE, which seeds at 0xFFFF and would make
|
||||
/// every descriptor this crate writes fail a conformant driver's validation.
|
||||
fn crc16(data: &[u8]) -> u16 {
|
||||
let mut crc: u16 = 0;
|
||||
for &b in data {
|
||||
crc ^= (b as u16) << 8;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x8000 != 0 {
|
||||
(crc << 1) ^ 0x1021
|
||||
} else {
|
||||
crc << 1
|
||||
};
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// Write an ECMA-167 3/7.2 descriptor tag over `buf[0..16]`.
|
||||
///
|
||||
/// `tag_loc` is the block number of the sector holding the descriptor —
|
||||
/// ABSOLUTE for the volume-space descriptors (AVDP, VDS, LVID) and
|
||||
/// PARTITION-RELATIVE for everything inside the partition (FSD, File Entries).
|
||||
/// Getting that wrong is the classic reason a hand-built volume mounts nowhere:
|
||||
/// a driver that validates the tag location rejects the descriptor outright.
|
||||
///
|
||||
/// `desc_len` is the descriptor's total length including the tag; the CRC
|
||||
/// covers `buf[16..desc_len]`.
|
||||
fn finish_tag(buf: &mut [u8], tag_id: u16, tag_loc: u32, desc_len: usize) {
|
||||
buf[0..2].copy_from_slice(&tag_id.to_le_bytes());
|
||||
buf[2..4].copy_from_slice(&DESC_VERSION.to_le_bytes());
|
||||
buf[4] = 0; // checksum, filled below
|
||||
buf[5] = 0; // reserved
|
||||
buf[6..8].copy_from_slice(&0u16.to_le_bytes()); // tag serial number
|
||||
let crc_len = desc_len - 16;
|
||||
let crc = crc16(&buf[16..desc_len]);
|
||||
buf[8..10].copy_from_slice(&crc.to_le_bytes());
|
||||
buf[10..12].copy_from_slice(&(crc_len as u16).to_le_bytes());
|
||||
buf[12..16].copy_from_slice(&tag_loc.to_le_bytes());
|
||||
// ECMA-167 3/7.2.3: sum of bytes 0..16 EXCLUDING byte 4, modulo 256.
|
||||
let sum: u32 = buf[0..16]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != 4)
|
||||
.map(|(_, b)| *b as u32)
|
||||
.sum();
|
||||
buf[4] = (sum % 256) as u8;
|
||||
}
|
||||
|
||||
/// ECMA-167 1/7.2.1 charspec: type 0 (CS0) + "OSTA Compressed Unicode".
|
||||
fn put_charspec(buf: &mut [u8]) {
|
||||
buf[0] = 0;
|
||||
let id = b"OSTA Compressed Unicode";
|
||||
buf[1..1 + id.len()].copy_from_slice(id);
|
||||
}
|
||||
|
||||
/// ECMA-167 1/7.4 EntityID: flags byte, 23 identifier bytes, 8 suffix bytes.
|
||||
fn put_entity_id(buf: &mut [u8], id: &[u8], suffix: &[u8]) {
|
||||
buf[0] = 0;
|
||||
let n = id.len().min(23);
|
||||
buf[1..1 + n].copy_from_slice(&id[..n]);
|
||||
let m = suffix.len().min(8);
|
||||
buf[24..24 + m].copy_from_slice(&suffix[..m]);
|
||||
}
|
||||
|
||||
/// The `*OSTA UDF Compliant` domain EntityID suffix: UDF revision, domain
|
||||
/// flags (0 = neither hard nor soft write-protected), reserved.
|
||||
fn domain_suffix() -> [u8; 8] {
|
||||
let mut s = [0u8; 8];
|
||||
s[0..2].copy_from_slice(&UDF_REVISION.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// This crate's implementation EntityID suffix: OS class / OS identifier
|
||||
/// (0 = undefined, deliberately — the image is not OS-specific) + 6 free bytes.
|
||||
fn impl_suffix() -> [u8; 8] {
|
||||
[0u8; 8]
|
||||
}
|
||||
|
||||
fn put_impl_id(buf: &mut [u8]) {
|
||||
put_entity_id(buf, b"*freemkv", &impl_suffix());
|
||||
}
|
||||
|
||||
fn put_domain_id(buf: &mut [u8]) {
|
||||
put_entity_id(buf, b"*OSTA UDF Compliant", &domain_suffix());
|
||||
}
|
||||
|
||||
/// OSTA CS0 d-string: a compression-ID byte, the characters, then the used
|
||||
/// length in the FIELD'S LAST byte (ECMA-167 1/7.2.12 + UDF 2.1.3). An
|
||||
/// all-zero field is the empty string.
|
||||
fn put_dstring(buf: &mut [u8], s: &str) {
|
||||
if s.is_empty() {
|
||||
return;
|
||||
}
|
||||
let encoded = encode_cs0(s);
|
||||
// Leave room for the trailing length byte.
|
||||
let room = buf.len() - 1;
|
||||
let n = encoded.len().min(room);
|
||||
buf[..n].copy_from_slice(&encoded[..n]);
|
||||
buf[buf.len() - 1] = n as u8;
|
||||
}
|
||||
|
||||
/// OSTA CS0: compression ID 8 (one byte per character) when every character
|
||||
/// is ASCII, otherwise compression ID 16 (UTF-16BE).
|
||||
///
|
||||
/// ASCII rather than Latin-1 for the 8-bit form on purpose: `parse_udf_name`
|
||||
/// (`udf.rs:1467`) decodes a compression-8 name with `from_utf8_lossy`, so a
|
||||
/// 0x80-0xFF byte — legal CS0 — would come back as U+FFFD. Every character
|
||||
/// above 0x7F therefore takes the 16-bit form, which that parser decodes
|
||||
/// correctly.
|
||||
pub(super) fn encode_cs0(s: &str) -> Vec<u8> {
|
||||
if s.is_ascii() {
|
||||
let mut v = Vec::with_capacity(1 + s.len());
|
||||
v.push(8u8);
|
||||
v.extend_from_slice(s.as_bytes());
|
||||
v
|
||||
} else {
|
||||
let mut v = vec![16u8];
|
||||
for u in s.encode_utf16() {
|
||||
v.extend_from_slice(&u.to_be_bytes());
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// ECMA-167 1/7.3 timestamp, 12 bytes. Type 1 (local time) with a zero
|
||||
/// offset, i.e. UTC.
|
||||
fn put_timestamp(buf: &mut [u8]) {
|
||||
buf[0..2].copy_from_slice(&0x1000u16.to_le_bytes());
|
||||
buf[2..4].copy_from_slice(&FIXED_TIME.year.to_le_bytes());
|
||||
buf[4] = FIXED_TIME.month;
|
||||
buf[5] = FIXED_TIME.day;
|
||||
}
|
||||
|
||||
/// ECMA-167 3/7.1 extent_ad: length in BYTES, then location.
|
||||
fn put_extent_ad(buf: &mut [u8], len_bytes: u32, lba: u32) {
|
||||
buf[0..4].copy_from_slice(&len_bytes.to_le_bytes());
|
||||
buf[4..8].copy_from_slice(&lba.to_le_bytes());
|
||||
}
|
||||
|
||||
/// ECMA-167 4/14.14.2 long_ad: length+type, then lb_addr (block, partition
|
||||
/// reference), then 6 implementation-use bytes.
|
||||
fn put_long_ad(buf: &mut [u8], len_bytes: u32, lba: u32) {
|
||||
buf[0..4].copy_from_slice(&len_bytes.to_le_bytes());
|
||||
buf[4..8].copy_from_slice(&lba.to_le_bytes());
|
||||
buf[8..10].copy_from_slice(&0u16.to_le_bytes()); // partition reference 0
|
||||
}
|
||||
|
||||
/// ECMA-167 4/14.14.1 short_ad. The top two bits of the length word are the
|
||||
/// extent TYPE (0 = recorded and allocated), which is exactly why `udf.rs`
|
||||
/// masks with `0x3FFF_FFFF` when it reads one back — the mask is the field
|
||||
/// boundary, not a truncation bug.
|
||||
fn put_short_ad(buf: &mut [u8], len_bytes: u32, lba: u32) {
|
||||
debug_assert!(len_bytes <= 0x3FFF_FFFF, "AD length must fit 30 bits");
|
||||
buf[0..4].copy_from_slice(&len_bytes.to_le_bytes());
|
||||
buf[4..8].copy_from_slice(&lba.to_le_bytes());
|
||||
}
|
||||
|
||||
fn blank() -> Box<[u8; SECTOR]> {
|
||||
Box::new([0u8; SECTOR])
|
||||
}
|
||||
|
||||
// ── Volume-space descriptors ────────────────────────────────────────────────
|
||||
|
||||
/// ECMA-167 2/9.1 Volume Structure Descriptor: the three-sector recognition
|
||||
/// sequence an OS looks for before it will even consider the volume UDF.
|
||||
fn volume_recognition(id: &[u8; 5]) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
s[0] = 0; // structure type
|
||||
s[1..6].copy_from_slice(id);
|
||||
s[6] = 1; // structure version
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 3/10.1 Primary Volume Descriptor.
|
||||
fn primary_volume(volume_id: &str, lba: u32, seq: u32) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
s[16..20].copy_from_slice(&seq.to_le_bytes());
|
||||
s[20..24].copy_from_slice(&0u32.to_le_bytes()); // PVD number
|
||||
put_dstring(&mut s[24..56], volume_id);
|
||||
s[56..58].copy_from_slice(&1u16.to_le_bytes()); // volume sequence number
|
||||
s[58..60].copy_from_slice(&1u16.to_le_bytes()); // max volume sequence number
|
||||
s[60..62].copy_from_slice(&2u16.to_le_bytes()); // interchange level
|
||||
s[62..64].copy_from_slice(&2u16.to_le_bytes()); // max interchange level
|
||||
s[64..68].copy_from_slice(&1u32.to_le_bytes()); // character set list
|
||||
s[68..72].copy_from_slice(&1u32.to_le_bytes()); // max character set list
|
||||
// UDF 2.2.2.5: the first 8 characters of the volume set identifier must be
|
||||
// unique. A fixed hex prefix plus the volume id is sufficient here — the
|
||||
// image is single-volume and never joins a real volume set.
|
||||
put_dstring(&mut s[72..200], &format!("46524D4B{volume_id}"));
|
||||
put_charspec(&mut s[200..264]); // descriptor character set
|
||||
put_charspec(&mut s[264..328]); // explanatory character set
|
||||
put_timestamp(&mut s[376..388]);
|
||||
put_impl_id(&mut s[388..420]);
|
||||
finish_tag(&mut s[..], 1, lba, 512);
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 3/10.4 + UDF 2.2.7 Implementation Use Volume Descriptor
|
||||
/// (`*UDF LV Info`). Not read by `udf.rs`, required by the spec.
|
||||
fn impl_use_volume(volume_id: &str, lba: u32, seq: u32) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
s[16..20].copy_from_slice(&seq.to_le_bytes());
|
||||
put_entity_id(&mut s[20..52], b"*UDF LV Info", &domain_suffix());
|
||||
put_charspec(&mut s[52..116]); // LVI charset
|
||||
put_dstring(&mut s[116..244], volume_id); // logical volume identifier
|
||||
put_impl_id(&mut s[352..384]);
|
||||
finish_tag(&mut s[..], 4, lba, 512);
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 3/10.5 Partition Descriptor — the descriptor `read_filesystem`
|
||||
/// takes `partition_start` from (offset 188).
|
||||
fn partition(part_start: u32, part_sectors: u32, lba: u32, seq: u32) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
s[16..20].copy_from_slice(&seq.to_le_bytes());
|
||||
s[20..22].copy_from_slice(&1u16.to_le_bytes()); // partition flags: allocated
|
||||
s[22..24].copy_from_slice(&0u16.to_le_bytes()); // partition number
|
||||
put_entity_id(&mut s[24..56], b"+NSR02", &[]);
|
||||
// s[56..184] partition contents use = Partition Header Descriptor. All
|
||||
// zero: a read-only partition records no unallocated/freed space tables.
|
||||
s[184..188].copy_from_slice(&1u32.to_le_bytes()); // access type: read only
|
||||
s[188..192].copy_from_slice(&part_start.to_le_bytes());
|
||||
s[192..196].copy_from_slice(&part_sectors.to_le_bytes());
|
||||
put_impl_id(&mut s[196..228]);
|
||||
finish_tag(&mut s[..], 5, lba, 512);
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 3/10.6 Logical Volume Descriptor. Carries the FSD long_ad and the
|
||||
/// partition map table; `read_filesystem` reads `num_partition_maps` at 268
|
||||
/// and takes the single-partition path when it is 1.
|
||||
fn logical_volume(
|
||||
volume_id: &str,
|
||||
fsd_lba: u32,
|
||||
integrity_lba: u32,
|
||||
integrity_sectors: u32,
|
||||
lba: u32,
|
||||
seq: u32,
|
||||
) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
s[16..20].copy_from_slice(&seq.to_le_bytes());
|
||||
put_charspec(&mut s[20..84]);
|
||||
put_dstring(&mut s[84..212], volume_id);
|
||||
s[212..216].copy_from_slice(&(SECTOR as u32).to_le_bytes()); // logical block size
|
||||
put_domain_id(&mut s[216..248]);
|
||||
// Logical volume contents use = long_ad of the File Set Descriptor,
|
||||
// partition-relative. One sector.
|
||||
put_long_ad(&mut s[248..264], SECTOR as u32, fsd_lba);
|
||||
s[264..268].copy_from_slice(&6u32.to_le_bytes()); // map table length
|
||||
s[268..272].copy_from_slice(&1u32.to_le_bytes()); // number of partition maps
|
||||
put_impl_id(&mut s[272..304]);
|
||||
put_extent_ad(
|
||||
&mut s[432..440],
|
||||
integrity_sectors * SECTOR as u32,
|
||||
integrity_lba,
|
||||
);
|
||||
// ECMA-167 3/10.7.2 Type 1 partition map.
|
||||
s[440] = 1; // map type
|
||||
s[441] = 6; // map length
|
||||
s[442..444].copy_from_slice(&1u16.to_le_bytes()); // volume sequence number
|
||||
s[444..446].copy_from_slice(&0u16.to_le_bytes()); // partition number
|
||||
finish_tag(&mut s[..], 6, lba, 446);
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 3/10.8 Unallocated Space Descriptor with zero extents — the whole
|
||||
/// volume is accounted for by the partition.
|
||||
fn unallocated_space(lba: u32, seq: u32) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
s[16..20].copy_from_slice(&seq.to_le_bytes());
|
||||
s[20..24].copy_from_slice(&0u32.to_le_bytes());
|
||||
finish_tag(&mut s[..], 7, lba, 24);
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 3/10.9 Terminating Descriptor.
|
||||
fn terminating(lba: u32) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
finish_tag(&mut s[..], 8, lba, 512);
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 3/10.10 + UDF 2.2.6 Logical Volume Integrity Descriptor, closed.
|
||||
fn integrity(
|
||||
part_sectors: u32,
|
||||
files: u32,
|
||||
dirs: u32,
|
||||
next_uid: u64,
|
||||
lba: u32,
|
||||
) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
put_timestamp(&mut s[16..28]);
|
||||
s[28..32].copy_from_slice(&1u32.to_le_bytes()); // integrity type: close
|
||||
// s[32..40] next integrity extent: none.
|
||||
s[40..48].copy_from_slice(&next_uid.to_le_bytes()); // logical volume contents use: next unique id
|
||||
s[72..76].copy_from_slice(&1u32.to_le_bytes()); // number of partitions
|
||||
s[76..80].copy_from_slice(&46u32.to_le_bytes()); // length of implementation use
|
||||
s[80..84].copy_from_slice(&0u32.to_le_bytes()); // free space: none (read-only)
|
||||
s[84..88].copy_from_slice(&part_sectors.to_le_bytes()); // size table
|
||||
put_impl_id(&mut s[88..120]);
|
||||
s[120..124].copy_from_slice(&files.to_le_bytes());
|
||||
s[124..128].copy_from_slice(&dirs.to_le_bytes());
|
||||
s[128..130].copy_from_slice(&UDF_REVISION.to_le_bytes()); // min read revision
|
||||
s[130..132].copy_from_slice(&UDF_REVISION.to_le_bytes()); // min write revision
|
||||
s[132..134].copy_from_slice(&UDF_REVISION.to_le_bytes()); // max write revision
|
||||
finish_tag(&mut s[..], 9, lba, 134);
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 3/10.2 Anchor Volume Descriptor Pointer. `read_filesystem` reads
|
||||
/// the main VDS extent from offsets 16..24 and sweeps it.
|
||||
fn anchor(main_lba: u32, reserve_lba: u32, vds_sectors: u32, lba: u32) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
put_extent_ad(&mut s[16..24], vds_sectors * SECTOR as u32, main_lba);
|
||||
put_extent_ad(&mut s[24..32], vds_sectors * SECTOR as u32, reserve_lba);
|
||||
finish_tag(&mut s[..], 2, lba, 512);
|
||||
s
|
||||
}
|
||||
|
||||
/// ECMA-167 4/14.1 File Set Descriptor. `read_filesystem` requires tag 256 at
|
||||
/// the first block of the (metadata =) partition and reads the root ICB block
|
||||
/// from offset 404.
|
||||
fn file_set(volume_id: &str, root_icb: u32, lba: u32) -> Box<[u8; SECTOR]> {
|
||||
let mut s = blank();
|
||||
put_timestamp(&mut s[16..28]);
|
||||
s[28..30].copy_from_slice(&3u16.to_le_bytes()); // interchange level
|
||||
s[30..32].copy_from_slice(&3u16.to_le_bytes()); // max interchange level
|
||||
s[32..36].copy_from_slice(&1u32.to_le_bytes()); // character set list
|
||||
s[36..40].copy_from_slice(&1u32.to_le_bytes()); // max character set list
|
||||
s[40..44].copy_from_slice(&0u32.to_le_bytes()); // file set number
|
||||
s[44..48].copy_from_slice(&0u32.to_le_bytes()); // file set descriptor number
|
||||
put_charspec(&mut s[48..112]);
|
||||
put_dstring(&mut s[112..240], volume_id);
|
||||
put_charspec(&mut s[240..304]);
|
||||
put_dstring(&mut s[304..336], volume_id);
|
||||
put_long_ad(&mut s[400..416], SECTOR as u32, root_icb);
|
||||
put_domain_id(&mut s[416..448]);
|
||||
finish_tag(&mut s[..], 256, lba, 512);
|
||||
s
|
||||
}
|
||||
|
||||
// ── Partition-space descriptors ─────────────────────────────────────────────
|
||||
|
||||
/// UDF permission word: read + execute for owner, group and other. No write
|
||||
/// bit anywhere — the volume is read-only.
|
||||
const PERM_R_X: u32 = 0x0000_1000 | 0x0000_0400 | 0x0000_0080 | 0x0000_0020 | 0x4 | 0x1;
|
||||
|
||||
/// ECMA-167 4/14.9 File Entry (tag 261).
|
||||
///
|
||||
/// Tag 261 rather than the Extended File Entry (266) real BD-ROMs use: an EFE
|
||||
/// requires UDF 2.00+, and this image declares 1.02. `udf.rs` reads both — the
|
||||
/// 261 field offsets it uses (l_ea 168, l_ad 172, ADs at 176 + l_ea) are the
|
||||
/// ones written here.
|
||||
///
|
||||
/// `extents` are partition-relative (block, byte-length) pairs, already split
|
||||
/// so no single one exceeds the 30-bit AD length field.
|
||||
fn file_entry(
|
||||
is_dir: bool,
|
||||
info_len: u64,
|
||||
extents: &[(u32, u32)],
|
||||
link_count: u16,
|
||||
unique_id: u64,
|
||||
lba: u32,
|
||||
) -> Result<Box<[u8; SECTOR]>> {
|
||||
let mut s = blank();
|
||||
// ICB tag (ECMA-167 4/14.6) at offset 16.
|
||||
s[16..20].copy_from_slice(&0u32.to_le_bytes()); // prior recorded direct entries
|
||||
s[20..22].copy_from_slice(&4u16.to_le_bytes()); // strategy type 4
|
||||
s[24..26].copy_from_slice(&1u16.to_le_bytes()); // max number of entries
|
||||
s[27] = if is_dir { 4 } else { 5 }; // file type: directory / byte sequence
|
||||
// s[28..34] parent ICB location: not recorded (permitted).
|
||||
// s[34..36] ICB flags: 0 => short allocation descriptors. `udf.rs:601`
|
||||
// reads exactly this word to pick its AD stride.
|
||||
s[34..36].copy_from_slice(&0u16.to_le_bytes());
|
||||
// UDF's sentinel for "not specified" is 0xFFFFFFFF, not 0 — 0 is a real
|
||||
// uid/gid (root). A synthesized image has no meaningful owner, and a driver
|
||||
// that maps these through would otherwise report every file as root-owned.
|
||||
s[36..40].copy_from_slice(&u32::MAX.to_le_bytes()); // uid: not specified
|
||||
s[40..44].copy_from_slice(&u32::MAX.to_le_bytes()); // gid: not specified
|
||||
s[44..48].copy_from_slice(&PERM_R_X.to_le_bytes());
|
||||
s[48..50].copy_from_slice(&link_count.to_le_bytes());
|
||||
s[56..64].copy_from_slice(&info_len.to_le_bytes());
|
||||
let blocks: u64 = extents
|
||||
.iter()
|
||||
.map(|(_, len)| (*len as u64).div_ceil(SECTOR as u64))
|
||||
.sum();
|
||||
s[64..72].copy_from_slice(&blocks.to_le_bytes()); // logical blocks recorded
|
||||
put_timestamp(&mut s[72..84]); // access
|
||||
put_timestamp(&mut s[84..96]); // modification
|
||||
put_timestamp(&mut s[96..108]); // attribute
|
||||
s[108..112].copy_from_slice(&1u32.to_le_bytes()); // checkpoint
|
||||
put_impl_id(&mut s[128..160]);
|
||||
s[160..168].copy_from_slice(&unique_id.to_le_bytes());
|
||||
s[168..172].copy_from_slice(&0u32.to_le_bytes()); // length of EAs
|
||||
let l_ad = extents.len() * 8;
|
||||
// A short AD is 8 bytes and the entry has 2048 - 176 = 1872 bytes for
|
||||
// them, i.e. 234 extents — over 200 GiB at the per-AD ceiling. Beyond
|
||||
// that an Allocation Extent Descriptor chain would be required; refuse
|
||||
// rather than write a truncated list.
|
||||
if 176 + l_ad > SECTOR {
|
||||
return Err(Error::DirImageTooLarge);
|
||||
}
|
||||
s[172..176].copy_from_slice(&(l_ad as u32).to_le_bytes());
|
||||
for (i, (elba, len)) in extents.iter().enumerate() {
|
||||
let off = 176 + i * 8;
|
||||
put_short_ad(&mut s[off..off + 8], *len, *elba);
|
||||
}
|
||||
finish_tag(&mut s[..], 261, lba, 176 + l_ad);
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// ECMA-167 4/14.4 File Identifier Descriptor, appended to `buf`.
|
||||
///
|
||||
/// FIDs are packed with no inter-descriptor padding beyond the 4-byte
|
||||
/// alignment the spec mandates, and they are allowed to span logical blocks —
|
||||
/// which is also what `read_directory` (`udf.rs:1312`) assumes: it walks the
|
||||
/// directory extent as one flat byte run and STOPS at the first non-257 tag,
|
||||
/// so any block-alignment gap would truncate the directory.
|
||||
fn push_fid(buf: &mut Vec<u8>, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) {
|
||||
let start = buf.len();
|
||||
let name_field: Vec<u8> = if is_parent {
|
||||
Vec::new()
|
||||
} else {
|
||||
encode_cs0(name)
|
||||
};
|
||||
let l_fi = name_field.len();
|
||||
let mut fid = vec![0u8; 38];
|
||||
fid[16..18].copy_from_slice(&1u16.to_le_bytes()); // file version number
|
||||
let mut chars = 0u8;
|
||||
if is_dir {
|
||||
chars |= 0x02;
|
||||
}
|
||||
if is_parent {
|
||||
chars |= 0x08;
|
||||
}
|
||||
fid[18] = chars;
|
||||
// The planner refuses any name whose encoding exceeds what this byte can
|
||||
// hold (`layout::MAX_CS0_NAME_BYTES`), so this cannot wrap in practice. The
|
||||
// assert states the invariant where it is relied on rather than trusting a
|
||||
// check three files away; a wrap here would desynchronise the directory.
|
||||
debug_assert!(
|
||||
l_fi <= u8::MAX as usize,
|
||||
"FID name length must fit one byte"
|
||||
);
|
||||
fid[19] = l_fi as u8;
|
||||
put_long_ad(&mut fid[20..36], SECTOR as u32, icb_lba);
|
||||
fid[36..38].copy_from_slice(&0u16.to_le_bytes()); // length of implementation use
|
||||
buf.extend_from_slice(&fid);
|
||||
buf.extend_from_slice(&name_field);
|
||||
let unpadded = buf.len() - start;
|
||||
let padded = unpadded.div_ceil(4) * 4;
|
||||
buf.resize(start + padded, 0);
|
||||
// The tag is written last: its CRC covers the descriptor body, which the
|
||||
// padding is not part of (ECMA-167 4/14.4.9 counts padding outside the
|
||||
// CRC'd length).
|
||||
let tag_loc_placeholder = 0;
|
||||
finish_tag(
|
||||
&mut buf[start..start + unpadded],
|
||||
257,
|
||||
tag_loc_placeholder,
|
||||
unpadded,
|
||||
);
|
||||
}
|
||||
|
||||
/// Serialize one directory's FID list (parent entry first, then children).
|
||||
pub(super) fn dir_fids(dir: &DirNode) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
push_fid(&mut buf, "", dir.parent_icb_lba, true, true);
|
||||
for sub in &dir.dirs {
|
||||
push_fid(&mut buf, &sub.name, sub.icb_lba, true, false);
|
||||
}
|
||||
for f in &dir.files {
|
||||
push_fid(&mut buf, &f.name, f.icb_lba, false, false);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Patch every FID's tag location to the block it actually lands in. ECMA-167
|
||||
/// 3/7.2.2 makes the tag location the block of the descriptor, and a FID that
|
||||
/// spans two blocks records the block it STARTS in.
|
||||
fn fix_fid_tag_locations(buf: &mut [u8], first_block: u32) {
|
||||
let mut pos = 0usize;
|
||||
while pos + 38 <= buf.len() {
|
||||
let l_fi = buf[pos + 19] as usize;
|
||||
let l_iu = u16::from_le_bytes([buf[pos + 36], buf[pos + 37]]) as usize;
|
||||
let unpadded = 38 + l_iu + l_fi;
|
||||
if pos + unpadded > buf.len() {
|
||||
break;
|
||||
}
|
||||
let block = first_block + (pos / SECTOR) as u32;
|
||||
finish_tag(&mut buf[pos..pos + unpadded], 257, block, unpadded);
|
||||
pos += unpadded.div_ceil(4) * 4;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Whole-image assembly ────────────────────────────────────────────────────
|
||||
|
||||
/// Volume-space block of the Volume Recognition Sequence.
|
||||
const VRS_START: u32 = 16;
|
||||
/// Volume-space block of the Main Volume Descriptor Sequence.
|
||||
pub(super) const MAIN_VDS_START: u32 = 32;
|
||||
/// Volume-space block of the Reserve Volume Descriptor Sequence.
|
||||
pub(super) const RESERVE_VDS_START: u32 = 48;
|
||||
/// Sectors reserved for each VDS. ECMA-167 3/10.2.1 requires an anchor to
|
||||
/// record at least 16.
|
||||
pub(super) const VDS_SECTORS: u32 = 16;
|
||||
/// Volume-space block of the Logical Volume Integrity Sequence.
|
||||
pub(super) const LVID_START: u32 = 64;
|
||||
/// Sectors reserved for the integrity sequence (LVID + TD).
|
||||
pub(super) const LVID_SECTORS: u32 = 2;
|
||||
/// The mandatory anchor block (ECMA-167 3/10.2).
|
||||
pub(super) const ANCHOR_LBA: u32 = 256;
|
||||
/// First block a partition may start at. Everything above is volume space.
|
||||
pub(super) const MIN_PART_START: u32 = 320;
|
||||
|
||||
/// Emit the six-descriptor Volume Descriptor Sequence at `start`.
|
||||
fn write_vds(out: &mut MetaSectors, layout: &Layout, start: u32) {
|
||||
let vid = &layout.volume_id;
|
||||
out.insert(start, primary_volume(vid, start, 1));
|
||||
out.insert(start + 1, impl_use_volume(vid, start + 1, 2));
|
||||
out.insert(
|
||||
start + 2,
|
||||
partition(layout.part_start, layout.part_sectors, start + 2, 3),
|
||||
);
|
||||
out.insert(
|
||||
start + 3,
|
||||
logical_volume(vid, 0, LVID_START, LVID_SECTORS, start + 3, 4),
|
||||
);
|
||||
out.insert(start + 4, unallocated_space(start + 4, 5));
|
||||
out.insert(start + 5, terminating(start + 5));
|
||||
}
|
||||
|
||||
/// Recursively emit one directory's File Entry and FID list, then its
|
||||
/// children's.
|
||||
fn write_dir(out: &mut MetaSectors, layout: &Layout, dir: &DirNode) -> Result<()> {
|
||||
let mut fids = dir_fids(dir);
|
||||
fix_fid_tag_locations(&mut fids, dir.data_lba);
|
||||
debug_assert_eq!(fids.len(), dir.data_bytes as usize);
|
||||
|
||||
// A directory's link count is 1 (its own FID in the parent) plus one for
|
||||
// each child directory's parent FID pointing back at it.
|
||||
// The planner caps subdirectory fan-out (`layout::MAX_SUBDIRS`) so this
|
||||
// cannot overflow; saturating rather than wrapping keeps a future change to
|
||||
// that cap from silently producing a wrong count.
|
||||
let link_count = (dir.dirs.len() as u16).saturating_add(1);
|
||||
let fe = file_entry(
|
||||
true,
|
||||
fids.len() as u64,
|
||||
&[(dir.data_lba, fids.len() as u32)],
|
||||
link_count,
|
||||
dir.unique_id,
|
||||
dir.icb_lba,
|
||||
)?;
|
||||
out.insert(layout.part_start + dir.icb_lba, fe);
|
||||
|
||||
for (i, chunk) in fids.chunks(SECTOR).enumerate() {
|
||||
let mut s = blank();
|
||||
s[..chunk.len()].copy_from_slice(chunk);
|
||||
out.insert(layout.part_start + dir.data_lba + i as u32, s);
|
||||
}
|
||||
|
||||
for f in &dir.files {
|
||||
let extents: Vec<(u32, u32)> = f.extents.iter().map(|e| (e.lba, e.bytes)).collect();
|
||||
let fe = file_entry(false, f.size, &extents, 1, f.unique_id, f.icb_lba)?;
|
||||
out.insert(layout.part_start + f.icb_lba, fe);
|
||||
}
|
||||
|
||||
for sub in &dir.dirs {
|
||||
write_dir(out, layout, sub)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build every metadata sector of the synthesized volume.
|
||||
pub(super) fn encode(layout: &Layout) -> Result<MetaSectors> {
|
||||
let mut out = MetaSectors::new();
|
||||
|
||||
out.insert(VRS_START, volume_recognition(b"BEA01"));
|
||||
out.insert(VRS_START + 1, volume_recognition(b"NSR02"));
|
||||
out.insert(VRS_START + 2, volume_recognition(b"TEA01"));
|
||||
|
||||
write_vds(&mut out, layout, MAIN_VDS_START);
|
||||
write_vds(&mut out, layout, RESERVE_VDS_START);
|
||||
|
||||
out.insert(
|
||||
LVID_START,
|
||||
integrity(
|
||||
layout.part_sectors,
|
||||
layout.file_count,
|
||||
layout.dir_count,
|
||||
layout.next_unique_id,
|
||||
LVID_START,
|
||||
),
|
||||
);
|
||||
out.insert(LVID_START + 1, terminating(LVID_START + 1));
|
||||
|
||||
let avdp = anchor(MAIN_VDS_START, RESERVE_VDS_START, VDS_SECTORS, ANCHOR_LBA);
|
||||
out.insert(ANCHOR_LBA, avdp);
|
||||
let last = layout.total_sectors - 1;
|
||||
out.insert(
|
||||
last,
|
||||
anchor(MAIN_VDS_START, RESERVE_VDS_START, VDS_SECTORS, last),
|
||||
);
|
||||
|
||||
// Partition block 0 must hold the File Set Descriptor: `read_filesystem`
|
||||
// reads exactly `metadata_start` (== partition start on a single-partition
|
||||
// volume) and rejects the volume outright if the tag there is not 256.
|
||||
out.insert(
|
||||
layout.part_start,
|
||||
file_set(&layout.volume_id, layout.root.icb_lba, 0),
|
||||
);
|
||||
out.insert(layout.part_start + 1, terminating(1));
|
||||
|
||||
write_dir(&mut out, layout, &layout.root)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The reference check value for CRC-16/XMODEM — poly 0x1021 seeded at 0,
|
||||
/// which is what ECMA-167 7.2.4 specifies: "123456789" → 0x31C3. Seeding
|
||||
/// at 0xFFFF instead (CCITT-FALSE) yields 0x29B1, and that mutant is
|
||||
/// invisible to `udf.rs`, which never verifies a tag CRC — it would only
|
||||
/// show up as a volume no operating system will mount.
|
||||
#[test]
|
||||
fn crc16_matches_the_ecma167_check_value() {
|
||||
assert_eq!(crc16(b"123456789"), 0x31C3);
|
||||
assert_ne!(crc16(b"123456789"), 0x29B1, "not the 0xFFFF-seeded variant");
|
||||
}
|
||||
|
||||
/// ECMA-167 3/7.2.3: the checksum is the sum of the tag's first 16 bytes
|
||||
/// EXCLUDING the checksum byte itself, modulo 256.
|
||||
#[test]
|
||||
fn tag_checksum_excludes_its_own_byte() {
|
||||
let mut buf = [0u8; 512];
|
||||
buf[16..24].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
finish_tag(&mut buf, 261, 0x1234, 512);
|
||||
let sum: u32 = buf[0..16]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != 4)
|
||||
.map(|(_, b)| *b as u32)
|
||||
.sum();
|
||||
assert_eq!(buf[4] as u32, sum % 256);
|
||||
// And the recorded CRC covers the body, not the tag.
|
||||
let crc = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
assert_eq!(crc, crc16(&buf[16..512]));
|
||||
assert_eq!(u16::from_le_bytes([buf[10], buf[11]]), 496);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]),
|
||||
0x1234
|
||||
);
|
||||
}
|
||||
|
||||
/// ASCII takes compression ID 8; anything above takes 16 (UTF-16BE),
|
||||
/// because `parse_udf_name` decodes compression-8 bytes as UTF-8.
|
||||
#[test]
|
||||
fn cs0_picks_the_encoding_the_parser_can_decode() {
|
||||
assert_eq!(encode_cs0("AB"), vec![8, b'A', b'B']);
|
||||
let e = encode_cs0("Ä");
|
||||
assert_eq!(e[0], 16);
|
||||
assert_eq!(&e[1..], &[0x00, 0xC4]);
|
||||
assert_eq!(crate::udf::parse_udf_name(&e), "Ä");
|
||||
}
|
||||
|
||||
/// A d-string records its used length in the field's LAST byte, and the
|
||||
/// production parser must read the same string back.
|
||||
#[test]
|
||||
fn dstring_round_trips_through_the_production_parser() {
|
||||
let mut field = [0u8; 32];
|
||||
put_dstring(&mut field, "FREEMKV");
|
||||
assert_eq!(field[31], 8, "compid byte + 7 characters");
|
||||
assert_eq!(crate::udf::parse_dstring_for_test(&field), "FREEMKV");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
||||
//! `dir://` as an image-level SOURCE: a synthetic UDF volume over a folder.
|
||||
//!
|
||||
//! A user's extracted disc — a DVD `VIDEO_TS/` or a Blu-ray `BDMV/`, typically
|
||||
//! a MakeMKV-style backup — has files but no sectors, and everything above the
|
||||
//! sector layer in this crate wants sectors: `Disc::scan_image`, `UdfFs`,
|
||||
//! `ifo.rs`, `mpls.rs`, `clpi.rs` and the mux all read through a
|
||||
//! [`SectorSource`]. [`DirImage`] supplies one.
|
||||
//!
|
||||
//! The trick is that nothing is emulated. A real, minimal, valid UDF 1.02
|
||||
//! volume is synthesized over the folder:
|
||||
//!
|
||||
//! * **Metadata sectors** (anchors, the volume descriptor sequences, the File
|
||||
//! Set Descriptor, every File Entry, every directory's FID list) are encoded
|
||||
//! into RAM by [`encode`] — a few MiB even for a large Blu-ray.
|
||||
//! * **Data sectors** are not materialized at all. Each one maps to a byte
|
||||
//! range of a real file, read on demand.
|
||||
//!
|
||||
//! So `udf::read_filesystem` parses this image by exactly the same code path it
|
||||
//! parses a real disc with, and every consumer above it is unchanged. The cost
|
||||
//! is that a single-partition synthetic volume never exercises the UDF 2.50
|
||||
//! Metadata Partition path (`udf.rs:946-991`) that every real BD-ROM uses —
|
||||
//! this module's tests do not cover that block and must not be read as if they
|
||||
//! did.
|
||||
//!
|
||||
//! What this module deliberately does NOT do:
|
||||
//!
|
||||
//! * **3D / SSIF** — rejected up front ([`Error::DirImageSsifUnsupported`]).
|
||||
//! An SSIF aliases the same sectors as its base and dependent `.m2ts`; the
|
||||
//! planner allocates disjoint extents, so a 3D folder would produce silently
|
||||
//! wrong output.
|
||||
//! * **HD-DVD `HVDVD_TS/`** — no title enumerator constraint is modelled.
|
||||
//! * **Encrypted folders** — a folder whose content is still AACS-scrambled is
|
||||
//! rejected by the caller-side probe, not decrypted here.
|
||||
|
||||
mod encode;
|
||||
mod layout;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::io::file_sector_source::linux::drop_window;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::io::file_sector_source::macos::drop_window;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
use crate::io::file_sector_source::other::drop_window;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::io::file_sector_source::windows::drop_window;
|
||||
use crate::sector::SectorSource;
|
||||
use encode::{MetaSectors, SECTOR};
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// How many host files may be held open at once.
|
||||
///
|
||||
/// A Blu-ray `BDMV/` can exceed a thousand files while macOS `RLIMIT_NOFILE`
|
||||
/// defaults to 256, so "open every file up front" is not available. Reads are
|
||||
/// overwhelmingly sequential through one large stream file at a time, so a
|
||||
/// small LRU keeps the hit rate near 1 while bounding descriptors.
|
||||
const HANDLE_CACHE: usize = 16;
|
||||
|
||||
/// One file's bytes at one place in the image.
|
||||
#[derive(Debug, Clone)]
|
||||
struct DataRange {
|
||||
/// Absolute first block.
|
||||
start_lba: u32,
|
||||
/// Blocks covered (the last one may be partially used, and is zero-padded).
|
||||
sectors: u32,
|
||||
/// Index into [`DirImage::files`].
|
||||
file: usize,
|
||||
/// Byte offset within the file at which this range's bytes begin.
|
||||
offset: u64,
|
||||
/// Byte length of the range.
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
/// A file the image reads through.
|
||||
#[derive(Debug)]
|
||||
struct FileRef {
|
||||
host: PathBuf,
|
||||
disc_path: String,
|
||||
size: u64,
|
||||
/// Host mtime at plan time — see `layout::FileNode::mtime` for why size
|
||||
/// alone is not enough.
|
||||
mtime: Option<std::time::SystemTime>,
|
||||
}
|
||||
|
||||
/// A synthesized UDF disc image over a host directory.
|
||||
///
|
||||
/// Owns everything it reads through (`PathBuf`s and its own file handles), so
|
||||
/// it is `Send + 'static` and can be moved into `build_iso_pipeline`, which
|
||||
/// hands it to `PrefetchedSectorSource`'s producer thread.
|
||||
pub struct DirImage {
|
||||
meta: MetaSectors,
|
||||
/// Sorted by `start_lba`, non-overlapping.
|
||||
ranges: Vec<DataRange>,
|
||||
files: Vec<FileRef>,
|
||||
open: Vec<(usize, File)>,
|
||||
total_sectors: u32,
|
||||
volume_id: String,
|
||||
data_bytes: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DirImage {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DirImage")
|
||||
.field("volume_id", &self.volume_id)
|
||||
.field("total_sectors", &self.total_sectors)
|
||||
.field("files", &self.files.len())
|
||||
.field("meta_sectors", &self.meta.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl DirImage {
|
||||
/// Plan and encode an image over `root`.
|
||||
///
|
||||
/// Every error is decided here, at plan time, where it can name the file
|
||||
/// responsible — the read path is deliberately left with nothing to decide
|
||||
/// except "this file changed underneath me".
|
||||
pub fn open(root: &Path) -> Result<Self> {
|
||||
let plan = layout::plan(root)?;
|
||||
let meta = encode::encode(&plan)?;
|
||||
|
||||
let mut nodes = Vec::new();
|
||||
layout::flatten(&plan.root, &mut nodes);
|
||||
|
||||
let mut files = Vec::with_capacity(nodes.len());
|
||||
let mut ranges = Vec::new();
|
||||
for (idx, node) in nodes.iter().enumerate() {
|
||||
// Carry the plan-time mtime ONLY for files whose CONTENT the plan
|
||||
// read — the DVD IFOs, whose bytes 0xC0/0xC4 decide where every VOB
|
||||
// is placed (`layout::place_video_ts` -> `read_head`).
|
||||
//
|
||||
// For every other file the plan depends on the SIZE alone, and size
|
||||
// is already checked. Comparing mtime on those buys nothing and
|
||||
// costs real false positives: disc backups commonly live on
|
||||
// exFAT/FAT32, which stores local time, so a long rip spanning a
|
||||
// DST transition sees a whole-hour shift on a file nobody touched
|
||||
// and would abort hours in, blaming a change that did not happen.
|
||||
// The multi-gigabyte VOBs are exactly the files a long rip re-opens
|
||||
// after the handle cache evicts them.
|
||||
let content_sensitive = node
|
||||
.disc_path
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("IFO"));
|
||||
files.push(FileRef {
|
||||
host: node.host.clone(),
|
||||
disc_path: node.disc_path.clone(),
|
||||
size: node.size,
|
||||
mtime: content_sensitive.then_some(node.mtime).flatten(),
|
||||
});
|
||||
let mut offset = 0u64;
|
||||
for e in &node.extents {
|
||||
ranges.push(DataRange {
|
||||
start_lba: plan.part_start + e.lba,
|
||||
sectors: (e.bytes as u64).div_ceil(SECTOR as u64) as u32,
|
||||
file: idx,
|
||||
offset,
|
||||
bytes: e.bytes as u64,
|
||||
});
|
||||
offset += e.bytes as u64;
|
||||
}
|
||||
}
|
||||
ranges.sort_by_key(|r| r.start_lba);
|
||||
debug_assert!(
|
||||
ranges
|
||||
.windows(2)
|
||||
.all(|w| w[0].start_lba + w[0].sectors <= w[1].start_lba),
|
||||
"planned data ranges must not overlap"
|
||||
);
|
||||
|
||||
let data_bytes = layout::total_data_bytes(&plan.root);
|
||||
tracing::info!(
|
||||
target: "freemkv::dirimage",
|
||||
volume_id = %plan.volume_id,
|
||||
files = files.len(),
|
||||
dirs = plan.dir_count,
|
||||
meta_blocks = layout::metadata_block_count(&plan.root),
|
||||
total_sectors = plan.total_sectors,
|
||||
"synthesized UDF image over directory"
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
meta,
|
||||
ranges,
|
||||
files,
|
||||
open: Vec::new(),
|
||||
total_sectors: plan.total_sectors,
|
||||
volume_id: plan.volume_id,
|
||||
data_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// UDF volume identifier the image declares (the folder's own name).
|
||||
pub fn volume_id(&self) -> &str {
|
||||
&self.volume_id
|
||||
}
|
||||
|
||||
/// Total bytes of real file content the image carries — the folder's size,
|
||||
/// not the image's (which also counts metadata and inter-file gaps).
|
||||
pub fn data_bytes(&self) -> u64 {
|
||||
self.data_bytes
|
||||
}
|
||||
|
||||
/// The range covering `lba`, if any.
|
||||
fn range_at(&self, lba: u32) -> Option<&DataRange> {
|
||||
let i = self.ranges.partition_point(|r| r.start_lba <= lba);
|
||||
let r = self.ranges.get(i.checked_sub(1)?)?;
|
||||
(lba < r.start_lba + r.sectors).then_some(r)
|
||||
}
|
||||
|
||||
/// Borrow an open handle for `file`, opening it (and evicting the
|
||||
/// least-recently-used handle) if necessary.
|
||||
///
|
||||
/// Opening is also where the plan is revalidated. A folder is not a disc:
|
||||
/// a file can be shortened or replaced between planning and reading, and
|
||||
/// zero-filling the difference would turn "the user deleted something"
|
||||
/// into corrupt output at exit 0. The size is re-checked here, and a
|
||||
/// truncation that happens while the handle is already open is caught by
|
||||
/// the short read in [`Self::fill`].
|
||||
fn handle(&mut self, file: usize) -> Result<&mut File> {
|
||||
if let Some(pos) = self.open.iter().position(|(i, _)| *i == file) {
|
||||
// `open` is ordered most-recently-used first.
|
||||
let entry = self.open.remove(pos);
|
||||
self.open.insert(0, entry);
|
||||
return Ok(&mut self.open[0].1);
|
||||
}
|
||||
let f = File::open(&self.files[file].host).map_err(Error::from)?;
|
||||
let md = f.metadata().map_err(Error::from)?;
|
||||
// Size AND mtime. Size alone is content-blind, and this plan depends on
|
||||
// content: a DVD's VOB placement comes from bytes 0xC0/0xC4 of its IFO,
|
||||
// and an IFO rewritten in place keeps its length because IFOs occupy a
|
||||
// whole number of sectors. The size check would pass while every title
|
||||
// extent pointed at the wrong sectors — corrupt video behind an intact
|
||||
// structure, reported complete at exit 0.
|
||||
//
|
||||
// Only compared when both sides have a timestamp; a platform or
|
||||
// filesystem that reports none simply falls back to the size check
|
||||
// rather than failing every read.
|
||||
let changed_size = md.len() != self.files[file].size;
|
||||
let changed_mtime = match (self.files[file].mtime, md.modified().ok()) {
|
||||
(Some(planned), Some(live)) => planned != live,
|
||||
_ => false,
|
||||
};
|
||||
if changed_size || changed_mtime {
|
||||
return Err(Error::DirImageFileChanged {
|
||||
path: self.files[file].disc_path.clone(),
|
||||
});
|
||||
}
|
||||
if self.open.len() >= HANDLE_CACHE {
|
||||
self.open.pop();
|
||||
}
|
||||
self.open.insert(0, (file, f));
|
||||
Ok(&mut self.open[0].1)
|
||||
}
|
||||
|
||||
/// Fill `out` (a whole number of sectors) from one data range, starting at
|
||||
/// `lba`. `out` is already zeroed, so a file's tail sector comes back
|
||||
/// zero-padded — which is exactly what `file_extents`' `div_ceil(2048)`
|
||||
/// (`udf.rs:816`) makes every consumer expect.
|
||||
fn fill(&mut self, r: &DataRange, lba: u32, out: &mut [u8]) -> Result<()> {
|
||||
let within = (lba - r.start_lba) as u64 * SECTOR as u64;
|
||||
let want = (r.bytes.saturating_sub(within)).min(out.len() as u64) as usize;
|
||||
if want == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let at = r.offset + within;
|
||||
let file = r.file;
|
||||
let h = self.handle(file)?;
|
||||
h.seek(SeekFrom::Start(at)).map_err(Error::from)?;
|
||||
let res = h.read_exact(&mut out[..want]);
|
||||
if res.is_ok() {
|
||||
// Release the window just read, every time.
|
||||
//
|
||||
// The ISO source accumulates and drops in chunks because it reads
|
||||
// one file linearly, so a running start offset always names the
|
||||
// bytes it has consumed. Reads here jump between files, so there is
|
||||
// no single cursor to accumulate against — an accumulated byte
|
||||
// count paired with one read's offset names 1/Nth of what was
|
||||
// actually consumed and leaves the rest pinned, which is how the
|
||||
// first version of this got it wrong.
|
||||
//
|
||||
// Dropping per read costs one advisory syscall per batch (4-16 MiB),
|
||||
// which is nothing against the read itself, and it is correct
|
||||
// regardless of how reads interleave across files.
|
||||
if let Some((_, fh)) = self.open.iter().find(|(i, _)| *i == file) {
|
||||
drop_window(fh, at, want as u64);
|
||||
}
|
||||
}
|
||||
match res {
|
||||
Ok(()) => Ok(()),
|
||||
// The file shrank while the handle was open. Same verdict as the
|
||||
// size check in `handle`, reached the other way.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
Err(Error::DirImageFileChanged {
|
||||
path: self.files[file].disc_path.clone(),
|
||||
})
|
||||
}
|
||||
Err(e) => Err(Error::from(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorSource for DirImage {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.total_sectors
|
||||
}
|
||||
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
let need = count as usize * SECTOR;
|
||||
if buf.len() < need {
|
||||
return Err(Error::UdfBufferTooSmall);
|
||||
}
|
||||
buf[..need].fill(0);
|
||||
// Walk the request in RUNS, not sector by sector. A mux batch is 8192
|
||||
// sectors and almost always lands entirely inside one stream file's
|
||||
// extent; per-sector seek+read would issue 8192 syscalls for what is
|
||||
// one 16 MiB sequential read.
|
||||
let mut i = 0u32;
|
||||
while i < count as u32 {
|
||||
// Checked: callers saturate their LBAs (`disc/dvd.rs` builds a cell
|
||||
// start as `vob_start_sector.saturating_add(cell.first_sector)`, and
|
||||
// the prefetcher adds an offset the same way), so a crafted IFO can
|
||||
// present a request at the very top of the address space. Wrapping
|
||||
// here would fold `at` back to a LOW sector and hand the muxer a
|
||||
// different file's bytes with nothing reported.
|
||||
let Some(at) = lba.checked_add(i) else {
|
||||
break;
|
||||
};
|
||||
let off = i as usize * SECTOR;
|
||||
if let Some(s) = self.meta.get(&at) {
|
||||
buf[off..off + SECTOR].copy_from_slice(&s[..]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Metadata blocks all sit below the data floor, so a data range is
|
||||
// never interrupted by one.
|
||||
match self.range_at(at).cloned() {
|
||||
Some(r) => {
|
||||
let run = (r.start_lba + r.sectors - at).min(count as u32 - i);
|
||||
let end = off + run as usize * SECTOR;
|
||||
self.fill(&r, at, &mut buf[off..end])?;
|
||||
i += run;
|
||||
}
|
||||
// A gap between planned extents. Reads as zeros, exactly as an
|
||||
// unrecorded sector of a real image does.
|
||||
None => i += 1,
|
||||
}
|
||||
}
|
||||
Ok(need)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
+1468
-333
File diff suppressed because it is too large
Load Diff
+350
-26
@@ -7,15 +7,50 @@ use crate::udf;
|
||||
|
||||
impl Disc {
|
||||
/// Scan DVD titles from IFO files (VIDEO_TS.IFO + VTS_XX_0.IFO).
|
||||
///
|
||||
/// Cancellation: `halt` is polled before the IFO tree is read, and an IFO
|
||||
/// read that fails with [`Error::Halted`] — how a live drive reports a
|
||||
/// Stop, since `Drive::checked_exec` fails EVERY command once its flag is
|
||||
/// set — is propagated rather than swallowed. Every other IFO failure
|
||||
/// keeps its best-effort `Ok(vec![])`.
|
||||
///
|
||||
/// It has to be an error and not an empty title list, for the same reason
|
||||
/// spelled out on [`Disc::scan_hddvd_titles`]: a cancelled enumeration
|
||||
/// that returned `Ok` would be indistinguishable from a disc that
|
||||
/// genuinely holds fewer titles. This one was the worst of the three
|
||||
/// enumerators — a bare `Err(_) => return Vec::new()` turned an operator
|
||||
/// Stop into ZERO titles at rc=0, a disc reported as carrying no video at
|
||||
/// all.
|
||||
///
|
||||
/// Returns the scanned titles plus, when the disc's own First-Play
|
||||
/// navigation deterministically dispatches to a title, that title's
|
||||
/// `playlist_id` — the nav-resolved main feature. The caller feeds it into
|
||||
/// `sort_titles_by_main_feature` as the DVD `nav_feature`, the same seam the
|
||||
/// BD path uses. It is `None` (→ existing ranking heuristics stand) whenever
|
||||
/// the navigation is menu-only, malformed, or does not converge.
|
||||
pub(super) fn scan_dvd_titles(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
) -> Vec<DiscTitle> {
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> Result<(Vec<DiscTitle>, Option<u16>)> {
|
||||
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
let dvd_info = match ifo::parse_vmg(reader, udf_fs) {
|
||||
Ok(info) => info,
|
||||
Err(_) => return Vec::new(),
|
||||
Err(Error::Halted) => return Err(Error::Halted),
|
||||
Err(_) => return Ok((Vec::new(), None)),
|
||||
};
|
||||
|
||||
// Follow the disc's First-Play navigation the way a player would, to see
|
||||
// which title it selects as the feature (issue #40). Read-only, and any
|
||||
// parse error / interactive-menu entry / non-convergence returns `None`
|
||||
// — a strict improvement: it never removes a title, only re-ranks toward
|
||||
// the nav-chosen one. The `(vtsn, vts_ttn)` it yields is matched to the
|
||||
// scanned title below to recover its `playlist_id`.
|
||||
let nav_target = crate::dvdnav::resolve_main_title(reader, udf_fs);
|
||||
let mut nav_feature: Option<u16> = None;
|
||||
|
||||
let mut titles = Vec::new();
|
||||
let mut title_number: u16 = 0;
|
||||
|
||||
@@ -109,6 +144,17 @@ impl Disc {
|
||||
for (vts_title_idx, dvd_title) in ts.titles.iter().enumerate() {
|
||||
title_number += 1;
|
||||
|
||||
// If the nav executor picked this title (identified by its VTS
|
||||
// number and 1-based position within the set — the same
|
||||
// `vts_ttn` convention used below), remember its global
|
||||
// `playlist_id` so the caller can promote it in ranking.
|
||||
if let Some(rt) = nav_target
|
||||
&& rt.vtsn == ts.vts_number
|
||||
&& rt.vts_ttn as usize == vts_title_idx + 1
|
||||
{
|
||||
nav_feature = Some(title_number);
|
||||
}
|
||||
|
||||
// Diagnostic dump (--log-level 3): per-cell category table +
|
||||
// chapter map for this title, BEFORE lowering drops the
|
||||
// per-cell IFO detail. No-op unless freemkv::diag is enabled.
|
||||
@@ -116,7 +162,7 @@ impl Disc {
|
||||
|
||||
// Feature start cell. Prefer the DVD nav-VM resolver, which
|
||||
// PARKED (#40, menu-at-start playback). The "menu at the start"
|
||||
// symptom (e.g. SOTL) was a sector-mapping fault — the absolute
|
||||
// symptom (seen on a real disc) was a sector-mapping fault — the absolute
|
||||
// VOB rebase in `ifo::parse_vts` (`vob_start_sector =
|
||||
// file_start_lba + vtstt_vobs`) — NOT a navigation problem, so
|
||||
// feature-start resolution is unnecessary for correct rips. The
|
||||
@@ -179,7 +225,10 @@ impl Disc {
|
||||
// the coded video frame the subpicture was authored against
|
||||
// (720x480 NTSC / 720x576 PAL) so players place and scale the
|
||||
// bitmap correctly.
|
||||
let (vid_w, vid_h) = ts.video.resolution.pixels();
|
||||
// format_palette guards on (0, 0) and omits its `size:` line,
|
||||
// so an unresolved resolution degrades to a palette-only .idx
|
||||
// rather than one claiming a 0x0 frame.
|
||||
let (vid_w, vid_h) = ts.video.resolution.pixels().unwrap_or((0, 0));
|
||||
let codec_data = dvd_title
|
||||
.palette
|
||||
.as_ref()
|
||||
@@ -240,7 +289,14 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
titles
|
||||
// Polled again AFTER the loop: a cancel raised during the IFO reads
|
||||
// that `parse_vmg` performs per title set has nothing left to poll,
|
||||
// so without this a partially enumerated disc could still be handed
|
||||
// back as success.
|
||||
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
Ok((titles, nav_feature))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,6 +581,119 @@ mod tests {
|
||||
// Tests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// A `SectorSource` that fails every read at or above `halt_at` with
|
||||
/// [`Error::Halted`] — how a LIVE DRIVE behaves once the operator presses
|
||||
/// Stop: `Drive::checked_exec` fails every SCSI command with `Halted` from
|
||||
/// then on, and `Drive::read` deliberately preserves the variant. Reads
|
||||
/// below the threshold still succeed, so the scan gets far enough to have
|
||||
/// something to truncate.
|
||||
struct HaltingReader<'a> {
|
||||
inner: &'a mut MemDisc,
|
||||
halt_at: u32,
|
||||
}
|
||||
impl SectorSource for HaltingReader<'_> {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
if lba >= self.halt_at {
|
||||
return Err(crate::error::Error::Halted);
|
||||
}
|
||||
self.inner.read_sectors(lba, count, buf, recovery)
|
||||
}
|
||||
}
|
||||
|
||||
/// A Stop on a LIVE DRIVE never touches `ScanOptions::halt`: `Drive` has
|
||||
/// its own flag and `checked_exec` fails every SCSI command with
|
||||
/// [`Error::Halted`] once it is set. The DVD enumerator must not swallow
|
||||
/// that into a successful scan.
|
||||
///
|
||||
/// This was the worst of the three enumerators. RED BEFORE GREEN, two
|
||||
/// distinct swallows, both measured with the fix reverted:
|
||||
/// * `ifo::parse_vmg` treats a failed title set as a placeholder entry
|
||||
/// and continues, so a cancel landing on VTS_02's IFO returned
|
||||
/// `Ok([VTS_01_1.VOB])` — one title from a two-title disc.
|
||||
/// * `scan_dvd_titles`'s `Err(_) => return Vec::new()` turned a cancel
|
||||
/// landing on VIDEO_TS.IFO itself into ZERO titles at rc=0 — a disc
|
||||
/// reported as holding no video at all.
|
||||
///
|
||||
/// Both are indistinguishable from a real disc, and both are now
|
||||
/// `Err(Error::Halted)`.
|
||||
#[test]
|
||||
fn halted_ifo_read_is_not_reported_as_a_shorter_disc() {
|
||||
// Two title sets: VTS_01's IFO data at PART_START+6000, VTS_02's at
|
||||
// PART_START+7000. Both ICBs sit far below, so the filesystem
|
||||
// metadata resolves and only the second title set's CONTENT is
|
||||
// cancelled — the truncation case.
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(1, 1, 1), (1, 2, 1)]);
|
||||
let vts1 = build_vts(100, 0x00, &[], &[], &[(0, 9)], false);
|
||||
let vts2 = build_vts(200, 0x00, &[], &[], &[(0, 19)], false);
|
||||
let udf = build_video_ts_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
FileSpec {
|
||||
name: "VIDEO_TS.IFO".into(),
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vmg,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_01_0.IFO".into(),
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: vts1,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_02_0.IFO".into(),
|
||||
icb_lba: 64,
|
||||
data_lba: 7000,
|
||||
contents: vts2,
|
||||
},
|
||||
],
|
||||
);
|
||||
// Sanity: both title sets enumerate when nothing is cancelled, so a
|
||||
// short list below can only be the cancel.
|
||||
assert_eq!(
|
||||
Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0
|
||||
.len(),
|
||||
2,
|
||||
"fixture must offer two title sets"
|
||||
);
|
||||
|
||||
let mut reader = HaltingReader {
|
||||
inner: &mut disc,
|
||||
halt_at: PART_START + 7000, // VTS_02_0.IFO's data extent
|
||||
};
|
||||
let res = Disc::scan_dvd_titles(&mut reader, &udf, None);
|
||||
assert!(
|
||||
matches!(res, Err(crate::error::Error::Halted)),
|
||||
"a cancelled title-set read must surface as a cancelled scan, not \
|
||||
as a disc with fewer titles; got {:?}",
|
||||
res.map(|(ts, _)| ts.iter().map(|t| t.playlist.clone()).collect::<Vec<_>>())
|
||||
);
|
||||
|
||||
// The same cancel one level up: VIDEO_TS.IFO itself. This is the
|
||||
// `Err(_) => Vec::new()` path — a cancel that used to report a DVD as
|
||||
// carrying no titles whatsoever.
|
||||
let mut reader = HaltingReader {
|
||||
inner: &mut disc,
|
||||
halt_at: PART_START + 5000,
|
||||
};
|
||||
let res = Disc::scan_dvd_titles(&mut reader, &udf, None);
|
||||
assert!(
|
||||
matches!(res, Err(crate::error::Error::Halted)),
|
||||
"a cancelled VMG read must surface as a cancelled scan, not as an \
|
||||
empty disc; got {:?}",
|
||||
res.map(|(ts, _)| ts.len())
|
||||
);
|
||||
}
|
||||
|
||||
/// scan_dvd_titles returns empty when VIDEO_TS.IFO can't be parsed
|
||||
/// (dvd.rs: `parse_vmg(...) Err → return Vec::new()`). Never panics.
|
||||
#[test]
|
||||
@@ -532,7 +701,12 @@ mod tests {
|
||||
let mut disc = MemDisc::new();
|
||||
// VIDEO_TS exists but VIDEO_TS.IFO is missing.
|
||||
let udf = build_video_ts_fs(&mut disc, &[]);
|
||||
assert!(Disc::scan_dvd_titles(&mut disc, &udf).is_empty());
|
||||
assert!(
|
||||
Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
/// Single VTS, single title, one cell. Extent absolute LBA =
|
||||
@@ -568,7 +742,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0;
|
||||
assert_eq!(titles.len(), 1);
|
||||
let t = &titles[0];
|
||||
assert_eq!(t.extents.len(), 1);
|
||||
@@ -594,7 +770,7 @@ mod tests {
|
||||
/// before the title VOB. Cell `first_sector` values are relative to
|
||||
/// `vtstt_vobs`; reading 0xC0 prepended the menu and shifted every extent
|
||||
/// back by `vtstt_vobs - vtsm_vobs`, so the rip opened on the parental
|
||||
/// prompt instead of the movie (Greenland NTSC R1: vtsm=44, vtstt=3640).
|
||||
/// prompt instead of the movie (one real NTSC R1 disc: vtsm=44, vtstt=3640).
|
||||
///
|
||||
/// Here `build_vts` stamps `vtstt_vobs = 3640` (0xC4); we additionally stamp
|
||||
/// a *different* `vtsm_vobs = 44` (0xC0). The extent must resolve from 3640.
|
||||
@@ -624,7 +800,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0;
|
||||
assert_eq!(titles.len(), 1);
|
||||
let t = &titles[0];
|
||||
assert_eq!(t.extents.len(), 1);
|
||||
@@ -644,7 +822,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// ABSOLUTE-REBASE regression (THESILENCEOFTHELAMBS / Greenland fix):
|
||||
/// ABSOLUTE-REBASE regression (real-disc menu-at-start fix):
|
||||
/// `ifo::parse_vts` now sets `vob_start_sector = file_start_lba(IFO) +
|
||||
/// vtstt_vobs`, so an extent's `start_lba` must equal the sum of THREE
|
||||
/// independent terms — the IFO file's absolute on-disc LBA, the
|
||||
@@ -682,7 +860,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
assert_eq!(t.extents.len(), 1);
|
||||
let got = t.extents[0].start_lba;
|
||||
// The one correct answer: all three terms summed (9000 + 700 + 33).
|
||||
@@ -740,7 +920,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
assert_eq!(t.extents.len(), 2);
|
||||
assert_eq!(t.extents[0].start_lba, 9500); // ifo_lba(9000) + 500 + 0
|
||||
assert_eq!(t.extents[0].sector_count, 100);
|
||||
@@ -781,7 +963,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
let v = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -836,7 +1020,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
let v = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -897,7 +1083,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
let audios: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -908,7 +1096,7 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(audios.len(), 2);
|
||||
assert_eq!(audios[0].codec, Codec::Ac3);
|
||||
assert_eq!(audios[0].language, "en");
|
||||
assert_eq!(audios[0].language, "eng");
|
||||
assert_eq!(audios[1].codec, Codec::Dts);
|
||||
// Real channel layouts survive the scan (not a 1ch placeholder): the
|
||||
// AC-3 is 5.1 (6ch), the DTS is 2.0 (2ch).
|
||||
@@ -967,7 +1155,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
let audios: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1024,7 +1214,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
let subs: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1037,7 +1229,7 @@ mod tests {
|
||||
// Languages preserved in order.
|
||||
assert_eq!(
|
||||
subs.iter().map(|s| s.language.as_str()).collect::<Vec<_>>(),
|
||||
vec!["en", "fr", "de"]
|
||||
vec!["eng", "fra", "deu"]
|
||||
);
|
||||
// PIDs are 0x20 + ordinal, all distinct.
|
||||
let pids: Vec<u16> = subs.iter().map(|s| s.pid).collect();
|
||||
@@ -1084,7 +1276,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
let sub = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1094,7 +1288,7 @@ mod tests {
|
||||
})
|
||||
.expect("subtitle stream");
|
||||
assert_eq!(sub.codec, Codec::DvdSub);
|
||||
assert_eq!(sub.language, "en");
|
||||
assert_eq!(sub.language, "eng");
|
||||
assert!(
|
||||
sub.codec_data.is_some(),
|
||||
"non-zero palette must yield codec_data"
|
||||
@@ -1138,7 +1332,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0;
|
||||
assert_eq!(titles.len(), 2);
|
||||
// title_number is a running counter across all title sets.
|
||||
assert_eq!(titles[0].playlist_id, 1);
|
||||
@@ -1151,6 +1347,79 @@ mod tests {
|
||||
assert_eq!(titles[1].extents[0].start_lba, 10200); // 10000 + 200
|
||||
}
|
||||
|
||||
/// Stamp a First-Play PGC into a VMG whose pre-command list is a single
|
||||
/// unconditional `JumpTT ttn`, so `dvdnav::resolve_main_title` resolves that
|
||||
/// title through TT_SRPT. Placed in sector 0, clear of the magic / FP_PGC /
|
||||
/// TT_SRPT pointers `build_vmg` already writes.
|
||||
fn stamp_first_play_jumptt(vmg: &mut [u8], ttn: u8) {
|
||||
const FP_PGC_PTR: usize = 0x84; // u32 byte offset of the First-Play PGC
|
||||
const CMD_TBL_PTR: usize = 0xE4; // u16 cmd-table offset (rel to PGC)
|
||||
let fp_pgc: u32 = 0x200;
|
||||
let cmd_tbl_rel: u16 = 0x100;
|
||||
vmg[FP_PGC_PTR..FP_PGC_PTR + 4].copy_from_slice(&fp_pgc.to_be_bytes());
|
||||
let pgc = fp_pgc as usize;
|
||||
vmg[pgc + CMD_TBL_PTR..pgc + CMD_TBL_PTR + 2].copy_from_slice(&cmd_tbl_rel.to_be_bytes());
|
||||
let tbl = pgc + cmd_tbl_rel as usize;
|
||||
vmg[tbl..tbl + 2].copy_from_slice(&1u16.to_be_bytes()); // nr_of_pre = 1
|
||||
// JumpTT command (type 1, direct=1, cmd=2): ttn in byte 5.
|
||||
vmg[tbl + 8..tbl + 16].copy_from_slice(&[0x30, 0x02, 0, 0, 0, ttn, 0, 0]);
|
||||
}
|
||||
|
||||
/// The DVD First-Play nav promotion branch (issue #40): when
|
||||
/// `resolve_main_title` returns a `(vtsn, vts_ttn)` target, `scan_dvd_titles`
|
||||
/// must map it — via the 1-based `vts_ttn == vts_title_idx + 1` join within
|
||||
/// the matching `vts_number` — to the running `title_number`, and surface it
|
||||
/// as the `nav_feature`. TT_SRPT here maps title 2 → (VTS 2, title-in-set 1);
|
||||
/// the First-Play unconditionally `JumpTT 2`, so the promoted feature must be
|
||||
/// the SECOND scanned title (VTS_02 → title_number 2). An off-by-one on the
|
||||
/// join or a vtsn/vts_ttn field swap would promote the wrong title or none.
|
||||
#[test]
|
||||
fn scan_dvd_titles_nav_promotes_first_play_target() {
|
||||
let mut disc = MemDisc::new();
|
||||
// TT_SRPT: title 1 → VTS 1 (in-set title 1); title 2 → VTS 2 (in-set 1).
|
||||
let mut vmg = build_vmg(&[(1, 1, 1), (1, 2, 1)]);
|
||||
stamp_first_play_jumptt(&mut vmg, 2); // First-Play → JumpTT title 2
|
||||
let vts1 = build_vts(100, 0x00, &[], &[], &[(0, 9)], false);
|
||||
let vts2 = build_vts(200, 0x00, &[], &[], &[(0, 19)], false);
|
||||
let udf = build_video_ts_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
FileSpec {
|
||||
name: "VIDEO_TS.IFO".into(),
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vmg,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_01_0.IFO".into(),
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: vts1,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_02_0.IFO".into(),
|
||||
icb_lba: 64,
|
||||
data_lba: 7000,
|
||||
contents: vts2,
|
||||
},
|
||||
],
|
||||
);
|
||||
let (titles, nav_feature) = Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan");
|
||||
assert_eq!(titles.len(), 2);
|
||||
// Sanity: the resolver reached the branch with the VTS-2 target.
|
||||
assert_eq!(
|
||||
crate::dvdnav::resolve_main_title(&mut disc, &udf),
|
||||
Some(crate::dvdnav::nav::ResolvedTitle {
|
||||
title: 2,
|
||||
vtsn: 2,
|
||||
vts_ttn: 1,
|
||||
})
|
||||
);
|
||||
// The promotion mapped that target to the second scanned title.
|
||||
assert_eq!(nav_feature, Some(2));
|
||||
assert_eq!(titles[1].playlist_id, 2);
|
||||
}
|
||||
|
||||
/// chapter_times from the IFO become Chapter entries with ordinal
|
||||
/// names (dvd.rs maps chapter_times → Chapter{time_secs, chapter_name}).
|
||||
#[test]
|
||||
@@ -1175,7 +1444,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
// One program in the program map → one chapter time (0.0 for the
|
||||
// first program). Name is the ordinal from chapter_name(0).
|
||||
assert_eq!(t.chapters.len(), 1);
|
||||
@@ -1260,7 +1531,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
// The leading 0x90 cell is dropped: 2 feature extents, not 3.
|
||||
assert_eq!(t.extents.len(), 2, "leading angle sub-block cell dropped");
|
||||
// First extent starts at the feature cell (vob 1000 + 100), not at 1000+0.
|
||||
@@ -1284,7 +1557,7 @@ mod tests {
|
||||
|
||||
/// Conservative guard end-to-end: a normal feature (every cell category
|
||||
/// 0x00) is muxed in full — the filter drops nothing and chapters are
|
||||
/// unshifted. This is the "Silence of the Lambs" case.
|
||||
/// unshifted. This is the plain-single-angle-feature case.
|
||||
#[test]
|
||||
fn scan_dvd_titles_plain_feature_untouched() {
|
||||
let mut disc = MemDisc::new();
|
||||
@@ -1312,7 +1585,9 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0[0];
|
||||
// Nothing dropped: both cells become extents, starting at the very head.
|
||||
assert_eq!(t.extents.len(), 2);
|
||||
assert_eq!(t.extents[0].start_lba, 9000 + 1000); // ifo_lba + vtstt + 0, head intact
|
||||
@@ -1320,4 +1595,53 @@ mod tests {
|
||||
// Chapter 0 stays at 0.0 (no shift).
|
||||
assert!((t.chapters[0].time_secs - 0.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// Audio PID fallback (dvd.rs `Disc::scan_dvd_titles`): when an audio
|
||||
/// stream has no on-wire private_stream_1 sub-stream id — MP1/MP2 audio,
|
||||
/// per `ifo::assign_audio_sub_stream_ids` — the PID falls back to
|
||||
/// `0xBD00 + i` where `i` is the stream's positional index in the IFO
|
||||
/// audio-attribute table. Two MPEG-audio (coding_mode 2) streams must
|
||||
/// land on two DISTINCT, correctly-offset PIDs: 0xBD00 and 0xBD01. This
|
||||
/// pins the `+` (not `-`/`*`) so the second stream doesn't collide with,
|
||||
/// or wrap under, the first.
|
||||
#[test]
|
||||
fn scan_dvd_titles_mp2_audio_pid_fallback_is_additive() {
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(1, 1, 1)]);
|
||||
// coding_mode bits are b0>>5 & 0x7; mode 2 = MPEG-1 Layer II (Mp2),
|
||||
// which `assign_audio_sub_stream_ids` leaves at `sub_stream_id: None`.
|
||||
// b0 = 0b010_00000 = 0x40. b1 = 0 (mono, sample rate 48k).
|
||||
let audio = [(0x40u8, 0x00u8, [0u8, 0u8]), (0x40u8, 0x00u8, [0u8, 0u8])];
|
||||
let vts = build_vts(1000, 0x00, &audio, &[], &[(10, 109)], false);
|
||||
let udf = build_video_ts_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
FileSpec {
|
||||
name: "VIDEO_TS.IFO".into(),
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vmg,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_01_0.IFO".into(),
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: vts,
|
||||
},
|
||||
],
|
||||
);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.0;
|
||||
let t = &titles[0];
|
||||
let audio_pids: Vec<u16> = t
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Stream::Audio(a) => Some(a.pid),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(audio_pids, vec![0xBD00u16, 0xBD01u16]);
|
||||
}
|
||||
}
|
||||
|
||||
+178
-10
@@ -1,6 +1,6 @@
|
||||
//! Physical AC-3 sub-stream probing for DVD audio routing.
|
||||
//!
|
||||
//! ## Why this exists (Silence-of-the-Lambs wrong-substream bug)
|
||||
//! ## Why this exists (a real-disc wrong-substream bug)
|
||||
//!
|
||||
//! A DVD VTS IFO declares its audio streams in a fixed table, and freemkv's
|
||||
//! scan assigns each declared stream a `private_stream_1` sub-stream id purely
|
||||
@@ -8,7 +8,7 @@
|
||||
//! `0x81`, and so on (`ifo::assign_audio_sub_stream_ids`). That assumes the
|
||||
//! physical sub-stream order on the wire matches the IFO declaration order.
|
||||
//!
|
||||
//! On some discs it does NOT. The R2 PAL "The Silence of the Lambs" feature
|
||||
//! On some discs it does NOT. A real R2 PAL DVD release's feature
|
||||
//! declares ONE AC-3 audio stream the IFO nibble marks as 5.1 (6 channels), but
|
||||
//! the physical VOB carries the 5.1 main mix and a 2.0 down-mix on DIFFERENT
|
||||
//! `0x8x` sub-stream ids, and the 2.0 is the one that happens to land at the
|
||||
@@ -34,7 +34,7 @@ use std::collections::BTreeMap;
|
||||
/// a DVD feature opens with logos/warnings whose audio is frequently a thin 2.0
|
||||
/// bed on the FIRST sub-stream only — the other physical `0x8x` sub-streams and
|
||||
/// the main 5.1 mix do not appear until a sector or two further in. 512 sectors
|
||||
/// (1 MiB) was too short: on Greenland it saw ONLY `0x80`, and only its opening
|
||||
/// (1 MiB) was too short: on one real disc it saw ONLY `0x80`, and only its opening
|
||||
/// 2.0 frames. 1024 sectors (2 MiB) reliably contains at least one frame of
|
||||
/// every physical AC-3 sub-stream AND enough of `0x80` to reach its 5.1 frames.
|
||||
/// Still bounded so a live drive is never hammered (see the project "don't
|
||||
@@ -54,7 +54,7 @@ const PROBE_SECTORS: u16 = 1024;
|
||||
/// The first frame of a sub-stream at the head of a feature is NOT
|
||||
/// representative. A DVD opens with logos/warnings, and the main `0x80`
|
||||
/// sub-stream there frequently carries a thin 2.0 bed before transitioning to
|
||||
/// its real 5.1 main mix a fraction of a second later (observed on Greenland:
|
||||
/// its real 5.1 main mix a fraction of a second later (observed on a real disc:
|
||||
/// `0x80`'s first frames are acmod=2 → 2 channels, then it becomes acmod=7+lfe →
|
||||
/// 6 channels within the same 2 MiB window). Recording only the FIRST frame read
|
||||
/// `0x80=2` and missed the 5.1 entirely, defeating the channel-match routing.
|
||||
@@ -104,10 +104,10 @@ fn max_substream_channels(data: &[u8]) -> Option<u8> {
|
||||
};
|
||||
let start = pos + rel;
|
||||
let frame = &data[start..];
|
||||
if let Some(ch) = ac3::acmod_channels(frame) {
|
||||
if ch > 0 {
|
||||
best = Some(best.map_or(ch, |b| b.max(ch)));
|
||||
}
|
||||
if let Some(ch) = ac3::acmod_channels(frame)
|
||||
&& ch > 0
|
||||
{
|
||||
best = Some(best.map_or(ch, |b| b.max(ch)));
|
||||
}
|
||||
// Advance past this frame by its declared size when that is mappable;
|
||||
// otherwise step 2 bytes past the sync and re-scan for the next one.
|
||||
@@ -242,7 +242,11 @@ pub fn probe_and_remap<S: SectorSource + ?Sized>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::{AudioChannels, AudioStream, Codec, LabelPurpose, SampleRate};
|
||||
use crate::disc::{
|
||||
AudioChannels, AudioStream, Codec, ContentFormat, DiscTitle, Extent, LabelPurpose,
|
||||
SampleRate,
|
||||
};
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
/// Build a single, correctly-SIZED AC-3 frame whose `acmod`/`lfeon` encode a
|
||||
/// known channel count. `byte4` is `fscod=0 | frmsizecod=0`, so
|
||||
@@ -343,7 +347,7 @@ mod tests {
|
||||
assert_eq!(probed.get(&0x81), Some(&6), "0x81 is the 5.1 main mix");
|
||||
}
|
||||
|
||||
/// GREENLAND regression — the probe must read each sub-stream's TRUE
|
||||
/// Real-disc regression — the probe must read each sub-stream's TRUE
|
||||
/// (max-mix) channel count, not be poisoned by an unrepresentative head
|
||||
/// frame, and must NOT cross-contaminate between sub-streams.
|
||||
///
|
||||
@@ -463,4 +467,168 @@ mod tests {
|
||||
};
|
||||
assert_eq!(a.pid, 0xBD80, "no probe data → keep ordinal");
|
||||
}
|
||||
|
||||
/// `max_substream_channels` must locate the sync at its true ABSOLUTE
|
||||
/// position (`pos + rel`) when it is preceded by non-sync bytes, not just
|
||||
/// when the sync sits at offset 0. Regression guard for a hand-checked
|
||||
/// mutation (`+` → `-` at the `pos + rel` offset computation): with `pos`
|
||||
/// starting at 0 and the first sync found 3 bytes in, `pos - rel` would
|
||||
/// underflow a `usize` and panic, or (if it somehow didn't) index the
|
||||
/// wrong start entirely. `pos + rel` is the only computation that is
|
||||
/// always in-bounds, since `rel` is itself bounded by the length of the
|
||||
/// slice searched from `pos`.
|
||||
#[test]
|
||||
fn max_substream_channels_locates_sync_after_leading_non_sync_bytes() {
|
||||
let mut data = vec![0xAA, 0xAA, 0xAA]; // no 0x0B77 pattern in here
|
||||
data.extend(ac3_frame(2, false)); // real 2.0 frame, sync at absolute offset 3
|
||||
assert_eq!(
|
||||
max_substream_channels(&data),
|
||||
Some(2),
|
||||
"must find and decode the frame whose sync is NOT at offset 0"
|
||||
);
|
||||
}
|
||||
|
||||
/// When an AC-3 header's `fscod`/`frmsizecod` is unmappable (reserved
|
||||
/// `fscod == 3`), `max_substream_channels` must fall back to stepping
|
||||
/// `start + 2` bytes past the sync to re-lock onto the next genuine sync,
|
||||
/// and must keep making forward progress doing so (never revisit the same
|
||||
/// sync, which would loop forever, and never jump so far that it skips
|
||||
/// the very next real frame). This lays a bogus-sized header at absolute
|
||||
/// offset 4 (so `start == 4`, `start + 2 == 6`) immediately followed, at
|
||||
/// offset 6, by a real, fully decodable 2.0 frame — the position the
|
||||
/// `+ 2` fallback must land on exactly.
|
||||
#[test]
|
||||
fn max_substream_channels_unmappable_size_steps_forward_by_two() {
|
||||
let mut real = ac3_frame(2, false);
|
||||
// Overwrite the (unchecked) CRC bytes of the real frame — these double
|
||||
// as byte4/byte5 of the bogus header 2 bytes earlier, at absolute
|
||||
// offset 4: byte4 = 0xC0 (fscod=3 reserved -> ac3_frame_size == 0,
|
||||
// unmappable), byte5 = 0xF8 (bsid=31 >= 11 -> acmod_channels == None,
|
||||
// so the bogus header itself never contributes a spurious channel
|
||||
// count).
|
||||
real[2] = 0xC0;
|
||||
real[3] = 0xF8;
|
||||
let mut data = vec![0xAA, 0xAA, 0xAA, 0xAA]; // offsets 0..4, no sync
|
||||
data.push(0x0B); // offset 4: bogus header sync byte 0
|
||||
data.push(0x77); // offset 5: bogus header sync byte 1
|
||||
data.extend(real); // offset 6..: the real frame (also serves as the
|
||||
// bogus header's byte4/byte5 at offsets 8/9)
|
||||
assert_eq!(
|
||||
max_substream_channels(&data),
|
||||
Some(2),
|
||||
"must recover the real frame 2 bytes after the unmappable-size sync, not lose it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Same fallback as above, but with the unmappable-size sync at absolute
|
||||
/// offset 0 (`start == 0`) so that stepping backward instead of forward
|
||||
/// (`start - 2`) would underflow rather than merely land on the wrong
|
||||
/// byte. Also proves the real frame is still found 6 bytes further in,
|
||||
/// confirming forward progress past the bogus header.
|
||||
#[test]
|
||||
fn max_substream_channels_unmappable_size_at_start_steps_forward_not_back() {
|
||||
let mut data = vec![0x0B, 0x77, 0x00, 0x00, 0xC0, 0xF8]; // bogus header, offsets 0..6
|
||||
data.extend(ac3_frame(2, false)); // real 2.0 frame at offset 6
|
||||
assert_eq!(
|
||||
max_substream_channels(&data),
|
||||
Some(2),
|
||||
"must step forward past the bogus header at offset 0 and find the real frame at offset 6"
|
||||
);
|
||||
}
|
||||
|
||||
/// `remap_audio_pids` must read a stream's CURRENT physical sub-stream id
|
||||
/// from the low byte of its PID via `pid & 0x00FF` — not `|` or `^` with
|
||||
/// `0x00FF`, both of which force the low byte to `0xFF` regardless of the
|
||||
/// real PID and so always miss the "already matches" shortcut. That
|
||||
/// matters observably when TWO physical sub-streams share the same probed
|
||||
/// channel count: with a correct read, a stream already sitting on a
|
||||
/// matching sub-stream is left alone (conservative, per the module's
|
||||
/// documented behaviour); with the low byte forced to `0xFF`,
|
||||
/// `probed.get(&0xFF)` is always `None`, so the code falls through to the
|
||||
/// "find any unclaimed match" path and picks the FIRST (lowest-keyed,
|
||||
/// BTreeMap-ordered) matching physical sub-stream instead — which here is
|
||||
/// a *different* sub-stream (0x80) than the one the PID already correctly
|
||||
/// names (0x81), producing a spurious PID change.
|
||||
#[test]
|
||||
fn remap_reads_current_substream_via_and_not_or_or_xor() {
|
||||
let mut probed = BTreeMap::new();
|
||||
probed.insert(0x80u8, 6u8);
|
||||
probed.insert(0x81u8, 6u8); // ambiguous: two physical 6ch sub-streams
|
||||
let mut streams = vec![ac3_stream(0xBD81, AudioChannels::Surround51)];
|
||||
let changed = remap_audio_pids(&mut streams, &probed);
|
||||
assert_eq!(
|
||||
changed, 0,
|
||||
"already sitting on a matching physical sub-stream (0x81) must be left alone"
|
||||
);
|
||||
let Stream::Audio(a) = &streams[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
a.pid, 0xBD81,
|
||||
"must not be bumped to the other matching sub-stream (0x80)"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `SectorSource` stub that hands back fixed bytes regardless of the
|
||||
/// requested LBA/count, for exercising `probe_and_remap`'s end-to-end
|
||||
/// wiring (format/AC-3/extent/count guards -> read -> probe -> remap).
|
||||
struct FixedSource {
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl SectorSource for FixedSource {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
_count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
let n = self.data.len().min(buf.len());
|
||||
buf[..n].copy_from_slice(&self.data[..n]);
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end `probe_and_remap`: a real-disc-shaped MpegPs
|
||||
/// title (one declared 5.1 AC-3 stream ordinally assigned 0x80) whose
|
||||
/// physical VOB bytes carry the 2.0 down-mix on 0x80 and the real 5.1 on
|
||||
/// 0x81. This must reach the `remap_audio_pids` call and re-route the
|
||||
/// stream to 0xBD81. It also, by construction, proves each of the guards
|
||||
/// along the way lets a real, positive case through: the content-format
|
||||
/// check must NOT bail on `MpegPs` (only on non-`MpegPs`), the AC-3
|
||||
/// presence check must NOT bail when AC-3 IS present, and the
|
||||
/// sector-count check must NOT bail when the count is nonzero — any one
|
||||
/// of those inverted would skip the probe entirely and leave the PID at
|
||||
/// its untouched ordinal value (0xBD80), which the assertion below would
|
||||
/// catch.
|
||||
#[test]
|
||||
fn probe_and_remap_reroutes_swapped_substream_scenario_end_to_end() {
|
||||
let mut bytes = ps_ac3(0x80, 2, false); // physical 0x80 = 2.0 down-mix
|
||||
bytes.extend(ps_ac3(0x81, 7, true)); // physical 0x81 = 5.1 main mix
|
||||
let mut title = DiscTitle {
|
||||
playlist: "00001.ifo".into(),
|
||||
playlist_id: 1,
|
||||
duration_secs: 60.0,
|
||||
size_bytes: bytes.len() as u64,
|
||||
clips: Vec::new(),
|
||||
streams: vec![ac3_stream(0xBD80, AudioChannels::Surround51)],
|
||||
chapters: Vec::new(),
|
||||
extents: vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 2,
|
||||
}],
|
||||
content_format: ContentFormat::MpegPs,
|
||||
codec_privates: vec![None],
|
||||
};
|
||||
let mut source = FixedSource { data: bytes };
|
||||
probe_and_remap(&mut source, &mut title);
|
||||
let Stream::Audio(a) = &title.streams[0] else {
|
||||
panic!("audio")
|
||||
};
|
||||
assert_eq!(
|
||||
a.pid, 0xBD81,
|
||||
"declared 5.1 stream must be re-routed to the physical 5.1 sub-stream 0x81"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+140
-72
@@ -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:
|
||||
@@ -86,9 +98,9 @@ impl AacsCertUnlocker<'_> {
|
||||
// MKB generation (best-effort) — forwarded to each source's
|
||||
// `host_certs(mkb)` so a source MAY select a generation-appropriate cert
|
||||
// (the default impl ignores it). A read failure leaves it `None`.
|
||||
let mkb_gen = aacs::read_mkb_from_drive(session.scsi_mut())
|
||||
let mkb_gen = aacs::inf::read_mkb_from_drive(session.scsi_mut())
|
||||
.ok()
|
||||
.and_then(|m| aacs::mkb_version(&m));
|
||||
.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
|
||||
@@ -111,13 +123,13 @@ impl AacsCertUnlocker<'_> {
|
||||
// through method calls, so clone the (cheap) identity first.
|
||||
let drive_id = session.drive_id.clone();
|
||||
let fu_certs = crate::unlock_bridge::map_host_certs(&host_certs);
|
||||
let unlocked = crate::unlock_bridge::run_unlockers(
|
||||
let (_, unlock_res) = crate::unlock_bridge::run_bus(
|
||||
session.scsi_mut(),
|
||||
&drive_id,
|
||||
freemkv_unlock::DiscKind::Aacs,
|
||||
&fu_certs,
|
||||
)
|
||||
.map_err(CertUnlockFailure::Unlock)?;
|
||||
);
|
||||
let unlocked = unlock_res.map_err(CertUnlockFailure::Unlock)?;
|
||||
// The cert handshake yields a VID on success; its absence is VidUnavailable.
|
||||
let Some(volume_id) = unlocked.vid else {
|
||||
return Err(CertUnlockFailure::Unlock(UnlockError::VidUnavailable));
|
||||
@@ -152,10 +164,10 @@ fn unlock_error_to_error(e: &CertUnlockFailure) -> Error {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::UnlockOutcome`]
|
||||
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::trace::UnlockOutcome`]
|
||||
/// for the resolution trace (English-free).
|
||||
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::UnlockOutcome {
|
||||
use crate::aacs::UnlockOutcome;
|
||||
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::trace::UnlockOutcome {
|
||||
use crate::aacs::trace::UnlockOutcome;
|
||||
use freemkv_unlock::UnlockError;
|
||||
match e {
|
||||
CertUnlockFailure::NoHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
|
||||
@@ -169,6 +181,24 @@ fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::UnlockOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -230,7 +260,10 @@ impl Disc {
|
||||
/// `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::HostCert> {
|
||||
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.
|
||||
@@ -308,18 +341,19 @@ impl Disc {
|
||||
) -> Result<AacsState> {
|
||||
use crate::aacs;
|
||||
|
||||
let uk_ro_data = udf_fs
|
||||
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
|
||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let dh = aacs::disc_hash(&uk_ro_data);
|
||||
let uk_ro_data =
|
||||
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 = udf_fs
|
||||
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
|
||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::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
|
||||
@@ -328,7 +362,7 @@ impl Disc {
|
||||
let version = cc
|
||||
.as_ref()
|
||||
.map(|c| c.version.major())
|
||||
.unwrap_or(aacs::AACS_MAJOR_UHD);
|
||||
.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
|
||||
@@ -353,7 +387,7 @@ impl Disc {
|
||||
// file/ISO, drive unlock, cert bus key). The gate enumerates nothing.
|
||||
if !bus_encryption_removed(bus_encryption, handshake) {
|
||||
let (rdk_err, has_vid) = handshake
|
||||
.map(|h| (h.read_data_key_err, h.volume_id != [0u8; 16]))
|
||||
.map(|h| (h.read_data_key_err, handshake_has_volume_id(h)))
|
||||
.unwrap_or((None, false));
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
@@ -394,12 +428,12 @@ impl Disc {
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let mkb_ver = aacs::mkb_version(&mkb_bytes);
|
||||
let mkb_ver = aacs::mkb::mkb_version(&mkb_bytes);
|
||||
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_vid_only",
|
||||
disc_hash = %aacs::disc_hash_hex(&dh),
|
||||
disc_hash = %aacs::inf::disc_hash_hex(&dh),
|
||||
version,
|
||||
bus_encryption,
|
||||
has_vid = handshake.is_some(),
|
||||
@@ -410,7 +444,7 @@ impl Disc {
|
||||
version,
|
||||
bus_encryption,
|
||||
mkb_version: mkb_ver,
|
||||
disc_hash: aacs::disc_hash_hex(&dh),
|
||||
disc_hash: aacs::inf::disc_hash_hex(&dh),
|
||||
key_source: KeyOrigin::ExternalUk,
|
||||
vuk: None,
|
||||
unit_keys: vec![],
|
||||
@@ -429,6 +463,27 @@ mod tests {
|
||||
use crate::sector::SectorSource;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `HandshakeResult` carries the Volume ID and the AACS 2.0 bus (read-data)
|
||||
/// key; `Debug` must redact both. Sentinel 213 (0xD5).
|
||||
#[test]
|
||||
fn handshake_result_debug_is_redacted() {
|
||||
let hs = HandshakeResult {
|
||||
volume_id: [0xD5; 16],
|
||||
read_data_key: Some([0xD5; 16]),
|
||||
read_data_key_err: None,
|
||||
drive_unlocked: false,
|
||||
};
|
||||
let d = format!("{hs:?}");
|
||||
assert!(
|
||||
!d.contains("213"),
|
||||
"HandshakeResult leaked VID/bus key: {d}"
|
||||
);
|
||||
assert!(
|
||||
d.contains("redacted"),
|
||||
"HandshakeResult missing marker: {d}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// In-memory disc + minimal UDF image with a single physical
|
||||
// partition (metadata_start == partition_start). Offsets cited
|
||||
@@ -569,7 +624,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A content certificate: type byte@0 (0x00 = V10, else V20),
|
||||
/// bus_encryption bit7@1, cc_id@14..20 (aacs/keys.rs parse_content_cert,
|
||||
/// bus_encryption bit7@1, cc_id@14..20 (aacs/inf.rs parse_content_cert,
|
||||
/// which requires ≥20 bytes and reads the bus flag from `data[1] >> 7`).
|
||||
fn build_content_cert(cert_type: u8, bus_encryption: bool) -> Vec<u8> {
|
||||
let mut v = vec![0u8; 20];
|
||||
@@ -581,7 +636,7 @@ mod tests {
|
||||
/// An MKB with one Type-and-Version record (type 0x10) carrying the
|
||||
/// version as BE u32 at record offset 8, followed by a recorded EOF
|
||||
/// record then trailing zero padding. mkb_content_len walks records
|
||||
/// and stops at the first padding (type 0) byte (aacs/keys.rs).
|
||||
/// and stops at the first padding (type 0) byte (aacs/inf.rs).
|
||||
fn build_mkb(version: u32, pad_to: usize) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
// Type 0x10 record, length 16 (>= 12 so version is read).
|
||||
@@ -687,14 +742,14 @@ mod tests {
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
||||
assert_eq!(
|
||||
st.version,
|
||||
aacs::AACS_MAJOR_UHD,
|
||||
aacs::mkb::AACS_MAJOR_UHD,
|
||||
"no cert → default UHD (major 2)"
|
||||
);
|
||||
assert!(!st.bus_encryption);
|
||||
}
|
||||
|
||||
/// disc_hash is SHA1 of the Unit_Key_RO.inf bytes, hex with 0x prefix
|
||||
/// and uppercase (aacs::disc_hash + disc_hash_hex). The state's
|
||||
/// and uppercase (aacs::inf::disc_hash + disc_hash_hex). The state's
|
||||
/// disc_hash must match independently computing it over the same bytes.
|
||||
#[test]
|
||||
fn resolve_vid_only_disc_hash_is_sha1_of_unit_key_ro() {
|
||||
@@ -710,7 +765,7 @@ mod tests {
|
||||
}],
|
||||
);
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
||||
let expected = aacs::disc_hash_hex(&aacs::disc_hash(&uk));
|
||||
let expected = aacs::inf::disc_hash_hex(&aacs::inf::disc_hash(&uk));
|
||||
assert_eq!(st.disc_hash, expected);
|
||||
assert!(st.disc_hash.starts_with("0x"));
|
||||
// uk_ro must be stashed verbatim for the external resolver.
|
||||
@@ -746,7 +801,7 @@ mod tests {
|
||||
// Real record stream is the single 16-byte type-0x10 record.
|
||||
assert_eq!(
|
||||
st.mkb.len(),
|
||||
aacs::mkb_content_len(&mkb),
|
||||
aacs::mkb::mkb_content_len(&mkb),
|
||||
"MKB must be trimmed to record-stream length, not the zero-pad"
|
||||
);
|
||||
assert_eq!(st.mkb.len(), 16);
|
||||
@@ -912,41 +967,54 @@ mod tests {
|
||||
|
||||
/// Unit_Key_RO.inf is read from /AACS/DUPLICATE when the primary copy
|
||||
/// is absent (encrypt.rs `.or_else(|_| read_file(DUPLICATE/...))`).
|
||||
/// This is the damaged-primary recovery path real discs rely on.
|
||||
#[test]
|
||||
fn resolve_vid_only_falls_back_to_duplicate_unit_key_ro() {
|
||||
let mut disc = MemDisc::new();
|
||||
// Build AACS dir with a DUPLICATE subdir holding Unit_Key_RO.inf.
|
||||
let uk = vec![0x55u8; 48];
|
||||
let mut dup_fids = Vec::new();
|
||||
push_fid(&mut dup_fids, "", 70, true, true);
|
||||
push_fid(&mut dup_fids, "Unit_Key_RO.inf", 72, false, false);
|
||||
disc.put(PART_START + 72, build_file_icb(uk.len() as u32, 9000));
|
||||
disc.put_bytes(PART_START + 9000, &uk);
|
||||
disc.put(PART_START + 70, build_file_icb(dup_fids.len() as u32, 71));
|
||||
disc.put_bytes(PART_START + 71, &dup_fids);
|
||||
// AACS dir: only a DUPLICATE subdir (no primary Unit_Key_RO.inf).
|
||||
let mut aacs_fids = Vec::new();
|
||||
push_fid(&mut aacs_fids, "", 50, true, true);
|
||||
push_fid(&mut aacs_fids, "DUPLICATE", 70, true, false);
|
||||
disc.put(PART_START + 50, build_file_icb(aacs_fids.len() as u32, 51));
|
||||
disc.put_bytes(PART_START + 51, &aacs_fids);
|
||||
let mut root_fids = Vec::new();
|
||||
push_fid(&mut root_fids, "", 10, true, true);
|
||||
push_fid(&mut root_fids, "AACS", 50, true, false);
|
||||
disc.put(PART_START + 10, build_file_icb(root_fids.len() as u32, 11));
|
||||
disc.put_bytes(PART_START + 11, &root_fids);
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
let udf = udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("DUPLICATE fallback");
|
||||
// disc_hash must be computed over the DUPLICATE bytes.
|
||||
assert_eq!(
|
||||
st.disc_hash,
|
||||
aacs::disc_hash_hex(&aacs::disc_hash(&uk)),
|
||||
"fallback must hash the DUPLICATE Unit_Key_RO.inf"
|
||||
fn handshake_has_volume_id_reports_presence_not_absence() {
|
||||
let with_vid = HandshakeResult {
|
||||
volume_id: [0x11u8; 16],
|
||||
read_data_key: None,
|
||||
read_data_key_err: None,
|
||||
drive_unlocked: false,
|
||||
};
|
||||
assert!(
|
||||
super::handshake_has_volume_id(&with_vid),
|
||||
"a non-zero Volume ID must report as PRESENT"
|
||||
);
|
||||
assert_eq!(st.uk_ro, uk);
|
||||
|
||||
let without = HandshakeResult {
|
||||
volume_id: [0u8; 16],
|
||||
..with_vid
|
||||
};
|
||||
assert!(
|
||||
!super::handshake_has_volume_id(&without),
|
||||
"an all-zero Volume ID is the absent case"
|
||||
);
|
||||
|
||||
// One bit of difference is still a VID: the check is != all-zero, not a
|
||||
// heuristic about how much of it looks populated.
|
||||
let mut barely = [0u8; 16];
|
||||
barely[15] = 1;
|
||||
assert!(
|
||||
super::handshake_has_volume_id(&HandshakeResult {
|
||||
volume_id: barely,
|
||||
..with_vid
|
||||
}),
|
||||
"any non-zero byte makes a Volume ID present"
|
||||
);
|
||||
}
|
||||
|
||||
/// The gate itself still hard-errors — the property the log line annotates.
|
||||
#[test]
|
||||
fn resolve_vid_only_bus_key_gate_hard_errors_without_a_read_data_key() {
|
||||
let (mut disc, udf) = disc_with_cert(0x01, true);
|
||||
let hs = HandshakeResult {
|
||||
volume_id: [0x11u8; 16],
|
||||
read_data_key: None,
|
||||
read_data_key_err: None,
|
||||
drive_unlocked: false,
|
||||
};
|
||||
let err = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs))
|
||||
.expect_err("bus-encrypted, no read_data_key must still hard-error");
|
||||
assert!(matches!(err, Error::AacsBusKeyUnavailable));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
@@ -965,8 +1033,8 @@ mod tests {
|
||||
// the route fails gracefully (AacsNoHostCert), never panics.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
fn fake_cert(tag: u8) -> aacs::HostCert {
|
||||
aacs::HostCert {
|
||||
fn fake_cert(tag: u8) -> aacs::types::HostCert {
|
||||
aacs::types::HostCert {
|
||||
private_key: [tag; 20],
|
||||
certificate: vec![tag; 92],
|
||||
private_key_v2: None,
|
||||
@@ -975,15 +1043,15 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A minimal in-test KeySource that yields no keys but a fixed cert list.
|
||||
struct CertSource(Vec<aacs::HostCert>);
|
||||
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::UnitKey>> {
|
||||
) -> Result<Vec<crate::aacs::types::UnitKey>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn host_certs(&self, _mkb: Option<u32>) -> Vec<aacs::HostCert> {
|
||||
fn host_certs(&self, _mkb: Option<u32>) -> Vec<aacs::types::HostCert> {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
@@ -1077,7 +1145,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cert_unlock_outcome_maps_to_structured_trace_step() {
|
||||
use crate::aacs::UnlockOutcome;
|
||||
use crate::aacs::trace::UnlockOutcome;
|
||||
use freemkv_unlock::UnlockError;
|
||||
// The libfreemkv-side no-cert case carries the MKB generation.
|
||||
assert_eq!(
|
||||
|
||||
+1088
-180
File diff suppressed because it is too large
Load Diff
+3639
File diff suppressed because it is too large
Load Diff
-1670
File diff suppressed because it is too large
Load Diff
+4556
-2945
File diff suppressed because it is too large
Load Diff
-1705
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,505 @@
|
||||
//! Normalized, format-agnostic disc profile — a thin typed VIEW over the scanned
|
||||
//! [`Disc`] model.
|
||||
//!
|
||||
//! The scan already normalizes every source (DVD/BD/UHD/HD-DVD) into the same
|
||||
//! [`Disc`] / [`DiscTitle`] / [`Stream`] shapes, carrying language / forced /
|
||||
//! qualifier / purpose / secondary / label per stream. This module hoists those
|
||||
//! into a flat, serde-friendly surface so a consumer reads
|
||||
//! `profile.titles[i].subtitles[j].forced` (and the analogous audio/video flags)
|
||||
//! for ANY disc with no per-format conditionals.
|
||||
//!
|
||||
//! Everything here is DERIVED from the model at construction: the enum `Stream`
|
||||
//! is split into three typed vectors, the `qualifier` / `purpose` enums are
|
||||
//! decomposed into booleans, and the "default track" selection is precomputed so
|
||||
//! downstream never recomputes it. Every field is always populated — a sensible
|
||||
//! default (`"und"` language, empty `name`, `false` flag) stands in where the
|
||||
//! model has nothing, so consumers never handle a bare `Option`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AudioStream, Disc, DiscFormat, DiscTitle, LabelPurpose, LabelQualifier, Stream, SubtitleStream,
|
||||
VideoStream,
|
||||
};
|
||||
|
||||
/// A disc's complete normalized profile: identity plus every title's typed
|
||||
/// track breakdown, with the main-feature selection hoisted to `main_title`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DiscProfile {
|
||||
/// Disc container family (`"bluray"`, `"uhd"`, `"dvd"`, `"hddvd"`, …).
|
||||
pub format: String,
|
||||
/// Best available display name (disc metadata title, else volume id).
|
||||
pub disc_name: String,
|
||||
/// Stable disc identifier (the UDF volume identifier).
|
||||
pub disc_id: String,
|
||||
/// Index into `titles` of the selected main feature. The scan pre-sorts
|
||||
/// titles so `titles[0]` is the main feature, so this is `0` whenever any
|
||||
/// title exists.
|
||||
pub main_title: usize,
|
||||
/// Every title, in the scan's main-feature order.
|
||||
pub titles: Vec<TitleProfile>,
|
||||
}
|
||||
|
||||
/// One title's normalized profile: identity, duration/size, chapter count, the
|
||||
/// main-feature flag, and its streams split into typed per-kind vectors.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TitleProfile {
|
||||
/// Position of this title within [`DiscProfile::titles`].
|
||||
pub index: usize,
|
||||
/// Playlist / program identifier (e.g. `"00800.mpls"`).
|
||||
pub playlist: String,
|
||||
/// Duration in seconds.
|
||||
pub duration_secs: f64,
|
||||
/// Total size in bytes.
|
||||
pub size_bytes: u64,
|
||||
/// Number of chapter points.
|
||||
pub chapters: usize,
|
||||
/// Whether this is the disc's main feature (`index == main_title`).
|
||||
pub is_main: bool,
|
||||
/// Video tracks, in declared order.
|
||||
pub video: Vec<VideoTrack>,
|
||||
/// Audio tracks, in declared order.
|
||||
pub audio: Vec<AudioTrack>,
|
||||
/// Subtitle tracks, in declared order.
|
||||
pub subtitles: Vec<SubtitleTrack>,
|
||||
}
|
||||
|
||||
/// A normalized video track.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VideoTrack {
|
||||
/// Compact codec id (e.g. `"hevc"`, `"h264"`, `"vc1"`, `"mpeg2"`).
|
||||
pub codec: String,
|
||||
/// Resolution label (e.g. `"2160p"`, `"1080p"`, `"576i"`).
|
||||
pub resolution: String,
|
||||
/// HDR format id (e.g. `"sdr"`, `"hdr10"`, `"hdr10+"`, `"dv"`, `"hlg"`).
|
||||
pub hdr: String,
|
||||
/// Frame-rate label (e.g. `"23.976"`, `"25"`).
|
||||
pub frame_rate: String,
|
||||
/// Whether this is the title's default video track (first non-secondary).
|
||||
pub default: bool,
|
||||
/// Whether this is a secondary stream (PiP / dependent view).
|
||||
pub secondary: bool,
|
||||
}
|
||||
|
||||
/// A normalized audio track.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AudioTrack {
|
||||
/// ISO 639-2 language code, `"und"` when the source stated none.
|
||||
pub language: String,
|
||||
/// Compact codec id (e.g. `"truehd"`, `"dtshd_ma"`, `"ac3"`).
|
||||
pub codec: String,
|
||||
/// Channel layout label (e.g. `"stereo"`, `"5.1"`, `"unknown"`).
|
||||
pub channels: String,
|
||||
/// Whether this is the title's default audio track (first non-secondary).
|
||||
pub default: bool,
|
||||
/// Editorial purpose flag: commentary track.
|
||||
pub commentary: bool,
|
||||
/// Editorial purpose flag: descriptive / audio-description track.
|
||||
pub descriptive: bool,
|
||||
/// Codec / variant text label; empty when the source stated none.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// A normalized subtitle track.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SubtitleTrack {
|
||||
/// ISO 639-2 language code, `"und"` when the source stated none.
|
||||
pub language: String,
|
||||
/// Compact codec id (e.g. `"pgs"`, `"dvdsub"`).
|
||||
pub codec: String,
|
||||
/// Forced-narrative flag.
|
||||
pub forced: bool,
|
||||
/// SDH (subtitles for the deaf / hard-of-hearing) flag.
|
||||
pub sdh: bool,
|
||||
/// Whether this is the title's default subtitle track. Always `false`: the
|
||||
/// mux never marks a subtitle default, and the normalized view mirrors that.
|
||||
pub default: bool,
|
||||
/// Track name; empty (the subtitle model carries no free-text label).
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Container family as a compact, stable id. Kept here (not on [`DiscFormat`])
|
||||
/// because it is a serialization concern of this view.
|
||||
fn format_id(format: DiscFormat) -> &'static str {
|
||||
match format {
|
||||
DiscFormat::Uhd => "uhd",
|
||||
DiscFormat::Fmts => "uhd_fmts",
|
||||
DiscFormat::BluRay => "bluray",
|
||||
DiscFormat::HdDvd => "hddvd",
|
||||
DiscFormat::Dvd => "dvd",
|
||||
DiscFormat::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// `"und"` for an empty language code, else the code unchanged. Mirrors the
|
||||
/// muxer's `language_or_und`: the one place a source with no language table has
|
||||
/// to fall back to the ISO 639-2 "undetermined" code.
|
||||
fn language_or_und(lang: &str) -> String {
|
||||
if lang.is_empty() {
|
||||
"und".to_string()
|
||||
} else {
|
||||
lang.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoTrack {
|
||||
fn from_stream(v: &VideoStream, default: bool) -> Self {
|
||||
Self {
|
||||
codec: v.codec.id().to_string(),
|
||||
resolution: v.resolution.to_string(),
|
||||
hdr: v.hdr.id().to_string(),
|
||||
frame_rate: v.frame_rate.to_string(),
|
||||
default,
|
||||
secondary: v.secondary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioTrack {
|
||||
fn from_stream(a: &AudioStream, default: bool) -> Self {
|
||||
Self {
|
||||
language: language_or_und(&a.language),
|
||||
codec: a.codec.id().to_string(),
|
||||
channels: a.channels.to_string(),
|
||||
default,
|
||||
commentary: a.purpose == LabelPurpose::Commentary,
|
||||
descriptive: a.purpose == LabelPurpose::Descriptive,
|
||||
name: a.label.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SubtitleTrack {
|
||||
fn from_stream(s: &SubtitleStream) -> Self {
|
||||
Self {
|
||||
language: language_or_und(&s.language),
|
||||
codec: s.codec.id().to_string(),
|
||||
// The authoritative forced flag (`forced`, set by the content probe /
|
||||
// STN) OR the label-derived qualifier — either is sufficient.
|
||||
forced: s.forced || s.qualifier == LabelQualifier::Forced,
|
||||
sdh: s.qualifier == LabelQualifier::Sdh,
|
||||
// The mux never defaults a subtitle track; mirror that here.
|
||||
default: false,
|
||||
name: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TitleProfile {
|
||||
/// Build a title's profile. `index` / `is_main` are disc-level facts the
|
||||
/// caller supplies (see [`DiscProfile::from_disc`]); the streams are split
|
||||
/// and the per-kind default track is hoisted here.
|
||||
pub fn from_title(title: &DiscTitle, index: usize, is_main: bool) -> Self {
|
||||
let mut video = Vec::new();
|
||||
let mut audio = Vec::new();
|
||||
let mut subtitles = Vec::new();
|
||||
// "First non-secondary is the default; keep only the first" — the same
|
||||
// rule the Matroska path applies (`is_default = !secondary`, then only
|
||||
// the first video and first audio survive as default).
|
||||
let mut video_default_taken = false;
|
||||
let mut audio_default_taken = false;
|
||||
for s in &title.streams {
|
||||
match s {
|
||||
Stream::Video(v) => {
|
||||
let default = !v.secondary && !video_default_taken;
|
||||
video_default_taken |= default;
|
||||
video.push(VideoTrack::from_stream(v, default));
|
||||
}
|
||||
Stream::Audio(a) => {
|
||||
let default = !a.secondary && !audio_default_taken;
|
||||
audio_default_taken |= default;
|
||||
audio.push(AudioTrack::from_stream(a, default));
|
||||
}
|
||||
Stream::Subtitle(t) => subtitles.push(SubtitleTrack::from_stream(t)),
|
||||
}
|
||||
}
|
||||
Self {
|
||||
index,
|
||||
playlist: title.playlist.clone(),
|
||||
duration_secs: title.duration_secs,
|
||||
size_bytes: title.size_bytes,
|
||||
chapters: title.chapters.len(),
|
||||
is_main,
|
||||
video,
|
||||
audio,
|
||||
subtitles,
|
||||
}
|
||||
}
|
||||
|
||||
/// The title's video tracks.
|
||||
pub fn video(&self) -> &[VideoTrack] {
|
||||
&self.video
|
||||
}
|
||||
|
||||
/// The title's audio tracks.
|
||||
pub fn audio(&self) -> &[AudioTrack] {
|
||||
&self.audio
|
||||
}
|
||||
|
||||
/// The title's subtitle tracks.
|
||||
pub fn subtitles(&self) -> &[SubtitleTrack] {
|
||||
&self.subtitles
|
||||
}
|
||||
}
|
||||
|
||||
impl DiscProfile {
|
||||
/// Build the normalized profile from a scanned [`Disc`].
|
||||
pub fn from_disc(disc: &Disc) -> Self {
|
||||
// The scan pre-sorts titles so `titles[0]` is the selected main feature
|
||||
// (`sort_titles_by_main_feature`), so the main title is index 0 whenever
|
||||
// any title exists.
|
||||
let main_title = 0;
|
||||
let has_titles = !disc.titles.is_empty();
|
||||
let titles = disc
|
||||
.titles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| TitleProfile::from_title(t, i, has_titles && i == main_title))
|
||||
.collect();
|
||||
Self {
|
||||
format: format_id(disc.format).to_string(),
|
||||
disc_name: disc
|
||||
.meta_title
|
||||
.clone()
|
||||
.unwrap_or_else(|| disc.volume_id.clone()),
|
||||
disc_id: disc.volume_id.clone(),
|
||||
main_title,
|
||||
titles,
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected main-feature title, or `None` when the disc scanned to zero
|
||||
/// titles (a data-only image with no /BDMV, /HVDVD_TS or /VIDEO_TS). Never
|
||||
/// panics — mirrors the `Option`-returning main-feature accessors elsewhere
|
||||
/// (`titles.first()`, `dvdnav::resolve_main_title`).
|
||||
pub fn main_title(&self) -> Option<&TitleProfile> {
|
||||
self.titles.get(self.main_title)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Disc> for DiscProfile {
|
||||
fn from(disc: &Disc) -> Self {
|
||||
DiscProfile::from_disc(disc)
|
||||
}
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
/// This disc's normalized, format-agnostic [`DiscProfile`].
|
||||
pub fn profile(&self) -> DiscProfile {
|
||||
DiscProfile::from_disc(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::{
|
||||
AudioChannels, AudioStream, Codec, ColorSpace, ContentFormat, DiscRegion, DiscTitle,
|
||||
FrameRate, HdrFormat, Resolution, SampleRate, Stream, SubtitleStream, VideoStream,
|
||||
};
|
||||
|
||||
/// A minimal titleless [`Disc`] for the disc-level tests.
|
||||
fn test_disc() -> Disc {
|
||||
Disc {
|
||||
volume_id: String::new(),
|
||||
meta_title: None,
|
||||
format: DiscFormat::BluRay,
|
||||
capacity_sectors: 0,
|
||||
capacity_bytes: 0,
|
||||
layers: 1,
|
||||
titles: Vec::new(),
|
||||
region: DiscRegion::Free,
|
||||
aacs: None,
|
||||
css: None,
|
||||
encrypted: false,
|
||||
aacs_error: None,
|
||||
css_error: None,
|
||||
content_format: ContentFormat::BdTs,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_title_is_none_on_empty_disc() {
|
||||
// A data-only image (no /BDMV, /HVDVD_TS, /VIDEO_TS) scans to zero
|
||||
// titles; main_title() must return None, not panic on titles[0].
|
||||
let profile = DiscProfile::from_disc(&test_disc());
|
||||
assert!(profile.titles.is_empty());
|
||||
assert!(profile.main_title().is_none());
|
||||
}
|
||||
|
||||
fn video(codec: Codec, res: Resolution, secondary: bool) -> Stream {
|
||||
Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec,
|
||||
resolution: res,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Hdr10,
|
||||
color_space: ColorSpace::Bt2020,
|
||||
display_aspect: None,
|
||||
secondary,
|
||||
label: String::new(),
|
||||
measured_cicp: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn audio(lang: &str, secondary: bool, purpose: LabelPurpose, label: &str) -> Stream {
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::TrueHd,
|
||||
channels: AudioChannels::Stereo,
|
||||
language: lang.into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary,
|
||||
purpose,
|
||||
label: label.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn subtitle(lang: &str, forced: bool, qualifier: LabelQualifier) -> Stream {
|
||||
Stream::Subtitle(SubtitleStream {
|
||||
pid: 0x1200,
|
||||
codec: Codec::Pgs,
|
||||
language: lang.into(),
|
||||
forced,
|
||||
qualifier,
|
||||
codec_data: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A BD transport-stream title with a mix of streams: two audio tracks
|
||||
/// (main + commentary), two subtitles (forced + SDH), a video track.
|
||||
fn bdts_title() -> DiscTitle {
|
||||
let mut t = DiscTitle::empty();
|
||||
t.content_format = ContentFormat::BdTs;
|
||||
t.playlist = "00800.mpls".into();
|
||||
t.duration_secs = 7200.0;
|
||||
t.size_bytes = 30_000_000_000;
|
||||
t.streams = vec![
|
||||
video(Codec::Hevc, Resolution::R2160p, false),
|
||||
audio("eng", false, LabelPurpose::Normal, "Dolby TrueHD 5.1"),
|
||||
audio("eng", true, LabelPurpose::Commentary, "Director"),
|
||||
subtitle("eng", true, LabelQualifier::Forced),
|
||||
subtitle("eng", false, LabelQualifier::Sdh),
|
||||
];
|
||||
t
|
||||
}
|
||||
|
||||
/// A DVD (MPEG program stream) title: two audio tracks (both non-secondary,
|
||||
/// second is descriptive), one language-less subtitle, one video track.
|
||||
fn dvd_title() -> DiscTitle {
|
||||
let mut t = DiscTitle::empty();
|
||||
t.content_format = ContentFormat::MpegPs;
|
||||
t.playlist = "VTS_01".into();
|
||||
t.duration_secs = 5400.0;
|
||||
t.size_bytes = 4_000_000_000;
|
||||
t.streams = vec![
|
||||
video(Codec::Mpeg2, Resolution::R576i, false),
|
||||
audio("eng", false, LabelPurpose::Normal, ""),
|
||||
audio("eng", false, LabelPurpose::Descriptive, ""),
|
||||
subtitle("", false, LabelQualifier::None),
|
||||
];
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_streams_into_typed_vectors_across_formats() {
|
||||
for title in [bdts_title(), dvd_title()] {
|
||||
let p = TitleProfile::from_title(&title, 0, true);
|
||||
assert_eq!(p.video.len(), 1, "one video track");
|
||||
assert_eq!(p.audio.len(), 2, "two audio tracks");
|
||||
assert_eq!(p.subtitles.len(), 1.max(p.subtitles.len()));
|
||||
// Accessors return the same vectors.
|
||||
assert_eq!(p.video(), p.video.as_slice());
|
||||
assert_eq!(p.audio(), p.audio.as_slice());
|
||||
assert_eq!(p.subtitles(), p.subtitles.as_slice());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hoists_default_flags_first_non_secondary() {
|
||||
let p = TitleProfile::from_title(&bdts_title(), 0, true);
|
||||
// First (non-secondary) video/audio is the default.
|
||||
assert!(p.video[0].default, "first video defaults");
|
||||
assert!(p.audio[0].default, "first non-secondary audio defaults");
|
||||
// The commentary track is secondary → never default.
|
||||
assert!(
|
||||
!p.audio[1].default,
|
||||
"secondary/commentary audio not default"
|
||||
);
|
||||
// Subtitles are never default.
|
||||
assert!(p.subtitles.iter().all(|s| !s.default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_default_cleared_when_two_non_secondary_audio() {
|
||||
// DVD title: BOTH audio tracks are non-secondary. Only the first keeps
|
||||
// the default flag (mirrors the muxer's "keep only first default").
|
||||
let p = TitleProfile::from_title(&dvd_title(), 0, true);
|
||||
assert!(p.audio[0].default, "first audio default");
|
||||
assert!(
|
||||
!p.audio[1].default,
|
||||
"second non-secondary audio default cleared"
|
||||
);
|
||||
assert!(p.audio[1].descriptive, "second audio is descriptive");
|
||||
assert!(!p.audio[1].commentary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_qualifier_and_purpose_and_language_defaults() {
|
||||
let p = TitleProfile::from_title(&bdts_title(), 0, true);
|
||||
// Audio purpose → commentary/descriptive booleans.
|
||||
assert!(!p.audio[0].commentary && !p.audio[0].descriptive);
|
||||
assert!(p.audio[1].commentary);
|
||||
assert_eq!(p.audio[1].name, "Director", "label maps to name");
|
||||
// Subtitle qualifier → forced/sdh booleans.
|
||||
assert!(p.subtitles[0].forced && !p.subtitles[0].sdh);
|
||||
assert!(p.subtitles[1].sdh && !p.subtitles[1].forced);
|
||||
// Language default: DVD title's blank subtitle language → "und".
|
||||
let dvd = TitleProfile::from_title(&dvd_title(), 0, true);
|
||||
assert_eq!(dvd.subtitles[0].language, "und");
|
||||
assert_eq!(dvd.audio[0].language, "eng");
|
||||
// Codec/resolution/hdr flattened as compact ids/labels.
|
||||
assert_eq!(p.video[0].codec, "hevc");
|
||||
assert_eq!(p.video[0].resolution, "2160p");
|
||||
assert_eq!(p.video[0].hdr, "hdr10");
|
||||
assert_eq!(dvd.video[0].codec, "mpeg2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_disc_hoists_main_and_is_main() {
|
||||
let mut disc = test_disc();
|
||||
disc.format = DiscFormat::BluRay;
|
||||
disc.meta_title = Some("SOME MOVIE".into());
|
||||
disc.volume_id = "VOL_ID".into();
|
||||
disc.titles = vec![bdts_title(), dvd_title()];
|
||||
let profile = disc.profile();
|
||||
assert_eq!(profile.format, "bluray");
|
||||
assert_eq!(profile.disc_name, "SOME MOVIE");
|
||||
assert_eq!(profile.disc_id, "VOL_ID");
|
||||
assert_eq!(profile.main_title, 0);
|
||||
assert!(profile.titles[0].is_main, "titles[0] is the main feature");
|
||||
assert!(!profile.titles[1].is_main);
|
||||
assert_eq!(profile.titles[1].index, 1);
|
||||
assert_eq!(profile.main_title().unwrap().playlist, "00800.mpls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_name_falls_back_to_volume_id() {
|
||||
let mut disc = test_disc();
|
||||
disc.meta_title = None;
|
||||
disc.volume_id = "PLAIN_VOLUME".into();
|
||||
assert_eq!(disc.profile().disc_name, "PLAIN_VOLUME");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip() {
|
||||
let mut disc = test_disc();
|
||||
disc.format = DiscFormat::Dvd;
|
||||
disc.volume_id = "RT".into();
|
||||
disc.titles = vec![bdts_title(), dvd_title()];
|
||||
let profile = disc.profile();
|
||||
let json = serde_json::to_string(&profile).expect("serialize");
|
||||
let back: DiscProfile = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(profile, back, "profile round-trips through serde");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,232 +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 },
|
||||
|
||||
/// Post-read verify downgrade. The producer's `UnitVerifier` found that the
|
||||
/// just-`Finished` clip unit at `[pos, pos+len)` is confidently undecryptable
|
||||
/// (a silent bad read). The consumer re-records the range as `NonTrimmed` so
|
||||
/// the patch pass re-reads it — the ISO bytes (ciphertext) already written by
|
||||
/// the preceding `Good` are left in place for the patch to overwrite. FIFO
|
||||
/// pipe ordering guarantees this arrives AFTER the `Good` that wrote them.
|
||||
MarkBad { 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::MarkBad { pos, len } => {
|
||||
// Verify downgrade: the ISO bytes are already written by the
|
||||
// preceding Good; only the mapfile status changes so patch
|
||||
// re-reads this range. No file write.
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
-1021
File diff suppressed because it is too large
Load Diff
+6
-8
@@ -23,14 +23,12 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
if !id.raw_inquiry.is_empty()
|
||||
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||
{
|
||||
drives.push((path, id));
|
||||
}
|
||||
}
|
||||
if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path))
|
||||
&& let Ok(id) = DriveId::from_drive(transport.as_mut())
|
||||
&& !id.raw_inquiry.is_empty()
|
||||
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||
{
|
||||
drives.push((path, id));
|
||||
}
|
||||
}
|
||||
drives
|
||||
|
||||
+35
-6
@@ -25,12 +25,11 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let path = std::path::Path::new(&info.path);
|
||||
match crate::scsi::open(path) {
|
||||
Ok(mut transport) => {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
if !id.raw_inquiry.is_empty()
|
||||
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||
{
|
||||
drives.push((info.path.clone(), id));
|
||||
}
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut())
|
||||
&& !id.raw_inquiry.is_empty()
|
||||
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||
{
|
||||
drives.push((info.path.clone(), id));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -53,3 +52,33 @@ pub fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> {
|
||||
}
|
||||
Ok((path.to_string(), DeviceResolution::Direct))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod resolve_device_tests {
|
||||
use super::*;
|
||||
|
||||
/// An existing path resolves unchanged as `Direct` — macOS has no
|
||||
/// `sr`->`sg` substitution, so the returned path must be byte-identical
|
||||
/// to the input, not some canonicalised/mutated form.
|
||||
#[test]
|
||||
fn existing_path_resolves_direct_unchanged() {
|
||||
// Use the test binary's own executable path: guaranteed to exist,
|
||||
// no fixture file needed.
|
||||
let exe = std::env::current_exe().unwrap();
|
||||
let path = exe.to_str().unwrap();
|
||||
let (resolved, kind) = resolve_device(path).expect("existing path must resolve");
|
||||
assert_eq!(resolved, path, "path must be returned unchanged");
|
||||
assert_eq!(kind, DeviceResolution::Direct);
|
||||
}
|
||||
|
||||
/// A path that does not exist must error with `DeviceNotFound` carrying
|
||||
/// the original path, never silently succeed.
|
||||
#[test]
|
||||
fn missing_path_is_device_not_found() {
|
||||
let path = "/dev/freemkv-definitely-does-not-exist-0xdead";
|
||||
match resolve_device(path) {
|
||||
Err(Error::DeviceNotFound { path: p }) => assert_eq!(p, path),
|
||||
other => panic!("expected DeviceNotFound, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1267
-142
File diff suppressed because it is too large
Load Diff
+8
-5
@@ -6,13 +6,16 @@
|
||||
//!
|
||||
//! Byte layout follows the DVD-Video specification (VMGI/VTSI headers,
|
||||
//! PGC/cell tables, PCI/HLI button packets); the VM command decoder is
|
||||
//! verified against libdvdnav's decoder.
|
||||
//! verified against real discs.
|
||||
//!
|
||||
//! Current contents: [`vmcmd`] — the VM command decoder (proven against the
|
||||
//! SOTL/Greenland test discs). The IFO/PCI parsing and the navigation executor
|
||||
//! that resolves the start cell build on top of this.
|
||||
//! Current contents: [`vmcmd`] — the VM command decoder; [`nav`] — the
|
||||
//! First-Play navigation executor that resolves which title the disc's own
|
||||
//! navigation selects as the feature.
|
||||
|
||||
pub mod vmcmd;
|
||||
pub(crate) mod nav;
|
||||
pub(crate) mod vmcmd;
|
||||
|
||||
pub(crate) use nav::resolve_main_title;
|
||||
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
//! DVD-Video navigation executor — the DVD arm of "mimic a real player" title
|
||||
//! selection (issue #40).
|
||||
//!
|
||||
//! A player enters a disc at the **First-Play PGC** (FP_PGC): a short program of
|
||||
//! VM commands (parsed by [`super::vmcmd`]) that runs before any menu draws and
|
||||
//! typically dispatches straight to the feature title. This module reads that
|
||||
//! program out of `VIDEO_TS.IFO` (VMGI), executes its pre-command list on a
|
||||
//! minimal register machine, and — when the program deterministically reaches a
|
||||
//! title dispatch (`JumpTT`) — maps the selected VMG title through the title
|
||||
//! search pointer table (`TT_SRPT`) to its `(VTS number, title-in-set)`
|
||||
//! coordinates. That is the title a player would land on, so DVD title
|
||||
//! selection can prefer it over the structural leading-cell heuristic.
|
||||
//!
|
||||
//! Contract: this is a strict improvement over the fallback. Any parse error,
|
||||
//! interactive/menu-only entry, or non-convergence yields `None`, and the caller
|
||||
//! keeps today's behaviour. Every table count and offset is attacker-controlled,
|
||||
//! so all arithmetic is checked and all reads are bounds-guarded — the entry
|
||||
//! point [`resolve_from_vmg`] never panics on any input (exercised by the
|
||||
//! never-panic harness).
|
||||
|
||||
use super::vmcmd::{self, Compare, Instr};
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
// ── VMGI management-table byte offsets into VIDEO_TS.IFO (DVD-Video spec) ─────
|
||||
//
|
||||
// The VMGI_MAT header carries these fixed offsets. The values they hold are
|
||||
// per-disc; the offsets are constant.
|
||||
const VMGI_MAGIC: &[u8; 12] = b"DVDVIDEO-VMG";
|
||||
/// u32 **byte** offset (from the start of VIDEO_TS.IFO) of the First-Play PGC.
|
||||
const VMGI_FP_PGC_PTR: usize = 0x84;
|
||||
/// u32 **sector** offset (from the start of VIDEO_TS.IFO) of TT_SRPT.
|
||||
const VMGI_TT_SRPT_PTR: usize = 0xC4;
|
||||
|
||||
/// Within a PGC, the command-table pointer is a u16 at `PGC + 0xE4` giving the
|
||||
/// command table's byte offset relative to the PGC start (DVD-Video PGC layout;
|
||||
/// the same layout `ifo::parse_pgc` reads for cell/program tables).
|
||||
const PGC_CMD_TBL_PTR: usize = 0xE4;
|
||||
|
||||
/// Upper bound on commands executed before declaring non-convergence. A
|
||||
/// conformant First-Play routine reaches a title in a handful of steps; the cap
|
||||
/// stops a crafted command list (self-`Goto`, mutual jumps) from spinning.
|
||||
const STEP_BUDGET: usize = 1024;
|
||||
|
||||
/// Maximum commands honoured in one PGC command list. The DVD-Video VM caps a
|
||||
/// list at 128 pre / 128 post / 128 cell commands; the on-disc count is an
|
||||
/// untrusted u16, so it is clamped to this format maximum.
|
||||
const MAX_CMDS: usize = 128;
|
||||
|
||||
/// Maximum TT_SRPT entries honoured — the DVD-Video 99-title format maximum
|
||||
/// (mirrors `ifo::MAX_TT_SRPT_TITLES`; the on-disc count is an untrusted u16).
|
||||
const MAX_TT_SRPT_TITLES: usize = 99;
|
||||
|
||||
/// The title the First-Play navigation selects, in VMG coordinates.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ResolvedTitle {
|
||||
/// 1-based VMG title number (the TT_SRPT index the `JumpTT` named).
|
||||
pub title: u8,
|
||||
/// 1-based VTS (title set) number the title lives in.
|
||||
pub vtsn: u8,
|
||||
/// 1-based title-within-set number (TT_SRPT `VTS_TTN`).
|
||||
pub vts_ttn: u8,
|
||||
}
|
||||
|
||||
// ── Bounds-guarded big-endian reads (return None past the end) ────────────────
|
||||
|
||||
#[inline]
|
||||
fn u16_at(b: &[u8], o: usize) -> Option<u16> {
|
||||
Some(u16::from_be_bytes([*b.get(o)?, *b.get(o.checked_add(1)?)?]))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn u32_at(b: &[u8], o: usize) -> Option<u32> {
|
||||
Some(u32::from_be_bytes([
|
||||
*b.get(o)?,
|
||||
*b.get(o.checked_add(1)?)?,
|
||||
*b.get(o.checked_add(2)?)?,
|
||||
*b.get(o.checked_add(3)?)?,
|
||||
]))
|
||||
}
|
||||
|
||||
/// A register operand index names a system parameter (SPRM) when it carries the
|
||||
/// `0x80` bias — matching [`Vm::reg`]'s split of GPRM (`< 128`) vs SPRM
|
||||
/// (`>= 128`). Only SPRM reads are session-specific and therefore taint a
|
||||
/// compare; GPRM reads do not.
|
||||
#[inline]
|
||||
fn is_sprm(idx: u8) -> bool {
|
||||
idx >= 128
|
||||
}
|
||||
|
||||
// ── Minimal navigation register machine ──────────────────────────────────────
|
||||
|
||||
/// The subset of the DVD-Video VM register file the First-Play resolver needs:
|
||||
/// 16 general parameters (GPRMs) and the system parameters (SPRMs). Execution
|
||||
/// starts from a cold machine (all zero) — the same starting point a player has
|
||||
/// before any user interaction. The SPRMs a First-Play routine might branch on
|
||||
/// (region, parental level, language) are player-/session-specific and unknown
|
||||
/// at scan time; leaving them zero means the resolver only commits to a title
|
||||
/// when the program reaches one *without* depending on them (an unconditional
|
||||
/// dispatch, or a conditional whose zero-register path a player also takes).
|
||||
/// When a branch genuinely depends on an unknown SPRM the routine is treated as
|
||||
/// undecidable and the caller falls back — see the module contract.
|
||||
struct Vm {
|
||||
gprm: [u16; 16],
|
||||
sprm: [u16; 24],
|
||||
/// Per-GPRM taint: set when the register holds a value derived from a system
|
||||
/// parameter (SPRM). An SPRM read taints a compare directly ([`is_sprm`]);
|
||||
/// this bit tracks an SPRM *laundered through* a GPRM store so a later
|
||||
/// GPRM-only compare that reads it is still recognised as undecidable.
|
||||
gprm_tainted: [bool; 16],
|
||||
}
|
||||
|
||||
impl Vm {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
gprm: [0; 16],
|
||||
sprm: [0; 24],
|
||||
gprm_tainted: [false; 16],
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a register *operand read* is session-specific: an SPRM read is
|
||||
/// always undecidable at scan time; a GPRM read is undecidable only when the
|
||||
/// register currently holds an SPRM-derived (tainted) value.
|
||||
fn reg_tainted(&self, idx: u8) -> bool {
|
||||
if is_sprm(idx) {
|
||||
true
|
||||
} else {
|
||||
self.gprm_tainted[(idx & 0x0F) as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a GPRM's post-state as unknown. Used when a store to it was guarded
|
||||
/// by an undecidable (tainted) predicate: whether the write happened is
|
||||
/// itself unknown, so the destination is conservatively tainted.
|
||||
fn taint_gprm(&mut self, reg: u8) {
|
||||
self.gprm_tainted[(reg & 0x0F) as usize] = true;
|
||||
}
|
||||
|
||||
/// Read a register operand. Per the VM command model, indices `< 16` are
|
||||
/// GPRMs and `>= 128` are SPRMs (0-23 after the `0x80` bias); anything out
|
||||
/// of range reads as 0 rather than panicking.
|
||||
fn reg(&self, idx: u8) -> u16 {
|
||||
if idx >= 128 {
|
||||
self.sprm.get((idx - 128) as usize).copied().unwrap_or(0)
|
||||
} else {
|
||||
self.gprm.get((idx & 0x0F) as usize).copied().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a compare predicate (op codes per [`Compare`]). Returns the
|
||||
/// predicate result together with a *taint* flag that is set when either
|
||||
/// operand read a system parameter (SPRM). An SPRM's value is
|
||||
/// player-/session-specific (region, parental level, language) and unknown
|
||||
/// at scan time, so a branch decision that depends on one is undecidable —
|
||||
/// the caller abstains rather than commit to the cold-power-on arm. A GPRM
|
||||
/// read taints only when that register was itself loaded from an SPRM
|
||||
/// (tracked by [`Vm::gprm_tainted`]); a GPRM holding a program-established
|
||||
/// value does not taint.
|
||||
fn eval(&self, c: &Compare) -> (bool, bool) {
|
||||
let mut tainted = self.reg_tainted(c.lhs_reg);
|
||||
let l = self.reg(c.lhs_reg);
|
||||
let r = if c.immediate {
|
||||
c.imm
|
||||
} else {
|
||||
tainted |= self.reg_tainted(c.rhs_reg);
|
||||
self.reg(c.rhs_reg)
|
||||
};
|
||||
let result = match c.op {
|
||||
1 => (l & r) != 0,
|
||||
2 => l == r,
|
||||
3 => l != r,
|
||||
4 => l >= r,
|
||||
5 => l > r,
|
||||
6 => l <= r,
|
||||
7 => l < r,
|
||||
_ => false,
|
||||
};
|
||||
(result, tainted)
|
||||
}
|
||||
|
||||
/// Apply a `SetGPRM`. Only the arithmetic/logic set-ops that a First-Play
|
||||
/// routine uses to prepare a dispatch are modelled; unmodelled ops
|
||||
/// (swap/rnd) leave the register unchanged (best-effort, never panic).
|
||||
fn set(&mut self, reg: u8, op: u8, immediate: bool, imm: u16, src: u8) {
|
||||
let idx = (reg & 0x0F) as usize;
|
||||
let v = if immediate { imm } else { self.reg(src) };
|
||||
// The value's taint follows its inputs: an immediate is concrete
|
||||
// (untainted); a register source carries its own taint ([`reg_tainted`]
|
||||
// covers both a direct SPRM read and a laundered GPRM).
|
||||
let src_tainted = !immediate && self.reg_tainted(src);
|
||||
let cur = self.gprm[idx];
|
||||
let cur_tainted = self.gprm_tainted[idx];
|
||||
// Each arm sets (new value, new taint). `mov` overwrites, so its taint is
|
||||
// the source's alone — this is what CLEARS a prior taint on a concrete
|
||||
// store. The accumulating ops fold the source into the current value, so
|
||||
// their taint is the union. Unmodelled ops leave value and taint intact.
|
||||
let (nv, nt) = match op {
|
||||
1 => (v, src_tainted), // mov
|
||||
3 => (cur.wrapping_add(v), cur_tainted || src_tainted), // add
|
||||
4 => (cur.wrapping_sub(v), cur_tainted || src_tainted), // sub
|
||||
5 => (cur.wrapping_mul(v), cur_tainted || src_tainted), // mul
|
||||
6 => (
|
||||
cur.checked_div(v).unwrap_or(cur),
|
||||
cur_tainted || src_tainted,
|
||||
), // div (÷0 → unchanged)
|
||||
7 => (
|
||||
cur.checked_rem(v).unwrap_or(cur),
|
||||
cur_tainted || src_tainted,
|
||||
), // mod (÷0 → unchanged)
|
||||
9 => (cur & v, cur_tainted || src_tainted), // and
|
||||
10 => (cur | v, cur_tainted || src_tainted), // or
|
||||
11 => (cur ^ v, cur_tainted || src_tainted), // xor
|
||||
_ => (cur, cur_tainted), // swap/rnd/unmodelled
|
||||
};
|
||||
self.gprm[idx] = nv;
|
||||
self.gprm_tainted[idx] = nt;
|
||||
}
|
||||
}
|
||||
|
||||
// ── VMGI structure readers ───────────────────────────────────────────────────
|
||||
|
||||
/// Extract the First-Play PGC pre-command list from VMGI bytes. Returns `None`
|
||||
/// when there is no First-Play PGC, no command table, or the table is out of
|
||||
/// bounds — all of which mean "nothing to execute" → fall back.
|
||||
fn fp_pre_commands(vmg: &[u8]) -> Option<Vec<[u8; 8]>> {
|
||||
let pgc = u32_at(vmg, VMGI_FP_PGC_PTR)? as usize;
|
||||
if pgc == 0 {
|
||||
return None; // no First-Play PGC on this disc
|
||||
}
|
||||
let cmd_ptr = u16_at(vmg, pgc.checked_add(PGC_CMD_TBL_PTR)?)? as usize;
|
||||
if cmd_ptr == 0 {
|
||||
return None; // First-Play PGC carries no command table
|
||||
}
|
||||
let tbl = pgc.checked_add(cmd_ptr)?;
|
||||
// Command table header: nr_of_pre (u16) at +0, then pre-commands at +8.
|
||||
let nr_pre = (u16_at(vmg, tbl)? as usize).min(MAX_CMDS);
|
||||
let base = tbl.checked_add(8)?;
|
||||
let mut cmds = Vec::with_capacity(nr_pre);
|
||||
for i in 0..nr_pre {
|
||||
let o = base.checked_add(i.checked_mul(8)?)?;
|
||||
if o.checked_add(8)? > vmg.len() {
|
||||
break; // truncated table — execute what parsed
|
||||
}
|
||||
let mut c = [0u8; 8];
|
||||
c.copy_from_slice(&vmg[o..o + 8]);
|
||||
cmds.push(c);
|
||||
}
|
||||
Some(cmds)
|
||||
}
|
||||
|
||||
/// Parse TT_SRPT into a per-title `(vtsn, vts_ttn)` list indexed by 1-based VMG
|
||||
/// title number. This is the mapping `JumpTT` needs: a title number → the VTS
|
||||
/// title-set coordinates that identify the corresponding scanned title.
|
||||
fn tt_srpt_titles(vmg: &[u8]) -> Option<Vec<(u8, u8)>> {
|
||||
let sector = u32_at(vmg, VMGI_TT_SRPT_PTR)? as usize;
|
||||
let base = sector.checked_mul(SECTOR_BYTES)?;
|
||||
let n = (u16_at(vmg, base)? as usize).min(MAX_TT_SRPT_TITLES);
|
||||
let entries = base.checked_add(8)?;
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
// Title entries are 12 bytes: VTSN at +6, VTS_TTN at +7.
|
||||
let e = entries.checked_add(i.checked_mul(12)?)?;
|
||||
if e.checked_add(12)? > vmg.len() {
|
||||
break; // truncated — map what parsed
|
||||
}
|
||||
out.push((vmg[e + 6], vmg[e + 7]));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
// ── First-Play executor ──────────────────────────────────────────────────────
|
||||
|
||||
/// Execute the First-Play pre-command list and return the VMG title number it
|
||||
/// dispatches to, if it reaches one deterministically. Returns `None` for a
|
||||
/// program that ends in a menu/sub-domain, links within the menu domain, exits,
|
||||
/// or does not converge inside the step budget — every case the caller falls
|
||||
/// back on.
|
||||
fn run_first_play(cmds: &[[u8; 8]]) -> Option<u8> {
|
||||
let mut vm = Vm::new();
|
||||
let mut pc = 0usize;
|
||||
let mut budget = STEP_BUDGET;
|
||||
|
||||
while pc < cmds.len() {
|
||||
if budget == 0 {
|
||||
return None; // non-convergence guard
|
||||
}
|
||||
budget -= 1;
|
||||
|
||||
let cmd = vmcmd::decode(&cmds[pc]);
|
||||
|
||||
// A guarded command's predicate decides whether its instruction runs.
|
||||
// `taken` is the predicate result; `tainted` marks it undecidable at scan
|
||||
// time (it read a system parameter, directly or laundered through a
|
||||
// GPRM). A command with no predicate always runs and is never tainted.
|
||||
let (taken, tainted) = match cmd.compare {
|
||||
Some(cmp) => vm.eval(&cmp),
|
||||
None => (true, false),
|
||||
};
|
||||
|
||||
// A decidable false predicate skips this line — no effect, taint
|
||||
// unchanged. When the predicate is tainted we cannot decide `taken`, so
|
||||
// we do not skip; the per-instruction handling below is conservative.
|
||||
if !tainted && !taken {
|
||||
pc += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
match cmd.instr {
|
||||
// Title dispatch — a control transfer, the answer we are looking for.
|
||||
// Abstain only when an undecidable predicate gates it: committing
|
||||
// would pick the cold-power-on arm of an SPRM-dependent decision.
|
||||
Instr::JumpTt { ttn } => {
|
||||
if tainted {
|
||||
return None;
|
||||
}
|
||||
return Some(ttn);
|
||||
}
|
||||
|
||||
// Control flow within the pre list (Goto lines are 1-based) — also a
|
||||
// control transfer, so an undecidable guard abstains.
|
||||
Instr::Goto { line } => {
|
||||
if tainted {
|
||||
return None;
|
||||
}
|
||||
let l = line as usize;
|
||||
if l == 0 || l > cmds.len() {
|
||||
return None;
|
||||
}
|
||||
pc = l - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Register preparation before a dispatch — a store, not a control
|
||||
// transfer, so a tainted guard does NOT abstain. If the guard is
|
||||
// undecidable we do not know whether the store ran, so the
|
||||
// destination's post-state is unknown → taint it. Otherwise apply the
|
||||
// store (which itself propagates or clears the destination's taint).
|
||||
Instr::SetGprm {
|
||||
reg,
|
||||
op,
|
||||
immediate,
|
||||
imm,
|
||||
src,
|
||||
} => {
|
||||
if tainted {
|
||||
vm.taint_gprm(reg);
|
||||
} else {
|
||||
vm.set(reg, op, immediate, imm, src);
|
||||
}
|
||||
}
|
||||
|
||||
// No-effect (for resolution) instructions: keep executing. SetSystem
|
||||
// writes an SPRM (never a GPRM in this decoder), and SPRM reads are
|
||||
// caught at read time, so no GPRM taint is needed here.
|
||||
Instr::Nop | Instr::SetSystem => {}
|
||||
|
||||
// Everything else leaves the deterministically-followable path:
|
||||
// Break/Exit end the pre list with no title selected; the JumpSS
|
||||
// sub-domain jumps and the intra-domain Link ops land in a menu or
|
||||
// depend on an interactive button selection that cannot be resolved
|
||||
// statically. Abstain → caller falls back.
|
||||
_ => return None,
|
||||
}
|
||||
|
||||
pc += 1;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// ── Public entry points ──────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve the main-feature title from raw VMGI (`VIDEO_TS.IFO`) bytes.
|
||||
///
|
||||
/// Pure and total: any malformed/hostile input returns `None`, never panics.
|
||||
/// This is the harness entry point.
|
||||
pub fn resolve_from_vmg(vmg: &[u8]) -> Option<ResolvedTitle> {
|
||||
if vmg.len() < 0xC8 || &vmg[0..12] != VMGI_MAGIC {
|
||||
return None;
|
||||
}
|
||||
let cmds = fp_pre_commands(vmg)?;
|
||||
let ttn = run_first_play(&cmds)?;
|
||||
if ttn == 0 {
|
||||
return None; // title 0 is not addressable
|
||||
}
|
||||
let titles = tt_srpt_titles(vmg)?;
|
||||
let (vtsn, vts_ttn) = titles.get((ttn as usize) - 1).copied()?;
|
||||
if vtsn == 0 {
|
||||
return None; // invalid TT_SRPT entry
|
||||
}
|
||||
Some(ResolvedTitle {
|
||||
title: ttn,
|
||||
vtsn,
|
||||
vts_ttn,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the main-feature title by reading the disc's VMGI and following its
|
||||
/// First-Play navigation. Returns `None` (→ caller keeps the heuristic) when the
|
||||
/// IFO cannot be read or the navigation does not deterministically reach a
|
||||
/// title.
|
||||
pub fn resolve_main_title(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ResolvedTitle> {
|
||||
let vmg = udf.read_file(reader, "/VIDEO_TS/VIDEO_TS.IFO").ok()?;
|
||||
let resolved = resolve_from_vmg(&vmg)?;
|
||||
tracing::debug!(
|
||||
target: "freemkv::dvdnav",
|
||||
title = resolved.title,
|
||||
vtsn = resolved.vtsn,
|
||||
vts_ttn = resolved.vts_ttn,
|
||||
"nav resolved main-feature title from First-Play program"
|
||||
);
|
||||
Some(resolved)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Fixture builders ────────────────────────────────────────────────────
|
||||
|
||||
/// Assemble a VMGI (`VIDEO_TS.IFO`) image with a First-Play PGC whose
|
||||
/// pre-command list is `pre`, and a TT_SRPT at sector `tt_srpt_sector`
|
||||
/// mapping each title to `(vtsn, vts_ttn)`.
|
||||
///
|
||||
/// Layout: the fixed VMGI header (magic + the FP_PGC and TT_SRPT pointers),
|
||||
/// then the FP_PGC (with a command table holding `pre`), then the TT_SRPT at
|
||||
/// its sector. Kept minimal but spec-shaped so the same code path a real
|
||||
/// disc takes is exercised.
|
||||
fn build_vmgi(pre: &[[u8; 8]], tt_srpt_sector: u32, titles: &[(u8, u8)]) -> Vec<u8> {
|
||||
// Place the FP_PGC at a fixed byte offset past the header.
|
||||
let fp_pgc_off: u32 = 0x400;
|
||||
// Command table sits 0x100 bytes into the PGC.
|
||||
let cmd_tbl_rel: u16 = 0x100;
|
||||
|
||||
let mut v = vec![0u8; 0x800];
|
||||
v[0..12].copy_from_slice(VMGI_MAGIC);
|
||||
v[VMGI_FP_PGC_PTR..VMGI_FP_PGC_PTR + 4].copy_from_slice(&fp_pgc_off.to_be_bytes());
|
||||
v[VMGI_TT_SRPT_PTR..VMGI_TT_SRPT_PTR + 4].copy_from_slice(&tt_srpt_sector.to_be_bytes());
|
||||
|
||||
// FP_PGC: command-table pointer at +0xE4.
|
||||
let pgc = fp_pgc_off as usize;
|
||||
v[pgc + PGC_CMD_TBL_PTR..pgc + PGC_CMD_TBL_PTR + 2]
|
||||
.copy_from_slice(&cmd_tbl_rel.to_be_bytes());
|
||||
|
||||
// Command table: nr_of_pre at +0, pre-commands from +8.
|
||||
let tbl = pgc + cmd_tbl_rel as usize;
|
||||
v[tbl..tbl + 2].copy_from_slice(&(pre.len() as u16).to_be_bytes());
|
||||
for (i, c) in pre.iter().enumerate() {
|
||||
let o = tbl + 8 + i * 8;
|
||||
v[o..o + 8].copy_from_slice(c);
|
||||
}
|
||||
|
||||
// TT_SRPT at its sector: count at +0, 12-byte entries from +8.
|
||||
let base = tt_srpt_sector as usize * SECTOR_BYTES;
|
||||
let need = base + 8 + titles.len() * 12;
|
||||
if v.len() < need {
|
||||
v.resize(need, 0);
|
||||
}
|
||||
v[base..base + 2].copy_from_slice(&(titles.len() as u16).to_be_bytes());
|
||||
for (i, &(vtsn, vts_ttn)) in titles.iter().enumerate() {
|
||||
let e = base + 8 + i * 12;
|
||||
v[e + 6] = vtsn;
|
||||
v[e + 7] = vts_ttn;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn h(s: &str) -> [u8; 8] {
|
||||
let v: Vec<u8> = (0..8)
|
||||
.map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap())
|
||||
.collect();
|
||||
v.try_into().unwrap()
|
||||
}
|
||||
|
||||
// ── Executor: convergence to a title ────────────────────────────────────
|
||||
|
||||
/// Unconditional-dispatch shape: First-Play is an unconditional `JumpTT 1`,
|
||||
/// and TT_SRPT maps title 1 to the feature title set (here VTS_02, title 1).
|
||||
/// The resolver must return that title.
|
||||
#[test]
|
||||
fn first_play_jumptt_1_resolves_to_its_title_set() {
|
||||
// 3002...0001 = JumpTT ttn=1.
|
||||
let vmgi = build_vmgi(&[h("3002000000010000")], 1, &[(2, 1), (3, 1)]);
|
||||
assert_eq!(
|
||||
resolve_from_vmg(&vmgi),
|
||||
Some(ResolvedTitle {
|
||||
title: 1,
|
||||
vtsn: 2,
|
||||
vts_ttn: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// A First-Play that sets up a register and then unconditionally dispatches
|
||||
/// still resolves (the SetGPRM is a no-op for an unconditional JumpTT).
|
||||
#[test]
|
||||
fn setgprm_then_jumptt_resolves() {
|
||||
let vmgi = build_vmgi(
|
||||
&[h("7100000603e80000"), h("3002000000020000")],
|
||||
1,
|
||||
&[(1, 1), (4, 1)],
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_from_vmg(&vmgi),
|
||||
Some(ResolvedTitle {
|
||||
title: 2,
|
||||
vtsn: 4,
|
||||
vts_ttn: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// A conditional dispatch whose predicate is true on the cold (zero)
|
||||
/// machine is taken. `if g0 == 0 -> JumpTT 1` with g0 defaulting to 0.
|
||||
#[test]
|
||||
fn conditional_jumptt_true_on_cold_machine_is_taken() {
|
||||
// 30 22: jump with EQ compare (op=2), operands are registers b6/b7 both
|
||||
// 0 (g0 == g0) → true; JumpTT ttn=b5=1.
|
||||
let vmgi = build_vmgi(&[h("3022000000010000")], 1, &[(5, 1)]);
|
||||
assert_eq!(
|
||||
resolve_from_vmg(&vmgi),
|
||||
Some(ResolvedTitle {
|
||||
title: 1,
|
||||
vtsn: 5,
|
||||
vts_ttn: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// A conditional dispatch that is false falls through to the next line's
|
||||
/// unconditional dispatch.
|
||||
#[test]
|
||||
fn false_conditional_falls_through_to_next_dispatch() {
|
||||
// line 1: 30 32 with GT compare (op=3 = !=) g0(b6=1?) ... build a clearly
|
||||
// false compare: if g0 != g0 -> JumpTT 9 (false, skipped);
|
||||
// line 2: unconditional JumpTT 1.
|
||||
let vmgi = build_vmgi(
|
||||
&[h("3032000000090000"), h("3002000000010000")],
|
||||
1,
|
||||
&[(7, 1)],
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_from_vmg(&vmgi),
|
||||
Some(ResolvedTitle {
|
||||
title: 1,
|
||||
vtsn: 7,
|
||||
vts_ttn: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ── Executor: abstention (→ caller falls back) ──────────────────────────
|
||||
|
||||
/// Menu-entry shape: First-Play jumps into a VTS menu (`JumpSS VTSM root`).
|
||||
/// That is an interactive menu, not a static dispatch — the resolver must
|
||||
/// abstain so the caller keeps the leading-cell heuristic.
|
||||
#[test]
|
||||
fn first_play_into_menu_abstains() {
|
||||
// 3006...0183 = JumpSS VTSM root.
|
||||
let vmgi = build_vmgi(&[h("3006000101830000")], 1, &[(3, 1)]);
|
||||
assert_eq!(resolve_from_vmg(&vmgi), None);
|
||||
}
|
||||
|
||||
/// A dispatch gated on a system parameter (SPRM) is undecidable at scan
|
||||
/// time — the resolver must abstain rather than commit to the cold-power-on
|
||||
/// arm. Here `if SPRM0 == g0 -> JumpTT 1` is *true* on the cold machine
|
||||
/// (both read 0), so without SPRM taint-tracking the resolver would wrongly
|
||||
/// return title 1; with it, the SPRM read taints the branch and it abstains.
|
||||
#[test]
|
||||
fn sprm_gated_dispatch_abstains() {
|
||||
// 30 22: jump with EQ compare (op=2); if_v2 operands are registers
|
||||
// b6/b7. b6=0x80 = SPRM0 (session-specific), b7=0x00 = g0. ttn=b5=1.
|
||||
let vmgi = build_vmgi(&[h("3022000000018000")], 1, &[(2, 1)]);
|
||||
assert_eq!(resolve_from_vmg(&vmgi), None);
|
||||
}
|
||||
|
||||
/// The mirror of the SPRM case: a compare that reads only GPRMs is decidable
|
||||
/// and must NOT over-abstain. `if g0 == g1 -> JumpTT 1` is true on the cold
|
||||
/// machine and resolves to title 1 (no SPRM operand → no taint).
|
||||
#[test]
|
||||
fn gprm_only_compare_does_not_taint() {
|
||||
// 30 22: jump EQ; b6=0x00 = g0, b7=0x01 = g1 (both 0 on the cold
|
||||
// machine → equal). ttn=b5=1.
|
||||
let vmgi = build_vmgi(&[h("3022000000010001")], 1, &[(6, 1)]);
|
||||
assert_eq!(
|
||||
resolve_from_vmg(&vmgi),
|
||||
Some(ResolvedTitle {
|
||||
title: 1,
|
||||
vtsn: 6,
|
||||
vts_ttn: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// Defect-1 regression (over-abstention): a store guarded by an SPRM-tainted
|
||||
/// compare does NOT gate a control transfer, so it must not abstain. Here
|
||||
/// `if SPRM0 == g0 -> g0 = 5` (a guarded store) is followed by an
|
||||
/// UNCONDITIONAL `JumpTT 1`. The tainted predicate only decides whether the
|
||||
/// store runs; the dispatch that follows is SPRM-independent, so the resolver
|
||||
/// must resolve to title 1.
|
||||
#[test]
|
||||
fn sprm_guarded_store_then_unconditional_dispatch_resolves() {
|
||||
// line 0: 71 20 | lhs=b2=0x80 (SPRM0), cmp EQ(op=2, register), rhs=b7=0
|
||||
// (g0) → tainted guard; SetGPRM g0 = imm(bytes4-5)=5 (mov).
|
||||
// line 1: unconditional JumpTT 1.
|
||||
let vmgi = build_vmgi(
|
||||
&[h("7120800000050000"), h("3002000000010000")],
|
||||
1,
|
||||
&[(2, 1)],
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_from_vmg(&vmgi),
|
||||
Some(ResolvedTitle {
|
||||
title: 1,
|
||||
vtsn: 2,
|
||||
vts_ttn: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// Defect-2 regression (under-abstention): an SPRM laundered through a GPRM
|
||||
/// must still taint a later branch. `SetGPRM g0 = SPRM20` (register source,
|
||||
/// index >= 128) followed by `if g0 == g1 -> JumpTT 1` — the compare reads
|
||||
/// only GPRMs, but g0 now holds an SPRM-derived value, so the decision is
|
||||
/// undecidable and the resolver must abstain.
|
||||
#[test]
|
||||
fn sprm_laundered_through_gprm_abstains() {
|
||||
// line 0: 61 00 | SetGPRM g0 = SPRM20 (mov, register src=b5=0x94).
|
||||
// line 1: 30 22 | if g0 == g1 -> JumpTT 1 (both 0 on cold machine).
|
||||
let vmgi = build_vmgi(
|
||||
&[h("6100000000940000"), h("3022000000010001")],
|
||||
1,
|
||||
&[(2, 1)],
|
||||
);
|
||||
assert_eq!(resolve_from_vmg(&vmgi), None);
|
||||
}
|
||||
|
||||
/// Guard: an unconditional immediate store CLEARS a prior taint (no sticky
|
||||
/// latch). Taint g0 from an SPRM, overwrite it with an immediate, then branch
|
||||
/// on g0 — the branch is now decidable and must resolve, not abstain.
|
||||
#[test]
|
||||
fn unconditional_immediate_store_clears_taint() {
|
||||
// line 0: SetGPRM g0 = SPRM20 (taints g0).
|
||||
// line 1: SetGPRM g0 = imm 0 (mov, immediate) → clears taint, g0 = 0.
|
||||
// line 2: if g0 == g1 -> JumpTT 1 (both 0) → resolves.
|
||||
let vmgi = build_vmgi(
|
||||
&[
|
||||
h("6100000000940000"),
|
||||
h("7100000000000000"),
|
||||
h("3022000000010001"),
|
||||
],
|
||||
1,
|
||||
&[(2, 1)],
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_from_vmg(&vmgi),
|
||||
Some(ResolvedTitle {
|
||||
title: 1,
|
||||
vtsn: 2,
|
||||
vts_ttn: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// Guard: copying an UNtainted GPRM does not taint the destination. `g0 = g1`
|
||||
/// (register source, g1 untainted) then `if g0 == g2 -> JumpTT 1` must
|
||||
/// resolve (no SPRM ever entered g0).
|
||||
#[test]
|
||||
fn gprm_copy_of_untainted_gprm_does_not_taint() {
|
||||
// line 0: 61 00 | SetGPRM g0 = g1 (mov, register src=b5=1).
|
||||
// line 1: 30 22 | if g0 == g2 -> JumpTT 1 (both 0) → resolves.
|
||||
let vmgi = build_vmgi(
|
||||
&[h("6100000000010000"), h("3022000000010002")],
|
||||
1,
|
||||
&[(2, 1)],
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_from_vmg(&vmgi),
|
||||
Some(ResolvedTitle {
|
||||
title: 1,
|
||||
vtsn: 2,
|
||||
vts_ttn: 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty First-Play pre list selects no title.
|
||||
#[test]
|
||||
fn empty_first_play_abstains() {
|
||||
let vmgi = build_vmgi(&[], 1, &[(2, 1)]);
|
||||
assert_eq!(resolve_from_vmg(&vmgi), None);
|
||||
}
|
||||
|
||||
/// A JumpTT whose title number is past the TT_SRPT table resolves to no
|
||||
/// coordinates (→ abstain) rather than indexing out of bounds.
|
||||
#[test]
|
||||
fn jumptt_past_tt_srpt_abstains() {
|
||||
// JumpTT ttn=9 but TT_SRPT declares only 1 title.
|
||||
let vmgi = build_vmgi(&[h("3002000000090000")], 1, &[(2, 1)]);
|
||||
assert_eq!(resolve_from_vmg(&vmgi), None);
|
||||
}
|
||||
|
||||
/// A self-referential `Goto` cannot spin forever — the step budget stops it
|
||||
/// and the resolver abstains.
|
||||
#[test]
|
||||
fn self_goto_hits_budget_and_abstains() {
|
||||
// Goto line 1 (0-special sub 1), byte7 = 01.
|
||||
let vmgi = build_vmgi(&[h("0001000000000001")], 1, &[(2, 1)]);
|
||||
assert_eq!(resolve_from_vmg(&vmgi), None);
|
||||
}
|
||||
|
||||
// ── Robustness ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn bad_magic_abstains() {
|
||||
let mut vmgi = build_vmgi(&[h("3002000000010000")], 1, &[(2, 1)]);
|
||||
vmgi[0] = b'X';
|
||||
assert_eq!(resolve_from_vmg(&vmgi), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_short_inputs_never_panic() {
|
||||
// Below the header minimum, and a valid header truncated at every length.
|
||||
assert_eq!(resolve_from_vmg(&[]), None);
|
||||
assert_eq!(resolve_from_vmg(VMGI_MAGIC), None);
|
||||
let full = build_vmgi(&[h("3002000000010000")], 1, &[(2, 1)]);
|
||||
for len in 0..full.len() {
|
||||
let _ = resolve_from_vmg(&full[..len]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A TT_SRPT pointer that would overflow when scaled to a byte offset must
|
||||
/// be rejected, not wrap. Build a normal fixture, then overwrite just the
|
||||
/// TT_SRPT sector pointer with the extreme value (building a real table at
|
||||
/// that sector would allocate terabytes in the fixture, not the resolver).
|
||||
#[test]
|
||||
fn overflowing_tt_srpt_sector_abstains() {
|
||||
let mut vmgi = build_vmgi(&[h("3002000000010000")], 1, &[(2, 1)]);
|
||||
vmgi[VMGI_TT_SRPT_PTR..VMGI_TT_SRPT_PTR + 4].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(resolve_from_vmg(&vmgi), None);
|
||||
}
|
||||
}
|
||||
+41
-22
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! An 8-byte navigation command as found in PGC command tables (pre/post/cell)
|
||||
//! and PCI button info. Decoded per the DVD-Video VM instruction set and
|
||||
//! verified against libdvdnav's command decoder.
|
||||
//! verified against real discs.
|
||||
//!
|
||||
//! Bit model: the 8 bytes are a big-endian 64-bit word. `byte0` bits 7-5 are the
|
||||
//! command **type**; for type 1, `byte0` bit 4 selects Link (0) vs Jump (1), and
|
||||
@@ -133,7 +133,7 @@ const JP_JUMP_SS: u8 = 6;
|
||||
const JP_CALL_SS: u8 = 8;
|
||||
|
||||
// Link (type 1, direct=0) sub-commands. NOTE: sub-op 0 is NOP/no-link and 1 is
|
||||
// the LinkSub form (libdvdnav `decoder.c` `eval_link_instruction`).
|
||||
// the LinkSub form (the DVD-Video VM link instruction).
|
||||
const LK_SUB: u8 = 1;
|
||||
const LK_PGCN: u8 = 4;
|
||||
const LK_PTTN: u8 = 5;
|
||||
@@ -159,13 +159,15 @@ 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 libdvdnav `decoder.c`. The op
|
||||
// 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].
|
||||
// v1 (special + link): lhs reg = b[3]; rhs imm = bytes4-5 / rhs reg = b[5]
|
||||
// (the register operand is the low byte of the reg-or-immediate field).
|
||||
// v2 (jump + system-set): lhs reg = b[6]; rhs reg = b[7] (registers only).
|
||||
// v3 (set-GPRM): lhs reg = b[2]; rhs imm = bytes6-7 / rhs reg = b[6].
|
||||
// v3 (set-GPRM): lhs reg = b[2]; rhs imm = bytes6-7 / rhs reg = b[7]
|
||||
// (the register operand is the low byte of the reg-or-immediate field).
|
||||
fn if_v1(b: &[u8; 8]) -> Option<Compare> {
|
||||
let op = (b[1] >> 4) & 7;
|
||||
(op != 0).then(|| Compare {
|
||||
@@ -173,7 +175,7 @@ fn if_v1(b: &[u8; 8]) -> Option<Compare> {
|
||||
lhs_reg: b[3],
|
||||
immediate: b[1] >> 7 != 0,
|
||||
imm: be16(b, 4),
|
||||
rhs_reg: b[4],
|
||||
rhs_reg: b[5],
|
||||
})
|
||||
}
|
||||
fn if_v2(b: &[u8; 8]) -> Option<Compare> {
|
||||
@@ -193,7 +195,7 @@ fn if_v3(b: &[u8; 8]) -> Option<Compare> {
|
||||
lhs_reg: b[2],
|
||||
immediate: b[1] >> 7 != 0,
|
||||
imm: be16(b, 6),
|
||||
rhs_reg: b[6],
|
||||
rhs_reg: b[7],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -205,7 +207,7 @@ pub fn decode(b: &[u8; 8]) -> Command {
|
||||
let cmd = b[1] & 0x0F;
|
||||
|
||||
// Compare predicate, with the operand layout for this command family
|
||||
// (libdvdnav `decoder.c` `vm_eval_command` type dispatch).
|
||||
// (the DVD-Video VM command type dispatch).
|
||||
let compare = match (typ, direct) {
|
||||
(TYPE_SPECIAL, _) => if_v1(b),
|
||||
(TYPE_LINK_JUMP, 1) => if_v2(b), // jump
|
||||
@@ -295,16 +297,16 @@ mod tests {
|
||||
v.try_into().unwrap()
|
||||
}
|
||||
|
||||
// KATs taken from the real SOTL / Greenland discs (decoded in the PoC).
|
||||
// KATs taken from real-world First-Play command streams.
|
||||
#[test]
|
||||
fn greenland_first_play_is_jumptt_1() {
|
||||
fn first_play_jumptt_1_decodes() {
|
||||
let c = decode(&h("3002000000010000"));
|
||||
assert_eq!(c.instr, Instr::JumpTt { ttn: 1 });
|
||||
assert!(c.compare.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sotl_first_play_is_jumpss_vtsm_root() {
|
||||
fn first_play_jumpss_vtsm_root_decodes() {
|
||||
// 30 06 ... byte5=0x83 -> sub 2 (VTSM), vts=byte4=1, menu=byte5&0xF=3 (root)
|
||||
let c = decode(&h("3006000101830000"));
|
||||
assert_eq!(
|
||||
@@ -318,8 +320,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sotl_title_dispatch_is_conditional_linkpgn_2() {
|
||||
// 20 a6 ... CmpLink: if GPRM0 == 2 -> LinkPGN 2 (cell 2 = the 5:02 start)
|
||||
fn title_dispatch_is_conditional_linkpgn_2() {
|
||||
// 20 a6 ... CmpLink: if GPRM0 == 2 -> LinkPGN 2 (the cell where the
|
||||
// feature begins).
|
||||
let c = decode(&h("20a6000000020002"));
|
||||
assert_eq!(c.instr, Instr::LinkPgn { pgn: 2 });
|
||||
let cmp = c.compare.expect("conditional");
|
||||
@@ -330,7 +333,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sotl_root_button_is_linkpgcn_37() {
|
||||
fn root_button_is_linkpgcn_37() {
|
||||
assert_eq!(
|
||||
decode(&h("2004000000000025")).instr,
|
||||
Instr::LinkPgcn { pgcn: 37 }
|
||||
@@ -338,7 +341,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn greenland_scene_button_is_linkpgn() {
|
||||
fn scene_button_is_linkpgn() {
|
||||
assert_eq!(
|
||||
decode(&h("2006000000001401")).instr,
|
||||
Instr::LinkPgn { pgn: 1 }
|
||||
@@ -354,7 +357,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn setgprm_immediate_mov() {
|
||||
// SOTL First-Play pre[0]: 71 00 | reg=byte3=6 | imm(bytes4-5)=0x03e8 -> g6 = 1000
|
||||
// First-Play pre[0]: 71 00 | reg=byte3=6 | imm(bytes4-5)=0x03e8 -> g6 = 1000
|
||||
match decode(&h("7100000603e80000")).instr {
|
||||
Instr::SetGprm {
|
||||
reg,
|
||||
@@ -372,7 +375,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for the libdvdnav cross-check: link sub-op 0 = NOP, 1 = LinkSub.
|
||||
// Regression for the link sub-op decode: 0 = NOP, 1 = LinkSub.
|
||||
#[test]
|
||||
fn link_subop_zero_is_nop_one_is_linksub() {
|
||||
assert_eq!(decode(&h("2000000000000000")).instr, Instr::Nop);
|
||||
@@ -382,16 +385,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// if_version_1 register compare: rhs register is byte4 (not byte5).
|
||||
// if_version_1 register compare: the register operand is the LOW byte of the
|
||||
// reg-or-immediate field (byte5), NOT the high byte (byte4). Distinct values
|
||||
// in byte4 (0xAA) vs byte5 (0x05) pin the field down.
|
||||
#[test]
|
||||
fn link_register_compare_rhs_is_byte4() {
|
||||
// 20 26: link, cmp=EQ(2), dircmp=0(register) ; cmd=6 LinkPGN
|
||||
let c = decode(&h("2026000304000002"));
|
||||
fn link_register_compare_rhs_is_low_byte() {
|
||||
// 20 26: link, cmp=EQ(2), dircmp=0(register) ; cmd=6 LinkPGN (pgn=byte7=2)
|
||||
let c = decode(&h("20260003aa050002"));
|
||||
assert_eq!(c.instr, Instr::LinkPgn { pgn: 2 });
|
||||
let cmp = c.compare.expect("conditional");
|
||||
assert!(!cmp.immediate);
|
||||
assert_eq!(cmp.lhs_reg, 3);
|
||||
assert_eq!(cmp.rhs_reg, 4);
|
||||
assert_eq!(cmp.rhs_reg, 5); // low byte of bytes4-5, not the 0xAA high byte
|
||||
}
|
||||
|
||||
// if_version_3 (set-GPRM) register compare: the register operand is the LOW
|
||||
// byte of the reg-or-immediate field (byte7), NOT the high byte (byte6).
|
||||
// Distinct values in byte6 (0xBB) vs byte7 (0x07) pin the field down.
|
||||
#[test]
|
||||
fn setgprm_register_compare_rhs_is_low_byte() {
|
||||
// 71 20: set-GPRM (type 3), cmp=EQ(2), dircmp=0(register); lhs=byte2=2.
|
||||
let c = decode(&h("712002000000bb07"));
|
||||
let cmp = c.compare.expect("conditional");
|
||||
assert_eq!(cmp.op, 2);
|
||||
assert!(!cmp.immediate);
|
||||
assert_eq!(cmp.lhs_reg, 2);
|
||||
assert_eq!(cmp.rhs_reg, 7); // low byte of bytes6-7, not the 0xBB high byte
|
||||
}
|
||||
|
||||
// if_version_2 jump compare: both operands are registers in byte6 / byte7.
|
||||
|
||||
+901
-101
File diff suppressed because it is too large
Load Diff
+387
@@ -0,0 +1,387 @@
|
||||
//! Seeded robustness harness for the untrusted-input parsers.
|
||||
//!
|
||||
//! Every parser reached from here takes bytes that came off a disc, and this
|
||||
//! crate's primary boundary is that the disc is untrusted: a malformed, damaged
|
||||
//! or hostile image must never crash the library. These tests assert exactly
|
||||
//! that one property — **the parser returns `Ok` or `Err`, and never panics.**
|
||||
//!
|
||||
//! # Why this exists rather than `cargo-fuzz`
|
||||
//!
|
||||
//! `cargo-fuzz` needs a nightly toolchain (`-Zsanitizer` plus SanitizerCoverage
|
||||
//! for libFuzzer's coverage feedback) and this project pins stable. So the
|
||||
//! generator lives here instead. It gives up coverage-guided mutation — the real
|
||||
//! loss — and keeps everything else: millions of cases, structure-aware input,
|
||||
//! and a crash corpus. It also gains determinism, which a fuzzer does not have:
|
||||
//! the same seed replays the same cases on any machine.
|
||||
//!
|
||||
//! # Why no `proptest` or `arbitrary`
|
||||
//!
|
||||
//! This crate has exactly one dev-dependency. That is a deliberate posture, and
|
||||
//! a randomness crate is not worth ten transitive dependencies when the parsers
|
||||
//! take plain `&[u8]` and a good enough generator is forty lines.
|
||||
//!
|
||||
//! # Budget
|
||||
//!
|
||||
//! `FREEMKV_HARNESS_CASES` sets cases per generator per target (default 256, low
|
||||
//! enough that the per-commit gate stays under a second). The overnight run sets
|
||||
//! it to millions. `FREEMKV_HARNESS_SEED` overrides the seed; the default is
|
||||
//! fixed so a failure in CI reproduces locally verbatim.
|
||||
//!
|
||||
//! # On failure
|
||||
//!
|
||||
//! The panic message carries the seed, generator and case index. Re-run with
|
||||
//! `FREEMKV_HARNESS_SEED=<seed>` to reproduce, then write the offending bytes
|
||||
//! into `tests/corpus/` as a permanent regression fixture — discovery happens
|
||||
//! here, defence happens there.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
/// Marsaglia xorshift64. Not cryptographic and does not need to be: the job is
|
||||
/// a reproducible spread of bytes, and a named algorithm beats an ad-hoc LCG
|
||||
/// whose period nobody has checked.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn new(seed: u64) -> Self {
|
||||
// A zero seed is a fixed point of xorshift — it would emit zeros forever
|
||||
// and every generated case would be identical.
|
||||
Self(if seed == 0 {
|
||||
0x2545_F491_4F6C_DD1D
|
||||
} else {
|
||||
seed
|
||||
})
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
|
||||
fn byte(&mut self) -> u8 {
|
||||
(self.next() >> 24) as u8
|
||||
}
|
||||
|
||||
/// Uniform-ish in `0..n`. The modulo bias is irrelevant at these magnitudes.
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
if n == 0 {
|
||||
0
|
||||
} else {
|
||||
(self.next() % n as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
fn fill(&mut self, len: usize) -> Vec<u8> {
|
||||
(0..len).map(|_| self.byte()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Budget per generator per target.
|
||||
fn cases() -> usize {
|
||||
std::env::var("FREEMKV_HARNESS_CASES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(256)
|
||||
}
|
||||
|
||||
fn seed() -> u64 {
|
||||
std::env::var("FREEMKV_HARNESS_SEED")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0x5EED_1234_ABCD_0001)
|
||||
}
|
||||
|
||||
/// Largest generated input. Big enough to carry a plausible header plus a body,
|
||||
/// small enough that millions of cases stay quick.
|
||||
const MAX_LEN: usize = 4096;
|
||||
|
||||
/// Drive `f` over three generators and report which case broke it.
|
||||
///
|
||||
/// A panic inside `f` fails the test on its own — nothing is caught here,
|
||||
/// because catching would risk reporting a pass on an input that aborted. The
|
||||
/// wrapper exists to make the failing case *identifiable*: the harness prints
|
||||
/// the seed, generator and index before each call, so the last line before a
|
||||
/// panic names the exact case to reproduce.
|
||||
fn sweep<F: FnMut(&[u8])>(target: &str, magic: &[u8], f: F) {
|
||||
sweep_n(target, magic, cases(), f)
|
||||
}
|
||||
|
||||
/// `sweep` with an explicit budget. The budget is a PARAMETER rather than read
|
||||
/// from the environment inside the loop: the meta-tests below need a small,
|
||||
/// fixed count, and `std::env::set_var` is unsound once the test harness runs
|
||||
/// tests in parallel — two tests setting the same variable race, which is
|
||||
/// exactly what happened on the first run of this file.
|
||||
fn sweep_n<F: FnMut(&[u8])>(target: &str, magic: &[u8], n: usize, mut f: F) {
|
||||
let s = seed();
|
||||
|
||||
// 1. Pure random bytes. Cheap, and almost always rejected at the magic
|
||||
// number — it exercises the entry guards and little else. Kept because
|
||||
// the entry guards are themselves worth exercising.
|
||||
let mut rng = Rng::new(s);
|
||||
for i in 0..n {
|
||||
let len = rng.below(MAX_LEN);
|
||||
let buf = rng.fill(len);
|
||||
run(target, "random", s, i, &buf, &mut f);
|
||||
}
|
||||
|
||||
// 2. Valid magic, random body. THE generator that matters: pure random
|
||||
// input dies at the magic check and never reaches the parser body, so
|
||||
// without this the sweep only ever tests the first few lines.
|
||||
let mut rng = Rng::new(s ^ 0xA5A5_A5A5_A5A5_A5A5);
|
||||
for i in 0..n {
|
||||
let mut buf = magic.to_vec();
|
||||
let tail = rng.below(MAX_LEN.saturating_sub(magic.len()));
|
||||
buf.extend(rng.fill(tail));
|
||||
run(target, "magic+noise", s, i, &buf, &mut f);
|
||||
}
|
||||
|
||||
// 3. Structured mutation of a plausible record: a valid magic, then mostly
|
||||
// zeroes, with a handful of bytes corrupted and a truncation. Length and
|
||||
// offset fields live in those early bytes, so this is what reaches the
|
||||
// arithmetic — the offsets, counts and sizes a hostile image would lie
|
||||
// about.
|
||||
let mut rng = Rng::new(s ^ 0x1234_5678_9ABC_DEF0);
|
||||
for i in 0..n {
|
||||
let mut buf = vec![0u8; 512];
|
||||
buf[..magic.len().min(512)].copy_from_slice(&magic[..magic.len().min(512)]);
|
||||
for _ in 0..rng.below(24) + 1 {
|
||||
let at = rng.below(buf.len());
|
||||
buf[at] = rng.byte();
|
||||
}
|
||||
buf.truncate(rng.below(buf.len()) + 1);
|
||||
run(target, "mutate", s, i, &buf, &mut f);
|
||||
}
|
||||
}
|
||||
|
||||
fn run<F: FnMut(&[u8])>(target: &str, generator: &str, seed: u64, i: usize, buf: &[u8], f: &mut F) {
|
||||
// Printed, not asserted: `cargo test` swallows stdout for passing tests and
|
||||
// shows it for failing ones, so this line is invisible until it is the last
|
||||
// thing before a panic — at which point it is exactly what is needed.
|
||||
println!(
|
||||
"harness {target}/{generator} seed={seed:#x} case={i} len={} :: \
|
||||
FREEMKV_HARNESS_SEED={seed} to reproduce",
|
||||
buf.len()
|
||||
);
|
||||
f(buf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpls_parse_never_panics() {
|
||||
sweep("mpls", b"MPLS", |b| {
|
||||
let _ = crate::mpls::parse(b);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clpi_parse_never_panics() {
|
||||
sweep("clpi", b"HDMV", |b| {
|
||||
let _ = crate::clpi::parse(b);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bdnav_index_parse_never_panics() {
|
||||
sweep("bdnav_index", b"INDX", |b| {
|
||||
let _ = crate::bdnav::index::parse(b);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bdnav_mobj_parse_never_panics() {
|
||||
sweep("bdnav_mobj", b"MOBJ", |b| {
|
||||
let _ = crate::bdnav::mobj::parse(b);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dvd_nav_resolve_never_panics() {
|
||||
// The DVD First-Play navigation resolver parses VMGI (`VIDEO_TS.IFO`) —
|
||||
// FP_PGC command table + TT_SRPT, all attacker-controlled offsets/counts —
|
||||
// and executes an untrusted command list on a bounded VM. Magic is the VMGI
|
||||
// signature so the sweep reaches past the header guard into the executor.
|
||||
sweep("dvd_nav", b"DVDVIDEO-VMG", |b| {
|
||||
let _ = crate::dvdnav::nav::resolve_from_vmg(b);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udf_name_parse_never_panics() {
|
||||
// No magic: the compression ID is the first byte and every value is legal
|
||||
// input to reject, so the "magic" is a byte the sweep will mutate anyway.
|
||||
sweep("udf_name", &[8], |b| {
|
||||
let _ = crate::udf::parse_udf_name(b);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_reader_parse_never_panics() {
|
||||
// Java `.class` files are a documented format we READ (never execute) to
|
||||
// recover BD-J stream labels — a peer of mpls/clpi here. Every count,
|
||||
// length and constant-pool index in the file is attacker-controlled disc
|
||||
// metadata, so the parser must reject malformed input with `Err`, never
|
||||
// panic. Magic is the class-file magic `0xCAFEBABE`.
|
||||
sweep("class_reader", &[0xCA, 0xFE, 0xBA, 0xBE], |b| {
|
||||
if let Ok(class) = crate::labels::class_reader::ClassFile::parse(b) {
|
||||
// Exercise the accessors a label parser drives, so their indexing
|
||||
// is fuzzed too, not just the structural parse.
|
||||
let _ = class.this_class_name();
|
||||
let _ = class.super_class_name();
|
||||
for m in &class.methods {
|
||||
let _ = class.member_name(m);
|
||||
if let Some(code) = m.code(&class.constant_pool) {
|
||||
for insn in code.instructions() {
|
||||
let _ = insn.cp_index();
|
||||
if let Some(idx) = insn.cp_index() {
|
||||
let _ = class.constant_pool.get(idx);
|
||||
let _ = class.constant_pool.member_ref(idx);
|
||||
let _ = class.constant_pool.load_constant_display(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jar_class_walk_never_panics() {
|
||||
// The jar layer opens a `/BDMV/JAR/<x>.jar` (a zip) and walks every
|
||||
// `.class` inside it, handing each to the class reader. Feed the sweep's
|
||||
// bytes as a zip archive; when they parse as one, walk the classes exactly
|
||||
// as `deluxe`/`dbp` do. Neither the zip open nor the class walk may panic.
|
||||
// Magic is the zip local-file-header signature `PK\x03\x04`.
|
||||
sweep("jar", &[0x50, 0x4B, 0x03, 0x04], |b| {
|
||||
if let Ok(mut archive) = zip::ZipArchive::new(std::io::Cursor::new(b.to_vec())) {
|
||||
crate::labels::jar::for_each_class(&mut archive, |_name, _class| {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deluxe_bytecode_decode_never_panics() {
|
||||
// The Deluxe BD-J label parser runs a symbolic stack machine over `<clinit>`
|
||||
// bytecode (`decode_binding_class`). Fuzzing it THROUGH a zip is vacuous —
|
||||
// `zip::ZipArchive::new` validates the end-of-central-directory record at the
|
||||
// buffer's end, which the generators (front magic + noise) cannot synthesize,
|
||||
// so the archive open always fails and the decoder is never reached. Drive
|
||||
// the decoder DIRECTLY instead: parse the sweep bytes as a `.class` (they also
|
||||
// exercise the class parser) and run the bytecode decoder over it with an
|
||||
// empty master-enum table. None may panic on crafted/corrupt bytecode.
|
||||
let master = crate::labels::deluxe::MasterEnumTable::from(&[] as &[(
|
||||
&'static str,
|
||||
crate::labels::deluxe::MasterEnum,
|
||||
)]);
|
||||
sweep("deluxe_decode", &[0xCA, 0xFE, 0xBA, 0xBE], |b| {
|
||||
if let Ok(class) = crate::labels::class_reader::ClassFile::parse(b) {
|
||||
let _ = crate::labels::deluxe::decode_binding_class(&class, &master);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fox_dcx_labels_never_panics() {
|
||||
// Fox's `dcx.xml` is untrusted disc-authored XML; `labels_from_dcx` scans it
|
||||
// with byte-offset XML helpers documented as UTF-8-boundary-prone. No magic —
|
||||
// feed lossy UTF-8 so multi-byte boundaries inside tags/attributes are hit.
|
||||
sweep("fox_dcx", b"<", |b| {
|
||||
let text = String::from_utf8_lossy(b);
|
||||
let _ = crate::labels::fox::labels_from_dcx(&text);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bdnav_vm_resolve_never_panics() {
|
||||
// The HDMV navigation VM executes an untrusted `MovieObject.bdmv` command
|
||||
// list on a bounded VM. Parse the sweep bytes as a MovieObject set and run
|
||||
// the VM from a First-Play entry — the combined parse+execute path a real
|
||||
// disc drives, which the per-parser index/mobj sweeps never reach.
|
||||
use crate::bdnav::index::{Index, PlaybackObj};
|
||||
sweep("bdnav_vm", b"MOBJ", |b| {
|
||||
if let Some(mobjs) = crate::bdnav::mobj::parse(b) {
|
||||
let index = Index {
|
||||
first_play: PlaybackObj::Hdmv { id_ref: 0 },
|
||||
top_menu: PlaybackObj::Hdmv { id_ref: 0 },
|
||||
titles: Vec::new(),
|
||||
};
|
||||
let _ = crate::bdnav::vm::resolve(&index, &mobjs, &|_| false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ps_demuxer_feed_never_panics() {
|
||||
// Stateful, unlike the others: the demuxer carries a buffer across feeds, so
|
||||
// each case is fed to a FRESH demuxer and then a shared one. The shared pass
|
||||
// is what exercises cross-feed state — a start code split over a boundary,
|
||||
// a held PES completed by later bytes, the carry-over cap.
|
||||
let mut shared = crate::mux::ps::PsDemuxer::new();
|
||||
sweep("ps_demux", &[0x00, 0x00, 0x01, 0xBA], |b| {
|
||||
let mut fresh = crate::mux::ps::PsDemuxer::new();
|
||||
let _ = fresh.feed(b);
|
||||
let _ = shared.feed(b);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkv_lacing_split_never_panics() {
|
||||
// All four lacing modes, including the reserved bit pattern. A degenerate
|
||||
// fixed lace was a real defect found by audit round 5.
|
||||
sweep("mkv_lacing", &[0x00], |b| {
|
||||
for lacing in 0u8..=3 {
|
||||
let _ = crate::mux::mkvstream::split_lacing(lacing, b);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The generators must actually differ, or the sweep is one generator run three
|
||||
/// times and the coverage claim is false.
|
||||
#[test]
|
||||
fn the_three_generators_produce_different_inputs() {
|
||||
let mut seen: Vec<Vec<u8>> = Vec::new();
|
||||
sweep_n("probe", b"MPLS", 1, |b| seen.push(b.to_vec()));
|
||||
assert_eq!(seen.len(), 3, "one case per generator");
|
||||
assert_ne!(seen[0], seen[1], "random and magic+noise must differ");
|
||||
assert_ne!(seen[1], seen[2], "magic+noise and mutate must differ");
|
||||
assert!(
|
||||
seen[1].starts_with(b"MPLS"),
|
||||
"the magic+noise generator must actually carry the magic, or it never \
|
||||
reaches the parser body"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same seed must replay the same bytes, or a reported failure cannot be
|
||||
/// reproduced and the harness is worthless as a regression tool.
|
||||
#[test]
|
||||
fn a_seed_replays_identically() {
|
||||
let mut a = Vec::new();
|
||||
let mut b = Vec::new();
|
||||
sweep_n("probe", b"MPLS", 4, |x| a.push(x.to_vec()));
|
||||
sweep_n("probe", b"MPLS", 4, |x| b.push(x.to_vec()));
|
||||
assert_eq!(a, b, "the same seed must produce the same cases");
|
||||
}
|
||||
|
||||
/// The harness is worthless if its cases die at the entry guards, so this
|
||||
/// MEASURES how deep they actually reach instead of assuming. A generator that
|
||||
/// never gets past a length or magic check exercises the first ten lines and
|
||||
/// nothing else — the fuzzing equivalent of a test that cannot fail.
|
||||
#[test]
|
||||
fn the_generators_actually_reach_the_parser_bodies() {
|
||||
// mpls::parse rejects at: len < 40, bad magic, then playlist_start + 10 >
|
||||
// len. Anything that returns Ok got all the way through the play-item loop.
|
||||
let mut ok = 0usize;
|
||||
let mut total = 0usize;
|
||||
sweep_n("reach", b"MPLS", 20000, |b| {
|
||||
total += 1;
|
||||
if crate::mpls::parse(b).is_ok() {
|
||||
ok += 1;
|
||||
}
|
||||
});
|
||||
assert!(
|
||||
ok > 0,
|
||||
"not one of {total} generated cases parsed successfully — the generators \
|
||||
are all being rejected at the entry guards, so this harness is testing \
|
||||
the guards and nothing behind them"
|
||||
);
|
||||
println!("mpls reach: {ok}/{total} cases parsed to completion");
|
||||
}
|
||||
+47
-5
@@ -16,11 +16,11 @@
|
||||
/// (case-insensitive), then requires an even run of ASCII hex digits. Any
|
||||
/// non-hex byte, or an odd length, yields `None`.
|
||||
pub fn parse_hex_bytes(s: &str) -> Option<Vec<u8>> {
|
||||
let body = strip_prefix(s.trim());
|
||||
let body = strip_hex_prefix(s.trim());
|
||||
let bytes = body.as_bytes();
|
||||
// Empty → empty Vec (a legitimately-empty variable-length field); odd length
|
||||
// is malformed. (`parse_hex_fixed` enforces a concrete length separately.)
|
||||
if bytes.len() % 2 != 0 {
|
||||
if !bytes.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(bytes.len() / 2);
|
||||
@@ -34,7 +34,7 @@ pub fn parse_hex_bytes(s: &str) -> Option<Vec<u8>> {
|
||||
/// prefix; requires EXACTLY `2*N` ASCII hex digits after it. `None` on any
|
||||
/// non-hex byte or a length mismatch.
|
||||
pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
|
||||
let body = strip_prefix(s.trim());
|
||||
let body = strip_hex_prefix(s.trim());
|
||||
let bytes = body.as_bytes();
|
||||
if bytes.len() != 2 * N {
|
||||
return None;
|
||||
@@ -46,8 +46,33 @@ pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Strip a single leading `0x` / `0X` if present (case-insensitive).
|
||||
fn strip_prefix(s: &str) -> &str {
|
||||
/// Parse a hex string into a `u16`. Accepts an optional `0x`/`0X` prefix
|
||||
/// (case-insensitive) via the same [`strip_hex_prefix`] the byte parsers use.
|
||||
/// `None` on any non-hex content or overflow.
|
||||
///
|
||||
/// Exists so callers never hand-roll `from_str_radix(s.trim_start_matches("0x"), 16)`
|
||||
/// — a **case-sensitive** strip that silently dropped an uppercase-`0X` value.
|
||||
/// (That reintroduced-in-keydb bug is exactly what this module was built to kill;
|
||||
/// the integer fields now share the one prefix rule.)
|
||||
pub fn parse_hex_u16(s: &str) -> Option<u16> {
|
||||
u16::from_str_radix(strip_hex_prefix(s.trim()), 16).ok()
|
||||
}
|
||||
|
||||
/// Parse a hex string into a `u32`. See [`parse_hex_u16`].
|
||||
pub fn parse_hex_u32(s: &str) -> Option<u32> {
|
||||
u32::from_str_radix(strip_hex_prefix(s.trim()), 16).ok()
|
||||
}
|
||||
|
||||
/// Parse a hex string into a `u8`. See [`parse_hex_u16`].
|
||||
pub fn parse_hex_u8(s: &str) -> Option<u8> {
|
||||
u8::from_str_radix(strip_hex_prefix(s.trim()), 16).ok()
|
||||
}
|
||||
|
||||
/// Strip a single leading `0x` / `0X` if present (case-insensitive). Public so
|
||||
/// callers that only need the prefix rule (e.g. normalizing a disc hash) reuse
|
||||
/// the one definition instead of hand-rolling a case-sensitive
|
||||
/// `trim_start_matches("0x")`.
|
||||
pub fn strip_hex_prefix(s: &str) -> &str {
|
||||
s.strip_prefix("0x")
|
||||
.or_else(|| s.strip_prefix("0X"))
|
||||
.unwrap_or(s)
|
||||
@@ -95,6 +120,23 @@ mod tests {
|
||||
assert_eq!(parse_hex_fixed::<16>(&s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_ints_accept_both_prefix_cases_and_bare() {
|
||||
// The regression the keydb device-key bug hit: uppercase `0X` must parse
|
||||
// identically to `0x` and to a bare value.
|
||||
assert_eq!(parse_hex_u16("0x0001"), Some(1));
|
||||
assert_eq!(parse_hex_u16("0X0001"), Some(1));
|
||||
assert_eq!(parse_hex_u16("0001"), Some(1));
|
||||
assert_eq!(parse_hex_u16(" 0XABCD "), Some(0xABCD));
|
||||
assert_eq!(parse_hex_u32("0X00000002"), Some(2));
|
||||
assert_eq!(parse_hex_u32("deadbeef"), Some(0xDEAD_BEEF));
|
||||
assert_eq!(parse_hex_u8("0X03"), Some(3));
|
||||
assert_eq!(parse_hex_u8("ff"), Some(0xFF));
|
||||
// Overflow / non-hex → None.
|
||||
assert_eq!(parse_hex_u8("0x1FF"), None);
|
||||
assert_eq!(parse_hex_u16("0xzz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_variable_length_and_odd_rejected() {
|
||||
assert_eq!(parse_hex_bytes("0xAABBCC"), Some(vec![0xAA, 0xBB, 0xCC]));
|
||||
|
||||
+193
-1
@@ -49,13 +49,30 @@ pub struct DriveId {
|
||||
pub raw_gc_010c: Vec<u8>,
|
||||
}
|
||||
|
||||
/// SPC-4 standard INQUIRY data: 36 bytes through `product_revision`. Anything
|
||||
/// shorter cannot populate the identity fields this type promises.
|
||||
const INQUIRY_STANDARD_LEN: usize = 36;
|
||||
|
||||
impl DriveId {
|
||||
/// Probe a real drive via SCSI and build its identity.
|
||||
pub fn from_drive(transport: &mut dyn ScsiTransport) -> Result<Self> {
|
||||
// INQUIRY — SPC-4 §6.4
|
||||
let mut inquiry = vec![0u8; 96];
|
||||
let cdb_inq = [0x12, 0x00, 0x00, 0x00, 0x60, 0x00];
|
||||
transport.execute(&cdb_inq, DataDirection::FromDevice, &mut inquiry, 5000)?;
|
||||
let inq = transport.execute(&cdb_inq, DataDirection::FromDevice, &mut inquiry, 5000)?;
|
||||
// `bytes_transferred` is device-reported and untrusted — the same rule
|
||||
// the two GET CONFIGURATION calls below already apply. It was ignored
|
||||
// here, and the buffer is pre-zeroed, so a drive answering GOOD with a
|
||||
// short or empty data phase (a USB-SATA bridge mid-wedge does exactly
|
||||
// this) decoded to blank identity strings and a byte 0 of 0x00. Every
|
||||
// platform enumerator gates on `raw_inquiry[0] & 0x1F == OPTICAL`, so
|
||||
// 0x00 reads as DIRECT ACCESS and the drive silently disappears from
|
||||
// the device list instead of reporting a failed probe.
|
||||
if inq.bytes_transferred < INQUIRY_STANDARD_LEN {
|
||||
return Err(crate::error::Error::DriveInquiryShort);
|
||||
}
|
||||
// Never decode past what the drive actually sent.
|
||||
inquiry.truncate(inq.bytes_transferred.min(inquiry.len()));
|
||||
|
||||
// GET CONFIGURATION Feature 010Ch — MMC-6 §6.6.
|
||||
// Best-effort: 010Ch (Firmware Information) is an optional feature.
|
||||
@@ -262,6 +279,54 @@ mod tests {
|
||||
/// Spec: SPC-4 §6.4.2 — bytes[8:16] are vendor ID; a truncated buffer
|
||||
/// (e.g. a device that reports fewer than 8 bytes) must not panic.
|
||||
/// Mutation: removing the `data.len() > start` guard makes it panic on short inputs.
|
||||
/// A drive that answers INQUIRY with GOOD status but a short or empty
|
||||
/// data phase must fail the probe, not present as a blank drive.
|
||||
///
|
||||
/// The buffer is pre-zeroed, so decoding it unconditionally yielded empty
|
||||
/// vendor/product/revision strings and a byte 0 of 0x00. Every platform
|
||||
/// enumerator gates on `raw_inquiry[0] & 0x1F == SCSI_PERIPHERAL_TYPE_OPTICAL`,
|
||||
/// and 0x00 is DIRECT ACCESS — so the drive silently vanished from the
|
||||
/// device list rather than reporting that its identity probe failed. A
|
||||
/// USB-SATA bridge mid-wedge does exactly this.
|
||||
///
|
||||
/// The two GET CONFIGURATION calls in the same function already clamped on
|
||||
/// `bytes_transferred`, with a comment calling it untrusted; INQUIRY, three
|
||||
/// lines above them, discarded it.
|
||||
#[test]
|
||||
fn inquiry_with_a_short_data_phase_fails_instead_of_reporting_a_blank_drive() {
|
||||
/// GOOD status, no sense, and only `n` bytes written.
|
||||
struct ShortInquiry(usize);
|
||||
impl ScsiTransport for ShortInquiry {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_buf: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
sense: [0u8; 32],
|
||||
bytes_transferred: self.0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Empty data phase — the case that made a real drive disappear.
|
||||
assert!(matches!(
|
||||
DriveId::from_drive(&mut ShortInquiry(0)),
|
||||
Err(crate::error::Error::DriveInquiryShort)
|
||||
));
|
||||
// One byte short of the SPC-4 standard 36-byte header.
|
||||
assert!(matches!(
|
||||
DriveId::from_drive(&mut ShortInquiry(35)),
|
||||
Err(crate::error::Error::DriveInquiryShort)
|
||||
));
|
||||
// Exactly the standard length is acceptable: the optional
|
||||
// vendor-specific tail past byte 36 is allowed to be absent.
|
||||
assert!(DriveId::from_drive(&mut ShortInquiry(36)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_field_short_buffer_returns_empty() {
|
||||
// Buffer of length 5: start=8 is beyond the end → empty string.
|
||||
@@ -339,9 +404,136 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `ascii_field`'s guard is `data.len() > start` (strictly greater), not
|
||||
/// `>=`: a buffer whose length is exactly `start` has NO byte at that
|
||||
/// offset, so it must still yield empty, not attempt to slice.
|
||||
/// Mutation: `>` -> `>=` would try to slice `data[start..]` when
|
||||
/// `data.len() == start`, which panics (empty range at the very end is
|
||||
/// fine, but the guard's job is the `< start` case below it — pinning the
|
||||
/// exact boundary catches an off-by-one either direction).
|
||||
#[test]
|
||||
fn ascii_field_boundary_len_equals_start_is_empty() {
|
||||
let buf = vec![0u8; 8];
|
||||
assert_eq!(ascii_field(&buf, 8, 16), "");
|
||||
}
|
||||
|
||||
/// One byte past the boundary: `data.len() == start + 1` must extract
|
||||
/// that single byte (clamped to `end`), proving the guard is `>` and not
|
||||
/// off by one in the other direction.
|
||||
#[test]
|
||||
fn ascii_field_boundary_len_one_past_start_extracts_one_byte() {
|
||||
let mut buf = vec![0u8; 9];
|
||||
buf[8] = b'X';
|
||||
assert_eq!(ascii_field(&buf, 8, 16), "X");
|
||||
}
|
||||
|
||||
/// `Display` renders the four trimmed identity fields space-separated —
|
||||
/// the human-readable counterpart of `match_key`'s pipe-separated form.
|
||||
/// Not exercised anywhere else in this test module.
|
||||
/// Mutation: replacing the `fmt` body with `Ok(Default::default())`
|
||||
/// writes nothing at all, so formatting any `DriveId` yields "".
|
||||
#[test]
|
||||
fn display_formats_trimmed_fields_space_separated() {
|
||||
let mut inquiry = vec![0u8; 96];
|
||||
inquiry[8..16].copy_from_slice(b"PIONEER ");
|
||||
inquiry[16..32].copy_from_slice(b"BD-RW BDR-S09 ");
|
||||
inquiry[32..36].copy_from_slice(b"1.34");
|
||||
inquiry[36..43].copy_from_slice(b" 16/04/");
|
||||
let id = DriveId::from_inquiry(&inquiry, "201604250000");
|
||||
assert_eq!(id.to_string(), "PIONEER BD-RW BDR-S09 1.34 16/04/");
|
||||
}
|
||||
|
||||
/// GET CONFIGURATION failure (transport error) must not abort the
|
||||
/// identity probe — firmware_date is empty, raw_gc_010c is empty.
|
||||
/// Mutation: propagating the GET_CONFIGURATION error with `?` aborts from_drive.
|
||||
/// Transport whose GET CONFIGURATION responses report an exact,
|
||||
/// caller-chosen `bytes_transferred` for each of the two GC features
|
||||
/// (010Ch firmware date / 0108h serial), so the `end > 12` / `> 12`
|
||||
/// boundary guards can be pinned precisely. INQUIRY always succeeds.
|
||||
struct FixedGcCountTransport {
|
||||
firmware_bytes: usize,
|
||||
serial_bytes: usize,
|
||||
}
|
||||
|
||||
impl ScsiTransport for FixedGcCountTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
buf: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
for b in buf.iter_mut() {
|
||||
*b = b'Z';
|
||||
}
|
||||
let bytes_transferred = match cdb.first() {
|
||||
Some(&0x12) => buf.len(),
|
||||
Some(&0x46) if cdb[3] == 0x0C => self.firmware_bytes,
|
||||
Some(&0x46) if cdb[3] == 0x08 => self.serial_bytes,
|
||||
_ => buf.len(),
|
||||
};
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `end > 12` in the firmware-date branch (`from_drive`) is a strict
|
||||
/// inequality: `bytes_transferred == 12` reports the field absent
|
||||
/// (offset 12 is the first byte of the 12-char date; a count of exactly
|
||||
/// 12 covers bytes 0..12, none of which is the date), so `firmware_date`
|
||||
/// must be empty, not the mutant's off-by-one read.
|
||||
/// Mutation: `>` -> `>=` would try `gc[12..12]` at the boundary — an
|
||||
/// empty but non-panicking slice — silently reporting "present" data
|
||||
/// that is actually all outside the transferred count.
|
||||
#[test]
|
||||
fn from_drive_firmware_date_boundary_exactly_12_is_empty() {
|
||||
let mut t = FixedGcCountTransport {
|
||||
firmware_bytes: 12,
|
||||
serial_bytes: 0,
|
||||
};
|
||||
let id = DriveId::from_drive(&mut t).unwrap();
|
||||
assert_eq!(id.firmware_date, "");
|
||||
}
|
||||
|
||||
/// One byte past the boundary (`bytes_transferred == 13`) must extract
|
||||
/// exactly the one available date byte (offset 12), proving the guard
|
||||
/// is `>` and the slice end is clamped to `end`, not always to 24.
|
||||
#[test]
|
||||
fn from_drive_firmware_date_boundary_13_extracts_one_byte() {
|
||||
let mut t = FixedGcCountTransport {
|
||||
firmware_bytes: 13,
|
||||
serial_bytes: 0,
|
||||
};
|
||||
let id = DriveId::from_drive(&mut t).unwrap();
|
||||
assert_eq!(id.firmware_date, "Z");
|
||||
}
|
||||
|
||||
/// Same `> 12` boundary for the serial-number branch: exactly 12
|
||||
/// transferred bytes must yield an empty serial.
|
||||
#[test]
|
||||
fn from_drive_serial_boundary_exactly_12_is_empty() {
|
||||
let mut t = FixedGcCountTransport {
|
||||
firmware_bytes: 0,
|
||||
serial_bytes: 12,
|
||||
};
|
||||
let id = DriveId::from_drive(&mut t).unwrap();
|
||||
assert_eq!(id.serial_number, "");
|
||||
}
|
||||
|
||||
/// One byte past the serial boundary extracts exactly that byte.
|
||||
#[test]
|
||||
fn from_drive_serial_boundary_13_extracts_one_byte() {
|
||||
let mut t = FixedGcCountTransport {
|
||||
firmware_bytes: 0,
|
||||
serial_bytes: 13,
|
||||
};
|
||||
let id = DriveId::from_drive(&mut t).unwrap();
|
||||
assert_eq!(id.serial_number, "Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_drive_gc_failure_yields_empty_firmware_date() {
|
||||
struct GcFailTransport;
|
||||
|
||||
+1180
-119
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -133,10 +133,10 @@ where
|
||||
match rx.recv_timeout(slice) {
|
||||
Ok(v) => return Ok(v),
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if let Some(h) = halt {
|
||||
if h.is_cancelled() {
|
||||
return Err(BoundedError::Halted);
|
||||
}
|
||||
if let Some(h) = halt
|
||||
&& h.is_cancelled()
|
||||
{
|
||||
return Err(BoundedError::Halted);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(BoundedError::Timeout);
|
||||
|
||||
@@ -249,6 +249,27 @@ impl Drop for BytePrefetcher {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `RECYCLE_DEPTH` must be one MORE than `FORWARD_DEPTH` per its own
|
||||
/// doc comment: the producer needs at least one buffer to fill while
|
||||
/// the consumer holds the other `FORWARD_DEPTH`-worth in flight. A
|
||||
/// `+` -> `*`/`-` mutation on `FORWARD_DEPTH + 1` would under-size the
|
||||
/// recycle channel (e.g. `FORWARD_DEPTH * 1 == FORWARD_DEPTH`, one
|
||||
/// short), which starves the producer of a spare buffer.
|
||||
#[test]
|
||||
fn recycle_depth_is_forward_depth_plus_one() {
|
||||
assert_eq!(RECYCLE_DEPTH, FORWARD_DEPTH + 1);
|
||||
assert_eq!(RECYCLE_DEPTH, 3, "FORWARD_DEPTH is 2, so recycle must be 3");
|
||||
}
|
||||
|
||||
/// `DEFAULT_CHUNK_BYTES` is documented as 16 MiB. Pins the literal so a
|
||||
/// `*` -> `+`/`/` mutation on either factor (16 * 1024 * 1024) is
|
||||
/// caught by a concrete, spec-derived expected value rather than by
|
||||
/// recomputing the same expression.
|
||||
#[test]
|
||||
fn default_chunk_bytes_is_16_mib() {
|
||||
assert_eq!(DEFAULT_CHUNK_BYTES, 16_777_216, "documented as 16 MiB");
|
||||
}
|
||||
|
||||
/// Endless reader: every `read` fills the whole buffer and never
|
||||
/// hits EOF, so the producer keeps trying to push batches forward
|
||||
/// until the forward channel disconnects. Exactly the shape that
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
use std::fs::File;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
pub(super) fn hint_sequential(file: &File, _len_bytes: u64) {
|
||||
pub(crate) fn hint_sequential(file: &File, _len_bytes: u64) {
|
||||
// Best-effort: return value ignored. A fadvise failure has no
|
||||
// user-observable consequence.
|
||||
unsafe {
|
||||
@@ -34,7 +34,7 @@ pub(super) fn hint_sequential(file: &File, _len_bytes: u64) {
|
||||
/// Drop pages in the half-open byte range `[start, start+len)` from
|
||||
/// the page cache. Called periodically by `read_sectors` to bound the
|
||||
/// read-side page cache pressure.
|
||||
pub(super) fn drop_window(file: &File, start: u64, len: u64) {
|
||||
pub(crate) fn drop_window(file: &File, start: u64, len: u64) {
|
||||
unsafe {
|
||||
libc::posix_fadvise(
|
||||
file.as_raw_fd(),
|
||||
@@ -58,7 +58,7 @@ pub(super) fn drop_window(file: &File, start: u64, len: u64) {
|
||||
/// can only pre-stage a tiny slice of the next batch. An explicit
|
||||
/// `readahead()` of the same size as the current batch tells the
|
||||
/// kernel to queue the full next-batch read now.
|
||||
pub(super) fn prefetch(file: &File, offset: u64, len: u64) {
|
||||
pub(crate) fn prefetch(file: &File, offset: u64, len: u64) {
|
||||
unsafe {
|
||||
libc::readahead(file.as_raw_fd(), offset as i64, len as usize);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::os::unix::io::AsRawFd;
|
||||
/// pipeline depth.
|
||||
const RDADVISE_MAX_BYTES: i64 = 64 * 1024 * 1024;
|
||||
|
||||
pub(super) fn hint_sequential(file: &File, len_bytes: u64) {
|
||||
pub(crate) fn hint_sequential(file: &File, len_bytes: u64) {
|
||||
let bytes = (len_bytes as i64).min(RDADVISE_MAX_BYTES);
|
||||
let mut ra = libc::radvisory {
|
||||
ra_offset: 0,
|
||||
@@ -33,14 +33,14 @@ pub(super) fn hint_sequential(file: &File, len_bytes: u64) {
|
||||
/// approximation: no-op. macOS's unified buffer cache is generally
|
||||
/// less prone to the pin-everything pathology that triggers the
|
||||
/// regression on Linux NFS clients.
|
||||
pub(super) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||
pub(crate) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||
|
||||
/// Async-prefetch the byte range `[offset, offset+len)`. macOS uses
|
||||
/// the same `fcntl(F_RDADVISE, &radvisory)` primitive as the open-
|
||||
/// time sequential hint, just targeted at a moving window instead of
|
||||
/// the whole file. The kernel queues I/O for the requested range and
|
||||
/// returns immediately.
|
||||
pub(super) fn prefetch(file: &File, offset: u64, len: u64) {
|
||||
pub(crate) fn prefetch(file: &File, offset: u64, len: u64) {
|
||||
let bytes = (len as i64).min(RDADVISE_MAX_BYTES);
|
||||
let mut ra = libc::radvisory {
|
||||
ra_offset: offset as libc::off_t,
|
||||
|
||||
@@ -48,22 +48,25 @@
|
||||
//! far smaller than our 16 MiB app-level batch.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
pub(crate) mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
pub(crate) mod macos;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
mod other;
|
||||
pub(crate) mod other;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
pub(crate) mod windows;
|
||||
|
||||
// The page-cache hints are shared with any other file-backed sector source:
|
||||
// `dirimage` reads host files the same way and needs the same eviction, or a
|
||||
// large rip pins every byte it has read (see this module's DONTNEED note).
|
||||
#[cfg(target_os = "linux")]
|
||||
use linux as platform;
|
||||
pub(crate) use linux as platform;
|
||||
#[cfg(target_os = "macos")]
|
||||
use macos as platform;
|
||||
pub(crate) use macos as platform;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
use other as platform;
|
||||
pub(crate) use other as platform;
|
||||
#[cfg(target_os = "windows")]
|
||||
use windows as platform;
|
||||
pub(crate) use windows as platform;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
@@ -78,18 +81,34 @@ use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
|
||||
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
|
||||
/// cache stays bounded the same way the write side does.
|
||||
///
|
||||
/// 32 MiB is the empirically tuned value on the rip1 test bed (single
|
||||
/// 7200rpm HDD via SATA): smaller windows (8 / 16 MiB) shorten the
|
||||
/// 32 MiB is the empirically tuned value on a 7200rpm HDD via SATA:
|
||||
/// smaller windows (8 / 16 MiB) shorten the
|
||||
/// kernel-readahead overlap and slow the producer; larger windows
|
||||
/// (64 / 128 MiB) let the page cache pin enough of the ISO to
|
||||
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
|
||||
const READ_DROP_CHUNK_BYTES_DEFAULT: u64 = 32 * 1024 * 1024;
|
||||
|
||||
/// Upper bound (in MiB) accepted from `FREEMKV_READ_DROP_CHUNK_MIB`. 64 GiB —
|
||||
/// generous for any real medium, and small enough that `n * 1024 * 1024` cannot
|
||||
/// wrap `u64`. Mirrors `WRITEBACK_CHUNK_MIB_MAX`, whose identical multiply is
|
||||
/// bounded for exactly this reason: without the bound, a value above 2^44
|
||||
/// overflows — a panic on the first ISO open in an overflow-checked build, and in
|
||||
/// release a wrap to a near-zero window that fires `drop_window` on every read.
|
||||
/// Out-of-range values fall back to the default.
|
||||
const READ_DROP_CHUNK_MIB_MAX: u64 = 64 * 1024;
|
||||
|
||||
fn read_drop_chunk_bytes() -> u64 {
|
||||
std::env::var("FREEMKV_READ_DROP_CHUNK_MIB")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.filter(|&n| n > 0)
|
||||
resolve_read_drop_chunk(
|
||||
std::env::var("FREEMKV_READ_DROP_CHUNK_MIB")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok()),
|
||||
)
|
||||
}
|
||||
|
||||
/// The pure part of [`read_drop_chunk_bytes`], split out so the bound is
|
||||
/// testable without mutating process environment.
|
||||
fn resolve_read_drop_chunk(mib: Option<u64>) -> u64 {
|
||||
mib.filter(|&n| n > 0 && n <= READ_DROP_CHUNK_MIB_MAX)
|
||||
.map(|n| n * 1024 * 1024)
|
||||
.unwrap_or(READ_DROP_CHUNK_BYTES_DEFAULT)
|
||||
}
|
||||
@@ -171,12 +190,20 @@ impl SectorSource for FileSectorSource {
|
||||
) -> Result<usize> {
|
||||
let count = count as u32;
|
||||
let bytes = count as usize * SECTOR_BYTES;
|
||||
debug_assert!(
|
||||
out.len() >= bytes,
|
||||
"FileSectorSource::read_sectors: out len {} < requested {}",
|
||||
out.len(),
|
||||
bytes
|
||||
);
|
||||
// A real check, not a debug_assert: this is a public `SectorSource` impl,
|
||||
// so an undersized `out` is caller input, and `out[..bytes]` below would
|
||||
// panic with 'range end index out of range' in release where the assert is
|
||||
// compiled away. `Drive::read_fua` already carries exactly this guard, with
|
||||
// a comment recording the same panic being fixed there — this impl was
|
||||
// simply never given it, and `PrefetchedSectorSource` has a regression test
|
||||
// for the case that this one lacked.
|
||||
if out.len() < bytes {
|
||||
return Err(Error::DiscRead {
|
||||
sector: lba as u64,
|
||||
status: None,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
if count == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -219,6 +246,40 @@ mod tests {
|
||||
use std::io::Write;
|
||||
use tempfile::tempdir;
|
||||
|
||||
/// An undersized output buffer must return an error, never panic. This is a
|
||||
/// public `SectorSource` impl, so buffer length is caller input, and the guard
|
||||
/// used to be a `debug_assert!` — compiled out in release, where the
|
||||
/// `out[..bytes]` slice then panicked with 'range end index out of range'.
|
||||
///
|
||||
/// `Drive::read_fua` already carries this exact guard with a comment recording
|
||||
/// the same panic being fixed there, and `PrefetchedSectorSource` has
|
||||
/// `direct_read_too_small_buffer_errors` for the same case; this impl had
|
||||
/// neither.
|
||||
#[test]
|
||||
fn read_sectors_with_an_undersized_buffer_errors_rather_than_panicking() {
|
||||
let dir = tempdir().unwrap();
|
||||
let iso = dir.path().join("t.iso");
|
||||
make_iso(&iso, 8);
|
||||
let mut src = FileSectorSource::open(&iso).expect("iso opens");
|
||||
|
||||
// Ask for four sectors but supply room for barely more than one.
|
||||
let mut out = vec![0u8; SECTOR_BYTES + 1];
|
||||
let err = src
|
||||
.read_sectors(0, 4, &mut out, false)
|
||||
.expect_err("an undersized buffer must be an error, not a panic");
|
||||
assert!(
|
||||
matches!(err, Error::DiscRead { .. }),
|
||||
"expected DiscRead, got {err:?}"
|
||||
);
|
||||
|
||||
// Exactly-sized still works, so the guard is not off by one.
|
||||
let mut out = vec![0u8; 4 * SECTOR_BYTES];
|
||||
assert_eq!(
|
||||
src.read_sectors(0, 4, &mut out, false).unwrap(),
|
||||
4 * SECTOR_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a deterministic ISO of `sectors` sectors where sector `n`
|
||||
/// is filled with the byte pattern `((n & 0xff) as u8)`. Lets us
|
||||
/// verify any sector by content alone.
|
||||
@@ -376,19 +437,36 @@ mod tests {
|
||||
// Additional coverage.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// `count == 0` must short-circuit to Ok(0) WITHOUT seeking or
|
||||
/// reading, even at an out-of-range LBA — the early-return guard
|
||||
/// runs before any I/O. Grounding: `if count == 0 { return Ok(0) }`.
|
||||
/// `count == 0` must short-circuit to Ok(0) WITHOUT seeking or reading,
|
||||
/// even at an out-of-range LBA. Grounding: `if count == 0 { return Ok(0) }`.
|
||||
///
|
||||
/// The `Ok(0)` return alone proves nothing: with the guard deleted, a seek
|
||||
/// past EOF succeeds (POSIX permits seeking beyond the end of a file) and a
|
||||
/// zero-length `read_exact` returns `Ok(())` immediately, so the call still
|
||||
/// returns `Ok(0)`. The observable difference is the file's cursor — the
|
||||
/// seek MOVES it to `lba * 2048`. Assert on that, so the guard is what the
|
||||
/// test is actually measuring.
|
||||
#[test]
|
||||
fn zero_count_returns_zero_no_io() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("zc.iso");
|
||||
make_iso(&path, 4);
|
||||
let mut src = FileSectorSource::open(&path).unwrap();
|
||||
let before = src.file.stream_position().expect("cursor readable");
|
||||
assert_eq!(before, 0, "a freshly opened file starts at offset 0");
|
||||
// LBA far past EOF — must not matter because count==0 returns early.
|
||||
let mut buf = [0u8; 1];
|
||||
let n = src.read_sectors(1_000_000, 0, &mut buf, false).unwrap();
|
||||
assert_eq!(n, 0);
|
||||
assert_eq!(
|
||||
src.file.stream_position().expect("cursor readable"),
|
||||
before,
|
||||
"count == 0 must return before the seek — an unmoved cursor is the \
|
||||
only observable proof that no I/O was issued"
|
||||
);
|
||||
// And the drop-window accounting must not have advanced either.
|
||||
assert_eq!(src.bytes_read_since_drop, 0);
|
||||
assert_eq!(src.drop_window_start, 0);
|
||||
}
|
||||
|
||||
/// Reading past EOF must ERROR (read_exact's UnexpectedEof), never
|
||||
@@ -518,4 +596,36 @@ mod tests {
|
||||
lba += batch as u32;
|
||||
}
|
||||
}
|
||||
|
||||
/// `FREEMKV_READ_DROP_CHUNK_MIB` must be BOUNDED before the MiB→byte
|
||||
/// multiply, exactly as its writeback twin bounds the identical multiply.
|
||||
/// Unbounded, any value above 2^44 overflowed `n * 1024 * 1024`: a panic on
|
||||
/// the first ISO open in an overflow-checked build, and in release a wrap to
|
||||
/// a near-zero window that fires `drop_window` on essentially every read.
|
||||
#[test]
|
||||
fn read_drop_chunk_env_is_bounded_before_the_multiply() {
|
||||
// Default when unset / zero / out of range.
|
||||
assert_eq!(resolve_read_drop_chunk(None), READ_DROP_CHUNK_BYTES_DEFAULT);
|
||||
assert_eq!(
|
||||
resolve_read_drop_chunk(Some(0)),
|
||||
READ_DROP_CHUNK_BYTES_DEFAULT
|
||||
);
|
||||
// The overflow value: `u64::MAX * 1024 * 1024` panicked here.
|
||||
assert_eq!(
|
||||
resolve_read_drop_chunk(Some(u64::MAX)),
|
||||
READ_DROP_CHUNK_BYTES_DEFAULT
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_read_drop_chunk(Some(READ_DROP_CHUNK_MIB_MAX + 1)),
|
||||
READ_DROP_CHUNK_BYTES_DEFAULT
|
||||
);
|
||||
// In-range values convert MiB→bytes. Mutation: `* 1024` breaks this.
|
||||
assert_eq!(resolve_read_drop_chunk(Some(1)), 1024 * 1024);
|
||||
assert_eq!(
|
||||
resolve_read_drop_chunk(Some(READ_DROP_CHUNK_MIB_MAX)),
|
||||
READ_DROP_CHUNK_MIB_MAX * 1024 * 1024
|
||||
);
|
||||
// And the bound itself keeps the multiply inside u64.
|
||||
assert!((READ_DROP_CHUNK_MIB_MAX as u128) * 1024 * 1024 <= u64::MAX as u128);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
use std::fs::File;
|
||||
|
||||
pub(super) fn hint_sequential(_file: &File, _len_bytes: u64) {}
|
||||
pub(crate) fn hint_sequential(_file: &File, _len_bytes: u64) {}
|
||||
|
||||
pub(super) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||
pub(crate) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||
|
||||
pub(super) fn prefetch(_file: &File, _offset: u64, _len: u64) {}
|
||||
pub(crate) fn prefetch(_file: &File, _offset: u64, _len: u64) {}
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::fs::File;
|
||||
/// No-op stub. `FILE_FLAG_SEQUENTIAL_SCAN` can only be set at
|
||||
/// `CreateFile` open time, which the plain `File::open` path does not
|
||||
/// do, so there is no post-open hint to issue here.
|
||||
pub(super) fn hint_sequential(_file: &File, _len_bytes: u64) {
|
||||
pub(crate) fn hint_sequential(_file: &File, _len_bytes: u64) {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"FileSectorSource hint_sequential: windows no-op stub"
|
||||
@@ -19,10 +19,10 @@ pub(super) fn hint_sequential(_file: &File, _len_bytes: u64) {
|
||||
/// Windows page-cache eviction is not exposed via a posix_fadvise
|
||||
/// equivalent. The kernel does its own working-set management. No-op
|
||||
/// for now.
|
||||
pub(super) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||
pub(crate) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||
|
||||
/// Windows async-prefetch hint. With FILE_FLAG_SEQUENTIAL_SCAN at
|
||||
/// open the kernel already prefetches aggressively, so there's no
|
||||
/// per-range hint we'd add on top. No-op stub for parity with the
|
||||
/// posix platforms.
|
||||
pub(super) fn prefetch(_file: &File, _offset: u64, _len: u64) {}
|
||||
pub(crate) fn prefetch(_file: &File, _offset: u64, _len: u64) {}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! `write_image` — write an image-level source out as a sector image.
|
||||
//!
|
||||
//! This is the plain image writer: sectors in from any [`SectorSource`], bytes
|
||||
//! out to a file, in order, once. It is what an `iso://` DESTINATION means when
|
||||
//! the source is not a physical drive.
|
||||
//!
|
||||
//! # Why this is not `freemkv_engine::copy`
|
||||
//!
|
||||
//! The engine's `copy` is the RECOVERY path — mapfile sidecar, `--multipass`
|
||||
//! sweep/patch, damage-jump, ECC-aware batching, auto-resume. Every one of those
|
||||
//! exists because an optical drive returns read errors on marginal media. A
|
||||
//! file-backed or synthesized source has no marginal media: a read either
|
||||
//! succeeds or the underlying file is broken, and retrying it is pointless.
|
||||
//!
|
||||
//! Routing a non-drive source through the recovery path is not merely wasteful,
|
||||
//! it is wrong. Its mapfile identity check compares AACS unit keys and the VID,
|
||||
//! both of which are empty for an already-decrypted source, so identity passes
|
||||
//! for ANY such source: a second run with a different input to the same output
|
||||
//! path would resume over the previous image and produce wrong content at exit
|
||||
//! zero. Keeping the two paths separate makes that unrepresentable.
|
||||
//!
|
||||
//! So: drive sources get `freemkv_engine::copy`. Everything else gets this.
|
||||
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::halt::Halt;
|
||||
use crate::sector::SectorSource;
|
||||
use std::fs::File;
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::Path;
|
||||
|
||||
/// Sectors per read/write batch. 4 MiB — large enough that per-call overhead
|
||||
/// disappears against a file-backed source, small enough that the buffer is not
|
||||
/// a notable allocation and cancellation stays responsive.
|
||||
const BATCH_SECTORS: u32 = 2048;
|
||||
|
||||
/// Write `total_sectors` sectors from `reader` to `dest`.
|
||||
///
|
||||
/// Reads sequentially from LBA 0 and writes in order, so the output is a faithful
|
||||
/// image of whatever the source presents — decrypted if the caller wrapped the
|
||||
/// source in a [`DecryptingSectorSource`](crate::sector::decrypting::DecryptingSectorSource),
|
||||
/// ciphertext if it did not. This function performs no decryption itself and makes
|
||||
/// no decryption decision; that belongs to the caller, which knows whether the run
|
||||
/// is `--raw`.
|
||||
///
|
||||
/// `on_progress` is called after each batch with the cumulative byte count, for
|
||||
/// front-end progress reporting. It must not block.
|
||||
///
|
||||
/// `halt` is checked once per batch. On cancellation the partial file is left in
|
||||
/// place — the caller decides whether a partial image is worth keeping, and
|
||||
/// deleting a multi-gigabyte file the user may want to inspect is not this
|
||||
/// function's call to make.
|
||||
///
|
||||
/// Returns the number of bytes written.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`Error::Halted`] if `halt` was cancelled.
|
||||
/// - [`Error::IoError`] if the destination cannot be created or written.
|
||||
/// - Whatever the source's `read_sectors` returns. A short read is an error, not
|
||||
/// a zero-fill: silently padding a truncated source produces an image that
|
||||
/// looks complete and is not.
|
||||
pub fn write_image(
|
||||
reader: &mut dyn SectorSource,
|
||||
dest: &Path,
|
||||
total_sectors: u32,
|
||||
halt: &Halt,
|
||||
mut on_progress: impl FnMut(u64),
|
||||
) -> Result<u64> {
|
||||
if total_sectors == 0 {
|
||||
return Err(Error::EmptyImage);
|
||||
}
|
||||
|
||||
let file = File::create(dest).map_err(|source| Error::IoError { source })?;
|
||||
let mut out = BufWriter::with_capacity(BATCH_SECTORS as usize * SECTOR_BYTES, file);
|
||||
|
||||
let mut buf = vec![0u8; BATCH_SECTORS as usize * SECTOR_BYTES];
|
||||
let mut written: u64 = 0;
|
||||
let mut lba: u32 = 0;
|
||||
|
||||
while lba < total_sectors {
|
||||
if halt.is_cancelled() {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
let count = BATCH_SECTORS.min(total_sectors - lba);
|
||||
let want = count as usize * SECTOR_BYTES;
|
||||
// `recovery = false`: a file-backed source ignores the flag, and a
|
||||
// retry loop over a local file would only re-read the same bytes.
|
||||
let got = reader.read_sectors(lba, count as u16, &mut buf[..want], false)?;
|
||||
if got != want {
|
||||
return Err(Error::ShortImageRead {
|
||||
lba,
|
||||
expected: want as u32,
|
||||
got: got as u32,
|
||||
});
|
||||
}
|
||||
out.write_all(&buf[..want])
|
||||
.map_err(|source| Error::IoError { source })?;
|
||||
written += want as u64;
|
||||
lba += count;
|
||||
on_progress(written);
|
||||
}
|
||||
|
||||
// flush() only pushes the BufWriter's bytes into the kernel via write(2).
|
||||
// It makes no durability promise at all, so returning Ok here would report
|
||||
// a finished image while up to several gigabytes of it still sit in the
|
||||
// page cache. A crash, a power loss, or yanking the removable/network
|
||||
// volume the image was written to then leaves a truncated or empty file
|
||||
// that the caller was told was complete.
|
||||
//
|
||||
// For a 6-90 GB image that is exactly the failure this crate treats as
|
||||
// worst: success reported over wrong output. `into_inner` is used rather
|
||||
// than `flush` so a buffered-write error is surfaced instead of being
|
||||
// dropped on the floor by BufWriter's Drop.
|
||||
let file = out.into_inner().map_err(|e| Error::IoError {
|
||||
source: e.into_error(),
|
||||
})?;
|
||||
file.sync_all()
|
||||
.map_err(|source| Error::IoError { source })?;
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Result as FmResult;
|
||||
|
||||
/// A source that yields a deterministic byte per sector, so the written
|
||||
/// image can be checked positionally rather than just by length.
|
||||
struct PatternSource {
|
||||
sectors: u32,
|
||||
/// Sectors after which `read_sectors` reports a short read.
|
||||
short_after: Option<u32>,
|
||||
}
|
||||
|
||||
impl SectorSource for PatternSource {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.sectors
|
||||
}
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> FmResult<usize> {
|
||||
let want = count as usize * SECTOR_BYTES;
|
||||
if self.short_after.is_some_and(|after| lba >= after) {
|
||||
return Ok(want - 1);
|
||||
}
|
||||
for s in 0..count as usize {
|
||||
let byte = ((lba as usize + s) % 251) as u8;
|
||||
buf[s * SECTOR_BYTES..(s + 1) * SECTOR_BYTES].fill(byte);
|
||||
}
|
||||
Ok(want)
|
||||
}
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("fmkv-image-writer-{name}-{}", std::process::id()));
|
||||
p
|
||||
}
|
||||
|
||||
/// The written image is byte-for-byte what the source presented, at the
|
||||
/// right offsets — not merely the right length.
|
||||
#[test]
|
||||
fn writes_every_sector_in_order() {
|
||||
let dest = tmp("order");
|
||||
let mut src = PatternSource {
|
||||
sectors: 5000,
|
||||
short_after: None,
|
||||
};
|
||||
let n = write_image(&mut src, &dest, 5000, &Halt::new(), |_| {}).expect("write");
|
||||
assert_eq!(n, 5000 * SECTOR_BYTES as u64);
|
||||
|
||||
let data = std::fs::read(&dest).expect("read back");
|
||||
assert_eq!(data.len(), 5000 * SECTOR_BYTES);
|
||||
// Spot-check across batch boundaries (BATCH_SECTORS = 2048): the last
|
||||
// sector of batch 0, the first of batch 1, and the final sector.
|
||||
for lba in [0usize, 2047, 2048, 4095, 4096, 4999] {
|
||||
let want = (lba % 251) as u8;
|
||||
assert_eq!(
|
||||
data[lba * SECTOR_BYTES],
|
||||
want,
|
||||
"sector {lba} head byte wrong — batching lost or duplicated a sector"
|
||||
);
|
||||
assert_eq!(
|
||||
data[(lba + 1) * SECTOR_BYTES - 1],
|
||||
want,
|
||||
"sector {lba} tail"
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
|
||||
/// A tail shorter than a full batch must still be written whole — the
|
||||
/// classic off-by-one when `total_sectors` is not a batch multiple.
|
||||
#[test]
|
||||
fn writes_a_partial_final_batch() {
|
||||
let dest = tmp("tail");
|
||||
let mut src = PatternSource {
|
||||
sectors: 2049,
|
||||
short_after: None,
|
||||
};
|
||||
let n = write_image(&mut src, &dest, 2049, &Halt::new(), |_| {}).expect("write");
|
||||
assert_eq!(n, 2049 * SECTOR_BYTES as u64);
|
||||
assert_eq!(
|
||||
std::fs::metadata(&dest).expect("stat").len(),
|
||||
2049 * SECTOR_BYTES as u64
|
||||
);
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
|
||||
/// A short read is an error. Zero-filling would yield an image that looks
|
||||
/// complete and is not — the single worst outcome for an archival copy.
|
||||
#[test]
|
||||
fn short_read_is_an_error_not_a_zero_fill() {
|
||||
let dest = tmp("short");
|
||||
let mut src = PatternSource {
|
||||
sectors: 4096,
|
||||
short_after: Some(2048),
|
||||
};
|
||||
let err = write_image(&mut src, &dest, 4096, &Halt::new(), |_| {}).expect_err("must fail");
|
||||
assert!(
|
||||
matches!(err, Error::ShortImageRead { lba: 2048, .. }),
|
||||
"got {err:?}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
|
||||
/// Cancellation stops the run and reports it, rather than finishing quietly
|
||||
/// or reporting success on a partial image.
|
||||
#[test]
|
||||
fn cancellation_halts_and_reports() {
|
||||
let dest = tmp("halt");
|
||||
let mut src = PatternSource {
|
||||
sectors: 100_000,
|
||||
short_after: None,
|
||||
};
|
||||
let halt = Halt::new();
|
||||
halt.cancel();
|
||||
let err = write_image(&mut src, &dest, 100_000, &halt, |_| {}).expect_err("must halt");
|
||||
assert!(matches!(err, Error::Halted), "got {err:?}");
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
|
||||
/// Progress is cumulative and monotonic, and its final value equals the
|
||||
/// returned byte count — a front-end that trusts the callback must not end
|
||||
/// up disagreeing with the return value.
|
||||
#[test]
|
||||
fn progress_is_cumulative_and_ends_at_the_total() {
|
||||
let dest = tmp("progress");
|
||||
let mut src = PatternSource {
|
||||
sectors: 5000,
|
||||
short_after: None,
|
||||
};
|
||||
let mut seen: Vec<u64> = Vec::new();
|
||||
let n = write_image(&mut src, &dest, 5000, &Halt::new(), |b| seen.push(b)).expect("write");
|
||||
assert!(
|
||||
seen.windows(2).all(|w| w[1] > w[0]),
|
||||
"not monotonic: {seen:?}"
|
||||
);
|
||||
assert_eq!(*seen.last().expect("at least one callback"), n);
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
}
|
||||
|
||||
/// A zero-sector source is a caller error, not a zero-byte image: an empty
|
||||
/// ISO is never what anyone wanted, and failing here names the problem.
|
||||
#[test]
|
||||
fn zero_sectors_is_an_error() {
|
||||
let dest = tmp("empty");
|
||||
let mut src = PatternSource {
|
||||
sectors: 0,
|
||||
short_after: None,
|
||||
};
|
||||
let err = write_image(&mut src, &dest, 0, &Halt::new(), |_| {}).expect_err("must fail");
|
||||
assert!(matches!(err, Error::EmptyImage), "got {err:?}");
|
||||
// The destination must not have been created — a failed run leaves no
|
||||
// stub for a later run to mistake for output.
|
||||
assert!(!dest.exists(), "empty run created a file");
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -31,6 +31,7 @@ pub(crate) mod bounded;
|
||||
pub mod byte_prefetcher;
|
||||
pub mod file_sector_source;
|
||||
pub mod fsync;
|
||||
pub mod image_writer;
|
||||
pub mod sink;
|
||||
mod writeback;
|
||||
mod writeback_file;
|
||||
@@ -40,9 +41,8 @@ pub(crate) mod platform_macos;
|
||||
|
||||
pub mod pipeline;
|
||||
|
||||
pub(crate) use writeback_file::WritebackFile;
|
||||
pub use writeback_file::WritebackFile;
|
||||
|
||||
pub use pipeline::{
|
||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
|
||||
WRITE_THROUGH_DEPTH,
|
||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink, WRITE_PIPELINE_DEPTH, WRITE_THROUGH_DEPTH,
|
||||
};
|
||||
|
||||
+360
-67
@@ -33,7 +33,7 @@
|
||||
//! consumer lag detection). This is critical for diagnosing stalls.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -117,8 +117,32 @@ fn consumer_panicked(payload: Box<dyn std::any::Any + Send>) -> Error {
|
||||
Error::PipelineConsumerPanicked
|
||||
}
|
||||
|
||||
/// Consumer lifecycle state, shared between the caller and the consumer thread.
|
||||
///
|
||||
/// A plain `AtomicBool` could not make "the caller abandons" and "the consumer
|
||||
/// commits to finalising" mutually exclusive: the consumer loaded the flag, the
|
||||
/// caller stored it, and the consumer then finalised the container anyway — the
|
||||
/// caller reporting the rip as interrupted while a fully finalised MKV (Cues
|
||||
/// written, Segment size patched) landed on disk, indistinguishable from a
|
||||
/// complete one. The two transitions are therefore a single compare-exchange each,
|
||||
/// out of [`state::RUNNING`]: whoever wins decides, and the loser observes the
|
||||
/// winner. (`ST_RUNNING` does not exist anywhere in the crate — the constants are
|
||||
/// `state::RUNNING` / `state::ABANDONED` / `state::CLOSING` below, and both
|
||||
/// compare-exchange sites that must stay in step with this argument name them.)
|
||||
mod state {
|
||||
/// Consumer is running; neither side has committed yet.
|
||||
pub const RUNNING: u8 = 0;
|
||||
/// The caller gave up on the consumer and will report failure — the consumer
|
||||
/// must NOT finalise the output.
|
||||
pub const ABANDONED: u8 = 1;
|
||||
/// The consumer has committed to `close()` (finalising the output). The caller
|
||||
/// can no longer abandon it; it must wait for the result it is about to
|
||||
/// produce.
|
||||
pub const CLOSING: u8 = 2;
|
||||
}
|
||||
|
||||
/// After a halt or deadline fires, spin-poll `handle.is_finished()` for
|
||||
/// [`FINISH_GRACE_SECS`] before accepting the thread leak. This converts
|
||||
/// `grace` before accepting the thread leak. This converts
|
||||
/// the common "nearly-done" consumer (whose own bounded_syscall just
|
||||
/// returned and is about to drop its output file) into a clean join,
|
||||
/// releasing the file handle without waiting the full grace period.
|
||||
@@ -132,11 +156,12 @@ fn consumer_panicked(payload: Box<dyn std::any::Any + Send>) -> Error {
|
||||
/// syscall itself; that still returns on its own (or at process exit).
|
||||
fn finish_with_grace<R: Send + 'static>(
|
||||
handle: thread::JoinHandle<Result<R, Error>>,
|
||||
abandoned: &Arc<AtomicBool>,
|
||||
state: &Arc<AtomicU8>,
|
||||
grace: Duration,
|
||||
leak_err: Error,
|
||||
) -> Result<R, Error> {
|
||||
let grace = Instant::now() + Duration::from_secs(FINISH_GRACE_SECS);
|
||||
while Instant::now() < grace {
|
||||
let deadline = Instant::now() + grace;
|
||||
while Instant::now() < deadline {
|
||||
if handle.is_finished() {
|
||||
return match handle.join() {
|
||||
Ok(result) => result,
|
||||
@@ -145,16 +170,50 @@ fn finish_with_grace<R: Send + 'static>(
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
// Grace expired. Signal abandonment, then log and leak. Setting the
|
||||
// flag BEFORE dropping the handle guarantees the leaked consumer
|
||||
// observes it the moment its wedged syscall returns: it then skips
|
||||
// any further `apply` and skips `close()`, rather than running on to
|
||||
// finalise the abandoned output file.
|
||||
// `Release` here pairs with the `Acquire` loads in the consumer loop so
|
||||
// the leaked consumer reliably observes the flag the moment its wedged
|
||||
// syscall returns, even on weak memory models (ARM64/POWER) where
|
||||
// `Relaxed` gives no cross-thread visibility guarantee.
|
||||
abandoned.store(true, Ordering::Release);
|
||||
// Grace expired. CLAIM abandonment, then log and leak. Claiming BEFORE
|
||||
// dropping the handle guarantees the leaked consumer observes it the moment
|
||||
// its wedged syscall returns: it then skips any further `apply` and skips
|
||||
// `close()`, rather than running on to finalise the abandoned output file.
|
||||
//
|
||||
// A compare-exchange, not a store, because the consumer may have committed to
|
||||
// `close()` in the instant between our last `is_finished()` poll and now. It
|
||||
// then cannot be stopped — the finalise IS happening — so abandoning it would
|
||||
// report the rip as interrupted while a valid, fully finalised container
|
||||
// lands on disk. Losing the race means waiting for the result the consumer is
|
||||
// already producing instead. `AcqRel` pairs with the consumer's own
|
||||
// compare-exchange and with the `Acquire` loads in its drain loop, so the flag
|
||||
// is reliably observed even on weak memory models (ARM64/POWER).
|
||||
if state
|
||||
.compare_exchange(
|
||||
state::RUNNING,
|
||||
state::ABANDONED,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "freemkv::pipeline",
|
||||
phase = "finish_with_halt_close_in_flight",
|
||||
"pipeline consumer had already committed to finalising the output; \
|
||||
waiting for it rather than reporting an unfinalised output"
|
||||
);
|
||||
let close_deadline = Instant::now() + grace;
|
||||
while Instant::now() < close_deadline {
|
||||
if handle.is_finished() {
|
||||
return match handle.join() {
|
||||
Ok(result) => result,
|
||||
Err(payload) => Err(consumer_panicked(payload)),
|
||||
};
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
// Still finalising after a second grace window: leak and report the wedge.
|
||||
// The output may end up finalised by the leaked thread — but that is now a
|
||||
// wedged-`close()` case, not the check-then-finalise race.
|
||||
drop(handle);
|
||||
return Err(leak_err);
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "freemkv::pipeline",
|
||||
phase = "finish_with_halt_grace_expired",
|
||||
@@ -173,14 +232,9 @@ fn finish_with_grace<R: Send + 'static>(
|
||||
|
||||
/// Default channel depth for callers without a specific reason to
|
||||
/// pick another value. Kept conservative (4) — most callers should
|
||||
/// use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead.
|
||||
/// use WRITE_PIPELINE_DEPTH instead.
|
||||
pub const DEFAULT_PIPELINE_DEPTH: usize = 4;
|
||||
|
||||
/// Read pipeline depth. Larger buffer compensates for drive variability
|
||||
/// and NFS sync_file_range stalls; keeps ISO reader thread fed even when
|
||||
/// consumer blocks on write.
|
||||
pub const READ_PIPELINE_DEPTH: usize = 32;
|
||||
|
||||
/// Write pipeline depth. Smaller buffer reduces backpressure risk when
|
||||
/// sync_file_range blocks; prevents producer from accumulating too much
|
||||
/// work while consumer waits for NFS to drain.
|
||||
@@ -189,7 +243,8 @@ pub const WRITE_PIPELINE_DEPTH: usize = 16;
|
||||
/// Channel depth for write-through pipelines. Each `send` fully
|
||||
/// drains before the next can enqueue. Use this when the producer
|
||||
/// must observe consumer side-effects (e.g. mapfile state) before
|
||||
/// emitting the next item. Currently used by `disc::patch`.
|
||||
/// emitting the next item. Used by `freemkv_engine::recovery::patch` — the
|
||||
/// recovery strategy moved to that crate in 1.6.0, so there is no `patch` here.
|
||||
pub const WRITE_THROUGH_DEPTH: usize = 1;
|
||||
|
||||
/// Outcome of [`Sink::apply`]: either keep feeding items
|
||||
@@ -247,7 +302,21 @@ pub struct Pipeline<I: Send + 'static, R: Send + 'static> {
|
||||
/// a syscall the consumer is currently wedged in, but it does bound
|
||||
/// the damage to "whatever write is already in flight" once that
|
||||
/// syscall returns, instead of running on to a clean finalise.
|
||||
abandoned: Arc<AtomicBool>,
|
||||
///
|
||||
/// One of [`state::RUNNING`] / [`state::ABANDONED`] / [`state::CLOSING`];
|
||||
/// both transitions are compare-exchanges so abandoning and finalising are
|
||||
/// mutually exclusive rather than racing.
|
||||
state: Arc<AtomicU8>,
|
||||
/// Set by the consumer the moment an `apply` returns `Err`. The consumer keeps
|
||||
/// draining the channel after that (so the producer never blocks on a dead
|
||||
/// receiver) — which means a producer watching only `send`'s return value
|
||||
/// cannot tell the difference between "being consumed" and "being discarded
|
||||
/// after a fatal write error", and would go on reading the whole remaining
|
||||
/// disc before `finish()` finally surfaced the error. This flag is that
|
||||
/// missing edge: [`Pipeline::send_with_halt`] fails fast on it, and
|
||||
/// [`Pipeline::consumer_failed`] exposes it to producers that use plain
|
||||
/// [`Pipeline::send`].
|
||||
failed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
@@ -256,17 +325,21 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
///
|
||||
/// The thread is named `freemkv-pipeline-consumer` so it shows up
|
||||
/// distinctly in stack traces and `top -H`. Callers that want a
|
||||
/// more specific name (e.g. `freemkv-sweep-consumer`) should use
|
||||
/// [`Pipeline::spawn_named`] instead. Returns an `Error::IoError`
|
||||
/// if the OS refuses the thread spawn (resource exhaustion);
|
||||
/// callers already operate in fallible context, so this is
|
||||
/// propagated rather than panicked.
|
||||
/// more specific name should use [`Pipeline::spawn_named`] instead.
|
||||
/// Returns an `Error::IoError` if the OS refuses the thread spawn
|
||||
/// (resource exhaustion); callers already operate in fallible context, so
|
||||
/// this is propagated rather than panicked.
|
||||
///
|
||||
/// Sweep uses [`Pipeline::spawn_named`] directly so the consumer
|
||||
/// thread shows up as `freemkv-sweep-consumer`; mux uses
|
||||
/// `freemkv-mux-consumer`. `Pipeline::spawn` (this function, with
|
||||
/// the default name) is used by `disc::patch` and by the unit
|
||||
/// tests in this module.
|
||||
/// Inside this crate the only [`Pipeline::spawn_named`] caller is the mux
|
||||
/// driver, which names its thread `freemkv-mux-consumer`. `Pipeline::spawn`
|
||||
/// (this function, with the default name) is used only by the unit tests in
|
||||
/// this module.
|
||||
///
|
||||
/// This paragraph twice named a caller that had left the crate: first
|
||||
/// `disc::patch`, then Sweep and its `freemkv-sweep-consumer` thread. Both
|
||||
/// went to freemkv-engine with the recovery passes in 1.6.0, and each in
|
||||
/// turn sent readers hunting a component that is not here. Name callers
|
||||
/// that live in THIS crate, or none.
|
||||
pub fn spawn<S: Sink<I, Output = R>>(depth: usize, sink: S) -> Result<Self, Error> {
|
||||
Self::spawn_named("freemkv-pipeline-consumer", depth, sink)
|
||||
}
|
||||
@@ -274,15 +347,17 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
/// Like [`Pipeline::spawn`] but lets the caller supply the
|
||||
/// consumer thread's name. Useful when several pipelines run in
|
||||
/// the same process and stack traces / `top -H` need to tell them
|
||||
/// apart (e.g. `freemkv-sweep-consumer`, `freemkv-mux-consumer`).
|
||||
/// apart (e.g. `freemkv-mux-consumer`).
|
||||
pub fn spawn_named<S: Sink<I, Output = R>>(
|
||||
name: &str,
|
||||
depth: usize,
|
||||
sink: S,
|
||||
) -> Result<Self, Error> {
|
||||
let (tx, rx) = bounded::<I>(depth);
|
||||
let abandoned = Arc::new(AtomicBool::new(false));
|
||||
let abandoned_consumer = abandoned.clone();
|
||||
let state = Arc::new(AtomicU8::new(state::RUNNING));
|
||||
let state_consumer = state.clone();
|
||||
let failed = Arc::new(AtomicBool::new(false));
|
||||
let failed_consumer = failed.clone();
|
||||
let handle = thread::Builder::new()
|
||||
.name(name.into())
|
||||
.spawn(move || -> Result<R, Error> {
|
||||
@@ -313,7 +388,7 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
// dead receiver, but we touch the output no further. The
|
||||
// final post-loop abandonment check returns the error
|
||||
// and skips `close()`.
|
||||
if abandoned_consumer.load(Ordering::Acquire) {
|
||||
if state_consumer.load(Ordering::Acquire) == state::ABANDONED {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -342,6 +417,12 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
tracing::debug!("Pipeline: apply error, stopping, err={:?}", e);
|
||||
}
|
||||
first_err = Some(e);
|
||||
// Publish the failure so the producer can stop
|
||||
// FEEDING a dead write side instead of only learning
|
||||
// about it at `finish()` — by which time it has read
|
||||
// the rest of the disc. `Release` pairs with the
|
||||
// `Acquire` load in `send_with_halt`.
|
||||
failed_consumer.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,13 +483,38 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
// MKV Cues + patching the segment header) on a file the
|
||||
// caller already reported as failed is exactly the
|
||||
// write race we must not run.
|
||||
if abandoned_consumer.load(Ordering::Acquire) {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
|
||||
match first_err {
|
||||
Some(e) => Err(e),
|
||||
None => sink.close(),
|
||||
// No `close()` on this path, so there is nothing to claim —
|
||||
// just report, unless the caller has already given up on us.
|
||||
Some(e) => {
|
||||
if state_consumer.load(Ordering::Acquire) == state::ABANDONED {
|
||||
Err(Error::Halted)
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
// CLAIM the finalise. A plain load here left a window in which
|
||||
// the caller stored `abandoned` AFTER we read it as clear, so
|
||||
// `close()` ran anyway and finalised (Cues + Segment-size
|
||||
// patch) an output the caller had already reported as
|
||||
// interrupted — a truncated rip indistinguishable from a
|
||||
// complete one. The compare-exchange closes that window: if the
|
||||
// caller got there first we skip `close()`, and if we get there
|
||||
// first the caller waits for us instead of abandoning.
|
||||
None => {
|
||||
if state_consumer
|
||||
.compare_exchange(
|
||||
state::RUNNING,
|
||||
state::CLOSING,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
sink.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
@@ -416,10 +522,24 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
Ok(Pipeline {
|
||||
tx,
|
||||
handle,
|
||||
abandoned,
|
||||
state,
|
||||
failed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the consumer's `apply` has already failed fatally.
|
||||
///
|
||||
/// The consumer keeps draining the channel after an `apply` error (so the
|
||||
/// producer never blocks on a dead receiver), which means `send` keeps
|
||||
/// succeeding and a producer has no other way to tell that everything it feeds
|
||||
/// is being discarded. A long-running producer — the mux frame pump reading a
|
||||
/// 60 GB title off an optical drive — should check this and unwind instead of
|
||||
/// reading the rest of the disc for a write that has already failed.
|
||||
/// [`Pipeline::send_with_halt`] checks it automatically.
|
||||
pub fn consumer_failed(&self) -> bool {
|
||||
self.failed.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Push one item. Blocks if the channel is full — that's the
|
||||
/// back-pressure the whole primitive exists to provide. Returns
|
||||
/// the item back if the consumer thread is gone (panicked or
|
||||
@@ -449,7 +569,10 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
} else {
|
||||
// Benign per-item OK: trace-level (L4) only; the
|
||||
// apply-side rolling summary carries throughput.
|
||||
tracing::trace!("Pipeline send: OK in {:.3}ms", elapsed.as_micros());
|
||||
tracing::trace!(
|
||||
"Pipeline send: OK in {:.3}ms",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -464,7 +587,10 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
} else {
|
||||
tracing::debug!("Pipeline send: failed after {:.3}ms", elapsed.as_micros());
|
||||
tracing::debug!(
|
||||
"Pipeline send: failed after {:.3}ms",
|
||||
elapsed.as_secs_f64() * 1000.0
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e.0)
|
||||
@@ -506,11 +632,34 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
/// wedged inside an unkillable syscall, the producer can still
|
||||
/// observe `/api/stop` and unwind within
|
||||
/// [`SEND_HALT_CHECK_INTERVAL`].
|
||||
/// NOT a `foo_with_X` variant of [`Pipeline::send`], despite the name.
|
||||
/// The two encode OPPOSITE policies on the same event, each with its own
|
||||
/// test: after the consumer's `apply` has failed, `send` still succeeds
|
||||
/// (the consumer keeps draining, so the channel accepts the item), while
|
||||
/// this one hands the item straight back — so a producer does not read an
|
||||
/// hour of disc for a write that died on the first frame. Collapsing them
|
||||
/// into one Option-parameterised method deletes one of those behaviours;
|
||||
/// it was tried and `apply_error_drains_then_propagates` caught it.
|
||||
pub fn send_with_halt(&self, item: I, halt: &Halt, deadline: Duration) -> Result<(), I> {
|
||||
use crossbeam_channel::SendTimeoutError;
|
||||
let end = Instant::now() + deadline;
|
||||
let mut pending = item;
|
||||
loop {
|
||||
// The consumer's `apply` has failed fatally: everything sent from here
|
||||
// is drained and discarded, so hand the item back at once. Without this
|
||||
// the producer saw every send succeed (the channel is always being
|
||||
// drained) and went on reading the whole remaining title — an hour of
|
||||
// drive time on a UHD — for a write that died on the first frame, only
|
||||
// learning about it at `finish()`.
|
||||
if self.consumer_failed() {
|
||||
if debug_enabled() {
|
||||
tracing::debug!(
|
||||
"Pipeline send_with_halt: consumer apply failed, returning item={}",
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
}
|
||||
return Err(pending);
|
||||
}
|
||||
// Pre-check the cheap exit conditions before parking.
|
||||
if halt.is_cancelled() {
|
||||
if debug_enabled() {
|
||||
@@ -565,7 +714,8 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
let Pipeline {
|
||||
tx,
|
||||
handle,
|
||||
abandoned: _,
|
||||
state: _,
|
||||
failed: _,
|
||||
} = self;
|
||||
// Explicit drop, although the destructure already drops `tx`
|
||||
// at end-of-scope. Being explicit keeps the intent obvious.
|
||||
@@ -601,11 +751,18 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
/// Plain [`Pipeline::finish`] is preserved for callers without a
|
||||
/// halt-token plumbed through; that path still blocks indefinitely
|
||||
/// on `join()`, matching pre-0.20.8 behaviour.
|
||||
/// Also not a `foo_with_X` variant: [`Pipeline::finish`] joins and waits
|
||||
/// however long the consumer needs, while this one gives up after
|
||||
/// `JOIN_TIMEOUT_SECS` and reports halted. Which is right depends on
|
||||
/// whether the caller has a user waiting to cancel — the mux driver does
|
||||
/// and uses this; the unit tests do not and use the plain join. Merging
|
||||
/// them means picking one of those policies for both.
|
||||
pub fn finish_with_halt(self, halt: Option<&Halt>) -> Result<R, Error> {
|
||||
let Pipeline {
|
||||
tx,
|
||||
handle,
|
||||
abandoned,
|
||||
state,
|
||||
failed: _,
|
||||
} = self;
|
||||
drop(tx);
|
||||
let deadline = Instant::now() + Duration::from_secs(JOIN_TIMEOUT_SECS);
|
||||
@@ -616,13 +773,23 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
Err(payload) => Err(consumer_panicked(payload)),
|
||||
};
|
||||
}
|
||||
if let Some(h) = halt {
|
||||
if h.is_cancelled() {
|
||||
return finish_with_grace(handle, &abandoned, Error::Halted);
|
||||
}
|
||||
if let Some(h) = halt
|
||||
&& h.is_cancelled()
|
||||
{
|
||||
return finish_with_grace(
|
||||
handle,
|
||||
&state,
|
||||
Duration::from_secs(FINISH_GRACE_SECS),
|
||||
Error::Halted,
|
||||
);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return finish_with_grace(handle, &abandoned, Error::PipelineJoinTimeout);
|
||||
return finish_with_grace(
|
||||
handle,
|
||||
&state,
|
||||
Duration::from_secs(FINISH_GRACE_SECS),
|
||||
Error::PipelineJoinTimeout,
|
||||
);
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
@@ -1074,20 +1241,6 @@ mod tests {
|
||||
/// A sink that records the exact order of items it receives, so we
|
||||
/// can prove the channel is FIFO (no reordering). `close` returns
|
||||
/// the recorded vector.
|
||||
struct OrderSink {
|
||||
seen: Vec<u64>,
|
||||
}
|
||||
impl Sink<u64> for OrderSink {
|
||||
type Output = Vec<u64>;
|
||||
fn apply(&mut self, item: u64) -> Result<Flow, Error> {
|
||||
self.seen.push(item);
|
||||
Ok(Flow::Continue)
|
||||
}
|
||||
fn close(self) -> Result<Vec<u64>, Error> {
|
||||
Ok(self.seen)
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero items sent: closing the pipeline immediately must still
|
||||
/// call `close()` exactly once and return its Output. The consumer
|
||||
/// loop's `while let Ok = rx.recv()` exits on the dropped tx with
|
||||
@@ -1592,4 +1745,144 @@ mod tests {
|
||||
let res = pipe.finish_with_halt(None);
|
||||
assert!(matches!(res, Ok(190)), "expected Ok(190), got {res:?}");
|
||||
}
|
||||
|
||||
/// A fatal `apply` error must become visible to the PRODUCER, not only to
|
||||
/// `finish()`. The consumer keeps draining after the error (so the producer
|
||||
/// never blocks on a dead receiver), which meant every `send_with_halt`
|
||||
/// returned `Ok` for the rest of the run: on a 60 GB mkv:// mux that hit
|
||||
/// ENOSPC on the first frame, the mux driver read the entire remaining title —
|
||||
/// an hour of optical-drive time — before learning the write had died.
|
||||
#[test]
|
||||
fn send_with_halt_fails_fast_once_apply_has_failed() {
|
||||
struct FailFirst {
|
||||
failed: Arc<AtomicUsize>,
|
||||
}
|
||||
impl Sink<u64> for FailFirst {
|
||||
type Output = ();
|
||||
fn apply(&mut self, _item: u64) -> Result<Flow, Error> {
|
||||
self.failed.fetch_add(1, Ordering::SeqCst);
|
||||
Err(Error::DecryptFailed)
|
||||
}
|
||||
fn close(self) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let applied = Arc::new(AtomicUsize::new(0));
|
||||
let pipe = Pipeline::spawn(
|
||||
DEFAULT_PIPELINE_DEPTH,
|
||||
FailFirst {
|
||||
failed: applied.clone(),
|
||||
},
|
||||
)
|
||||
.expect("spawn");
|
||||
let halt = crate::halt::Halt::new();
|
||||
let deadline = Duration::from_secs(5);
|
||||
|
||||
// Feed one item and wait until the consumer has actually applied (and
|
||||
// failed on) it, so the check below is deterministic rather than racy.
|
||||
pipe.send_with_halt(0u64, &halt, deadline)
|
||||
.expect("the first send lands");
|
||||
let until = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < until && applied.load(Ordering::SeqCst) == 0 {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(applied.load(Ordering::SeqCst), 1, "apply ran and failed");
|
||||
|
||||
assert!(pipe.consumer_failed(), "the failure must be observable");
|
||||
// The very next send must hand the item straight back — the producer's
|
||||
// signal to stop reading the disc.
|
||||
assert_eq!(
|
||||
pipe.send_with_halt(1u64, &halt, deadline),
|
||||
Err(1u64),
|
||||
"send_with_halt must fail fast once the consumer's apply has failed"
|
||||
);
|
||||
// The halt was never fired, so this is not a cancellation: the real error
|
||||
// still comes out of finish().
|
||||
assert!(matches!(pipe.finish(), Err(Error::DecryptFailed)));
|
||||
assert_eq!(
|
||||
applied.load(Ordering::SeqCst),
|
||||
1,
|
||||
"no further item was applied"
|
||||
);
|
||||
}
|
||||
|
||||
/// The abandon/finalise race. A consumer that has ALREADY committed to
|
||||
/// `close()` when the grace period expires cannot be stopped — the finalise is
|
||||
/// happening — so the caller must wait for its result instead of reporting the
|
||||
/// output as un-finalised. With a plain flag the consumer read it as clear, the
|
||||
/// caller then stored it, and the caller returned `Err(Halted)`
|
||||
/// (`completed = false`) while a fully finalised MKV (Cues written, Segment
|
||||
/// size patched) landed on disk — a truncated rip indistinguishable from a
|
||||
/// complete one.
|
||||
#[test]
|
||||
fn abandon_loses_to_a_close_already_committed() {
|
||||
let state = Arc::new(AtomicU8::new(state::RUNNING));
|
||||
let release = Arc::new(AtomicBool::new(false));
|
||||
let in_close = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let (st, rel, inc) = (state.clone(), release.clone(), in_close.clone());
|
||||
let handle = thread::Builder::new()
|
||||
.name("test-consumer".into())
|
||||
.spawn(move || -> Result<u64, Error> {
|
||||
// Exactly what the consumer does before finalising: claim the
|
||||
// right to close.
|
||||
assert!(
|
||||
st.compare_exchange(
|
||||
state::RUNNING,
|
||||
state::CLOSING,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire
|
||||
)
|
||||
.is_ok(),
|
||||
"the consumer claims the finalise first"
|
||||
);
|
||||
inc.store(true, Ordering::SeqCst);
|
||||
// Inside `close()`, finalising the container.
|
||||
while !rel.load(Ordering::SeqCst) {
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
Ok(42)
|
||||
})
|
||||
.expect("spawn");
|
||||
|
||||
let until = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < until && !in_close.load(Ordering::SeqCst) {
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(in_close.load(Ordering::SeqCst), "consumer reached close()");
|
||||
|
||||
// Finish the close only AFTER the first grace window has expired, so the
|
||||
// caller genuinely reaches the abandon decision with a close in flight.
|
||||
let rel = release.clone();
|
||||
thread::spawn(move || {
|
||||
// Past the first grace window (and past the 250 ms poll cadence that
|
||||
// bounds when the window is actually observed), inside the second.
|
||||
//
|
||||
// These intervals used to be 600 ms against a 300 ms grace, which
|
||||
// left NO margin: two 300 ms windows end at 600 ms, and the 250 ms
|
||||
// poll cadence can push the observation later still, so on a loaded
|
||||
// runner the second window expired first and the caller abandoned —
|
||||
// failing with Err(Halted) against a race, not a defect.
|
||||
//
|
||||
// Scaled up so the jitter is small relative to the intervals: the
|
||||
// first window ends at ~1.0-1.25 s and the second at ~2.0-2.25 s,
|
||||
// so releasing at 1.6 s sits well inside the second with roughly
|
||||
// 350 ms of slack on either side. The ordering under test is
|
||||
// unchanged; only the margin is.
|
||||
thread::sleep(Duration::from_millis(1600));
|
||||
rel.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
let grace = Duration::from_secs(1);
|
||||
let res = finish_with_grace(handle, &state, grace, Error::Halted);
|
||||
assert!(
|
||||
matches!(res, Ok(42)),
|
||||
"a finalise already in flight must be waited for, not abandoned: {res:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
state.load(Ordering::SeqCst),
|
||||
state::CLOSING,
|
||||
"the caller must not have overwritten the consumer's claim"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ impl WritebackPipeline {
|
||||
self.chunk_bytes,
|
||||
self.skip_wait(),
|
||||
);
|
||||
if self.chunk_count % SIZE_LOG_INTERVAL == 0 {
|
||||
if self.chunk_count.is_multiple_of(SIZE_LOG_INTERVAL) {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"WritebackPipeline chunk_bytes={} after {} chunks is_nfs={} degraded={}",
|
||||
|
||||
@@ -30,14 +30,15 @@ pub(super) fn preallocate(file: &File, size_bytes: u64) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Run `fsync` on `file` with a 60 s deadline. On timeout — and
|
||||
/// likewise on halt or a lost worker — we log and return `Ok(())`: the
|
||||
/// kernel will still flush on close, so the data is best-effort durable.
|
||||
/// The alternative (trap the thread for the rest of the rip, or return
|
||||
/// an error that aborts an otherwise-complete mux) is worse, so all
|
||||
/// three fallbacks return `Ok(())`. `Ok(())` from these paths is NOT a
|
||||
/// durability barrier — the durable flush did not complete; only the
|
||||
/// hang is bounded.
|
||||
/// Run `fsync` on `file` with a 60 s deadline. On timeout, halt or a lost
|
||||
/// worker we log and return `Err` — matching macOS. POSIX gives `fsync`
|
||||
/// exactly one way to say "the data is on stable storage" and that is a zero
|
||||
/// return; a call that never reached the device has not earned it, so `Ok(())`
|
||||
/// from here means the flush completed and nothing else.
|
||||
///
|
||||
/// The kernel will still flush on close, so the data is usually durable
|
||||
/// anyway — but that is a probability, not a barrier, and a caller that needs
|
||||
/// crash-consistency has to be able to tell the difference.
|
||||
///
|
||||
/// ## fd-reuse safety
|
||||
///
|
||||
@@ -78,27 +79,7 @@ pub(super) fn durable_sync(file: &File) -> io::Result<()> {
|
||||
},
|
||||
) {
|
||||
Ok(inner) => inner,
|
||||
Err(crate::io::bounded::BoundedError::Timeout) => {
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fsync timed out after 60s; kernel will flush on close (best-effort)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(crate::io::bounded::BoundedError::Halted) => {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fsync skipped (halt requested); data not durably flushed, kernel will flush on close"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(crate::io::bounded::BoundedError::WorkerLost) => {
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fsync worker lost before completion; data not durably flushed, kernel will flush on close"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => bounded_failure_to_result(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,3 +124,77 @@ mod tests {
|
||||
durable_sync(f.as_file()).expect("durable_sync must return Ok on a local tempfile");
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a [`crate::io::bounded::BoundedError`] from the bounded `fsync` onto the
|
||||
/// `io::Error` `durable_sync` returns.
|
||||
///
|
||||
/// Every arm means the same thing: **no sync observably ran**. All three used to
|
||||
/// return `Ok(())`, so `WritebackFile::sync_all` reported success for a
|
||||
/// durability barrier that never happened. POSIX gives `fsync` one way to say
|
||||
/// "the data is on stable storage" — a zero return — and a call that never
|
||||
/// reached the device has not earned it.
|
||||
///
|
||||
/// This mirrors the macOS `F_FULLFSYNC` mapping exactly. The two were found
|
||||
/// carrying the identical defect, and a platform disagreeing with its sibling
|
||||
/// about whether a failed sync is an error is the "works on my platform" class
|
||||
/// this crate has been bitten by before — most recently an over-length SCSI CDB
|
||||
/// that macOS rejected and the other two silently truncated.
|
||||
///
|
||||
/// No message text (this crate ships no user-facing English): the kind, and
|
||||
/// `EIO` for the worker-lost case, are the signal; `tracing` carries the detail.
|
||||
fn bounded_failure_to_result(e: crate::io::bounded::BoundedError) -> io::Result<()> {
|
||||
match e {
|
||||
crate::io::bounded::BoundedError::Timeout => {
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fsync timed out after 60s; data NOT durably flushed, kernel will flush on close"
|
||||
);
|
||||
Err(crate::error::Error::SyncTimeout.into())
|
||||
}
|
||||
crate::io::bounded::BoundedError::Halted => {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fsync skipped (halt requested); data NOT durably flushed, kernel will flush on close"
|
||||
);
|
||||
Err(crate::error::Error::Halted.into())
|
||||
}
|
||||
crate::io::bounded::BoundedError::WorkerLost => {
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fsync worker lost before completion; data NOT durably flushed, kernel will flush on close"
|
||||
);
|
||||
// EIO, matching the macOS sibling: a consumer distinguishing these
|
||||
// three failures does so on the same value on every platform.
|
||||
// ErrorKind::Other carries nothing a caller can branch on.
|
||||
Err(crate::error::Error::SyncWorkerLost.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bounded_failure_tests {
|
||||
use super::*;
|
||||
use crate::io::bounded::BoundedError;
|
||||
|
||||
/// Every bounded-fsync failure must be an error. Asserted per variant rather
|
||||
/// than as a loop so a new variant defaulting to Ok cannot slip through.
|
||||
#[test]
|
||||
fn no_bounded_fsync_failure_maps_to_ok() {
|
||||
assert_eq!(
|
||||
bounded_failure_to_result(BoundedError::Timeout)
|
||||
.expect_err("a timed-out fsync must be an error")
|
||||
.kind(),
|
||||
io::ErrorKind::TimedOut
|
||||
);
|
||||
assert_eq!(
|
||||
bounded_failure_to_result(BoundedError::Halted)
|
||||
.expect_err("a halted fsync must be an error")
|
||||
.kind(),
|
||||
io::ErrorKind::Interrupted
|
||||
);
|
||||
assert!(
|
||||
bounded_failure_to_result(BoundedError::WorkerLost).is_err(),
|
||||
"a lost fsync worker must be an error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,15 +105,46 @@ pub(super) fn durable_sync(file: &File) -> io::Result<()> {
|
||||
},
|
||||
) {
|
||||
Ok(inner) => inner,
|
||||
Err(crate::io::bounded::BoundedError::Timeout) => {
|
||||
Err(e) => bounded_failure_to_result(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a [`crate::io::bounded::BoundedError`] from the bounded `F_FULLFSYNC`
|
||||
/// onto the `io::Error` `durable_sync` returns.
|
||||
///
|
||||
/// Every arm here means the same thing: **no sync observably ran**. All three
|
||||
/// previously returned `Ok(())`, so `WritebackFile::sync_all` reported success
|
||||
/// for a durability barrier that never happened — a total failure exiting 0,
|
||||
/// with only a log line to distinguish it. POSIX gives `fsync` exactly one way
|
||||
/// to say "the data is on stable storage" and that is a zero return; a call
|
||||
/// that never reached the device has not earned it.
|
||||
///
|
||||
/// The errors carry no message text (this crate ships no user-facing English):
|
||||
/// the kind, and `EIO` for the worker-lost case, are the whole signal, and the
|
||||
/// `tracing` lines above/below carry the operator detail.
|
||||
fn bounded_failure_to_result(e: crate::io::bounded::BoundedError) -> io::Result<()> {
|
||||
match e {
|
||||
crate::io::bounded::BoundedError::Timeout => {
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all F_FULLFSYNC timed out after 60s; kernel will flush on close (best-effort)"
|
||||
"WritebackFile::sync_all F_FULLFSYNC timed out after 60s; data NOT durably flushed, kernel will flush on close"
|
||||
);
|
||||
Ok(())
|
||||
Err(crate::error::Error::SyncTimeout.into())
|
||||
}
|
||||
crate::io::bounded::BoundedError::Halted => {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all F_FULLFSYNC skipped (halt requested); data NOT durably flushed, kernel will flush on close"
|
||||
);
|
||||
Err(crate::error::Error::Halted.into())
|
||||
}
|
||||
crate::io::bounded::BoundedError::WorkerLost => {
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all F_FULLFSYNC worker lost before completion; data NOT durably flushed, kernel will flush on close"
|
||||
);
|
||||
Err(crate::error::Error::SyncWorkerLost.into())
|
||||
}
|
||||
Err(crate::io::bounded::BoundedError::Halted) => Ok(()),
|
||||
Err(crate::io::bounded::BoundedError::WorkerLost) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,4 +187,75 @@ mod tests {
|
||||
// durable_sync must complete without error on the local tempfile.
|
||||
durable_sync(f.as_file()).expect("durable_sync must return Ok on a local tempfile");
|
||||
}
|
||||
|
||||
/// Every `BoundedError` arm of the bounded `F_FULLFSYNC` means no sync
|
||||
/// observably ran. All three returned `Ok(())`, so `sync_all` reported a
|
||||
/// durability barrier that never happened — the caller could not tell a
|
||||
/// completed flush from a skipped one by any means except reading a log.
|
||||
///
|
||||
/// Asserted on the concrete `ErrorKind` / `errno` each arm must produce,
|
||||
/// so a future arm that quietly reverts to `Ok(())` fails here.
|
||||
#[test]
|
||||
fn every_bounded_failure_is_reported_as_an_error() {
|
||||
use crate::io::bounded::BoundedError;
|
||||
|
||||
let timeout = bounded_failure_to_result(BoundedError::Timeout)
|
||||
.expect_err("a timed-out F_FULLFSYNC must be an error");
|
||||
assert_eq!(
|
||||
timeout.kind(),
|
||||
io::ErrorKind::TimedOut,
|
||||
"a timed-out F_FULLFSYNC must not be reported as a completed sync"
|
||||
);
|
||||
|
||||
let halted = bounded_failure_to_result(BoundedError::Halted)
|
||||
.expect_err("a halted F_FULLFSYNC must be an error");
|
||||
assert_eq!(
|
||||
halted.kind(),
|
||||
io::ErrorKind::Interrupted,
|
||||
"a halted F_FULLFSYNC must not be reported as a completed sync"
|
||||
);
|
||||
|
||||
// The three arms must be DISTINGUISHABLE, not merely non-Ok. Each
|
||||
// carries its own numeric code through the "E<code>" prefix that
|
||||
// `From<Error> for io::Error` mints — the only shape `error_code`
|
||||
// recognises. A bare `ErrorKind` cannot be classified, which is how a
|
||||
// user cancel here used to read as a hard I/O failure.
|
||||
let lost = bounded_failure_to_result(BoundedError::WorkerLost)
|
||||
.expect_err("a lost F_FULLFSYNC worker must be an error");
|
||||
assert!(
|
||||
lost.to_string()
|
||||
.starts_with(&format!("E{}", crate::error::E_SYNC_WORKER_LOST)),
|
||||
"a lost worker must be identifiable, got {lost}"
|
||||
);
|
||||
assert!(
|
||||
timeout
|
||||
.to_string()
|
||||
.starts_with(&format!("E{}", crate::error::E_SYNC_TIMEOUT)),
|
||||
"a timeout must be distinguishable from a lost worker, got {timeout}"
|
||||
);
|
||||
assert!(
|
||||
crate::error::is_halt(&halted),
|
||||
"a halt must satisfy the crate's own is_halt(), or the CLI reports a \
|
||||
user cancel as a failure; got {halted}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The failure path must be reachable through the public surface: a
|
||||
/// `WritebackFile::sync_all` that hits any of these arms must surface an
|
||||
/// `Err`, not a silent `Ok`. Pinned at the mapping boundary because the
|
||||
/// timeout itself is not deterministically inducible in a unit test.
|
||||
#[test]
|
||||
fn bounded_failures_are_never_mapped_to_ok() {
|
||||
use crate::io::bounded::BoundedError;
|
||||
for e in [
|
||||
BoundedError::Timeout,
|
||||
BoundedError::Halted,
|
||||
BoundedError::WorkerLost,
|
||||
] {
|
||||
assert!(
|
||||
bounded_failure_to_result(e).is_err(),
|
||||
"a bounded F_FULLFSYNC failure must never map to Ok"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user