mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
697
Commits
qa
..
v1.7.7-rc9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04f28447bc | ||
|
|
53e94576ad | ||
|
|
6a17e560fc | ||
|
|
8d15f5211e | ||
|
|
8a0cb2adbf | ||
|
|
71b6df577a | ||
|
|
a53d40bfac | ||
|
|
7c718e31e4 | ||
|
|
37fde516af | ||
|
|
54b50a815e | ||
|
|
dc6d0fe693 | ||
|
|
6727fe893a | ||
|
|
07a7ea8aad | ||
|
|
8e317afc16 | ||
|
|
9e9ff21ba1 | ||
|
|
414766ec44 | ||
|
|
de4658dab8 | ||
|
|
f7ed7278b2 | ||
|
|
ca0f9d5bc8 | ||
|
|
134fdd973f | ||
|
|
09b9324903 | ||
|
|
6af1dcd912 | ||
|
|
735efac62b | ||
|
|
92b14006a2 | ||
|
|
f49f966c46 | ||
|
|
d3534bafdd | ||
|
|
566c9c8df3 | ||
|
|
92a79afef0 | ||
|
|
b14e6f1113 | ||
|
|
8f5b45b907 | ||
|
|
31a0f0d4d2 | ||
|
|
3b24038700 | ||
|
|
38b302fc86 | ||
|
|
a0a058a012 | ||
|
|
d8c6ea7fb1 | ||
|
|
10e2d400bd | ||
|
|
286997119a | ||
|
|
c37ef82dbf | ||
|
|
bbeb2d5ead | ||
|
|
b8f88bfe9a | ||
|
|
6fc8f657ac | ||
|
|
089ef9edda | ||
|
|
19066aa771 | ||
|
|
535d1b8dd9 | ||
|
|
82b2dd6cd2 | ||
|
|
389340bb92 | ||
|
|
4876f61d47 | ||
|
|
927bc1f8ec | ||
|
|
cd208c8c18 | ||
|
|
66162585d1 | ||
|
|
1355b35026 | ||
|
|
c9e7f81036 | ||
|
|
5189ef1f6e | ||
|
|
858847ffcf | ||
|
|
2908d11f09 | ||
|
|
cb3949f390 | ||
|
|
e408f699f4 | ||
|
|
5aa492397a | ||
|
|
8d25ec4890 | ||
|
|
9cb0ad73d3 | ||
|
|
5940b72850 | ||
|
|
c08b71263c | ||
|
|
d3436c0ff0 | ||
|
|
1924037d80 | ||
|
|
1ec12d0701 | ||
|
|
de89fbf27c | ||
|
|
3cfb86f7c6 | ||
|
|
087297c9d5 | ||
|
|
9f913b7db8 | ||
|
|
b242fe9ed0 | ||
|
|
15bbb52090 | ||
|
|
58c1191dd1 | ||
|
|
18060529a9 | ||
|
|
2a95f4a41a | ||
|
|
8823e5cf6d | ||
|
|
7faccaab4b | ||
|
|
69329b29ca | ||
|
|
1a3e56f8c7 | ||
|
|
6ddadda5dd | ||
|
|
82217737e1 | ||
|
|
761e1ac212 | ||
|
|
60cb67c096 | ||
|
|
b62efb79a8 | ||
|
|
62d56dff5b | ||
|
|
08aece29ff | ||
|
|
00cd0d42cf | ||
|
|
389725a7ee | ||
|
|
db69131636 | ||
|
|
2f51b1cdcf | ||
|
|
96e538c559 | ||
|
|
3a2c448a74 | ||
|
|
9ab985fbb2 | ||
|
|
064679b201 | ||
|
|
01507ddb10 | ||
|
|
ed02bc1392 | ||
|
|
1f5f5df8cb | ||
|
|
79b7893f2e | ||
|
|
9f87155c95 | ||
|
|
8b165585b8 | ||
|
|
9e792fc105 | ||
|
|
98b17c3898 | ||
|
|
ff3c18c404 | ||
|
|
1583c5e139 | ||
|
|
d335fe5f89 | ||
|
|
b67d82a5e1 | ||
|
|
7005cc0551 | ||
|
|
880bbcab4a | ||
|
|
b6579a85ff | ||
|
|
10d85b2444 | ||
|
|
d552ffed88 | ||
|
|
7c35ce6ef4 | ||
|
|
b6b888bb55 | ||
|
|
e52f190f89 | ||
|
|
e05358624f | ||
|
|
0a3667729e | ||
|
|
132a64c814 | ||
|
|
c0618a0e8a | ||
|
|
8e7b2e2821 | ||
|
|
1e3e18d861 | ||
|
|
81f798646a | ||
|
|
f7a241b5e8 | ||
|
|
d176d9c71d | ||
|
|
f956511d5c | ||
|
|
c827d9dd1e | ||
|
|
cc8f7853cc | ||
|
|
239447fab1 | ||
|
|
35a69c15a2 | ||
|
|
e6780eed56 | ||
|
|
0e6922cf8a | ||
|
|
570c54fc3c | ||
|
|
0c61a72cc1 | ||
|
|
bfed25dbb6 | ||
|
|
2f3d656601 | ||
|
|
512292d476 | ||
|
|
80d0454a54 | ||
|
|
3478b46174 | ||
|
|
02dd7e853b | ||
|
|
5de6502980 | ||
|
|
abfdee110f | ||
|
|
219f568579 | ||
|
|
dbdb81ceff | ||
|
|
17a4f4e070 | ||
|
|
d3cb5ad3cb | ||
|
|
9290ea081f | ||
|
|
a0a1445bad | ||
|
|
f859b63fc3 | ||
|
|
16ff692b33 | ||
|
|
bac7f5d44d | ||
|
|
88a5fbc9c1 | ||
|
|
5d70d6c9f4 | ||
|
|
c9e33dd68e | ||
|
|
beb7b5c187 | ||
|
|
1ce340a600 | ||
|
|
f1f52c9fcf | ||
|
|
5bddf66f35 | ||
|
|
74b853fb0d | ||
|
|
f8e49a7cab | ||
|
|
69073cb054 | ||
|
|
1e61cd44c5 | ||
|
|
40a9290258 | ||
|
|
5b37f7a9ee | ||
|
|
14dc6345a2 | ||
|
|
c1b58882de | ||
|
|
e76b43973d | ||
|
|
4e5da3758a | ||
|
|
e0d619c77a | ||
|
|
a4a6d372c3 | ||
|
|
328245b35e | ||
|
|
2d8b268927 | ||
|
|
5075cd0f4a | ||
|
|
7b8658eb8c | ||
|
|
c833097fc6 | ||
|
|
0d4228527d | ||
|
|
cb9381eb0b | ||
|
|
d83c31e133 | ||
|
|
e2039557da | ||
|
|
d6e89948bb | ||
|
|
5029a7e236 | ||
|
|
88b58d2066 | ||
|
|
b215c17eba | ||
|
|
3fdce66f52 | ||
|
|
eaa25c3c04 | ||
|
|
cb86968bb3 | ||
|
|
ed151f2c1d | ||
|
|
0c651b072f | ||
|
|
47fc74fc2f | ||
|
|
85e5f3a5d0 | ||
|
|
8024a443e5 | ||
|
|
255c09881a | ||
|
|
56ebfe2e02 | ||
|
|
9e8a6cdd65 | ||
|
|
a2baedad53 | ||
|
|
4a20748f96 | ||
|
|
de15252187 | ||
|
|
f29b4be6c6 | ||
|
|
5a3c20c499 | ||
|
|
06c62111e6 | ||
|
|
6e16d51e8f | ||
|
|
6561bcc1c2 | ||
|
|
fc1f2f7235 | ||
|
|
24c3234b2f | ||
|
|
86ea581a81 | ||
|
|
6eadc36327 | ||
|
|
9fa5df0d35 | ||
|
|
8239fd210d | ||
|
|
43bf9895b3 | ||
|
|
26b42bc6d7 | ||
|
|
fb1d5c3ac1 | ||
|
|
82d4a87041 | ||
|
|
3aba5b0262 | ||
|
|
414c4219b1 | ||
|
|
00d52768bf | ||
|
|
db9f9982fc | ||
|
|
ceba71d8a1 | ||
|
|
dee42e263d | ||
|
|
9f0e5a2df4 | ||
|
|
b40e7f94cf | ||
|
|
ebd23bce47 | ||
|
|
d1ba39c424 | ||
|
|
5298367e72 | ||
|
|
3e90614baf | ||
|
|
7b6767b3bc | ||
|
|
6eaf59950a | ||
|
|
f25866d11d | ||
|
|
a63fe29aa2 | ||
|
|
92469116a5 | ||
|
|
6a08053f0c | ||
|
|
68aa6657a3 | ||
|
|
b632b687f1 | ||
|
|
760d82e7c5 | ||
|
|
c9f409ab55 | ||
|
|
3fa672026e | ||
|
|
7d1da4731c | ||
|
|
3ee96f121f | ||
|
|
1a2b390f0c | ||
|
|
608109a257 | ||
|
|
0106f40d90 | ||
|
|
b2ab7534b9 | ||
|
|
5c47e2a58b | ||
|
|
bcaafdd8c2 | ||
|
|
ee06d6536e | ||
|
|
3308a74cb8 | ||
|
|
d5e17a7974 | ||
|
|
355c47f4fd | ||
|
|
f0902903ba | ||
|
|
115e642f80 | ||
|
|
2ef95eecb7 | ||
|
|
c7865985d9 | ||
|
|
854cfce434 | ||
|
|
8412bcb45d | ||
|
|
58630e0c82 | ||
|
|
3ac519d209 | ||
|
|
148e0f6989 | ||
|
|
13db988177 | ||
|
|
e9a058fc6e | ||
|
|
c06fa0cfc6 | ||
|
|
d2f7be2a3a | ||
|
|
d1fa263485 | ||
|
|
fcc418ee89 | ||
|
|
caa6f8c6ee | ||
|
|
31ca296eed | ||
|
|
1a325bc5d6 | ||
|
|
5c7b629eca | ||
|
|
c354ebd471 | ||
|
|
90cde7b496 | ||
|
|
211a632d54 | ||
|
|
0e517dd599 | ||
|
|
3370ba5138 | ||
|
|
528d83fd1f | ||
|
|
950385b34b | ||
|
|
e32aa31e07 | ||
|
|
e041e37c51 | ||
|
|
f8a3904023 | ||
|
|
e91777e117 | ||
|
|
2f0f316abe | ||
|
|
46b2f6a39c | ||
|
|
3c3a3d0860 | ||
|
|
525bf73c8a | ||
|
|
922026f763 | ||
|
|
ac0ec2a039 | ||
|
|
26b029bda4 | ||
|
|
baecb0475c | ||
|
|
715cd0be7e | ||
|
|
3feb0e0f8b | ||
|
|
339a4b780a | ||
|
|
93bf291deb | ||
|
|
c2eb2b4426 | ||
|
|
15ae09f269 | ||
|
|
e6b7841153 | ||
|
|
43b97465da | ||
|
|
3b343f1476 | ||
|
|
5fd3d7c63d | ||
|
|
4e2acd1fbb | ||
|
|
07d1c7aa1e | ||
|
|
bba24c45fe | ||
|
|
00e0a4e00c | ||
|
|
0753b4d25f | ||
|
|
f72fbc20a5 | ||
|
|
becfa26c68 | ||
|
|
d09c200de5 | ||
|
|
2db7ba9cdf | ||
|
|
b25faa112a | ||
|
|
94eda804cf | ||
|
|
8e7f8d81af | ||
|
|
02fb788b9f | ||
|
|
bde66445da | ||
|
|
c6b994d9b4 | ||
|
|
7b44773872 | ||
|
|
f51b663104 | ||
|
|
dc8e1a84d9 | ||
|
|
21d0893851 | ||
|
|
d463892bcd | ||
|
|
f08704a2de | ||
|
|
f754fa12d0 | ||
|
|
8369d682f4 | ||
|
|
2d6d210261 | ||
|
|
4c198c7979 | ||
|
|
5ccfbfd733 | ||
|
|
a02557112f | ||
|
|
6ad36a6b1b | ||
|
|
0e7e458d2c | ||
|
|
96434f286c | ||
|
|
6a08f2ab62 | ||
|
|
3e8f834e6f | ||
|
|
344d77cd48 | ||
|
|
bb72b0e327 | ||
|
|
5bdcf07fe7 | ||
|
|
ca1b180521 | ||
|
|
9f10e8dd1c | ||
|
|
b88bfaa0e5 | ||
|
|
c981655851 | ||
|
|
8ab15815e2 | ||
|
|
76b6bf2022 | ||
|
|
eb185797ca | ||
|
|
a526609ae0 | ||
|
|
9144baf0f0 | ||
|
|
8d1791b84e | ||
|
|
8ac8b53eb6 | ||
|
|
0f0c906d1f | ||
|
|
ff3d10f0a6 | ||
|
|
1558ecf617 | ||
|
|
7c1d2a203b | ||
|
|
7e855da188 | ||
|
|
d7183b4de8 | ||
|
|
11a901571e | ||
|
|
5faff8ad98 | ||
|
|
a67bcde42b | ||
|
|
e1d9f8c3eb | ||
|
|
8be92d3c7d | ||
|
|
027f1a848d | ||
|
|
3e9e8315ed | ||
|
|
9fc09be25f | ||
|
|
496fa3b10d | ||
|
|
2eb33647bd | ||
|
|
c69937267b | ||
|
|
cd9d5def4a | ||
|
|
bd6653f3cd | ||
|
|
14fd002856 | ||
|
|
5a6b5617e6 | ||
|
|
deacbf232c | ||
|
|
7d2de0cc7b | ||
|
|
278cbdeeda | ||
|
|
843f9501c4 | ||
|
|
54522a656b | ||
|
|
c065bb5a61 | ||
|
|
dfff4f3656 | ||
|
|
2338a19857 | ||
|
|
0f6e183808 | ||
|
|
4420390bf3 | ||
|
|
2e433fcbd3 | ||
|
|
ea418670b2 | ||
|
|
0f83cdcdc7 | ||
|
|
c898c4a879 | ||
|
|
988ccc1c30 | ||
|
|
86a399feda | ||
|
|
71e71b5ef4 | ||
|
|
6d296d0755 | ||
|
|
77c152993c | ||
|
|
0486dd73f5 | ||
|
|
03a128e041 | ||
|
|
9f279cd184 | ||
|
|
4b7b02a54e | ||
|
|
badb0ce398 | ||
|
|
693f02652b | ||
|
|
8e5ce67a37 | ||
|
|
94271c382f | ||
|
|
9aa650ed05 | ||
|
|
bae6d39c97 | ||
|
|
81e8d9a994 | ||
|
|
effb7f660b | ||
|
|
0b351c6060 | ||
|
|
3d153784c9 | ||
|
|
eb1ad87f8c | ||
|
|
1f3ababe25 | ||
|
|
071db05822 | ||
|
|
ed715b7ff6 | ||
|
|
a3f11963f3 | ||
|
|
e7119a566f | ||
|
|
a3847073b2 | ||
|
|
0bbe3dc3d4 | ||
|
|
48be07b719 | ||
|
|
85b204bea1 | ||
|
|
e5c59cec2e | ||
|
|
0968505dc1 | ||
|
|
1eae1a6f12 | ||
|
|
fb01e4f6c4 | ||
|
|
cb76dccbeb | ||
|
|
5015565523 | ||
|
|
5ebbf325be | ||
|
|
48b1024c13 | ||
|
|
a24e62f43e | ||
|
|
a5a74922bb | ||
|
|
e867e1c43a | ||
|
|
094996d8c4 | ||
|
|
abf5c1c571 | ||
|
|
a867d5f20b | ||
|
|
104cf02f66 | ||
|
|
1d3ce1fd62 | ||
|
|
cfe7a3f008 | ||
|
|
bdbe9fa51e | ||
|
|
d5d263e843 | ||
|
|
2a10ec9a4c | ||
|
|
756784df99 | ||
|
|
517d7a0ed0 | ||
|
|
78ec324d1e | ||
|
|
949113b8f3 | ||
|
|
5fe32e1d13 | ||
|
|
4da13b94be | ||
|
|
67c8306b1f | ||
|
|
eeaf5d0ae3 | ||
|
|
51c9d5c0d4 | ||
|
|
db0338cfbc | ||
|
|
ff0bf7d275 | ||
|
|
5aecf952ec | ||
|
|
b120b50e58 | ||
|
|
ab577935e2 | ||
|
|
842e9e0de3 | ||
|
|
47b5b26f6d | ||
|
|
2076c64a21 | ||
|
|
1d2ca8764a | ||
|
|
f2c8ce72e1 | ||
|
|
58dbbbc0fd | ||
|
|
8308bcac29 | ||
|
|
361f8611e0 | ||
|
|
48cedd6964 | ||
|
|
dfbb3078cb | ||
|
|
1b4461a3f6 | ||
|
|
80950dee65 | ||
|
|
812f9c1d70 | ||
|
|
b9a62683be | ||
|
|
fdb73a2c32 | ||
|
|
ea48d57f2a | ||
|
|
30060ab407 | ||
|
|
2c2f679d2c | ||
|
|
4cf69adf39 | ||
|
|
27a273dfba | ||
|
|
6992284b53 | ||
|
|
ad74f80a37 | ||
|
|
c39ff6bb3a | ||
|
|
32dfec84cd | ||
|
|
0fd7ea5247 | ||
|
|
86e34f10b6 | ||
|
|
5cb4f7d710 | ||
|
|
7897e5cb07 | ||
|
|
51a9372651 | ||
|
|
789581c3f4 | ||
|
|
d14a3425a1 | ||
|
|
d8d922c840 | ||
|
|
466d9c238b | ||
|
|
dc0bf2133d | ||
|
|
e979d069ba | ||
|
|
b609c6c650 | ||
|
|
c111fae949 | ||
|
|
1698faefbd | ||
|
|
6fbcf4432c | ||
|
|
9c74ce9714 | ||
|
|
be8a5b9ecd | ||
|
|
10dcbdb51e | ||
|
|
dd7682caba | ||
|
|
e4ece31b5e | ||
|
|
2ebcbb9378 | ||
|
|
600ae9b8d5 | ||
|
|
2178dbd76b | ||
|
|
2ab5d82a45 | ||
|
|
98125bd96f | ||
|
|
fe206e637b | ||
|
|
92ea9eae74 | ||
|
|
51c72fb7f3 | ||
|
|
b4c34dfd18 | ||
|
|
22b5fa2b59 | ||
|
|
9c7f1b8aac | ||
|
|
a3fce75924 | ||
|
|
ceb8c1a1eb | ||
|
|
7a393b7f78 | ||
|
|
6d9dc55875 | ||
|
|
d361992cd5 | ||
|
|
a8a93ee967 | ||
|
|
1ca9ad13f9 | ||
|
|
f67c25df2c | ||
|
|
e44ee04c27 | ||
|
|
36ab86e611 | ||
|
|
d264354efc | ||
|
|
938e06e86f | ||
|
|
01c99056d5 | ||
|
|
b602dd6730 | ||
|
|
8d46c7ed43 | ||
|
|
a73fc8e0e3 | ||
|
|
9f3dc05fdc | ||
|
|
43451aa3c1 | ||
|
|
50166c151b | ||
|
|
6c73749c36 | ||
|
|
4d48a8d9ac | ||
|
|
842ae28e24 | ||
|
|
55c060d774 | ||
|
|
faf949029e | ||
|
|
4ee124ca6f | ||
|
|
b993214df6 | ||
|
|
6091ba9844 | ||
|
|
1033673421 | ||
|
|
9b9b8b9ebc | ||
|
|
3ba04c7f1c | ||
|
|
0bdf9b9848 | ||
|
|
a64b0b7447 | ||
|
|
29b1231512 | ||
|
|
7891ff8d06 | ||
|
|
dab65c2e1c | ||
|
|
a9e8fcf1b2 | ||
|
|
313fb73ab5 | ||
|
|
e47799e669 | ||
|
|
a0fe55ee71 | ||
|
|
22c40c2424 | ||
|
|
3dbef556a2 | ||
|
|
432ea98619 | ||
|
|
2e1f8181fb | ||
|
|
fc29a46941 | ||
|
|
f7a1f0aab3 | ||
|
|
e64a4b4bf2 | ||
|
|
d5e2b91d83 | ||
|
|
fadfcb9be9 | ||
|
|
958b0911a7 | ||
|
|
56a262ac0a | ||
|
|
101304f793 | ||
|
|
0b9adee672 | ||
|
|
b754cd4831 | ||
|
|
bd35fee5d7 | ||
|
|
a303d9d3a0 | ||
|
|
163f8e08bc | ||
|
|
af05fc224c | ||
|
|
56a93ff33d | ||
|
|
dc0eebb64d | ||
|
|
fcf1dd46ce | ||
|
|
5158e1b285 | ||
|
|
04d570bf37 | ||
|
|
6757f2d241 | ||
|
|
28f961416d | ||
|
|
94aef92216 | ||
|
|
a6fc43e565 | ||
|
|
29330ea3ce | ||
|
|
5923a03f7b | ||
|
|
13a4b11d2f | ||
|
|
32d8a7fa6a | ||
|
|
96c4487ea8 | ||
|
|
191021f60d | ||
|
|
bbcce1dff4 | ||
|
|
11458235dd | ||
|
|
c2ad9290ef | ||
|
|
21b6665537 | ||
|
|
222624b6dc | ||
|
|
2dc8a19836 | ||
|
|
bf39ffc14f | ||
|
|
31f80b04dd | ||
|
|
7af639be0d | ||
|
|
6397512d16 | ||
|
|
1782932e17 | ||
|
|
45a5ef1be8 | ||
|
|
d9377222e8 | ||
|
|
b89ef83f10 | ||
|
|
99eeb7ae3a | ||
|
|
7533d876c4 | ||
|
|
ef42eb2ef8 | ||
|
|
ac9fbbe263 | ||
|
|
fc64ecd01b | ||
|
|
4e3e9fffff | ||
|
|
e2fc55cf01 | ||
|
|
52131315a3 | ||
|
|
07cb513bae | ||
|
|
c3eac58970 | ||
|
|
bef728a5e1 | ||
|
|
00649dfe7f | ||
|
|
74ae0a3fa5 | ||
|
|
7cf94da55e | ||
|
|
448e3eabb5 | ||
|
|
b4d322eac4 | ||
|
|
b90a4cba6c | ||
|
|
35c9e0f614 | ||
|
|
adfccbc1ae | ||
|
|
0c407748b4 | ||
|
|
f9dcda21e6 | ||
|
|
0a0f319bb3 | ||
|
|
1917830812 | ||
|
|
866a0733e6 | ||
|
|
63ec0dd8c3 | ||
|
|
5bbc24d915 | ||
|
|
5488082737 | ||
|
|
0aee5d0ac2 | ||
|
|
4659ee7a97 | ||
|
|
6de0e6ed7b | ||
|
|
6e432d32ed | ||
|
|
190ca2437e | ||
|
|
90a4b826d3 | ||
|
|
e0e679b108 | ||
|
|
c52ea7e971 | ||
|
|
cdf6ac49be | ||
|
|
21959049c2 | ||
|
|
dfc38dae91 | ||
|
|
f2099ddc04 | ||
|
|
70315f0bf2 | ||
|
|
740cfcb9cf | ||
|
|
45e6f960cb | ||
|
|
38fa3203dc | ||
|
|
af47b2cb4e | ||
|
|
5683e77946 | ||
|
|
975bb5acaf | ||
|
|
6a817e7fca | ||
|
|
9c47c0697b | ||
|
|
c6ab9886d2 | ||
|
|
01ca1b3039 | ||
|
|
1225ecea81 | ||
|
|
5ede33de4a | ||
|
|
dda18dd483 | ||
|
|
c5748d4205 | ||
|
|
90dfe0315d | ||
|
|
ed1a665bde | ||
|
|
5b78796da8 | ||
|
|
4512f641ef | ||
|
|
0120d111e7 | ||
|
|
c55b1cffa2 | ||
|
|
028a6ee2bd | ||
|
|
4bc3398f5d | ||
|
|
8a8f6a7410 | ||
|
|
37023f1bd0 | ||
|
|
2b8a6ec051 | ||
|
|
5f02a7ac47 | ||
|
|
1229e7e0c4 | ||
|
|
d962604971 | ||
|
|
24979f52fb | ||
|
|
839f948edf | ||
|
|
8ee3c62e5b | ||
|
|
d372a1f4be | ||
|
|
4eb7f4eb37 | ||
|
|
ad398361b9 | ||
|
|
e154aa08b2 | ||
|
|
545897c118 | ||
|
|
7c15666a60 | ||
|
|
777d546821 | ||
|
|
d61c3a3ad2 | ||
|
|
685245d9b3 | ||
|
|
8e058cd202 | ||
|
|
a8c553675e | ||
|
|
74ab7fd704 | ||
|
|
853be657da | ||
|
|
2b1e29209b | ||
|
|
b3ddd97cee | ||
|
|
25ee75a60e | ||
|
|
93eb6b79e0 | ||
|
|
e6252c214d | ||
|
|
2b877cca77 | ||
|
|
041187ecb6 | ||
|
|
8c8fe3c97d | ||
|
|
cb14a008b1 | ||
|
|
6bf4eb9714 | ||
|
|
1b87f11b39 | ||
|
|
2af01428e1 | ||
|
|
44c33b70fb | ||
|
|
8db86fad4b | ||
|
|
44c10a3062 | ||
|
|
ea432f2eba | ||
|
|
5a618f8bfe | ||
|
|
2615c86ec3 | ||
|
|
5070efe9e7 | ||
|
|
248341bbe5 | ||
|
|
0f8d557305 | ||
|
|
1c3a39b0a1 | ||
|
|
a1b29c1712 | ||
|
|
4beb426b32 | ||
|
|
5867cd7d31 | ||
|
|
e1555397a3 | ||
|
|
78f030149a | ||
|
|
80a8ae1d86 | ||
|
|
c72c8bf51f | ||
|
|
665fa360ef | ||
|
|
9b45971ec4 | ||
|
|
592015800a | ||
|
|
b5e6e0a32b | ||
|
|
7319da8a74 | ||
|
|
58d99a194d | ||
|
|
c22c7f9066 |
+50
-31
@@ -1,33 +1,52 @@
|
||||
# Version updates are monthly and grouped into one PR per ecosystem.
|
||||
# Security updates are separate and run whenever an advisory is published.
|
||||
version: 2
|
||||
|
||||
# Dependency updates land on `dev`, never on `main`.
|
||||
#
|
||||
# `main` here is a RELEASE POINTER that freemkv's release-orchestrate.yml
|
||||
# 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: monthly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- '*'
|
||||
ignore:
|
||||
- dependency-name: freemkv-unlock
|
||||
- dependency-name: libfreemkv
|
||||
- dependency-name: freemkv-keysources
|
||||
- dependency-name: freemkv-i18n
|
||||
- dependency-name: freemkv-engine
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- '*'
|
||||
ignore:
|
||||
- dependency-name: dtolnay/rust-toolchain
|
||||
- dependency-name: freemkv/.github*
|
||||
- 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.
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
ignore:
|
||||
# The freemkv crates depend on each other by GIT TAG, re-pinned by
|
||||
# release-orchestrate.yml in the release commit. A bot bump would fight
|
||||
# the release cascade and could pin a tag that does not exist yet.
|
||||
- dependency-name: freemkv-unlock
|
||||
- dependency-name: libfreemkv
|
||||
- dependency-name: freemkv-keysources
|
||||
- dependency-name: freemkv-i18n
|
||||
- dependency-name: freemkv-engine
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
actions:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
ignore:
|
||||
# Versioned by the Rust release it installs; CI picks the toolchain via
|
||||
# `toolchain:` on @master/@stable. Dependabot proposes Rust versions that
|
||||
# do not exist. Toolchain bumps are a deliberate all-repo change.
|
||||
- dependency-name: dtolnay/rust-toolchain
|
||||
# The org's own reusable workflows/actions. @v1 and @v2 are independent
|
||||
# moving tags (v2 is only the comment guard), not semver releases: a
|
||||
# "bump" from v1 to v2 would move a caller onto an OLDER guard.
|
||||
- dependency-name: "freemkv/.github*"
|
||||
|
||||
+1
-15
@@ -1,14 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [1.8.1] — 2026-10-07
|
||||
|
||||
- `DriveInfo::display_name` is the name the OS shows users: the drive letter (`E:`) on Windows, `/dev/srN` on Linux and `diskN` on macOS while a disc is mounted; it equals `path` when the OS has no name. `path` remains the selector to open. Windows lists lettered drives in letter order, then unlettered drives by `CdRomN`.
|
||||
- Drive listing enumerates instead of guessing device numbers. Windows lists every `CdRomN` from `QueryDosDeviceW` plus optical drive letters (previously only `CdRom0`–`15`), retries INQUIRY with 36 bytes for strict USB bridges, and logs skipped devices. Linux also lists optical `sr` devices that have no `sg` node (the `sg` module is not loaded) and, without sysfs, reads `/dev` instead of probing `sg0`–`15`. macOS no longer stops at 8 drives.
|
||||
- macOS CLI/GUI/server transport diagnostics include selected IOKit service, plug-in metadata, interface initialization stages, exclusive-access attempts and cleanup results.
|
||||
|
||||
- macOS optical-drive enumeration keeps the same IOKit service selector with or without mounted media, so exclusive access during a rip does not turn one drive into two identities.
|
||||
|
||||
## [1.8.0] — 2026-10-05
|
||||
## [1.8.0] — Unreleased
|
||||
|
||||
### Breaking
|
||||
|
||||
@@ -62,10 +54,6 @@
|
||||
|
||||
### Added
|
||||
|
||||
- DVD: the region mask from the VMG (`DvdInfo.region_mask`, `DiscRegion::Unknown`), stream labels from the audio and subpicture code-extension byte, chapter names from the TXTDT text data, and a probe of each title's subpictures for forced-display (`FSTA_DSP`) commands.
|
||||
- HD DVD: AACS decryption per pack, keyed by each EVOBU's CPI; AACS-wrapped playlists and features split across `PEVOB_n` files.
|
||||
- AACS: a Class II MKB is reported as an unsupported class (E7109) instead of as a missing key.
|
||||
- UHD: HDR10 mastering metadata is kept when content light level is absent; `hdr_plus_flag` is read from the HEVC stream attributes; the Dolby Vision enhancement-layer track's `dvcC` carries `bl_present=0`.
|
||||
- `mpg://` as a source and a destination: a DVD-Video / MPEG-2 program stream file reads through the decryption stage and the DVD sector pipeline (a scrambled `.vob` is cracked once, `--raw` keeps the ciphertext; a clear MPEG-1 or stuffed MPEG-2 stream is no longer read as scrambled, and a scrambled pack with no key fails E7023). `MpgSink` writes an H.222.0 program stream with P-STD pack timing, system header and PSM; `Error::MpgNoVideoTrack` (E9074), `Error::MpgUnpacketized` (E9075). `FileSectorSource::open_padded` zero-pads a partial tail sector.
|
||||
- `keys` module: `KeyRing::acquire(evidence, sampler, sources, opts, ctx)` (`KeyScope::{None, Titles, WholeDisc}`, `AcquireOptions`, `KeyResolution`) asks the key sources once before any output and proves each held key against the disc's ciphertext per stream file; keys and the Volume ID stay in memory (`Debug` redacts). Also `title_reader`, `whole_disc_reader`, `decrypt_status`, `check_decryptable`, `KeySetStatus`, `ForensicState`, `DecryptStatus`, `ProofCache`. A piece that cannot be proven up front is proven on first read; a unit no held key opens stops a title rip with E7022; in an image or folder it is blanked, warned and counted. `ResolveCtx::halt()` / `progress()` give sources the Stop token; key-service retries back off 1 s to 8 s. `MediaKeyVariantError::code()` returns E7100-E7108.
|
||||
- `disc_root_of(path)`: the AACS disc folder a loose Blu-ray clip sits in (`<root>/BDMV/STREAM/x.m2ts` with `<root>/AACS`), so a caller can resolve the clip's keys on `dir://<root>`.
|
||||
@@ -85,7 +73,6 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- **DVD titles are `ContentFormat::DvdPs`**, set only by the DVD scan; `MpegPs` now means HD DVD or a plain program-stream file. DVD-only steps (cell filter and clock joins, CSS title-key crack, forced-subtitle probe) no longer run on HD DVD or `mpg://`. The `json://` `format` field reads `"DvdPs"` for DVD titles.
|
||||
- **AACS damage policy (E7013):** a flagged AACS unit no key opens (no TS sync, zero-filled or garbled head, lone FMTS verify failure, off-grid) is zero-filled, warned and counted on every path (ISO mux, live mux, extract, whole-disc reader) instead of failing with E7013, with an end-of-rip "N damaged AACS units blanked" log. A wrong key still fails: E7013 at resolve (or when two or more FMTS units fail under one key with none verifying), E7022 on arrival in a title rip (an image or folder blanks the unit instead).
|
||||
- A BD-TS mux with no AACS key set fails E7022 on the first AACS-flagged unit instead of writing ciphertext; an AACS HD DVD session with no set fails `NoDiscKey`. A key-set stop (E7022) is never shrunk, recovered or skipped.
|
||||
- A Stop during a mux is always `completed = false, halted = true` (`MuxOutcome::halted`), whether it lands during the open or the pump: `mux_with_keys` no longer returns `Err(Halted)` from a stopped open. A Stop reaches `iso://` and `dir://` sources inside a blocked read, the image scan and the CSS crack (it was seen only between frames), and `iso://`, `dir://` and `mpg://` URL muxes report read progress like the disc arms. A stopped read is a stop, not a skipped sector. A read error repeats on every later `read()` of the highway stream. A halted `PrefetchedSectorSource` close is `Err(Halted)`, not EOF.
|
||||
@@ -111,7 +98,6 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- A program-stream PES that opens inside one picture times the next picture commencing in it (ISO/IEC 13818-1 §2.4.3.7), not the one it opens in. A program stream packing pictures back to back gave each picture its successor's PTS, so MPEG-2 GOPs re-locked a frame off and an open GOP's leading B-pictures were dropped as after a join. DVD and HD DVD titles, which open a PES at each timed picture, are unchanged.
|
||||
- `m2ts://` output of a multi-clip title rides the clip-join timeline like the MKV, MP4 and MPG sinks: the PTS no longer restarts at each join, and reordered video keeps its DTS after the first join instead of falling back to PTS only.
|
||||
- An image title whose extent ends off the 3-sector AACS unit grid (an HD DVD `.EVO`) no longer fails E9030 at that extent's end: the sub-unit tail is read, passed when clear and refused (E7013) when it is part of an encrypted unit.
|
||||
- Every AACS format takes one encryption decision in `KeyRing::acquire`: content its own detector reads in the clear needs no key and asks no source, whatever key files the disc declares (an HD DVD copy in the clear declaring several keys, and an FMTS disc whose forensic segments are also clear, no longer ask for or refuse on keys); encrypted content is keyed by the format's rules and refused (E7022 / E7032) when no source has the key.
|
||||
|
||||
Generated
+2
-9
@@ -221,13 +221,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.8.0"
|
||||
version = "1.7.7"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"pioneer-optical",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -318,7 +317,7 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "libfreemkv"
|
||||
version = "1.8.0"
|
||||
version = "1.7.7"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -431,12 +430,6 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pioneer-optical"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf5074a27cc39fb664d0b994814ffe8530e3e8590aff0d104723332dbcb1abc4"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.8.1"
|
||||
version = "1.7.7"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
license = "MIT"
|
||||
@@ -66,7 +66,7 @@ sha2 = { version = "0.11", optional = true }
|
||||
aes = { version = "0.9", optional = true }
|
||||
# Resolved by the git-tag [patch.crates-io] below (re-pinned by
|
||||
# release-orchestrate.yml); local dev patches it to ../freemkv-unlock.
|
||||
freemkv-unlock = { version = "1.8", optional = true }
|
||||
freemkv-unlock = { version = "1.7", optional = true }
|
||||
zip = { version = "8", default-features = false, features = ["deflate"], optional = true }
|
||||
# zlib for Matroska ContentCompression (mkvmerge compresses S_VOBSUB by default); the
|
||||
# same backend `zip` already pulls in.
|
||||
@@ -146,7 +146,7 @@ harness = false
|
||||
# freemkv-unlock is off crates.io: resolve it from its GIT TAG. Consumers carry
|
||||
# the same patch; a gitignored .cargo/config.toml overrides it to a local path.
|
||||
[patch.crates-io]
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.8.1" }
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.7.7" }
|
||||
|
||||
[lints.rust]
|
||||
# `loom` is set by RUSTFLAGS for the model checker above, never by a feature.
|
||||
|
||||
+1458
-26
File diff suppressed because it is too large
Load Diff
@@ -1,157 +0,0 @@
|
||||
use super::*;
|
||||
use crate::spec::keys::*;
|
||||
use aes::cipher::{BlockCipherDecrypt, BlockCipherEncrypt};
|
||||
|
||||
const KCU: [u8; 16] = [
|
||||
0x6B, 0x1D, 0xE2, 0x04, 0x93, 0x5A, 0xC7, 0x38, 0x0F, 0xB4, 0x71, 0x2E, 0xD9, 0x86, 0x45, 0x10,
|
||||
];
|
||||
|
||||
// Ciphertext-looking unit with no all-zero packet and a flagged, varied seed.
|
||||
fn unit(salt: u8) -> Vec<u8> {
|
||||
let mut u: Vec<u8> = (0..ALIGNED_UNIT_LEN)
|
||||
.map(|i| (i as u8).wrapping_mul(31).wrapping_add(salt) | 1)
|
||||
.collect();
|
||||
u[0] |= 0xC0;
|
||||
u
|
||||
}
|
||||
|
||||
fn aes_e(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] {
|
||||
let mut b: Array<u8, _> = (*block).into();
|
||||
Aes128::new(&(*key).into()).encrypt_block(&mut b);
|
||||
b.into()
|
||||
}
|
||||
|
||||
// KS-3 / KS-19 by hand: AES-128-CBC decrypt of 16..6144 under iv0, with the
|
||||
// KS-4 / KS-23 Block Key = AES-128E(Kcu, seed) ⊕ seed.
|
||||
fn reference_decrypt(unit: &[u8], kcu: &[u8; 16]) -> Vec<u8> {
|
||||
let seed: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let mut bk = aes_e(kcu, &seed);
|
||||
bk.iter_mut().zip(seed).for_each(|(b, s)| *b ^= s);
|
||||
let cipher = Aes128::new(&bk.into());
|
||||
let mut prev = AACS_IV;
|
||||
let mut out = unit.to_vec();
|
||||
for (o, c) in out[16..].chunks_mut(16).zip(unit[16..].chunks(16)) {
|
||||
let mut b: Array<u8, _> = <[u8; 16]>::try_from(c).unwrap().into();
|
||||
cipher.decrypt_block(&mut b);
|
||||
o.iter_mut()
|
||||
.zip(b.iter().zip(prev))
|
||||
.for_each(|(o, (p, v))| *o = p ^ v);
|
||||
prev = c.try_into().unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// per spec; do not change without a spec citation — KS-3, KS-4 [BD] §3.10.1: "The
|
||||
/// first 16 bytes of each Aligned Unit is used as the seed"; "The final 6128 bytes … encrypted".
|
||||
#[test]
|
||||
fn seed_is_first_16_bytes_and_stays_clear() {
|
||||
assert!(
|
||||
KS_4_SEED
|
||||
.text
|
||||
.starts_with("The first 16 bytes of each Aligned Unit")
|
||||
);
|
||||
assert!(
|
||||
KS_3_CBC_PER_UNIT
|
||||
.text
|
||||
.starts_with("The final 6128 bytes of each Aligned")
|
||||
);
|
||||
let enc = unit(7);
|
||||
let mut dec = enc.clone();
|
||||
decrypt_unit(&mut dec, &KCU);
|
||||
assert_eq!(dec[..16], enc[..16], "the seed is never decrypted");
|
||||
let changed = dec[16..]
|
||||
.chunks(16)
|
||||
.zip(enc[16..].chunks(16))
|
||||
.all(|(d, e)| d != e);
|
||||
assert!(changed, "every block of the final 6128 bytes is decrypted");
|
||||
}
|
||||
|
||||
/// per spec; do not change without a spec citation proving otherwise — KS-3 [BD]
|
||||
/// §3.10.1: "A new CBC cipher chain is started for each Aligned Unit": the mapped decrypt
|
||||
/// of a multi-unit buffer, in either order, never chains one unit into the next.
|
||||
#[test]
|
||||
fn cbc_chain_restarts_every_unit() {
|
||||
use crate::decrypt::{AacsKeyMap, DecryptKeys, Phase, decrypt_sectors_mapped};
|
||||
assert!(
|
||||
KS_3_CBC_PER_UNIT
|
||||
.text
|
||||
.contains("new CBC cipher chain is started for each")
|
||||
);
|
||||
// Encrypted TS units (sync at +4, CPI 11₂ per packet) so the mapped path decrypts.
|
||||
let plain: Vec<Vec<u8>> = (0..2)
|
||||
.map(|i| {
|
||||
let mut u = unit(40 + i);
|
||||
for p in u.chunks_mut(crate::consts::BD_SOURCE_PACKET_BYTES) {
|
||||
p[0] |= 0xC0;
|
||||
p[4] = TS_SYNC;
|
||||
}
|
||||
u
|
||||
})
|
||||
.collect();
|
||||
let enc: Vec<Vec<u8>> = plain
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut u = p.clone();
|
||||
assert!(encrypt_unit(&mut u, &KCU));
|
||||
u
|
||||
})
|
||||
.collect();
|
||||
let keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, KCU)],
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let map = AacsKeyMap::from_ranges_phased(vec![(0, 2 * ALIGNED_UNIT_SECTORS, 0, Phase::All)]);
|
||||
for order in [[0, 1], [1, 0]] {
|
||||
let mut buf: Vec<u8> = order.iter().flat_map(|&i| enc[i].clone()).collect();
|
||||
decrypt_sectors_mapped(&mut buf, &keys, 0, &map).expect("both units keyed");
|
||||
// KS-5: a decrypted unit's CPI reads 00₂ (KU design §5.4); the chain rule is KS-3.
|
||||
let want: Vec<u8> = order
|
||||
.iter()
|
||||
.flat_map(|&i| super::cpi_cleared(plain[i].clone()))
|
||||
.collect();
|
||||
assert!(
|
||||
buf == want,
|
||||
"order {order:?}: each unit decrypts on its own chain"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// per spec; do not change without a spec citation — KS-4 [BD] §3.10.1 (Figure 3-8),
|
||||
/// corroborated by KS-23 libaacs: Block Key = AES-128E(Kcu, seed) ⊕ seed.
|
||||
#[test]
|
||||
fn block_key_is_aes128e_of_seed_xor_seed() {
|
||||
assert!(
|
||||
KS_23_LIBAACS_BLOCK_KEY
|
||||
.text
|
||||
.contains("key[a] ^= out_buf[a];")
|
||||
);
|
||||
let plain = unit(3);
|
||||
let seed: [u8; 16] = plain[..16].try_into().unwrap();
|
||||
let mut bk = aes_e(&KCU, &seed);
|
||||
bk.iter_mut().zip(seed).for_each(|(b, s)| *b ^= s);
|
||||
// First CBC block under the independent Block Key and iv0.
|
||||
let mut x: [u8; 16] = plain[16..32].try_into().unwrap();
|
||||
x.iter_mut().zip(AACS_IV).for_each(|(b, v)| *b ^= v);
|
||||
let mut u = plain.clone();
|
||||
assert!(encrypt_unit(&mut u, &KCU));
|
||||
assert_eq!(
|
||||
u[16..32],
|
||||
aes_e(&bk, &x),
|
||||
"encrypt uses AES-128E(Kcu, seed) ⊕ seed"
|
||||
);
|
||||
decrypt_unit(&mut u, &KCU);
|
||||
assert_eq!(u, plain);
|
||||
}
|
||||
|
||||
/// per spec; do not change without a spec citation — KS-3 [BD] §3.10.1 "AES-128CBCE";
|
||||
/// KS-19 [CM] §2.1.2 iv0: the unit decrypt equals an independent AES-128-CBC.
|
||||
#[test]
|
||||
fn bd_aes_primitives_match_independent_impl() {
|
||||
assert!(KS_3_CBC_PER_UNIT.text.contains("Block Key and AES-128CBCE"));
|
||||
for salt in [0u8, 99, 200] {
|
||||
let enc = unit(salt);
|
||||
let mut got = enc.clone();
|
||||
decrypt_unit(&mut got, &KCU);
|
||||
assert_eq!(got, reference_decrypt(&enc, &KCU), "salt {salt}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+100
-4
@@ -176,9 +176,105 @@ pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "crypto_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// s0 transcribed independently from `[C]` §3.2.2, not read from AESG3_SEED, so this can't
|
||||
// assert the production constant against itself.
|
||||
const S0: [u8; 16] = [
|
||||
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B,
|
||||
0xD9,
|
||||
];
|
||||
|
||||
/// An arbitrary non-degenerate key. Nothing about it is secret or special;
|
||||
/// the AES-G3 relation holds for every key, and a constant-returning body
|
||||
/// cannot satisfy it for any.
|
||||
const K: [u8; 16] = [
|
||||
0x0F, 0x1E, 0x2D, 0x3C, 0x4B, 0x5A, 0x69, 0x78, 0x87, 0x96, 0xA5, 0xB4, 0xC3, 0xD2, 0xE1,
|
||||
0xF0,
|
||||
];
|
||||
|
||||
// aesg3 is the SD-tree node function; a wrong `^` or a constant body would derive a
|
||||
// plausible-but-wrong Processing Key. Pinned via the spec relation.
|
||||
#[test]
|
||||
fn aesg3_inverts_to_the_spec_seed_under_aes_encrypt() {
|
||||
for inc in 0u8..=2 {
|
||||
let mut seed = S0;
|
||||
seed[15] = seed[15].wrapping_add(inc);
|
||||
|
||||
let out = aesg3(&K, inc);
|
||||
|
||||
// out == AES-128D(K, seed) XOR seed, so out XOR seed is the raw
|
||||
// decryption and re-encrypting it must land back on the seed.
|
||||
let mut pre = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
pre[i] = out[i] ^ seed[i];
|
||||
}
|
||||
assert_eq!(
|
||||
aes_ecb_encrypt(&K, &pre),
|
||||
seed,
|
||||
"AES-G3 inc={inc} must satisfy out = AES-128D(k, s0+inc) XOR (s0+inc)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The Triple Generator's three outputs are one node's two children plus its Processing Key;
|
||||
// if `inc` were ignored a descent would revisit its own parent.
|
||||
#[test]
|
||||
fn aesg3_yields_three_distinct_subkeys_for_the_three_increments() {
|
||||
let left = aesg3(&K, 0);
|
||||
let pk = aesg3(&K, 1);
|
||||
let right = aesg3(&K, 2);
|
||||
assert_ne!(left, pk, "left child and Processing Key must differ");
|
||||
assert_ne!(pk, right, "Processing Key and right child must differ");
|
||||
assert_ne!(left, right, "left and right children must differ");
|
||||
}
|
||||
|
||||
/// Distinct parent keys must yield distinct subkeys — the tree would
|
||||
/// collapse otherwise.
|
||||
#[test]
|
||||
fn aesg3_separates_distinct_parent_keys() {
|
||||
let mut other = K;
|
||||
other[0] ^= 0x01;
|
||||
assert_ne!(aesg3(&K, 1), aesg3(&other, 1));
|
||||
}
|
||||
|
||||
// The batched CBC decrypt inverts the (block-at-a-time) CBC encrypt at every
|
||||
// batch-boundary shape: one block, exactly one batch, a batch plus one, and a
|
||||
// whole de-bussed sector body.
|
||||
#[test]
|
||||
fn batched_cbc_decrypt_inverts_cbc_encrypt_across_batch_boundaries() {
|
||||
for blocks in [1usize, 31, 32, 33, 64, 383] {
|
||||
let plain: Vec<u8> = (0..blocks * 16).map(|i| (i * 7 + 3) as u8).collect();
|
||||
let mut data = plain.clone();
|
||||
aes_cbc_encrypt(&K, &mut data);
|
||||
assert_ne!(data, plain);
|
||||
aes_cbc_decrypt(&K, &mut data);
|
||||
assert_eq!(data, plain, "{blocks} blocks must round-trip");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "crypto_spec_guards_tests.rs"]
|
||||
mod spec_guards;
|
||||
mod spec_guards {
|
||||
use super::AACS_IV;
|
||||
use crate::spec::keys::KS_19_IV0;
|
||||
|
||||
/// per spec; do not change without a spec citation — KS-19 [CM] §2.1.2: "iv0, which
|
||||
/// is: 0BA0F8DDFEA61FB3D8DF9F566A050F78₁₆" (the value is read from the quote).
|
||||
#[test]
|
||||
fn cbc_iv_is_iv0() {
|
||||
let hex = KS_19_IV0
|
||||
.text
|
||||
.rsplit(' ')
|
||||
.next()
|
||||
.and_then(|h| h.strip_suffix("₁₆"))
|
||||
.expect("KS-19 ends with the iv0 value");
|
||||
let iv0: Vec<u8> = (0..32)
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
|
||||
.collect();
|
||||
assert_eq!(hex.len(), 32);
|
||||
assert_eq!(AACS_IV.to_vec(), iv0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
use super::AACS_IV;
|
||||
use crate::spec::keys::KS_19_IV0;
|
||||
|
||||
/// per spec; do not change without a spec citation — KS-19 [CM] §2.1.2: "iv0, which
|
||||
/// is: 0BA0F8DDFEA61FB3D8DF9F566A050F78₁₆" (the value is read from the quote).
|
||||
#[test]
|
||||
fn cbc_iv_is_iv0() {
|
||||
let hex = KS_19_IV0
|
||||
.text
|
||||
.rsplit(' ')
|
||||
.next()
|
||||
.and_then(|h| h.strip_suffix("₁₆"))
|
||||
.expect("KS-19 ends with the iv0 value");
|
||||
let iv0: Vec<u8> = (0..32)
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
|
||||
.collect();
|
||||
assert_eq!(hex.len(), 32);
|
||||
assert_eq!(AACS_IV.to_vec(), iv0);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// s0 transcribed independently from `[C]` §3.2.2, not read from AESG3_SEED, so this can't
|
||||
// assert the production constant against itself.
|
||||
const S0: [u8; 16] = [
|
||||
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
|
||||
];
|
||||
|
||||
/// An arbitrary non-degenerate key. Nothing about it is secret or special;
|
||||
/// the AES-G3 relation holds for every key, and a constant-returning body
|
||||
/// cannot satisfy it for any.
|
||||
const K: [u8; 16] = [
|
||||
0x0F, 0x1E, 0x2D, 0x3C, 0x4B, 0x5A, 0x69, 0x78, 0x87, 0x96, 0xA5, 0xB4, 0xC3, 0xD2, 0xE1, 0xF0,
|
||||
];
|
||||
|
||||
// aesg3 is the SD-tree node function; a wrong `^` or a constant body would derive a
|
||||
// plausible-but-wrong Processing Key. Pinned via the spec relation.
|
||||
#[test]
|
||||
fn aesg3_inverts_to_the_spec_seed_under_aes_encrypt() {
|
||||
for inc in 0u8..=2 {
|
||||
let mut seed = S0;
|
||||
seed[15] = seed[15].wrapping_add(inc);
|
||||
|
||||
let out = aesg3(&K, inc);
|
||||
|
||||
// out == AES-128D(K, seed) XOR seed, so out XOR seed is the raw
|
||||
// decryption and re-encrypting it must land back on the seed.
|
||||
let mut pre = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
pre[i] = out[i] ^ seed[i];
|
||||
}
|
||||
assert_eq!(
|
||||
aes_ecb_encrypt(&K, &pre),
|
||||
seed,
|
||||
"AES-G3 inc={inc} must satisfy out = AES-128D(k, s0+inc) XOR (s0+inc)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The Triple Generator's three outputs are one node's two children plus its Processing Key;
|
||||
// if `inc` were ignored a descent would revisit its own parent.
|
||||
#[test]
|
||||
fn aesg3_yields_three_distinct_subkeys_for_the_three_increments() {
|
||||
let left = aesg3(&K, 0);
|
||||
let pk = aesg3(&K, 1);
|
||||
let right = aesg3(&K, 2);
|
||||
assert_ne!(left, pk, "left child and Processing Key must differ");
|
||||
assert_ne!(pk, right, "Processing Key and right child must differ");
|
||||
assert_ne!(left, right, "left and right children must differ");
|
||||
}
|
||||
|
||||
/// Distinct parent keys must yield distinct subkeys — the tree would
|
||||
/// collapse otherwise.
|
||||
#[test]
|
||||
fn aesg3_separates_distinct_parent_keys() {
|
||||
let mut other = K;
|
||||
other[0] ^= 0x01;
|
||||
assert_ne!(aesg3(&K, 1), aesg3(&other, 1));
|
||||
}
|
||||
|
||||
// The batched CBC decrypt inverts the (block-at-a-time) CBC encrypt at every
|
||||
// batch-boundary shape: one block, exactly one batch, a batch plus one, and a
|
||||
// whole de-bussed sector body.
|
||||
#[test]
|
||||
fn batched_cbc_decrypt_inverts_cbc_encrypt_across_batch_boundaries() {
|
||||
for blocks in [1usize, 31, 32, 33, 64, 383] {
|
||||
let plain: Vec<u8> = (0..blocks * 16).map(|i| (i * 7 + 3) as u8).collect();
|
||||
let mut data = plain.clone();
|
||||
aes_cbc_encrypt(&K, &mut data);
|
||||
assert_ne!(data, plain);
|
||||
aes_cbc_decrypt(&K, &mut data);
|
||||
assert_eq!(data, plain, "{blocks} blocks must round-trip");
|
||||
}
|
||||
}
|
||||
+1573
-46
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,531 +0,0 @@
|
||||
use super::*;
|
||||
use crate::aacs::crypto::aes_ecb_encrypt;
|
||||
|
||||
// A Class II MKB is reported as an unsupported class (typed, E7109), not as "no key
|
||||
// matched": derivation stops before reading its tables. A pre-recorded MKB passes.
|
||||
#[test]
|
||||
fn a_class_ii_mkb_is_an_explicit_unsupported_class() {
|
||||
let mkb = |t: u32| {
|
||||
let mut m = vec![0x10, 0x00, 0x00, 0x0C];
|
||||
m.extend_from_slice(&t.to_be_bytes());
|
||||
m.extend_from_slice(&[0, 0, 0, 1]);
|
||||
m
|
||||
};
|
||||
let class2 = mkb(MKB_TYPE_10_CLASS_II);
|
||||
let err = check_mkb_class(&class2).expect_err("class II is unsupported");
|
||||
assert_eq!(err, MkbClassError::Unsupported(MkbType::ClassII));
|
||||
assert_eq!(err.code(), crate::error::E_MKB_CLASS_UNSUPPORTED);
|
||||
assert!(MkbTables::parse(&class2).is_none());
|
||||
assert_eq!(derive_media_key_from_pk(&class2, &[[0u8; 16]]), None);
|
||||
assert_eq!(check_mkb_class(&mkb(MKB_TYPE_4_PRERECORDED)), Ok(()));
|
||||
assert_eq!(check_mkb_class(&mkb(MKB_20_CATEGORY_C)), Ok(()));
|
||||
assert_eq!(check_mkb_class(&[]), Ok(()));
|
||||
}
|
||||
|
||||
// km_verifies gates every candidate Media Key; mutation testing found `-> true` surviving
|
||||
// all 2,556 tests, i.e. unverified verification.
|
||||
#[test]
|
||||
fn km_verifies_accepts_only_the_key_its_record_was_built_for() {
|
||||
use crate::aacs::mkb::mkb_find_mk_dv;
|
||||
|
||||
let km: [u8; 16] = [
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF,
|
||||
];
|
||||
|
||||
let mut plain = [0u8; 16];
|
||||
plain[..8].copy_from_slice(&VERIFY_MAGIC);
|
||||
plain[8..].copy_from_slice(&[0xA5; 8]);
|
||||
let mk_dv = aes_ecb_encrypt(&km, &plain);
|
||||
|
||||
let mut mkb = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
|
||||
];
|
||||
mkb.extend_from_slice(&[0x81, 0x00, 0x00, 0x18]);
|
||||
mkb.extend_from_slice(&mk_dv);
|
||||
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
|
||||
assert_eq!(
|
||||
mkb_find_mk_dv(&mkb),
|
||||
Some(mk_dv),
|
||||
"fixture malformed — the verify record is not being found at all"
|
||||
);
|
||||
|
||||
assert!(
|
||||
probe::km_verifies(&mkb, &km),
|
||||
"the key the record was built for must verify"
|
||||
);
|
||||
|
||||
// The half that kills the `-> true` mutant. One flipped bit is the
|
||||
// strongest form of wrong key: a near-miss, not a random one.
|
||||
let mut wrong = km;
|
||||
wrong[15] ^= 0x01;
|
||||
assert!(
|
||||
!probe::km_verifies(&mkb, &wrong),
|
||||
"a key differing by ONE BIT must not verify; if it did, the MK-pool \
|
||||
brute force would accept whichever candidate it happened to try first"
|
||||
);
|
||||
|
||||
// No verify record means UNVERIFIABLE, which is not the same as verified.
|
||||
let bare = vec![0x10, 0x00, 0x00, 0x0C, 0, 0, 0, 0, 0, 0, 0, 1];
|
||||
assert!(
|
||||
!probe::km_verifies(&bare, &km),
|
||||
"an MKB with no verify record must not default to yes"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ResolvedChain.unit_keys` holds raw title-key bytes (the other rungs are
|
||||
/// self-redacting `types` newtypes). `Debug` must not leak the title keys.
|
||||
#[test]
|
||||
fn resolved_chain_debug_is_redacted() {
|
||||
let c = ResolvedChain {
|
||||
unit_keys: vec![(1, [0xD5; 16])],
|
||||
vuk: None,
|
||||
mk: None,
|
||||
pk: None,
|
||||
dk: None,
|
||||
};
|
||||
let dbg = format!("{c:?}");
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"ResolvedChain leaked unit keys: {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("unit_keys_len"),
|
||||
"ResolvedChain missing redaction: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Minimal AACS-1.0 (48-byte stride) `Unit_Key_RO.inf` with `n` encrypted
|
||||
/// unit keys — `parse_unit_key_ro` numbers CPS units 1..=n.
|
||||
fn synth_inf(encs: &[[u8; 16]]) -> Vec<u8> {
|
||||
let uk_pos = 32usize;
|
||||
let stride = 48usize;
|
||||
let n = encs.len();
|
||||
let total = uk_pos + 48 + n.saturating_sub(1) * stride + 16;
|
||||
let mut inf = vec![0u8; total.max(20)];
|
||||
inf[..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
|
||||
inf[uk_pos..uk_pos + 2].copy_from_slice(&(n as u16).to_be_bytes());
|
||||
for (i, k) in encs.iter().enumerate() {
|
||||
let o = uk_pos + 48 + i * stride;
|
||||
inf[o..o + 16].copy_from_slice(k);
|
||||
}
|
||||
inf
|
||||
}
|
||||
|
||||
/// A VUK candidate boils to ALL the disc's unit keys, each paired with its
|
||||
/// declared CPS-unit number, and each key equals the VUK-decrypt of its slot.
|
||||
#[test]
|
||||
fn resolve_candidate_vuk_returns_all_cps_units() {
|
||||
let vuk = Vuk([0x33u8; 16]);
|
||||
let encs = [[0x11u8; 16], [0x22u8; 16], [0x44u8; 16]];
|
||||
let inf = synth_inf(&encs);
|
||||
let r = resolve_candidate(&KeyCandidate::Vuk(vuk), &[], &inf, None, AacsVersion::V10)
|
||||
.expect("vuk derives");
|
||||
let cps: Vec<u32> = r.unit_keys.iter().map(|(c, _)| *c).collect();
|
||||
assert_eq!(
|
||||
cps,
|
||||
vec![1, 2, 3],
|
||||
"every CPS unit surfaced, numbered from the inf"
|
||||
);
|
||||
for ((_, key), enc) in r.unit_keys.iter().zip(encs.iter()) {
|
||||
assert_eq!(
|
||||
*key,
|
||||
decrypt_unit_key(&vuk.0, enc),
|
||||
"key = VUK-decrypt of its slot"
|
||||
);
|
||||
}
|
||||
assert_eq!(r.vuk, Some(vuk));
|
||||
assert!(r.mk.is_none() && r.pk.is_none() && r.dk.is_none());
|
||||
}
|
||||
|
||||
/// A bare UK candidate is terminal — it returns itself keyed by its own idx.
|
||||
#[test]
|
||||
fn resolve_candidate_uk_is_itself() {
|
||||
let uk = UnitKey::new(2, [0x9u8; 16]);
|
||||
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None, AacsVersion::V10)
|
||||
.expect("uk is terminal");
|
||||
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
|
||||
assert!(r.vuk.is_none() && r.mk.is_none());
|
||||
}
|
||||
|
||||
/// A UK candidate's positional idx is reported as the declared CPS-unit number.
|
||||
#[test]
|
||||
fn resolve_candidate_uk_reports_the_declared_cps_unit_number() {
|
||||
let inf = synth_inf(&[[0x11u8; 16], [0x22u8; 16]]);
|
||||
let uk = UnitKey::new(1, [0x9u8; 16]);
|
||||
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &inf, None, AacsVersion::V10)
|
||||
.expect("terminal");
|
||||
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
|
||||
}
|
||||
|
||||
/// The declared number comes from the file's own numbering (an HD DVD VTKF with a gap), and
|
||||
/// falls back to the position when the file does not parse or lacks that slot.
|
||||
#[test]
|
||||
fn resolve_candidate_uk_declared_number_follows_the_file_with_positional_fallback() {
|
||||
let mut vtkf = vec![0u8; 0x80 + 64 * 0x24];
|
||||
vtkf[..12].copy_from_slice(b"DVD_HD_V_TKF");
|
||||
// Slots 2 and 5 (1-based) are present; slot 1 is a gap.
|
||||
for slot in [1usize, 4] {
|
||||
vtkf[0x80 + slot * 0x24] = 0x80;
|
||||
}
|
||||
let at = |idx: u32| {
|
||||
let uk = UnitKey::new(idx, [0x9u8; 16]);
|
||||
resolve_candidate(&KeyCandidate::Uk(uk), &[], &vtkf, None, AacsVersion::V10)
|
||||
.expect("terminal")
|
||||
.unit_keys[0]
|
||||
.0
|
||||
};
|
||||
assert_eq!(at(0), 2);
|
||||
assert_eq!(at(1), 5, "the file's number, not idx + 1");
|
||||
assert_eq!(at(7), 7, "no such slot: the position");
|
||||
let uk = UnitKey::new(3, [0x9u8; 16]);
|
||||
let r = resolve_candidate(
|
||||
&KeyCandidate::Uk(uk),
|
||||
&[],
|
||||
&[0u8; 4],
|
||||
None,
|
||||
AacsVersion::V10,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.unit_keys[0].0, 3, "unparseable file: the position");
|
||||
}
|
||||
|
||||
/// MK/PK/DK paths derive the VUK from a VID; without one, derivation stops.
|
||||
#[test]
|
||||
fn resolve_candidate_mk_requires_vid() {
|
||||
let r = resolve_candidate(
|
||||
&KeyCandidate::Mk(MediaKey([1u8; 16])),
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
AacsVersion::V10,
|
||||
);
|
||||
assert!(r.is_none(), "MK path returns None without a VID");
|
||||
}
|
||||
|
||||
/// A planted Processing Key resolves against a synthetic MKB and drives the
|
||||
/// FULL chain PK → MK → VUK → UK — proving a PK candidate yields real keys.
|
||||
#[test]
|
||||
fn resolve_candidate_pk_drives_full_chain() {
|
||||
let pk: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
|
||||
0x00,
|
||||
];
|
||||
let mk: [u8; 16] = [
|
||||
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE,
|
||||
0xAF,
|
||||
];
|
||||
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
|
||||
|
||||
let mut mk_raw = mk;
|
||||
for a in 0..4 {
|
||||
mk_raw[12 + a] ^= uv[a];
|
||||
}
|
||||
let cv = aes_ecb_encrypt(&pk, &mk_raw);
|
||||
|
||||
let mut vd = [0x11u8; 16];
|
||||
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||
let mk_dv = aes_ecb_encrypt(&mk, &vd);
|
||||
|
||||
// 4-byte record header (type + BE24 total length) + body.
|
||||
let rec = |t: u8, body: &[u8]| -> Vec<u8> {
|
||||
let total = 4 + body.len();
|
||||
let mut r = vec![
|
||||
t,
|
||||
((total >> 16) & 0xFF) as u8,
|
||||
((total >> 8) & 0xFF) as u8,
|
||||
(total & 0xFF) as u8,
|
||||
];
|
||||
r.extend_from_slice(body);
|
||||
r
|
||||
};
|
||||
let mut sd = vec![0u8];
|
||||
sd.extend_from_slice(&uv);
|
||||
let mut mkb = Vec::new();
|
||||
mkb.extend_from_slice(&rec(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
|
||||
mkb.extend_from_slice(&rec(0x86, &mk_dv));
|
||||
mkb.extend_from_slice(&rec(0x04, &sd));
|
||||
mkb.extend_from_slice(&rec(0x05, &cv));
|
||||
|
||||
let vid = Vid([0x42u8; 16]);
|
||||
let plain_uk = [0x7Eu8; 16];
|
||||
let vuk = derive_vuk(&mk, &vid.0);
|
||||
let enc = aes_ecb_encrypt(&vuk, &plain_uk);
|
||||
let inf = synth_inf(std::slice::from_ref(&enc));
|
||||
|
||||
let r = resolve_candidate(
|
||||
&KeyCandidate::Pk(ProcessingKey(pk)),
|
||||
&mkb,
|
||||
&inf,
|
||||
Some(vid),
|
||||
AacsVersion::V10,
|
||||
)
|
||||
.expect("planted PK resolves the full chain");
|
||||
assert_eq!(r.mk, Some(MediaKey(mk)), "PK recovers the planted MK");
|
||||
assert_eq!(r.unit_keys.len(), 1);
|
||||
assert_eq!(
|
||||
r.unit_keys[0].1, plain_uk,
|
||||
"PK chain recovers the title key"
|
||||
);
|
||||
}
|
||||
|
||||
// VUK relation `Kvu = AES-128D(Km, IDv) XOR IDv` ([PR]/[BD] §3.3), anchored
|
||||
// to the FIPS-197 AES-128 known-answer vector so the test pins BOTH the AES
|
||||
// primitive and the trailing XOR (a dropped XOR or encrypt-swap changes it).
|
||||
#[test]
|
||||
fn derive_vuk_matches_the_fips197_aes_decrypt_xor_relation() {
|
||||
use crate::aacs::crypto::aes_ecb_decrypt;
|
||||
|
||||
// FIPS-197 Appendix example: AES-128D(key, ciphertext) == plaintext.
|
||||
let key: [u8; 16] = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
|
||||
0x0f,
|
||||
];
|
||||
let ciphertext: [u8; 16] = [
|
||||
0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4, 0xc5,
|
||||
0x5a,
|
||||
];
|
||||
let plaintext: [u8; 16] = [
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
|
||||
0xff,
|
||||
];
|
||||
// The known AES-128 decrypt result — pins the primitive derive_vuk builds on.
|
||||
assert_eq!(
|
||||
aes_ecb_decrypt(&key, &ciphertext),
|
||||
plaintext,
|
||||
"FIPS-197 AES-128 decrypt vector"
|
||||
);
|
||||
|
||||
// Treating `key` as the Media Key and `ciphertext` as the Volume ID, the
|
||||
// VUK is the known plaintext XOR the Volume ID.
|
||||
let mut expected = plaintext;
|
||||
for i in 0..16 {
|
||||
expected[i] ^= ciphertext[i];
|
||||
}
|
||||
assert_eq!(
|
||||
derive_vuk(&key, &ciphertext),
|
||||
expected,
|
||||
"VUK = AES-128D(Km, IDv) XOR IDv against a known AES vector"
|
||||
);
|
||||
|
||||
// And it is genuinely XOR: a Volume ID sharing set bits with the decrypt
|
||||
// must CLEAR them, which an OR could never do.
|
||||
let mk = [0xFFu8; 16];
|
||||
let vid = [0xABu8; 16];
|
||||
let mut want = aes_ecb_decrypt(&mk, &vid);
|
||||
for i in 0..16 {
|
||||
want[i] ^= vid[i];
|
||||
}
|
||||
assert_eq!(derive_vuk(&mk, &vid), want, "XOR, not OR, of IDv");
|
||||
}
|
||||
|
||||
/// A single-CPS `Unit_Key_RO.inf` in the real on-disc shape (Bruges/Titanic:
|
||||
/// one encrypted title key at offset 0x50) parses to exactly one key numbered
|
||||
/// CPS unit 1, and `derive_unit_keys` unwraps it with the VUK.
|
||||
#[test]
|
||||
fn single_cps_inf_parses_one_key_at_offset_0x50_and_boils_it() {
|
||||
// uk_pos = 0x20; first (only) key at uk_pos + 48 = 0x50.
|
||||
let mut inf = vec![0u8; 0x50 + 16];
|
||||
inf[0..4].copy_from_slice(&0x20u32.to_be_bytes());
|
||||
inf[0x20..0x22].copy_from_slice(&1u16.to_be_bytes()); // num_unit_keys = 1
|
||||
let enc = [0x9Au8; 16];
|
||||
inf[0x50..0x60].copy_from_slice(&enc);
|
||||
|
||||
let ukf = parse_unit_key_ro(&inf, AacsVersion::V10).expect("valid single-CPS inf");
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys,
|
||||
vec![(1u32, enc)],
|
||||
"one CPS unit, numbered 1, read from offset 0x50"
|
||||
);
|
||||
|
||||
let vuk = [0x5Cu8; 16];
|
||||
let boiled = derive_unit_keys(&ukf, &vuk);
|
||||
assert_eq!(
|
||||
boiled,
|
||||
vec![(1u32, decrypt_unit_key(&vuk, &enc))],
|
||||
"the single unit key is the VUK-decrypt of its slot, keyed by CPS 1"
|
||||
);
|
||||
}
|
||||
|
||||
/// Build an `n`-key `Unit_Key_RO.inf` at a chosen stride, each slot filled
|
||||
/// with a distinct byte so a mis-strided read is visible.
|
||||
fn multi_cps_inf(n: usize, stride: usize) -> (Vec<u8>, Vec<[u8; 16]>) {
|
||||
let uk_pos = 0x20usize;
|
||||
let key0 = uk_pos + 48;
|
||||
let total = key0 + (n.saturating_sub(1)) * stride + 16;
|
||||
let mut inf = vec![0u8; total];
|
||||
inf[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
|
||||
inf[uk_pos..uk_pos + 2].copy_from_slice(&(n as u16).to_be_bytes());
|
||||
let mut encs = Vec::new();
|
||||
for i in 0..n {
|
||||
let k = [(0x10 + i as u8); 16];
|
||||
let o = key0 + i * stride;
|
||||
inf[o..o + 16].copy_from_slice(&k);
|
||||
encs.push(k);
|
||||
}
|
||||
(inf, encs)
|
||||
}
|
||||
|
||||
/// A MULTI-CPS inf yields one key per CPS unit, numbered 1..=n in on-disc
|
||||
/// order, and each boils to the VUK-decrypt of its own slot — the map cannot
|
||||
/// drift or renumber.
|
||||
#[test]
|
||||
fn multi_cps_inf_numbers_each_unit_and_maps_to_its_slot() {
|
||||
let (inf, encs) = multi_cps_inf(4, 48); // V10 stride
|
||||
let ukf = parse_unit_key_ro(&inf, AacsVersion::V10).expect("valid multi-CPS inf");
|
||||
assert_eq!(ukf.encrypted_keys.len(), 4, "one key per CPS unit");
|
||||
let cps: Vec<u32> = ukf.encrypted_keys.iter().map(|(c, _)| *c).collect();
|
||||
assert_eq!(cps, vec![1, 2, 3, 4], "CPS units numbered 1..=n in order");
|
||||
|
||||
let vuk = [0x33u8; 16];
|
||||
let boiled = derive_unit_keys(&ukf, &vuk);
|
||||
assert_eq!(boiled.len(), 4, "every CPS unit boils to a key");
|
||||
for (i, (num, key)) in boiled.iter().enumerate() {
|
||||
assert_eq!(*num, (i + 1) as u32, "CPS number is the 1-based slot index");
|
||||
assert_eq!(
|
||||
*key,
|
||||
decrypt_unit_key(&vuk, &encs[i]),
|
||||
"each unit key is the VUK-decrypt of its own encrypted slot"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The stride `parse_unit_key_ro` walks is driven by the AACS generation:
|
||||
/// V10 reads 48-byte spacing, V20/V21 read 64-byte spacing. Same buffer, two
|
||||
/// versions, DIFFERENT second key — the V10-vs-2.x distinction the on-disc
|
||||
/// layout hinges on.
|
||||
#[test]
|
||||
fn parse_unit_key_ro_stride_follows_the_aacs_version() {
|
||||
// Two keys spaced at the V20 (64-byte) stride. Key 0 is shared (both
|
||||
// strides read +48); key 1 sits at +64, which a V10 parse (+48) misses.
|
||||
let uk_pos = 0x20usize;
|
||||
let key0 = uk_pos + 48;
|
||||
let v10_key1 = key0 + 48;
|
||||
let v20_key1 = key0 + 64;
|
||||
let mut inf = vec![0u8; v20_key1 + 16];
|
||||
inf[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
|
||||
inf[uk_pos..uk_pos + 2].copy_from_slice(&2u16.to_be_bytes());
|
||||
inf[key0..key0 + 16].fill(0xA0);
|
||||
inf[v10_key1..v10_key1 + 16].fill(0x10);
|
||||
inf[v20_key1..v20_key1 + 16].fill(0x20);
|
||||
|
||||
let v10 = parse_unit_key_ro(&inf, AacsVersion::V10).expect("v10");
|
||||
let v20 = parse_unit_key_ro(&inf, AacsVersion::V20).expect("v20");
|
||||
let v21 = parse_unit_key_ro(&inf, AacsVersion::V21).expect("v21");
|
||||
|
||||
assert_eq!(v10.encrypted_keys[0].1, [0xA0; 16], "key 0 shared");
|
||||
assert_eq!(v20.encrypted_keys[0].1, [0xA0; 16]);
|
||||
assert_eq!(v10.encrypted_keys[1].1, [0x10; 16], "V10 reads +48");
|
||||
assert_eq!(v20.encrypted_keys[1].1, [0x20; 16], "V20 reads +64");
|
||||
assert_eq!(
|
||||
v21.encrypted_keys[1].1, [0x20; 16],
|
||||
"V21 shares the V20 64-byte stride"
|
||||
);
|
||||
assert_ne!(v10.encrypted_keys[1].1, v20.encrypted_keys[1].1);
|
||||
}
|
||||
|
||||
/// `resolve_candidate` parses the inf at the version it is given (from the shared
|
||||
/// `resolve_aacs_version`), not at its own guess from the MKB: an unreadable MKB on a
|
||||
/// UHD still reads the second key at +64, and a V10 cert outranks a 2.0 MKB.
|
||||
#[test]
|
||||
fn resolve_candidate_boils_at_the_resolved_version_stride() {
|
||||
// A two-key inf whose 2nd key differs by stride (see the parse test).
|
||||
let uk_pos = 0x20usize;
|
||||
let key0 = uk_pos + 48;
|
||||
let v10_key1 = key0 + 48;
|
||||
let v20_key1 = key0 + 64;
|
||||
let mut inf = vec![0u8; v20_key1 + 16];
|
||||
inf[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
|
||||
inf[uk_pos..uk_pos + 2].copy_from_slice(&2u16.to_be_bytes());
|
||||
inf[key0..key0 + 16].fill(0xA0);
|
||||
inf[v10_key1..v10_key1 + 16].fill(0x10);
|
||||
inf[v20_key1..v20_key1 + 16].fill(0x20);
|
||||
|
||||
let vuk = Vuk([0x44u8; 16]);
|
||||
|
||||
// A minimal Category-C 2.0 Type-and-Version record → generation V20.
|
||||
let v20_mkb: [u8; 12] = [
|
||||
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
|
||||
];
|
||||
assert_eq!(
|
||||
mkb_type(&v20_mkb).map(|t| t.generation()),
|
||||
Some(AacsVersion::V20),
|
||||
"fixture check: this MKB declares AACS 2.0"
|
||||
);
|
||||
let boil = |mkb: &[u8], version| {
|
||||
resolve_candidate(&KeyCandidate::Vuk(vuk), mkb, &inf, None, version)
|
||||
.expect("boils the inf")
|
||||
.unit_keys[1]
|
||||
.1
|
||||
};
|
||||
let at64 = decrypt_unit_key(&vuk.0, &[0x20; 16]);
|
||||
let at48 = decrypt_unit_key(&vuk.0, &[0x10; 16]);
|
||||
// The stride follows the version handed in, never a re-read of the MKB: a UHD whose
|
||||
// MKB is unreadable (empty) still reads key 2 at +64 ...
|
||||
let uhd = resolve_aacs_version(None, &[], Some(true));
|
||||
assert_eq!(boil(&[], uhd), at64);
|
||||
// ... and when the certificate (V10) disagrees with the MKB (2.0), the cert wins.
|
||||
let bd = resolve_aacs_version(Some(1), &v20_mkb, Some(true));
|
||||
assert_eq!(bd, AacsVersion::V10);
|
||||
assert_eq!(boil(&v20_mkb, bd), at48);
|
||||
assert_eq!(
|
||||
boil(&v20_mkb, resolve_aacs_version(None, &v20_mkb, None)),
|
||||
at64
|
||||
);
|
||||
}
|
||||
|
||||
/// PIN: a `Mk` candidate is PURE DERIVATION — `resolve_candidate` does NOT
|
||||
/// validate the Media Key against the MKB. A WRONG MK (one the MKB's verify
|
||||
/// record would reject) still boils a full set of unit keys, from the VUK
|
||||
/// that wrong MK produces. This documents the "computed-but-wrong-UK" case:
|
||||
/// the caller's sample/`is_clean_ts` step, not this function, is the gate.
|
||||
#[test]
|
||||
fn resolve_candidate_mk_does_not_validate_the_media_key_against_the_mkb() {
|
||||
// An MKB carrying a 0x86 verify record built for a DIFFERENT media key,
|
||||
// so `km_verifies` would reject the candidate below.
|
||||
let real_km = [0xC3u8; 16];
|
||||
let mut vd = [0x11u8; 16];
|
||||
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||
let mk_dv = aes_ecb_encrypt(&real_km, &vd);
|
||||
let mut mkb = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
|
||||
];
|
||||
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&mk_dv);
|
||||
// The MK we hand in is NOT the one the verify record was built for.
|
||||
let wrong_mk = MediaKey([0x77u8; 16]);
|
||||
assert!(
|
||||
!probe::km_verifies(&mkb, &wrong_mk.0),
|
||||
"fixture check: the wrong MK must fail the MKB verify record"
|
||||
);
|
||||
|
||||
let vid = Vid([0x42u8; 16]);
|
||||
let enc = [0x9Au8; 16];
|
||||
let inf = synth_inf(std::slice::from_ref(&enc));
|
||||
|
||||
let r = resolve_candidate(
|
||||
&KeyCandidate::Mk(wrong_mk),
|
||||
&mkb,
|
||||
&inf,
|
||||
Some(vid),
|
||||
AacsVersion::V10,
|
||||
)
|
||||
.expect("resolve_candidate boils regardless of MK-vs-MKB validity");
|
||||
// The chain is derived straight from the (wrong) MK, no gate applied.
|
||||
let expected_vuk = derive_vuk(&wrong_mk.0, &vid.0);
|
||||
assert_eq!(
|
||||
r.vuk,
|
||||
Some(Vuk(expected_vuk)),
|
||||
"VUK is derived from the given MK"
|
||||
);
|
||||
assert_eq!(
|
||||
r.mk,
|
||||
Some(wrong_mk),
|
||||
"the candidate MK is carried through unchecked"
|
||||
);
|
||||
assert_eq!(
|
||||
r.unit_keys,
|
||||
vec![(1u32, decrypt_unit_key(&expected_vuk, &enc))],
|
||||
"unit keys are boiled from the wrong VUK — derivation does not validate"
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
use super::*;
|
||||
use crate::spec::keys::KS_15_KCU_WRAP;
|
||||
use crate::test_util::unit_key_ro;
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||
|
||||
fn aes_e(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] {
|
||||
let mut b: Array<u8, _> = (*block).into();
|
||||
Aes128::new(&(*key).into()).encrypt_block(&mut b);
|
||||
b.into()
|
||||
}
|
||||
|
||||
/// per spec; do not change without a spec citation — KS-15 [BD] §3.9.3: "The CPS Unit
|
||||
/// Key is encrypted as follows: AES-128E( Kvu, Kcu )", so Kcu = AES-128D(Kvu, stored).
|
||||
#[test]
|
||||
fn kcu_is_aes128d_of_encrypted_key_under_kvu() {
|
||||
assert!(KS_15_KCU_WRAP.text.ends_with("AES-128E( Kvu, Kcu )"));
|
||||
let kvu: [u8; 16] = core::array::from_fn(|i| 0x30 + i as u8 * 7);
|
||||
let kcu = [[0x5Au8; 16], core::array::from_fn(|i| i as u8)];
|
||||
let wrapped = [aes_e(&kvu, &kcu[0]), aes_e(&kvu, &kcu[1])];
|
||||
let inf = unit_key_ro(AacsVersion::V10, &wrapped, &[1, 2]);
|
||||
let ukf = parse_unit_key_ro(&inf, AacsVersion::V10).unwrap();
|
||||
assert_eq!(derive_unit_keys(&ukf, &kvu), vec![(1, kcu[0]), (2, kcu[1])]);
|
||||
assert_eq!(decrypt_unit_key(&kvu, &wrapped[1]), kcu[1]);
|
||||
assert_ne!(
|
||||
derive_unit_keys(&ukf, &kcu[0])[0].1,
|
||||
kcu[0],
|
||||
"Kvu is the wrapping key"
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
//! HD DVD pack encryption: `[HD]` = AACS "HD DVD and DVD Pre-recorded Book", Final Rev 0.952.
|
||||
//!
|
||||
//! An EVOB is encrypted per 2048-byte pack (`[HD]` §4.3.2): bytes 0..128 are the Unencrypted
|
||||
//! Portion, bytes 128..2048 the Encrypted Portion, CBC-encrypted under the AACS IV with the
|
||||
//! Content Key `Kc = AES-G(Kt, Dtk || CPI_lsb96)`. `Dtk` is pack bytes 84..88 (Table 4-7).
|
||||
//! `Kt` is the Title Key that `TITLE_KEY_PTR` of the EVOBU's CPI names (Table 4-2), and the CPI
|
||||
//! rides in the GCI packet of the NV_PCK that leads every EVOBU. A pack is encrypted when its
|
||||
//! `PES_scrambling_control` is `01` (§4.3.2); an HL_PCK is always encrypted while KEY_VF is
|
||||
//! `01` or `10` (§4.3.2). NV_PCK and ADV_PCK are never encrypted (§4.3.1).
|
||||
|
||||
use super::crypto::{aes_cbc_decrypt, aes_g};
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
|
||||
/// One HD DVD pack: a 2048-byte sector.
|
||||
pub(crate) const PACK_LEN: usize = SECTOR_BYTES;
|
||||
/// `[HD]` §4.3.2: "the first 128 bytes are called the Unencrypted Portion".
|
||||
pub(crate) const CLEAR_LEN: usize = 128;
|
||||
// `[HD]` Table 4-7: Title Key Data (Dtk) is bytes 84..=87 of the pack.
|
||||
const DTK_OFF: usize = 84;
|
||||
const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA];
|
||||
const SYSTEM_HEADER: u8 = 0xBB;
|
||||
const PRIVATE_STREAM_1: u8 = 0xBD;
|
||||
const PADDING: u8 = 0xBE;
|
||||
const PRIVATE_STREAM_2: u8 = 0xBF;
|
||||
// private_stream_2 sub_stream_ids: the GCI packet of an NV_PCK, and an HL_PCK.
|
||||
const GCI_SUB_STREAM_ID: u8 = 0x04;
|
||||
const HLI_SUB_STREAM_ID: u8 = 0x08;
|
||||
// The CPI is 16 bytes at offset 12 of the GCI data (after its sub_stream_id). The HD DVD-Video
|
||||
// book that fixes it is not public; measured on encrypted discs (pack offset 60 of an NV_PCK).
|
||||
const CPI_IN_GCI: usize = 12;
|
||||
const SCRAMBLE_MASK: u8 = 0x30;
|
||||
|
||||
/// The 16-byte Content Protection Information of an EVOBU (`[HD]` §4.2, Tables 4-1 and 4-2).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Cpi(pub(crate) [u8; 16]);
|
||||
|
||||
impl Cpi {
|
||||
/// `KEY_VF` (Table 4-2): `0b10` the Title Key Pointer is valid, `0b01` the Segment Key
|
||||
/// Pointer, `0b00` neither.
|
||||
pub(crate) fn key_vf(&self) -> u8 {
|
||||
self.0[0] >> 6
|
||||
}
|
||||
|
||||
/// `TITLE_KEY_PTR` (Table 4-2): the 1-based Title Key entry of the Title Key File.
|
||||
pub(crate) fn title_key_ptr(&self) -> u32 {
|
||||
u32::from(u16::from_be_bytes([self.0[1], self.0[2]]))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a pack is, as far as decryption is concerned.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PackKind {
|
||||
/// An NV_PCK, with the CPI of its GCI packet when one parses.
|
||||
Nav(Option<Cpi>),
|
||||
/// A PES pack whose `PES_scrambling_control` is not `00`.
|
||||
Scrambled,
|
||||
/// An HL_PCK: encrypted whenever its EVOBU's KEY_VF is `01` or `10`.
|
||||
Highlight,
|
||||
/// Anything else: never encrypted.
|
||||
Clear,
|
||||
}
|
||||
|
||||
// Offset of the first packet after an MPEG-2 pack header (with its stuffing), or `None`.
|
||||
fn first_packet(pack: &[u8]) -> Option<usize> {
|
||||
if pack.len() < 14 || pack[..4] != PACK_START || pack[4] & 0xC0 != 0x40 {
|
||||
return None;
|
||||
}
|
||||
Some(14 + usize::from(pack[13] & 0x07))
|
||||
}
|
||||
|
||||
// Does stream id `sid` carry the optional PES header (with PES_scrambling_control)?
|
||||
fn has_pes_flags(sid: u8) -> bool {
|
||||
sid >= PRIVATE_STREAM_1 && !matches!(sid, PADDING | PRIVATE_STREAM_2)
|
||||
}
|
||||
|
||||
/// Classify one pack. A slice shorter than a pack is judged on the bytes it holds.
|
||||
pub(crate) fn classify(pack: &[u8]) -> PackKind {
|
||||
let Some(o) = first_packet(pack) else {
|
||||
return PackKind::Clear;
|
||||
};
|
||||
if pack.len() < o + 7 || pack[o..o + 3] != [0, 0, 1] {
|
||||
return PackKind::Clear;
|
||||
}
|
||||
match pack[o + 3] {
|
||||
SYSTEM_HEADER => PackKind::Nav(nav_cpi(pack, o)),
|
||||
PRIVATE_STREAM_2 if pack[o + 6] == HLI_SUB_STREAM_ID => PackKind::Highlight,
|
||||
sid if has_pes_flags(sid) && pack[o + 6] & 0xC0 == 0x80 => {
|
||||
match pack[o + 6] & SCRAMBLE_MASK {
|
||||
0 => PackKind::Clear,
|
||||
_ => PackKind::Scrambled,
|
||||
}
|
||||
}
|
||||
_ => PackKind::Clear,
|
||||
}
|
||||
}
|
||||
|
||||
// The CPI of the GCI packet in an NV_PCK whose first packet (the system header) is at `o`.
|
||||
fn nav_cpi(pack: &[u8], mut q: usize) -> Option<Cpi> {
|
||||
while q + 7 <= pack.len() && pack[q..q + 3] == [0, 0, 1] {
|
||||
let len = usize::from(u16::from_be_bytes([pack[q + 4], pack[q + 5]]));
|
||||
if pack[q + 3] == PRIVATE_STREAM_2 && pack[q + 6] == GCI_SUB_STREAM_ID {
|
||||
if len < 1 + CPI_IN_GCI + 16 {
|
||||
return None;
|
||||
}
|
||||
let at = q + 7 + CPI_IN_GCI;
|
||||
return Some(Cpi(pack.get(at..at + 16)?.try_into().ok()?));
|
||||
}
|
||||
q += 6 + len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `pack` must be decrypted under an EVOBU whose CPI is `cpi`.
|
||||
pub(crate) fn needs_key(kind: PackKind, cpi: Option<&Cpi>) -> bool {
|
||||
match kind {
|
||||
PackKind::Scrambled => true,
|
||||
PackKind::Highlight => cpi.is_some_and(|c| matches!(c.key_vf(), 0b01 | 0b10)),
|
||||
PackKind::Nav(_) | PackKind::Clear => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `[HD]` §4.3.2: `Kc = AES-G(Kt, Dtk || CPI_lsb96)`, `Dtk` = pack bytes 84..88.
|
||||
pub(crate) fn content_key(title_key: &[u8; 16], pack: &[u8], cpi: &Cpi) -> [u8; 16] {
|
||||
let mut d = [0u8; 16];
|
||||
d[..4].copy_from_slice(&pack[DTK_OFF..DTK_OFF + 4]);
|
||||
d[4..].copy_from_slice(&cpi.0[4..]);
|
||||
aes_g(title_key, &d)
|
||||
}
|
||||
|
||||
/// Decrypt one whole pack in place (`[HD]` §4.3.4 step 3) and mark a PES pack unscrambled.
|
||||
/// Bytes 0..128 are never touched but for that flag. A short slice is left alone.
|
||||
pub(crate) fn decrypt_pack(pack: &mut [u8], title_key: &[u8; 16], cpi: &Cpi) {
|
||||
if pack.len() != PACK_LEN {
|
||||
return;
|
||||
}
|
||||
let kc = content_key(title_key, pack, cpi);
|
||||
aes_cbc_decrypt(&kc, &mut pack[CLEAR_LEN..]);
|
||||
clear_scrambling(pack);
|
||||
}
|
||||
|
||||
// `[HD]` §4.3.2: "Otherwise, the PES_scrambling_control shall be 00₂": true of the plaintext.
|
||||
fn clear_scrambling(pack: &mut [u8]) {
|
||||
if let Some(o) = first_packet(pack)
|
||||
&& pack.len() >= o + 7
|
||||
&& has_pes_flags(pack[o + 3])
|
||||
&& pack[o + 6] & 0xC0 == 0x80
|
||||
{
|
||||
pack[o + 6] &= !SCRAMBLE_MASK;
|
||||
}
|
||||
}
|
||||
|
||||
/// The exact inverse of [`decrypt_pack`] for test fixtures: sets the scrambling flag of a PES
|
||||
/// pack, then CBC-encrypts bytes 128..2048 (`[HD]` §4.3.2, `Ce = AES-128CBCE(Kc, C)`).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn encrypt_pack(pack: &mut [u8], title_key: &[u8; 16], cpi: &Cpi) {
|
||||
if let Some(o) = first_packet(pack)
|
||||
&& has_pes_flags(pack[o + 3])
|
||||
{
|
||||
pack[o + 6] = (pack[o + 6] & !SCRAMBLE_MASK) | 0x10;
|
||||
}
|
||||
let kc = content_key(title_key, pack, cpi);
|
||||
super::crypto::aes_cbc_encrypt(&kc, &mut pack[CLEAR_LEN..]);
|
||||
}
|
||||
|
||||
/// Structural proof that a pack's bytes past 128 are plaintext, for key proof and verify.
|
||||
///
|
||||
/// `Some(false)` when a test that random bytes pass about once in 2^16 fails: a PES or padding
|
||||
/// start code where the chain of packet lengths puts one at or past byte 128, or the AC-3 /
|
||||
/// E-AC-3 sync word where a `private_stream_1` first-access-unit pointer lands at or past it.
|
||||
/// `Some(true)` when such a test ran and passed, `None` when none applied (most video packs,
|
||||
/// whose one PES fills the pack).
|
||||
pub(crate) fn payload_check(pack: &[u8]) -> Option<bool> {
|
||||
if pack.len() != PACK_LEN {
|
||||
return None;
|
||||
}
|
||||
let mut q = first_packet(pack)?;
|
||||
let mut tested = false;
|
||||
while q + 6 <= PACK_LEN {
|
||||
if pack[q..q + 3] != [0, 0, 1] {
|
||||
// A broken chain in the clear head proves nothing about the key.
|
||||
return (q >= CLEAR_LEN).then_some(false).or(tested.then_some(true));
|
||||
}
|
||||
tested |= q >= CLEAR_LEN;
|
||||
let len = usize::from(u16::from_be_bytes([pack[q + 4], pack[q + 5]]));
|
||||
if pack[q + 3] == PRIVATE_STREAM_1 {
|
||||
match ac3_sync_check(pack, q, len) {
|
||||
Some(false) => return Some(false),
|
||||
Some(true) => tested = true,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
q += 6 + len;
|
||||
}
|
||||
tested.then_some(true)
|
||||
}
|
||||
|
||||
// The AC-3 / E-AC-3 sync word at the first access unit of a private_stream_1 packet at `q`.
|
||||
// Sub-stream ids 0x80-0x87 (AC-3) and 0xC0-0xC7 (E-AC-3); the pointer counts from its own
|
||||
// last byte, as on DVD.
|
||||
fn ac3_sync_check(pack: &[u8], q: usize, len: usize) -> Option<bool> {
|
||||
if pack.get(q + 6)? & 0xC0 != 0x80 {
|
||||
return None;
|
||||
}
|
||||
let hd = q + 9 + usize::from(*pack.get(q + 8)?);
|
||||
let sub = *pack.get(hd)?;
|
||||
if !matches!(sub, 0x80..=0x87 | 0xC0..=0xC7) || hd + 4 > PACK_LEN {
|
||||
return None;
|
||||
}
|
||||
let ptr = usize::from(u16::from_be_bytes([pack[hd + 2], pack[hd + 3]]));
|
||||
let at = hd + 3 + ptr;
|
||||
let end = (q + 6 + len).min(PACK_LEN);
|
||||
(ptr != 0 && at >= CLEAR_LEN && at + 2 <= end).then(|| pack[at..at + 2] == [0x0B, 0x77])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "hddvd_tests.rs"]
|
||||
pub(crate) mod tests;
|
||||
@@ -1,176 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// A pack header with no stuffing, SCR and mux rate zero.
|
||||
fn pack_header() -> Vec<u8> {
|
||||
let mut v = PACK_START.to_vec();
|
||||
v.extend_from_slice(&[0x44, 0, 0x04, 0, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8]);
|
||||
v
|
||||
}
|
||||
|
||||
// A PES packet with an empty optional header, `payload` bytes long.
|
||||
fn pes(sid: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut v = vec![0, 0, 1, sid];
|
||||
v.extend_from_slice(&((payload.len() + 3) as u16).to_be_bytes());
|
||||
v.extend_from_slice(&[0x80, 0x00, 0x00]);
|
||||
v.extend_from_slice(payload);
|
||||
v
|
||||
}
|
||||
|
||||
// A padding packet filling the pack to 2048 bytes.
|
||||
fn pad_to_pack(mut v: Vec<u8>) -> Vec<u8> {
|
||||
let left = PACK_LEN - v.len() - 6;
|
||||
v.extend_from_slice(&[0, 0, 1, PADDING]);
|
||||
v.extend_from_slice(&(left as u16).to_be_bytes());
|
||||
v.resize(PACK_LEN, 0xFF);
|
||||
v
|
||||
}
|
||||
|
||||
/// A synthetic CPI: KEY_VF `10`, `TITLE_KEY_PTR` = `ptr`, CH_PTR = `ch`.
|
||||
pub(crate) fn cpi(ptr: u16, ch: u32) -> Cpi {
|
||||
let mut c = [0u8; 16];
|
||||
c[0] = 0x80;
|
||||
c[1..3].copy_from_slice(&ptr.to_be_bytes());
|
||||
c[4..8].copy_from_slice(&ch.to_be_bytes());
|
||||
c[8] = 0x80;
|
||||
Cpi(c)
|
||||
}
|
||||
|
||||
/// An NV_PCK: system header, then a GCI packet carrying `cpi` at GCI data offset 12.
|
||||
pub(crate) fn nav_pack(cpi: &Cpi) -> Vec<u8> {
|
||||
let mut v = pack_header();
|
||||
v.extend_from_slice(&[0, 0, 1, SYSTEM_HEADER, 0, 21]);
|
||||
v.extend_from_slice(&[0x80; 21]);
|
||||
let mut gci = vec![0, 0, 1, PRIVATE_STREAM_2, 0x01, 0x01, GCI_SUB_STREAM_ID];
|
||||
gci.extend_from_slice(&[0x11; CPI_IN_GCI]);
|
||||
gci.extend_from_slice(&cpi.0);
|
||||
gci.resize(6 + 0x101, 0);
|
||||
v.extend_from_slice(&gci);
|
||||
pad_to_pack(v)
|
||||
}
|
||||
|
||||
/// A plaintext E-AC-3 audio pack: its first access unit (sync `0B 77`) at byte 400, then
|
||||
/// padding from byte 1800. `seed` varies the Dtk bytes and the payload.
|
||||
pub(crate) fn audio_pack(seed: u8) -> Vec<u8> {
|
||||
let mut v = pack_header();
|
||||
let hd_len = 1800 - v.len() - 6 - 3;
|
||||
let mut body: Vec<u8> = (0..hd_len).map(|i| (i as u8).wrapping_mul(seed)).collect();
|
||||
body[0] = 0xC0;
|
||||
body[1] = 1;
|
||||
// The pointer counts from its own last byte (body[3], pack byte 26).
|
||||
let ptr = 400 - 26;
|
||||
body[2..4].copy_from_slice(&(ptr as u16).to_be_bytes());
|
||||
body[400 - 23..400 - 21].copy_from_slice(&[0x0B, 0x77]);
|
||||
v.extend_from_slice(&pes(PRIVATE_STREAM_1, &body));
|
||||
pad_to_pack(v)
|
||||
}
|
||||
|
||||
/// A plaintext video pack whose one PES fills the pack: no structural test applies.
|
||||
pub(crate) fn video_pack(seed: u8) -> Vec<u8> {
|
||||
let mut v = pack_header();
|
||||
let n = PACK_LEN - v.len() - 9;
|
||||
let body: Vec<u8> = (0..n).map(|i| (i as u8) ^ seed).collect();
|
||||
v.extend_from_slice(&pes(0xE0, &body));
|
||||
v
|
||||
}
|
||||
|
||||
fn hl_pack() -> Vec<u8> {
|
||||
let mut v = pack_header();
|
||||
v.extend_from_slice(&[0, 0, 1, PRIVATE_STREAM_2, 0x07, 0xEC, HLI_SUB_STREAM_ID]);
|
||||
v.resize(PACK_LEN, 0x5A);
|
||||
v
|
||||
}
|
||||
|
||||
const KT: [u8; 16] = [0x42; 16];
|
||||
|
||||
#[test]
|
||||
fn nav_pack_yields_its_cpi_and_fields() {
|
||||
let c = cpi(17, 9);
|
||||
assert_eq!(classify(&nav_pack(&c)), PackKind::Nav(Some(c)));
|
||||
assert_eq!(c.key_vf(), 0b10);
|
||||
assert_eq!(c.title_key_ptr(), 17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_key_is_aes_g_of_dtk_and_cpi_lsb96() {
|
||||
let c = cpi(1, 0x0102_0304);
|
||||
let pack = audio_pack(3);
|
||||
let mut d = [0u8; 16];
|
||||
d[..4].copy_from_slice(&pack[84..88]);
|
||||
d[4..].copy_from_slice(&c.0[4..16]);
|
||||
assert_eq!(content_key(&KT, &pack, &c), aes_g(&KT, &d));
|
||||
// CPI msb bytes (KMI) are not key input; Dtk and CH_PTR are.
|
||||
let mut other = c;
|
||||
other.0[3] ^= 0xFF;
|
||||
assert_eq!(content_key(&KT, &pack, &other), aes_g(&KT, &d));
|
||||
assert_ne!(content_key(&KT, &pack, &cpi(1, 5)), aes_g(&KT, &d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_keeps_the_clear_head_and_decrypt_restores_the_pack() {
|
||||
let c = cpi(3, 77);
|
||||
let plain = audio_pack(5);
|
||||
let mut p = plain.clone();
|
||||
encrypt_pack(&mut p, &KT, &c);
|
||||
assert_eq!(classify(&p), PackKind::Scrambled);
|
||||
assert_eq!(p[..20], plain[..20]);
|
||||
assert_eq!(p[21..CLEAR_LEN], plain[21..CLEAR_LEN]);
|
||||
assert_ne!(p[CLEAR_LEN..], plain[CLEAR_LEN..]);
|
||||
assert_eq!(p[..4], PACK_START, "an encrypted pack keeps its start code");
|
||||
decrypt_pack(&mut p, &KT, &c);
|
||||
assert_eq!(p, plain);
|
||||
assert_eq!(classify(&p), PackKind::Clear);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shifted_dtk_or_wrong_key_does_not_open_the_pack() {
|
||||
let c = cpi(3, 77);
|
||||
let mut p = audio_pack(5);
|
||||
encrypt_pack(&mut p, &KT, &c);
|
||||
let mut wrong = p.clone();
|
||||
decrypt_pack(&mut wrong, &[0x43; 16], &c);
|
||||
assert_eq!(payload_check(&wrong), Some(false));
|
||||
// Dtk one byte early (83..87) is the wrong key input.
|
||||
let mut shifted = p.clone();
|
||||
let mut d = [0u8; 16];
|
||||
d[..4].copy_from_slice(&shifted[83..87]);
|
||||
d[4..].copy_from_slice(&c.0[4..]);
|
||||
aes_cbc_decrypt(&aes_g(&KT, &d), &mut shifted[CLEAR_LEN..]);
|
||||
assert_eq!(payload_check(&shifted), Some(false));
|
||||
decrypt_pack(&mut p, &KT, &c);
|
||||
assert_eq!(payload_check(&p), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_check_judges_only_bytes_past_the_clear_head() {
|
||||
assert_eq!(payload_check(&audio_pack(9)), Some(true));
|
||||
assert_eq!(payload_check(&video_pack(9)), None);
|
||||
let mut bad = audio_pack(9);
|
||||
bad[400] ^= 1;
|
||||
assert_eq!(payload_check(&bad), Some(false));
|
||||
let mut bad_pad = audio_pack(9);
|
||||
bad_pad[1800] = 0x47;
|
||||
assert_eq!(payload_check(&bad_pad), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrambling_is_read_per_pack_from_the_pes_header() {
|
||||
let mut v = video_pack(1);
|
||||
assert_eq!(classify(&v), PackKind::Clear);
|
||||
v[20] |= 0x10;
|
||||
assert_eq!(classify(&v), PackKind::Scrambled);
|
||||
// A system header's byte 20 is rate_bound, never a scrambling flag.
|
||||
let mut nav = nav_pack(&cpi(1, 1));
|
||||
nav[20] = 0xB2;
|
||||
assert!(matches!(classify(&nav), PackKind::Nav(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_packs_need_a_key_only_under_a_valid_key_pointer() {
|
||||
let hl = hl_pack();
|
||||
assert_eq!(classify(&hl), PackKind::Highlight);
|
||||
assert!(needs_key(classify(&hl), Some(&cpi(1, 1))));
|
||||
let mut none = cpi(1, 1);
|
||||
none.0[0] = 0;
|
||||
assert!(!needs_key(classify(&hl), Some(&none)));
|
||||
assert!(!needs_key(classify(&hl), None));
|
||||
}
|
||||
+115
-2
@@ -71,5 +71,118 @@ pub fn unit_disposition(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "index_select_tests.rs"]
|
||||
mod tests;
|
||||
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() {
|
||||
// Index 288 (0x0120) truncates to 32 in a u8, which would alias our
|
||||
// resolved index 32 under the old `as u8` compare; u16 compare must
|
||||
// classify it as foreign instead.
|
||||
let segs = tbl(&[(288, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(32)),
|
||||
UnitDisposition::DropForeignIndex(255)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straddling_unit_still_classified_as_its_segment() {
|
||||
// A unit whose 32-packet span only tails into the segment still routes
|
||||
// to the segment (matches segment_for_unit's span test).
|
||||
let segs = tbl(&[(5, 100, 200)]);
|
||||
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
|
||||
// Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
assert!(80 + unit_packets > 100, "sanity: unit tails into seg");
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(5)),
|
||||
UnitDisposition::Index(5)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
use super::*;
|
||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
|
||||
|
||||
/// Build a one-record segment table (index, start_spn, end_spn).
|
||||
fn tbl(recs: &[(u16, u32, u32)]) -> Vec<Segment> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
||||
v.extend_from_slice(&(recs.len() as u16).to_be_bytes());
|
||||
v.extend_from_slice(&16u16.to_be_bytes());
|
||||
for &(n, s, e) in recs {
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
||||
v.extend_from_slice(&n.to_be_bytes());
|
||||
v.extend_from_slice(&1u16.to_be_bytes());
|
||||
v.extend_from_slice(&s.to_be_bytes());
|
||||
v.extend_from_slice(&e.to_be_bytes());
|
||||
}
|
||||
parse_individual_segments(&v).expect("parse")
|
||||
}
|
||||
|
||||
fn uk(idx: u32, index: u8) -> UnitKey {
|
||||
if index == 0 {
|
||||
UnitKey::new(idx, [0u8; 16])
|
||||
} else {
|
||||
UnitKey::forensic(idx, [index; 16], index)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_picks_the_single_index_key() {
|
||||
// Default keys only → no index resolved.
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 0)]), None);
|
||||
assert_eq!(resolve_disc_index(&[]), None);
|
||||
// One index key among defaults → that index.
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 0), uk(1, 7)]), Some(7));
|
||||
// Defensive: lowest of several distinct indexes (deterministic).
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 9), uk(1, 3)]), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_outside_segments_is_default() {
|
||||
let segs = tbl(&[(1, 343680, 346239)]);
|
||||
let off = 1000u64 * SOURCE_PACKET_LEN; // well before the segment
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(1)),
|
||||
UnitDisposition::Default
|
||||
);
|
||||
// With no segments at all (1.0 / 2.0), everything is Default.
|
||||
assert_eq!(
|
||||
unit_disposition(off, &[], Some(1)),
|
||||
UnitDisposition::Default
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_in_our_index_decrypts() {
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(7)),
|
||||
UnitDisposition::Index(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_in_foreign_index_drops() {
|
||||
// Segment tagged index 7, but our disc index is 3 → drop it.
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(3)),
|
||||
UnitDisposition::DropForeignIndex(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forensic_unit_with_no_key_is_concealed() {
|
||||
// A forensic segment but we never resolved an index → conceal as loss.
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, None),
|
||||
UnitDisposition::ForensicNoKey(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_index_does_not_truncate_into_ours() {
|
||||
// Index 288 (0x0120) truncates to 32 in a u8, which would alias our
|
||||
// resolved index 32 under the old `as u8` compare; u16 compare must
|
||||
// classify it as foreign instead.
|
||||
let segs = tbl(&[(288, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(32)),
|
||||
UnitDisposition::DropForeignIndex(255)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straddling_unit_still_classified_as_its_segment() {
|
||||
// A unit whose 32-packet span only tails into the segment still routes
|
||||
// to the segment (matches segment_for_unit's span test).
|
||||
let segs = tbl(&[(5, 100, 200)]);
|
||||
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
|
||||
// Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
assert!(80 + unit_packets > 100, "sanity: unit tails into seg");
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(5)),
|
||||
UnitDisposition::Index(5)
|
||||
);
|
||||
}
|
||||
+641
-8
@@ -396,17 +396,650 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inf_vtkf_tests.rs"]
|
||||
mod vtkf_tests;
|
||||
mod vtkf_tests {
|
||||
use super::*;
|
||||
|
||||
// Synthesize a `VTKF%%%.AACS`: magic, BE32 size, playlist name, reserved to 0x80, 64 entry
|
||||
// slots (first `keys.len()` present), MAC trailer.
|
||||
fn synth_vtkf(keys: &[[u8; 16]]) -> Vec<u8> {
|
||||
const FILE_LEN: usize = 2480;
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(VTKF_MAGIC); // 0x00
|
||||
v.extend_from_slice(&(FILE_LEN as u32).to_be_bytes()); // 0x0C HD_VTKF_SIZE
|
||||
v.extend_from_slice(b"VPLST000.XPL"); // 0x10 playlist name
|
||||
v.resize(VTKF_HEADER_LEN, 0); // reserve to first entry (0x80)
|
||||
for n in 0..VTKF_MAX_ENTRIES {
|
||||
if let Some(k) = keys.get(n) {
|
||||
v.push(VTKF_AV_FLG); // BIFO: AV_FLG set (present)
|
||||
v.extend_from_slice(&[0, 0, 0]); // reserved
|
||||
v.extend_from_slice(k); // 16-byte encrypted title key
|
||||
v.extend_from_slice(&[0xFFu8; 16]); // binding MAC (0xFF, pre-recorded)
|
||||
} else {
|
||||
v.extend_from_slice(&[0u8; VTKF_ENTRY_LEN]); // empty slot (AV_FLG clear)
|
||||
}
|
||||
}
|
||||
v.resize(FILE_LEN - 16, 0); // reserved gap before the trailer
|
||||
v.extend_from_slice(&[0xABu8; 16]); // TKF MAC (must NOT be read as a key)
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_reads_present_entries_skips_empty_ignores_mac() {
|
||||
let k1 = [0x11u8; 16];
|
||||
let k2 = [0x22u8; 16];
|
||||
let k3 = [0x33u8; 16];
|
||||
let data = synth_vtkf(&[k1, k2, k3]);
|
||||
|
||||
let ukf = parse_vtkf(&data).expect("valid VTKF must parse");
|
||||
// Exactly the three present entries — empty slots and the trailing 16-byte
|
||||
// TKF MAC are not mistaken for keys. k2/k3 are read at the 36-byte stride
|
||||
// (0xA4, 0xC8); the old 32-byte stride misread them from the prior MAC.
|
||||
assert_eq!(ukf.encrypted_keys.len(), 3);
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys[0],
|
||||
(1, k1),
|
||||
"CPS units = 1-based slot index"
|
||||
);
|
||||
assert_eq!(ukf.encrypted_keys[1], (2, k2));
|
||||
assert_eq!(ukf.encrypted_keys[2], (3, k3));
|
||||
assert_eq!(ukf.version, AacsVersion::V10, "HD DVD is AACS 1.0");
|
||||
// disc_hash is SHA1 of the whole file (the KEYDB lookup key).
|
||||
assert_eq!(ukf.disc_hash, disc_hash(&data));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_reads_a_full_64_entry_file() {
|
||||
// Real discs (Freedom, Dukes) carry all 64 slots present. Every key must
|
||||
// come back, none dropped and none drifted — the regression the 32-byte
|
||||
// stride failed.
|
||||
let keys: Vec<[u8; 16]> = (0..VTKF_MAX_ENTRIES).map(|n| [n as u8; 16]).collect();
|
||||
let ukf = parse_vtkf(&synth_vtkf(&keys)).expect("64-entry VTKF");
|
||||
assert_eq!(ukf.encrypted_keys.len(), 64);
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys[63],
|
||||
(64, [63u8; 16]),
|
||||
"entry 64 at 0x{:x}",
|
||||
VTKF_HEADER_LEN + 63 * VTKF_ENTRY_LEN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_rejects_a_file_cut_inside_the_entry_table() {
|
||||
let full = synth_vtkf(&[[0x11u8; 16], [0x22u8; 16]]);
|
||||
let cut = VTKF_HEADER_LEN + 10 * VTKF_ENTRY_LEN;
|
||||
assert!(parse_vtkf(&full[..cut]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_rejects_non_magic() {
|
||||
let mut data = synth_vtkf(&[[0x11u8; 16]]);
|
||||
data[0] = b'X'; // corrupt magic
|
||||
assert!(
|
||||
parse_vtkf(&data).is_none(),
|
||||
"non-VTKF magic must be rejected"
|
||||
);
|
||||
assert!(
|
||||
parse_vtkf(&[0u8; 4]).is_none(),
|
||||
"too short must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_title_keys_dispatches_by_magic() {
|
||||
// VTKF magic → parse_vtkf.
|
||||
let data = synth_vtkf(&[[0x44u8; 16], [0x55u8; 16]]);
|
||||
let ukf = parse_title_keys(&data, AacsVersion::V10).expect("VTKF dispatch");
|
||||
assert_eq!(ukf.encrypted_keys.len(), 2);
|
||||
|
||||
// Non-VTKF → parse_unit_key_ro (a 2-byte buffer is not a valid inf, so
|
||||
// this proves it ROUTED to the BD parser rather than parse_vtkf).
|
||||
assert!(
|
||||
parse_title_keys(&[0x00, 0x00], AacsVersion::V10).is_none(),
|
||||
"non-magic input must route to parse_unit_key_ro"
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole point of the seam: a parsed VTKF feeds the SHARED VUK→title-key
|
||||
/// crypto (`decrypt_unit_key`) exactly like a BD `Unit_Key_RO.inf` would —
|
||||
/// no HD-DVD-specific crypto path.
|
||||
#[test]
|
||||
fn vtkf_encrypted_keys_feed_shared_vuk_unwrap() {
|
||||
let enc = [0x9Au8; 16];
|
||||
let data = synth_vtkf(&[enc]);
|
||||
let ukf = parse_vtkf(&data).unwrap();
|
||||
let vuk = [0x5Cu8; 16];
|
||||
let derived = super::super::derive::decrypt_unit_key(&vuk, &ukf.encrypted_keys[0].1);
|
||||
// Same as applying the shared unwrap directly to the stored enc key.
|
||||
assert_eq!(derived, super::super::derive::decrypt_unit_key(&vuk, &enc));
|
||||
}
|
||||
|
||||
// Sentinel byte 0xD5 = decimal 213: a derived Debug would render it in decimal.
|
||||
#[test]
|
||||
fn unit_key_file_debug_is_redacted() {
|
||||
let f = UnitKeyFile {
|
||||
disc_hash: [0xD5; 20],
|
||||
app_type: 1,
|
||||
num_bdmv_dir: 1,
|
||||
use_skb_mkb: false,
|
||||
version: AacsVersion::V20,
|
||||
encrypted_keys: vec![(0, [0xD5; 16]), (1, [0xD5; 16])],
|
||||
title_cps_unit: vec![0, 1],
|
||||
};
|
||||
let dbg = format!("{f:?}");
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"UnitKeyFile Debug leaked key bytes (decimal 213): {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"UnitKeyFile Debug missing redaction marker: {dbg}"
|
||||
);
|
||||
// Non-secret shape is still useful for diagnostics.
|
||||
assert!(dbg.contains("encrypted_keys_len: 2"), "{dbg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inf_read_mkb_tests.rs"]
|
||||
mod read_mkb_tests;
|
||||
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],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Pins the CONTENT: concatenated pack payloads, in pack order, byte for byte.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_returns_the_concatenated_pack_payload() {
|
||||
let pack0: Vec<u8> = (0..600u32).map(|i| (i % 251) as u8).collect();
|
||||
let pack1: Vec<u8> = (0..300u32).map(|i| (i % 253) as u8 ^ 0xA5).collect();
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![pack0.clone(), pack1.clone()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
|
||||
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
|
||||
|
||||
let mut expected = pack0.clone();
|
||||
expected.extend_from_slice(&pack1);
|
||||
assert_eq!(
|
||||
mkb.len(),
|
||||
expected.len(),
|
||||
"every pack's payload must be concatenated, none dropped"
|
||||
);
|
||||
assert!(
|
||||
mkb == expected,
|
||||
"MKB bytes must be the drive's payload in pack order; first \
|
||||
mismatch at {:?}",
|
||||
(0..expected.len()).find(|&i| mkb[i] != expected[i])
|
||||
);
|
||||
|
||||
// MMC-6 READ DISC STRUCTURE with the AACS MKB format code, one command
|
||||
// per pack, pack number in the address field.
|
||||
assert_eq!(drive.cdbs.len(), 2, "one command per declared pack");
|
||||
for (i, cdb) in drive.cdbs.iter().enumerate() {
|
||||
assert_eq!(cdb[0], SCSI_READ_DISC_STRUCTURE, "opcode");
|
||||
assert_eq!(cdb[7], 0x83, "AACS MKB disc-structure format code");
|
||||
assert_eq!(
|
||||
u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]),
|
||||
i as u32,
|
||||
"pack {i} must be requested by number"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pins the WHOLE 12-byte CDB (MMC-6 READ DISC STRUCTURE, AACS MKB format) so no field can
|
||||
// drift.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_issues_the_exact_mmc_cdb_for_each_pack() {
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![vec![0x11u8; 64], vec![0x22u8; 64], vec![0x33u8; 64]],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
read_mkb_from_drive(&mut drive).expect("scripted drive answers");
|
||||
|
||||
assert_eq!(drive.cdbs.len(), 3, "one command per declared pack");
|
||||
for (pack, cdb) in drive.cdbs.iter().enumerate() {
|
||||
let p = pack as u32;
|
||||
let expected: [u8; 12] = [
|
||||
SCSI_READ_DISC_STRUCTURE,
|
||||
0x01,
|
||||
(p >> 24) as u8,
|
||||
(p >> 16) as u8,
|
||||
(p >> 8) as u8,
|
||||
p as u8,
|
||||
0x00,
|
||||
0x83, // AACS MKB disc-structure format
|
||||
0x80, // allocation length 32772 = 0x8004, high byte
|
||||
0x04, // …low byte
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
assert_eq!(
|
||||
cdb.as_slice(),
|
||||
&expected[..],
|
||||
"CDB for pack {pack} must match the MMC-6 READ DISC STRUCTURE layout"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A full 32768-byte pack must come back whole; small payloads elsewhere never exercise this
|
||||
// bound.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_accepts_a_full_size_pack() {
|
||||
let full: Vec<u8> = (0..32768u32).map(|i| (i % 251) as u8).collect();
|
||||
let other: Vec<u8> = (0..32768u32).map(|i| (i % 241) as u8 ^ 0x5A).collect();
|
||||
// TWO maximal packs: the first-pack read and the per-pack loop carry
|
||||
// separate bounds, so both must accept a full-window payload.
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![full.clone(), other.clone()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
|
||||
assert_eq!(
|
||||
mkb.len(),
|
||||
65536,
|
||||
"neither maximal pack may be dropped at the size bound"
|
||||
);
|
||||
let mut expected = full.clone();
|
||||
expected.extend_from_slice(&other);
|
||||
assert!(mkb == expected, "both maximal packs' bytes must be intact");
|
||||
}
|
||||
|
||||
/// A header-only pack inside a multi-pack MKB is a hole: fail rather than return a
|
||||
/// silently shortened MKB. A single-pack header-only response stays an empty MKB.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_zero_length_pack_contributes_nothing() {
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![Vec::new(), vec![0xABu8; 32]],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
assert!(matches!(
|
||||
read_mkb_from_drive(&mut drive),
|
||||
Err(crate::error::Error::AacsKeyRead)
|
||||
));
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![vec![0xABu8; 32], Vec::new()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
assert!(matches!(
|
||||
read_mkb_from_drive(&mut drive),
|
||||
Err(crate::error::Error::AacsKeyRead)
|
||||
));
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![Vec::new()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
read_mkb_from_drive(&mut drive).expect("single pack"),
|
||||
Vec::<u8>::new()
|
||||
);
|
||||
}
|
||||
|
||||
// A drive-declared length past the 32772-byte buffer is a drive fault: the MKB
|
||||
// would have a hole, so fail rather than return a corrupt MKB as Ok.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_rejects_a_pack_declaring_more_than_the_buffer_holds() {
|
||||
/// Pack 0 is honest; pack 1 declares a 60000-byte payload it never sent.
|
||||
struct LyingDrive {
|
||||
honest: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for LyingDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]);
|
||||
data[3] = 2; // two packs declared
|
||||
if pack == 0 {
|
||||
let dl = self.honest.len() + 2;
|
||||
data[0..2].copy_from_slice(&(dl as u16).to_be_bytes());
|
||||
data[4..4 + self.honest.len()].copy_from_slice(&self.honest);
|
||||
} else {
|
||||
// A length far beyond the 32772-byte response buffer.
|
||||
data[0..2].copy_from_slice(&60_000u16.to_be_bytes());
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let mut drive = LyingDrive {
|
||||
honest: vec![0xC7u8; 256],
|
||||
};
|
||||
let err = read_mkb_from_drive(&mut drive).expect_err("a holed MKB is not Ok");
|
||||
assert_eq!(err.code(), crate::error::Error::AacsKeyRead.code());
|
||||
}
|
||||
|
||||
/// The same over-declaration on the FIRST pack, which uses a separate bound
|
||||
/// from the loop's.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_rejects_a_first_pack_declaring_more_than_the_buffer() {
|
||||
struct LyingFirst;
|
||||
impl ScsiTransport for LyingFirst {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
data[0..2].copy_from_slice(&60_000u16.to_be_bytes());
|
||||
data[3] = 1;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let err = read_mkb_from_drive(&mut LyingFirst).expect_err("over-declared first pack");
|
||||
assert_eq!(err.code(), crate::error::Error::AacsKeyRead.code());
|
||||
}
|
||||
|
||||
/// A drive declaring a payload but transferring fewer bytes (zero-filled tail) is a
|
||||
/// fault; so is a header-only pack after a real first pack (a hole).
|
||||
#[test]
|
||||
fn read_mkb_from_drive_rejects_short_transfer_and_holed_pack() {
|
||||
struct Scripted {
|
||||
declared: [u16; 2],
|
||||
xfer: usize,
|
||||
}
|
||||
impl ScsiTransport for Scripted {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
let pack = cdb[5] as usize;
|
||||
data[0..2].copy_from_slice(&self.declared[pack].to_be_bytes());
|
||||
data[3] = 2;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: if pack == 0 { self.xfer } else { 4 },
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let short = read_mkb_from_drive(&mut Scripted {
|
||||
declared: [102, 2],
|
||||
xfer: 4,
|
||||
})
|
||||
.expect_err("short transfer");
|
||||
assert_eq!(short.code(), crate::error::Error::AacsKeyRead.code());
|
||||
let hole = read_mkb_from_drive(&mut Scripted {
|
||||
declared: [102, 0],
|
||||
xfer: 104,
|
||||
})
|
||||
.expect_err("holed pack");
|
||||
assert_eq!(hole.code(), crate::error::Error::AacsKeyRead.code());
|
||||
}
|
||||
|
||||
/// A single-pack disc still yields that pack's bytes — the common case, and
|
||||
/// the one where a body returning an empty vector looks most plausible.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_returns_a_single_packs_payload() {
|
||||
let pack: Vec<u8> = (0..1024u32).map(|i| (i * 7 % 256) as u8).collect();
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![pack.clone()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
|
||||
assert_eq!(mkb.len(), pack.len(), "single pack payload length");
|
||||
assert!(mkb == pack, "single pack payload bytes");
|
||||
}
|
||||
|
||||
/// A drive that reports a header-only response (`data_len < 2`) has no MKB
|
||||
/// to give. That must be an EMPTY vec, not a partial one — the distinction
|
||||
/// matters because the AACS paths treat a non-empty MKB as parseable.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_empty_response_is_empty() {
|
||||
struct NoMkb;
|
||||
impl ScsiTransport for NoMkb {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
data[0..2].copy_from_slice(&0u16.to_be_bytes());
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mkb = read_mkb_from_drive(&mut NoMkb).expect("no-MKB drive still returns Ok");
|
||||
assert!(
|
||||
mkb.is_empty(),
|
||||
"a header-only response carries no MKB bytes"
|
||||
);
|
||||
}
|
||||
|
||||
/// A header the drive never transferred, or a zero-length first pack of a multi-pack MKB,
|
||||
/// is a drive fault: `AacsKeyRead`, not an empty MKB.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_untransferred_header_and_holed_first_pack_fail() {
|
||||
struct Scripted {
|
||||
xfer: usize,
|
||||
num_packs: u8,
|
||||
}
|
||||
impl ScsiTransport for Scripted {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
data[0..2].copy_from_slice(&0u16.to_be_bytes());
|
||||
data[3] = self.num_packs;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: self.xfer,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
for (xfer, num_packs) in [(0, 0), (4, 2)] {
|
||||
assert!(
|
||||
matches!(
|
||||
read_mkb_from_drive(&mut Scripted { xfer, num_packs }),
|
||||
Err(crate::error::Error::AacsKeyRead)
|
||||
),
|
||||
"xfer {xfer}, packs {num_packs}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport failure on the FIRST pack must propagate as an error — the
|
||||
/// MKB is the root of the whole AACS ladder, so an unreadable one cannot be
|
||||
/// downgraded to "an MKB with no records".
|
||||
#[test]
|
||||
fn read_mkb_from_drive_propagates_the_first_pack_failure() {
|
||||
struct DeadDrive;
|
||||
impl ScsiTransport for DeadDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
Err(crate::error::Error::ScsiError {
|
||||
opcode: SCSI_READ_DISC_STRUCTURE,
|
||||
status: 0x02,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
read_mkb_from_drive(&mut DeadDrive).is_err(),
|
||||
"an unreadable MKB must surface as an error, not an empty MKB"
|
||||
);
|
||||
}
|
||||
|
||||
/// A transport failure on a NON-first pack must ALSO propagate. Before the
|
||||
/// fix the per-pack loop tested `.is_ok()` and dropped the error, so a
|
||||
/// mid-walk failure silently TRUNCATED the MKB and returned the partial
|
||||
/// pack-0 data as Ok. Pack 0 succeeds, pack 1 errors → the whole read errors.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_propagates_a_mid_walk_pack_failure() {
|
||||
struct FlakyDrive;
|
||||
impl ScsiTransport for FlakyDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]);
|
||||
if pack == 0 {
|
||||
// Honest first pack: 64 payload bytes, TWO packs declared so
|
||||
// the loop goes on to request pack 1.
|
||||
let body = [0x5Au8; 64];
|
||||
data[0..2].copy_from_slice(&((body.len() + 2) as u16).to_be_bytes());
|
||||
data[2] = 0x00;
|
||||
data[3] = 2;
|
||||
data[4..4 + body.len()].copy_from_slice(&body);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4 + body.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
} else {
|
||||
// Pack 1 read fails mid-walk.
|
||||
Err(crate::error::Error::ScsiError {
|
||||
opcode: SCSI_READ_DISC_STRUCTURE,
|
||||
status: 0x02,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
read_mkb_from_drive(&mut FlakyDrive).is_err(),
|
||||
"a transport error on pack 1 must surface, not truncate the MKB to pack 0"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inf_unit_key_ro_tests.rs"]
|
||||
mod unit_key_ro_tests;
|
||||
mod unit_key_ro_tests {
|
||||
use super::*;
|
||||
|
||||
/// Finding 17: a Unit_Key_RO.inf that parses its key list fully but declares
|
||||
/// more title entries than the buffer can hold is TRUNCATED and must be
|
||||
/// rejected. Pre-fix the loop silently skipped the missing entries and
|
||||
/// returned `Some` with a short title→CPS map (mirrors the key-list check).
|
||||
#[test]
|
||||
fn parse_unit_key_ro_rejects_truncated_title_table() {
|
||||
let uk_pos = 26usize;
|
||||
let mut data = vec![0u8; 90];
|
||||
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes()); // key storage @26
|
||||
data[24..26].copy_from_slice(&20u16.to_be_bytes()); // num_titles=20 (needs bytes to 106)
|
||||
data[uk_pos..uk_pos + 2].copy_from_slice(&1u16.to_be_bytes()); // num_uk=1
|
||||
// One present key at uk_pos+48 = 74..90 (V10 48-byte stride) so the key
|
||||
// list parses fully; only the title table overruns the 90-byte buffer.
|
||||
data[74..90].copy_from_slice(&[0xAB; 16]);
|
||||
assert!(
|
||||
parse_unit_key_ro(&data, AacsVersion::V10).is_none(),
|
||||
"a title table that runs past the buffer is truncated → reject"
|
||||
);
|
||||
}
|
||||
|
||||
/// Fixture guard for the test above: the SAME buffer with an honest title
|
||||
/// count parses to `Some`, proving it is only the truncation that is rejected
|
||||
/// (not some unrelated malformation).
|
||||
#[test]
|
||||
fn parse_unit_key_ro_accepts_the_same_buffer_with_an_honest_title_count() {
|
||||
let uk_pos = 26usize;
|
||||
let mut data = vec![0u8; 90];
|
||||
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
|
||||
data[24..26].copy_from_slice(&15u16.to_be_bytes()); // 15 titles: last entry ends at 88 <= 90
|
||||
data[uk_pos..uk_pos + 2].copy_from_slice(&1u16.to_be_bytes());
|
||||
data[74..90].copy_from_slice(&[0xAB; 16]);
|
||||
let ukf = parse_unit_key_ro(&data, AacsVersion::V10).expect("fits → Some");
|
||||
assert_eq!(ukf.encrypted_keys.len(), 1);
|
||||
assert_eq!(ukf.title_cps_unit.len(), 2 + 15); // first_play + top_menu + 15 titles
|
||||
}
|
||||
|
||||
/// Finding 15: a malformed key-storage offset pointing far past the buffer
|
||||
/// must be rejected, never indexed. The `checked_add` guards also stop the
|
||||
/// offset arithmetic (`uk_pos + 2`, `uk_pos + 48`) from overflowing `usize`
|
||||
/// on 32-bit targets rather than panicking.
|
||||
#[test]
|
||||
fn parse_unit_key_ro_rejects_out_of_range_key_offset() {
|
||||
let mut data = vec![0u8; 32];
|
||||
data[0..4].copy_from_slice(&u32::MAX.to_be_bytes()); // uk_pos = 0xFFFFFFFF
|
||||
assert!(
|
||||
parse_unit_key_ro(&data, AacsVersion::V10).is_none(),
|
||||
"a key-storage offset past the buffer must not be indexed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inf_content_cert_tests.rs"]
|
||||
mod content_cert_tests;
|
||||
mod content_cert_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_content_cert_reads_cc_id_from_bytes_14_to_20() {
|
||||
let mut data = vec![0u8; 20];
|
||||
data[1] = 0x80;
|
||||
for (i, b) in data[14..20].iter_mut().enumerate() {
|
||||
*b = 0xA0 + i as u8;
|
||||
}
|
||||
data[13] = 0xEE;
|
||||
let cert = parse_content_cert(&data).expect("20-byte cert parses");
|
||||
assert_eq!(cert.cc_id, [0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5]);
|
||||
assert!(cert.bus_encryption);
|
||||
assert!(
|
||||
parse_content_cert(&data[..19]).is_none(),
|
||||
"19 bytes is short"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_content_cert_reads_cc_id_from_bytes_14_to_20() {
|
||||
let mut data = vec![0u8; 20];
|
||||
data[1] = 0x80;
|
||||
for (i, b) in data[14..20].iter_mut().enumerate() {
|
||||
*b = 0xA0 + i as u8;
|
||||
}
|
||||
data[13] = 0xEE;
|
||||
let cert = parse_content_cert(&data).expect("20-byte cert parses");
|
||||
assert_eq!(cert.cc_id, [0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5]);
|
||||
assert!(cert.bus_encryption);
|
||||
assert!(
|
||||
parse_content_cert(&data[..19]).is_none(),
|
||||
"19 bytes is short"
|
||||
);
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
use super::*;
|
||||
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE, ScsiResult, ScsiTransport};
|
||||
|
||||
/// A drive that answers READ DISC STRUCTURE format 0x83 from a scripted set
|
||||
/// of packs and records every CDB it was handed.
|
||||
struct MkbDrive {
|
||||
/// One entry per pack: the pack's MKB payload bytes.
|
||||
packs: Vec<Vec<u8>>,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ScsiTransport for MkbDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
// Pack number is carried in the CDB address field (bytes 2..6),
|
||||
// MMC-6 READ DISC STRUCTURE.
|
||||
let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]) as usize;
|
||||
let body = self.packs.get(pack).cloned().unwrap_or_default();
|
||||
// Header: BE16 data length (counts the 2 header bytes that follow
|
||||
// it plus the payload), reserved byte, pack count, then payload.
|
||||
let data_len = body.len() + 2;
|
||||
data[0..2].copy_from_slice(&(data_len as u16).to_be_bytes());
|
||||
data[2] = 0x00;
|
||||
data[3] = self.packs.len() as u8;
|
||||
data[4..4 + body.len()].copy_from_slice(&body);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4 + body.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Pins the CONTENT: concatenated pack payloads, in pack order, byte for byte.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_returns_the_concatenated_pack_payload() {
|
||||
let pack0: Vec<u8> = (0..600u32).map(|i| (i % 251) as u8).collect();
|
||||
let pack1: Vec<u8> = (0..300u32).map(|i| (i % 253) as u8 ^ 0xA5).collect();
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![pack0.clone(), pack1.clone()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
|
||||
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
|
||||
|
||||
let mut expected = pack0.clone();
|
||||
expected.extend_from_slice(&pack1);
|
||||
assert_eq!(
|
||||
mkb.len(),
|
||||
expected.len(),
|
||||
"every pack's payload must be concatenated, none dropped"
|
||||
);
|
||||
assert!(
|
||||
mkb == expected,
|
||||
"MKB bytes must be the drive's payload in pack order; first \
|
||||
mismatch at {:?}",
|
||||
(0..expected.len()).find(|&i| mkb[i] != expected[i])
|
||||
);
|
||||
|
||||
// MMC-6 READ DISC STRUCTURE with the AACS MKB format code, one command
|
||||
// per pack, pack number in the address field.
|
||||
assert_eq!(drive.cdbs.len(), 2, "one command per declared pack");
|
||||
for (i, cdb) in drive.cdbs.iter().enumerate() {
|
||||
assert_eq!(cdb[0], SCSI_READ_DISC_STRUCTURE, "opcode");
|
||||
assert_eq!(cdb[7], 0x83, "AACS MKB disc-structure format code");
|
||||
assert_eq!(
|
||||
u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]),
|
||||
i as u32,
|
||||
"pack {i} must be requested by number"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pins the WHOLE 12-byte CDB (MMC-6 READ DISC STRUCTURE, AACS MKB format) so no field can
|
||||
// drift.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_issues_the_exact_mmc_cdb_for_each_pack() {
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![vec![0x11u8; 64], vec![0x22u8; 64], vec![0x33u8; 64]],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
read_mkb_from_drive(&mut drive).expect("scripted drive answers");
|
||||
|
||||
assert_eq!(drive.cdbs.len(), 3, "one command per declared pack");
|
||||
for (pack, cdb) in drive.cdbs.iter().enumerate() {
|
||||
let p = pack as u32;
|
||||
let expected: [u8; 12] = [
|
||||
SCSI_READ_DISC_STRUCTURE,
|
||||
0x01,
|
||||
(p >> 24) as u8,
|
||||
(p >> 16) as u8,
|
||||
(p >> 8) as u8,
|
||||
p as u8,
|
||||
0x00,
|
||||
0x83, // AACS MKB disc-structure format
|
||||
0x80, // allocation length 32772 = 0x8004, high byte
|
||||
0x04, // …low byte
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
assert_eq!(
|
||||
cdb.as_slice(),
|
||||
&expected[..],
|
||||
"CDB for pack {pack} must match the MMC-6 READ DISC STRUCTURE layout"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A full 32768-byte pack must come back whole; small payloads elsewhere never exercise this
|
||||
// bound.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_accepts_a_full_size_pack() {
|
||||
let full: Vec<u8> = (0..32768u32).map(|i| (i % 251) as u8).collect();
|
||||
let other: Vec<u8> = (0..32768u32).map(|i| (i % 241) as u8 ^ 0x5A).collect();
|
||||
// TWO maximal packs: the first-pack read and the per-pack loop carry
|
||||
// separate bounds, so both must accept a full-window payload.
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![full.clone(), other.clone()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
|
||||
assert_eq!(
|
||||
mkb.len(),
|
||||
65536,
|
||||
"neither maximal pack may be dropped at the size bound"
|
||||
);
|
||||
let mut expected = full.clone();
|
||||
expected.extend_from_slice(&other);
|
||||
assert!(mkb == expected, "both maximal packs' bytes must be intact");
|
||||
}
|
||||
|
||||
/// A header-only pack inside a multi-pack MKB is a hole: fail rather than return a
|
||||
/// silently shortened MKB. A single-pack header-only response stays an empty MKB.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_zero_length_pack_contributes_nothing() {
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![Vec::new(), vec![0xABu8; 32]],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
assert!(matches!(
|
||||
read_mkb_from_drive(&mut drive),
|
||||
Err(crate::error::Error::AacsKeyRead)
|
||||
));
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![vec![0xABu8; 32], Vec::new()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
assert!(matches!(
|
||||
read_mkb_from_drive(&mut drive),
|
||||
Err(crate::error::Error::AacsKeyRead)
|
||||
));
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![Vec::new()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
read_mkb_from_drive(&mut drive).expect("single pack"),
|
||||
Vec::<u8>::new()
|
||||
);
|
||||
}
|
||||
|
||||
// A drive-declared length past the 32772-byte buffer is a drive fault: the MKB
|
||||
// would have a hole, so fail rather than return a corrupt MKB as Ok.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_rejects_a_pack_declaring_more_than_the_buffer_holds() {
|
||||
/// Pack 0 is honest; pack 1 declares a 60000-byte payload it never sent.
|
||||
struct LyingDrive {
|
||||
honest: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for LyingDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]);
|
||||
data[3] = 2; // two packs declared
|
||||
if pack == 0 {
|
||||
let dl = self.honest.len() + 2;
|
||||
data[0..2].copy_from_slice(&(dl as u16).to_be_bytes());
|
||||
data[4..4 + self.honest.len()].copy_from_slice(&self.honest);
|
||||
} else {
|
||||
// A length far beyond the 32772-byte response buffer.
|
||||
data[0..2].copy_from_slice(&60_000u16.to_be_bytes());
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let mut drive = LyingDrive {
|
||||
honest: vec![0xC7u8; 256],
|
||||
};
|
||||
let err = read_mkb_from_drive(&mut drive).expect_err("a holed MKB is not Ok");
|
||||
assert_eq!(err.code(), crate::error::Error::AacsKeyRead.code());
|
||||
}
|
||||
|
||||
/// The same over-declaration on the FIRST pack, which uses a separate bound
|
||||
/// from the loop's.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_rejects_a_first_pack_declaring_more_than_the_buffer() {
|
||||
struct LyingFirst;
|
||||
impl ScsiTransport for LyingFirst {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
data[0..2].copy_from_slice(&60_000u16.to_be_bytes());
|
||||
data[3] = 1;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let err = read_mkb_from_drive(&mut LyingFirst).expect_err("over-declared first pack");
|
||||
assert_eq!(err.code(), crate::error::Error::AacsKeyRead.code());
|
||||
}
|
||||
|
||||
/// A drive declaring a payload but transferring fewer bytes (zero-filled tail) is a
|
||||
/// fault; so is a header-only pack after a real first pack (a hole).
|
||||
#[test]
|
||||
fn read_mkb_from_drive_rejects_short_transfer_and_holed_pack() {
|
||||
struct Scripted {
|
||||
declared: [u16; 2],
|
||||
xfer: usize,
|
||||
}
|
||||
impl ScsiTransport for Scripted {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
let pack = cdb[5] as usize;
|
||||
data[0..2].copy_from_slice(&self.declared[pack].to_be_bytes());
|
||||
data[3] = 2;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: if pack == 0 { self.xfer } else { 4 },
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let short = read_mkb_from_drive(&mut Scripted {
|
||||
declared: [102, 2],
|
||||
xfer: 4,
|
||||
})
|
||||
.expect_err("short transfer");
|
||||
assert_eq!(short.code(), crate::error::Error::AacsKeyRead.code());
|
||||
let hole = read_mkb_from_drive(&mut Scripted {
|
||||
declared: [102, 0],
|
||||
xfer: 104,
|
||||
})
|
||||
.expect_err("holed pack");
|
||||
assert_eq!(hole.code(), crate::error::Error::AacsKeyRead.code());
|
||||
}
|
||||
|
||||
/// A single-pack disc still yields that pack's bytes — the common case, and
|
||||
/// the one where a body returning an empty vector looks most plausible.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_returns_a_single_packs_payload() {
|
||||
let pack: Vec<u8> = (0..1024u32).map(|i| (i * 7 % 256) as u8).collect();
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![pack.clone()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
|
||||
assert_eq!(mkb.len(), pack.len(), "single pack payload length");
|
||||
assert!(mkb == pack, "single pack payload bytes");
|
||||
}
|
||||
|
||||
/// A drive that reports a header-only response (`data_len < 2`) has no MKB
|
||||
/// to give. That must be an EMPTY vec, not a partial one — the distinction
|
||||
/// matters because the AACS paths treat a non-empty MKB as parseable.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_empty_response_is_empty() {
|
||||
struct NoMkb;
|
||||
impl ScsiTransport for NoMkb {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
data[0..2].copy_from_slice(&0u16.to_be_bytes());
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mkb = read_mkb_from_drive(&mut NoMkb).expect("no-MKB drive still returns Ok");
|
||||
assert!(
|
||||
mkb.is_empty(),
|
||||
"a header-only response carries no MKB bytes"
|
||||
);
|
||||
}
|
||||
|
||||
/// A header the drive never transferred, or a zero-length first pack of a multi-pack MKB,
|
||||
/// is a drive fault: `AacsKeyRead`, not an empty MKB.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_untransferred_header_and_holed_first_pack_fail() {
|
||||
struct Scripted {
|
||||
xfer: usize,
|
||||
num_packs: u8,
|
||||
}
|
||||
impl ScsiTransport for Scripted {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
data[0..2].copy_from_slice(&0u16.to_be_bytes());
|
||||
data[3] = self.num_packs;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: self.xfer,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
for (xfer, num_packs) in [(0, 0), (4, 2)] {
|
||||
assert!(
|
||||
matches!(
|
||||
read_mkb_from_drive(&mut Scripted { xfer, num_packs }),
|
||||
Err(crate::error::Error::AacsKeyRead)
|
||||
),
|
||||
"xfer {xfer}, packs {num_packs}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport failure on the FIRST pack must propagate as an error — the
|
||||
/// MKB is the root of the whole AACS ladder, so an unreadable one cannot be
|
||||
/// downgraded to "an MKB with no records".
|
||||
#[test]
|
||||
fn read_mkb_from_drive_propagates_the_first_pack_failure() {
|
||||
struct DeadDrive;
|
||||
impl ScsiTransport for DeadDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
Err(crate::error::Error::ScsiError {
|
||||
opcode: SCSI_READ_DISC_STRUCTURE,
|
||||
status: 0x02,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
read_mkb_from_drive(&mut DeadDrive).is_err(),
|
||||
"an unreadable MKB must surface as an error, not an empty MKB"
|
||||
);
|
||||
}
|
||||
|
||||
/// A transport failure on a NON-first pack must ALSO propagate. Before the
|
||||
/// fix the per-pack loop tested `.is_ok()` and dropped the error, so a
|
||||
/// mid-walk failure silently TRUNCATED the MKB and returned the partial
|
||||
/// pack-0 data as Ok. Pack 0 succeeds, pack 1 errors → the whole read errors.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_propagates_a_mid_walk_pack_failure() {
|
||||
struct FlakyDrive;
|
||||
impl ScsiTransport for FlakyDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]);
|
||||
if pack == 0 {
|
||||
// Honest first pack: 64 payload bytes, TWO packs declared so
|
||||
// the loop goes on to request pack 1.
|
||||
let body = [0x5Au8; 64];
|
||||
data[0..2].copy_from_slice(&((body.len() + 2) as u16).to_be_bytes());
|
||||
data[2] = 0x00;
|
||||
data[3] = 2;
|
||||
data[4..4 + body.len()].copy_from_slice(&body);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4 + body.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
} else {
|
||||
// Pack 1 read fails mid-walk.
|
||||
Err(crate::error::Error::ScsiError {
|
||||
opcode: SCSI_READ_DISC_STRUCTURE,
|
||||
status: 0x02,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
read_mkb_from_drive(&mut FlakyDrive).is_err(),
|
||||
"a transport error on pack 1 must surface, not truncate the MKB to pack 0"
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// Finding 17: a Unit_Key_RO.inf that parses its key list fully but declares
|
||||
/// more title entries than the buffer can hold is TRUNCATED and must be
|
||||
/// rejected. Pre-fix the loop silently skipped the missing entries and
|
||||
/// returned `Some` with a short title→CPS map (mirrors the key-list check).
|
||||
#[test]
|
||||
fn parse_unit_key_ro_rejects_truncated_title_table() {
|
||||
let uk_pos = 26usize;
|
||||
let mut data = vec![0u8; 90];
|
||||
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes()); // key storage @26
|
||||
data[24..26].copy_from_slice(&20u16.to_be_bytes()); // num_titles=20 (needs bytes to 106)
|
||||
data[uk_pos..uk_pos + 2].copy_from_slice(&1u16.to_be_bytes()); // num_uk=1
|
||||
// One present key at uk_pos+48 = 74..90 (V10 48-byte stride) so the key
|
||||
// list parses fully; only the title table overruns the 90-byte buffer.
|
||||
data[74..90].copy_from_slice(&[0xAB; 16]);
|
||||
assert!(
|
||||
parse_unit_key_ro(&data, AacsVersion::V10).is_none(),
|
||||
"a title table that runs past the buffer is truncated → reject"
|
||||
);
|
||||
}
|
||||
|
||||
/// Fixture guard for the test above: the SAME buffer with an honest title
|
||||
/// count parses to `Some`, proving it is only the truncation that is rejected
|
||||
/// (not some unrelated malformation).
|
||||
#[test]
|
||||
fn parse_unit_key_ro_accepts_the_same_buffer_with_an_honest_title_count() {
|
||||
let uk_pos = 26usize;
|
||||
let mut data = vec![0u8; 90];
|
||||
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
|
||||
data[24..26].copy_from_slice(&15u16.to_be_bytes()); // 15 titles: last entry ends at 88 <= 90
|
||||
data[uk_pos..uk_pos + 2].copy_from_slice(&1u16.to_be_bytes());
|
||||
data[74..90].copy_from_slice(&[0xAB; 16]);
|
||||
let ukf = parse_unit_key_ro(&data, AacsVersion::V10).expect("fits → Some");
|
||||
assert_eq!(ukf.encrypted_keys.len(), 1);
|
||||
assert_eq!(ukf.title_cps_unit.len(), 2 + 15); // first_play + top_menu + 15 titles
|
||||
}
|
||||
|
||||
/// Finding 15: a malformed key-storage offset pointing far past the buffer
|
||||
/// must be rejected, never indexed. The `checked_add` guards also stop the
|
||||
/// offset arithmetic (`uk_pos + 2`, `uk_pos + 48`) from overflowing `usize`
|
||||
/// on 32-bit targets rather than panicking.
|
||||
#[test]
|
||||
fn parse_unit_key_ro_rejects_out_of_range_key_offset() {
|
||||
let mut data = vec![0u8; 32];
|
||||
data[0..4].copy_from_slice(&u32::MAX.to_be_bytes()); // uk_pos = 0xFFFFFFFF
|
||||
assert!(
|
||||
parse_unit_key_ro(&data, AacsVersion::V10).is_none(),
|
||||
"a key-storage offset past the buffer must not be indexed"
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// Synthesize a `VTKF%%%.AACS`: magic, BE32 size, playlist name, reserved to 0x80, 64 entry
|
||||
// slots (first `keys.len()` present), MAC trailer.
|
||||
fn synth_vtkf(keys: &[[u8; 16]]) -> Vec<u8> {
|
||||
const FILE_LEN: usize = 2480;
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(VTKF_MAGIC); // 0x00
|
||||
v.extend_from_slice(&(FILE_LEN as u32).to_be_bytes()); // 0x0C HD_VTKF_SIZE
|
||||
v.extend_from_slice(b"VPLST000.XPL"); // 0x10 playlist name
|
||||
v.resize(VTKF_HEADER_LEN, 0); // reserve to first entry (0x80)
|
||||
for n in 0..VTKF_MAX_ENTRIES {
|
||||
if let Some(k) = keys.get(n) {
|
||||
v.push(VTKF_AV_FLG); // BIFO: AV_FLG set (present)
|
||||
v.extend_from_slice(&[0, 0, 0]); // reserved
|
||||
v.extend_from_slice(k); // 16-byte encrypted title key
|
||||
v.extend_from_slice(&[0xFFu8; 16]); // binding MAC (0xFF, pre-recorded)
|
||||
} else {
|
||||
v.extend_from_slice(&[0u8; VTKF_ENTRY_LEN]); // empty slot (AV_FLG clear)
|
||||
}
|
||||
}
|
||||
v.resize(FILE_LEN - 16, 0); // reserved gap before the trailer
|
||||
v.extend_from_slice(&[0xABu8; 16]); // TKF MAC (must NOT be read as a key)
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_reads_present_entries_skips_empty_ignores_mac() {
|
||||
let k1 = [0x11u8; 16];
|
||||
let k2 = [0x22u8; 16];
|
||||
let k3 = [0x33u8; 16];
|
||||
let data = synth_vtkf(&[k1, k2, k3]);
|
||||
|
||||
let ukf = parse_vtkf(&data).expect("valid VTKF must parse");
|
||||
// Exactly the three present entries — empty slots and the trailing 16-byte
|
||||
// TKF MAC are not mistaken for keys. k2/k3 are read at the 36-byte stride
|
||||
// (0xA4, 0xC8); the old 32-byte stride misread them from the prior MAC.
|
||||
assert_eq!(ukf.encrypted_keys.len(), 3);
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys[0],
|
||||
(1, k1),
|
||||
"CPS units = 1-based slot index"
|
||||
);
|
||||
assert_eq!(ukf.encrypted_keys[1], (2, k2));
|
||||
assert_eq!(ukf.encrypted_keys[2], (3, k3));
|
||||
assert_eq!(ukf.version, AacsVersion::V10, "HD DVD is AACS 1.0");
|
||||
// disc_hash is SHA1 of the whole file (the KEYDB lookup key).
|
||||
assert_eq!(ukf.disc_hash, disc_hash(&data));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_reads_a_full_64_entry_file() {
|
||||
// Real discs (Freedom, Dukes) carry all 64 slots present. Every key must
|
||||
// come back, none dropped and none drifted — the regression the 32-byte
|
||||
// stride failed.
|
||||
let keys: Vec<[u8; 16]> = (0..VTKF_MAX_ENTRIES).map(|n| [n as u8; 16]).collect();
|
||||
let ukf = parse_vtkf(&synth_vtkf(&keys)).expect("64-entry VTKF");
|
||||
assert_eq!(ukf.encrypted_keys.len(), 64);
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys[63],
|
||||
(64, [63u8; 16]),
|
||||
"entry 64 at 0x{:x}",
|
||||
VTKF_HEADER_LEN + 63 * VTKF_ENTRY_LEN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_rejects_a_file_cut_inside_the_entry_table() {
|
||||
let full = synth_vtkf(&[[0x11u8; 16], [0x22u8; 16]]);
|
||||
let cut = VTKF_HEADER_LEN + 10 * VTKF_ENTRY_LEN;
|
||||
assert!(parse_vtkf(&full[..cut]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_rejects_non_magic() {
|
||||
let mut data = synth_vtkf(&[[0x11u8; 16]]);
|
||||
data[0] = b'X'; // corrupt magic
|
||||
assert!(
|
||||
parse_vtkf(&data).is_none(),
|
||||
"non-VTKF magic must be rejected"
|
||||
);
|
||||
assert!(
|
||||
parse_vtkf(&[0u8; 4]).is_none(),
|
||||
"too short must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_title_keys_dispatches_by_magic() {
|
||||
// VTKF magic → parse_vtkf.
|
||||
let data = synth_vtkf(&[[0x44u8; 16], [0x55u8; 16]]);
|
||||
let ukf = parse_title_keys(&data, AacsVersion::V10).expect("VTKF dispatch");
|
||||
assert_eq!(ukf.encrypted_keys.len(), 2);
|
||||
|
||||
// Non-VTKF → parse_unit_key_ro (a 2-byte buffer is not a valid inf, so
|
||||
// this proves it ROUTED to the BD parser rather than parse_vtkf).
|
||||
assert!(
|
||||
parse_title_keys(&[0x00, 0x00], AacsVersion::V10).is_none(),
|
||||
"non-magic input must route to parse_unit_key_ro"
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole point of the seam: a parsed VTKF feeds the SHARED VUK→title-key
|
||||
/// crypto (`decrypt_unit_key`) exactly like a BD `Unit_Key_RO.inf` would —
|
||||
/// no HD-DVD-specific crypto path.
|
||||
#[test]
|
||||
fn vtkf_encrypted_keys_feed_shared_vuk_unwrap() {
|
||||
let enc = [0x9Au8; 16];
|
||||
let data = synth_vtkf(&[enc]);
|
||||
let ukf = parse_vtkf(&data).unwrap();
|
||||
let vuk = [0x5Cu8; 16];
|
||||
let derived = super::super::derive::decrypt_unit_key(&vuk, &ukf.encrypted_keys[0].1);
|
||||
// Same as applying the shared unwrap directly to the stored enc key.
|
||||
assert_eq!(derived, super::super::derive::decrypt_unit_key(&vuk, &enc));
|
||||
}
|
||||
|
||||
// Sentinel byte 0xD5 = decimal 213: a derived Debug would render it in decimal.
|
||||
#[test]
|
||||
fn unit_key_file_debug_is_redacted() {
|
||||
let f = UnitKeyFile {
|
||||
disc_hash: [0xD5; 20],
|
||||
app_type: 1,
|
||||
num_bdmv_dir: 1,
|
||||
use_skb_mkb: false,
|
||||
version: AacsVersion::V20,
|
||||
encrypted_keys: vec![(0, [0xD5; 16]), (1, [0xD5; 16])],
|
||||
title_cps_unit: vec![0, 1],
|
||||
};
|
||||
let dbg = format!("{f:?}");
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"UnitKeyFile Debug leaked key bytes (decimal 213): {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"UnitKeyFile Debug missing redaction marker: {dbg}"
|
||||
);
|
||||
// Non-secret shape is still useful for diagnostics.
|
||||
assert!(dbg.contains("encrypted_keys_len: 2"), "{dbg}");
|
||||
}
|
||||
+267
-27
@@ -148,29 +148,6 @@ impl AacsVersion {
|
||||
}
|
||||
}
|
||||
|
||||
/// The AACS generation that sets the `Unit_Key_RO.inf` stride, from the strongest signal
|
||||
/// present: the content certificate's major version, then a pre-recorded MKB's type, then
|
||||
/// `index.bdmv` (`INDX0300` is UHD). With none of them, UHD's 64-byte stride. The one
|
||||
/// resolver for every path that parses the unit-key file, so they cannot disagree.
|
||||
pub fn resolve_aacs_version(
|
||||
cert_major: Option<u8>,
|
||||
mkb: &[u8],
|
||||
index_is_uhd: Option<bool>,
|
||||
) -> AacsVersion {
|
||||
if let Some(major) = cert_major {
|
||||
return AacsVersion::from_major(major);
|
||||
}
|
||||
if let Some(t @ (MkbType::Prerecorded | MkbType::CategoryC20 | MkbType::CategoryC21)) =
|
||||
mkb_type(mkb)
|
||||
{
|
||||
return t.generation();
|
||||
}
|
||||
match index_is_uhd {
|
||||
Some(false) => AacsVersion::V10,
|
||||
_ => AacsVersion::V20,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
|
||||
/// 0x81: `[C]` §3.2.5.1.4. 0x86 (AACS 2.x): `[RE]` — not in the public spec (from real 2.x MKBs).
|
||||
pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
@@ -210,8 +187,7 @@ pub(crate) fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
}
|
||||
|
||||
// Find the Media Key Data Record (cvalues table) in an MKB. `[C]` §3.2.4 / §3.2.5.1.7. 0x05 is
|
||||
// the pre-recorded / Category C table (libaacs `mkb_cvalues`); a Class II MKB carries 0x0c
|
||||
// instead (see `check_mkb_class`). 0x07 is the Subset-Difference Index.
|
||||
// the only cvalue table (libaacs `mkb_cvalues`); 0x07 is the Subset-Difference Index.
|
||||
pub(crate) fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
find_record_body(mkb, REC_MEDIA_KEY_DATA)
|
||||
}
|
||||
@@ -356,5 +332,269 @@ pub fn mkb_is_uhd(mkb: &[u8]) -> Option<bool> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mkb_tests.rs"]
|
||||
mod tests;
|
||||
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);
|
||||
}
|
||||
|
||||
// A genuinely-old AACS 1.0 Blu-ray really does carry MKB version 1 (the "50
|
||||
// First Dates" disc, MKB 12628 bytes, is a real example). `mkb_version` must
|
||||
// read that `00 00 00 01` verbatim — never reject/clamp a low value as garbage.
|
||||
#[test]
|
||||
fn mkb_version_reads_a_genuine_version_one_prerecorded_mkb() {
|
||||
// Realistic early-BD layout: Type-and-Version (type 0x00041003, v1)
|
||||
// followed by another framed record, then the end marker.
|
||||
let mut mkb = type_and_version(MKB_TYPE_4_PRERECORDED, 1);
|
||||
mkb.extend(rec(REC_MEDIA_KEY_DATA, &[0x5A; 32]));
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
|
||||
assert_eq!(
|
||||
mkb_version(&mkb),
|
||||
Some(1),
|
||||
"version 1 is a real, valid MKB version and must be read verbatim"
|
||||
);
|
||||
assert_eq!(
|
||||
mkb_type(&mkb),
|
||||
Some(MkbType::Prerecorded),
|
||||
"AACS 1.0 pre-recorded Blu-ray type must still decode alongside v1"
|
||||
);
|
||||
assert_eq!(mkb_is_uhd(&mkb), Some(false), "a v1 BD is not UHD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_mkb_keeps_only_the_framed_records() {
|
||||
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 1);
|
||||
let content_len = mkb.len(); // the single framed record, no end marker
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
|
||||
mkb.extend([0xDE; 4096]); // trailing padding past the end marker
|
||||
let trimmed = trim_mkb(mkb);
|
||||
assert_eq!(
|
||||
trimmed.len(),
|
||||
content_len,
|
||||
"trim keeps the framed records, dropping the end marker and padding"
|
||||
);
|
||||
}
|
||||
|
||||
// BE24 length field (all THREE bytes): a walker that dropped the high byte would mis-frame
|
||||
// every large record (real cvalue/variant tables).
|
||||
#[test]
|
||||
fn mkb_records_honors_the_high_byte_of_the_be24_length() {
|
||||
const TOTAL: usize = 0x0001_0004; // 65_540 — high byte 0x01
|
||||
let mut mkb = vec![REC_VKD_TABLE, 0x01, 0x00, 0x04];
|
||||
mkb.resize(TOTAL, 0xAB);
|
||||
// A second record follows, so a walker that mis-read the length would
|
||||
// frame a different number of records rather than merely a short one.
|
||||
mkb.extend(rec(REC_TYPE_AND_VERSION, &[0x11; 8]));
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 2, "the big record must be framed as ONE record");
|
||||
assert_eq!(
|
||||
recs[0].rec_len, TOTAL,
|
||||
"rec_len must include the high BE24 byte"
|
||||
);
|
||||
assert_eq!(recs[0].body.len(), TOTAL - 4);
|
||||
assert_eq!(
|
||||
recs[1].rec_type, REC_TYPE_AND_VERSION,
|
||||
"the following record must start where the big one ends"
|
||||
);
|
||||
}
|
||||
|
||||
// Header-only records and the exact end marker: `rec_len == 4` is a well-formed HEADER-ONLY
|
||||
// record, including one at the buffer end.
|
||||
#[test]
|
||||
fn mkb_records_yields_a_header_only_record_at_the_buffer_end() {
|
||||
let mut mkb = rec(REC_TYPE_AND_VERSION, &[0xAA, 0xBB]);
|
||||
mkb.extend([REC_VKD_TABLE, 0x00, 0x00, 0x04]); // 4-byte, empty body, at EOF
|
||||
assert_eq!(
|
||||
mkb.len(),
|
||||
10,
|
||||
"sanity: the last record ends at the buffer end"
|
||||
);
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 2, "the trailing header-only record is a record");
|
||||
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
|
||||
assert_eq!(recs[1].rec_len, 4);
|
||||
assert!(recs[1].body.is_empty());
|
||||
}
|
||||
|
||||
// ONLY the exact `00 00 00 00` marker ends the walk. A type-0 record with a
|
||||
// real length is a record, not the end — stopping there would truncate
|
||||
// everything after it, including the cvalue/verify records.
|
||||
#[test]
|
||||
fn mkb_records_stops_only_on_the_all_zero_end_marker() {
|
||||
// A type-0 record of length 8, then a normal record, then the marker.
|
||||
let mut mkb = vec![0x00, 0x00, 0x00, 0x08, 1, 2, 3, 4];
|
||||
mkb.extend(rec(REC_VKD_TABLE, &[0x55; 16]));
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // the real end marker
|
||||
mkb.extend(rec(0x99, &[0xFF; 4])); // past the marker: not walked
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(
|
||||
recs.len(),
|
||||
2,
|
||||
"a type-0 record with a non-zero length is a record, not the end"
|
||||
);
|
||||
assert_eq!(recs[0].rec_type, 0x00);
|
||||
assert_eq!(recs[0].rec_len, 8);
|
||||
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
|
||||
assert_eq!(recs[1].body, vec![0x55; 16]);
|
||||
}
|
||||
|
||||
// `mkb_type_raw` reports the 32-bit MKBType field verbatim, including unrecognised values;
|
||||
// all four bytes must come from the record body.
|
||||
#[test]
|
||||
fn mkb_type_raw_reads_all_four_body_bytes() {
|
||||
const RAW: u32 = 0xDEAD_BEEF;
|
||||
let mkb = type_and_version(RAW, 7);
|
||||
assert_eq!(
|
||||
mkb_type_raw(&mkb),
|
||||
Some(RAW),
|
||||
"every byte of the MKBType field must come from the record body"
|
||||
);
|
||||
assert_eq!(mkb_version(&mkb), Some(7));
|
||||
}
|
||||
|
||||
// Every MkbType category the Type-and-Version field can name must decode to
|
||||
// its variant, and an unknown value must round-trip verbatim through
|
||||
// `Other(raw)` — the categorisation the stride/generation dispatch keys on.
|
||||
#[test]
|
||||
fn mkb_type_classifies_every_category_byte() {
|
||||
let cases: &[(u32, MkbType)] = &[
|
||||
(MKB_TYPE_3_RECORDABLE, MkbType::Recordable),
|
||||
(MKB_TYPE_4_PRERECORDED, MkbType::Prerecorded),
|
||||
(MKB_TYPE_10_CLASS_II, MkbType::ClassII),
|
||||
(MKB_20_CATEGORY_C, MkbType::CategoryC20),
|
||||
(MKB_21_CATEGORY_C, MkbType::CategoryC21),
|
||||
];
|
||||
for &(raw, expected) in cases {
|
||||
assert_eq!(
|
||||
MkbType::from_raw(raw),
|
||||
expected,
|
||||
"raw {raw:#010x} must decode to {expected:?}"
|
||||
);
|
||||
// …and through the byte-level path a real MKB takes.
|
||||
let mut mkb = type_and_version(raw, 1);
|
||||
mkb.extend([0, 0, 0, 0]);
|
||||
assert_eq!(mkb_type(&mkb), Some(expected));
|
||||
}
|
||||
// An unrecognised MKBType is preserved, not coerced to a known variant.
|
||||
const UNKNOWN: u32 = 0x1234_5678;
|
||||
assert_eq!(MkbType::from_raw(UNKNOWN), MkbType::Other(UNKNOWN));
|
||||
assert!(!matches!(
|
||||
MkbType::from_raw(UNKNOWN),
|
||||
MkbType::CategoryC20 | MkbType::CategoryC21
|
||||
));
|
||||
// No Type-and-Version record at all → no classification (not a default).
|
||||
assert_eq!(mkb_type(&[]), None);
|
||||
assert_eq!(mkb_type_raw(&[]), None);
|
||||
}
|
||||
|
||||
// generation()/major()/is_uhd()/unit_key_stride() must agree per category:
|
||||
// only Category C is UHD (2.x, 64-byte stride), and only CategoryC21 is V21.
|
||||
#[test]
|
||||
fn mkb_generation_stride_and_major_per_category() {
|
||||
// Category C 2.1 → V21, UHD, major 2, 64-byte stride.
|
||||
assert_eq!(MkbType::CategoryC21.generation(), AacsVersion::V21);
|
||||
assert!(MkbType::CategoryC21.is_uhd());
|
||||
// Category C 2.0 → V20, UHD, major 2, 64-byte stride.
|
||||
assert_eq!(MkbType::CategoryC20.generation(), AacsVersion::V20);
|
||||
assert!(MkbType::CategoryC20.is_uhd());
|
||||
// Everything else → V10 (BD), NOT UHD.
|
||||
for t in [
|
||||
MkbType::Recordable,
|
||||
MkbType::Prerecorded,
|
||||
MkbType::ClassII,
|
||||
MkbType::Other(0xDEAD_BEEF),
|
||||
] {
|
||||
assert_eq!(t.generation(), AacsVersion::V10, "{t:?} is AACS 1.0");
|
||||
assert!(!t.is_uhd(), "{t:?} is not UHD");
|
||||
}
|
||||
|
||||
// Stride and major are the two consumers of the generation enum.
|
||||
assert_eq!(AacsVersion::V10.unit_key_stride(), 48);
|
||||
assert_eq!(AacsVersion::V20.unit_key_stride(), 64);
|
||||
assert_eq!(AacsVersion::V21.unit_key_stride(), 64);
|
||||
assert_eq!(AacsVersion::V10.major(), AACS_MAJOR_BD);
|
||||
assert_eq!(AacsVersion::V20.major(), AACS_MAJOR_UHD);
|
||||
assert_eq!(AacsVersion::V21.major(), AACS_MAJOR_UHD);
|
||||
}
|
||||
|
||||
// from_major is the inverse the keysource stride dispatch relies on: ONLY
|
||||
// the BD major (1) is V10; every other integer takes the V20 64-byte stride.
|
||||
#[test]
|
||||
fn aacs_version_from_major_only_bd_is_v10() {
|
||||
assert_eq!(AacsVersion::from_major(AACS_MAJOR_BD), AacsVersion::V10);
|
||||
assert_eq!(AacsVersion::from_major(AACS_MAJOR_UHD), AacsVersion::V20);
|
||||
// Unknown majors are NOT V10 — they must not take the 48-byte stride.
|
||||
for m in [0u8, 3, 4, 255] {
|
||||
assert_eq!(
|
||||
AacsVersion::from_major(m),
|
||||
AacsVersion::V20,
|
||||
"major {m} must not select the V10 stride"
|
||||
);
|
||||
assert_eq!(AacsVersion::from_major(m).unit_key_stride(), 64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// One stride resolver: cert, then a pre-recorded MKB's type, then index.bdmv, then UHD.
|
||||
// Each later signal is consulted only when every earlier one is missing.
|
||||
#[test]
|
||||
fn resolve_aacs_version_ranks_cert_then_mkb_then_index() {
|
||||
let rec = |t: u32| {
|
||||
let mut m = vec![0x10, 0x00, 0x00, 0x0C];
|
||||
m.extend_from_slice(&t.to_be_bytes());
|
||||
m.extend_from_slice(&[0, 0, 0, 1]);
|
||||
m
|
||||
};
|
||||
let (bd, uhd21) = (rec(MKB_TYPE_4_PRERECORDED), rec(MKB_21_CATEGORY_C));
|
||||
let class2 = rec(MKB_TYPE_10_CLASS_II);
|
||||
use AacsVersion::*;
|
||||
// The certificate wins over a disagreeing MKB and index.
|
||||
assert_eq!(
|
||||
resolve_aacs_version(Some(AACS_MAJOR_BD), &uhd21, Some(true)),
|
||||
V10
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_aacs_version(Some(AACS_MAJOR_UHD), &bd, Some(false)),
|
||||
V20
|
||||
);
|
||||
// No cert: the MKB type wins over a disagreeing index.
|
||||
assert_eq!(resolve_aacs_version(None, &bd, Some(true)), V10);
|
||||
assert_eq!(resolve_aacs_version(None, &uhd21, Some(false)), V21);
|
||||
// No cert, no pre-recorded MKB type: index.bdmv decides.
|
||||
assert_eq!(resolve_aacs_version(None, &[], Some(false)), V10);
|
||||
assert_eq!(resolve_aacs_version(None, &class2, Some(false)), V10);
|
||||
assert_eq!(resolve_aacs_version(None, &[], Some(true)), V20);
|
||||
// Nothing at all: UHD's 64-byte stride.
|
||||
assert_eq!(resolve_aacs_version(None, &[], None), V20);
|
||||
}
|
||||
|
||||
/// One MKB record: 1 type byte + big-endian 24-bit total length + body.
|
||||
fn rec(rec_type: u8, body: &[u8]) -> Vec<u8> {
|
||||
let len = 4 + body.len();
|
||||
let mut v = vec![rec_type, (len >> 16) as u8, (len >> 8) as u8, len as u8];
|
||||
v.extend_from_slice(body);
|
||||
v
|
||||
}
|
||||
|
||||
/// Type-and-Version record (0x10): body = 4-byte MKBType + 4-byte version.
|
||||
fn type_and_version(mkb_type: u32, version: u32) -> Vec<u8> {
|
||||
let mut body = mkb_type.to_be_bytes().to_vec();
|
||||
body.extend_from_slice(&version.to_be_bytes());
|
||||
rec(REC_TYPE_AND_VERSION, &body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walker_frames_records_and_stops_at_end_marker() {
|
||||
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 77);
|
||||
mkb.extend(rec(REC_VKD_TABLE, &[0xAA; 16]));
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
|
||||
mkb.extend(rec(0x99, &[0xFF; 8])); // must NOT be walked (past the marker)
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 2, "walk stops at the 00 000000 end marker");
|
||||
assert_eq!(recs[0].rec_type, REC_TYPE_AND_VERSION);
|
||||
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
|
||||
assert_eq!(recs[1].body, vec![0xAA; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walker_stops_on_malformed_or_out_of_bounds_length() {
|
||||
// A record whose declared length runs past the buffer end must terminate
|
||||
// the walk rather than panic or read OOB.
|
||||
let mkb = vec![REC_VKD_TABLE, 0x00, 0xFF, 0xFF, 0x01, 0x02]; // len=0xFFFF, only 6 bytes
|
||||
assert!(
|
||||
walk_mkb(&mkb).is_empty(),
|
||||
"over-long record yields no records"
|
||||
);
|
||||
// A sub-4 length (shorter than the header itself) is also rejected.
|
||||
let short = vec![REC_VKD_TABLE, 0x00, 0x00, 0x02];
|
||||
assert!(walk_mkb(&short).is_empty(), "sub-4 length is rejected");
|
||||
// A truncated header (< 4 bytes) yields nothing.
|
||||
assert!(walk_mkb(&[0x10, 0x00]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_type_and_version_decode_from_the_type_record() {
|
||||
let mut mkb = type_and_version(MKB_21_CATEGORY_C, 100);
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]);
|
||||
assert_eq!(mkb_type_raw(&mkb), Some(MKB_21_CATEGORY_C));
|
||||
assert_eq!(mkb_version(&mkb), Some(100));
|
||||
assert_eq!(mkb_is_uhd(&mkb), Some(true), "2.1 Category C is UHD");
|
||||
|
||||
let bd = type_and_version(MKB_TYPE_4_PRERECORDED, 68);
|
||||
assert_eq!(
|
||||
mkb_is_uhd(&bd),
|
||||
Some(false),
|
||||
"AACS 1.0 prerecorded is not UHD"
|
||||
);
|
||||
// No Type record → None (not a panic, not a fabricated value).
|
||||
assert_eq!(mkb_version(&rec(REC_VKD_TABLE, &[0; 16])), None);
|
||||
assert_eq!(mkb_type_raw(&[]), None);
|
||||
}
|
||||
|
||||
// A genuinely-old AACS 1.0 Blu-ray really does carry MKB version 1 (the "50
|
||||
// First Dates" disc, MKB 12628 bytes, is a real example). `mkb_version` must
|
||||
// read that `00 00 00 01` verbatim — never reject/clamp a low value as garbage.
|
||||
#[test]
|
||||
fn mkb_version_reads_a_genuine_version_one_prerecorded_mkb() {
|
||||
// Realistic early-BD layout: Type-and-Version (type 0x00041003, v1)
|
||||
// followed by another framed record, then the end marker.
|
||||
let mut mkb = type_and_version(MKB_TYPE_4_PRERECORDED, 1);
|
||||
mkb.extend(rec(REC_MEDIA_KEY_DATA, &[0x5A; 32]));
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
|
||||
assert_eq!(
|
||||
mkb_version(&mkb),
|
||||
Some(1),
|
||||
"version 1 is a real, valid MKB version and must be read verbatim"
|
||||
);
|
||||
assert_eq!(
|
||||
mkb_type(&mkb),
|
||||
Some(MkbType::Prerecorded),
|
||||
"AACS 1.0 pre-recorded Blu-ray type must still decode alongside v1"
|
||||
);
|
||||
assert_eq!(mkb_is_uhd(&mkb), Some(false), "a v1 BD is not UHD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_mkb_keeps_only_the_framed_records() {
|
||||
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 1);
|
||||
let content_len = mkb.len(); // the single framed record, no end marker
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
|
||||
mkb.extend([0xDE; 4096]); // trailing padding past the end marker
|
||||
let trimmed = trim_mkb(mkb);
|
||||
assert_eq!(
|
||||
trimmed.len(),
|
||||
content_len,
|
||||
"trim keeps the framed records, dropping the end marker and padding"
|
||||
);
|
||||
}
|
||||
|
||||
// BE24 length field (all THREE bytes): a walker that dropped the high byte would mis-frame
|
||||
// every large record (real cvalue/variant tables).
|
||||
#[test]
|
||||
fn mkb_records_honors_the_high_byte_of_the_be24_length() {
|
||||
const TOTAL: usize = 0x0001_0004; // 65_540 — high byte 0x01
|
||||
let mut mkb = vec![REC_VKD_TABLE, 0x01, 0x00, 0x04];
|
||||
mkb.resize(TOTAL, 0xAB);
|
||||
// A second record follows, so a walker that mis-read the length would
|
||||
// frame a different number of records rather than merely a short one.
|
||||
mkb.extend(rec(REC_TYPE_AND_VERSION, &[0x11; 8]));
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 2, "the big record must be framed as ONE record");
|
||||
assert_eq!(
|
||||
recs[0].rec_len, TOTAL,
|
||||
"rec_len must include the high BE24 byte"
|
||||
);
|
||||
assert_eq!(recs[0].body.len(), TOTAL - 4);
|
||||
assert_eq!(
|
||||
recs[1].rec_type, REC_TYPE_AND_VERSION,
|
||||
"the following record must start where the big one ends"
|
||||
);
|
||||
}
|
||||
|
||||
// Header-only records and the exact end marker: `rec_len == 4` is a well-formed HEADER-ONLY
|
||||
// record, including one at the buffer end.
|
||||
#[test]
|
||||
fn mkb_records_yields_a_header_only_record_at_the_buffer_end() {
|
||||
let mut mkb = rec(REC_TYPE_AND_VERSION, &[0xAA, 0xBB]);
|
||||
mkb.extend([REC_VKD_TABLE, 0x00, 0x00, 0x04]); // 4-byte, empty body, at EOF
|
||||
assert_eq!(
|
||||
mkb.len(),
|
||||
10,
|
||||
"sanity: the last record ends at the buffer end"
|
||||
);
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 2, "the trailing header-only record is a record");
|
||||
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
|
||||
assert_eq!(recs[1].rec_len, 4);
|
||||
assert!(recs[1].body.is_empty());
|
||||
}
|
||||
|
||||
// ONLY the exact `00 00 00 00` marker ends the walk. A type-0 record with a
|
||||
// real length is a record, not the end — stopping there would truncate
|
||||
// everything after it, including the cvalue/verify records.
|
||||
#[test]
|
||||
fn mkb_records_stops_only_on_the_all_zero_end_marker() {
|
||||
// A type-0 record of length 8, then a normal record, then the marker.
|
||||
let mut mkb = vec![0x00, 0x00, 0x00, 0x08, 1, 2, 3, 4];
|
||||
mkb.extend(rec(REC_VKD_TABLE, &[0x55; 16]));
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // the real end marker
|
||||
mkb.extend(rec(0x99, &[0xFF; 4])); // past the marker: not walked
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(
|
||||
recs.len(),
|
||||
2,
|
||||
"a type-0 record with a non-zero length is a record, not the end"
|
||||
);
|
||||
assert_eq!(recs[0].rec_type, 0x00);
|
||||
assert_eq!(recs[0].rec_len, 8);
|
||||
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
|
||||
assert_eq!(recs[1].body, vec![0x55; 16]);
|
||||
}
|
||||
|
||||
// `mkb_type_raw` reports the 32-bit MKBType field verbatim, including unrecognised values;
|
||||
// all four bytes must come from the record body.
|
||||
#[test]
|
||||
fn mkb_type_raw_reads_all_four_body_bytes() {
|
||||
const RAW: u32 = 0xDEAD_BEEF;
|
||||
let mkb = type_and_version(RAW, 7);
|
||||
assert_eq!(
|
||||
mkb_type_raw(&mkb),
|
||||
Some(RAW),
|
||||
"every byte of the MKBType field must come from the record body"
|
||||
);
|
||||
assert_eq!(mkb_version(&mkb), Some(7));
|
||||
}
|
||||
|
||||
// Every MkbType category the Type-and-Version field can name must decode to
|
||||
// its variant, and an unknown value must round-trip verbatim through
|
||||
// `Other(raw)` — the categorisation the stride/generation dispatch keys on.
|
||||
#[test]
|
||||
fn mkb_type_classifies_every_category_byte() {
|
||||
let cases: &[(u32, MkbType)] = &[
|
||||
(MKB_TYPE_3_RECORDABLE, MkbType::Recordable),
|
||||
(MKB_TYPE_4_PRERECORDED, MkbType::Prerecorded),
|
||||
(MKB_TYPE_10_CLASS_II, MkbType::ClassII),
|
||||
(MKB_20_CATEGORY_C, MkbType::CategoryC20),
|
||||
(MKB_21_CATEGORY_C, MkbType::CategoryC21),
|
||||
];
|
||||
for &(raw, expected) in cases {
|
||||
assert_eq!(
|
||||
MkbType::from_raw(raw),
|
||||
expected,
|
||||
"raw {raw:#010x} must decode to {expected:?}"
|
||||
);
|
||||
// …and through the byte-level path a real MKB takes.
|
||||
let mut mkb = type_and_version(raw, 1);
|
||||
mkb.extend([0, 0, 0, 0]);
|
||||
assert_eq!(mkb_type(&mkb), Some(expected));
|
||||
}
|
||||
// An unrecognised MKBType is preserved, not coerced to a known variant.
|
||||
const UNKNOWN: u32 = 0x1234_5678;
|
||||
assert_eq!(MkbType::from_raw(UNKNOWN), MkbType::Other(UNKNOWN));
|
||||
assert!(!matches!(
|
||||
MkbType::from_raw(UNKNOWN),
|
||||
MkbType::CategoryC20 | MkbType::CategoryC21
|
||||
));
|
||||
// No Type-and-Version record at all → no classification (not a default).
|
||||
assert_eq!(mkb_type(&[]), None);
|
||||
assert_eq!(mkb_type_raw(&[]), None);
|
||||
}
|
||||
|
||||
// generation()/major()/is_uhd()/unit_key_stride() must agree per category:
|
||||
// only Category C is UHD (2.x, 64-byte stride), and only CategoryC21 is V21.
|
||||
#[test]
|
||||
fn mkb_generation_stride_and_major_per_category() {
|
||||
// Category C 2.1 → V21, UHD, major 2, 64-byte stride.
|
||||
assert_eq!(MkbType::CategoryC21.generation(), AacsVersion::V21);
|
||||
assert!(MkbType::CategoryC21.is_uhd());
|
||||
// Category C 2.0 → V20, UHD, major 2, 64-byte stride.
|
||||
assert_eq!(MkbType::CategoryC20.generation(), AacsVersion::V20);
|
||||
assert!(MkbType::CategoryC20.is_uhd());
|
||||
// Everything else → V10 (BD), NOT UHD.
|
||||
for t in [
|
||||
MkbType::Recordable,
|
||||
MkbType::Prerecorded,
|
||||
MkbType::ClassII,
|
||||
MkbType::Other(0xDEAD_BEEF),
|
||||
] {
|
||||
assert_eq!(t.generation(), AacsVersion::V10, "{t:?} is AACS 1.0");
|
||||
assert!(!t.is_uhd(), "{t:?} is not UHD");
|
||||
}
|
||||
|
||||
// Stride and major are the two consumers of the generation enum.
|
||||
assert_eq!(AacsVersion::V10.unit_key_stride(), 48);
|
||||
assert_eq!(AacsVersion::V20.unit_key_stride(), 64);
|
||||
assert_eq!(AacsVersion::V21.unit_key_stride(), 64);
|
||||
assert_eq!(AacsVersion::V10.major(), AACS_MAJOR_BD);
|
||||
assert_eq!(AacsVersion::V20.major(), AACS_MAJOR_UHD);
|
||||
assert_eq!(AacsVersion::V21.major(), AACS_MAJOR_UHD);
|
||||
}
|
||||
|
||||
// from_major is the inverse the keysource stride dispatch relies on: ONLY
|
||||
// the BD major (1) is V10; every other integer takes the V20 64-byte stride.
|
||||
#[test]
|
||||
fn aacs_version_from_major_only_bd_is_v10() {
|
||||
assert_eq!(AacsVersion::from_major(AACS_MAJOR_BD), AacsVersion::V10);
|
||||
assert_eq!(AacsVersion::from_major(AACS_MAJOR_UHD), AacsVersion::V20);
|
||||
// Unknown majors are NOT V10 — they must not take the 48-byte stride.
|
||||
for m in [0u8, 3, 4, 255] {
|
||||
assert_eq!(
|
||||
AacsVersion::from_major(m),
|
||||
AacsVersion::V20,
|
||||
"major {m} must not select the V10 stride"
|
||||
);
|
||||
assert_eq!(AacsVersion::from_major(m).unit_key_stride(), 64);
|
||||
}
|
||||
}
|
||||
+289
-15
@@ -5,8 +5,6 @@
|
||||
pub mod content;
|
||||
pub mod crypto;
|
||||
pub mod derive;
|
||||
// HD DVD per-pack content encryption (`[HD]` §4.3).
|
||||
pub(crate) mod hddvd;
|
||||
pub mod host_certs;
|
||||
// No production caller; not the live FMTS path.
|
||||
#[doc(hidden)]
|
||||
@@ -47,22 +45,16 @@ pub enum AacsRole {
|
||||
ContentCert,
|
||||
}
|
||||
|
||||
// The HD DVD AACS directory in a parsed UDF tree, if present: any root directory holding
|
||||
// MKBROM.AACS, whatever its name (`ANY!`, `AAC!`, plain `AACS` on "300"). A `*_BAK` mirror
|
||||
// is used only when no primary copy exists.
|
||||
// The HD DVD AACS directory in a parsed UDF tree, if present. Identified structurally (name
|
||||
// ends `!`, contains MKBROM.AACS), not by a hardcoded name.
|
||||
pub(crate) fn find_hddvd_aacs_dir(udf: &crate::udf::UdfFs) -> Option<&crate::udf::DirEntry> {
|
||||
let mut dirs = udf.root.entries.iter().filter(|e| {
|
||||
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"))
|
||||
});
|
||||
let is_bak = |e: &crate::udf::DirEntry| e.name.to_ascii_uppercase().ends_with("_BAK");
|
||||
let first = dirs.next()?;
|
||||
if !is_bak(first) {
|
||||
return Some(first);
|
||||
}
|
||||
dirs.find(|e| !is_bak(e)).or(Some(first))
|
||||
})
|
||||
}
|
||||
|
||||
// Ordered candidate paths for an AACS key role: fixed BD/UHD `/AACS/…` paths
|
||||
@@ -142,5 +134,287 @@ pub use derive::derive_vuk;
|
||||
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
//! Surface guards. The public API is the module tree itself (no facade).
|
||||
//! Touching one representative item per module keeps these as a
|
||||
//! compile-time contract that the module paths stay stable.
|
||||
|
||||
use super::content::ALIGNED_UNIT_LEN;
|
||||
use super::inf::{disc_hash, disc_hash_hex};
|
||||
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
||||
use super::variant::is_variant_mkb;
|
||||
|
||||
#[test]
|
||||
fn aligned_unit_len_is_three_2048_byte_sectors() {
|
||||
// ALIGNED_UNIT_LEN is the AACS aligned-unit size (3 × 2048 = 6144);
|
||||
// pin it here so the public constant tracks the spec.
|
||||
assert_eq!(ALIGNED_UNIT_LEN, 6144);
|
||||
assert_eq!(ALIGNED_UNIT_LEN, 3 * 2048);
|
||||
}
|
||||
|
||||
// Any error but UdfNotFound / DiscRead is a hard failure: a good DUPLICATE copy must not
|
||||
// mask it.
|
||||
#[test]
|
||||
fn read_first_propagates_a_hard_error_without_trying_the_duplicate() {
|
||||
use crate::error::Error;
|
||||
let candidates = ["/AACS/Unit_Key_RO.inf", "/AACS/DUPLICATE/Unit_Key_RO.inf"];
|
||||
let mut tried = Vec::new();
|
||||
let out = super::read_first(&candidates, |p| {
|
||||
tried.push(p.to_string());
|
||||
if p == "/AACS/DUPLICATE/Unit_Key_RO.inf" {
|
||||
Ok(vec![0xAA])
|
||||
} else {
|
||||
Err(Error::DecryptFailed)
|
||||
}
|
||||
});
|
||||
assert!(matches!(out, Err(Error::DecryptFailed)));
|
||||
assert_eq!(tried, vec!["/AACS/Unit_Key_RO.inf".to_string()]);
|
||||
}
|
||||
|
||||
// AACS `/AACS/DUPLICATE/` redundancy: a `DiscRead` on the primary managed
|
||||
// file must fall through to the backup copy, not abort the whole read.
|
||||
#[test]
|
||||
fn read_first_falls_through_a_primary_disc_read_to_the_duplicate() {
|
||||
use crate::error::Error;
|
||||
let candidates = ["/AACS/Unit_Key_RO.inf", "/AACS/DUPLICATE/Unit_Key_RO.inf"];
|
||||
let out = super::read_first(&candidates, |p| {
|
||||
if p == "/AACS/DUPLICATE/Unit_Key_RO.inf" {
|
||||
Ok(vec![0xAA, 0xC5])
|
||||
} else {
|
||||
Err(Error::DiscRead {
|
||||
sector: 42,
|
||||
status: Some(0x02),
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
out.unwrap(),
|
||||
vec![0xAA, 0xC5],
|
||||
"a primary DiscRead must fall through to the DUPLICATE copy"
|
||||
);
|
||||
}
|
||||
|
||||
// When every candidate fails to read, the remembered `DiscRead` propagates
|
||||
// (more informative than a bare `AacsNoKeys`); a purely absent set yields
|
||||
// `AacsNoKeys`.
|
||||
#[test]
|
||||
fn read_first_propagates_disc_read_only_after_all_candidates_fail() {
|
||||
use crate::error::Error;
|
||||
let candidates = ["/AACS/Unit_Key_RO.inf", "/AACS/DUPLICATE/Unit_Key_RO.inf"];
|
||||
let err = super::read_first(&candidates, |p| {
|
||||
if p.contains("DUPLICATE") {
|
||||
Err(Error::UdfNotFound { path: p.into() })
|
||||
} else {
|
||||
Err(Error::DiscRead {
|
||||
sector: 7,
|
||||
status: None,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, Error::DiscRead { sector: 7, .. }),
|
||||
"the primary DiscRead must propagate once the DUPLICATE is absent, got {err:?}"
|
||||
);
|
||||
|
||||
let all_absent =
|
||||
super::read_first(&candidates, |p| Err(Error::UdfNotFound { path: p.into() }))
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(all_absent, Error::AacsNoKeys),
|
||||
"an entirely absent candidate set yields AacsNoKeys, got {all_absent:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_strides_are_reexported_and_distinct() {
|
||||
// V10 (48) vs V20/V21 (64) stride is the load-bearing distinction;
|
||||
// confirm the enum re-export is usable and variants are distinct.
|
||||
assert_ne!(AacsVersion::V10, AacsVersion::V20);
|
||||
assert_ne!(AacsVersion::V20, AacsVersion::V21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_helpers_are_callable_by_module_path() {
|
||||
// Touch a representative function from each module so a dropped/renamed
|
||||
// item fails to compile. Smoke calls, not behavioural assertions.
|
||||
let _ = !crate::aacs::content::is_clean(
|
||||
&[0u8; ALIGNED_UNIT_LEN],
|
||||
crate::disc::ContentFormat::BdTs,
|
||||
);
|
||||
let _ = mkb_content_len(&[]);
|
||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||
let _ = super::derive::resolve_candidate(
|
||||
&super::derive::KeyCandidate::Uk(super::types::UnitKey::new(0, [0u8; 16])),
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
// ── HD DVD AACS directory / filename discovery ────────────────────────
|
||||
// Dir/filename are authoring-specific (previously hardcoded to `/ANY!/VTKF000.AACS`);
|
||||
// verify against Freedom (`AAC!` + `VTKF090`/`VTKF100`) and a BD/UHD disc (no HD DVD dir).
|
||||
|
||||
#[test]
|
||||
fn role_paths_discovers_hddvd_dir_and_globs_all_vtkf_variants() {
|
||||
use crate::udf::fixture::*;
|
||||
// Freedom-shaped: an `AAC!` dir (NOT `ANY!`) holding MKBROM + two VTKF
|
||||
// variants (090/100, NOT 000) + a VTUF usage file (must be excluded),
|
||||
// plus the `AAC!_BAK` mirror (must NOT be picked as the AACS dir).
|
||||
let mut disc = MemDisc::new();
|
||||
let aacs_files = vec![
|
||||
file("MKBROM.AACS", 100, 5000, 4096, true),
|
||||
file("CONTENT_CERT.AACS", 101, 5100, 2048, true),
|
||||
file("VTKF100.AACS", 102, 5200, 2048, true),
|
||||
file("VTKF090.AACS", 103, 5300, 2048, true),
|
||||
file("VTUF090.AACS", 104, 5400, 2048, true),
|
||||
];
|
||||
let bak_files = vec![file("MKBROM.AACS", 110, 6000, 4096, true)];
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![
|
||||
DirSpec {
|
||||
name: "AAC!".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: aacs_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
name: "AAC!_BAK".to_string(),
|
||||
icb_lba: 30,
|
||||
dir_data_lba: 31,
|
||||
files: bak_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
// Discovered structurally (ends in '!', holds MKBROM.AACS) — the real
|
||||
// AACS dir, never the `_BAK` mirror.
|
||||
let dir = super::find_hddvd_aacs_dir(&udf).expect("aacs dir");
|
||||
assert_eq!(dir.name, "AAC!");
|
||||
|
||||
// UnitKey: BD/UHD paths first, then EVERY VTKF*.AACS in sorted order
|
||||
// (090 before 100) — NOT hardcoded VTKF000; VTUF (usage) excluded.
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
"/AAC!/VTKF090.AACS".to_string(),
|
||||
"/AAC!/VTKF100.AACS".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::Mkb)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/MKBROM.AACS"
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::ContentCert)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/CONTENT_CERT.AACS"
|
||||
);
|
||||
}
|
||||
|
||||
// Both the `!`-suffix AND MKBROM.AACS presence are required (conjunction, not disjunction)
|
||||
// — else the HD DVD path resolves key files under a dir holding none.
|
||||
#[test]
|
||||
fn a_bang_suffixed_directory_without_mkbrom_is_not_the_aacs_directory() {
|
||||
use crate::udf::fixture::*;
|
||||
let mut disc = MemDisc::new();
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
// Ends in '!' — but carries no MKBROM.AACS, so it is not the
|
||||
// HD DVD AACS directory.
|
||||
name: "AAC!".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![
|
||||
file("VTKF090.AACS", 102, 5200, 2048, true),
|
||||
file("CONTENT_CERT.AACS", 103, 5300, 2048, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert!(
|
||||
super::find_hddvd_aacs_dir(&udf).is_none(),
|
||||
"a '!' directory without MKBROM.AACS is not the AACS directory"
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
],
|
||||
"no HD DVD candidates may be appended from a directory that was \
|
||||
never identified as the AACS directory"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_paths_bd_uhd_disc_yields_no_hddvd_candidates() {
|
||||
use crate::udf::fixture::*;
|
||||
// A `/AACS/` tree (BD/UHD) has no '!' directory → discovery finds none
|
||||
// and the candidate list is exactly the static BD/UHD paths.
|
||||
let mut disc = MemDisc::new();
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
name: "AACS".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![
|
||||
file("Unit_Key_RO.inf", 100, 5000, 2048, true),
|
||||
file("MKB_RO.inf", 101, 5100, 2048, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert!(super::find_hddvd_aacs_dir(&udf).is_none());
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
]
|
||||
);
|
||||
// MKB fallback is the DUPLICATE copy of MKB_RO (libaacs `_mkb_open`), never
|
||||
// MKB_RW.inf — a different MKB that derives a different Media Key.
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::Mkb),
|
||||
vec![
|
||||
"/AACS/MKB_RO.inf".to_string(),
|
||||
"/AACS/DUPLICATE/MKB_RO.inf".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
//! Surface guards. The public API is the module tree itself (no facade).
|
||||
//! Touching one representative item per module keeps these as a
|
||||
//! compile-time contract that the module paths stay stable.
|
||||
|
||||
use super::content::ALIGNED_UNIT_LEN;
|
||||
use super::inf::{disc_hash, disc_hash_hex};
|
||||
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
||||
use super::variant::is_variant_mkb;
|
||||
|
||||
#[test]
|
||||
fn aligned_unit_len_is_three_2048_byte_sectors() {
|
||||
// ALIGNED_UNIT_LEN is the AACS aligned-unit size (3 × 2048 = 6144);
|
||||
// pin it here so the public constant tracks the spec.
|
||||
assert_eq!(ALIGNED_UNIT_LEN, 6144);
|
||||
assert_eq!(ALIGNED_UNIT_LEN, 3 * 2048);
|
||||
}
|
||||
|
||||
// Any error but UdfNotFound / DiscRead is a hard failure: a good DUPLICATE copy must not
|
||||
// mask it.
|
||||
#[test]
|
||||
fn read_first_propagates_a_hard_error_without_trying_the_duplicate() {
|
||||
use crate::error::Error;
|
||||
let candidates = ["/AACS/Unit_Key_RO.inf", "/AACS/DUPLICATE/Unit_Key_RO.inf"];
|
||||
let mut tried = Vec::new();
|
||||
let out = super::read_first(&candidates, |p| {
|
||||
tried.push(p.to_string());
|
||||
if p == "/AACS/DUPLICATE/Unit_Key_RO.inf" {
|
||||
Ok(vec![0xAA])
|
||||
} else {
|
||||
Err(Error::DecryptFailed)
|
||||
}
|
||||
});
|
||||
assert!(matches!(out, Err(Error::DecryptFailed)));
|
||||
assert_eq!(tried, vec!["/AACS/Unit_Key_RO.inf".to_string()]);
|
||||
}
|
||||
|
||||
// AACS `/AACS/DUPLICATE/` redundancy: a `DiscRead` on the primary managed
|
||||
// file must fall through to the backup copy, not abort the whole read.
|
||||
#[test]
|
||||
fn read_first_falls_through_a_primary_disc_read_to_the_duplicate() {
|
||||
use crate::error::Error;
|
||||
let candidates = ["/AACS/Unit_Key_RO.inf", "/AACS/DUPLICATE/Unit_Key_RO.inf"];
|
||||
let out = super::read_first(&candidates, |p| {
|
||||
if p == "/AACS/DUPLICATE/Unit_Key_RO.inf" {
|
||||
Ok(vec![0xAA, 0xC5])
|
||||
} else {
|
||||
Err(Error::DiscRead {
|
||||
sector: 42,
|
||||
status: Some(0x02),
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
out.unwrap(),
|
||||
vec![0xAA, 0xC5],
|
||||
"a primary DiscRead must fall through to the DUPLICATE copy"
|
||||
);
|
||||
}
|
||||
|
||||
// When every candidate fails to read, the remembered `DiscRead` propagates
|
||||
// (more informative than a bare `AacsNoKeys`); a purely absent set yields
|
||||
// `AacsNoKeys`.
|
||||
#[test]
|
||||
fn read_first_propagates_disc_read_only_after_all_candidates_fail() {
|
||||
use crate::error::Error;
|
||||
let candidates = ["/AACS/Unit_Key_RO.inf", "/AACS/DUPLICATE/Unit_Key_RO.inf"];
|
||||
let err = super::read_first(&candidates, |p| {
|
||||
if p.contains("DUPLICATE") {
|
||||
Err(Error::UdfNotFound { path: p.into() })
|
||||
} else {
|
||||
Err(Error::DiscRead {
|
||||
sector: 7,
|
||||
status: None,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, Error::DiscRead { sector: 7, .. }),
|
||||
"the primary DiscRead must propagate once the DUPLICATE is absent, got {err:?}"
|
||||
);
|
||||
|
||||
let all_absent =
|
||||
super::read_first(&candidates, |p| Err(Error::UdfNotFound { path: p.into() })).unwrap_err();
|
||||
assert!(
|
||||
matches!(all_absent, Error::AacsNoKeys),
|
||||
"an entirely absent candidate set yields AacsNoKeys, got {all_absent:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_strides_are_reexported_and_distinct() {
|
||||
// V10 (48) vs V20/V21 (64) stride is the load-bearing distinction;
|
||||
// confirm the enum re-export is usable and variants are distinct.
|
||||
assert_ne!(AacsVersion::V10, AacsVersion::V20);
|
||||
assert_ne!(AacsVersion::V20, AacsVersion::V21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_helpers_are_callable_by_module_path() {
|
||||
// Touch a representative function from each module so a dropped/renamed
|
||||
// item fails to compile. Smoke calls, not behavioural assertions.
|
||||
let _ =
|
||||
!crate::aacs::content::is_clean(&[0u8; ALIGNED_UNIT_LEN], crate::disc::ContentFormat::BdTs);
|
||||
let _ = mkb_content_len(&[]);
|
||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||
let _ = super::derive::resolve_candidate(
|
||||
&super::derive::KeyCandidate::Uk(super::types::UnitKey::new(0, [0u8; 16])),
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
super::mkb::AacsVersion::V10,
|
||||
);
|
||||
}
|
||||
|
||||
// ── HD DVD AACS directory / filename discovery ────────────────────────
|
||||
// Dir/filename are authoring-specific (previously hardcoded to `/ANY!/VTKF000.AACS`);
|
||||
// verify against Freedom (`AAC!` + `VTKF090`/`VTKF100`) and a BD/UHD disc (no HD DVD dir).
|
||||
|
||||
#[test]
|
||||
fn role_paths_discovers_hddvd_dir_and_globs_all_vtkf_variants() {
|
||||
use crate::udf::fixture::*;
|
||||
// Freedom-shaped: an `AAC!` dir (NOT `ANY!`) holding MKBROM + two VTKF
|
||||
// variants (090/100, NOT 000) + a VTUF usage file (must be excluded),
|
||||
// plus the `AAC!_BAK` mirror (must NOT be picked as the AACS dir).
|
||||
let mut disc = MemDisc::new();
|
||||
let aacs_files = vec![
|
||||
file("MKBROM.AACS", 100, 5000, 4096, true),
|
||||
file("CONTENT_CERT.AACS", 101, 5100, 2048, true),
|
||||
file("VTKF100.AACS", 102, 5200, 2048, true),
|
||||
file("VTKF090.AACS", 103, 5300, 2048, true),
|
||||
file("VTUF090.AACS", 104, 5400, 2048, true),
|
||||
];
|
||||
let bak_files = vec![file("MKBROM.AACS", 110, 6000, 4096, true)];
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![
|
||||
DirSpec {
|
||||
name: "AAC!".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: aacs_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
name: "AAC!_BAK".to_string(),
|
||||
icb_lba: 30,
|
||||
dir_data_lba: 31,
|
||||
files: bak_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
// Discovered structurally (holds MKBROM.AACS) — the real
|
||||
// AACS dir, never the `_BAK` mirror.
|
||||
let dir = super::find_hddvd_aacs_dir(&udf).expect("aacs dir");
|
||||
assert_eq!(dir.name, "AAC!");
|
||||
|
||||
// UnitKey: BD/UHD paths first, then EVERY VTKF*.AACS in sorted order
|
||||
// (090 before 100) — NOT hardcoded VTKF000; VTUF (usage) excluded.
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
"/AAC!/VTKF090.AACS".to_string(),
|
||||
"/AAC!/VTKF100.AACS".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::Mkb)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/MKBROM.AACS"
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::ContentCert)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/CONTENT_CERT.AACS"
|
||||
);
|
||||
}
|
||||
|
||||
// "300" HD DVDs keep MKBROM/VTKF/CONTENT_CERT in a plain `/AACS` (mirror `AACS_BAK`):
|
||||
// found by MKBROM.AACS, not the name, and the mirror is skipped even when listed first.
|
||||
#[test]
|
||||
fn a_plain_aacs_directory_holding_mkbrom_is_the_hddvd_aacs_directory() {
|
||||
use crate::udf::fixture::*;
|
||||
let mut disc = MemDisc::new();
|
||||
let dir = |name: &str, lba: u32, files| DirSpec {
|
||||
name: name.to_string(),
|
||||
icb_lba: lba,
|
||||
dir_data_lba: lba + 1,
|
||||
files,
|
||||
subdirs: vec![],
|
||||
};
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![
|
||||
dir(
|
||||
"AACS_BAK",
|
||||
30,
|
||||
vec![file("MKBROM.AACS", 110, 6000, 4096, true)],
|
||||
),
|
||||
dir(
|
||||
"AACS",
|
||||
20,
|
||||
vec![
|
||||
file("MKBROM.AACS", 100, 5000, 4096, true),
|
||||
file("CONTENT_CERT.AACS", 101, 5100, 2048, true),
|
||||
file("VTKF000.AACS", 102, 5200, 2048, true),
|
||||
],
|
||||
),
|
||||
],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
assert_eq!(super::find_hddvd_aacs_dir(&udf).expect("dir").name, "AACS");
|
||||
let mkb = super::role_paths(&udf, super::AacsRole::Mkb);
|
||||
assert_eq!(mkb.last().unwrap(), "/AACS/MKBROM.AACS");
|
||||
let uk = super::role_paths(&udf, super::AacsRole::UnitKey);
|
||||
assert_eq!(uk.last().unwrap(), "/AACS/VTKF000.AACS");
|
||||
}
|
||||
|
||||
// A mirror alone is still used: a damaged primary must not leave the disc unkeyable.
|
||||
#[test]
|
||||
fn a_lone_bak_mirror_is_used_when_no_primary_exists() {
|
||||
use crate::udf::fixture::*;
|
||||
let mut disc = MemDisc::new();
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
name: "ANY!_BAK".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![file("MKBROM.AACS", 100, 5000, 4096, true)],
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
assert_eq!(
|
||||
super::find_hddvd_aacs_dir(&udf).expect("dir").name,
|
||||
"ANY!_BAK"
|
||||
);
|
||||
}
|
||||
|
||||
// MKBROM.AACS presence is required — else the HD DVD path resolves key files under a
|
||||
// dir holding none.
|
||||
#[test]
|
||||
fn a_bang_suffixed_directory_without_mkbrom_is_not_the_aacs_directory() {
|
||||
use crate::udf::fixture::*;
|
||||
let mut disc = MemDisc::new();
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
// Ends in '!' — but carries no MKBROM.AACS, so it is not the
|
||||
// HD DVD AACS directory.
|
||||
name: "AAC!".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![
|
||||
file("VTKF090.AACS", 102, 5200, 2048, true),
|
||||
file("CONTENT_CERT.AACS", 103, 5300, 2048, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert!(
|
||||
super::find_hddvd_aacs_dir(&udf).is_none(),
|
||||
"a '!' directory without MKBROM.AACS is not the AACS directory"
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
],
|
||||
"no HD DVD candidates may be appended from a directory that was \
|
||||
never identified as the AACS directory"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_paths_bd_uhd_disc_yields_no_hddvd_candidates() {
|
||||
use crate::udf::fixture::*;
|
||||
// A `/AACS/` tree (BD/UHD) has no '!' directory → discovery finds none
|
||||
// and the candidate list is exactly the static BD/UHD paths.
|
||||
let mut disc = MemDisc::new();
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
name: "AACS".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![
|
||||
file("Unit_Key_RO.inf", 100, 5000, 2048, true),
|
||||
file("MKB_RO.inf", 101, 5100, 2048, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert!(super::find_hddvd_aacs_dir(&udf).is_none());
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
]
|
||||
);
|
||||
// MKB fallback is the DUPLICATE copy of MKB_RO (libaacs `_mkb_open`), never
|
||||
// MKB_RW.inf — a different MKB that derives a different Media Key.
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::Mkb),
|
||||
vec![
|
||||
"/AACS/MKB_RO.inf".to_string(),
|
||||
"/AACS/DUPLICATE/MKB_RO.inf".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
+296
-2
@@ -188,5 +188,299 @@ pub fn fmts_key_ranges(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "segment_tests.rs"]
|
||||
mod tests;
|
||||
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_carries_a_non_2048_aligned_segment() {
|
||||
use crate::disc::Extent;
|
||||
let extents = vec![Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 1_000_000,
|
||||
}];
|
||||
// start_spn=1 → start_byte=192 (NOT 2048-aligned); end_spn=10 → end_byte-1=2111.
|
||||
// Correct carry floor(2111/2048)-floor(192/2048)=1 maps to LBA 1000..=1001; the
|
||||
// old aligned (2111-192)/2048=0 mismatched b-a=1 and DROPPED it to the Unit Key (empty map).
|
||||
let segs = vec![Segment {
|
||||
index: 5,
|
||||
start_spn: 1,
|
||||
end_spn: 10,
|
||||
}];
|
||||
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||
assert_eq!(
|
||||
ranges.len(),
|
||||
1,
|
||||
"a non-2048-aligned segment must still carry, not be dropped"
|
||||
);
|
||||
assert_eq!(ranges[0], (1000, 1002, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmts_key_ranges_skips_inverted_segment_without_underflow() {
|
||||
use crate::disc::Extent;
|
||||
let extents = vec![Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 1_000_000,
|
||||
}];
|
||||
// start_spn == end_spn + 1: `end_byte - 1 - start_byte` would underflow.
|
||||
// The record must be skipped rather than panic (debug) / wrap (release).
|
||||
let segs = vec![Segment {
|
||||
index: 5,
|
||||
start_spn: 200,
|
||||
end_spn: 199,
|
||||
}];
|
||||
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||
assert!(ranges.is_empty(), "inverted segment yields no range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmts_key_ranges_skips_a_segment_straddling_non_adjacent_extents() {
|
||||
use crate::disc::Extent;
|
||||
// Clip bytes [0, 20480) at LBA 100.., [20480, 40960) at LBA 500.. (or reversed).
|
||||
// Packets 104..=109 span bytes 19968..21119, crossing the extent boundary.
|
||||
let segs = vec![Segment {
|
||||
index: 5,
|
||||
start_spn: 104,
|
||||
end_spn: 109,
|
||||
}];
|
||||
for (first, second) in [(100, 500), (500, 100)] {
|
||||
let extents = vec![
|
||||
Extent {
|
||||
start_lba: first,
|
||||
sector_count: 10,
|
||||
},
|
||||
Extent {
|
||||
start_lba: second,
|
||||
sector_count: 10,
|
||||
},
|
||||
];
|
||||
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||
assert!(ranges.is_empty(), "{first}/{second}: {ranges:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_edges_are_inclusive() {
|
||||
let seg = Segment {
|
||||
index: 1,
|
||||
start_spn: 100,
|
||||
end_spn: 200,
|
||||
};
|
||||
assert!(seg.contains_spn(100) && seg.contains_spn(200));
|
||||
assert!(!seg.contains_spn(99) && !seg.contains_spn(201));
|
||||
// A unit touching exactly one edge packet overlaps.
|
||||
assert!(seg.overlaps_spn(69, 100));
|
||||
assert!(seg.overlaps_spn(200, 231));
|
||||
assert!(!seg.overlaps_spn(68, 99));
|
||||
assert!(!seg.overlaps_spn(201, 232));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_byte_to_lba_walks_extents() {
|
||||
use crate::disc::Extent;
|
||||
let extents = vec![
|
||||
Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 10,
|
||||
}, // clip bytes [0, 20480)
|
||||
Extent {
|
||||
start_lba: 500,
|
||||
sector_count: 10,
|
||||
}, // clip bytes [20480, 40960)
|
||||
];
|
||||
assert_eq!(clip_byte_to_lba(&extents, 0), Some(100));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 2048), Some(101));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 20480), Some(500)); // second extent
|
||||
assert_eq!(clip_byte_to_lba(&extents, 22528), Some(501));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 40960), None); // past the clip
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_real_disc_layout() {
|
||||
// First three records observed on retail 2.1: the variant
|
||||
// field counts 1,2,3,… (it wraps at 32 further into the table — see
|
||||
// `index_field_cycles_one_to_thirty_two`), segments are 2560 packets.
|
||||
let tbl = build_tbl(&[
|
||||
(1, 343680, 346239),
|
||||
(2, 695616, 698175),
|
||||
(3, 1051840, 1054399),
|
||||
]);
|
||||
let segs = parse_individual_segments(&tbl).expect("parse");
|
||||
assert_eq!(segs.len(), 3);
|
||||
assert_eq!(segs[0].index, 1);
|
||||
assert_eq!(segs[1].index, 2);
|
||||
assert_eq!(segs[2].index, 3);
|
||||
assert_eq!(segs[0].start_spn, 343680);
|
||||
assert_eq!(segs[0].end_spn, 346239);
|
||||
assert_eq!(segs[0].packet_count(), 2560);
|
||||
assert_eq!(segs[0].byte_len(), 2560 * 192);
|
||||
assert_eq!(segs[0].start_byte(), 343680 * 192);
|
||||
assert!(segs[0].contains_spn(345000));
|
||||
assert!(!segs[0].contains_spn(343679));
|
||||
assert!(!segs[0].contains_spn(346240));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_record_size() {
|
||||
let mut tbl = build_tbl(&[(1, 0, 10)]);
|
||||
tbl[6..8].copy_from_slice(&20u16.to_be_bytes()); // record_size != 16
|
||||
assert!(parse_individual_segments(&tbl).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_and_overrun() {
|
||||
assert!(parse_individual_segments(&[0u8; 4]).is_none()); // < header
|
||||
let mut tbl = build_tbl(&[(1, 0, 10)]);
|
||||
tbl[4..6].copy_from_slice(&99u16.to_be_bytes()); // claims 99 recs, has 1
|
||||
assert!(parse_individual_segments(&tbl).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_table_is_empty_not_none() {
|
||||
let tbl = build_tbl(&[]);
|
||||
assert_eq!(parse_individual_segments(&tbl), Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packets_per_unit_is_thirty_two() {
|
||||
// 6144-byte aligned unit / 192-byte source packet.
|
||||
assert_eq!(PACKETS_PER_UNIT, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_inside_segment_routes_to_index() {
|
||||
// A real first-record segment: packets [343680, 346239].
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
|
||||
let off = 344000u64 * SOURCE_PACKET_LEN;
|
||||
let hit = segment_for_unit(&segs, off).expect("inside the segment");
|
||||
assert_eq!(hit.index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_field_cycles_one_to_thirty_two() {
|
||||
// Reality on a retail 2.1 disc: field@4 is the index, cycling 1..=32 in file
|
||||
// order (NOT a sequential segment id). Reproduce one-and-a-bit cycles.
|
||||
let mut recs = Vec::new();
|
||||
let mut spn = 1000u32;
|
||||
for row in 0..2 {
|
||||
for v in 1..=32u16 {
|
||||
recs.push((v, spn, spn + 2559));
|
||||
spn += 50_000; // ~one segment every ~67 MB
|
||||
}
|
||||
let _ = row;
|
||||
}
|
||||
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
|
||||
assert_eq!(segs.len(), 64);
|
||||
assert_eq!(segs[31].index, 32); // end of first cycle
|
||||
assert_eq!(segs[32].index, 1); // wraps, does not become 33
|
||||
assert!(segs.iter().all(|s| (1..=32).contains(&s.index)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_outside_every_segment_is_unit_key_miss() {
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit well before the segment is ordinary content → None (unit-key path).
|
||||
let off = 1000u64 * SOURCE_PACKET_LEN;
|
||||
assert!(segment_for_unit(&segs, off).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_straddling_a_segment_edge_counts_as_forensic() {
|
||||
// Segment starts at packet 100. A unit that ENDS just inside it (its 32
|
||||
// packets straddle the boundary) must still route to the index key,
|
||||
// because part of its ciphertext is forensic-encrypted.
|
||||
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
|
||||
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
let hit = segment_for_unit(&segs, off).expect("straddles the start edge");
|
||||
assert_eq!(hit.index, 7);
|
||||
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
|
||||
let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99]
|
||||
assert!(segment_for_unit(&segs, before).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_segments_never_routes_to_index() {
|
||||
// The 1.0 / 2.0 case: no forensic map, so every miss is a unit-key miss.
|
||||
assert!(segment_for_unit(&[], lba_byte_offset(0)).is_none());
|
||||
assert!(segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lba_maps_to_the_packet_grid() {
|
||||
// A unit is 3 sectors (6144 bytes) = 32 packets. Clip-relative LBA 3 is
|
||||
// the second aligned unit, which starts at packet 32.
|
||||
let off = lba_byte_offset(3);
|
||||
assert_eq!(off / SOURCE_PACKET_LEN, 32);
|
||||
}
|
||||
}
|
||||
|
||||
+60
-2
@@ -91,5 +91,63 @@ impl<'a> SegmentKeyTable<'a> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "segment_key_tests.rs"]
|
||||
mod tests;
|
||||
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], 0x34, "record 0x1234 carries its own fixture tag");
|
||||
assert_eq!(&rec[..8], &[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
|
||||
assert_eq!(t.record_payload(0x1234).unwrap().len(), 528);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_index_space_bounds_lookups() {
|
||||
let data = build(4, 32);
|
||||
let t = SegmentKeyTable::parse(&data).expect("parse");
|
||||
assert_eq!(t.record_count(), 4);
|
||||
assert!(t.record(3).is_some());
|
||||
assert!(t.record(4).is_none(), "selector past the table is None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_size_mismatch_and_truncation() {
|
||||
assert!(SegmentKeyTable::parse(&[0u8; 4]).is_none());
|
||||
let mut data = build(4, 32);
|
||||
data.truncate(data.len() - 1); // body no longer matches header
|
||||
assert!(SegmentKeyTable::parse(&data).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// Build a container with `record_size` and the given `index_space`, filling
|
||||
/// each record with a distinguishable byte so lookups can be checked.
|
||||
fn build(index_space: u16, record_size: u16) -> Vec<u8> {
|
||||
let count = if index_space == 0xffff {
|
||||
0x1_0000
|
||||
} else {
|
||||
index_space as usize
|
||||
};
|
||||
let mut v = Vec::with_capacity(HEADER_LEN + count * record_size as usize);
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // tag
|
||||
v.extend_from_slice(&index_space.to_be_bytes());
|
||||
v.extend_from_slice(&record_size.to_be_bytes());
|
||||
for i in 0..count {
|
||||
let mut rec = vec![(i & 0xff) as u8; record_size as usize];
|
||||
// sub-header, as seen on disc
|
||||
rec[..8].copy_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
|
||||
v.extend_from_slice(&rec);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_retail_container_geometry() {
|
||||
// The real disc: 0xffff index space, 536-byte records, 35,127,304 total.
|
||||
let data = build(0xffff, 536);
|
||||
assert_eq!(
|
||||
data.len(),
|
||||
35_127_304,
|
||||
"matches the retail file size exactly"
|
||||
);
|
||||
let t = SegmentKeyTable::parse(&data).expect("parse");
|
||||
assert_eq!(t.record_count(), 65_536);
|
||||
assert_eq!(t.record_size(), 536);
|
||||
let rec = t.record(0x1234).expect("record");
|
||||
assert_eq!(rec.len(), 536);
|
||||
assert_eq!(rec[8], 0x34, "record 0x1234 carries its own fixture tag");
|
||||
assert_eq!(&rec[..8], &[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
|
||||
assert_eq!(t.record_payload(0x1234).unwrap().len(), 528);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_index_space_bounds_lookups() {
|
||||
let data = build(4, 32);
|
||||
let t = SegmentKeyTable::parse(&data).expect("parse");
|
||||
assert_eq!(t.record_count(), 4);
|
||||
assert!(t.record(3).is_some());
|
||||
assert!(t.record(4).is_none(), "selector past the table is None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_size_mismatch_and_truncation() {
|
||||
assert!(SegmentKeyTable::parse(&[0u8; 4]).is_none());
|
||||
let mut data = build(4, 32);
|
||||
data.truncate(data.len() - 1); // body no longer matches header
|
||||
assert!(SegmentKeyTable::parse(&data).is_none());
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
|
||||
/// records. `recs` are `(index, start_spn, end_spn)`.
|
||||
fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
|
||||
v.extend_from_slice(&(recs.len() as u16).to_be_bytes()); // count
|
||||
v.extend_from_slice(&(SEGMENT_RECORD_LEN as u16).to_be_bytes()); // record_size
|
||||
for &(n, s, e) in recs {
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // marker
|
||||
v.extend_from_slice(&n.to_be_bytes());
|
||||
v.extend_from_slice(&1u16.to_be_bytes()); // flag
|
||||
v.extend_from_slice(&s.to_be_bytes());
|
||||
v.extend_from_slice(&e.to_be_bytes());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmts_key_ranges_maps_segments_to_lba_by_index() {
|
||||
use crate::disc::Extent;
|
||||
// One big clip extent starting at LBA 1000. Clip byte B lives at
|
||||
// LBA 1000 + B/2048.
|
||||
let extents = vec![Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 1_000_000,
|
||||
}];
|
||||
// Two segments, indexes 5 and 7 (spn ranges as on a real disc).
|
||||
let segs = vec![
|
||||
Segment {
|
||||
index: 5,
|
||||
start_spn: 100,
|
||||
end_spn: 199,
|
||||
},
|
||||
Segment {
|
||||
index: 7,
|
||||
start_spn: 10_000,
|
||||
end_spn: 10_099,
|
||||
},
|
||||
];
|
||||
// Pool layout [base, idx1, idx2, …] → index N uses key slot N.
|
||||
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||
assert_eq!(ranges.len(), 2, "one LBA range per segment");
|
||||
// Segment 0: spn 100..=199 → clip bytes [19200, 38400) → sectors 9..=18
|
||||
// → LBA 1009..1019, key index 5.
|
||||
assert_eq!(ranges[0], (1009, 1019, 5));
|
||||
// Segment 1: spn 10000..=10099 → bytes [1_920_000, 1_939_200) →
|
||||
// sectors 937..=946 → LBA 1937..1947, key index 7.
|
||||
assert_eq!(ranges[1], (1937, 1947, 7));
|
||||
|
||||
// The ranges drive a positive AacsKeyMap: an LBA in no range has no key.
|
||||
let map = crate::decrypt::AacsKeyMap::from_ranges(ranges);
|
||||
assert_eq!(map.key_idx_for(500), None, "outside any segment → no key");
|
||||
assert_eq!(
|
||||
map.key_idx_for(1012),
|
||||
Some(5),
|
||||
"inside index-5 segment → key 5"
|
||||
);
|
||||
assert_eq!(
|
||||
map.key_idx_for(1940),
|
||||
Some(7),
|
||||
"inside index-7 segment → key 7"
|
||||
);
|
||||
assert_eq!(
|
||||
map.key_idx_for(1019),
|
||||
None,
|
||||
"segment end is exclusive → no key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmts_key_ranges_carries_a_non_2048_aligned_segment() {
|
||||
use crate::disc::Extent;
|
||||
let extents = vec![Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 1_000_000,
|
||||
}];
|
||||
// start_spn=1 → start_byte=192 (NOT 2048-aligned); end_spn=10 → end_byte-1=2111.
|
||||
// Correct carry floor(2111/2048)-floor(192/2048)=1 maps to LBA 1000..=1001; the
|
||||
// old aligned (2111-192)/2048=0 mismatched b-a=1 and DROPPED it to the Unit Key (empty map).
|
||||
let segs = vec![Segment {
|
||||
index: 5,
|
||||
start_spn: 1,
|
||||
end_spn: 10,
|
||||
}];
|
||||
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||
assert_eq!(
|
||||
ranges.len(),
|
||||
1,
|
||||
"a non-2048-aligned segment must still carry, not be dropped"
|
||||
);
|
||||
assert_eq!(ranges[0], (1000, 1002, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmts_key_ranges_skips_inverted_segment_without_underflow() {
|
||||
use crate::disc::Extent;
|
||||
let extents = vec![Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 1_000_000,
|
||||
}];
|
||||
// start_spn == end_spn + 1: `end_byte - 1 - start_byte` would underflow.
|
||||
// The record must be skipped rather than panic (debug) / wrap (release).
|
||||
let segs = vec![Segment {
|
||||
index: 5,
|
||||
start_spn: 200,
|
||||
end_spn: 199,
|
||||
}];
|
||||
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||
assert!(ranges.is_empty(), "inverted segment yields no range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmts_key_ranges_skips_a_segment_straddling_non_adjacent_extents() {
|
||||
use crate::disc::Extent;
|
||||
// Clip bytes [0, 20480) at LBA 100.., [20480, 40960) at LBA 500.. (or reversed).
|
||||
// Packets 104..=109 span bytes 19968..21119, crossing the extent boundary.
|
||||
let segs = vec![Segment {
|
||||
index: 5,
|
||||
start_spn: 104,
|
||||
end_spn: 109,
|
||||
}];
|
||||
for (first, second) in [(100, 500), (500, 100)] {
|
||||
let extents = vec![
|
||||
Extent {
|
||||
start_lba: first,
|
||||
sector_count: 10,
|
||||
},
|
||||
Extent {
|
||||
start_lba: second,
|
||||
sector_count: 10,
|
||||
},
|
||||
];
|
||||
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||
assert!(ranges.is_empty(), "{first}/{second}: {ranges:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_edges_are_inclusive() {
|
||||
let seg = Segment {
|
||||
index: 1,
|
||||
start_spn: 100,
|
||||
end_spn: 200,
|
||||
};
|
||||
assert!(seg.contains_spn(100) && seg.contains_spn(200));
|
||||
assert!(!seg.contains_spn(99) && !seg.contains_spn(201));
|
||||
// A unit touching exactly one edge packet overlaps.
|
||||
assert!(seg.overlaps_spn(69, 100));
|
||||
assert!(seg.overlaps_spn(200, 231));
|
||||
assert!(!seg.overlaps_spn(68, 99));
|
||||
assert!(!seg.overlaps_spn(201, 232));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_byte_to_lba_walks_extents() {
|
||||
use crate::disc::Extent;
|
||||
let extents = vec![
|
||||
Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 10,
|
||||
}, // clip bytes [0, 20480)
|
||||
Extent {
|
||||
start_lba: 500,
|
||||
sector_count: 10,
|
||||
}, // clip bytes [20480, 40960)
|
||||
];
|
||||
assert_eq!(clip_byte_to_lba(&extents, 0), Some(100));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 2048), Some(101));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 20480), Some(500)); // second extent
|
||||
assert_eq!(clip_byte_to_lba(&extents, 22528), Some(501));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 40960), None); // past the clip
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_real_disc_layout() {
|
||||
// First three records observed on retail 2.1: the variant
|
||||
// field counts 1,2,3,… (it wraps at 32 further into the table — see
|
||||
// `index_field_cycles_one_to_thirty_two`), segments are 2560 packets.
|
||||
let tbl = build_tbl(&[
|
||||
(1, 343680, 346239),
|
||||
(2, 695616, 698175),
|
||||
(3, 1051840, 1054399),
|
||||
]);
|
||||
let segs = parse_individual_segments(&tbl).expect("parse");
|
||||
assert_eq!(segs.len(), 3);
|
||||
assert_eq!(segs[0].index, 1);
|
||||
assert_eq!(segs[1].index, 2);
|
||||
assert_eq!(segs[2].index, 3);
|
||||
assert_eq!(segs[0].start_spn, 343680);
|
||||
assert_eq!(segs[0].end_spn, 346239);
|
||||
assert_eq!(segs[0].packet_count(), 2560);
|
||||
assert_eq!(segs[0].byte_len(), 2560 * 192);
|
||||
assert_eq!(segs[0].start_byte(), 343680 * 192);
|
||||
assert!(segs[0].contains_spn(345000));
|
||||
assert!(!segs[0].contains_spn(343679));
|
||||
assert!(!segs[0].contains_spn(346240));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_record_size() {
|
||||
let mut tbl = build_tbl(&[(1, 0, 10)]);
|
||||
tbl[6..8].copy_from_slice(&20u16.to_be_bytes()); // record_size != 16
|
||||
assert!(parse_individual_segments(&tbl).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_and_overrun() {
|
||||
assert!(parse_individual_segments(&[0u8; 4]).is_none()); // < header
|
||||
let mut tbl = build_tbl(&[(1, 0, 10)]);
|
||||
tbl[4..6].copy_from_slice(&99u16.to_be_bytes()); // claims 99 recs, has 1
|
||||
assert!(parse_individual_segments(&tbl).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_table_is_empty_not_none() {
|
||||
let tbl = build_tbl(&[]);
|
||||
assert_eq!(parse_individual_segments(&tbl), Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packets_per_unit_is_thirty_two() {
|
||||
// 6144-byte aligned unit / 192-byte source packet.
|
||||
assert_eq!(PACKETS_PER_UNIT, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_inside_segment_routes_to_index() {
|
||||
// A real first-record segment: packets [343680, 346239].
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
|
||||
let off = 344000u64 * SOURCE_PACKET_LEN;
|
||||
let hit = segment_for_unit(&segs, off).expect("inside the segment");
|
||||
assert_eq!(hit.index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_field_cycles_one_to_thirty_two() {
|
||||
// Reality on a retail 2.1 disc: field@4 is the index, cycling 1..=32 in file
|
||||
// order (NOT a sequential segment id). Reproduce one-and-a-bit cycles.
|
||||
let mut recs = Vec::new();
|
||||
let mut spn = 1000u32;
|
||||
for row in 0..2 {
|
||||
for v in 1..=32u16 {
|
||||
recs.push((v, spn, spn + 2559));
|
||||
spn += 50_000; // ~one segment every ~67 MB
|
||||
}
|
||||
let _ = row;
|
||||
}
|
||||
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
|
||||
assert_eq!(segs.len(), 64);
|
||||
assert_eq!(segs[31].index, 32); // end of first cycle
|
||||
assert_eq!(segs[32].index, 1); // wraps, does not become 33
|
||||
assert!(segs.iter().all(|s| (1..=32).contains(&s.index)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_outside_every_segment_is_unit_key_miss() {
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit well before the segment is ordinary content → None (unit-key path).
|
||||
let off = 1000u64 * SOURCE_PACKET_LEN;
|
||||
assert!(segment_for_unit(&segs, off).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_straddling_a_segment_edge_counts_as_forensic() {
|
||||
// Segment starts at packet 100. A unit that ENDS just inside it (its 32
|
||||
// packets straddle the boundary) must still route to the index key,
|
||||
// because part of its ciphertext is forensic-encrypted.
|
||||
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
|
||||
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
let hit = segment_for_unit(&segs, off).expect("straddles the start edge");
|
||||
assert_eq!(hit.index, 7);
|
||||
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
|
||||
let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99]
|
||||
assert!(segment_for_unit(&segs, before).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_segments_never_routes_to_index() {
|
||||
// The 1.0 / 2.0 case: no forensic map, so every miss is a unit-key miss.
|
||||
assert!(segment_for_unit(&[], lba_byte_offset(0)).is_none());
|
||||
assert!(segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lba_maps_to_the_packet_grid() {
|
||||
// A unit is 3 sectors (6144 bytes) = 32 packets. Clip-relative LBA 3 is
|
||||
// the second aligned unit, which starts at packet 32.
|
||||
let off = lba_byte_offset(3);
|
||||
assert_eq!(off / SOURCE_PACKET_LEN, 32);
|
||||
}
|
||||
+37
-2
@@ -168,5 +168,40 @@ pub enum KeyOrigin {
|
||||
// map variants to display text (see freemkv's `disc_info::key_origin_label`).
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "trace_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The trace types are constructible, derive the required traits, and an
|
||||
/// empty trace round-trips. Pins the structural contract apps build against.
|
||||
#[test]
|
||||
fn trace_is_constructible_and_comparable() {
|
||||
let t = ResolutionTrace {
|
||||
unlock: vec![UnlockStep {
|
||||
who: "AACS cert".to_string(),
|
||||
outcome: UnlockOutcome::NoUsableHostCert { mkb: Some(68) },
|
||||
}],
|
||||
keys: vec![KeyStep {
|
||||
who: "keydb".to_string(),
|
||||
path: vec![
|
||||
KeyNode::MatchedDisc,
|
||||
KeyNode::FoundVuk,
|
||||
KeyNode::DerivedUnitKeys,
|
||||
],
|
||||
outcome: KeyOutcome::Resolved,
|
||||
matched_entry: None,
|
||||
store_entries: None,
|
||||
}],
|
||||
};
|
||||
// Clone + PartialEq (derive contract the renderers rely on).
|
||||
assert_eq!(t.clone(), t);
|
||||
// `who` is the source's name carried verbatim.
|
||||
assert_eq!(t.keys[0].who, "keydb");
|
||||
assert_eq!(t.unlock[0].who, "AACS cert");
|
||||
// Apps match on this id.
|
||||
assert_eq!(BUS_BLOCKED, "bus_blocked");
|
||||
// Default / new is empty.
|
||||
assert_eq!(ResolutionTrace::new(), ResolutionTrace::default());
|
||||
assert!(ResolutionTrace::new().unlock.is_empty());
|
||||
assert!(ResolutionTrace::new().keys.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// The trace types are constructible, derive the required traits, and an
|
||||
/// empty trace round-trips. Pins the structural contract apps build against.
|
||||
#[test]
|
||||
fn trace_is_constructible_and_comparable() {
|
||||
let t = ResolutionTrace {
|
||||
unlock: vec![UnlockStep {
|
||||
who: "AACS cert".to_string(),
|
||||
outcome: UnlockOutcome::NoUsableHostCert { mkb: Some(68) },
|
||||
}],
|
||||
keys: vec![KeyStep {
|
||||
who: "keydb".to_string(),
|
||||
path: vec![
|
||||
KeyNode::MatchedDisc,
|
||||
KeyNode::FoundVuk,
|
||||
KeyNode::DerivedUnitKeys,
|
||||
],
|
||||
outcome: KeyOutcome::Resolved,
|
||||
matched_entry: None,
|
||||
store_entries: None,
|
||||
}],
|
||||
};
|
||||
// Clone + PartialEq (derive contract the renderers rely on).
|
||||
assert_eq!(t.clone(), t);
|
||||
// `who` is the source's name carried verbatim.
|
||||
assert_eq!(t.keys[0].who, "keydb");
|
||||
assert_eq!(t.unlock[0].who, "AACS cert");
|
||||
// Apps match on this id.
|
||||
assert_eq!(BUS_BLOCKED, "bus_blocked");
|
||||
// Default / new is empty.
|
||||
assert_eq!(ResolutionTrace::new(), ResolutionTrace::default());
|
||||
assert!(ResolutionTrace::new().unlock.is_empty());
|
||||
assert!(ResolutionTrace::new().keys.is_empty());
|
||||
}
|
||||
+123
-4
@@ -188,9 +188,128 @@ impl std::fmt::Debug for DiscEntry {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "types_unit_key_tests.rs"]
|
||||
mod unit_key_tests;
|
||||
mod unit_key_tests {
|
||||
use super::*;
|
||||
|
||||
// Pinned against the two named constructors: `UnitKey::new` is the ordinary key,
|
||||
// `UnitKey::forensic` is an index key.
|
||||
#[test]
|
||||
fn is_default_index_separates_the_two_constructors() {
|
||||
let ordinary = UnitKey::new(0, [0xAA; 16]);
|
||||
assert!(
|
||||
ordinary.is_default_index(),
|
||||
"UnitKey::new builds the ordinary (index-0) key"
|
||||
);
|
||||
|
||||
// Every forensic index the spec allows must be reported as NOT default.
|
||||
for n in 1u8..=32 {
|
||||
let k = UnitKey::forensic(0, [0xAA; 16], n);
|
||||
assert!(
|
||||
!k.is_default_index(),
|
||||
"UnitKey::forensic({n}) is an index key, not the default key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Must agree with `resolve_disc_index`, the one consumer of `index_number`.
|
||||
#[test]
|
||||
fn is_default_index_agrees_with_the_forensic_index_resolver() {
|
||||
use crate::aacs::index_select::resolve_disc_index;
|
||||
|
||||
let keys = [
|
||||
UnitKey::new(0, [0x11; 16]),
|
||||
UnitKey::forensic(1, [0x22; 16], 7),
|
||||
];
|
||||
assert_eq!(
|
||||
resolve_disc_index(&keys),
|
||||
Some(7),
|
||||
"sanity: the resolver picks the forensic key's index"
|
||||
);
|
||||
|
||||
let non_default: Vec<u8> = keys
|
||||
.iter()
|
||||
.filter(|k| !k.is_default_index())
|
||||
.map(|k| k.index_number)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
non_default,
|
||||
vec![7],
|
||||
"exactly the key the resolver picked must be non-default"
|
||||
);
|
||||
|
||||
// An all-ordinary key set resolves no index, and every key must report
|
||||
// itself default.
|
||||
let plain = [UnitKey::new(0, [0x11; 16]), UnitKey::new(1, [0x22; 16])];
|
||||
assert_eq!(resolve_disc_index(&plain), None);
|
||||
assert!(plain.iter().all(|k| k.is_default_index()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "types_redaction_tests.rs"]
|
||||
mod redaction_tests;
|
||||
mod redaction_tests {
|
||||
use super::*;
|
||||
|
||||
// Sentinel byte 0xD5 = decimal 213: a derived `Debug` prints `[u8;N]` as
|
||||
// decimal, so a leak surfaces "213" (no non-secret field is 213). Each type
|
||||
// must also carry a "redacted" marker so re-adding `#[derive(Debug)]` fails.
|
||||
const S: u8 = 0xD5;
|
||||
|
||||
fn assert_redacted(what: &str, dbg: &str) {
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"{what}: Debug leaked key bytes (found decimal 213): {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"{what}: Debug missing redaction marker: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_key_debug_is_redacted() {
|
||||
let d = DeviceKey {
|
||||
key: [S; 16],
|
||||
node: 1,
|
||||
uv: 2,
|
||||
u_mask_shift: 3,
|
||||
};
|
||||
assert_redacted("DeviceKey", &format!("{d:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_cert_debug_is_redacted() {
|
||||
let h = HostCert {
|
||||
private_key: [S; 20],
|
||||
certificate: vec![0u8; 92],
|
||||
private_key_v2: Some([S; 32]),
|
||||
certificate_v2: None,
|
||||
};
|
||||
assert_redacted("HostCert", &format!("{h:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newtype_keys_debug_is_redacted() {
|
||||
assert_redacted("Vid", &format!("{:?}", Vid([S; 16])));
|
||||
assert_redacted("MediaKey", &format!("{:?}", MediaKey([S; 16])));
|
||||
assert_redacted("Vuk", &format!("{:?}", Vuk([S; 16])));
|
||||
assert_redacted("ProcessingKey", &format!("{:?}", ProcessingKey([S; 16])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_key_debug_is_redacted() {
|
||||
assert_redacted("UnitKey", &format!("{:?}", UnitKey::new(0, [S; 16])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_entry_debug_is_redacted() {
|
||||
let e = DiscEntry {
|
||||
disc_hash: "0xAA".into(),
|
||||
title: "T".into(),
|
||||
media_key: Some([S; 16]),
|
||||
disc_id: Some([S; 16]),
|
||||
vuk: Some([S; 16]),
|
||||
unit_keys: vec![(1, [S; 16])],
|
||||
};
|
||||
assert_redacted("DiscEntry", &format!("{e:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// Sentinel byte 0xD5 = decimal 213: a derived `Debug` prints `[u8;N]` as
|
||||
// decimal, so a leak surfaces "213" (no non-secret field is 213). Each type
|
||||
// must also carry a "redacted" marker so re-adding `#[derive(Debug)]` fails.
|
||||
const S: u8 = 0xD5;
|
||||
|
||||
fn assert_redacted(what: &str, dbg: &str) {
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"{what}: Debug leaked key bytes (found decimal 213): {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"{what}: Debug missing redaction marker: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_key_debug_is_redacted() {
|
||||
let d = DeviceKey {
|
||||
key: [S; 16],
|
||||
node: 1,
|
||||
uv: 2,
|
||||
u_mask_shift: 3,
|
||||
};
|
||||
assert_redacted("DeviceKey", &format!("{d:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_cert_debug_is_redacted() {
|
||||
let h = HostCert {
|
||||
private_key: [S; 20],
|
||||
certificate: vec![0u8; 92],
|
||||
private_key_v2: Some([S; 32]),
|
||||
certificate_v2: None,
|
||||
};
|
||||
assert_redacted("HostCert", &format!("{h:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newtype_keys_debug_is_redacted() {
|
||||
assert_redacted("Vid", &format!("{:?}", Vid([S; 16])));
|
||||
assert_redacted("MediaKey", &format!("{:?}", MediaKey([S; 16])));
|
||||
assert_redacted("Vuk", &format!("{:?}", Vuk([S; 16])));
|
||||
assert_redacted("ProcessingKey", &format!("{:?}", ProcessingKey([S; 16])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_key_debug_is_redacted() {
|
||||
assert_redacted("UnitKey", &format!("{:?}", UnitKey::new(0, [S; 16])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_entry_debug_is_redacted() {
|
||||
let e = DiscEntry {
|
||||
disc_hash: "0xAA".into(),
|
||||
title: "T".into(),
|
||||
media_key: Some([S; 16]),
|
||||
disc_id: Some([S; 16]),
|
||||
vuk: Some([S; 16]),
|
||||
unit_keys: vec![(1, [S; 16])],
|
||||
};
|
||||
assert_redacted("DiscEntry", &format!("{e:?}"));
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// Pinned against the two named constructors: `UnitKey::new` is the ordinary key,
|
||||
// `UnitKey::forensic` is an index key.
|
||||
#[test]
|
||||
fn is_default_index_separates_the_two_constructors() {
|
||||
let ordinary = UnitKey::new(0, [0xAA; 16]);
|
||||
assert!(
|
||||
ordinary.is_default_index(),
|
||||
"UnitKey::new builds the ordinary (index-0) key"
|
||||
);
|
||||
|
||||
// Every forensic index the spec allows must be reported as NOT default.
|
||||
for n in 1u8..=32 {
|
||||
let k = UnitKey::forensic(0, [0xAA; 16], n);
|
||||
assert!(
|
||||
!k.is_default_index(),
|
||||
"UnitKey::forensic({n}) is an index key, not the default key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Must agree with `resolve_disc_index`, the one consumer of `index_number`.
|
||||
#[test]
|
||||
fn is_default_index_agrees_with_the_forensic_index_resolver() {
|
||||
use crate::aacs::index_select::resolve_disc_index;
|
||||
|
||||
let keys = [
|
||||
UnitKey::new(0, [0x11; 16]),
|
||||
UnitKey::forensic(1, [0x22; 16], 7),
|
||||
];
|
||||
assert_eq!(
|
||||
resolve_disc_index(&keys),
|
||||
Some(7),
|
||||
"sanity: the resolver picks the forensic key's index"
|
||||
);
|
||||
|
||||
let non_default: Vec<u8> = keys
|
||||
.iter()
|
||||
.filter(|k| !k.is_default_index())
|
||||
.map(|k| k.index_number)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
non_default,
|
||||
vec![7],
|
||||
"exactly the key the resolver picked must be non-default"
|
||||
);
|
||||
|
||||
// An all-ordinary key set resolves no index, and every key must report
|
||||
// itself default.
|
||||
let plain = [UnitKey::new(0, [0x11; 16]), UnitKey::new(1, [0x22; 16])];
|
||||
assert_eq!(resolve_disc_index(&plain), None);
|
||||
assert!(plain.iter().all(|k| k.is_default_index()));
|
||||
}
|
||||
+1540
-2
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+285
-2
@@ -227,5 +227,288 @@ impl<'a> BitReader<'a> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "bdjo_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Bit reader unit tests ──────────────────────────────────────────────
|
||||
#[test]
|
||||
fn bit_reader_reads_msb_first_and_bounds_checks() {
|
||||
let data = [0b1011_0010u8, 0b0100_0001];
|
||||
let mut r = BitReader::new(&data);
|
||||
assert_eq!(r.read(1), Some(1));
|
||||
assert_eq!(r.read(3), Some(0b011));
|
||||
assert_eq!(r.read(4), Some(0b0010));
|
||||
assert_eq!(r.read(8), Some(0b0100_0001));
|
||||
// Nothing left.
|
||||
assert_eq!(r.read(1), None);
|
||||
assert_eq!(r.read(0), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bit_reader_skip_past_end_is_none() {
|
||||
let data = [0u8; 2];
|
||||
let mut r = BitReader::new(&data);
|
||||
assert_eq!(r.skip(16), Some(()));
|
||||
assert_eq!(r.skip(1), None);
|
||||
}
|
||||
|
||||
// ── Fixture builder ─────────────────────────────────────────────────────
|
||||
// Byte-aligned BDJO builder for a single-app AMT. All bit fields here happen
|
||||
// to fall on byte boundaries, so the fixture can be assembled from bytes.
|
||||
struct AppSpec {
|
||||
control_code: u8,
|
||||
base_dir: &'static str,
|
||||
classpath_extension: &'static str,
|
||||
initial_class: &'static str,
|
||||
}
|
||||
|
||||
// Optional content the minimal fixture leaves empty, laid out per the BD-J
|
||||
// Object (BDJO) file format: cache items, accessible playlists, and
|
||||
// per-app profiles / application_name bytes / parameter bytes.
|
||||
#[derive(Default)]
|
||||
struct Extras {
|
||||
cache_items: usize,
|
||||
playlists: usize,
|
||||
profiles: usize,
|
||||
name_bytes: usize,
|
||||
param_bytes: usize,
|
||||
}
|
||||
|
||||
fn push_app_string(buf: &mut Vec<u8>, s: &str) {
|
||||
buf.push(s.len() as u8);
|
||||
buf.extend_from_slice(s.as_bytes());
|
||||
if s.len().is_multiple_of(2) {
|
||||
buf.push(0); // word-align pad on even length
|
||||
}
|
||||
}
|
||||
|
||||
fn build_bdjo(apps: &[AppSpec]) -> Vec<u8> {
|
||||
build_bdjo_with(apps, &Extras::default())
|
||||
}
|
||||
|
||||
fn build_bdjo_with(apps: &[AppSpec], x: &Extras) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
// Header
|
||||
b.extend_from_slice(b"BDJO");
|
||||
b.extend_from_slice(b"0200");
|
||||
b.extend_from_slice(&[0u8; 40]); // section-address table
|
||||
|
||||
// TerminalInfo: length(4) + default_font(5) + 1 byte (havi+masks) + pad(34 bits)
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
b.extend_from_slice(&[0u8; 5]);
|
||||
// 4+1+1 = 6 bits then 34 bits padding = 40 bits = 5 bytes total.
|
||||
b.extend_from_slice(&[0u8; 5]);
|
||||
|
||||
// AppCacheInfo: length(4) + num_item(1) + pad(1), then 12-byte items:
|
||||
// type(1) + ref_to_name(5) + lang_code(3) + pad(3).
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
b.push(x.cache_items as u8);
|
||||
b.push(0);
|
||||
for _ in 0..x.cache_items {
|
||||
b.push(1);
|
||||
b.extend_from_slice(b"00007eng");
|
||||
b.extend_from_slice(&[0u8; 3]);
|
||||
}
|
||||
|
||||
// AccessiblePlaylists: length(4) + [num_pl(11)+flags(2)+pad(19)], then
|
||||
// 6-byte entries: name(5) + pad(1).
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
b.extend_from_slice(&(((x.playlists as u32) << 21) | (1 << 20)).to_be_bytes());
|
||||
for _ in 0..x.playlists {
|
||||
b.extend_from_slice(b"00800\0");
|
||||
}
|
||||
|
||||
// AppManagementTable: length(4) + num_app(1) + pad(1)
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
b.push(apps.len() as u8);
|
||||
b.push(0);
|
||||
|
||||
for a in apps {
|
||||
b.push(a.control_code); // control_code(8)
|
||||
b.push(0); // type(4)+reserved(4)
|
||||
b.extend_from_slice(&[0u8; 4]); // org_id(32)
|
||||
b.extend_from_slice(&[0u8; 2]); // app_id(16)
|
||||
b.extend_from_slice(&[0u8; 10]); // descriptor tag+length(80)
|
||||
// num_profile(4) + pad(12), then 6-byte profiles:
|
||||
// profile(2) + major(1) + minor(1) + micro(1) + pad(1).
|
||||
b.push((x.profiles as u8) << 4);
|
||||
b.push(0);
|
||||
for _ in 0..x.profiles {
|
||||
b.extend_from_slice(&[0, 1, 1, 0, 0, 0]);
|
||||
}
|
||||
b.push(0); // priority(8)
|
||||
b.push(0); // binding(2)+visibility(2)+reserved(4)
|
||||
// application_name: data_length(16) + bytes, word-aligned (pad when odd).
|
||||
b.extend_from_slice(&(x.name_bytes as u16).to_be_bytes());
|
||||
b.extend(std::iter::repeat_n(b'n', x.name_bytes));
|
||||
if !x.name_bytes.is_multiple_of(2) {
|
||||
b.push(0);
|
||||
}
|
||||
// icon_locator (empty word-aligned string): len=0 + pad
|
||||
push_app_string(&mut b, "");
|
||||
b.extend_from_slice(&[0u8; 2]); // icon_flags(16)
|
||||
push_app_string(&mut b, a.base_dir);
|
||||
push_app_string(&mut b, a.classpath_extension);
|
||||
push_app_string(&mut b, a.initial_class);
|
||||
// application_parameters: data_length(8) + bytes, word-aligned (pad when even).
|
||||
b.push(x.param_bytes as u8);
|
||||
b.extend(std::iter::repeat_n(b'p', x.param_bytes));
|
||||
if x.param_bytes.is_multiple_of(2) {
|
||||
b.push(0);
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
// Every skipped-by-width region populated (odd and even name/param lengths):
|
||||
// a wrong item/profile/padding width misaligns the strings that follow.
|
||||
#[test]
|
||||
fn parses_apps_with_cache_items_playlists_profiles_names_and_params() {
|
||||
for (name_bytes, param_bytes) in [(5, 3), (6, 4)] {
|
||||
let x = Extras {
|
||||
cache_items: 2,
|
||||
playlists: 3,
|
||||
profiles: 2,
|
||||
name_bytes,
|
||||
param_bytes,
|
||||
};
|
||||
let bytes = build_bdjo_with(
|
||||
&[
|
||||
AppSpec {
|
||||
control_code: 2,
|
||||
base_dir: "00009",
|
||||
classpath_extension: "",
|
||||
initial_class: "com.studio.Helper",
|
||||
},
|
||||
AppSpec {
|
||||
control_code: 1,
|
||||
base_dir: "00000",
|
||||
classpath_extension: "00001",
|
||||
initial_class: "com.studio.MainXlet",
|
||||
},
|
||||
],
|
||||
&x,
|
||||
);
|
||||
let apps = parse(&bytes).expect("parses");
|
||||
assert_eq!(apps.len(), 2);
|
||||
assert_eq!(apps[0].initial_class, "com.studio.Helper");
|
||||
assert_eq!(apps[1].jar_ids(), vec!["00000", "00001"]);
|
||||
assert_eq!(apps[1].initial_class, "com.studio.MainXlet");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_strings_drop_trailing_nul_padding() {
|
||||
let bytes = build_bdjo(&[AppSpec {
|
||||
control_code: 1,
|
||||
base_dir: "0000\0",
|
||||
classpath_extension: "",
|
||||
initial_class: "a.B\0\0",
|
||||
}]);
|
||||
let apps = parse(&bytes).expect("parses");
|
||||
assert_eq!(apps[0].base_directory, "0000");
|
||||
assert_eq!(apps[0].initial_class, "a.B");
|
||||
assert_eq!(apps[0].jar_ids(), vec!["0000"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_autostart_app_fqcn_and_jar_ids() {
|
||||
let bytes = build_bdjo(&[AppSpec {
|
||||
control_code: 1,
|
||||
base_dir: "00000",
|
||||
classpath_extension: "00001;00002",
|
||||
initial_class: "com.foxbd.StandardMenuXlet",
|
||||
}]);
|
||||
let apps = parse(&bytes).expect("parses");
|
||||
assert_eq!(apps.len(), 1);
|
||||
let a = &apps[0];
|
||||
assert!(a.is_autostart());
|
||||
assert_eq!(a.initial_class, "com.foxbd.StandardMenuXlet");
|
||||
assert_eq!(a.base_directory, "00000");
|
||||
assert_eq!(a.jar_ids(), vec!["00000", "00001", "00002"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multiple_apps_and_finds_the_autostart_one() {
|
||||
let bytes = build_bdjo(&[
|
||||
AppSpec {
|
||||
control_code: 2, // PRESENT, not autostart
|
||||
base_dir: "00009",
|
||||
classpath_extension: "",
|
||||
initial_class: "com.studio.Helper",
|
||||
},
|
||||
AppSpec {
|
||||
control_code: 1, // AUTOSTART
|
||||
base_dir: "00000",
|
||||
classpath_extension: "",
|
||||
initial_class: "com.studio.MainXlet",
|
||||
},
|
||||
]);
|
||||
let apps = parse(&bytes).expect("parses");
|
||||
assert_eq!(apps.len(), 2);
|
||||
let auto: Vec<&BdjoApp> = apps.iter().filter(|a| a.is_autostart()).collect();
|
||||
assert_eq!(auto.len(), 1);
|
||||
assert_eq!(auto[0].initial_class, "com.studio.MainXlet");
|
||||
assert_eq!(auto[0].jar_ids(), vec!["00000"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_magic() {
|
||||
let mut bytes = build_bdjo(&[AppSpec {
|
||||
control_code: 1,
|
||||
base_dir: "00000",
|
||||
classpath_extension: "",
|
||||
initial_class: "X",
|
||||
}]);
|
||||
bytes[0..4].copy_from_slice(b"NOPE");
|
||||
assert!(parse(&bytes).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_yields_none_never_panics() {
|
||||
let bytes = build_bdjo(&[AppSpec {
|
||||
control_code: 1,
|
||||
base_dir: "00000",
|
||||
classpath_extension: "",
|
||||
initial_class: "com.studio.MainXlet",
|
||||
}]);
|
||||
// Every truncation length must return None, never panic or a partial Some.
|
||||
for cut in 0..bytes.len() {
|
||||
assert_eq!(parse(&bytes[..cut]), None, "cut={cut}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_never_panics_on_random_bytes() {
|
||||
let mut state: u64 = 0xB0D0_DEAD_BEEF_1234u64;
|
||||
for _ in 0..500 {
|
||||
// xorshift64*
|
||||
let mut x = state;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
state = x;
|
||||
let len = (x % 300) as usize;
|
||||
let mut buf = vec![0u8; len];
|
||||
for (i, b) in buf.iter_mut().enumerate() {
|
||||
*b = ((x >> (i % 57)) & 0xFF) as u8;
|
||||
}
|
||||
// Give some of them a valid magic to exercise deeper paths.
|
||||
if len >= 4 && x & 1 == 0 {
|
||||
buf[0..4].copy_from_slice(b"BDJO");
|
||||
}
|
||||
let _ = parse(&buf);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jar_ids_dedups_and_skips_empties() {
|
||||
let a = BdjoApp {
|
||||
control_code: 1,
|
||||
base_directory: "00000".into(),
|
||||
classpath_extension: "00000;;00003; ".into(),
|
||||
initial_class: "X".into(),
|
||||
};
|
||||
assert_eq!(a.jar_ids(), vec!["00000", "00003"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// ── Bit reader unit tests ──────────────────────────────────────────────
|
||||
#[test]
|
||||
fn bit_reader_reads_msb_first_and_bounds_checks() {
|
||||
let data = [0b1011_0010u8, 0b0100_0001];
|
||||
let mut r = BitReader::new(&data);
|
||||
assert_eq!(r.read(1), Some(1));
|
||||
assert_eq!(r.read(3), Some(0b011));
|
||||
assert_eq!(r.read(4), Some(0b0010));
|
||||
assert_eq!(r.read(8), Some(0b0100_0001));
|
||||
// Nothing left.
|
||||
assert_eq!(r.read(1), None);
|
||||
assert_eq!(r.read(0), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bit_reader_skip_past_end_is_none() {
|
||||
let data = [0u8; 2];
|
||||
let mut r = BitReader::new(&data);
|
||||
assert_eq!(r.skip(16), Some(()));
|
||||
assert_eq!(r.skip(1), None);
|
||||
}
|
||||
|
||||
// ── Fixture builder ─────────────────────────────────────────────────────
|
||||
// Byte-aligned BDJO builder for a single-app AMT. All bit fields here happen
|
||||
// to fall on byte boundaries, so the fixture can be assembled from bytes.
|
||||
struct AppSpec {
|
||||
control_code: u8,
|
||||
base_dir: &'static str,
|
||||
classpath_extension: &'static str,
|
||||
initial_class: &'static str,
|
||||
}
|
||||
|
||||
// Optional content the minimal fixture leaves empty, laid out per the BD-J
|
||||
// Object (BDJO) file format: cache items, accessible playlists, and
|
||||
// per-app profiles / application_name bytes / parameter bytes.
|
||||
#[derive(Default)]
|
||||
struct Extras {
|
||||
cache_items: usize,
|
||||
playlists: usize,
|
||||
profiles: usize,
|
||||
name_bytes: usize,
|
||||
param_bytes: usize,
|
||||
}
|
||||
|
||||
fn push_app_string(buf: &mut Vec<u8>, s: &str) {
|
||||
buf.push(s.len() as u8);
|
||||
buf.extend_from_slice(s.as_bytes());
|
||||
if s.len().is_multiple_of(2) {
|
||||
buf.push(0); // word-align pad on even length
|
||||
}
|
||||
}
|
||||
|
||||
fn build_bdjo(apps: &[AppSpec]) -> Vec<u8> {
|
||||
build_bdjo_with(apps, &Extras::default())
|
||||
}
|
||||
|
||||
fn build_bdjo_with(apps: &[AppSpec], x: &Extras) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
// Header
|
||||
b.extend_from_slice(b"BDJO");
|
||||
b.extend_from_slice(b"0200");
|
||||
b.extend_from_slice(&[0u8; 40]); // section-address table
|
||||
|
||||
// TerminalInfo: length(4) + default_font(5) + 1 byte (havi+masks) + pad(34 bits)
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
b.extend_from_slice(&[0u8; 5]);
|
||||
// 4+1+1 = 6 bits then 34 bits padding = 40 bits = 5 bytes total.
|
||||
b.extend_from_slice(&[0u8; 5]);
|
||||
|
||||
// AppCacheInfo: length(4) + num_item(1) + pad(1), then 12-byte items:
|
||||
// type(1) + ref_to_name(5) + lang_code(3) + pad(3).
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
b.push(x.cache_items as u8);
|
||||
b.push(0);
|
||||
for _ in 0..x.cache_items {
|
||||
b.push(1);
|
||||
b.extend_from_slice(b"00007eng");
|
||||
b.extend_from_slice(&[0u8; 3]);
|
||||
}
|
||||
|
||||
// AccessiblePlaylists: length(4) + [num_pl(11)+flags(2)+pad(19)], then
|
||||
// 6-byte entries: name(5) + pad(1).
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
b.extend_from_slice(&(((x.playlists as u32) << 21) | (1 << 20)).to_be_bytes());
|
||||
for _ in 0..x.playlists {
|
||||
b.extend_from_slice(b"00800\0");
|
||||
}
|
||||
|
||||
// AppManagementTable: length(4) + num_app(1) + pad(1)
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
b.push(apps.len() as u8);
|
||||
b.push(0);
|
||||
|
||||
for a in apps {
|
||||
b.push(a.control_code); // control_code(8)
|
||||
b.push(0); // type(4)+reserved(4)
|
||||
b.extend_from_slice(&[0u8; 4]); // org_id(32)
|
||||
b.extend_from_slice(&[0u8; 2]); // app_id(16)
|
||||
b.extend_from_slice(&[0u8; 10]); // descriptor tag+length(80)
|
||||
// num_profile(4) + pad(12), then 6-byte profiles:
|
||||
// profile(2) + major(1) + minor(1) + micro(1) + pad(1).
|
||||
b.push((x.profiles as u8) << 4);
|
||||
b.push(0);
|
||||
for _ in 0..x.profiles {
|
||||
b.extend_from_slice(&[0, 1, 1, 0, 0, 0]);
|
||||
}
|
||||
b.push(0); // priority(8)
|
||||
b.push(0); // binding(2)+visibility(2)+reserved(4)
|
||||
// application_name: data_length(16) + bytes, word-aligned (pad when odd).
|
||||
b.extend_from_slice(&(x.name_bytes as u16).to_be_bytes());
|
||||
b.extend(std::iter::repeat_n(b'n', x.name_bytes));
|
||||
if !x.name_bytes.is_multiple_of(2) {
|
||||
b.push(0);
|
||||
}
|
||||
// icon_locator (empty word-aligned string): len=0 + pad
|
||||
push_app_string(&mut b, "");
|
||||
b.extend_from_slice(&[0u8; 2]); // icon_flags(16)
|
||||
push_app_string(&mut b, a.base_dir);
|
||||
push_app_string(&mut b, a.classpath_extension);
|
||||
push_app_string(&mut b, a.initial_class);
|
||||
// application_parameters: data_length(8) + bytes, word-aligned (pad when even).
|
||||
b.push(x.param_bytes as u8);
|
||||
b.extend(std::iter::repeat_n(b'p', x.param_bytes));
|
||||
if x.param_bytes.is_multiple_of(2) {
|
||||
b.push(0);
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
// Every skipped-by-width region populated (odd and even name/param lengths):
|
||||
// a wrong item/profile/padding width misaligns the strings that follow.
|
||||
#[test]
|
||||
fn parses_apps_with_cache_items_playlists_profiles_names_and_params() {
|
||||
for (name_bytes, param_bytes) in [(5, 3), (6, 4)] {
|
||||
let x = Extras {
|
||||
cache_items: 2,
|
||||
playlists: 3,
|
||||
profiles: 2,
|
||||
name_bytes,
|
||||
param_bytes,
|
||||
};
|
||||
let bytes = build_bdjo_with(
|
||||
&[
|
||||
AppSpec {
|
||||
control_code: 2,
|
||||
base_dir: "00009",
|
||||
classpath_extension: "",
|
||||
initial_class: "com.studio.Helper",
|
||||
},
|
||||
AppSpec {
|
||||
control_code: 1,
|
||||
base_dir: "00000",
|
||||
classpath_extension: "00001",
|
||||
initial_class: "com.studio.MainXlet",
|
||||
},
|
||||
],
|
||||
&x,
|
||||
);
|
||||
let apps = parse(&bytes).expect("parses");
|
||||
assert_eq!(apps.len(), 2);
|
||||
assert_eq!(apps[0].initial_class, "com.studio.Helper");
|
||||
assert_eq!(apps[1].jar_ids(), vec!["00000", "00001"]);
|
||||
assert_eq!(apps[1].initial_class, "com.studio.MainXlet");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_strings_drop_trailing_nul_padding() {
|
||||
let bytes = build_bdjo(&[AppSpec {
|
||||
control_code: 1,
|
||||
base_dir: "0000\0",
|
||||
classpath_extension: "",
|
||||
initial_class: "a.B\0\0",
|
||||
}]);
|
||||
let apps = parse(&bytes).expect("parses");
|
||||
assert_eq!(apps[0].base_directory, "0000");
|
||||
assert_eq!(apps[0].initial_class, "a.B");
|
||||
assert_eq!(apps[0].jar_ids(), vec!["0000"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_autostart_app_fqcn_and_jar_ids() {
|
||||
let bytes = build_bdjo(&[AppSpec {
|
||||
control_code: 1,
|
||||
base_dir: "00000",
|
||||
classpath_extension: "00001;00002",
|
||||
initial_class: "com.foxbd.StandardMenuXlet",
|
||||
}]);
|
||||
let apps = parse(&bytes).expect("parses");
|
||||
assert_eq!(apps.len(), 1);
|
||||
let a = &apps[0];
|
||||
assert!(a.is_autostart());
|
||||
assert_eq!(a.initial_class, "com.foxbd.StandardMenuXlet");
|
||||
assert_eq!(a.base_directory, "00000");
|
||||
assert_eq!(a.jar_ids(), vec!["00000", "00001", "00002"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multiple_apps_and_finds_the_autostart_one() {
|
||||
let bytes = build_bdjo(&[
|
||||
AppSpec {
|
||||
control_code: 2, // PRESENT, not autostart
|
||||
base_dir: "00009",
|
||||
classpath_extension: "",
|
||||
initial_class: "com.studio.Helper",
|
||||
},
|
||||
AppSpec {
|
||||
control_code: 1, // AUTOSTART
|
||||
base_dir: "00000",
|
||||
classpath_extension: "",
|
||||
initial_class: "com.studio.MainXlet",
|
||||
},
|
||||
]);
|
||||
let apps = parse(&bytes).expect("parses");
|
||||
assert_eq!(apps.len(), 2);
|
||||
let auto: Vec<&BdjoApp> = apps.iter().filter(|a| a.is_autostart()).collect();
|
||||
assert_eq!(auto.len(), 1);
|
||||
assert_eq!(auto[0].initial_class, "com.studio.MainXlet");
|
||||
assert_eq!(auto[0].jar_ids(), vec!["00000"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_magic() {
|
||||
let mut bytes = build_bdjo(&[AppSpec {
|
||||
control_code: 1,
|
||||
base_dir: "00000",
|
||||
classpath_extension: "",
|
||||
initial_class: "X",
|
||||
}]);
|
||||
bytes[0..4].copy_from_slice(b"NOPE");
|
||||
assert!(parse(&bytes).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_yields_none_never_panics() {
|
||||
let bytes = build_bdjo(&[AppSpec {
|
||||
control_code: 1,
|
||||
base_dir: "00000",
|
||||
classpath_extension: "",
|
||||
initial_class: "com.studio.MainXlet",
|
||||
}]);
|
||||
// Every truncation length must return None, never panic or a partial Some.
|
||||
for cut in 0..bytes.len() {
|
||||
assert_eq!(parse(&bytes[..cut]), None, "cut={cut}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_never_panics_on_random_bytes() {
|
||||
let mut state: u64 = 0xB0D0_DEAD_BEEF_1234u64;
|
||||
for _ in 0..500 {
|
||||
// xorshift64*
|
||||
let mut x = state;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
state = x;
|
||||
let len = (x % 300) as usize;
|
||||
let mut buf = vec![0u8; len];
|
||||
for (i, b) in buf.iter_mut().enumerate() {
|
||||
*b = ((x >> (i % 57)) & 0xFF) as u8;
|
||||
}
|
||||
// Give some of them a valid magic to exercise deeper paths.
|
||||
if len >= 4 && x & 1 == 0 {
|
||||
buf[0..4].copy_from_slice(b"BDJO");
|
||||
}
|
||||
let _ = parse(&buf);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jar_ids_dedups_and_skips_empties() {
|
||||
let a = BdjoApp {
|
||||
control_code: 1,
|
||||
base_directory: "00000".into(),
|
||||
classpath_extension: "00000;;00003; ".into(),
|
||||
initial_class: "X".into(),
|
||||
};
|
||||
assert_eq!(a.jar_ids(), vec!["00000", "00003"]);
|
||||
}
|
||||
+84
-3
@@ -60,7 +60,7 @@ pub(crate) fn parse(d: &[u8]) -> Option<Index> {
|
||||
if d.get(0..4)? != b"INDX" {
|
||||
return None;
|
||||
}
|
||||
// version at 4..8 ("0100"/"0200"/"0240"/"0300") is not load-bearing for resolution.
|
||||
// version at 4..8 ("0100"/"0200"/"0300") is not load-bearing for resolution.
|
||||
let indexes_start = be_u32(d, 8)? as usize;
|
||||
// At indexes_start: u32 index_len, First-Play(12), Top-Menu(12), u16 titles.
|
||||
let mut o = indexes_start.checked_add(4)?;
|
||||
@@ -89,5 +89,86 @@ pub(crate) fn parse(d: &[u8]) -> Option<Index> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "index_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a 12-byte object record: `object_type` in the top two bits of
|
||||
/// byte 0, HDMV `id_ref` big-endian at offset 6.
|
||||
fn hdmv_obj(id_ref: u16) -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = 1 << 6;
|
||||
b[6..8].copy_from_slice(&id_ref.to_be_bytes());
|
||||
b
|
||||
}
|
||||
fn bdj_obj() -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = 2 << 6;
|
||||
b
|
||||
}
|
||||
|
||||
fn build(first: [u8; 12], top: [u8; 12], titles: &[[u8; 12]]) -> Vec<u8> {
|
||||
let indexes_start = 48u32;
|
||||
let mut d = vec![0u8; indexes_start as usize];
|
||||
d[0..4].copy_from_slice(b"INDX");
|
||||
d[4..8].copy_from_slice(b"0300");
|
||||
d[8..12].copy_from_slice(&indexes_start.to_be_bytes());
|
||||
d.extend_from_slice(&0u32.to_be_bytes()); // index_len (unused by parser)
|
||||
d.extend_from_slice(&first);
|
||||
d.extend_from_slice(&top);
|
||||
d.extend_from_slice(&(titles.len() as u16).to_be_bytes());
|
||||
for t in titles {
|
||||
d.extend_from_slice(t);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hdmv_first_play_and_titles() {
|
||||
let d = build(hdmv_obj(0), bdj_obj(), &[bdj_obj(), bdj_obj()]);
|
||||
let idx = parse(&d).expect("parses");
|
||||
assert_eq!(idx.first_play, PlaybackObj::Hdmv { id_ref: 0 });
|
||||
assert_eq!(idx.top_menu, PlaybackObj::BdJ);
|
||||
assert_eq!(idx.titles.len(), 2);
|
||||
assert_eq!(idx.titles[0], PlaybackObj::BdJ);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_magic_and_truncation() {
|
||||
assert!(parse(b"NOPE").is_none());
|
||||
let d = build(hdmv_obj(3), hdmv_obj(0), &[hdmv_obj(1)]);
|
||||
// Truncating below the declared title span must yield None, never panic.
|
||||
assert!(parse(&d[..d.len() - 4]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_title_count_over_the_cap() {
|
||||
let mut d = build(hdmv_obj(0), bdj_obj(), &[bdj_obj()]);
|
||||
// num_titles (u16) is at 48 (indexes_start) + 4 (index_len) + 12 + 12 = 76.
|
||||
// Overwrite it with MAX_TITLES + 1: the cap must reject before any record
|
||||
// read, so an attacker-huge count can't drive a 4097-entry parse/alloc.
|
||||
d[76..78].copy_from_slice(&((MAX_TITLES as u16) + 1).to_be_bytes());
|
||||
assert!(
|
||||
parse(&d).is_none(),
|
||||
"title count over MAX_TITLES must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_titles() {
|
||||
let d = build(hdmv_obj(0), bdj_obj(), &[]);
|
||||
assert!(parse(&d).is_none(), "num_titles == 0 must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_object_types_parse_as_unknown() {
|
||||
let mut none = [0u8; 12]; // object_type 0
|
||||
let mut reserved = [0u8; 12];
|
||||
reserved[0] = 3 << 6; // object_type 3
|
||||
none[0] = 0x3F; // low bits set must not leak into the type
|
||||
let d = build(none, reserved, &[reserved]);
|
||||
let idx = parse(&d).expect("parses");
|
||||
assert_eq!(idx.first_play, PlaybackObj::Unknown);
|
||||
assert_eq!(idx.top_menu, PlaybackObj::Unknown);
|
||||
assert_eq!(idx.titles[0], PlaybackObj::Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// Build a 12-byte object record: `object_type` in the top two bits of
|
||||
/// byte 0, HDMV `id_ref` big-endian at offset 6.
|
||||
fn hdmv_obj(id_ref: u16) -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = 1 << 6;
|
||||
b[6..8].copy_from_slice(&id_ref.to_be_bytes());
|
||||
b
|
||||
}
|
||||
fn bdj_obj() -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = 2 << 6;
|
||||
b
|
||||
}
|
||||
|
||||
fn build(first: [u8; 12], top: [u8; 12], titles: &[[u8; 12]]) -> Vec<u8> {
|
||||
let indexes_start = 48u32;
|
||||
let mut d = vec![0u8; indexes_start as usize];
|
||||
d[0..4].copy_from_slice(b"INDX");
|
||||
d[4..8].copy_from_slice(b"0300");
|
||||
d[8..12].copy_from_slice(&indexes_start.to_be_bytes());
|
||||
d.extend_from_slice(&0u32.to_be_bytes()); // index_len (unused by parser)
|
||||
d.extend_from_slice(&first);
|
||||
d.extend_from_slice(&top);
|
||||
d.extend_from_slice(&(titles.len() as u16).to_be_bytes());
|
||||
for t in titles {
|
||||
d.extend_from_slice(t);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hdmv_first_play_and_titles() {
|
||||
let d = build(hdmv_obj(0), bdj_obj(), &[bdj_obj(), bdj_obj()]);
|
||||
let idx = parse(&d).expect("parses");
|
||||
assert_eq!(idx.first_play, PlaybackObj::Hdmv { id_ref: 0 });
|
||||
assert_eq!(idx.top_menu, PlaybackObj::BdJ);
|
||||
assert_eq!(idx.titles.len(), 2);
|
||||
assert_eq!(idx.titles[0], PlaybackObj::BdJ);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_magic_and_truncation() {
|
||||
assert!(parse(b"NOPE").is_none());
|
||||
let d = build(hdmv_obj(3), hdmv_obj(0), &[hdmv_obj(1)]);
|
||||
// Truncating below the declared title span must yield None, never panic.
|
||||
assert!(parse(&d[..d.len() - 4]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_title_count_over_the_cap() {
|
||||
let mut d = build(hdmv_obj(0), bdj_obj(), &[bdj_obj()]);
|
||||
// num_titles (u16) is at 48 (indexes_start) + 4 (index_len) + 12 + 12 = 76.
|
||||
// Overwrite it with MAX_TITLES + 1: the cap must reject before any record
|
||||
// read, so an attacker-huge count can't drive a 4097-entry parse/alloc.
|
||||
d[76..78].copy_from_slice(&((MAX_TITLES as u16) + 1).to_be_bytes());
|
||||
assert!(
|
||||
parse(&d).is_none(),
|
||||
"title count over MAX_TITLES must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_titles() {
|
||||
let d = build(hdmv_obj(0), bdj_obj(), &[]);
|
||||
assert!(parse(&d).is_none(), "num_titles == 0 must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_object_types_parse_as_unknown() {
|
||||
let mut none = [0u8; 12]; // object_type 0
|
||||
let mut reserved = [0u8; 12];
|
||||
reserved[0] = 3 << 6; // object_type 3
|
||||
none[0] = 0x3F; // low bits set must not leak into the type
|
||||
let d = build(none, reserved, &[reserved]);
|
||||
let idx = parse(&d).expect("parses");
|
||||
assert_eq!(idx.first_play, PlaybackObj::Unknown);
|
||||
assert_eq!(idx.top_menu, PlaybackObj::Unknown);
|
||||
assert_eq!(idx.titles[0], PlaybackObj::Unknown);
|
||||
}
|
||||
+77
-2
@@ -88,5 +88,80 @@ pub(crate) fn parse(d: &[u8]) -> Option<Vec<MovieObject>> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mobj_tests.rs"]
|
||||
pub(crate) mod tests;
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Encode one command from its opcode bytes + operands.
|
||||
pub(crate) fn cmd(b0: u8, b1: u8, b2: u8, b3: u8, dst: u32, src: u32) -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = b0;
|
||||
b[1] = b1;
|
||||
b[2] = b2;
|
||||
b[3] = b3;
|
||||
b[4..8].copy_from_slice(&dst.to_be_bytes());
|
||||
b[8..12].copy_from_slice(&src.to_be_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub(crate) fn build(objects: &[&[[u8; 12]]]) -> Vec<u8> {
|
||||
let mut d = vec![0u8; 50];
|
||||
d[0..4].copy_from_slice(b"MOBJ");
|
||||
d[48..50].copy_from_slice(&(objects.len() as u16).to_be_bytes());
|
||||
for obj in objects {
|
||||
d.push(0); // flags
|
||||
d.push(0); // reserved
|
||||
d.extend_from_slice(&(obj.len() as u16).to_be_bytes());
|
||||
for c in *obj {
|
||||
d.extend_from_slice(c);
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_objects_and_commands() {
|
||||
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
|
||||
let b0 = (1 << 5) | 2;
|
||||
let play = cmd(b0, 0x80, 0, 0, 11, 0);
|
||||
let d = build(&[&[play]]);
|
||||
let objs = parse(&d).expect("parses");
|
||||
assert_eq!(objs.len(), 1);
|
||||
assert_eq!(objs[0].cmds.len(), 1);
|
||||
assert_eq!(objs[0].cmds[0].dst, 11);
|
||||
assert_eq!(objs[0].cmds[0].sub_grp, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncation() {
|
||||
let play = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
|
||||
let d = build(&[&[play]]);
|
||||
assert!(parse(&d[..d.len() - 3]).is_none());
|
||||
assert!(parse(b"NOPE").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_object_count_over_the_cap() {
|
||||
// A real (non-truncated) buffer declaring MAX_OBJECTS + 1 empty objects:
|
||||
// if the cap were removed, this would parse fine (nothing to truncate on),
|
||||
// so only the cap itself can reject it.
|
||||
let empty: Vec<&[[u8; 12]]> = vec![&[]; MAX_OBJECTS + 1];
|
||||
let d = build(&empty);
|
||||
assert!(
|
||||
parse(&d).is_none(),
|
||||
"object count over MAX_OBJECTS must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// No command-count cap exists to test: `num_cmds` is a u16 (max 65535) and
|
||||
// each object's span is bounded against the input length, so the former
|
||||
// MAX_CMDS check was dead code and was removed rather than left flagged.
|
||||
|
||||
#[test]
|
||||
fn decode_cmd_masks_ignore_reserved_bits() {
|
||||
// b2/b3 upper bits are reserved and must not leak into cmp_opt/set_opt.
|
||||
let c = decode_cmd(&cmd(0xFF, 0xFF, 0xF3, 0xE5, 0, 0));
|
||||
assert_eq!((c.op_cnt, c.grp, c.sub_grp), (7, 3, 7));
|
||||
assert!(c.imm_op1 && c.imm_op2);
|
||||
assert_eq!((c.branch_opt, c.cmp_opt, c.set_opt), (0x0f, 0x03, 0x05));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// Encode one command from its opcode bytes + operands.
|
||||
pub(crate) fn cmd(b0: u8, b1: u8, b2: u8, b3: u8, dst: u32, src: u32) -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = b0;
|
||||
b[1] = b1;
|
||||
b[2] = b2;
|
||||
b[3] = b3;
|
||||
b[4..8].copy_from_slice(&dst.to_be_bytes());
|
||||
b[8..12].copy_from_slice(&src.to_be_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub(crate) fn build(objects: &[&[[u8; 12]]]) -> Vec<u8> {
|
||||
let mut d = vec![0u8; 50];
|
||||
d[0..4].copy_from_slice(b"MOBJ");
|
||||
d[48..50].copy_from_slice(&(objects.len() as u16).to_be_bytes());
|
||||
for obj in objects {
|
||||
d.push(0); // flags
|
||||
d.push(0); // reserved
|
||||
d.extend_from_slice(&(obj.len() as u16).to_be_bytes());
|
||||
for c in *obj {
|
||||
d.extend_from_slice(c);
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_objects_and_commands() {
|
||||
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
|
||||
let b0 = (1 << 5) | 2;
|
||||
let play = cmd(b0, 0x80, 0, 0, 11, 0);
|
||||
let d = build(&[&[play]]);
|
||||
let objs = parse(&d).expect("parses");
|
||||
assert_eq!(objs.len(), 1);
|
||||
assert_eq!(objs[0].cmds.len(), 1);
|
||||
assert_eq!(objs[0].cmds[0].dst, 11);
|
||||
assert_eq!(objs[0].cmds[0].sub_grp, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncation() {
|
||||
let play = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
|
||||
let d = build(&[&[play]]);
|
||||
assert!(parse(&d[..d.len() - 3]).is_none());
|
||||
assert!(parse(b"NOPE").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_object_count_over_the_cap() {
|
||||
// A real (non-truncated) buffer declaring MAX_OBJECTS + 1 empty objects:
|
||||
// if the cap were removed, this would parse fine (nothing to truncate on),
|
||||
// so only the cap itself can reject it.
|
||||
let empty: Vec<&[[u8; 12]]> = vec![&[]; MAX_OBJECTS + 1];
|
||||
let d = build(&empty);
|
||||
assert!(
|
||||
parse(&d).is_none(),
|
||||
"object count over MAX_OBJECTS must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// No command-count cap exists to test: `num_cmds` is a u16 (max 65535) and
|
||||
// each object's span is bounded against the input length, so the former
|
||||
// MAX_CMDS check was dead code and was removed rather than left flagged.
|
||||
|
||||
#[test]
|
||||
fn decode_cmd_masks_ignore_reserved_bits() {
|
||||
// b2/b3 upper bits are reserved and must not leak into cmp_opt/set_opt.
|
||||
let c = decode_cmd(&cmd(0xFF, 0xFF, 0xF3, 0xE5, 0, 0));
|
||||
assert_eq!((c.op_cnt, c.grp, c.sub_grp), (7, 3, 7));
|
||||
assert!(c.imm_op1 && c.imm_op2);
|
||||
assert_eq!((c.branch_opt, c.cmp_opt, c.set_opt), (0x0f, 0x03, 0x05));
|
||||
}
|
||||
+108
-2
@@ -47,5 +47,111 @@ fn resolve_inner(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::mobj::tests::{build as build_mobj, cmd};
|
||||
use super::*;
|
||||
use crate::udf::fixture::{DirSpec, MemDisc, build_udf_skeleton, file_with, lay_dir};
|
||||
|
||||
// Encode one 12-byte `index.bdmv` playback object: `object_type` in the top
|
||||
// two bits of byte 0 (1 = HDMV, `id_ref` big-endian @6; 2 = BD-J). Mirrors
|
||||
// index.rs's own private test builder (kept local).
|
||||
fn hdmv_obj(id_ref: u16) -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = 1 << 6;
|
||||
b[6..8].copy_from_slice(&id_ref.to_be_bytes());
|
||||
b
|
||||
}
|
||||
fn bdj_obj() -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = 2 << 6;
|
||||
b
|
||||
}
|
||||
|
||||
/// Build a minimal, valid `index.bdmv`: First-Play = HDMV object 0,
|
||||
/// Top Menu = BD-J, one (unused) title. Layout per `index::parse`.
|
||||
fn build_index(first_play: [u8; 12]) -> Vec<u8> {
|
||||
let indexes_start = 48u32;
|
||||
let mut d = vec![0u8; indexes_start as usize];
|
||||
d[0..4].copy_from_slice(b"INDX");
|
||||
d[4..8].copy_from_slice(b"0300");
|
||||
d[8..12].copy_from_slice(&indexes_start.to_be_bytes());
|
||||
d.extend_from_slice(&0u32.to_be_bytes()); // index_len (unused by parser)
|
||||
d.extend_from_slice(&first_play);
|
||||
d.extend_from_slice(&bdj_obj()); // top_menu
|
||||
d.extend_from_slice(&1u16.to_be_bytes()); // num_titles
|
||||
d.extend_from_slice(&bdj_obj()); // titles[0] (not exercised here)
|
||||
d
|
||||
}
|
||||
|
||||
// Smoke/e2e test of the resolver: minimal index.bdmv + MovieObject.bdmv on
|
||||
// an in-memory UDF disc, resolved via read_file + index::parse +
|
||||
// mobj::parse + vm::resolve. First-Play HDMV object 0 unconditionally PlayPLs playlist 11.
|
||||
#[test]
|
||||
fn resolve_feature_end_to_end_resolves_playlist() {
|
||||
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
|
||||
let play_pl_11 = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
|
||||
let mobj_bytes = build_mobj(&[&[play_pl_11]]);
|
||||
let index_bytes = build_index(hdmv_obj(0));
|
||||
|
||||
let mut disc = MemDisc::new();
|
||||
let bdmv = DirSpec {
|
||||
name: "BDMV".to_string(),
|
||||
icb_lba: 12,
|
||||
dir_data_lba: 13,
|
||||
files: vec![
|
||||
file_with("index.bdmv", 14, 500, index_bytes, true),
|
||||
file_with("MovieObject.bdmv", 15, 600, mobj_bytes, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
};
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![bdmv],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert_eq!(
|
||||
resolve_feature(&mut disc, &udf, |id| id == 11),
|
||||
Some(11),
|
||||
"First-Play's unconditional PlayPL 11 must resolve as the feature"
|
||||
);
|
||||
}
|
||||
|
||||
/// A malformed index (`index.bdmv` truncated to just its magic) must make
|
||||
/// the whole resolver abstain, not panic — proving `resolve_feature`
|
||||
/// really is wired through `index::parse`'s failure path end-to-end.
|
||||
#[test]
|
||||
fn resolve_feature_abstains_on_malformed_index() {
|
||||
let play_pl_11 = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
|
||||
let mobj_bytes = build_mobj(&[&[play_pl_11]]);
|
||||
|
||||
let mut disc = MemDisc::new();
|
||||
let bdmv = DirSpec {
|
||||
name: "BDMV".to_string(),
|
||||
icb_lba: 12,
|
||||
dir_data_lba: 13,
|
||||
files: vec![
|
||||
file_with("index.bdmv", 14, 500, b"INDX".to_vec(), true),
|
||||
file_with("MovieObject.bdmv", 15, 600, mobj_bytes, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
};
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![bdmv],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert_eq!(resolve_feature(&mut disc, &udf, |id| id == 11), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
use super::mobj::tests::{build as build_mobj, cmd};
|
||||
use super::*;
|
||||
use crate::udf::fixture::{DirSpec, MemDisc, build_udf_skeleton, file_with, lay_dir};
|
||||
|
||||
// Encode one 12-byte `index.bdmv` playback object: `object_type` in the top
|
||||
// two bits of byte 0 (1 = HDMV, `id_ref` big-endian @6; 2 = BD-J). Mirrors
|
||||
// index.rs's own private test builder (kept local).
|
||||
fn hdmv_obj(id_ref: u16) -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = 1 << 6;
|
||||
b[6..8].copy_from_slice(&id_ref.to_be_bytes());
|
||||
b
|
||||
}
|
||||
fn bdj_obj() -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = 2 << 6;
|
||||
b
|
||||
}
|
||||
|
||||
/// Build a minimal, valid `index.bdmv`: First-Play = HDMV object 0,
|
||||
/// Top Menu = BD-J, one (unused) title. Layout per `index::parse`.
|
||||
fn build_index(first_play: [u8; 12]) -> Vec<u8> {
|
||||
let indexes_start = 48u32;
|
||||
let mut d = vec![0u8; indexes_start as usize];
|
||||
d[0..4].copy_from_slice(b"INDX");
|
||||
d[4..8].copy_from_slice(b"0300");
|
||||
d[8..12].copy_from_slice(&indexes_start.to_be_bytes());
|
||||
d.extend_from_slice(&0u32.to_be_bytes()); // index_len (unused by parser)
|
||||
d.extend_from_slice(&first_play);
|
||||
d.extend_from_slice(&bdj_obj()); // top_menu
|
||||
d.extend_from_slice(&1u16.to_be_bytes()); // num_titles
|
||||
d.extend_from_slice(&bdj_obj()); // titles[0] (not exercised here)
|
||||
d
|
||||
}
|
||||
|
||||
// Smoke/e2e test of the resolver: minimal index.bdmv + MovieObject.bdmv on
|
||||
// an in-memory UDF disc, resolved via read_file + index::parse +
|
||||
// mobj::parse + vm::resolve. First-Play HDMV object 0 unconditionally PlayPLs playlist 11.
|
||||
#[test]
|
||||
fn resolve_feature_end_to_end_resolves_playlist() {
|
||||
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
|
||||
let play_pl_11 = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
|
||||
let mobj_bytes = build_mobj(&[&[play_pl_11]]);
|
||||
let index_bytes = build_index(hdmv_obj(0));
|
||||
|
||||
let mut disc = MemDisc::new();
|
||||
let bdmv = DirSpec {
|
||||
name: "BDMV".to_string(),
|
||||
icb_lba: 12,
|
||||
dir_data_lba: 13,
|
||||
files: vec![
|
||||
file_with("index.bdmv", 14, 500, index_bytes, true),
|
||||
file_with("MovieObject.bdmv", 15, 600, mobj_bytes, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
};
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![bdmv],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert_eq!(
|
||||
resolve_feature(&mut disc, &udf, |id| id == 11),
|
||||
Some(11),
|
||||
"First-Play's unconditional PlayPL 11 must resolve as the feature"
|
||||
);
|
||||
}
|
||||
|
||||
/// A malformed index (`index.bdmv` truncated to just its magic) must make
|
||||
/// the whole resolver abstain, not panic — proving `resolve_feature`
|
||||
/// really is wired through `index::parse`'s failure path end-to-end.
|
||||
#[test]
|
||||
fn resolve_feature_abstains_on_malformed_index() {
|
||||
let play_pl_11 = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
|
||||
let mobj_bytes = build_mobj(&[&[play_pl_11]]);
|
||||
|
||||
let mut disc = MemDisc::new();
|
||||
let bdmv = DirSpec {
|
||||
name: "BDMV".to_string(),
|
||||
icb_lba: 12,
|
||||
dir_data_lba: 13,
|
||||
files: vec![
|
||||
file_with("index.bdmv", 14, 500, b"INDX".to_vec(), true),
|
||||
file_with("MovieObject.bdmv", 15, 600, mobj_bytes, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
};
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![bdmv],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert_eq!(resolve_feature(&mut disc, &udf, |id| id == 11), None);
|
||||
}
|
||||
+474
-2
@@ -289,5 +289,477 @@ fn run(vm: &mut Vm, mut obj_id: usize, is_feature: &dyn Fn(u16) -> bool) -> Opti
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "vm_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::super::index::{Index, PlaybackObj};
|
||||
use super::super::mobj::{self, tests::build, tests::cmd};
|
||||
use super::*;
|
||||
|
||||
fn play_pl(id: u16) -> [u8; 12] {
|
||||
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
|
||||
cmd((1 << 5) | 2, 0x80, 0, 0, id as u32, 0)
|
||||
}
|
||||
fn jump_object(obj: u16) -> [u8; 12] {
|
||||
// grp=BRANCH(0), sub_grp=JUMP(1), branch_opt=JUMP_OBJECT(0), imm dst.
|
||||
cmd((1 << 5) | 1, 0x80, 0, 0, obj as u32, 0)
|
||||
}
|
||||
fn jump_title(title: u16) -> [u8; 12] {
|
||||
// sub_grp=JUMP(1), branch_opt=JUMP_TITLE(1), imm dst.
|
||||
cmd((1 << 5) | 1, 0x81, 0, 0, title as u32, 0)
|
||||
}
|
||||
fn call_object(obj: u16) -> [u8; 12] {
|
||||
// sub_grp=JUMP(1), branch_opt=CALL_OBJECT(0x02), imm dst. For play-path
|
||||
// resolution a Call follows the same target as a Jump (the return
|
||||
// address is not modelled).
|
||||
cmd((1 << 5) | 1, 0x82, 0, 0, obj as u32, 0)
|
||||
}
|
||||
fn call_title(title: u16) -> [u8; 12] {
|
||||
// sub_grp=JUMP(1), branch_opt=CALL_TITLE(0x03), imm dst.
|
||||
cmd((1 << 5) | 1, 0x83, 0, 0, title as u32, 0)
|
||||
}
|
||||
fn set_move_gpr(reg: u16, imm: u16) -> [u8; 12] {
|
||||
// grp=SET(2), sub_grp=SET(0), set_opt=MOVE(1); dst=reg (GPR), imm src.
|
||||
// op_cnt=2, imm_op2=1 (src immediate), imm_op1=0 (dst is a register).
|
||||
cmd((2 << 5) | (2 << 3), 0x40, 0, 0x01, reg as u32, imm as u32)
|
||||
}
|
||||
fn play_pl_reg(reg: u16) -> [u8; 12] {
|
||||
// PLAY_PL with dst = GPR[reg] (op_cnt=1, imm_op1=0).
|
||||
cmd((1 << 5) | 2, 0x00, 0, 0, reg as u32, 0)
|
||||
}
|
||||
|
||||
fn idx(first: PlaybackObj, titles: Vec<PlaybackObj>) -> Index {
|
||||
Index {
|
||||
first_play: first,
|
||||
top_menu: PlaybackObj::BdJ,
|
||||
titles,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_immediate_playpl() {
|
||||
// First-Play HDMV obj0 → JumpObject 1 → PlayPL 11.
|
||||
let d = build(&[&[jump_object(1)], &[play_pl(11)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 11), Some(11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_feature_playpl_then_returns_feature() {
|
||||
// Autoplay chain: logo PlayPL 99 (not a candidate) → feature PlayPL 1.
|
||||
let d = build(&[&[play_pl(99), play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
// Only 1 is a feature candidate; 99 is a logo.
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 1), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_object_follows_the_play_path_like_jump_object() {
|
||||
// First-Play HDMV obj0 → CallObject 1 → PlayPL 11. CallObject follows
|
||||
// the same target as JumpObject for feature resolution.
|
||||
let d = build(&[&[call_object(1)], &[play_pl(11)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 11), Some(11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_title_resolves_through_the_index_like_jump_title() {
|
||||
// CallTitle 1 → titles[0] = HDMV obj 1 (which PlayPLs 42).
|
||||
let d = build(&[&[call_title(1)], &[play_pl(42)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(
|
||||
PlaybackObj::Hdmv { id_ref: 0 },
|
||||
vec![PlaybackObj::Hdmv { id_ref: 1 }],
|
||||
);
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 42), Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abstains_when_first_play_jumps_to_bdj_title() {
|
||||
// Computed-title-number shape: First-Play computes a title number in a
|
||||
// GPR and JumpTitles to it; the target title is BD-J → abstain (None).
|
||||
let d = build(&[&[set_move_gpr(0xFEB & 0xfff, 2), {
|
||||
// JumpTitle with dst = GPR[0xFEB].
|
||||
cmd((1 << 5) | 1, 0x01, 0, 0, 0x0000_0FEB, 0)
|
||||
}]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
// title 2 → titles[1] = BD-J.
|
||||
let index = idx(
|
||||
PlaybackObj::Hdmv { id_ref: 0 },
|
||||
vec![PlaybackObj::Hdmv { id_ref: 5 }, PlaybackObj::BdJ],
|
||||
);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abstains_when_first_play_is_bdj() {
|
||||
let d = build(&[&[play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::BdJ, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jumptitle_first_play_is_0xffff_not_n_plus_1() {
|
||||
// BD-ROM title numbering: 0 = Top Menu, 0xFFFF = First Play,
|
||||
// 1..=N = titles. `N+1` is an INVALID title ref.
|
||||
let index = idx(
|
||||
PlaybackObj::Hdmv { id_ref: 7 },
|
||||
vec![PlaybackObj::Hdmv { id_ref: 1 }],
|
||||
);
|
||||
let vm = Vm::new(&[], &index);
|
||||
assert_eq!(vm.title_obj(0), Some(index.top_menu), "title 0 = Top Menu");
|
||||
assert_eq!(
|
||||
vm.title_obj(1),
|
||||
Some(PlaybackObj::Hdmv { id_ref: 1 }),
|
||||
"title 1 = titles[0]"
|
||||
);
|
||||
assert_eq!(
|
||||
vm.title_obj(0xFFFF),
|
||||
Some(PlaybackObj::Hdmv { id_ref: 7 }),
|
||||
"0xFFFF = First Play (abstained wrongly before the fix)"
|
||||
);
|
||||
assert_eq!(
|
||||
vm.title_obj(2),
|
||||
None,
|
||||
"N+1 is an invalid title ref, not First Play (resolved wrongly before the fix)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_to_psr_is_refused() {
|
||||
// SET MOVE to PSR6 must be a no-op (PSR stores are refused by spec).
|
||||
// Program: MOVE PSR6<-5; CMP PSR6==5; PlayPL 100 else 200. Refused write
|
||||
// keeps PSR6 at 0 -> PlayPL 200; an illegal write would wrongly pick 100.
|
||||
let set_psr6 = cmd((2 << 5) | (2 << 3), 0x40, 0, 0x01, 0x8000_0006, 5);
|
||||
let cmp_psr6_eq_5 = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0x8000_0006, 5);
|
||||
let d = build(&[&[set_psr6, cmp_psr6_eq_5, play_pl(100), play_pl(200)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(
|
||||
resolve(&index, &mobjs, &|id| id == 100 || id == 200),
|
||||
Some(200),
|
||||
"PSR store must be refused, so the compare is false and 200 is reached"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_fold_and_cmp_skip_reach_playpl() {
|
||||
// SET GPR[3]=7; CMP GPR[3]==7 (true → do NOT skip); PlayPL GPR[3].
|
||||
// grp=CMP(1), op_cnt=2, imm_op2=1 (src=7 immediate), cmp_opt=EQ(2); dst=GPR3.
|
||||
let cmp_eq_reg = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 3, 0x0000_0007);
|
||||
let d = build(&[&[set_move_gpr(3, 7), cmp_eq_reg, play_pl_reg(3), play_pl(999)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
// If the compare wrongly skipped, we'd hit PlayPL 999 instead of 7.
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 7 || id == 999), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jumptitle_namespace_differs_from_jumpobject() {
|
||||
// JumpTitle 1 → titles[0] = HDMV obj 1 (which PlayPLs 42). Proves title
|
||||
// numbers resolve through the index, not as object indices.
|
||||
let d = build(&[&[jump_title(1)], &[play_pl(42)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(
|
||||
PlaybackObj::Hdmv { id_ref: 0 },
|
||||
vec![PlaybackObj::Hdmv { id_ref: 1 }],
|
||||
);
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 42), Some(42));
|
||||
}
|
||||
|
||||
// ADD/MUL saturate at 0xFFFFFFFF (libbluray hdmv_vm.c ADD_u32/MUL_u32,
|
||||
// reverse-engineered player behaviour), like SUB and DIV/MOD by 0 already do.
|
||||
#[test]
|
||||
fn add_and_mul_saturate() {
|
||||
let set = |opt: u8, reg: u32, imm: u32| cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm);
|
||||
let cmp_eq_0 = |reg: u32| cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, reg, 0);
|
||||
for ops in [
|
||||
[set(0x01, 0, 0xffff_ffff), set(0x03, 0, 1)],
|
||||
[set(0x01, 0, 0x1_0000), set(0x05, 0, 0x1_0000)],
|
||||
] {
|
||||
// A wrap to 0 makes the compare true and plays 1; saturation plays 800.
|
||||
let d = build(&[&[ops[0], ops[1], cmp_eq_0(0), play_pl(1), play_pl(800)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(
|
||||
resolve(&index, &mobjs, &|id| id == 1 || id == 800),
|
||||
Some(800)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_exchanges_two_registers() {
|
||||
// MOVE g0=11; MOVE g1=22; SWAP g0<->g1; PlayPL GPR[0]. SWAP: grp=SET(2),
|
||||
// sub_grp=SET(0), set_opt=SWAP(2), both operands registers (imm_op*=0).
|
||||
let swap01 = cmd((2 << 5) | (2 << 3), 0x00, 0, 0x02, 0, 1);
|
||||
let d = build(&[&[
|
||||
set_move_gpr(0, 11),
|
||||
set_move_gpr(1, 22),
|
||||
swap01,
|
||||
play_pl_reg(0),
|
||||
]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm::new(&mobjs, &index);
|
||||
// After the swap GPR0 holds the old GPR1 (22) and PlayPL plays it.
|
||||
assert_eq!(run(&mut vm, 0, &|id| id == 22), Some(22));
|
||||
// Full exchange: GPR1 now holds the old GPR0 (11).
|
||||
assert_eq!(vm.gpr[1], 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_convergent_self_goto_bails_via_max_steps() {
|
||||
// An unconditional GOTO to its own line never reaches a PlayPL: the
|
||||
// MAX_STEPS guard must bail (None) rather than loop forever. If the
|
||||
// guard were removed this would hang instead of returning.
|
||||
let self_goto = cmd(1 << 5, 0x81, 0, 0, 0, 0);
|
||||
let d = build(&[&[self_goto]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goto_lands_on_its_target_and_break_terminates() {
|
||||
// op_cnt=1, grp=BRANCH(0), sub_grp=GOTO(0), imm dst; branch_opt GOTO(1) / BREAK(2).
|
||||
let goto = |line: u32| cmd(1 << 5, 0x81, 0, 0, line, 0);
|
||||
let brk = cmd(1 << 5, 0x82, 0, 0, 0, 0);
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
// GOTO line 2 jumps over the logo PlayPL 99 (a candidate) to the feature PlayPL 1.
|
||||
let d = build(&[&[goto(2), play_pl(99), play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), Some(1));
|
||||
let d = build(&[&[brk, play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_play_pl_family_emits_a_playlist() {
|
||||
// sub_grp=PLAY(2); branch_opt 3 is Terminate/Link, which never plays dst.
|
||||
let d = build(&[&[cmd((1 << 5) | 2, 0x83, 0, 0, 11, 0)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setsystem_does_not_execute_as_a_gpr_set() {
|
||||
// grp=SET(2), sub_grp=SETSYSTEM(1), set_opt=MOVE(1): must not write GPR5.
|
||||
let setsys = cmd((2 << 5) | (2 << 3) | 1, 0x40, 0, 0x01, 5, 77);
|
||||
let d = build(&[&[setsys]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm::new(&mobjs, &index);
|
||||
assert_eq!(run(&mut vm, 0, &|_| false), None);
|
||||
assert_eq!(vm.gpr[5], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_immediate_operand_is_not_written_back() {
|
||||
// SWAP dst=GPR0 with src=IMMEDIATE 5 (imm_op1=0, imm_op2=1) must refuse
|
||||
// writing to the aliased register: GPR[5] stays 77, only GPR0 gets 5.
|
||||
// Dropping the `imm_op1`/`imm_op2` guard makes this assertion fail.
|
||||
let swap_imm = cmd((2 << 5) | (2 << 3), 0x40, 0, 0x02, 0, 5);
|
||||
let d = build(&[&[set_move_gpr(5, 77), swap_imm]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm::new(&mobjs, &index);
|
||||
// No PlayPL → program runs off the end (None); inspect registers after.
|
||||
assert_eq!(run(&mut vm, 0, &|_| false), None);
|
||||
assert_eq!(
|
||||
vm.gpr[5], 77,
|
||||
"immediate operand must not be a write target"
|
||||
);
|
||||
assert_eq!(vm.gpr[0], 5, "register operand receives the immediate");
|
||||
}
|
||||
|
||||
// libbluray hdmv_vm.c: BITSET/BITCLR with a bit number >= 32 are no-ops and
|
||||
// SHL/SHR by >= 32 give 0 (no `& 31` masking).
|
||||
#[test]
|
||||
fn bit_ops_and_shifts_past_31_follow_libbluray() {
|
||||
let set = |opt: u8, reg: u32, imm: u32| cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm);
|
||||
let d = build(&[&[
|
||||
set(0x0c, 0, 32),
|
||||
set(0x01, 1, 1),
|
||||
set(0x0e, 1, 32),
|
||||
set(0x01, 2, 0x8000_0000),
|
||||
set(0x0f, 2, 40),
|
||||
set(0x01, 3, 0xffff_ffff),
|
||||
set(0x0d, 3, 33),
|
||||
]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm::new(&mobjs, &index);
|
||||
assert_eq!(run(&mut vm, 0, &|_| false), None);
|
||||
assert_eq!(vm.gpr[..4], [0, 0, 0, 0xffff_ffff]);
|
||||
}
|
||||
|
||||
// RND is non-deterministic: a compare on its result abstains.
|
||||
#[test]
|
||||
fn compare_on_rnd_result_abstains() {
|
||||
let set = |opt: u8, reg: u32, imm: u32| cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm);
|
||||
let cmp_eq = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0, 5);
|
||||
let d = build(&[&[
|
||||
set(0x01, 0, 5),
|
||||
set(0x08, 0, 10),
|
||||
cmp_eq,
|
||||
play_pl(1),
|
||||
play_pl(800),
|
||||
]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 1 || id == 800), None);
|
||||
}
|
||||
|
||||
fn run_gpr0(prog: &[[u8; 12]], gprs: usize) -> Vec<u32> {
|
||||
let d = build(&[prog]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm::new(&mobjs, &index);
|
||||
assert_eq!(run(&mut vm, 0, &|_| false), None);
|
||||
vm.gpr[..gprs].to_vec()
|
||||
}
|
||||
|
||||
fn set_imm(opt: u8, reg: u32, imm: u32) -> [u8; 12] {
|
||||
cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm)
|
||||
}
|
||||
|
||||
// CMP dst(GPR0 = a) against imm b: true falls through to PlayPL 1, false skips to 2.
|
||||
fn cmp_picks_true(opt: u8, a: u32, b: u32) -> bool {
|
||||
let cmp = cmd((2 << 5) | (1 << 3), 0x40, opt, 0, 0, b);
|
||||
let d = build(&[&[set_imm(0x01, 0, a), cmp, play_pl(1), play_pl(2)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
resolve(&index, &mobjs, &|id| id == 1 || id == 2) == Some(1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmp_ops_ge_gt_le_lt_ne() {
|
||||
// (cmp_opt, a, b, expected)
|
||||
let cases = [
|
||||
(0x04, 5, 5, true),
|
||||
(0x04, 4, 5, false),
|
||||
(0x04, 6, 5, true),
|
||||
(0x05, 5, 5, false),
|
||||
(0x05, 6, 5, true),
|
||||
(0x05, 4, 5, false),
|
||||
(0x06, 5, 5, true),
|
||||
(0x06, 6, 5, false),
|
||||
(0x06, 4, 5, true),
|
||||
(0x07, 5, 5, false),
|
||||
(0x07, 4, 5, true),
|
||||
(0x07, 6, 5, false),
|
||||
(0x03, 5, 5, false),
|
||||
(0x03, 5, 6, true),
|
||||
];
|
||||
for (opt, a, b, want) in cases {
|
||||
assert_eq!(cmp_picks_true(opt, a, b), want, "opt {opt:#x} {a} vs {b}");
|
||||
}
|
||||
}
|
||||
|
||||
// libbluray INSN_BC skips the next command when `dst & ~src` is non-zero:
|
||||
// true iff every bit of dst is set in src.
|
||||
#[test]
|
||||
fn cmp_bc_tests_dst_bits_within_src() {
|
||||
assert!(!cmp_picks_true(0x01, 0b1110, 0b0110));
|
||||
assert!(cmp_picks_true(0x01, 0b0100, 0b0110));
|
||||
assert!(cmp_picks_true(0x01, 0b0110, 0b1110));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_arithmetic_and_logic_ops() {
|
||||
// (set_opt, a, b, expected)
|
||||
let cases = [
|
||||
(0x03, 2, 3, 5),
|
||||
(0x04, 10, 3, 7),
|
||||
(0x04, 3, 10, 0),
|
||||
(0x05, 2, 3, 6),
|
||||
(0x06, 10, 3, 3),
|
||||
(0x06, 10, 0, 0xffff_ffff),
|
||||
(0x07, 10, 3, 1),
|
||||
(0x07, 10, 0, 0xffff_ffff),
|
||||
(0x09, 0b1100, 0b1010, 0b1000),
|
||||
(0x0a, 0b1100, 0b1010, 0b1110),
|
||||
(0x0b, 0b1100, 0b1010, 0b0110),
|
||||
(0x0c, 0, 3, 8),
|
||||
(0x0d, 0xf, 1, 0xd),
|
||||
(0x0e, 1, 4, 16),
|
||||
(0x0f, 0x100, 4, 0x10),
|
||||
];
|
||||
for (opt, a, b, want) in cases {
|
||||
let g = run_gpr0(&[set_imm(0x01, 0, a), set_imm(opt, 0, b)], 1);
|
||||
assert_eq!(g[0], want, "set_opt {opt:#x} on {a}, {b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_or_compare_on_rnd_value_abstains() {
|
||||
// GPR0 = RND; then PlayPL GPR0 (branch grp, dst tainted).
|
||||
let rnd = set_imm(0x08, 0, 10);
|
||||
let d = build(&[&[rnd, play_pl_reg(0), play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
// Compare with a clean dst (GPR1) and a tainted register src (GPR0).
|
||||
let cmp = cmd((2 << 5) | (1 << 3), 0x00, 0x02, 0, 1, 0);
|
||||
let d = build(&[&[rnd, cmp, play_pl(1), play_pl(2)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_from_rnd_source_taints_destination() {
|
||||
// GPR0 = RND; GPR1 (clean) += GPR0 (register src); PlayPL GPR1 must abstain.
|
||||
let rnd = set_imm(0x08, 0, 10);
|
||||
let add = cmd((2 << 5) | (2 << 3), 0x00, 0, 0x03, 1, 0);
|
||||
let d = build(&[&[rnd, add, play_pl_reg(1), play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playpl_operand_over_16_bits_is_not_a_playlist_id() {
|
||||
// 0x1_0001 must not alias playlist 1; the VM falls through to 800.
|
||||
let wide = cmd((1 << 5) | 2, 0x80, 0, 0, 0x1_0001, 0);
|
||||
let d = build(&[&[wide, play_pl(800)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(
|
||||
resolve(&index, &mobjs, &|id| id == 1 || id == 800),
|
||||
Some(800)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_on_psr_defaults_are_visible_to_compares() {
|
||||
// (psr, expected power-on value)
|
||||
let cases: [(u32, u32); 12] = [
|
||||
(0, 1),
|
||||
(1, 0xff),
|
||||
(3, 1),
|
||||
(4, 0xffff),
|
||||
(5, 0xffff),
|
||||
(6, 0),
|
||||
(7, 0),
|
||||
(8, 0),
|
||||
(20, 2),
|
||||
(31, 0x0003_0200),
|
||||
(48, 0xffff_ffff),
|
||||
(62, 0),
|
||||
];
|
||||
for (psr, want) in cases {
|
||||
// CMP PSR == want (imm): true plays 1, false plays 2.
|
||||
let cmp = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0x8000_0000 | psr, want);
|
||||
let d = build(&[&[cmp, play_pl(1), play_pl(2)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let got = resolve(&index, &mobjs, &|id| id == 1 || id == 2);
|
||||
assert_eq!(got, Some(1), "PSR{psr} != {want:#x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,472 +0,0 @@
|
||||
use super::super::index::{Index, PlaybackObj};
|
||||
use super::super::mobj::{self, tests::build, tests::cmd};
|
||||
use super::*;
|
||||
|
||||
fn play_pl(id: u16) -> [u8; 12] {
|
||||
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
|
||||
cmd((1 << 5) | 2, 0x80, 0, 0, id as u32, 0)
|
||||
}
|
||||
fn jump_object(obj: u16) -> [u8; 12] {
|
||||
// grp=BRANCH(0), sub_grp=JUMP(1), branch_opt=JUMP_OBJECT(0), imm dst.
|
||||
cmd((1 << 5) | 1, 0x80, 0, 0, obj as u32, 0)
|
||||
}
|
||||
fn jump_title(title: u16) -> [u8; 12] {
|
||||
// sub_grp=JUMP(1), branch_opt=JUMP_TITLE(1), imm dst.
|
||||
cmd((1 << 5) | 1, 0x81, 0, 0, title as u32, 0)
|
||||
}
|
||||
fn call_object(obj: u16) -> [u8; 12] {
|
||||
// sub_grp=JUMP(1), branch_opt=CALL_OBJECT(0x02), imm dst. For play-path
|
||||
// resolution a Call follows the same target as a Jump (the return
|
||||
// address is not modelled).
|
||||
cmd((1 << 5) | 1, 0x82, 0, 0, obj as u32, 0)
|
||||
}
|
||||
fn call_title(title: u16) -> [u8; 12] {
|
||||
// sub_grp=JUMP(1), branch_opt=CALL_TITLE(0x03), imm dst.
|
||||
cmd((1 << 5) | 1, 0x83, 0, 0, title as u32, 0)
|
||||
}
|
||||
fn set_move_gpr(reg: u16, imm: u16) -> [u8; 12] {
|
||||
// grp=SET(2), sub_grp=SET(0), set_opt=MOVE(1); dst=reg (GPR), imm src.
|
||||
// op_cnt=2, imm_op2=1 (src immediate), imm_op1=0 (dst is a register).
|
||||
cmd((2 << 5) | (2 << 3), 0x40, 0, 0x01, reg as u32, imm as u32)
|
||||
}
|
||||
fn play_pl_reg(reg: u16) -> [u8; 12] {
|
||||
// PLAY_PL with dst = GPR[reg] (op_cnt=1, imm_op1=0).
|
||||
cmd((1 << 5) | 2, 0x00, 0, 0, reg as u32, 0)
|
||||
}
|
||||
|
||||
fn idx(first: PlaybackObj, titles: Vec<PlaybackObj>) -> Index {
|
||||
Index {
|
||||
first_play: first,
|
||||
top_menu: PlaybackObj::BdJ,
|
||||
titles,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_immediate_playpl() {
|
||||
// First-Play HDMV obj0 → JumpObject 1 → PlayPL 11.
|
||||
let d = build(&[&[jump_object(1)], &[play_pl(11)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 11), Some(11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_feature_playpl_then_returns_feature() {
|
||||
// Autoplay chain: logo PlayPL 99 (not a candidate) → feature PlayPL 1.
|
||||
let d = build(&[&[play_pl(99), play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
// Only 1 is a feature candidate; 99 is a logo.
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 1), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_object_follows_the_play_path_like_jump_object() {
|
||||
// First-Play HDMV obj0 → CallObject 1 → PlayPL 11. CallObject follows
|
||||
// the same target as JumpObject for feature resolution.
|
||||
let d = build(&[&[call_object(1)], &[play_pl(11)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 11), Some(11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_title_resolves_through_the_index_like_jump_title() {
|
||||
// CallTitle 1 → titles[0] = HDMV obj 1 (which PlayPLs 42).
|
||||
let d = build(&[&[call_title(1)], &[play_pl(42)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(
|
||||
PlaybackObj::Hdmv { id_ref: 0 },
|
||||
vec![PlaybackObj::Hdmv { id_ref: 1 }],
|
||||
);
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 42), Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abstains_when_first_play_jumps_to_bdj_title() {
|
||||
// Computed-title-number shape: First-Play computes a title number in a
|
||||
// GPR and JumpTitles to it; the target title is BD-J → abstain (None).
|
||||
let d = build(&[&[set_move_gpr(0xFEB & 0xfff, 2), {
|
||||
// JumpTitle with dst = GPR[0xFEB].
|
||||
cmd((1 << 5) | 1, 0x01, 0, 0, 0x0000_0FEB, 0)
|
||||
}]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
// title 2 → titles[1] = BD-J.
|
||||
let index = idx(
|
||||
PlaybackObj::Hdmv { id_ref: 0 },
|
||||
vec![PlaybackObj::Hdmv { id_ref: 5 }, PlaybackObj::BdJ],
|
||||
);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abstains_when_first_play_is_bdj() {
|
||||
let d = build(&[&[play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::BdJ, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jumptitle_first_play_is_0xffff_not_n_plus_1() {
|
||||
// BD-ROM title numbering: 0 = Top Menu, 0xFFFF = First Play,
|
||||
// 1..=N = titles. `N+1` is an INVALID title ref.
|
||||
let index = idx(
|
||||
PlaybackObj::Hdmv { id_ref: 7 },
|
||||
vec![PlaybackObj::Hdmv { id_ref: 1 }],
|
||||
);
|
||||
let vm = Vm::new(&[], &index);
|
||||
assert_eq!(vm.title_obj(0), Some(index.top_menu), "title 0 = Top Menu");
|
||||
assert_eq!(
|
||||
vm.title_obj(1),
|
||||
Some(PlaybackObj::Hdmv { id_ref: 1 }),
|
||||
"title 1 = titles[0]"
|
||||
);
|
||||
assert_eq!(
|
||||
vm.title_obj(0xFFFF),
|
||||
Some(PlaybackObj::Hdmv { id_ref: 7 }),
|
||||
"0xFFFF = First Play (abstained wrongly before the fix)"
|
||||
);
|
||||
assert_eq!(
|
||||
vm.title_obj(2),
|
||||
None,
|
||||
"N+1 is an invalid title ref, not First Play (resolved wrongly before the fix)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_to_psr_is_refused() {
|
||||
// SET MOVE to PSR6 must be a no-op (PSR stores are refused by spec).
|
||||
// Program: MOVE PSR6<-5; CMP PSR6==5; PlayPL 100 else 200. Refused write
|
||||
// keeps PSR6 at 0 -> PlayPL 200; an illegal write would wrongly pick 100.
|
||||
let set_psr6 = cmd((2 << 5) | (2 << 3), 0x40, 0, 0x01, 0x8000_0006, 5);
|
||||
let cmp_psr6_eq_5 = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0x8000_0006, 5);
|
||||
let d = build(&[&[set_psr6, cmp_psr6_eq_5, play_pl(100), play_pl(200)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(
|
||||
resolve(&index, &mobjs, &|id| id == 100 || id == 200),
|
||||
Some(200),
|
||||
"PSR store must be refused, so the compare is false and 200 is reached"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_fold_and_cmp_skip_reach_playpl() {
|
||||
// SET GPR[3]=7; CMP GPR[3]==7 (true → do NOT skip); PlayPL GPR[3].
|
||||
// grp=CMP(1), op_cnt=2, imm_op2=1 (src=7 immediate), cmp_opt=EQ(2); dst=GPR3.
|
||||
let cmp_eq_reg = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 3, 0x0000_0007);
|
||||
let d = build(&[&[set_move_gpr(3, 7), cmp_eq_reg, play_pl_reg(3), play_pl(999)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
// If the compare wrongly skipped, we'd hit PlayPL 999 instead of 7.
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 7 || id == 999), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jumptitle_namespace_differs_from_jumpobject() {
|
||||
// JumpTitle 1 → titles[0] = HDMV obj 1 (which PlayPLs 42). Proves title
|
||||
// numbers resolve through the index, not as object indices.
|
||||
let d = build(&[&[jump_title(1)], &[play_pl(42)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(
|
||||
PlaybackObj::Hdmv { id_ref: 0 },
|
||||
vec![PlaybackObj::Hdmv { id_ref: 1 }],
|
||||
);
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 42), Some(42));
|
||||
}
|
||||
|
||||
// ADD/MUL saturate at 0xFFFFFFFF (libbluray hdmv_vm.c ADD_u32/MUL_u32,
|
||||
// reverse-engineered player behaviour), like SUB and DIV/MOD by 0 already do.
|
||||
#[test]
|
||||
fn add_and_mul_saturate() {
|
||||
let set = |opt: u8, reg: u32, imm: u32| cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm);
|
||||
let cmp_eq_0 = |reg: u32| cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, reg, 0);
|
||||
for ops in [
|
||||
[set(0x01, 0, 0xffff_ffff), set(0x03, 0, 1)],
|
||||
[set(0x01, 0, 0x1_0000), set(0x05, 0, 0x1_0000)],
|
||||
] {
|
||||
// A wrap to 0 makes the compare true and plays 1; saturation plays 800.
|
||||
let d = build(&[&[ops[0], ops[1], cmp_eq_0(0), play_pl(1), play_pl(800)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(
|
||||
resolve(&index, &mobjs, &|id| id == 1 || id == 800),
|
||||
Some(800)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_exchanges_two_registers() {
|
||||
// MOVE g0=11; MOVE g1=22; SWAP g0<->g1; PlayPL GPR[0]. SWAP: grp=SET(2),
|
||||
// sub_grp=SET(0), set_opt=SWAP(2), both operands registers (imm_op*=0).
|
||||
let swap01 = cmd((2 << 5) | (2 << 3), 0x00, 0, 0x02, 0, 1);
|
||||
let d = build(&[&[
|
||||
set_move_gpr(0, 11),
|
||||
set_move_gpr(1, 22),
|
||||
swap01,
|
||||
play_pl_reg(0),
|
||||
]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm::new(&mobjs, &index);
|
||||
// After the swap GPR0 holds the old GPR1 (22) and PlayPL plays it.
|
||||
assert_eq!(run(&mut vm, 0, &|id| id == 22), Some(22));
|
||||
// Full exchange: GPR1 now holds the old GPR0 (11).
|
||||
assert_eq!(vm.gpr[1], 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_convergent_self_goto_bails_via_max_steps() {
|
||||
// An unconditional GOTO to its own line never reaches a PlayPL: the
|
||||
// MAX_STEPS guard must bail (None) rather than loop forever. If the
|
||||
// guard were removed this would hang instead of returning.
|
||||
let self_goto = cmd(1 << 5, 0x81, 0, 0, 0, 0);
|
||||
let d = build(&[&[self_goto]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goto_lands_on_its_target_and_break_terminates() {
|
||||
// op_cnt=1, grp=BRANCH(0), sub_grp=GOTO(0), imm dst; branch_opt GOTO(1) / BREAK(2).
|
||||
let goto = |line: u32| cmd(1 << 5, 0x81, 0, 0, line, 0);
|
||||
let brk = cmd(1 << 5, 0x82, 0, 0, 0, 0);
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
// GOTO line 2 jumps over the logo PlayPL 99 (a candidate) to the feature PlayPL 1.
|
||||
let d = build(&[&[goto(2), play_pl(99), play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), Some(1));
|
||||
let d = build(&[&[brk, play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_play_pl_family_emits_a_playlist() {
|
||||
// sub_grp=PLAY(2); branch_opt 3 is Terminate/Link, which never plays dst.
|
||||
let d = build(&[&[cmd((1 << 5) | 2, 0x83, 0, 0, 11, 0)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setsystem_does_not_execute_as_a_gpr_set() {
|
||||
// grp=SET(2), sub_grp=SETSYSTEM(1), set_opt=MOVE(1): must not write GPR5.
|
||||
let setsys = cmd((2 << 5) | (2 << 3) | 1, 0x40, 0, 0x01, 5, 77);
|
||||
let d = build(&[&[setsys]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm::new(&mobjs, &index);
|
||||
assert_eq!(run(&mut vm, 0, &|_| false), None);
|
||||
assert_eq!(vm.gpr[5], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_immediate_operand_is_not_written_back() {
|
||||
// SWAP dst=GPR0 with src=IMMEDIATE 5 (imm_op1=0, imm_op2=1) must refuse
|
||||
// writing to the aliased register: GPR[5] stays 77, only GPR0 gets 5.
|
||||
// Dropping the `imm_op1`/`imm_op2` guard makes this assertion fail.
|
||||
let swap_imm = cmd((2 << 5) | (2 << 3), 0x40, 0, 0x02, 0, 5);
|
||||
let d = build(&[&[set_move_gpr(5, 77), swap_imm]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm::new(&mobjs, &index);
|
||||
// No PlayPL → program runs off the end (None); inspect registers after.
|
||||
assert_eq!(run(&mut vm, 0, &|_| false), None);
|
||||
assert_eq!(
|
||||
vm.gpr[5], 77,
|
||||
"immediate operand must not be a write target"
|
||||
);
|
||||
assert_eq!(vm.gpr[0], 5, "register operand receives the immediate");
|
||||
}
|
||||
|
||||
// libbluray hdmv_vm.c: BITSET/BITCLR with a bit number >= 32 are no-ops and
|
||||
// SHL/SHR by >= 32 give 0 (no `& 31` masking).
|
||||
#[test]
|
||||
fn bit_ops_and_shifts_past_31_follow_libbluray() {
|
||||
let set = |opt: u8, reg: u32, imm: u32| cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm);
|
||||
let d = build(&[&[
|
||||
set(0x0c, 0, 32),
|
||||
set(0x01, 1, 1),
|
||||
set(0x0e, 1, 32),
|
||||
set(0x01, 2, 0x8000_0000),
|
||||
set(0x0f, 2, 40),
|
||||
set(0x01, 3, 0xffff_ffff),
|
||||
set(0x0d, 3, 33),
|
||||
]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm::new(&mobjs, &index);
|
||||
assert_eq!(run(&mut vm, 0, &|_| false), None);
|
||||
assert_eq!(vm.gpr[..4], [0, 0, 0, 0xffff_ffff]);
|
||||
}
|
||||
|
||||
// RND is non-deterministic: a compare on its result abstains.
|
||||
#[test]
|
||||
fn compare_on_rnd_result_abstains() {
|
||||
let set = |opt: u8, reg: u32, imm: u32| cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm);
|
||||
let cmp_eq = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0, 5);
|
||||
let d = build(&[&[
|
||||
set(0x01, 0, 5),
|
||||
set(0x08, 0, 10),
|
||||
cmp_eq,
|
||||
play_pl(1),
|
||||
play_pl(800),
|
||||
]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 1 || id == 800), None);
|
||||
}
|
||||
|
||||
fn run_gpr0(prog: &[[u8; 12]], gprs: usize) -> Vec<u32> {
|
||||
let d = build(&[prog]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm::new(&mobjs, &index);
|
||||
assert_eq!(run(&mut vm, 0, &|_| false), None);
|
||||
vm.gpr[..gprs].to_vec()
|
||||
}
|
||||
|
||||
fn set_imm(opt: u8, reg: u32, imm: u32) -> [u8; 12] {
|
||||
cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm)
|
||||
}
|
||||
|
||||
// CMP dst(GPR0 = a) against imm b: true falls through to PlayPL 1, false skips to 2.
|
||||
fn cmp_picks_true(opt: u8, a: u32, b: u32) -> bool {
|
||||
let cmp = cmd((2 << 5) | (1 << 3), 0x40, opt, 0, 0, b);
|
||||
let d = build(&[&[set_imm(0x01, 0, a), cmp, play_pl(1), play_pl(2)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
resolve(&index, &mobjs, &|id| id == 1 || id == 2) == Some(1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmp_ops_ge_gt_le_lt_ne() {
|
||||
// (cmp_opt, a, b, expected)
|
||||
let cases = [
|
||||
(0x04, 5, 5, true),
|
||||
(0x04, 4, 5, false),
|
||||
(0x04, 6, 5, true),
|
||||
(0x05, 5, 5, false),
|
||||
(0x05, 6, 5, true),
|
||||
(0x05, 4, 5, false),
|
||||
(0x06, 5, 5, true),
|
||||
(0x06, 6, 5, false),
|
||||
(0x06, 4, 5, true),
|
||||
(0x07, 5, 5, false),
|
||||
(0x07, 4, 5, true),
|
||||
(0x07, 6, 5, false),
|
||||
(0x03, 5, 5, false),
|
||||
(0x03, 5, 6, true),
|
||||
];
|
||||
for (opt, a, b, want) in cases {
|
||||
assert_eq!(cmp_picks_true(opt, a, b), want, "opt {opt:#x} {a} vs {b}");
|
||||
}
|
||||
}
|
||||
|
||||
// libbluray INSN_BC skips the next command when `dst & ~src` is non-zero:
|
||||
// true iff every bit of dst is set in src.
|
||||
#[test]
|
||||
fn cmp_bc_tests_dst_bits_within_src() {
|
||||
assert!(!cmp_picks_true(0x01, 0b1110, 0b0110));
|
||||
assert!(cmp_picks_true(0x01, 0b0100, 0b0110));
|
||||
assert!(cmp_picks_true(0x01, 0b0110, 0b1110));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_arithmetic_and_logic_ops() {
|
||||
// (set_opt, a, b, expected)
|
||||
let cases = [
|
||||
(0x03, 2, 3, 5),
|
||||
(0x04, 10, 3, 7),
|
||||
(0x04, 3, 10, 0),
|
||||
(0x05, 2, 3, 6),
|
||||
(0x06, 10, 3, 3),
|
||||
(0x06, 10, 0, 0xffff_ffff),
|
||||
(0x07, 10, 3, 1),
|
||||
(0x07, 10, 0, 0xffff_ffff),
|
||||
(0x09, 0b1100, 0b1010, 0b1000),
|
||||
(0x0a, 0b1100, 0b1010, 0b1110),
|
||||
(0x0b, 0b1100, 0b1010, 0b0110),
|
||||
(0x0c, 0, 3, 8),
|
||||
(0x0d, 0xf, 1, 0xd),
|
||||
(0x0e, 1, 4, 16),
|
||||
(0x0f, 0x100, 4, 0x10),
|
||||
];
|
||||
for (opt, a, b, want) in cases {
|
||||
let g = run_gpr0(&[set_imm(0x01, 0, a), set_imm(opt, 0, b)], 1);
|
||||
assert_eq!(g[0], want, "set_opt {opt:#x} on {a}, {b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_or_compare_on_rnd_value_abstains() {
|
||||
// GPR0 = RND; then PlayPL GPR0 (branch grp, dst tainted).
|
||||
let rnd = set_imm(0x08, 0, 10);
|
||||
let d = build(&[&[rnd, play_pl_reg(0), play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
// Compare with a clean dst (GPR1) and a tainted register src (GPR0).
|
||||
let cmp = cmd((2 << 5) | (1 << 3), 0x00, 0x02, 0, 1, 0);
|
||||
let d = build(&[&[rnd, cmp, play_pl(1), play_pl(2)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_from_rnd_source_taints_destination() {
|
||||
// GPR0 = RND; GPR1 (clean) += GPR0 (register src); PlayPL GPR1 must abstain.
|
||||
let rnd = set_imm(0x08, 0, 10);
|
||||
let add = cmd((2 << 5) | (2 << 3), 0x00, 0, 0x03, 1, 0);
|
||||
let d = build(&[&[rnd, add, play_pl_reg(1), play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playpl_operand_over_16_bits_is_not_a_playlist_id() {
|
||||
// 0x1_0001 must not alias playlist 1; the VM falls through to 800.
|
||||
let wide = cmd((1 << 5) | 2, 0x80, 0, 0, 0x1_0001, 0);
|
||||
let d = build(&[&[wide, play_pl(800)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(
|
||||
resolve(&index, &mobjs, &|id| id == 1 || id == 800),
|
||||
Some(800)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_on_psr_defaults_are_visible_to_compares() {
|
||||
// (psr, expected power-on value)
|
||||
let cases: [(u32, u32); 12] = [
|
||||
(0, 1),
|
||||
(1, 0xff),
|
||||
(3, 1),
|
||||
(4, 0xffff),
|
||||
(5, 0xffff),
|
||||
(6, 0),
|
||||
(7, 0),
|
||||
(8, 0),
|
||||
(20, 2),
|
||||
(31, 0x0003_0200),
|
||||
(48, 0xffff_ffff),
|
||||
(62, 0),
|
||||
];
|
||||
for (psr, want) in cases {
|
||||
// CMP PSR == want (imm): true plays 1, false plays 2.
|
||||
let cmp = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0x8000_0000 | psr, want);
|
||||
let d = build(&[&[cmp, play_pl(1), play_pl(2)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let got = resolve(&index, &mobjs, &|id| id == 1 || id == 2);
|
||||
assert_eq!(got, Some(1), "PSR{psr} != {want:#x}");
|
||||
}
|
||||
}
|
||||
+404
-2
@@ -137,5 +137,407 @@ fn parse_program_info(data: &[u8]) -> Vec<ClpiStream> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "clpi_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a minimal CLPI binary.
|
||||
/// `cpi_data` is the raw CPI section bytes (starting with the 4-byte CPI length).
|
||||
fn build_clpi(source_packet_count: u32, cpi_data: Option<&[u8]>) -> Vec<u8> {
|
||||
// Need at least 60 bytes for the header area: "HDMV"/"0200" magic,
|
||||
// seq_info_start/prog_info_start (unused here), cpi_start, reserved
|
||||
// padding, then the ClipInfo section ending in source_packet_count.
|
||||
|
||||
let cpi_start: u32 = if cpi_data.is_some() { 60 } else { 0 };
|
||||
|
||||
let mut buf = vec![0u8; 60];
|
||||
// Magic + version
|
||||
buf[0..4].copy_from_slice(b"HDMV");
|
||||
buf[4..8].copy_from_slice(b"0200");
|
||||
// seq_info_start = 0
|
||||
// prog_info_start = 0
|
||||
// cpi_start
|
||||
buf[16..20].copy_from_slice(&cpi_start.to_be_bytes());
|
||||
// source_packet_count at offset 56
|
||||
buf[56..60].copy_from_slice(&source_packet_count.to_be_bytes());
|
||||
|
||||
if let Some(cpi) = cpi_data {
|
||||
buf.extend_from_slice(cpi);
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_truncated_clipinfo_parses_with_unknown_count() {
|
||||
let full = build_clpi(1000, None);
|
||||
for len in 16..60usize {
|
||||
let c = parse(&full[..len]).unwrap_or_else(|e| panic!("len {len}: {e:?}"));
|
||||
assert_eq!(c.source_packet_count, 0, "len {len}");
|
||||
}
|
||||
assert_eq!(parse(&full[..60]).expect("60").source_packet_count, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_magic() {
|
||||
let mut data = build_clpi(1000, None);
|
||||
data[0] = b'X';
|
||||
data[1] = b'X';
|
||||
data[2] = b'X';
|
||||
data[3] = b'X';
|
||||
assert!(parse(&data).is_err());
|
||||
}
|
||||
|
||||
// Added hardening tests, grounded in the BD-ROM CLPI spec byte layout. Build a ProgramInfo
|
||||
// section; `streams` = Vec<(pid, sci_bytes)>.
|
||||
fn build_program_info(streams: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut body = Vec::new();
|
||||
body.push(0); // reserved (offset 4)
|
||||
body.push(1); // num_programs = 1 (offset 5)
|
||||
// program 0 header (8 bytes)
|
||||
body.extend_from_slice(&0u32.to_be_bytes()); // spn_program_sequence_start
|
||||
body.extend_from_slice(&0u16.to_be_bytes()); // program_map_pid
|
||||
body.push(streams.len() as u8); // num_streams
|
||||
body.push(0); // num_groups
|
||||
for (pid, sci) in streams {
|
||||
body.extend_from_slice(&pid.to_be_bytes());
|
||||
body.push(sci.len() as u8);
|
||||
body.extend_from_slice(sci);
|
||||
}
|
||||
// Prepend length(4) = bytes after the length field.
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a CLPI with a ProgramInfo section. prog_info_start is placed
|
||||
/// right after the 60-byte header; cpi (if any) follows program_info.
|
||||
fn build_clpi_with_proginfo(
|
||||
source_packet_count: u32,
|
||||
prog_info: &[u8],
|
||||
cpi_data: Option<&[u8]>,
|
||||
) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 60];
|
||||
buf[0..4].copy_from_slice(b"HDMV");
|
||||
buf[4..8].copy_from_slice(b"0200");
|
||||
let prog_info_start: u32 = 60;
|
||||
buf[12..16].copy_from_slice(&prog_info_start.to_be_bytes());
|
||||
let cpi_start: u32 = if cpi_data.is_some() {
|
||||
(60 + prog_info.len()) as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
buf[16..20].copy_from_slice(&cpi_start.to_be_bytes());
|
||||
buf[56..60].copy_from_slice(&source_packet_count.to_be_bytes());
|
||||
buf.extend_from_slice(prog_info);
|
||||
if let Some(cpi) = cpi_data {
|
||||
buf.extend_from_slice(cpi);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// source_packet_count is a big-endian u32 at offset [56..60]. Verify
|
||||
/// BE decode of a value with all four bytes distinct (not LE / wrong
|
||||
/// offset).
|
||||
#[test]
|
||||
fn source_packet_count_big_endian_offset_56() {
|
||||
let data = build_clpi(0x01020304, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.source_packet_count, 0x01020304);
|
||||
}
|
||||
|
||||
/// Magic must be exactly "HDMV" at [0..4]. Anything else → ClpiParse.
|
||||
/// Spec: CLPI files begin with the type_indicator "HDMV".
|
||||
#[test]
|
||||
fn wrong_magic_rejected() {
|
||||
let mut data = build_clpi(1000, None);
|
||||
data[0..4].copy_from_slice(b"INDX");
|
||||
assert!(parse(&data).is_err());
|
||||
}
|
||||
|
||||
/// Input shorter than the 16-byte header is rejected before any field read.
|
||||
#[test]
|
||||
fn under_16_bytes_rejected() {
|
||||
assert!(parse(&[0u8; 15]).is_err());
|
||||
assert!(parse(b"HDMV0200").is_err());
|
||||
assert!(parse(&[]).is_err());
|
||||
}
|
||||
|
||||
/// ProgramInfo: a video stream (coding 0x1B = H.264) carries
|
||||
/// format/rate in sci[1] nibbles and NO language. Verify the video
|
||||
/// arm: format hi-nibble, rate lo-nibble, language stays empty.
|
||||
#[test]
|
||||
fn program_info_video_stream() {
|
||||
// sci = coding_type(0x1B) + format_rate(0x61 → fmt 6, rate 1)
|
||||
let sci = vec![0x1Bu8, 0x61];
|
||||
let pi = build_program_info(&[(0x1011, sci)]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams.len(), 1);
|
||||
assert_eq!(clip.streams[0].pid, 0x1011);
|
||||
assert_eq!(clip.streams[0].coding_type, 0x1B);
|
||||
assert_eq!(clip.streams[0].language, "");
|
||||
}
|
||||
|
||||
/// ProgramInfo primary-audio (coding 0x80..=0x86): sci[1] = format/rate
|
||||
/// nibbles, sci[2..5] = ISO 639 language. Verify TrueHD (0x83) at
|
||||
/// offset, 5.1 / 48kHz, language "eng".
|
||||
#[test]
|
||||
fn program_info_audio_stream_lang_offset() {
|
||||
// sci = 0x83 + 0x61 (fmt 6, rate 1) + "eng"
|
||||
let sci = vec![0x83u8, 0x61, b'e', b'n', b'g'];
|
||||
let pi = build_program_info(&[(0x1100, sci)]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams[0].coding_type, 0x83);
|
||||
assert_eq!(clip.streams[0].language, "eng");
|
||||
}
|
||||
|
||||
/// An audio or TextST sci shorter than 5 bytes has no language field: the stream is
|
||||
/// kept, the language stays empty, and nothing indexes past the sci.
|
||||
#[test]
|
||||
fn program_info_short_audio_and_textst_sci_keep_the_stream_without_a_language() {
|
||||
let pi = build_program_info(&[
|
||||
(0x1100, vec![0x83u8, 0x61, b'e', b'n']),
|
||||
(0x1200, vec![0x92u8, 0x01, b'e', b'n']),
|
||||
]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams.len(), 2);
|
||||
assert!(clip.streams.iter().all(|s| s.language.is_empty()));
|
||||
}
|
||||
|
||||
/// ProgramInfo PG (0x90)/IG (0x91): layout is coding_type(1)+lang(3),
|
||||
/// so language is at sci[1..4] (NOT sci[2..5] like audio). Verify the
|
||||
/// PG arm reads from the right offset.
|
||||
#[test]
|
||||
fn program_info_pg_lang_offset() {
|
||||
// sci = 0x90 + "fra" (lang directly after coding_type)
|
||||
let sci = vec![0x90u8, b'f', b'r', b'a'];
|
||||
let pi = build_program_info(&[(0x1200, sci)]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams[0].coding_type, 0x90);
|
||||
assert_eq!(clip.streams[0].language, "fra");
|
||||
// Audio nibbles must NOT be populated for a PG stream.
|
||||
}
|
||||
|
||||
/// ProgramInfo with multiple streams: PID and coding for each must be
|
||||
/// read from the correct per-stream offset (pid(2)+sci_len(1)+sci).
|
||||
/// Three mixed streams must all parse with distinct PIDs in order.
|
||||
#[test]
|
||||
fn program_info_multiple_streams_advance_correctly() {
|
||||
let v = (0x1011u16, vec![0x24u8, 0x81]); // HEVC video
|
||||
let a = (0x1100u16, vec![0x86u8, 0x61, b'e', b'n', b'g']); // DTS-HD MA
|
||||
let s = (0x1200u16, vec![0x90u8, b'j', b'p', b'n']); // PG
|
||||
let pi = build_program_info(&[v, a, s]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams.len(), 3);
|
||||
assert_eq!(clip.streams[0].pid, 0x1011);
|
||||
assert_eq!(clip.streams[0].coding_type, 0x24);
|
||||
assert_eq!(clip.streams[1].pid, 0x1100);
|
||||
assert_eq!(clip.streams[1].coding_type, 0x86);
|
||||
assert_eq!(clip.streams[1].language, "eng");
|
||||
assert_eq!(clip.streams[2].pid, 0x1200);
|
||||
assert_eq!(clip.streams[2].language, "jpn");
|
||||
}
|
||||
|
||||
// Best-effort: a stream whose sci_len runs past the section end
|
||||
// (`sci_end > data.len()`) returns streams collected so far (none
|
||||
// here), never panics — source returns `out` early on overflow.
|
||||
#[test]
|
||||
fn program_info_truncated_sci_no_panic() {
|
||||
// One stream claiming sci_len = 200 but with no body.
|
||||
let mut body = Vec::new();
|
||||
body.push(0); // reserved
|
||||
body.push(1); // num_programs
|
||||
body.extend_from_slice(&0u32.to_be_bytes());
|
||||
body.extend_from_slice(&0u16.to_be_bytes());
|
||||
body.push(1); // num_streams
|
||||
body.push(0); // num_groups
|
||||
body.extend_from_slice(&0x1011u16.to_be_bytes()); // pid
|
||||
body.push(200); // sci_len = 200, no body follows
|
||||
let mut pi = Vec::new();
|
||||
pi.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
pi.extend_from_slice(&body);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should not panic");
|
||||
assert!(clip.streams.is_empty());
|
||||
}
|
||||
|
||||
/// parse_program_info rejects sci_len == 0 (`sci_len < 1` → return).
|
||||
/// A zero-length stream_coding_info is unusable.
|
||||
#[test]
|
||||
fn program_info_zero_sci_len_yields_no_stream() {
|
||||
let mut body = Vec::new();
|
||||
body.push(0);
|
||||
body.push(1);
|
||||
body.extend_from_slice(&0u32.to_be_bytes());
|
||||
body.extend_from_slice(&0u16.to_be_bytes());
|
||||
body.push(1);
|
||||
body.push(0);
|
||||
body.extend_from_slice(&0x1011u16.to_be_bytes());
|
||||
body.push(0); // sci_len = 0
|
||||
let mut pi = Vec::new();
|
||||
pi.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
pi.extend_from_slice(&body);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert!(clip.streams.is_empty());
|
||||
}
|
||||
|
||||
// Section-offset gates in `parse`: prog_info_start == 0 means "no ProgramInfo section" —
|
||||
// offset-0 bytes must NOT be reinterpreted as a table.
|
||||
#[test]
|
||||
fn prog_info_start_zero_does_not_parse_header_as_program_info() {
|
||||
let mut data = build_clpi(1000, None);
|
||||
data[5] = 2; // num_programs = 2 if read from offset 0
|
||||
// program 0 header = data[6..14]; its num_streams byte is data[12],
|
||||
// which is prog_info_start's first byte and must stay 0.
|
||||
data[20] = 1; // program 1 (header data[14..22]) declares 1 stream
|
||||
data[22..24].copy_from_slice(&0x1011u16.to_be_bytes()); // pid
|
||||
data[24] = 2; // sci_len
|
||||
data[25] = 0x1B; // coding_type H.264
|
||||
data[26] = 0x61; // video format 6 / rate 1
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert!(
|
||||
clip.streams.is_empty(),
|
||||
"prog_info_start == 0 must mean absent, got {:?}",
|
||||
clip.streams
|
||||
);
|
||||
}
|
||||
|
||||
// parse_program_info: secondary audio (0xA1/0xA2) shares primary
|
||||
// audio's sci layout — sci[1] = presentation-type/rate nibbles,
|
||||
// sci[2..5] = ISO 639-2 language. Both sub-fields must be populated.
|
||||
#[test]
|
||||
fn program_info_secondary_audio_fields() {
|
||||
for coding in [c_ac3_plus_secondary(), c_dts_hd_secondary()] {
|
||||
let sci = vec![coding, 0x61, b'd', b'e', b'u'];
|
||||
let pi = build_program_info(&[(0x1A00, sci)]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams.len(), 1, "coding {coding:#04x}");
|
||||
let s = &clip.streams[0];
|
||||
assert_eq!(s.coding_type, coding);
|
||||
// 0x61: high nibble 6, low nibble 1 — distinct values, so a
|
||||
// swapped/ORed/XORed nibble extraction cannot pass.
|
||||
assert_eq!(s.language, "deu", "coding {coding:#04x}");
|
||||
}
|
||||
}
|
||||
|
||||
fn c_ac3_plus_secondary() -> u8 {
|
||||
crate::consts::coding_type::AC3_PLUS_SECONDARY
|
||||
}
|
||||
fn c_dts_hd_secondary() -> u8 {
|
||||
crate::consts::coding_type::DTS_HD_SECONDARY
|
||||
}
|
||||
|
||||
// A 1-byte sci (coding_type only) is the accepted minimum: PID and
|
||||
// coding_type get recorded, all sub-fields needing more bytes stay
|
||||
// empty — a PG stream must NOT read sci[1..4] when only sci[0] exists.
|
||||
#[test]
|
||||
fn program_info_sci_len_one_yields_bare_stream() {
|
||||
let pi = build_program_info(&[(0x1200, vec![0x90u8])]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should not panic");
|
||||
assert_eq!(clip.streams.len(), 1);
|
||||
assert_eq!(clip.streams[0].pid, 0x1200);
|
||||
assert_eq!(clip.streams[0].coding_type, 0x90);
|
||||
assert_eq!(clip.streams[0].language, "");
|
||||
}
|
||||
|
||||
/// Below the 6-byte ProgramInfo header (length(4)+reserved(1)+
|
||||
/// num_programs(1)) there is nothing to read; the length guard must fire
|
||||
/// before `data[5]`.
|
||||
#[test]
|
||||
fn program_info_below_header_size_is_empty() {
|
||||
for len in 0..6usize {
|
||||
assert!(parse_program_info(&vec![0u8; len]).is_empty(), "len={len}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A declared program whose 8-byte header runs past the section end must
|
||||
/// stop before reading num_streams at `data[pos + 6]`.
|
||||
#[test]
|
||||
fn program_info_truncated_program_header_is_empty() {
|
||||
// length(4) + reserved(1) + num_programs=1 (1) + only 4 of the 8
|
||||
// program-header bytes.
|
||||
let mut data = vec![0u8; 6];
|
||||
data[5] = 1;
|
||||
data.extend_from_slice(&[0u8; 4]);
|
||||
assert!(parse_program_info(&data).is_empty());
|
||||
}
|
||||
|
||||
/// A declared stream whose 3-byte header (pid(2)+sci_len(1)) runs past
|
||||
/// the section end must stop before reading the PID.
|
||||
#[test]
|
||||
fn program_info_truncated_stream_header_is_empty() {
|
||||
let mut data = vec![0u8; 6];
|
||||
data[5] = 1; // num_programs
|
||||
data.extend_from_slice(&[0u8; 8]); // program header
|
||||
data[6 + 6] = 1; // num_streams = 1
|
||||
data.extend_from_slice(&[0u8; 2]); // only 2 of the 3 stream bytes
|
||||
assert_eq!(data.len(), 16);
|
||||
assert!(parse_program_info(&data).is_empty());
|
||||
}
|
||||
|
||||
/// TextST (0x92): coding_type + character_code + 3-byte language, so the
|
||||
/// language sits at sci[2..5].
|
||||
#[test]
|
||||
fn program_info_text_subtitle_lang_offset() {
|
||||
let sci = vec![0x92u8, 0x01, b'd', b'e', b'u'];
|
||||
let pi = build_program_info(&[(0x1800, sci)]);
|
||||
let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse");
|
||||
assert_eq!(clip.streams[0].coding_type, 0x92);
|
||||
assert_eq!(clip.streams[0].language, "deu");
|
||||
}
|
||||
|
||||
/// IG (0x91) shares the PG layout: language at sci[1..4].
|
||||
#[test]
|
||||
fn program_info_ig_lang_offset() {
|
||||
let sci = vec![0x91u8, b'e', b's', b'p'];
|
||||
let pi = build_program_info(&[(0x1400, sci)]);
|
||||
let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse");
|
||||
assert_eq!(clip.streams[0].language, "esp");
|
||||
}
|
||||
|
||||
/// Streams from every program are collected, in order.
|
||||
#[test]
|
||||
fn program_info_multiple_programs_all_collected() {
|
||||
let mut body = vec![0u8, 2]; // reserved, num_programs = 2
|
||||
for (pid, lang) in [(0x1200u16, b"fra"), (0x1201u16, b"jpn")] {
|
||||
body.extend_from_slice(&[0u8; 6]); // spn + pmt_pid
|
||||
body.extend_from_slice(&[1, 0]); // num_streams, num_groups
|
||||
body.extend_from_slice(&pid.to_be_bytes());
|
||||
body.push(4);
|
||||
body.push(0x90);
|
||||
body.extend_from_slice(lang);
|
||||
}
|
||||
let mut pi = (body.len() as u32).to_be_bytes().to_vec();
|
||||
pi.extend_from_slice(&body);
|
||||
let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse");
|
||||
assert_eq!(clip.streams.len(), 2);
|
||||
assert_eq!(clip.streams[0].language, "fra");
|
||||
assert_eq!(clip.streams[1].pid, 0x1201);
|
||||
assert_eq!(clip.streams[1].language, "jpn");
|
||||
}
|
||||
|
||||
/// A trailing CPI section after ProgramInfo does not disturb parsing.
|
||||
#[test]
|
||||
fn cpi_section_present_still_parses() {
|
||||
let pi = build_program_info(&[(0x1200, vec![0x90u8, b'f', b'r', b'a'])]);
|
||||
let cpi = [0u8, 0, 0, 2, 0, 0];
|
||||
let data = build_clpi_with_proginfo(777, &pi, Some(&cpi));
|
||||
let clip = parse(&data).expect("parse");
|
||||
assert_eq!(clip.source_packet_count, 777);
|
||||
assert_eq!(clip.streams.len(), 1);
|
||||
let clip = parse(&build_clpi(5, Some(&cpi))).expect("parse");
|
||||
assert_eq!(clip.source_packet_count, 5);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// parse_cpi — low-level fixtures
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
}
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// Build a minimal CLPI binary.
|
||||
/// `cpi_data` is the raw CPI section bytes (starting with the 4-byte CPI length).
|
||||
fn build_clpi(source_packet_count: u32, cpi_data: Option<&[u8]>) -> Vec<u8> {
|
||||
// Need at least 60 bytes for the header area: "HDMV"/"0200" magic,
|
||||
// seq_info_start/prog_info_start (unused here), cpi_start, reserved
|
||||
// padding, then the ClipInfo section ending in source_packet_count.
|
||||
|
||||
let cpi_start: u32 = if cpi_data.is_some() { 60 } else { 0 };
|
||||
|
||||
let mut buf = vec![0u8; 60];
|
||||
// Magic + version
|
||||
buf[0..4].copy_from_slice(b"HDMV");
|
||||
buf[4..8].copy_from_slice(b"0200");
|
||||
// seq_info_start = 0
|
||||
// prog_info_start = 0
|
||||
// cpi_start
|
||||
buf[16..20].copy_from_slice(&cpi_start.to_be_bytes());
|
||||
// source_packet_count at offset 56
|
||||
buf[56..60].copy_from_slice(&source_packet_count.to_be_bytes());
|
||||
|
||||
if let Some(cpi) = cpi_data {
|
||||
buf.extend_from_slice(cpi);
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_truncated_clipinfo_parses_with_unknown_count() {
|
||||
let full = build_clpi(1000, None);
|
||||
for len in 16..60usize {
|
||||
let c = parse(&full[..len]).unwrap_or_else(|e| panic!("len {len}: {e:?}"));
|
||||
assert_eq!(c.source_packet_count, 0, "len {len}");
|
||||
}
|
||||
assert_eq!(parse(&full[..60]).expect("60").source_packet_count, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_magic() {
|
||||
let mut data = build_clpi(1000, None);
|
||||
data[0] = b'X';
|
||||
data[1] = b'X';
|
||||
data[2] = b'X';
|
||||
data[3] = b'X';
|
||||
assert!(parse(&data).is_err());
|
||||
}
|
||||
|
||||
// Added hardening tests, grounded in the BD-ROM CLPI spec byte layout. Build a ProgramInfo
|
||||
// section; `streams` = Vec<(pid, sci_bytes)>.
|
||||
fn build_program_info(streams: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut body = Vec::new();
|
||||
body.push(0); // reserved (offset 4)
|
||||
body.push(1); // num_programs = 1 (offset 5)
|
||||
// program 0 header (8 bytes)
|
||||
body.extend_from_slice(&0u32.to_be_bytes()); // spn_program_sequence_start
|
||||
body.extend_from_slice(&0u16.to_be_bytes()); // program_map_pid
|
||||
body.push(streams.len() as u8); // num_streams
|
||||
body.push(0); // num_groups
|
||||
for (pid, sci) in streams {
|
||||
body.extend_from_slice(&pid.to_be_bytes());
|
||||
body.push(sci.len() as u8);
|
||||
body.extend_from_slice(sci);
|
||||
}
|
||||
// Prepend length(4) = bytes after the length field.
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a CLPI with a ProgramInfo section. prog_info_start is placed
|
||||
/// right after the 60-byte header; cpi (if any) follows program_info.
|
||||
fn build_clpi_with_proginfo(
|
||||
source_packet_count: u32,
|
||||
prog_info: &[u8],
|
||||
cpi_data: Option<&[u8]>,
|
||||
) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 60];
|
||||
buf[0..4].copy_from_slice(b"HDMV");
|
||||
buf[4..8].copy_from_slice(b"0200");
|
||||
let prog_info_start: u32 = 60;
|
||||
buf[12..16].copy_from_slice(&prog_info_start.to_be_bytes());
|
||||
let cpi_start: u32 = if cpi_data.is_some() {
|
||||
(60 + prog_info.len()) as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
buf[16..20].copy_from_slice(&cpi_start.to_be_bytes());
|
||||
buf[56..60].copy_from_slice(&source_packet_count.to_be_bytes());
|
||||
buf.extend_from_slice(prog_info);
|
||||
if let Some(cpi) = cpi_data {
|
||||
buf.extend_from_slice(cpi);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// source_packet_count is a big-endian u32 at offset [56..60]. Verify
|
||||
/// BE decode of a value with all four bytes distinct (not LE / wrong
|
||||
/// offset).
|
||||
#[test]
|
||||
fn source_packet_count_big_endian_offset_56() {
|
||||
let data = build_clpi(0x01020304, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.source_packet_count, 0x01020304);
|
||||
}
|
||||
|
||||
/// Magic must be exactly "HDMV" at [0..4]. Anything else → ClpiParse.
|
||||
/// Spec: CLPI files begin with the type_indicator "HDMV".
|
||||
#[test]
|
||||
fn wrong_magic_rejected() {
|
||||
let mut data = build_clpi(1000, None);
|
||||
data[0..4].copy_from_slice(b"INDX");
|
||||
assert!(parse(&data).is_err());
|
||||
}
|
||||
|
||||
/// Input shorter than the 16-byte header is rejected before any field read.
|
||||
#[test]
|
||||
fn under_16_bytes_rejected() {
|
||||
assert!(parse(&[0u8; 15]).is_err());
|
||||
assert!(parse(b"HDMV0200").is_err());
|
||||
assert!(parse(&[]).is_err());
|
||||
}
|
||||
|
||||
/// ProgramInfo: a video stream (coding 0x1B = H.264) carries
|
||||
/// format/rate in sci[1] nibbles and NO language. Verify the video
|
||||
/// arm: format hi-nibble, rate lo-nibble, language stays empty.
|
||||
#[test]
|
||||
fn program_info_video_stream() {
|
||||
// sci = coding_type(0x1B) + format_rate(0x61 → fmt 6, rate 1)
|
||||
let sci = vec![0x1Bu8, 0x61];
|
||||
let pi = build_program_info(&[(0x1011, sci)]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams.len(), 1);
|
||||
assert_eq!(clip.streams[0].pid, 0x1011);
|
||||
assert_eq!(clip.streams[0].coding_type, 0x1B);
|
||||
assert_eq!(clip.streams[0].language, "");
|
||||
}
|
||||
|
||||
/// ProgramInfo primary-audio (coding 0x80..=0x86): sci[1] = format/rate
|
||||
/// nibbles, sci[2..5] = ISO 639 language. Verify TrueHD (0x83) at
|
||||
/// offset, 5.1 / 48kHz, language "eng".
|
||||
#[test]
|
||||
fn program_info_audio_stream_lang_offset() {
|
||||
// sci = 0x83 + 0x61 (fmt 6, rate 1) + "eng"
|
||||
let sci = vec![0x83u8, 0x61, b'e', b'n', b'g'];
|
||||
let pi = build_program_info(&[(0x1100, sci)]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams[0].coding_type, 0x83);
|
||||
assert_eq!(clip.streams[0].language, "eng");
|
||||
}
|
||||
|
||||
/// An audio or TextST sci shorter than 5 bytes has no language field: the stream is
|
||||
/// kept, the language stays empty, and nothing indexes past the sci.
|
||||
#[test]
|
||||
fn program_info_short_audio_and_textst_sci_keep_the_stream_without_a_language() {
|
||||
let pi = build_program_info(&[
|
||||
(0x1100, vec![0x83u8, 0x61, b'e', b'n']),
|
||||
(0x1200, vec![0x92u8, 0x01, b'e', b'n']),
|
||||
]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams.len(), 2);
|
||||
assert!(clip.streams.iter().all(|s| s.language.is_empty()));
|
||||
}
|
||||
|
||||
/// ProgramInfo PG (0x90)/IG (0x91): layout is coding_type(1)+lang(3),
|
||||
/// so language is at sci[1..4] (NOT sci[2..5] like audio). Verify the
|
||||
/// PG arm reads from the right offset.
|
||||
#[test]
|
||||
fn program_info_pg_lang_offset() {
|
||||
// sci = 0x90 + "fra" (lang directly after coding_type)
|
||||
let sci = vec![0x90u8, b'f', b'r', b'a'];
|
||||
let pi = build_program_info(&[(0x1200, sci)]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams[0].coding_type, 0x90);
|
||||
assert_eq!(clip.streams[0].language, "fra");
|
||||
// Audio nibbles must NOT be populated for a PG stream.
|
||||
}
|
||||
|
||||
/// ProgramInfo with multiple streams: PID and coding for each must be
|
||||
/// read from the correct per-stream offset (pid(2)+sci_len(1)+sci).
|
||||
/// Three mixed streams must all parse with distinct PIDs in order.
|
||||
#[test]
|
||||
fn program_info_multiple_streams_advance_correctly() {
|
||||
let v = (0x1011u16, vec![0x24u8, 0x81]); // HEVC video
|
||||
let a = (0x1100u16, vec![0x86u8, 0x61, b'e', b'n', b'g']); // DTS-HD MA
|
||||
let s = (0x1200u16, vec![0x90u8, b'j', b'p', b'n']); // PG
|
||||
let pi = build_program_info(&[v, a, s]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams.len(), 3);
|
||||
assert_eq!(clip.streams[0].pid, 0x1011);
|
||||
assert_eq!(clip.streams[0].coding_type, 0x24);
|
||||
assert_eq!(clip.streams[1].pid, 0x1100);
|
||||
assert_eq!(clip.streams[1].coding_type, 0x86);
|
||||
assert_eq!(clip.streams[1].language, "eng");
|
||||
assert_eq!(clip.streams[2].pid, 0x1200);
|
||||
assert_eq!(clip.streams[2].language, "jpn");
|
||||
}
|
||||
|
||||
// Best-effort: a stream whose sci_len runs past the section end
|
||||
// (`sci_end > data.len()`) returns streams collected so far (none
|
||||
// here), never panics — source returns `out` early on overflow.
|
||||
#[test]
|
||||
fn program_info_truncated_sci_no_panic() {
|
||||
// One stream claiming sci_len = 200 but with no body.
|
||||
let mut body = Vec::new();
|
||||
body.push(0); // reserved
|
||||
body.push(1); // num_programs
|
||||
body.extend_from_slice(&0u32.to_be_bytes());
|
||||
body.extend_from_slice(&0u16.to_be_bytes());
|
||||
body.push(1); // num_streams
|
||||
body.push(0); // num_groups
|
||||
body.extend_from_slice(&0x1011u16.to_be_bytes()); // pid
|
||||
body.push(200); // sci_len = 200, no body follows
|
||||
let mut pi = Vec::new();
|
||||
pi.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
pi.extend_from_slice(&body);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should not panic");
|
||||
assert!(clip.streams.is_empty());
|
||||
}
|
||||
|
||||
/// parse_program_info rejects sci_len == 0 (`sci_len < 1` → return).
|
||||
/// A zero-length stream_coding_info is unusable.
|
||||
#[test]
|
||||
fn program_info_zero_sci_len_yields_no_stream() {
|
||||
let mut body = Vec::new();
|
||||
body.push(0);
|
||||
body.push(1);
|
||||
body.extend_from_slice(&0u32.to_be_bytes());
|
||||
body.extend_from_slice(&0u16.to_be_bytes());
|
||||
body.push(1);
|
||||
body.push(0);
|
||||
body.extend_from_slice(&0x1011u16.to_be_bytes());
|
||||
body.push(0); // sci_len = 0
|
||||
let mut pi = Vec::new();
|
||||
pi.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
pi.extend_from_slice(&body);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert!(clip.streams.is_empty());
|
||||
}
|
||||
|
||||
// Section-offset gates in `parse`: prog_info_start == 0 means "no ProgramInfo section" —
|
||||
// offset-0 bytes must NOT be reinterpreted as a table.
|
||||
#[test]
|
||||
fn prog_info_start_zero_does_not_parse_header_as_program_info() {
|
||||
let mut data = build_clpi(1000, None);
|
||||
data[5] = 2; // num_programs = 2 if read from offset 0
|
||||
// program 0 header = data[6..14]; its num_streams byte is data[12],
|
||||
// which is prog_info_start's first byte and must stay 0.
|
||||
data[20] = 1; // program 1 (header data[14..22]) declares 1 stream
|
||||
data[22..24].copy_from_slice(&0x1011u16.to_be_bytes()); // pid
|
||||
data[24] = 2; // sci_len
|
||||
data[25] = 0x1B; // coding_type H.264
|
||||
data[26] = 0x61; // video format 6 / rate 1
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert!(
|
||||
clip.streams.is_empty(),
|
||||
"prog_info_start == 0 must mean absent, got {:?}",
|
||||
clip.streams
|
||||
);
|
||||
}
|
||||
|
||||
// parse_program_info: secondary audio (0xA1/0xA2) shares primary
|
||||
// audio's sci layout — sci[1] = presentation-type/rate nibbles,
|
||||
// sci[2..5] = ISO 639-2 language. Both sub-fields must be populated.
|
||||
#[test]
|
||||
fn program_info_secondary_audio_fields() {
|
||||
for coding in [c_ac3_plus_secondary(), c_dts_hd_secondary()] {
|
||||
let sci = vec![coding, 0x61, b'd', b'e', b'u'];
|
||||
let pi = build_program_info(&[(0x1A00, sci)]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams.len(), 1, "coding {coding:#04x}");
|
||||
let s = &clip.streams[0];
|
||||
assert_eq!(s.coding_type, coding);
|
||||
// 0x61: high nibble 6, low nibble 1 — distinct values, so a
|
||||
// swapped/ORed/XORed nibble extraction cannot pass.
|
||||
assert_eq!(s.language, "deu", "coding {coding:#04x}");
|
||||
}
|
||||
}
|
||||
|
||||
fn c_ac3_plus_secondary() -> u8 {
|
||||
crate::consts::coding_type::AC3_PLUS_SECONDARY
|
||||
}
|
||||
fn c_dts_hd_secondary() -> u8 {
|
||||
crate::consts::coding_type::DTS_HD_SECONDARY
|
||||
}
|
||||
|
||||
// A 1-byte sci (coding_type only) is the accepted minimum: PID and
|
||||
// coding_type get recorded, all sub-fields needing more bytes stay
|
||||
// empty — a PG stream must NOT read sci[1..4] when only sci[0] exists.
|
||||
#[test]
|
||||
fn program_info_sci_len_one_yields_bare_stream() {
|
||||
let pi = build_program_info(&[(0x1200, vec![0x90u8])]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should not panic");
|
||||
assert_eq!(clip.streams.len(), 1);
|
||||
assert_eq!(clip.streams[0].pid, 0x1200);
|
||||
assert_eq!(clip.streams[0].coding_type, 0x90);
|
||||
assert_eq!(clip.streams[0].language, "");
|
||||
}
|
||||
|
||||
/// Below the 6-byte ProgramInfo header (length(4)+reserved(1)+
|
||||
/// num_programs(1)) there is nothing to read; the length guard must fire
|
||||
/// before `data[5]`.
|
||||
#[test]
|
||||
fn program_info_below_header_size_is_empty() {
|
||||
for len in 0..6usize {
|
||||
assert!(parse_program_info(&vec![0u8; len]).is_empty(), "len={len}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A declared program whose 8-byte header runs past the section end must
|
||||
/// stop before reading num_streams at `data[pos + 6]`.
|
||||
#[test]
|
||||
fn program_info_truncated_program_header_is_empty() {
|
||||
// length(4) + reserved(1) + num_programs=1 (1) + only 4 of the 8
|
||||
// program-header bytes.
|
||||
let mut data = vec![0u8; 6];
|
||||
data[5] = 1;
|
||||
data.extend_from_slice(&[0u8; 4]);
|
||||
assert!(parse_program_info(&data).is_empty());
|
||||
}
|
||||
|
||||
/// A declared stream whose 3-byte header (pid(2)+sci_len(1)) runs past
|
||||
/// the section end must stop before reading the PID.
|
||||
#[test]
|
||||
fn program_info_truncated_stream_header_is_empty() {
|
||||
let mut data = vec![0u8; 6];
|
||||
data[5] = 1; // num_programs
|
||||
data.extend_from_slice(&[0u8; 8]); // program header
|
||||
data[6 + 6] = 1; // num_streams = 1
|
||||
data.extend_from_slice(&[0u8; 2]); // only 2 of the 3 stream bytes
|
||||
assert_eq!(data.len(), 16);
|
||||
assert!(parse_program_info(&data).is_empty());
|
||||
}
|
||||
|
||||
/// TextST (0x92): coding_type + character_code + 3-byte language, so the
|
||||
/// language sits at sci[2..5].
|
||||
#[test]
|
||||
fn program_info_text_subtitle_lang_offset() {
|
||||
let sci = vec![0x92u8, 0x01, b'd', b'e', b'u'];
|
||||
let pi = build_program_info(&[(0x1800, sci)]);
|
||||
let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse");
|
||||
assert_eq!(clip.streams[0].coding_type, 0x92);
|
||||
assert_eq!(clip.streams[0].language, "deu");
|
||||
}
|
||||
|
||||
/// IG (0x91) shares the PG layout: language at sci[1..4].
|
||||
#[test]
|
||||
fn program_info_ig_lang_offset() {
|
||||
let sci = vec![0x91u8, b'e', b's', b'p'];
|
||||
let pi = build_program_info(&[(0x1400, sci)]);
|
||||
let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse");
|
||||
assert_eq!(clip.streams[0].language, "esp");
|
||||
}
|
||||
|
||||
/// Streams from every program are collected, in order.
|
||||
#[test]
|
||||
fn program_info_multiple_programs_all_collected() {
|
||||
let mut body = vec![0u8, 2]; // reserved, num_programs = 2
|
||||
for (pid, lang) in [(0x1200u16, b"fra"), (0x1201u16, b"jpn")] {
|
||||
body.extend_from_slice(&[0u8; 6]); // spn + pmt_pid
|
||||
body.extend_from_slice(&[1, 0]); // num_streams, num_groups
|
||||
body.extend_from_slice(&pid.to_be_bytes());
|
||||
body.push(4);
|
||||
body.push(0x90);
|
||||
body.extend_from_slice(lang);
|
||||
}
|
||||
let mut pi = (body.len() as u32).to_be_bytes().to_vec();
|
||||
pi.extend_from_slice(&body);
|
||||
let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse");
|
||||
assert_eq!(clip.streams.len(), 2);
|
||||
assert_eq!(clip.streams[0].language, "fra");
|
||||
assert_eq!(clip.streams[1].pid, 0x1201);
|
||||
assert_eq!(clip.streams[1].language, "jpn");
|
||||
}
|
||||
|
||||
/// A trailing CPI section after ProgramInfo does not disturb parsing.
|
||||
#[test]
|
||||
fn cpi_section_present_still_parses() {
|
||||
let pi = build_program_info(&[(0x1200, vec![0x90u8, b'f', b'r', b'a'])]);
|
||||
let cpi = [0u8, 0, 0, 2, 0, 0];
|
||||
let data = build_clpi_with_proginfo(777, &pi, Some(&cpi));
|
||||
let clip = parse(&data).expect("parse");
|
||||
assert_eq!(clip.source_packet_count, 777);
|
||||
assert_eq!(clip.streams.len(), 1);
|
||||
let clip = parse(&build_clpi(5, Some(&cpi))).expect("parse");
|
||||
assert_eq!(clip.source_packet_count, 5);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// parse_cpi — low-level fixtures
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
+673
-2
@@ -270,5 +270,676 @@ fn crack_title_key_inner(sector: &[u8]) -> Option<[u8; 5]> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "keyless_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::super::lfsr::scramble_sector;
|
||||
use super::*;
|
||||
|
||||
// Synthesize a scrambled sector for a title key/seed with `plain` at byte 0x80.
|
||||
fn synth_sector(title_key: &[u8; 5], seed: &[u8; 5], plain: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut plaintext = vec![0u8; SECTOR_BYTES];
|
||||
plaintext[0..4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
|
||||
plaintext[4] = 0x44; // '01': a 13818-1 pack
|
||||
plaintext[FLAG_BYTE] = 0x10;
|
||||
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
||||
plaintext[ENCRYPTED_START..ENCRYPTED_START + plain.len()].copy_from_slice(plain);
|
||||
|
||||
let body = plaintext.clone();
|
||||
|
||||
// scramble_sector turns the plaintext body into ciphertext and sets
|
||||
// the scramble flag.
|
||||
scramble_sector(title_key, &mut plaintext);
|
||||
(plaintext, body)
|
||||
}
|
||||
|
||||
// Synthesize a sector whose cleartext ends in a periodic run continuing into the encrypted
|
||||
// region.
|
||||
fn synth_periodic_sector(
|
||||
title_key: &[u8; 5],
|
||||
seed: &[u8; 5],
|
||||
period: usize,
|
||||
) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut plaintext = vec![0u8; SECTOR_BYTES];
|
||||
plaintext[FLAG_BYTE] = 0x10;
|
||||
|
||||
// Periodic run over the cleartext header tail (RUN_START..0x80) into
|
||||
// the encrypted region, mirroring a real VOB. Must not overlap the seed
|
||||
// bytes (0x54..0x59); RUN_START=0x59 gives >=2 cycles of every period.
|
||||
const RUN_START: usize = 0x59;
|
||||
let pat: Vec<u8> = (0..period)
|
||||
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
|
||||
.collect();
|
||||
for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) {
|
||||
*b = pat[i % period];
|
||||
}
|
||||
|
||||
// Seed sits below the run, undisturbed.
|
||||
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
||||
|
||||
let body = plaintext.clone();
|
||||
scramble_sector(title_key, &mut plaintext);
|
||||
(plaintext, body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crack_unscrambled_returns_none() {
|
||||
let sector = vec![0u8; SECTOR_BYTES];
|
||||
assert!(crack_title_key(§or).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crack_too_short_returns_none() {
|
||||
let sector = vec![0u8; 100];
|
||||
assert!(crack_title_key(§or).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_needs_min_plain() {
|
||||
let sector = vec![0u8; SECTOR_BYTES];
|
||||
let short_plain = [0u8; 4];
|
||||
assert!(recover_title_key(§or, &short_plain).is_none());
|
||||
}
|
||||
|
||||
/// The known plaintext used at byte 0x80 for the direct-recovery tests.
|
||||
/// A realistic MPEG-2 PES header start.
|
||||
const PES: [u8; 10] = [0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21];
|
||||
|
||||
// MANDATORY round-trip (Task C.1): recovered key must descramble the body back to
|
||||
// plaintext.
|
||||
#[test]
|
||||
fn recover_round_trips_known_keys() {
|
||||
let cases: &[([u8; 5], [u8; 5])] = &[
|
||||
(
|
||||
[0x42, 0x13, 0x37, 0xBE, 0xEF],
|
||||
[0x11, 0x22, 0x33, 0x44, 0x55],
|
||||
),
|
||||
(
|
||||
[0x01, 0x02, 0x03, 0x04, 0x05],
|
||||
[0xDE, 0xAD, 0xBE, 0xEF, 0x42],
|
||||
),
|
||||
(
|
||||
[0xFE, 0xDC, 0xBA, 0x98, 0x76],
|
||||
[0x00, 0xFF, 0x80, 0x7F, 0x01],
|
||||
),
|
||||
(
|
||||
[0x9A, 0x78, 0x56, 0x34, 0x12],
|
||||
[0xA5, 0x5A, 0x0F, 0xF0, 0xCC],
|
||||
),
|
||||
(
|
||||
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
||||
[0x01, 0x01, 0x01, 0x01, 0x01],
|
||||
),
|
||||
];
|
||||
for (title_key, seed) in cases {
|
||||
let (mut sector, body) = synth_sector(title_key, seed, &PES);
|
||||
let recovered =
|
||||
recover_title_key(§or, &PES).expect("recover_title_key returned None");
|
||||
descramble_sector(&recovered, &mut sector);
|
||||
assert_eq!(
|
||||
§or[ENCRYPTED_START..SECTOR_BYTES],
|
||||
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||
"recovered key did not descramble the full body for \
|
||||
title={title_key:02x?} seed={seed:02x?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// descramble_matches is the ONLY gate between the LFSR search and a key handed to the
|
||||
// caller.
|
||||
#[test]
|
||||
fn descramble_matches_accepts_only_the_key_the_sector_was_scrambled_with() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let (sector, _body) = synth_sector(&title_key, &seed, &PES);
|
||||
|
||||
assert!(
|
||||
descramble_matches(§or, &title_key, &PES),
|
||||
"the key the sector was scrambled with must be accepted"
|
||||
);
|
||||
|
||||
for byte in 0..5usize {
|
||||
for bit in 0..8u32 {
|
||||
let mut wrong = title_key;
|
||||
wrong[byte] ^= 1u8 << bit;
|
||||
assert!(
|
||||
!descramble_matches(§or, &wrong, &PES),
|
||||
"key differing only in byte {byte} bit {bit} must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The gate must verify against a COPY, never mutate the caller's sector.
|
||||
#[test]
|
||||
fn descramble_matches_does_not_disturb_the_caller_s_sector() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let (sector, _body) = synth_sector(&title_key, &seed, &PES);
|
||||
let before = sector.clone();
|
||||
|
||||
assert!(descramble_matches(§or, &title_key, &PES));
|
||||
let mut wrong = title_key;
|
||||
wrong[0] ^= 0x01;
|
||||
assert!(!descramble_matches(§or, &wrong, &PES));
|
||||
|
||||
assert_eq!(
|
||||
sector, before,
|
||||
"verification must leave the sector byte-for-byte unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
/// MANDATORY (Task C.1): the crib-based entry point crack_title_key —
|
||||
/// no plaintext supplied — recovers a round-tripping key when the
|
||||
/// cleartext ends in a periodic run that continues into 0x80.
|
||||
#[test]
|
||||
fn crack_title_key_recovers_via_attack_pattern() {
|
||||
for &period in &[2usize, 3, 5, 8, 16] {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
|
||||
let (sector, body) = synth_periodic_sector(&title_key, &seed, period);
|
||||
|
||||
let cracked = crack_title_key(§or)
|
||||
.unwrap_or_else(|| panic!("crack_title_key returned None for period {period}"));
|
||||
let mut test = sector.clone();
|
||||
descramble_sector(&cracked, &mut test);
|
||||
assert_eq!(
|
||||
&test[ENCRYPTED_START..SECTOR_BYTES],
|
||||
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||
"crack_title_key key did not round-trip the body (period {period})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// recover_title_key_from_plain inverts descramble_sector exactly: scramble
|
||||
/// a known body, hand back the keystream-derived key, and the recovered
|
||||
/// key (XOR-back included) reproduces the plaintext.
|
||||
#[test]
|
||||
fn recovered_key_descrambles_back_to_plaintext() {
|
||||
let cases: &[([u8; 5], [u8; 5])] = &[
|
||||
(
|
||||
[0x42, 0x13, 0x37, 0xBE, 0xEF],
|
||||
[0x11, 0x22, 0x33, 0x44, 0x55],
|
||||
),
|
||||
(
|
||||
[0x9A, 0x78, 0x56, 0x34, 0x12],
|
||||
[0xA5, 0x5A, 0x0F, 0xF0, 0xCC],
|
||||
),
|
||||
(
|
||||
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
||||
[0x01, 0x01, 0x01, 0x01, 0x01],
|
||||
),
|
||||
];
|
||||
for (title_key, seed) in cases {
|
||||
let (mut sector, body) = synth_sector(title_key, seed, &PES);
|
||||
let recovered =
|
||||
recover_title_key(§or, &PES).expect("recover_title_key returned None");
|
||||
descramble_sector(&recovered, &mut sector);
|
||||
assert_eq!(
|
||||
§or[ENCRYPTED_START..SECTOR_BYTES],
|
||||
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||
"descramble with recovered key did not reproduce the body \
|
||||
for title={title_key:02x?} seed={seed:02x?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── early-return guards ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn recover_rejects_sector_one_byte_short() {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES - 1];
|
||||
sector[FLAG_BYTE] = 0x30;
|
||||
assert!(recover_title_key(§or, &PES).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_rejects_unscrambled_sector() {
|
||||
let sector = vec![0x00u8; SECTOR_BYTES];
|
||||
assert!(recover_title_key(§or, &PES).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_high_flag_bits_are_not_scramble() {
|
||||
for &flag in &[0x40u8, 0x80, 0xC0] {
|
||||
let mut sector = vec![0x11u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = flag;
|
||||
assert!(
|
||||
recover_title_key(§or, &PES).is_none(),
|
||||
"flag {flag:#04x} has scramble bits clear; recover must return None"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crack_high_flag_bits_are_not_scramble() {
|
||||
for &flag in &[0x40u8, 0x80, 0xC0] {
|
||||
let mut sector = vec![0x11u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = flag;
|
||||
assert!(
|
||||
crack_title_key(§or).is_none(),
|
||||
"flag {flag:#04x} clear scramble bits -> crack must return None"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crack_rejects_sector_one_byte_short() {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES - 1];
|
||||
if sector.len() > FLAG_BYTE {
|
||||
sector[FLAG_BYTE] = 0x30;
|
||||
}
|
||||
assert!(crack_title_key(§or).is_none());
|
||||
}
|
||||
|
||||
/// crack_title_key must never panic on a fully scrambled sector with
|
||||
/// arbitrary (non-periodic) content — it just returns None.
|
||||
#[test]
|
||||
fn crack_full_path_never_panics() {
|
||||
for seed in 0u32..3 {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = 0x30;
|
||||
let mut x = seed.wrapping_mul(2_654_435_761).wrapping_add(7);
|
||||
for b in sector.iter_mut().skip(0x80) {
|
||||
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||||
*b = (x >> 16) as u8;
|
||||
}
|
||||
for (i, b) in sector[SEED_OFFSET..SEED_OFFSET + 5].iter_mut().enumerate() {
|
||||
*b = (seed.wrapping_add(i as u32) ^ 0xA5) as u8;
|
||||
}
|
||||
let _ = crack_title_key(§or);
|
||||
}
|
||||
}
|
||||
|
||||
// ── entry-point guards on caller- and disc-supplied lengths: a sector buffer that ENDS
|
||||
// inside the encrypted region must be refused, not sliced.
|
||||
#[test]
|
||||
fn recover_rejects_a_sector_that_ends_inside_the_encrypted_region() {
|
||||
for len in [0x81usize, 0x85, 0x89] {
|
||||
let mut sector = vec![0x11u8; len];
|
||||
sector[FLAG_BYTE] = 0x30; // scrambled, so no other guard fires first
|
||||
assert!(
|
||||
recover_title_key(§or, &PES).is_none(),
|
||||
"a {len}-byte buffer cannot supply ten ciphertext bytes at 0x80"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A buffer LONGER than one sector is still one sector: both entry points must recover from
|
||||
// the first SECTOR_BYTES.
|
||||
#[test]
|
||||
fn a_buffer_longer_than_one_sector_still_yields_its_key() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
|
||||
let (sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||
let mut padded = sector.clone();
|
||||
padded.extend_from_slice(&[0xA7u8; 512]);
|
||||
assert_eq!(
|
||||
recover_title_key(&padded, &PES),
|
||||
Some(title_key),
|
||||
"a two-and-a-bit-sector buffer must still recover the first sector's key"
|
||||
);
|
||||
|
||||
let (periodic, _) = synth_periodic_sector(&title_key, &seed, 5);
|
||||
let mut padded = periodic.clone();
|
||||
padded.extend_from_slice(&[0xA7u8; 512]);
|
||||
assert_eq!(
|
||||
crack_title_key(&padded),
|
||||
crack_title_key(&periodic),
|
||||
"padding past the sector must not change the crack result"
|
||||
);
|
||||
assert!(crack_title_key(&padded).is_some());
|
||||
}
|
||||
|
||||
// recover_title_key accepts and uses MORE than ten bytes of known plaintext (ten is a
|
||||
// MINIMUM).
|
||||
#[test]
|
||||
fn recover_accepts_more_than_ten_bytes_of_known_plaintext() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let long_plain: Vec<u8> = (0..64u8)
|
||||
.map(|k| k.wrapping_mul(37).wrapping_add(5))
|
||||
.collect();
|
||||
let (sector, _) = synth_sector(&title_key, &seed, &long_plain);
|
||||
|
||||
assert_eq!(
|
||||
recover_title_key(§or, &long_plain),
|
||||
Some(title_key),
|
||||
"64 bytes of known plaintext must be accepted, not rejected as \
|
||||
'more than ten'"
|
||||
);
|
||||
}
|
||||
|
||||
// The scramble-flag gate on a sector whose BODY really is ciphertext and whose key IS
|
||||
// recoverable.
|
||||
#[test]
|
||||
fn a_recoverable_sector_with_the_scramble_bits_cleared_is_still_refused() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
|
||||
let (mut sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||
assert_eq!(
|
||||
recover_title_key(§or, &PES),
|
||||
Some(title_key),
|
||||
"fixture check: with the flag set this sector's key IS recoverable"
|
||||
);
|
||||
sector[FLAG_BYTE] = 0x00;
|
||||
assert_eq!(
|
||||
recover_title_key(§or, &PES),
|
||||
None,
|
||||
"scramble bits clear → no title key, even though one could be found"
|
||||
);
|
||||
|
||||
let (mut periodic, _) = synth_periodic_sector(&title_key, &seed, 5);
|
||||
assert!(
|
||||
crack_title_key(&periodic).is_some(),
|
||||
"fixture check: with the flag set this sector cracks"
|
||||
);
|
||||
assert!(
|
||||
attack_crib(&periodic).is_some(),
|
||||
"fixture check: with the flag set this sector has a usable crib"
|
||||
);
|
||||
periodic[FLAG_BYTE] = 0x00;
|
||||
assert_eq!(
|
||||
crack_title_key(&periodic),
|
||||
None,
|
||||
"scramble bits clear → no crack, even though one would succeed"
|
||||
);
|
||||
// `attack_crib` carries its own copy of the same gate and is the one that
|
||||
// actually stops the crack (`crack_title_key`'s is defensive duplication);
|
||||
// it also doubles as the decrypt path's cached-key oracle.
|
||||
assert_eq!(
|
||||
attack_crib(&periodic),
|
||||
None,
|
||||
"an unscrambled sector has no predicted plaintext to offer"
|
||||
);
|
||||
}
|
||||
|
||||
// ── descramble_matches: the verification gate's own mechanics — the gate must verify
|
||||
// against the sector's CIPHERTEXT regardless of the flag byte.
|
||||
#[test]
|
||||
fn descramble_matches_forces_the_scramble_flag_on_its_own_copy() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let (mut sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||
sector[FLAG_BYTE] = 0x00;
|
||||
|
||||
assert!(
|
||||
descramble_matches(§or, &title_key, &PES),
|
||||
"the body is ciphertext and the key is right — the gate must \
|
||||
descramble it even though the flag byte says otherwise"
|
||||
);
|
||||
let mut wrong = title_key;
|
||||
wrong[0] ^= 0x01;
|
||||
assert!(!descramble_matches(§or, &wrong, &PES));
|
||||
}
|
||||
|
||||
// The gate compares the WHOLE supplied plaintext, clamped to the encrypted region.
|
||||
#[test]
|
||||
fn descramble_matches_compares_all_of_the_plaintext_and_no_more_than_the_sector() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let body: Vec<u8> = (0..64u8)
|
||||
.map(|k| k.wrapping_mul(29).wrapping_add(3))
|
||||
.collect();
|
||||
let (sector, _) = synth_sector(&title_key, &seed, &body);
|
||||
|
||||
assert!(descramble_matches(§or, &title_key, &body));
|
||||
|
||||
// A crib agreeing for the first 16 bytes and diverging after must be
|
||||
// rejected: the comparison window is the crib's length, not a fixed
|
||||
// prefix.
|
||||
let mut tail_wrong = body.clone();
|
||||
tail_wrong[40] ^= 0xFF;
|
||||
assert!(
|
||||
!descramble_matches(§or, &title_key, &tail_wrong),
|
||||
"a crib that diverges at byte 40 must not match"
|
||||
);
|
||||
assert_eq!(
|
||||
tail_wrong[..16],
|
||||
body[..16],
|
||||
"fixture check: the first 16 bytes are identical, so only a \
|
||||
comparison that runs past them can tell these apart"
|
||||
);
|
||||
|
||||
// A crib LONGER than the encrypted region: the comparison is clamped to
|
||||
// the sector, not run off the end of it.
|
||||
let plain_len = SECTOR_BYTES - ENCRYPTED_START;
|
||||
let mut over_long = vec![0u8; plain_len + 10];
|
||||
let (full_sector, full_body) = synth_sector(&title_key, &seed, &[0x00u8; 10]);
|
||||
over_long[..plain_len].copy_from_slice(&full_body[ENCRYPTED_START..]);
|
||||
assert!(
|
||||
descramble_matches(&full_sector, &title_key, &over_long),
|
||||
"a crib longer than the encrypted region must be clamped, not \
|
||||
compared past the end of the sector"
|
||||
);
|
||||
}
|
||||
|
||||
// ── attack_crib: known-answer vectors. Sector whose clear header ends in
|
||||
// a period-length run of run_len bytes before 0x80, rest 0xFF.
|
||||
fn sector_with_trailing_run(period: usize, run_len: usize) -> Vec<u8> {
|
||||
assert!(
|
||||
run_len < ENCRYPTED_START,
|
||||
"the run lives in the clear header"
|
||||
);
|
||||
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = 0x10;
|
||||
for b in sector[ENCRYPTED_START..].iter_mut() {
|
||||
*b = 0xFF;
|
||||
}
|
||||
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
|
||||
for x in (ENCRYPTED_START - run_len)..ENCRYPTED_START {
|
||||
sector[x] = pat[x % period];
|
||||
}
|
||||
sector
|
||||
}
|
||||
|
||||
/// The crib the run PREDICTS: the periodic pattern continued past 0x80.
|
||||
fn expected_crib(period: usize) -> [u8; 10] {
|
||||
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
|
||||
let mut out = [0u8; 10];
|
||||
for (i, o) in out.iter_mut().enumerate() {
|
||||
*o = pat[(ENCRYPTED_START + i) % period];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// KNOWN ANSWER: the crib is the period-5 run continued forward, the same
|
||||
// ten bytes for every run length.
|
||||
#[test]
|
||||
fn attack_crib_predicts_the_periodic_run_continuing_past_0x80() {
|
||||
for &run_len in &[11usize, 12, 13, 14, 15, 16, 20, 31] {
|
||||
let sector = sector_with_trailing_run(5, run_len);
|
||||
assert_eq!(
|
||||
attack_crib(§or),
|
||||
Some(expected_crib(5)),
|
||||
"period-5 run of {run_len} bytes must predict the run continuing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The same known answer across several periods, including a period that
|
||||
/// does NOT divide 0x80 (so the crib's phase is non-zero and a body that
|
||||
/// restarted the pattern at index 0 gives a different answer).
|
||||
#[test]
|
||||
fn attack_crib_recovers_the_run_period_and_phase() {
|
||||
// 0x80 % period: 3 for 5, 2 for 6, 2 for 7, 8 for 0x18 — all non-zero,
|
||||
// so the predicted first byte is NOT pat[0] in any of these cases.
|
||||
for &period in &[5usize, 6, 7, 0x18] {
|
||||
let sector = sector_with_trailing_run(period, 3 * period + 1);
|
||||
let crib =
|
||||
attack_crib(§or).unwrap_or_else(|| panic!("no crib for period {period}"));
|
||||
assert_eq!(crib, expected_crib(period), "period {period}");
|
||||
assert_ne!(
|
||||
crib[0], 0xD0,
|
||||
"period {period} does not divide 0x80, so the crib must not \
|
||||
start at pattern index 0"
|
||||
);
|
||||
assert!(
|
||||
crib.iter().all(|&b| b != 0xFF),
|
||||
"period {period}: the crib must never contain a byte read from \
|
||||
the encrypted region"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A run of exactly ONE cycle is not enough to predict forward; attack_crib requires two
|
||||
// full cycles.
|
||||
#[test]
|
||||
fn attack_crib_refuses_a_run_shorter_than_two_cycles() {
|
||||
// period 8, run of 9 bytes: best_plen = 8, 8 / 8 == 1 cycle.
|
||||
assert_eq!(attack_crib(§or_with_trailing_run(8, 9)), None);
|
||||
// period 0x18, run of 0x19 bytes: one cycle.
|
||||
assert_eq!(attack_crib(§or_with_trailing_run(0x18, 0x19)), None);
|
||||
// ...and one more byte of run does not conjure a second cycle either.
|
||||
assert_eq!(attack_crib(§or_with_trailing_run(8, 10)), None);
|
||||
}
|
||||
|
||||
/// A header with no repeating tail at all yields no crib. Asserted on a
|
||||
/// header whose bytes are pairwise distinct right up to 0x80, so no cycle
|
||||
/// length in 2..0x2F can match even one byte.
|
||||
#[test]
|
||||
fn attack_crib_refuses_a_header_with_no_periodic_tail() {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = 0x10;
|
||||
// 0x00..0x80 strictly increasing: sec[a] == sec[b] iff a == b, so the
|
||||
// detector's `sec[0x7f - (j % i)] == sec[0x7f - j]` needs j % i == j,
|
||||
// which the scan's starting `j = i + 1` already excludes.
|
||||
for (x, b) in sector[..ENCRYPTED_START].iter_mut().enumerate() {
|
||||
*b = x as u8;
|
||||
}
|
||||
assert_eq!(attack_crib(§or), None);
|
||||
// And the cracker built on it reports no key rather than guessing.
|
||||
assert_eq!(crack_title_key(§or), None);
|
||||
}
|
||||
|
||||
// attack_crib indexes sector[0x7f - j] unbounded, so its own length guard is the only thing
|
||||
// stopping an out-of-bounds read.
|
||||
#[test]
|
||||
fn attack_crib_refuses_a_buffer_shorter_than_a_sector() {
|
||||
for len in [0x15usize, 0x40, 0x7F, SECTOR_BYTES - 1] {
|
||||
let mut sector = vec![0x11u8; len];
|
||||
sector[FLAG_BYTE] = 0x30; // scrambled, so the flag half cannot fire
|
||||
assert_eq!(
|
||||
attack_crib(§or),
|
||||
None,
|
||||
"a {len}-byte buffer is not a sector"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A header periodic ALL THE WAY to offset 0 must not walk the backward scan off the front
|
||||
// of the sector.
|
||||
#[test]
|
||||
fn attack_crib_survives_a_header_that_is_periodic_to_offset_zero() {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = 0x30;
|
||||
let period = 5usize;
|
||||
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
|
||||
for (x, b) in sector[..ENCRYPTED_START].iter_mut().enumerate() {
|
||||
*b = pat[x % period];
|
||||
}
|
||||
for b in sector[ENCRYPTED_START..].iter_mut() {
|
||||
*b = 0xFF;
|
||||
}
|
||||
// The FLAG byte at 0x14 would interrupt the pattern, but j only needs to
|
||||
// reach 0x7f - 0x14 = 0x6b, well short of the bound. So instead put the
|
||||
// scramble flag bits into a byte value that IS the pattern's.
|
||||
sector[FLAG_BYTE] = pat[FLAG_BYTE % period];
|
||||
assert_ne!(
|
||||
sector[FLAG_BYTE] & 0x30,
|
||||
0,
|
||||
"fixture check: the pattern byte at 0x14 must itself carry \
|
||||
scramble bits, so the header stays unbroken"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
attack_crib(§or),
|
||||
Some(expected_crib(period)),
|
||||
"a fully periodic header must predict its own continuation, and \
|
||||
the backward scan must stop at offset 0"
|
||||
);
|
||||
}
|
||||
|
||||
// The crib is read from the CLEAR header only, never the encrypted region.
|
||||
#[test]
|
||||
fn attack_crib_is_independent_of_the_encrypted_region() {
|
||||
let base = sector_with_trailing_run(5, 11);
|
||||
let crib = attack_crib(&base).expect("crib");
|
||||
for fill in [0x00u8, 0x5A, 0xD1, 0xFF] {
|
||||
let mut s = base.clone();
|
||||
for b in s[ENCRYPTED_START..].iter_mut() {
|
||||
*b = fill;
|
||||
}
|
||||
assert_eq!(
|
||||
attack_crib(&s),
|
||||
Some(crib),
|
||||
"the crib must not depend on the encrypted region (fill {fill:#04x})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── recover_title_key_from_plain: input-length guard is the only thing standing between a
|
||||
// short slice and an index-out-of-bounds PANIC.
|
||||
#[test]
|
||||
fn recover_title_key_from_plain_refuses_fewer_than_ten_bytes_of_either_input() {
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let full = [0xA5u8; 10];
|
||||
for n in 0..10usize {
|
||||
assert_eq!(
|
||||
recover_title_key_from_plain(&full[..n], &full, &seed),
|
||||
None,
|
||||
"{n} ciphertext bytes is fewer than the ten the cipher iterates"
|
||||
);
|
||||
assert_eq!(
|
||||
recover_title_key_from_plain(&full, &full[..n], &seed),
|
||||
None,
|
||||
"{n} plaintext bytes is fewer than the ten the cipher iterates"
|
||||
);
|
||||
}
|
||||
// Exactly ten of each is ACCEPTED — the boundary is `< 10`, not `<= 10`.
|
||||
// What must not happen is an early `None` from the guard; proven via the
|
||||
// round-trip fixture, whose inputs are exactly ten bytes.
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let (sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||
assert_eq!(
|
||||
recover_title_key_from_plain(
|
||||
§or[ENCRYPTED_START..ENCRYPTED_START + 10],
|
||||
&PES,
|
||||
&seed
|
||||
),
|
||||
Some(title_key),
|
||||
"exactly ten bytes of each input must run the search, not trip the guard"
|
||||
);
|
||||
}
|
||||
|
||||
// The seed XOR-back turns the recovered LFSR key into the TITLE key: key ^= sector_seed.
|
||||
#[test]
|
||||
fn recover_title_key_from_plain_xors_the_sector_seed_back_out() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let (sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||
let crypted = §or[ENCRYPTED_START..ENCRYPTED_START + 10];
|
||||
|
||||
// The cipher is seeded from `title_key XOR seed`, so re-running the SAME
|
||||
// ciphertext/plaintext against a seed differing in one byte must return
|
||||
// a title key differing in exactly that byte — the XOR is a bijection.
|
||||
assert_eq!(
|
||||
recover_title_key_from_plain(crypted, &PES, &seed),
|
||||
Some(title_key)
|
||||
);
|
||||
for byte in 0..5usize {
|
||||
for bit in [0u32, 3, 7] {
|
||||
let mut alt_seed = seed;
|
||||
alt_seed[byte] ^= 1u8 << bit;
|
||||
let mut expected = title_key;
|
||||
expected[byte] ^= 1u8 << bit;
|
||||
assert_eq!(
|
||||
recover_title_key_from_plain(crypted, &PES, &alt_seed),
|
||||
Some(expected),
|
||||
"seed byte {byte} bit {bit} must XOR straight through to the \
|
||||
title key"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,660 +0,0 @@
|
||||
use super::super::lfsr::scramble_sector;
|
||||
use super::*;
|
||||
|
||||
// Synthesize a scrambled sector for a title key/seed with `plain` at byte 0x80.
|
||||
fn synth_sector(title_key: &[u8; 5], seed: &[u8; 5], plain: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut plaintext = vec![0u8; SECTOR_BYTES];
|
||||
plaintext[0..4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
|
||||
plaintext[4] = 0x44; // '01': a 13818-1 pack
|
||||
plaintext[FLAG_BYTE] = 0x10;
|
||||
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
||||
plaintext[ENCRYPTED_START..ENCRYPTED_START + plain.len()].copy_from_slice(plain);
|
||||
|
||||
let body = plaintext.clone();
|
||||
|
||||
// scramble_sector turns the plaintext body into ciphertext and sets
|
||||
// the scramble flag.
|
||||
scramble_sector(title_key, &mut plaintext);
|
||||
(plaintext, body)
|
||||
}
|
||||
|
||||
// Synthesize a sector whose cleartext ends in a periodic run continuing into the encrypted
|
||||
// region.
|
||||
fn synth_periodic_sector(title_key: &[u8; 5], seed: &[u8; 5], period: usize) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut plaintext = vec![0u8; SECTOR_BYTES];
|
||||
plaintext[FLAG_BYTE] = 0x10;
|
||||
|
||||
// Periodic run over the cleartext header tail (RUN_START..0x80) into
|
||||
// the encrypted region, mirroring a real VOB. Must not overlap the seed
|
||||
// bytes (0x54..0x59); RUN_START=0x59 gives >=2 cycles of every period.
|
||||
const RUN_START: usize = 0x59;
|
||||
let pat: Vec<u8> = (0..period)
|
||||
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
|
||||
.collect();
|
||||
for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) {
|
||||
*b = pat[i % period];
|
||||
}
|
||||
|
||||
// Seed sits below the run, undisturbed.
|
||||
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
||||
|
||||
let body = plaintext.clone();
|
||||
scramble_sector(title_key, &mut plaintext);
|
||||
(plaintext, body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crack_unscrambled_returns_none() {
|
||||
let sector = vec![0u8; SECTOR_BYTES];
|
||||
assert!(crack_title_key(§or).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crack_too_short_returns_none() {
|
||||
let sector = vec![0u8; 100];
|
||||
assert!(crack_title_key(§or).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_needs_min_plain() {
|
||||
let sector = vec![0u8; SECTOR_BYTES];
|
||||
let short_plain = [0u8; 4];
|
||||
assert!(recover_title_key(§or, &short_plain).is_none());
|
||||
}
|
||||
|
||||
/// The known plaintext used at byte 0x80 for the direct-recovery tests.
|
||||
/// A realistic MPEG-2 PES header start.
|
||||
const PES: [u8; 10] = [0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21];
|
||||
|
||||
// MANDATORY round-trip (Task C.1): recovered key must descramble the body back to
|
||||
// plaintext.
|
||||
#[test]
|
||||
fn recover_round_trips_known_keys() {
|
||||
let cases: &[([u8; 5], [u8; 5])] = &[
|
||||
(
|
||||
[0x42, 0x13, 0x37, 0xBE, 0xEF],
|
||||
[0x11, 0x22, 0x33, 0x44, 0x55],
|
||||
),
|
||||
(
|
||||
[0x01, 0x02, 0x03, 0x04, 0x05],
|
||||
[0xDE, 0xAD, 0xBE, 0xEF, 0x42],
|
||||
),
|
||||
(
|
||||
[0xFE, 0xDC, 0xBA, 0x98, 0x76],
|
||||
[0x00, 0xFF, 0x80, 0x7F, 0x01],
|
||||
),
|
||||
(
|
||||
[0x9A, 0x78, 0x56, 0x34, 0x12],
|
||||
[0xA5, 0x5A, 0x0F, 0xF0, 0xCC],
|
||||
),
|
||||
(
|
||||
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
||||
[0x01, 0x01, 0x01, 0x01, 0x01],
|
||||
),
|
||||
];
|
||||
for (title_key, seed) in cases {
|
||||
let (mut sector, body) = synth_sector(title_key, seed, &PES);
|
||||
let recovered = recover_title_key(§or, &PES).expect("recover_title_key returned None");
|
||||
descramble_sector(&recovered, &mut sector);
|
||||
assert_eq!(
|
||||
§or[ENCRYPTED_START..SECTOR_BYTES],
|
||||
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||
"recovered key did not descramble the full body for \
|
||||
title={title_key:02x?} seed={seed:02x?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// descramble_matches is the ONLY gate between the LFSR search and a key handed to the
|
||||
// caller.
|
||||
#[test]
|
||||
fn descramble_matches_accepts_only_the_key_the_sector_was_scrambled_with() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let (sector, _body) = synth_sector(&title_key, &seed, &PES);
|
||||
|
||||
assert!(
|
||||
descramble_matches(§or, &title_key, &PES),
|
||||
"the key the sector was scrambled with must be accepted"
|
||||
);
|
||||
|
||||
for byte in 0..5usize {
|
||||
for bit in 0..8u32 {
|
||||
let mut wrong = title_key;
|
||||
wrong[byte] ^= 1u8 << bit;
|
||||
assert!(
|
||||
!descramble_matches(§or, &wrong, &PES),
|
||||
"key differing only in byte {byte} bit {bit} must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The gate must verify against a COPY, never mutate the caller's sector.
|
||||
#[test]
|
||||
fn descramble_matches_does_not_disturb_the_caller_s_sector() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let (sector, _body) = synth_sector(&title_key, &seed, &PES);
|
||||
let before = sector.clone();
|
||||
|
||||
assert!(descramble_matches(§or, &title_key, &PES));
|
||||
let mut wrong = title_key;
|
||||
wrong[0] ^= 0x01;
|
||||
assert!(!descramble_matches(§or, &wrong, &PES));
|
||||
|
||||
assert_eq!(
|
||||
sector, before,
|
||||
"verification must leave the sector byte-for-byte unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
/// MANDATORY (Task C.1): the crib-based entry point crack_title_key —
|
||||
/// no plaintext supplied — recovers a round-tripping key when the
|
||||
/// cleartext ends in a periodic run that continues into 0x80.
|
||||
#[test]
|
||||
fn crack_title_key_recovers_via_attack_pattern() {
|
||||
for &period in &[2usize, 3, 5, 8, 16] {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
|
||||
let (sector, body) = synth_periodic_sector(&title_key, &seed, period);
|
||||
|
||||
let cracked = crack_title_key(§or)
|
||||
.unwrap_or_else(|| panic!("crack_title_key returned None for period {period}"));
|
||||
let mut test = sector.clone();
|
||||
descramble_sector(&cracked, &mut test);
|
||||
assert_eq!(
|
||||
&test[ENCRYPTED_START..SECTOR_BYTES],
|
||||
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||
"crack_title_key key did not round-trip the body (period {period})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// recover_title_key_from_plain inverts descramble_sector exactly: scramble
|
||||
/// a known body, hand back the keystream-derived key, and the recovered
|
||||
/// key (XOR-back included) reproduces the plaintext.
|
||||
#[test]
|
||||
fn recovered_key_descrambles_back_to_plaintext() {
|
||||
let cases: &[([u8; 5], [u8; 5])] = &[
|
||||
(
|
||||
[0x42, 0x13, 0x37, 0xBE, 0xEF],
|
||||
[0x11, 0x22, 0x33, 0x44, 0x55],
|
||||
),
|
||||
(
|
||||
[0x9A, 0x78, 0x56, 0x34, 0x12],
|
||||
[0xA5, 0x5A, 0x0F, 0xF0, 0xCC],
|
||||
),
|
||||
(
|
||||
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
||||
[0x01, 0x01, 0x01, 0x01, 0x01],
|
||||
),
|
||||
];
|
||||
for (title_key, seed) in cases {
|
||||
let (mut sector, body) = synth_sector(title_key, seed, &PES);
|
||||
let recovered = recover_title_key(§or, &PES).expect("recover_title_key returned None");
|
||||
descramble_sector(&recovered, &mut sector);
|
||||
assert_eq!(
|
||||
§or[ENCRYPTED_START..SECTOR_BYTES],
|
||||
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||
"descramble with recovered key did not reproduce the body \
|
||||
for title={title_key:02x?} seed={seed:02x?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── early-return guards ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn recover_rejects_sector_one_byte_short() {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES - 1];
|
||||
sector[FLAG_BYTE] = 0x30;
|
||||
assert!(recover_title_key(§or, &PES).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_rejects_unscrambled_sector() {
|
||||
let sector = vec![0x00u8; SECTOR_BYTES];
|
||||
assert!(recover_title_key(§or, &PES).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_high_flag_bits_are_not_scramble() {
|
||||
for &flag in &[0x40u8, 0x80, 0xC0] {
|
||||
let mut sector = vec![0x11u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = flag;
|
||||
assert!(
|
||||
recover_title_key(§or, &PES).is_none(),
|
||||
"flag {flag:#04x} has scramble bits clear; recover must return None"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crack_high_flag_bits_are_not_scramble() {
|
||||
for &flag in &[0x40u8, 0x80, 0xC0] {
|
||||
let mut sector = vec![0x11u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = flag;
|
||||
assert!(
|
||||
crack_title_key(§or).is_none(),
|
||||
"flag {flag:#04x} clear scramble bits -> crack must return None"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crack_rejects_sector_one_byte_short() {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES - 1];
|
||||
if sector.len() > FLAG_BYTE {
|
||||
sector[FLAG_BYTE] = 0x30;
|
||||
}
|
||||
assert!(crack_title_key(§or).is_none());
|
||||
}
|
||||
|
||||
/// crack_title_key must never panic on a fully scrambled sector with
|
||||
/// arbitrary (non-periodic) content — it just returns None.
|
||||
#[test]
|
||||
fn crack_full_path_never_panics() {
|
||||
for seed in 0u32..3 {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = 0x30;
|
||||
let mut x = seed.wrapping_mul(2_654_435_761).wrapping_add(7);
|
||||
for b in sector.iter_mut().skip(0x80) {
|
||||
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||||
*b = (x >> 16) as u8;
|
||||
}
|
||||
for (i, b) in sector[SEED_OFFSET..SEED_OFFSET + 5].iter_mut().enumerate() {
|
||||
*b = (seed.wrapping_add(i as u32) ^ 0xA5) as u8;
|
||||
}
|
||||
let _ = crack_title_key(§or);
|
||||
}
|
||||
}
|
||||
|
||||
// ── entry-point guards on caller- and disc-supplied lengths: a sector buffer that ENDS
|
||||
// inside the encrypted region must be refused, not sliced.
|
||||
#[test]
|
||||
fn recover_rejects_a_sector_that_ends_inside_the_encrypted_region() {
|
||||
for len in [0x81usize, 0x85, 0x89] {
|
||||
let mut sector = vec![0x11u8; len];
|
||||
sector[FLAG_BYTE] = 0x30; // scrambled, so no other guard fires first
|
||||
assert!(
|
||||
recover_title_key(§or, &PES).is_none(),
|
||||
"a {len}-byte buffer cannot supply ten ciphertext bytes at 0x80"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A buffer LONGER than one sector is still one sector: both entry points must recover from
|
||||
// the first SECTOR_BYTES.
|
||||
#[test]
|
||||
fn a_buffer_longer_than_one_sector_still_yields_its_key() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
|
||||
let (sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||
let mut padded = sector.clone();
|
||||
padded.extend_from_slice(&[0xA7u8; 512]);
|
||||
assert_eq!(
|
||||
recover_title_key(&padded, &PES),
|
||||
Some(title_key),
|
||||
"a two-and-a-bit-sector buffer must still recover the first sector's key"
|
||||
);
|
||||
|
||||
let (periodic, _) = synth_periodic_sector(&title_key, &seed, 5);
|
||||
let mut padded = periodic.clone();
|
||||
padded.extend_from_slice(&[0xA7u8; 512]);
|
||||
assert_eq!(
|
||||
crack_title_key(&padded),
|
||||
crack_title_key(&periodic),
|
||||
"padding past the sector must not change the crack result"
|
||||
);
|
||||
assert!(crack_title_key(&padded).is_some());
|
||||
}
|
||||
|
||||
// recover_title_key accepts and uses MORE than ten bytes of known plaintext (ten is a
|
||||
// MINIMUM).
|
||||
#[test]
|
||||
fn recover_accepts_more_than_ten_bytes_of_known_plaintext() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let long_plain: Vec<u8> = (0..64u8)
|
||||
.map(|k| k.wrapping_mul(37).wrapping_add(5))
|
||||
.collect();
|
||||
let (sector, _) = synth_sector(&title_key, &seed, &long_plain);
|
||||
|
||||
assert_eq!(
|
||||
recover_title_key(§or, &long_plain),
|
||||
Some(title_key),
|
||||
"64 bytes of known plaintext must be accepted, not rejected as \
|
||||
'more than ten'"
|
||||
);
|
||||
}
|
||||
|
||||
// The scramble-flag gate on a sector whose BODY really is ciphertext and whose key IS
|
||||
// recoverable.
|
||||
#[test]
|
||||
fn a_recoverable_sector_with_the_scramble_bits_cleared_is_still_refused() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
|
||||
let (mut sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||
assert_eq!(
|
||||
recover_title_key(§or, &PES),
|
||||
Some(title_key),
|
||||
"fixture check: with the flag set this sector's key IS recoverable"
|
||||
);
|
||||
sector[FLAG_BYTE] = 0x00;
|
||||
assert_eq!(
|
||||
recover_title_key(§or, &PES),
|
||||
None,
|
||||
"scramble bits clear → no title key, even though one could be found"
|
||||
);
|
||||
|
||||
let (mut periodic, _) = synth_periodic_sector(&title_key, &seed, 5);
|
||||
assert!(
|
||||
crack_title_key(&periodic).is_some(),
|
||||
"fixture check: with the flag set this sector cracks"
|
||||
);
|
||||
assert!(
|
||||
attack_crib(&periodic).is_some(),
|
||||
"fixture check: with the flag set this sector has a usable crib"
|
||||
);
|
||||
periodic[FLAG_BYTE] = 0x00;
|
||||
assert_eq!(
|
||||
crack_title_key(&periodic),
|
||||
None,
|
||||
"scramble bits clear → no crack, even though one would succeed"
|
||||
);
|
||||
// `attack_crib` carries its own copy of the same gate and is the one that
|
||||
// actually stops the crack (`crack_title_key`'s is defensive duplication);
|
||||
// it also doubles as the decrypt path's cached-key oracle.
|
||||
assert_eq!(
|
||||
attack_crib(&periodic),
|
||||
None,
|
||||
"an unscrambled sector has no predicted plaintext to offer"
|
||||
);
|
||||
}
|
||||
|
||||
// ── descramble_matches: the verification gate's own mechanics — the gate must verify
|
||||
// against the sector's CIPHERTEXT regardless of the flag byte.
|
||||
#[test]
|
||||
fn descramble_matches_forces_the_scramble_flag_on_its_own_copy() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let (mut sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||
sector[FLAG_BYTE] = 0x00;
|
||||
|
||||
assert!(
|
||||
descramble_matches(§or, &title_key, &PES),
|
||||
"the body is ciphertext and the key is right — the gate must \
|
||||
descramble it even though the flag byte says otherwise"
|
||||
);
|
||||
let mut wrong = title_key;
|
||||
wrong[0] ^= 0x01;
|
||||
assert!(!descramble_matches(§or, &wrong, &PES));
|
||||
}
|
||||
|
||||
// The gate compares the WHOLE supplied plaintext, clamped to the encrypted region.
|
||||
#[test]
|
||||
fn descramble_matches_compares_all_of_the_plaintext_and_no_more_than_the_sector() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let body: Vec<u8> = (0..64u8)
|
||||
.map(|k| k.wrapping_mul(29).wrapping_add(3))
|
||||
.collect();
|
||||
let (sector, _) = synth_sector(&title_key, &seed, &body);
|
||||
|
||||
assert!(descramble_matches(§or, &title_key, &body));
|
||||
|
||||
// A crib agreeing for the first 16 bytes and diverging after must be
|
||||
// rejected: the comparison window is the crib's length, not a fixed
|
||||
// prefix.
|
||||
let mut tail_wrong = body.clone();
|
||||
tail_wrong[40] ^= 0xFF;
|
||||
assert!(
|
||||
!descramble_matches(§or, &title_key, &tail_wrong),
|
||||
"a crib that diverges at byte 40 must not match"
|
||||
);
|
||||
assert_eq!(
|
||||
tail_wrong[..16],
|
||||
body[..16],
|
||||
"fixture check: the first 16 bytes are identical, so only a \
|
||||
comparison that runs past them can tell these apart"
|
||||
);
|
||||
|
||||
// A crib LONGER than the encrypted region: the comparison is clamped to
|
||||
// the sector, not run off the end of it.
|
||||
let plain_len = SECTOR_BYTES - ENCRYPTED_START;
|
||||
let mut over_long = vec![0u8; plain_len + 10];
|
||||
let (full_sector, full_body) = synth_sector(&title_key, &seed, &[0x00u8; 10]);
|
||||
over_long[..plain_len].copy_from_slice(&full_body[ENCRYPTED_START..]);
|
||||
assert!(
|
||||
descramble_matches(&full_sector, &title_key, &over_long),
|
||||
"a crib longer than the encrypted region must be clamped, not \
|
||||
compared past the end of the sector"
|
||||
);
|
||||
}
|
||||
|
||||
// ── attack_crib: known-answer vectors. Sector whose clear header ends in
|
||||
// a period-length run of run_len bytes before 0x80, rest 0xFF.
|
||||
fn sector_with_trailing_run(period: usize, run_len: usize) -> Vec<u8> {
|
||||
assert!(
|
||||
run_len < ENCRYPTED_START,
|
||||
"the run lives in the clear header"
|
||||
);
|
||||
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = 0x10;
|
||||
for b in sector[ENCRYPTED_START..].iter_mut() {
|
||||
*b = 0xFF;
|
||||
}
|
||||
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
|
||||
for x in (ENCRYPTED_START - run_len)..ENCRYPTED_START {
|
||||
sector[x] = pat[x % period];
|
||||
}
|
||||
sector
|
||||
}
|
||||
|
||||
/// The crib the run PREDICTS: the periodic pattern continued past 0x80.
|
||||
fn expected_crib(period: usize) -> [u8; 10] {
|
||||
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
|
||||
let mut out = [0u8; 10];
|
||||
for (i, o) in out.iter_mut().enumerate() {
|
||||
*o = pat[(ENCRYPTED_START + i) % period];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// KNOWN ANSWER: the crib is the period-5 run continued forward, the same
|
||||
// ten bytes for every run length.
|
||||
#[test]
|
||||
fn attack_crib_predicts_the_periodic_run_continuing_past_0x80() {
|
||||
for &run_len in &[11usize, 12, 13, 14, 15, 16, 20, 31] {
|
||||
let sector = sector_with_trailing_run(5, run_len);
|
||||
assert_eq!(
|
||||
attack_crib(§or),
|
||||
Some(expected_crib(5)),
|
||||
"period-5 run of {run_len} bytes must predict the run continuing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The same known answer across several periods, including a period that
|
||||
/// does NOT divide 0x80 (so the crib's phase is non-zero and a body that
|
||||
/// restarted the pattern at index 0 gives a different answer).
|
||||
#[test]
|
||||
fn attack_crib_recovers_the_run_period_and_phase() {
|
||||
// 0x80 % period: 3 for 5, 2 for 6, 2 for 7, 8 for 0x18 — all non-zero,
|
||||
// so the predicted first byte is NOT pat[0] in any of these cases.
|
||||
for &period in &[5usize, 6, 7, 0x18] {
|
||||
let sector = sector_with_trailing_run(period, 3 * period + 1);
|
||||
let crib = attack_crib(§or).unwrap_or_else(|| panic!("no crib for period {period}"));
|
||||
assert_eq!(crib, expected_crib(period), "period {period}");
|
||||
assert_ne!(
|
||||
crib[0], 0xD0,
|
||||
"period {period} does not divide 0x80, so the crib must not \
|
||||
start at pattern index 0"
|
||||
);
|
||||
assert!(
|
||||
crib.iter().all(|&b| b != 0xFF),
|
||||
"period {period}: the crib must never contain a byte read from \
|
||||
the encrypted region"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A run of exactly ONE cycle is not enough to predict forward; attack_crib requires two
|
||||
// full cycles.
|
||||
#[test]
|
||||
fn attack_crib_refuses_a_run_shorter_than_two_cycles() {
|
||||
// period 8, run of 9 bytes: best_plen = 8, 8 / 8 == 1 cycle.
|
||||
assert_eq!(attack_crib(§or_with_trailing_run(8, 9)), None);
|
||||
// period 0x18, run of 0x19 bytes: one cycle.
|
||||
assert_eq!(attack_crib(§or_with_trailing_run(0x18, 0x19)), None);
|
||||
// ...and one more byte of run does not conjure a second cycle either.
|
||||
assert_eq!(attack_crib(§or_with_trailing_run(8, 10)), None);
|
||||
}
|
||||
|
||||
/// A header with no repeating tail at all yields no crib. Asserted on a
|
||||
/// header whose bytes are pairwise distinct right up to 0x80, so no cycle
|
||||
/// length in 2..0x2F can match even one byte.
|
||||
#[test]
|
||||
fn attack_crib_refuses_a_header_with_no_periodic_tail() {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = 0x10;
|
||||
// 0x00..0x80 strictly increasing: sec[a] == sec[b] iff a == b, so the
|
||||
// detector's `sec[0x7f - (j % i)] == sec[0x7f - j]` needs j % i == j,
|
||||
// which the scan's starting `j = i + 1` already excludes.
|
||||
for (x, b) in sector[..ENCRYPTED_START].iter_mut().enumerate() {
|
||||
*b = x as u8;
|
||||
}
|
||||
assert_eq!(attack_crib(§or), None);
|
||||
// And the cracker built on it reports no key rather than guessing.
|
||||
assert_eq!(crack_title_key(§or), None);
|
||||
}
|
||||
|
||||
// attack_crib indexes sector[0x7f - j] unbounded, so its own length guard is the only thing
|
||||
// stopping an out-of-bounds read.
|
||||
#[test]
|
||||
fn attack_crib_refuses_a_buffer_shorter_than_a_sector() {
|
||||
for len in [0x15usize, 0x40, 0x7F, SECTOR_BYTES - 1] {
|
||||
let mut sector = vec![0x11u8; len];
|
||||
sector[FLAG_BYTE] = 0x30; // scrambled, so the flag half cannot fire
|
||||
assert_eq!(
|
||||
attack_crib(§or),
|
||||
None,
|
||||
"a {len}-byte buffer is not a sector"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A header periodic ALL THE WAY to offset 0 must not walk the backward scan off the front
|
||||
// of the sector.
|
||||
#[test]
|
||||
fn attack_crib_survives_a_header_that_is_periodic_to_offset_zero() {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = 0x30;
|
||||
let period = 5usize;
|
||||
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
|
||||
for (x, b) in sector[..ENCRYPTED_START].iter_mut().enumerate() {
|
||||
*b = pat[x % period];
|
||||
}
|
||||
for b in sector[ENCRYPTED_START..].iter_mut() {
|
||||
*b = 0xFF;
|
||||
}
|
||||
// The FLAG byte at 0x14 would interrupt the pattern, but j only needs to
|
||||
// reach 0x7f - 0x14 = 0x6b, well short of the bound. So instead put the
|
||||
// scramble flag bits into a byte value that IS the pattern's.
|
||||
sector[FLAG_BYTE] = pat[FLAG_BYTE % period];
|
||||
assert_ne!(
|
||||
sector[FLAG_BYTE] & 0x30,
|
||||
0,
|
||||
"fixture check: the pattern byte at 0x14 must itself carry \
|
||||
scramble bits, so the header stays unbroken"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
attack_crib(§or),
|
||||
Some(expected_crib(period)),
|
||||
"a fully periodic header must predict its own continuation, and \
|
||||
the backward scan must stop at offset 0"
|
||||
);
|
||||
}
|
||||
|
||||
// The crib is read from the CLEAR header only, never the encrypted region.
|
||||
#[test]
|
||||
fn attack_crib_is_independent_of_the_encrypted_region() {
|
||||
let base = sector_with_trailing_run(5, 11);
|
||||
let crib = attack_crib(&base).expect("crib");
|
||||
for fill in [0x00u8, 0x5A, 0xD1, 0xFF] {
|
||||
let mut s = base.clone();
|
||||
for b in s[ENCRYPTED_START..].iter_mut() {
|
||||
*b = fill;
|
||||
}
|
||||
assert_eq!(
|
||||
attack_crib(&s),
|
||||
Some(crib),
|
||||
"the crib must not depend on the encrypted region (fill {fill:#04x})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── recover_title_key_from_plain: input-length guard is the only thing standing between a
|
||||
// short slice and an index-out-of-bounds PANIC.
|
||||
#[test]
|
||||
fn recover_title_key_from_plain_refuses_fewer_than_ten_bytes_of_either_input() {
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let full = [0xA5u8; 10];
|
||||
for n in 0..10usize {
|
||||
assert_eq!(
|
||||
recover_title_key_from_plain(&full[..n], &full, &seed),
|
||||
None,
|
||||
"{n} ciphertext bytes is fewer than the ten the cipher iterates"
|
||||
);
|
||||
assert_eq!(
|
||||
recover_title_key_from_plain(&full, &full[..n], &seed),
|
||||
None,
|
||||
"{n} plaintext bytes is fewer than the ten the cipher iterates"
|
||||
);
|
||||
}
|
||||
// Exactly ten of each is ACCEPTED — the boundary is `< 10`, not `<= 10`.
|
||||
// What must not happen is an early `None` from the guard; proven via the
|
||||
// round-trip fixture, whose inputs are exactly ten bytes.
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let (sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||
assert_eq!(
|
||||
recover_title_key_from_plain(§or[ENCRYPTED_START..ENCRYPTED_START + 10], &PES, &seed),
|
||||
Some(title_key),
|
||||
"exactly ten bytes of each input must run the search, not trip the guard"
|
||||
);
|
||||
}
|
||||
|
||||
// The seed XOR-back turns the recovered LFSR key into the TITLE key: key ^= sector_seed.
|
||||
#[test]
|
||||
fn recover_title_key_from_plain_xors_the_sector_seed_back_out() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let (sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||
let crypted = §or[ENCRYPTED_START..ENCRYPTED_START + 10];
|
||||
|
||||
// The cipher is seeded from `title_key XOR seed`, so re-running the SAME
|
||||
// ciphertext/plaintext against a seed differing in one byte must return
|
||||
// a title key differing in exactly that byte — the XOR is a bijection.
|
||||
assert_eq!(
|
||||
recover_title_key_from_plain(crypted, &PES, &seed),
|
||||
Some(title_key)
|
||||
);
|
||||
for byte in 0..5usize {
|
||||
for bit in [0u32, 3, 7] {
|
||||
let mut alt_seed = seed;
|
||||
alt_seed[byte] ^= 1u8 << bit;
|
||||
let mut expected = title_key;
|
||||
expected[byte] ^= 1u8 << bit;
|
||||
assert_eq!(
|
||||
recover_title_key_from_plain(crypted, &PES, &alt_seed),
|
||||
Some(expected),
|
||||
"seed byte {byte} bit {bit} must XOR straight through to the \
|
||||
title key"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+310
-2
@@ -119,5 +119,313 @@ static TAB1_INV: std::sync::LazyLock<[u8; 256]> = std::sync::LazyLock::new(|| {
|
||||
});
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lfsr_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn descramble_skips_unscrambled() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let mut sector = vec![0xAA; 2048];
|
||||
sector[0x14] = 0x00;
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_eq!(sector, original);
|
||||
}
|
||||
|
||||
// Regression vector pinning this implementation's own output (NOT an independent CSS
|
||||
// known-answer). key = 42 13 37 BE EF, seed = DE AD BE EF 42, body = 0xAA.
|
||||
#[test]
|
||||
fn descramble_output_is_pinned_regression_vector() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let mut sector = vec![0xAAu8; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_eq!(
|
||||
§or[0x80..0x90],
|
||||
&[
|
||||
0x81, 0x92, 0x24, 0xA2, 0x46, 0x70, 0x3C, 0x64, 0xA6, 0x91, 0x84, 0xF5, 0x1F, 0x98,
|
||||
0xA0, 0x31
|
||||
],
|
||||
"descramble body head changed from the pinned output"
|
||||
);
|
||||
assert_eq!(
|
||||
§or[0x7F8..0x800],
|
||||
&[0x46, 0x94, 0x80, 0x0E, 0x67, 0x36, 0x65, 0xBC],
|
||||
"descramble body tail changed from the pinned output"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descramble_modifies_scrambled() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let mut sector = vec![0xAA; 2048];
|
||||
sector[0x14] = 0x30; // scramble flag set
|
||||
// Set a sector seed
|
||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
// Header (0..128) unchanged except byte 0x14 (flag cleared)
|
||||
for i in 0..128 {
|
||||
if i == 0x14 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(sector[i], original[i], "header byte {} changed", i);
|
||||
}
|
||||
// Encrypted region should be different
|
||||
assert_ne!(§or[128..256], &original[128..256]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descramble_clears_flags() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let mut sector = vec![0x00; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
sector[0x54..0x59].copy_from_slice(&[0x00; 5]);
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_eq!(sector[0x14] & 0x30, 0x00);
|
||||
}
|
||||
|
||||
// Not a plain XOR involution (TAB1 applies to ciphertext); scramble_sector is the true
|
||||
// inverse.
|
||||
#[test]
|
||||
fn css_descramble_inverts_scramble_over_body() {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
|
||||
let mut sector = vec![0xAAu8; 2048];
|
||||
sector[0x14] = 0x30; // scramble flag
|
||||
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
||||
|
||||
let original = sector.clone();
|
||||
|
||||
// Scramble the plaintext body into ciphertext.
|
||||
scramble_sector(&title_key, &mut sector);
|
||||
// Header (0..128) unchanged except the flag byte (set by scramble).
|
||||
for i in 0..128 {
|
||||
if i == 0x14 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(sector[i], original[i], "header byte {} changed", i);
|
||||
}
|
||||
// Encrypted region modified
|
||||
assert_ne!(§or[128..256], &original[128..256]);
|
||||
|
||||
// Descramble restores the plaintext body byte-for-byte.
|
||||
descramble_sector(&title_key, &mut sector);
|
||||
assert_eq!(sector[0x14] & 0x30, 0x00, "flag cleared after descramble");
|
||||
assert_eq!(
|
||||
§or[128..2048],
|
||||
&original[128..2048],
|
||||
"descramble(scramble(body)) did not restore the body"
|
||||
);
|
||||
}
|
||||
|
||||
// TAB1 is a substitution table; verify it is a permutation of 0..255.
|
||||
#[test]
|
||||
fn css_tab1_is_permutation() {
|
||||
let mut seen = [false; 256];
|
||||
for tab1_val in &TAB1 {
|
||||
let v = *tab1_val as usize;
|
||||
assert!(!seen[v], "TAB1 maps two inputs to {:#04x}", v);
|
||||
seen[v] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// css_tab4_is_bit_reversal
|
||||
///
|
||||
/// TAB4 reverses the bits of each byte: TAB4[0x01] = 0x80, TAB4[0x80] = 0x01, etc.
|
||||
#[test]
|
||||
fn css_tab4_is_bit_reversal() {
|
||||
for i in 0u16..256 {
|
||||
let expected = (0..8).fold(0u8, |acc, bit| acc | (((i as u8 >> bit) & 1) << (7 - bit)));
|
||||
assert_eq!(
|
||||
TAB4[i as usize], expected,
|
||||
"TAB4[{:#04x}] = {:#04x}, expected {:#04x} (bit reversal)",
|
||||
i, TAB4[i as usize], expected
|
||||
);
|
||||
}
|
||||
// Also verify TAB4 is an involution: TAB4[TAB4[x]] == x
|
||||
for i in 0..256 {
|
||||
assert_eq!(
|
||||
TAB4[TAB4[i] as usize], i as u8,
|
||||
"TAB4 is not an involution at {:#04x}",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scramble-flag detection (byte 0x14, bits 4-5): only bits 4-5 are the flag (mask 0x30);
|
||||
// 0x40/0x80 must read UNSCRAMBLED.
|
||||
#[test]
|
||||
fn descramble_treats_high_bits_of_0x14_as_clear() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
for &flag in &[0x40u8, 0x80, 0xC0, 0x0F, 0x4F, 0x8F] {
|
||||
let mut sector = vec![0xAA; 2048];
|
||||
sector[0x14] = flag;
|
||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_eq!(
|
||||
sector, original,
|
||||
"byte 0x14 = {flag:#04x} has flag bits 4-5 clear; sector must be untouched"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Each scramble bit (4 and 5) independently marks a sector encrypted: 0x10 and 0x20 must
|
||||
// both trigger descrambling.
|
||||
#[test]
|
||||
fn descramble_triggers_on_either_flag_bit() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
for &flag in &[0x10u8, 0x20, 0x30] {
|
||||
let mut sector = vec![0xAA; 2048];
|
||||
sector[0x14] = flag;
|
||||
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_ne!(
|
||||
§or[128..256],
|
||||
&original[128..256],
|
||||
"flag {flag:#04x} (bits 4-5 nonzero) must descramble the body"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Only the two scramble bits are cleared (`& 0xCF`); bits 6-7 of byte 0x14 survive: 0xF0
|
||||
// becomes 0xC0, NOT 0x00.
|
||||
#[test]
|
||||
fn descramble_clear_preserves_high_bits_of_0x14() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let mut sector = vec![0x00; 2048];
|
||||
sector[0x14] = 0xF0; // bits 4-7 set; bits 4-5 are the flag
|
||||
sector[0x54..0x59].copy_from_slice(&[0x00; 5]);
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_eq!(
|
||||
sector[0x14], 0xC0,
|
||||
"scramble bits cleared, bits 6-7 preserved (0xF0 & 0xCF)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── header / body boundary (encrypted region is 0x80..0x800) ───────────
|
||||
|
||||
// Encrypted region is exactly 0x80..0x800; header bytes 0x00..0x80 (incl. the seed at
|
||||
// 0x54..0x59) must stay untouched.
|
||||
#[test]
|
||||
fn descramble_leaves_header_and_seed_intact() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let mut sector = vec![0x5Au8; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||
sector[0x54..0x59].copy_from_slice(&seed);
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
for i in 0..0x80usize {
|
||||
if i == 0x14 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(
|
||||
sector[i], original[i],
|
||||
"header byte {i:#04x} must be untouched"
|
||||
);
|
||||
}
|
||||
assert_eq!(§or[0x54..0x59], &seed, "sector seed must survive");
|
||||
}
|
||||
|
||||
// The descrambler must touch the WHOLE body, including the final byte (index 2047),
|
||||
// guarding the `.take(2048)` bound.
|
||||
#[test]
|
||||
fn descramble_covers_final_body_byte() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let mut sector = vec![0x00u8; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
descramble_sector(&key, &mut sector);
|
||||
// Body was all zero; any nonzero in [0x80,0x800) is keystream. Confirm
|
||||
// the keystream reaches the final byte.
|
||||
assert_ne!(
|
||||
§or[2040..2048],
|
||||
&[0u8; 8][..],
|
||||
"the tail of the body must be descrambled (loop must reach index 2047)"
|
||||
);
|
||||
}
|
||||
|
||||
// The length guard is a FLOOR, not a ceiling: descramble_sector processes the first sector
|
||||
// of any over-long buffer.
|
||||
#[test]
|
||||
fn descramble_processes_the_first_sector_of_an_over_long_buffer() {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||
|
||||
// Two sectors' worth of buffer; only the first is a sector.
|
||||
let mut buf = vec![0xAAu8; 4096];
|
||||
buf[0x14] = 0x30;
|
||||
buf[0x54..0x59].copy_from_slice(&seed);
|
||||
let original = buf.clone();
|
||||
|
||||
descramble_sector(&title_key, &mut buf);
|
||||
|
||||
assert_ne!(
|
||||
&buf[0x80..0x800],
|
||||
&original[0x80..0x800],
|
||||
"the first sector's body must be descrambled"
|
||||
);
|
||||
assert_eq!(buf[0x14] & 0x30, 0x00, "and its scramble flag cleared");
|
||||
assert_eq!(
|
||||
&buf[2048..4096],
|
||||
&original[2048..4096],
|
||||
"bytes past the first sector must be left untouched"
|
||||
);
|
||||
|
||||
// The result must equal what a caller gets by passing exactly one
|
||||
// sector — the same transform, not a length-dependent one.
|
||||
let mut one = original[..2048].to_vec();
|
||||
descramble_sector(&title_key, &mut one);
|
||||
assert_eq!(
|
||||
&buf[..2048],
|
||||
&one[..],
|
||||
"the first sector must descramble identically either way"
|
||||
);
|
||||
}
|
||||
|
||||
// Descramble is keyed by `title_key XOR seed`: two different title keys must produce two
|
||||
// different bodies.
|
||||
#[test]
|
||||
fn descramble_output_depends_on_title_key() {
|
||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||
let make = |k: &[u8; 5]| {
|
||||
let mut s = vec![0x00u8; 2048];
|
||||
s[0x14] = 0x30;
|
||||
s[0x54..0x59].copy_from_slice(&seed);
|
||||
descramble_sector(k, &mut s);
|
||||
s
|
||||
};
|
||||
let a = make(&[0x01, 0x02, 0x03, 0x04, 0x05]);
|
||||
let b = make(&[0x01, 0x02, 0x03, 0x04, 0x06]); // differs in last byte
|
||||
assert_ne!(
|
||||
&a[128..2048],
|
||||
&b[128..2048],
|
||||
"different title keys must descramble differently"
|
||||
);
|
||||
}
|
||||
|
||||
// Descramble is keyed by the sector seed too: same title key, different seed -> different
|
||||
// body.
|
||||
#[test]
|
||||
fn descramble_output_depends_on_seed() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let make = |seed: [u8; 5]| {
|
||||
let mut s = vec![0x00u8; 2048];
|
||||
s[0x14] = 0x30;
|
||||
s[0x54..0x59].copy_from_slice(&seed);
|
||||
descramble_sector(&key, &mut s);
|
||||
s
|
||||
};
|
||||
let a = make([0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
let b = make([0x11, 0x22, 0x33, 0x44, 0x56]);
|
||||
assert_ne!(
|
||||
&a[128..2048],
|
||||
&b[128..2048],
|
||||
"different seeds must descramble differently"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn descramble_skips_unscrambled() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let mut sector = vec![0xAA; 2048];
|
||||
sector[0x14] = 0x00;
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_eq!(sector, original);
|
||||
}
|
||||
|
||||
// Regression vector pinning this implementation's own output (NOT an independent CSS
|
||||
// known-answer). key = 42 13 37 BE EF, seed = DE AD BE EF 42, body = 0xAA.
|
||||
#[test]
|
||||
fn descramble_output_is_pinned_regression_vector() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let mut sector = vec![0xAAu8; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_eq!(
|
||||
§or[0x80..0x90],
|
||||
&[
|
||||
0x81, 0x92, 0x24, 0xA2, 0x46, 0x70, 0x3C, 0x64, 0xA6, 0x91, 0x84, 0xF5, 0x1F, 0x98,
|
||||
0xA0, 0x31
|
||||
],
|
||||
"descramble body head changed from the pinned output"
|
||||
);
|
||||
assert_eq!(
|
||||
§or[0x7F8..0x800],
|
||||
&[0x46, 0x94, 0x80, 0x0E, 0x67, 0x36, 0x65, 0xBC],
|
||||
"descramble body tail changed from the pinned output"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descramble_modifies_scrambled() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let mut sector = vec![0xAA; 2048];
|
||||
sector[0x14] = 0x30; // scramble flag set
|
||||
// Set a sector seed
|
||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
// Header (0..128) unchanged except byte 0x14 (flag cleared)
|
||||
for i in 0..128 {
|
||||
if i == 0x14 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(sector[i], original[i], "header byte {} changed", i);
|
||||
}
|
||||
// Encrypted region should be different
|
||||
assert_ne!(§or[128..256], &original[128..256]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descramble_clears_flags() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let mut sector = vec![0x00; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
sector[0x54..0x59].copy_from_slice(&[0x00; 5]);
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_eq!(sector[0x14] & 0x30, 0x00);
|
||||
}
|
||||
|
||||
// Not a plain XOR involution (TAB1 applies to ciphertext); scramble_sector is the true
|
||||
// inverse.
|
||||
#[test]
|
||||
fn css_descramble_inverts_scramble_over_body() {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
|
||||
let mut sector = vec![0xAAu8; 2048];
|
||||
sector[0x14] = 0x30; // scramble flag
|
||||
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
||||
|
||||
let original = sector.clone();
|
||||
|
||||
// Scramble the plaintext body into ciphertext.
|
||||
scramble_sector(&title_key, &mut sector);
|
||||
// Header (0..128) unchanged except the flag byte (set by scramble).
|
||||
for i in 0..128 {
|
||||
if i == 0x14 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(sector[i], original[i], "header byte {} changed", i);
|
||||
}
|
||||
// Encrypted region modified
|
||||
assert_ne!(§or[128..256], &original[128..256]);
|
||||
|
||||
// Descramble restores the plaintext body byte-for-byte.
|
||||
descramble_sector(&title_key, &mut sector);
|
||||
assert_eq!(sector[0x14] & 0x30, 0x00, "flag cleared after descramble");
|
||||
assert_eq!(
|
||||
§or[128..2048],
|
||||
&original[128..2048],
|
||||
"descramble(scramble(body)) did not restore the body"
|
||||
);
|
||||
}
|
||||
|
||||
// TAB1 is a substitution table; verify it is a permutation of 0..255.
|
||||
#[test]
|
||||
fn css_tab1_is_permutation() {
|
||||
let mut seen = [false; 256];
|
||||
for tab1_val in &TAB1 {
|
||||
let v = *tab1_val as usize;
|
||||
assert!(!seen[v], "TAB1 maps two inputs to {:#04x}", v);
|
||||
seen[v] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// css_tab4_is_bit_reversal
|
||||
///
|
||||
/// TAB4 reverses the bits of each byte: TAB4[0x01] = 0x80, TAB4[0x80] = 0x01, etc.
|
||||
#[test]
|
||||
fn css_tab4_is_bit_reversal() {
|
||||
for i in 0u16..256 {
|
||||
let expected = (0..8).fold(0u8, |acc, bit| acc | (((i as u8 >> bit) & 1) << (7 - bit)));
|
||||
assert_eq!(
|
||||
TAB4[i as usize], expected,
|
||||
"TAB4[{:#04x}] = {:#04x}, expected {:#04x} (bit reversal)",
|
||||
i, TAB4[i as usize], expected
|
||||
);
|
||||
}
|
||||
// Also verify TAB4 is an involution: TAB4[TAB4[x]] == x
|
||||
for i in 0..256 {
|
||||
assert_eq!(
|
||||
TAB4[TAB4[i] as usize], i as u8,
|
||||
"TAB4 is not an involution at {:#04x}",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scramble-flag detection (byte 0x14, bits 4-5): only bits 4-5 are the flag (mask 0x30);
|
||||
// 0x40/0x80 must read UNSCRAMBLED.
|
||||
#[test]
|
||||
fn descramble_treats_high_bits_of_0x14_as_clear() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
for &flag in &[0x40u8, 0x80, 0xC0, 0x0F, 0x4F, 0x8F] {
|
||||
let mut sector = vec![0xAA; 2048];
|
||||
sector[0x14] = flag;
|
||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_eq!(
|
||||
sector, original,
|
||||
"byte 0x14 = {flag:#04x} has flag bits 4-5 clear; sector must be untouched"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Each scramble bit (4 and 5) independently marks a sector encrypted: 0x10 and 0x20 must
|
||||
// both trigger descrambling.
|
||||
#[test]
|
||||
fn descramble_triggers_on_either_flag_bit() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
for &flag in &[0x10u8, 0x20, 0x30] {
|
||||
let mut sector = vec![0xAA; 2048];
|
||||
sector[0x14] = flag;
|
||||
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_ne!(
|
||||
§or[128..256],
|
||||
&original[128..256],
|
||||
"flag {flag:#04x} (bits 4-5 nonzero) must descramble the body"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Only the two scramble bits are cleared (`& 0xCF`); bits 6-7 of byte 0x14 survive: 0xF0
|
||||
// becomes 0xC0, NOT 0x00.
|
||||
#[test]
|
||||
fn descramble_clear_preserves_high_bits_of_0x14() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let mut sector = vec![0x00; 2048];
|
||||
sector[0x14] = 0xF0; // bits 4-7 set; bits 4-5 are the flag
|
||||
sector[0x54..0x59].copy_from_slice(&[0x00; 5]);
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_eq!(
|
||||
sector[0x14], 0xC0,
|
||||
"scramble bits cleared, bits 6-7 preserved (0xF0 & 0xCF)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── header / body boundary (encrypted region is 0x80..0x800) ───────────
|
||||
|
||||
// Encrypted region is exactly 0x80..0x800; header bytes 0x00..0x80 (incl. the seed at
|
||||
// 0x54..0x59) must stay untouched.
|
||||
#[test]
|
||||
fn descramble_leaves_header_and_seed_intact() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let mut sector = vec![0x5Au8; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||
sector[0x54..0x59].copy_from_slice(&seed);
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
for i in 0..0x80usize {
|
||||
if i == 0x14 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(
|
||||
sector[i], original[i],
|
||||
"header byte {i:#04x} must be untouched"
|
||||
);
|
||||
}
|
||||
assert_eq!(§or[0x54..0x59], &seed, "sector seed must survive");
|
||||
}
|
||||
|
||||
// The descrambler must touch the WHOLE body, including the final byte (index 2047),
|
||||
// guarding the `.take(2048)` bound.
|
||||
#[test]
|
||||
fn descramble_covers_final_body_byte() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let mut sector = vec![0x00u8; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
descramble_sector(&key, &mut sector);
|
||||
// Body was all zero; any nonzero in [0x80,0x800) is keystream. Confirm
|
||||
// the keystream reaches the final byte.
|
||||
assert_ne!(
|
||||
§or[2040..2048],
|
||||
&[0u8; 8][..],
|
||||
"the tail of the body must be descrambled (loop must reach index 2047)"
|
||||
);
|
||||
}
|
||||
|
||||
// The length guard is a FLOOR, not a ceiling: descramble_sector processes the first sector
|
||||
// of any over-long buffer.
|
||||
#[test]
|
||||
fn descramble_processes_the_first_sector_of_an_over_long_buffer() {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||
|
||||
// Two sectors' worth of buffer; only the first is a sector.
|
||||
let mut buf = vec![0xAAu8; 4096];
|
||||
buf[0x14] = 0x30;
|
||||
buf[0x54..0x59].copy_from_slice(&seed);
|
||||
let original = buf.clone();
|
||||
|
||||
descramble_sector(&title_key, &mut buf);
|
||||
|
||||
assert_ne!(
|
||||
&buf[0x80..0x800],
|
||||
&original[0x80..0x800],
|
||||
"the first sector's body must be descrambled"
|
||||
);
|
||||
assert_eq!(buf[0x14] & 0x30, 0x00, "and its scramble flag cleared");
|
||||
assert_eq!(
|
||||
&buf[2048..4096],
|
||||
&original[2048..4096],
|
||||
"bytes past the first sector must be left untouched"
|
||||
);
|
||||
|
||||
// The result must equal what a caller gets by passing exactly one
|
||||
// sector — the same transform, not a length-dependent one.
|
||||
let mut one = original[..2048].to_vec();
|
||||
descramble_sector(&title_key, &mut one);
|
||||
assert_eq!(
|
||||
&buf[..2048],
|
||||
&one[..],
|
||||
"the first sector must descramble identically either way"
|
||||
);
|
||||
}
|
||||
|
||||
// Descramble is keyed by `title_key XOR seed`: two different title keys must produce two
|
||||
// different bodies.
|
||||
#[test]
|
||||
fn descramble_output_depends_on_title_key() {
|
||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||
let make = |k: &[u8; 5]| {
|
||||
let mut s = vec![0x00u8; 2048];
|
||||
s[0x14] = 0x30;
|
||||
s[0x54..0x59].copy_from_slice(&seed);
|
||||
descramble_sector(k, &mut s);
|
||||
s
|
||||
};
|
||||
let a = make(&[0x01, 0x02, 0x03, 0x04, 0x05]);
|
||||
let b = make(&[0x01, 0x02, 0x03, 0x04, 0x06]); // differs in last byte
|
||||
assert_ne!(
|
||||
&a[128..2048],
|
||||
&b[128..2048],
|
||||
"different title keys must descramble differently"
|
||||
);
|
||||
}
|
||||
|
||||
// Descramble is keyed by the sector seed too: same title key, different seed -> different
|
||||
// body.
|
||||
#[test]
|
||||
fn descramble_output_depends_on_seed() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let make = |seed: [u8; 5]| {
|
||||
let mut s = vec![0x00u8; 2048];
|
||||
s[0x14] = 0x30;
|
||||
s[0x54..0x59].copy_from_slice(&seed);
|
||||
descramble_sector(&key, &mut s);
|
||||
s
|
||||
};
|
||||
let a = make([0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
let b = make([0x11, 0x22, 0x33, 0x44, 0x56]);
|
||||
assert_ne!(
|
||||
&a[128..2048],
|
||||
&b[128..2048],
|
||||
"different seeds must descramble differently"
|
||||
);
|
||||
}
|
||||
+1380
-4
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+130
-2
@@ -133,5 +133,133 @@ pub const TAB5: [u8; 256] = [
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tables_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Pins the documented relationship `TAB5[i] == TAB4[i] ^ 0xFF` so the
|
||||
/// table doc cannot drift from the data.
|
||||
#[test]
|
||||
fn tab5_is_complement_of_tab4() {
|
||||
for i in 0..256 {
|
||||
assert_eq!(
|
||||
TAB5[i],
|
||||
TAB4[i] ^ 0xFF,
|
||||
"TAB5[{i:#04x}] != TAB4[{i:#04x}] ^ 0xFF"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TAB1 is a bijection on 0..256; CSS uses it as an invertible output permutation in
|
||||
// css_DecryptKey.
|
||||
#[test]
|
||||
fn tab1_is_a_permutation() {
|
||||
let mut seen = [false; 256];
|
||||
for (i, &v) in TAB1.iter().enumerate() {
|
||||
assert!(
|
||||
!seen[v as usize],
|
||||
"TAB1 maps two inputs to {v:#04x} (collision at index {i:#04x})"
|
||||
);
|
||||
seen[v as usize] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// TAB1's fixed structural anchors: TAB1[0x00] == 0x33 and TAB1[0x33] == 0x00, the canonical
|
||||
// CSS-spec landmarks pinning the table's orientation.
|
||||
#[test]
|
||||
fn tab1_known_spec_anchors() {
|
||||
assert_eq!(TAB1[0x00], 0x33, "TAB1[0] is the published 0x33");
|
||||
assert_eq!(TAB1[0x33], 0x00, "TAB1[0x33] is the published 0x00");
|
||||
}
|
||||
|
||||
// FNV-1a over a whole table: a swapped pair keeps a permutation and the anchors, so only
|
||||
// a full-content pin catches a mistyped or regenerated table.
|
||||
fn fnv1a(table: &[u8]) -> u64 {
|
||||
table.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &b| {
|
||||
(h ^ b as u64).wrapping_mul(0x0000_0100_0000_01b3)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab1_and_tab2_contents_are_pinned() {
|
||||
assert_eq!(fnv1a(&TAB1), 0xcdb8_7228_1c08_c265, "TAB1 content changed");
|
||||
assert_eq!(fnv1a(&TAB2), 0x79e0_69d7_0f3d_2f25, "TAB2 content changed");
|
||||
}
|
||||
|
||||
// TAB2 is a permutation of 0..256 (LFSR1 high-byte feedback substitution); a non-bijective
|
||||
// table would bias the LFSR1 keystream.
|
||||
#[test]
|
||||
fn tab2_is_a_permutation() {
|
||||
let mut seen = [false; 256];
|
||||
for (i, &v) in TAB2.iter().enumerate() {
|
||||
assert!(
|
||||
!seen[v as usize],
|
||||
"TAB2 maps two inputs to {v:#04x} (collision at index {i:#04x})"
|
||||
);
|
||||
seen[v as usize] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// TAB3 is CSS's LFSR1 low-word table: BASE = [0x00,0x24,0x49,0x6d,0x92, 0xb6,0xdb,0xff]
|
||||
// repeated 64x (TAB3[i] == BASE[i & 7]); only the low 3 bits of the 9-bit index matter.
|
||||
#[test]
|
||||
fn tab3_matches_lfsr1_generating_formula() {
|
||||
const BASE: [u8; 8] = [0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff];
|
||||
for i in 0..512usize {
|
||||
let expected = BASE[i & 7];
|
||||
assert_eq!(
|
||||
TAB3[i], expected,
|
||||
"TAB3[{i:#05x}] = {:#04x}, formula BASE[i&7] = {expected:#04x}",
|
||||
TAB3[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TAB4 is the exact bit-reversal of each byte (CSS permutes LFSR0 bytes with it on
|
||||
// seed/output), hence also an involution: TAB4[TAB4[b]] == b.
|
||||
#[test]
|
||||
fn tab4_is_exact_bit_reversal_and_involution() {
|
||||
for b in 0u16..256 {
|
||||
let rev = (0..8).fold(0u8, |acc, k| acc | (((b as u8 >> k) & 1) << (7 - k)));
|
||||
assert_eq!(
|
||||
TAB4[b as usize], rev,
|
||||
"TAB4[{b:#04x}] is not the bit-reversal {rev:#04x}"
|
||||
);
|
||||
}
|
||||
for b in 0..256usize {
|
||||
assert_eq!(
|
||||
TAB4[TAB4[b] as usize], b as u8,
|
||||
"TAB4 not an involution at {b:#04x}"
|
||||
);
|
||||
}
|
||||
// Spec landmark entries.
|
||||
assert_eq!(TAB4[0x01], 0x80);
|
||||
assert_eq!(TAB4[0x80], 0x01);
|
||||
assert_eq!(TAB4[0x00], 0x00);
|
||||
assert_eq!(TAB4[0xFF], 0xFF);
|
||||
}
|
||||
|
||||
// TAB4 is a permutation (bit-reversal is bijective); distinct from the reversal test since
|
||||
// "reversal except swapped/duplicated entries" cases would pass one test but fail the
|
||||
// other.
|
||||
#[test]
|
||||
fn tab4_is_a_permutation() {
|
||||
let mut seen = [false; 256];
|
||||
for &v in TAB4.iter() {
|
||||
assert!(!seen[v as usize], "TAB4 maps two inputs to {v:#04x}");
|
||||
seen[v as usize] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// TAB5 is a permutation (complement of a bijection) with orientation anchors
|
||||
// TAB5[0x00]==0xFF and TAB5[0xFF]==0x00, independent of the complement-loop test.
|
||||
#[test]
|
||||
fn tab5_is_permutation_with_anchors() {
|
||||
let mut seen = [false; 256];
|
||||
for &v in TAB5.iter() {
|
||||
assert!(!seen[v as usize], "TAB5 maps two inputs to {v:#04x}");
|
||||
seen[v as usize] = true;
|
||||
}
|
||||
assert_eq!(TAB5[0x00], 0xFF, "TAB5[0] = TAB4[0]^0xFF = 0xFF");
|
||||
assert_eq!(TAB5[0xFF], 0x00, "TAB5[0xFF] = TAB4[0xFF]^0xFF = 0x00");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// Pins the documented relationship `TAB5[i] == TAB4[i] ^ 0xFF` so the
|
||||
/// table doc cannot drift from the data.
|
||||
#[test]
|
||||
fn tab5_is_complement_of_tab4() {
|
||||
for i in 0..256 {
|
||||
assert_eq!(
|
||||
TAB5[i],
|
||||
TAB4[i] ^ 0xFF,
|
||||
"TAB5[{i:#04x}] != TAB4[{i:#04x}] ^ 0xFF"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TAB1 is a bijection on 0..256; CSS uses it as an invertible output permutation in
|
||||
// css_DecryptKey.
|
||||
#[test]
|
||||
fn tab1_is_a_permutation() {
|
||||
let mut seen = [false; 256];
|
||||
for (i, &v) in TAB1.iter().enumerate() {
|
||||
assert!(
|
||||
!seen[v as usize],
|
||||
"TAB1 maps two inputs to {v:#04x} (collision at index {i:#04x})"
|
||||
);
|
||||
seen[v as usize] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// TAB1's fixed structural anchors: TAB1[0x00] == 0x33 and TAB1[0x33] == 0x00, the canonical
|
||||
// CSS-spec landmarks pinning the table's orientation.
|
||||
#[test]
|
||||
fn tab1_known_spec_anchors() {
|
||||
assert_eq!(TAB1[0x00], 0x33, "TAB1[0] is the published 0x33");
|
||||
assert_eq!(TAB1[0x33], 0x00, "TAB1[0x33] is the published 0x00");
|
||||
}
|
||||
|
||||
// FNV-1a over a whole table: a swapped pair keeps a permutation and the anchors, so only
|
||||
// a full-content pin catches a mistyped or regenerated table.
|
||||
fn fnv1a(table: &[u8]) -> u64 {
|
||||
table.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &b| {
|
||||
(h ^ b as u64).wrapping_mul(0x0000_0100_0000_01b3)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab1_and_tab2_contents_are_pinned() {
|
||||
assert_eq!(fnv1a(&TAB1), 0xcdb8_7228_1c08_c265, "TAB1 content changed");
|
||||
assert_eq!(fnv1a(&TAB2), 0x79e0_69d7_0f3d_2f25, "TAB2 content changed");
|
||||
}
|
||||
|
||||
// TAB2 is a permutation of 0..256 (LFSR1 high-byte feedback substitution); a non-bijective
|
||||
// table would bias the LFSR1 keystream.
|
||||
#[test]
|
||||
fn tab2_is_a_permutation() {
|
||||
let mut seen = [false; 256];
|
||||
for (i, &v) in TAB2.iter().enumerate() {
|
||||
assert!(
|
||||
!seen[v as usize],
|
||||
"TAB2 maps two inputs to {v:#04x} (collision at index {i:#04x})"
|
||||
);
|
||||
seen[v as usize] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// TAB3 is CSS's LFSR1 low-word table: BASE = [0x00,0x24,0x49,0x6d,0x92, 0xb6,0xdb,0xff]
|
||||
// repeated 64x (TAB3[i] == BASE[i & 7]); only the low 3 bits of the 9-bit index matter.
|
||||
#[test]
|
||||
fn tab3_matches_lfsr1_generating_formula() {
|
||||
const BASE: [u8; 8] = [0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff];
|
||||
for i in 0..512usize {
|
||||
let expected = BASE[i & 7];
|
||||
assert_eq!(
|
||||
TAB3[i], expected,
|
||||
"TAB3[{i:#05x}] = {:#04x}, formula BASE[i&7] = {expected:#04x}",
|
||||
TAB3[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TAB4 is the exact bit-reversal of each byte (CSS permutes LFSR0 bytes with it on
|
||||
// seed/output), hence also an involution: TAB4[TAB4[b]] == b.
|
||||
#[test]
|
||||
fn tab4_is_exact_bit_reversal_and_involution() {
|
||||
for b in 0u16..256 {
|
||||
let rev = (0..8).fold(0u8, |acc, k| acc | (((b as u8 >> k) & 1) << (7 - k)));
|
||||
assert_eq!(
|
||||
TAB4[b as usize], rev,
|
||||
"TAB4[{b:#04x}] is not the bit-reversal {rev:#04x}"
|
||||
);
|
||||
}
|
||||
for b in 0..256usize {
|
||||
assert_eq!(
|
||||
TAB4[TAB4[b] as usize], b as u8,
|
||||
"TAB4 not an involution at {b:#04x}"
|
||||
);
|
||||
}
|
||||
// Spec landmark entries.
|
||||
assert_eq!(TAB4[0x01], 0x80);
|
||||
assert_eq!(TAB4[0x80], 0x01);
|
||||
assert_eq!(TAB4[0x00], 0x00);
|
||||
assert_eq!(TAB4[0xFF], 0xFF);
|
||||
}
|
||||
|
||||
// TAB4 is a permutation (bit-reversal is bijective); distinct from the reversal test since
|
||||
// "reversal except swapped/duplicated entries" cases would pass one test but fail the
|
||||
// other.
|
||||
#[test]
|
||||
fn tab4_is_a_permutation() {
|
||||
let mut seen = [false; 256];
|
||||
for &v in TAB4.iter() {
|
||||
assert!(!seen[v as usize], "TAB4 maps two inputs to {v:#04x}");
|
||||
seen[v as usize] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// TAB5 is a permutation (complement of a bijection) with orientation anchors
|
||||
// TAB5[0x00]==0xFF and TAB5[0xFF]==0x00, independent of the complement-loop test.
|
||||
#[test]
|
||||
fn tab5_is_permutation_with_anchors() {
|
||||
let mut seen = [false; 256];
|
||||
for &v in TAB5.iter() {
|
||||
assert!(!seen[v as usize], "TAB5 maps two inputs to {v:#04x}");
|
||||
seen[v as usize] = true;
|
||||
}
|
||||
assert_eq!(TAB5[0x00], 0xFF, "TAB5[0] = TAB4[0]^0xFF = 0xFF");
|
||||
assert_eq!(TAB5[0xFF], 0x00, "TAB5[0xFF] = TAB4[0xFF]^0xFF = 0x00");
|
||||
}
|
||||
+47
-2
@@ -135,5 +135,50 @@ impl Diag {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ctx_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Clones share the token, the events and the counters: a stage holding a clone
|
||||
// observes the caller's Stop and reports into the caller's totals.
|
||||
#[test]
|
||||
fn a_clone_shares_halt_and_stats() {
|
||||
let ctx = Ctx::default();
|
||||
let stage = ctx.clone();
|
||||
ctx.halt.cancel();
|
||||
stage.stats.add_skip(2048);
|
||||
stage.stats.add_blanked(2);
|
||||
stage.stats.add_resync_dropped(3);
|
||||
assert!(stage.halt.is_cancelled());
|
||||
assert_eq!(
|
||||
ctx.stats.snapshot(),
|
||||
LossReport {
|
||||
read_skips: 1,
|
||||
bytes_lost: 2048,
|
||||
units_blanked: 2,
|
||||
resync_dropped: 3,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Each switch follows its own variable; no other test reads either one.
|
||||
#[test]
|
||||
fn diag_from_env_reads_each_switch_from_its_own_variable() {
|
||||
let set = |k: &str, on: bool| unsafe {
|
||||
if on {
|
||||
std::env::set_var(k, "1")
|
||||
} else {
|
||||
std::env::remove_var(k)
|
||||
}
|
||||
};
|
||||
set("FREEMKV_SKIP_PARSE", true);
|
||||
set("FREEMKV_PROFILE", false);
|
||||
let d = Diag::from_env();
|
||||
assert!(d.skip_parse && !d.profile);
|
||||
set("FREEMKV_SKIP_PARSE", false);
|
||||
set("FREEMKV_PROFILE", true);
|
||||
let d = Diag::from_env();
|
||||
assert!(!d.skip_parse && d.profile);
|
||||
set("FREEMKV_PROFILE", false);
|
||||
assert_eq!(Diag::from_env(), Diag::default());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// Clones share the token, the events and the counters: a stage holding a clone
|
||||
// observes the caller's Stop and reports into the caller's totals.
|
||||
#[test]
|
||||
fn a_clone_shares_halt_and_stats() {
|
||||
let ctx = Ctx::default();
|
||||
let stage = ctx.clone();
|
||||
ctx.halt.cancel();
|
||||
stage.stats.add_skip(2048);
|
||||
stage.stats.add_blanked(2);
|
||||
stage.stats.add_resync_dropped(3);
|
||||
assert!(stage.halt.is_cancelled());
|
||||
assert_eq!(
|
||||
ctx.stats.snapshot(),
|
||||
LossReport {
|
||||
read_skips: 1,
|
||||
bytes_lost: 2048,
|
||||
units_blanked: 2,
|
||||
resync_dropped: 3,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Each switch follows its own variable; no other test reads either one.
|
||||
#[test]
|
||||
fn diag_from_env_reads_each_switch_from_its_own_variable() {
|
||||
let set = |k: &str, on: bool| unsafe {
|
||||
if on {
|
||||
std::env::set_var(k, "1")
|
||||
} else {
|
||||
std::env::remove_var(k)
|
||||
}
|
||||
};
|
||||
set("FREEMKV_SKIP_PARSE", true);
|
||||
set("FREEMKV_PROFILE", false);
|
||||
let d = Diag::from_env();
|
||||
assert!(d.skip_parse && !d.profile);
|
||||
set("FREEMKV_SKIP_PARSE", false);
|
||||
set("FREEMKV_PROFILE", true);
|
||||
let d = Diag::from_env();
|
||||
assert!(!d.skip_parse && d.profile);
|
||||
set("FREEMKV_PROFILE", false);
|
||||
assert_eq!(Diag::from_env(), Diag::default());
|
||||
}
|
||||
+1700
-115
File diff suppressed because it is too large
Load Diff
@@ -1,166 +0,0 @@
|
||||
use super::*;
|
||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, encrypt_unit};
|
||||
use crate::disc::ContentFormat;
|
||||
use crate::spec::keys::{KS_5_CPI, KS_6_TP_EXTRA_HEADER, KS_22_LIBAACS_VERIFY_TS};
|
||||
|
||||
const OURS: [u8; 16] = [0xA1; 16];
|
||||
const ALT: [u8; 16] = [0xB2; 16];
|
||||
|
||||
// An encrypted unit: TS with CPI 11₂ in every packet (KS-5), encrypted under `key`.
|
||||
fn encrypted(key: &[u8; 16], salt: u8) -> Vec<u8> {
|
||||
let mut u: Vec<u8> = (0..ALIGNED_UNIT_LEN)
|
||||
.map(|i| (i as u8).wrapping_add(salt) | 1)
|
||||
.collect();
|
||||
for p in u.chunks_mut(192) {
|
||||
p[0] |= 0xC0;
|
||||
p[4] = 0x47;
|
||||
}
|
||||
assert!(encrypt_unit(&mut u, key));
|
||||
u
|
||||
}
|
||||
|
||||
fn cpi(unit: &[u8]) -> Vec<u8> {
|
||||
unit.chunks(192).map(|p| p[0] & 0xC0).collect()
|
||||
}
|
||||
|
||||
/// per spec; do not change without a spec citation — KS-5 [BD] §3.10.2: "shall be set
|
||||
/// to 11₂ if the data is encrypted": a unit left as ciphertext keeps its CPI bits.
|
||||
/// Covers the alternate FMTS phase, an orphan, and a `Phase::Verify` unit that fails
|
||||
/// `is_clean` and is restored.
|
||||
#[test]
|
||||
fn ciphertext_units_keep_cpi() {
|
||||
assert!(
|
||||
KS_5_CPI
|
||||
.text
|
||||
.contains("set to 11₂ if the data is encrypted")
|
||||
);
|
||||
let keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, OURS)],
|
||||
format: ContentFormat::BdTs,
|
||||
};
|
||||
// Alternate phase: unit 1 of an Even-phase range is the other variant's.
|
||||
let mut buf = [encrypted(&OURS, 1), encrypted(&ALT, 2)].concat();
|
||||
let alt_before = buf[ALIGNED_UNIT_LEN..].to_vec();
|
||||
let map = AacsKeyMap::from_ranges_phased(vec![(0, 6, 0, Phase::Even)]);
|
||||
decrypt_sectors_mapped(&mut buf, &keys, 0, &map).expect("our phase decrypts");
|
||||
assert_eq!(
|
||||
buf[ALIGNED_UNIT_LEN..],
|
||||
alt_before[..],
|
||||
"alternate phase untouched"
|
||||
);
|
||||
assert_eq!(
|
||||
cpi(&buf[ALIGNED_UNIT_LEN..]),
|
||||
cpi(&alt_before),
|
||||
"CPI of all 32 packets"
|
||||
);
|
||||
assert_eq!(
|
||||
buf[ALIGNED_UNIT_LEN] & 0xC0,
|
||||
0xC0,
|
||||
"packet 0's clear CPI still 11₂"
|
||||
);
|
||||
// Orphan: an encrypted unit no range covers is refused, and left as it was.
|
||||
let mut orphan = encrypted(&ALT, 3);
|
||||
let before = orphan.clone();
|
||||
let map = AacsKeyMap::from_ranges_phased(vec![(30, 36, 0, Phase::All)]);
|
||||
let got = decrypt_sectors_mapped(&mut orphan, &keys, 0, &map);
|
||||
assert!(
|
||||
matches!(got, Err(crate::error::Error::DecryptFailed)),
|
||||
"{got:?}"
|
||||
);
|
||||
assert_eq!(orphan, before, "the orphan keeps every byte, CPI included");
|
||||
assert_eq!(orphan[0] & 0xC0, 0xC0);
|
||||
// A `Verify` unit our key does not open: restored, every byte and CPI kept.
|
||||
let mut failed = encrypted(&ALT, 4);
|
||||
let before = failed.clone();
|
||||
let map = AacsKeyMap::from_ranges_phased(vec![(0, 3, 0, Phase::Verify)]);
|
||||
decrypt_sectors_mapped(&mut failed, &keys, 0, &map).expect("a failed verify is no error");
|
||||
assert_eq!(
|
||||
failed, before,
|
||||
"the failed Verify unit is restored as ciphertext"
|
||||
);
|
||||
assert_eq!(cpi(&failed), cpi(&before), "CPI of all 32 packets");
|
||||
assert_eq!(failed[0] & 0xC0, 0xC0, "packet 0's clear CPI still 11₂");
|
||||
}
|
||||
|
||||
/// per spec; do not change without a spec citation — KS-5 [BD] §3.10.2: "shall be set
|
||||
/// to 11₂ if the data is encrypted, or … 00₂ if the data is not encrypted"; KS-6 (CPI is
|
||||
/// the top 2 bits of TP_extra_header); corroborated by KS-22 libaacs `buf[i] &= ~0xc0`.
|
||||
#[test]
|
||||
fn every_decrypted_unit_clears_cpi_in_all_32_packets() {
|
||||
assert!(KS_5_CPI.text.contains("00₂ if the data is not encrypted"));
|
||||
assert!(
|
||||
KS_6_TP_EXTRA_HEADER
|
||||
.text
|
||||
.contains("Copy_permission_indicator 2 uimsbf Arrival_time_stamp 30 uimsbf")
|
||||
);
|
||||
assert!(KS_22_LIBAACS_VERIFY_TS.text.contains("buf[i] &= ~0xc0;"));
|
||||
let keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, OURS)],
|
||||
format: ContentFormat::BdTs,
|
||||
};
|
||||
let check = |label: &str, got: &[u8], plain: Vec<u8>| {
|
||||
assert!(
|
||||
cpi(got).iter().all(|&c| c == 0),
|
||||
"{label}: CPI of all 32 packets"
|
||||
);
|
||||
// Byte 0 of each packet keeps its 6 Arrival_time_stamp bits (KS-6).
|
||||
let want = crate::aacs::content::cpi_cleared(plain);
|
||||
assert_eq!(got, &want[..], "{label}: ATS and payload untouched");
|
||||
};
|
||||
|
||||
// A map-keyed unit (`Phase::All`, the base Unit Key).
|
||||
let mut unit = encrypted(&OURS, 7);
|
||||
let mut plain = unit.clone();
|
||||
crate::aacs::content::decrypt_unit(&mut plain, &OURS);
|
||||
let map = AacsKeyMap::from_ranges(vec![(0, 3, 0)]);
|
||||
decrypt_sectors_mapped(&mut unit, &keys, 0, &map).expect("map-keyed unit decrypts");
|
||||
check("map-keyed", &unit, plain);
|
||||
|
||||
// A DAMAGED map-keyed unit: media garbage, still decrypted, so no longer ciphertext.
|
||||
let mut damaged: Vec<u8> = (0..ALIGNED_UNIT_LEN)
|
||||
.map(|i| (i as u8).wrapping_mul(37) ^ 0x5A)
|
||||
.collect();
|
||||
damaged[0] |= 0xC0;
|
||||
damaged[4] = 0x47; // the clear seed's sync: read on the unit grid
|
||||
let mut plain = damaged.clone();
|
||||
crate::aacs::content::decrypt_unit(&mut plain, &OURS);
|
||||
decrypt_sectors_mapped(&mut damaged, &keys, 0, &map).expect("no verify on Phase::All");
|
||||
check("damaged map-keyed", &damaged, plain);
|
||||
|
||||
// Our half of a forensic segment (`Phase::Even`, unit 0 of the range).
|
||||
let mut ours = encrypted(&OURS, 9);
|
||||
let mut plain = ours.clone();
|
||||
crate::aacs::content::decrypt_unit(&mut plain, &OURS);
|
||||
let map = AacsKeyMap::from_ranges_phased(vec![(0, 3, 0, Phase::Even)]);
|
||||
decrypt_sectors_mapped(&mut ours, &keys, 0, &map).expect("our phase decrypts");
|
||||
check("our forensic phase", &ours, plain);
|
||||
|
||||
// A kept `Phase::Verify` unit (it verified under our key). The on-arrival leg
|
||||
// joins with the on-arrival proof (KU-L2).
|
||||
let mut kept = encrypted(&OURS, 11);
|
||||
let mut plain = kept.clone();
|
||||
crate::aacs::content::decrypt_unit(&mut plain, &OURS);
|
||||
let map = AacsKeyMap::from_ranges_phased(vec![(0, 3, 0, Phase::Verify)]);
|
||||
decrypt_sectors_mapped(&mut kept, &keys, 0, &map).expect("a verified unit decrypts");
|
||||
check("kept Verify", &kept, plain);
|
||||
}
|
||||
// A flagged partial unit is blanked only when it ends the source (a truncated copy); a
|
||||
// partial read mid-content is left for the decrypt to refuse.
|
||||
#[test]
|
||||
fn a_flagged_partial_unit_is_blanked_only_at_the_end() {
|
||||
use crate::disc::ContentFormat;
|
||||
let mut tail = vec![0x5Au8; 4096];
|
||||
tail[0] |= 0xC0;
|
||||
tail[4] = 0x47;
|
||||
let mut mid = tail.clone();
|
||||
assert_eq!(
|
||||
blank_damaged_units(&mut mid, 0, ContentFormat::BdTs, &|_| true, false),
|
||||
0
|
||||
);
|
||||
assert_eq!(mid, tail);
|
||||
assert_eq!(
|
||||
blank_damaged_units(&mut tail, 0, ContentFormat::BdTs, &|_| true, true),
|
||||
1
|
||||
);
|
||||
assert!(tail.iter().all(|&b| b == 0));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+272
-2
@@ -631,5 +631,275 @@ fn dump_title(ti: usize, title: &DiscTitle) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "diag_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
// Needed only by the tests: the production code in this file no longer names
|
||||
// these types directly, since the local channel/sample-rate duplicates were
|
||||
// deleted in favour of the canonical accessors.
|
||||
use crate::disc::{AudioChannels, SampleRate};
|
||||
|
||||
// The main-feature decision row must NAME the comparator's real sort keys, not a restated
|
||||
// (driftable) copy.
|
||||
#[test]
|
||||
fn main_feature_reason_names_the_comparators_real_keys() {
|
||||
use crate::disc::{Clip, Disc, DiscTitle};
|
||||
|
||||
let sized = |size_bytes: u64, n_clips: usize| DiscTitle {
|
||||
size_bytes,
|
||||
clips: (0..n_clips)
|
||||
.map(|i| Clip {
|
||||
feed_span: None,
|
||||
clip_id: format!("{i:05}"),
|
||||
in_time: 0,
|
||||
out_time: 0,
|
||||
duration_secs: 0.0,
|
||||
source_packets: 0,
|
||||
})
|
||||
.collect(),
|
||||
..DiscTitle::empty()
|
||||
};
|
||||
// A 40-clip 8 GB title beats a 1-clip 1 GB title: the comparator's primary
|
||||
// key among disc-fitting titles is LARGEST SIZE, not "fewest clips" (the
|
||||
// drifted string described a rule the comparator does not implement).
|
||||
let many_clips_big = sized(8_000_000_000, 40);
|
||||
let one_clip_small = sized(1_000_000_000, 1);
|
||||
assert_eq!(
|
||||
Disc::canonical_title_order(&many_clips_big, &one_clip_small, 25_000_000_000),
|
||||
std::cmp::Ordering::Less,
|
||||
"largest size wins regardless of clip count"
|
||||
);
|
||||
|
||||
let reason = main_feature_reason();
|
||||
assert!(
|
||||
!reason.contains("clips"),
|
||||
"the reason must not advertise a clip-count key the comparator dropped: {reason}"
|
||||
);
|
||||
// Equal-size seamless siblings (issue #45) are split ONLY by the final
|
||||
// lowest-playlist-id key, so the reason must name it.
|
||||
let sibling = |playlist_id: u16| DiscTitle {
|
||||
playlist_id,
|
||||
..sized(8_000_000_000, 1)
|
||||
};
|
||||
assert_eq!(
|
||||
Disc::canonical_title_order(&sibling(800), &sibling(808), 25_000_000_000),
|
||||
std::cmp::Ordering::Less,
|
||||
"the comparator's final key is lowest playlist id"
|
||||
);
|
||||
assert!(
|
||||
reason.ends_with(", lowest-playlist-id)"),
|
||||
"the final tiebreak must be named: {reason}"
|
||||
);
|
||||
assert_eq!(
|
||||
reason,
|
||||
"main_feature_order(nav-feature, authoring-feature, standalone, has-video, fits-disc, largest-size, longest, richest-audio, more-video, more-subs, lowest-playlist-id)",
|
||||
"the reason must name the selection keys (authoring, standalone-over-composite, and has-video gates, then the physical keys) in priority order"
|
||||
);
|
||||
}
|
||||
|
||||
// A track index that does not fit the u8 record field is skipped, not aliased.
|
||||
#[test]
|
||||
fn opening_capture_skips_track_indices_above_255() {
|
||||
let path = std::env::temp_dir().join(format!("fmk-diag-hi-{}.bin", std::process::id()));
|
||||
let file = std::fs::File::create(&path).unwrap();
|
||||
let mut cap = OpeningCapture {
|
||||
file,
|
||||
counts: vec![0; 300],
|
||||
};
|
||||
cap.record(256, 0, true, b"x");
|
||||
let len = std::fs::metadata(&path).unwrap().len();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(len, 0, "idx 256 must not write an aliased track-0 record");
|
||||
assert_eq!(cap.counts[256], 0);
|
||||
}
|
||||
|
||||
// A failed write may leave a torn record, so capture stops on every track.
|
||||
#[test]
|
||||
fn opening_capture_write_error_disables_all_tracks() {
|
||||
let path = std::env::temp_dir().join(format!("fmk-diag-ro-{}.bin", std::process::id()));
|
||||
std::fs::write(&path, b"").unwrap();
|
||||
let file = std::fs::File::open(&path).unwrap(); // read-only: writes fail
|
||||
let mut cap = OpeningCapture {
|
||||
file,
|
||||
counts: vec![0; 2],
|
||||
};
|
||||
cap.record(0, 0, true, b"x");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(
|
||||
cap.counts[1], OPENING_FRAMES_PER_TRACK,
|
||||
"other tracks must stop after a write error"
|
||||
);
|
||||
}
|
||||
|
||||
// The per-track cap stops one track at OPENING_FRAMES_PER_TRACK records without
|
||||
// affecting another, and an out-of-range track index is ignored.
|
||||
#[test]
|
||||
fn opening_capture_caps_each_track_and_ignores_unknown_tracks() {
|
||||
let path = std::env::temp_dir().join(format!("fmk-diag-cap-{}.bin", std::process::id()));
|
||||
let file = std::fs::File::create(&path).unwrap();
|
||||
let mut cap = OpeningCapture {
|
||||
file,
|
||||
counts: vec![0; 2],
|
||||
};
|
||||
for _ in 0..OPENING_FRAMES_PER_TRACK + 5 {
|
||||
cap.record(0, 0, true, b"x");
|
||||
}
|
||||
cap.record(1, 0, false, b"y");
|
||||
cap.record(2, 0, true, b"z");
|
||||
let len = std::fs::metadata(&path).unwrap().len();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(cap.counts, vec![OPENING_FRAMES_PER_TRACK, 1]);
|
||||
assert_eq!(len as usize, (OPENING_FRAMES_PER_TRACK + 1) * 15);
|
||||
}
|
||||
|
||||
// With the diag target off, no side file is created and the rip is unaffected.
|
||||
#[test]
|
||||
fn opening_capture_new_is_none_when_diag_is_off() {
|
||||
let path = std::env::temp_dir().join(format!("fmk-diag-off-{}.mkv", std::process::id()));
|
||||
assert!(OpeningCapture::new(&path, 3).is_none());
|
||||
let mut side = path.as_os_str().to_os_string();
|
||||
side.push(".opening.bin");
|
||||
assert!(!std::path::Path::new(&side).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_str_keeps_interlace_marker() {
|
||||
assert_eq!(res_str(Resolution::R576i), "576i");
|
||||
assert_eq!(res_str(Resolution::R480i), "480i");
|
||||
assert_eq!(res_str(Resolution::R2160p), "2160p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fps_and_tv_system() {
|
||||
assert_eq!(fps_str(FrameRate::F25), "25");
|
||||
assert_eq!(tv_system_str(FrameRate::F25), "PAL");
|
||||
assert_eq!(fps_str(FrameRate::F29_97), "29.97");
|
||||
assert_eq!(tv_system_str(FrameRate::F29_97), "NTSC");
|
||||
assert_eq!(tv_system_str(FrameRate::F50), "PAL");
|
||||
assert_eq!(tv_system_str(FrameRate::F23_976), "NTSC");
|
||||
assert_eq!(tv_system_str(FrameRate::F59_94), "NTSC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_and_hdr() {
|
||||
assert_eq!(color_str(ColorSpace::Bt470bg), "BT.470BG");
|
||||
assert_eq!(color_str(ColorSpace::Bt2020), "BT.2020");
|
||||
assert_eq!(hdr_str(HdrFormat::Hdr10), "HDR10");
|
||||
assert_eq!(hdr_str(HdrFormat::DolbyVision), "DoVi");
|
||||
assert_eq!(hdr_str(HdrFormat::Sdr), "SDR");
|
||||
}
|
||||
|
||||
/// Moved from the deleted local duplicates onto the canonical accessors,
|
||||
/// with the Unknown case added — which is the whole point of the change.
|
||||
#[test]
|
||||
fn channel_count_matches_layout_and_is_zero_when_unknown() {
|
||||
assert_eq!(AudioChannels::Mono.count(), 1);
|
||||
assert_eq!(AudioChannels::Stereo.count(), 2);
|
||||
assert_eq!(AudioChannels::Surround51.count(), 6);
|
||||
assert_eq!(AudioChannels::Surround71.count(), 8);
|
||||
// The one that matters. This used to return 6, which is indistinguishable
|
||||
// from a real 5.1 track and left every caller responsible for checking
|
||||
// the variant first.
|
||||
assert_eq!(
|
||||
AudioChannels::Unknown.count(),
|
||||
0,
|
||||
"an unknown layout must not report a plausible channel count"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_rate_hz_values_and_zero_when_unknown() {
|
||||
assert_eq!(SampleRate::S48.hz(), 48000.0);
|
||||
assert_eq!(SampleRate::S96.hz(), 96000.0);
|
||||
assert_eq!(
|
||||
SampleRate::Unknown.hz(),
|
||||
0.0,
|
||||
"an unknown sample rate must not report a plausible 48 kHz"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_hex_renders_caps_and_handles_empty() {
|
||||
// None / empty → "none" (no hex). The Windows-fps diagnosis only needs
|
||||
// the seq-header prefix, so render it but cap long blobs.
|
||||
assert_eq!(codec_private_hex(None), "none");
|
||||
assert_eq!(codec_private_hex(Some(&[])), "none");
|
||||
// Short blob: full uppercase hex, no suffix. An MPEG-2 seq header starts
|
||||
// 00 00 01 B3 — exactly what a reader greps for in a bug log.
|
||||
assert_eq!(
|
||||
codec_private_hex(Some(&[0x00, 0x00, 0x01, 0xB3])),
|
||||
"000001B3"
|
||||
);
|
||||
// Over the cap: first CODEC_PRIVATE_HEX_CAP bytes + a "..(+NB)" summary.
|
||||
let big = vec![0xABu8; CODEC_PRIVATE_HEX_CAP + 5];
|
||||
let s = codec_private_hex(Some(&big));
|
||||
assert!(s.starts_with(&"AB".repeat(CODEC_PRIVATE_HEX_CAP)), "{s}");
|
||||
assert!(s.ends_with("..(+5B)"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_record_layout_is_parseable() {
|
||||
// The .opening.bin record framing must round-trip so a future tool can
|
||||
// split the side file back into frames without the disc:
|
||||
// [track:u8][keyframe:u8][pts_ns:i64 LE][len:u32 LE][raw bytes].
|
||||
let data = [0xDEu8, 0xAD, 0xBE, 0xEF];
|
||||
let rec = frame_record(2, -40_000_000, true, &data);
|
||||
assert_eq!(rec.len(), 14 + data.len());
|
||||
assert_eq!(rec[0], 2, "track index");
|
||||
assert_eq!(rec[1], 1, "keyframe flag");
|
||||
assert_eq!(
|
||||
i64::from_le_bytes(rec[2..10].try_into().unwrap()),
|
||||
-40_000_000,
|
||||
"pts_ns survives (signed — opening back-anchor can be negative)"
|
||||
);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(rec[10..14].try_into().unwrap()),
|
||||
4,
|
||||
"len"
|
||||
);
|
||||
assert_eq!(&rec[14..], &data, "raw frame bytes follow");
|
||||
// A non-keyframe records the flag as 0.
|
||||
let delta = frame_record(0, 0, false, &[]);
|
||||
assert_eq!(delta[1], 0);
|
||||
assert_eq!(u32::from_le_bytes(delta[10..14].try_into().unwrap()), 0);
|
||||
}
|
||||
|
||||
/// The cell row shows the raw category byte (0xNN) beside the decode, and
|
||||
/// the keep/drop verdict. A plain feature cell (0x00) is "keep"; a leading
|
||||
/// secondary-block cell flagged dropped reads "DROP".
|
||||
#[test]
|
||||
fn cell_row_shows_raw_byte_and_verdict() {
|
||||
let plain = crate::ifo::DvdCell {
|
||||
first_sector: 100,
|
||||
last_sector: 199,
|
||||
category: 0x00,
|
||||
duration_secs: 12.5,
|
||||
};
|
||||
let row = dvd_cell_row(0, &plain, false);
|
||||
assert!(row.contains("cat=0x00"), "{row}");
|
||||
assert!(row.contains("block_mode=0"), "{row}");
|
||||
assert!(row.contains("first=100"), "{row}");
|
||||
assert!(row.contains("last=199"), "{row}");
|
||||
assert!(row.contains("dur=12.5s"), "{row}");
|
||||
assert!(row.contains("keep(plain-feature)"), "{row}");
|
||||
assert!(!row.contains("DROP"), "{row}");
|
||||
|
||||
// 0x90 = in-block cell of an angle block (block_mode=2, block_type=1),
|
||||
// shown dropped as a leading secondary piece.
|
||||
let sec = crate::ifo::DvdCell {
|
||||
first_sector: 0,
|
||||
last_sector: 9,
|
||||
category: 0x90,
|
||||
duration_secs: 1.0,
|
||||
};
|
||||
let row = dvd_cell_row(0, &sec, true);
|
||||
assert!(row.contains("cat=0x90"), "{row}");
|
||||
assert!(row.contains("block_mode=2"), "{row}");
|
||||
assert!(row.contains("block_type=1"), "{row}");
|
||||
assert!(row.contains("DROP(leading-secondary-block-piece)"), "{row}");
|
||||
|
||||
// The same secondary piece past the leading run is kept, as feature body.
|
||||
let row = dvd_cell_row(3, &sec, false);
|
||||
assert!(row.contains("keep(feature-body)"), "{row}");
|
||||
assert!(!row.contains("keep(plain-feature)"), "{row}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
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 the comparator's real sort keys, not a restated
|
||||
// (driftable) copy.
|
||||
#[test]
|
||||
fn main_feature_reason_names_the_comparators_real_keys() {
|
||||
use crate::disc::{Clip, Disc, DiscTitle};
|
||||
|
||||
let sized = |size_bytes: u64, n_clips: usize| DiscTitle {
|
||||
size_bytes,
|
||||
clips: (0..n_clips)
|
||||
.map(|i| Clip {
|
||||
feed_span: None,
|
||||
clip_id: format!("{i:05}"),
|
||||
in_time: 0,
|
||||
out_time: 0,
|
||||
duration_secs: 0.0,
|
||||
source_packets: 0,
|
||||
})
|
||||
.collect(),
|
||||
..DiscTitle::empty()
|
||||
};
|
||||
// A 40-clip 8 GB title beats a 1-clip 1 GB title: the comparator's primary
|
||||
// key among disc-fitting titles is LARGEST SIZE, not "fewest clips" (the
|
||||
// drifted string described a rule the comparator does not implement).
|
||||
let many_clips_big = sized(8_000_000_000, 40);
|
||||
let one_clip_small = sized(1_000_000_000, 1);
|
||||
assert_eq!(
|
||||
Disc::canonical_title_order(&many_clips_big, &one_clip_small, 25_000_000_000),
|
||||
std::cmp::Ordering::Less,
|
||||
"largest size wins regardless of clip count"
|
||||
);
|
||||
|
||||
let reason = main_feature_reason();
|
||||
assert!(
|
||||
!reason.contains("clips"),
|
||||
"the reason must not advertise a clip-count key the comparator dropped: {reason}"
|
||||
);
|
||||
// Equal-size seamless siblings (issue #45) are split ONLY by the final
|
||||
// lowest-playlist-id key, so the reason must name it.
|
||||
let sibling = |playlist_id: u16| DiscTitle {
|
||||
playlist_id,
|
||||
..sized(8_000_000_000, 1)
|
||||
};
|
||||
assert_eq!(
|
||||
Disc::canonical_title_order(&sibling(800), &sibling(808), 25_000_000_000),
|
||||
std::cmp::Ordering::Less,
|
||||
"the comparator's final key is lowest playlist id"
|
||||
);
|
||||
assert!(
|
||||
reason.ends_with(", lowest-playlist-id)"),
|
||||
"the final tiebreak must be named: {reason}"
|
||||
);
|
||||
assert_eq!(
|
||||
reason,
|
||||
"main_feature_order(nav-feature, authoring-feature, standalone, has-video, fits-disc, largest-size, longest, richest-audio, more-video, more-subs, lowest-playlist-id)",
|
||||
"the reason must name the selection keys (authoring, standalone-over-composite, and has-video gates, then the physical keys) in priority order"
|
||||
);
|
||||
}
|
||||
|
||||
// A track index that does not fit the u8 record field is skipped, not aliased.
|
||||
#[test]
|
||||
fn opening_capture_skips_track_indices_above_255() {
|
||||
let path = std::env::temp_dir().join(format!("fmk-diag-hi-{}.bin", std::process::id()));
|
||||
let file = std::fs::File::create(&path).unwrap();
|
||||
let mut cap = OpeningCapture {
|
||||
file,
|
||||
counts: vec![0; 300],
|
||||
};
|
||||
cap.record(256, 0, true, b"x");
|
||||
let len = std::fs::metadata(&path).unwrap().len();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(len, 0, "idx 256 must not write an aliased track-0 record");
|
||||
assert_eq!(cap.counts[256], 0);
|
||||
}
|
||||
|
||||
// A failed write may leave a torn record, so capture stops on every track.
|
||||
#[test]
|
||||
fn opening_capture_write_error_disables_all_tracks() {
|
||||
let path = std::env::temp_dir().join(format!("fmk-diag-ro-{}.bin", std::process::id()));
|
||||
std::fs::write(&path, b"").unwrap();
|
||||
let file = std::fs::File::open(&path).unwrap(); // read-only: writes fail
|
||||
let mut cap = OpeningCapture {
|
||||
file,
|
||||
counts: vec![0; 2],
|
||||
};
|
||||
cap.record(0, 0, true, b"x");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(
|
||||
cap.counts[1], OPENING_FRAMES_PER_TRACK,
|
||||
"other tracks must stop after a write error"
|
||||
);
|
||||
}
|
||||
|
||||
// The per-track cap stops one track at OPENING_FRAMES_PER_TRACK records without
|
||||
// affecting another, and an out-of-range track index is ignored.
|
||||
#[test]
|
||||
fn opening_capture_caps_each_track_and_ignores_unknown_tracks() {
|
||||
let path = std::env::temp_dir().join(format!("fmk-diag-cap-{}.bin", std::process::id()));
|
||||
let file = std::fs::File::create(&path).unwrap();
|
||||
let mut cap = OpeningCapture {
|
||||
file,
|
||||
counts: vec![0; 2],
|
||||
};
|
||||
for _ in 0..OPENING_FRAMES_PER_TRACK + 5 {
|
||||
cap.record(0, 0, true, b"x");
|
||||
}
|
||||
cap.record(1, 0, false, b"y");
|
||||
cap.record(2, 0, true, b"z");
|
||||
let len = std::fs::metadata(&path).unwrap().len();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(cap.counts, vec![OPENING_FRAMES_PER_TRACK, 1]);
|
||||
assert_eq!(len as usize, (OPENING_FRAMES_PER_TRACK + 1) * 15);
|
||||
}
|
||||
|
||||
// With the diag target off, no side file is created and the rip is unaffected.
|
||||
#[test]
|
||||
fn opening_capture_new_is_none_when_diag_is_off() {
|
||||
let path = std::env::temp_dir().join(format!("fmk-diag-off-{}.mkv", std::process::id()));
|
||||
assert!(OpeningCapture::new(&path, 3).is_none());
|
||||
let mut side = path.as_os_str().to_os_string();
|
||||
side.push(".opening.bin");
|
||||
assert!(!std::path::Path::new(&side).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn res_str_keeps_interlace_marker() {
|
||||
assert_eq!(res_str(Resolution::R576i), "576i");
|
||||
assert_eq!(res_str(Resolution::R480i), "480i");
|
||||
assert_eq!(res_str(Resolution::R2160p), "2160p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fps_and_tv_system() {
|
||||
assert_eq!(fps_str(FrameRate::F25), "25");
|
||||
assert_eq!(tv_system_str(FrameRate::F25), "PAL");
|
||||
assert_eq!(fps_str(FrameRate::F29_97), "29.97");
|
||||
assert_eq!(tv_system_str(FrameRate::F29_97), "NTSC");
|
||||
assert_eq!(tv_system_str(FrameRate::F50), "PAL");
|
||||
assert_eq!(tv_system_str(FrameRate::F23_976), "NTSC");
|
||||
assert_eq!(tv_system_str(FrameRate::F59_94), "NTSC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_and_hdr() {
|
||||
assert_eq!(color_str(ColorSpace::Bt470bg), "BT.470BG");
|
||||
assert_eq!(color_str(ColorSpace::Bt2020), "BT.2020");
|
||||
assert_eq!(hdr_str(HdrFormat::Hdr10), "HDR10");
|
||||
assert_eq!(hdr_str(HdrFormat::DolbyVision), "DoVi");
|
||||
assert_eq!(hdr_str(HdrFormat::Sdr), "SDR");
|
||||
}
|
||||
|
||||
/// Moved from the deleted local duplicates onto the canonical accessors,
|
||||
/// with the Unknown case added — which is the whole point of the change.
|
||||
#[test]
|
||||
fn channel_count_matches_layout_and_is_zero_when_unknown() {
|
||||
assert_eq!(AudioChannels::Mono.count(), 1);
|
||||
assert_eq!(AudioChannels::Stereo.count(), 2);
|
||||
assert_eq!(AudioChannels::Surround51.count(), 6);
|
||||
assert_eq!(AudioChannels::Surround71.count(), 8);
|
||||
// The one that matters. This used to return 6, which is indistinguishable
|
||||
// from a real 5.1 track and left every caller responsible for checking
|
||||
// the variant first.
|
||||
assert_eq!(
|
||||
AudioChannels::Unknown.count(),
|
||||
0,
|
||||
"an unknown layout must not report a plausible channel count"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_rate_hz_values_and_zero_when_unknown() {
|
||||
assert_eq!(SampleRate::S48.hz(), 48000.0);
|
||||
assert_eq!(SampleRate::S96.hz(), 96000.0);
|
||||
assert_eq!(
|
||||
SampleRate::Unknown.hz(),
|
||||
0.0,
|
||||
"an unknown sample rate must not report a plausible 48 kHz"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_hex_renders_caps_and_handles_empty() {
|
||||
// None / empty → "none" (no hex). The Windows-fps diagnosis only needs
|
||||
// the seq-header prefix, so render it but cap long blobs.
|
||||
assert_eq!(codec_private_hex(None), "none");
|
||||
assert_eq!(codec_private_hex(Some(&[])), "none");
|
||||
// Short blob: full uppercase hex, no suffix. An MPEG-2 seq header starts
|
||||
// 00 00 01 B3 — exactly what a reader greps for in a bug log.
|
||||
assert_eq!(
|
||||
codec_private_hex(Some(&[0x00, 0x00, 0x01, 0xB3])),
|
||||
"000001B3"
|
||||
);
|
||||
// Over the cap: first CODEC_PRIVATE_HEX_CAP bytes + a "..(+NB)" summary.
|
||||
let big = vec![0xABu8; CODEC_PRIVATE_HEX_CAP + 5];
|
||||
let s = codec_private_hex(Some(&big));
|
||||
assert!(s.starts_with(&"AB".repeat(CODEC_PRIVATE_HEX_CAP)), "{s}");
|
||||
assert!(s.ends_with("..(+5B)"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_record_layout_is_parseable() {
|
||||
// The .opening.bin record framing must round-trip so a future tool can
|
||||
// split the side file back into frames without the disc:
|
||||
// [track:u8][keyframe:u8][pts_ns:i64 LE][len:u32 LE][raw bytes].
|
||||
let data = [0xDEu8, 0xAD, 0xBE, 0xEF];
|
||||
let rec = frame_record(2, -40_000_000, true, &data);
|
||||
assert_eq!(rec.len(), 14 + data.len());
|
||||
assert_eq!(rec[0], 2, "track index");
|
||||
assert_eq!(rec[1], 1, "keyframe flag");
|
||||
assert_eq!(
|
||||
i64::from_le_bytes(rec[2..10].try_into().unwrap()),
|
||||
-40_000_000,
|
||||
"pts_ns survives (signed — opening back-anchor can be negative)"
|
||||
);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(rec[10..14].try_into().unwrap()),
|
||||
4,
|
||||
"len"
|
||||
);
|
||||
assert_eq!(&rec[14..], &data, "raw frame bytes follow");
|
||||
// A non-keyframe records the flag as 0.
|
||||
let delta = frame_record(0, 0, false, &[]);
|
||||
assert_eq!(delta[1], 0);
|
||||
assert_eq!(u32::from_le_bytes(delta[10..14].try_into().unwrap()), 0);
|
||||
}
|
||||
|
||||
/// The cell row shows the raw category byte (0xNN) beside the decode, and
|
||||
/// the keep/drop verdict. A plain feature cell (0x00) is "keep"; a leading
|
||||
/// secondary-block cell flagged dropped reads "DROP".
|
||||
#[test]
|
||||
fn cell_row_shows_raw_byte_and_verdict() {
|
||||
let plain = crate::ifo::DvdCell {
|
||||
first_sector: 100,
|
||||
last_sector: 199,
|
||||
category: 0x00,
|
||||
duration_secs: 12.5,
|
||||
};
|
||||
let row = dvd_cell_row(0, &plain, false);
|
||||
assert!(row.contains("cat=0x00"), "{row}");
|
||||
assert!(row.contains("block_mode=0"), "{row}");
|
||||
assert!(row.contains("first=100"), "{row}");
|
||||
assert!(row.contains("last=199"), "{row}");
|
||||
assert!(row.contains("dur=12.5s"), "{row}");
|
||||
assert!(row.contains("keep(plain-feature)"), "{row}");
|
||||
assert!(!row.contains("DROP"), "{row}");
|
||||
|
||||
// 0x90 = in-block cell of an angle block (block_mode=2, block_type=1),
|
||||
// shown dropped as a leading secondary piece.
|
||||
let sec = crate::ifo::DvdCell {
|
||||
first_sector: 0,
|
||||
last_sector: 9,
|
||||
category: 0x90,
|
||||
duration_secs: 1.0,
|
||||
};
|
||||
let row = dvd_cell_row(0, &sec, true);
|
||||
assert!(row.contains("cat=0x90"), "{row}");
|
||||
assert!(row.contains("block_mode=2"), "{row}");
|
||||
assert!(row.contains("block_type=1"), "{row}");
|
||||
assert!(row.contains("DROP(leading-secondary-block-piece)"), "{row}");
|
||||
|
||||
// The same secondary piece past the leading run is kept, as feature body.
|
||||
let row = dvd_cell_row(3, &sec, false);
|
||||
assert!(row.contains("keep(feature-body)"), "{row}");
|
||||
assert!(!row.contains("keep(plain-feature)"), "{row}");
|
||||
}
|
||||
+72
-2
@@ -644,5 +644,75 @@ pub(super) fn encode(layout: &Layout) -> Result<MetaSectors> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "encode_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Reference check value for CRC-16/XMODEM: poly 0x1021 seeded at 0
|
||||
// (ECMA-167 7.2.4): "123456789" -> 0x31C3. CCITT-FALSE (seed 0xFFFF)
|
||||
// gives 0x29B1, a mutant udf.rs wouldn't catch — it never verifies a CRC.
|
||||
#[test]
|
||||
fn crc16_matches_the_ecma167_check_value() {
|
||||
assert_eq!(crc16(b"123456789"), 0x31C3);
|
||||
}
|
||||
|
||||
/// 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 Latin-1.
|
||||
#[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");
|
||||
}
|
||||
|
||||
/// L089: the PVD's interchange level must be 3 (not 2) — the level that
|
||||
/// permits a file to span multiple extents, needed for a large VOB/M2TS
|
||||
/// on a hybrid video disc. `file_set` already writes 3 for the same reason.
|
||||
#[test]
|
||||
fn primary_volume_interchange_level_permits_multi_extent_files() {
|
||||
let pvd = primary_volume("FREEMKV", 16, 0);
|
||||
assert_eq!(
|
||||
(
|
||||
u16::from_le_bytes([pvd[60], pvd[61]]),
|
||||
u16::from_le_bytes([pvd[62], pvd[63]]),
|
||||
),
|
||||
(3, 3),
|
||||
"PVD interchange level / max must be 3, permitting multi-extent files"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// Reference check value for CRC-16/XMODEM: poly 0x1021 seeded at 0
|
||||
// (ECMA-167 7.2.4): "123456789" -> 0x31C3. CCITT-FALSE (seed 0xFFFF)
|
||||
// gives 0x29B1, a mutant udf.rs wouldn't catch — it never verifies a CRC.
|
||||
#[test]
|
||||
fn crc16_matches_the_ecma167_check_value() {
|
||||
assert_eq!(crc16(b"123456789"), 0x31C3);
|
||||
}
|
||||
|
||||
/// 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 Latin-1.
|
||||
#[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");
|
||||
}
|
||||
|
||||
/// L089: the PVD's interchange level must be 3 (not 2) — the level that
|
||||
/// permits a file to span multiple extents, needed for a large VOB/M2TS
|
||||
/// on a hybrid video disc. `file_set` already writes 3 for the same reason.
|
||||
#[test]
|
||||
fn primary_volume_interchange_level_permits_multi_extent_files() {
|
||||
let pvd = primary_volume("FREEMKV", 16, 0);
|
||||
assert_eq!(
|
||||
(
|
||||
u16::from_le_bytes([pvd[60], pvd[61]]),
|
||||
u16::from_le_bytes([pvd[62], pvd[63]]),
|
||||
),
|
||||
(3, 3),
|
||||
"PVD interchange level / max must be 3, permitting multi-extent files"
|
||||
);
|
||||
}
|
||||
+291
-2
@@ -689,5 +689,294 @@ pub(super) fn metadata_block_count(root: &DirNode) -> u64 {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "layout_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// A file above the 30-bit AD ceiling must split on a block boundary, or the next extent's
|
||||
// bytes start mid-sector.
|
||||
#[test]
|
||||
fn a_file_past_the_ad_ceiling_splits_on_a_block_boundary() {
|
||||
let mut f = FileNode {
|
||||
name: "BIG.M2TS".into(),
|
||||
disc_path: "/BIG.M2TS".into(),
|
||||
host: PathBuf::new(),
|
||||
size: MAX_AD_BYTES + 4096,
|
||||
mtime: None,
|
||||
icb_lba: 0,
|
||||
unique_id: 0,
|
||||
extents: Vec::new(),
|
||||
};
|
||||
let end = place_file(&mut f, 1000).unwrap();
|
||||
assert_eq!(f.extents.len(), 2);
|
||||
assert_eq!(f.extents[0].bytes as u64, MAX_AD_BYTES);
|
||||
assert_eq!(f.extents[0].bytes % SECTOR as u32, 0, "block multiple");
|
||||
assert!(f.extents[0].bytes <= 0x3FFF_FFFF);
|
||||
assert_eq!(f.extents[1].bytes, 4096);
|
||||
assert_eq!(
|
||||
f.extents[1].lba,
|
||||
1000 + (MAX_AD_BYTES / SECTOR as u64) as u32,
|
||||
"the second extent starts where the first ends"
|
||||
);
|
||||
assert_eq!(end, f.extents[1].lba + 2);
|
||||
assert_eq!(
|
||||
f.extents.iter().map(|e| e.bytes as u64).sum::<u64>(),
|
||||
f.size,
|
||||
"no bytes lost or invented"
|
||||
);
|
||||
}
|
||||
|
||||
/// The metadata ceiling must account for each directory's FID-list sectors, not just one
|
||||
/// File Entry per node: a wide directory's FID list can span many sectors that the old
|
||||
/// (dir_count + file_count) count ignored, so an oversized folder could slip past the guard
|
||||
/// and be materialized in full.
|
||||
#[test]
|
||||
fn metadata_block_count_includes_the_fid_list_sectors() {
|
||||
let files: Vec<FileNode> = (0..300)
|
||||
.map(|i| FileNode {
|
||||
name: format!("A_REASONABLY_LONG_FILENAME_{i:04}.M2TS"),
|
||||
disc_path: format!("/{i}"),
|
||||
host: PathBuf::new(),
|
||||
size: 0,
|
||||
mtime: None,
|
||||
icb_lba: 0,
|
||||
unique_id: 0,
|
||||
extents: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
let root = DirNode {
|
||||
name: String::new(),
|
||||
icb_lba: 0,
|
||||
parent_icb_lba: 0,
|
||||
data_lba: 0,
|
||||
data_bytes: 0,
|
||||
unique_id: 0,
|
||||
dirs: Vec::new(),
|
||||
files,
|
||||
};
|
||||
|
||||
let mut dir_count = 0;
|
||||
let mut file_count = 0;
|
||||
count_nodes(&root, &mut dir_count, &mut file_count);
|
||||
// Old formula: one File Entry per node only.
|
||||
let file_entry_only = dir_count as u64 + file_count as u64;
|
||||
let fid_sectors = dir_bytes(&root.dirs, &root.files).div_ceil(SECTOR) as u64;
|
||||
assert!(
|
||||
fid_sectors > 1,
|
||||
"300 files must fill more than one FID sector"
|
||||
);
|
||||
// 2 (FSD + Terminating Descriptor) + File Entries + FID-list sectors.
|
||||
assert_eq!(
|
||||
metadata_block_count(&root),
|
||||
2 + file_entry_only + fid_sectors
|
||||
);
|
||||
assert!(
|
||||
metadata_block_count(&root) > file_entry_only,
|
||||
"the ceiling must count FID-list sectors the old formula omitted"
|
||||
);
|
||||
}
|
||||
|
||||
/// A zero-byte file records no allocation descriptors at all and consumes
|
||||
/// no blocks.
|
||||
#[test]
|
||||
fn an_empty_file_gets_no_extents() {
|
||||
let mut f = FileNode {
|
||||
name: "EMPTY".into(),
|
||||
disc_path: "/EMPTY".into(),
|
||||
host: PathBuf::new(),
|
||||
size: 0,
|
||||
mtime: None,
|
||||
icb_lba: 0,
|
||||
unique_id: 0,
|
||||
extents: Vec::new(),
|
||||
};
|
||||
assert_eq!(place_file(&mut f, 500).unwrap(), 500);
|
||||
assert!(f.extents.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_artefacts_are_not_disc_content() {
|
||||
assert!(is_excluded(".DS_Store"));
|
||||
assert!(is_excluded("._00000.m2ts"));
|
||||
assert!(is_excluded("00000.m2ts.partial"));
|
||||
assert!(!is_excluded("00000.m2ts"));
|
||||
assert!(!is_excluded("VTS_01_1.VOB"));
|
||||
}
|
||||
|
||||
// Two host names the READER collapses into one must be refused by the planner. Calls `plan`
|
||||
// on a real folder.
|
||||
#[test]
|
||||
fn two_names_the_reader_cannot_tell_apart_are_refused() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"fmkv-shadow-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let stream = dir.join("BDMV/STREAM");
|
||||
std::fs::create_dir_all(&stream).expect("mkdir");
|
||||
std::fs::write(stream.join("00000.m2ts"), b"a").expect("write");
|
||||
// Same name to the reader: it trims the leading space.
|
||||
std::fs::write(stream.join(" 00000.m2ts"), b"b").expect("write");
|
||||
|
||||
// Precondition — if this stops holding the fixture is wrong, not the code.
|
||||
assert_eq!(
|
||||
crate::udf::parse_udf_name(&crate::dirimage::encode::encode_cs0(" 00000.m2ts")),
|
||||
crate::udf::parse_udf_name(&crate::dirimage::encode::encode_cs0("00000.m2ts")),
|
||||
"fixture: these two host names must read back identically"
|
||||
);
|
||||
|
||||
let got = plan(&dir);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(
|
||||
matches!(got, Err(Error::DirNameCollision { .. })),
|
||||
"a name the reader cannot distinguish must be refused, got {got:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// A name too long for the FID's one-byte length field is refused by the planner, on a real
|
||||
// folder.
|
||||
#[test]
|
||||
fn an_over_long_name_is_refused_by_the_planner() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"fmkv-longname-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(dir.join("BDMV/STREAM")).expect("mkdir");
|
||||
// 255 bytes: the exact length that used to narrow to zero.
|
||||
let name = "a".repeat(255);
|
||||
assert_eq!(
|
||||
crate::dirimage::encode::encode_cs0(&name).len(),
|
||||
256,
|
||||
"fixture: NAME_MAX encodes to 256 bytes with the compression byte"
|
||||
);
|
||||
std::fs::write(dir.join("BDMV/STREAM").join(&name), b"x").expect("write");
|
||||
|
||||
let err = plan(&dir).expect_err("the planner must refuse this folder");
|
||||
assert!(
|
||||
matches!(err, Error::DirNameTooLong { .. }),
|
||||
"expected DirNameTooLong, got {err:?}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A name at the cap is accepted, so the guard rejects only what it must.
|
||||
#[test]
|
||||
fn a_name_at_the_cap_is_accepted() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"fmkv-okname-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(dir.join("BDMV/STREAM")).expect("mkdir");
|
||||
// One byte of name shorter, so the encoding lands exactly on the cap.
|
||||
let name = "a".repeat(MAX_CS0_NAME_BYTES - 1);
|
||||
assert_eq!(
|
||||
crate::dirimage::encode::encode_cs0(&name).len(),
|
||||
MAX_CS0_NAME_BYTES
|
||||
);
|
||||
std::fs::write(dir.join("BDMV/STREAM").join(&name), b"x").expect("write");
|
||||
assert!(
|
||||
plan(&dir).is_ok(),
|
||||
"a name whose encoding equals the cap must be accepted"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// The subdirectory cap must keep the 16-bit link count representable. Pins the arithmetic;
|
||||
// does NOT exercise `walk`.
|
||||
#[test]
|
||||
fn the_subdir_cap_refuses_a_folder_with_too_many_subdirectories() {
|
||||
// Executes the guard rather than restating the constant: link count (child
|
||||
// dirs + 1) is 16 bits, so exceeding it wraps and the image lies about
|
||||
// its own directory structure.
|
||||
let dir = std::env::temp_dir().join(format!("fmkv-fanout-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for i in 0..=MAX_SUBDIRS {
|
||||
std::fs::create_dir_all(dir.join(format!("d{i}"))).unwrap();
|
||||
}
|
||||
|
||||
let err = plan(&dir).expect_err("more subdirectories than the link count can represent");
|
||||
assert!(
|
||||
matches!(err, Error::DirImageFanout { .. }),
|
||||
"expected DirImageFanout, got {err:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// `walk` counts every entry against `MAX_ENTRIES`: the entry that would pass it is refused.
|
||||
#[test]
|
||||
fn the_entry_cap_refuses_the_entry_past_max_entries() {
|
||||
let dir = std::env::temp_dir().join(format!("fmkv-entries-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("a.bin"), b"x").unwrap();
|
||||
let mut at_cap = MAX_ENTRIES - 1;
|
||||
assert!(
|
||||
walk(&dir, "/", 0, &mut at_cap).is_ok(),
|
||||
"the last allowed entry"
|
||||
);
|
||||
let mut past = MAX_ENTRIES;
|
||||
assert!(matches!(
|
||||
walk(&dir, "/", 0, &mut past),
|
||||
Err(Error::DirImageTooLarge)
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// The nesting cap (`MAX_DEPTH`) refuses a tree deeper than the planner
|
||||
// represents, before it recurses without bound. Executes `walk`'s depth
|
||||
// guard on a real directory chain, not the constant.
|
||||
#[test]
|
||||
fn the_nesting_cap_refuses_a_tree_deeper_than_max_depth() {
|
||||
let base = std::env::temp_dir().join(format!("fmkv-depth-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
// MAX_DEPTH + 2 nested levels below the root guarantees a `walk` call at
|
||||
// a depth greater than MAX_DEPTH.
|
||||
let mut deep = base.clone();
|
||||
for _ in 0..(MAX_DEPTH as usize + 2) {
|
||||
deep = deep.join("d");
|
||||
}
|
||||
std::fs::create_dir_all(&deep).unwrap();
|
||||
|
||||
let err = plan(&base).expect_err("a tree deeper than the nesting cap must be refused");
|
||||
assert!(
|
||||
matches!(err, Error::DirImageTooLarge),
|
||||
"expected DirImageTooLarge, got {err:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
// Exactly MAX_DEPTH levels below the root is accepted; one more is refused.
|
||||
#[test]
|
||||
fn the_nesting_cap_accepts_exactly_max_depth() {
|
||||
let mk = |tag: &str, levels: usize| {
|
||||
let base = std::env::temp_dir().join(format!("fmkv-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
// BDMV is level 1, so the tree is a valid disc layout.
|
||||
let mut deep = base.join("BDMV");
|
||||
for _ in 1..levels {
|
||||
deep = deep.join("d");
|
||||
}
|
||||
std::fs::create_dir_all(&deep).unwrap();
|
||||
base
|
||||
};
|
||||
let ok = mk("depth-ok", MAX_DEPTH as usize);
|
||||
assert!(plan(&ok).is_ok(), "MAX_DEPTH levels must be accepted");
|
||||
let over = mk("depth-over", MAX_DEPTH as usize + 1);
|
||||
assert!(matches!(plan(&over), Err(Error::DirImageTooLarge)));
|
||||
let _ = std::fs::remove_dir_all(&ok);
|
||||
let _ = std::fs::remove_dir_all(&over);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// A file above the 30-bit AD ceiling must split on a block boundary, or the next extent's
|
||||
// bytes start mid-sector.
|
||||
#[test]
|
||||
fn a_file_past_the_ad_ceiling_splits_on_a_block_boundary() {
|
||||
let mut f = FileNode {
|
||||
name: "BIG.M2TS".into(),
|
||||
disc_path: "/BIG.M2TS".into(),
|
||||
host: PathBuf::new(),
|
||||
size: MAX_AD_BYTES + 4096,
|
||||
mtime: None,
|
||||
icb_lba: 0,
|
||||
unique_id: 0,
|
||||
extents: Vec::new(),
|
||||
};
|
||||
let end = place_file(&mut f, 1000).unwrap();
|
||||
assert_eq!(f.extents.len(), 2);
|
||||
assert_eq!(f.extents[0].bytes as u64, MAX_AD_BYTES);
|
||||
assert_eq!(f.extents[0].bytes % SECTOR as u32, 0, "block multiple");
|
||||
assert!(f.extents[0].bytes <= 0x3FFF_FFFF);
|
||||
assert_eq!(f.extents[1].bytes, 4096);
|
||||
assert_eq!(
|
||||
f.extents[1].lba,
|
||||
1000 + (MAX_AD_BYTES / SECTOR as u64) as u32,
|
||||
"the second extent starts where the first ends"
|
||||
);
|
||||
assert_eq!(end, f.extents[1].lba + 2);
|
||||
assert_eq!(
|
||||
f.extents.iter().map(|e| e.bytes as u64).sum::<u64>(),
|
||||
f.size,
|
||||
"no bytes lost or invented"
|
||||
);
|
||||
}
|
||||
|
||||
/// The metadata ceiling must account for each directory's FID-list sectors, not just one
|
||||
/// File Entry per node: a wide directory's FID list can span many sectors that the old
|
||||
/// (dir_count + file_count) count ignored, so an oversized folder could slip past the guard
|
||||
/// and be materialized in full.
|
||||
#[test]
|
||||
fn metadata_block_count_includes_the_fid_list_sectors() {
|
||||
let files: Vec<FileNode> = (0..300)
|
||||
.map(|i| FileNode {
|
||||
name: format!("A_REASONABLY_LONG_FILENAME_{i:04}.M2TS"),
|
||||
disc_path: format!("/{i}"),
|
||||
host: PathBuf::new(),
|
||||
size: 0,
|
||||
mtime: None,
|
||||
icb_lba: 0,
|
||||
unique_id: 0,
|
||||
extents: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
let root = DirNode {
|
||||
name: String::new(),
|
||||
icb_lba: 0,
|
||||
parent_icb_lba: 0,
|
||||
data_lba: 0,
|
||||
data_bytes: 0,
|
||||
unique_id: 0,
|
||||
dirs: Vec::new(),
|
||||
files,
|
||||
};
|
||||
|
||||
let mut dir_count = 0;
|
||||
let mut file_count = 0;
|
||||
count_nodes(&root, &mut dir_count, &mut file_count);
|
||||
// Old formula: one File Entry per node only.
|
||||
let file_entry_only = dir_count as u64 + file_count as u64;
|
||||
let fid_sectors = dir_bytes(&root.dirs, &root.files).div_ceil(SECTOR) as u64;
|
||||
assert!(
|
||||
fid_sectors > 1,
|
||||
"300 files must fill more than one FID sector"
|
||||
);
|
||||
// 2 (FSD + Terminating Descriptor) + File Entries + FID-list sectors.
|
||||
assert_eq!(
|
||||
metadata_block_count(&root),
|
||||
2 + file_entry_only + fid_sectors
|
||||
);
|
||||
assert!(
|
||||
metadata_block_count(&root) > file_entry_only,
|
||||
"the ceiling must count FID-list sectors the old formula omitted"
|
||||
);
|
||||
}
|
||||
|
||||
/// A zero-byte file records no allocation descriptors at all and consumes
|
||||
/// no blocks.
|
||||
#[test]
|
||||
fn an_empty_file_gets_no_extents() {
|
||||
let mut f = FileNode {
|
||||
name: "EMPTY".into(),
|
||||
disc_path: "/EMPTY".into(),
|
||||
host: PathBuf::new(),
|
||||
size: 0,
|
||||
mtime: None,
|
||||
icb_lba: 0,
|
||||
unique_id: 0,
|
||||
extents: Vec::new(),
|
||||
};
|
||||
assert_eq!(place_file(&mut f, 500).unwrap(), 500);
|
||||
assert!(f.extents.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_artefacts_are_not_disc_content() {
|
||||
assert!(is_excluded(".DS_Store"));
|
||||
assert!(is_excluded("._00000.m2ts"));
|
||||
assert!(is_excluded("00000.m2ts.partial"));
|
||||
assert!(!is_excluded("00000.m2ts"));
|
||||
assert!(!is_excluded("VTS_01_1.VOB"));
|
||||
}
|
||||
|
||||
// Two host names the READER collapses into one must be refused by the planner. Calls `plan`
|
||||
// on a real folder.
|
||||
#[test]
|
||||
fn two_names_the_reader_cannot_tell_apart_are_refused() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"fmkv-shadow-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let stream = dir.join("BDMV/STREAM");
|
||||
std::fs::create_dir_all(&stream).expect("mkdir");
|
||||
std::fs::write(stream.join("00000.m2ts"), b"a").expect("write");
|
||||
// Same name to the reader: it trims the leading space.
|
||||
std::fs::write(stream.join(" 00000.m2ts"), b"b").expect("write");
|
||||
|
||||
// Precondition — if this stops holding the fixture is wrong, not the code.
|
||||
assert_eq!(
|
||||
crate::udf::parse_udf_name(&crate::dirimage::encode::encode_cs0(" 00000.m2ts")),
|
||||
crate::udf::parse_udf_name(&crate::dirimage::encode::encode_cs0("00000.m2ts")),
|
||||
"fixture: these two host names must read back identically"
|
||||
);
|
||||
|
||||
let got = plan(&dir);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(
|
||||
matches!(got, Err(Error::DirNameCollision { .. })),
|
||||
"a name the reader cannot distinguish must be refused, got {got:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// A name too long for the FID's one-byte length field is refused by the planner, on a real
|
||||
// folder.
|
||||
#[test]
|
||||
fn an_over_long_name_is_refused_by_the_planner() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"fmkv-longname-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(dir.join("BDMV/STREAM")).expect("mkdir");
|
||||
// 255 bytes: the exact length that used to narrow to zero.
|
||||
let name = "a".repeat(255);
|
||||
assert_eq!(
|
||||
crate::dirimage::encode::encode_cs0(&name).len(),
|
||||
256,
|
||||
"fixture: NAME_MAX encodes to 256 bytes with the compression byte"
|
||||
);
|
||||
std::fs::write(dir.join("BDMV/STREAM").join(&name), b"x").expect("write");
|
||||
|
||||
let err = plan(&dir).expect_err("the planner must refuse this folder");
|
||||
assert!(
|
||||
matches!(err, Error::DirNameTooLong { .. }),
|
||||
"expected DirNameTooLong, got {err:?}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A name at the cap is accepted, so the guard rejects only what it must.
|
||||
#[test]
|
||||
fn a_name_at_the_cap_is_accepted() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"fmkv-okname-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(dir.join("BDMV/STREAM")).expect("mkdir");
|
||||
// One byte of name shorter, so the encoding lands exactly on the cap.
|
||||
let name = "a".repeat(MAX_CS0_NAME_BYTES - 1);
|
||||
assert_eq!(
|
||||
crate::dirimage::encode::encode_cs0(&name).len(),
|
||||
MAX_CS0_NAME_BYTES
|
||||
);
|
||||
std::fs::write(dir.join("BDMV/STREAM").join(&name), b"x").expect("write");
|
||||
assert!(
|
||||
plan(&dir).is_ok(),
|
||||
"a name whose encoding equals the cap must be accepted"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// The subdirectory cap must keep the 16-bit link count representable. Pins the arithmetic;
|
||||
// does NOT exercise `walk`.
|
||||
#[test]
|
||||
fn the_subdir_cap_refuses_a_folder_with_too_many_subdirectories() {
|
||||
// Executes the guard rather than restating the constant: link count (child
|
||||
// dirs + 1) is 16 bits, so exceeding it wraps and the image lies about
|
||||
// its own directory structure.
|
||||
let dir = std::env::temp_dir().join(format!("fmkv-fanout-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for i in 0..=MAX_SUBDIRS {
|
||||
std::fs::create_dir_all(dir.join(format!("d{i}"))).unwrap();
|
||||
}
|
||||
|
||||
let err = plan(&dir).expect_err("more subdirectories than the link count can represent");
|
||||
assert!(
|
||||
matches!(err, Error::DirImageFanout { .. }),
|
||||
"expected DirImageFanout, got {err:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// `walk` counts every entry against `MAX_ENTRIES`: the entry that would pass it is refused.
|
||||
#[test]
|
||||
fn the_entry_cap_refuses_the_entry_past_max_entries() {
|
||||
let dir = std::env::temp_dir().join(format!("fmkv-entries-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("a.bin"), b"x").unwrap();
|
||||
let mut at_cap = MAX_ENTRIES - 1;
|
||||
assert!(
|
||||
walk(&dir, "/", 0, &mut at_cap).is_ok(),
|
||||
"the last allowed entry"
|
||||
);
|
||||
let mut past = MAX_ENTRIES;
|
||||
assert!(matches!(
|
||||
walk(&dir, "/", 0, &mut past),
|
||||
Err(Error::DirImageTooLarge)
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// The nesting cap (`MAX_DEPTH`) refuses a tree deeper than the planner
|
||||
// represents, before it recurses without bound. Executes `walk`'s depth
|
||||
// guard on a real directory chain, not the constant.
|
||||
#[test]
|
||||
fn the_nesting_cap_refuses_a_tree_deeper_than_max_depth() {
|
||||
let base = std::env::temp_dir().join(format!("fmkv-depth-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
// MAX_DEPTH + 2 nested levels below the root guarantees a `walk` call at
|
||||
// a depth greater than MAX_DEPTH.
|
||||
let mut deep = base.clone();
|
||||
for _ in 0..(MAX_DEPTH as usize + 2) {
|
||||
deep = deep.join("d");
|
||||
}
|
||||
std::fs::create_dir_all(&deep).unwrap();
|
||||
|
||||
let err = plan(&base).expect_err("a tree deeper than the nesting cap must be refused");
|
||||
assert!(
|
||||
matches!(err, Error::DirImageTooLarge),
|
||||
"expected DirImageTooLarge, got {err:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
// Exactly MAX_DEPTH levels below the root is accepted; one more is refused.
|
||||
#[test]
|
||||
fn the_nesting_cap_accepts_exactly_max_depth() {
|
||||
let mk = |tag: &str, levels: usize| {
|
||||
let base = std::env::temp_dir().join(format!("fmkv-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
// BDMV is level 1, so the tree is a valid disc layout.
|
||||
let mut deep = base.join("BDMV");
|
||||
for _ in 1..levels {
|
||||
deep = deep.join("d");
|
||||
}
|
||||
std::fs::create_dir_all(&deep).unwrap();
|
||||
base
|
||||
};
|
||||
let ok = mk("depth-ok", MAX_DEPTH as usize);
|
||||
assert!(plan(&ok).is_ok(), "MAX_DEPTH levels must be accepted");
|
||||
let over = mk("depth-over", MAX_DEPTH as usize + 1);
|
||||
assert!(matches!(plan(&over), Err(Error::DirImageTooLarge)));
|
||||
let _ = std::fs::remove_dir_all(&ok);
|
||||
let _ = std::fs::remove_dir_all(&over);
|
||||
}
|
||||
+2658
-19
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1946
-181
File diff suppressed because it is too large
Load Diff
+239
-4
@@ -87,8 +87,8 @@ pub fn probe_and_remap<S: SectorSource + ?Sized>(
|
||||
if !tracing::enabled!(target: "freemkv::diag", tracing::Level::DEBUG) {
|
||||
return;
|
||||
}
|
||||
// Only DVD-Video titles (`DvdPs`) carry these private_stream_1 AC-3 sub-streams.
|
||||
if title.content_format != crate::disc::ContentFormat::DvdPs {
|
||||
// Only DVD (MPEG-PS) titles carry private_stream_1 AC-3 sub-streams.
|
||||
if title.content_format != crate::disc::ContentFormat::MpegPs {
|
||||
return;
|
||||
}
|
||||
// Nothing to report unless there is at least one AC-3 audio stream.
|
||||
@@ -120,5 +120,240 @@ pub fn probe_and_remap<S: SectorSource + ?Sized>(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "dvd_audio_probe_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::{
|
||||
AudioChannels, AudioStream, Codec, ContentFormat, DiscTitle, Extent, LabelPurpose,
|
||||
SampleRate,
|
||||
};
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
// Builds a correctly-SIZED AC-3 frame (128 bytes, matching frmsizecod=0) whose
|
||||
// `acmod`/`lfeon` encode a known channel count, via a bit writer so the test never
|
||||
// hand-miscomputes the lfeon offset.
|
||||
fn ac3_frame(acmod: u8, lfeon: bool) -> Vec<u8> {
|
||||
let mut bits: Vec<u8> = Vec::new();
|
||||
let push = |val: u32, n: usize, bits: &mut Vec<u8>| {
|
||||
for i in (0..n).rev() {
|
||||
bits.push(((val >> i) & 1) as u8);
|
||||
}
|
||||
};
|
||||
push(acmod as u32, 3, &mut bits);
|
||||
if (acmod & 0x1) != 0 && acmod != 0x1 {
|
||||
push(0, 2, &mut bits); // cmixlev
|
||||
}
|
||||
if (acmod & 0x4) != 0 {
|
||||
push(0, 2, &mut bits); // surmixlev
|
||||
}
|
||||
if acmod == 0x2 {
|
||||
push(0, 2, &mut bits); // dsurmod
|
||||
}
|
||||
push(lfeon as u32, 1, &mut bits);
|
||||
// Pack the bit vector MSB-first into bytes (byte6 onward).
|
||||
let mut tail = Vec::new();
|
||||
let mut cur = 0u8;
|
||||
for (i, b) in bits.iter().enumerate() {
|
||||
cur = (cur << 1) | b;
|
||||
if i % 8 == 7 {
|
||||
tail.push(cur);
|
||||
cur = 0;
|
||||
}
|
||||
}
|
||||
let rem = bits.len() % 8;
|
||||
if rem != 0 {
|
||||
cur <<= 8 - rem;
|
||||
tail.push(cur);
|
||||
}
|
||||
// AC-3 frame: 0x0B 0x77 crc(2) byte4(fscod=0,frmsizecod=0) bsid<<3 then BSI.
|
||||
let mut frame = vec![0x0B, 0x77, 0x00, 0x00, 0x00, 8u8 << 3];
|
||||
frame.extend_from_slice(&tail);
|
||||
// frmsizecod=0 @ 48kHz → 64 words = 128 bytes. Pad to the real size so
|
||||
// the frame-stepping in max_substream_channels lands on the next sync.
|
||||
frame.resize(128, 0);
|
||||
frame
|
||||
}
|
||||
|
||||
// Builds a minimal `private_stream_1` PES carrying `frames` for `sub_id`, mirroring the
|
||||
// on-disc layout the PS demux expects.
|
||||
fn ps_ac3_frames(sub_id: u8, frames: &[Vec<u8>]) -> Vec<u8> {
|
||||
// PES sub-header for AC-3: sub_id + frame_count + 2-byte access ptr.
|
||||
let mut payload = vec![sub_id, frames.len() as u8, 0x00, 0x04];
|
||||
for f in frames {
|
||||
payload.extend_from_slice(f);
|
||||
}
|
||||
// PES packet: start code 00 00 01 BD, length(2), flags(2), hdr_len(0).
|
||||
let pes_payload_len = 3 + payload.len(); // flags(2)+hdrlen(1)+payload
|
||||
let mut pkt = vec![0x00, 0x00, 0x01, 0xBD];
|
||||
pkt.extend_from_slice(&(pes_payload_len as u16).to_be_bytes());
|
||||
pkt.extend_from_slice(&[0x80, 0x00, 0x00]); // no PTS, header_data_len=0
|
||||
pkt.extend_from_slice(&payload);
|
||||
pkt
|
||||
}
|
||||
|
||||
/// Single-frame `private_stream_1` PES — the common case in existing tests.
|
||||
fn ps_ac3(sub_id: u8, acmod: u8, lfeon: bool) -> Vec<u8> {
|
||||
ps_ac3_frames(sub_id, &[ac3_frame(acmod, lfeon)])
|
||||
}
|
||||
|
||||
fn ac3_stream(pid: u16, channels: AudioChannels) -> Stream {
|
||||
Stream::Audio(AudioStream {
|
||||
pid,
|
||||
codec: Codec::Ac3,
|
||||
channels,
|
||||
language: "en".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The probe decodes the real channel count of each physical sub-stream.
|
||||
/// 0x80 carries a 2.0 frame (acmod=2,no lfe → 2ch); 0x81 carries 5.1
|
||||
/// (acmod=7 + lfe → 6ch).
|
||||
#[test]
|
||||
fn probe_decodes_per_substream_channels() {
|
||||
let mut bytes = ps_ac3(0x80, 2, false);
|
||||
bytes.extend(ps_ac3(0x81, 7, true));
|
||||
let probed = probe_ac3_substream_channels(&bytes);
|
||||
assert_eq!(probed.get(&0x80), Some(&2), "0x80 is the 2.0 down-mix");
|
||||
assert_eq!(probed.get(&0x81), Some(&6), "0x81 is the 5.1 main mix");
|
||||
}
|
||||
|
||||
// Real-disc regression: the probe must read each sub-stream's TRUE (max-mix) channel count
|
||||
// without cross-contaminating between sub-streams.
|
||||
#[test]
|
||||
fn probe_reads_max_channels_no_cross_contamination() {
|
||||
let mut bytes = Vec::new();
|
||||
// 0x80 opens with a 2.0 frame (the logo bed)...
|
||||
bytes.extend(ps_ac3_frames(0x80, &[ac3_frame(2, false)]));
|
||||
// ...0x81 interleaves a pure-2.0 PES (must NOT bleed 6 into 0x80)...
|
||||
bytes.extend(ps_ac3_frames(
|
||||
0x81,
|
||||
&[ac3_frame(2, false), ac3_frame(2, false)],
|
||||
));
|
||||
// ...then 0x80 reaches its real 5.1 main mix (acmod=7 + lfe → 6 ch),
|
||||
// with a trailing 2.0 frame in the SAME PES to prove we take the max,
|
||||
// not the last frame.
|
||||
bytes.extend(ps_ac3_frames(
|
||||
0x80,
|
||||
&[ac3_frame(7, true), ac3_frame(2, false)],
|
||||
));
|
||||
|
||||
let probed = probe_ac3_substream_channels(&bytes);
|
||||
assert_eq!(
|
||||
probed.get(&0x80),
|
||||
Some(&6),
|
||||
"0x80's real 5.1 mix must win over its 2.0 head/tail frames"
|
||||
);
|
||||
assert_eq!(
|
||||
probed.get(&0x81),
|
||||
Some(&2),
|
||||
"0x81 is a pure 2.0 stream — must not absorb 0x80's 6-channel frame"
|
||||
);
|
||||
}
|
||||
|
||||
// Mutation guard for `pos + rel` (not `pos - rel`, which could underflow `usize`) as the
|
||||
// sync's true absolute position.
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
|
||||
// On an unmappable AC-3 size, must fall back to a forward `start + 2` rescan (not loop or
|
||||
// overshoot).
|
||||
#[test]
|
||||
fn max_substream_channels_unmappable_size_steps_forward_by_two() {
|
||||
let mut real = ac3_frame(2, false);
|
||||
// Overwrite the real frame's (unchecked) CRC bytes, which double as byte4/5
|
||||
// of the bogus header at offset 4: 0xC0 (fscod=3 -> ac3_frame_size == 0,
|
||||
// unmappable) and 0xF8 (bsid=31 -> acmod_channels == None, no spurious count).
|
||||
real[2] = 0xC0;
|
||||
real[3] = 0xF8;
|
||||
let mut data = vec![0xAA, 0xAA, 0xAA, 0xAA]; // offsets 0..4, no sync
|
||||
data.push(0x0B); // offset 4: bogus header sync byte 0
|
||||
data.push(0x77); // offset 5: bogus header sync byte 1
|
||||
data.extend(real); // offset 6..: the real frame (also serves as the
|
||||
// bogus header's byte4/byte5 at offsets 8/9)
|
||||
assert_eq!(
|
||||
max_substream_channels(&data),
|
||||
Some(2),
|
||||
"must recover the real frame 2 bytes after the unmappable-size sync, not lose it"
|
||||
);
|
||||
}
|
||||
|
||||
// Same fallback, sync at offset 0 (`start - 2` would underflow).
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// L084b: the scanner routes by PGC_AST_CTL, which every player follows, so the probe must
|
||||
// not move a stream off it, even when the IFO channel count disagrees with the VOB.
|
||||
#[test]
|
||||
fn probe_never_overrides_the_ast_ctl_route() {
|
||||
let mut bytes = ps_ac3(0x80, 7, true); // 5.1 this PGC does not play
|
||||
bytes.extend(ps_ac3(0x81, 2, false)); // the AST_CTL-routed stream, really 2.0
|
||||
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(0xBD81, AudioChannels::Surround51)],
|
||||
chapters: Vec::new(),
|
||||
extents: vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 2,
|
||||
}],
|
||||
content_format: ContentFormat::MpegPs,
|
||||
codec_privates: vec![None],
|
||||
};
|
||||
let ((), ev) = crate::testlog::capture(|| {
|
||||
probe_and_remap(&mut FixedSource { data: bytes }, &mut title)
|
||||
});
|
||||
let probed: Vec<&str> = ev.iter().map(|e| e.message()).collect();
|
||||
assert!(
|
||||
probed.iter().any(|m| m.contains("sub_id=0x80 channels=6")),
|
||||
"the probe still runs and reports the VOB's real layout: {probed:?}"
|
||||
);
|
||||
let Stream::Audio(a) = &title.streams[0] else {
|
||||
panic!("audio")
|
||||
};
|
||||
assert_eq!(a.pid, 0xBD81);
|
||||
assert_eq!(a.channels, AudioChannels::Surround51);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
use super::*;
|
||||
use crate::disc::{
|
||||
AudioChannels, AudioStream, Codec, ContentFormat, DiscTitle, Extent, LabelPurpose, SampleRate,
|
||||
};
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
// Builds a correctly-SIZED AC-3 frame (128 bytes, matching frmsizecod=0) whose
|
||||
// `acmod`/`lfeon` encode a known channel count, via a bit writer so the test never
|
||||
// hand-miscomputes the lfeon offset.
|
||||
fn ac3_frame(acmod: u8, lfeon: bool) -> Vec<u8> {
|
||||
let mut bits: Vec<u8> = Vec::new();
|
||||
let push = |val: u32, n: usize, bits: &mut Vec<u8>| {
|
||||
for i in (0..n).rev() {
|
||||
bits.push(((val >> i) & 1) as u8);
|
||||
}
|
||||
};
|
||||
push(acmod as u32, 3, &mut bits);
|
||||
if (acmod & 0x1) != 0 && acmod != 0x1 {
|
||||
push(0, 2, &mut bits); // cmixlev
|
||||
}
|
||||
if (acmod & 0x4) != 0 {
|
||||
push(0, 2, &mut bits); // surmixlev
|
||||
}
|
||||
if acmod == 0x2 {
|
||||
push(0, 2, &mut bits); // dsurmod
|
||||
}
|
||||
push(lfeon as u32, 1, &mut bits);
|
||||
// Pack the bit vector MSB-first into bytes (byte6 onward).
|
||||
let mut tail = Vec::new();
|
||||
let mut cur = 0u8;
|
||||
for (i, b) in bits.iter().enumerate() {
|
||||
cur = (cur << 1) | b;
|
||||
if i % 8 == 7 {
|
||||
tail.push(cur);
|
||||
cur = 0;
|
||||
}
|
||||
}
|
||||
let rem = bits.len() % 8;
|
||||
if rem != 0 {
|
||||
cur <<= 8 - rem;
|
||||
tail.push(cur);
|
||||
}
|
||||
// AC-3 frame: 0x0B 0x77 crc(2) byte4(fscod=0,frmsizecod=0) bsid<<3 then BSI.
|
||||
let mut frame = vec![0x0B, 0x77, 0x00, 0x00, 0x00, 8u8 << 3];
|
||||
frame.extend_from_slice(&tail);
|
||||
// frmsizecod=0 @ 48kHz → 64 words = 128 bytes. Pad to the real size so
|
||||
// the frame-stepping in max_substream_channels lands on the next sync.
|
||||
frame.resize(128, 0);
|
||||
frame
|
||||
}
|
||||
|
||||
// Builds a minimal `private_stream_1` PES carrying `frames` for `sub_id`, mirroring the
|
||||
// on-disc layout the PS demux expects.
|
||||
fn ps_ac3_frames(sub_id: u8, frames: &[Vec<u8>]) -> Vec<u8> {
|
||||
// PES sub-header for AC-3: sub_id + frame_count + 2-byte access ptr.
|
||||
let mut payload = vec![sub_id, frames.len() as u8, 0x00, 0x04];
|
||||
for f in frames {
|
||||
payload.extend_from_slice(f);
|
||||
}
|
||||
// PES packet: start code 00 00 01 BD, length(2), flags(2), hdr_len(0).
|
||||
let pes_payload_len = 3 + payload.len(); // flags(2)+hdrlen(1)+payload
|
||||
let mut pkt = vec![0x00, 0x00, 0x01, 0xBD];
|
||||
pkt.extend_from_slice(&(pes_payload_len as u16).to_be_bytes());
|
||||
pkt.extend_from_slice(&[0x80, 0x00, 0x00]); // no PTS, header_data_len=0
|
||||
pkt.extend_from_slice(&payload);
|
||||
pkt
|
||||
}
|
||||
|
||||
/// Single-frame `private_stream_1` PES — the common case in existing tests.
|
||||
fn ps_ac3(sub_id: u8, acmod: u8, lfeon: bool) -> Vec<u8> {
|
||||
ps_ac3_frames(sub_id, &[ac3_frame(acmod, lfeon)])
|
||||
}
|
||||
|
||||
fn ac3_stream(pid: u16, channels: AudioChannels) -> Stream {
|
||||
Stream::Audio(AudioStream {
|
||||
pid,
|
||||
codec: Codec::Ac3,
|
||||
channels,
|
||||
language: "en".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The probe decodes the real channel count of each physical sub-stream.
|
||||
/// 0x80 carries a 2.0 frame (acmod=2,no lfe → 2ch); 0x81 carries 5.1
|
||||
/// (acmod=7 + lfe → 6ch).
|
||||
#[test]
|
||||
fn probe_decodes_per_substream_channels() {
|
||||
let mut bytes = ps_ac3(0x80, 2, false);
|
||||
bytes.extend(ps_ac3(0x81, 7, true));
|
||||
let probed = probe_ac3_substream_channels(&bytes);
|
||||
assert_eq!(probed.get(&0x80), Some(&2), "0x80 is the 2.0 down-mix");
|
||||
assert_eq!(probed.get(&0x81), Some(&6), "0x81 is the 5.1 main mix");
|
||||
}
|
||||
|
||||
// Real-disc regression: the probe must read each sub-stream's TRUE (max-mix) channel count
|
||||
// without cross-contaminating between sub-streams.
|
||||
#[test]
|
||||
fn probe_reads_max_channels_no_cross_contamination() {
|
||||
let mut bytes = Vec::new();
|
||||
// 0x80 opens with a 2.0 frame (the logo bed)...
|
||||
bytes.extend(ps_ac3_frames(0x80, &[ac3_frame(2, false)]));
|
||||
// ...0x81 interleaves a pure-2.0 PES (must NOT bleed 6 into 0x80)...
|
||||
bytes.extend(ps_ac3_frames(
|
||||
0x81,
|
||||
&[ac3_frame(2, false), ac3_frame(2, false)],
|
||||
));
|
||||
// ...then 0x80 reaches its real 5.1 main mix (acmod=7 + lfe → 6 ch),
|
||||
// with a trailing 2.0 frame in the SAME PES to prove we take the max,
|
||||
// not the last frame.
|
||||
bytes.extend(ps_ac3_frames(
|
||||
0x80,
|
||||
&[ac3_frame(7, true), ac3_frame(2, false)],
|
||||
));
|
||||
|
||||
let probed = probe_ac3_substream_channels(&bytes);
|
||||
assert_eq!(
|
||||
probed.get(&0x80),
|
||||
Some(&6),
|
||||
"0x80's real 5.1 mix must win over its 2.0 head/tail frames"
|
||||
);
|
||||
assert_eq!(
|
||||
probed.get(&0x81),
|
||||
Some(&2),
|
||||
"0x81 is a pure 2.0 stream — must not absorb 0x80's 6-channel frame"
|
||||
);
|
||||
}
|
||||
|
||||
// Mutation guard for `pos + rel` (not `pos - rel`, which could underflow `usize`) as the
|
||||
// sync's true absolute position.
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
|
||||
// On an unmappable AC-3 size, must fall back to a forward `start + 2` rescan (not loop or
|
||||
// overshoot).
|
||||
#[test]
|
||||
fn max_substream_channels_unmappable_size_steps_forward_by_two() {
|
||||
let mut real = ac3_frame(2, false);
|
||||
// Overwrite the real frame's (unchecked) CRC bytes, which double as byte4/5
|
||||
// of the bogus header at offset 4: 0xC0 (fscod=3 -> ac3_frame_size == 0,
|
||||
// unmappable) and 0xF8 (bsid=31 -> acmod_channels == None, no spurious count).
|
||||
real[2] = 0xC0;
|
||||
real[3] = 0xF8;
|
||||
let mut data = vec![0xAA, 0xAA, 0xAA, 0xAA]; // offsets 0..4, no sync
|
||||
data.push(0x0B); // offset 4: bogus header sync byte 0
|
||||
data.push(0x77); // offset 5: bogus header sync byte 1
|
||||
data.extend(real); // offset 6..: the real frame (also serves as the
|
||||
// bogus header's byte4/byte5 at offsets 8/9)
|
||||
assert_eq!(
|
||||
max_substream_channels(&data),
|
||||
Some(2),
|
||||
"must recover the real frame 2 bytes after the unmappable-size sync, not lose it"
|
||||
);
|
||||
}
|
||||
|
||||
// Same fallback, sync at offset 0 (`start - 2` would underflow).
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// L084b: the scanner routes by PGC_AST_CTL, which every player follows, so the probe must
|
||||
// not move a stream off it, even when the IFO channel count disagrees with the VOB.
|
||||
#[test]
|
||||
fn probe_never_overrides_the_ast_ctl_route() {
|
||||
let mut bytes = ps_ac3(0x80, 7, true); // 5.1 this PGC does not play
|
||||
bytes.extend(ps_ac3(0x81, 2, false)); // the AST_CTL-routed stream, really 2.0
|
||||
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(0xBD81, AudioChannels::Surround51)],
|
||||
chapters: Vec::new(),
|
||||
extents: vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 2,
|
||||
}],
|
||||
content_format: ContentFormat::DvdPs,
|
||||
codec_privates: vec![None],
|
||||
};
|
||||
let ((), ev) =
|
||||
crate::testlog::capture(|| probe_and_remap(&mut FixedSource { data: bytes }, &mut title));
|
||||
let probed: Vec<&str> = ev.iter().map(|e| e.message()).collect();
|
||||
assert!(
|
||||
probed.iter().any(|m| m.contains("sub_id=0x80 channels=6")),
|
||||
"the probe still runs and reports the VOB's real layout: {probed:?}"
|
||||
);
|
||||
let Stream::Audio(a) = &title.streams[0] else {
|
||||
panic!("audio")
|
||||
};
|
||||
assert_eq!(a.pid, 0xBD81);
|
||||
assert_eq!(a.channels, AudioChannels::Surround51);
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
//! Content-based forced-subtitle detection for DVD VobSub tracks.
|
||||
//!
|
||||
//! The DVD counterpart of [`super::pgs_forced_probe`]: a subpicture unit (SPU) whose display
|
||||
//! control sequence opens with FSTA_DSP (0x00, "Forced Start Display") is shown even with
|
||||
//! subtitles off. A track is forced iff EVERY sampled displayed SPU is forced and at least one
|
||||
//! was seen. Reads are bounded by the same budget and sample windows as the PGS probe, and the
|
||||
//! probe only ever SETS `forced`: the IFO code extension 9 is an equal signal it never clears.
|
||||
|
||||
use super::pgs_forced_probe::{PROBE_BUDGET_SECTORS, PROMOTE_MIN_DISPLAY_SETS, plan_windows};
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
use crate::disc::{Codec, DiscTitle, Extent, LabelQualifier, Stream};
|
||||
use crate::mux::ps::PsDemuxer;
|
||||
use crate::sector::SectorSource;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Sectors per read (~2 MiB), the PGS probe's chunk.
|
||||
const CHUNK_SECTORS: u16 = 1023;
|
||||
|
||||
// Sectors per read of the CSS crack, the image scan's batch.
|
||||
const CRACK_BATCH_SECTORS: u16 = 32;
|
||||
|
||||
// An SPU's 16-bit size field bounds it; a unit growing past this is corrupt.
|
||||
const MAX_SPU_BYTES: usize = 0xFFFF;
|
||||
|
||||
// SP_DCSQ commands (mpucoder "Sub-Pictures"; FFmpeg dvdsubdec.c decode_dvd_subtitles).
|
||||
const FSTA_DSP: u8 = 0x00;
|
||||
const STA_DSP: u8 = 0x01;
|
||||
const CMD_END: u8 = 0xFF;
|
||||
|
||||
/// How a displayed subpicture unit is started.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum SpuStart {
|
||||
/// FSTA_DSP: shown even when the player's subtitles are off.
|
||||
Forced,
|
||||
/// STA_DSP: shown only when the stream is selected.
|
||||
Normal,
|
||||
}
|
||||
|
||||
/// How the SPU in `spu` (SPUH included) starts displaying, from the first FSTA_DSP or STA_DSP
|
||||
/// command along its SP_DCSQ chain; `None` for a unit that never starts a display or does not
|
||||
/// parse. Offsets are big-endian u16s relative to the SPU start.
|
||||
pub(crate) fn spu_start(spu: &[u8]) -> Option<SpuStart> {
|
||||
let word = |o: usize| -> Option<usize> {
|
||||
Some(u16::from_be_bytes([*spu.get(o)?, *spu.get(o + 1)?]) as usize)
|
||||
};
|
||||
let size = word(0)?.min(spu.len());
|
||||
let mut dcsq = word(2)?;
|
||||
// Each SP_DCSQ moves forward or points at itself (the last one); bound the walk anyway.
|
||||
for _ in 0..64 {
|
||||
if dcsq + 4 > size {
|
||||
return None;
|
||||
}
|
||||
let next = word(dcsq + 2)?;
|
||||
let mut pos = dcsq + 4;
|
||||
while pos < size {
|
||||
let cmd = spu[pos];
|
||||
pos += 1;
|
||||
let args = match cmd {
|
||||
FSTA_DSP => return Some(SpuStart::Forced),
|
||||
STA_DSP => return Some(SpuStart::Normal),
|
||||
0x02 => 0,
|
||||
0x03 | 0x04 => 2,
|
||||
0x05 => 6,
|
||||
0x06 => 4,
|
||||
// CHG_COLCON: a size word counting itself, then its parameters.
|
||||
0x07 => word(pos)?,
|
||||
CMD_END => break,
|
||||
_ => return None,
|
||||
};
|
||||
pos += args;
|
||||
}
|
||||
if next <= dcsq {
|
||||
return None;
|
||||
}
|
||||
dcsq = next;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Reassembles one sub-stream's SPUs: a PES with a PTS starts a unit whose first two bytes give
|
||||
// its size; PTS-less PES continue it. A gap drops the open unit rather than splice across it.
|
||||
#[derive(Default)]
|
||||
struct SpuAssembler {
|
||||
pending: Option<(usize, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl SpuAssembler {
|
||||
fn push(&mut self, starts_unit: bool, data: &[u8]) -> Option<Vec<u8>> {
|
||||
if starts_unit {
|
||||
let size = match data {
|
||||
[hi, lo, ..] => u16::from_be_bytes([*hi, *lo]) as usize,
|
||||
_ => 0,
|
||||
};
|
||||
self.pending = (size >= 4).then(|| (size, Vec::with_capacity(size)));
|
||||
}
|
||||
let (size, buf) = self.pending.as_mut()?;
|
||||
buf.extend_from_slice(data);
|
||||
if buf.len() > MAX_SPU_BYTES {
|
||||
self.pending = None;
|
||||
return None;
|
||||
}
|
||||
if buf.len() < *size {
|
||||
return None;
|
||||
}
|
||||
let (size, mut buf) = self.pending.take()?;
|
||||
buf.truncate(size);
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
fn gap(&mut self) {
|
||||
self.pending = None;
|
||||
}
|
||||
}
|
||||
|
||||
// What the sampled SPUs showed about one subpicture track.
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
|
||||
struct SpuEvidence {
|
||||
/// Displayed SPUs seen.
|
||||
units: u32,
|
||||
/// At least one of them started with STA_DSP.
|
||||
non_forced: bool,
|
||||
}
|
||||
|
||||
impl SpuEvidence {
|
||||
fn observe(&mut self, start: SpuStart) {
|
||||
self.units = self.units.saturating_add(1);
|
||||
self.non_forced |= start == SpuStart::Normal;
|
||||
}
|
||||
}
|
||||
|
||||
// Why the read loop stopped; as in the PGS probe, only a designed stop lets "no non-forced SPU
|
||||
// was seen" stand as a forced verdict.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum StopReason {
|
||||
Exhausted,
|
||||
Budget,
|
||||
Halted,
|
||||
ReadFailed,
|
||||
}
|
||||
|
||||
/// Conclusive evidence of probed titles keyed by their extent list (with whether it was a
|
||||
/// sample), so a title reading the same cells as one already probed is not read again.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct DvdForcedProbeCache(HashMap<Vec<(u32, u32)>, TitleEvidence>);
|
||||
|
||||
// One probed title's per-track evidence, and whether it rests on a sample.
|
||||
type TitleEvidence = (HashMap<u16, SpuEvidence>, bool);
|
||||
|
||||
// CSS for the probe's reads: the first scrambled pack runs the title's one keyless crack
|
||||
// (the mux's own), after which packs are descrambled; with no key a scrambled pack is a gap.
|
||||
#[derive(Default)]
|
||||
struct Descrambler {
|
||||
key: Option<[u8; 5]>,
|
||||
tried: bool,
|
||||
}
|
||||
|
||||
impl Descrambler {
|
||||
// Leaves `sector` parseable (`Ok(true)`), or `Ok(false)` for a sector to skip; `Err` is a
|
||||
// Stop during the crack.
|
||||
fn clear(
|
||||
&mut self,
|
||||
sector: &mut [u8],
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> Result<bool, StopReason> {
|
||||
if !crate::css::is_scrambled_pack(sector) {
|
||||
return Ok(true);
|
||||
}
|
||||
if !self.tried {
|
||||
self.tried = true;
|
||||
match crate::css::crack_key_outcome(reader, extents, CRACK_BATCH_SECTORS, halt) {
|
||||
crate::css::CrackOutcome::Cracked(state) => self.key = Some(state.title_key),
|
||||
crate::css::CrackOutcome::Halted => return Err(StopReason::Halted),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(match self.key.as_mut() {
|
||||
Some(key) => crate::css::descramble_region(sector, key).is_ok(),
|
||||
None => false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a sample of the title's VobSub streams and set `forced` on each whose sampled SPUs all
|
||||
/// start with FSTA_DSP. Best-effort: never fails, never clears a flag, and an inconclusive run
|
||||
/// (halt, read fault) asserts nothing and is not memoised.
|
||||
pub(crate) fn probe_and_set_forced(
|
||||
reader: &mut dyn SectorSource,
|
||||
title: &mut DiscTitle,
|
||||
cache: &mut DvdForcedProbeCache,
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) {
|
||||
// Streams the IFO already marks forced have nothing left to learn.
|
||||
let pids: Vec<u16> = title
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Stream::Subtitle(sub) if sub.codec == Codec::DvdSub && !sub.forced => Some(sub.pid),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if pids.is_empty() || title.extents.is_empty() {
|
||||
return;
|
||||
}
|
||||
let key: Vec<(u32, u32)> = title
|
||||
.extents
|
||||
.iter()
|
||||
.map(|e| (e.start_lba, e.sector_count))
|
||||
.collect();
|
||||
if let Some((known, sampled)) = cache.0.get(&key)
|
||||
&& pids.iter().all(|p| known.contains_key(p))
|
||||
{
|
||||
let forced = verdicts(known, *sampled);
|
||||
apply_forced(title, &forced, true);
|
||||
return;
|
||||
}
|
||||
|
||||
let (evidence, stop, sampled, sectors_read) =
|
||||
read_evidence(reader, &title.extents, &pids, halt);
|
||||
let conclusive = matches!(stop, StopReason::Exhausted | StopReason::Budget);
|
||||
let forced = apply_forced(title, &verdicts(&evidence, sampled), conclusive);
|
||||
tracing::debug!(
|
||||
target: "freemkv::scan",
|
||||
title = title.playlist_id,
|
||||
stop = ?stop,
|
||||
sectors_read,
|
||||
tracks = pids.len(),
|
||||
forced,
|
||||
"dvd forced-subtitle probe"
|
||||
);
|
||||
if conclusive {
|
||||
cache.0.insert(key, (evidence, sampled));
|
||||
} else {
|
||||
tracing::debug!(
|
||||
target: "freemkv::scan",
|
||||
stop = ?stop,
|
||||
sectors_read,
|
||||
tracks = pids.len(),
|
||||
"forced-subtitle probe truncated; verdicts limited and truncated extents not cached"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The disc runs that title sectors `offset..offset + len` cover, counting along `extents` in
|
||||
// read order: (the extent's start, first LBA, sectors) each.
|
||||
fn title_runs(extents: &[Extent], offset: u32, len: u32) -> Vec<(u32, u32, u32)> {
|
||||
let (from, to) = (u64::from(offset), u64::from(offset) + u64::from(len));
|
||||
let mut runs = Vec::new();
|
||||
let mut at = 0u64;
|
||||
for ext in extents {
|
||||
let end = at + u64::from(ext.sector_count);
|
||||
let (lo, hi) = (from.max(at), to.min(end));
|
||||
if lo < hi {
|
||||
let lba = u64::from(ext.start_lba) + (lo - at);
|
||||
if let Ok(lba) = u32::try_from(lba) {
|
||||
runs.push((ext.start_lba, lba, (hi - lo) as u32));
|
||||
}
|
||||
}
|
||||
if end >= to {
|
||||
break;
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
// Sample the title for the tracks in `pids`: (evidence, why reading stopped, whether it was a
|
||||
// sample, sectors read). Windows span the title end to end, so thousands of small extents are
|
||||
// sampled across its length rather than read from the head until the budget runs out.
|
||||
fn read_evidence(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
pids: &[u16],
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> (HashMap<u16, SpuEvidence>, StopReason, bool, u32) {
|
||||
let mut evidence: HashMap<u16, SpuEvidence> =
|
||||
pids.iter().map(|&p| (p, SpuEvidence::default())).collect();
|
||||
let total: u64 = extents.iter().map(|e| u64::from(e.sector_count)).sum();
|
||||
let total = u32::try_from(total).unwrap_or(u32::MAX);
|
||||
// Once every track has shown a non-forced SPU, nothing further can change a verdict.
|
||||
let settled = |ev: &HashMap<u16, SpuEvidence>| ev.values().all(|e| e.non_forced);
|
||||
|
||||
let mut buf = vec![0u8; CHUNK_SECTORS as usize * SECTOR_BYTES];
|
||||
let mut css = Descrambler::default();
|
||||
let mut sectors_read: u32 = 0;
|
||||
let plan = plan_windows(total, PROBE_BUDGET_SECTORS);
|
||||
let sampled = !matches!(plan.as_slice(), [w] if w.offset == 0 && w.len == total);
|
||||
for window in &plan {
|
||||
// Demux and reassembly state are per window: windows are discontiguous. The runs of
|
||||
// one window are the title's contiguous playback, so they share it.
|
||||
let mut demux = PsDemuxer::new();
|
||||
let mut spus: HashMap<u16, SpuAssembler> =
|
||||
pids.iter().map(|&p| (p, SpuAssembler::default())).collect();
|
||||
for (base, mut lba, mut remaining) in title_runs(extents, window.offset, window.len) {
|
||||
reader.set_unit_base(base);
|
||||
while remaining > 0 {
|
||||
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||
return (evidence, StopReason::Halted, true, sectors_read);
|
||||
}
|
||||
if sectors_read >= PROBE_BUDGET_SECTORS {
|
||||
return (evidence, StopReason::Budget, true, sectors_read);
|
||||
}
|
||||
let count = remaining
|
||||
.min(u32::from(CHUNK_SECTORS))
|
||||
.min(PROBE_BUDGET_SECTORS - sectors_read) as u16;
|
||||
let want = count as usize * SECTOR_BYTES;
|
||||
let got = match reader.read_sectors(lba, count, &mut buf[..want], false) {
|
||||
Ok(n) => (n.min(want) / SECTOR_BYTES) as u32,
|
||||
Err(_) => return (evidence, StopReason::ReadFailed, true, sectors_read),
|
||||
};
|
||||
if got == 0 {
|
||||
return (evidence, StopReason::ReadFailed, true, sectors_read);
|
||||
}
|
||||
for sector in buf[..got as usize * SECTOR_BYTES].chunks_mut(SECTOR_BYTES) {
|
||||
let clear = match css.clear(sector, reader, extents, halt) {
|
||||
Ok(clear) => clear,
|
||||
Err(stop) => return (evidence, stop, true, sectors_read),
|
||||
};
|
||||
if !clear {
|
||||
observe(demux.flush(), &mut spus, &mut evidence);
|
||||
spus.values_mut().for_each(SpuAssembler::gap);
|
||||
continue;
|
||||
}
|
||||
observe(demux.feed(sector), &mut spus, &mut evidence);
|
||||
}
|
||||
lba = lba.saturating_add(got);
|
||||
remaining = remaining.saturating_sub(got);
|
||||
sectors_read += got;
|
||||
if settled(&evidence) {
|
||||
// The rest of the title goes unread: the evidence is a sample.
|
||||
return (evidence, StopReason::Exhausted, true, sectors_read);
|
||||
}
|
||||
}
|
||||
}
|
||||
observe(demux.flush(), &mut spus, &mut evidence);
|
||||
}
|
||||
(evidence, StopReason::Exhausted, sampled, sectors_read)
|
||||
}
|
||||
|
||||
// Feed demuxed packets to their tracks' SPU reassembly, recording each completed unit's start.
|
||||
fn observe(
|
||||
packets: Vec<crate::mux::ps::PsPacket>,
|
||||
spus: &mut HashMap<u16, SpuAssembler>,
|
||||
evidence: &mut HashMap<u16, SpuEvidence>,
|
||||
) {
|
||||
for p in packets {
|
||||
let Some(pid) = p.dvd_pid() else { continue };
|
||||
let (Some(asm), Some(ev)) = (spus.get_mut(&pid), evidence.get_mut(&pid)) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(start) = asm
|
||||
.push(p.pts.is_some(), &p.data)
|
||||
.as_deref()
|
||||
.and_then(spu_start)
|
||||
{
|
||||
ev.observe(start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The tracks the evidence calls forced: seen, never non-forced, and over a sample at least
|
||||
// PROMOTE_MIN_DISPLAY_SETS units, the PGS probe's guard against one stray forced unit.
|
||||
fn verdicts(evidence: &HashMap<u16, SpuEvidence>, sampled: bool) -> HashMap<u16, SpuEvidence> {
|
||||
evidence
|
||||
.iter()
|
||||
.filter(|(_, e)| e.units > 0 && !e.non_forced)
|
||||
.filter(|(_, e)| !sampled || e.units >= PROMOTE_MIN_DISPLAY_SETS)
|
||||
.map(|(&p, &e)| (p, e))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Mark the tracks in `forced` as forced when the run that found them may assert it; returns
|
||||
// how many were marked.
|
||||
fn apply_forced(
|
||||
title: &mut DiscTitle,
|
||||
forced: &HashMap<u16, SpuEvidence>,
|
||||
conclusive: bool,
|
||||
) -> usize {
|
||||
if !conclusive {
|
||||
return 0;
|
||||
}
|
||||
let mut marked = 0;
|
||||
for s in &mut title.streams {
|
||||
if let Stream::Subtitle(sub) = s
|
||||
&& sub.codec == Codec::DvdSub
|
||||
&& forced.contains_key(&sub.pid)
|
||||
{
|
||||
sub.forced = true;
|
||||
if sub.qualifier == LabelQualifier::None {
|
||||
sub.qualifier = LabelQualifier::Forced;
|
||||
}
|
||||
marked += 1;
|
||||
}
|
||||
}
|
||||
marked
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "dvd_forced_probe_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,193 +0,0 @@
|
||||
use super::*;
|
||||
use crate::disc::SubtitleStream;
|
||||
|
||||
// An SPU: header, 4 pixel bytes, a first SP_DCSQ opening with `start` (plus the usual
|
||||
// SET_COLOR/SET_CONTR/SET_DAREA/SET_DSPXA), then a last, self-pointing STP_DSP SP_DCSQ.
|
||||
fn spu(start: u8) -> Vec<u8> {
|
||||
let mut u = vec![0, 0, 0, 0, 0x11, 0x22, 0x33, 0x44];
|
||||
let first = u.len();
|
||||
u.extend_from_slice(&[0, 0, 0, 0, start]);
|
||||
u.extend_from_slice(&[0x03, 0x01, 0x23, 0x04, 0xFF, 0xF0]);
|
||||
u.extend_from_slice(&[0x05, 0, 0, 0x2C, 0, 0, 0x1F, 0x06, 0, 4, 0, 6, CMD_END]);
|
||||
let last = u.len();
|
||||
u.extend_from_slice(&[0, 0x10, 0, 0, 0x02, CMD_END]);
|
||||
u[first + 2..first + 4].copy_from_slice(&(last as u16).to_be_bytes());
|
||||
u[last + 2..last + 4].copy_from_slice(&(last as u16).to_be_bytes());
|
||||
let len = u.len() as u16;
|
||||
u[0..2].copy_from_slice(&len.to_be_bytes());
|
||||
u[2..4].copy_from_slice(&(first as u16).to_be_bytes());
|
||||
u
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spu_start_reads_the_first_display_command() {
|
||||
assert_eq!(spu_start(&spu(FSTA_DSP)), Some(SpuStart::Forced));
|
||||
assert_eq!(spu_start(&spu(STA_DSP)), Some(SpuStart::Normal));
|
||||
// A unit that only stops a display starts none.
|
||||
assert_eq!(spu_start(&spu(0x02)), None);
|
||||
// Truncated or garbage units do not parse.
|
||||
assert_eq!(spu_start(&spu(FSTA_DSP)[..6]), None);
|
||||
assert_eq!(spu_start(&[0, 8, 0, 4, 0, 0, 0, 4]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spu_start_skips_a_chg_colcon_parameter_area() {
|
||||
let mut u = vec![0, 0, 0, 4, 0, 0, 0, 4];
|
||||
// CHG_COLCON: size word 6 (itself + one LN_CTLI end marker), then STA_DSP.
|
||||
u.extend_from_slice(&[0x07, 0, 6, 0x0F, 0xFF, 0xFF, 0xFF, STA_DSP, CMD_END]);
|
||||
let len = u.len() as u16;
|
||||
u[0..2].copy_from_slice(&len.to_be_bytes());
|
||||
assert_eq!(spu_start(&u), Some(SpuStart::Normal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assembler_joins_a_unit_split_across_packets() {
|
||||
let unit = spu(FSTA_DSP);
|
||||
let mut asm = SpuAssembler::default();
|
||||
assert_eq!(asm.push(true, &unit[..10]), None);
|
||||
assert_eq!(asm.push(false, &unit[10..]), Some(unit.clone()));
|
||||
// A gap drops the open unit; its continuation is not spliced on.
|
||||
assert_eq!(asm.push(true, &unit[..10]), None);
|
||||
asm.gap();
|
||||
assert_eq!(asm.push(false, &unit[10..]), None);
|
||||
}
|
||||
|
||||
// One 2048-byte DVD pack carrying `spu` whole on sub-stream `sub`, padded to the sector.
|
||||
fn sector(sub: u8, spu: &[u8]) -> Vec<u8> {
|
||||
let mut s = vec![0, 0, 1, 0xBA, 0x44, 0, 4, 0, 4, 1, 1, 0x89, 0xC3, 0xF8];
|
||||
let pes_len = (3 + 5 + 1 + spu.len()) as u16;
|
||||
s.extend_from_slice(&[0, 0, 1, 0xBD]);
|
||||
s.extend_from_slice(&pes_len.to_be_bytes());
|
||||
s.extend_from_slice(&[0x81, 0x80, 0x05, 0x21, 0, 1, 0, 1, sub]);
|
||||
s.extend_from_slice(spu);
|
||||
let pad = (2048 - s.len() - 6) as u16;
|
||||
s.extend_from_slice(&[0, 0, 1, 0xBE]);
|
||||
s.extend_from_slice(&pad.to_be_bytes());
|
||||
s.resize(2048, 0xFF);
|
||||
s
|
||||
}
|
||||
|
||||
struct Image(Vec<u8>);
|
||||
impl SectorSource for Image {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
let at = lba as usize * SECTOR_BYTES;
|
||||
let n = (count as usize * SECTOR_BYTES).min(self.0.len().saturating_sub(at));
|
||||
buf[..n].copy_from_slice(&self.0[at..at + n]);
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
fn vobsub(pid: u16) -> Stream {
|
||||
Stream::Subtitle(SubtitleStream {
|
||||
pid,
|
||||
codec: Codec::DvdSub,
|
||||
language: "deu".into(),
|
||||
forced: false,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_data: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn probed(sectors: &[Vec<u8>], pids: &[u16], halt: Option<&crate::halt::Halt>) -> Vec<bool> {
|
||||
let mut title = DiscTitle {
|
||||
content_format: crate::disc::ContentFormat::DvdPs,
|
||||
streams: pids.iter().map(|&p| vobsub(p)).collect(),
|
||||
extents: vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: sectors.len() as u32,
|
||||
}],
|
||||
..DiscTitle::empty()
|
||||
};
|
||||
let mut reader = Image(sectors.concat());
|
||||
let mut cache = DvdForcedProbeCache::default();
|
||||
probe_and_set_forced(&mut reader, &mut title, &mut cache, halt);
|
||||
title
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Stream::Subtitle(s) => Some(s.forced),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stream_of_only_forced_units_is_forced_and_a_mixed_one_is_not() {
|
||||
let sectors = vec![
|
||||
sector(0x20, &spu(FSTA_DSP)),
|
||||
sector(0x21, &spu(FSTA_DSP)),
|
||||
sector(0x20, &spu(FSTA_DSP)),
|
||||
sector(0x21, &spu(STA_DSP)),
|
||||
sector(0x22, &spu(STA_DSP)),
|
||||
];
|
||||
// 0x20 all forced; 0x21 mixes both; 0x22 never forced; 0x23 never seen.
|
||||
assert_eq!(
|
||||
probed(§ors, &[0x20, 0x21, 0x22, 0x23], None),
|
||||
vec![true, false, false, false]
|
||||
);
|
||||
}
|
||||
|
||||
// A title of thousands of small extents (interleaved units) is sampled across its length
|
||||
// within the budget, not read from its head until the budget runs out.
|
||||
#[test]
|
||||
fn many_small_extents_are_sampled_across_the_title() {
|
||||
struct Spy(Vec<(u32, u16)>);
|
||||
impl SectorSource for Spy {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
self.0.push((lba, count));
|
||||
let n = count as usize * SECTOR_BYTES;
|
||||
buf[..n].fill(0);
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
let extents: Vec<Extent> = (0..5000)
|
||||
.map(|i| Extent {
|
||||
start_lba: i * 200,
|
||||
sector_count: 100,
|
||||
})
|
||||
.collect();
|
||||
let mut reader = Spy(Vec::new());
|
||||
let (_, _, sampled, read) = read_evidence(&mut reader, &extents, &[0x20], None);
|
||||
assert!(sampled);
|
||||
assert!(read <= PROBE_BUDGET_SECTORS, "read {read}");
|
||||
let last = reader.0.iter().map(|&(lba, _)| lba).max().unwrap();
|
||||
assert!(last >= 4000 * 200, "reads stop at LBA {last}");
|
||||
}
|
||||
|
||||
// A window's title sectors map onto the extents it crosses, in read order.
|
||||
#[test]
|
||||
fn title_runs_cross_extent_boundaries() {
|
||||
let extents = [
|
||||
Extent {
|
||||
start_lba: 10,
|
||||
sector_count: 5,
|
||||
},
|
||||
Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 5,
|
||||
},
|
||||
];
|
||||
assert_eq!(title_runs(&extents, 3, 4), vec![(10, 13, 2), (100, 100, 2)]);
|
||||
assert_eq!(title_runs(&extents, 5, 5), vec![(100, 100, 5)]);
|
||||
assert!(title_runs(&extents, 10, 5).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_halted_probe_asserts_nothing() {
|
||||
let sectors = vec![sector(0x20, &spu(FSTA_DSP)), sector(0x20, &spu(FSTA_DSP))];
|
||||
let halt = crate::halt::Halt::new();
|
||||
halt.cancel();
|
||||
assert_eq!(probed(§ors, &[0x20], Some(&halt)), vec![false]);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1165
-36
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2716
-2
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2653
-101
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+5829
-102
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1772
-8
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+288
-2
@@ -301,5 +301,291 @@ impl Disc {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "profile_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::{
|
||||
AudioChannels, AudioStream, Codec, ColorSpace, ContentFormat, DiscRegion, DiscTitle,
|
||||
FrameRate, HdrFormat, Resolution, SampleRate, Stream, SubtitleStream, VideoStream,
|
||||
};
|
||||
|
||||
/// A minimal titleless [`Disc`] for the disc-level tests.
|
||||
fn test_disc() -> Disc {
|
||||
Disc {
|
||||
volume_id: String::new(),
|
||||
meta_title: None,
|
||||
format: DiscFormat::BluRay,
|
||||
capacity_sectors: 0,
|
||||
capacity_bytes: 0,
|
||||
layers: 1,
|
||||
titles: Vec::new(),
|
||||
region: DiscRegion::Free,
|
||||
aacs: None,
|
||||
css: None,
|
||||
encrypted: false,
|
||||
aacs_error: None,
|
||||
css_error: None,
|
||||
content_format: ContentFormat::BdTs,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_title_is_none_on_empty_disc() {
|
||||
// A data-only image (no /BDMV, /HVDVD_TS, /VIDEO_TS) scans to zero
|
||||
// titles; main_title() must return None, not panic on titles[0].
|
||||
let profile = DiscProfile::from_disc(&test_disc());
|
||||
assert!(profile.titles.is_empty());
|
||||
assert!(profile.main_title().is_none());
|
||||
}
|
||||
|
||||
fn video(codec: Codec, res: Resolution, secondary: bool) -> Stream {
|
||||
Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec,
|
||||
resolution: res,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Hdr10,
|
||||
color_space: ColorSpace::Bt2020,
|
||||
display_aspect: None,
|
||||
secondary,
|
||||
label: String::new(),
|
||||
measured_cicp: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn audio(lang: &str, secondary: bool, purpose: LabelPurpose, label: &str) -> Stream {
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::TrueHd,
|
||||
channels: AudioChannels::Stereo,
|
||||
language: lang.into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary,
|
||||
purpose,
|
||||
label: label.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn subtitle(lang: &str, forced: bool, qualifier: LabelQualifier) -> Stream {
|
||||
Stream::Subtitle(SubtitleStream {
|
||||
pid: 0x1200,
|
||||
codec: Codec::Pgs,
|
||||
language: lang.into(),
|
||||
forced,
|
||||
qualifier,
|
||||
codec_data: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A BD transport-stream title with a mix of streams: two audio tracks
|
||||
/// (main + commentary), two subtitles (forced + SDH), a video track.
|
||||
fn bdts_title() -> DiscTitle {
|
||||
let mut t = DiscTitle::empty();
|
||||
t.content_format = ContentFormat::BdTs;
|
||||
t.playlist = "00800.mpls".into();
|
||||
t.duration_secs = 7200.0;
|
||||
t.size_bytes = 30_000_000_000;
|
||||
t.streams = vec![
|
||||
video(Codec::Hevc, Resolution::R2160p, false),
|
||||
audio("eng", false, LabelPurpose::Normal, "Dolby TrueHD 5.1"),
|
||||
audio("eng", true, LabelPurpose::Commentary, "Director"),
|
||||
subtitle("eng", true, LabelQualifier::Forced),
|
||||
subtitle("eng", false, LabelQualifier::Sdh),
|
||||
];
|
||||
t
|
||||
}
|
||||
|
||||
/// A DVD (MPEG program stream) title: two audio tracks (both non-secondary,
|
||||
/// second is descriptive), one language-less subtitle, one video track.
|
||||
fn dvd_title() -> DiscTitle {
|
||||
let mut t = DiscTitle::empty();
|
||||
t.content_format = ContentFormat::MpegPs;
|
||||
t.playlist = "VTS_01".into();
|
||||
t.duration_secs = 5400.0;
|
||||
t.size_bytes = 4_000_000_000;
|
||||
t.streams = vec![
|
||||
video(Codec::Mpeg2, Resolution::R576i, false),
|
||||
audio("eng", false, LabelPurpose::Normal, ""),
|
||||
audio("eng", false, LabelPurpose::Descriptive, ""),
|
||||
subtitle("", false, LabelQualifier::None),
|
||||
];
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_streams_into_typed_vectors_across_formats() {
|
||||
for (title, expect_subs) in [(bdts_title(), 2), (dvd_title(), 1)] {
|
||||
let p = TitleProfile::from_title(&title, 0, true);
|
||||
assert_eq!(p.video.len(), 1, "one video track");
|
||||
assert_eq!(p.audio.len(), 2, "two audio tracks");
|
||||
assert_eq!(p.subtitles.len(), expect_subs, "subtitle count per format");
|
||||
// Accessors return the same vectors.
|
||||
assert_eq!(p.video(), p.video.as_slice());
|
||||
assert_eq!(p.audio(), p.audio.as_slice());
|
||||
assert_eq!(p.subtitles(), p.subtitles.as_slice());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hoists_default_flags_first_non_secondary() {
|
||||
let p = TitleProfile::from_title(&bdts_title(), 0, true);
|
||||
// First (non-secondary) video/audio is the default.
|
||||
assert!(p.video[0].default, "first video defaults");
|
||||
assert!(p.audio[0].default, "first non-secondary audio defaults");
|
||||
// The commentary track is secondary → never default.
|
||||
assert!(
|
||||
!p.audio[1].default,
|
||||
"secondary/commentary audio not default"
|
||||
);
|
||||
// Subtitles are never default.
|
||||
assert!(p.subtitles.iter().all(|s| !s.default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_first_or_only_track_is_never_default() {
|
||||
let mut t = DiscTitle::empty();
|
||||
t.streams = vec![
|
||||
video(Codec::Hevc, Resolution::R2160p, true),
|
||||
video(Codec::Hevc, Resolution::R2160p, false),
|
||||
audio("eng", true, LabelPurpose::Commentary, "Director"),
|
||||
audio("eng", false, LabelPurpose::Normal, ""),
|
||||
];
|
||||
let p = TitleProfile::from_title(&t, 0, true);
|
||||
assert!(!p.video[0].default && p.video[1].default);
|
||||
assert!(!p.audio[0].default && p.audio[1].default);
|
||||
// Only a secondary track of each kind: nothing is default.
|
||||
t.streams = vec![
|
||||
video(Codec::Hevc, Resolution::R2160p, true),
|
||||
audio("eng", true, LabelPurpose::Commentary, ""),
|
||||
];
|
||||
let p = TitleProfile::from_title(&t, 0, true);
|
||||
assert!(!p.video[0].default && !p.audio[0].default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_default_cleared_when_two_non_secondary_audio() {
|
||||
// DVD title: BOTH audio tracks are non-secondary. Only the first keeps
|
||||
// the default flag (mirrors the muxer's "keep only first default").
|
||||
let p = TitleProfile::from_title(&dvd_title(), 0, true);
|
||||
assert!(p.audio[0].default, "first audio default");
|
||||
assert!(
|
||||
!p.audio[1].default,
|
||||
"second non-secondary audio default cleared"
|
||||
);
|
||||
assert!(p.audio[1].descriptive, "second audio is descriptive");
|
||||
assert!(!p.audio[1].commentary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_qualifier_and_purpose_and_language_defaults() {
|
||||
let p = TitleProfile::from_title(&bdts_title(), 0, true);
|
||||
// Audio purpose → commentary/descriptive booleans.
|
||||
assert!(!p.audio[0].commentary && !p.audio[0].descriptive);
|
||||
assert!(p.audio[1].commentary);
|
||||
assert_eq!(p.audio[1].name, "Director", "label maps to name");
|
||||
// Subtitle qualifier → forced/sdh booleans.
|
||||
assert!(p.subtitles[0].forced && !p.subtitles[0].sdh);
|
||||
assert!(p.subtitles[1].sdh && !p.subtitles[1].forced);
|
||||
// Language default: DVD title's blank subtitle language → "und".
|
||||
let dvd = TitleProfile::from_title(&dvd_title(), 0, true);
|
||||
assert_eq!(dvd.subtitles[0].language, "und");
|
||||
assert_eq!(dvd.audio[0].language, "eng");
|
||||
// Codec/resolution/hdr flattened as compact ids/labels.
|
||||
assert_eq!(p.video[0].codec, "hevc");
|
||||
assert_eq!(p.video[0].resolution, "2160p");
|
||||
assert_eq!(p.video[0].hdr, "hdr10");
|
||||
assert_eq!(dvd.video[0].codec, "mpeg2");
|
||||
}
|
||||
|
||||
// Forced is the probe/STN flag OR the label qualifier: either alone is enough.
|
||||
#[test]
|
||||
fn subtitle_forced_is_the_flag_or_the_qualifier() {
|
||||
for (flag, qualifier, want) in [
|
||||
(false, LabelQualifier::None, false),
|
||||
(true, LabelQualifier::None, true),
|
||||
(false, LabelQualifier::Forced, true),
|
||||
(true, LabelQualifier::Forced, true),
|
||||
] {
|
||||
let Stream::Subtitle(s) = subtitle("eng", flag, qualifier) else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(
|
||||
SubtitleTrack::from_stream(&s).forced,
|
||||
want,
|
||||
"{flag} {qualifier:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_disc_hoists_main_and_is_main() {
|
||||
let mut disc = test_disc();
|
||||
disc.format = DiscFormat::BluRay;
|
||||
disc.meta_title = Some("SOME MOVIE".into());
|
||||
disc.volume_id = "VOL_ID".into();
|
||||
disc.titles = vec![bdts_title(), dvd_title()];
|
||||
let profile = disc.profile();
|
||||
assert_eq!(profile.format, "bluray");
|
||||
assert_eq!(profile.disc_name, "SOME MOVIE");
|
||||
assert_eq!(profile.disc_id, "VOL_ID");
|
||||
assert_eq!(profile.main_title, 0);
|
||||
assert!(profile.titles[0].is_main, "titles[0] is the main feature");
|
||||
assert!(!profile.titles[1].is_main);
|
||||
assert_eq!(profile.titles[1].index, 1);
|
||||
assert_eq!(profile.main_title().unwrap().playlist, "00800.mpls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_name_falls_back_to_volume_id() {
|
||||
let mut disc = test_disc();
|
||||
disc.meta_title = None;
|
||||
disc.volume_id = "PLAIN_VOLUME".into();
|
||||
assert_eq!(disc.profile().disc_name, "PLAIN_VOLUME");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encryption_status_and_key_error_surface_numerically() {
|
||||
// Encrypted disc whose key resolution FAILED: the encrypted flag is set
|
||||
// and the numeric aacs_error code surfaces (never English text), so it
|
||||
// no longer serializes identically to a rippable disc.
|
||||
let mut disc = test_disc();
|
||||
disc.encrypted = true;
|
||||
let err = crate::error::Error::AacsVidUnavailable;
|
||||
let aacs_code = u32::from(err.code());
|
||||
disc.aacs_error = Some(err);
|
||||
let p = disc.profile();
|
||||
assert!(p.encrypted);
|
||||
assert_eq!(p.key_error, Some(aacs_code));
|
||||
|
||||
// AACS takes precedence: with BOTH errors set, the aacs code wins.
|
||||
let mut both = test_disc();
|
||||
both.encrypted = true;
|
||||
both.aacs_error = Some(crate::error::Error::AacsVidUnavailable);
|
||||
both.css_error = Some(crate::error::Error::CssKeyMissing);
|
||||
assert_eq!(both.profile().key_error, Some(aacs_code));
|
||||
|
||||
// A CSS-only failure surfaces the css code.
|
||||
let mut css = test_disc();
|
||||
css.encrypted = true;
|
||||
let css_err = crate::error::Error::CssKeyMissing;
|
||||
let css_code = u32::from(css_err.code());
|
||||
css.css_error = Some(css_err);
|
||||
assert_eq!(css.profile().key_error, Some(css_code));
|
||||
|
||||
// A clean, rippable disc: not encrypted, no key error.
|
||||
let clean = test_disc().profile();
|
||||
assert!(!clean.encrypted);
|
||||
assert_eq!(clean.key_error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip() {
|
||||
let mut disc = test_disc();
|
||||
disc.format = DiscFormat::Dvd;
|
||||
disc.volume_id = "RT".into();
|
||||
disc.titles = vec![bdts_title(), dvd_title()];
|
||||
let profile = disc.profile();
|
||||
let json = serde_json::to_string(&profile).expect("serialize");
|
||||
let back: DiscProfile = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(profile, back, "profile round-trips through serde");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
use super::*;
|
||||
use crate::disc::{
|
||||
AudioChannels, AudioStream, Codec, ColorSpace, ContentFormat, DiscRegion, DiscTitle, FrameRate,
|
||||
HdrFormat, Resolution, SampleRate, Stream, SubtitleStream, VideoStream,
|
||||
};
|
||||
|
||||
/// A minimal titleless [`Disc`] for the disc-level tests.
|
||||
fn test_disc() -> Disc {
|
||||
Disc {
|
||||
volume_id: String::new(),
|
||||
meta_title: None,
|
||||
format: DiscFormat::BluRay,
|
||||
capacity_sectors: 0,
|
||||
capacity_bytes: 0,
|
||||
layers: 1,
|
||||
titles: Vec::new(),
|
||||
region: DiscRegion::Free,
|
||||
aacs: None,
|
||||
css: None,
|
||||
encrypted: false,
|
||||
aacs_error: None,
|
||||
css_error: None,
|
||||
content_format: ContentFormat::BdTs,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_title_is_none_on_empty_disc() {
|
||||
// A data-only image (no /BDMV, /HVDVD_TS, /VIDEO_TS) scans to zero
|
||||
// titles; main_title() must return None, not panic on titles[0].
|
||||
let profile = DiscProfile::from_disc(&test_disc());
|
||||
assert!(profile.titles.is_empty());
|
||||
assert!(profile.main_title().is_none());
|
||||
}
|
||||
|
||||
fn video(codec: Codec, res: Resolution, secondary: bool) -> Stream {
|
||||
Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec,
|
||||
resolution: res,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Hdr10,
|
||||
color_space: ColorSpace::Bt2020,
|
||||
display_aspect: None,
|
||||
secondary,
|
||||
label: String::new(),
|
||||
measured_cicp: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn audio(lang: &str, secondary: bool, purpose: LabelPurpose, label: &str) -> Stream {
|
||||
Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::TrueHd,
|
||||
channels: AudioChannels::Stereo,
|
||||
language: lang.into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary,
|
||||
purpose,
|
||||
label: label.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn subtitle(lang: &str, forced: bool, qualifier: LabelQualifier) -> Stream {
|
||||
Stream::Subtitle(SubtitleStream {
|
||||
pid: 0x1200,
|
||||
codec: Codec::Pgs,
|
||||
language: lang.into(),
|
||||
forced,
|
||||
qualifier,
|
||||
codec_data: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A BD transport-stream title with a mix of streams: two audio tracks
|
||||
/// (main + commentary), two subtitles (forced + SDH), a video track.
|
||||
fn bdts_title() -> DiscTitle {
|
||||
let mut t = DiscTitle::empty();
|
||||
t.content_format = ContentFormat::BdTs;
|
||||
t.playlist = "00800.mpls".into();
|
||||
t.duration_secs = 7200.0;
|
||||
t.size_bytes = 30_000_000_000;
|
||||
t.streams = vec![
|
||||
video(Codec::Hevc, Resolution::R2160p, false),
|
||||
audio("eng", false, LabelPurpose::Normal, "Dolby TrueHD 5.1"),
|
||||
audio("eng", true, LabelPurpose::Commentary, "Director"),
|
||||
subtitle("eng", true, LabelQualifier::Forced),
|
||||
subtitle("eng", false, LabelQualifier::Sdh),
|
||||
];
|
||||
t
|
||||
}
|
||||
|
||||
/// A DVD (MPEG program stream) title: two audio tracks (both non-secondary,
|
||||
/// second is descriptive), one language-less subtitle, one video track.
|
||||
fn dvd_title() -> DiscTitle {
|
||||
let mut t = DiscTitle::empty();
|
||||
t.content_format = ContentFormat::MpegPs;
|
||||
t.playlist = "VTS_01".into();
|
||||
t.duration_secs = 5400.0;
|
||||
t.size_bytes = 4_000_000_000;
|
||||
t.streams = vec![
|
||||
video(Codec::Mpeg2, Resolution::R576i, false),
|
||||
audio("eng", false, LabelPurpose::Normal, ""),
|
||||
audio("eng", false, LabelPurpose::Descriptive, ""),
|
||||
subtitle("", false, LabelQualifier::None),
|
||||
];
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_streams_into_typed_vectors_across_formats() {
|
||||
for (title, expect_subs) in [(bdts_title(), 2), (dvd_title(), 1)] {
|
||||
let p = TitleProfile::from_title(&title, 0, true);
|
||||
assert_eq!(p.video.len(), 1, "one video track");
|
||||
assert_eq!(p.audio.len(), 2, "two audio tracks");
|
||||
assert_eq!(p.subtitles.len(), expect_subs, "subtitle count per format");
|
||||
// Accessors return the same vectors.
|
||||
assert_eq!(p.video(), p.video.as_slice());
|
||||
assert_eq!(p.audio(), p.audio.as_slice());
|
||||
assert_eq!(p.subtitles(), p.subtitles.as_slice());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hoists_default_flags_first_non_secondary() {
|
||||
let p = TitleProfile::from_title(&bdts_title(), 0, true);
|
||||
// First (non-secondary) video/audio is the default.
|
||||
assert!(p.video[0].default, "first video defaults");
|
||||
assert!(p.audio[0].default, "first non-secondary audio defaults");
|
||||
// The commentary track is secondary → never default.
|
||||
assert!(
|
||||
!p.audio[1].default,
|
||||
"secondary/commentary audio not default"
|
||||
);
|
||||
// Subtitles are never default.
|
||||
assert!(p.subtitles.iter().all(|s| !s.default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_first_or_only_track_is_never_default() {
|
||||
let mut t = DiscTitle::empty();
|
||||
t.streams = vec![
|
||||
video(Codec::Hevc, Resolution::R2160p, true),
|
||||
video(Codec::Hevc, Resolution::R2160p, false),
|
||||
audio("eng", true, LabelPurpose::Commentary, "Director"),
|
||||
audio("eng", false, LabelPurpose::Normal, ""),
|
||||
];
|
||||
let p = TitleProfile::from_title(&t, 0, true);
|
||||
assert!(!p.video[0].default && p.video[1].default);
|
||||
assert!(!p.audio[0].default && p.audio[1].default);
|
||||
// Only a secondary track of each kind: nothing is default.
|
||||
t.streams = vec![
|
||||
video(Codec::Hevc, Resolution::R2160p, true),
|
||||
audio("eng", true, LabelPurpose::Commentary, ""),
|
||||
];
|
||||
let p = TitleProfile::from_title(&t, 0, true);
|
||||
assert!(!p.video[0].default && !p.audio[0].default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_default_cleared_when_two_non_secondary_audio() {
|
||||
// DVD title: BOTH audio tracks are non-secondary. Only the first keeps
|
||||
// the default flag (mirrors the muxer's "keep only first default").
|
||||
let p = TitleProfile::from_title(&dvd_title(), 0, true);
|
||||
assert!(p.audio[0].default, "first audio default");
|
||||
assert!(
|
||||
!p.audio[1].default,
|
||||
"second non-secondary audio default cleared"
|
||||
);
|
||||
assert!(p.audio[1].descriptive, "second audio is descriptive");
|
||||
assert!(!p.audio[1].commentary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_qualifier_and_purpose_and_language_defaults() {
|
||||
let p = TitleProfile::from_title(&bdts_title(), 0, true);
|
||||
// Audio purpose → commentary/descriptive booleans.
|
||||
assert!(!p.audio[0].commentary && !p.audio[0].descriptive);
|
||||
assert!(p.audio[1].commentary);
|
||||
assert_eq!(p.audio[1].name, "Director", "label maps to name");
|
||||
// Subtitle qualifier → forced/sdh booleans.
|
||||
assert!(p.subtitles[0].forced && !p.subtitles[0].sdh);
|
||||
assert!(p.subtitles[1].sdh && !p.subtitles[1].forced);
|
||||
// Language default: DVD title's blank subtitle language → "und".
|
||||
let dvd = TitleProfile::from_title(&dvd_title(), 0, true);
|
||||
assert_eq!(dvd.subtitles[0].language, "und");
|
||||
assert_eq!(dvd.audio[0].language, "eng");
|
||||
// Codec/resolution/hdr flattened as compact ids/labels.
|
||||
assert_eq!(p.video[0].codec, "hevc");
|
||||
assert_eq!(p.video[0].resolution, "2160p");
|
||||
assert_eq!(p.video[0].hdr, "hdr10");
|
||||
assert_eq!(dvd.video[0].codec, "mpeg2");
|
||||
}
|
||||
|
||||
// Forced is the probe/STN flag OR the label qualifier: either alone is enough.
|
||||
#[test]
|
||||
fn subtitle_forced_is_the_flag_or_the_qualifier() {
|
||||
for (flag, qualifier, want) in [
|
||||
(false, LabelQualifier::None, false),
|
||||
(true, LabelQualifier::None, true),
|
||||
(false, LabelQualifier::Forced, true),
|
||||
(true, LabelQualifier::Forced, true),
|
||||
] {
|
||||
let Stream::Subtitle(s) = subtitle("eng", flag, qualifier) else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(
|
||||
SubtitleTrack::from_stream(&s).forced,
|
||||
want,
|
||||
"{flag} {qualifier:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_disc_hoists_main_and_is_main() {
|
||||
let mut disc = test_disc();
|
||||
disc.format = DiscFormat::BluRay;
|
||||
disc.meta_title = Some("SOME MOVIE".into());
|
||||
disc.volume_id = "VOL_ID".into();
|
||||
disc.titles = vec![bdts_title(), dvd_title()];
|
||||
let profile = disc.profile();
|
||||
assert_eq!(profile.format, "bluray");
|
||||
assert_eq!(profile.disc_name, "SOME MOVIE");
|
||||
assert_eq!(profile.disc_id, "VOL_ID");
|
||||
assert_eq!(profile.main_title, 0);
|
||||
assert!(profile.titles[0].is_main, "titles[0] is the main feature");
|
||||
assert!(!profile.titles[1].is_main);
|
||||
assert_eq!(profile.titles[1].index, 1);
|
||||
assert_eq!(profile.main_title().unwrap().playlist, "00800.mpls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_name_falls_back_to_volume_id() {
|
||||
let mut disc = test_disc();
|
||||
disc.meta_title = None;
|
||||
disc.volume_id = "PLAIN_VOLUME".into();
|
||||
assert_eq!(disc.profile().disc_name, "PLAIN_VOLUME");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encryption_status_and_key_error_surface_numerically() {
|
||||
// Encrypted disc whose key resolution FAILED: the encrypted flag is set
|
||||
// and the numeric aacs_error code surfaces (never English text), so it
|
||||
// no longer serializes identically to a rippable disc.
|
||||
let mut disc = test_disc();
|
||||
disc.encrypted = true;
|
||||
let err = crate::error::Error::AacsVidUnavailable;
|
||||
let aacs_code = u32::from(err.code());
|
||||
disc.aacs_error = Some(err);
|
||||
let p = disc.profile();
|
||||
assert!(p.encrypted);
|
||||
assert_eq!(p.key_error, Some(aacs_code));
|
||||
|
||||
// AACS takes precedence: with BOTH errors set, the aacs code wins.
|
||||
let mut both = test_disc();
|
||||
both.encrypted = true;
|
||||
both.aacs_error = Some(crate::error::Error::AacsVidUnavailable);
|
||||
both.css_error = Some(crate::error::Error::CssKeyMissing);
|
||||
assert_eq!(both.profile().key_error, Some(aacs_code));
|
||||
|
||||
// A CSS-only failure surfaces the css code.
|
||||
let mut css = test_disc();
|
||||
css.encrypted = true;
|
||||
let css_err = crate::error::Error::CssKeyMissing;
|
||||
let css_code = u32::from(css_err.code());
|
||||
css.css_error = Some(css_err);
|
||||
assert_eq!(css.profile().key_error, Some(css_code));
|
||||
|
||||
// A clean, rippable disc: not encrypted, no key error.
|
||||
let clean = test_disc().profile();
|
||||
assert!(!clean.encrypted);
|
||||
assert_eq!(clean.key_error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip() {
|
||||
let mut disc = test_disc();
|
||||
disc.format = DiscFormat::Dvd;
|
||||
disc.volume_id = "RT".into();
|
||||
disc.titles = vec![bdts_title(), dvd_title()];
|
||||
let profile = disc.profile();
|
||||
let json = serde_json::to_string(&profile).expect("serialize");
|
||||
let back: DiscProfile = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(profile, back, "profile round-trips through serde");
|
||||
}
|
||||
@@ -673,24 +673,7 @@ fn pre_ake_read_refusal_is_a_plain_scan_error() {
|
||||
assert_eq!(rig.count(|c| matches!(c[0], 0xA3 | 0xA4)), 0);
|
||||
}
|
||||
|
||||
// 64 video packs, each flagged scrambled (`[HD]` §4.3.2) or each clear (a decrypted rip).
|
||||
fn evo(scrambled: bool) -> Vec<u8> {
|
||||
(0..64u8)
|
||||
.flat_map(|i| {
|
||||
let mut p = crate::aacs::hddvd::tests::video_pack(i);
|
||||
if scrambled {
|
||||
p[20] |= 0x10;
|
||||
}
|
||||
p
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hddvd_disc() -> MemDisc {
|
||||
hddvd_disc_with(true)
|
||||
}
|
||||
|
||||
fn hddvd_disc_with(scrambled: bool) -> MemDisc {
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
@@ -701,7 +684,7 @@ fn hddvd_disc_with(scrambled: bool) -> MemDisc {
|
||||
name: "HVDVD_TS".into(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![file_with("MAIN.EVO", 100, 5_000, evo(scrambled), true)],
|
||||
files: vec![file("MAIN.EVO", 100, 5_000, 3_000_000, true)],
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
@@ -755,17 +738,6 @@ fn hddvd_image_aacs_dir_is_captured() {
|
||||
assert!(d.encrypted && d.aacs.is_some() && d.aacs_error.is_none());
|
||||
}
|
||||
|
||||
// A rip keeps a renamed AACS dir (`AACS!` here) over clear EVOs: encryption follows the packs'
|
||||
// scrambling flags, not the directory, for the fast identify and the full scan alike.
|
||||
#[test]
|
||||
fn hddvd_rip_with_an_aacs_dir_over_clear_packs_is_not_encrypted() {
|
||||
let mut rig = Rig::new(hddvd_disc_with(false), |_| {});
|
||||
assert!(!Disc::identify(&mut rig.drive).expect("identify").encrypted);
|
||||
let mut mem = hddvd_disc_with(false);
|
||||
let d = Disc::scan_image(&mut mem, 9_999, &ScanOptions::default()).expect("scan");
|
||||
assert!(!d.encrypted && d.aacs.is_none() && !d.titles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halted_during_capture_propagates() {
|
||||
for lba in [CERT_LBA, MKB_LBA] {
|
||||
|
||||
+67
-2
@@ -75,5 +75,70 @@ pub(crate) fn allowed_after_cancel(cdb: &[u8], ledger: Ledger, ctx: CleanupCtx)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "allow_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ALLOW: [u8; 6] = [0x1E, 0, 0, 0, 0x00, 0];
|
||||
const PREVENT: [u8; 6] = [0x1E, 0, 0, 0, 0x01, 0];
|
||||
const EJECT: [u8; 6] = EJECT_CDB;
|
||||
const START: [u8; 6] = [0x1B, 0, 0, 0, 0x01, 0];
|
||||
const READ: [u8; 10] = [0x28, 0, 0, 0, 0, 0, 0, 0, 1, 0];
|
||||
|
||||
fn release(agid: u8) -> [u8; 12] {
|
||||
let mut c = [0u8; 12];
|
||||
c[0] = REPORT_KEY;
|
||||
c[10] = (agid << 6) | KEY_FORMAT_INVALIDATE_AGID;
|
||||
c
|
||||
}
|
||||
|
||||
/// LD4 `exec_cleanup_allow_list`: one case per §2.4 row, and one refusal per row.
|
||||
/// Per evidence SS-7 (AGID) and per the §2.4 table; do not change without a design
|
||||
/// revision.
|
||||
#[test]
|
||||
fn exec_cleanup_allow_list() {
|
||||
let none = Ledger::default();
|
||||
let locked = Ledger {
|
||||
tray_locked: true,
|
||||
agids: 0,
|
||||
};
|
||||
let agid2 = Ledger {
|
||||
tray_locked: false,
|
||||
agids: 1 << 2,
|
||||
};
|
||||
use CleanupCtx::*;
|
||||
// Row 1: ALLOW iff tray_locked.
|
||||
assert!(allowed_after_cancel(&ALLOW, locked, Plain));
|
||||
assert!(!allowed_after_cancel(&ALLOW, none, Plain));
|
||||
assert!(
|
||||
!allowed_after_cancel(&PREVENT, locked, Plain),
|
||||
"never PREVENT"
|
||||
);
|
||||
// Row 2: LoEj only inside finish(Eject).
|
||||
assert!(allowed_after_cancel(&EJECT, none, FinishEject));
|
||||
assert!(!allowed_after_cancel(&EJECT, locked, Plain));
|
||||
assert!(!allowed_after_cancel(&START, none, FinishEject), "LoEj=0");
|
||||
// LoEj=1 with Start=1 loads the tray: not an eject.
|
||||
let load = [0x1B, 0, 0, 0, 0x03, 0];
|
||||
assert!(!allowed_after_cancel(&load, none, FinishEject), "Start=1");
|
||||
// Row 3: 0x3F iff that AGID's bit is set.
|
||||
assert!(allowed_after_cancel(&release(2), agid2, Plain));
|
||||
assert!(!allowed_after_cancel(&release(1), agid2, Plain));
|
||||
assert!(!allowed_after_cancel(&release(2), none, Plain));
|
||||
// Row 4: any CDB inside a pre-cancel critical section.
|
||||
assert!(allowed_after_cancel(&READ, none, Critical));
|
||||
assert!(!allowed_after_cancel(&READ, locked, Plain));
|
||||
assert!(!allowed_after_cancel(&READ, agid2, FinishEject));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidated_agid_reads_byte_10() {
|
||||
assert_eq!(invalidated_agid(&release(3)), Some(3));
|
||||
let mut alloc = release(0);
|
||||
alloc[10] = 0x00;
|
||||
assert_eq!(invalidated_agid(&alloc), None, "format 0 allocates");
|
||||
assert_eq!(invalidated_agid(&[0xA4, 0, 0]), None, "short CDB");
|
||||
let mut other = release(3);
|
||||
other[0] = 0x00;
|
||||
assert_eq!(invalidated_agid(&other), None, "not REPORT KEY");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
const ALLOW: [u8; 6] = [0x1E, 0, 0, 0, 0x00, 0];
|
||||
const PREVENT: [u8; 6] = [0x1E, 0, 0, 0, 0x01, 0];
|
||||
const EJECT: [u8; 6] = EJECT_CDB;
|
||||
const START: [u8; 6] = [0x1B, 0, 0, 0, 0x01, 0];
|
||||
const READ: [u8; 10] = [0x28, 0, 0, 0, 0, 0, 0, 0, 1, 0];
|
||||
|
||||
fn release(agid: u8) -> [u8; 12] {
|
||||
let mut c = [0u8; 12];
|
||||
c[0] = REPORT_KEY;
|
||||
c[10] = (agid << 6) | KEY_FORMAT_INVALIDATE_AGID;
|
||||
c
|
||||
}
|
||||
|
||||
/// LD4 `exec_cleanup_allow_list`: one case per §2.4 row, and one refusal per row.
|
||||
/// Per evidence SS-7 (AGID) and per the §2.4 table; do not change without a design
|
||||
/// revision.
|
||||
#[test]
|
||||
fn exec_cleanup_allow_list() {
|
||||
let none = Ledger::default();
|
||||
let locked = Ledger {
|
||||
tray_locked: true,
|
||||
agids: 0,
|
||||
};
|
||||
let agid2 = Ledger {
|
||||
tray_locked: false,
|
||||
agids: 1 << 2,
|
||||
};
|
||||
use CleanupCtx::*;
|
||||
// Row 1: ALLOW iff tray_locked.
|
||||
assert!(allowed_after_cancel(&ALLOW, locked, Plain));
|
||||
assert!(!allowed_after_cancel(&ALLOW, none, Plain));
|
||||
assert!(
|
||||
!allowed_after_cancel(&PREVENT, locked, Plain),
|
||||
"never PREVENT"
|
||||
);
|
||||
// Row 2: LoEj only inside finish(Eject).
|
||||
assert!(allowed_after_cancel(&EJECT, none, FinishEject));
|
||||
assert!(!allowed_after_cancel(&EJECT, locked, Plain));
|
||||
assert!(!allowed_after_cancel(&START, none, FinishEject), "LoEj=0");
|
||||
// LoEj=1 with Start=1 loads the tray: not an eject.
|
||||
let load = [0x1B, 0, 0, 0, 0x03, 0];
|
||||
assert!(!allowed_after_cancel(&load, none, FinishEject), "Start=1");
|
||||
// Row 3: 0x3F iff that AGID's bit is set.
|
||||
assert!(allowed_after_cancel(&release(2), agid2, Plain));
|
||||
assert!(!allowed_after_cancel(&release(1), agid2, Plain));
|
||||
assert!(!allowed_after_cancel(&release(2), none, Plain));
|
||||
// Row 4: any CDB inside a pre-cancel critical section.
|
||||
assert!(allowed_after_cancel(&READ, none, Critical));
|
||||
assert!(!allowed_after_cancel(&READ, locked, Plain));
|
||||
assert!(!allowed_after_cancel(&READ, agid2, FinishEject));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidated_agid_reads_byte_10() {
|
||||
assert_eq!(invalidated_agid(&release(3)), Some(3));
|
||||
let mut alloc = release(0);
|
||||
alloc[10] = 0x00;
|
||||
assert_eq!(invalidated_agid(&alloc), None, "format 0 allocates");
|
||||
assert_eq!(invalidated_agid(&[0xA4, 0, 0]), None, "short CDB");
|
||||
let mut other = release(3);
|
||||
other[0] = 0x00;
|
||||
assert_eq!(invalidated_agid(&other), None, "not REPORT KEY");
|
||||
}
|
||||
+19
-2
@@ -94,5 +94,22 @@ pub fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "linux_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Unfiltered sg nodes (sysfs unreadable) must be INQUIRY-checked before a
|
||||
// Drive::open; sysfs-filtered ones are not probed at all.
|
||||
#[test]
|
||||
fn optical_candidates_probes_only_when_sysfs_did_not_filter() {
|
||||
let paths = || ["/dev/sg0", "/dev/sg1"].map(String::from).into_iter();
|
||||
let mut probed = Vec::new();
|
||||
let kept = optical_candidates(paths(), true, |p| {
|
||||
probed.push(p.to_string());
|
||||
false
|
||||
});
|
||||
assert_eq!(kept, ["/dev/sg0", "/dev/sg1"]);
|
||||
assert!(probed.is_empty(), "type-filtered paths need no INQUIRY");
|
||||
let kept = optical_candidates(paths(), false, |p| p == "/dev/sg1");
|
||||
assert_eq!(kept, ["/dev/sg1"], "non-optical sg0 dropped");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// Unfiltered sg nodes (sysfs unreadable) must be INQUIRY-checked before a
|
||||
// Drive::open; sysfs-filtered ones are not probed at all.
|
||||
#[test]
|
||||
fn optical_candidates_probes_only_when_sysfs_did_not_filter() {
|
||||
let paths = || ["/dev/sg0", "/dev/sg1"].map(String::from).into_iter();
|
||||
let mut probed = Vec::new();
|
||||
let kept = optical_candidates(paths(), true, |p| {
|
||||
probed.push(p.to_string());
|
||||
false
|
||||
});
|
||||
assert_eq!(kept, ["/dev/sg0", "/dev/sg1"]);
|
||||
assert!(probed.is_empty(), "type-filtered paths need no INQUIRY");
|
||||
let kept = optical_candidates(paths(), false, |p| p == "/dev/sg1");
|
||||
assert_eq!(kept, ["/dev/sg1"], "non-optical sg0 dropped");
|
||||
}
|
||||
+28
-2
@@ -17,5 +17,31 @@ pub fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "macos_resolve_device_tests.rs"]
|
||||
mod resolve_device_tests;
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
+2464
-4
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,95 +0,0 @@
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct Idle;
|
||||
impl ScsiTransport for Idle {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_: &[u8],
|
||||
_: crate::scsi::DataDirection,
|
||||
_: &mut [u8],
|
||||
_: u32,
|
||||
) -> Result<crate::scsi::ScsiResult> {
|
||||
unreachable!("pause issues no CDB")
|
||||
}
|
||||
}
|
||||
|
||||
fn idle() -> Drive {
|
||||
Drive::from_transport_for_test(Box::new(Idle))
|
||||
}
|
||||
|
||||
// `Drive::pause` (was `sleep_until_halted`) is `Halt::wait` on the attached token.
|
||||
#[test]
|
||||
fn pause_completes_when_not_halted() {
|
||||
let t0 = Instant::now();
|
||||
assert!(idle().pause(Duration::from_millis(150)).is_ok());
|
||||
assert!(t0.elapsed() >= Duration::from_millis(140));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_returns_immediately_if_prehalted() {
|
||||
let d = idle();
|
||||
d.halt();
|
||||
let t0 = Instant::now();
|
||||
assert!(matches!(
|
||||
d.pause(Duration::from_secs(10)),
|
||||
Err(Error::Halted)
|
||||
));
|
||||
assert!(t0.elapsed() < Duration::from_millis(200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_wakes_mid_sleep() {
|
||||
let d = idle();
|
||||
let flag = d.halt_flag();
|
||||
let t0 = Instant::now();
|
||||
let stopper = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
flag.store(true, std::sync::atomic::Ordering::Release);
|
||||
});
|
||||
let r = d.pause(Duration::from_secs(10));
|
||||
stopper.join().unwrap();
|
||||
assert!(matches!(r, Err(Error::Halted)));
|
||||
let waited = t0.elapsed();
|
||||
assert!(waited < Duration::from_secs(2), "waited {waited:?}");
|
||||
assert!(waited >= Duration::from_millis(140), "waited {waited:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_zero_duration_is_noop_when_not_halted() {
|
||||
assert!(idle().pause(Duration::ZERO).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_capacity_short_transfer_is_rejected() {
|
||||
// bytes_transferred < 4 must NOT decode to capacity=1 from
|
||||
// zero-init bytes.
|
||||
let buf = [0u8; 8];
|
||||
assert!(matches!(
|
||||
decode_read_capacity(&buf, 0),
|
||||
Err(Error::DiscCapacityMalformed)
|
||||
));
|
||||
assert!(matches!(
|
||||
decode_read_capacity(&buf, 3),
|
||||
Err(Error::DiscCapacityMalformed)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_capacity_full_transfer_decodes_last_lba_plus_one() {
|
||||
// last_lba = 0x00012344 -> capacity 0x00012345.
|
||||
let buf = [0x00, 0x01, 0x23, 0x44, 0, 0, 0, 0];
|
||||
assert_eq!(decode_read_capacity(&buf, 8).unwrap(), 0x0001_2345);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_capacity_overflow_is_rejected() {
|
||||
// last_lba = u32::MAX (the "capacity exceeds 32-bit" sentinel) -> +1
|
||||
// overflows; reported as the distinct DiscCapacityOverflow, not the
|
||||
// short-transfer DiscCapacityMalformed.
|
||||
let buf = [0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0, 0];
|
||||
assert!(matches!(
|
||||
decode_read_capacity(&buf, 8),
|
||||
Err(Error::DiscCapacityOverflow)
|
||||
));
|
||||
}
|
||||
@@ -138,7 +138,7 @@ fn every_drive_cdb_goes_through_exec() {
|
||||
#[test]
|
||||
fn no_raw_execute_outside_dispatch() {
|
||||
let src = include_str!("mod.rs");
|
||||
let body = src;
|
||||
let body = &src[..src.find("#[cfg(test)]\nmod halt_tests").unwrap()];
|
||||
let raw = body.matches(".execute(").count();
|
||||
assert_eq!(raw, 1, "only `dispatch` calls the transport's execute");
|
||||
let id = include_str!("../identity.rs");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user