Compare commits
536
Commits
decb87a250
..
v1.6.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a63bac709 | ||
|
|
14eafa0692 | ||
|
|
9560248c49 | ||
|
|
14408b75a0 | ||
|
|
e7b6f727d9 | ||
|
|
040bc8b14d | ||
|
|
7abcfaab72 | ||
|
|
341e079e1e | ||
|
|
25893f1be4 | ||
|
|
efb69e3ba5 | ||
|
|
4cd9b7baa1 | ||
|
|
02e7bc605d | ||
|
|
0563b58f2e | ||
|
|
313460c97f | ||
|
|
68a1a55958 | ||
|
|
0955730045 | ||
|
|
d3103453f1 | ||
|
|
b64a96042e | ||
|
|
074b1ee829 | ||
|
|
8f9bde9b9a | ||
|
|
2d1563c63a | ||
|
|
cbb127a175 | ||
|
|
9c6b7baf83 | ||
|
|
3d738af58f | ||
|
|
3e400edd4f | ||
|
|
9c9de0e095 | ||
|
|
539dd0131b | ||
|
|
5677f42c69 | ||
|
|
f434b9cf2c | ||
|
|
b17761a24e | ||
|
|
f2dd1d2e34 | ||
|
|
671c3c7c8c | ||
|
|
c6be942d1a | ||
|
|
90d20304cd | ||
|
|
50dfe877b9 | ||
|
|
17622a1b59 | ||
|
|
32824fba5b | ||
|
|
109afcdcf7 | ||
|
|
dd749132d5 | ||
|
|
3ff2abbff5 | ||
|
|
03c6d20447 | ||
|
|
237794a6ea | ||
|
|
835e97ce71 | ||
|
|
70e1807e61 | ||
|
|
418abfe79e | ||
|
|
42c62d46b6 | ||
|
|
06d0f9ef8b | ||
|
|
b3887be90f | ||
|
|
3ecebf5b40 | ||
|
|
282651186c | ||
|
|
c3c37380ae | ||
|
|
8ec71834dd | ||
|
|
991977f297 | ||
|
|
d4a32d3a26 | ||
|
|
813edd0965 | ||
|
|
b16eacd6b4 | ||
|
|
776a4fd6eb | ||
|
|
a0f76a4f18 | ||
|
|
afa0a213d6 | ||
|
|
e5f92e591a | ||
|
|
79dbb2cf84 | ||
|
|
b9ca75f471 | ||
|
|
adc8ee37be | ||
|
|
c3e82f28b5 | ||
|
|
6b67c52249 | ||
|
|
dd9e92ed52 | ||
|
|
65ccbcbd92 | ||
|
|
aa149099ec | ||
|
|
7deacf1761 | ||
|
|
47789f71bc | ||
|
|
c31d9fc88e | ||
|
|
0e8c31a9c3 | ||
|
|
03d088abfc | ||
|
|
84e0ba9fa8 | ||
|
|
bc5e3453b4 | ||
|
|
6d9791affc | ||
|
|
d1551eb588 | ||
|
|
c875df49e3 | ||
|
|
93ad1f4854 | ||
|
|
d0393fb629 | ||
|
|
c64bc311ff | ||
|
|
a018e1adc4 | ||
|
|
1d35dcf7c6 | ||
|
|
77ad147563 | ||
|
|
5c64662213 | ||
|
|
1f70398774 | ||
|
|
35c5eedc20 | ||
|
|
f4b95b3dea | ||
|
|
0f61be00b7 | ||
|
|
f4fb5c65e0 | ||
|
|
c8fafec393 | ||
|
|
764535bb7d | ||
|
|
60d9cc1bac | ||
|
|
cbb3517afe | ||
|
|
3980aa8976 | ||
|
|
ffbc1d8399 | ||
|
|
9247e7da2f | ||
|
|
6c92370013 | ||
|
|
3090314717 | ||
|
|
ce246a1c87 | ||
|
|
617532e6ff | ||
|
|
dfd2f023d0 | ||
|
|
bd2ba08bb7 | ||
|
|
dc7c3a7db5 | ||
|
|
cfce270186 | ||
|
|
4ab8303a65 | ||
|
|
80564e0470 | ||
|
|
bab566da40 | ||
|
|
4fe05ba4c0 | ||
|
|
7199ee497a | ||
|
|
9ccb3c8444 | ||
|
|
4959b48386 | ||
|
|
37e056f070 | ||
|
|
1df36c78b2 | ||
|
|
6d2ff4d1fc | ||
|
|
94377c75fd | ||
|
|
0d4aab99df | ||
|
|
b93d10082d | ||
|
|
17bb13b077 | ||
|
|
b68765fe84 | ||
|
|
abffa4235a | ||
|
|
c94e9f4fb7 | ||
|
|
28d5897b86 | ||
|
|
3ed8630535 | ||
|
|
d0d8e2c9bf | ||
|
|
01d4a1ba00 | ||
|
|
5c8b4dc7c5 | ||
|
|
841aa1a1c6 | ||
|
|
14049bb477 | ||
|
|
8ffce6b621 | ||
|
|
9fda690d00 | ||
|
|
f53abfe0d4 | ||
|
|
80be34bff5 | ||
|
|
4447bd60ce | ||
|
|
c054893540 | ||
|
|
6758370f8d | ||
|
|
b2a274782a | ||
|
|
cf7ee69fd5 | ||
|
|
e008e71a17 | ||
|
|
c59e1e3342 | ||
|
|
39d9714ae7 | ||
|
|
dd940583c7 | ||
|
|
4b7e4ddbb3 | ||
|
|
5222458411 | ||
|
|
dd5118ee9c | ||
|
|
a8db2435cd | ||
|
|
ff18d4c3c8 | ||
|
|
f8ed0b99f4 | ||
|
|
8189da1b0c | ||
|
|
698ba36ae4 | ||
|
|
1eb8bdc9c7 | ||
|
|
90a7fe2ff1 | ||
|
|
4e70d9a5c5 | ||
|
|
1b95d346bb | ||
|
|
48663c6a2f | ||
|
|
a05f1d4498 | ||
|
|
3ecb2510e8 | ||
|
|
048f125879 | ||
|
|
65dbcb1ca6 | ||
|
|
b002da4221 | ||
|
|
51d2b14d03 | ||
|
|
c4ad4184ec | ||
|
|
528a6b7345 | ||
|
|
fb321f51eb | ||
|
|
e0ff0cfeb4 | ||
|
|
71686f1407 | ||
|
|
d50a7173ad | ||
|
|
e9811a1e01 | ||
|
|
f3841c8aca | ||
|
|
7d48d820e5 | ||
|
|
42591c77fc | ||
|
|
2efe1425d6 | ||
|
|
b86f7aef17 | ||
|
|
5559987325 | ||
|
|
72bcc371fb | ||
|
|
54d038e478 | ||
|
|
6868b93b7e | ||
|
|
8d39ccc613 | ||
|
|
8c0de5711e | ||
|
|
9f25a4c454 | ||
|
|
30bea12392 | ||
|
|
b2b611fa3b | ||
|
|
4f4b1ed222 | ||
|
|
944e6a8b09 | ||
|
|
5360f8d309 | ||
|
|
8b8bcff106 | ||
|
|
d5a9e70700 | ||
|
|
0bc8d7af9c | ||
|
|
e99b634635 | ||
|
|
f9d081ed45 | ||
|
|
3e13a155fa | ||
|
|
b2e1982051 | ||
|
|
84a77f6a0e | ||
|
|
9de88969ca | ||
|
|
170fd0c064 | ||
|
|
55b97ac576 | ||
|
|
c610285910 | ||
|
|
e4b1e5b19e | ||
|
|
8d4a6d54a4 | ||
|
|
93e1436fc0 | ||
|
|
18f8b285c4 | ||
|
|
c63dafcf1a | ||
|
|
46eb88c51f | ||
|
|
dea968f32b | ||
|
|
079c9b1327 | ||
|
|
327087c70e | ||
|
|
b8fa5e74dc | ||
|
|
3f7d7af472 | ||
|
|
fdd473d7e9 | ||
|
|
05fed1b0e0 | ||
|
|
d444afbdfc | ||
|
|
921404d135 | ||
|
|
399c3d2769 | ||
|
|
dc5b67ed46 | ||
|
|
5c6a6d0785 | ||
|
|
f5e169efb3 | ||
|
|
0bbceed985 | ||
|
|
4fcd28b487 | ||
|
|
9527bc1e13 | ||
|
|
58bdb42f8e | ||
|
|
62450e19bd | ||
|
|
013881ac06 | ||
|
|
5f8dc392c0 | ||
|
|
a32373ff40 | ||
|
|
3efa6211f3 | ||
|
|
9ad68dd092 | ||
|
|
13897e14f0 | ||
|
|
b4bf0daa82 | ||
|
|
ef36b452ad | ||
|
|
f338552969 | ||
|
|
38aa895038 | ||
|
|
e3676e7cdf | ||
|
|
807eb053ca | ||
|
|
7322f4dd8a | ||
|
|
4ed245868e | ||
|
|
50f37462db | ||
|
|
22a3e3fd01 | ||
|
|
99c5fd3500 | ||
|
|
d4c913e0d3 | ||
|
|
0e23a6b291 | ||
|
|
bcf47cc4ca | ||
|
|
a9dc3d7244 | ||
|
|
94a876664b | ||
|
|
7030de4ec9 | ||
|
|
c0434e87de | ||
|
|
ec5cd31ae1 | ||
|
|
a1304f9e78 | ||
|
|
a39045adf1 | ||
|
|
ea72e6df5f | ||
|
|
b2c7490b5b | ||
|
|
3db4106253 | ||
|
|
d34979ac57 | ||
|
|
bf2d15f39c | ||
|
|
d09ed76e07 | ||
|
|
f76688a0dc | ||
|
|
840cb9aef0 | ||
|
|
f3e80c8499 | ||
|
|
c812a32f3d | ||
|
|
eedd27e352 | ||
|
|
d8e5b97c86 | ||
|
|
0151e199ef | ||
|
|
ea99c82e32 | ||
|
|
8822c29905 | ||
|
|
0ee8341aea | ||
|
|
d22d09c898 | ||
|
|
43564d5752 | ||
|
|
508a2c3376 | ||
|
|
45a16991ff | ||
|
|
ca0daaea07 | ||
|
|
a02bbbdd24 | ||
|
|
ba8114f29b | ||
|
|
8421c227cd | ||
|
|
b79ff71b43 | ||
|
|
9b3e281f4d | ||
|
|
e0456c72da | ||
|
|
1c7ccd5a85 | ||
|
|
3bd2fd23b0 | ||
|
|
c00384d4df | ||
|
|
e8c151792d | ||
|
|
20b36229a5 | ||
|
|
71aad385c7 | ||
|
|
ec5b10f83a | ||
|
|
5181f6ce19 | ||
|
|
a711ee1d00 | ||
|
|
f2b7cc9bdd | ||
|
|
6f53767e8b | ||
|
|
7ed798e386 | ||
|
|
b9568242df | ||
|
|
8ac18fa631 | ||
|
|
197489fb7c | ||
|
|
dc7dfc6041 | ||
|
|
0acb326079 | ||
|
|
e632874665 | ||
|
|
88e58bfc95 | ||
|
|
279ba0dd7c | ||
|
|
1eb6910bdb | ||
|
|
e380e3b7c8 | ||
|
|
ff349fa61b | ||
|
|
52fd0f733a | ||
|
|
5b03fd8ebc | ||
|
|
c635190b0d | ||
|
|
34c5293704 | ||
|
|
bb59166e48 | ||
|
|
ea047b57d6 | ||
|
|
909fe48628 | ||
|
|
da19280950 | ||
|
|
2274423a6f | ||
|
|
c1f1593003 | ||
|
|
6718c9cdb2 | ||
|
|
9fdd5edb65 | ||
|
|
8a5a26f2a5 | ||
|
|
97ce0b7fab | ||
|
|
e194ef1585 | ||
|
|
4d1b922232 | ||
|
|
3841ae2250 | ||
|
|
2ccb5c9d01 | ||
|
|
b2bd5f8b3e | ||
|
|
6f055394c7 | ||
|
|
98f3dc513f | ||
|
|
5ecfe7c69a | ||
|
|
f255361683 | ||
|
|
c6e6bb9f4b | ||
|
|
f7edd4e6a9 | ||
|
|
a947439171 | ||
|
|
8e6114cd2a | ||
|
|
8f55cb78d2 | ||
|
|
65e14fe3b7 | ||
|
|
1e3610fd75 | ||
|
|
0c9d375548 | ||
|
|
8aff7fe708 | ||
|
|
489545c865 | ||
|
|
281d8baed6 | ||
|
|
6a4ac97a33 | ||
|
|
a8563e9fa3 | ||
|
|
63f6909ff0 | ||
|
|
9f33306a0a | ||
|
|
43cdc1351d | ||
|
|
5728a7c577 | ||
|
|
f85d91a17a | ||
|
|
a688e2c642 | ||
|
|
05fb632d7c | ||
|
|
3cb0a8f41c | ||
|
|
9dbfb70f7e | ||
|
|
9af3f7da7a | ||
|
|
2638c3075e | ||
|
|
3661942bdb | ||
|
|
43c1f9bda0 | ||
|
|
37832ac2dd | ||
|
|
2263d2cc4e | ||
|
|
3546648faa | ||
|
|
98000869b2 | ||
|
|
e308c5b825 | ||
|
|
5e1f880f6e | ||
|
|
ffe8ee8684 | ||
|
|
93571d9181 | ||
|
|
89af9876ae | ||
|
|
0471e0ca40 | ||
|
|
38207d2272 | ||
|
|
add9d8e0cd | ||
|
|
edc60582ec | ||
|
|
ccb7cafc68 | ||
|
|
0183bfb58c | ||
|
|
830d1e360c | ||
|
|
04728d7d94 | ||
|
|
e62ffed2b1 | ||
|
|
9d37043b3e | ||
|
|
6858cd064d | ||
|
|
f99670ceaa | ||
|
|
75b0e68b85 | ||
|
|
4a341331e2 | ||
|
|
422f2b6bcf | ||
|
|
d4021114cd | ||
|
|
fd6dfbe5b0 | ||
|
|
573d2f46c4 | ||
|
|
ef39674194 | ||
|
|
9973849408 | ||
|
|
d9db268b06 | ||
|
|
129c34b002 | ||
|
|
ebf30a679e | ||
|
|
09a8dd183d | ||
|
|
057c878831 | ||
|
|
43cbfa07f5 | ||
|
|
0e0967795e | ||
|
|
68b5372415 | ||
|
|
9e8f196b20 | ||
|
|
b8f0af9ef5 | ||
|
|
24e2bc33cf | ||
|
|
a7df91b92b | ||
|
|
0eb0188ba7 | ||
|
|
e2f595d558 | ||
|
|
18082d0df1 | ||
|
|
640502d5a8 | ||
|
|
270f9d88b3 | ||
|
|
6a0e61d415 | ||
|
|
92e3b41468 | ||
|
|
7d852419b5 | ||
|
|
9066433c29 | ||
|
|
c81a6e05cd | ||
|
|
0a9bdf08f6 | ||
|
|
2b74a9b21f | ||
|
|
b5a5138569 | ||
|
|
26423187d3 | ||
|
|
a94f78d090 | ||
|
|
14c4227292 | ||
|
|
fc3e1dd003 | ||
|
|
5090ddab6c | ||
|
|
3633882d6c | ||
|
|
ae27a097b9 | ||
|
|
5fdff5664f | ||
|
|
f8bea78db5 | ||
|
|
48bec4cc03 | ||
|
|
bfe88d2673 | ||
|
|
0d587d1154 | ||
|
|
67aba17173 | ||
|
|
45c12fc5ce | ||
|
|
3da8228068 | ||
|
|
974e886742 | ||
|
|
1f3f52d225 | ||
|
|
85347597cc | ||
|
|
bc04ee7bd2 | ||
|
|
122a03b23d | ||
|
|
dcc553a716 | ||
|
|
3b06a4c844 | ||
|
|
f9d112e481 | ||
|
|
f4fe651cb9 | ||
|
|
5ff04649ba | ||
|
|
84aaceceb7 | ||
|
|
cdee9739fd | ||
|
|
f55f11d043 | ||
|
|
f55d8f7acd | ||
|
|
31b0ba323a | ||
|
|
188baced39 | ||
|
|
bce11a2de0 | ||
|
|
7a79577343 | ||
|
|
f07251c2d4 | ||
|
|
eb23ab4586 | ||
|
|
61b6070f03 | ||
|
|
125a8e5bf0 | ||
|
|
741c1ea11d | ||
|
|
e713b26b87 | ||
|
|
fcbd667add | ||
|
|
88c03152e2 | ||
|
|
c8e7ad5e56 | ||
|
|
f122f08628 | ||
|
|
b2c3540989 | ||
|
|
3a4307def6 | ||
|
|
917026d566 | ||
|
|
6b0bcbb43f | ||
|
|
516ff4e581 | ||
|
|
c27009d443 | ||
|
|
789d988314 | ||
|
|
1e0dc4c514 | ||
|
|
629ed32e9e | ||
|
|
ac3b3fcfa4 | ||
|
|
0c8153304e | ||
|
|
e94319c099 | ||
|
|
a93da78621 | ||
|
|
cf13838f12 | ||
|
|
9519628954 | ||
|
|
21f8cec419 | ||
|
|
d5d72c4e22 | ||
|
|
c781fb7193 | ||
|
|
541ca2139f | ||
|
|
2dd98c3e32 | ||
|
|
ceaa1da369 | ||
|
|
fe14a2d5e5 | ||
|
|
8f6a92ccd4 | ||
|
|
29f76aad68 | ||
|
|
840ba8390c | ||
|
|
e803905265 | ||
|
|
11d4c33477 | ||
|
|
8d775cd341 | ||
|
|
bc07011bcb | ||
|
|
d65b776a8e | ||
|
|
ff7f3028a5 | ||
|
|
25a7b131b4 | ||
|
|
0cab32a08a | ||
|
|
22f0f5eb6f | ||
|
|
2013ef8c44 | ||
|
|
acaae3d0a4 | ||
|
|
65c6835363 | ||
|
|
0f0c496a4d | ||
|
|
923b9edbf4 | ||
|
|
caf1b03fd4 | ||
|
|
9cc422bd00 | ||
|
|
5b39a0af2b | ||
|
|
bd0a21bdb4 | ||
|
|
c8822ddea3 | ||
|
|
7a7ab2c9d8 | ||
|
|
7a0ef5412c | ||
|
|
8c38cb0918 | ||
|
|
b36896564f | ||
|
|
e648bde94b | ||
|
|
c86fa9bfc6 | ||
|
|
2ba6274eae | ||
|
|
3bdb6f8b1a | ||
|
|
80e8523db2 | ||
|
|
ab8f09645f | ||
|
|
326d17c2f4 | ||
|
|
bac105a022 | ||
|
|
cfcc524367 | ||
|
|
03820c68f8 | ||
|
|
f682405973 | ||
|
|
93fbfac6f0 | ||
|
|
a7c8ee09b0 | ||
|
|
263950622f | ||
|
|
9e6af4a729 | ||
|
|
be08e3938b | ||
|
|
cb7d78ac6a | ||
|
|
039a8f9f19 | ||
|
|
c5515d310f | ||
|
|
789b699f95 | ||
|
|
71b4b09c93 | ||
|
|
067fd207d5 | ||
|
|
e6180a429b | ||
|
|
d715a0943a | ||
|
|
9a7be7a1a5 | ||
|
|
a731e7b26b | ||
|
|
4d6f5c0a98 | ||
|
|
cad5929afe | ||
|
|
59681dfd4b | ||
|
|
eba34f4c20 | ||
|
|
49e5627a69 | ||
|
|
fb8405385e | ||
|
|
f23338b5dd | ||
|
|
a7bd574c34 | ||
|
|
f49ef023cf | ||
|
|
ba5e4fdafa | ||
|
|
ced89133cc | ||
|
|
aefd6b6342 | ||
|
|
78f78d285e | ||
|
|
80ecb671fd | ||
|
|
c49a180ce7 | ||
|
|
afa218fc8f | ||
|
|
835cc990ad | ||
|
|
d8c323bf9f |
@@ -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
|
||||||
+188
-10
@@ -2,50 +2,228 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
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:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- uses: dtolnay/rust-toolchain@1.86.0
|
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.97.0
|
||||||
with:
|
with:
|
||||||
components: clippy, rustfmt
|
components: clippy, rustfmt
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: libfreemkv
|
||||||
- run: cargo fmt --check
|
- run: cargo fmt --check
|
||||||
|
working-directory: libfreemkv
|
||||||
# libfreemkv is a library — Cargo.lock is gitignored. --locked
|
# libfreemkv is a library — Cargo.lock is gitignored. --locked
|
||||||
# would always fail on a fresh runner because there's no committed
|
# would always fail on a fresh runner because there's no committed
|
||||||
# lockfile to lock against. The binary crates (freemkv, autorip,
|
# lockfile to lock against. The binary crates (freemkv, autorip,
|
||||||
# bdemu) track Cargo.lock and DO use --locked.
|
# 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:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- uses: dtolnay/rust-toolchain@1.86.0
|
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.97.0
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: libfreemkv
|
||||||
- run: cargo test --tests
|
- run: cargo test --tests
|
||||||
|
working-directory: libfreemkv
|
||||||
|
|
||||||
check-macos:
|
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
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- uses: dtolnay/rust-toolchain@1.86.0
|
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.97.0
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: libfreemkv
|
||||||
- run: cargo check
|
- run: cargo check
|
||||||
|
working-directory: libfreemkv
|
||||||
|
|
||||||
check-windows:
|
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
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- uses: dtolnay/rust-toolchain@1.86.0
|
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.97.0
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: libfreemkv
|
||||||
# Build the tests (not just `cargo check`): catches errors in test
|
# Build the tests (not just `cargo check`): catches errors in test
|
||||||
# code and forces full codegen of the Windows-only SPTI transport
|
# code and forces full codegen of the Windows-only SPTI transport
|
||||||
# (src/scsi/windows.rs), which never compiles on the Linux/macOS dev
|
# (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
|
# 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.
|
# extra build is the value; running tests is covered by the Linux job.
|
||||||
- run: cargo build --tests
|
- 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
|
||||||
|
- uses: dtolnay/rust-toolchain@1.97.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
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
leak-guard:
|
leak-guard:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Compute commit range
|
- 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.97.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.97.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.97.0
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: libfreemkv
|
||||||
|
- run: cargo build --release --tests
|
||||||
|
working-directory: libfreemkv
|
||||||
@@ -4,6 +4,11 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- '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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -12,7 +17,7 @@ jobs:
|
|||||||
verify:
|
verify:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- name: Verify Cargo.toml version matches tag
|
- name: Verify Cargo.toml version matches tag
|
||||||
run: |
|
run: |
|
||||||
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
|
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
|
# 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
|
# 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.97, same commit). Binary consumers
|
||||||
# (freemkv/autorip/bdemu) git-tag-pin libfreemkv and therefore start building
|
# (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
|
# the instant this tag exists — so this test job and the crates.io publish
|
||||||
# below must NOT sit on their critical path.
|
# below must NOT sit on their critical path.
|
||||||
@@ -32,36 +37,18 @@ jobs:
|
|||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- uses: dtolnay/rust-toolchain@1.86.0
|
- uses: dtolnay/rust-toolchain@1.97.0
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
# libfreemkv is a library — Cargo.lock isn't tracked, so --locked
|
# libfreemkv is a library — Cargo.lock isn't tracked, so --locked
|
||||||
# would always fail (no lockfile to lock against on a fresh runner).
|
# would always fail (no lockfile to lock against on a fresh runner).
|
||||||
- run: cargo test
|
- run: cargo test
|
||||||
|
|
||||||
# crates.io publish is an INDEPENDENT job: it serves EXTERNAL consumers only.
|
# NOTE: there is no crates.io publish job. libfreemkv is git-tag-only
|
||||||
# The freemkv binaries no longer depend on it (they git-tag-pin libfreemkv via
|
# (`package.publish = false` — it git-deps the firmware crate freemkv-unlock,
|
||||||
# a committed [patch.crates-io]), so this publish runs in parallel with their
|
# which never ships to crates.io). Every consumer git-tag-pins libfreemkv via
|
||||||
# release builds rather than gating them. It `needs: [verify, test]` so a
|
# a committed [patch.crates-io]; the git tag itself IS the release artifact.
|
||||||
# failing test suite still blocks publication to crates.io — external
|
# A `cargo publish` here fails hard on `publish = false`, so it was removed.
|
||||||
# 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 }}
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
# Only needs `verify`; the GitHub Release can be cut as soon as the version
|
# Only needs `verify`; the GitHub Release can be cut as soon as the version
|
||||||
@@ -69,8 +56,8 @@ jobs:
|
|||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
generate_release_notes: true
|
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)
|
# internal agent context — never publish (path AND dir; leak-guard blocks both)
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.claude/
|
.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/
|
||||||
|
|||||||
+878
-279
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
|
## Enforcement
|
||||||
|
|
||||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at matthew@pq.io. 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.
|
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
|
## 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.
|
||||||
|
|||||||
+22
-15
@@ -1,15 +1,18 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "libfreemkv"
|
name = "libfreemkv"
|
||||||
version = "1.0.0-rc.5.3"
|
version = "1.6.6"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.86"
|
rust-version = "1.97"
|
||||||
license = "AGPL-3.0-only"
|
license = "MIT"
|
||||||
description = "Open source raw disc access library for optical drives"
|
description = "Open source raw disc access library for optical drives"
|
||||||
repository = "https://github.com/freemkv/libfreemkv"
|
repository = "https://github.com/freemkv/libfreemkv"
|
||||||
keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
|
keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
|
||||||
categories = ["hardware-support", "multimedia"]
|
categories = ["hardware-support", "multimedia"]
|
||||||
# Keep internal AI-instruction / private notes out of the published crate.
|
# Keep internal AI-instruction / private notes out of the published crate.
|
||||||
exclude = ["CLAUDE.md"]
|
exclude = ["CLAUDE.md"]
|
||||||
|
# OFF crates.io: libfreemkv git-deps freemkv-unlock (firmware, never published),
|
||||||
|
# so libfreemkv itself can only be consumed by git tag. Clients git-tag-pin it.
|
||||||
|
publish = false
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
@@ -19,18 +22,22 @@ codegen-units = 1
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
sha2 = "0.10"
|
aes = "0.9"
|
||||||
aes = "0.8"
|
# Interim path dep for local cross-repo dev; the release script re-pins this to
|
||||||
cbc = "0.1"
|
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
|
||||||
flate2 = "1"
|
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
||||||
num-bigint = "0.4"
|
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.6.5" }
|
||||||
num-traits = "0.2"
|
rand = "0.10"
|
||||||
num-integer = "0.1"
|
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||||
rand = "0.8"
|
base64 = "0.23"
|
||||||
cmac = "0.7"
|
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
|
||||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
|
||||||
base64 = "0.22.1"
|
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
|
||||||
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
|
# 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)
|
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
|
||||||
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
MIT License
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
|
||||||
Copyright (C) 2026 FreeMKV Contributors
|
Copyright (c) 2026 Matthew Jackson & Contributors
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
it under the terms of the GNU Affero General Public License as published
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
by the Free Software Foundation, version 3 of the License.
|
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,
|
The above copyright notice and this permission notice shall be included in all
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
copies or substantial portions of the Software.
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
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,26 +1,26 @@
|
|||||||
[](https://crates.io/crates/libfreemkv)
|
[](LICENSE)
|
||||||
[](https://docs.rs/libfreemkv)
|
|
||||||
[](LICENSE)
|
|
||||||
|
|
||||||
# libfreemkv
|
# libfreemkv
|
||||||
|
|
||||||
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Firmware-clean core: drive-unlock support is plugged in via the `Unlocker` trait, with concrete unlockers shipped as separate crates.
|
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Drive-level unlocking is handled internally; consumers work with disc access and decryption only.
|
||||||
|
|
||||||
DVDs (CSS) decrypt out of the box. Blu-ray and UHD (AACS) require a `keydb.cfg` (default `~/.config/freemkv/keydb.cfg`) supplying disc-specific volume unique keys; no AACS key material is compiled in.
|
DVDs (CSS) decrypt out of the box. Blu-ray and UHD (AACS) require a `keydb.cfg` (default `~/.config/freemkv/keydb.cfg`) supplying disc-specific volume unique keys; no AACS key material is compiled in.
|
||||||
|
|
||||||
**12+ MB/s** sustained read speeds on BD. Drive prep routes through the pluggable unlock seam — register an unlocker and `init()` drives it; with none registered the library rips via the host-certificate AACS handshake.
|
**12+ MB/s** sustained read speeds on BD. Drive prep (`init()`) handles unlocking internally via the `freemkv-unlock` crate — clients never see it; when no drive unlock applies, the library rips via the host-certificate AACS handshake.
|
||||||
|
|
||||||
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
|
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
|
||||||
|
|
||||||
**[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)**
|
**[Source & API](https://github.com/freemkv/libfreemkv)** · **[Technical Docs](docs/)**
|
||||||
|
|
||||||
Part of the [freemkv](https://github.com/freemkv) project.
|
Part of the [freemkv](https://github.com/freemkv) project.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
Consumed by git tag (not published to crates.io):
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
libfreemkv = "1.0.0-rc.1"
|
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "vX.Y.Z" }
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -32,7 +32,7 @@ use std::path::Path;
|
|||||||
// Open drive — identified via INQUIRY
|
// Open drive — identified via INQUIRY
|
||||||
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||||
drive.wait_ready()?; // wait for disc
|
drive.wait_ready()?; // wait for disc
|
||||||
drive.init()?; // route through the unlock seam (if an unlocker is registered)
|
drive.init()?; // unlock + prep (handled internally)
|
||||||
drive.probe_disc()?; // probe disc surface for optimal speeds
|
drive.probe_disc()?; // probe disc surface for optimal speeds
|
||||||
|
|
||||||
// Scan disc — UDF, playlists, streams, AACS (all automatic)
|
// Scan disc — UDF, playlists, streams, AACS (all automatic)
|
||||||
@@ -55,52 +55,19 @@ output.finish()?;
|
|||||||
|
|
||||||
### Multi-pass recovery rip
|
### Multi-pass recovery rip
|
||||||
|
|
||||||
For damaged discs the library exposes two flat verbs — `Disc::sweep` for the
|
Recovery moved OUT of this crate in 1.6.0. The sweep/patch strategy, the
|
||||||
forward Pass 1 and `Disc::patch` for retrying bad ranges. The library never
|
ddrescue mapfile, damage classification and the multipass loop now live in the
|
||||||
loops; the multipass policy is the caller's job. See
|
`freemkv-engine` crate as `freemkv_engine::recovery::{copy, sweep, patch}`.
|
||||||
[`docs/rip-recovery.md`](docs/rip-recovery.md).
|
|
||||||
|
|
||||||
```rust
|
libfreemkv keeps the layers underneath: the raw single-shot read
|
||||||
use libfreemkv::{SweepOptions, PatchOptions};
|
(`Drive::read`) and the SCSI-fact translation (`SenseFamily`) that the engine's
|
||||||
use libfreemkv::disc::{mapfile, mapfile_path_for};
|
strategy is built on. The dependency runs engine → libfreemkv, so this crate
|
||||||
use std::path::Path;
|
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.
|
||||||
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).
|
|
||||||
```
|
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
- **Drive access** — open, identify, pluggable unlock seam, speed control, eject
|
- **Drive access** — open, identify, internal unlock + prep, speed control, eject
|
||||||
- **12+ MB/s reads** — auto-detects kernel transfer limits, sustained full speed
|
- **12+ MB/s reads** — auto-detects kernel transfer limits, sustained full speed
|
||||||
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
|
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
|
||||||
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||||
@@ -114,7 +81,7 @@ loop {
|
|||||||
| Stream | Input | Output | Transport |
|
| Stream | Input | Output | Transport |
|
||||||
|--------|-------|--------|-----------|
|
|--------|-------|--------|-----------|
|
||||||
| DiscStream | Yes | -- | Optical drive via SCSI |
|
| 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 |
|
| MkvStream | Yes | Yes | Matroska container |
|
||||||
| M2tsStream | Yes | Yes | BD transport stream with FMKV metadata header |
|
| M2tsStream | Yes | Yes | BD transport stream with FMKV metadata header |
|
||||||
| NetworkStream | Yes (listen) | Yes (connect) | TCP with FMKV metadata header |
|
| NetworkStream | Yes (listen) | Yes (connect) | TCP with FMKV metadata header |
|
||||||
@@ -134,8 +101,8 @@ Blu-rays and UHD (AACS) require a `keydb.cfg` at `~/.config/freemkv/keydb.cfg` (
|
|||||||
```text
|
```text
|
||||||
Drive — open, identify, init, single-shot read
|
Drive — open, identify, init, single-shot read
|
||||||
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
|
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
|
||||||
└── Unlocker seam — pluggable trait + registry; concrete unlockers
|
└── unlock_bridge — private seam to the freemkv-unlock crate
|
||||||
live in the separate freemkv-unlock repo
|
(firmware / AACS cert / CSS bus-auth unlockers)
|
||||||
|
|
||||||
Disc — scan titles, streams, AACS/CSS state
|
Disc — scan titles, streams, AACS/CSS state
|
||||||
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
||||||
@@ -190,4 +157,4 @@ Run `freemkv info disc:// --share` with the [freemkv CLI](https://github.com/fre
|
|||||||
|
|
||||||
## License
|
## 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.
|
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 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+ (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 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.
|
- **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.
|
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:
|
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`.
|
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.
|
3. **Connection type:** USB (with bridge chipset if known) or direct SATA.
|
||||||
4. **Operating system and kernel:** `uname -a`.
|
4. **Operating system and kernel:** `uname -a`.
|
||||||
5. **Kernel messages during the failure:** `dmesg | tail -50` immediately after the crash.
|
5. **Kernel messages during the failure:** `dmesg | tail -50` immediately after the crash.
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
|
emit_git_suffix();
|
||||||
|
|
||||||
let target = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
let target = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||||
if target == "macos" {
|
if target == "macos" {
|
||||||
println!("cargo:rustc-link-lib=framework=IOKit");
|
println!("cargo:rustc-link-lib=framework=IOKit");
|
||||||
@@ -20,7 +22,7 @@ fn main() {
|
|||||||
&target_arch // x86_64 → x86_64
|
&target_arch // x86_64 → x86_64
|
||||||
};
|
};
|
||||||
|
|
||||||
std::process::Command::new("cc")
|
let cc_status = std::process::Command::new("cc")
|
||||||
.args([
|
.args([
|
||||||
"-arch",
|
"-arch",
|
||||||
clang_arch,
|
clang_arch,
|
||||||
@@ -36,15 +38,67 @@ fn main() {
|
|||||||
"-O2",
|
"-O2",
|
||||||
])
|
])
|
||||||
.status()
|
.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])
|
.args(["rcs", &lib, &obj])
|
||||||
.status()
|
.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-search=native={out_dir}");
|
||||||
println!("cargo:rustc-link-lib=static=macos_scsi");
|
println!("cargo:rustc-link-lib=static=macos_scsi");
|
||||||
println!("cargo:rerun-if-changed=src/scsi/macos_shim.c");
|
println!("cargo:rerun-if-changed=src/scsi/macos_shim.c");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bake the git short hash into the build as `GIT_SUFFIX` so any muxed MKV or
|
||||||
|
/// FVI index is traceable to the exact source revision (e.g. ` (g835cc99)`).
|
||||||
|
/// Empty when git or the repo is unavailable (e.g. a crates.io tarball build),
|
||||||
|
/// leaving just the package version. Always emitted so `env!("GIT_SUFFIX")`
|
||||||
|
/// resolves on every target.
|
||||||
|
fn emit_git_suffix() {
|
||||||
|
// Version label for the muxing-app / FVI generator tag. `FREEMKV_BUILD_LABEL`
|
||||||
|
// overrides the Cargo package version when set (non-empty) — used to stamp a
|
||||||
|
// pre-release/test build without bumping Cargo.toml and disturbing the
|
||||||
|
// tag-pinned [patch] version matching. Unset → the package version.
|
||||||
|
let version = std::env::var("FREEMKV_BUILD_LABEL")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.or_else(|| std::env::var("CARGO_PKG_VERSION").ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
println!("cargo:rustc-env=FREEMKV_VERSION={version}");
|
||||||
|
println!("cargo:rerun-if-env-changed=FREEMKV_BUILD_LABEL");
|
||||||
|
|
||||||
|
let suffix = git_short_hash()
|
||||||
|
.map(|h| format!(" (g{h})"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
println!("cargo:rustc-env=GIT_SUFFIX={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")
|
||||||
|
&& let Some(ref_path) = head.strip_prefix("ref: ")
|
||||||
|
{
|
||||||
|
println!("cargo:rerun-if-changed=.git/{}", ref_path.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_short_hash() -> Option<String> {
|
||||||
|
let out = std::process::Command::new("git")
|
||||||
|
.args(["rev-parse", "--short=7", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let h = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
||||||
|
if h.is_empty() { None } else { Some(h) }
|
||||||
|
}
|
||||||
|
|||||||
+49
-37
@@ -1,7 +1,8 @@
|
|||||||
# FVI — Freemkv Video Index Format
|
# FVI — Freemkv Video Index Format
|
||||||
|
|
||||||
**Specification version:** 1.0 (DRAFT)
|
**Specification version:** 1.0 (DRAFT)\
|
||||||
**File extension:** `.fvi` **Media type:** `application/vnd.freemkv.fvi+jsonl`
|
**File extension:** `.fvi`\
|
||||||
|
**Media type:** `application/vnd.freemkv.fvi+jsonl`\
|
||||||
**Status:** Draft for review. This document is the normative reference for the FVI
|
**Status:** Draft for review. This document is the normative reference for the FVI
|
||||||
format; implementations and downstream tools cite it by section.
|
format; implementations and downstream tools cite it by section.
|
||||||
|
|
||||||
@@ -25,13 +26,6 @@ FVI is **not** a container, a codec, or a copy of the bitstream. It indexes; it
|
|||||||
never stores coded samples. It is the serialized form of an indexer's per-picture
|
never stores coded samples. It is the serialized form of an indexer's per-picture
|
||||||
truth — carried from the demuxer, **never reconstructed** (§9).
|
truth — carried from the demuxer, **never reconstructed** (§9).
|
||||||
|
|
||||||
### 1.1 Relationship to prior art
|
|
||||||
|
|
||||||
Legacy MPEG-only project-index formats from the AviSynth frameserving ecosystem
|
|
||||||
solve a narrow version of (1) and (2) for MPEG-1/2 only, in a bespoke,
|
|
||||||
single-tool text encoding. FVI generalizes that idea: codec-agnostic, JSON-based,
|
|
||||||
provenance-native, and openly specified so any tool may read or write it.
|
|
||||||
|
|
||||||
## 2. Conformance
|
## 2. Conformance
|
||||||
|
|
||||||
The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**,
|
The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**,
|
||||||
@@ -105,15 +99,15 @@ start of a new stream section.
|
|||||||
| `width`,`height` | integer | MUST | Coded luma dimensions in pixels. |
|
| `width`,`height` | integer | MUST | Coded luma dimensions in pixels. |
|
||||||
| `dar` | `[int,int]` | SHOULD | Display aspect ratio as `[num,den]`. |
|
| `dar` | `[int,int]` | SHOULD | Display aspect ratio as `[num,den]`. |
|
||||||
| `frame_rate` | `[int,int]` | SHOULD | Nominal rate as exact rational `[num,den]` (e.g. `[24000,1001]`). |
|
| `frame_rate` | `[int,int]` | SHOULD | Nominal rate as exact rational `[num,den]` (e.g. `[24000,1001]`). |
|
||||||
| `scan` | string | MUST | `"progressive"` \| `"interlaced"` \| `"mbaff"`. |
|
| `scan` | string | MUST | `"progressive"`<br>`"interlaced"`<br>`"mbaff"` |
|
||||||
| `colour` | object | SHOULD | CICP per ITU-T H.273: `primaries`,`transfer`,`matrix` (integer CICP codes or registered names), `range` (`"limited"`\|`"full"`). HDR: `mastering_display`, `max_cll`, `max_fall` per ITU-T H.273 / SMPTE ST 2086. |
|
| `colour` | object | SHOULD | CICP per ITU-T H.273: `primaries`, `transfer`, `matrix` (integer CICP codes or registered names)<br>`range`: `"limited"` \| `"full"`<br>HDR: `mastering_display`, `max_cll`, `max_fall` per ITU-T H.273 / SMPTE ST 2086. |
|
||||||
| `language` | string | MAY | BCP 47 tag, if known. |
|
| `language` | string | MAY | BCP 47 tag, if known. |
|
||||||
|
|
||||||
### 6.2 `source` object
|
### 6.2 `source` object
|
||||||
|
|
||||||
| Member | JSON type | Req | Semantics |
|
| Member | JSON type | Req | Semantics / reference |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `medium` | string | MUST | `"disc"` \| `"iso"` \| `"file"` \| `"stream"`. |
|
| `medium` | string | MUST | `"disc"`<br>`"iso"`<br>`"file"`<br>`"stream"` |
|
||||||
| `path` | string | MAY | Source path/label. |
|
| `path` | string | MAY | Source path/label. |
|
||||||
| `title` | integer | MAY | Title/program number. |
|
| `title` | integer | MAY | Title/program number. |
|
||||||
| `playlist` | string | MAY | Playlist/PGC identifier. |
|
| `playlist` | string | MAY | Playlist/PGC identifier. |
|
||||||
@@ -128,13 +122,13 @@ One JSON object per coded picture, in coded order.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `n` | integer | MUST | Coded-order index, 0-based, contiguous. |
|
| `n` | integer | MUST | Coded-order index, 0-based, contiguous. |
|
||||||
| `src` | object | MUST | Provenance: `{ "file": int?, "sector": uint, "byte": uint }` — the offset of this AU's **first byte** in the source (§9). MUST be carried from demux, never reconstructed. |
|
| `src` | object | MUST | Provenance: `{ "file": int?, "sector": uint, "byte": uint }` — the offset of this AU's **first byte** in the source (§9). MUST be carried from demux, never reconstructed. |
|
||||||
| `type` | string | MUST | Coding type: `"I"` \| `"P"` \| `"B"` (ISO/IEC 13818-2 §6.3.9; H.264/H.265 slice types collapsed to frame type). |
|
| `type` | string | MUST | Coding type:<br>`"I"`<br>`"P"`<br>`"B"`<br>_ISO/IEC 13818-2 §6.3.9; H.264/H.265 slice types collapsed to frame type._ |
|
||||||
| `key` | boolean | MUST | `true` iff this picture is an intra (I) picture / parser-flagged decode-restart point (IDR / IRAP / I-picture). MPEG-2 open-GOP clean-RAP precision (`closed_gop`) is not currently distinguished — see note below. |
|
| `key` | boolean | MUST | `true` iff this picture is an intra (I) picture / parser-flagged decode-restart point (IDR / IRAP / I-picture).<br>_MPEG-2 open-GOP clean-RAP precision (`closed_gop`) is not currently distinguished — see note below._ |
|
||||||
| `gop` | boolean | SHOULD | `true` iff this picture begins a GOP / coded video sequence. Omitted when the implementation does not carry a distinct GOP-boundary signal. |
|
| `gop` | boolean | SHOULD | `true` iff this picture begins a GOP / coded video sequence.<br>_Omitted when the implementation does not carry a distinct GOP-boundary signal._ |
|
||||||
| `pts` | integer\|null | SHOULD | Presentation timestamp in `timescale` ticks; `null` if unknown. |
|
| `pts` | integer\|null | SHOULD | Presentation timestamp in `timescale` ticks; `null` if unknown. |
|
||||||
| `dts` | integer\|null | MAY | Decode timestamp in `timescale` ticks. |
|
| `dts` | integer\|null | MAY | Decode timestamp in `timescale` ticks. |
|
||||||
| `size` | integer | MAY | AU length in bytes; enables byte-range extraction with `src`. |
|
| `size` | integer | MAY | AU length in bytes; enables byte-range extraction with `src`. |
|
||||||
| `recovered` | boolean | MAY | `true` iff any byte of this AU came from a retried/marginal read (§9.1). Default `false`. |
|
| `recovered` | boolean | MAY | `true` iff any byte of this AU came from a retried/marginal read (§9.1).<br>_Default `false`._ |
|
||||||
| codec ext | object | MAY | Codec-specific members under the codec's namespace (§8). |
|
| codec ext | object | MAY | Codec-specific members under the codec's namespace (§8). |
|
||||||
|
|
||||||
The `type` and `key` members are **codec-agnostic** and MUST be populated for
|
The `type` and `key` members are **codec-agnostic** and MUST be populated for
|
||||||
@@ -162,11 +156,11 @@ per-picture coding accessors (MPEG-2: ISO/IEC 13818-2 §6.3.10). Emitted as
|
|||||||
top-level members of the record, and ONLY when the codec actually measured the
|
top-level members of the record, and ONLY when the codec actually measured the
|
||||||
signal — an OPTIONAL member that is omitted (not defaulted) when unknown:
|
signal — an OPTIONAL member that is omitted (not defaulted) when unknown:
|
||||||
|
|
||||||
| Member | JSON type | Req | Semantics |
|
| Member | JSON type | Req | Semantics / reference |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `field_order` | string | MAY | Display field order: `"tff"` (top field first) \| `"bff"` (bottom field first) \| `"progressive"` (no field order applies). Omitted when the codec did not signal it. |
|
| `field_order` | string | MAY | Display field order:<br>`"tff"` — top field first<br>`"bff"` — bottom field first<br>`"progressive"` — no field order applies<br>_Omitted when the codec did not signal it._ |
|
||||||
| `progressive` | boolean | MAY | `true` iff the picture is progressive. Omitted when the codec did not signal it. |
|
| `progressive` | boolean | MAY | `true` iff the picture is progressive.<br>_Omitted when the codec did not signal it._ |
|
||||||
| `nb_fields` | integer | MAY | Number of displayed field periods this picture occupies (the soft-telecine / 2:3 pulldown basis): `1` for a single field picture, `2` for a normal frame, `3`/`4`/`6` for `repeat_first_field` pulldown per §6.3.10. |
|
| `nb_fields` | integer | MAY | Number of displayed field periods this picture occupies (the soft-telecine / 2:3 pulldown basis):<br>`1` for a single field picture<br>`2` for a normal frame<br>`3`/`4`/`6` for `repeat_first_field` pulldown per §6.3.10 |
|
||||||
|
|
||||||
Codecs that carry only a coding type (e.g. H.264 / HEVC / VC-1 through this
|
Codecs that carry only a coding type (e.g. H.264 / HEVC / VC-1 through this
|
||||||
pipeline) omit `field_order` and `progressive` rather than guessing a default.
|
pipeline) omit `field_order` and `progressive` rather than guessing a default.
|
||||||
@@ -179,8 +173,21 @@ registered set per codec profile (e.g. MPEG-2 §7.1), or (b) placed under an
|
|||||||
`ext` object keyed by codec id for richer/optional data:
|
`ext` object keyed by codec id for richer/optional data:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"n":42,"type":"P","key":false,"src":{"sector":17,"byte":924},
|
{
|
||||||
"ext":{"hevc":{"temporal_id":0,"nal_type":1}}}
|
"n": 42,
|
||||||
|
"type": "P",
|
||||||
|
"key": false,
|
||||||
|
"src": {
|
||||||
|
"sector": 17,
|
||||||
|
"byte": 924
|
||||||
|
},
|
||||||
|
"ext": {
|
||||||
|
"hevc": {
|
||||||
|
"temporal_id": 0,
|
||||||
|
"nal_type": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
New codecs and members are added through Appendix B (codec registry) without a
|
New codecs and members are added through Appendix B (codec registry) without a
|
||||||
@@ -238,13 +245,13 @@ Header:
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["format","fvi_version","stream","source","timescale"],
|
"required": ["format", "fvi_version", "stream", "source", "timescale"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"format": {"const": "freemkv/video-index"},
|
"format": { "const": "freemkv/video-index" },
|
||||||
"fvi_version": {"type": "integer", "minimum": 1},
|
"fvi_version": { "type": "integer", "minimum": 1 },
|
||||||
"timescale": {"type": "integer", "minimum": 1},
|
"timescale": { "type": "integer", "minimum": 1 },
|
||||||
"stream": {"type": "object", "required": ["codec","width","height","scan"]},
|
"stream": { "type": "object", "required": ["codec", "width", "height", "scan"] },
|
||||||
"source": {"type": "object", "required": ["medium"]}
|
"source": { "type": "object", "required": ["medium"] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -255,15 +262,20 @@ Record:
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["n","src","type","key"],
|
"required": ["n", "src", "type", "key"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"n": {"type": "integer", "minimum": 0},
|
"n": { "type": "integer", "minimum": 0 },
|
||||||
"type": {"enum": ["I","P","B"]},
|
"type": { "enum": ["I", "P", "B"] },
|
||||||
"key": {"type": "boolean"},
|
"key": { "type": "boolean" },
|
||||||
"src": {"type":"object","required":["sector","byte"],
|
"src": {
|
||||||
"properties":{"file":{"type":"integer"},
|
"type": "object",
|
||||||
"sector":{"type":"integer","minimum":0},
|
"required": ["sector", "byte"],
|
||||||
"byte":{"type":"integer","minimum":0}}}
|
"properties": {
|
||||||
|
"file": { "type": "integer" },
|
||||||
|
"sector": { "type": "integer", "minimum": 0 },
|
||||||
|
"byte": { "type": "integer", "minimum": 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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
|
// Read content -- decryption is applied on read by the DiscStream decorator.
|
||||||
let mut reader = disc.open_title(&mut session, 0).unwrap();
|
// Live disc does NOT go through the URL resolver: `input("disc://...")` returns
|
||||||
while let Some(unit) = reader.read_unit().unwrap() {
|
// Error::DiscUrlNotDirect by design.
|
||||||
// decrypted content
|
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
|
The application never calls decryption functions and never manages the
|
||||||
manages handshakes. All of that is internal to `Disc::scan()` and the content
|
drive-level handshake. It DOES own key resolution — see below.
|
||||||
reader.
|
|
||||||
|
|
||||||
### KEYDB Location
|
### Key resolution is the caller's job
|
||||||
|
|
||||||
`ScanOptions` controls where the keydb is loaded from. If no explicit path is
|
`libfreemkv` is **lookup-free: it resolves no keys and reads no keydb.** There is
|
||||||
set, the library checks the standard config locations. To specify an explicit
|
no `ScanOptions::with_keydb`, and `ScanOptions` has no keydb field — its only
|
||||||
path:
|
scan input is the optional drive credentials for the live-drive authenticated
|
||||||
|
handshake.
|
||||||
|
|
||||||
```rust
|
The caller resolves a key out-of-band through a key source and applies it with
|
||||||
let opts = ScanOptions::with_keydb("/path/to/keydb.cfg");
|
[`Disc::decrypt_with`]. `freemkv-keysources` is the crate that implements the
|
||||||
let disc = Disc::scan(&mut session, &opts).unwrap();
|
keydb and key-server sources; `ScanOptions::key_sources` takes them as
|
||||||
```
|
`Box<dyn KeySource>`.
|
||||||
|
|
||||||
### AacsState
|
### AacsState
|
||||||
|
|
||||||
@@ -97,7 +108,7 @@ After a successful scan, `disc.aacs` contains an `AacsState`:
|
|||||||
| `bus_encryption` | `bool` | Whether bus encryption is active |
|
| `bus_encryption` | `bool` | Whether bus encryption is active |
|
||||||
| `mkb_version` | `Option<u32>` | MKB version from disc |
|
| `mkb_version` | `Option<u32>` | MKB version from disc |
|
||||||
| `disc_hash` | `String` | Identifier for the disc's key-input files |
|
| `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
|
## keydb.cfg
|
||||||
|
|
||||||
|
|||||||
+10
-7
@@ -170,17 +170,20 @@ libfreemkv/src/
|
|||||||
│ ├── writeback_file.rs WritebackFile (was crate::io::Writer)
|
│ ├── writeback_file.rs WritebackFile (was crate::io::Writer)
|
||||||
│ └── writeback.rs sync_file_range pipeline
|
│ └── writeback.rs sync_file_range pipeline
|
||||||
├── drive/ Drive (open, init, single-shot read)
|
├── 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
|
│ ├── capture.rs Raw drive SCSI capture (INQUIRY/GET_CONFIG) for contribution
|
||||||
│ ├── linux.rs Linux drive discovery
|
│ ├── linux.rs Linux drive discovery
|
||||||
│ ├── macos.rs macOS drive discovery
|
│ ├── macos.rs macOS drive discovery
|
||||||
│ └── windows.rs Windows drive discovery
|
│ └── windows.rs Windows drive discovery
|
||||||
├── disc/ Disc (scan, titles, AACS setup, sweep, patch)
|
├── disc/ Disc (scan, titles, AACS setup, per-format parsing)
|
||||||
│ ├── mod.rs Disc struct, scan, titles, formats; Disc::copy + Disc::sweep (Pass 1)
|
│ ├── mod.rs Disc struct, scan, titles, formats
|
||||||
│ ├── sweep.rs Pass 1 internal helpers (pub(super))
|
│ ├── bluray.rs Blu-ray / UHD scanning (MPLS/CLPI-driven)
|
||||||
│ ├── patch.rs Disc::patch (Pass N retry over mapfile)
|
│ ├── dvd.rs DVD-Video scanning (IFO-driven)
|
||||||
│ ├── mapfile.rs ddrescue-format mapfile
|
│ ├── hddvd.rs HD-DVD scanning
|
||||||
│ └── read_error.rs ReadCtx / ReadAction state machine
|
│ ├── 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)
|
├── scsi/ SCSI transport (Linux SG_IO, macOS IOKit, Windows SPTI)
|
||||||
├── unlock.rs Unlocker trait + registry (pluggable unlock seam)
|
├── unlock.rs Unlocker trait + registry (pluggable unlock seam)
|
||||||
├── aacs/ AACS decryption (handshake, keys, keydb, decrypt)
|
├── 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.
|
material is compiled in; DVD CSS player keys are the only compiled-in keys.
|
||||||
|
|
||||||
**Repository:** <https://github.com/freemkv/libfreemkv>
|
**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
|
- `init()` -- routes to the matching registered unlocker (if any); otherwise
|
||||||
a no-op and the cert handshake carries the disc
|
a no-op and the cert handshake carries the disc
|
||||||
- `probe_disc()` -- probe disc surface for optimal speeds
|
- `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
|
- `wait_ready()` -- wait for disc insertion
|
||||||
- `eject()` -- eject tray
|
- `eject()` -- eject tray
|
||||||
|
|
||||||
Recovery is layered above `Drive::read`, not inside it. Layer 1
|
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
|
Layer 3 (`DiscStream::fill_extents` adaptive batch sizer) handles in-loop
|
||||||
request-size adaptation. Inline recovery (gentle retry → SCSI reset → retry)
|
request-size adaptation. Inline recovery (gentle retry → SCSI reset → retry)
|
||||||
was removed in 0.13.6 — see [`rip-recovery.md`](rip-recovery.md) and
|
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
|
Offset Size Field
|
||||||
------ ---- -----
|
------ ---- -----
|
||||||
0 2 Stream PID
|
2 2 stream_PID (byte-aligned)
|
||||||
2 2 Reserved + EP stream type
|
4 10 Bit-packed block, 80 bits total (see below)
|
||||||
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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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.
|
libfreemkv parses only the first stream (primary video), which is sufficient for sector-level seeking.
|
||||||
|
|
||||||
### Two-Level Index
|
### Two-Level Index
|
||||||
|
|||||||
+2
-2
@@ -79,7 +79,7 @@ Insert disc
|
|||||||
│ Or: read sectors → decrypt → raw bytes (for ISO output)
|
│ Or: read sectors → decrypt → raw bytes (for ISO output)
|
||||||
│ Drive::read() is single-shot. DiscStream::fill_extents adapts the
|
│ Drive::read() is single-shot. DiscStream::fill_extents adapts the
|
||||||
│ batch size on failure (halve / probe-up). Bad-range retry is layer
|
│ 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.)
|
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 |
|
| aacs/ | [aacs.md](aacs.md) | Key resolution + content decrypt + bus handshake |
|
||||||
| css/ | -- | DVD CSS cipher |
|
| css/ | -- | DVD CSS cipher |
|
||||||
| decrypt.rs | -- | Unified decrypt dispatcher (AACS/CSS/None) |
|
| 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) |
|
| labels/ | -- | BD-J stream labels (5 format parsers) |
|
||||||
| mux/ | -- | Stream implementations (7 stream types) |
|
| mux/ | -- | Stream implementations (7 stream types) |
|
||||||
| pes.rs | -- | PES frame types + FrameSource / FrameSink traits |
|
| pes.rs | -- | PES frame types + FrameSource / FrameSink traits |
|
||||||
|
|||||||
@@ -58,15 +58,16 @@ selects the per-CDB timeout:
|
|||||||
|
|
||||||
| `recovery` | Timeout | Used by |
|
| `recovery` | Timeout | Used by |
|
||||||
|------------|----------|------------------------------------------|
|
|------------|----------|------------------------------------------|
|
||||||
| `false` | 1.5 s | `Disc::sweep` fast skip-forward pass, `DiscStream::fill_extents` |
|
| `false` | 10 s | `freemkv_engine::recovery::sweep` fast skip-forward pass, `DiscStream::fill_extents` |
|
||||||
| `true` | 30 s | `Disc::patch` retry pass over the mapfile |
|
| `true` | 60 s | `freemkv_engine::recovery::patch` retry pass over the mapfile |
|
||||||
|
|
||||||
On any SCSI failure or timeout, `read` returns `Err(DiscRead)` immediately.
|
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.
|
There are no inline retries, no SCSI reset, no Phase 1/2/3 escalation.
|
||||||
|
|
||||||
Recovery is layered above `Drive::read`:
|
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.
|
`read(.., recovery=true)` against each non-`+` range.
|
||||||
- **Layer 3 — `DiscStream::fill_extents`** halves the request size on
|
- **Layer 3 — `DiscStream::fill_extents`** halves the request size on
|
||||||
failure, retries at the same LBA, and probes back up on a clean-read
|
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
|
**Recovery strategy moved OUT of this crate in 1.6.0.** The forward sweep, the
|
||||||
discs: a fast forward sweep that tolerates read failures, in-loop request-size
|
targeted retry pass, the ddrescue mapfile, damage classification and the
|
||||||
adaptation that survives transient drive trouble without bailing, and targeted
|
multipass loop now live in the **`freemkv-engine`** crate as
|
||||||
retry passes against a persistent bad-range map. The stream pipeline
|
`freemkv_engine::recovery::{copy, sweep, patch}`. The dependency runs
|
||||||
(`DiscStream` + `input`/`output`) operates against the resulting ISO image, so
|
engine → libfreemkv, so this crate cannot call into the engine; front-ends
|
||||||
the mux stage never touches the drive.
|
(`freemkv` CLI, autorip) get recovery from the engine directly.
|
||||||
|
|
||||||
Recovery is layered cleanly. Each layer has one responsibility and does not
|
What stayed here are the two layers underneath the strategy: the single-shot
|
||||||
reach into the others.
|
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 |
|
| 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. |
|
| 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
|
Layer 2 also translates drive facts: [`SenseFamily`](../src/scsi/mod.rs)
|
||||||
runs `Disc::sweep` once, then loops `Disc::patch` until either the mapfile is
|
classifies SCSI sense data into the categories the engine's strategy routes on
|
||||||
clean or the configured retry budget is exhausted, then hands the ISO off to
|
(marginal vs. hardware vs. not-ready). Getting that classification wrong
|
||||||
the mux pipeline. The `freemkv` CLI does the same shape with a
|
silently misroutes recovery, which is why it lives next to the transport rather
|
||||||
terminal-output progress sink. Layer 3 runs inside any consumer of
|
than in the strategy.
|
||||||
`DiscStream` (direct PES pipeline, ISO playback, etc.) without caller
|
|
||||||
involvement.
|
|
||||||
|
|
||||||
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 |
|
## In-stream — adaptive batch halving (`DiscStream::fill_extents`)
|
||||||
|---------------------------|-----------------------------------------------------------------------|
|
|
||||||
| `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`)
|
|
||||||
|
|
||||||
When a consumer reads a `DiscStream` directly (no ISO intermediate),
|
When a consumer reads a `DiscStream` directly (no ISO intermediate),
|
||||||
`fill_extents` runs an adaptive sizer in front of `Drive::read`:
|
`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
|
`EventKind::SectorSkipped`) when `skip_errors` is set, otherwise return
|
||||||
`Err(DiscRead)`.
|
`Err(DiscRead)`.
|
||||||
|
|
||||||
This is layer 3. It exists so a transient single-sector glitch in a 32-sector
|
This exists so a transient single-sector glitch inside a 32-sector batch can be
|
||||||
batch can be isolated and read individually without the caller needing to
|
isolated and read individually without the caller implementing retry logic. See
|
||||||
implement retry logic.
|
[`src/event.rs`](../src/event.rs) for the emitted events.
|
||||||
|
|
||||||
## Design choices
|
## Design choices
|
||||||
|
|
||||||
**`Drive::read` is single-shot.** No inline retry phases, no SCSI reset,
|
These are constraints on the read path, enforced here and relied on by the
|
||||||
no eject cycle. The `recovery` flag controls only the per-CDB timeout
|
engine's strategy.
|
||||||
(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.
|
|
||||||
|
|
||||||
**No `MODE SELECT` to disable drive retries.** Neither ddrescue
|
**`Drive::read` is single-shot.** No inline retry phases, no SCSI reset, no
|
||||||
nor any consumer ripper does this. Drive firmware has access to raw analog signal, laser
|
eject cycle. The `recovery` flag controls only the per-CDB timeout (10 s vs.
|
||||||
power control, and drive-specific ECC tuning that userspace can't replicate —
|
60 s); on any failure it returns `Err(DiscRead)` immediately. Inline recovery
|
||||||
disabling it throws away recovery headroom on marginal sectors. We fail fast
|
(5× gentle retry → close + SCSI reset + reopen → 5× more) was removed in
|
||||||
via short SG_IO timeouts in pass 1 and let the firmware work the long timeout
|
0.13.6. See the stop-wedge postmortem (2026-04-25) for rationale: the inline
|
||||||
in pass 2 / patch.
|
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
|
**No `MODE SELECT` to disable drive retries.** Neither ddrescue nor any
|
||||||
trimmed to a kernel SG_IO state flush plus ALLOW MEDIUM REMOVAL — the
|
consumer ripper does this. Drive firmware has access to raw analog signal,
|
||||||
`SG_SCSI_RESET` ioctl and STOP/START UNIT escalation were removed in 0.13.6.
|
laser power control and drive-specific ECC tuning that userspace cannot
|
||||||
The macOS reset (which had been a no-op) was removed entirely. The top-level
|
replicate — disabling it throws away recovery headroom on marginal sectors. The
|
||||||
`scsi::reset()` / `reset_with_timeout()` / `reset_blocking()` wrappers were
|
fast pass fails quickly via short SG_IO timeouts and lets the firmware work the
|
||||||
also removed (no callers). The remaining `Drive::reset()` is only invoked
|
long timeout during retry.
|
||||||
explicitly by callers that need an eject-cycle escape hatch — it is never
|
|
||||||
reached from a read path.
|
|
||||||
|
|
||||||
**ISO intermediate, even for single-pass.** Pass 1 always writes an ISO. The
|
**No SCSI reset from any read path.** There is no reset escape hatch on
|
||||||
mux stage reads the ISO via `FileSectorSource`. For single-pass (no retries),
|
`Drive` at all: the `SG_SCSI_RESET` ioctl and STOP/START UNIT escalation went in
|
||||||
this adds ~2-3 min (local disk mux) but gains resumability across crashes,
|
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
|
re-muxability without re-ripping, and a persistent forensic artifact. Callers
|
||||||
who need pure speed can bypass and use `DiscStream::new(Box::new(drive), …)`
|
who need pure speed can bypass it with `DiscStream::new(Box::new(drive), …)` —
|
||||||
directly — the lib doesn't forbid it, and layer 3 (adaptive batch halving)
|
nothing forbids it, and layer 3 still applies there.
|
||||||
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.
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- [ddrescue manual, Algorithm chapter](https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html)
|
- [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)
|
- [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.
|
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.
|
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.
|
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.
|
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,200 +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)
|
|
||||||
//! vuk_from_mk(MediaKey, Vid) → Vuk (= AES-G(Km, VID))
|
|
||||||
//! uk_from_vuk(Vuk, enc_title_keys) → [UnitKey] (decrypt_unit_key each)
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use super::keys::{decrypt_unit_key, 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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,993 +0,0 @@
|
|||||||
//! AACS content decryption — AES primitives, unit decryption, bus encryption.
|
|
||||||
|
|
||||||
use aes::Aes128;
|
|
||||||
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
|
|
||||||
|
|
||||||
// ── AACS constants ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Fixed IV used by AACS for all AES-CBC operations.
|
|
||||||
pub(crate) const AACS_IV: [u8; 16] = [
|
|
||||||
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Size of an AACS aligned unit (3 × 2048-byte sectors).
|
|
||||||
pub const ALIGNED_UNIT_LEN: usize = 6144;
|
|
||||||
|
|
||||||
/// An AACS aligned unit spans this many 2048-byte sectors (3).
|
|
||||||
pub const ALIGNED_UNIT_SECTORS: u32 = (ALIGNED_UNIT_LEN / SECTOR_BYTES) as u32;
|
|
||||||
|
|
||||||
/// Whether `lba` sits on an AACS aligned-unit boundary, measured **relative to
|
|
||||||
/// the encrypted region's base LBA** (`unit_base` = the clip/extent `start_lba`,
|
|
||||||
/// NOT absolute disc LBA 0).
|
|
||||||
///
|
|
||||||
/// AACS aligned units (6144 B / 3 sectors) are anchored at the start of each
|
|
||||||
/// clip's encrypted region, so a read must begin a whole number of units past
|
|
||||||
/// that base for `decrypt_sectors` (which anchors units at buffer offset 0) to
|
|
||||||
/// align the CBC correctly. This is the SINGLE source of truth for the test —
|
|
||||||
/// the decrypt-on-read gate, the inline and highway mux read paths, and the
|
|
||||||
/// key-validation sample reader all key off this, never absolute `lba % 3`. A
|
|
||||||
/// disc whose clip `start_lba` is not itself 3-aligned would otherwise mis-gate
|
|
||||||
/// (reject readable units, then report "Decryption failed") on exactly the
|
|
||||||
/// titles whose clips land off a 3-boundary.
|
|
||||||
///
|
|
||||||
/// `lba` is always `>= unit_base` by contract (a read never begins before the
|
|
||||||
/// extent base it is measured against). `saturating_sub` makes the `lba <
|
|
||||||
/// unit_base` case well-defined anyway — it clamps the offset to 0, which is a
|
|
||||||
/// unit boundary — rather than the latent `wrapping_sub` trap where an
|
|
||||||
/// underflow wraps to ~2^32 and, because `2^32 ≡ 1 (mod 3)`, mis-reports the
|
|
||||||
/// alignment (e.g. `lba == unit_base - 1` would falsely read as aligned).
|
|
||||||
pub fn is_unit_aligned(lba: u32, unit_base: u32) -> bool {
|
|
||||||
lba.saturating_sub(unit_base) % ALIGNED_UNIT_SECTORS == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
use crate::consts::SECTOR_BYTES;
|
|
||||||
|
|
||||||
use crate::consts::BD_SOURCE_PACKET_BYTES;
|
|
||||||
|
|
||||||
/// TS sync byte.
|
|
||||||
const TS_SYNC: u8 = 0x47;
|
|
||||||
|
|
||||||
// ── AES primitives ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// AES-128-ECB encrypt a single 16-byte block.
|
|
||||||
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
|
||||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
|
||||||
let mut block = GenericArray::clone_from_slice(data);
|
|
||||||
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.
|
|
||||||
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
|
||||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
|
||||||
let mut block = GenericArray::clone_from_slice(data);
|
|
||||||
cipher.decrypt_block(&mut block);
|
|
||||||
let mut out = [0u8; 16];
|
|
||||||
out.copy_from_slice(&block);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// AES-128-CBC decrypt in-place with the fixed AACS IV.
|
|
||||||
///
|
|
||||||
/// 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.
|
|
||||||
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
|
||||||
debug_assert!(
|
|
||||||
data.len() % 16 == 0,
|
|
||||||
"aes_cbc_decrypt requires a block-aligned slice"
|
|
||||||
);
|
|
||||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
|
||||||
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 block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
|
|
||||||
cipher.decrypt_block(&mut block);
|
|
||||||
for j in 0..16 {
|
|
||||||
data[offset + j] = block[j] ^ prev[j];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Content decryption ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// True if a 6144-byte aligned unit is AACS-scrambled on disc.
|
|
||||||
///
|
|
||||||
/// AACS encrypts the unit body, which destroys the MPEG-TS sync bytes (`0x47`)
|
|
||||||
/// a clear unit carries at offsets 4, 196, 388, … (one per 192-byte source
|
|
||||||
/// packet). So "scrambled" = "the TS syncs are NOT intact". This is
|
|
||||||
/// flag-independent: it does NOT read the TP_extra copy-control bits (byte 0)
|
|
||||||
/// or the TS scrambling-control bits (byte 7) — AACS sets neither reliably
|
|
||||||
/// across discs/players.
|
|
||||||
///
|
|
||||||
/// This is the single shared definition of "encrypted" for the whole ecosystem
|
|
||||||
/// — libfreemkv's decrypt gate, autorip's sample selection, and the online key
|
|
||||||
/// service's validation gate all call THIS, so they always agree on what is
|
|
||||||
/// encrypted. A correctly-decrypted (or natively-clear) unit reports `false`,
|
|
||||||
/// so the decrypt path never double-decrypts and there is no flag to clear.
|
|
||||||
pub fn is_aacs_scrambled(unit: &[u8]) -> bool {
|
|
||||||
unit.len() >= ALIGNED_UNIT_LEN && !ts_syncs_intact(unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride
|
|
||||||
/// (offset 4 and every 192 bytes after — 4-byte TP_extra_header + 188-byte
|
|
||||||
/// TS packet). A clear or correctly-decrypted m2ts unit shows ~one per
|
|
||||||
/// packet; an encrypted unit, or a non-content unit decrypted under a key
|
|
||||||
/// that doesn't apply, shows ~none.
|
|
||||||
pub fn ts_sync_count(unit: &[u8]) -> usize {
|
|
||||||
let mut count = 0;
|
|
||||||
let mut offset = 4;
|
|
||||||
while offset < unit.len() {
|
|
||||||
if unit[offset] == TS_SYNC {
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
offset += BD_SOURCE_PACKET_BYTES;
|
|
||||||
}
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of BD-TS packets in the unit — the maximum possible sync count.
|
|
||||||
pub fn ts_packet_total(unit: &[u8]) -> usize {
|
|
||||||
// One sync byte per 192-byte BD-TS packet (at offset 4 of each). The old
|
|
||||||
// `(len - 4) / BD_SOURCE_PACKET_BYTES + 1` over-counted by one for lengths of the
|
|
||||||
// form `4 + k·192`.
|
|
||||||
unit.len() / BD_SOURCE_PACKET_BYTES
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ts_syncs_intact(unit: &[u8]) -> bool {
|
|
||||||
ts_sync_count(unit) > ts_packet_total(unit) / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify a decrypted unit looks like clear MPEG-TS (sync bytes intact).
|
|
||||||
fn verify_ts(unit: &[u8]) -> bool {
|
|
||||||
ts_syncs_intact(unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt one AACS aligned unit (6144 bytes) in-place.
|
|
||||||
/// Returns true if the unit is now clear MPEG-TS: either it was already
|
|
||||||
/// unscrambled (returned untouched, no key used) or it was decrypted and
|
|
||||||
/// verified by its TS sync bytes. Returns false only when the unit was
|
|
||||||
/// scrambled and this key failed verification.
|
|
||||||
///
|
|
||||||
/// Algorithm:
|
|
||||||
/// 1. AES-128-ECB encrypt first 16 bytes with unit_key → derived
|
|
||||||
/// 2. XOR derived with original 16 bytes → unit_decrypt_key
|
|
||||||
/// 3. AES-128-CBC decrypt bytes 16..6143 with unit_decrypt_key and AACS IV
|
|
||||||
///
|
|
||||||
/// Decryption restores the TS sync bytes, so the unit reads as clear afterward;
|
|
||||||
/// there is no flag to clear.
|
|
||||||
pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
|
||||||
if unit.len() < ALIGNED_UNIT_LEN {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if !is_aacs_scrambled(unit) {
|
|
||||||
return true; // not encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save original first 16 bytes (they're plaintext TP_extra_header)
|
|
||||||
let mut header = [0u8; 16];
|
|
||||||
header.copy_from_slice(&unit[..16]);
|
|
||||||
|
|
||||||
// Step 1: Encrypt header with unit key to derive per-unit key
|
|
||||||
let derived = aes_ecb_encrypt(unit_key, &header);
|
|
||||||
|
|
||||||
// Step 2: XOR to get the actual decryption key
|
|
||||||
let mut decrypt_key = [0u8; 16];
|
|
||||||
for i in 0..16 {
|
|
||||||
decrypt_key[i] = derived[i] ^ header[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Decrypt bytes 16..6143 with AES-CBC
|
|
||||||
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
|
|
||||||
|
|
||||||
// Decryption restored the TS syncs; verify the unit now looks like clear TS.
|
|
||||||
verify_ts(unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fast, NON-MUTATING unit-key validation for the brute-force key search.
|
|
||||||
///
|
|
||||||
/// `decrypt_unit` pays a full 6128-byte (383-block) CBC decrypt before
|
|
||||||
/// `verify_ts` can reject a wrong key — but in a brute scan ~every candidate is
|
|
||||||
/// wrong. In CBC the plaintext of block *i* is `AES_dec(C_i) XOR C_{i-1}`, so
|
|
||||||
/// the FIRST restored TS sync byte (payload offset 196, which lands in CBC
|
|
||||||
/// block 11 of the `unit[16..]` region) can be recovered with a SINGLE block
|
|
||||||
/// decrypt instead of 383. A wrong key fails this 1-byte gate ~255/256 of the
|
|
||||||
/// time for the cost of one AES block; the rare survivor is then confirmed with
|
|
||||||
/// the full [`decrypt_unit`], so the set of accepted keys is bit-for-bit
|
|
||||||
/// identical to the slow path.
|
|
||||||
///
|
|
||||||
/// The caller MUST pass an aligned, already-[`is_aacs_scrambled`] unit
|
|
||||||
/// (`unit.len() >= ALIGNED_UNIT_LEN`). The brute pre-filters its units, so the
|
|
||||||
/// per-candidate scramble re-scan is intentionally skipped here.
|
|
||||||
///
|
|
||||||
/// NOTE: this is a search accelerator — it never writes the input and never
|
|
||||||
/// participates in the content decrypt path. Aggregate correctness (does a key
|
|
||||||
/// validate against *any* of the disc's units) is preserved because a true key
|
|
||||||
/// restores offset-196 on every standard BD-TS unit.
|
|
||||||
pub fn unit_key_validates(unit: &[u8], unit_key: &[u8; 16]) -> bool {
|
|
||||||
if unit.len() < ALIGNED_UNIT_LEN {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Per-unit decrypt key: AES-ECB-encrypt the 16-byte plaintext header with
|
|
||||||
// the unit key, XOR with the header (same derivation as `decrypt_unit`).
|
|
||||||
let mut header = [0u8; 16];
|
|
||||||
header.copy_from_slice(&unit[..16]);
|
|
||||||
let derived = aes_ecb_encrypt(unit_key, &header);
|
|
||||||
let mut decrypt_key = [0u8; 16];
|
|
||||||
for i in 0..16 {
|
|
||||||
decrypt_key[i] = derived[i] ^ header[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cheap gate: recover ONLY payload byte 196 (the 2nd BD-TS packet's sync).
|
|
||||||
// The CBC region is `unit[16..]`; payload offset 196 → region offset 180 =
|
|
||||||
// block 11, byte 4. P[11] = AES_dec(C[11]) XOR C[10]; C[10] is raw
|
|
||||||
// ciphertext (no decrypt needed). Constant offsets for the fixed 6144 unit.
|
|
||||||
const SYNC_PAYLOAD_OFF: usize = 196;
|
|
||||||
let region_off = SYNC_PAYLOAD_OFF - 16; // 180
|
|
||||||
let blk = region_off / 16; // 11
|
|
||||||
let byte = region_off % 16; // 4
|
|
||||||
let c11 = 16 + blk * 16; // absolute offset of C[11] in `unit` (=192)
|
|
||||||
let cipher = Aes128::new(GenericArray::from_slice(&decrypt_key));
|
|
||||||
let mut b = GenericArray::clone_from_slice(&unit[c11..c11 + 16]);
|
|
||||||
cipher.decrypt_block(&mut b);
|
|
||||||
let prev = unit[c11 - 16 + byte]; // C[10] byte (region block 10)
|
|
||||||
if b[byte] ^ prev != TS_SYNC {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Survivor (~1/256 of candidates): confirm with the authoritative full
|
|
||||||
// decrypt + verify, so the verdict matches `decrypt_unit` exactly.
|
|
||||||
let mut full = [0u8; ALIGNED_UNIT_LEN];
|
|
||||||
full.copy_from_slice(&unit[..ALIGNED_UNIT_LEN]);
|
|
||||||
decrypt_unit(&mut full, unit_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Outcome of [`decrypt_unit_try_keys`].
|
|
||||||
///
|
|
||||||
/// Distinguishes "the unit was already clear, no key was consumed" from "key
|
|
||||||
/// at index `i` decrypted it" — the bare `Option<usize>` form conflated the two
|
|
||||||
/// (a clear unit reported `Some(0)`, indistinguishable from key index 0, and
|
|
||||||
/// possibly out of range when `unit_keys` is empty).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum UnitKeyResult {
|
|
||||||
/// The unit was not scrambled; it was left untouched and no key was used.
|
|
||||||
AlreadyClear,
|
|
||||||
/// The unit was decrypted in place by `unit_keys[index]`.
|
|
||||||
DecryptedWith(usize),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt one aligned unit trying multiple unit keys.
|
|
||||||
///
|
|
||||||
/// Returns [`UnitKeyResult::AlreadyClear`] if the unit was not scrambled (no key
|
|
||||||
/// consumed), [`UnitKeyResult::DecryptedWith(i)`] if key `i` decrypted it, or
|
|
||||||
/// `None` if no key worked (the unit is restored to its original bytes).
|
|
||||||
pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<UnitKeyResult> {
|
|
||||||
if !is_aacs_scrambled(unit) {
|
|
||||||
return Some(UnitKeyResult::AlreadyClear);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save original for retry. Stack-backed buffer — no heap allocation, and the
|
|
||||||
// restore-on-failure contract holds uniformly regardless of key count.
|
|
||||||
let mut original = [0u8; ALIGNED_UNIT_LEN];
|
|
||||||
original.copy_from_slice(&unit[..ALIGNED_UNIT_LEN]);
|
|
||||||
|
|
||||||
for (i, key) in unit_keys.iter().enumerate() {
|
|
||||||
unit[..ALIGNED_UNIT_LEN].copy_from_slice(&original);
|
|
||||||
if decrypt_unit(unit, key) {
|
|
||||||
return Some(UnitKeyResult::DecryptedWith(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore original on failure
|
|
||||||
unit[..ALIGNED_UNIT_LEN].copy_from_slice(&original);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove bus encryption from an aligned unit (AACS 2.0 / UHD).
|
|
||||||
/// Bus encryption uses read_data_key, decrypting bytes 16..2047 of each 2048-byte sector.
|
|
||||||
pub fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
|
|
||||||
for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
|
||||||
if sector_start + SECTOR_BYTES > unit.len() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// First 16 bytes of each sector are plaintext
|
|
||||||
aes_cbc_decrypt(
|
|
||||||
read_data_key,
|
|
||||||
&mut unit[sector_start + 16..sector_start + SECTOR_BYTES],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full decrypt of an aligned unit: bus decrypt (if needed) then AACS decrypt.
|
|
||||||
pub fn decrypt_unit_full(
|
|
||||||
unit: &mut [u8],
|
|
||||||
unit_key: &[u8; 16],
|
|
||||||
read_data_key: Option<&[u8; 16]>,
|
|
||||||
) -> bool {
|
|
||||||
if !is_aacs_scrambled(unit) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if let Some(rdk) = read_data_key {
|
|
||||||
decrypt_bus(unit, rdk);
|
|
||||||
}
|
|
||||||
decrypt_unit(unit, unit_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_aes_ecb_roundtrip() {
|
|
||||||
let key = [
|
|
||||||
0x15u8, 0x66, 0x5F, 0x98, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A,
|
|
||||||
0x0B, 0x0C,
|
|
||||||
];
|
|
||||||
let plain = [0x41u8; 16];
|
|
||||||
let enc = aes_ecb_encrypt(&key, &plain);
|
|
||||||
let dec = aes_ecb_decrypt(&key, &enc);
|
|
||||||
assert_eq!(dec, plain);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_unit_aligned_relative_to_base() {
|
|
||||||
// Aligned at the base and every 3 sectors above it; misaligned between.
|
|
||||||
assert!(is_unit_aligned(100, 100), "base itself is aligned");
|
|
||||||
assert!(is_unit_aligned(103, 100), "one unit past base is aligned");
|
|
||||||
assert!(is_unit_aligned(106, 100));
|
|
||||||
assert!(!is_unit_aligned(101, 100));
|
|
||||||
assert!(!is_unit_aligned(102, 100));
|
|
||||||
// Non-3-aligned base: alignment is RELATIVE to the base, not absolute.
|
|
||||||
assert!(is_unit_aligned(101, 101), "non-3-aligned base is aligned");
|
|
||||||
assert!(is_unit_aligned(104, 101));
|
|
||||||
assert!(!is_unit_aligned(102, 101));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_unit_aligned_lba_below_base_is_well_defined() {
|
|
||||||
// Latent-trap contract (rc.5.2 audit #5): a read never starts before its
|
|
||||||
// extent base, but if `lba < unit_base` the result must be well-defined,
|
|
||||||
// NOT the `wrapping_sub` underflow that — because 2^32 ≡ 1 (mod 3) —
|
|
||||||
// would falsely report alignment. `saturating_sub` clamps to offset 0,
|
|
||||||
// which is a unit boundary, so any `lba <= unit_base` reads as aligned.
|
|
||||||
assert!(
|
|
||||||
is_unit_aligned(99, 100),
|
|
||||||
"lba just below base must not wrap"
|
|
||||||
);
|
|
||||||
assert!(is_unit_aligned(98, 100));
|
|
||||||
assert!(is_unit_aligned(0, 100));
|
|
||||||
// The specific wrapping_sub trap value: unit_base - 1. With wrapping_sub
|
|
||||||
// this is 0xFFFF_FFFF % 3 == 0 → falsely "aligned" by underflow; with
|
|
||||||
// saturating_sub it is genuinely 0 → aligned, for the right reason.
|
|
||||||
assert!(is_unit_aligned(u32::MAX, u32::MAX)); // base == lba, trivially aligned
|
|
||||||
assert!(
|
|
||||||
is_unit_aligned(0, u32::MAX),
|
|
||||||
"max base, lba 0 must saturate to 0"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_decrypt_unit_unencrypted() {
|
|
||||||
// A clear unit (TS syncs intact) is not scrambled → passes through.
|
|
||||||
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
|
||||||
let mut off = 4;
|
|
||||||
while off < ALIGNED_UNIT_LEN {
|
|
||||||
unit[off] = TS_SYNC;
|
|
||||||
off += BD_SOURCE_PACKET_BYTES;
|
|
||||||
}
|
|
||||||
let key = [0u8; 16];
|
|
||||||
assert!(!is_aacs_scrambled(&unit));
|
|
||||||
assert!(decrypt_unit(&mut unit, &key));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ts_packet_total_no_off_by_one() {
|
|
||||||
// The maximum sync count is exactly the number of stride
|
|
||||||
// positions the counting loop visits (offset 4, 196, ...), i.e.
|
|
||||||
// len / 192, NOT (len - 4) / 192 + 1. For the 6144-byte aligned unit
|
|
||||||
// the loop checks offsets 4..=5956 → 32 positions.
|
|
||||||
let unit = vec![0u8; ALIGNED_UNIT_LEN];
|
|
||||||
assert_eq!(ts_packet_total(&unit), 32);
|
|
||||||
// Confirm the loop visits exactly that many stride positions.
|
|
||||||
let visited = (4..ALIGNED_UNIT_LEN)
|
|
||||||
.step_by(BD_SOURCE_PACKET_BYTES)
|
|
||||||
.count();
|
|
||||||
assert_eq!(visited, ts_packet_total(&unit));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scramble_detection_at_16_32_boundary() {
|
|
||||||
// With 32 stride positions the majority threshold is
|
|
||||||
// total/2 = 16. A unit with EXACTLY half its syncs intact (16) must
|
|
||||||
// NOT be over-counted into the "scrambled" bucket by an inflated
|
|
||||||
// total: 16 > 16 is false → not-intact → scrambled. 17 intact → clear.
|
|
||||||
// The fix is that `total` is 32 (not 33), so the boundary sits cleanly
|
|
||||||
// at the real midpoint.
|
|
||||||
let set_syncs = |n: usize| {
|
|
||||||
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
|
||||||
let mut off = 4;
|
|
||||||
let mut placed = 0;
|
|
||||||
while off < ALIGNED_UNIT_LEN && placed < n {
|
|
||||||
unit[off] = TS_SYNC;
|
|
||||||
off += BD_SOURCE_PACKET_BYTES;
|
|
||||||
placed += 1;
|
|
||||||
}
|
|
||||||
unit
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(ts_sync_count(&set_syncs(16)), 16);
|
|
||||||
assert_eq!(ts_sync_count(&set_syncs(17)), 17);
|
|
||||||
|
|
||||||
// Exactly half intact → classified scrambled (16 > 16 is false).
|
|
||||||
assert!(is_aacs_scrambled(&set_syncs(16)));
|
|
||||||
// One past half → classified clear.
|
|
||||||
assert!(!is_aacs_scrambled(&set_syncs(17)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scramble_detection_extremes() {
|
|
||||||
// Detection semantics for the clear-cut cases must be preserved:
|
|
||||||
// a fully-clear unit (all 32 syncs) is NOT scrambled; a unit with no
|
|
||||||
// syncs (fully scrambled body) IS scrambled.
|
|
||||||
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
|
|
||||||
let mut off = 4;
|
|
||||||
while off < ALIGNED_UNIT_LEN {
|
|
||||||
clear[off] = TS_SYNC;
|
|
||||||
off += BD_SOURCE_PACKET_BYTES;
|
|
||||||
}
|
|
||||||
assert_eq!(ts_sync_count(&clear), 32);
|
|
||||||
assert!(
|
|
||||||
!is_aacs_scrambled(&clear),
|
|
||||||
"fully-clear unit → not scrambled"
|
|
||||||
);
|
|
||||||
|
|
||||||
let scrambled = vec![0u8; ALIGNED_UNIT_LEN];
|
|
||||||
assert_eq!(ts_sync_count(&scrambled), 0);
|
|
||||||
assert!(is_aacs_scrambled(&scrambled), "no syncs → scrambled");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_aes_cbc_roundtrip() {
|
|
||||||
let key = [
|
|
||||||
0x11u8, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
|
||||||
0xFF, 0x00,
|
|
||||||
];
|
|
||||||
let original = vec![0x42u8; 128]; // 8 blocks
|
|
||||||
let mut data = original.clone();
|
|
||||||
|
|
||||||
// Encrypt with CBC manually (forward direction)
|
|
||||||
fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) {
|
|
||||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
|
||||||
let mut prev = super::AACS_IV;
|
|
||||||
let num_blocks = data.len() / 16;
|
|
||||||
for i in 0..num_blocks {
|
|
||||||
let offset = i * 16;
|
|
||||||
for j in 0..16 {
|
|
||||||
data[offset + j] ^= prev[j];
|
|
||||||
}
|
|
||||||
let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
|
|
||||||
cipher.encrypt_block(&mut block);
|
|
||||||
data[offset..offset + 16].copy_from_slice(&block);
|
|
||||||
prev.copy_from_slice(&data[offset..offset + 16]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
aes_cbc_encrypt(&key, &mut data);
|
|
||||||
assert_ne!(data, original); // should be different after encrypt
|
|
||||||
|
|
||||||
super::aes_cbc_decrypt(&key, &mut data);
|
|
||||||
assert_eq!(data, original); // should match after roundtrip
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_decrypt_unit_synthetic() {
|
|
||||||
// Build a fake 6144-byte aligned unit with known TS sync pattern,
|
|
||||||
// encrypt it with the AACS algorithm, then decrypt and verify.
|
|
||||||
let unit_key = [0xAAu8; 16];
|
|
||||||
|
|
||||||
// Build plaintext unit with TS sync bytes every 192 bytes starting at offset 4
|
|
||||||
let mut plain = vec![0u8; ALIGNED_UNIT_LEN];
|
|
||||||
let mut offset = 4;
|
|
||||||
while offset < ALIGNED_UNIT_LEN {
|
|
||||||
plain[offset] = TS_SYNC;
|
|
||||||
offset += BD_SOURCE_PACKET_BYTES;
|
|
||||||
}
|
|
||||||
// No flag set: CBC-encrypting the body below scrambles packets 1..31's
|
|
||||||
// TS syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects.
|
|
||||||
|
|
||||||
// Now encrypt bytes 16..6143 using the AACS algorithm (reverse of decrypt)
|
|
||||||
let header: [u8; 16] = plain[..16].try_into().unwrap();
|
|
||||||
let derived = aes_ecb_encrypt(&unit_key, &header);
|
|
||||||
let mut encrypt_key = [0u8; 16];
|
|
||||||
for i in 0..16 {
|
|
||||||
encrypt_key[i] = derived[i] ^ header[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
// CBC encrypt bytes 16..6143
|
|
||||||
let cipher = Aes128::new(GenericArray::from_slice(&encrypt_key));
|
|
||||||
let mut prev = AACS_IV;
|
|
||||||
let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
|
|
||||||
for i in 0..num_blocks {
|
|
||||||
let off = 16 + i * 16;
|
|
||||||
for j in 0..16 {
|
|
||||||
plain[off + j] ^= prev[j];
|
|
||||||
}
|
|
||||||
let mut block = GenericArray::clone_from_slice(&plain[off..off + 16]);
|
|
||||||
cipher.encrypt_block(&mut block);
|
|
||||||
plain[off..off + 16].copy_from_slice(&block);
|
|
||||||
prev.copy_from_slice(&plain[off..off + 16]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now plain contains encrypted data. Decrypt it.
|
|
||||||
let mut unit = plain;
|
|
||||||
assert!(is_aacs_scrambled(&unit));
|
|
||||||
assert!(decrypt_unit(&mut unit, &unit_key));
|
|
||||||
assert!(!is_aacs_scrambled(&unit)); // decrypted: TS syncs restored
|
|
||||||
|
|
||||||
// Verify TS sync bytes
|
|
||||||
let mut count = 0;
|
|
||||||
let mut off = 4;
|
|
||||||
while off < ALIGNED_UNIT_LEN {
|
|
||||||
if unit[off] == TS_SYNC {
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
off += BD_SOURCE_PACKET_BYTES;
|
|
||||||
}
|
|
||||||
// Assert against the single canonical packet count, not the old
|
|
||||||
// `(len - 4) / 192 + 1` form that `ts_packet_total` corrected away from.
|
|
||||||
assert_eq!(count, ts_packet_total(&unit));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers for the hardening tests below ──────────────────────────────
|
|
||||||
|
|
||||||
/// Encrypt an aligned unit in place with the AACS unit-decrypt
|
|
||||||
/// algorithm run in reverse, so [`decrypt_unit`] with the same
|
|
||||||
/// `unit_key` recovers the plaintext. This is the exact inverse of
|
|
||||||
/// the production decrypt: derive `decrypt_key = AES-ECB-E(unit_key,
|
|
||||||
/// header) XOR header`, then CBC-encrypt bytes 16..6144 under the
|
|
||||||
/// fixed AACS IV.
|
|
||||||
fn aacs_encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
|
|
||||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
|
||||||
let derived = aes_ecb_encrypt(unit_key, &header);
|
|
||||||
let mut k = [0u8; 16];
|
|
||||||
for i in 0..16 {
|
|
||||||
k[i] = derived[i] ^ header[i];
|
|
||||||
}
|
|
||||||
let cipher = Aes128::new(GenericArray::from_slice(&k));
|
|
||||||
let mut prev = AACS_IV;
|
|
||||||
let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
|
|
||||||
for i in 0..num_blocks {
|
|
||||||
let off = 16 + i * 16;
|
|
||||||
for j in 0..16 {
|
|
||||||
unit[off + j] ^= prev[j];
|
|
||||||
}
|
|
||||||
let mut block = GenericArray::clone_from_slice(&unit[off..off + 16]);
|
|
||||||
cipher.encrypt_block(&mut block);
|
|
||||||
unit[off..off + 16].copy_from_slice(&block);
|
|
||||||
prev.copy_from_slice(&unit[off..off + 16]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a clear aligned unit with TS sync bytes at offset 4 + k*192.
|
|
||||||
fn clear_unit() -> Vec<u8> {
|
|
||||||
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
|
||||||
let mut off = 4;
|
|
||||||
while off < ALIGNED_UNIT_LEN {
|
|
||||||
unit[off] = TS_SYNC;
|
|
||||||
off += BD_SOURCE_PACKET_BYTES;
|
|
||||||
}
|
|
||||||
unit
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── AES-ECB KAT (FIPS-197 Appendix C.1) ────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn aes_ecb_matches_fips197_known_answer() {
|
|
||||||
// FIPS-197 Appendix C.1 AES-128 KAT:
|
|
||||||
// key = 000102030405060708090a0b0c0d0e0f
|
|
||||||
// plaintext = 00112233445566778899aabbccddeeff
|
|
||||||
// ciphertext= 69c4e0d86a7b0430d8cdb78070b4c55a
|
|
||||||
// This pins the AES primitive against a published vector — a wrong
|
|
||||||
// cipher (or a key/plaintext byte-order slip) fails it.
|
|
||||||
let key = [
|
|
||||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
|
|
||||||
0x0E, 0x0F,
|
|
||||||
];
|
|
||||||
let pt = [
|
|
||||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
|
|
||||||
0xEE, 0xFF,
|
|
||||||
];
|
|
||||||
let expected = [
|
|
||||||
0x69, 0xC4, 0xE0, 0xD8, 0x6A, 0x7B, 0x04, 0x30, 0xD8, 0xCD, 0xB7, 0x80, 0x70, 0xB4,
|
|
||||||
0xC5, 0x5A,
|
|
||||||
];
|
|
||||||
assert_eq!(aes_ecb_encrypt(&key, &pt), expected);
|
|
||||||
// And decrypt is the exact inverse.
|
|
||||||
assert_eq!(aes_ecb_decrypt(&key, &expected), pt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CBC decrypt: first-block uses fixed AACS IV ────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cbc_decrypt_first_block_xors_aacs_iv() {
|
|
||||||
// CBC: P[0] = AES-D(K, C[0]) XOR IV, and the IV is the fixed AACS
|
|
||||||
// constant (not zero). Encrypt a single block forward with IV, then
|
|
||||||
// confirm aes_cbc_decrypt recovers it — proving the IV used on block
|
|
||||||
// 0 is exactly AACS_IV. A mutation that swaps AACS_IV for [0u8;16]
|
|
||||||
// makes the recovered block wrong.
|
|
||||||
let key = [0x24u8; 16];
|
|
||||||
let plain = [0x5Au8; 16];
|
|
||||||
// Forward CBC for one block: C = AES-E(K, P XOR IV).
|
|
||||||
let mut x = plain;
|
|
||||||
for j in 0..16 {
|
|
||||||
x[j] ^= AACS_IV[j];
|
|
||||||
}
|
|
||||||
let ct = aes_ecb_encrypt(&key, &x);
|
|
||||||
let mut buf = ct;
|
|
||||||
aes_cbc_decrypt(&key, &mut buf);
|
|
||||||
assert_eq!(buf, plain, "block-0 CBC must XOR the fixed AACS IV");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CBC decrypt KAT (NIST SP 800-38A F.2.2, AES-128-CBC) ───────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn aes_cbc_decrypt_matches_nist_sp800_38a_f2_2() {
|
|
||||||
// NIST SP 800-38A Appendix F.2.2 (CBC-AES128.Decrypt) published vector:
|
|
||||||
// Key = 2b7e151628aed2a6abf7158809cf4f3c
|
|
||||||
// IV = 000102030405060708090a0b0c0d0e0f
|
|
||||||
// CT = 7649abac8119b246cee98e9b12e9197d (block 0)
|
|
||||||
// 5086cb9b507219ee95db113a917678b2 (block 1)
|
|
||||||
// 73bed6b8e3c1743b7116e69e22229516 (block 2)
|
|
||||||
// 3ff1caa1681fac09120eca307586e1a7 (block 3)
|
|
||||||
// PT = 6bc1bee22e409f96e93d7e117393172a (block 0)
|
|
||||||
// ae2d8a571e03ac9c9eb76fac45af8e51 (block 1)
|
|
||||||
// 30c81c46a35ce411e5fbc1191a0a52ef (block 2)
|
|
||||||
// f69f2445df4f9b17ad2b417be66c3710 (block 3)
|
|
||||||
//
|
|
||||||
// `aes_cbc_decrypt` hardwires the fixed AACS IV for block 0 (it never
|
|
||||||
// takes a caller IV), so:
|
|
||||||
// * Blocks 1..=3 are independent of the IV — they MUST equal the NIST
|
|
||||||
// plaintext byte-for-byte (P[i] = AES-D(K, C[i]) XOR C[i-1]). This
|
|
||||||
// pins the real reverse-order CBC chaining against a published KAT.
|
|
||||||
// * Block 0 = AES-D(K, C[0]) XOR AACS_IV = NIST_PT[0] XOR NIST_IV
|
|
||||||
// XOR AACS_IV — the documented IV substitution. Asserting this exact
|
|
||||||
// relation pins both the AES decrypt of C[0] AND that block 0 uses
|
|
||||||
// AACS_IV (a swap to [0u8;16] or a chaining bug fails it).
|
|
||||||
let key = [
|
|
||||||
0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF,
|
|
||||||
0x4F, 0x3C,
|
|
||||||
];
|
|
||||||
let nist_iv = [
|
|
||||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
|
|
||||||
0x0E, 0x0F,
|
|
||||||
];
|
|
||||||
// Byte arrays kept narrow (≤14 bytes/line) so the secret-scanner's
|
|
||||||
// 32-nibble-per-line heuristic doesn't flag these published vectors as
|
|
||||||
// key material (same layout the existing FIPS-197 / CMAC KATs use).
|
|
||||||
let ciphertext: [u8; 64] = [
|
|
||||||
0x76, 0x49, 0xAB, 0xAC, 0x81, 0x19, 0xB2, 0x46, 0xCE, 0xE9, 0x8E, 0x9B, 0x12, 0xE9,
|
|
||||||
0x19, 0x7D, 0x50, 0x86, 0xCB, 0x9B, 0x50, 0x72, 0x19, 0xEE, 0x95, 0xDB, 0x11, 0x3A,
|
|
||||||
0x91, 0x76, 0x78, 0xB2, 0x73, 0xBE, 0xD6, 0xB8, 0xE3, 0xC1, 0x74, 0x3B, 0x71, 0x16,
|
|
||||||
0xE6, 0x9E, 0x22, 0x22, 0x95, 0x16, 0x3F, 0xF1, 0xCA, 0xA1, 0x68, 0x1F, 0xAC, 0x09,
|
|
||||||
0x12, 0x0E, 0xCA, 0x30, 0x75, 0x86, 0xE1, 0xA7,
|
|
||||||
];
|
|
||||||
let nist_plaintext: [u8; 64] = [
|
|
||||||
0x6B, 0xC1, 0xBE, 0xE2, 0x2E, 0x40, 0x9F, 0x96, 0xE9, 0x3D, 0x7E, 0x11, 0x73, 0x93,
|
|
||||||
0x17, 0x2A, 0xAE, 0x2D, 0x8A, 0x57, 0x1E, 0x03, 0xAC, 0x9C, 0x9E, 0xB7, 0x6F, 0xAC,
|
|
||||||
0x45, 0xAF, 0x8E, 0x51, 0x30, 0xC8, 0x1C, 0x46, 0xA3, 0x5C, 0xE4, 0x11, 0xE5, 0xFB,
|
|
||||||
0xC1, 0x19, 0x1A, 0x0A, 0x52, 0xEF, 0xF6, 0x9F, 0x24, 0x45, 0xDF, 0x4F, 0x9B, 0x17,
|
|
||||||
0xAD, 0x2B, 0x41, 0x7B, 0xE6, 0x6C, 0x37, 0x10,
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut buf = ciphertext;
|
|
||||||
aes_cbc_decrypt(&key, &mut buf);
|
|
||||||
|
|
||||||
// Blocks 1..=3: exact match against the published NIST plaintext.
|
|
||||||
assert_eq!(
|
|
||||||
&buf[16..64],
|
|
||||||
&nist_plaintext[16..64],
|
|
||||||
"CBC chaining (blocks 1..3) must match NIST SP 800-38A F.2.2 plaintext"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Block 0: NIST_PT[0] XOR NIST_IV XOR AACS_IV (the fixed-IV substitution).
|
|
||||||
let mut expected_block0 = [0u8; 16];
|
|
||||||
for i in 0..16 {
|
|
||||||
expected_block0[i] = nist_plaintext[i] ^ nist_iv[i] ^ AACS_IV[i];
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
&buf[0..16],
|
|
||||||
&expected_block0,
|
|
||||||
"block-0 plaintext must equal NIST PT XOR NIST IV XOR AACS_IV (fixed-IV path)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── decrypt_unit: full round trip restores TS syncs ────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decrypt_unit_roundtrip_restores_all_syncs() {
|
|
||||||
// Encrypt a clear unit, confirm it reads as scrambled, then decrypt
|
|
||||||
// and confirm every TS sync byte at the 192-byte stride is restored.
|
|
||||||
let unit_key = [0x37u8; 16];
|
|
||||||
let mut unit = clear_unit();
|
|
||||||
aacs_encrypt_unit(&mut unit, &unit_key);
|
|
||||||
assert!(
|
|
||||||
is_aacs_scrambled(&unit),
|
|
||||||
"encrypted unit must look scrambled"
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(decrypt_unit(&mut unit, &unit_key));
|
|
||||||
// All 32 stride positions carry sync after decrypt.
|
|
||||||
assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit));
|
|
||||||
assert!(!is_aacs_scrambled(&unit));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decrypt_unit_wrong_key_fails_and_does_not_falsely_clear() {
|
|
||||||
// A wrong unit key fails verify_ts (the body stays scrambled), so
|
|
||||||
// decrypt_unit returns false. Grounds the brute-force gate: a bad key
|
|
||||||
// must NOT report success.
|
|
||||||
let good = [0x11u8; 16];
|
|
||||||
let bad = [0x22u8; 16];
|
|
||||||
let mut unit = clear_unit();
|
|
||||||
aacs_encrypt_unit(&mut unit, &good);
|
|
||||||
assert!(!decrypt_unit(&mut unit, &bad), "wrong key must not verify");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decrypt_unit_rejects_short_unit() {
|
|
||||||
// unit.len() < ALIGNED_UNIT_LEN → false (no panic on the 16.. slice).
|
|
||||||
let mut short = vec![0u8; ALIGNED_UNIT_LEN - 1];
|
|
||||||
assert!(!decrypt_unit(&mut short, &[0u8; 16]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decrypt_unit_only_touches_bytes_16_onward() {
|
|
||||||
// The first 16 bytes are the plaintext TP_extra header and must be
|
|
||||||
// left untouched by decrypt (only unit[16..] is CBC-processed).
|
|
||||||
let unit_key = [0x9Au8; 16];
|
|
||||||
let mut clear = clear_unit();
|
|
||||||
// Put a distinctive header so we can confirm it survives.
|
|
||||||
clear[..16].copy_from_slice(&[
|
|
||||||
0xA0, 0xA1, 0xA2, 0xA3, 0x47, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
|
||||||
0xAE, 0xAF,
|
|
||||||
]);
|
|
||||||
let header_before: [u8; 16] = clear[..16].try_into().unwrap();
|
|
||||||
let mut unit = clear;
|
|
||||||
aacs_encrypt_unit(&mut unit, &unit_key);
|
|
||||||
// Encryption also leaves the header untouched (only 16.. is encrypted).
|
|
||||||
assert_eq!(&unit[..16], &header_before);
|
|
||||||
decrypt_unit(&mut unit, &unit_key);
|
|
||||||
assert_eq!(
|
|
||||||
&unit[..16],
|
|
||||||
&header_before,
|
|
||||||
"header bytes must be preserved"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── decrypt_unit_try_keys: AlreadyClear vs DecryptedWith vs None ───────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn try_keys_reports_already_clear_without_consuming_a_key() {
|
|
||||||
// A clear unit returns AlreadyClear even with an empty key list — the
|
|
||||||
// old Option<usize> form conflated this with Some(0). Grounds the
|
|
||||||
// UnitKeyResult enum distinction.
|
|
||||||
let mut unit = clear_unit();
|
|
||||||
assert_eq!(
|
|
||||||
decrypt_unit_try_keys(&mut unit, &[]),
|
|
||||||
Some(UnitKeyResult::AlreadyClear)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn try_keys_reports_correct_index_among_several() {
|
|
||||||
// Three keys, only the 3rd (index 2) decrypts → DecryptedWith(2).
|
|
||||||
let real = [0x44u8; 16];
|
|
||||||
let mut unit = clear_unit();
|
|
||||||
aacs_encrypt_unit(&mut unit, &real);
|
|
||||||
let keys = [[0x01u8; 16], [0x02u8; 16], real];
|
|
||||||
assert_eq!(
|
|
||||||
decrypt_unit_try_keys(&mut unit, &keys),
|
|
||||||
Some(UnitKeyResult::DecryptedWith(2))
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!is_aacs_scrambled(&unit),
|
|
||||||
"unit must be clear after the hit"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn try_keys_restores_original_bytes_on_total_failure() {
|
|
||||||
// When no key works, the unit must be byte-identical to the input
|
|
||||||
// (the function CBC-mangles it per attempt, then restores). A buggy
|
|
||||||
// restore would leave the unit corrupted — silent data damage.
|
|
||||||
let real = [0x55u8; 16];
|
|
||||||
let mut unit = clear_unit();
|
|
||||||
aacs_encrypt_unit(&mut unit, &real);
|
|
||||||
let snapshot = unit.clone();
|
|
||||||
let wrong = [[0xAAu8; 16], [0xBBu8; 16]];
|
|
||||||
assert_eq!(decrypt_unit_try_keys(&mut unit, &wrong), None);
|
|
||||||
assert_eq!(unit, snapshot, "failed try must restore the original bytes");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── unit_key_validates: matches decrypt_unit's verdict exactly ─────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unit_key_validates_agrees_with_decrypt_unit() {
|
|
||||||
// The fast 1-byte gate's accept/reject set must be identical to the
|
|
||||||
// authoritative decrypt_unit. Confirm: correct key → true on both;
|
|
||||||
// wrong key → false on both.
|
|
||||||
let good = [0x6Au8; 16];
|
|
||||||
let bad = [0x6Bu8; 16];
|
|
||||||
let mut enc = clear_unit();
|
|
||||||
aacs_encrypt_unit(&mut enc, &good);
|
|
||||||
|
|
||||||
assert!(unit_key_validates(&enc, &good));
|
|
||||||
let mut probe = enc.clone();
|
|
||||||
assert!(decrypt_unit(&mut probe, &good));
|
|
||||||
|
|
||||||
assert!(!unit_key_validates(&enc, &bad));
|
|
||||||
let mut probe2 = enc.clone();
|
|
||||||
assert!(!decrypt_unit(&mut probe2, &bad));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unit_key_validates_is_non_mutating() {
|
|
||||||
// The accelerator must never write its input (it operates on the
|
|
||||||
// ciphertext and confirms on a copy). A mutation that decrypted in
|
|
||||||
// place would corrupt the caller's buffer.
|
|
||||||
let good = [0x7Cu8; 16];
|
|
||||||
let mut enc = clear_unit();
|
|
||||||
aacs_encrypt_unit(&mut enc, &good);
|
|
||||||
let snapshot = enc.clone();
|
|
||||||
let _ = unit_key_validates(&enc, &good);
|
|
||||||
assert_eq!(enc, snapshot, "unit_key_validates must not mutate input");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unit_key_validates_rejects_short_unit() {
|
|
||||||
let short = vec![0u8; ALIGNED_UNIT_LEN - 16];
|
|
||||||
assert!(!unit_key_validates(&short, &[0u8; 16]));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── bus decryption (AACS 2.0 / UHD) ────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decrypt_bus_roundtrips_per_sector_skipping_first_16_bytes() {
|
|
||||||
// Bus encryption CBC-encrypts bytes 16..2048 of EACH 2048-byte sector
|
|
||||||
// (3 sectors per aligned unit), leaving the first 16 plaintext. Build
|
|
||||||
// the forward transform, then confirm decrypt_bus inverts it and
|
|
||||||
// leaves each sector's first 16 bytes untouched.
|
|
||||||
let rdk = [0x13u8; 16];
|
|
||||||
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
|
||||||
// Fill with a recognisable pattern.
|
|
||||||
for (i, b) in unit.iter_mut().enumerate() {
|
|
||||||
*b = (i % 251) as u8;
|
|
||||||
}
|
|
||||||
let plain = unit.clone();
|
|
||||||
|
|
||||||
// Forward: CBC-encrypt unit[s+16 .. s+2048] per sector under AACS IV.
|
|
||||||
let cipher = Aes128::new(GenericArray::from_slice(&rdk));
|
|
||||||
for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
|
||||||
let mut prev = AACS_IV;
|
|
||||||
let body = s + 16;
|
|
||||||
let end = s + SECTOR_BYTES;
|
|
||||||
let nblocks = (end - body) / 16;
|
|
||||||
for i in 0..nblocks {
|
|
||||||
let off = body + i * 16;
|
|
||||||
for j in 0..16 {
|
|
||||||
unit[off + j] ^= prev[j];
|
|
||||||
}
|
|
||||||
let mut blk = GenericArray::clone_from_slice(&unit[off..off + 16]);
|
|
||||||
cipher.encrypt_block(&mut blk);
|
|
||||||
unit[off..off + 16].copy_from_slice(&blk);
|
|
||||||
prev.copy_from_slice(&unit[off..off + 16]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert_ne!(unit, plain, "forward bus-encrypt must change the body");
|
|
||||||
|
|
||||||
decrypt_bus(&mut unit, &rdk);
|
|
||||||
assert_eq!(
|
|
||||||
unit, plain,
|
|
||||||
"decrypt_bus must invert per-sector bus encrypt"
|
|
||||||
);
|
|
||||||
// Each sector's first 16 bytes equal the original (never touched).
|
|
||||||
for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
|
||||||
assert_eq!(&unit[s..s + 16], &plain[s..s + 16]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── decrypt_unit_full: bus-then-AACS ordering, and clear passthrough ───
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decrypt_unit_full_passthrough_when_already_clear() {
|
|
||||||
// A clear unit returns true and is not modified, regardless of keys.
|
|
||||||
let mut unit = clear_unit();
|
|
||||||
let snapshot = unit.clone();
|
|
||||||
assert!(decrypt_unit_full(
|
|
||||||
&mut unit,
|
|
||||||
&[0u8; 16],
|
|
||||||
Some(&[0xFFu8; 16])
|
|
||||||
));
|
|
||||||
assert_eq!(unit, snapshot, "clear unit must pass through untouched");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decrypt_unit_full_applies_bus_then_aacs() {
|
|
||||||
// AACS 2.0 pipeline: content is first AACS-unit-encrypted, then
|
|
||||||
// bus-encrypted on top. Decrypt must undo bus FIRST, then AACS.
|
|
||||||
// Build that exact two-layer ciphertext and confirm full recovery.
|
|
||||||
let unit_key = [0x21u8; 16];
|
|
||||||
let rdk = [0x84u8; 16];
|
|
||||||
|
|
||||||
let mut unit = clear_unit();
|
|
||||||
// Layer 1: AACS unit-encrypt.
|
|
||||||
aacs_encrypt_unit(&mut unit, &unit_key);
|
|
||||||
// Layer 2: bus-encrypt on top (per-sector, bytes 16..2048).
|
|
||||||
let cipher = Aes128::new(GenericArray::from_slice(&rdk));
|
|
||||||
for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
|
||||||
let mut prev = AACS_IV;
|
|
||||||
for i in 0..((SECTOR_BYTES - 16) / 16) {
|
|
||||||
let off = s + 16 + i * 16;
|
|
||||||
for j in 0..16 {
|
|
||||||
unit[off + j] ^= prev[j];
|
|
||||||
}
|
|
||||||
let mut blk = GenericArray::clone_from_slice(&unit[off..off + 16]);
|
|
||||||
cipher.encrypt_block(&mut blk);
|
|
||||||
unit[off..off + 16].copy_from_slice(&blk);
|
|
||||||
prev.copy_from_slice(&unit[off..off + 16]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(is_aacs_scrambled(&unit));
|
|
||||||
assert!(decrypt_unit_full(&mut unit, &unit_key, Some(&rdk)));
|
|
||||||
assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── is_aacs_scrambled / ts_sync_count edge cases ───────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_aacs_scrambled_false_for_sub_unit_length() {
|
|
||||||
// The function guards on `len >= ALIGNED_UNIT_LEN` first; anything
|
|
||||||
// shorter is reported NOT scrambled (so the decrypt gate skips it)
|
|
||||||
// rather than indexing past the end.
|
|
||||||
assert!(!is_aacs_scrambled(&[]));
|
|
||||||
assert!(!is_aacs_scrambled(&vec![0u8; ALIGNED_UNIT_LEN - 1]));
|
|
||||||
// A scrambled-looking buffer that is one byte short is still "not
|
|
||||||
// scrambled" by the length guard.
|
|
||||||
let mut almost = vec![0u8; ALIGNED_UNIT_LEN - 1];
|
|
||||||
almost[4] = 0x00; // no syncs
|
|
||||||
assert!(!is_aacs_scrambled(&almost));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ts_sync_count_only_samples_the_192_byte_stride() {
|
|
||||||
// A 0x47 placed OFF the stride (e.g. offset 5) must not be counted —
|
|
||||||
// the detector samples exactly offset 4, 196, 388, ... A mutation that
|
|
||||||
// scanned every byte would over-count and misclassify scrambled units.
|
|
||||||
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
|
||||||
unit[5] = TS_SYNC; // off-stride
|
|
||||||
unit[197] = TS_SYNC; // off-stride
|
|
||||||
assert_eq!(ts_sync_count(&unit), 0, "off-stride 0x47 must not count");
|
|
||||||
unit[4] = TS_SYNC; // on-stride
|
|
||||||
assert_eq!(ts_sync_count(&unit), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ts_packet_total_for_various_lengths() {
|
|
||||||
// total = len / 192 (BD-TS packet size). Pin a few lengths.
|
|
||||||
assert_eq!(ts_packet_total(&[0u8; 192]), 1);
|
|
||||||
assert_eq!(ts_packet_total(&[0u8; 384]), 2);
|
|
||||||
assert_eq!(ts_packet_total(&[0u8; 191]), 0);
|
|
||||||
// 6144 = 32 packets.
|
|
||||||
assert_eq!(ts_packet_total(&[0u8; ALIGNED_UNIT_LEN]), 32);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1769
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
//! Host-certificate collection — the one libfreemkv-side concern left from the
|
||||||
|
//! old in-tree AACS handshake. The cert mutual-auth itself now lives in the
|
||||||
|
//! `freemkv-unlock` AACS unlocker; libfreemkv only gathers the certs (a
|
||||||
|
//! keysource concern) and hands them across the seam.
|
||||||
|
|
||||||
|
/// Union the host certificates a scan can offer the drive: the explicit
|
||||||
|
/// `DriveCredentials`, then each key source's `host_certs(mkb)`. Host certs are
|
||||||
|
/// keysource-served, never compiled in. `mkb` lets a source pick a
|
||||||
|
/// generation-appropriate cert (the default impl ignores it).
|
||||||
|
pub fn collect_host_certs(
|
||||||
|
opts: &crate::disc::ScanOptions,
|
||||||
|
mkb: Option<u32>,
|
||||||
|
) -> 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());
|
||||||
|
}
|
||||||
|
for src in &opts.key_sources {
|
||||||
|
host_certs.extend(src.host_certs(mkb));
|
||||||
|
}
|
||||||
|
host_certs
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
/// (Freedom, Harry Potter) 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+354
-50
@@ -13,54 +13,189 @@
|
|||||||
//!
|
//!
|
||||||
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
|
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
|
||||||
//! Title keys decrypt m2ts stream content (AES-128-CBC).
|
//! 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 content;
|
||||||
pub mod decrypt;
|
pub mod crypto;
|
||||||
pub mod handshake;
|
pub mod derive;
|
||||||
pub mod keys;
|
pub mod host_certs;
|
||||||
|
pub mod index_select;
|
||||||
|
pub mod inf;
|
||||||
|
pub mod mkb;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
|
pub mod resolve;
|
||||||
|
pub mod segment;
|
||||||
|
pub mod segment_key;
|
||||||
pub mod trace;
|
pub mod trace;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod variants;
|
pub mod variant;
|
||||||
|
|
||||||
// Boil-down derivation primitives (thin newtypes + wrappers over the crypto).
|
/// On-disc UDF paths to the AACS key-input files, plus HD DVD AACS-directory
|
||||||
pub use boil::{MediaKey, UnitKey, Vid, Vuk, mk_from_dk, uk_from_vuk, vuk_from_mk};
|
/// discovery.
|
||||||
// Structured, English-free resolution trace.
|
///
|
||||||
pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, UnlockStep};
|
/// 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";
|
||||||
|
pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf";
|
||||||
|
pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
|
||||||
|
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
|
||||||
|
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
||||||
|
|
||||||
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
|
/// An AACS key-input role. [`role_paths`] maps it to an ordered candidate path
|
||||||
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
|
/// list (BD/UHD constants, then the discovered HD DVD files).
|
||||||
pub use decrypt::{
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, decrypt_bus, decrypt_unit,
|
pub enum AacsRole {
|
||||||
decrypt_unit_full, decrypt_unit_try_keys, is_aacs_scrambled, is_unit_aligned, ts_packet_total,
|
/// Title-key file: BD/UHD `Unit_Key_RO.inf`, HD DVD `VTKF*.AACS`
|
||||||
ts_sync_count, unit_key_validates,
|
/// (magic `DVD_HD_V_TKF`). The disc_hash is `SHA1` of this file.
|
||||||
};
|
UnitKey,
|
||||||
pub use keys::probe;
|
/// Media Key Block: BD/UHD `MKB_RO/RW.inf`, HD DVD `MKBROM.AACS`.
|
||||||
pub use keys::{
|
Mkb,
|
||||||
AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C, MKB_TYPE_3_RECORDABLE,
|
/// Content certificate: BD/UHD `Content000/001.cer`, HD DVD
|
||||||
MKB_TYPE_4_PRERECORDED, MKB_TYPE_10_CLASS_II, MkbType, ResolveContext, ResolveFailure,
|
/// `CONTENT_CERT.AACS` (byte 0 gives the AACS major).
|
||||||
ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_and_pk_from_dk,
|
ContentCert,
|
||||||
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,
|
/// The HD DVD AACS directory in a parsed UDF tree, if present.
|
||||||
resolve_keys_v21, resolve_keys_with_reason, trim_mkb,
|
///
|
||||||
};
|
/// Identified structurally, NOT by a hardcoded name: the root child directory
|
||||||
pub use provider::KeyProvider;
|
/// whose name ends in `!` (so the `<name>!_BAK` backup mirror, which also ends
|
||||||
pub use types::{DeviceKey, DiscEntry, HostCert};
|
/// in a non-`!` char, is not mistaken for it) and which contains `MKBROM.AACS`.
|
||||||
pub use variants::{
|
/// Observed real names: `ANY!` (Dukes of Hazzard), `AAC!` (Freedom). A BD/UHD
|
||||||
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
|
/// disc has no such directory → `None`.
|
||||||
derive_media_key_variant, is_variant_mkb, variant_nonce, walk_mkb, walk_processing_key,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
//! Re-export surface guards. The module's public API is the set of
|
//! Surface guards. The public API is the module tree itself (no facade).
|
||||||
//! `pub use` items above. A regression that drops or renames an export
|
//! Touching one representative item per module keeps these as a
|
||||||
//! (the class of bug that shipped in 0.31.0 by silently changing a
|
//! compile-time contract that the module paths stay stable.
|
||||||
//! surface) breaks compilation of these references, so they act as a
|
|
||||||
//! compile-time contract for the crate's AACS surface.
|
|
||||||
|
|
||||||
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]
|
#[test]
|
||||||
fn aligned_unit_len_is_three_2048_byte_sectors() {
|
fn aligned_unit_len_is_three_2048_byte_sectors() {
|
||||||
@@ -82,20 +217,189 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn key_correction_data_placeholder_is_all_zero() {
|
fn public_helpers_are_callable_by_module_path() {
|
||||||
// The variant chain refuses to run against this all-zero placeholder
|
// Touch a representative function from each module so a dropped/renamed
|
||||||
// KCD; the public constant must therefore be exactly 16 zero bytes.
|
// item fails to compile. Smoke calls, not behavioural assertions.
|
||||||
assert_eq!(KEY_CORRECTION_DATA_PLACEHOLDER, [0u8; 16]);
|
let _ = !crate::aacs::content::is_clean(
|
||||||
}
|
&[0u8; ALIGNED_UNIT_LEN],
|
||||||
|
crate::disc::ContentFormat::BdTs,
|
||||||
#[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 _ = is_aacs_scrambled(&[0u8; ALIGNED_UNIT_LEN]);
|
|
||||||
let _ = mkb_content_len(&[]);
|
let _ = mkb_content_len(&[]);
|
||||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||||
|
let _ = super::derive::resolve_candidate(
|
||||||
|
&super::derive::KeyCandidate::Uk(super::types::UnitKey::new(0, [0u8; 16])),
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HD DVD AACS directory / filename discovery ────────────────────────
|
||||||
|
//
|
||||||
|
// The HD DVD AACS dir name and title-key filename are authoring-specific
|
||||||
|
// and were previously hardcoded to `/ANY!/VTKF000.AACS`. These verify the
|
||||||
|
// discovery replacement against both real-disc shapes: Freedom (`AAC!` +
|
||||||
|
// `VTKF090`/`VTKF100`) and a BD/UHD disc (no HD DVD dir).
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_paths_discovers_hddvd_dir_and_globs_all_vtkf_variants() {
|
||||||
|
use crate::udf::fixture::*;
|
||||||
|
// Freedom-shaped: an `AAC!` dir (NOT `ANY!`) holding MKBROM + two VTKF
|
||||||
|
// variants (090/100, NOT 000) + a VTUF usage file (must be excluded),
|
||||||
|
// plus the `AAC!_BAK` mirror (must NOT be picked as the AACS dir).
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let aacs_files = vec![
|
||||||
|
file("MKBROM.AACS", 100, 5000, 4096, true),
|
||||||
|
file("CONTENT_CERT.AACS", 101, 5100, 2048, true),
|
||||||
|
file("VTKF100.AACS", 102, 5200, 2048, true),
|
||||||
|
file("VTKF090.AACS", 103, 5300, 2048, true),
|
||||||
|
file("VTUF090.AACS", 104, 5400, 2048, true),
|
||||||
|
];
|
||||||
|
let bak_files = vec![file("MKBROM.AACS", 110, 6000, 4096, true)];
|
||||||
|
let root = DirSpec {
|
||||||
|
name: String::new(),
|
||||||
|
icb_lba: 10,
|
||||||
|
dir_data_lba: 11,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: vec![
|
||||||
|
DirSpec {
|
||||||
|
name: "AAC!".to_string(),
|
||||||
|
icb_lba: 20,
|
||||||
|
dir_data_lba: 21,
|
||||||
|
files: aacs_files,
|
||||||
|
subdirs: vec![],
|
||||||
|
},
|
||||||
|
DirSpec {
|
||||||
|
name: "AAC!_BAK".to_string(),
|
||||||
|
icb_lba: 30,
|
||||||
|
dir_data_lba: 31,
|
||||||
|
files: bak_files,
|
||||||
|
subdirs: vec![],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
build_udf_skeleton(&mut disc, 10);
|
||||||
|
lay_dir(&mut disc, &root);
|
||||||
|
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||||
|
|
||||||
|
// Discovered structurally (ends in '!', holds MKBROM.AACS) — the real
|
||||||
|
// AACS dir, never the `_BAK` mirror.
|
||||||
|
let dir = super::find_hddvd_aacs_dir(&udf).expect("aacs dir");
|
||||||
|
assert_eq!(dir.name, "AAC!");
|
||||||
|
|
||||||
|
// UnitKey: BD/UHD paths first, then EVERY VTKF*.AACS in sorted order
|
||||||
|
// (090 before 100) — NOT hardcoded VTKF000; VTUF (usage) excluded.
|
||||||
|
assert_eq!(
|
||||||
|
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||||
|
vec![
|
||||||
|
super::PATH_UNIT_KEY_RO.to_string(),
|
||||||
|
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||||
|
"/AAC!/VTKF090.AACS".to_string(),
|
||||||
|
"/AAC!/VTKF100.AACS".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::role_paths(&udf, super::AacsRole::Mkb)
|
||||||
|
.last()
|
||||||
|
.unwrap(),
|
||||||
|
"/AAC!/MKBROM.AACS"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::role_paths(&udf, super::AacsRole::ContentCert)
|
||||||
|
.last()
|
||||||
|
.unwrap(),
|
||||||
|
"/AAC!/CONTENT_CERT.AACS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `!`-suffix and the `MKBROM.AACS` presence test are BOTH required —
|
||||||
|
/// the discovery is a conjunction, not a disjunction.
|
||||||
|
///
|
||||||
|
/// The existing fixtures only ever present a directory that satisfies both
|
||||||
|
/// (`AAC!` with `MKBROM.AACS`) alongside one that satisfies neither
|
||||||
|
/// (`AAC!_BAK` — which contains `MKBROM.AACS` but is ALSO reached only after
|
||||||
|
/// the real dir), so either half of the conjunction could be dropped and the
|
||||||
|
/// same directory would still be found. Here a directory satisfies the name
|
||||||
|
/// half and NOT the contents half: it must not be picked.
|
||||||
|
///
|
||||||
|
/// If it were, the HD DVD path would resolve `MKBROM.AACS`,
|
||||||
|
/// `CONTENT_CERT.AACS` and the title-key file under a directory that holds
|
||||||
|
/// none of them — the disc reports "no AACS key files" and never rips.
|
||||||
|
#[test]
|
||||||
|
fn a_bang_suffixed_directory_without_mkbrom_is_not_the_aacs_directory() {
|
||||||
|
use crate::udf::fixture::*;
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let root = DirSpec {
|
||||||
|
name: String::new(),
|
||||||
|
icb_lba: 10,
|
||||||
|
dir_data_lba: 11,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: vec![DirSpec {
|
||||||
|
// Ends in '!' — but carries no MKBROM.AACS, so it is not the
|
||||||
|
// HD DVD AACS directory.
|
||||||
|
name: "AAC!".to_string(),
|
||||||
|
icb_lba: 20,
|
||||||
|
dir_data_lba: 21,
|
||||||
|
files: vec![
|
||||||
|
file("VTKF090.AACS", 102, 5200, 2048, true),
|
||||||
|
file("CONTENT_CERT.AACS", 103, 5300, 2048, true),
|
||||||
|
],
|
||||||
|
subdirs: vec![],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
build_udf_skeleton(&mut disc, 10);
|
||||||
|
lay_dir(&mut disc, &root);
|
||||||
|
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
super::find_hddvd_aacs_dir(&udf).is_none(),
|
||||||
|
"a '!' directory without MKBROM.AACS is not the AACS directory"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||||
|
vec![
|
||||||
|
super::PATH_UNIT_KEY_RO.to_string(),
|
||||||
|
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||||
|
],
|
||||||
|
"no HD DVD candidates may be appended from a directory that was \
|
||||||
|
never identified as the AACS directory"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_paths_bd_uhd_disc_yields_no_hddvd_candidates() {
|
||||||
|
use crate::udf::fixture::*;
|
||||||
|
// A `/AACS/` tree (BD/UHD) has no '!' directory → discovery finds none
|
||||||
|
// and the candidate list is exactly the static BD/UHD paths.
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let root = DirSpec {
|
||||||
|
name: String::new(),
|
||||||
|
icb_lba: 10,
|
||||||
|
dir_data_lba: 11,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: vec![DirSpec {
|
||||||
|
name: "AACS".to_string(),
|
||||||
|
icb_lba: 20,
|
||||||
|
dir_data_lba: 21,
|
||||||
|
files: vec![
|
||||||
|
file("Unit_Key_RO.inf", 100, 5000, 2048, true),
|
||||||
|
file("MKB_RO.inf", 101, 5100, 2048, true),
|
||||||
|
],
|
||||||
|
subdirs: vec![],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
build_udf_skeleton(&mut disc, 10);
|
||||||
|
lay_dir(&mut disc, &root);
|
||||||
|
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||||
|
|
||||||
|
assert!(super::find_hddvd_aacs_dir(&udf).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||||
|
vec![
|
||||||
|
super::PATH_UNIT_KEY_RO.to_string(),
|
||||||
|
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-1
@@ -42,7 +42,7 @@ use super::types::{DeviceKey, DiscEntry, HostCert};
|
|||||||
/// Source of AACS key material.
|
/// Source of AACS key material.
|
||||||
///
|
///
|
||||||
/// Implementors return raw material only — the resolver in
|
/// 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.
|
/// MK→VUK→TK derivation). See module docs for method semantics.
|
||||||
pub trait KeyProvider: Send + Sync {
|
pub trait KeyProvider: Send + Sync {
|
||||||
/// Device keys (top-of-tree, walked by the resolver).
|
/// 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 {
|
fn dk(byte: u8, node: u16) -> DeviceKey {
|
||||||
DeviceKey {
|
DeviceKey {
|
||||||
key: [byte; 16],
|
key: [byte; 16],
|
||||||
@@ -357,6 +368,42 @@ mod tests {
|
|||||||
assert_eq!(got.disc_hash, "vid-a");
|
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]
|
#[test]
|
||||||
fn providers_empty_array_yields_nothing() {
|
fn providers_empty_array_yields_nothing() {
|
||||||
let arr: &[&dyn KeyProvider] = &[];
|
let arr: &[&dyn KeyProvider] = &[];
|
||||||
|
|||||||
+360
-1204
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -31,7 +31,7 @@ impl ResolutionTrace {
|
|||||||
// ── Unlock phase ────────────────────────────────────────────────────────────
|
// ── Unlock phase ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// One unlocker's contribution to the unlock phase. `who` is the unlocker's
|
/// One unlocker's contribution to the unlock phase. `who` is the unlocker's
|
||||||
/// `name()` (a stable identifier, e.g. `"LibreDrive"`), carried verbatim.
|
/// `name()` (a stable, product-neutral identifier), carried verbatim.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct UnlockStep {
|
pub struct UnlockStep {
|
||||||
pub who: String,
|
pub who: String,
|
||||||
|
|||||||
+296
-3
@@ -6,7 +6,7 @@
|
|||||||
//! owns only the crypto and these value types that flow through it.
|
//! owns only the crypto and these value types that flow through it.
|
||||||
|
|
||||||
/// A device key for MKB subset-difference tree processing.
|
/// A device key for MKB subset-difference tree processing.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DeviceKey {
|
pub struct DeviceKey {
|
||||||
pub key: [u8; 16],
|
pub key: [u8; 16],
|
||||||
pub node: u16,
|
pub node: u16,
|
||||||
@@ -15,7 +15,7 @@ pub struct DeviceKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Host certificate + private key for AACS SCSI authentication.
|
/// Host certificate + private key for AACS SCSI authentication.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct HostCert {
|
pub struct HostCert {
|
||||||
/// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes.
|
/// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes.
|
||||||
pub private_key: [u8; 20],
|
pub private_key: [u8; 20],
|
||||||
@@ -27,8 +27,75 @@ pub struct HostCert {
|
|||||||
pub certificate_v2: Option<Vec<u8>>,
|
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.
|
/// A per-disc entry from the key database.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DiscEntry {
|
pub struct DiscEntry {
|
||||||
/// Disc hash (20 bytes, hex)
|
/// Disc hash (20 bytes, hex)
|
||||||
pub disc_hash: String,
|
pub disc_hash: String,
|
||||||
@@ -43,3 +110,229 @@ pub struct DiscEntry {
|
|||||||
/// Unit keys (title keys) indexed by CPS unit number
|
/// Unit keys (title keys) indexed by CPS unit number
|
||||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
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
+109
-848
File diff suppressed because it is too large
Load Diff
+104
@@ -8,8 +8,22 @@
|
|||||||
|
|
||||||
/// Bytes per logical sector on every optical medium freemkv reads
|
/// Bytes per logical sector on every optical medium freemkv reads
|
||||||
/// (Blu-ray, DVD-Video, CD-ROM Mode 1). Universal — hence unprefixed.
|
/// (Blu-ray, DVD-Video, CD-ROM Mode 1). Universal — hence unprefixed.
|
||||||
|
///
|
||||||
|
/// `usize` because its dominant use is buffer sizing and slice indexing, where
|
||||||
|
/// Rust *requires* `usize` (`vec![0u8; SECTOR_BYTES]`, `buf.len() < SECTOR_BYTES`).
|
||||||
|
/// For byte-offset / capacity arithmetic — which is `u64` because a disc can
|
||||||
|
/// exceed 4 GiB — use [`SECTOR_BYTES_U64`] instead of casting at each site.
|
||||||
pub const SECTOR_BYTES: usize = 2048;
|
pub const SECTOR_BYTES: usize = 2048;
|
||||||
|
|
||||||
|
/// [`SECTOR_BYTES`] as `u64`, for byte-offset and capacity arithmetic. The
|
||||||
|
/// single `usize → u64` boundary cast lives here, once, so offset math across
|
||||||
|
/// the workspace reads as `sectors * SECTOR_BYTES_U64` with no per-site cast.
|
||||||
|
pub const SECTOR_BYTES_U64: u64 = SECTOR_BYTES as u64;
|
||||||
|
|
||||||
|
/// Milliseconds per second. For turning a byte count ÷ bytes-per-second into a
|
||||||
|
/// movie-time figure (`bytes / bps * MILLIS_PER_SEC`) without a bare `1000.0`.
|
||||||
|
pub const MILLIS_PER_SEC: f64 = 1_000.0;
|
||||||
|
|
||||||
/// Bytes per MPEG-2 transport-stream packet. Common to all MPEG-TS, not just
|
/// Bytes per MPEG-2 transport-stream packet. Common to all MPEG-TS, not just
|
||||||
/// Blu-ray — prefixed by the format, not a disc type.
|
/// Blu-ray — prefixed by the format, not a disc type.
|
||||||
pub const TS_PACKET_BYTES: usize = 188;
|
pub const TS_PACKET_BYTES: usize = 188;
|
||||||
@@ -31,3 +45,93 @@ pub const TS_PAYLOAD_BYTES: usize = TS_PACKET_BYTES - TS_HEADER_BYTES;
|
|||||||
/// prefixed with the [`BD_TIMESTAMP_PREFIX_BYTES`] arrival-timestamp header.
|
/// prefixed with the [`BD_TIMESTAMP_PREFIX_BYTES`] arrival-timestamp header.
|
||||||
/// A BDAV/M2TS construct only — DVD VOBs have no source packets — hence `BD_`.
|
/// A BDAV/M2TS construct only — DVD VOBs have no source packets — hence `BD_`.
|
||||||
pub const BD_SOURCE_PACKET_BYTES: usize = TS_PACKET_BYTES + BD_TIMESTAMP_PREFIX_BYTES;
|
pub const BD_SOURCE_PACKET_BYTES: usize = TS_PACKET_BYTES + BD_TIMESTAMP_PREFIX_BYTES;
|
||||||
|
|
||||||
|
/// Elementary-stream coding-type codes — the single source of truth for the
|
||||||
|
/// byte that identifies a stream's codec.
|
||||||
|
///
|
||||||
|
/// This is one registry used in two places that share the same value space:
|
||||||
|
/// the MPEG-TS PMT `stream_type` (ISO/IEC 13818-1 Table 2-34) and the Blu-ray
|
||||||
|
/// STN/CLPI `stream_coding_type` (BD-ROM Part 3). The standardized video codes
|
||||||
|
/// (`0x02`, `0x1B`, `0x24`) are ISO assignments (ISO/IEC 13818-1 Table 2-34);
|
||||||
|
/// `0xEA` (VC-1) is a BD-ROM convention in the ISO user-private range. The
|
||||||
|
/// `0x80..=0xA2` audio/graphics codes also sit in the user-private range and follow the
|
||||||
|
/// Blu-ray Disc Association / ATSC A/52 convention. Because every consumer
|
||||||
|
/// reads or writes this single byte, the family is unprefixed — the scope is
|
||||||
|
/// "any elementary stream freemkv parses or muxes".
|
||||||
|
///
|
||||||
|
/// Each constant is `u8`: the spec defines an 8-bit field and the code compares
|
||||||
|
/// it directly against a byte read from the buffer, so no casts are needed.
|
||||||
|
pub mod coding_type {
|
||||||
|
/// MPEG-2 video (ISO/IEC 13818-1 Table 2-34).
|
||||||
|
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).
|
||||||
|
pub const VC1: u8 = 0xEA;
|
||||||
|
|
||||||
|
/// LPCM audio (BD-ROM convention).
|
||||||
|
pub const LPCM: u8 = 0x80;
|
||||||
|
/// Dolby Digital (AC-3) audio (BD-ROM / ATSC A/52 convention).
|
||||||
|
pub const AC3: u8 = 0x81;
|
||||||
|
/// DTS audio (BD-ROM convention).
|
||||||
|
pub const DTS: u8 = 0x82;
|
||||||
|
/// Dolby TrueHD audio (BD-ROM convention).
|
||||||
|
pub const TRUEHD: u8 = 0x83;
|
||||||
|
/// Dolby Digital Plus (E-AC-3 / AC-3+) audio (BD-ROM convention).
|
||||||
|
pub const AC3_PLUS: u8 = 0x84;
|
||||||
|
/// DTS-HD High Resolution audio (BD-ROM Part 3-1).
|
||||||
|
pub const DTS_HD_HR: u8 = 0x85;
|
||||||
|
/// DTS-HD Master Audio (lossless) (BD-ROM Part 3-1).
|
||||||
|
pub const DTS_HD_MA: u8 = 0x86;
|
||||||
|
|
||||||
|
/// Presentation Graphics — PG subtitle stream (BD-ROM HDMV).
|
||||||
|
pub const PG: u8 = 0x90;
|
||||||
|
/// Interactive Graphics — IG / BD-J menu overlay, NOT a subtitle (BD-ROM HDMV).
|
||||||
|
pub const IG: u8 = 0x91;
|
||||||
|
/// Text subtitle stream (BD-ROM HDMV).
|
||||||
|
pub const TEXT_SUBTITLE: u8 = 0x92;
|
||||||
|
|
||||||
|
/// Secondary Dolby Digital Plus audio (BD-ROM convention).
|
||||||
|
pub const AC3_PLUS_SECONDARY: u8 = 0xA1;
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MPEG PES `stream_id` codes — the byte after the `00 00 01` start-code prefix
|
||||||
|
/// that identifies an elementary stream's role in a PES packet (ISO/IEC
|
||||||
|
/// 13818-1 Table 2-22). Shared by the program-stream demuxer and the TS/M2TS
|
||||||
|
/// muxers, so defined here once. Each is `u8` (matches the byte on the wire).
|
||||||
|
pub mod pes_stream_id {
|
||||||
|
/// Video stream (`110x xxxx`; freemkv emits the base id `0xE0`).
|
||||||
|
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.
|
||||||
|
pub const PADDING_STREAM: u8 = 0xBE;
|
||||||
|
/// private_stream_2 — DVD navigation (PCI/DSI); carries no muxable ES.
|
||||||
|
pub const PRIVATE_STREAM_2: u8 = 0xBF;
|
||||||
|
|
||||||
|
/// Highest video stream_id — the `110x xxxx` video range tops out at 0xEF.
|
||||||
|
pub const VIDEO_MAX: u8 = 0xEF;
|
||||||
|
|
||||||
|
/// Inclusive range of every PES `stream_id` that carries demuxable payload:
|
||||||
|
/// [`PRIVATE_STREAM_1`] (0xBD) through [`VIDEO_MAX`] (0xEF) — i.e. private
|
||||||
|
/// stream 1/2, padding, MPEG audio (0xC0-0xDF) and video (0xE0-0xEF). The
|
||||||
|
/// pack (0xBA), system-header (0xBB) and program-end (0xB9) codes sit below
|
||||||
|
/// this range and are deliberately excluded: they're structural, not ES.
|
||||||
|
pub const PAYLOAD_RANGE: core::ops::RangeInclusive<u8> = PRIVATE_STREAM_1..=VIDEO_MAX;
|
||||||
|
}
|
||||||
|
|||||||
-835
@@ -1,835 +0,0 @@
|
|||||||
//! CSS drive bus-authentication — read-unlock primitive.
|
|
||||||
//!
|
|
||||||
//! A CSS-enforcing DVD drive refuses to return scrambled sectors until a
|
|
||||||
//! CSS bus-auth handshake has set its Authentication Success Flag (ASF=1).
|
|
||||||
//! [`unlock_css_reads`] runs that bus-auth challenge-response (which is what
|
|
||||||
//! actually opens scrambled-sector reads), then a best-effort, non-fatal
|
|
||||||
//! disc-key REPORT KEY. The bytes are NOT used as keys: the descramble title
|
|
||||||
//! key is recovered keylessly by the Stevenson known-plaintext attack (see
|
|
||||||
//! [`super::crack_key`]).
|
|
||||||
|
|
||||||
use crate::drive::Drive;
|
|
||||||
use crate::error::{Error, Result};
|
|
||||||
|
|
||||||
// ── CryptKey tables ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const CRYPT_TAB0: [u8; 256] = [
|
|
||||||
0xB7, 0xF4, 0x82, 0x57, 0xDA, 0x4D, 0xDB, 0xE2, 0x2F, 0x52, 0x1A, 0xA8, 0x68, 0x5A, 0x8A, 0xFF,
|
|
||||||
0xFB, 0x0E, 0x6D, 0x35, 0xF7, 0x5C, 0x76, 0x12, 0xCE, 0x25, 0x79, 0x29, 0x39, 0x62, 0x08, 0x24,
|
|
||||||
0xA5, 0x85, 0x7B, 0x56, 0x01, 0x23, 0x68, 0xCF, 0x0A, 0xE2, 0x5A, 0xED, 0x3D, 0x59, 0xB0, 0xA9,
|
|
||||||
0xB0, 0x2C, 0xF2, 0xB8, 0xEF, 0x32, 0xA9, 0x40, 0x80, 0x71, 0xAF, 0x1E, 0xDE, 0x8F, 0x58, 0x88,
|
|
||||||
0xB8, 0x3A, 0xD0, 0xFC, 0xC4, 0x1E, 0xB5, 0xA0, 0xBB, 0x3B, 0x0F, 0x01, 0x7E, 0x1F, 0x9F, 0xD9,
|
|
||||||
0xAA, 0xB8, 0x3D, 0x9D, 0x74, 0x1E, 0x25, 0xDB, 0x37, 0x56, 0x8F, 0x16, 0xBA, 0x49, 0x2B, 0xAC,
|
|
||||||
0xD0, 0xBD, 0x95, 0x20, 0xBE, 0x7A, 0x28, 0xD0, 0x51, 0x64, 0x63, 0x1C, 0x7F, 0x66, 0x10, 0xBB,
|
|
||||||
0xC4, 0x56, 0x1A, 0x04, 0x6E, 0x0A, 0xEC, 0x9C, 0xD6, 0xE8, 0x9A, 0x7A, 0xCF, 0x8C, 0xDB, 0xB1,
|
|
||||||
0xEF, 0x71, 0xDE, 0x31, 0xFF, 0x54, 0x3E, 0x5E, 0x07, 0x69, 0x96, 0xB0, 0xCF, 0xDD, 0x9E, 0x47,
|
|
||||||
0xC7, 0x96, 0x8F, 0xE4, 0x2B, 0x59, 0xC6, 0xEE, 0xB9, 0x86, 0x9A, 0x64, 0x84, 0x72, 0xE2, 0x5B,
|
|
||||||
0xA2, 0x96, 0x58, 0x99, 0x50, 0x03, 0xF5, 0x38, 0x4D, 0x02, 0x7D, 0xE7, 0x7D, 0x75, 0xA7, 0xB8,
|
|
||||||
0x67, 0x87, 0x84, 0x3F, 0x1D, 0x11, 0xE5, 0xFC, 0x1E, 0xD3, 0x83, 0x16, 0xA5, 0x29, 0xF6, 0xC7,
|
|
||||||
0x15, 0x61, 0x29, 0x1A, 0x43, 0x4F, 0x9B, 0xAF, 0xC5, 0x87, 0x34, 0x6C, 0x0F, 0x3B, 0xA8, 0x1D,
|
|
||||||
0x45, 0x58, 0x25, 0xDC, 0xA8, 0xA3, 0x3B, 0xD1, 0x79, 0x1B, 0x48, 0xF2, 0xE9, 0x93, 0x1F, 0xFC,
|
|
||||||
0xDB, 0x2A, 0x90, 0xA9, 0x8A, 0x3D, 0x39, 0x18, 0xA3, 0x8E, 0x58, 0x6C, 0xE0, 0x12, 0xBB, 0x25,
|
|
||||||
0xCD, 0x71, 0x22, 0xA2, 0x64, 0xC6, 0xE7, 0xFB, 0xAD, 0x94, 0x77, 0x04, 0x9A, 0x39, 0xCF, 0x7C,
|
|
||||||
];
|
|
||||||
|
|
||||||
const CRYPT_TAB1: [u8; 256] = [
|
|
||||||
0x8C, 0x47, 0xB0, 0xE1, 0xEB, 0xFC, 0xEB, 0x56, 0x10, 0xE5, 0x2C, 0x1A, 0x5D, 0xEF, 0xBE, 0x4F,
|
|
||||||
0x08, 0x75, 0x97, 0x4B, 0x0E, 0x25, 0x8E, 0x6E, 0x39, 0x5A, 0x87, 0x53, 0xC4, 0x1F, 0xF4, 0x5C,
|
|
||||||
0x4E, 0xE6, 0x99, 0x30, 0xE0, 0x42, 0x88, 0xAB, 0xE5, 0x85, 0xBC, 0x8F, 0xD8, 0x3C, 0x54, 0xC9,
|
|
||||||
0x53, 0x47, 0x18, 0xD6, 0x06, 0x5B, 0x41, 0x2C, 0x67, 0x1E, 0x41, 0x74, 0x33, 0xE2, 0xB4, 0xE0,
|
|
||||||
0x23, 0x29, 0x42, 0xEA, 0x55, 0x0F, 0x25, 0xB4, 0x24, 0x2C, 0x99, 0x13, 0xEB, 0x0A, 0x0B, 0xC9,
|
|
||||||
0xF9, 0x63, 0x67, 0x43, 0x2D, 0xC7, 0x7D, 0x07, 0x60, 0x89, 0xD1, 0xCC, 0xE7, 0x94, 0x77, 0x74,
|
|
||||||
0x9B, 0x7E, 0xD7, 0xE6, 0xFF, 0xBB, 0x68, 0x14, 0x1E, 0xA3, 0x25, 0xDE, 0x3A, 0xA3, 0x54, 0x7B,
|
|
||||||
0x87, 0x9D, 0x50, 0xCA, 0x27, 0xC3, 0xA4, 0x50, 0x91, 0x27, 0xD4, 0xB0, 0x82, 0x41, 0x97, 0x79,
|
|
||||||
0x94, 0x82, 0xAC, 0xC7, 0x8E, 0xA5, 0x4E, 0xAA, 0x78, 0x9E, 0xE0, 0x42, 0xBA, 0x28, 0xEA, 0xB7,
|
|
||||||
0x74, 0xAD, 0x35, 0xDA, 0x92, 0x60, 0x7E, 0xD2, 0x0E, 0xB9, 0x24, 0x5E, 0x39, 0x4F, 0x5E, 0x63,
|
|
||||||
0x09, 0xB5, 0xFA, 0xBF, 0xF1, 0x22, 0x55, 0x1C, 0xE2, 0x25, 0xDB, 0xC5, 0xD8, 0x50, 0x03, 0x98,
|
|
||||||
0xC4, 0xAC, 0x2E, 0x11, 0xB4, 0x38, 0x4D, 0xD0, 0xB9, 0xFC, 0x2D, 0x3C, 0x08, 0x04, 0x5A, 0xEF,
|
|
||||||
0xCE, 0x32, 0xFB, 0x4C, 0x92, 0x1E, 0x4B, 0xFB, 0x1A, 0xD0, 0xE2, 0x3E, 0xDA, 0x6E, 0x7C, 0x4D,
|
|
||||||
0x56, 0xC3, 0x3F, 0x42, 0xB1, 0x3A, 0x23, 0x4D, 0x6E, 0x84, 0x56, 0x68, 0xF4, 0x0E, 0x03, 0x64,
|
|
||||||
0xD0, 0xA9, 0x92, 0x2F, 0x8B, 0xBC, 0x39, 0x9C, 0xAC, 0x09, 0x5E, 0xEE, 0xE5, 0x97, 0xBF, 0xA5,
|
|
||||||
0xCE, 0xFA, 0x28, 0x2C, 0x6D, 0x4F, 0xEF, 0x77, 0xAA, 0x1B, 0x79, 0x8E, 0x97, 0xB4, 0xC3, 0xF4,
|
|
||||||
];
|
|
||||||
|
|
||||||
const CRYPT_TAB2: [u8; 256] = [
|
|
||||||
0xB7, 0x75, 0x81, 0xD5, 0xDC, 0xCA, 0xDE, 0x66, 0x23, 0xDF, 0x15, 0x26, 0x62, 0xD1, 0x83, 0x77,
|
|
||||||
0xE3, 0x97, 0x76, 0xAF, 0xE9, 0xC3, 0x6B, 0x8E, 0xDA, 0xB0, 0x6E, 0xBF, 0x2B, 0xF1, 0x19, 0xB4,
|
|
||||||
0x95, 0x34, 0x48, 0xE4, 0x37, 0x94, 0x5D, 0x7B, 0x36, 0x5F, 0x65, 0x53, 0x07, 0xE2, 0x89, 0x11,
|
|
||||||
0x98, 0x85, 0xD9, 0x12, 0xC1, 0x9D, 0x84, 0xEC, 0xA4, 0xD4, 0x88, 0xB8, 0xFC, 0x2C, 0x79, 0x28,
|
|
||||||
0xD8, 0xDB, 0xB3, 0x1E, 0xA2, 0xF9, 0xD0, 0x44, 0xD7, 0xD6, 0x60, 0xEF, 0x14, 0xF4, 0xF6, 0x31,
|
|
||||||
0xD2, 0x41, 0x46, 0x67, 0x0A, 0xE1, 0x58, 0x27, 0x43, 0xA3, 0xF8, 0xE0, 0xC8, 0xBA, 0x5A, 0x5C,
|
|
||||||
0x80, 0x6C, 0xC6, 0xF2, 0xE8, 0xAD, 0x7D, 0x04, 0x0D, 0xB9, 0x3C, 0xC2, 0x25, 0xBD, 0x49, 0x63,
|
|
||||||
0x8C, 0x9F, 0x51, 0xCE, 0x20, 0xC5, 0xA1, 0x50, 0x92, 0x2D, 0xDD, 0xBC, 0x8D, 0x4F, 0x9A, 0x71,
|
|
||||||
0x2F, 0x30, 0x1D, 0x73, 0x39, 0x13, 0xFB, 0x1A, 0xCB, 0x24, 0x59, 0xFE, 0x05, 0x96, 0x57, 0x0F,
|
|
||||||
0x1F, 0xCF, 0x54, 0xBE, 0xF5, 0x06, 0x1B, 0xB2, 0x6D, 0xD3, 0x4D, 0x32, 0x56, 0x21, 0x33, 0x0B,
|
|
||||||
0x52, 0xE7, 0xAB, 0xEB, 0xA6, 0x74, 0x00, 0x4C, 0xB1, 0x7F, 0x82, 0x99, 0x87, 0x0E, 0x5E, 0xC0,
|
|
||||||
0x8F, 0xEE, 0x6F, 0x55, 0xF3, 0x7E, 0x08, 0x90, 0xFA, 0xB6, 0x64, 0x70, 0x47, 0x4A, 0x17, 0xA7,
|
|
||||||
0xB5, 0x40, 0x8A, 0x38, 0xE5, 0x68, 0x3E, 0x8B, 0x69, 0xAA, 0x9B, 0x42, 0xA5, 0x10, 0x01, 0x35,
|
|
||||||
0xFD, 0x61, 0x9E, 0xE6, 0x16, 0x9C, 0x86, 0xED, 0xCD, 0x2E, 0xFF, 0xC4, 0x5B, 0xA0, 0xAE, 0xCC,
|
|
||||||
0x4B, 0x3B, 0x03, 0xBB, 0x1C, 0x2A, 0xAC, 0x0C, 0x3F, 0x93, 0xC7, 0x72, 0x7A, 0x09, 0x22, 0x3D,
|
|
||||||
0x45, 0x78, 0xA9, 0xA8, 0xEA, 0xC9, 0x6A, 0xF7, 0x29, 0x91, 0xF0, 0x02, 0x18, 0x3A, 0x4E, 0x7C,
|
|
||||||
];
|
|
||||||
|
|
||||||
const CRYPT_TAB3: [u8; 256] = [
|
|
||||||
0x73, 0x51, 0x95, 0xE1, 0x12, 0xE4, 0xC0, 0x58, 0xEE, 0xF2, 0x08, 0x1B, 0xA9, 0xFA, 0x98, 0x4C,
|
|
||||||
0xA7, 0x33, 0xE2, 0x1B, 0xA7, 0x6D, 0xF5, 0x30, 0x97, 0x1D, 0xF3, 0x02, 0x60, 0x5A, 0x82, 0x0F,
|
|
||||||
0x91, 0xD0, 0x9C, 0x10, 0x39, 0x7A, 0x83, 0x85, 0x3B, 0xB2, 0xB8, 0xAE, 0x0C, 0x09, 0x52, 0xEA,
|
|
||||||
0x1C, 0xE1, 0x8D, 0x66, 0x4F, 0xF3, 0xDA, 0x92, 0x29, 0xB9, 0xD5, 0xC5, 0x77, 0x47, 0x22, 0x53,
|
|
||||||
0x14, 0xF7, 0xAF, 0x22, 0x64, 0xDF, 0xC6, 0x72, 0x12, 0xF3, 0x75, 0xDA, 0xD7, 0xD7, 0xE5, 0x02,
|
|
||||||
0x9E, 0xED, 0xDA, 0xDB, 0x4C, 0x47, 0xCE, 0x91, 0x06, 0x06, 0x6D, 0x55, 0x8B, 0x19, 0xC9, 0xEF,
|
|
||||||
0x8C, 0x80, 0x1A, 0x0E, 0xEE, 0x4B, 0xAB, 0xF2, 0x08, 0x5C, 0xE9, 0x37, 0x26, 0x5E, 0x9A, 0x90,
|
|
||||||
0x00, 0xF3, 0x0D, 0xB2, 0xA6, 0xA3, 0xF7, 0x26, 0x17, 0x48, 0x88, 0xC9, 0x0E, 0x2C, 0xC9, 0x02,
|
|
||||||
0xE7, 0x18, 0x05, 0x4B, 0xF3, 0x39, 0xE1, 0x20, 0x02, 0x0D, 0x40, 0xC7, 0xCA, 0xB9, 0x48, 0x30,
|
|
||||||
0x57, 0x67, 0xCC, 0x06, 0xBF, 0xAC, 0x81, 0x08, 0x24, 0x7A, 0xD4, 0x8B, 0x19, 0x8E, 0xAC, 0xB4,
|
|
||||||
0x5A, 0x0F, 0x73, 0x13, 0xAC, 0x9E, 0xDA, 0xB6, 0xB8, 0x96, 0x5B, 0x60, 0x88, 0xE1, 0x81, 0x3F,
|
|
||||||
0x07, 0x86, 0x37, 0x2D, 0x79, 0x14, 0x52, 0xEA, 0x73, 0xDF, 0x3D, 0x09, 0xC8, 0x25, 0x48, 0xD8,
|
|
||||||
0x75, 0x60, 0x9A, 0x08, 0x27, 0x4A, 0x2C, 0xB9, 0xA8, 0x8B, 0x8A, 0x73, 0x62, 0x37, 0x16, 0x02,
|
|
||||||
0xBD, 0xC1, 0x0E, 0x56, 0x54, 0x3E, 0x14, 0x5F, 0x8C, 0x8F, 0x6E, 0x75, 0x1C, 0x07, 0x39, 0x7B,
|
|
||||||
0x4B, 0xDB, 0xD3, 0x4B, 0x1E, 0xC8, 0x7E, 0xFE, 0x3E, 0x72, 0x16, 0x83, 0x7D, 0xEE, 0xF5, 0xCA,
|
|
||||||
0xC5, 0x18, 0xF9, 0xD8, 0x68, 0xAB, 0x38, 0x85, 0xA8, 0xF0, 0xA1, 0x73, 0x9F, 0x5D, 0x19, 0x0B,
|
|
||||||
];
|
|
||||||
|
|
||||||
const VARIANTS: [u8; 32] = [
|
|
||||||
0xB7, 0x74, 0x85, 0xD0, 0xCC, 0xDB, 0xCA, 0x73, 0x03, 0xFE, 0x31, 0x03, 0x52, 0xE0, 0xB7, 0x42,
|
|
||||||
0x63, 0x16, 0xF2, 0x2A, 0x79, 0x52, 0xFF, 0x1B, 0x7A, 0x11, 0xCA, 0x1A, 0x9B, 0x40, 0xAD, 0x01,
|
|
||||||
];
|
|
||||||
|
|
||||||
const SECRET: [u8; 5] = [0x55, 0xD6, 0xC4, 0xC5, 0x28];
|
|
||||||
|
|
||||||
const PERM_CHALLENGE: [[usize; 10]; 3] = [
|
|
||||||
[1, 3, 0, 7, 5, 2, 9, 6, 4, 8],
|
|
||||||
[6, 1, 9, 3, 8, 5, 7, 4, 0, 2],
|
|
||||||
[4, 0, 3, 5, 7, 2, 8, 6, 1, 9],
|
|
||||||
];
|
|
||||||
|
|
||||||
const PERM_VARIANT: [[u8; 32]; 2] = [
|
|
||||||
[
|
|
||||||
0x0A, 0x08, 0x0E, 0x0C, 0x0B, 0x09, 0x0F, 0x0D, 0x1A, 0x18, 0x1E, 0x1C, 0x1B, 0x19, 0x1F,
|
|
||||||
0x1D, 0x02, 0x00, 0x06, 0x04, 0x03, 0x01, 0x07, 0x05, 0x12, 0x10, 0x16, 0x14, 0x13, 0x11,
|
|
||||||
0x17, 0x15,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
0x12, 0x1A, 0x16, 0x1E, 0x02, 0x0A, 0x06, 0x0E, 0x10, 0x18, 0x14, 0x1C, 0x00, 0x08, 0x04,
|
|
||||||
0x0C, 0x13, 0x1B, 0x17, 0x1F, 0x03, 0x0B, 0x07, 0x0F, 0x11, 0x19, 0x15, 0x1D, 0x01, 0x09,
|
|
||||||
0x05, 0x0D,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// CSS bus-auth **unlock** primitive.
|
|
||||||
///
|
|
||||||
/// Runs the bus-auth challenge-response (which sets the drive's ASF=1 and is
|
|
||||||
/// what actually unlocks scrambled-sector reads), then a best-effort,
|
|
||||||
/// non-fatal disc-key REPORT KEY. The title-key REPORT KEY is NOT issued: it
|
|
||||||
/// is unnecessary (the descramble key is recovered keylessly by the Stevenson
|
|
||||||
/// attack in [`super::crack_key`]) and its hard failure on some USB bridges
|
|
||||||
/// used to abort the whole unlock (the 7014 bug). The bytes are discarded.
|
|
||||||
pub fn unlock_css_reads(drive: &mut Drive, lba: u32) -> Result<()> {
|
|
||||||
let t0 = std::time::Instant::now();
|
|
||||||
tracing::info!(target: "freemkv::css", phase = "unlock_css_reads", lba, "begin");
|
|
||||||
let r = unlock_css_reads_inner(drive, lba);
|
|
||||||
tracing::info!(
|
|
||||||
target: "freemkv::css",
|
|
||||||
phase = "unlock_css_reads",
|
|
||||||
lba,
|
|
||||||
ok = r.is_ok(),
|
|
||||||
elapsed_ms = t0.elapsed().as_millis() as u64,
|
|
||||||
"end"
|
|
||||||
);
|
|
||||||
r
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unlock_css_reads_inner(drive: &mut Drive, _lba: u32) -> Result<()> {
|
|
||||||
tracing::debug!(target: "freemkv::css", "css unlock: begin");
|
|
||||||
// The bus-auth challenge-response sets the drive's Authentication Success
|
|
||||||
// Flag (ASF=1), which is what opens scrambled-sector reads. This is the
|
|
||||||
// ONLY step required to unlock reads; a failure here is fatal — we
|
|
||||||
// genuinely cannot read scrambled sectors.
|
|
||||||
let (agid, _bus_key) = bus_auth(drive).inspect_err(|e| {
|
|
||||||
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus_auth failed");
|
|
||||||
})?;
|
|
||||||
tracing::debug!(target: "freemkv::css", agid, "css unlock: bus_auth ok");
|
|
||||||
// Disc-key REPORT KEY: issued BEST-EFFORT for any firmware that ties part
|
|
||||||
// of its read-unlock to it. The bytes are unused (the descramble key is
|
|
||||||
// recovered keylessly) and a failure is NON-FATAL — the gate is already
|
|
||||||
// open from bus-auth. This replaces the title-key REPORT KEY, whose hard
|
|
||||||
// failure used to abort the whole unlock (the 7014 bug on USB bridges).
|
|
||||||
if let Err(e) = read_disc_key(drive, agid) {
|
|
||||||
tracing::debug!(target: "freemkv::css", error_code = e.code(), "css unlock: disc-key REPORT KEY skipped (non-fatal)");
|
|
||||||
}
|
|
||||||
tracing::debug!(target: "freemkv::css", "css unlock: ok");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 1: Bus Authentication ────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn bus_auth(drive: &mut Drive) -> Result<(u8, [u8; 5])> {
|
|
||||||
let scsi = drive.scsi_mut();
|
|
||||||
|
|
||||||
// Invalidate all AGIDs via REPORT KEY format 0x3F
|
|
||||||
for agid in 0..4u8 {
|
|
||||||
let mut cdb = [0u8; 12];
|
|
||||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
|
||||||
// alloc_len = 0 (no data transfer)
|
|
||||||
cdb[10] = (agid << 6) | 0x3F;
|
|
||||||
let mut buf = [0u8; 8];
|
|
||||||
let _ = scsi.execute(
|
|
||||||
&cdb,
|
|
||||||
crate::scsi::DataDirection::FromDevice,
|
|
||||||
&mut buf,
|
|
||||||
5_000,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allocate AGID
|
|
||||||
let mut buf = [0u8; 8];
|
|
||||||
scsi.execute(
|
|
||||||
&report_key_cdb(0, 0x00, 8),
|
|
||||||
crate::scsi::DataDirection::FromDevice,
|
|
||||||
&mut buf,
|
|
||||||
5_000,
|
|
||||||
)
|
|
||||||
.map_err(|_| Error::CssAuthFailed)?;
|
|
||||||
let agid = (buf[7] >> 6) & 0x03;
|
|
||||||
|
|
||||||
// Host sends challenge. The spec wants a fresh per-session random nonce,
|
|
||||||
// not a fixed constant — a predictable challenge weakens the bus-auth
|
|
||||||
// handshake.
|
|
||||||
let mut host_challenge = [0u8; 10];
|
|
||||||
{
|
|
||||||
use rand::RngCore;
|
|
||||||
rand::thread_rng().fill_bytes(&mut host_challenge);
|
|
||||||
}
|
|
||||||
let mut hc_buf = [0u8; 16];
|
|
||||||
hc_buf[0] = 0x00;
|
|
||||||
hc_buf[1] = 0x0E;
|
|
||||||
for i in 0..10 {
|
|
||||||
hc_buf[4 + i] = host_challenge[9 - i];
|
|
||||||
}
|
|
||||||
scsi.execute(
|
|
||||||
&send_key_cdb(agid, 0x01, 16),
|
|
||||||
crate::scsi::DataDirection::ToDevice,
|
|
||||||
&mut hc_buf,
|
|
||||||
5_000,
|
|
||||||
)
|
|
||||||
.map_err(|_| Error::CssAuthFailed)?;
|
|
||||||
|
|
||||||
// Get Key1 from drive
|
|
||||||
let mut dk_buf = [0u8; 12];
|
|
||||||
scsi.execute(
|
|
||||||
&report_key_cdb(agid, 0x02, 12),
|
|
||||||
crate::scsi::DataDirection::FromDevice,
|
|
||||||
&mut dk_buf,
|
|
||||||
5_000,
|
|
||||||
)
|
|
||||||
.map_err(|_| Error::CssAuthFailed)?;
|
|
||||||
let mut key1 = [0u8; 5];
|
|
||||||
for i in 0..5 {
|
|
||||||
key1[i] = dk_buf[4 + (4 - i)];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brute-force variant (0-31)
|
|
||||||
let mut variant: Option<u8> = None;
|
|
||||||
for v in 0..32u8 {
|
|
||||||
if crypt_key(0, v, &host_challenge) == key1 {
|
|
||||||
variant = Some(v);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let variant = variant.ok_or(Error::CssAuthFailed)?;
|
|
||||||
|
|
||||||
// Get drive challenge
|
|
||||||
let mut dc_buf = [0u8; 16];
|
|
||||||
scsi.execute(
|
|
||||||
&report_key_cdb(agid, 0x01, 16),
|
|
||||||
crate::scsi::DataDirection::FromDevice,
|
|
||||||
&mut dc_buf,
|
|
||||||
5_000,
|
|
||||||
)
|
|
||||||
.map_err(|_| Error::CssAuthFailed)?;
|
|
||||||
let mut drive_challenge = [0u8; 10];
|
|
||||||
for i in 0..10 {
|
|
||||||
drive_challenge[i] = dc_buf[4 + (9 - i)];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute Key2 and send it
|
|
||||||
let key2 = crypt_key(1, variant, &drive_challenge);
|
|
||||||
let mut hk_buf = [0u8; 12];
|
|
||||||
hk_buf[0] = 0x00;
|
|
||||||
hk_buf[1] = 0x0A;
|
|
||||||
for i in 0..5 {
|
|
||||||
hk_buf[4 + i] = key2[4 - i];
|
|
||||||
}
|
|
||||||
scsi.execute(
|
|
||||||
&send_key_cdb(agid, 0x03, 12),
|
|
||||||
crate::scsi::DataDirection::ToDevice,
|
|
||||||
&mut hk_buf,
|
|
||||||
5_000,
|
|
||||||
)
|
|
||||||
.map_err(|_| Error::CssAuthFailed)?;
|
|
||||||
|
|
||||||
// Bus key = CryptKey(2, variant, key1 || key2)
|
|
||||||
let mut combined = [0u8; 10];
|
|
||||||
combined[..5].copy_from_slice(&key1);
|
|
||||||
combined[5..].copy_from_slice(&key2);
|
|
||||||
let bus_key = crypt_key(2, variant, &combined);
|
|
||||||
|
|
||||||
Ok((agid, bus_key))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 2: Disc Key ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Issue the disc-key REPORT KEY (READ DVD STRUCTURE, format 0x02) purely
|
|
||||||
/// for the bus-auth unlock side effect. The returned block contents are
|
|
||||||
/// not used — the descramble title key is recovered keylessly elsewhere.
|
|
||||||
fn read_disc_key(drive: &mut Drive, agid: u8) -> Result<()> {
|
|
||||||
let scsi = drive.scsi_mut();
|
|
||||||
|
|
||||||
// READ DVD STRUCTURE, format 0x02 (disc key), 2048+4 bytes
|
|
||||||
let alloc_len: u16 = 2048 + 4;
|
|
||||||
let mut cdb = [0u8; 12];
|
|
||||||
cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE;
|
|
||||||
// bytes 2-5: address = 0
|
|
||||||
cdb[6] = 0; // layer
|
|
||||||
cdb[7] = 0x02; // format = disc key
|
|
||||||
cdb[8] = (alloc_len >> 8) as u8;
|
|
||||||
cdb[9] = alloc_len as u8;
|
|
||||||
cdb[10] = agid << 6;
|
|
||||||
|
|
||||||
let mut buf = vec![0u8; alloc_len as usize];
|
|
||||||
let dvd_result = scsi.execute(
|
|
||||||
&cdb,
|
|
||||||
crate::scsi::DataDirection::FromDevice,
|
|
||||||
&mut buf,
|
|
||||||
5_000,
|
|
||||||
);
|
|
||||||
dvd_result.map_err(|_| Error::CssAuthFailed)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CSSCryptKey ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
|
||||||
// key_type indexes PERM_CHALLENGE ([_;3]); variant indexes
|
|
||||||
// VARIANTS/PERM_VARIANT ([_;32]). All internal callers pass key_type in
|
|
||||||
// 0..3 and variant in 0..32; the asserts document the contract for the
|
|
||||||
// pub(crate) test entry point test_crypt_key and turn a would-be
|
|
||||||
// out-of-bounds panic into an explicit precondition violation.
|
|
||||||
debug_assert!(key_type < 3, "crypt_key: key_type out of range");
|
|
||||||
debug_assert!((variant as usize) < 32, "crypt_key: variant out of range");
|
|
||||||
let perm = &PERM_CHALLENGE[key_type];
|
|
||||||
let mut scratch = [0u8; 10];
|
|
||||||
for i in 0..10 {
|
|
||||||
scratch[i] = challenge[perm[i]];
|
|
||||||
}
|
|
||||||
|
|
||||||
let css_variant = match key_type {
|
|
||||||
0 => variant as usize,
|
|
||||||
1 => PERM_VARIANT[0][variant as usize] as usize,
|
|
||||||
_ => PERM_VARIANT[1][variant as usize] as usize,
|
|
||||||
};
|
|
||||||
|
|
||||||
let cse = VARIANTS[css_variant] ^ CRYPT_TAB2[css_variant];
|
|
||||||
|
|
||||||
let mut tmp1 = [0u8; 5];
|
|
||||||
for i in 0..5 {
|
|
||||||
tmp1[i] = scratch[5 + i] ^ SECRET[i] ^ CRYPT_TAB2[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut lfsr0: u32 = ((tmp1[0] as u32) << 17)
|
|
||||||
| ((tmp1[1] as u32) << 9)
|
|
||||||
| (((tmp1[2] as u32) & !7) << 1)
|
|
||||||
| 8
|
|
||||||
| (tmp1[2] as u32 & 7);
|
|
||||||
|
|
||||||
let mut lfsr1: u32 = ((tmp1[3] as u32) << 9) | 0x100 | (tmp1[4] as u32);
|
|
||||||
|
|
||||||
let mut bits = [0u8; 30];
|
|
||||||
let mut carry: u32 = 0;
|
|
||||||
for idx in (0..30).rev() {
|
|
||||||
let mut val: u8 = 0;
|
|
||||||
for bit in 0..8u8 {
|
|
||||||
let lfsr0_out = ((lfsr0 >> 24) ^ (lfsr0 >> 21) ^ (lfsr0 >> 20) ^ (lfsr0 >> 12)) & 1;
|
|
||||||
lfsr0 = ((lfsr0 << 1) | lfsr0_out) & 0x1FFFFFF;
|
|
||||||
|
|
||||||
let lfsr1_out = ((lfsr1 >> 16) ^ (lfsr1 >> 2)) & 1;
|
|
||||||
lfsr1 = ((lfsr1 << 1) | lfsr1_out) & 0x1FFFF;
|
|
||||||
|
|
||||||
let combined = ((!lfsr1_out) & 1) + carry + ((!lfsr0_out) & 1);
|
|
||||||
carry = (combined >> 1) & 1;
|
|
||||||
val |= ((combined & 1) as u8) << bit;
|
|
||||||
}
|
|
||||||
bits[idx] = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tmp1 = [scratch[0], scratch[1], scratch[2], scratch[3], scratch[4]];
|
|
||||||
let mut tmp2 = [0u8; 5];
|
|
||||||
|
|
||||||
// Round 1: bits[25..29] ^ scratch -> tmp1 (term from original scratch)
|
|
||||||
{
|
|
||||||
let mut term: u8 = 0;
|
|
||||||
for i in (0..5usize).rev() {
|
|
||||||
let idx = (bits[25 + i] ^ tmp1[i]) as usize;
|
|
||||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
|
||||||
tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
|
|
||||||
term = scratch[i]; // original challenge, NOT modified tmp1
|
|
||||||
}
|
|
||||||
tmp1[4] ^= tmp1[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Round 2
|
|
||||||
{
|
|
||||||
let mut term: u8 = 0;
|
|
||||||
for i in (0..5usize).rev() {
|
|
||||||
let idx = (bits[20 + i] ^ tmp1[i]) as usize;
|
|
||||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
|
||||||
tmp2[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
|
|
||||||
term = tmp1[i];
|
|
||||||
}
|
|
||||||
tmp2[4] ^= tmp2[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Round 3 (uses CRYPT_TAB0)
|
|
||||||
{
|
|
||||||
let mut term: u8 = 0;
|
|
||||||
for i in (0..5usize).rev() {
|
|
||||||
let idx = (bits[15 + i] ^ tmp2[i]) as usize;
|
|
||||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
|
||||||
let idx3 = (CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term) as usize;
|
|
||||||
tmp1[i] = CRYPT_TAB0[idx3] ^ CRYPT_TAB2[idx3];
|
|
||||||
term = tmp2[i];
|
|
||||||
}
|
|
||||||
tmp1[4] ^= tmp1[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Round 4 (uses CRYPT_TAB0)
|
|
||||||
{
|
|
||||||
let mut term: u8 = 0;
|
|
||||||
for i in (0..5usize).rev() {
|
|
||||||
let idx = (bits[10 + i] ^ tmp1[i]) as usize;
|
|
||||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
|
||||||
let idx3 = (CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term) as usize;
|
|
||||||
tmp2[i] = CRYPT_TAB0[idx3] ^ CRYPT_TAB2[idx3];
|
|
||||||
term = tmp1[i];
|
|
||||||
}
|
|
||||||
tmp2[4] ^= tmp2[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Round 5
|
|
||||||
{
|
|
||||||
let mut term: u8 = 0;
|
|
||||||
for i in (0..5usize).rev() {
|
|
||||||
let idx = (bits[5 + i] ^ tmp2[i]) as usize;
|
|
||||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
|
||||||
tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
|
|
||||||
term = tmp2[i];
|
|
||||||
}
|
|
||||||
tmp1[4] ^= tmp1[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Round 6
|
|
||||||
let mut key = [0u8; 5];
|
|
||||||
{
|
|
||||||
let mut term: u8 = 0;
|
|
||||||
for i in (0..5usize).rev() {
|
|
||||||
let idx = (bits[i] ^ tmp1[i]) as usize;
|
|
||||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
|
||||||
key[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
|
|
||||||
term = tmp1[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
key
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SCSI CDB builders ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn report_key_cdb(agid: u8, format: u8, alloc_len: u16) -> [u8; 12] {
|
|
||||||
let mut cdb = [0u8; 12];
|
|
||||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
|
||||||
cdb[8] = (alloc_len >> 8) as u8;
|
|
||||||
cdb[9] = alloc_len as u8;
|
|
||||||
cdb[10] = (agid << 6) | (format & 0x3F);
|
|
||||||
cdb
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_key_cdb(agid: u8, format: u8, param_len: u16) -> [u8; 12] {
|
|
||||||
let mut cdb = [0u8; 12];
|
|
||||||
cdb[0] = crate::scsi::SCSI_SEND_KEY;
|
|
||||||
cdb[8] = (param_len >> 8) as u8;
|
|
||||||
cdb[9] = param_len as u8;
|
|
||||||
cdb[10] = (agid << 6) | (format & 0x3F);
|
|
||||||
cdb
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// SECURITY REGRESSION GUARD: no instrumentation in libfreemkv may emit
|
|
||||||
/// raw key material. Scan every source file for a `tracing` field that
|
|
||||||
/// binds a forbidden key name to a value-producing expression (`= expr`
|
|
||||||
/// or `%expr` / `?expr`). The only allowed forms are a string literal
|
|
||||||
/// (e.g. `disc_key = "<redacted>"`) or a `_fp` fingerprint field.
|
|
||||||
///
|
|
||||||
/// This is a source-scan test (not a runtime capture) so it stays cheap
|
|
||||||
/// and catches re-introductions at compile/CI time.
|
|
||||||
#[test]
|
|
||||||
fn no_key_bytes_in_instrumentation() {
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
// Forbidden field names whose VALUES must never be logged.
|
|
||||||
const FORBIDDEN: &[&str] = &[
|
|
||||||
"title_key",
|
|
||||||
"disc_key",
|
|
||||||
"unit_key",
|
|
||||||
"vuk",
|
|
||||||
"player_key",
|
|
||||||
"bus_key",
|
|
||||||
];
|
|
||||||
|
|
||||||
fn scan_dir(dir: &Path, forbidden: &[&str], violations: &mut Vec<String>) {
|
|
||||||
let entries = match std::fs::read_dir(dir) {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
scan_dir(&path, forbidden, violations);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let src = match std::fs::read_to_string(&path) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
for (lineno, line) in src.lines().enumerate() {
|
|
||||||
let trimmed = line.trim_start();
|
|
||||||
// Only inspect tracing instrumentation lines.
|
|
||||||
if !(trimmed.contains("tracing::")
|
|
||||||
|| trimmed.starts_with("debug!")
|
|
||||||
|| trimmed.starts_with("info!")
|
|
||||||
|| trimmed.starts_with("warn!")
|
|
||||||
|| trimmed.starts_with("trace!")
|
|
||||||
|| trimmed.starts_with("error!"))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// This guard test itself contains the forbidden names.
|
|
||||||
if path.file_name().and_then(|n| n.to_str()) == Some("auth.rs")
|
|
||||||
&& line.contains("FORBIDDEN")
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for &name in forbidden {
|
|
||||||
// A fingerprint field (`<name>_fp = ...`) is allowed.
|
|
||||||
// Match `<name>` followed by optional fingerprint
|
|
||||||
// suffix then `=` and a value that is NOT a string
|
|
||||||
// literal redaction marker.
|
|
||||||
if let Some(idx) = line.find(name) {
|
|
||||||
let after = &line[idx + name.len()..];
|
|
||||||
let after = after.trim_start();
|
|
||||||
// `<name>_fp` / `<name>_id` etc. are safe.
|
|
||||||
if after.starts_with('_') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Must be a field binding `name = ...`.
|
|
||||||
let Some(rest) = after.strip_prefix('=') else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let rest = rest.trim_start();
|
|
||||||
// Redaction string literal is the only allowed value.
|
|
||||||
if rest.starts_with('"') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Anything else (`%expr`, `?expr`, bare expr) leaks bytes.
|
|
||||||
violations.push(format!(
|
|
||||||
"{}:{}: forbidden key field `{}` logged with a value: {}",
|
|
||||||
path.display(),
|
|
||||||
lineno + 1,
|
|
||||||
name,
|
|
||||||
line.trim()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan this crate's `src` plus the sibling workspace crates so the
|
|
||||||
// key-material logging guard covers every crate that can reach the
|
|
||||||
// CSS/AACS internals, not just libfreemkv. Missing sibling dirs (e.g.
|
|
||||||
// when building the crate standalone) are simply skipped.
|
|
||||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
||||||
let workspace = manifest.parent().unwrap_or(manifest);
|
|
||||||
let mut violations = Vec::new();
|
|
||||||
scan_dir(&manifest.join("src"), FORBIDDEN, &mut violations);
|
|
||||||
for sibling in ["autorip", "freemkv", "freemkv-keysources"] {
|
|
||||||
let dir = workspace.join(sibling).join("src");
|
|
||||||
if dir.is_dir() {
|
|
||||||
scan_dir(&dir, FORBIDDEN, &mut violations);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
violations.is_empty(),
|
|
||||||
"key material logged in instrumentation:\n{}",
|
|
||||||
violations.join("\n")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn crypt_key_is_deterministic() {
|
|
||||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
||||||
for v in 0..32u8 {
|
|
||||||
let r1 = crypt_key(0, v, &challenge);
|
|
||||||
let r2 = crypt_key(0, v, &challenge);
|
|
||||||
assert_eq!(r1, r2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn crypt_key_varies_by_variant() {
|
|
||||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
||||||
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn crypt_key_varies_by_type() {
|
|
||||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
||||||
assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn crypt_key_nonzero() {
|
|
||||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
||||||
for v in 0..32u8 {
|
|
||||||
assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CSS constant-table integrity ───────────────────────────────────────
|
|
||||||
|
|
||||||
/// Each PERM_CHALLENGE row is a permutation of indices 0..10 (it reorders
|
|
||||||
/// the 10 challenge bytes). A non-permutation would drop/duplicate
|
|
||||||
/// challenge bytes, weakening or corrupting the bus key derivation.
|
|
||||||
///
|
|
||||||
/// Grounding: crypt_key does `scratch[i] = challenge[perm[i]]` for i in
|
|
||||||
/// 0..10 — perm must be a bijection on 0..10 to use every challenge byte
|
|
||||||
/// exactly once.
|
|
||||||
/// Mutation: change PERM_CHALLENGE[0] entry `9` to `8` (duplicate) -> the
|
|
||||||
/// "covers 0..10" assert fires.
|
|
||||||
#[test]
|
|
||||||
fn perm_challenge_rows_are_permutations() {
|
|
||||||
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
|
|
||||||
let mut seen = [false; 10];
|
|
||||||
for &idx in perm.iter() {
|
|
||||||
assert!(idx < 10, "PERM_CHALLENGE[{row}] index {idx} out of range");
|
|
||||||
assert!(!seen[idx], "PERM_CHALLENGE[{row}] duplicates index {idx}");
|
|
||||||
seen[idx] = true;
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
seen.iter().all(|&b| b),
|
|
||||||
"PERM_CHALLENGE[{row}] misses an index"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Each PERM_VARIANT row maps the 32 variants to 32 distinct 5-bit values
|
|
||||||
/// (it is a permutation of 0..32). key_type 1 uses PERM_VARIANT[0],
|
|
||||||
/// key_type 2 uses PERM_VARIANT[1] to pick the css_variant; a collision
|
|
||||||
/// would make two variants indistinguishable.
|
|
||||||
///
|
|
||||||
/// Grounding: `css_variant = PERM_VARIANT[k][variant]` then indexes
|
|
||||||
/// VARIANTS[css_variant] (0..32).
|
|
||||||
/// Mutation: set PERM_VARIANT[0][1] = PERM_VARIANT[0][0] -> duplicate
|
|
||||||
/// assert fires; also any value >= 32 would later index VARIANTS OOB.
|
|
||||||
#[test]
|
|
||||||
fn perm_variant_rows_are_permutations_of_0_31() {
|
|
||||||
for (row, perm) in PERM_VARIANT.iter().enumerate() {
|
|
||||||
let mut seen = [false; 32];
|
|
||||||
for &v in perm.iter() {
|
|
||||||
let v = v as usize;
|
|
||||||
assert!(v < 32, "PERM_VARIANT[{row}] value {v} out of 0..32");
|
|
||||||
assert!(!seen[v], "PERM_VARIANT[{row}] duplicates {v}");
|
|
||||||
seen[v] = true;
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
seen.iter().all(|&b| b),
|
|
||||||
"PERM_VARIANT[{row}] misses a value"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── crypt_key behaviour ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// crypt_key result depends on every challenge byte. The challenge is
|
|
||||||
/// permuted into `scratch` and folded through the LFSR seeding and the 6
|
|
||||||
/// XOR rounds. Flipping any single challenge byte must change the output.
|
|
||||||
///
|
|
||||||
/// Grounding: scratch[i]=challenge[perm[i]] for all 10 i, and scratch
|
|
||||||
/// seeds both LFSRs (bytes 5..10 via tmp1) and the round terms (bytes
|
|
||||||
/// 0..5).
|
|
||||||
/// Mutation: in `scratch[i] = challenge[perm[i]]` replace with
|
|
||||||
/// `challenge[i]` for a perm that drops a byte — or hardcode one scratch
|
|
||||||
/// entry — and some challenge byte stops mattering; this fails.
|
|
||||||
#[test]
|
|
||||||
fn crypt_key_depends_on_every_challenge_byte() {
|
|
||||||
let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
||||||
let base_out = crypt_key(0, 5, &base);
|
|
||||||
for i in 0..10 {
|
|
||||||
let mut c = base;
|
|
||||||
c[i] ^= 0x55;
|
|
||||||
assert_ne!(
|
|
||||||
crypt_key(0, 5, &c),
|
|
||||||
base_out,
|
|
||||||
"flipping challenge byte {i} did not change the bus-key derivation"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// crypt_key(0, v, ..) must produce a DISTINCT result for each of the 32
|
|
||||||
/// variants on a fixed challenge. bus_auth brute-forces the variant by
|
|
||||||
/// matching crypt_key(0, v, host_challenge) == key1; if two variants
|
|
||||||
/// collided, the wrong variant could be selected and the whole auth
|
|
||||||
/// derail.
|
|
||||||
///
|
|
||||||
/// Grounding: variant selects css_variant -> VARIANTS[css_variant] -> cse,
|
|
||||||
/// which feeds every round; distinct variants give distinct cse-driven
|
|
||||||
/// keys in practice.
|
|
||||||
/// Mutation: make `cse` ignore the variant (e.g. `let cse = 0`) -> all 32
|
|
||||||
/// outputs collapse to one value; the distinctness assert fires.
|
|
||||||
#[test]
|
|
||||||
fn crypt_key_type0_distinct_per_variant() {
|
|
||||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
||||||
let mut outs = Vec::new();
|
|
||||||
for v in 0..32u8 {
|
|
||||||
let k = crypt_key(0, v, &challenge);
|
|
||||||
assert!(
|
|
||||||
!outs.contains(&k),
|
|
||||||
"variant {v} collides with an earlier variant"
|
|
||||||
);
|
|
||||||
outs.push(k);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// crypt_key enforces its documented precondition `key_type < 3` via
|
|
||||||
/// debug_assert (active in test builds). A key_type of 3 would index
|
|
||||||
/// PERM_CHALLENGE (len 3) out of bounds; the assert turns that into an
|
|
||||||
/// explicit precondition panic.
|
|
||||||
///
|
|
||||||
/// Grounding: `debug_assert!(key_type < 3, ...)`; PERM_CHALLENGE has 3
|
|
||||||
/// rows (indices 0,1,2).
|
|
||||||
/// Mutation: delete the debug_assert AND the match-arm guard — but the
|
|
||||||
/// match `_ =>` arm would then index PERM_CHALLENGE[3] OOB and panic
|
|
||||||
/// differently; with the assert in place this test pins the contract.
|
|
||||||
#[test]
|
|
||||||
#[should_panic]
|
|
||||||
fn crypt_key_rejects_out_of_range_key_type() {
|
|
||||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
||||||
let _ = crypt_key(3, 0, &challenge);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// crypt_key enforces `variant < 32` via debug_assert. A variant of 32
|
|
||||||
/// would index VARIANTS / PERM_VARIANT (len 32) out of bounds.
|
|
||||||
///
|
|
||||||
/// Grounding: `debug_assert!((variant as usize) < 32, ...)`.
|
|
||||||
/// Mutation: removing the assert makes this index VARIANTS[32] (still a
|
|
||||||
/// panic, but unguarded); the assert documents/enforces the contract.
|
|
||||||
#[test]
|
|
||||||
#[should_panic]
|
|
||||||
fn crypt_key_rejects_out_of_range_variant() {
|
|
||||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
||||||
let _ = crypt_key(0, 32, &challenge);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SCSI CDB builders (MMC REPORT KEY / SEND KEY layout) ───────────────
|
|
||||||
|
|
||||||
/// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB:
|
|
||||||
/// byte 0 = operation code 0xA4
|
|
||||||
/// bytes 8-9 = allocation length, big-endian
|
|
||||||
/// byte 10 = (AGID << 6) | (key_format & 0x3F)
|
|
||||||
/// All other bytes are zero.
|
|
||||||
///
|
|
||||||
/// Grounding: MMC REPORT KEY CDB; the AGID is the top 2 bits of byte 10,
|
|
||||||
/// key format the low 6 bits.
|
|
||||||
/// Mutation: change `(alloc_len >> 8)` to `alloc_len` for byte 8 (lose the
|
|
||||||
/// big-endian split) -> byte 8/9 assert fails. Change `agid << 6` to
|
|
||||||
/// `agid << 5` -> the AGID-position assert fails.
|
|
||||||
#[test]
|
|
||||||
fn report_key_cdb_matches_mmc_layout() {
|
|
||||||
let cdb = report_key_cdb(0b10, 0x04, 0x010C); // AGID=2, format=0x04, len=268
|
|
||||||
assert_eq!(cdb[0], 0xA4, "REPORT KEY opcode");
|
|
||||||
assert_eq!(cdb[8], 0x01, "alloc_len high byte (big-endian)");
|
|
||||||
assert_eq!(cdb[9], 0x0C, "alloc_len low byte");
|
|
||||||
assert_eq!(
|
|
||||||
cdb[10],
|
|
||||||
(0b10 << 6) | 0x04,
|
|
||||||
"AGID in bits 6-7, format in bits 0-5"
|
|
||||||
);
|
|
||||||
// Every other byte must be zero.
|
|
||||||
for (i, &b) in cdb.iter().enumerate() {
|
|
||||||
if ![0, 8, 9, 10].contains(&i) {
|
|
||||||
assert_eq!(b, 0, "CDB byte {i} must be zero");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert_eq!(cdb.len(), 12, "REPORT KEY is a 12-byte CDB");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The key format field is masked to 6 bits: a format with high bits set
|
|
||||||
/// must not corrupt the AGID. report_key_cdb(0, 0xFF, _) -> byte 10 low 6
|
|
||||||
/// bits = 0x3F, AGID = 0.
|
|
||||||
///
|
|
||||||
/// Grounding: `(agid << 6) | (format & 0x3F)`.
|
|
||||||
/// Mutation: drop the `& 0x3F` mask -> 0xFF would overwrite the AGID bits;
|
|
||||||
/// byte 10 would be 0xFF not 0x3F, this fails.
|
|
||||||
#[test]
|
|
||||||
fn report_key_cdb_masks_format_to_6_bits() {
|
|
||||||
let cdb = report_key_cdb(0, 0xFF, 8);
|
|
||||||
assert_eq!(cdb[10], 0x3F, "format masked to 6 bits, AGID stays 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the
|
|
||||||
/// parameter-list length at bytes 8-9 (big-endian) and AGID/format at byte
|
|
||||||
/// 10.
|
|
||||||
///
|
|
||||||
/// Grounding: MMC SEND KEY CDB layout.
|
|
||||||
/// Mutation: change opcode to SCSI_REPORT_KEY -> opcode assert fails;
|
|
||||||
/// swap bytes 8/9 -> length assert fails.
|
|
||||||
#[test]
|
|
||||||
fn send_key_cdb_matches_mmc_layout() {
|
|
||||||
let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12
|
|
||||||
assert_eq!(cdb[0], 0xA3, "SEND KEY opcode");
|
|
||||||
assert_eq!(cdb[8], 0x00, "param_len high byte");
|
|
||||||
assert_eq!(cdb[9], 0x0C, "param_len low byte");
|
|
||||||
assert_eq!(
|
|
||||||
cdb[10],
|
|
||||||
(0b11 << 6) | 0x03,
|
|
||||||
"AGID bits 6-7, format bits 0-5"
|
|
||||||
);
|
|
||||||
assert_eq!(cdb.len(), 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Allocation length larger than 255 must split across bytes 8 (high) and
|
|
||||||
/// 9 (low) — a 16-bit big-endian field. report_key_cdb with alloc_len
|
|
||||||
/// 0x0804 (2052, the disc-key block size used in read_disc_key) -> byte 8
|
|
||||||
/// = 0x08, byte 9 = 0x04.
|
|
||||||
///
|
|
||||||
/// Grounding: read_disc_key uses `alloc_len = 2048 + 4 = 2052 = 0x0804`
|
|
||||||
/// and writes `cdb[8] = (alloc_len >> 8); cdb[9] = alloc_len`.
|
|
||||||
/// Mutation: write only byte 9 (`cdb[9] = alloc_len as u8`) without byte 8
|
|
||||||
/// -> the drive sees a 4-byte transfer, truncating the disc-key block;
|
|
||||||
/// this asserts the high byte is present.
|
|
||||||
#[test]
|
|
||||||
fn report_key_cdb_alloc_len_is_16bit_big_endian() {
|
|
||||||
let cdb = report_key_cdb(0, 0x00, 0x0804);
|
|
||||||
assert_eq!(cdb[8], 0x08, "high byte of 2052-byte transfer");
|
|
||||||
assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+163
-134
@@ -1,58 +1,46 @@
|
|||||||
//! 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:
|
//! The algorithm is the one recovered and published in Frank A. Stevenson's
|
||||||
//! - LFSR1: 17-bit state (9-bit lo + 8-bit hi register, seeded from
|
//! 1999 cryptanalysis ("Cryptanalysis of Contents Scrambling System") and
|
||||||
//! key[0..2]), driven by TAB2/TAB3
|
//! described in the open CSS literature. It is implemented here from that public
|
||||||
//! - LFSR0: 24-bit feedback register (seeded from key[2..5] XOR seed[2..5],
|
//! description; its constants (see [`super::tables`]) are the cipher's own
|
||||||
//! masked to 0xFFFFFF), driven by a feedback polynomial through TAB4
|
//! 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.
|
//! The cipher uses two table-driven linear-feedback circuits:
|
||||||
//! Content descrambling computes plain = TAB1[cipher] ^ keystream — a TAB1
|
//! - **LFSR1** — a 17-bit register (a 9-bit and an 8-bit half seeded from
|
||||||
//! substitution of each ciphertext byte followed by an XOR with the keystream
|
//! `key[0..2] XOR seed[0..2]`), stepped through `TAB2`/`TAB3`/`TAB5`.
|
||||||
//! (NOT a plain XOR; the cipher is not its own inverse).
|
//! - **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).
|
//! Each output byte is the sum-with-carry of the two register outputs. A body
|
||||||
//! Tables: CSS specification constants.
|
//! 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};
|
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||||
|
|
||||||
/// Descramble a CSS-encrypted DVD sector in place.
|
/// Descramble a CSS-encrypted DVD sector in place.
|
||||||
///
|
///
|
||||||
/// Exact port of libdvdcss `dvdcss_unscramble` (css.c). The two content
|
/// The two feedback registers are seeded **directly** from
|
||||||
/// LFSRs are seeded **directly** from `title_key XOR sector_seed` — there is
|
/// `title_key XOR sector_seed` (bytes `0x54..0x59`) — there is no title-key
|
||||||
/// no `decrypt_key` mangling on this path (that is the disc/title-key
|
/// mangling on the content path (that belongs to the disc/title-key hierarchy,
|
||||||
/// hierarchy, not the content cipher). Bytes 0x80..0x800 are recovered with
|
/// not the sector cipher). Only the body, bytes `0x80..0x800`, is transformed:
|
||||||
/// `*p = TAB1[*p] ^ (i_t5 & 0xff)`.
|
/// `body[i] = TAB1[body[i]] ^ (keystream & 0xff)`.
|
||||||
///
|
///
|
||||||
/// The scramble flag at byte 0x14 (bits 4-5) indicates encryption. Like
|
/// The scramble flag at byte `0x14` (bits 4-5) marks an encrypted sector. This
|
||||||
/// libdvdcss, the flag byte is NOT modified here — the caller treats a
|
/// routine CLEARS that flag after unscrambling, so a descrambled sector reads as
|
||||||
/// nonzero `sector[0x14] & 0x30` as "needs unscrambling" and the descramble
|
/// `sector[0x14] & 0x30 == 0`; callers and tests use that to tell it from
|
||||||
/// is its own inverse, so re-running it on plaintext would re-scramble.
|
/// ciphertext, and re-running descramble on an already-cleared sector is a no-op
|
||||||
/// (freemkv historically cleared the flag; we keep clearing it so callers
|
/// (the flag guard below skips it). Clearing does not affect the recovered body.
|
||||||
/// and the existing tests can distinguish a descrambled sector. This does
|
|
||||||
/// not affect the recovered body.)
|
|
||||||
///
|
///
|
||||||
/// No-op (returns without modifying `sector`) in two cases:
|
/// No-op (returns without modifying `sector`) in two cases:
|
||||||
/// - `sector.len() < 2048`: the encrypted region (0x80..0x800) is not
|
/// - `sector.len() < 2048`: the encrypted region (`0x80..0x800`) is not fully
|
||||||
/// fully present. Callers chunk by 2048, so a trailing partial chunk is
|
/// present. Callers chunk by 2048, so a trailing partial chunk is left
|
||||||
/// left untouched. The `debug_assert!` flags this misuse in debug/test
|
/// untouched. The `debug_assert!` flags this misuse in debug/test builds; a
|
||||||
/// builds; a DVD sector is always exactly 2048 bytes.
|
/// DVD sector is always exactly 2048 bytes.
|
||||||
/// - scramble flags are zero: the sector is not CSS-encrypted.
|
/// - 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]) {
|
pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
sector.len() >= 2048,
|
sector.len() >= 2048,
|
||||||
@@ -62,102 +50,103 @@ pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// libdvdcss: `if( !(p_sec[0x14] & 0x30) ) return;`
|
// Not scrambled (flag bits 4-5 clear) → nothing to do.
|
||||||
if sector[0x14] & 0x30 == 0 {
|
if sector[0x14] & 0x30 == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// LFSR1: seeded directly from (key ^ seed) — NO decrypt_key.
|
// LFSR1 halves, seeded from (key ^ seed) bytes 0-1. The 9-bit half carries a
|
||||||
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
// set bit 8 (`| 0x100`) as its running marker.
|
||||||
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) 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;
|
||||||
|
|
||||||
// LFSR0 (i_t3): 24-bit feedback register seeded from the remaining three
|
// LFSR0 (24-bit), seeded from the remaining three key/seed bytes, then
|
||||||
// key/seed bytes, then transformed `i_t3 = i_t3*2 + 8 - (i_t3 & 7)`.
|
// pre-conditioned `r0 = r0*2 + 8 - (r0 & 7)`.
|
||||||
let mut i_t3: u32 = (((title_key[2] as u32)
|
let mut r0: u32 = (((title_key[2] as u32)
|
||||||
| ((title_key[3] as u32) << 8)
|
| ((title_key[3] as u32) << 8)
|
||||||
| ((title_key[4] as u32) << 16))
|
| ((title_key[4] as u32) << 16))
|
||||||
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
||||||
& 0xFF_FFFF;
|
& 0xFF_FFFF;
|
||||||
let i_t4_seed = i_t3 & 7;
|
r0 = r0 * 2 + 8 - (r0 & 7);
|
||||||
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
|
|
||||||
|
|
||||||
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) {
|
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||||
// Advance LFSR1.
|
// Step LFSR1: its output byte `o1`.
|
||||||
let mut i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
|
||||||
i_t2 = i_t1 >> 1;
|
r1b = r1a >> 1;
|
||||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
r1a = ((r1a & 1) << 8) ^ o1;
|
||||||
i_t4 = TAB5[i_t4 as usize] as u32;
|
o1 = TAB5[o1 as usize] as u32;
|
||||||
|
|
||||||
// Advance LFSR0 (i_t3) and fold both outputs into i_t5.
|
// Step LFSR0: its output byte `o0`.
|
||||||
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xFF;
|
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
|
||||||
i_t3 = (i_t3 << 8) | i_t6;
|
r0 = (r0 << 8) | o0;
|
||||||
i_t6 = TAB4[i_t6 as usize] as u32;
|
o0 = TAB4[o0 as usize] as u32;
|
||||||
i_t5 += i_t6 + i_t4;
|
|
||||||
|
|
||||||
*byte = TAB1[*byte as usize] ^ (i_t5 & 0xFF) as u8;
|
// Combine (sum with carry) and recover the plaintext byte.
|
||||||
i_t5 >>= 8;
|
acc += o0 + o1;
|
||||||
|
*byte = TAB1[*byte as usize] ^ (acc & 0xFF) as u8;
|
||||||
|
acc >>= 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// libdvdcss leaves byte 0x14 untouched; freemkv clears the scramble bits
|
// Clear the scramble bits so downstream code and tests can tell a sector was
|
||||||
// so downstream code and tests can tell a sector was descrambled.
|
// descrambled; bits 6-7 of byte 0x14 are preserved.
|
||||||
sector[0x14] &= 0xCF;
|
sector[0x14] &= 0xCF;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exact inverse of [`descramble_sector`]: turn a plaintext sector body into
|
/// Exact inverse of [`descramble_sector`]: turn a plaintext sector body into
|
||||||
/// CSS ciphertext under `title_key`.
|
/// CSS ciphertext under `title_key`.
|
||||||
///
|
///
|
||||||
/// Descramble computes `plain = TAB1[cipher] ^ (i_t5 & 0xff)`, so the
|
/// Descramble computes `plain = TAB1[cipher] ^ (keystream & 0xff)`, so the
|
||||||
/// inverse is `cipher = TAB1_INV[plain ^ (i_t5 & 0xff)]` with the identical
|
/// inverse is `cipher = TAB1_INV[plain ^ (keystream & 0xff)]` with the identical
|
||||||
/// LFSR keystream. The keystream derivation is byte-for-byte the same as
|
/// keystream. The keystream derivation is the same as [`descramble_sector`];
|
||||||
/// `descramble_sector` (libdvdcss `dvdcss_unscramble`); only the final
|
/// only the final substitution differs. Bytes `0x80..0x800` are rewritten in
|
||||||
/// substitution differs. Bytes 0x80..0x800 are rewritten in place; the
|
/// place; the scramble flag is set to `0x10` so a subsequent descramble runs.
|
||||||
/// scramble flag is set to 0x10 so a subsequent descramble runs.
|
|
||||||
///
|
///
|
||||||
/// Not on any production read path — it exists so the key-recovery tests
|
/// Not on any production read path — it exists so the key-recovery tests (and
|
||||||
/// (and any caller that needs to produce a known CSS-encrypted sector) can
|
/// any caller that needs a known CSS-encrypted sector) can build genuine
|
||||||
/// build genuine ciphertext rather than approximating it.
|
/// ciphertext rather than approximating it.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn scramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
pub(crate) fn scramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||||
if sector.len() < 2048 {
|
if sector.len() < 2048 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
let mut r1a: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||||
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
let mut r1b: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||||
let mut i_t3: u32 = (((title_key[2] as u32)
|
let mut r0: u32 = (((title_key[2] as u32)
|
||||||
| ((title_key[3] as u32) << 8)
|
| ((title_key[3] as u32) << 8)
|
||||||
| ((title_key[4] as u32) << 16))
|
| ((title_key[4] as u32) << 16))
|
||||||
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
||||||
& 0xFF_FFFF;
|
& 0xFF_FFFF;
|
||||||
let i_t4_seed = i_t3 & 7;
|
r0 = r0 * 2 + 8 - (r0 & 7);
|
||||||
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
|
|
||||||
|
|
||||||
let mut i_t5: u32 = 0;
|
let mut acc: u32 = 0;
|
||||||
|
|
||||||
for byte in sector.iter_mut().take(2048).skip(128) {
|
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;
|
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
|
||||||
i_t2 = i_t1 >> 1;
|
r1b = r1a >> 1;
|
||||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
r1a = ((r1a & 1) << 8) ^ o1;
|
||||||
i_t4 = TAB5[i_t4 as usize] as u32;
|
o1 = TAB5[o1 as usize] as u32;
|
||||||
|
|
||||||
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xFF;
|
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
|
||||||
i_t3 = (i_t3 << 8) | i_t6;
|
r0 = (r0 << 8) | o0;
|
||||||
i_t6 = TAB4[i_t6 as usize] as u32;
|
o0 = TAB4[o0 as usize] as u32;
|
||||||
i_t5 += i_t6 + i_t4;
|
acc += o0 + o1;
|
||||||
|
|
||||||
// Inverse of `*p = TAB1[*p] ^ ks`: apply ks then TAB1's inverse.
|
// Inverse of `*p = TAB1[*p] ^ ks`: apply ks then TAB1's inverse.
|
||||||
*byte = (*TAB1_INV)[(*byte ^ (i_t5 & 0xFF) as u8) as usize];
|
*byte = (*TAB1_INV)[(*byte ^ (acc & 0xFF) as u8) as usize];
|
||||||
i_t5 >>= 8;
|
acc >>= 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark the sector scrambled so the descrambler will process it.
|
// Mark the sector scrambled so the descrambler will process it.
|
||||||
sector[0x14] = (sector[0x14] & 0xCF) | 0x10;
|
sector[0x14] = (sector[0x14] & 0xCF) | 0x10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a
|
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a bijection on
|
||||||
/// bijection on 0..256, so `TAB1_INV[TAB1[x]] == x`.
|
/// `0..256`, so `TAB1_INV[TAB1[x]] == x`.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
static TAB1_INV: std::sync::LazyLock<[u8; 256]> = std::sync::LazyLock::new(|| {
|
static TAB1_INV: std::sync::LazyLock<[u8; 256]> = std::sync::LazyLock::new(|| {
|
||||||
let mut inv = [0u8; 256];
|
let mut inv = [0u8; 256];
|
||||||
@@ -181,14 +170,15 @@ mod tests {
|
|||||||
assert_eq!(sector, original);
|
assert_eq!(sector, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cross-check `descramble_sector` against the EXACT output of libdvdcss
|
/// Regression vector: the deterministic output of the CSS content cipher for
|
||||||
/// `dvdcss_unscramble` (css.c) for a fixed sector, computed from the
|
/// a fixed key/seed/body. The value is generated by this implementation and
|
||||||
/// reference C semantics with the reference tables. Pins the content
|
/// is self-consistent with the scramble/descramble round-trip below — any
|
||||||
/// cipher to libdvdcss byte-for-byte.
|
/// 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.
|
/// key = 42 13 37 BE EF, seed (0x54..0x59) = DE AD BE EF 42, body = 0xAA.
|
||||||
#[test]
|
#[test]
|
||||||
fn descramble_matches_libdvdcss_unscramble_vector() {
|
fn descramble_produces_the_reference_css_vector() {
|
||||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
let mut sector = vec![0xAAu8; 2048];
|
let mut sector = vec![0xAAu8; 2048];
|
||||||
sector[0x14] = 0x30;
|
sector[0x14] = 0x30;
|
||||||
@@ -200,12 +190,12 @@ mod tests {
|
|||||||
0x81, 0x92, 0x24, 0xA2, 0x46, 0x70, 0x3C, 0x64, 0xA6, 0x91, 0x84, 0xF5, 0x1F, 0x98,
|
0x81, 0x92, 0x24, 0xA2, 0x46, 0x70, 0x3C, 0x64, 0xA6, 0x91, 0x84, 0xF5, 0x1F, 0x98,
|
||||||
0xA0, 0x31
|
0xA0, 0x31
|
||||||
],
|
],
|
||||||
"descramble body head must match libdvdcss dvdcss_unscramble"
|
"descramble body head must match the reference CSS vector"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
§or[0x7F8..0x800],
|
§or[0x7F8..0x800],
|
||||||
&[0x46, 0x94, 0x80, 0x0E, 0x67, 0x36, 0x65, 0xBC],
|
&[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 +231,8 @@ mod tests {
|
|||||||
|
|
||||||
/// Test 2: descramble inverts scramble over the body.
|
/// Test 2: descramble inverts scramble over the body.
|
||||||
///
|
///
|
||||||
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to
|
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to the
|
||||||
/// the ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
|
/// ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
|
||||||
/// [`scramble_sector`]. Scrambling a plaintext body and then descrambling
|
/// [`scramble_sector`]. Scrambling a plaintext body and then descrambling
|
||||||
/// with the same key must reproduce the original body exactly.
|
/// with the same key must reproduce the original body exactly.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -279,9 +269,9 @@ mod tests {
|
|||||||
|
|
||||||
/// css_tab1_relationship
|
/// css_tab1_relationship
|
||||||
///
|
///
|
||||||
/// Verify the structure of TAB1: it is a substitution table used in
|
/// Verify the structure of TAB1: it is a substitution table used in key
|
||||||
/// key mangling. Check that no two inputs map to the same output
|
/// mangling. Check that no two inputs map to the same output (TAB1 is a
|
||||||
/// (TAB1 is a permutation of 0..255).
|
/// permutation of 0..255).
|
||||||
#[test]
|
#[test]
|
||||||
fn css_tab1_is_permutation() {
|
fn css_tab1_is_permutation() {
|
||||||
let mut seen = [false; 256];
|
let mut seen = [false; 256];
|
||||||
@@ -332,8 +322,8 @@ mod tests {
|
|||||||
/// UNSCRAMBLED and left byte-for-byte unchanged. This guards against a
|
/// UNSCRAMBLED and left byte-for-byte unchanged. This guards against a
|
||||||
/// too-wide mask silently "descrambling" (and thus corrupting) clear data.
|
/// too-wide mask silently "descrambling" (and thus corrupting) clear data.
|
||||||
///
|
///
|
||||||
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live
|
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live in
|
||||||
/// in bits 4-5; the masked value 0 means not scrambled.
|
/// bits 4-5; the masked value 0 means not scrambled.
|
||||||
/// Mutation: widen the mask `0x30` to `0x70`/`0xF0` -> 0x40/0x80 would be
|
/// Mutation: widen the mask `0x30` to `0x70`/`0xF0` -> 0x40/0x80 would be
|
||||||
/// seen as scrambled and the body would change.
|
/// seen as scrambled and the body would change.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -352,11 +342,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Each individual scramble bit (4 and 5) independently marks the sector
|
/// Each individual scramble bit (4 and 5) independently marks the sector as
|
||||||
/// as encrypted: 0x10 and 0x20 must both trigger descrambling.
|
/// encrypted: 0x10 and 0x20 must both trigger descrambling.
|
||||||
///
|
///
|
||||||
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both
|
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both nonzero.
|
||||||
/// nonzero.
|
|
||||||
/// Mutation: change `!= 0` early-return condition to `== 3` -> a sector
|
/// Mutation: change `!= 0` early-return condition to `== 3` -> a sector
|
||||||
/// flagged only 0x10 or 0x20 would be skipped and left scrambled.
|
/// flagged only 0x10 or 0x20 would be skipped and left scrambled.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -381,8 +370,8 @@ mod tests {
|
|||||||
/// becomes 0xC0 (bits 6,7 kept, bits 4,5 cleared), NOT 0x00.
|
/// becomes 0xC0 (bits 6,7 kept, bits 4,5 cleared), NOT 0x00.
|
||||||
///
|
///
|
||||||
/// Grounding: code does `sector[0x14] &= 0xCF`; 0xF0 & 0xCF == 0xC0.
|
/// Grounding: code does `sector[0x14] &= 0xCF`; 0xF0 & 0xCF == 0xC0.
|
||||||
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved
|
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved high
|
||||||
/// high bits assert fails.
|
/// bits assert fails.
|
||||||
#[test]
|
#[test]
|
||||||
fn descramble_clear_preserves_high_bits_of_0x14() {
|
fn descramble_clear_preserves_high_bits_of_0x14() {
|
||||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||||
@@ -398,11 +387,11 @@ mod tests {
|
|||||||
|
|
||||||
// ── header / body boundary (encrypted region is 0x80..0x800) ───────────
|
// ── header / body boundary (encrypted region is 0x80..0x800) ───────────
|
||||||
|
|
||||||
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80
|
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80 (the
|
||||||
/// (the header) must NOT be modified by the keystream — except byte 0x14
|
/// header) must NOT be modified by the keystream — except byte 0x14 whose
|
||||||
/// whose flag is cleared. In particular the sector-seed bytes 0x54..0x59
|
/// flag is cleared. In particular the sector-seed bytes 0x54..0x59 (which
|
||||||
/// (which live inside the header) must survive untouched, since the
|
/// live inside the header) must survive untouched, since the descrambler
|
||||||
/// descrambler reads them but never writes them.
|
/// reads them but never writes them.
|
||||||
///
|
///
|
||||||
/// Grounding: loop is `sector.iter_mut().take(2048).skip(128)` -> indices
|
/// Grounding: loop is `sector.iter_mut().take(2048).skip(128)` -> indices
|
||||||
/// 128..2048 only.
|
/// 128..2048 only.
|
||||||
@@ -429,16 +418,15 @@ mod tests {
|
|||||||
assert_eq!(§or[0x54..0x59], &seed, "sector seed must survive");
|
assert_eq!(§or[0x54..0x59], &seed, "sector seed must survive");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a
|
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a prefix.
|
||||||
/// prefix. With a constant body and constant key, the keystream is
|
/// With a constant body and constant key, the keystream is non-degenerate
|
||||||
/// non-degenerate enough that the very last sector byte (index 2047) is
|
/// enough that the very last sector byte (index 2047) is altered. This guards
|
||||||
/// altered. This guards the loop bound `.take(2048)` against an
|
/// the loop bound `.take(2048)` against an off-by-one that would leave the
|
||||||
/// off-by-one that would leave the final byte(s) scrambled.
|
/// final byte(s) scrambled.
|
||||||
///
|
///
|
||||||
/// Grounding: encrypted region end is 0x800 == 2048 (exclusive).
|
/// Grounding: encrypted region end is 0x800 == 2048 (exclusive).
|
||||||
/// Mutation: change `.take(2048)` to `.take(2047)` -> last byte unchanged,
|
/// Mutation: change `.take(2048)` to `.take(2047)` -> last byte unchanged,
|
||||||
/// assert fires (keystream byte for the last position is verified nonzero
|
/// assert fires (this body is all-zero so any keystream XOR shows).
|
||||||
/// below by the round-trip, and this body is all-zero so any XOR shows).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn descramble_covers_final_body_byte() {
|
fn descramble_covers_final_body_byte() {
|
||||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
@@ -447,9 +435,7 @@ mod tests {
|
|||||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||||
descramble_sector(&key, &mut sector);
|
descramble_sector(&key, &mut sector);
|
||||||
// Body was all zero; any nonzero in [0x80,0x800) is keystream. Confirm
|
// Body was all zero; any nonzero in [0x80,0x800) is keystream. Confirm
|
||||||
// the keystream reaches the final byte. (If the last keystream byte
|
// the keystream reaches the final byte.
|
||||||
// happened to be 0 this could be a flaky test, so assert the run-end
|
|
||||||
// region as a whole differs from zero.)
|
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
§or[2040..2048],
|
§or[2040..2048],
|
||||||
&[0u8; 8][..],
|
&[0u8; 8][..],
|
||||||
@@ -457,14 +443,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
|
/// Descramble is keyed by `title_key XOR seed`: two different title keys
|
||||||
/// produce two different bodies for the same scrambled input. A cipher
|
/// produce two different bodies for the same scrambled input. A cipher that
|
||||||
/// that ignored the title key (or mixed it in wrongly) would yield
|
/// ignored the title key (or mixed it in wrongly) would yield identical
|
||||||
/// identical output — silent wrong-key decryption.
|
/// output — silent wrong-key decryption.
|
||||||
///
|
///
|
||||||
/// Grounding: per-sector key = title_key[i] ^ sector[0x54+i].
|
/// Grounding: per-sector key = title_key[i] ^ sector[0x54+i].
|
||||||
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both
|
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both keys
|
||||||
/// keys give the same body, assert fires.
|
/// give the same body, assert fires.
|
||||||
#[test]
|
#[test]
|
||||||
fn descramble_output_depends_on_title_key() {
|
fn descramble_output_depends_on_title_key() {
|
||||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||||
|
|||||||
+1020
-84
File diff suppressed because it is too large
Load Diff
+610
-61
@@ -1,48 +1,37 @@
|
|||||||
//! CSS title-key recovery — Frank A. Stevenson's divide-and-conquer attack
|
//! CSS title-key recovery — Frank A. Stevenson's divide-and-conquer attack
|
||||||
//! (1999), ported exactly from libdvdcss `RecoverTitleKey` + `AttackPattern`
|
//! (1999), implemented from his published cryptanalysis ("Cryptanalysis of
|
||||||
//! (css.c).
|
//! Contents Scrambling System"). It recovers the 5-byte CSS title key from a
|
||||||
//!
|
//! single scrambled DVD sector with no player keys and no disc-key crack, using
|
||||||
//! Recovers the 5-byte CSS title key from a single scrambled DVD sector with
|
//! only known plaintext. Implemented from that public description; nothing here
|
||||||
//! no player keys and no disc-key crack, using only known plaintext.
|
//! is copied or translated from any particular CSS software.
|
||||||
//!
|
//!
|
||||||
//! # The cipher this attacks
|
//! # The cipher this attacks
|
||||||
//!
|
//!
|
||||||
//! The content descrambler ([`super::lfsr::descramble_sector`], = libdvdcss
|
//! The content descrambler ([`super::lfsr::descramble_sector`]) seeds its two
|
||||||
//! `dvdcss_unscramble`) seeds its two LFSRs **directly** from
|
//! LFSRs **directly** from `key = title_key XOR sector_seed` (seed =
|
||||||
//! `key = title_key XOR sector_seed` (seed = `sector[0x54..0x59]`):
|
//! `sector[0x54..0x59]`): LFSR1 from key/seed bytes 0-1, LFSR0 (24-bit) from
|
||||||
//!
|
//! bytes 2-4 with the pre-conditioning `r0 = r0*2 + 8 - (r0 & 7)`, and each body
|
||||||
//! ```text
|
//! byte recovered as `plain = TAB1[cipher] ^ (keystream & 0xff)`. There is no
|
||||||
//! i_t1 = (key[0] ^ sec[0x54]) | 0x100; // LFSR1 low (9-bit)
|
//! title-key mangling on the content path, so the recovery is a single inversion
|
||||||
//! i_t2 = key[1] ^ sec[0x55]; // LFSR1 high
|
//! of the sector cipher.
|
||||||
//! 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
|
//! # The attack
|
||||||
//!
|
//!
|
||||||
//! 1. **Known plaintext → keystream.** Because the descramble applies TAB1
|
//! 1. **Known plaintext → keystream.** Because descramble applies TAB1 to the
|
||||||
//! to the ciphertext, the per-byte keystream is
|
//! ciphertext, the per-byte keystream is `TAB1[cipher[i]] ^ plain[i]`.
|
||||||
//! `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
|
//! 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
|
//! forward; for the first four steps deduce the LFSR0 output bytes from the
|
||||||
//! the keystream (carry-tracked), reconstructing `i_t3`. For the next six
|
//! keystream (carry-tracked), reconstructing LFSR0's state. For the next six
|
||||||
//! steps clock LFSR0 normally and check it reproduces the keystream — a
|
//! steps clock LFSR0 normally and check it reproduces the keystream — a wrong
|
||||||
//! wrong LFSR1 seed fails fast.
|
//! LFSR1 seed fails fast.
|
||||||
//! 3. **Back-clock LFSR0.** Run four backward `i_t3` steps (each a 256-way
|
//! 3. **Back-clock LFSR0.** Run four backward steps (each a 256-way search for
|
||||||
//! search for the byte shifted in) to reach the initial state, then undo
|
//! the byte shifted in) to reach the initial state, then undo the
|
||||||
//! `i_t3 = i_t3*2 + 8 - (i_t3 & 7)` to recover key[2..5].
|
//! `r0*2 + 8 - (r0 & 7)` pre-conditioning to recover key[2..5].
|
||||||
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]` (plain XOR —
|
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]`.
|
||||||
//! the descramble seeds directly, so there is no inversion).
|
|
||||||
//!
|
//!
|
||||||
//! `AttackPattern` finds known plaintext for step 1: the longest periodic
|
//! Known plaintext for step 1 comes from the longest periodic run in the
|
||||||
//! run in the cleartext `sec[0x00..0x80]`, assumed to continue into the
|
//! cleartext `sec[0x00..0x80]`, assumed to continue into the encrypted region at
|
||||||
//! encrypted region at 0x80.
|
//! 0x80.
|
||||||
|
|
||||||
use super::lfsr::descramble_sector;
|
use super::lfsr::descramble_sector;
|
||||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||||
@@ -52,13 +41,11 @@ const ENCRYPTED_START: usize = 0x80; // byte 128
|
|||||||
const SEED_OFFSET: usize = 0x54; // sector seed at bytes 0x54-0x58
|
const SEED_OFFSET: usize = 0x54; // sector seed at bytes 0x54-0x58
|
||||||
const FLAG_BYTE: usize = 0x14;
|
const FLAG_BYTE: usize = 0x14;
|
||||||
|
|
||||||
/// RecoverTitleKey: recover the title key from cipher + known plaintext.
|
/// Recover the title key from cipher + known plaintext (the core of Stevenson's
|
||||||
///
|
/// attack). `crypted` is the ciphertext starting at sector byte 0x80;
|
||||||
/// Exact port of libdvdcss `RecoverTitleKey` (css.c). `crypted` is the
|
/// `decrypted` is the matching known plaintext; `seed` is `sector[0x54..0x59]`.
|
||||||
/// ciphertext starting at sector byte 0x80; `decrypted` is the matching
|
/// On success returns the recovered 5-byte title key; `None` if no LFSR seed
|
||||||
/// known plaintext; `seed` is `sector[0x54..0x59]`. On success returns the
|
/// reproduces the keystream.
|
||||||
/// 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
|
/// At least 10 bytes of `crypted`/`decrypted` are required (the cipher is
|
||||||
/// iterated 10 times: 4 to reconstruct LFSR0, 6 to validate).
|
/// iterated 10 times: 4 to reconstruct LFSR0, 6 to validate).
|
||||||
@@ -222,14 +209,13 @@ fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool {
|
|||||||
test[ENCRYPTED_START..ENCRYPTED_START + n] == plain[..n]
|
test[ENCRYPTED_START..ENCRYPTED_START + n] == plain[..n]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AttackPattern: find a repeating pattern just before the encrypted region
|
/// Find a repeating pattern just before the encrypted region and assume the
|
||||||
/// and assume the plaintext at 0x80 continues it.
|
/// plaintext at 0x80 continues it — the known-plaintext step of Stevenson's
|
||||||
///
|
/// attack. Scans cleartext `sec[0x00..0x80]` for the longest run that repeats
|
||||||
/// Exact port of libdvdcss `AttackPattern` (css.c). Scans cleartext
|
/// with a cycle length in 2..0x2F. If the run is long enough (`plen > 3` and at
|
||||||
/// `sec[0x00..0x80]` for the longest run that repeats with a cycle length in
|
/// least two full cycles), the known plaintext at 0x80 is taken to be the
|
||||||
/// 2..0x2F. If the run is long enough (`plen > 3` and at least two full
|
/// periodic run continuing forward, and [`recover_title_key_from_plain`] is
|
||||||
/// cycles), the known plaintext at 0x80 is taken to be the periodic run
|
/// applied.
|
||||||
/// continuing forward, and [`recover_title_key_from_plain`] is applied.
|
|
||||||
pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
||||||
if sector.len() < SECTOR_BYTES {
|
if sector.len() < SECTOR_BYTES {
|
||||||
return None;
|
return None;
|
||||||
@@ -258,10 +244,7 @@ pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inner body of [`crack_title_key`] — the actual AttackPattern search. Split
|
/// Crib: the predicted 10-byte plaintext at byte 0x80.
|
||||||
/// 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
|
/// 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
|
/// run that repeats with a cycle length in 2..0x2F. If the run is long enough
|
||||||
@@ -367,7 +350,7 @@ mod tests {
|
|||||||
|
|
||||||
/// Build a synthetic scrambled sector whose CLEARTEXT (0x00..0x80) ends
|
/// Build a synthetic scrambled sector whose CLEARTEXT (0x00..0x80) ends
|
||||||
/// in a periodic run that continues into the encrypted region — the case
|
/// in a periodic run that continues into the encrypted region — the case
|
||||||
/// `AttackPattern` (crack_title_key) is designed to crack.
|
/// `crack_title_key` is designed to crack.
|
||||||
fn synth_periodic_sector(
|
fn synth_periodic_sector(
|
||||||
title_key: &[u8; 5],
|
title_key: &[u8; 5],
|
||||||
seed: &[u8; 5],
|
seed: &[u8; 5],
|
||||||
@@ -380,7 +363,7 @@ mod tests {
|
|||||||
// (RUN_START..0x80) and continuing into the encrypted region. This
|
// (RUN_START..0x80) and continuing into the encrypted region. This
|
||||||
// mirrors a real VOB: a periodic data run just before the scrambled
|
// 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
|
// part. The run must NOT overlap the seed bytes (0x54..0x59), or the
|
||||||
// AttackPattern detector would break mid-run. The phase is anchored to
|
// the crib detector would break mid-run. The phase is anchored to
|
||||||
// offset 0 so the run is consistent across the 0x80 boundary.
|
// offset 0 so the run is consistent across the 0x80 boundary.
|
||||||
// Just above the seed (0x54..0x59); gives a 39-byte run (0x59..0x80)
|
// Just above the seed (0x54..0x59); gives a 39-byte run (0x59..0x80)
|
||||||
// — enough for >=2 cycles of every tested period (<=19).
|
// — enough for >=2 cycles of every tested period (<=19).
|
||||||
@@ -402,7 +385,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn crack_unscrambled_returns_none() {
|
fn crack_unscrambled_returns_none() {
|
||||||
let sector = vec![0u8; 2048];
|
let sector = vec![0u8; SECTOR_BYTES];
|
||||||
assert!(crack_title_key(§or).is_none());
|
assert!(crack_title_key(§or).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +397,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recover_needs_min_plain() {
|
fn recover_needs_min_plain() {
|
||||||
let sector = vec![0u8; 2048];
|
let sector = vec![0u8; SECTOR_BYTES];
|
||||||
let short_plain = [0u8; 4];
|
let short_plain = [0u8; 4];
|
||||||
assert!(recover_title_key(§or, &short_plain).is_none());
|
assert!(recover_title_key(§or, &short_plain).is_none());
|
||||||
}
|
}
|
||||||
@@ -467,7 +450,65 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MANDATORY (Task C.1): the AttackPattern entry point crack_title_key —
|
/// `descramble_matches` is the ONLY gate between the LFSR search and a key
|
||||||
|
/// handed back to the caller: both [`recover_title_key`] and the crib-driven
|
||||||
|
/// `crack_title_key_inner` return a candidate only if this says the key
|
||||||
|
/// really descrambles the sector to the known plaintext. A body that always
|
||||||
|
/// answered `true` would let the first spurious LFSR-seed match through as
|
||||||
|
/// the title key — the ripper would then descramble the whole title with a
|
||||||
|
/// key that opens nothing, producing garbage rather than a "no key" error.
|
||||||
|
///
|
||||||
|
/// Pinned both directions: the genuine key is accepted, and EVERY key one
|
||||||
|
/// bit away from it is rejected. The one-bit neighbours are the strongest
|
||||||
|
/// form of wrong key — a gate that only rejects wildly different keys would
|
||||||
|
/// still pass a near-miss out of the 2^16 seed search.
|
||||||
|
#[test]
|
||||||
|
fn descramble_matches_accepts_only_the_key_the_sector_was_scrambled_with() {
|
||||||
|
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
|
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||||
|
let (sector, _body) = synth_sector(&title_key, &seed, &PES);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
descramble_matches(§or, &title_key, &PES),
|
||||||
|
"the key the sector was scrambled with must be accepted"
|
||||||
|
);
|
||||||
|
|
||||||
|
for byte in 0..5usize {
|
||||||
|
for bit in 0..8u32 {
|
||||||
|
let mut wrong = title_key;
|
||||||
|
wrong[byte] ^= 1u8 << bit;
|
||||||
|
assert!(
|
||||||
|
!descramble_matches(§or, &wrong, &PES),
|
||||||
|
"key differing only in byte {byte} bit {bit} must be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gate is applied to a COPY: verifying a candidate must not modify the
|
||||||
|
/// caller's sector. `recover_title_key` runs the gate and then hands the
|
||||||
|
/// sector on to be descrambled for real — if verification descrambled in
|
||||||
|
/// place, that second descramble would run over already-transformed bytes
|
||||||
|
/// (and, worse, a rejected candidate would leave the sector corrupted).
|
||||||
|
#[test]
|
||||||
|
fn descramble_matches_does_not_disturb_the_caller_s_sector() {
|
||||||
|
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
|
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||||
|
let (sector, _body) = synth_sector(&title_key, &seed, &PES);
|
||||||
|
let before = sector.clone();
|
||||||
|
|
||||||
|
assert!(descramble_matches(§or, &title_key, &PES));
|
||||||
|
let mut wrong = title_key;
|
||||||
|
wrong[0] ^= 0x01;
|
||||||
|
assert!(!descramble_matches(§or, &wrong, &PES));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sector, before,
|
||||||
|
"verification must leave the sector byte-for-byte unchanged"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MANDATORY (Task C.1): the crib-based entry point crack_title_key —
|
||||||
/// no plaintext supplied — recovers a round-tripping key when the
|
/// no plaintext supplied — recovers a round-tripping key when the
|
||||||
/// cleartext ends in a periodic run that continues into 0x80.
|
/// cleartext ends in a periodic run that continues into 0x80.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -489,7 +530,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// recover_title_key_from_plain inverts dvdcss_unscramble exactly: scramble
|
/// recover_title_key_from_plain inverts descramble_sector exactly: scramble
|
||||||
/// a known body, hand back the keystream-derived key, and the recovered
|
/// a known body, hand back the keystream-derived key, and the recovered
|
||||||
/// key (XOR-back included) reproduces the plaintext.
|
/// key (XOR-back included) reproduces the plaintext.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -588,4 +629,512 @@ mod tests {
|
|||||||
let _ = crack_title_key(§or);
|
let _ = crack_title_key(§or);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── entry-point guards on caller- and disc-supplied lengths ────────────
|
||||||
|
|
||||||
|
/// A sector buffer that ENDS inside the encrypted region must be refused,
|
||||||
|
/// not sliced.
|
||||||
|
///
|
||||||
|
/// `recover_title_key` slices `sector[0x80..0x8A]` unconditionally after its
|
||||||
|
/// length guard. The existing short-sector test uses `SECTOR_BYTES - 1`,
|
||||||
|
/// which is still long enough for that slice to succeed — so the guard was
|
||||||
|
/// never the thing producing the `None`, and dropping it (or weakening the
|
||||||
|
/// `||` to `&&`, which a full-length crib satisfies) changed nothing
|
||||||
|
/// observable. On a real short read this is an out-of-bounds panic on the
|
||||||
|
/// rip thread.
|
||||||
|
#[test]
|
||||||
|
fn recover_rejects_a_sector_that_ends_inside_the_encrypted_region() {
|
||||||
|
for len in [0x81usize, 0x85, 0x89] {
|
||||||
|
let mut sector = vec![0x11u8; len];
|
||||||
|
sector[FLAG_BYTE] = 0x30; // scrambled, so no other guard fires first
|
||||||
|
assert!(
|
||||||
|
recover_title_key(§or, &PES).is_none(),
|
||||||
|
"a {len}-byte buffer cannot supply ten ciphertext bytes at 0x80"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A buffer LONGER than one sector is still one sector: both entry points
|
||||||
|
/// read the first `SECTOR_BYTES` and must recover the key from it.
|
||||||
|
///
|
||||||
|
/// Callers read DVD data in multi-sector blocks, so an over-long slice is
|
||||||
|
/// the normal case, not an exotic one. A length guard that rejected it
|
||||||
|
/// (`len > SECTOR_BYTES` instead of `<`) would make every block-read caller
|
||||||
|
/// silently unable to crack anything.
|
||||||
|
#[test]
|
||||||
|
fn a_buffer_longer_than_one_sector_still_yields_its_key() {
|
||||||
|
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
|
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||||
|
|
||||||
|
let (sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||||
|
let mut padded = sector.clone();
|
||||||
|
padded.extend_from_slice(&[0xA7u8; 512]);
|
||||||
|
assert_eq!(
|
||||||
|
recover_title_key(&padded, &PES),
|
||||||
|
Some(title_key),
|
||||||
|
"a two-and-a-bit-sector buffer must still recover the first sector's key"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (periodic, _) = synth_periodic_sector(&title_key, &seed, 5);
|
||||||
|
let mut padded = periodic.clone();
|
||||||
|
padded.extend_from_slice(&[0xA7u8; 512]);
|
||||||
|
assert_eq!(
|
||||||
|
crack_title_key(&padded),
|
||||||
|
crack_title_key(&periodic),
|
||||||
|
"padding past the sector must not change the crack result"
|
||||||
|
);
|
||||||
|
assert!(crack_title_key(&padded).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `recover_title_key` accepts MORE than ten bytes of known plaintext, and
|
||||||
|
/// uses all of it: the extra bytes tighten the `descramble_matches` gate.
|
||||||
|
/// The ten-byte figure is a MINIMUM (the cipher is iterated ten times), not
|
||||||
|
/// an exact requirement — a guard reading it as an upper bound would reject
|
||||||
|
/// every caller that knows a longer crib.
|
||||||
|
#[test]
|
||||||
|
fn recover_accepts_more_than_ten_bytes_of_known_plaintext() {
|
||||||
|
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
|
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||||
|
let long_plain: Vec<u8> = (0..64u8)
|
||||||
|
.map(|k| k.wrapping_mul(37).wrapping_add(5))
|
||||||
|
.collect();
|
||||||
|
let (sector, _) = synth_sector(&title_key, &seed, &long_plain);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
recover_title_key(§or, &long_plain),
|
||||||
|
Some(title_key),
|
||||||
|
"64 bytes of known plaintext must be accepted, not rejected as \
|
||||||
|
'more than ten'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scramble-flag gate on a sector whose BODY really is ciphertext.
|
||||||
|
///
|
||||||
|
/// Both entry points refuse a sector with `sector[0x14] & 0x30 == 0`: an
|
||||||
|
/// unscrambled sector has no title key to recover, and its bytes at 0x80
|
||||||
|
/// are already plaintext. Every prior test of this gate used an all-zero or
|
||||||
|
/// all-`0x11` sector, where the recovery would have found nothing anyway —
|
||||||
|
/// so widening the mask test (`&` to `|`, which makes it true for EVERY
|
||||||
|
/// flag byte) produced the same `None` and went unseen.
|
||||||
|
///
|
||||||
|
/// Here the sector is genuinely scrambled and its key IS recoverable; only
|
||||||
|
/// the cleared flag stands in the way. If the gate stops working, both
|
||||||
|
/// functions start returning keys for sectors the disc says are in the
|
||||||
|
/// clear.
|
||||||
|
#[test]
|
||||||
|
fn a_recoverable_sector_with_the_scramble_bits_cleared_is_still_refused() {
|
||||||
|
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
|
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||||
|
|
||||||
|
let (mut sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||||
|
assert_eq!(
|
||||||
|
recover_title_key(§or, &PES),
|
||||||
|
Some(title_key),
|
||||||
|
"fixture check: with the flag set this sector's key IS recoverable"
|
||||||
|
);
|
||||||
|
sector[FLAG_BYTE] = 0x00;
|
||||||
|
assert_eq!(
|
||||||
|
recover_title_key(§or, &PES),
|
||||||
|
None,
|
||||||
|
"scramble bits clear → no title key, even though one could be found"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (mut periodic, _) = synth_periodic_sector(&title_key, &seed, 5);
|
||||||
|
assert!(
|
||||||
|
crack_title_key(&periodic).is_some(),
|
||||||
|
"fixture check: with the flag set this sector cracks"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
attack_crib(&periodic).is_some(),
|
||||||
|
"fixture check: with the flag set this sector has a usable crib"
|
||||||
|
);
|
||||||
|
periodic[FLAG_BYTE] = 0x00;
|
||||||
|
assert_eq!(
|
||||||
|
crack_title_key(&periodic),
|
||||||
|
None,
|
||||||
|
"scramble bits clear → no crack, even though one would succeed"
|
||||||
|
);
|
||||||
|
// `attack_crib` carries its own copy of the same gate, and it is the one
|
||||||
|
// that actually stops the crack (`crack_title_key`'s is defensive
|
||||||
|
// duplication). The crib doubles as the decrypt path's cached-key
|
||||||
|
// oracle, so a widened mask there would hand that path a "predicted
|
||||||
|
// plaintext" for sectors that were never scrambled.
|
||||||
|
assert_eq!(
|
||||||
|
attack_crib(&periodic),
|
||||||
|
None,
|
||||||
|
"an unscrambled sector has no predicted plaintext to offer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── descramble_matches: the verification gate's own mechanics ──────────
|
||||||
|
|
||||||
|
/// The gate must verify a candidate against the sector's CIPHERTEXT
|
||||||
|
/// regardless of what the sector's own flag byte says.
|
||||||
|
///
|
||||||
|
/// `descramble_matches` forces `0x10` on its copy precisely because
|
||||||
|
/// [`super::lfsr::descramble_sector`] is a no-op when the scramble bits are
|
||||||
|
/// clear — without that, verifying a scrambled-but-unflagged sector
|
||||||
|
/// compares raw ciphertext against the crib, and every candidate key is
|
||||||
|
/// rejected. Nothing exercised it: every fixture already had the flag set,
|
||||||
|
/// where forcing the bit is a no-op.
|
||||||
|
#[test]
|
||||||
|
fn descramble_matches_forces_the_scramble_flag_on_its_own_copy() {
|
||||||
|
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
|
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||||
|
let (mut sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||||
|
sector[FLAG_BYTE] = 0x00;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
descramble_matches(§or, &title_key, &PES),
|
||||||
|
"the body is ciphertext and the key is right — the gate must \
|
||||||
|
descramble it even though the flag byte says otherwise"
|
||||||
|
);
|
||||||
|
let mut wrong = title_key;
|
||||||
|
wrong[0] ^= 0x01;
|
||||||
|
assert!(!descramble_matches(§or, &wrong, &PES));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gate compares the WHOLE supplied plaintext, clamped to the encrypted
|
||||||
|
/// region.
|
||||||
|
///
|
||||||
|
/// Two properties in one, because they are the two halves of
|
||||||
|
/// `plain.len().min(SECTOR_BYTES - ENCRYPTED_START)`:
|
||||||
|
///
|
||||||
|
/// - it must compare beyond the first sixteen bytes, or a key that opens
|
||||||
|
/// only the head of the crib is accepted; and
|
||||||
|
/// - it must never compare past the end of the sector — a caller that
|
||||||
|
/// knows more plaintext than the 1920-byte encrypted region holds
|
||||||
|
/// otherwise indexes off the end of the buffer and panics.
|
||||||
|
#[test]
|
||||||
|
fn descramble_matches_compares_all_of_the_plaintext_and_no_more_than_the_sector() {
|
||||||
|
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
|
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||||
|
let body: Vec<u8> = (0..64u8)
|
||||||
|
.map(|k| k.wrapping_mul(29).wrapping_add(3))
|
||||||
|
.collect();
|
||||||
|
let (sector, _) = synth_sector(&title_key, &seed, &body);
|
||||||
|
|
||||||
|
assert!(descramble_matches(§or, &title_key, &body));
|
||||||
|
|
||||||
|
// A crib agreeing for the first 16 bytes and diverging after must be
|
||||||
|
// rejected: the comparison window is the crib's length, not a fixed
|
||||||
|
// prefix.
|
||||||
|
let mut tail_wrong = body.clone();
|
||||||
|
tail_wrong[40] ^= 0xFF;
|
||||||
|
assert!(
|
||||||
|
!descramble_matches(§or, &title_key, &tail_wrong),
|
||||||
|
"a crib that diverges at byte 40 must not match"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tail_wrong[..16],
|
||||||
|
body[..16],
|
||||||
|
"fixture check: the first 16 bytes are identical, so only a \
|
||||||
|
comparison that runs past them can tell these apart"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A crib LONGER than the encrypted region: the comparison is clamped to
|
||||||
|
// the sector, not run off the end of it.
|
||||||
|
let plain_len = SECTOR_BYTES - ENCRYPTED_START;
|
||||||
|
let mut over_long = vec![0u8; plain_len + 10];
|
||||||
|
let (full_sector, full_body) = synth_sector(&title_key, &seed, &[0x00u8; 10]);
|
||||||
|
over_long[..plain_len].copy_from_slice(&full_body[ENCRYPTED_START..]);
|
||||||
|
assert!(
|
||||||
|
descramble_matches(&full_sector, &title_key, &over_long),
|
||||||
|
"a crib longer than the encrypted region must be clamped, not \
|
||||||
|
compared past the end of the sector"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── attack_crib: known-answer vectors ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// `attack_crib` is BOTH the cracker's known plaintext and the decrypt
|
||||||
|
// path's "did the cached key descramble correctly?" oracle. Until now it
|
||||||
|
// was only ever exercised end-to-end through `crack_title_key`, on a
|
||||||
|
// fixture whose periodic run covered 39 bytes (0x59..0x80) — long enough
|
||||||
|
// that the run start, the cycle count and the `i % best_p` wrap were all
|
||||||
|
// slack. A crib that silently drifts costs a rip its title key.
|
||||||
|
|
||||||
|
/// Build a sector whose clear header ends in a `period`-length repeating
|
||||||
|
/// run of exactly `run_len` bytes immediately before 0x80.
|
||||||
|
///
|
||||||
|
/// The run is anchored to ABSOLUTE sector offset (`sec[x] = pat[x % period]`),
|
||||||
|
/// which is what makes "the run continues past 0x80" a statement independent
|
||||||
|
/// of the code under test: the byte at `0x80 + i` of the underlying
|
||||||
|
/// plaintext is `pat[(0x80 + i) % period]`.
|
||||||
|
///
|
||||||
|
/// Everything before the run is `0x00` (the pattern bytes are all >= 0xD0,
|
||||||
|
/// so the run cannot be extended backwards by accident), and the encrypted
|
||||||
|
/// region is filled with `0xFF` — so a crib that reads past 0x80 into
|
||||||
|
/// "ciphertext" is immediately visible.
|
||||||
|
fn sector_with_trailing_run(period: usize, run_len: usize) -> Vec<u8> {
|
||||||
|
assert!(
|
||||||
|
run_len < ENCRYPTED_START,
|
||||||
|
"the run lives in the clear header"
|
||||||
|
);
|
||||||
|
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||||
|
sector[FLAG_BYTE] = 0x10;
|
||||||
|
for b in sector[ENCRYPTED_START..].iter_mut() {
|
||||||
|
*b = 0xFF;
|
||||||
|
}
|
||||||
|
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
|
||||||
|
for x in (ENCRYPTED_START - run_len)..ENCRYPTED_START {
|
||||||
|
sector[x] = pat[x % period];
|
||||||
|
}
|
||||||
|
sector
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The crib the run PREDICTS: the periodic pattern continued past 0x80.
|
||||||
|
fn expected_crib(period: usize) -> [u8; 10] {
|
||||||
|
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
|
||||||
|
let mut out = [0u8; 10];
|
||||||
|
for (i, o) in out.iter_mut().enumerate() {
|
||||||
|
*o = pat[(ENCRYPTED_START + i) % period];
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// KNOWN ANSWER: for a run of `run_len` bytes with period 5 ending exactly
|
||||||
|
/// at 0x80, the crib is the run continued forward — the same ten bytes for
|
||||||
|
/// every run length, because the prediction depends only on the pattern and
|
||||||
|
/// the phase, never on how many cycles happened to be visible.
|
||||||
|
///
|
||||||
|
/// The short lengths are the load-bearing ones: at `run_len = 11` the crib
|
||||||
|
/// window starts at 0x76 and is only 10 bytes from the end of the header, so
|
||||||
|
/// any drift in `plain_start`, in `cycles * best_p`, or in the `i % best_p`
|
||||||
|
/// wrap reads the 0xFF "ciphertext" instead of the run.
|
||||||
|
#[test]
|
||||||
|
fn attack_crib_predicts_the_periodic_run_continuing_past_0x80() {
|
||||||
|
for &run_len in &[11usize, 12, 13, 14, 15, 16, 20, 31] {
|
||||||
|
let sector = sector_with_trailing_run(5, run_len);
|
||||||
|
assert_eq!(
|
||||||
|
attack_crib(§or),
|
||||||
|
Some(expected_crib(5)),
|
||||||
|
"period-5 run of {run_len} bytes must predict the run continuing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same known answer across several periods, including a period that
|
||||||
|
/// does NOT divide 0x80 (so the crib's phase is non-zero and a body that
|
||||||
|
/// restarted the pattern at index 0 gives a different answer).
|
||||||
|
#[test]
|
||||||
|
fn attack_crib_recovers_the_run_period_and_phase() {
|
||||||
|
// 0x80 % period: 3 for 5, 2 for 6, 2 for 7, 8 for 0x18 — all non-zero,
|
||||||
|
// so the predicted first byte is NOT pat[0] in any of these cases.
|
||||||
|
for &period in &[5usize, 6, 7, 0x18] {
|
||||||
|
let sector = sector_with_trailing_run(period, 3 * period + 1);
|
||||||
|
let crib =
|
||||||
|
attack_crib(§or).unwrap_or_else(|| panic!("no crib for period {period}"));
|
||||||
|
assert_eq!(crib, expected_crib(period), "period {period}");
|
||||||
|
assert_ne!(
|
||||||
|
crib[0], 0xD0,
|
||||||
|
"period {period} does not divide 0x80, so the crib must not \
|
||||||
|
start at pattern index 0"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
crib.iter().all(|&b| b != 0xFF),
|
||||||
|
"period {period}: the crib must never contain a byte read from \
|
||||||
|
the encrypted region"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A run of exactly ONE cycle (plus the trivial tail the detector counts) is
|
||||||
|
/// not enough to predict forward: [`attack_crib`] requires at least two full
|
||||||
|
/// cycles. Weakening that guard would let a one-off byte sequence be
|
||||||
|
/// declared periodic and produce a confidently wrong crib — which the
|
||||||
|
/// decrypt path uses as its "is my cached key still right?" oracle.
|
||||||
|
#[test]
|
||||||
|
fn attack_crib_refuses_a_run_shorter_than_two_cycles() {
|
||||||
|
// period 8, run of 9 bytes: best_plen = 8, 8 / 8 == 1 cycle.
|
||||||
|
assert_eq!(attack_crib(§or_with_trailing_run(8, 9)), None);
|
||||||
|
// period 0x18, run of 0x19 bytes: one cycle.
|
||||||
|
assert_eq!(attack_crib(§or_with_trailing_run(0x18, 0x19)), None);
|
||||||
|
// ...and one more byte of run does not conjure a second cycle either.
|
||||||
|
assert_eq!(attack_crib(§or_with_trailing_run(8, 10)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A header with no repeating tail at all yields no crib. Asserted on a
|
||||||
|
/// header whose bytes are pairwise distinct right up to 0x80, so no cycle
|
||||||
|
/// length in 2..0x2F can match even one byte.
|
||||||
|
#[test]
|
||||||
|
fn attack_crib_refuses_a_header_with_no_periodic_tail() {
|
||||||
|
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||||
|
sector[FLAG_BYTE] = 0x10;
|
||||||
|
// 0x00..0x80 strictly increasing: sec[a] == sec[b] iff a == b, so the
|
||||||
|
// detector's `sec[0x7f - (j % i)] == sec[0x7f - j]` needs j % i == j,
|
||||||
|
// which the scan's starting `j = i + 1` already excludes.
|
||||||
|
for (x, b) in sector[..ENCRYPTED_START].iter_mut().enumerate() {
|
||||||
|
*b = x as u8;
|
||||||
|
}
|
||||||
|
assert_eq!(attack_crib(§or), None);
|
||||||
|
// And the cracker built on it reports no key rather than guessing.
|
||||||
|
assert_eq!(crack_title_key(§or), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `attack_crib` indexes `sector[0x7f - j]` with no per-access bound, so its
|
||||||
|
/// own length guard is the only thing between a short buffer and an
|
||||||
|
/// out-of-bounds read. Nothing reached it: every caller-level test used a
|
||||||
|
/// full sector, and the entry points' guards fire first.
|
||||||
|
#[test]
|
||||||
|
fn attack_crib_refuses_a_buffer_shorter_than_a_sector() {
|
||||||
|
for len in [0x15usize, 0x40, 0x7F, SECTOR_BYTES - 1] {
|
||||||
|
let mut sector = vec![0x11u8; len];
|
||||||
|
sector[FLAG_BYTE] = 0x30; // scrambled, so the flag half cannot fire
|
||||||
|
assert_eq!(
|
||||||
|
attack_crib(§or),
|
||||||
|
None,
|
||||||
|
"a {len}-byte buffer is not a sector"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A header that is periodic ALL THE WAY to offset 0 must not walk the
|
||||||
|
/// backward scan off the front of the sector.
|
||||||
|
///
|
||||||
|
/// The detector counts backwards from 0x7f while `j < 0x80`. On a fully
|
||||||
|
/// periodic header the run never breaks, so `j` reaches 0x7f and the bound
|
||||||
|
/// is the ONLY thing that stops it — one step further and `0x7f - j`
|
||||||
|
/// underflows a `usize` and panics. A constant or fully-patterned 128-byte
|
||||||
|
/// header is ordinary DVD data (padding, a run of zeros), not a crafted
|
||||||
|
/// input, and every existing fixture had a filler/run boundary well before
|
||||||
|
/// offset 0 that stopped the scan early.
|
||||||
|
#[test]
|
||||||
|
fn attack_crib_survives_a_header_that_is_periodic_to_offset_zero() {
|
||||||
|
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||||
|
sector[FLAG_BYTE] = 0x30;
|
||||||
|
let period = 5usize;
|
||||||
|
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
|
||||||
|
for (x, b) in sector[..ENCRYPTED_START].iter_mut().enumerate() {
|
||||||
|
*b = pat[x % period];
|
||||||
|
}
|
||||||
|
for b in sector[ENCRYPTED_START..].iter_mut() {
|
||||||
|
*b = 0xFF;
|
||||||
|
}
|
||||||
|
// The FLAG byte sits inside the header at 0x14, so it interrupts the
|
||||||
|
// pattern there; re-lay it and accept that 0x14 breaks the run — the
|
||||||
|
// scan still reaches offset 0x15 - 1 = 0x14 going backwards, i.e.
|
||||||
|
// j = 0x7f - 0x14 = 0x6b, well short of the bound. Instead put the
|
||||||
|
// scramble flag bits into a byte value that IS the pattern's.
|
||||||
|
sector[FLAG_BYTE] = pat[FLAG_BYTE % period];
|
||||||
|
assert_ne!(
|
||||||
|
sector[FLAG_BYTE] & 0x30,
|
||||||
|
0,
|
||||||
|
"fixture check: the pattern byte at 0x14 must itself carry \
|
||||||
|
scramble bits, so the header stays unbroken"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
attack_crib(§or),
|
||||||
|
Some(expected_crib(period)),
|
||||||
|
"a fully periodic header must predict its own continuation, and \
|
||||||
|
the backward scan must stop at offset 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The crib is read from the CLEAR header only. A run that reaches 0x80 must
|
||||||
|
/// predict from the header bytes, never from the encrypted region — the
|
||||||
|
/// previously-fixed bug this function's doc comment records. Pinned by
|
||||||
|
/// rewriting the encrypted region and requiring the crib not to move.
|
||||||
|
#[test]
|
||||||
|
fn attack_crib_is_independent_of_the_encrypted_region() {
|
||||||
|
let base = sector_with_trailing_run(5, 11);
|
||||||
|
let crib = attack_crib(&base).expect("crib");
|
||||||
|
for fill in [0x00u8, 0x5A, 0xD1, 0xFF] {
|
||||||
|
let mut s = base.clone();
|
||||||
|
for b in s[ENCRYPTED_START..].iter_mut() {
|
||||||
|
*b = fill;
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
attack_crib(&s),
|
||||||
|
Some(crib),
|
||||||
|
"the crib must not depend on the encrypted region (fill {fill:#04x})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── recover_title_key_from_plain: input-length guard ───────────────────
|
||||||
|
|
||||||
|
/// `recover_title_key_from_plain` unconditionally builds a 10-byte keystream
|
||||||
|
/// buffer from `crypted[0..10]` and `decrypted[0..10]`, so its length guard
|
||||||
|
/// is the only thing standing between a short slice and an
|
||||||
|
/// index-out-of-bounds PANIC.
|
||||||
|
///
|
||||||
|
/// Nothing reached that guard before: `recover_title_key` rejects
|
||||||
|
/// `plain.len() < 10` at its own door and always hands on exactly ten
|
||||||
|
/// ciphertext bytes, and `crack_title_key_inner` always passes a fixed
|
||||||
|
/// `[u8; 10]` crib. The guard is a live contract for any future caller and
|
||||||
|
/// was executed by no test at either boundary.
|
||||||
|
#[test]
|
||||||
|
fn recover_title_key_from_plain_refuses_fewer_than_ten_bytes_of_either_input() {
|
||||||
|
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||||
|
let full = [0xA5u8; 10];
|
||||||
|
for n in 0..10usize {
|
||||||
|
assert_eq!(
|
||||||
|
recover_title_key_from_plain(&full[..n], &full, &seed),
|
||||||
|
None,
|
||||||
|
"{n} ciphertext bytes is fewer than the ten the cipher iterates"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
recover_title_key_from_plain(&full, &full[..n], &seed),
|
||||||
|
None,
|
||||||
|
"{n} plaintext bytes is fewer than the ten the cipher iterates"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Exactly ten of each is ACCEPTED as far as the search — the boundary is
|
||||||
|
// `< 10`, not `<= 10`. (Whether this particular keystream has a seed is
|
||||||
|
// immaterial; what must not happen is an early `None` from the guard.)
|
||||||
|
// Proven through the round-trip fixture, whose inputs are exactly ten
|
||||||
|
// bytes and which does recover its key.
|
||||||
|
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
|
let (sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||||
|
assert_eq!(
|
||||||
|
recover_title_key_from_plain(
|
||||||
|
§or[ENCRYPTED_START..ENCRYPTED_START + 10],
|
||||||
|
&PES,
|
||||||
|
&seed
|
||||||
|
),
|
||||||
|
Some(title_key),
|
||||||
|
"exactly ten bytes of each input must run the search, not trip the guard"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The seed XOR-back ([`recover_title_key_from_plain`]'s last step) is what
|
||||||
|
/// turns the recovered LFSR key into the TITLE key: `key ^= sector_seed`.
|
||||||
|
/// Pinned as a known answer across seeds that differ only in one byte — the
|
||||||
|
/// same ciphertext/plaintext pair therefore must yield title keys differing
|
||||||
|
/// in exactly that byte.
|
||||||
|
///
|
||||||
|
/// Without this, a body that ORed the seed in (or dropped the step) still
|
||||||
|
/// round-trips on any fixture whose seed is zero, and on the non-zero ones
|
||||||
|
/// the failure looks like "no key found" rather than a wrong step.
|
||||||
|
#[test]
|
||||||
|
fn recover_title_key_from_plain_xors_the_sector_seed_back_out() {
|
||||||
|
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
|
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||||
|
let (sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||||
|
let crypted = §or[ENCRYPTED_START..ENCRYPTED_START + 10];
|
||||||
|
|
||||||
|
// The cipher is seeded from `title_key XOR seed`, so re-running the SAME
|
||||||
|
// ciphertext/plaintext against a seed differing in one byte must return
|
||||||
|
// a title key differing in exactly that byte — the XOR is a bijection.
|
||||||
|
assert_eq!(
|
||||||
|
recover_title_key_from_plain(crypted, &PES, &seed),
|
||||||
|
Some(title_key)
|
||||||
|
);
|
||||||
|
for byte in 0..5usize {
|
||||||
|
for bit in [0u32, 3, 7] {
|
||||||
|
let mut alt_seed = seed;
|
||||||
|
alt_seed[byte] ^= 1u8 << bit;
|
||||||
|
let mut expected = title_key;
|
||||||
|
expected[byte] ^= 1u8 << bit;
|
||||||
|
assert_eq!(
|
||||||
|
recover_title_key_from_plain(crypted, &PES, &alt_seed),
|
||||||
|
Some(expected),
|
||||||
|
"seed byte {byte} bit {bit} must XOR straight through to the \
|
||||||
|
title key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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,
|
0xb7, 0xf7, 0xbf, 0xa2, 0xe7, 0xa7, 0xef, 0xf2, 0xba, 0xfa, 0xb2, 0xaf, 0xea, 0xaa, 0xe2, 0xff,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Table 2: LFSR1 high-byte feedback permutation.
|
/// Table 2: LFSR1 high-byte feedback permutation — a fixed constant of the CSS
|
||||||
///
|
/// cipher (per the published algorithm).
|
||||||
/// Byte-identical to libdvdcss `p_css_tab2` (csstables.h).
|
|
||||||
pub const TAB2: [u8; 256] = [
|
pub const TAB2: [u8; 256] = [
|
||||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x09, 0x08, 0x0b, 0x0a, 0x0d, 0x0c, 0x0f, 0x0e,
|
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,
|
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,
|
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
|
/// It is the 8-value block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step
|
||||||
/// block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step indexes this
|
/// indexes this table with the 9-bit low register (0x100..=0x1FF), but only the
|
||||||
/// table with the 9-bit low register (0x100..=0x1FF), but only the low 3
|
/// low 3 bits select the output — the high bits are ignored, hence the constant
|
||||||
/// 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
|
/// blocks. The 512-entry width simply lets the 9-bit index be used without
|
||||||
/// masking.
|
/// masking.
|
||||||
pub const TAB3: [u8; 512] = [
|
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]
|
/// BASE = [0x00,0x24,0x49,0x6d,0x92,0xb6,0xdb,0xff]
|
||||||
/// repeated 64 times — `TAB3[i] == BASE[i & 7]`. The high bits of the
|
/// 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
|
/// 9-bit index do not affect the output (the LFSR1 step indexes with the
|
||||||
/// with the full 9-bit low register but only `& 7` matters). This pins
|
/// full 9-bit low register but only `& 7` matters). This pins all 512
|
||||||
/// all 512 entries to the published table.
|
/// entries to the published cipher's table.
|
||||||
///
|
///
|
||||||
/// Mutation: flip any single byte in the TAB3 literal -> the formula
|
/// Mutation: flip any single byte in the TAB3 literal -> the formula
|
||||||
/// check fails at that index.
|
/// check fails at that index.
|
||||||
|
|||||||
+1390
-525
File diff suppressed because it is too large
Load Diff
+105
-47
@@ -22,10 +22,7 @@
|
|||||||
//! `Disc`-level dump ([`dump_disc`]) covers everything that survives
|
//! `Disc`-level dump ([`dump_disc`]) covers everything that survives
|
||||||
//! lowering: titles, streams, the picked main feature, and AACS state.
|
//! lowering: titles, streams, the picked main feature, and AACS state.
|
||||||
|
|
||||||
use crate::disc::{
|
use crate::disc::{ColorSpace, Disc, DiscTitle, FrameRate, HdrFormat, Resolution, Stream};
|
||||||
AudioChannels, ColorSpace, Disc, DiscTitle, FrameRate, HdrFormat, Resolution, SampleRate,
|
|
||||||
Stream,
|
|
||||||
};
|
|
||||||
use crate::ifo::{CellCategory, DvdTitle};
|
use crate::ifo::{CellCategory, DvdTitle};
|
||||||
|
|
||||||
const DIAG: &str = "freemkv::diag";
|
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
|
// `channel_count` and `sample_rate_hz` lived here as a third copy of the
|
||||||
/// `Channels` element).
|
// AudioChannels/SampleRate mappings. They were the only HONEST copy — returning
|
||||||
pub fn channel_count(ch: AudioChannels) -> u8 {
|
// 0 for Unknown where the canonical accessors fabricated 6 channels at 48 kHz —
|
||||||
match ch {
|
// and their only caller was the trace line below, in this same file. The
|
||||||
AudioChannels::Mono => 1,
|
// canonical accessors are honest now, so the duplicates are gone rather than
|
||||||
AudioChannels::Stereo => 2,
|
// left to drift a fourth time.
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DVD cell-category dump (from the IFO scan, pre-lowering) ─────────────────
|
// ── 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
|
/// `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
|
/// [`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.
|
/// 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() {
|
if !diag_enabled() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -499,15 +472,31 @@ pub fn dump_disc(disc: &Disc) {
|
|||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
target: DIAG,
|
target: DIAG,
|
||||||
"tag=decision pick=main_feature title_idx=0 playlist={:?} dur={:.1}s \
|
"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={} reason={}",
|
||||||
main.playlist,
|
main.playlist,
|
||||||
main.duration_secs,
|
main.duration_secs,
|
||||||
main.size_bytes,
|
main.size_bytes,
|
||||||
main.clips.len(),
|
main.clips.len(),
|
||||||
|
main_feature_reason(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
format!(
|
||||||
|
"canonical_title_order({})",
|
||||||
|
Disc::CANONICAL_TITLE_ORDER_KEYS.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn dump_aacs(disc: &Disc) {
|
fn dump_aacs(disc: &Disc) {
|
||||||
let Some(a) = disc.aacs.as_ref() else {
|
let Some(a) = disc.aacs.as_ref() else {
|
||||||
if disc.css.is_some() {
|
if disc.css.is_some() {
|
||||||
@@ -530,7 +519,7 @@ fn dump_aacs(disc: &Disc) {
|
|||||||
a.bus_encryption,
|
a.bus_encryption,
|
||||||
a.mkb_version,
|
a.mkb_version,
|
||||||
a.disc_hash,
|
a.disc_hash,
|
||||||
a.key_source.name(),
|
a.key_source,
|
||||||
a.vuk.is_some(),
|
a.vuk.is_some(),
|
||||||
a.unit_keys.len(),
|
a.unit_keys.len(),
|
||||||
a.uk_ro.len(),
|
a.uk_ro.len(),
|
||||||
@@ -610,8 +599,8 @@ fn dump_title(ti: usize, title: &DiscTitle) {
|
|||||||
a.pid,
|
a.pid,
|
||||||
a.codec,
|
a.codec,
|
||||||
a.channels,
|
a.channels,
|
||||||
channel_count(a.channels),
|
a.channels.count(),
|
||||||
sample_rate_hz(a.sample_rate),
|
a.sample_rate.hz(),
|
||||||
a.language,
|
a.language,
|
||||||
a.secondary,
|
a.secondary,
|
||||||
),
|
),
|
||||||
@@ -631,6 +620,60 @@ fn dump_title(ti: usize, title: &DiscTitle) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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, "canonical_title_order(fits-disc, largest-size, longest, richest-audio)",
|
||||||
|
"the reason must name the comparator's four keys in priority order"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn res_str_keeps_interlace_marker() {
|
fn res_str_keeps_interlace_marker() {
|
||||||
@@ -656,18 +699,33 @@ mod tests {
|
|||||||
assert_eq!(hdr_str(HdrFormat::Sdr), "SDR");
|
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]
|
#[test]
|
||||||
fn channel_count_matches_layout() {
|
fn channel_count_matches_layout_and_is_zero_when_unknown() {
|
||||||
assert_eq!(channel_count(AudioChannels::Mono), 1);
|
assert_eq!(AudioChannels::Mono.count(), 1);
|
||||||
assert_eq!(channel_count(AudioChannels::Stereo), 2);
|
assert_eq!(AudioChannels::Stereo.count(), 2);
|
||||||
assert_eq!(channel_count(AudioChannels::Surround51), 6);
|
assert_eq!(AudioChannels::Surround51.count(), 6);
|
||||||
assert_eq!(channel_count(AudioChannels::Surround71), 8);
|
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]
|
#[test]
|
||||||
fn sample_rate_hz_values() {
|
fn sample_rate_hz_values_and_zero_when_unknown() {
|
||||||
assert_eq!(sample_rate_hz(SampleRate::S48), 48000);
|
assert_eq!(SampleRate::S48.hz(), 48000.0);
|
||||||
assert_eq!(sample_rate_hz(SampleRate::S96), 96000);
|
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]
|
#[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
+1465
-331
File diff suppressed because it is too large
Load Diff
+344
-56
@@ -7,13 +7,32 @@ use crate::udf;
|
|||||||
|
|
||||||
impl Disc {
|
impl Disc {
|
||||||
/// Scan DVD titles from IFO files (VIDEO_TS.IFO + VTS_XX_0.IFO).
|
/// 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.
|
||||||
pub(super) fn scan_dvd_titles(
|
pub(super) fn scan_dvd_titles(
|
||||||
reader: &mut dyn SectorSource,
|
reader: &mut dyn SectorSource,
|
||||||
udf_fs: &udf::UdfFs,
|
udf_fs: &udf::UdfFs,
|
||||||
) -> Vec<DiscTitle> {
|
halt: Option<&crate::halt::Halt>,
|
||||||
|
) -> Result<Vec<DiscTitle>> {
|
||||||
|
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||||
|
return Err(Error::Halted);
|
||||||
|
}
|
||||||
let dvd_info = match ifo::parse_vmg(reader, udf_fs) {
|
let dvd_info = match ifo::parse_vmg(reader, udf_fs) {
|
||||||
Ok(info) => info,
|
Ok(info) => info,
|
||||||
Err(_) => return Vec::new(),
|
Err(Error::Halted) => return Err(Error::Halted),
|
||||||
|
Err(_) => return Ok(Vec::new()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut titles = Vec::new();
|
let mut titles = Vec::new();
|
||||||
@@ -106,7 +125,7 @@ impl Disc {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for dvd_title in &ts.titles {
|
for (vts_title_idx, dvd_title) in ts.titles.iter().enumerate() {
|
||||||
title_number += 1;
|
title_number += 1;
|
||||||
|
|
||||||
// Diagnostic dump (--log-level 3): per-cell category table +
|
// Diagnostic dump (--log-level 3): per-cell category table +
|
||||||
@@ -114,13 +133,32 @@ impl Disc {
|
|||||||
// per-cell IFO detail. No-op unless freemkv::diag is enabled.
|
// per-cell IFO detail. No-op unless freemkv::diag is enabled.
|
||||||
crate::diag::dump_dvd_cells(ts.vts_number, title_number, dvd_title);
|
crate::diag::dump_dvd_cells(ts.vts_number, title_number, dvd_title);
|
||||||
|
|
||||||
// Bug-4 leading-cell filter: drop any leading scene-index /
|
// Feature start cell. Prefer the DVD nav-VM resolver, which
|
||||||
// interleaved-angle sub-block cells so the feature starts at the
|
// PARKED (#40, menu-at-start playback). The "menu at the start"
|
||||||
// movie. Conservative — `feature_start_cell` only ever skips a
|
// symptom (e.g. SOTL) was a sector-mapping fault — the absolute
|
||||||
// prefix of secondary-block cells and never truncates a normal
|
// VOB rebase in `ifo::parse_vts` (`vob_start_sector =
|
||||||
// feature (category 0x00 on cell 0 → no-op). See
|
// file_start_lba + vtstt_vobs`) — NOT a navigation problem, so
|
||||||
// `ifo::DvdTitle::feature_start_cell`.
|
// feature-start resolution is unnecessary for correct rips. The
|
||||||
let feature_start = dvd_title.feature_start_cell();
|
// nav resolver + verified VM decoder (`dvdnav`) are kept compiled
|
||||||
|
// but deliberately bypassed; flip `USE_NAV_RESOLVER` to re-enable
|
||||||
|
// once the nav executor is finished. The fallback is the
|
||||||
|
// structural leading-cell filter (`feature_start_cell`), which
|
||||||
|
// drops leading scene-index / interleaved-angle sub-block cells
|
||||||
|
// and is a no-op for a normal feature (category 0x00 on cell 0).
|
||||||
|
// See `dvdnav::resolve_feature_start`.
|
||||||
|
const USE_NAV_RESOLVER: bool = false;
|
||||||
|
let feature_start = if USE_NAV_RESOLVER {
|
||||||
|
crate::dvdnav::resolve_feature_start(
|
||||||
|
reader,
|
||||||
|
udf_fs,
|
||||||
|
ts.vts_number as u16,
|
||||||
|
(vts_title_idx + 1) as u16,
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|| dvd_title.feature_start_cell())
|
||||||
|
} else {
|
||||||
|
dvd_title.feature_start_cell()
|
||||||
|
}
|
||||||
|
.min(dvd_title.cells.len());
|
||||||
let dropped_secs: f64 = dvd_title.cells[..feature_start]
|
let dropped_secs: f64 = dvd_title.cells[..feature_start]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| c.duration_secs)
|
.map(|c| c.duration_secs)
|
||||||
@@ -136,9 +174,9 @@ impl Disc {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build extents from cell sector ranges (absolute = vob_start + cell offset)
|
// Build extents from cell sector ranges (absolute = vob_start + cell offset),
|
||||||
let extents: Vec<Extent> = dvd_title
|
// starting at the resolved feature-start cell.
|
||||||
.feature_cells()
|
let extents: Vec<Extent> = dvd_title.cells[feature_start..]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|cell| {
|
.map(|cell| {
|
||||||
let start = ts.vob_start_sector.saturating_add(cell.first_sector);
|
let start = ts.vob_start_sector.saturating_add(cell.first_sector);
|
||||||
@@ -160,7 +198,10 @@ impl Disc {
|
|||||||
// the coded video frame the subpicture was authored against
|
// the coded video frame the subpicture was authored against
|
||||||
// (720x480 NTSC / 720x576 PAL) so players place and scale the
|
// (720x480 NTSC / 720x576 PAL) so players place and scale the
|
||||||
// bitmap correctly.
|
// 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
|
let codec_data = dvd_title
|
||||||
.palette
|
.palette
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -221,7 +262,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +554,118 @@ mod tests {
|
|||||||
// 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")
|
||||||
|
.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
|
/// scan_dvd_titles returns empty when VIDEO_TS.IFO can't be parsed
|
||||||
/// (dvd.rs: `parse_vmg(...) Err → return Vec::new()`). Never panics.
|
/// (dvd.rs: `parse_vmg(...) Err → return Vec::new()`). Never panics.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -513,7 +673,11 @@ mod tests {
|
|||||||
let mut disc = MemDisc::new();
|
let mut disc = MemDisc::new();
|
||||||
// VIDEO_TS exists but VIDEO_TS.IFO is missing.
|
// VIDEO_TS exists but VIDEO_TS.IFO is missing.
|
||||||
let udf = build_video_ts_fs(&mut disc, &[]);
|
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")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Single VTS, single title, one cell. Extent absolute LBA =
|
/// Single VTS, single title, one cell. Extent absolute LBA =
|
||||||
@@ -549,12 +713,14 @@ mod tests {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
|
let titles = Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan");
|
||||||
assert_eq!(titles.len(), 1);
|
assert_eq!(titles.len(), 1);
|
||||||
let t = &titles[0];
|
let t = &titles[0];
|
||||||
assert_eq!(t.extents.len(), 1);
|
assert_eq!(t.extents.len(), 1);
|
||||||
// absolute start = vob_start(1000) + first_sector(10) = 1010.
|
// absolute start = ifo_lba + vtstt_vobs(1000) + first_sector(10).
|
||||||
assert_eq!(t.extents[0].start_lba, 1010);
|
// The IFO file sits at PART_START(3000) + data_lba(6000) = 9000, so
|
||||||
|
// 9000 + 1000 + 10 = 10010.
|
||||||
|
assert_eq!(t.extents[0].start_lba, 10010);
|
||||||
// inclusive: 109 - 10 + 1 = 100 sectors.
|
// inclusive: 109 - 10 + 1 = 100 sectors.
|
||||||
assert_eq!(t.extents[0].sector_count, 100);
|
assert_eq!(t.extents[0].sector_count, 100);
|
||||||
// DVD sector = 2048 bytes.
|
// DVD sector = 2048 bytes.
|
||||||
@@ -603,16 +769,89 @@ mod tests {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
|
let titles = Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan");
|
||||||
assert_eq!(titles.len(), 1);
|
assert_eq!(titles.len(), 1);
|
||||||
let t = &titles[0];
|
let t = &titles[0];
|
||||||
assert_eq!(t.extents.len(), 1);
|
assert_eq!(t.extents.len(), 1);
|
||||||
// Title VOBS (3640) + cell first_sector (0) = 3640 — NOT the menu 44.
|
// ifo_lba(9000) + vtstt_vobs(3640) + first_sector(0) = 12640 — built
|
||||||
|
// from the Title VOBS (0xC4), NOT the menu VOBS (0xC0). The IFO file is
|
||||||
|
// at PART_START(3000) + data_lba(6000) = 9000.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
t.extents[0].start_lba, 12640,
|
||||||
|
"extent must start at ifo_lba + vtstt_vobs (0xC4), not vtsm_vobs (0xC0)"
|
||||||
|
);
|
||||||
|
// Must not resolve from the menu VOBS (would be 9000 + 44 = 9044), nor
|
||||||
|
// use the raw IFO-relative vtstt_vobs (3640) without the absolute base.
|
||||||
|
assert_ne!(t.extents[0].start_lba, 9044, "must not use the menu VOBS");
|
||||||
|
assert_ne!(
|
||||||
t.extents[0].start_lba, 3640,
|
t.extents[0].start_lba, 3640,
|
||||||
"extent must start at vtstt_vobs (0xC4), not vtsm_vobs (0xC0)"
|
"must add the IFO's absolute disc LBA, not use the raw relative value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ABSOLUTE-REBASE regression (THESILENCEOFTHELAMBS / Greenland 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
|
||||||
|
/// IFO-relative `vtstt_vobs` (0xC4), and the cell's `first_sector` — none of
|
||||||
|
/// which may be dropped. The earlier code used the bare relative
|
||||||
|
/// `vtstt_vobs`, placing every extent `ifo_lba` sectors too early (the rip
|
||||||
|
/// opened in the VMGI/menu region before drifting into the movie). The
|
||||||
|
/// other tests fold two of the three terms together (zero cell offset, or
|
||||||
|
/// a single combined expectation); this one keeps all three distinct and
|
||||||
|
/// non-overlapping so a regression to ANY two-term combination is caught.
|
||||||
|
#[test]
|
||||||
|
fn scan_dvd_titles_extent_is_absolute_three_term_sum() {
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let vmg = build_vmg(&[(1, 1, 1)]);
|
||||||
|
// vtstt_vobs (Title VOBS, 0xC4) = 700; one cell first_sector = 33.
|
||||||
|
let vts = build_vts(700, 0x00, &[], &[], &[(33, 132)], false);
|
||||||
|
// IFO data at data_lba 6000 → absolute ifo_lba = PART_START(3000) + 6000.
|
||||||
|
let ifo_lba = PART_START + 6000; // 9000
|
||||||
|
let vtstt_vobs = 700u32;
|
||||||
|
let first_sector = 33u32;
|
||||||
|
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 t = &Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan")[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).
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
ifo_lba + vtstt_vobs + first_sector,
|
||||||
|
"extent start must be file_start_lba(IFO) + vtstt_vobs + cell.first_sector"
|
||||||
|
);
|
||||||
|
// Each wrong two-term combination must be rejected:
|
||||||
|
assert_ne!(
|
||||||
|
got,
|
||||||
|
vtstt_vobs + first_sector,
|
||||||
|
"must not use the bare relative vtstt_vobs (missing the IFO's absolute LBA)"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
got,
|
||||||
|
ifo_lba + first_sector,
|
||||||
|
"must not drop vtstt_vobs (the Title VOBS pointer)"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
got,
|
||||||
|
ifo_lba + vtstt_vobs,
|
||||||
|
"must not drop the cell's first_sector offset"
|
||||||
);
|
);
|
||||||
assert_ne!(t.extents[0].start_lba, 44, "must not use the menu VOBS");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Multi-cell title: extents preserve cell order and each maps to its
|
/// Multi-cell title: extents preserve cell order and each maps to its
|
||||||
@@ -646,11 +885,11 @@ 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];
|
||||||
assert_eq!(t.extents.len(), 2);
|
assert_eq!(t.extents.len(), 2);
|
||||||
assert_eq!(t.extents[0].start_lba, 500); // 500 + 0
|
assert_eq!(t.extents[0].start_lba, 9500); // ifo_lba(9000) + 500 + 0
|
||||||
assert_eq!(t.extents[0].sector_count, 100);
|
assert_eq!(t.extents[0].sector_count, 100);
|
||||||
assert_eq!(t.extents[1].start_lba, 700); // 500 + 200
|
assert_eq!(t.extents[1].start_lba, 9700); // ifo_lba(9000) + 500 + 200
|
||||||
assert_eq!(t.extents[1].sector_count, 100);
|
assert_eq!(t.extents[1].sector_count, 100);
|
||||||
assert_eq!(t.size_bytes, 200 * 2048);
|
assert_eq!(t.size_bytes, 200 * 2048);
|
||||||
}
|
}
|
||||||
@@ -687,7 +926,7 @@ 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];
|
||||||
let v = t
|
let v = t
|
||||||
.streams
|
.streams
|
||||||
.iter()
|
.iter()
|
||||||
@@ -742,7 +981,7 @@ 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];
|
||||||
let v = t
|
let v = t
|
||||||
.streams
|
.streams
|
||||||
.iter()
|
.iter()
|
||||||
@@ -803,7 +1042,7 @@ 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];
|
||||||
let audios: Vec<_> = t
|
let audios: Vec<_> = t
|
||||||
.streams
|
.streams
|
||||||
.iter()
|
.iter()
|
||||||
@@ -814,7 +1053,7 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
assert_eq!(audios.len(), 2);
|
assert_eq!(audios.len(), 2);
|
||||||
assert_eq!(audios[0].codec, Codec::Ac3);
|
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);
|
assert_eq!(audios[1].codec, Codec::Dts);
|
||||||
// Real channel layouts survive the scan (not a 1ch placeholder): the
|
// Real channel layouts survive the scan (not a 1ch placeholder): the
|
||||||
// AC-3 is 5.1 (6ch), the DTS is 2.0 (2ch).
|
// AC-3 is 5.1 (6ch), the DTS is 2.0 (2ch).
|
||||||
@@ -828,18 +1067,19 @@ mod tests {
|
|||||||
2,
|
2,
|
||||||
"DTS 2.0 nibble must decode to 2 channels"
|
"DTS 2.0 nibble must decode to 2 channels"
|
||||||
);
|
);
|
||||||
// PIDs route via the per-codec sub-id table: AC-3 #0 → 0x80 → 0xBD80,
|
// PIDs route via the positional sub-id table: AC-3 @ pos 0 → 0x80 →
|
||||||
// DTS #0 → 0x88 → 0xBD88. Distinct (no 0xBD00 collision) AND the exact
|
// 0xBD80, DTS @ pos 1 → 0x89 → 0xBD89 (the shared audio-stream number
|
||||||
// canonical PIDs.
|
// in the low nibble, NOT a per-codec ordinal). Distinct AND the exact
|
||||||
assert_eq!(audios[0].pid, 0xBD80, "AC-3 #0 → 0xBD80");
|
// canonical wire PIDs the demux routes on.
|
||||||
assert_eq!(audios[1].pid, 0xBD88, "DTS #0 → 0xBD88");
|
assert_eq!(audios[0].pid, 0xBD80, "AC-3 @ pos 0 → 0xBD80");
|
||||||
|
assert_eq!(audios[1].pid, 0xBD89, "DTS @ pos 1 → 0xBD89");
|
||||||
assert_ne!(audios[0].pid, audios[1].pid);
|
assert_ne!(audios[0].pid, audios[1].pid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LPCM SCAN ROUTING (audit §2 / §5 #6): the 0xA0..=0xA7 PID range was never
|
/// LPCM SCAN ROUTING (audit §2 / §5 #6): the 0xA0..=0xA7 PID range was never
|
||||||
/// exercised in the dvd.rs scan. An LPCM stream (coding_mode 4) must get
|
/// exercised in the dvd.rs scan. An LPCM stream (coding_mode 4) at audio
|
||||||
/// sub_stream_id 0xA0 → PID 0xBDA0 via `dvd_audio_pid`, distinct from the
|
/// position 1 must get sub_stream_id 0xA1 → PID 0xBDA1 via `dvd_audio_pid`,
|
||||||
/// AC-3 0xBD80 space, with its real channel count preserved.
|
/// distinct from the AC-3 0xBD80 space, with its real channel count preserved.
|
||||||
#[test]
|
#[test]
|
||||||
fn scan_dvd_titles_lpcm_routes_to_a0_pid_range() {
|
fn scan_dvd_titles_lpcm_routes_to_a0_pid_range() {
|
||||||
let mut disc = MemDisc::new();
|
let mut disc = MemDisc::new();
|
||||||
@@ -872,7 +1112,7 @@ 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];
|
||||||
let audios: Vec<_> = t
|
let audios: Vec<_> = t
|
||||||
.streams
|
.streams
|
||||||
.iter()
|
.iter()
|
||||||
@@ -884,10 +1124,10 @@ mod tests {
|
|||||||
assert_eq!(audios.len(), 2);
|
assert_eq!(audios.len(), 2);
|
||||||
assert_eq!(audios[0].codec, Codec::Ac3);
|
assert_eq!(audios[0].codec, Codec::Ac3);
|
||||||
assert_eq!(audios[1].codec, Codec::Lpcm, "coding_mode 4 → LPCM");
|
assert_eq!(audios[1].codec, Codec::Lpcm, "coding_mode 4 → LPCM");
|
||||||
assert_eq!(audios[0].pid, 0xBD80, "AC-3 #0 → 0xBD80");
|
assert_eq!(audios[0].pid, 0xBD80, "AC-3 @ pos 0 → 0xBD80");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
audios[1].pid, 0xBDA0,
|
audios[1].pid, 0xBDA1,
|
||||||
"LPCM #0 → 0xBDA0 (the 0xA0 sub-id range), NOT the AC-3 space"
|
"LPCM @ pos 1 → 0xBDA1 (the 0xA0 sub-id range | position), NOT the AC-3 space"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
audios[1].channels.count(),
|
audios[1].channels.count(),
|
||||||
@@ -929,7 +1169,7 @@ 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];
|
||||||
let subs: Vec<_> = t
|
let subs: Vec<_> = t
|
||||||
.streams
|
.streams
|
||||||
.iter()
|
.iter()
|
||||||
@@ -942,7 +1182,7 @@ mod tests {
|
|||||||
// Languages preserved in order.
|
// Languages preserved in order.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
subs.iter().map(|s| s.language.as_str()).collect::<Vec<_>>(),
|
subs.iter().map(|s| s.language.as_str()).collect::<Vec<_>>(),
|
||||||
vec!["en", "fr", "de"]
|
vec!["eng", "fra", "deu"]
|
||||||
);
|
);
|
||||||
// PIDs are 0x20 + ordinal, all distinct.
|
// PIDs are 0x20 + ordinal, all distinct.
|
||||||
let pids: Vec<u16> = subs.iter().map(|s| s.pid).collect();
|
let pids: Vec<u16> = subs.iter().map(|s| s.pid).collect();
|
||||||
@@ -989,7 +1229,7 @@ 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];
|
||||||
let sub = t
|
let sub = t
|
||||||
.streams
|
.streams
|
||||||
.iter()
|
.iter()
|
||||||
@@ -999,7 +1239,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.expect("subtitle stream");
|
.expect("subtitle stream");
|
||||||
assert_eq!(sub.codec, Codec::DvdSub);
|
assert_eq!(sub.codec, Codec::DvdSub);
|
||||||
assert_eq!(sub.language, "en");
|
assert_eq!(sub.language, "eng");
|
||||||
assert!(
|
assert!(
|
||||||
sub.codec_data.is_some(),
|
sub.codec_data.is_some(),
|
||||||
"non-zero palette must yield codec_data"
|
"non-zero palette must yield codec_data"
|
||||||
@@ -1043,7 +1283,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
|
let titles = Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan");
|
||||||
assert_eq!(titles.len(), 2);
|
assert_eq!(titles.len(), 2);
|
||||||
// title_number is a running counter across all title sets.
|
// title_number is a running counter across all title sets.
|
||||||
assert_eq!(titles[0].playlist_id, 1);
|
assert_eq!(titles[0].playlist_id, 1);
|
||||||
@@ -1051,8 +1291,9 @@ mod tests {
|
|||||||
assert_eq!(titles[0].playlist, "VTS_01_1.VOB");
|
assert_eq!(titles[0].playlist, "VTS_01_1.VOB");
|
||||||
assert_eq!(titles[1].playlist, "VTS_02_2.VOB");
|
assert_eq!(titles[1].playlist, "VTS_02_2.VOB");
|
||||||
// Distinct vob_start → distinct extents.
|
// Distinct vob_start → distinct extents.
|
||||||
assert_eq!(titles[0].extents[0].start_lba, 100);
|
// VTS_01 IFO @ PART_START(3000)+6000=9000; VTS_02 IFO @ 3000+7000=10000.
|
||||||
assert_eq!(titles[1].extents[0].start_lba, 200);
|
assert_eq!(titles[0].extents[0].start_lba, 9100); // 9000 + 100
|
||||||
|
assert_eq!(titles[1].extents[0].start_lba, 10200); // 10000 + 200
|
||||||
}
|
}
|
||||||
|
|
||||||
/// chapter_times from the IFO become Chapter entries with ordinal
|
/// chapter_times from the IFO become Chapter entries with ordinal
|
||||||
@@ -1079,7 +1320,7 @@ 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];
|
||||||
// One program in the program map → one chapter time (0.0 for the
|
// One program in the program map → one chapter time (0.0 for the
|
||||||
// first program). Name is the ordinal from chapter_name(0).
|
// first program). Name is the ordinal from chapter_name(0).
|
||||||
assert_eq!(t.chapters.len(), 1);
|
assert_eq!(t.chapters.len(), 1);
|
||||||
@@ -1164,12 +1405,12 @@ 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];
|
||||||
// The leading 0x90 cell is dropped: 2 feature extents, not 3.
|
// The leading 0x90 cell is dropped: 2 feature extents, not 3.
|
||||||
assert_eq!(t.extents.len(), 2, "leading angle sub-block cell dropped");
|
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.
|
// First extent starts at the feature cell (vob 1000 + 100), not at 1000+0.
|
||||||
assert_eq!(t.extents[0].start_lba, 1000 + 100);
|
assert_eq!(t.extents[0].start_lba, 9000 + 1000 + 100); // ifo_lba + vtstt + first
|
||||||
assert_eq!(t.extents[1].start_lba, 1000 + 300);
|
assert_eq!(t.extents[1].start_lba, 9000 + 1000 + 300);
|
||||||
// Chapter times shift earlier by the dropped 5s. Program 0 was at the
|
// Chapter times shift earlier by the dropped 5s. Program 0 was at the
|
||||||
// dropped head (clamped to 0); program 1 was at cell 3 =
|
// dropped head (clamped to 0); program 1 was at cell 3 =
|
||||||
// dur(cell0)+dur(cell1) = 5 + 59 = 64s, now 59s after the 5s shift.
|
// dur(cell0)+dur(cell1) = 5 + 59 = 64s, now 59s after the 5s shift.
|
||||||
@@ -1216,12 +1457,59 @@ 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];
|
||||||
// Nothing dropped: both cells become extents, starting at the very head.
|
// Nothing dropped: both cells become extents, starting at the very head.
|
||||||
assert_eq!(t.extents.len(), 2);
|
assert_eq!(t.extents.len(), 2);
|
||||||
assert_eq!(t.extents[0].start_lba, 1000); // 1000 + 0, head intact
|
assert_eq!(t.extents[0].start_lba, 9000 + 1000); // ifo_lba + vtstt + 0, head intact
|
||||||
assert_eq!(t.extents[1].start_lba, 1200);
|
assert_eq!(t.extents[1].start_lba, 9000 + 1200);
|
||||||
// Chapter 0 stays at 0.0 (no shift).
|
// Chapter 0 stays at 0.0 (no shift).
|
||||||
assert!((t.chapters[0].time_secs - 0.0).abs() < 0.01);
|
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");
|
||||||
|
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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+173
-5
@@ -104,10 +104,10 @@ fn max_substream_channels(data: &[u8]) -> Option<u8> {
|
|||||||
};
|
};
|
||||||
let start = pos + rel;
|
let start = pos + rel;
|
||||||
let frame = &data[start..];
|
let frame = &data[start..];
|
||||||
if let Some(ch) = ac3::acmod_channels(frame) {
|
if let Some(ch) = ac3::acmod_channels(frame)
|
||||||
if ch > 0 {
|
&& ch > 0
|
||||||
best = Some(best.map_or(ch, |b| b.max(ch)));
|
{
|
||||||
}
|
best = Some(best.map_or(ch, |b| b.max(ch)));
|
||||||
}
|
}
|
||||||
// Advance past this frame by its declared size when that is mappable;
|
// 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.
|
// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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
|
/// Build a single, correctly-SIZED AC-3 frame whose `acmod`/`lfeon` encode a
|
||||||
/// known channel count. `byte4` is `fscod=0 | frmsizecod=0`, so
|
/// known channel count. `byte4` is `fscod=0 | frmsizecod=0`, so
|
||||||
@@ -463,4 +467,168 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(a.pid, 0xBD80, "no probe data → keep ordinal");
|
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 Silence-of-the-Lambs-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_silence_of_the_lambs_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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+372
-260
@@ -7,51 +7,106 @@ use crate::udf;
|
|||||||
|
|
||||||
/// Result of SCSI AACS handshake (ECDH authentication).
|
/// Result of SCSI AACS handshake (ECDH authentication).
|
||||||
/// Only available when scanning from a real drive, not ISO images.
|
/// Only available when scanning from a real drive, not ISO images.
|
||||||
#[derive(Debug)]
|
|
||||||
pub(super) struct HandshakeResult {
|
pub(super) struct HandshakeResult {
|
||||||
pub volume_id: [u8; 16],
|
pub volume_id: [u8; 16],
|
||||||
pub read_data_key: Option<[u8; 16]>,
|
pub read_data_key: Option<[u8; 16]>,
|
||||||
|
/// When `read_data_key` is `None` because the bus-key read FAILED (as opposed
|
||||||
|
/// to a path that never attempts it), the error code from `read_data_keys`.
|
||||||
|
/// Carried so the downstream bus-key gate can log WHY the bus key is missing
|
||||||
|
/// instead of a bare "unavailable" — the difference between a diagnosable log
|
||||||
|
/// and archaeology.
|
||||||
|
pub read_data_key_err: Option<u16>,
|
||||||
|
/// True when the VID came from an unlocker (in `freemkv-unlock`) that
|
||||||
|
/// unlocked the drive. Such a drive serves CLEAR
|
||||||
|
/// content, so AACS bus encryption is already removed AT THE DRIVE — the same
|
||||||
|
/// end state a successful cert handshake's `read_data_key` provides, just via
|
||||||
|
/// firmware instead of the AKE. The bus-key gate MUST credit this as a valid
|
||||||
|
/// bus-removal: bus encryption is unremovable only when NEITHER the firmware
|
||||||
|
/// unlocked the drive NOR the cert handshake yielded a bus key. Without this,
|
||||||
|
/// a SUCCESSFUL unlock (VID present, `read_data_key: None`) paradoxically trips
|
||||||
|
/// the gate and blocks ALL key resolution (incl. the online source).
|
||||||
|
pub drive_unlocked: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-tree AACS host-certificate cert-auth "unlocker" — the Drive-level peer of
|
// Redacting `Debug`: `volume_id` and `read_data_key` (the AACS 2.0 bus key) are
|
||||||
/// the external firmware [`crate::unlock::Unlocker`]s.
|
// 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:
|
||||||
|
/// - the disc never had it (`!bus_encryption`): nothing to remove;
|
||||||
|
/// - file/ISO reads (`handshake == None`): content is already clear at read time;
|
||||||
|
/// - an unlocker unlocked the drive (`drive_unlocked`): it serves clear
|
||||||
|
/// content;
|
||||||
|
/// - the cert handshake produced the bus key (`read_data_key`).
|
||||||
///
|
///
|
||||||
/// It is NOT a registry `dyn Unlocker`: the cert handshake helpers
|
/// Add a NEW removal mechanism HERE, never in the gate.
|
||||||
/// ([`crate::aacs::handshake::aacs_authenticate`] et al.) operate on a concrete
|
fn bus_encryption_removed(bus_encryption: bool, handshake: Option<&HandshakeResult>) -> bool {
|
||||||
/// `&mut Drive`, whereas the registry trait hands out a `&mut dyn ScsiTransport`
|
if !bus_encryption {
|
||||||
/// for external firmware unlockers (and keeps their unit tests trivially
|
return true; // never had it → nothing to remove
|
||||||
/// fakeable). So the firmware path stays transport-level and registry-routed,
|
}
|
||||||
/// while this cert path is an in-tree Drive-level peer invoked directly by
|
match handshake {
|
||||||
/// [`Disc::do_handshake`]. Both produce a Volume ID under the shared
|
None => true, // file/ISO: clear at read time
|
||||||
/// [`crate::unlock::UnlockError`] taxonomy.
|
Some(h) => h.drive_unlocked || h.read_data_key.is_some(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// libfreemkv-side driver for the AACS cert route. It owns the host-cert
|
||||||
|
/// collection (a keysource concern that stays in libfreemkv) and then dispatches
|
||||||
|
/// the actual mutual-auth to the `freemkv-unlock` AACS unlocker via the
|
||||||
|
/// [`crate::unlock_bridge`]. The firmware (drive-prep) and CSS routes dispatch
|
||||||
|
/// the same way at their own call sites; this one carries the host certs.
|
||||||
struct AacsCertUnlocker<'a> {
|
struct AacsCertUnlocker<'a> {
|
||||||
opts: &'a ScanOptions,
|
opts: &'a ScanOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why the AACS cert path produced no Volume ID. Distinguishes the libfreemkv-
|
||||||
|
/// side "no host cert at all" case (which carries the disc MKB generation for
|
||||||
|
/// the outcome trace) from the unlocker-reported [`freemkv_unlock::UnlockError`].
|
||||||
|
enum CertUnlockFailure {
|
||||||
|
/// No host cert was available from any source — detected in libfreemkv
|
||||||
|
/// before the unlocker runs, so the MKB generation is still known.
|
||||||
|
NoHostCert { mkb: Option<u32> },
|
||||||
|
/// The AACS unlocker ran and reported a specific failure.
|
||||||
|
Unlock(freemkv_unlock::UnlockError),
|
||||||
|
}
|
||||||
|
|
||||||
impl AacsCertUnlocker<'_> {
|
impl AacsCertUnlocker<'_> {
|
||||||
/// Run the host-certificate mutual-auth handshake: collect non-compiled-in
|
/// Run the host-certificate mutual-auth handshake: collect non-compiled-in
|
||||||
/// host certs from the key sources + credentials, try each (wedge-guarded),
|
/// host certs from the key sources + credentials, then hand them to the AACS
|
||||||
/// and on success read the Volume ID + `read_data_key` (the AACS 2.0 bus
|
/// unlocker (via the `freemkv-unlock` dispatch), which tries each cert
|
||||||
/// key). Returns a structured [`crate::unlock::UnlockError`] on every
|
/// (wedge-guarded) and on success yields the Volume ID + `read_data_key`
|
||||||
/// no-VID outcome.
|
/// (the AACS 2.0 bus key). Returns a [`CertUnlockFailure`] on every no-VID
|
||||||
|
/// outcome.
|
||||||
fn authenticate(
|
fn authenticate(
|
||||||
&self,
|
&self,
|
||||||
session: &mut crate::drive::Drive,
|
session: &mut crate::drive::Drive,
|
||||||
) -> std::result::Result<HandshakeResult, crate::unlock::UnlockError> {
|
) -> std::result::Result<HandshakeResult, CertUnlockFailure> {
|
||||||
use crate::aacs;
|
use crate::aacs;
|
||||||
use crate::unlock::UnlockError;
|
use freemkv_unlock::UnlockError;
|
||||||
|
|
||||||
// MKB generation (best-effort) — forwarded to each source's
|
// MKB generation (best-effort) — forwarded to each source's
|
||||||
// `host_certs(mkb)` so a source MAY select a generation-appropriate cert
|
// `host_certs(mkb)` so a source MAY select a generation-appropriate cert
|
||||||
// (the default impl ignores it). A read failure leaves it `None`.
|
// (the default impl ignores it). A read failure leaves it `None`.
|
||||||
let mkb_gen = aacs::read_mkb_from_drive(session)
|
let mkb_gen = aacs::inf::read_mkb_from_drive(session.scsi_mut())
|
||||||
.ok()
|
.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
|
// Host certs are keysource-served, never compiled in — unioned from the
|
||||||
// explicit `DriveCredentials` and the key-source layer. With ZERO certs
|
// explicit `DriveCredentials` and the key-source layer. With ZERO certs
|
||||||
// the cert route cannot run: NoUsableHostCert (folded to AacsNoHostCert
|
// the cert route cannot run: NoHostCert (folded to AacsNoHostCert by the
|
||||||
// by the caller, preserving the graceful path-1 disc-hash → VUK fallback).
|
// caller, preserving the graceful path-1 disc-hash → VUK fallback). This
|
||||||
|
// is detected here, where the MKB generation is still in hand.
|
||||||
let host_certs = Disc::collect_host_certs(self.opts, mkb_gen);
|
let host_certs = Disc::collect_host_certs(self.opts, mkb_gen);
|
||||||
if host_certs.is_empty() {
|
if host_certs.is_empty() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -59,135 +114,101 @@ impl AacsCertUnlocker<'_> {
|
|||||||
phase = "handshake_no_host_cert",
|
phase = "handshake_no_host_cert",
|
||||||
"No AACS host certificate available from any key source, so the host-certificate handshake can't run."
|
"No AACS host certificate available from any key source, so the host-certificate handshake can't run."
|
||||||
);
|
);
|
||||||
return Err(UnlockError::NoUsableHostCert { mkb: mkb_gen });
|
return Err(CertUnlockFailure::NoHostCert { mkb: mkb_gen });
|
||||||
}
|
}
|
||||||
let host_cert_count = host_certs.len();
|
|
||||||
tracing::debug!(
|
|
||||||
target: "freemkv::disc",
|
|
||||||
phase = "handshake_start",
|
|
||||||
host_cert_count,
|
|
||||||
"handshake starting"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cert-attempt wedge guard. An earlier version fired up to 16 AACS
|
// Hand the collected certs to the AACS unlocker. The cert-route bus
|
||||||
// authenticate attempts back-to-back with no pause — 80-160 SCSI
|
// removal depends on the read_data_key, NOT a drive unlock. The
|
||||||
// REPORT_KEY/SEND_KEY commands in a few hundred ms, which can drive
|
// borrow checker can't split `session` across `scsi_mut()` + `&drive_id`
|
||||||
// consumer optical drives into a fast-fail firmware wedge (every CDB
|
// through method calls, so clone the (cheap) identity first.
|
||||||
// returns ILLEGAL_REQUEST until power-cycled). Defense-in-depth: cap
|
let drive_id = session.drive_id.clone();
|
||||||
// attempts, sleep between, bail early on the drive's wedge sense.
|
let fu_certs = crate::unlock_bridge::map_host_certs(&host_certs);
|
||||||
const MAX_CERT_ATTEMPTS: usize = 3;
|
let (_, unlock_res) = crate::unlock_bridge::run_bus(
|
||||||
const PER_CERT_BACKOFF_MS: u64 = 1000;
|
session.scsi_mut(),
|
||||||
let mut last_err_code: Option<u16> = None;
|
&drive_id,
|
||||||
for (idx, hc) in host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
|
freemkv_unlock::DiscKind::Aacs,
|
||||||
if idx > 0 {
|
&fu_certs,
|
||||||
std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS));
|
|
||||||
}
|
|
||||||
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
|
|
||||||
Ok(mut auth) => {
|
|
||||||
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
|
|
||||||
Ok(vid) => vid,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "freemkv::disc",
|
|
||||||
phase = "handshake_vid_read_failed",
|
|
||||||
cert_index = idx,
|
|
||||||
error_code = e.code(),
|
|
||||||
"auth ok but volume ID read failed"
|
|
||||||
);
|
|
||||||
return Err(UnlockError::VidUnavailable);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
|
|
||||||
.ok()
|
|
||||||
.map(|(rdk, _)| rdk);
|
|
||||||
tracing::debug!(
|
|
||||||
target: "freemkv::disc",
|
|
||||||
phase = "handshake_ok",
|
|
||||||
cert_index = idx,
|
|
||||||
has_read_data_key = read_data_key.is_some(),
|
|
||||||
);
|
|
||||||
return Ok(HandshakeResult {
|
|
||||||
volume_id,
|
|
||||||
read_data_key,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
last_err_code = Some(e.code());
|
|
||||||
// Read the wedge sense off the structured ScsiSense, NOT
|
|
||||||
// `e.code()` (a flat constant for every ScsiError). On
|
|
||||||
// ILLEGAL_REQUEST the drive is signalling it won't talk to us
|
|
||||||
// — trying more certs worsens the wedge, so bail immediately.
|
|
||||||
let sense = e.scsi_sense();
|
|
||||||
if sense.map(|s| s.is_illegal_request()).unwrap_or(false) {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "freemkv::disc",
|
|
||||||
phase = "handshake_wedge_detected",
|
|
||||||
cert_index = idx,
|
|
||||||
sense_key = sense.map(|s| s.sense_key),
|
|
||||||
asc = sense.map(|s| s.asc),
|
|
||||||
ascq = sense.map(|s| s.ascq),
|
|
||||||
"drive returned ILLEGAL_REQUEST during auth; bailing out to avoid wedge"
|
|
||||||
);
|
|
||||||
return Err(UnlockError::HandshakeRejected);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tracing::info!(
|
|
||||||
target: "freemkv::disc",
|
|
||||||
phase = "vid_cert_rejected",
|
|
||||||
host_cert_count,
|
|
||||||
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
|
|
||||||
last_error_code = last_err_code,
|
|
||||||
"The drive rejected the AACS host certificate, so no Volume ID was obtained."
|
|
||||||
);
|
);
|
||||||
Err(UnlockError::HandshakeRejected)
|
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));
|
||||||
|
};
|
||||||
|
Ok(HandshakeResult {
|
||||||
|
volume_id,
|
||||||
|
read_data_key: unlocked.bus_key,
|
||||||
|
// The generic `Unlocked` contract carries no bus-key error code; the
|
||||||
|
// AACS-specific "why the read_data_key read failed" diagnostic does
|
||||||
|
// not cross the seam. The bus-key gate keys off presence, not cause.
|
||||||
|
read_data_key_err: None,
|
||||||
|
drive_unlocked: unlocked.drive_unlocked,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map an [`crate::unlock::UnlockError`] from the cert path back to the
|
/// Map a [`CertUnlockFailure`] back to the `Error` variant `do_handshake_cert`
|
||||||
/// `Error` variant `do_handshake_cert` has always surfaced, so `scan_with`'s
|
/// has always surfaced, so `scan_with`'s rendering and the path-1 disc-hash →
|
||||||
/// rendering and the path-1 disc-hash → VUK fallback are byte-for-byte
|
/// VUK fallback are byte-for-byte unchanged. (`NoHostCert` keeps the
|
||||||
/// unchanged. (`NoUsableHostCert` keeps the `<no host cert>` sentinel.)
|
/// `<no host cert>` sentinel.)
|
||||||
fn unlock_error_to_error(e: crate::unlock::UnlockError) -> Error {
|
fn unlock_error_to_error(e: &CertUnlockFailure) -> Error {
|
||||||
use crate::unlock::UnlockError;
|
use freemkv_unlock::UnlockError;
|
||||||
match e {
|
match e {
|
||||||
UnlockError::NoUsableHostCert { .. } => Error::AacsNoHostCert {
|
CertUnlockFailure::NoHostCert { .. }
|
||||||
|
| CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => Error::AacsNoHostCert {
|
||||||
path: "<no host cert>".into(),
|
path: "<no host cert>".into(),
|
||||||
},
|
},
|
||||||
UnlockError::VidUnavailable => Error::AacsVidUnavailable,
|
CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => Error::AacsVidUnavailable,
|
||||||
UnlockError::HandshakeRejected
|
CertUnlockFailure::Unlock(
|
||||||
| UnlockError::CertRevoked { .. }
|
UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport,
|
||||||
| UnlockError::FirmwareNotUnlockable
|
) => Error::AacsHostCertRejected,
|
||||||
| UnlockError::Scsi(_) => Error::AacsHostCertRejected,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a cert-path [`crate::unlock::UnlockError`] to a structured
|
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::trace::UnlockOutcome`]
|
||||||
/// [`crate::aacs::UnlockOutcome`] for the resolution trace (English-free).
|
/// for the resolution trace (English-free).
|
||||||
fn cert_unlock_outcome(e: &crate::unlock::UnlockError) -> crate::aacs::UnlockOutcome {
|
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::trace::UnlockOutcome {
|
||||||
use crate::aacs::UnlockOutcome;
|
use crate::aacs::trace::UnlockOutcome;
|
||||||
use crate::unlock::UnlockError;
|
use freemkv_unlock::UnlockError;
|
||||||
match e {
|
match e {
|
||||||
UnlockError::FirmwareNotUnlockable => UnlockOutcome::FirmwareNotUnlockable,
|
CertUnlockFailure::NoHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
|
||||||
UnlockError::NoUsableHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
|
CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => {
|
||||||
UnlockError::CertRevoked { mkb } => UnlockOutcome::CertRevoked { mkb: *mkb },
|
UnlockOutcome::NoUsableHostCert { mkb: None }
|
||||||
UnlockError::VidUnavailable => UnlockOutcome::VidUnavailable,
|
}
|
||||||
UnlockError::HandshakeRejected | UnlockError::Scsi(_) => UnlockOutcome::HandshakeRejected,
|
CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => UnlockOutcome::VidUnavailable,
|
||||||
|
CertUnlockFailure::Unlock(
|
||||||
|
UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport,
|
||||||
|
) => UnlockOutcome::HandshakeRejected,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
impl Disc {
|
||||||
/// SCSI handshake — drives the VID-acquisition flow and returns
|
/// SCSI handshake — drives the VID-acquisition flow and returns
|
||||||
/// a structured `HandshakeResult` for downstream key resolution.
|
/// a structured `HandshakeResult` for downstream key resolution.
|
||||||
///
|
///
|
||||||
/// VID acquisition runs through [`Self::do_handshake_cert`], which first
|
/// VID acquisition runs through [`Self::do_handshake_cert`], which first
|
||||||
/// asks the pluggable [`crate::unlock::Unlocker`] seam for the OEM VID
|
/// uses the OEM VID a firmware unlocker may have stashed at drive `init()`
|
||||||
/// (a drive-functionality capability decoupled from the host cert + HRL)
|
/// (a drive-functionality capability decoupled from the host cert + HRL)
|
||||||
/// and falls back to the cert-based mutual-auth handshake when no
|
/// and falls back to the cert-based mutual-auth handshake (dispatched to the
|
||||||
/// unlocker serves one. The cert path also yields `read_data_key`,
|
/// `freemkv-unlock` AACS unlocker) when none is present. The cert path also
|
||||||
/// required for AACS 2.0 bus decryption.
|
/// yields `read_data_key`, required for AACS 2.0 bus decryption.
|
||||||
///
|
///
|
||||||
/// Returns `(handshake, error)`:
|
/// Returns `(handshake, error)`:
|
||||||
/// * `(Some(_), None)` — VID acquired
|
/// * `(Some(_), None)` — VID acquired
|
||||||
@@ -219,11 +240,11 @@ impl Disc {
|
|||||||
|
|
||||||
/// Cert-based AACS handshake — the cert route for VID acquisition.
|
/// Cert-based AACS handshake — the cert route for VID acquisition.
|
||||||
///
|
///
|
||||||
/// Before running the cert mutual-auth, this asks the pluggable
|
/// Before running the cert mutual-auth, this checks for an OEM Volume ID a
|
||||||
/// [`crate::unlock::Unlocker`] seam for the OEM Volume ID. An unlocker
|
/// firmware unlocker stashed at drive `init()`. Such an unlocker unlocks
|
||||||
/// unlocks *drive functionality*, not just the disc: VID retrieval via
|
/// *drive functionality*, not just the disc: VID retrieval via the drive's
|
||||||
/// the drive's OEM CDB is a capability separate from `unlock`. When the
|
/// OEM CDB is a capability separate from `unlock`. When one served a VID,
|
||||||
/// matching unlocker serves a VID, we use it and SKIP the cert handshake
|
/// we use it and SKIP the cert handshake
|
||||||
/// entirely — the OEM path gets the VID *without* the host certificate +
|
/// entirely — the OEM path gets the VID *without* the host certificate +
|
||||||
/// HRL, decoupling VID from the cert chain. The OEM path yields no
|
/// HRL, decoupling VID from the cert chain. The OEM path yields no
|
||||||
/// `read_data_key` (no bus-key is derived); AACS 2.0 content needing
|
/// `read_data_key` (no bus-key is derived); AACS 2.0 content needing
|
||||||
@@ -239,24 +260,23 @@ impl Disc {
|
|||||||
/// `mkb` is the disc's MKB generation when known, forwarded to each source's
|
/// `mkb` is the disc's MKB generation when known, forwarded to each source's
|
||||||
/// [`crate::KeySource::host_certs`] so a source MAY return only
|
/// [`crate::KeySource::host_certs`] so a source MAY return only
|
||||||
/// generation-appropriate certs (the default ignores it).
|
/// generation-appropriate certs (the default ignores it).
|
||||||
fn collect_host_certs(opts: &ScanOptions, mkb: Option<u32>) -> Vec<crate::aacs::HostCert> {
|
fn collect_host_certs(
|
||||||
let mut host_certs: Vec<crate::aacs::HostCert> = Vec::new();
|
opts: &ScanOptions,
|
||||||
if let Some(c) = &opts.credentials {
|
mkb: Option<u32>,
|
||||||
host_certs.extend(c.host_certs.iter().cloned());
|
) -> Vec<crate::aacs::types::HostCert> {
|
||||||
}
|
// Delegates to the shared cert primitive (the external freemkv-unlock-aacs
|
||||||
for src in &opts.key_sources {
|
// plugin uses the same one). Kept as a thin Disc method so the existing
|
||||||
host_certs.extend(src.host_certs(mkb));
|
// collect_host_certs_* unit tests and call sites are unchanged.
|
||||||
}
|
crate::aacs::host_certs::collect_host_certs(opts, mkb)
|
||||||
host_certs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn do_handshake_cert(
|
fn do_handshake_cert(
|
||||||
session: &mut crate::drive::Drive,
|
session: &mut crate::drive::Drive,
|
||||||
opts: &ScanOptions,
|
opts: &ScanOptions,
|
||||||
) -> (Option<HandshakeResult>, Option<Error>) {
|
) -> (Option<HandshakeResult>, Option<Error>) {
|
||||||
// OEM VID shortcut: a matching firmware unlocker stashed the disc's
|
// OEM VID shortcut: a matching unlocker stashed the disc's Volume ID at
|
||||||
// Volume ID at drive `init()` (the new `unlock()` folds in the old
|
// drive `init()` (the new `unlock()` folds in the old `read_volume_id`).
|
||||||
// `read_volume_id`). Use it and SKIP the cert handshake — the OEM path
|
// Use it and SKIP the cert handshake — the OEM path
|
||||||
// decouples the VID from the host cert + HRL. It yields no
|
// decouples the VID from the host cert + HRL. It yields no
|
||||||
// `read_data_key`; a bus-encrypted disc that needs the bus key is caught
|
// `read_data_key`; a bus-encrypted disc that needs the bus key is caught
|
||||||
// by the bus-key gate in `resolve_vid_only`.
|
// by the bus-key gate in `resolve_vid_only`.
|
||||||
@@ -270,6 +290,13 @@ impl Disc {
|
|||||||
Some(HandshakeResult {
|
Some(HandshakeResult {
|
||||||
volume_id,
|
volume_id,
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
// OEM/VID-only path never attempts the bus-key read — None here
|
||||||
|
// is "not attempted", not "failed".
|
||||||
|
read_data_key_err: None,
|
||||||
|
// The unlocker stashed this VID at init, which means it
|
||||||
|
// matched and unlocked the drive — it now serves clear content,
|
||||||
|
// so bus encryption is removed at the drive. Credit it.
|
||||||
|
drive_unlocked: true,
|
||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@@ -296,7 +323,7 @@ impl Disc {
|
|||||||
outcome = ?cert_unlock_outcome(&e),
|
outcome = ?cert_unlock_outcome(&e),
|
||||||
"AACS cert handshake produced no VID; a key source may still supply this disc's key."
|
"AACS cert handshake produced no VID; a key source may still supply this disc's key."
|
||||||
);
|
);
|
||||||
(None, Some(unlock_error_to_error(e)))
|
(None, Some(unlock_error_to_error(&e)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,70 +341,99 @@ impl Disc {
|
|||||||
) -> Result<AacsState> {
|
) -> Result<AacsState> {
|
||||||
use crate::aacs;
|
use crate::aacs;
|
||||||
|
|
||||||
let uk_ro_data = udf_fs
|
let uk_ro_data =
|
||||||
.read_file(reader, "/AACS/Unit_Key_RO.inf")
|
aacs::read_first(&aacs::role_paths(udf_fs, aacs::AacsRole::UnitKey), |p| {
|
||||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
udf_fs.read_file(reader, p)
|
||||||
.map_err(|_| Error::AacsNoKeys)?;
|
})?;
|
||||||
let dh = aacs::disc_hash(&uk_ro_data);
|
let dh = aacs::inf::disc_hash(&uk_ro_data);
|
||||||
|
|
||||||
let cc = udf_fs
|
let cc = aacs::read_first(
|
||||||
.read_file(reader, "/AACS/Content000.cer")
|
&aacs::role_paths(udf_fs, aacs::AacsRole::ContentCert),
|
||||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
|
|p| udf_fs.read_file(reader, p),
|
||||||
.ok()
|
)
|
||||||
.as_deref()
|
.ok()
|
||||||
.and_then(aacs::parse_content_cert);
|
.as_deref()
|
||||||
|
.and_then(aacs::inf::parse_content_cert);
|
||||||
let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false);
|
let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false);
|
||||||
let version = match cc.as_ref().map(|c| c.version) {
|
// No-cert default = UHD (V20 stride), matching `read_aacs_version` so the
|
||||||
Some(aacs::AacsVersion::V10) => 1,
|
// scanned `AacsState.version` and the out-of-band fetch agree. A wrong
|
||||||
Some(_) => 2,
|
// stride on the main resolve path fails loudly (sample validation) rather
|
||||||
None if bus_encryption => 2,
|
// than silently, so the conservative V20 default is safe here too.
|
||||||
None => 1,
|
let version = cc
|
||||||
};
|
.as_ref()
|
||||||
|
.map(|c| c.version.major())
|
||||||
|
.unwrap_or(aacs::mkb::AACS_MAJOR_UHD);
|
||||||
|
|
||||||
// OEM bus-key gate (wrong-keys guard). A bus-encrypted disc (Content
|
// Bus-encryption gate (wrong-keys guard). A bus-encrypted disc (Content
|
||||||
// Certificate bus-encryption bit set) still carries bus encryption on
|
// Certificate bus-encryption bit set) carries bus encryption on its
|
||||||
// its sectors; descrambling needs the `read_data_key` (bus key), which
|
// sectors, which MUST be removed before any AACS key can decrypt them.
|
||||||
// ONLY the AACS host-certificate cert-auth handshake produces. A
|
// There are TWO ways it gets removed, and bus encryption is unremovable
|
||||||
// VID-only OEM unlock path returns `read_data_key: None`, and a VID
|
// only when NEITHER succeeded:
|
||||||
// alone does NOT remove bus encryption — so if a handshake ran (live
|
// 1. An unlocker unlocked the drive → it serves CLEAR content
|
||||||
// drive) and yielded a VID but no bus key on a bus-encrypted disc, the
|
// (`drive_unlocked`). This is the common live-drive case and yields
|
||||||
// bytes would decrypt to garbage. Fail loudly here instead.
|
// no `read_data_key` — it doesn't need one.
|
||||||
|
// 2. The AACS host-certificate cert-auth handshake produced the bus key
|
||||||
|
// (`read_data_key`).
|
||||||
|
// The old gate credited ONLY (2), so a SUCCESSFUL drive unlock (VID
|
||||||
|
// present, `read_data_key: None`, `drive_unlocked: true`) tripped it and
|
||||||
|
// blocked ALL key resolution — including the online source — even though
|
||||||
|
// the drive was serving clear content. That was the bug.
|
||||||
//
|
//
|
||||||
// Gated on `handshake.is_some()` so the two preserved cases never
|
// Also skipped when `handshake = None` (file-backed/ISO scans — bus
|
||||||
// regress: (1) file-backed/ISO scans reach here with `handshake = None`
|
// encryption already removed at read time) and when `bus_encryption` is
|
||||||
// and have already had bus encryption removed at read time; (2) AACS 1.0
|
// false (AACS 1.0 BD is not bus-encrypted).
|
||||||
// BD is not bus-encrypted, so `bus_encryption` is false and the gate is
|
// ONE question — "is AACS bus encryption gone?" — asked of the single
|
||||||
// skipped (its `read_data_key` is legitimately absent).
|
// `bus_encryption_removed` predicate, which OWNS every case (never had it,
|
||||||
if bus_encryption && handshake.is_some_and(|h| h.read_data_key.is_none()) {
|
// 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, handshake_has_volume_id(h)))
|
||||||
|
.unwrap_or((None, false));
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "bus_key_unavailable",
|
phase = "bus_key_unavailable",
|
||||||
"Disc declares bus encryption but the handshake produced no read_data_key; a VID-only/OEM unlock cannot remove bus encryption. Refusing to proceed with a key that would decrypt to garbage."
|
read_data_key_err = ?rdk_err,
|
||||||
|
has_volume_id = has_vid,
|
||||||
|
"Disc declares bus encryption but it could not be removed: no unlocker \
|
||||||
|
unlocked the drive AND the cert handshake produced no read_data_key. Refusing to \
|
||||||
|
emit a key that would decrypt to garbage."
|
||||||
);
|
);
|
||||||
return Err(Error::AacsBusKeyUnavailable);
|
return Err(Error::AacsBusKeyUnavailable);
|
||||||
}
|
}
|
||||||
// MKB_RO/RW are allocated to a fixed ~128 MiB and zero-padded; trim to
|
// Read the MKB record stream via the SAME bounded reader the
|
||||||
// the real record length (same as `read_aacs_inputs`). Without this the
|
// out-of-band `read_aacs_inputs` uses (`read_mkb_content`: a prefix-grow
|
||||||
// MKB stashed on `AacsState` — which `Disc::inputs()` and the device/
|
// read + trim), NOT a full `read_file`. MKB_RO/RW is allocated to a
|
||||||
// processing-key `decrypt_with` derivation consume, and which a key
|
// fixed ~128 MiB of zero padding, and a full `read_file` of it FAILS on
|
||||||
// source ships to an online service — is the full 128 MiB pad, not the
|
// file-backed / large readers — which left `a.mkb` empty here, silently
|
||||||
// ~few-MB record stream.
|
// breaking online key resolution: `Disc::inputs()` shipped `mkb=0` to
|
||||||
let mkb_bytes = udf_fs
|
// the decode service and it 404'd, while autorip's separate
|
||||||
.read_file(reader, "/AACS/MKB_RO.inf")
|
// `read_aacs_inputs` path (this same helper) worked. One reader now, so
|
||||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf"))
|
// `Disc::inputs()` is the single complete source of AACS inputs.
|
||||||
.ok()
|
// A read ERROR is surfaced (logged), not silently emptied: an empty MKB
|
||||||
.unwrap_or_default();
|
// here is invisible until an online key service rejects the request, so
|
||||||
// Trim to the real record length. Use trim_mkb rather than a raw
|
// a transient I/O hiccup must not masquerade as "no MKB". We still
|
||||||
// truncate: trim_mkb only truncates when content_len > 0 and strictly
|
// continue with an empty MKB (disc-hash-keyed keydb lookups don't need
|
||||||
// inside the buffer, so a malformed/unrecognised MKB is preserved
|
// it), but the cause is now on the log.
|
||||||
// intact instead of being zeroed by truncate(0).
|
let mkb_bytes = match Self::read_mkb_content(reader, udf_fs) {
|
||||||
let mkb_bytes = aacs::trim_mkb(mkb_bytes);
|
Ok(m) => m,
|
||||||
let mkb_ver = aacs::mkb_version(&mkb_bytes);
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::disc",
|
||||||
|
phase = "scan_aacs_mkb",
|
||||||
|
error = %e,
|
||||||
|
"MKB read failed at scan; AACS inputs will carry an empty MKB \
|
||||||
|
(online key resolution cannot proceed without it). Continuing \
|
||||||
|
— disc-hash-keyed lookups are unaffected."
|
||||||
|
);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mkb_ver = aacs::mkb::mkb_version(&mkb_bytes);
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "scan_aacs_vid_only",
|
phase = "scan_aacs_vid_only",
|
||||||
disc_hash = %aacs::disc_hash_hex(&dh),
|
disc_hash = %aacs::inf::disc_hash_hex(&dh),
|
||||||
version,
|
version,
|
||||||
bus_encryption,
|
bus_encryption,
|
||||||
has_vid = handshake.is_some(),
|
has_vid = handshake.is_some(),
|
||||||
@@ -388,7 +444,7 @@ impl Disc {
|
|||||||
version,
|
version,
|
||||||
bus_encryption,
|
bus_encryption,
|
||||||
mkb_version: mkb_ver,
|
mkb_version: mkb_ver,
|
||||||
disc_hash: aacs::disc_hash_hex(&dh),
|
disc_hash: aacs::inf::disc_hash_hex(&dh),
|
||||||
key_source: KeyOrigin::ExternalUk,
|
key_source: KeyOrigin::ExternalUk,
|
||||||
vuk: None,
|
vuk: None,
|
||||||
unit_keys: vec![],
|
unit_keys: vec![],
|
||||||
@@ -407,6 +463,27 @@ mod tests {
|
|||||||
use crate::sector::SectorSource;
|
use crate::sector::SectorSource;
|
||||||
use std::collections::HashMap;
|
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
|
// In-memory disc + minimal UDF image with a single physical
|
||||||
// partition (metadata_start == partition_start). Offsets cited
|
// partition (metadata_start == partition_start). Offsets cited
|
||||||
@@ -547,18 +624,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A content certificate: type byte@0 (0x00 = V10, else V20),
|
/// A content certificate: type byte@0 (0x00 = V10, else V20),
|
||||||
/// bus_encryption bit0@1, cc_id@2..8 (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> {
|
fn build_content_cert(cert_type: u8, bus_encryption: bool) -> Vec<u8> {
|
||||||
let mut v = vec![0u8; 8];
|
let mut v = vec![0u8; 20];
|
||||||
v[0] = cert_type;
|
v[0] = cert_type;
|
||||||
v[1] = if bus_encryption { 0x01 } else { 0x00 };
|
v[1] = if bus_encryption { 0x80 } else { 0x00 };
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An MKB with one Type-and-Version record (type 0x10) carrying the
|
/// 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
|
/// version as BE u32 at record offset 8, followed by a recorded EOF
|
||||||
/// record then trailing zero padding. mkb_content_len walks records
|
/// 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> {
|
fn build_mkb(version: u32, pad_to: usize) -> Vec<u8> {
|
||||||
let mut v = Vec::new();
|
let mut v = Vec::new();
|
||||||
// Type 0x10 record, length 16 (>= 12 so version is read).
|
// Type 0x10 record, length 16 (>= 12 so version is read).
|
||||||
@@ -645,10 +723,12 @@ mod tests {
|
|||||||
assert!(st.bus_encryption, "cert bus_encryption bit must propagate");
|
assert!(st.bus_encryption, "cert bus_encryption bit must propagate");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// No content cert at all but bus_encryption can't be read → version
|
/// No content cert at all → version defaults to UHD (major 2), matching
|
||||||
/// defaults to 1 (encrypt.rs: `None => 1`). bus_encryption false.
|
/// `read_aacs_version` so the scanned `AacsState.version` and the out-of-band
|
||||||
|
/// fetch agree on the Unit_Key_RO stride (audit #4: a wrong BD-vs-UHD guess
|
||||||
|
/// mis-parses unit keys). bus_encryption false (unreadable → off).
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_vid_only_no_cert_defaults_version_1() {
|
fn resolve_vid_only_no_cert_defaults_version_uhd() {
|
||||||
let mut disc = MemDisc::new();
|
let mut disc = MemDisc::new();
|
||||||
let udf = build_aacs_fs(
|
let udf = build_aacs_fs(
|
||||||
&mut disc,
|
&mut disc,
|
||||||
@@ -660,12 +740,16 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
||||||
assert_eq!(st.version, 1, "no cert → default version 1");
|
assert_eq!(
|
||||||
|
st.version,
|
||||||
|
aacs::mkb::AACS_MAJOR_UHD,
|
||||||
|
"no cert → default UHD (major 2)"
|
||||||
|
);
|
||||||
assert!(!st.bus_encryption);
|
assert!(!st.bus_encryption);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// disc_hash is SHA1 of the Unit_Key_RO.inf bytes, hex with 0x prefix
|
/// 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.
|
/// disc_hash must match independently computing it over the same bytes.
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_vid_only_disc_hash_is_sha1_of_unit_key_ro() {
|
fn resolve_vid_only_disc_hash_is_sha1_of_unit_key_ro() {
|
||||||
@@ -681,7 +765,7 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
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_eq!(st.disc_hash, expected);
|
||||||
assert!(st.disc_hash.starts_with("0x"));
|
assert!(st.disc_hash.starts_with("0x"));
|
||||||
// uk_ro must be stashed verbatim for the external resolver.
|
// uk_ro must be stashed verbatim for the external resolver.
|
||||||
@@ -717,7 +801,7 @@ mod tests {
|
|||||||
// Real record stream is the single 16-byte type-0x10 record.
|
// Real record stream is the single 16-byte type-0x10 record.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
st.mkb.len(),
|
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"
|
"MKB must be trimmed to record-stream length, not the zero-pad"
|
||||||
);
|
);
|
||||||
assert_eq!(st.mkb.len(), 16);
|
assert_eq!(st.mkb.len(), 16);
|
||||||
@@ -764,6 +848,8 @@ mod tests {
|
|||||||
let hs = HandshakeResult {
|
let hs = HandshakeResult {
|
||||||
volume_id: vid,
|
volume_id: vid,
|
||||||
read_data_key: Some(rdk),
|
read_data_key: Some(rdk),
|
||||||
|
read_data_key_err: None,
|
||||||
|
drive_unlocked: false,
|
||||||
};
|
};
|
||||||
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("state");
|
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("state");
|
||||||
assert_eq!(st.volume_id, vid);
|
assert_eq!(st.volume_id, vid);
|
||||||
@@ -808,6 +894,8 @@ mod tests {
|
|||||||
let hs = HandshakeResult {
|
let hs = HandshakeResult {
|
||||||
volume_id: [0x11u8; 16],
|
volume_id: [0x11u8; 16],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
read_data_key_err: None,
|
||||||
|
drive_unlocked: false,
|
||||||
};
|
};
|
||||||
let err = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs))
|
let err = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs))
|
||||||
.expect_err("bus-encrypted disc with no bus key must hard-error");
|
.expect_err("bus-encrypted disc with no bus key must hard-error");
|
||||||
@@ -822,6 +910,8 @@ mod tests {
|
|||||||
let hs = HandshakeResult {
|
let hs = HandshakeResult {
|
||||||
volume_id: [0x11u8; 16],
|
volume_id: [0x11u8; 16],
|
||||||
read_data_key: Some([0x22u8; 16]),
|
read_data_key: Some([0x22u8; 16]),
|
||||||
|
read_data_key_err: None,
|
||||||
|
drive_unlocked: false,
|
||||||
};
|
};
|
||||||
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("bus key present → ok");
|
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("bus key present → ok");
|
||||||
assert!(st.bus_encryption);
|
assert!(st.bus_encryption);
|
||||||
@@ -848,6 +938,8 @@ mod tests {
|
|||||||
let hs = HandshakeResult {
|
let hs = HandshakeResult {
|
||||||
volume_id: [0x11u8; 16],
|
volume_id: [0x11u8; 16],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
read_data_key_err: None,
|
||||||
|
drive_unlocked: false,
|
||||||
};
|
};
|
||||||
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("AACS 1.0 → ok");
|
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("AACS 1.0 → ok");
|
||||||
assert!(!st.bus_encryption);
|
assert!(!st.bus_encryption);
|
||||||
@@ -875,41 +967,54 @@ mod tests {
|
|||||||
|
|
||||||
/// Unit_Key_RO.inf is read from /AACS/DUPLICATE when the primary copy
|
/// Unit_Key_RO.inf is read from /AACS/DUPLICATE when the primary copy
|
||||||
/// is absent (encrypt.rs `.or_else(|_| read_file(DUPLICATE/...))`).
|
/// is absent (encrypt.rs `.or_else(|_| read_file(DUPLICATE/...))`).
|
||||||
/// This is the damaged-primary recovery path real discs rely on.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_vid_only_falls_back_to_duplicate_unit_key_ro() {
|
fn handshake_has_volume_id_reports_presence_not_absence() {
|
||||||
let mut disc = MemDisc::new();
|
let with_vid = HandshakeResult {
|
||||||
// Build AACS dir with a DUPLICATE subdir holding Unit_Key_RO.inf.
|
volume_id: [0x11u8; 16],
|
||||||
let uk = vec![0x55u8; 48];
|
read_data_key: None,
|
||||||
let mut dup_fids = Vec::new();
|
read_data_key_err: None,
|
||||||
push_fid(&mut dup_fids, "", 70, true, true);
|
drive_unlocked: false,
|
||||||
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));
|
assert!(
|
||||||
disc.put_bytes(PART_START + 9000, &uk);
|
super::handshake_has_volume_id(&with_vid),
|
||||||
disc.put(PART_START + 70, build_file_icb(dup_fids.len() as u32, 71));
|
"a non-zero Volume ID must report as PRESENT"
|
||||||
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"
|
|
||||||
);
|
);
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
@@ -928,8 +1033,8 @@ mod tests {
|
|||||||
// the route fails gracefully (AacsNoHostCert), never panics.
|
// the route fails gracefully (AacsNoHostCert), never panics.
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
fn fake_cert(tag: u8) -> aacs::HostCert {
|
fn fake_cert(tag: u8) -> aacs::types::HostCert {
|
||||||
aacs::HostCert {
|
aacs::types::HostCert {
|
||||||
private_key: [tag; 20],
|
private_key: [tag; 20],
|
||||||
certificate: vec![tag; 92],
|
certificate: vec![tag; 92],
|
||||||
private_key_v2: None,
|
private_key_v2: None,
|
||||||
@@ -938,15 +1043,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A minimal in-test KeySource that yields no keys but a fixed cert list.
|
/// 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 {
|
impl crate::KeySource for CertSource {
|
||||||
fn get_uk(
|
fn get_unit_keys(
|
||||||
&self,
|
&self,
|
||||||
_ctx: &dyn crate::keysource::ResolveCtx,
|
_ctx: &dyn crate::keysource::ResolveCtx,
|
||||||
) -> Result<Vec<crate::aacs::UnitKey>> {
|
) -> Result<Vec<crate::aacs::types::UnitKey>> {
|
||||||
Ok(Vec::new())
|
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()
|
self.0.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1012,45 +1117,52 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unlock_error_maps_to_legacy_error_variants() {
|
fn unlock_error_maps_to_legacy_error_variants() {
|
||||||
use crate::unlock::UnlockError;
|
use freemkv_unlock::UnlockError;
|
||||||
// No host cert keeps the AacsNoHostCert sentinel path.
|
// No host cert (libfreemkv-side, carries mkb) keeps the AacsNoHostCert
|
||||||
match unlock_error_to_error(UnlockError::NoUsableHostCert { mkb: Some(68) }) {
|
// sentinel path — as does the unlocker's own NoUsableHostCert.
|
||||||
|
match unlock_error_to_error(&CertUnlockFailure::NoHostCert { mkb: Some(68) }) {
|
||||||
|
Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"),
|
||||||
|
other => panic!("expected AacsNoHostCert, got {other:?}"),
|
||||||
|
}
|
||||||
|
match unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert)) {
|
||||||
Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"),
|
Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"),
|
||||||
other => panic!("expected AacsNoHostCert, got {other:?}"),
|
other => panic!("expected AacsNoHostCert, got {other:?}"),
|
||||||
}
|
}
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
unlock_error_to_error(UnlockError::VidUnavailable),
|
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)),
|
||||||
Error::AacsVidUnavailable
|
Error::AacsVidUnavailable
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
unlock_error_to_error(UnlockError::HandshakeRejected),
|
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)),
|
||||||
Error::AacsHostCertRejected
|
Error::AacsHostCertRejected
|
||||||
));
|
));
|
||||||
|
// A transport fault folds to the rejected surface too.
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
unlock_error_to_error(UnlockError::CertRevoked { mkb: None }),
|
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::Transport)),
|
||||||
Error::AacsHostCertRejected
|
Error::AacsHostCertRejected
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cert_unlock_outcome_maps_to_structured_trace_step() {
|
fn cert_unlock_outcome_maps_to_structured_trace_step() {
|
||||||
use crate::aacs::UnlockOutcome;
|
use crate::aacs::trace::UnlockOutcome;
|
||||||
use crate::unlock::UnlockError;
|
use freemkv_unlock::UnlockError;
|
||||||
|
// The libfreemkv-side no-cert case carries the MKB generation.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cert_unlock_outcome(&UnlockError::NoUsableHostCert { mkb: Some(77) }),
|
cert_unlock_outcome(&CertUnlockFailure::NoHostCert { mkb: Some(77) }),
|
||||||
UnlockOutcome::NoUsableHostCert { mkb: Some(77) }
|
UnlockOutcome::NoUsableHostCert { mkb: Some(77) }
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cert_unlock_outcome(&UnlockError::VidUnavailable),
|
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)),
|
||||||
UnlockOutcome::VidUnavailable
|
UnlockOutcome::VidUnavailable
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cert_unlock_outcome(&UnlockError::HandshakeRejected),
|
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)),
|
||||||
UnlockOutcome::HandshakeRejected
|
UnlockOutcome::HandshakeRejected
|
||||||
);
|
);
|
||||||
// A SCSI/transport error folds to HandshakeRejected at the trace layer.
|
// A transport fault folds to HandshakeRejected at the trace layer.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cert_unlock_outcome(&UnlockError::Scsi(4000)),
|
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::Transport)),
|
||||||
UnlockOutcome::HandshakeRejected
|
UnlockOutcome::HandshakeRejected
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1111
-132
File diff suppressed because it is too large
Load Diff
+3639
File diff suppressed because it is too large
Load Diff
-1696
File diff suppressed because it is too large
Load Diff
+4164
-2726
File diff suppressed because it is too large
Load Diff
-3071
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,212 +0,0 @@
|
|||||||
//! `Disc::sweep`'s consumer-side `Sink<WorkItem>`.
|
|
||||||
//!
|
|
||||||
//! Background: the original sweep loop runs strictly serialised —
|
|
||||||
//! SCSI read → decrypt → seek + write → mapfile.record → next iter.
|
|
||||||
//! On a healthy disc the SCSI read costs ~5-12 ms per 64 KB batch and
|
|
||||||
//! the post-read work (decrypt 1-3 ms + file write + mapfile fsync
|
|
||||||
//! 5-15 ms) adds another batch's worth of latency. The drive idles
|
|
||||||
//! during the post-read work; throughput tops out at the *sum* of
|
|
||||||
//! both costs.
|
|
||||||
//!
|
|
||||||
//! A producer/consumer split overlaps the two stages on the generic
|
|
||||||
//! [`crate::io::Pipeline`] + [`crate::io::Sink`] primitive. This module
|
|
||||||
//! is the sweep-specific `Sink` impl; the producer-side state machine
|
|
||||||
//! (read_error context, decrypt, set_speed, halt) stays in
|
|
||||||
//! `Disc::sweep` in `disc/mod.rs`.
|
|
||||||
//!
|
|
||||||
//! Correctness invariants preserved:
|
|
||||||
//! - Mapfile is single-writer (consumer-only). No locking.
|
|
||||||
//! - All `read_error::ReadCtx` state stays on the producer thread.
|
|
||||||
//! - `set_speed` calls happen on the producer thread (same thread that
|
|
||||||
//! owns the `SectorSource`). No new SCSI concurrency.
|
|
||||||
//! - Per-iteration ordering of file-write → mapfile-record is kept
|
|
||||||
//! intact in the consumer (write before record), so the on-disk
|
|
||||||
//! invariant "mapfile only marks Finished what the file has
|
|
||||||
//! received" survives a crash mid-pass.
|
|
||||||
//! - Only one SCSI command is in flight at a time; error-path timing
|
|
||||||
//! is identical and no new retry logic is introduced.
|
|
||||||
|
|
||||||
use std::io::{Seek, SeekFrom, Write};
|
|
||||||
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
|
|
||||||
|
|
||||||
use crate::error::Error;
|
|
||||||
use crate::io::{Flow, Sink};
|
|
||||||
|
|
||||||
use super::mapfile::{MapStats, Mapfile, SectorStatus};
|
|
||||||
|
|
||||||
/// Reusable zero buffer for SkipFill / GapFill / BisectBad. 64 KB
|
|
||||||
/// matches the existing zero_gap chunk size used by the pre-split
|
|
||||||
/// sweep loop.
|
|
||||||
const ZERO_CHUNK: usize = 64 * 1024;
|
|
||||||
|
|
||||||
/// Producer → Consumer messages. The consumer applies these in FIFO
|
|
||||||
/// order; ordering of file writes and mapfile records across items is
|
|
||||||
/// preserved.
|
|
||||||
pub(super) enum WorkItem {
|
|
||||||
/// Successful batch read. Producer has already decrypted `buf` if
|
|
||||||
/// `opts.decrypt` was set. Consumer writes `buf` at `pos` and
|
|
||||||
/// records the range as `Finished`.
|
|
||||||
Good { pos: u64, buf: Vec<u8> },
|
|
||||||
|
|
||||||
/// Bisect inner-loop good single sector (already decrypted by the
|
|
||||||
/// producer). 2048 bytes.
|
|
||||||
BisectGood { pos: u64, buf: Box<[u8; 2048]> },
|
|
||||||
|
|
||||||
/// Bisect inner-loop bad single sector. Consumer writes 2048
|
|
||||||
/// zeros at `pos` and records the sector as `NonTrimmed`.
|
|
||||||
BisectBad { pos: u64 },
|
|
||||||
|
|
||||||
/// Whole-batch zero-fill (failed batch on `SkipBlock`, or the
|
|
||||||
/// failed batch portion of `JumpAhead`). Consumer streams zeros
|
|
||||||
/// across `[pos, pos+len)` and records the range as `NonTrimmed`.
|
|
||||||
SkipFill { pos: u64, len: u64 },
|
|
||||||
|
|
||||||
/// Gap fill following a `JumpAhead`. Same effect as `SkipFill`;
|
|
||||||
/// distinguished only so future logging / instrumentation can
|
|
||||||
/// tell them apart without parsing a flag.
|
|
||||||
GapFill { pos: u64, len: u64 },
|
|
||||||
|
|
||||||
/// Producer wants the latest mapfile stats for the progress
|
|
||||||
/// callback. Consumer responds on `prog_tx` with a fresh
|
|
||||||
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
|
|
||||||
/// drained the previous snapshot, the new one is silently
|
|
||||||
/// dropped — the producer's local cache stays current enough.
|
|
||||||
StatsRequest,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Snapshot the consumer sends back to the producer for the progress
|
|
||||||
/// callback.
|
|
||||||
pub(super) struct ProgressSnapshot {
|
|
||||||
pub stats: MapStats,
|
|
||||||
pub bad_ranges: Vec<(u64, u64)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Final summary returned by the consumer thread on shutdown — what
|
|
||||||
/// `SweepSink::close` produces, surfaced to the producer via
|
|
||||||
/// `Pipeline::finish`.
|
|
||||||
pub(super) struct ConsumerSummary {
|
|
||||||
pub stats: MapStats,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drain any pending progress snapshots from the consumer. Returns
|
|
||||||
/// the most recent one, if any. The producer caches it and uses it
|
|
||||||
/// for subsequent progress callbacks until a fresh one arrives.
|
|
||||||
pub(super) fn try_recv_progress(rx: &Receiver<ProgressSnapshot>) -> Option<ProgressSnapshot> {
|
|
||||||
let mut latest = None;
|
|
||||||
while let Ok(snap) = rx.try_recv() {
|
|
||||||
latest = Some(snap);
|
|
||||||
}
|
|
||||||
latest
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `Sink<WorkItem>` for sweep. Owns the writeback file + mapfile +
|
|
||||||
/// progress back-channel. `apply` carries the file-write +
|
|
||||||
/// mapfile.record per item; `close` drains the writeback pipeline,
|
|
||||||
/// fsyncs the ISO, and flushes the mapfile.
|
|
||||||
pub(super) struct SweepSink {
|
|
||||||
file: crate::io::WritebackFile,
|
|
||||||
map: Mapfile,
|
|
||||||
/// `sync_all`-on-failure-is-an-error iff the output is a regular
|
|
||||||
/// file. `/dev/null` and pipes always fail `sync_all`; that's not
|
|
||||||
/// a real error.
|
|
||||||
is_regular: bool,
|
|
||||||
/// Back-channel for `StatsRequest` responses. The producer caches
|
|
||||||
/// the latest snapshot and uses it for the progress callback;
|
|
||||||
/// dropped sends on a full channel are by design.
|
|
||||||
prog_tx: SyncSender<ProgressSnapshot>,
|
|
||||||
/// Reusable zero buffer for SkipFill / GapFill / BisectBad. Held
|
|
||||||
/// in the sink so each apply call doesn't reallocate.
|
|
||||||
zero: Box<[u8; ZERO_CHUNK]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SweepSink {
|
|
||||||
/// Construct a new `SweepSink` plus the matching progress
|
|
||||||
/// receiver. Channel depth on the back-channel is `1` — the
|
|
||||||
/// producer's cache is the source of truth between snapshots.
|
|
||||||
pub(super) fn new(
|
|
||||||
file: crate::io::WritebackFile,
|
|
||||||
map: Mapfile,
|
|
||||||
is_regular: bool,
|
|
||||||
) -> (Self, Receiver<ProgressSnapshot>) {
|
|
||||||
let (prog_tx, prog_rx) = sync_channel::<ProgressSnapshot>(1);
|
|
||||||
let sink = SweepSink {
|
|
||||||
file,
|
|
||||||
map,
|
|
||||||
is_regular,
|
|
||||||
prog_tx,
|
|
||||||
zero: Box::new([0u8; ZERO_CHUNK]),
|
|
||||||
};
|
|
||||||
(sink, prog_rx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Sink<WorkItem> for SweepSink {
|
|
||||||
type Output = ConsumerSummary;
|
|
||||||
|
|
||||||
fn apply(&mut self, item: WorkItem) -> Result<Flow, Error> {
|
|
||||||
match item {
|
|
||||||
WorkItem::Good { pos, buf } => {
|
|
||||||
// Decrypt is on the producer; consumer assumes plaintext.
|
|
||||||
let len = buf.len() as u64;
|
|
||||||
self.file.seek(SeekFrom::Start(pos))?;
|
|
||||||
self.file.write_all(&buf)?;
|
|
||||||
self.map.record(pos, len, SectorStatus::Finished)?;
|
|
||||||
}
|
|
||||||
WorkItem::BisectGood { pos, buf } => {
|
|
||||||
self.file.seek(SeekFrom::Start(pos))?;
|
|
||||||
self.file.write_all(&buf[..])?;
|
|
||||||
self.map.record(pos, 2048, SectorStatus::Finished)?;
|
|
||||||
}
|
|
||||||
WorkItem::BisectBad { pos } => {
|
|
||||||
self.file.seek(SeekFrom::Start(pos))?;
|
|
||||||
self.file.write_all(&self.zero[..2048])?;
|
|
||||||
self.map.record(pos, 2048, SectorStatus::NonTrimmed)?;
|
|
||||||
}
|
|
||||||
WorkItem::SkipFill { pos, len } | WorkItem::GapFill { pos, len } => {
|
|
||||||
self.file.seek(SeekFrom::Start(pos))?;
|
|
||||||
// Subsequent writes are sequential; `WritebackFile`'s
|
|
||||||
// seek-elision keeps them on the writeback pipeline path.
|
|
||||||
let mut filled = 0u64;
|
|
||||||
while filled < len {
|
|
||||||
let chunk = (len - filled).min(self.zero.len() as u64) as usize;
|
|
||||||
self.file.write_all(&self.zero[..chunk])?;
|
|
||||||
filled += chunk as u64;
|
|
||||||
}
|
|
||||||
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
|
|
||||||
}
|
|
||||||
WorkItem::StatsRequest => {
|
|
||||||
let stats = self.map.stats();
|
|
||||||
let bad_ranges = self.map.ranges_with(&[
|
|
||||||
SectorStatus::NonTrimmed,
|
|
||||||
SectorStatus::Unreadable,
|
|
||||||
SectorStatus::NonScraped,
|
|
||||||
SectorStatus::NonTried,
|
|
||||||
]);
|
|
||||||
// Best-effort: drop on backpressure; producer's cache
|
|
||||||
// stays current enough.
|
|
||||||
let _ = self
|
|
||||||
.prog_tx
|
|
||||||
.try_send(ProgressSnapshot { stats, bad_ranges });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Flow::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close(mut self) -> Result<Self::Output, Error> {
|
|
||||||
// Drain the writeback pipeline + fsync the ISO, then persist
|
|
||||||
// any pending mapfile state. Same finalisation order as the
|
|
||||||
// pre-Pipeline consumer loop.
|
|
||||||
if let Err(e) = self.file.sync_all() {
|
|
||||||
if self.is_regular {
|
|
||||||
return Err(Error::IoError { source: e });
|
|
||||||
}
|
|
||||||
// Non-regular outputs (/dev/null, pipes) always fail
|
|
||||||
// sync_all; that's not a real error.
|
|
||||||
}
|
|
||||||
self.map.flush()?;
|
|
||||||
|
|
||||||
Ok(ConsumerSummary {
|
|
||||||
stats: self.map.stats(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+6
-8
@@ -23,14 +23,12 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
|||||||
if !std::path::Path::new(&path).exists() {
|
if !std::path::Path::new(&path).exists() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) {
|
if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path))
|
||||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
&& let Ok(id) = DriveId::from_drive(transport.as_mut())
|
||||||
if !id.raw_inquiry.is_empty()
|
&& !id.raw_inquiry.is_empty()
|
||||||
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||||
{
|
{
|
||||||
drives.push((path, id));
|
drives.push((path, id));
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drives
|
drives
|
||||||
|
|||||||
+35
-6
@@ -25,12 +25,11 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
|||||||
let path = std::path::Path::new(&info.path);
|
let path = std::path::Path::new(&info.path);
|
||||||
match crate::scsi::open(path) {
|
match crate::scsi::open(path) {
|
||||||
Ok(mut transport) => {
|
Ok(mut transport) => {
|
||||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
if let Ok(id) = DriveId::from_drive(transport.as_mut())
|
||||||
if !id.raw_inquiry.is_empty()
|
&& !id.raw_inquiry.is_empty()
|
||||||
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||||
{
|
{
|
||||||
drives.push((info.path.clone(), id));
|
drives.push((info.path.clone(), id));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -53,3 +52,33 @@ pub fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> {
|
|||||||
}
|
}
|
||||||
Ok((path.to_string(), DeviceResolution::Direct))
|
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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1433
-200
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
//! DVD-Video navigation — read-only resolver for the **main-feature start
|
||||||
|
//! point** (issue #40). Mirrors what a DVD player's nav VM resolves: First-Play
|
||||||
|
//! → menu "Play" → title dispatch → the first cell of the feature, so the rip
|
||||||
|
//! starts at the movie rather than at raw cell 0 (e.g. skipping a leading
|
||||||
|
//! logo/warning segment when the disc's own navigation does).
|
||||||
|
//!
|
||||||
|
//! Byte layout follows the DVD-Video specification (VMGI/VTSI headers,
|
||||||
|
//! PGC/cell tables, PCI/HLI button packets); the VM command decoder is
|
||||||
|
//! verified against real discs.
|
||||||
|
//!
|
||||||
|
//! Current contents: [`vmcmd`] — the VM command decoder (proven against the
|
||||||
|
//! SOTL/Greenland test discs). The IFO/PCI parsing and the navigation executor
|
||||||
|
//! that resolves the start cell build on top of this.
|
||||||
|
|
||||||
|
pub mod vmcmd;
|
||||||
|
|
||||||
|
use crate::sector::SectorSource;
|
||||||
|
|
||||||
|
/// Resolve the feature title's **true start cell** (0-based index into the
|
||||||
|
/// title PGC's cell list) by following the disc's own navigation — First-Play →
|
||||||
|
/// menu "Play" → title dispatch — the way a player reaches the movie. This is
|
||||||
|
/// what lets the rip begin at the feature instead of at raw cell 0 when the
|
||||||
|
/// disc's nav enters the title past a leading logo/warning segment (e.g. a
|
||||||
|
/// disc whose "Play" resolves to a later cell than cell 0).
|
||||||
|
///
|
||||||
|
/// Returns `None` when navigation cannot be resolved, so the caller falls back
|
||||||
|
/// to the structural leading-cell filter (today's behaviour, ≈ cell 0 / 0:00).
|
||||||
|
///
|
||||||
|
/// TODO(#40): the IFO/PCI parsing + nav executor (built on [`vmcmd`]) land
|
||||||
|
/// incrementally. Until the executor is complete this returns `None`, so wiring
|
||||||
|
/// it in is behaviour-neutral; improvements to the resolver take effect here
|
||||||
|
/// without touching the call site.
|
||||||
|
pub fn resolve_feature_start(
|
||||||
|
reader: &mut dyn SectorSource,
|
||||||
|
udf: &crate::udf::UdfFs,
|
||||||
|
vtsn: u16,
|
||||||
|
vts_ttn: u16,
|
||||||
|
) -> Option<usize> {
|
||||||
|
// `reader`/`udf` are the seam inputs the nav executor will consume to read
|
||||||
|
// VIDEO_TS.IFO + the VTS IFOs/menu VOBs. Reserved until that lands.
|
||||||
|
let _ = (reader, udf);
|
||||||
|
tracing::trace!(
|
||||||
|
target: "freemkv::dvdnav",
|
||||||
|
vtsn,
|
||||||
|
vts_ttn,
|
||||||
|
"nav start-cell resolver: unresolved — caller falls back to leading-cell filter"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
//! DVD-Video VM command decoder.
|
||||||
|
//!
|
||||||
|
//! 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 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
|
||||||
|
//! `byte1` bits 3-0 are the sub-command. Compare predicates live in `byte1`
|
||||||
|
//! bits 6-4 with the operands in bytes 2-5.
|
||||||
|
//!
|
||||||
|
//! This module is pure decode + a register model — no I/O, no English (numeric
|
||||||
|
//! semantics only), matching libfreemkv conventions. The navigation *executor*
|
||||||
|
//! and IFO/PCI parsing build on top of this.
|
||||||
|
|
||||||
|
/// A decoded navigation instruction. Only the variants freemkv's start-point
|
||||||
|
/// resolver needs are modelled explicitly; everything else is [`Instr::Other`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Instr {
|
||||||
|
Nop,
|
||||||
|
/// Stop executing the current command list (resume cell playback).
|
||||||
|
Break,
|
||||||
|
/// Goto command line within the same list (1-based).
|
||||||
|
Goto {
|
||||||
|
line: u8,
|
||||||
|
},
|
||||||
|
/// Leave the current domain.
|
||||||
|
Exit,
|
||||||
|
/// Jump to a VMG title (1-based TT_SRPT index).
|
||||||
|
JumpTt {
|
||||||
|
ttn: u8,
|
||||||
|
},
|
||||||
|
/// Jump to a title within the current VTS (1-based VTS title index).
|
||||||
|
JumpVtsTt {
|
||||||
|
ttn: u8,
|
||||||
|
},
|
||||||
|
/// Jump to a part-of-title (chapter) within a VTS title.
|
||||||
|
JumpVtsPtt {
|
||||||
|
ttn: u8,
|
||||||
|
pttn: u16,
|
||||||
|
},
|
||||||
|
/// Jump to the First-Play PGC.
|
||||||
|
JumpSsFp,
|
||||||
|
/// Jump to a Video-Manager menu (`menu` = menu id).
|
||||||
|
JumpSsVmgm {
|
||||||
|
menu: u8,
|
||||||
|
},
|
||||||
|
/// Jump to a Video-Title-Set menu.
|
||||||
|
JumpSsVtsm {
|
||||||
|
vts: u8,
|
||||||
|
ttn: u8,
|
||||||
|
menu: u8,
|
||||||
|
},
|
||||||
|
/// Jump to a specific VMGM menu PGC.
|
||||||
|
JumpSsVmgmPgc {
|
||||||
|
pgcn: u16,
|
||||||
|
},
|
||||||
|
/// Call a sub-domain (raw retained; resume handled by the executor).
|
||||||
|
CallSs {
|
||||||
|
sub: u8,
|
||||||
|
},
|
||||||
|
/// Link to a PGC number within the current domain.
|
||||||
|
LinkPgcn {
|
||||||
|
pgcn: u16,
|
||||||
|
},
|
||||||
|
/// Link to a part-of-title within the current PGC's title.
|
||||||
|
LinkPttn {
|
||||||
|
pttn: u16,
|
||||||
|
},
|
||||||
|
/// Link to a program number within the current PGC (1-based).
|
||||||
|
LinkPgn {
|
||||||
|
pgn: u8,
|
||||||
|
},
|
||||||
|
/// Link to a cell number within the current PGC (1-based).
|
||||||
|
LinkCn {
|
||||||
|
cn: u8,
|
||||||
|
},
|
||||||
|
/// A link "subset" op (LinkTopCell/NextPG/RSM/…); `sub` is the raw code.
|
||||||
|
LinkSub {
|
||||||
|
sub: u8,
|
||||||
|
},
|
||||||
|
/// Set a GPRM. `op` is the set-op code (1=mov, 3=add, …); value is immediate
|
||||||
|
/// (`imm`) when `immediate`, else the contents of register `src`.
|
||||||
|
SetGprm {
|
||||||
|
reg: u8,
|
||||||
|
op: u8,
|
||||||
|
immediate: bool,
|
||||||
|
imm: u16,
|
||||||
|
src: u8,
|
||||||
|
},
|
||||||
|
/// Set a system parameter / unmodelled set — executor may ignore.
|
||||||
|
SetSystem,
|
||||||
|
/// Anything not individually modelled (kept as raw bytes).
|
||||||
|
Other([u8; 8]),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compare predicate carried by a command (`byte1` bits 6-4). `None` = always.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Compare {
|
||||||
|
/// Compare op: 1=&,2===,3=!=,4=>=,5=>,6=<=,7=<.
|
||||||
|
pub op: u8,
|
||||||
|
/// Left register index (GPRM 0-15, SPRM 128+).
|
||||||
|
pub lhs_reg: u8,
|
||||||
|
/// Right side: immediate when `immediate`, else register `rhs_reg`.
|
||||||
|
pub immediate: bool,
|
||||||
|
pub imm: u16,
|
||||||
|
pub rhs_reg: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fully decoded command: its predicate (if any) and the instruction.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Command {
|
||||||
|
pub compare: Option<Compare>,
|
||||||
|
pub instr: Instr,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command types — `byte0` bits 7-5.
|
||||||
|
const TYPE_SPECIAL: u8 = 0;
|
||||||
|
const TYPE_LINK_JUMP: u8 = 1;
|
||||||
|
const TYPE_SET_SYSTEM: u8 = 2;
|
||||||
|
const TYPE_SET_GPRM: u8 = 3;
|
||||||
|
|
||||||
|
// Special (type 0) sub-commands — `byte1` bits 3-0.
|
||||||
|
const SP_GOTO: u8 = 1;
|
||||||
|
const SP_BREAK: u8 = 2;
|
||||||
|
|
||||||
|
// Jump/Call (type 1, direct=1) sub-commands.
|
||||||
|
const JP_EXIT: u8 = 1;
|
||||||
|
const JP_JUMP_TT: u8 = 2;
|
||||||
|
const JP_JUMP_VTS_TT: u8 = 3;
|
||||||
|
const JP_JUMP_VTS_PTT: u8 = 5;
|
||||||
|
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 (the DVD-Video VM link instruction).
|
||||||
|
const LK_SUB: u8 = 1;
|
||||||
|
const LK_PGCN: u8 = 4;
|
||||||
|
const LK_PTTN: u8 = 5;
|
||||||
|
const LK_PGN: u8 = 6;
|
||||||
|
const LK_CN: u8 = 7;
|
||||||
|
|
||||||
|
// JumpSS sub-domain selector — `byte5` bits 7-6.
|
||||||
|
const SS_FP: u8 = 0;
|
||||||
|
const SS_VMGM_MENU: u8 = 1;
|
||||||
|
const SS_VTSM: u8 = 2;
|
||||||
|
|
||||||
|
// Operand field widths (spec-defined bit counts).
|
||||||
|
const MASK_TTN: u8 = 0x7F; // 7-bit title number
|
||||||
|
const MASK_PGN: u8 = 0x7F; // 7-bit program number
|
||||||
|
const MASK_LINKOP: u8 = 0x1F; // 5-bit link sub-op
|
||||||
|
const MASK_REG: u8 = 0x0F; // 4-bit GPRM index
|
||||||
|
const MASK_MENU: u8 = 0x0F; // 4-bit menu id
|
||||||
|
const MASK_PTTN: u16 = 0x03FF; // 10-bit part-of-title
|
||||||
|
const MASK_PGCN: u16 = 0x7FFF; // 15-bit PGC number
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn be16(b: &[u8; 8], o: usize) -> u16 {
|
||||||
|
((b[o] as u16) << 8) | b[o + 1] as u16
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare-operand layouts ("if_version"s) per the DVD-Video VM. The op
|
||||||
|
// nibble is always `byte1` bits 6-4; the immediate flag is `byte1` bit 7. The
|
||||||
|
// operand *offsets* differ by command family.
|
||||||
|
//
|
||||||
|
// v1 (special + link): lhs reg = b[3]; rhs imm = bytes4-5 / rhs reg = b[4].
|
||||||
|
// v2 (jump + system-set): lhs reg = b[6]; rhs reg = b[7] (registers only).
|
||||||
|
// v3 (set-GPRM): lhs reg = b[2]; rhs imm = bytes6-7 / rhs reg = b[6].
|
||||||
|
fn if_v1(b: &[u8; 8]) -> Option<Compare> {
|
||||||
|
let op = (b[1] >> 4) & 7;
|
||||||
|
(op != 0).then(|| Compare {
|
||||||
|
op,
|
||||||
|
lhs_reg: b[3],
|
||||||
|
immediate: b[1] >> 7 != 0,
|
||||||
|
imm: be16(b, 4),
|
||||||
|
rhs_reg: b[4],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn if_v2(b: &[u8; 8]) -> Option<Compare> {
|
||||||
|
let op = (b[1] >> 4) & 7;
|
||||||
|
(op != 0).then(|| Compare {
|
||||||
|
op,
|
||||||
|
lhs_reg: b[6],
|
||||||
|
immediate: false,
|
||||||
|
imm: 0,
|
||||||
|
rhs_reg: b[7],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn if_v3(b: &[u8; 8]) -> Option<Compare> {
|
||||||
|
let op = (b[1] >> 4) & 7;
|
||||||
|
(op != 0).then(|| Compare {
|
||||||
|
op,
|
||||||
|
lhs_reg: b[2],
|
||||||
|
immediate: b[1] >> 7 != 0,
|
||||||
|
imm: be16(b, 6),
|
||||||
|
rhs_reg: b[6],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode an 8-byte VM command.
|
||||||
|
pub fn decode(b: &[u8; 8]) -> Command {
|
||||||
|
let typ = b[0] >> 5;
|
||||||
|
let direct = (b[0] >> 4) & 1;
|
||||||
|
let setop = b[0] & 0x0F;
|
||||||
|
let cmd = b[1] & 0x0F;
|
||||||
|
|
||||||
|
// Compare predicate, with the operand layout for this command family
|
||||||
|
// (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
|
||||||
|
(TYPE_LINK_JUMP, 0) => if_v1(b), // link
|
||||||
|
(TYPE_SET_SYSTEM, _) => if_v2(b),
|
||||||
|
(TYPE_SET_GPRM, _) => if_v3(b),
|
||||||
|
_ => None, // 4/5/6 compound — not needed by the resolver
|
||||||
|
};
|
||||||
|
|
||||||
|
// JumpSS sub-domain selector lives in byte5 bits 7-6.
|
||||||
|
let ss_sel = b[5] >> 6;
|
||||||
|
|
||||||
|
let instr = match typ {
|
||||||
|
TYPE_LINK_JUMP if direct == 1 => match cmd {
|
||||||
|
JP_EXIT => Instr::Exit,
|
||||||
|
JP_JUMP_TT => Instr::JumpTt {
|
||||||
|
ttn: b[5] & MASK_TTN,
|
||||||
|
},
|
||||||
|
JP_JUMP_VTS_TT => Instr::JumpVtsTt {
|
||||||
|
ttn: b[5] & MASK_TTN,
|
||||||
|
},
|
||||||
|
JP_JUMP_VTS_PTT => Instr::JumpVtsPtt {
|
||||||
|
ttn: b[5] & MASK_TTN,
|
||||||
|
pttn: be16(b, 2) & MASK_PTTN,
|
||||||
|
},
|
||||||
|
JP_JUMP_SS => match ss_sel {
|
||||||
|
SS_FP => Instr::JumpSsFp,
|
||||||
|
SS_VMGM_MENU => Instr::JumpSsVmgm {
|
||||||
|
menu: b[5] & MASK_MENU,
|
||||||
|
},
|
||||||
|
SS_VTSM => Instr::JumpSsVtsm {
|
||||||
|
vts: b[4],
|
||||||
|
ttn: b[3],
|
||||||
|
menu: b[5] & MASK_MENU,
|
||||||
|
},
|
||||||
|
_ => Instr::JumpSsVmgmPgc {
|
||||||
|
pgcn: be16(b, 2) & MASK_PGCN,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
JP_CALL_SS => Instr::CallSs { sub: ss_sel },
|
||||||
|
_ => Instr::Nop,
|
||||||
|
},
|
||||||
|
TYPE_LINK_JUMP => match cmd {
|
||||||
|
// direct == 0 (link). sub-op 0 = NOP/no-link.
|
||||||
|
LK_SUB => Instr::LinkSub {
|
||||||
|
sub: b[7] & MASK_LINKOP,
|
||||||
|
},
|
||||||
|
LK_PGCN => Instr::LinkPgcn {
|
||||||
|
pgcn: be16(b, 6) & MASK_PGCN,
|
||||||
|
},
|
||||||
|
LK_PTTN => Instr::LinkPttn {
|
||||||
|
pttn: be16(b, 6) & MASK_PTTN,
|
||||||
|
},
|
||||||
|
LK_PGN => Instr::LinkPgn {
|
||||||
|
pgn: b[7] & MASK_PGN,
|
||||||
|
},
|
||||||
|
LK_CN => Instr::LinkCn { cn: b[7] },
|
||||||
|
_ => Instr::Nop,
|
||||||
|
},
|
||||||
|
TYPE_SPECIAL => match cmd {
|
||||||
|
SP_GOTO => Instr::Goto { line: b[7] },
|
||||||
|
SP_BREAK => Instr::Break,
|
||||||
|
_ => Instr::Nop,
|
||||||
|
},
|
||||||
|
TYPE_SET_GPRM => Instr::SetGprm {
|
||||||
|
reg: b[3] & MASK_REG,
|
||||||
|
op: setop,
|
||||||
|
immediate: direct != 0,
|
||||||
|
imm: be16(b, 4),
|
||||||
|
src: b[5],
|
||||||
|
},
|
||||||
|
TYPE_SET_SYSTEM => Instr::SetSystem,
|
||||||
|
_ => Instr::Other(*b),
|
||||||
|
};
|
||||||
|
|
||||||
|
Command { compare, instr }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// KATs taken from the real SOTL / Greenland discs (decoded in the PoC).
|
||||||
|
#[test]
|
||||||
|
fn greenland_first_play_is_jumptt_1() {
|
||||||
|
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() {
|
||||||
|
// 30 06 ... byte5=0x83 -> sub 2 (VTSM), vts=byte4=1, menu=byte5&0xF=3 (root)
|
||||||
|
let c = decode(&h("3006000101830000"));
|
||||||
|
assert_eq!(
|
||||||
|
c.instr,
|
||||||
|
Instr::JumpSsVtsm {
|
||||||
|
vts: 1,
|
||||||
|
ttn: 1,
|
||||||
|
menu: 3
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sotl_title_dispatch_is_conditional_linkpgn_2() {
|
||||||
|
// 20 a6 ... CmpLink: if GPRM0 == 2 -> LinkPGN 2 (cell 2 = the 5:02 start)
|
||||||
|
let c = decode(&h("20a6000000020002"));
|
||||||
|
assert_eq!(c.instr, Instr::LinkPgn { pgn: 2 });
|
||||||
|
let cmp = c.compare.expect("conditional");
|
||||||
|
assert_eq!(cmp.op, 2); // ==
|
||||||
|
assert_eq!(cmp.lhs_reg, 0); // GPRM0
|
||||||
|
assert!(cmp.immediate);
|
||||||
|
assert_eq!(cmp.imm, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sotl_root_button_is_linkpgcn_37() {
|
||||||
|
assert_eq!(
|
||||||
|
decode(&h("2004000000000025")).instr,
|
||||||
|
Instr::LinkPgcn { pgcn: 37 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn greenland_scene_button_is_linkpgn() {
|
||||||
|
assert_eq!(
|
||||||
|
decode(&h("2006000000001401")).instr,
|
||||||
|
Instr::LinkPgn { pgn: 1 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jumpvts_ptt_decodes_ttn_and_pttn() {
|
||||||
|
// synthetic: 30 05 | ptt(bytes2-3)=0x0002 | ttn(byte5)=1
|
||||||
|
let c = decode(&h("3005000200010000"));
|
||||||
|
assert_eq!(c.instr, Instr::JumpVtsPtt { ttn: 1, pttn: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setgprm_immediate_mov() {
|
||||||
|
// SOTL First-Play pre[0]: 71 00 | reg=byte3=6 | imm(bytes4-5)=0x03e8 -> g6 = 1000
|
||||||
|
match decode(&h("7100000603e80000")).instr {
|
||||||
|
Instr::SetGprm {
|
||||||
|
reg,
|
||||||
|
op,
|
||||||
|
immediate,
|
||||||
|
imm,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(reg, 6);
|
||||||
|
assert_eq!(op, 1); // mov
|
||||||
|
assert!(immediate);
|
||||||
|
assert_eq!(imm, 1000);
|
||||||
|
}
|
||||||
|
other => panic!("expected SetGprm, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
assert_eq!(
|
||||||
|
decode(&h("2001000000000010")).instr,
|
||||||
|
Instr::LinkSub { sub: 0x10 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if_version_1 register compare: rhs register is byte4 (not byte5).
|
||||||
|
#[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"));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if_version_2 jump compare: both operands are registers in byte6 / byte7.
|
||||||
|
#[test]
|
||||||
|
fn jump_compare_uses_bytes6_and_7() {
|
||||||
|
// 30 22: jump, cmp=EQ(2) ; cmd=2 JumpTT ttn=byte5=5
|
||||||
|
let c = decode(&h("3022000000050607"));
|
||||||
|
assert_eq!(c.instr, Instr::JumpTt { ttn: 5 });
|
||||||
|
let cmp = c.compare.expect("conditional");
|
||||||
|
assert!(!cmp.immediate);
|
||||||
|
assert_eq!(cmp.lhs_reg, 6);
|
||||||
|
assert_eq!(cmp.rhs_reg, 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
+932
-105
File diff suppressed because it is too large
Load Diff
+267
@@ -0,0 +1,267 @@
|
|||||||
|
//! 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 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 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");
|
||||||
|
}
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
//! The single hex → bytes parser for the whole workspace.
|
||||||
|
//!
|
||||||
|
//! Key material arrives as hex from three third-party sources — the keydb, an
|
||||||
|
//! online key service, and the mapfile's `# freemkv-vid:` comment — and each
|
||||||
|
//! used to parse it slightly differently (one stripped `0x`/`0X`, one stripped
|
||||||
|
//! nothing, one stripped `0x` only). A key written with a prefix one parser
|
||||||
|
//! didn't expect was silently dropped → "can't decrypt" with no error. This is
|
||||||
|
//! the one parser they all call, so the prefix/case/validation rules live in
|
||||||
|
//! exactly one place.
|
||||||
|
//!
|
||||||
|
//! Operates on BYTES, not `&str` char indices: the inputs are untrusted, so a
|
||||||
|
//! multi-byte UTF-8 scalar must reject as malformed, never panic on a
|
||||||
|
//! mid-codepoint slice.
|
||||||
|
|
||||||
|
/// Parse a hex string into bytes. Accepts an optional `0x`/`0X` prefix
|
||||||
|
/// (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_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().is_multiple_of(2) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(bytes.len() / 2);
|
||||||
|
for pair in bytes.chunks_exact(2) {
|
||||||
|
out.push(byte(pair[0], pair[1])?);
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a hex string into a fixed `[u8; N]`. Accepts an optional `0x`/`0X`
|
||||||
|
/// 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_hex_prefix(s.trim());
|
||||||
|
let bytes = body.as_bytes();
|
||||||
|
if bytes.len() != 2 * N {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = [0u8; N];
|
||||||
|
for (i, slot) in out.iter_mut().enumerate() {
|
||||||
|
*slot = byte(bytes[2 * i], bytes[2 * i + 1])?;
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combine two ASCII hex-digit bytes into one byte. `as char` is intentional:
|
||||||
|
/// for a non-ASCII byte it produces a Latin-1 scalar that `to_digit(16)` then
|
||||||
|
/// rejects — so non-hex (incl. `+`/`-` sign chars) and multi-byte input fail
|
||||||
|
/// cleanly rather than slipping through `from_str_radix`'s sign handling.
|
||||||
|
fn byte(hi: u8, lo: u8) -> Option<u8> {
|
||||||
|
let hi = (hi as char).to_digit(16)?;
|
||||||
|
let lo = (lo as char).to_digit(16)?;
|
||||||
|
Some((hi * 16 + lo) as u8)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_accepts_0x_0x_and_bare_same_result() {
|
||||||
|
let want = [0x00, 0x11, 0xab, 0xCD, 0xef, 0x42, 0x99, 0x00];
|
||||||
|
let bare = "0011abcdef429900";
|
||||||
|
assert_eq!(parse_hex_fixed::<8>(bare), Some(want));
|
||||||
|
assert_eq!(parse_hex_fixed::<8>(&format!("0x{bare}")), Some(want));
|
||||||
|
// The case that used to be dropped by one parser but not another.
|
||||||
|
assert_eq!(parse_hex_fixed::<8>(&format!("0X{bare}")), Some(want));
|
||||||
|
assert_eq!(parse_hex_fixed::<8>(&format!(" 0X{bare} ")), Some(want));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_rejects_wrong_length_and_non_hex_and_signs() {
|
||||||
|
assert_eq!(parse_hex_fixed::<16>("00"), None); // too short
|
||||||
|
assert_eq!(parse_hex_fixed::<2>("00112233"), None); // too long
|
||||||
|
assert_eq!(parse_hex_fixed::<2>("zz11"), None); // non-hex
|
||||||
|
assert_eq!(parse_hex_fixed::<2>("+5-A"), None); // sign chars
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_panic_on_multibyte_of_exact_byte_length() {
|
||||||
|
// "中" is 3 bytes; + 29 'a' = 32 bytes → would mis-slice a &str-indexed
|
||||||
|
// parser. Must reject, not panic.
|
||||||
|
let s = "中".to_string() + &"a".repeat(29);
|
||||||
|
assert_eq!(s.len(), 32);
|
||||||
|
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]));
|
||||||
|
assert_eq!(parse_hex_bytes("AABBC"), None); // odd
|
||||||
|
// Empty (or prefix-only) → empty Vec: a legitimately-empty field.
|
||||||
|
assert_eq!(parse_hex_bytes(""), Some(vec![]));
|
||||||
|
assert_eq!(parse_hex_bytes("0x"), Some(vec![]));
|
||||||
|
}
|
||||||
|
}
|
||||||
+193
-1
@@ -49,13 +49,30 @@ pub struct DriveId {
|
|||||||
pub raw_gc_010c: Vec<u8>,
|
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 {
|
impl DriveId {
|
||||||
/// Probe a real drive via SCSI and build its identity.
|
/// Probe a real drive via SCSI and build its identity.
|
||||||
pub fn from_drive(transport: &mut dyn ScsiTransport) -> Result<Self> {
|
pub fn from_drive(transport: &mut dyn ScsiTransport) -> Result<Self> {
|
||||||
// INQUIRY — SPC-4 §6.4
|
// INQUIRY — SPC-4 §6.4
|
||||||
let mut inquiry = vec![0u8; 96];
|
let mut inquiry = vec![0u8; 96];
|
||||||
let cdb_inq = [0x12, 0x00, 0x00, 0x00, 0x60, 0x00];
|
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.
|
// GET CONFIGURATION Feature 010Ch — MMC-6 §6.6.
|
||||||
// Best-effort: 010Ch (Firmware Information) is an optional feature.
|
// 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
|
/// 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.
|
/// (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.
|
/// 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]
|
#[test]
|
||||||
fn ascii_field_short_buffer_returns_empty() {
|
fn ascii_field_short_buffer_returns_empty() {
|
||||||
// Buffer of length 5: start=8 is beyond the end → empty string.
|
// 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
|
/// GET CONFIGURATION failure (transport error) must not abort the
|
||||||
/// identity probe — firmware_date is empty, raw_gc_010c is empty.
|
/// identity probe — firmware_date is empty, raw_gc_010c is empty.
|
||||||
/// Mutation: propagating the GET_CONFIGURATION error with `?` aborts from_drive.
|
/// 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]
|
#[test]
|
||||||
fn from_drive_gc_failure_yields_empty_firmware_date() {
|
fn from_drive_gc_failure_yields_empty_firmware_date() {
|
||||||
struct GcFailTransport;
|
struct GcFailTransport;
|
||||||
|
|||||||
+1258
-151
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -133,10 +133,10 @@ where
|
|||||||
match rx.recv_timeout(slice) {
|
match rx.recv_timeout(slice) {
|
||||||
Ok(v) => return Ok(v),
|
Ok(v) => return Ok(v),
|
||||||
Err(RecvTimeoutError::Timeout) => {
|
Err(RecvTimeoutError::Timeout) => {
|
||||||
if let Some(h) = halt {
|
if let Some(h) = halt
|
||||||
if h.is_cancelled() {
|
&& h.is_cancelled()
|
||||||
return Err(BoundedError::Halted);
|
{
|
||||||
}
|
return Err(BoundedError::Halted);
|
||||||
}
|
}
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
return Err(BoundedError::Timeout);
|
return Err(BoundedError::Timeout);
|
||||||
|
|||||||
@@ -249,6 +249,27 @@ impl Drop for BytePrefetcher {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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
|
/// Endless reader: every `read` fills the whole buffer and never
|
||||||
/// hits EOF, so the producer keeps trying to push batches forward
|
/// hits EOF, so the producer keeps trying to push batches forward
|
||||||
/// until the forward channel disconnects. Exactly the shape that
|
/// until the forward channel disconnects. Exactly the shape that
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::os::unix::io::AsRawFd;
|
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
|
// Best-effort: return value ignored. A fadvise failure has no
|
||||||
// user-observable consequence.
|
// user-observable consequence.
|
||||||
unsafe {
|
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
|
/// Drop pages in the half-open byte range `[start, start+len)` from
|
||||||
/// the page cache. Called periodically by `read_sectors` to bound the
|
/// the page cache. Called periodically by `read_sectors` to bound the
|
||||||
/// read-side page cache pressure.
|
/// 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 {
|
unsafe {
|
||||||
libc::posix_fadvise(
|
libc::posix_fadvise(
|
||||||
file.as_raw_fd(),
|
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
|
/// can only pre-stage a tiny slice of the next batch. An explicit
|
||||||
/// `readahead()` of the same size as the current batch tells the
|
/// `readahead()` of the same size as the current batch tells the
|
||||||
/// kernel to queue the full next-batch read now.
|
/// 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 {
|
unsafe {
|
||||||
libc::readahead(file.as_raw_fd(), offset as i64, len as usize);
|
libc::readahead(file.as_raw_fd(), offset as i64, len as usize);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use std::os::unix::io::AsRawFd;
|
|||||||
/// pipeline depth.
|
/// pipeline depth.
|
||||||
const RDADVISE_MAX_BYTES: i64 = 64 * 1024 * 1024;
|
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 bytes = (len_bytes as i64).min(RDADVISE_MAX_BYTES);
|
||||||
let mut ra = libc::radvisory {
|
let mut ra = libc::radvisory {
|
||||||
ra_offset: 0,
|
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
|
/// approximation: no-op. macOS's unified buffer cache is generally
|
||||||
/// less prone to the pin-everything pathology that triggers the
|
/// less prone to the pin-everything pathology that triggers the
|
||||||
/// regression on Linux NFS clients.
|
/// 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
|
/// Async-prefetch the byte range `[offset, offset+len)`. macOS uses
|
||||||
/// the same `fcntl(F_RDADVISE, &radvisory)` primitive as the open-
|
/// the same `fcntl(F_RDADVISE, &radvisory)` primitive as the open-
|
||||||
/// time sequential hint, just targeted at a moving window instead of
|
/// time sequential hint, just targeted at a moving window instead of
|
||||||
/// the whole file. The kernel queues I/O for the requested range and
|
/// the whole file. The kernel queues I/O for the requested range and
|
||||||
/// returns immediately.
|
/// 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 bytes = (len as i64).min(RDADVISE_MAX_BYTES);
|
||||||
let mut ra = libc::radvisory {
|
let mut ra = libc::radvisory {
|
||||||
ra_offset: offset as libc::off_t,
|
ra_offset: offset as libc::off_t,
|
||||||
|
|||||||
@@ -48,22 +48,25 @@
|
|||||||
//! far smaller than our 16 MiB app-level batch.
|
//! far smaller than our 16 MiB app-level batch.
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod linux;
|
pub(crate) mod linux;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod macos;
|
pub(crate) mod macos;
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||||
mod other;
|
pub(crate) mod other;
|
||||||
#[cfg(target_os = "windows")]
|
#[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")]
|
#[cfg(target_os = "linux")]
|
||||||
use linux as platform;
|
pub(crate) use linux as platform;
|
||||||
#[cfg(target_os = "macos")]
|
#[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")))]
|
#[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")]
|
#[cfg(target_os = "windows")]
|
||||||
use windows as platform;
|
pub(crate) use windows as platform;
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
@@ -72,24 +75,40 @@ use std::path::Path;
|
|||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::sector::SectorSource;
|
use crate::sector::SectorSource;
|
||||||
|
|
||||||
use crate::consts::SECTOR_BYTES;
|
use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
|
||||||
|
|
||||||
/// Bytes-read threshold per `posix_fadvise(DONTNEED)` drop on the
|
/// Bytes-read threshold per `posix_fadvise(DONTNEED)` drop on the
|
||||||
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
|
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
|
||||||
/// cache stays bounded the same way the write side does.
|
/// cache stays bounded the same way the write side does.
|
||||||
///
|
///
|
||||||
/// 32 MiB is the empirically tuned value on the rip1 test bed (single
|
/// 32 MiB is the empirically tuned value on a 7200rpm HDD via SATA:
|
||||||
/// 7200rpm HDD via SATA): smaller windows (8 / 16 MiB) shorten the
|
/// smaller windows (8 / 16 MiB) shorten the
|
||||||
/// kernel-readahead overlap and slow the producer; larger windows
|
/// kernel-readahead overlap and slow the producer; larger windows
|
||||||
/// (64 / 128 MiB) let the page cache pin enough of the ISO to
|
/// (64 / 128 MiB) let the page cache pin enough of the ISO to
|
||||||
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
|
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
|
||||||
const READ_DROP_CHUNK_BYTES_DEFAULT: u64 = 32 * 1024 * 1024;
|
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 {
|
fn read_drop_chunk_bytes() -> u64 {
|
||||||
std::env::var("FREEMKV_READ_DROP_CHUNK_MIB")
|
resolve_read_drop_chunk(
|
||||||
.ok()
|
std::env::var("FREEMKV_READ_DROP_CHUNK_MIB")
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
.ok()
|
||||||
.filter(|&n| n > 0)
|
.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)
|
.map(|n| n * 1024 * 1024)
|
||||||
.unwrap_or(READ_DROP_CHUNK_BYTES_DEFAULT)
|
.unwrap_or(READ_DROP_CHUNK_BYTES_DEFAULT)
|
||||||
}
|
}
|
||||||
@@ -134,7 +153,7 @@ impl FileSectorSource {
|
|||||||
.metadata()
|
.metadata()
|
||||||
.map_err(|e| Error::IoError { source: e })?
|
.map_err(|e| Error::IoError { source: e })?
|
||||||
.len();
|
.len();
|
||||||
let sectors = len / SECTOR_BYTES as u64;
|
let sectors = len / SECTOR_BYTES_U64;
|
||||||
if sectors > u32::MAX as u64 {
|
if sectors > u32::MAX as u64 {
|
||||||
return Err(Error::IsoTooLarge {
|
return Err(Error::IsoTooLarge {
|
||||||
path: path.to_string_lossy().into_owned(),
|
path: path.to_string_lossy().into_owned(),
|
||||||
@@ -171,16 +190,24 @@ impl SectorSource for FileSectorSource {
|
|||||||
) -> Result<usize> {
|
) -> Result<usize> {
|
||||||
let count = count as u32;
|
let count = count as u32;
|
||||||
let bytes = count as usize * SECTOR_BYTES;
|
let bytes = count as usize * SECTOR_BYTES;
|
||||||
debug_assert!(
|
// A real check, not a debug_assert: this is a public `SectorSource` impl,
|
||||||
out.len() >= bytes,
|
// so an undersized `out` is caller input, and `out[..bytes]` below would
|
||||||
"FileSectorSource::read_sectors: out len {} < requested {}",
|
// panic with 'range end index out of range' in release where the assert is
|
||||||
out.len(),
|
// compiled away. `Drive::read_fua` already carries exactly this guard, with
|
||||||
bytes
|
// 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 {
|
if count == 0 {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let offset = lba as u64 * SECTOR_BYTES as u64;
|
let offset = lba as u64 * SECTOR_BYTES_U64;
|
||||||
self.file
|
self.file
|
||||||
.seek(SeekFrom::Start(offset))
|
.seek(SeekFrom::Start(offset))
|
||||||
.map_err(|e| Error::IoError { source: e })?;
|
.map_err(|e| Error::IoError { source: e })?;
|
||||||
@@ -219,6 +246,40 @@ mod tests {
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use tempfile::tempdir;
|
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`
|
/// Build a deterministic ISO of `sectors` sectors where sector `n`
|
||||||
/// is filled with the byte pattern `((n & 0xff) as u8)`. Lets us
|
/// is filled with the byte pattern `((n & 0xff) as u8)`. Lets us
|
||||||
/// verify any sector by content alone.
|
/// verify any sector by content alone.
|
||||||
@@ -376,19 +437,36 @@ mod tests {
|
|||||||
// Additional coverage.
|
// Additional coverage.
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
/// `count == 0` must short-circuit to Ok(0) WITHOUT seeking or
|
/// `count == 0` must short-circuit to Ok(0) WITHOUT seeking or reading,
|
||||||
/// reading, even at an out-of-range LBA — the early-return guard
|
/// even at an out-of-range LBA. Grounding: `if count == 0 { return Ok(0) }`.
|
||||||
/// runs before any I/O. 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]
|
#[test]
|
||||||
fn zero_count_returns_zero_no_io() {
|
fn zero_count_returns_zero_no_io() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let path = dir.path().join("zc.iso");
|
let path = dir.path().join("zc.iso");
|
||||||
make_iso(&path, 4);
|
make_iso(&path, 4);
|
||||||
let mut src = FileSectorSource::open(&path).unwrap();
|
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.
|
// LBA far past EOF — must not matter because count==0 returns early.
|
||||||
let mut buf = [0u8; 1];
|
let mut buf = [0u8; 1];
|
||||||
let n = src.read_sectors(1_000_000, 0, &mut buf, false).unwrap();
|
let n = src.read_sectors(1_000_000, 0, &mut buf, false).unwrap();
|
||||||
assert_eq!(n, 0);
|
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
|
/// Reading past EOF must ERROR (read_exact's UnexpectedEof), never
|
||||||
@@ -494,7 +572,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn dontneed_eviction_does_not_affect_data() {
|
fn dontneed_eviction_does_not_affect_data() {
|
||||||
// 32 MiB default chunk = 16384 sectors; read a bit past it.
|
// 32 MiB default chunk = 16384 sectors; read a bit past it.
|
||||||
let total = (READ_DROP_CHUNK_BYTES_DEFAULT / SECTOR_BYTES as u64) as u32 + 64;
|
let total = (READ_DROP_CHUNK_BYTES_DEFAULT / SECTOR_BYTES_U64) as u32 + 64;
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let path = dir.path().join("drop.iso");
|
let path = dir.path().join("drop.iso");
|
||||||
make_iso(&path, total);
|
make_iso(&path, total);
|
||||||
@@ -518,4 +596,36 @@ mod tests {
|
|||||||
lba += batch as u32;
|
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;
|
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
|
/// No-op stub. `FILE_FLAG_SEQUENTIAL_SCAN` can only be set at
|
||||||
/// `CreateFile` open time, which the plain `File::open` path does not
|
/// `CreateFile` open time, which the plain `File::open` path does not
|
||||||
/// do, so there is no post-open hint to issue here.
|
/// 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!(
|
tracing::debug!(
|
||||||
target: "mux",
|
target: "mux",
|
||||||
"FileSectorSource hint_sequential: windows no-op stub"
|
"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
|
/// Windows page-cache eviction is not exposed via a posix_fadvise
|
||||||
/// equivalent. The kernel does its own working-set management. No-op
|
/// equivalent. The kernel does its own working-set management. No-op
|
||||||
/// for now.
|
/// 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
|
/// Windows async-prefetch hint. With FILE_FLAG_SEQUENTIAL_SCAN at
|
||||||
/// open the kernel already prefetches aggressively, so there's no
|
/// 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
|
/// per-range hint we'd add on top. No-op stub for parity with the
|
||||||
/// posix platforms.
|
/// 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 byte_prefetcher;
|
||||||
pub mod file_sector_source;
|
pub mod file_sector_source;
|
||||||
pub mod fsync;
|
pub mod fsync;
|
||||||
|
pub mod image_writer;
|
||||||
pub mod sink;
|
pub mod sink;
|
||||||
mod writeback;
|
mod writeback;
|
||||||
mod writeback_file;
|
mod writeback_file;
|
||||||
@@ -40,9 +41,8 @@ pub(crate) mod platform_macos;
|
|||||||
|
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
|
||||||
pub(crate) use writeback_file::WritebackFile;
|
pub use writeback_file::WritebackFile;
|
||||||
|
|
||||||
pub use pipeline::{
|
pub use pipeline::{
|
||||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
|
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink, WRITE_PIPELINE_DEPTH, WRITE_THROUGH_DEPTH,
|
||||||
WRITE_THROUGH_DEPTH,
|
|
||||||
};
|
};
|
||||||
|
|||||||
+360
-67
@@ -33,7 +33,7 @@
|
|||||||
//! consumer lag detection). This is critical for diagnosing stalls.
|
//! consumer lag detection). This is critical for diagnosing stalls.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -117,8 +117,32 @@ fn consumer_panicked(payload: Box<dyn std::any::Any + Send>) -> Error {
|
|||||||
Error::PipelineConsumerPanicked
|
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
|
/// 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
|
/// the common "nearly-done" consumer (whose own bounded_syscall just
|
||||||
/// returned and is about to drop its output file) into a clean join,
|
/// returned and is about to drop its output file) into a clean join,
|
||||||
/// releasing the file handle without waiting the full grace period.
|
/// 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).
|
/// syscall itself; that still returns on its own (or at process exit).
|
||||||
fn finish_with_grace<R: Send + 'static>(
|
fn finish_with_grace<R: Send + 'static>(
|
||||||
handle: thread::JoinHandle<Result<R, Error>>,
|
handle: thread::JoinHandle<Result<R, Error>>,
|
||||||
abandoned: &Arc<AtomicBool>,
|
state: &Arc<AtomicU8>,
|
||||||
|
grace: Duration,
|
||||||
leak_err: Error,
|
leak_err: Error,
|
||||||
) -> Result<R, Error> {
|
) -> Result<R, Error> {
|
||||||
let grace = Instant::now() + Duration::from_secs(FINISH_GRACE_SECS);
|
let deadline = Instant::now() + grace;
|
||||||
while Instant::now() < grace {
|
while Instant::now() < deadline {
|
||||||
if handle.is_finished() {
|
if handle.is_finished() {
|
||||||
return match handle.join() {
|
return match handle.join() {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
@@ -145,16 +170,50 @@ fn finish_with_grace<R: Send + 'static>(
|
|||||||
}
|
}
|
||||||
thread::sleep(POLL_INTERVAL);
|
thread::sleep(POLL_INTERVAL);
|
||||||
}
|
}
|
||||||
// Grace expired. Signal abandonment, then log and leak. Setting the
|
// Grace expired. CLAIM abandonment, then log and leak. Claiming BEFORE
|
||||||
// flag BEFORE dropping the handle guarantees the leaked consumer
|
// dropping the handle guarantees the leaked consumer observes it the moment
|
||||||
// observes it the moment its wedged syscall returns: it then skips
|
// its wedged syscall returns: it then skips any further `apply` and skips
|
||||||
// any further `apply` and skips `close()`, rather than running on to
|
// `close()`, rather than running on to finalise the abandoned output file.
|
||||||
// finalise the abandoned output file.
|
//
|
||||||
// `Release` here pairs with the `Acquire` loads in the consumer loop so
|
// A compare-exchange, not a store, because the consumer may have committed to
|
||||||
// the leaked consumer reliably observes the flag the moment its wedged
|
// `close()` in the instant between our last `is_finished()` poll and now. It
|
||||||
// syscall returns, even on weak memory models (ARM64/POWER) where
|
// then cannot be stopped — the finalise IS happening — so abandoning it would
|
||||||
// `Relaxed` gives no cross-thread visibility guarantee.
|
// report the rip as interrupted while a valid, fully finalised container
|
||||||
abandoned.store(true, Ordering::Release);
|
// 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!(
|
tracing::warn!(
|
||||||
target: "freemkv::pipeline",
|
target: "freemkv::pipeline",
|
||||||
phase = "finish_with_halt_grace_expired",
|
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
|
/// Default channel depth for callers without a specific reason to
|
||||||
/// pick another value. Kept conservative (4) — most callers should
|
/// 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;
|
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
|
/// Write pipeline depth. Smaller buffer reduces backpressure risk when
|
||||||
/// sync_file_range blocks; prevents producer from accumulating too much
|
/// sync_file_range blocks; prevents producer from accumulating too much
|
||||||
/// work while consumer waits for NFS to drain.
|
/// 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
|
/// Channel depth for write-through pipelines. Each `send` fully
|
||||||
/// drains before the next can enqueue. Use this when the producer
|
/// drains before the next can enqueue. Use this when the producer
|
||||||
/// must observe consumer side-effects (e.g. mapfile state) before
|
/// 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;
|
pub const WRITE_THROUGH_DEPTH: usize = 1;
|
||||||
|
|
||||||
/// Outcome of [`Sink::apply`]: either keep feeding items
|
/// 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
|
/// a syscall the consumer is currently wedged in, but it does bound
|
||||||
/// the damage to "whatever write is already in flight" once that
|
/// the damage to "whatever write is already in flight" once that
|
||||||
/// syscall returns, instead of running on to a clean finalise.
|
/// 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> {
|
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
|
/// The thread is named `freemkv-pipeline-consumer` so it shows up
|
||||||
/// distinctly in stack traces and `top -H`. Callers that want a
|
/// distinctly in stack traces and `top -H`. Callers that want a
|
||||||
/// more specific name (e.g. `freemkv-sweep-consumer`) should use
|
/// more specific name should use [`Pipeline::spawn_named`] instead.
|
||||||
/// [`Pipeline::spawn_named`] instead. Returns an `Error::IoError`
|
/// Returns an `Error::IoError` if the OS refuses the thread spawn
|
||||||
/// if the OS refuses the thread spawn (resource exhaustion);
|
/// (resource exhaustion); callers already operate in fallible context, so
|
||||||
/// callers already operate in fallible context, so this is
|
/// this is propagated rather than panicked.
|
||||||
/// propagated rather than panicked.
|
|
||||||
///
|
///
|
||||||
/// Sweep uses [`Pipeline::spawn_named`] directly so the consumer
|
/// Inside this crate the only [`Pipeline::spawn_named`] caller is the mux
|
||||||
/// thread shows up as `freemkv-sweep-consumer`; mux uses
|
/// driver, which names its thread `freemkv-mux-consumer`. `Pipeline::spawn`
|
||||||
/// `freemkv-mux-consumer`. `Pipeline::spawn` (this function, with
|
/// (this function, with the default name) is used only by the unit tests in
|
||||||
/// the default name) is used by `disc::patch` and by the unit
|
/// this module.
|
||||||
/// 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> {
|
pub fn spawn<S: Sink<I, Output = R>>(depth: usize, sink: S) -> Result<Self, Error> {
|
||||||
Self::spawn_named("freemkv-pipeline-consumer", depth, sink)
|
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
|
/// Like [`Pipeline::spawn`] but lets the caller supply the
|
||||||
/// consumer thread's name. Useful when several pipelines run in
|
/// consumer thread's name. Useful when several pipelines run in
|
||||||
/// the same process and stack traces / `top -H` need to tell them
|
/// 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>>(
|
pub fn spawn_named<S: Sink<I, Output = R>>(
|
||||||
name: &str,
|
name: &str,
|
||||||
depth: usize,
|
depth: usize,
|
||||||
sink: S,
|
sink: S,
|
||||||
) -> Result<Self, Error> {
|
) -> Result<Self, Error> {
|
||||||
let (tx, rx) = bounded::<I>(depth);
|
let (tx, rx) = bounded::<I>(depth);
|
||||||
let abandoned = Arc::new(AtomicBool::new(false));
|
let state = Arc::new(AtomicU8::new(state::RUNNING));
|
||||||
let abandoned_consumer = abandoned.clone();
|
let state_consumer = state.clone();
|
||||||
|
let failed = Arc::new(AtomicBool::new(false));
|
||||||
|
let failed_consumer = failed.clone();
|
||||||
let handle = thread::Builder::new()
|
let handle = thread::Builder::new()
|
||||||
.name(name.into())
|
.name(name.into())
|
||||||
.spawn(move || -> Result<R, Error> {
|
.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
|
// dead receiver, but we touch the output no further. The
|
||||||
// final post-loop abandonment check returns the error
|
// final post-loop abandonment check returns the error
|
||||||
// and skips `close()`.
|
// and skips `close()`.
|
||||||
if abandoned_consumer.load(Ordering::Acquire) {
|
if state_consumer.load(Ordering::Acquire) == state::ABANDONED {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,6 +417,12 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
|||||||
tracing::debug!("Pipeline: apply error, stopping, err={:?}", e);
|
tracing::debug!("Pipeline: apply error, stopping, err={:?}", e);
|
||||||
}
|
}
|
||||||
first_err = Some(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
|
// MKV Cues + patching the segment header) on a file the
|
||||||
// caller already reported as failed is exactly the
|
// caller already reported as failed is exactly the
|
||||||
// write race we must not run.
|
// write race we must not run.
|
||||||
if abandoned_consumer.load(Ordering::Acquire) {
|
|
||||||
return Err(Error::Halted);
|
|
||||||
}
|
|
||||||
|
|
||||||
match first_err {
|
match first_err {
|
||||||
Some(e) => Err(e),
|
// No `close()` on this path, so there is nothing to claim —
|
||||||
None => sink.close(),
|
// 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 })?;
|
.map_err(|e| Error::IoError { source: e })?;
|
||||||
@@ -416,10 +522,24 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
|||||||
Ok(Pipeline {
|
Ok(Pipeline {
|
||||||
tx,
|
tx,
|
||||||
handle,
|
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
|
/// Push one item. Blocks if the channel is full — that's the
|
||||||
/// back-pressure the whole primitive exists to provide. Returns
|
/// back-pressure the whole primitive exists to provide. Returns
|
||||||
/// the item back if the consumer thread is gone (panicked or
|
/// 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 {
|
} else {
|
||||||
// Benign per-item OK: trace-level (L4) only; the
|
// Benign per-item OK: trace-level (L4) only; the
|
||||||
// apply-side rolling summary carries throughput.
|
// 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(())
|
Ok(())
|
||||||
@@ -464,7 +587,10 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
|||||||
std::any::type_name::<I>()
|
std::any::type_name::<I>()
|
||||||
);
|
);
|
||||||
} else {
|
} 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)
|
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
|
/// wedged inside an unkillable syscall, the producer can still
|
||||||
/// observe `/api/stop` and unwind within
|
/// observe `/api/stop` and unwind within
|
||||||
/// [`SEND_HALT_CHECK_INTERVAL`].
|
/// [`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> {
|
pub fn send_with_halt(&self, item: I, halt: &Halt, deadline: Duration) -> Result<(), I> {
|
||||||
use crossbeam_channel::SendTimeoutError;
|
use crossbeam_channel::SendTimeoutError;
|
||||||
let end = Instant::now() + deadline;
|
let end = Instant::now() + deadline;
|
||||||
let mut pending = item;
|
let mut pending = item;
|
||||||
loop {
|
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.
|
// Pre-check the cheap exit conditions before parking.
|
||||||
if halt.is_cancelled() {
|
if halt.is_cancelled() {
|
||||||
if debug_enabled() {
|
if debug_enabled() {
|
||||||
@@ -565,7 +714,8 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
|||||||
let Pipeline {
|
let Pipeline {
|
||||||
tx,
|
tx,
|
||||||
handle,
|
handle,
|
||||||
abandoned: _,
|
state: _,
|
||||||
|
failed: _,
|
||||||
} = self;
|
} = self;
|
||||||
// Explicit drop, although the destructure already drops `tx`
|
// Explicit drop, although the destructure already drops `tx`
|
||||||
// at end-of-scope. Being explicit keeps the intent obvious.
|
// 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
|
/// Plain [`Pipeline::finish`] is preserved for callers without a
|
||||||
/// halt-token plumbed through; that path still blocks indefinitely
|
/// halt-token plumbed through; that path still blocks indefinitely
|
||||||
/// on `join()`, matching pre-0.20.8 behaviour.
|
/// 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> {
|
pub fn finish_with_halt(self, halt: Option<&Halt>) -> Result<R, Error> {
|
||||||
let Pipeline {
|
let Pipeline {
|
||||||
tx,
|
tx,
|
||||||
handle,
|
handle,
|
||||||
abandoned,
|
state,
|
||||||
|
failed: _,
|
||||||
} = self;
|
} = self;
|
||||||
drop(tx);
|
drop(tx);
|
||||||
let deadline = Instant::now() + Duration::from_secs(JOIN_TIMEOUT_SECS);
|
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)),
|
Err(payload) => Err(consumer_panicked(payload)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if let Some(h) = halt {
|
if let Some(h) = halt
|
||||||
if h.is_cancelled() {
|
&& h.is_cancelled()
|
||||||
return finish_with_grace(handle, &abandoned, Error::Halted);
|
{
|
||||||
}
|
return finish_with_grace(
|
||||||
|
handle,
|
||||||
|
&state,
|
||||||
|
Duration::from_secs(FINISH_GRACE_SECS),
|
||||||
|
Error::Halted,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if Instant::now() >= deadline {
|
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);
|
thread::sleep(POLL_INTERVAL);
|
||||||
}
|
}
|
||||||
@@ -1074,20 +1241,6 @@ mod tests {
|
|||||||
/// A sink that records the exact order of items it receives, so we
|
/// A sink that records the exact order of items it receives, so we
|
||||||
/// can prove the channel is FIFO (no reordering). `close` returns
|
/// can prove the channel is FIFO (no reordering). `close` returns
|
||||||
/// the recorded vector.
|
/// 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
|
/// Zero items sent: closing the pipeline immediately must still
|
||||||
/// call `close()` exactly once and return its Output. The consumer
|
/// call `close()` exactly once and return its Output. The consumer
|
||||||
/// loop's `while let Ok = rx.recv()` exits on the dropped tx with
|
/// 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);
|
let res = pipe.finish_with_halt(None);
|
||||||
assert!(matches!(res, Ok(190)), "expected Ok(190), got {res:?}");
|
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.chunk_bytes,
|
||||||
self.skip_wait(),
|
self.skip_wait(),
|
||||||
);
|
);
|
||||||
if self.chunk_count % SIZE_LOG_INTERVAL == 0 {
|
if self.chunk_count.is_multiple_of(SIZE_LOG_INTERVAL) {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
target: "mux",
|
target: "mux",
|
||||||
"WritebackPipeline chunk_bytes={} after {} chunks is_nfs={} degraded={}",
|
"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
|
/// Run `fsync` on `file` with a 60 s deadline. On timeout, halt or a lost
|
||||||
/// likewise on halt or a lost worker — we log and return `Ok(())`: the
|
/// worker we log and return `Err` — matching macOS. POSIX gives `fsync`
|
||||||
/// kernel will still flush on close, so the data is best-effort durable.
|
/// exactly one way to say "the data is on stable storage" and that is a zero
|
||||||
/// The alternative (trap the thread for the rest of the rip, or return
|
/// return; a call that never reached the device has not earned it, so `Ok(())`
|
||||||
/// an error that aborts an otherwise-complete mux) is worse, so all
|
/// from here means the flush completed and nothing else.
|
||||||
/// three fallbacks return `Ok(())`. `Ok(())` from these paths is NOT a
|
///
|
||||||
/// durability barrier — the durable flush did not complete; only the
|
/// The kernel will still flush on close, so the data is usually durable
|
||||||
/// hang is bounded.
|
/// 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
|
/// ## fd-reuse safety
|
||||||
///
|
///
|
||||||
@@ -78,27 +79,7 @@ pub(super) fn durable_sync(file: &File) -> io::Result<()> {
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Ok(inner) => inner,
|
Ok(inner) => inner,
|
||||||
Err(crate::io::bounded::BoundedError::Timeout) => {
|
Err(e) => bounded_failure_to_result(e),
|
||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,3 +124,77 @@ mod tests {
|
|||||||
durable_sync(f.as_file()).expect("durable_sync must return Ok on a local tempfile");
|
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,
|
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!(
|
tracing::error!(
|
||||||
target: "mux",
|
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 must complete without error on the local tempfile.
|
||||||
durable_sync(f.as_file()).expect("durable_sync must return Ok on a local tempfile");
|
durable_sync(f.as_file()).expect("durable_sync must return Ok on a local tempfile");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every `BoundedError` arm of the bounded `F_FULLFSYNC` means no sync
|
||||||
|
/// observably ran. All three returned `Ok(())`, so `sync_all` reported a
|
||||||
|
/// durability barrier that never happened — the caller could not tell a
|
||||||
|
/// completed flush from a skipped one by any means except reading a log.
|
||||||
|
///
|
||||||
|
/// Asserted on the concrete `ErrorKind` / `errno` each arm must produce,
|
||||||
|
/// so a future arm that quietly reverts to `Ok(())` fails here.
|
||||||
|
#[test]
|
||||||
|
fn every_bounded_failure_is_reported_as_an_error() {
|
||||||
|
use crate::io::bounded::BoundedError;
|
||||||
|
|
||||||
|
let timeout = bounded_failure_to_result(BoundedError::Timeout)
|
||||||
|
.expect_err("a timed-out F_FULLFSYNC must be an error");
|
||||||
|
assert_eq!(
|
||||||
|
timeout.kind(),
|
||||||
|
io::ErrorKind::TimedOut,
|
||||||
|
"a timed-out F_FULLFSYNC must not be reported as a completed sync"
|
||||||
|
);
|
||||||
|
|
||||||
|
let halted = bounded_failure_to_result(BoundedError::Halted)
|
||||||
|
.expect_err("a halted F_FULLFSYNC must be an error");
|
||||||
|
assert_eq!(
|
||||||
|
halted.kind(),
|
||||||
|
io::ErrorKind::Interrupted,
|
||||||
|
"a halted F_FULLFSYNC must not be reported as a completed sync"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The three arms must be DISTINGUISHABLE, not merely non-Ok. Each
|
||||||
|
// carries its own numeric code through the "E<code>" prefix that
|
||||||
|
// `From<Error> for io::Error` mints — the only shape `error_code`
|
||||||
|
// recognises. A bare `ErrorKind` cannot be classified, which is how a
|
||||||
|
// user cancel here used to read as a hard I/O failure.
|
||||||
|
let lost = bounded_failure_to_result(BoundedError::WorkerLost)
|
||||||
|
.expect_err("a lost F_FULLFSYNC worker must be an error");
|
||||||
|
assert!(
|
||||||
|
lost.to_string()
|
||||||
|
.starts_with(&format!("E{}", crate::error::E_SYNC_WORKER_LOST)),
|
||||||
|
"a lost worker must be identifiable, got {lost}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
timeout
|
||||||
|
.to_string()
|
||||||
|
.starts_with(&format!("E{}", crate::error::E_SYNC_TIMEOUT)),
|
||||||
|
"a timeout must be distinguishable from a lost worker, got {timeout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
crate::error::is_halt(&halted),
|
||||||
|
"a halt must satisfy the crate's own is_halt(), or the CLI reports a \
|
||||||
|
user cancel as a failure; got {halted}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The failure path must be reachable through the public surface: a
|
||||||
|
/// `WritebackFile::sync_all` that hits any of these arms must surface an
|
||||||
|
/// `Err`, not a silent `Ok`. Pinned at the mapping boundary because the
|
||||||
|
/// timeout itself is not deterministically inducible in a unit test.
|
||||||
|
#[test]
|
||||||
|
fn bounded_failures_are_never_mapped_to_ok() {
|
||||||
|
use crate::io::bounded::BoundedError;
|
||||||
|
for e in [
|
||||||
|
BoundedError::Timeout,
|
||||||
|
BoundedError::Halted,
|
||||||
|
BoundedError::WorkerLost,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
bounded_failure_to_result(e).is_err(),
|
||||||
|
"a bounded F_FULLFSYNC failure must never map to Ok"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ fn writeback_chunk_bytes() -> u64 {
|
|||||||
.unwrap_or(WRITEBACK_CHUNK_BYTES_DEFAULT)
|
.unwrap_or(WRITEBACK_CHUNK_BYTES_DEFAULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct WritebackFile {
|
pub struct WritebackFile {
|
||||||
file: File,
|
file: File,
|
||||||
pipeline: WritebackPipeline,
|
pipeline: WritebackPipeline,
|
||||||
pos: u64,
|
pos: u64,
|
||||||
@@ -115,7 +115,7 @@ impl WritebackFile {
|
|||||||
/// once so the pipeline starts tracking from wherever the file
|
/// once so the pipeline starts tracking from wherever the file
|
||||||
/// already is (typically 0 for fresh files; non-zero for resumed
|
/// already is (typically 0 for fresh files; non-zero for resumed
|
||||||
/// or appended files).
|
/// or appended files).
|
||||||
pub(crate) fn new(mut file: File) -> io::Result<Self> {
|
pub fn new(mut file: File) -> io::Result<Self> {
|
||||||
let pos = file.stream_position()?;
|
let pos = file.stream_position()?;
|
||||||
let pipeline = WritebackPipeline::new(&file, pos, writeback_chunk_bytes());
|
let pipeline = WritebackPipeline::new(&file, pos, writeback_chunk_bytes());
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -136,7 +136,7 @@ impl WritebackFile {
|
|||||||
/// [`Self::create_with_size_hint`] so the kernel can pre-reserve
|
/// [`Self::create_with_size_hint`] so the kernel can pre-reserve
|
||||||
/// extents.
|
/// extents.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub(crate) fn create(path: &Path) -> io::Result<Self> {
|
pub fn create(path: &Path) -> io::Result<Self> {
|
||||||
let file = File::create(path)?;
|
let file = File::create(path)?;
|
||||||
Self::new(file)
|
Self::new(file)
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ impl WritebackFile {
|
|||||||
/// On platforms without an extent-preallocation primitive this is
|
/// On platforms without an extent-preallocation primitive this is
|
||||||
/// equivalent to `create` — the size hint is dropped after a debug
|
/// equivalent to `create` — the size hint is dropped after a debug
|
||||||
/// log.
|
/// log.
|
||||||
pub(crate) fn create_with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
|
pub fn create_with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
|
||||||
let file = File::create(path)?;
|
let file = File::create(path)?;
|
||||||
platform::preallocate(&file, size_bytes);
|
platform::preallocate(&file, size_bytes);
|
||||||
Self::new(file)
|
Self::new(file)
|
||||||
@@ -163,7 +163,7 @@ impl WritebackFile {
|
|||||||
/// wrap it. Mirrors `File::open` semantics for the writable case
|
/// wrap it. Mirrors `File::open` semantics for the writable case
|
||||||
/// — used by patch / resume paths that mutate an existing ISO in
|
/// — used by patch / resume paths that mutate an existing ISO in
|
||||||
/// place.
|
/// place.
|
||||||
pub(crate) fn open(path: &Path) -> io::Result<Self> {
|
pub fn open(path: &Path) -> io::Result<Self> {
|
||||||
let file = OpenOptions::new().write(true).open(path)?;
|
let file = OpenOptions::new().write(true).open(path)?;
|
||||||
Self::new(file)
|
Self::new(file)
|
||||||
}
|
}
|
||||||
@@ -178,13 +178,20 @@ impl WritebackFile {
|
|||||||
/// is left to the kernel's normal flush-on-close path — best
|
/// is left to the kernel's normal flush-on-close path — best
|
||||||
/// effort, but bounded.
|
/// effort, but bounded.
|
||||||
///
|
///
|
||||||
/// IMPORTANT: on Linux/macOS a successful `Ok(())` does NOT
|
/// A bounded-fsync failure is returned as an `Err` on BOTH platforms, so
|
||||||
/// guarantee the data is durable if the bounded fsync timed out or
|
/// `Ok(())` means the flush completed and a caller needing
|
||||||
/// was halted — only the hang is bounded, the fsync may not have
|
/// crash-consistency can treat it as a durability barrier.
|
||||||
/// completed. Callers needing crash-consistency (e.g. mux-finish
|
///
|
||||||
/// then external commit/DB update) must not treat `Ok(())` as a
|
/// The three causes are DISTINGUISHABLE by numeric code, because a caller
|
||||||
/// durability barrier.
|
/// should not retry a lost worker the way it retries a timeout, and must
|
||||||
pub(crate) fn sync_all(&mut self) -> io::Result<()> {
|
/// not report a user cancel as a failure:
|
||||||
|
///
|
||||||
|
/// * [`E_SYNC_TIMEOUT`](crate::error::E_SYNC_TIMEOUT) — deadline expired
|
||||||
|
/// * [`E_HALTED`](crate::error::E_HALTED) — cancelled;
|
||||||
|
/// [`is_halt`](crate::error::is_halt) recognises it
|
||||||
|
/// * [`E_SYNC_WORKER_LOST`](crate::error::E_SYNC_WORKER_LOST) — the worker
|
||||||
|
/// thread died before reporting
|
||||||
|
pub fn sync_all(&mut self) -> io::Result<()> {
|
||||||
if self.seek_count > 0 {
|
if self.seek_count > 0 {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
target: "mux",
|
target: "mux",
|
||||||
@@ -256,9 +263,8 @@ impl super::sink::SequentialSink for WritebackFile {
|
|||||||
/// the same work [`Self::sync_all`] does. Implemented explicitly (no
|
/// the same work [`Self::sync_all`] does. Implemented explicitly (no
|
||||||
/// blanket impl) so a `dyn SequentialSink` / `dyn RandomAccessSink`
|
/// blanket impl) so a `dyn SequentialSink` / `dyn RandomAccessSink`
|
||||||
/// `finish()` actually finalises + fsyncs instead of hitting a no-op
|
/// `finish()` actually finalises + fsyncs instead of hitting a no-op
|
||||||
/// default. Note the bounded-fsync caveat from [`Self::sync_all`]
|
/// default. A bounded-fsync failure surfaces as an `Err` here, on every
|
||||||
/// applies: `Ok(())` is not a durability barrier if the fsync timed
|
/// platform, exactly as it does from [`Self::sync_all`].
|
||||||
/// out or was halted.
|
|
||||||
fn finish(&mut self) -> io::Result<()> {
|
fn finish(&mut self) -> io::Result<()> {
|
||||||
self.sync_all()
|
self.sync_all()
|
||||||
}
|
}
|
||||||
|
|||||||
-869
@@ -1,869 +0,0 @@
|
|||||||
//! KEYDB.cfg updater — HTTP GET, unzip, verify, save.
|
|
||||||
//!
|
|
||||||
//! Zero external HTTP dependencies. Raw TCP for HTTP GET.
|
|
||||||
//! Uses `zip` and `flate2` (already in deps) for extraction.
|
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::net::{TcpStream, ToSocketAddrs};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// Network operation timeout (connect / read / write). Keeps the daily
|
|
||||||
/// refresh thread from blocking indefinitely on an unresponsive mirror.
|
|
||||||
const NET_TIMEOUT: Duration = Duration::from_secs(10);
|
|
||||||
|
|
||||||
/// Read timeout — longer than connect/write since the keydb body can be
|
|
||||||
/// several MiB over a slow link.
|
|
||||||
const READ_TIMEOUT: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
/// Maximum redirects to follow before giving up.
|
|
||||||
const MAX_REDIRECTS: usize = 5;
|
|
||||||
|
|
||||||
/// Upper bound on decompressed keydb size. The published keydb is a few
|
|
||||||
/// MiB; 64 MiB is a generous ceiling that still caps a decompression
|
|
||||||
/// bomb (a tiny zip/gz can otherwise inflate to GiB and OOM the daily
|
|
||||||
/// refresh thread).
|
|
||||||
const MAX_KEYDB_BYTES: u64 = 64 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Read a decompressed stream into a String with a hard size ceiling.
|
|
||||||
/// Returns `Error::KeydbInvalid` if the input exceeds the cap, or
|
|
||||||
/// `Error::KeydbParse` if the bytes are not valid UTF-8.
|
|
||||||
fn read_capped_to_string<R: Read>(reader: R) -> Result<String> {
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
// Read one byte past the cap so an exactly-at-cap stream is accepted
|
|
||||||
// but anything larger is rejected.
|
|
||||||
reader
|
|
||||||
.take(MAX_KEYDB_BYTES + 1)
|
|
||||||
.read_to_end(&mut buf)
|
|
||||||
.map_err(|_| Error::KeydbParse)?;
|
|
||||||
if buf.len() as u64 > MAX_KEYDB_BYTES {
|
|
||||||
return Err(Error::KeydbInvalid);
|
|
||||||
}
|
|
||||||
String::from_utf8(buf).map_err(|_| Error::KeydbParse)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the error returned when the executable's own directory can't be
|
|
||||||
/// determined (`std::env::current_exe()` fails or has no parent). This is an
|
|
||||||
/// *environment* failure — the process can't locate itself, which typically
|
|
||||||
/// signals a stripped container or CI configuration — not a corrupt or
|
|
||||||
/// unparseable keydb file. Map it to a `NotFound` I/O error so
|
|
||||||
/// display/remediation paths never claim a keydb parse failure for a file
|
|
||||||
/// that was never consulted.
|
|
||||||
fn no_home_dir() -> Error {
|
|
||||||
Error::IoError {
|
|
||||||
source: std::io::Error::from(std::io::ErrorKind::NotFound),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Standard keydb storage path — the canonical location to write the keydb to.
|
|
||||||
///
|
|
||||||
/// The keydb lives *next to the executable*: `<dir of current exe>/keydb.cfg`,
|
|
||||||
/// where the directory is `std::env::current_exe()`'s parent. This makes
|
|
||||||
/// freemkv a portable, standalone binary — drop the exe and its `keydb.cfg`
|
|
||||||
/// in the same folder and it works, with no OS-specific config dir
|
|
||||||
/// (`%APPDATA%`, `~/.config`, XDG) involved at all.
|
|
||||||
///
|
|
||||||
/// The CLI's read-side search lives in
|
|
||||||
/// `freemkv-keysources::keydb_search_paths`; it resolves the same exe-local
|
|
||||||
/// location, so the *write* default used by `save`/`update` and the read-side
|
|
||||||
/// search always agree.
|
|
||||||
///
|
|
||||||
/// Returns a `NotFound` I/O error (via [`no_home_dir`]) if the executable's
|
|
||||||
/// own directory can't be determined.
|
|
||||||
pub fn default_path() -> Result<PathBuf> {
|
|
||||||
std::env::current_exe()
|
|
||||||
.ok()
|
|
||||||
.and_then(|exe| exe.parent().map(|dir| dir.join("keydb.cfg")))
|
|
||||||
.ok_or_else(no_home_dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Download a KEYDB from a URL, verify, save to the standard path.
|
|
||||||
pub fn update(url: &str) -> Result<UpdateResult> {
|
|
||||||
let body = http_get(url)?;
|
|
||||||
save(&body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify and save raw keydb bytes (plain text, .zip, or .gz).
|
|
||||||
pub fn save(data: &[u8]) -> Result<UpdateResult> {
|
|
||||||
let text = if data.starts_with(b"PK\x03\x04") {
|
|
||||||
extract_zip(data)?
|
|
||||||
} else if data.starts_with(&[0x1f, 0x8b]) {
|
|
||||||
read_capped_to_string(flate2::read::GzDecoder::new(data))?
|
|
||||||
} else {
|
|
||||||
// Plain-text body: route through the same capped reader as the gz/zip
|
|
||||||
// branches so an oversized uncompressed upload can't bypass
|
|
||||||
// MAX_KEYDB_BYTES.
|
|
||||||
read_capped_to_string(std::io::Cursor::new(data))?
|
|
||||||
};
|
|
||||||
|
|
||||||
let entries = text
|
|
||||||
.lines()
|
|
||||||
.filter(|l| {
|
|
||||||
let t = l.trim();
|
|
||||||
t.starts_with("0x")
|
|
||||||
|| t.starts_with("| DK")
|
|
||||||
|| t.starts_with("| PK")
|
|
||||||
|| t.starts_with("| HC")
|
|
||||||
})
|
|
||||||
.count();
|
|
||||||
|
|
||||||
if entries == 0 {
|
|
||||||
return Err(Error::KeydbInvalid);
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = default_path()?;
|
|
||||||
write_atomic(&path, &text)?;
|
|
||||||
|
|
||||||
Ok(UpdateResult {
|
|
||||||
path,
|
|
||||||
entries,
|
|
||||||
bytes: text.len(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write `text` to `path` crash-safely (create parent dir, write a sibling
|
|
||||||
/// temp file, fsync, then atomic rename).
|
|
||||||
///
|
|
||||||
/// keydb.cfg is the single source of AACS truth, and `save`/`update` run
|
|
||||||
/// unattended (first-boot download + daily-refresh thread, with a container
|
|
||||||
/// restart on every release). A bare in-place `fs::write` truncates the file
|
|
||||||
/// before writing, so a SIGKILL (docker stop's grace window), OOM-kill, power
|
|
||||||
/// loss, or ENOSPC mid-write would leave the keydb half-written — the prior
|
|
||||||
/// good copy already gone. A truncated keydb doesn't error at write time; it
|
|
||||||
/// silently breaks key resolution on every later AACS rip. Writing to a temp
|
|
||||||
/// file then renaming (POSIX rename is atomic within a filesystem) means an
|
|
||||||
/// interrupted update leaves the previous keydb fully intact.
|
|
||||||
///
|
|
||||||
/// The fsync MUST succeed before the rename: a `sync_all` failure (ENOSPC,
|
|
||||||
/// ESTALE on the bind-mounted volume) means the kernel never guaranteed the
|
|
||||||
/// bytes reached stable storage, so publishing them via rename would defeat
|
|
||||||
/// crash-safety. The temp name is unique per call (pid + monotonic counter)
|
|
||||||
/// so a concurrent update can't share a fixed temp path and rename a mangled
|
|
||||||
/// file over the keydb.
|
|
||||||
fn write_atomic(path: &std::path::Path, text: &str) -> Result<()> {
|
|
||||||
let werr = || Error::KeydbWrite {
|
|
||||||
path: path.display().to_string(),
|
|
||||||
};
|
|
||||||
if let Some(dir) = path.parent() {
|
|
||||||
std::fs::create_dir_all(dir).map_err(|e| {
|
|
||||||
tracing::warn!(error = %e, path = %path.display(), "keydb dir create failed");
|
|
||||||
werr()
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
let tmp = {
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
||||||
path.with_extension(format!(
|
|
||||||
"tmp.{}.{}",
|
|
||||||
std::process::id(),
|
|
||||||
TMP_COUNTER.fetch_add(1, Ordering::Relaxed)
|
|
||||||
))
|
|
||||||
};
|
|
||||||
let write_result = (|| -> std::io::Result<()> {
|
|
||||||
let mut f = std::fs::File::create(&tmp)?;
|
|
||||||
f.write_all(text.as_bytes())?;
|
|
||||||
f.sync_all()?;
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
if let Err(e) = write_result {
|
|
||||||
let _ = std::fs::remove_file(&tmp);
|
|
||||||
tracing::warn!(error = %e, path = %path.display(), "keydb write/fsync failed; keydb unchanged");
|
|
||||||
return Err(werr());
|
|
||||||
}
|
|
||||||
if let Err(e) = std::fs::rename(&tmp, path) {
|
|
||||||
let _ = std::fs::remove_file(&tmp);
|
|
||||||
tracing::warn!(error = %e, path = %path.display(), "keydb rename failed; keydb unchanged");
|
|
||||||
return Err(werr());
|
|
||||||
}
|
|
||||||
// Durably commit the new dirent: on POSIX filesystems (ext2, some NFS) a
|
|
||||||
// crash right after the rename can lose the directory entry even though the
|
|
||||||
// rename returned. Best-effort (swallowed on failure); no-op on Windows.
|
|
||||||
if let Some(dir) = path.parent() {
|
|
||||||
crate::io::fsync::dir(dir);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of a KEYDB update -- path written, entry count, and byte size.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct UpdateResult {
|
|
||||||
pub path: PathBuf,
|
|
||||||
pub entries: usize,
|
|
||||||
pub bytes: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn http_get(url: &str) -> Result<Vec<u8>> {
|
|
||||||
let (mut host, mut port, mut path) = parse_url(url)?;
|
|
||||||
|
|
||||||
for _ in 0..MAX_REDIRECTS {
|
|
||||||
// Resolve to a concrete socket address so we can bound the connect
|
|
||||||
// with connect_timeout (plain connect() uses the OS default, which
|
|
||||||
// can be minutes).
|
|
||||||
let addr = (host.as_str(), port)
|
|
||||||
.to_socket_addrs()
|
|
||||||
.ok()
|
|
||||||
.and_then(|mut it| it.next())
|
|
||||||
.ok_or_else(|| Error::KeydbConnect { host: host.clone() })?;
|
|
||||||
let mut stream = TcpStream::connect_timeout(&addr, NET_TIMEOUT).map_err(|e| {
|
|
||||||
tracing::debug!(error = %e, host = %host, "keydb connect failed");
|
|
||||||
Error::KeydbConnect { host: host.clone() }
|
|
||||||
})?;
|
|
||||||
stream
|
|
||||||
.set_read_timeout(Some(READ_TIMEOUT))
|
|
||||||
.map_err(|_| Error::KeydbConnect { host: host.clone() })?;
|
|
||||||
stream
|
|
||||||
.set_write_timeout(Some(NET_TIMEOUT))
|
|
||||||
.map_err(|_| Error::KeydbConnect { host: host.clone() })?;
|
|
||||||
|
|
||||||
// HTTP/1.0 forces close-delimited framing: the server cannot reply
|
|
||||||
// with Transfer-Encoding: chunked, so the raw body is the keydb
|
|
||||||
// bytes with no chunk-size lines to de-frame.
|
|
||||||
let request = format!(
|
|
||||||
"GET {path} HTTP/1.0\r\nHost: {host}\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n"
|
|
||||||
);
|
|
||||||
stream
|
|
||||||
.write_all(request.as_bytes())
|
|
||||||
.map_err(|_| Error::KeydbConnect { host: host.clone() })?;
|
|
||||||
|
|
||||||
// Read the header block incrementally up to the \r\n\r\n terminator,
|
|
||||||
// bounded to ~64 KiB, BEFORE pulling any body. This avoids buffering up
|
|
||||||
// to 100 MiB per redirect hop just to inspect the status / Location.
|
|
||||||
const MAX_HEADER_BYTES: usize = 64 * 1024;
|
|
||||||
let mut reader = std::io::BufReader::new(stream);
|
|
||||||
let mut header_buf: Vec<u8> = Vec::with_capacity(1024);
|
|
||||||
let mut byte = [0u8; 1];
|
|
||||||
loop {
|
|
||||||
let n = reader
|
|
||||||
.read(&mut byte)
|
|
||||||
.map_err(|_| Error::KeydbConnect { host: host.clone() })?;
|
|
||||||
if n == 0 {
|
|
||||||
// Server closed the connection before the header block
|
|
||||||
// completed: a connection/protocol-level fault, not malformed
|
|
||||||
// keydb content.
|
|
||||||
return Err(Error::KeydbConnect { host: host.clone() });
|
|
||||||
}
|
|
||||||
header_buf.push(byte[0]);
|
|
||||||
if header_buf.ends_with(b"\r\n\r\n") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if header_buf.len() >= MAX_HEADER_BYTES {
|
|
||||||
// Oversized header block from the server: a protocol-level
|
|
||||||
// fault, not a keydb content parse failure. `>=` caps the
|
|
||||||
// buffer at exactly MAX_HEADER_BYTES (the `>` form admitted one
|
|
||||||
// extra byte before tripping).
|
|
||||||
return Err(Error::KeydbConnect { host: host.clone() });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// header_buf includes the trailing \r\n\r\n.
|
|
||||||
let header_end = header_buf.len() - 4;
|
|
||||||
// Lossy: a stray non-UTF-8 byte in the header block must not blank
|
|
||||||
// out the whole status line / Location header (which would surface
|
|
||||||
// as an undiagnosable KeydbHttp{status:0}).
|
|
||||||
let headers = String::from_utf8_lossy(&header_buf[..header_end]).into_owned();
|
|
||||||
let headers = headers.as_str();
|
|
||||||
|
|
||||||
let status = parse_status(headers).ok_or(Error::KeydbParse)?;
|
|
||||||
|
|
||||||
// Only treat a Location header as a redirect when the status is
|
|
||||||
// actually 3xx; a 200 carrying a stray Location (some proxies) is
|
|
||||||
// not a redirect, and a 3xx without Location is a malformed redirect.
|
|
||||||
if (300..=399).contains(&status) {
|
|
||||||
let location =
|
|
||||||
extract_header(headers, "Location").ok_or(Error::KeydbHttp { status })?;
|
|
||||||
let (next_host, next_port, next_path) = resolve_redirect(&location, &host, port)?;
|
|
||||||
host = next_host;
|
|
||||||
port = next_port;
|
|
||||||
path = next_path;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if status != 200 {
|
|
||||||
return Err(Error::KeydbHttp { status });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now read the body, still bounded by the existing 100 MiB cap. The
|
|
||||||
// BufReader carries any bytes already buffered past the header.
|
|
||||||
let mut body = Vec::new();
|
|
||||||
reader
|
|
||||||
.take(100 * 1024 * 1024)
|
|
||||||
.read_to_end(&mut body)
|
|
||||||
.map_err(|_| Error::KeydbConnect { host: host.clone() })?;
|
|
||||||
return Ok(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(Error::KeydbTooManyRedirects)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve a `Location` value against the current request target.
|
|
||||||
/// Handles absolute `http://` URLs, scheme-relative `//host/path`,
|
|
||||||
/// absolute paths `/path`, and rejects unsupported schemes (e.g.
|
|
||||||
/// `https://`, which this dependency-light client cannot fetch) with a
|
|
||||||
/// diagnosable error rather than a generic parse failure.
|
|
||||||
fn resolve_redirect(
|
|
||||||
location: &str,
|
|
||||||
cur_host: &str,
|
|
||||||
cur_port: u16,
|
|
||||||
) -> Result<(String, u16, String)> {
|
|
||||||
let loc = location.trim();
|
|
||||||
|
|
||||||
if let Some(rest) = loc.strip_prefix("//") {
|
|
||||||
// Scheme-relative: //host[:port]/path — inherit http.
|
|
||||||
return parse_url(&format!("http://{rest}"));
|
|
||||||
}
|
|
||||||
if loc.starts_with('/') {
|
|
||||||
// Absolute path on the same host/port.
|
|
||||||
return Ok((cur_host.to_string(), cur_port, loc.to_string()));
|
|
||||||
}
|
|
||||||
if let Some(scheme) = loc.split("://").next() {
|
|
||||||
if loc.contains("://") && !scheme.eq_ignore_ascii_case("http") {
|
|
||||||
return Err(Error::KeydbUnsupportedScheme {
|
|
||||||
scheme: scheme.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parse_url(loc)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_url(url: &str) -> Result<(String, u16, String)> {
|
|
||||||
// Reject non-http(s) up front so the caller gets a scheme diagnostic
|
|
||||||
// rather than an opaque parse error.
|
|
||||||
if let Some(scheme) = url.split("://").next() {
|
|
||||||
if url.contains("://") && !scheme.eq_ignore_ascii_case("http") {
|
|
||||||
return Err(Error::KeydbUnsupportedScheme {
|
|
||||||
scheme: scheme.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let url = url.strip_prefix("http://").ok_or(Error::KeydbParse)?;
|
|
||||||
let (host_port, path) = match url.find('/') {
|
|
||||||
Some(i) => (&url[..i], &url[i..]),
|
|
||||||
None => (url, "/"),
|
|
||||||
};
|
|
||||||
let (host, port) = match host_port.find(':') {
|
|
||||||
Some(i) => {
|
|
||||||
let port_str = &host_port[i + 1..];
|
|
||||||
// A non-empty-but-unparseable port is a malformed URL; only an
|
|
||||||
// omitted port defaults to 80.
|
|
||||||
let port = if port_str.is_empty() {
|
|
||||||
80
|
|
||||||
} else {
|
|
||||||
port_str.parse().map_err(|_| Error::KeydbParse)?
|
|
||||||
};
|
|
||||||
(&host_port[..i], port)
|
|
||||||
}
|
|
||||||
None => (host_port, 80u16),
|
|
||||||
};
|
|
||||||
Ok((host.to_string(), port, path.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_status(headers: &str) -> Option<u16> {
|
|
||||||
headers
|
|
||||||
.lines()
|
|
||||||
.next()
|
|
||||||
.and_then(|l| l.split_whitespace().nth(1))
|
|
||||||
.and_then(|s| s.parse().ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Locate the end of the HTTP header block (the index of the `\r\n\r\n`).
|
|
||||||
/// Retained for the framing unit tests; the live path now reads headers
|
|
||||||
/// incrementally in `http_get` so the whole response is never buffered.
|
|
||||||
#[cfg_attr(not(test), allow(dead_code))]
|
|
||||||
fn find_header_end(data: &[u8]) -> Option<usize> {
|
|
||||||
data.windows(4).position(|w| w == b"\r\n\r\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_header(headers: &str, name: &str) -> Option<String> {
|
|
||||||
// Split on the first ':' rather than byte-indexing at name.len(),
|
|
||||||
// which would panic on a multibyte UTF-8 codepoint straddling that
|
|
||||||
// offset (headers are decoded from untrusted network bytes). Also
|
|
||||||
// accepts single-character values (e.g. "Location:x").
|
|
||||||
for line in headers.lines() {
|
|
||||||
if let Some((key, value)) = line.split_once(':') {
|
|
||||||
if key.trim().eq_ignore_ascii_case(name) {
|
|
||||||
return Some(value.trim().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_zip(data: &[u8]) -> Result<String> {
|
|
||||||
let cursor = std::io::Cursor::new(data);
|
|
||||||
let mut archive = zip::ZipArchive::new(cursor).map_err(|_| Error::KeydbParse)?;
|
|
||||||
|
|
||||||
for i in 0..archive.len() {
|
|
||||||
let file = archive.by_index(i).map_err(|_| Error::KeydbParse)?;
|
|
||||||
if file.name().ends_with(".cfg") || file.name().ends_with(".CFG") {
|
|
||||||
return read_capped_to_string(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(Error::KeydbInvalid)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
// Per project convention, tests never touch /tmp (wiped on reboot).
|
|
||||||
// Anchor scratch under the crate's target/ (gitignored), not /tmp.
|
|
||||||
fn scratch(tag: &str) -> std::path::PathBuf {
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
static CTR: AtomicU64 = AtomicU64::new(0);
|
|
||||||
let n = CTR.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let d = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
||||||
.join("target/test-scratch")
|
|
||||||
.join(format!("keydb-test-{}-{}-{}", std::process::id(), tag, n));
|
|
||||||
let _ = std::fs::remove_dir_all(&d);
|
|
||||||
std::fs::create_dir_all(&d).unwrap();
|
|
||||||
d
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regression: a missing home directory (HOME/USERPROFILE unset) is an
|
|
||||||
// *environment* failure, not a corrupt keydb. It must NOT surface as
|
|
||||||
// E8004 (KeydbParse → "failed to parse the keydb file"), which would
|
|
||||||
// blame a file that was never consulted. It maps to a NotFound I/O
|
|
||||||
// error in the 5xxx (I/O) category instead.
|
|
||||||
#[test]
|
|
||||||
fn no_home_dir_is_io_not_found_not_keydb_parse() {
|
|
||||||
let e = no_home_dir();
|
|
||||||
match e {
|
|
||||||
Error::IoError { source } => {
|
|
||||||
assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
|
|
||||||
}
|
|
||||||
other => panic!("expected IoError(NotFound), got {other:?}"),
|
|
||||||
}
|
|
||||||
// And explicitly: it is not the keydb-parse code.
|
|
||||||
assert_ne!(no_home_dir().code(), Error::KeydbParse.code());
|
|
||||||
}
|
|
||||||
|
|
||||||
// The write default is local to the executable: `<exe dir>/keydb.cfg`.
|
|
||||||
// Under `cargo test`, `current_exe()` is the test binary in `target/…`;
|
|
||||||
// assert against the same computation, not a hardcoded path.
|
|
||||||
#[test]
|
|
||||||
fn default_path_is_local_to_executable() {
|
|
||||||
let expected = std::env::current_exe()
|
|
||||||
.ok()
|
|
||||||
.and_then(|exe| exe.parent().map(|dir| dir.join("keydb.cfg")));
|
|
||||||
match expected {
|
|
||||||
Some(p) => assert_eq!(default_path().unwrap(), p),
|
|
||||||
None => {
|
|
||||||
// No exe parent available → must surface the env failure.
|
|
||||||
assert!(matches!(default_path(), Err(Error::IoError { .. })));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn write_atomic_replaces_existing_and_leaves_no_temp() {
|
|
||||||
let dir = scratch("atomic");
|
|
||||||
let path = dir.join("freemkv").join("keydb.cfg");
|
|
||||||
|
|
||||||
// First write creates the parent dir + file.
|
|
||||||
write_atomic(&path, "0xAAAA = old\n").unwrap();
|
|
||||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "0xAAAA = old\n");
|
|
||||||
|
|
||||||
// Second write replaces it in place.
|
|
||||||
write_atomic(&path, "0xBBBB = new\n").unwrap();
|
|
||||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "0xBBBB = new\n");
|
|
||||||
|
|
||||||
// No leftover *.tmp.* sibling — the temp file was renamed, not orphaned.
|
|
||||||
let leftovers: Vec<_> = std::fs::read_dir(path.parent().unwrap())
|
|
||||||
.unwrap()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
|
||||||
.filter(|n| n.contains(".tmp."))
|
|
||||||
.collect();
|
|
||||||
assert!(leftovers.is_empty(), "stray temp files: {leftovers:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn write_atomic_failure_preserves_prior_keydb() {
|
|
||||||
// Simulate the crash window: a good keydb already on disk, then an
|
|
||||||
// update whose write target can't be created (parent path is a file,
|
|
||||||
// so create_dir_all under it fails — i.e. ENOTDIR). The rename never
|
|
||||||
// happens, so the existing keydb must survive untouched.
|
|
||||||
let dir = scratch("preserve");
|
|
||||||
let good = dir.join("keydb.cfg");
|
|
||||||
write_atomic(&good, "0xGOOD = keep\n").unwrap();
|
|
||||||
|
|
||||||
// `good` is a regular file; treating it as a directory parent fails.
|
|
||||||
let doomed = good.join("freemkv").join("keydb.cfg");
|
|
||||||
let err = write_atomic(&doomed, "0xBAD = partial\n");
|
|
||||||
assert!(matches!(err, Err(Error::KeydbWrite { .. })));
|
|
||||||
|
|
||||||
// Prior good copy is intact.
|
|
||||||
assert_eq!(std::fs::read_to_string(&good).unwrap(), "0xGOOD = keep\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_url_defaults_and_paths() {
|
|
||||||
let (h, p, path) = parse_url("http://example.com/keydb.zip").unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
(h.as_str(), p, path.as_str()),
|
|
||||||
("example.com", 80, "/keydb.zip")
|
|
||||||
);
|
|
||||||
|
|
||||||
let (h, p, path) = parse_url("http://example.com:8080").unwrap();
|
|
||||||
assert_eq!((h.as_str(), p, path.as_str()), ("example.com", 8080, "/"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_url_rejects_https_scheme() {
|
|
||||||
// TLS is unsupported by this client; surface a scheme diagnostic
|
|
||||||
// rather than a generic parse error.
|
|
||||||
assert!(matches!(
|
|
||||||
parse_url("https://example.com/k.zip"),
|
|
||||||
Err(Error::KeydbUnsupportedScheme { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_url_rejects_malformed_port() {
|
|
||||||
// Non-empty-but-unparseable port must error, not silently fall to 80.
|
|
||||||
assert!(matches!(
|
|
||||||
parse_url("http://example.com:abc/path"),
|
|
||||||
Err(Error::KeydbParse)
|
|
||||||
));
|
|
||||||
// An empty port still defaults to 80.
|
|
||||||
let (_, p, _) = parse_url("http://example.com:/path").unwrap();
|
|
||||||
assert_eq!(p, 80);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn redirect_to_https_is_unsupported_scheme_not_parse_error() {
|
|
||||||
// The bplaced-style mirror enabling TLS on a redirect must produce a
|
|
||||||
// diagnosable scheme error, not KeydbParse.
|
|
||||||
assert!(matches!(
|
|
||||||
resolve_redirect("https://mirror.example/keydb.zip", "old.host", 80),
|
|
||||||
Err(Error::KeydbUnsupportedScheme { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn redirect_scheme_relative_and_absolute_path() {
|
|
||||||
// Scheme-relative //host/path inherits http.
|
|
||||||
let (h, p, path) = resolve_redirect("//mirror.example/a.zip", "old.host", 80).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
(h.as_str(), p, path.as_str()),
|
|
||||||
("mirror.example", 80, "/a.zip")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Absolute path stays on the current host/port.
|
|
||||||
let (h, p, path) = resolve_redirect("/new/path.zip", "cur.host", 8080).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
(h.as_str(), p, path.as_str()),
|
|
||||||
("cur.host", 8080, "/new/path.zip")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Absolute http URL is followed normally.
|
|
||||||
let (h, _, path) = resolve_redirect("http://other.host/x.zip", "cur.host", 80).unwrap();
|
|
||||||
assert_eq!((h.as_str(), path.as_str()), ("other.host", "/x.zip"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_status_extracts_code() {
|
|
||||||
assert_eq!(parse_status("HTTP/1.0 200 OK\r\nFoo: bar"), Some(200));
|
|
||||||
assert_eq!(parse_status("HTTP/1.1 301 Moved Permanently"), Some(301));
|
|
||||||
assert_eq!(parse_status("garbage"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── New comprehensive tests ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// find_header_end detects the \r\n\r\n separator (RFC 7230 §3 — HTTP header
|
|
||||||
/// terminator is CRLF CRLF). Returns the byte position of the first \r.
|
|
||||||
/// Mutation: searching for \n\n instead of \r\n\r\n misses the boundary.
|
|
||||||
#[test]
|
|
||||||
fn find_header_end_locates_crlfcrlf() {
|
|
||||||
let data = b"HTTP/1.0 200 OK\r\nContent-Length: 42\r\n\r\nbody starts here";
|
|
||||||
// The \r\n\r\n starts at byte 37 (after the Content-Length line).
|
|
||||||
let pos = find_header_end(data).expect("must find header end");
|
|
||||||
// body starts at pos + 4 (past the \r\n\r\n).
|
|
||||||
assert_eq!(
|
|
||||||
&data[pos + 4..],
|
|
||||||
b"body starts here",
|
|
||||||
"body must begin immediately after the \\r\\n\\r\\n boundary"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// find_header_end returns None when there is no \r\n\r\n.
|
|
||||||
/// Mutation: returning Some(0) unconditionally makes this fail.
|
|
||||||
#[test]
|
|
||||||
fn find_header_end_returns_none_when_absent() {
|
|
||||||
let data = b"no separator here at all";
|
|
||||||
assert!(find_header_end(data).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// extract_header is case-insensitive per RFC 7230 §3.2.
|
|
||||||
/// Mutation: using case-sensitive comparison misses "location" vs "Location".
|
|
||||||
#[test]
|
|
||||||
fn extract_header_case_insensitive() {
|
|
||||||
let headers = "HTTP/1.1 301 Moved\r\nlocation: http://new.host/path\r\n";
|
|
||||||
let val = extract_header(headers, "Location").expect("must find Location");
|
|
||||||
assert_eq!(val, "http://new.host/path");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// extract_header with a missing header returns None.
|
|
||||||
/// Mutation: returning Some("") makes the caller proceed on a missing Location header.
|
|
||||||
#[test]
|
|
||||||
fn extract_header_missing_returns_none() {
|
|
||||||
let headers = "HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\n";
|
|
||||||
assert!(extract_header(headers, "Location").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// extract_header trims leading/trailing whitespace from the value.
|
|
||||||
/// RFC 7230 §3.2.6: optional whitespace around field value.
|
|
||||||
/// Mutation: not trimming the value keeps leading spaces in the URL.
|
|
||||||
#[test]
|
|
||||||
fn extract_header_trims_value_whitespace() {
|
|
||||||
let headers = "HTTP/1.1 301 Moved\r\nLocation: /new/path \r\n";
|
|
||||||
let val = extract_header(headers, "Location").unwrap();
|
|
||||||
assert_eq!(val, "/new/path", "value must be trimmed");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// save() rejects data that is not a valid keydb (no recognisable entries).
|
|
||||||
/// Spec: entries are lines starting with "0x", "| DK", "| PK", or "| HC".
|
|
||||||
/// Mutation: dropping the entries==0 check lets an empty file be saved.
|
|
||||||
#[test]
|
|
||||||
fn save_rejects_empty_text() {
|
|
||||||
// Plain text with no valid keydb entries.
|
|
||||||
let garbage = b"this is not a keydb\njust random text\n";
|
|
||||||
assert!(
|
|
||||||
matches!(save(garbage), Err(Error::KeydbInvalid)),
|
|
||||||
"keydb without valid entries must be rejected"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// save() accepts plain text with at least one "0x"-prefixed entry line.
|
|
||||||
/// Mutation: counting only "| DK" lines ignores the "0x" entry format.
|
|
||||||
#[test]
|
|
||||||
fn save_accepts_plaintext_with_0x_entries() {
|
|
||||||
// Minimal keydb-style file with a VUK entry (0x-prefixed).
|
|
||||||
let content = b"0xDEADBEEFCAFEBABE0102030405060708090A0B0C0D0E0F\n";
|
|
||||||
// We can't predict the HOME path in test environments without
|
|
||||||
// potentially writing to a real location. So only check that save()
|
|
||||||
// accepts this content as valid (may return KeydbWrite if dir exists
|
|
||||||
// but we lack permission — that still proves it passed the parse check).
|
|
||||||
let result = save(content);
|
|
||||||
// Accept either Ok (wrote successfully) or a write error (env issue),
|
|
||||||
// but NOT KeydbInvalid or KeydbParse.
|
|
||||||
match &result {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(Error::KeydbWrite { .. }) => {}
|
|
||||||
Err(e) => panic!("unexpected error for valid keydb content: {:?}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// save() accepts content with "| DK" entries (device-key table format).
|
|
||||||
/// Mutation: only accepting "0x" lines rejects DK-format keydb files.
|
|
||||||
#[test]
|
|
||||||
fn save_accepts_pipe_dk_entry_format() {
|
|
||||||
let content = b"| DK 0102030405060708 | 0102030405060708090a0b0c0d0e0f10 |\n";
|
|
||||||
let result = save(content);
|
|
||||||
match &result {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(Error::KeydbWrite { .. }) => {}
|
|
||||||
Err(e) => panic!("unexpected error for DK-format entry: {:?}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// save() accepts content with "| PK" entries (processing-key format).
|
|
||||||
/// Mutation: not including "| PK" in the filter rejects PK-format keydb files.
|
|
||||||
#[test]
|
|
||||||
fn save_accepts_pipe_pk_entry_format() {
|
|
||||||
let content = b"| PK 0102030405060708090a0b0c0d0e0f10 |\n";
|
|
||||||
let result = save(content);
|
|
||||||
match &result {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(Error::KeydbWrite { .. }) => {}
|
|
||||||
Err(e) => panic!("unexpected error for PK-format entry: {:?}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// save() accepts content with "| HC" entries (host certificate format).
|
|
||||||
/// Mutation: not including "| HC" in the filter rejects HC-format keydb files.
|
|
||||||
#[test]
|
|
||||||
fn save_accepts_pipe_hc_entry_format() {
|
|
||||||
let content = b"| HC 0102030405060708090a0b0c0d0e0f10 |\n";
|
|
||||||
let result = save(content);
|
|
||||||
match &result {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(Error::KeydbWrite { .. }) => {}
|
|
||||||
Err(e) => panic!("unexpected error for HC-format entry: {:?}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// save() recognises gzip-compressed input (magic bytes 0x1f 0x8b).
|
|
||||||
/// Spec: gzip format magic is 0x1F 0x8B (RFC 1952 §2.3.1).
|
|
||||||
/// Mutation: treating gzip magic as plain text fails to decompress.
|
|
||||||
#[test]
|
|
||||||
fn save_recognises_gzip_magic() {
|
|
||||||
// Truncated gzip (header only, no valid body) — must not be treated as
|
|
||||||
// plain text (no KeydbInvalid about entries), but as a parse error.
|
|
||||||
let bad_gz = [0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03];
|
|
||||||
let result = save(&bad_gz);
|
|
||||||
// A truncated gzip is either KeydbParse (decompression error) or
|
|
||||||
// KeydbInvalid (decompressed to empty). Must not be Ok.
|
|
||||||
assert!(result.is_err(), "truncated gzip must not be accepted");
|
|
||||||
// Crucially: must NOT be a plain-text UTF-8 error — gzip magic is not UTF-8.
|
|
||||||
match result.unwrap_err() {
|
|
||||||
Error::KeydbParse | Error::KeydbInvalid => {}
|
|
||||||
e => panic!("wrong error kind for truncated gzip: {:?}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// save() recognises ZIP magic bytes PK\x03\x04 and routes to extract_zip.
|
|
||||||
/// Spec: ZIP local file header signature is 0x50 0x4B 0x03 0x04 (PKZIP APPNOTE §4.3.6).
|
|
||||||
/// A truncated ZIP must error, but NOT as plain UTF-8 text.
|
|
||||||
/// Mutation: checking gzip magic before ZIP magic means ZIP files are
|
|
||||||
/// fed to the gzip decoder and produce the wrong error.
|
|
||||||
#[test]
|
|
||||||
fn save_recognises_zip_magic() {
|
|
||||||
// Valid ZIP magic followed by garbage — must be routed to extract_zip.
|
|
||||||
let bad_zip = b"PK\x03\x04garbage that is not a real zip";
|
|
||||||
let result = save(bad_zip);
|
|
||||||
assert!(result.is_err(), "invalid zip must be rejected");
|
|
||||||
// Must be a parse error, not a UTF-8 error.
|
|
||||||
match result.unwrap_err() {
|
|
||||||
Error::KeydbParse | Error::KeydbInvalid => {}
|
|
||||||
e => panic!("wrong error for bad zip: {:?}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// read_capped_to_string rejects data exceeding MAX_KEYDB_BYTES.
|
|
||||||
/// Spec: doc says "Returns Error::KeydbInvalid if the input exceeds the cap".
|
|
||||||
/// Mutation: removing the length check accepts decompression bombs.
|
|
||||||
#[test]
|
|
||||||
fn read_capped_to_string_rejects_oversized_input() {
|
|
||||||
// Build a reader that reports it has more data than the cap.
|
|
||||||
// We use a Cursor with MAX_KEYDB_BYTES + 1 bytes of content.
|
|
||||||
let too_big = vec![b'A'; (MAX_KEYDB_BYTES + 1) as usize];
|
|
||||||
let cursor = std::io::Cursor::new(too_big);
|
|
||||||
let result = read_capped_to_string(cursor);
|
|
||||||
assert!(
|
|
||||||
matches!(result, Err(Error::KeydbInvalid)),
|
|
||||||
"oversized input must yield KeydbInvalid, got: {:?}",
|
|
||||||
result
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// read_capped_to_string returns KeydbParse (not KeydbInvalid) for
|
|
||||||
/// non-UTF-8 input. Guards the doc/behavior contract: KeydbInvalid is
|
|
||||||
/// reserved for the size-cap violation, a decode failure is a parse error.
|
|
||||||
#[test]
|
|
||||||
fn read_capped_to_string_non_utf8_yields_parse() {
|
|
||||||
// 0xFF is never a valid UTF-8 byte.
|
|
||||||
let cursor = std::io::Cursor::new(vec![0xFFu8, 0xFE, 0xFD]);
|
|
||||||
let result = read_capped_to_string(cursor);
|
|
||||||
assert!(
|
|
||||||
matches!(result, Err(Error::KeydbParse)),
|
|
||||||
"non-UTF-8 input must yield KeydbParse, got: {:?}",
|
|
||||||
result
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// read_capped_to_string accepts exactly MAX_KEYDB_BYTES (at-cap is allowed).
|
|
||||||
/// Spec: doc says "Read one byte past the cap so an exactly-at-cap stream is accepted."
|
|
||||||
/// Mutation: using `>=` instead of `>` in the length check rejects valid at-cap files.
|
|
||||||
#[test]
|
|
||||||
fn read_capped_to_string_accepts_at_cap_size() {
|
|
||||||
let at_cap = vec![b'A'; MAX_KEYDB_BYTES as usize];
|
|
||||||
let cursor = std::io::Cursor::new(at_cap);
|
|
||||||
let result = read_capped_to_string(cursor);
|
|
||||||
assert!(result.is_ok(), "exactly MAX_KEYDB_BYTES must be accepted");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// parse_status returns None for an empty/malformed status line (not a
|
|
||||||
/// meaningless 0). The call site maps None to Error::KeydbParse.
|
|
||||||
#[test]
|
|
||||||
fn parse_status_empty_input_returns_none() {
|
|
||||||
assert_eq!(parse_status(""), None);
|
|
||||||
assert_eq!(parse_status("\r\n"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression: set_read_timeout / set_write_timeout failures must surface as
|
|
||||||
/// KeydbConnect, not be silently swallowed.
|
|
||||||
///
|
|
||||||
/// We can't easily synthesise a TcpStream whose set_*timeout syscall fails
|
|
||||||
/// without a platform-specific socket hack, so instead we verify that the
|
|
||||||
/// error-mapping expression itself is correct: if set_read_timeout were to
|
|
||||||
/// fail for a given host, the result must be Err(KeydbConnect { host }).
|
|
||||||
///
|
|
||||||
/// The test constructs the exact Err value the code would return and asserts
|
|
||||||
/// it is KeydbConnect (not, say, silently Ok or a different variant). This
|
|
||||||
/// pins the variant selection so a future refactor that changes the `.ok()`
|
|
||||||
/// pattern back would need to update this test as well.
|
|
||||||
#[test]
|
|
||||||
fn timeout_set_failure_maps_to_keydb_connect() {
|
|
||||||
// Simulate what the propagated error looks like.
|
|
||||||
let host = "hostile.example.com".to_string();
|
|
||||||
// The io::Error that set_read_timeout would return on failure.
|
|
||||||
let io_err = std::io::Error::from(std::io::ErrorKind::InvalidInput);
|
|
||||||
// Apply the same map_err the production code uses.
|
|
||||||
let result: Result<()> =
|
|
||||||
Err(io_err).map_err(|_| Error::KeydbConnect { host: host.clone() });
|
|
||||||
assert!(
|
|
||||||
matches!(result, Err(Error::KeydbConnect { host: ref h }) if h == "hostile.example.com"),
|
|
||||||
"set_timeout failure must map to KeydbConnect, got: {:?}",
|
|
||||||
result
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression: http_get to an unreachable host returns KeydbConnect, not a hang.
|
|
||||||
/// This exercises the connect_timeout path (and thus confirms the overall
|
|
||||||
/// error-propagation chain is wired); the timeout-set propagation is exercised
|
|
||||||
/// by the unit test above.
|
|
||||||
///
|
|
||||||
/// Uses port 1 on localhost, which is reserved/unassigned and virtually never
|
|
||||||
/// listening. connect_timeout with NET_TIMEOUT will refuse or time out quickly.
|
|
||||||
/// We only assert the error variant, not the host field, since the OS may
|
|
||||||
/// resolve the address differently.
|
|
||||||
#[test]
|
|
||||||
fn http_get_unreachable_host_returns_keydb_connect() {
|
|
||||||
// Port 1 on loopback — almost always refused immediately.
|
|
||||||
let result = http_get("http://127.0.0.1:1/keydb.zip");
|
|
||||||
// Must be an Err; KeydbConnect is expected for a TCP-level failure.
|
|
||||||
// KeydbParse or KeydbHttp would indicate the wrong error path.
|
|
||||||
assert!(result.is_err(), "unreachable host must fail");
|
|
||||||
match result.unwrap_err() {
|
|
||||||
Error::KeydbConnect { .. } => {}
|
|
||||||
e => panic!("expected KeydbConnect for unreachable host, got: {:?}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression: when the server accepts the connection but closes it before
|
|
||||||
/// sending complete HTTP headers (the `n == 0` byte-read path), that is a
|
|
||||||
/// connection/protocol-level fault. It must surface as KeydbConnect (E8000),
|
|
||||||
/// NOT KeydbParse (E8004) — the keydb content was never received, let alone
|
|
||||||
/// malformed.
|
|
||||||
#[test]
|
|
||||||
fn http_get_server_drops_before_headers_returns_keydb_connect() {
|
|
||||||
use std::io::Read as _;
|
|
||||||
use std::net::TcpListener;
|
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
|
|
||||||
let server = std::thread::spawn(move || {
|
|
||||||
if let Ok((mut sock, _)) = listener.accept() {
|
|
||||||
// Drain the request so the client's write_all completes, then
|
|
||||||
// drop the socket without writing any response. The client's
|
|
||||||
// header read then returns n == 0.
|
|
||||||
let mut buf = [0u8; 512];
|
|
||||||
let _ = sock.read(&mut buf);
|
|
||||||
drop(sock);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let url = format!("http://127.0.0.1:{}/keydb.zip", addr.port());
|
|
||||||
let result = http_get(&url);
|
|
||||||
server.join().unwrap();
|
|
||||||
|
|
||||||
assert!(result.is_err(), "dropped connection must fail");
|
|
||||||
match result.unwrap_err() {
|
|
||||||
Error::KeydbConnect { .. } => {}
|
|
||||||
e => panic!("expected KeydbConnect for dropped connection, got: {:?}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1088
-62
File diff suppressed because it is too large
Load Diff
+45
-16
@@ -100,10 +100,10 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<DiscMetadata>
|
|||||||
// Disc-set position is disc-global; first one we successfully
|
// Disc-set position is disc-global; first one we successfully
|
||||||
// read wins. (All bdmt_*.xml on a given disc carry the same
|
// read wins. (All bdmt_*.xml on a given disc carry the same
|
||||||
// value in practice.)
|
// value in practice.)
|
||||||
if out.disc_number.is_none() {
|
if out.disc_number.is_none()
|
||||||
if let Some(ds) = disc_set {
|
&& let Some(ds) = disc_set
|
||||||
out.disc_number = Some(ds);
|
{
|
||||||
}
|
out.disc_number = Some(ds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,20 +184,20 @@ fn extract_title(xml_text: &str) -> Option<String> {
|
|||||||
// xml::text already trims its result, so an empty string after
|
// xml::text already trims its result, so an empty string after
|
||||||
// extraction means a genuinely empty element.
|
// extraction means a genuinely empty element.
|
||||||
for tag in ["name", "title"] {
|
for tag in ["name", "title"] {
|
||||||
if let Some(s) = xml::text(xml_text, tag) {
|
if let Some(s) = xml::text(xml_text, tag)
|
||||||
if !s.is_empty() {
|
&& !s.is_empty()
|
||||||
return Some(s);
|
{
|
||||||
}
|
return Some(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// tableOfContents/titleName: search inside the toc block so we
|
// tableOfContents/titleName: search inside the toc block so we
|
||||||
// don't accidentally pick a stray <titleName> from elsewhere.
|
// don't accidentally pick a stray <titleName> from elsewhere.
|
||||||
if let Some((s, e)) = xml::find_element(xml_text, "tableOfContents", 0) {
|
if let Some((s, e)) = xml::find_element(xml_text, "tableOfContents", 0) {
|
||||||
let block = &xml_text[s..e];
|
let block = &xml_text[s..e];
|
||||||
if let Some(t) = xml::text(block, "titleName") {
|
if let Some(t) = xml::text(block, "titleName")
|
||||||
if !t.is_empty() {
|
&& !t.is_empty()
|
||||||
return Some(t);
|
{
|
||||||
}
|
return Some(t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@@ -370,10 +370,10 @@ mod tests {
|
|||||||
if let Some(d) = desc {
|
if let Some(d) = desc {
|
||||||
meta.descriptions.insert(lang.to_string(), d);
|
meta.descriptions.insert(lang.to_string(), d);
|
||||||
}
|
}
|
||||||
if meta.disc_number.is_none() {
|
if meta.disc_number.is_none()
|
||||||
if let Some(d) = ds {
|
&& let Some(d) = ds
|
||||||
meta.disc_number = Some(d);
|
{
|
||||||
}
|
meta.disc_number = Some(d);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,4 +566,33 @@ mod tests {
|
|||||||
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
|
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
|
||||||
assert_eq!(title, "Real Title");
|
assert_eq!(title, "Real Title");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `is_bdmt_filename` must recognize the `bdmt_<lang>.xml` convention
|
||||||
|
/// and reject everything else — it drives `detect`'s directory scan.
|
||||||
|
/// Mutation: stub the return to a constant `true`/`false` → every
|
||||||
|
/// directory listing (or none) would match regardless of filename.
|
||||||
|
#[test]
|
||||||
|
fn is_bdmt_filename_matches_convention_only() {
|
||||||
|
assert!(is_bdmt_filename("bdmt_eng.xml"));
|
||||||
|
assert!(is_bdmt_filename("BDMT_FRA.XML"));
|
||||||
|
assert!(!is_bdmt_filename("bdmt_engl.xml"));
|
||||||
|
assert!(!is_bdmt_filename("index.bdmv"));
|
||||||
|
assert!(!is_bdmt_filename("foo.xml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spec: "Disc 1 of 1" (a single-disc release whose bdmt XML still
|
||||||
|
/// carries `<di:numSets>1</di:numSets>`) is a valid, non-nonsensical
|
||||||
|
/// pair — `total < 1` must reject only `total == 0`, not `total == 1`.
|
||||||
|
/// Mutation: `total < 1` -> `total == 1` or `total <= 1` would reject
|
||||||
|
/// this legitimate (1, 1) pair as if it were malformed.
|
||||||
|
#[test]
|
||||||
|
fn disc_set_allows_single_disc_release() {
|
||||||
|
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||||
|
<di:name>Film</di:name>
|
||||||
|
<di:discNumber>1</di:discNumber>
|
||||||
|
<di:numSets>1</di:numSets>
|
||||||
|
</discInfo>"#;
|
||||||
|
let (_, _, set) = parse_bdmt_xml(xml).unwrap();
|
||||||
|
assert_eq!(set, Some((1, 1)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+387
-1
@@ -989,7 +989,18 @@ impl<'a> Reader<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn slice(&mut self, n: usize, needed: &'static str) -> Result<&'a [u8]> {
|
fn slice(&mut self, n: usize, needed: &'static str) -> Result<&'a [u8]> {
|
||||||
if self.pos + n > self.data.len() {
|
// `n` is attacker-supplied: it comes from a JVMS `u4` attribute_length
|
||||||
|
// / code_length (§4.7, §4.7.3) or a `u2` Utf8 length (§4.4.7). Unlike
|
||||||
|
// the fixed-width readers above, whose `self.pos + k` cannot leave the
|
||||||
|
// buffer's own address range, `self.pos + n` can wrap — on a 32-bit
|
||||||
|
// target a `u4` length near 0xFFFF_FFFF plus a non-zero `pos` panics
|
||||||
|
// in debug and in release wraps to a SMALL end offset that passes the
|
||||||
|
// bounds check, after which the slice index itself panics. Checked, so
|
||||||
|
// an out-of-range length is the EOF error it always should have been.
|
||||||
|
let Some(end) = self.pos.checked_add(n) else {
|
||||||
|
return Err(Error::UnexpectedEof { needed });
|
||||||
|
};
|
||||||
|
if end > self.data.len() {
|
||||||
return Err(Error::UnexpectedEof { needed });
|
return Err(Error::UnexpectedEof { needed });
|
||||||
}
|
}
|
||||||
let s = &self.data[self.pos..self.pos + n];
|
let s = &self.data[self.pos..self.pos + n];
|
||||||
@@ -1006,6 +1017,46 @@ impl<'a> Reader<'a> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// `Reader::slice` takes an attacker-supplied length: a JVMS `u4`
|
||||||
|
/// `attribute_length` / `code_length` (§4.7, §4.7.3) or a `u2` Utf8
|
||||||
|
/// length (§4.4.7). Adding it to `pos` without a wrap check panics on
|
||||||
|
/// overflow in debug and, in release, wraps to a small end offset that
|
||||||
|
/// slips past the bounds check and then panics inside the slice index.
|
||||||
|
/// Both are panics escaping a parser whose whole input is untrusted disc
|
||||||
|
/// bytes; the contract is an EOF error.
|
||||||
|
#[test]
|
||||||
|
fn slice_rejects_a_length_that_would_wrap_pos() {
|
||||||
|
let data = [0u8; 16];
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
r.u64("advance pos").expect("8 bytes available");
|
||||||
|
// pos is now 8; usize::MAX would wrap the end offset to 7.
|
||||||
|
match r.slice(usize::MAX, "wrapping length") {
|
||||||
|
Err(Error::UnexpectedEof { .. }) => {}
|
||||||
|
Err(other) => panic!("expected UnexpectedEof, got {other:?}"),
|
||||||
|
Ok(s) => panic!("expected UnexpectedEof, got a {}-byte slice", s.len()),
|
||||||
|
}
|
||||||
|
// The reader must not have consumed anything.
|
||||||
|
match r.slice(8, "remaining bytes") {
|
||||||
|
Ok(s) => assert_eq!(s.len(), 8, "pos moved on the rejected slice"),
|
||||||
|
Err(e) => panic!("the remaining 8 bytes must still be readable: {e:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ordinary out-of-range case (no wrap) must keep returning EOF, and
|
||||||
|
/// an exactly-fitting length must still succeed — the check is `>`, not
|
||||||
|
/// `>=`.
|
||||||
|
#[test]
|
||||||
|
fn slice_boundary_is_inclusive_of_the_final_byte() {
|
||||||
|
let data = [0u8; 16];
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert_eq!(r.slice(16, "whole buffer").expect("exact fit").len(), 16);
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert!(matches!(
|
||||||
|
r.slice(17, "one past"),
|
||||||
|
Err(Error::UnexpectedEof { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_non_class_bytes() {
|
fn rejects_non_class_bytes() {
|
||||||
match ClassFile::parse(b"\x00\x01\x02\x03DEAD") {
|
match ClassFile::parse(b"\x00\x01\x02\x03DEAD") {
|
||||||
@@ -1337,4 +1388,339 @@ mod tests {
|
|||||||
let _ = decode_modified_utf8(&buf);
|
let _ = decode_modified_utf8(&buf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// ConstantPool / ClassFile accessor correctness
|
||||||
|
//
|
||||||
|
// These exercise plain data accessors on an already-parsed pool
|
||||||
|
// (built via the test-only `from_entries` constructor) — not the
|
||||||
|
// untrusted-bytes parsing path, just "does the right variant map to
|
||||||
|
// the right Option value."
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
fn sample_pool() -> ConstantPool {
|
||||||
|
// index: 0=Empty (reserved), 1=Utf8("Hello"), 2=Integer(42),
|
||||||
|
// 3=String{string_index:1}, 4=Class{name_index:1}, 5=Float(1.5),
|
||||||
|
// 6=Long(9), 7=Empty (2-slot tail), 8=Double(2.5), 9=Empty (tail).
|
||||||
|
ConstantPool::from_entries(vec![
|
||||||
|
CpInfo::Empty,
|
||||||
|
CpInfo::Utf8("Hello".to_string()),
|
||||||
|
CpInfo::Integer(42),
|
||||||
|
CpInfo::String { string_index: 1 },
|
||||||
|
CpInfo::Class { name_index: 1 },
|
||||||
|
CpInfo::Float(1.5),
|
||||||
|
CpInfo::Long(9),
|
||||||
|
CpInfo::Empty,
|
||||||
|
CpInfo::Double(2.5),
|
||||||
|
CpInfo::Empty,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constant_pool_string_resolves_through_string_index() {
|
||||||
|
let pool = sample_pool();
|
||||||
|
// index 3 is CpInfo::String{string_index: 1} -> utf8(1) = "Hello".
|
||||||
|
assert_eq!(pool.string(3), Some("Hello"));
|
||||||
|
// Wrong variant (Integer at index 2) must not resolve as a string.
|
||||||
|
assert_eq!(pool.string(2), None);
|
||||||
|
// Out of range index.
|
||||||
|
assert_eq!(pool.string(999), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constant_pool_integer_resolves_only_integer_entries() {
|
||||||
|
let pool = sample_pool();
|
||||||
|
assert_eq!(pool.integer(2), Some(42));
|
||||||
|
// Wrong variant (Utf8 at index 1) must not resolve as an integer.
|
||||||
|
assert_eq!(pool.integer(1), None);
|
||||||
|
assert_eq!(pool.integer(999), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constant_pool_load_constant_display_covers_ldc_operand_kinds() {
|
||||||
|
let pool = sample_pool();
|
||||||
|
assert_eq!(
|
||||||
|
pool.load_constant_display(1),
|
||||||
|
Some("utf8:\"Hello\"".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(pool.load_constant_display(2), Some("int:42".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
pool.load_constant_display(3),
|
||||||
|
Some("str:\"Hello\"".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pool.load_constant_display(4),
|
||||||
|
Some("class:\"Hello\"".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(pool.load_constant_display(5), Some("float:1.5".to_string()));
|
||||||
|
assert_eq!(pool.load_constant_display(6), Some("long:9".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
pool.load_constant_display(8),
|
||||||
|
Some("double:2.5".to_string())
|
||||||
|
);
|
||||||
|
// A variant with no display arm (e.g. reserved Empty slot) -> None.
|
||||||
|
assert_eq!(pool.load_constant_display(0), None);
|
||||||
|
assert_eq!(pool.load_constant_display(999), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constant_pool_len_and_is_empty() {
|
||||||
|
let pool = sample_pool();
|
||||||
|
assert_eq!(pool.len(), 10);
|
||||||
|
assert!(!pool.is_empty());
|
||||||
|
|
||||||
|
let empty = ConstantPool::from_entries(vec![]);
|
||||||
|
assert_eq!(empty.len(), 0);
|
||||||
|
assert!(empty.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constant_pool_iter_yields_index_and_entry_pairs() {
|
||||||
|
let pool = ConstantPool::from_entries(vec![
|
||||||
|
CpInfo::Empty,
|
||||||
|
CpInfo::Utf8("A".to_string()),
|
||||||
|
CpInfo::Integer(7),
|
||||||
|
]);
|
||||||
|
let indices: Vec<u16> = pool.iter().map(|(i, _)| i).collect();
|
||||||
|
assert_eq!(indices, vec![0, 1, 2]);
|
||||||
|
// Confirm the entries themselves come through, not an empty iterator.
|
||||||
|
let utf8_at_1 = pool.iter().find(|(i, _)| *i == 1).map(|(_, e)| match e {
|
||||||
|
CpInfo::Utf8(s) => s.as_str(),
|
||||||
|
_ => "?",
|
||||||
|
});
|
||||||
|
assert_eq!(utf8_at_1, Some("A"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class_file_with(this_class: u16, super_class: u16, pool: ConstantPool) -> ClassFile {
|
||||||
|
ClassFile {
|
||||||
|
minor_version: 0,
|
||||||
|
major_version: 0,
|
||||||
|
constant_pool: pool,
|
||||||
|
access_flags: 0,
|
||||||
|
this_class,
|
||||||
|
super_class,
|
||||||
|
interfaces: Vec::new(),
|
||||||
|
fields: Vec::new(),
|
||||||
|
methods: Vec::new(),
|
||||||
|
attributes: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn this_class_name_and_super_class_name_resolve_distinct_indices() {
|
||||||
|
let pool = ConstantPool::from_entries(vec![
|
||||||
|
CpInfo::Empty,
|
||||||
|
CpInfo::Utf8("com/example/Foo".to_string()),
|
||||||
|
CpInfo::Utf8("com/example/Bar".to_string()),
|
||||||
|
CpInfo::Class { name_index: 1 },
|
||||||
|
CpInfo::Class { name_index: 2 },
|
||||||
|
]);
|
||||||
|
let cf = class_file_with(3, 4, pool);
|
||||||
|
assert_eq!(cf.this_class_name(), Some("com/example/Foo"));
|
||||||
|
assert_eq!(cf.super_class_name(), Some("com/example/Bar"));
|
||||||
|
|
||||||
|
// this_class index pointing at a non-Class entry must not resolve.
|
||||||
|
let pool2 = ConstantPool::from_entries(vec![
|
||||||
|
CpInfo::Empty,
|
||||||
|
CpInfo::Utf8("not a class ref".to_string()),
|
||||||
|
]);
|
||||||
|
let cf2 = class_file_with(1, 1, pool2);
|
||||||
|
assert_eq!(cf2.this_class_name(), None);
|
||||||
|
assert_eq!(cf2.super_class_name(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn member_descriptor_resolves_the_descriptor_not_the_name() {
|
||||||
|
let pool = ConstantPool::from_entries(vec![
|
||||||
|
CpInfo::Empty,
|
||||||
|
CpInfo::Utf8("doStuff".to_string()), // index 1: name
|
||||||
|
CpInfo::Utf8("()V".to_string()), // index 2: descriptor
|
||||||
|
]);
|
||||||
|
let cf = class_file_with(0, 0, pool);
|
||||||
|
let m = Member {
|
||||||
|
access_flags: 0,
|
||||||
|
name_index: 1,
|
||||||
|
descriptor_index: 2,
|
||||||
|
attributes: Vec::new(),
|
||||||
|
};
|
||||||
|
assert_eq!(cf.member_descriptor(&m), Some("()V"));
|
||||||
|
assert_ne!(cf.member_descriptor(&m), Some("doStuff"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Reader::u16/u32/u64 boundary + value correctness
|
||||||
|
//
|
||||||
|
// Mirrors `slice_boundary_is_inclusive_of_the_final_byte`: an
|
||||||
|
// exact-fit read must succeed, one byte short must fail. Plus
|
||||||
|
// positive-value tests so a scrambled byte assembly (not just an
|
||||||
|
// out-of-bounds read) would be caught.
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u16_boundary_is_inclusive_of_the_final_byte() {
|
||||||
|
let data = [0xAB, 0xCD];
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert_eq!(r.u16("exact fit").expect("2 bytes available"), 0xABCD);
|
||||||
|
|
||||||
|
let data = [0xAB];
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert!(matches!(
|
||||||
|
r.u16("one byte short"),
|
||||||
|
Err(Error::UnexpectedEof { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u16_decodes_big_endian_value() {
|
||||||
|
let data = [0x01, 0x02];
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert_eq!(r.u16("value").unwrap(), 0x0102);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u32_boundary_is_inclusive_of_the_final_byte() {
|
||||||
|
let data = [0x00, 0x00, 0x00, 0x2A];
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert_eq!(r.u32("exact fit").expect("4 bytes available"), 42);
|
||||||
|
|
||||||
|
let data = [0x00, 0x00, 0x00];
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert!(matches!(
|
||||||
|
r.u32("one byte short"),
|
||||||
|
Err(Error::UnexpectedEof { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u32_decodes_big_endian_value() {
|
||||||
|
let data = [0x00, 0x00, 0x05, 0x39]; // 1337
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert_eq!(r.u32("value").unwrap(), 1337);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u64_boundary_is_inclusive_of_the_final_byte() {
|
||||||
|
// pos == 0, buffer exactly 8 bytes: must succeed.
|
||||||
|
let data = [0, 0, 0, 0, 0, 0, 0, 0x7B]; // 123
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert_eq!(r.u64("exact fit").expect("8 bytes available"), 123);
|
||||||
|
|
||||||
|
// pos == 0, buffer one byte short of 8: must fail cleanly, not
|
||||||
|
// panic on the internal self.data[self.pos + 7] index.
|
||||||
|
let data = [0u8; 7];
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert!(matches!(
|
||||||
|
r.u64("one byte short"),
|
||||||
|
Err(Error::UnexpectedEof { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u64_decodes_big_endian_value() {
|
||||||
|
let data = [0, 0, 0, 0, 0, 0, 0x05, 0x39]; // 1337
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert_eq!(r.u64("value").unwrap(), 1337);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// decode_modified_utf8: 3-byte (BMP) decode path
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modified_utf8_three_byte_cjk() {
|
||||||
|
// U+3042 (hiragana あ) in modified UTF-8: 1110xxxx 10xxxxxx 10xxxxxx
|
||||||
|
// = 0xE3 0x81 0x82.
|
||||||
|
let s = decode_modified_utf8(&[0xE3, 0x81, 0x82]).unwrap();
|
||||||
|
assert_eq!(s, "\u{3042}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modified_utf8_three_byte_rejects_bad_first_continuation() {
|
||||||
|
// Valid lead byte (0xE3), but the first continuation byte is not
|
||||||
|
// 10xxxxxx (0x01 instead) — must be rejected, proving the first
|
||||||
|
// `& 0xC0 != 0x80` check is live.
|
||||||
|
assert!(decode_modified_utf8(&[0xE3, 0x01, 0x82]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modified_utf8_three_byte_rejects_bad_second_continuation() {
|
||||||
|
// Valid lead + first continuation, but the second continuation
|
||||||
|
// byte is not 10xxxxxx — proves the second check is independently
|
||||||
|
// live (not short-circuited by the first).
|
||||||
|
assert!(decode_modified_utf8(&[0xE3, 0x81, 0x01]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// read_constant_pool: Long/Double two-slot skip, real byte parsing
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constant_pool_long_entry_occupies_two_slots_via_real_parse() {
|
||||||
|
// Real class-file bytes (not the `from_entries` synthetic ctor):
|
||||||
|
// magic + minor/major + cp_count=4 + tag=5 (Long, 8-byte payload
|
||||||
|
// at index 1, reserved slot at index 2) + tag=1 (Utf8 at index 3)
|
||||||
|
// + empty access_flags/this/super/interfaces/fields/methods/attrs.
|
||||||
|
let mut buf = vec![
|
||||||
|
0xCA, 0xFE, 0xBA, 0xBE, // magic
|
||||||
|
0x00, 0x00, // minor
|
||||||
|
0x00, 0x34, // major
|
||||||
|
0x00, 0x04, // cp_count = 4 (0=Empty,1=Long,2=Empty tail,3=Utf8)
|
||||||
|
5, // Long tag
|
||||||
|
];
|
||||||
|
buf.extend_from_slice(&0x1122_3344_5566_7788u64.to_be_bytes()); // 8-byte payload
|
||||||
|
buf.push(1); // Utf8 tag
|
||||||
|
let name = b"marker";
|
||||||
|
buf.extend_from_slice(&(name.len() as u16).to_be_bytes());
|
||||||
|
buf.extend_from_slice(name);
|
||||||
|
// access_flags, this_class, super_class, interfaces_count
|
||||||
|
buf.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
|
// fields_count, methods_count, attributes_count
|
||||||
|
buf.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
|
||||||
|
|
||||||
|
let cf = ClassFile::parse(&buf).expect("well-formed synthetic class file");
|
||||||
|
assert_eq!(cf.constant_pool.len(), 4);
|
||||||
|
// The Long occupies indices 1 AND 2 (its reserved tail slot).
|
||||||
|
// The Utf8 must resolve at index 3 = long_index(1) + 2, NOT +1.
|
||||||
|
assert_eq!(cf.constant_pool.utf8(3), Some("marker"));
|
||||||
|
// Index 2 is the reserved tail slot: not a Utf8, must not
|
||||||
|
// resolve as one (guards against the Utf8 landing one slot early).
|
||||||
|
assert_eq!(cf.constant_pool.utf8(2), None);
|
||||||
|
match cf.constant_pool.get(1) {
|
||||||
|
Some(CpInfo::Long(v)) => assert_eq!(*v, 0x1122_3344_5566_7788u64 as i64),
|
||||||
|
other => panic!("expected Long at index 1, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// instruction_size: tableswitch/lookupswitch with non-degenerate
|
||||||
|
// low/high/npairs (the existing tests only cover low==high==0 and
|
||||||
|
// npairs==0, which can't distinguish `-` from `+` in the entry-count
|
||||||
|
// arithmetic).
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instruction_size_tableswitch_non_degenerate_range() {
|
||||||
|
// low=1, high=4 -> 4 entries (high-low+1 = 4). A `-`->`+` mutation
|
||||||
|
// on that arithmetic would instead compute high+low+1 = 6.
|
||||||
|
let mut code = vec![TABLESWITCH];
|
||||||
|
code.extend_from_slice(&[0, 0, 0]); // padding
|
||||||
|
code.extend_from_slice(&[0, 0, 0, 0]); // default offset
|
||||||
|
code.extend_from_slice(&1i32.to_be_bytes()); // low = 1
|
||||||
|
code.extend_from_slice(&4i32.to_be_bytes()); // high = 4
|
||||||
|
code.extend_from_slice(&[0; 16]); // 4 jump entries * 4 bytes
|
||||||
|
// total = 1 (opcode) + 3 (pad) + 12 (default/low/high) + 16 (entries) = 32
|
||||||
|
assert_eq!(instruction_size(&code, 0), Some(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instruction_size_lookupswitch_non_degenerate_npairs() {
|
||||||
|
// npairs = 3 -> 3 * 8 = 24 bytes of pairs.
|
||||||
|
let mut code = vec![LOOKUPSWITCH];
|
||||||
|
code.extend_from_slice(&[0, 0, 0]); // padding
|
||||||
|
code.extend_from_slice(&[0, 0, 0, 0]); // default
|
||||||
|
code.extend_from_slice(&3i32.to_be_bytes()); // npairs = 3
|
||||||
|
code.extend_from_slice(&[0; 24]); // 3 pairs
|
||||||
|
// total = 1 + 3 + 8 (default/npairs) + 24 = 36
|
||||||
|
assert_eq!(instruction_size(&code, 0), Some(36));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+242
-56
@@ -16,7 +16,7 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
/// Cheap signature check: a Criterion disc ships `streamproperties.xml`
|
/// Cheap signature check: a Criterion disc ships `streamproperties.xml`
|
||||||
/// inside a `/BDMV/JAR/*` archive.
|
/// inside a `/BDMV/JAR/*` archive.
|
||||||
pub fn detect(udf: &UdfFs) -> bool {
|
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||||
super::jar_file_exists(udf, "streamproperties.xml")
|
super::jar_file_exists(udf, "streamproperties.xml")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,17 +36,18 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
|||||||
|
|
||||||
// Stream number mapping from playbackconfig.xml
|
// Stream number mapping from playbackconfig.xml
|
||||||
let mut stream_map: HashMap<String, u16> = HashMap::new();
|
let mut stream_map: HashMap<String, u16> = HashMap::new();
|
||||||
if let Some(pc_data) = super::read_jar_file(reader, udf, "playbackconfig.xml") {
|
if let Some(pc_data) = super::read_jar_file(reader, udf, "playbackconfig.xml")
|
||||||
if let Ok(pc_text) = std::str::from_utf8(&pc_data) {
|
&& let Ok(pc_text) = std::str::from_utf8(&pc_data)
|
||||||
parse_playback_config(pc_text, &mut stream_map);
|
{
|
||||||
}
|
parse_playback_config(pc_text, &mut stream_map);
|
||||||
}
|
}
|
||||||
|
|
||||||
let stream_nums = assign_stream_numbers(&stream_infos, &stream_map);
|
let stream_nums = assign_stream_numbers(&stream_infos, &stream_map)?;
|
||||||
|
|
||||||
let mut labels = Vec::new();
|
let mut labels = Vec::new();
|
||||||
for (info, &stream_num) in stream_infos.iter().zip(stream_nums.iter()) {
|
for (info, &stream_num) in stream_infos.iter().zip(stream_nums.iter()) {
|
||||||
labels.push(StreamLabel {
|
labels.push(StreamLabel {
|
||||||
|
stream_id: None,
|
||||||
stream_number: stream_num,
|
stream_number: stream_num,
|
||||||
stream_type: info.stream_type,
|
stream_type: info.stream_type,
|
||||||
language: info.language.clone(),
|
language: info.language.clone(),
|
||||||
@@ -76,12 +77,41 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
|||||||
/// map-assigned one. (Both numbering domains are 1-based per type, and
|
/// map-assigned one. (Both numbering domains are 1-based per type, and
|
||||||
/// `apply_labels` matches on `(type, stream_number)`, so a collision
|
/// `apply_labels` matches on `(type, stream_number)`, so a collision
|
||||||
/// would mislabel tracks.)
|
/// would mislabel tracks.)
|
||||||
fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap<String, u16>) -> Vec<u16> {
|
///
|
||||||
// Numbers already claimed by the map, per type.
|
/// Returns `None` when the 1-based stream-number space is exhausted — every
|
||||||
|
/// number in `1..=u16::MAX` for that type is either already claimed by the map
|
||||||
|
/// or already synthesized. That is unreachable on real media: the BD STN_table
|
||||||
|
/// carries at most 32 primary audio and 32 PG streams per playlist, so the
|
||||||
|
/// 65535-wide space leaves >2000x headroom. It IS reachable from a crafted
|
||||||
|
/// `streamproperties.xml` listing >65535 stream entries, and the only correct
|
||||||
|
/// answers there are "fail the parse" or "emit colliding numbers"; we fail.
|
||||||
|
///
|
||||||
|
/// The skip search is bounded by the numbering space itself: a `u16`
|
||||||
|
/// `saturating_add` here parked the counter at `u16::MAX` forever whenever the
|
||||||
|
/// map also claimed `u16::MAX`, turning an overflow guard into a hang that
|
||||||
|
/// `apply()`'s `catch_unwind` cannot interrupt. The counters are therefore
|
||||||
|
/// widened to `u32` so the skip loop strictly increases toward a fixed ceiling
|
||||||
|
/// (guaranteeing termination) and exhaustion is reported rather than absorbed.
|
||||||
|
fn assign_stream_numbers(
|
||||||
|
infos: &[StreamInfo],
|
||||||
|
stream_map: &HashMap<String, u16>,
|
||||||
|
) -> Option<Vec<u16>> {
|
||||||
|
/// One past the last assignable stream number, as a `u32` so the
|
||||||
|
/// counters can step off the end of the `u16` domain without wrapping.
|
||||||
|
const NUMBER_SPACE_END: u32 = u16::MAX as u32 + 1;
|
||||||
|
|
||||||
|
// Numbers already claimed by the map, per type. A map value of 0 is NOT a
|
||||||
|
// claim: apply_labels binds on 1-based stream numbers, so 0 is unmatchable.
|
||||||
|
// Treat 0 as "unmapped" here (defense in depth — parse_playback_config also
|
||||||
|
// filters it) so such a stream gets a real synthesized number instead of an
|
||||||
|
// orphan 0 that collides with / shadows a genuine stream 1.
|
||||||
let mut taken_audio: Vec<u16> = Vec::new();
|
let mut taken_audio: Vec<u16> = Vec::new();
|
||||||
let mut taken_sub: Vec<u16> = Vec::new();
|
let mut taken_sub: Vec<u16> = Vec::new();
|
||||||
for info in infos {
|
for info in infos {
|
||||||
if let Some(&n) = stream_map.get(&info.id) {
|
if let Some(&n) = stream_map.get(&info.id) {
|
||||||
|
if n == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match info.stream_type {
|
match info.stream_type {
|
||||||
StreamLabelType::Audio => taken_audio.push(n),
|
StreamLabelType::Audio => taken_audio.push(n),
|
||||||
StreamLabelType::Subtitle => taken_sub.push(n),
|
StreamLabelType::Subtitle => taken_sub.push(n),
|
||||||
@@ -89,32 +119,42 @@ fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap<String, u16>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut audio_idx: u16 = 1;
|
let mut audio_idx: u32 = 1;
|
||||||
let mut sub_idx: u16 = 1;
|
let mut sub_idx: u32 = 1;
|
||||||
let mut out = Vec::with_capacity(infos.len());
|
let mut out = Vec::with_capacity(infos.len());
|
||||||
for info in infos {
|
for info in infos {
|
||||||
let n = match stream_map.get(&info.id).copied() {
|
let n = match stream_map.get(&info.id).copied() {
|
||||||
Some(n) => n,
|
Some(n) if n != 0 => n,
|
||||||
None => {
|
_ => {
|
||||||
let (idx, taken) = match info.stream_type {
|
let (idx, taken) = match info.stream_type {
|
||||||
StreamLabelType::Audio => (&mut audio_idx, &taken_audio),
|
StreamLabelType::Audio => (&mut audio_idx, &taken_audio),
|
||||||
StreamLabelType::Subtitle => (&mut sub_idx, &taken_sub),
|
StreamLabelType::Subtitle => (&mut sub_idx, &taken_sub),
|
||||||
};
|
};
|
||||||
// Advance past any number already claimed via the map.
|
// Advance past any number already claimed via the map. The
|
||||||
// saturating: a crafted XML with >65k stream entries must
|
// counter strictly increases and NUMBER_SPACE_END is fixed, so
|
||||||
// not overflow (panic in debug, wrap-to-0 in release) on
|
// this terminates in at most 65535 steps for any input.
|
||||||
// untrusted disc bytes.
|
while *idx < NUMBER_SPACE_END && taken.contains(&(*idx as u16)) {
|
||||||
while taken.contains(idx) {
|
*idx += 1;
|
||||||
*idx = idx.saturating_add(1);
|
|
||||||
}
|
}
|
||||||
let n = *idx;
|
if *idx >= NUMBER_SPACE_END {
|
||||||
*idx = idx.saturating_add(1);
|
// Numbering space exhausted. Emitting anything here would
|
||||||
|
// either wrap to 0 (unmatchable) or duplicate a number
|
||||||
|
// already bound to a different stream, so the parse fails.
|
||||||
|
tracing::warn!(
|
||||||
|
streams = infos.len(),
|
||||||
|
"criterion: 1-based u16 stream-number space exhausted; \
|
||||||
|
refusing to synthesize a colliding stream number"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let n = *idx as u16;
|
||||||
|
*idx += 1;
|
||||||
n
|
n
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
out.push(n);
|
out.push(n);
|
||||||
}
|
}
|
||||||
out
|
Some(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StreamInfo {
|
struct StreamInfo {
|
||||||
@@ -182,14 +222,13 @@ fn parse_playback_config(text: &str, map: &mut HashMap<String, u16>) {
|
|||||||
if let (Some(stream_id_str), Some(info_id)) = (
|
if let (Some(stream_id_str), Some(info_id)) = (
|
||||||
xml::text(block, "StreamID"),
|
xml::text(block, "StreamID"),
|
||||||
xml::text(block, "StreamInfo_ID"),
|
xml::text(block, "StreamInfo_ID"),
|
||||||
) {
|
) && let Ok(stream_num) = stream_id_str.parse::<u16>()
|
||||||
if let Ok(stream_num) = stream_id_str.parse::<u16>() {
|
{
|
||||||
// Stream numbers are 1-based per the apply_labels
|
// Stream numbers are 1-based per the apply_labels
|
||||||
// contract; a mapped 0 is unmatchable and silently
|
// contract; a mapped 0 is unmatchable and silently
|
||||||
// drops the label. Skip it rather than store it.
|
// drops the label. Skip it rather than store it.
|
||||||
if stream_num != 0 {
|
if stream_num != 0 {
|
||||||
map.insert(info_id, stream_num);
|
map.insert(info_id, stream_num);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
from = end;
|
from = end;
|
||||||
@@ -219,11 +258,89 @@ mod tests {
|
|||||||
info("a1", StreamLabelType::Audio),
|
info("a1", StreamLabelType::Audio),
|
||||||
info("s0", StreamLabelType::Subtitle),
|
info("s0", StreamLabelType::Subtitle),
|
||||||
];
|
];
|
||||||
let nums = assign_stream_numbers(&infos, &HashMap::new());
|
let nums =
|
||||||
|
assign_stream_numbers(&infos, &HashMap::new()).expect("numbering space not exhausted");
|
||||||
// Per-type 1-based: audio 1,2 ; subtitle 1.
|
// Per-type 1-based: audio 1,2 ; subtitle 1.
|
||||||
assert_eq!(nums, vec![1, 2, 1]);
|
assert_eq!(nums, vec![1, 2, 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Immunity pin. `parse_stream_infos` emits one `StreamInfo` per
|
||||||
|
/// `*StreamInfos` element unconditionally — no filter, no `continue` — so
|
||||||
|
/// an element whose fields are missing or unrecognized still occupies its
|
||||||
|
/// position, and `assign_stream_numbers` still spends a number on it.
|
||||||
|
///
|
||||||
|
/// That is the property that keeps this parser out of the failure mode
|
||||||
|
/// where a skipped entry pulls every later label one stream forward. It
|
||||||
|
/// is load-bearing for the fallback path specifically: with no
|
||||||
|
/// `playbackconfig.xml` the numbers come purely from position in this
|
||||||
|
/// list, so dropping an element there would shift the rest.
|
||||||
|
///
|
||||||
|
/// Mutation: skip elements with an empty `ID`/`LangInfoID` → the two
|
||||||
|
/// real audio streams renumber to 1 and 2.
|
||||||
|
/// Immunity pin, section-boundary half. Each stream here is one closed XML
|
||||||
|
/// element, and every field is read out of `&text[start..end]` — the range
|
||||||
|
/// `xml::find_element` returned — so one element can never absorb the next
|
||||||
|
/// one's fields, however the document is malformed around it. Contrast the
|
||||||
|
/// flat-string walk in pixelogic, where a section whose end marker is
|
||||||
|
/// missing keeps consuming entries as STN slots.
|
||||||
|
///
|
||||||
|
/// The missing-boundary case fails closed. An element with no close tag of
|
||||||
|
/// its own ends at the NEXT close tag, so it absorbs the element behind it
|
||||||
|
/// — the list comes back SHORTER. It cannot come back longer: nothing
|
||||||
|
/// outside a returned range is ever read as a stream, and `find_element`
|
||||||
|
/// yields `None` rather than a range running to EOF when no close tag
|
||||||
|
/// exists at all. A malformed document can cost this parser a slot; it can
|
||||||
|
/// never invent one.
|
||||||
|
///
|
||||||
|
/// Mutation: read fields from the document rather than the element's
|
||||||
|
/// range, or let a close-less element run to EOF → the trailing elements
|
||||||
|
/// re-enter the list as extra streams.
|
||||||
|
#[test]
|
||||||
|
fn an_unterminated_stream_element_shortens_the_list_it_cannot_extend_it() {
|
||||||
|
let sp = concat!(
|
||||||
|
"<AudioStreamInfos><ID>a0</ID><LangInfoID>ENG</LangInfoID></AudioStreamInfos>",
|
||||||
|
// No `</AudioStreamInfos>` for this one.
|
||||||
|
"<AudioStreamInfos><ID>a1</ID><LangInfoID>FRA</LangInfoID>",
|
||||||
|
"<AudioStreamInfos><ID>a2</ID><LangInfoID>DEU</LangInfoID></AudioStreamInfos>",
|
||||||
|
);
|
||||||
|
let infos = parse_stream_infos(sp);
|
||||||
|
assert_eq!(
|
||||||
|
infos.iter().map(|i| i.id.as_str()).collect::<Vec<_>>(),
|
||||||
|
vec!["a0", "a1"],
|
||||||
|
"the close-less element absorbs the one behind it — two slots, not \
|
||||||
|
three, and never four"
|
||||||
|
);
|
||||||
|
assert_eq!(infos[1].language, "fra", "and keeps its own leading fields");
|
||||||
|
|
||||||
|
// With no close tag anywhere behind it, the element is not returned at
|
||||||
|
// all and the walk ends — the tail of the document never becomes a
|
||||||
|
// stream list.
|
||||||
|
let no_close = "<AudioStreamInfos><ID>a0</ID><LangInfoID>ENG</LangInfoID>";
|
||||||
|
assert!(parse_stream_infos(no_close).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unusable_stream_element_still_occupies_its_position() {
|
||||||
|
let sp = r#"
|
||||||
|
<AudioStreamInfos><ID>a0</ID><LangInfoID>ENG_US</LangInfoID></AudioStreamInfos>
|
||||||
|
<AudioStreamInfos></AudioStreamInfos>
|
||||||
|
<AudioStreamInfos><ID>a2</ID><LangInfoID>FRA</LangInfoID><Content>COMMENTARY</Content></AudioStreamInfos>
|
||||||
|
<SubtitleStreamInfos><ID>s0</ID><LangInfoID></LangInfoID><Qualifier>WAT</Qualifier></SubtitleStreamInfos>
|
||||||
|
<SubtitleStreamInfos><ID>s1</ID><LangInfoID>ENG</LangInfoID><Qualifier>SDH</Qualifier></SubtitleStreamInfos>
|
||||||
|
"#;
|
||||||
|
let infos = parse_stream_infos(sp);
|
||||||
|
assert_eq!(infos.len(), 5, "every element yields a StreamInfo");
|
||||||
|
let nums =
|
||||||
|
assign_stream_numbers(&infos, &HashMap::new()).expect("numbering space not exhausted");
|
||||||
|
assert_eq!(
|
||||||
|
nums,
|
||||||
|
vec![1, 2, 3, 1, 2],
|
||||||
|
"the blank element owns audio slot 2, so the commentary is slot 3"
|
||||||
|
);
|
||||||
|
assert_eq!(infos[2].purpose, LabelPurpose::Commentary);
|
||||||
|
assert_eq!(infos[4].qualifier, LabelQualifier::Sdh);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fallback_does_not_collide_with_partial_map() {
|
fn fallback_does_not_collide_with_partial_map() {
|
||||||
// Map claims audio "a1" -> 1. The unmapped audio "a0" must NOT
|
// Map claims audio "a1" -> 1. The unmapped audio "a0" must NOT
|
||||||
@@ -235,7 +352,7 @@ mod tests {
|
|||||||
info("a1", StreamLabelType::Audio), // mapped → 1
|
info("a1", StreamLabelType::Audio), // mapped → 1
|
||||||
info("a2", StreamLabelType::Audio), // unmapped → fallback
|
info("a2", StreamLabelType::Audio), // unmapped → fallback
|
||||||
];
|
];
|
||||||
let nums = assign_stream_numbers(&infos, &map);
|
let nums = assign_stream_numbers(&infos, &map).expect("numbering space not exhausted");
|
||||||
// a0 skips the taken 1 → 2; a1 keeps 1; a2 → 3. All distinct.
|
// a0 skips the taken 1 → 2; a1 keeps 1; a2 → 3. All distinct.
|
||||||
assert_eq!(nums, vec![2, 1, 3]);
|
assert_eq!(nums, vec![2, 1, 3]);
|
||||||
let mut sorted = nums.clone();
|
let mut sorted = nums.clone();
|
||||||
@@ -253,7 +370,10 @@ mod tests {
|
|||||||
info("a0", StreamLabelType::Audio),
|
info("a0", StreamLabelType::Audio),
|
||||||
info("a1", StreamLabelType::Audio),
|
info("a1", StreamLabelType::Audio),
|
||||||
];
|
];
|
||||||
assert_eq!(assign_stream_numbers(&infos, &map), vec![5, 9]);
|
assert_eq!(
|
||||||
|
assign_stream_numbers(&infos, &map).expect("numbering space not exhausted"),
|
||||||
|
vec![5, 9]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Additional hardening tests ─────────────────────────────────────────
|
// ── Additional hardening tests ─────────────────────────────────────────
|
||||||
@@ -269,7 +389,8 @@ mod tests {
|
|||||||
info("a1", StreamLabelType::Audio),
|
info("a1", StreamLabelType::Audio),
|
||||||
info("s1", StreamLabelType::Subtitle),
|
info("s1", StreamLabelType::Subtitle),
|
||||||
];
|
];
|
||||||
let nums = assign_stream_numbers(&infos, &HashMap::new());
|
let nums =
|
||||||
|
assign_stream_numbers(&infos, &HashMap::new()).expect("numbering space not exhausted");
|
||||||
// Audio: 1, 2; Subtitle: 1, 2 — each counter resets at 1 per type.
|
// Audio: 1, 2; Subtitle: 1, 2 — each counter resets at 1 per type.
|
||||||
assert_eq!(nums[0], 1); // audio 1
|
assert_eq!(nums[0], 1); // audio 1
|
||||||
assert_eq!(nums[1], 1); // subtitle 1
|
assert_eq!(nums[1], 1); // subtitle 1
|
||||||
@@ -277,27 +398,34 @@ mod tests {
|
|||||||
assert_eq!(nums[3], 2); // subtitle 2
|
assert_eq!(nums[3], 2); // subtitle 2
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spec: map stream_num=0 is explicitly rejected (apply_labels uses 1-based).
|
/// Spec: a map value of 0 is unmatchable (apply_labels is 1-based), so
|
||||||
/// This is documented in parse_playback_config: `if stream_num != 0`.
|
/// assign_stream_numbers must treat it as unmapped and synthesize a real
|
||||||
/// Mutation: remove the `!= 0` guard → zero is stored in map.
|
/// 1-based number rather than emit an orphan 0.
|
||||||
|
/// Mutation: read the map value verbatim → stream_number 0 leaks out.
|
||||||
#[test]
|
#[test]
|
||||||
fn map_zero_stream_num_is_skipped() {
|
fn map_zero_stream_num_is_synthesized_not_emitted() {
|
||||||
// parse_playback_config skips zero; simulate that: the zero shouldn't
|
|
||||||
// end up in the map. We test assign_stream_numbers with a zero-containing
|
|
||||||
// map to verify it won't freeze the fallback counter at 1 forever.
|
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
map.insert("a0".to_string(), 0u16); // zero — per spec, was filtered by parse_playback_config
|
map.insert("a0".to_string(), 0u16); // 0 must not be treated as a claim
|
||||||
let infos = vec![info("a0", StreamLabelType::Audio)];
|
let infos = vec![info("a0", StreamLabelType::Audio)];
|
||||||
// If 0 IS in the map and assign_stream_numbers uses it, stream_number=0
|
let nums = assign_stream_numbers(&infos, &map).expect("numbering space not exhausted");
|
||||||
// is not matchable (apply_labels is 1-based). The fallback counter
|
// 0 is treated as unmapped → the fallback counter assigns 1.
|
||||||
// would assign 1 instead. Test both paths:
|
assert_eq!(nums[0], 1);
|
||||||
let nums = assign_stream_numbers(&infos, &map);
|
}
|
||||||
// If the map has 0 for a0, assign_stream_numbers returns 0 (map wins).
|
|
||||||
// This is a known limitation — the guard lives in parse_playback_config.
|
/// A stream genuinely mapped to 1 plus another stream whose map value is 0
|
||||||
// The test documents the ACTUAL behavior so a code change that introduces
|
/// must NOT both land on 1: the 0-stream is synthesized past the claimed 1.
|
||||||
// the guard in assign_stream_numbers would be caught.
|
#[test]
|
||||||
// Current behavior: map wins → 0.
|
fn map_zero_does_not_collide_with_a_real_stream_one() {
|
||||||
assert_eq!(nums[0], 0);
|
let mut map = HashMap::new();
|
||||||
|
map.insert("real".to_string(), 1u16);
|
||||||
|
map.insert("bad".to_string(), 0u16);
|
||||||
|
let infos = vec![
|
||||||
|
info("real", StreamLabelType::Audio),
|
||||||
|
info("bad", StreamLabelType::Audio),
|
||||||
|
];
|
||||||
|
let nums = assign_stream_numbers(&infos, &map).expect("numbering space not exhausted");
|
||||||
|
assert_eq!(nums[0], 1); // the genuinely-mapped stream keeps 1
|
||||||
|
assert_eq!(nums[1], 2); // the 0-stream is synthesized to the next free slot
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spec: collision-avoidance works across audio AND subtitle independently.
|
/// Spec: collision-avoidance works across audio AND subtitle independently.
|
||||||
@@ -312,16 +440,74 @@ mod tests {
|
|||||||
info("a0", StreamLabelType::Audio), // fallback
|
info("a0", StreamLabelType::Audio), // fallback
|
||||||
info("s0", StreamLabelType::Subtitle), // mapped → 2
|
info("s0", StreamLabelType::Subtitle), // mapped → 2
|
||||||
];
|
];
|
||||||
let nums = assign_stream_numbers(&infos, &map);
|
let nums = assign_stream_numbers(&infos, &map).expect("numbering space not exhausted");
|
||||||
// Audio fallback for a0 → 1 (subtitle's taken-2 doesn't block it).
|
// Audio fallback for a0 → 1 (subtitle's taken-2 doesn't block it).
|
||||||
assert_eq!(nums[0], 1);
|
assert_eq!(nums[0], 1);
|
||||||
assert_eq!(nums[1], 2);
|
assert_eq!(nums[1], 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spec: saturating_add prevents overflow when many streams are listed.
|
/// A crafted `streamproperties.xml` can drive the fallback counter to the
|
||||||
/// Mutation: use wrapping_add → counter wraps to 0 and collides.
|
/// top of the 1-based u16 stream-number space and then present one more
|
||||||
|
/// unmapped stream whose successor number is also claimed by the map.
|
||||||
|
///
|
||||||
|
/// This must TERMINATE. The bound is the numbering space itself, so the
|
||||||
|
/// assertion is on the spec-derived exhaustion behaviour (`None`), not on
|
||||||
|
/// any tunable constant. Run on a worker thread with a deadline so a
|
||||||
|
/// non-terminating loop fails the test in 20 s instead of hanging CI.
|
||||||
#[test]
|
#[test]
|
||||||
fn assign_stream_numbers_saturation_on_overflow() {
|
fn exhausted_numbering_terminates_instead_of_looping() {
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let worker = std::thread::spawn(move || {
|
||||||
|
// One mapped audio stream claims the last number in the space.
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
map.insert("claims_max".to_string(), u16::MAX);
|
||||||
|
let mut infos = vec![info("claims_max", StreamLabelType::Audio)];
|
||||||
|
// Enough unmapped audio streams to walk the counter to the top.
|
||||||
|
for i in 0..=(u16::MAX as u32) {
|
||||||
|
infos.push(info(&format!("u{i}"), StreamLabelType::Audio));
|
||||||
|
}
|
||||||
|
let _ = tx.send(assign_stream_numbers(&infos, &map));
|
||||||
|
});
|
||||||
|
match rx.recv_timeout(std::time::Duration::from_secs(20)) {
|
||||||
|
Ok(result) => {
|
||||||
|
worker.join().expect("worker panicked");
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"an exhausted 1-based u16 numbering space must fail the parse, \
|
||||||
|
not emit colliding or wrapped stream numbers"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(_) => panic!(
|
||||||
|
"assign_stream_numbers did not terminate within 20s — \
|
||||||
|
non-terminating skip loop on crafted stream_map"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole 1-based u16 space must remain usable: 65535 unmapped audio
|
||||||
|
/// streams get 65535 distinct numbers with no panic and no wrap. The
|
||||||
|
/// literals here are the JVMS-independent, spec-derived size of a u16
|
||||||
|
/// 1-based numbering domain, not a tunable cap.
|
||||||
|
#[test]
|
||||||
|
fn full_u16_numbering_space_is_usable_and_unique() {
|
||||||
|
let infos: Vec<StreamInfo> = (0..65_535u32)
|
||||||
|
.map(|i| info(&format!("a{i}"), StreamLabelType::Audio))
|
||||||
|
.collect();
|
||||||
|
let nums = assign_stream_numbers(&infos, &HashMap::new()).expect("space is not exhausted");
|
||||||
|
assert_eq!(nums.len(), 65_535);
|
||||||
|
assert_eq!(nums[0], 1);
|
||||||
|
assert_eq!(nums[65_534], 65_535);
|
||||||
|
let mut sorted = nums.clone();
|
||||||
|
sorted.sort_unstable();
|
||||||
|
sorted.dedup();
|
||||||
|
assert_eq!(sorted.len(), 65_535, "stream numbers must all be distinct");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spec: a partially-mapped playlist with many claimed numbers must still
|
||||||
|
/// synthesize past every claim without panicking or colliding.
|
||||||
|
/// Mutation: drop the skip loop → the fallback reuses a claimed number.
|
||||||
|
#[test]
|
||||||
|
fn fallback_skips_a_dense_block_of_claimed_numbers() {
|
||||||
// Force the counter past u16::MAX by pre-taking all values 1..=u16::MAX.
|
// Force the counter past u16::MAX by pre-taking all values 1..=u16::MAX.
|
||||||
// Doing that for real would be slow; instead inject u16::MAX into taken.
|
// Doing that for real would be slow; instead inject u16::MAX into taken.
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
@@ -348,7 +534,7 @@ mod tests {
|
|||||||
qualifier: LabelQualifier::None,
|
qualifier: LabelQualifier::None,
|
||||||
});
|
});
|
||||||
// This must not panic.
|
// This must not panic.
|
||||||
let nums = assign_stream_numbers(&infos, &map);
|
let nums = assign_stream_numbers(&infos, &map).expect("numbering space not exhausted");
|
||||||
assert_eq!(nums.len(), 501);
|
assert_eq!(nums.len(), 501);
|
||||||
// The last (unmapped) entry's number must be > 500 (skipped all taken).
|
// The last (unmapped) entry's number must be > 500 (skipped all taken).
|
||||||
assert!(nums[500] > 500);
|
assert!(nums[500] > 500);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user