mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
717
Commits
qa
..
v1.7.7-rc14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c8c9ef3bd | ||
|
|
a27431d962 | ||
|
|
ff96202a22 | ||
|
|
dc1a0cec5a | ||
|
|
3b0c1f2c6c | ||
|
|
1ea2b22ced | ||
|
|
4d5987b21e | ||
|
|
b3e1a93e9e | ||
|
|
ae7ca7a457 | ||
|
|
5d3bc2edb3 | ||
|
|
318a1d6768 | ||
|
|
b998aa1261 | ||
|
|
3992cee538 | ||
|
|
2b8133ed5d | ||
|
|
ff48331f02 | ||
|
|
bdc5e39463 | ||
|
|
68dfffda94 | ||
|
|
5dfb680493 | ||
|
|
5bf8ef5303 | ||
|
|
a8b6a2c1e0 | ||
|
|
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 |
+1
-14
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
+1420
-4
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");
|
||||
}
|
||||
}
|
||||
+1631
-6
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"
|
||||
);
|
||||
}
|
||||
+178
-2
@@ -213,5 +213,181 @@ fn ac3_sync_check(pack: &[u8], q: usize, len: usize) -> Option<bool> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "hddvd_tests.rs"]
|
||||
pub(crate) mod tests;
|
||||
pub(crate) mod tests {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
+299
-2
@@ -356,5 +356,302 @@ pub fn mkb_is_uhd(mkb: &[u8]) -> Option<bool> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mkb_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+357
-2
@@ -142,5 +142,360 @@ 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,
|
||||
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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
+83
-2
@@ -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"
|
||||
);
|
||||
}
|
||||
+1407
-2
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());
|
||||
}
|
||||
+1796
-4
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);
|
||||
}
|
||||
+2706
-2
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2345
-2
File diff suppressed because it is too large
Load Diff
+237
-2
@@ -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::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,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);
|
||||
}
|
||||
@@ -397,5 +397,198 @@ fn apply_forced(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "dvd_forced_probe_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
+1203
-2
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
+2676
-2
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+5825
-2
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1766
-2
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");
|
||||
}
|
||||
+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");
|
||||
|
||||
+20
-2
@@ -66,5 +66,23 @@ fn normalize_path(path: &str) -> String {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "windows_tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_drive_letter() {
|
||||
assert_eq!(normalize_path("D:"), "\\\\.\\D:");
|
||||
assert_eq!(normalize_path("E:\\"), "\\\\.\\E:");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_already_prefixed() {
|
||||
assert_eq!(normalize_path("\\\\.\\D:"), "\\\\.\\D:");
|
||||
assert_eq!(normalize_path("\\\\.\\CdRom0"), "\\\\.\\CdRom0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_cdrom() {
|
||||
assert_eq!(normalize_path("CdRom0"), "\\\\.\\CdRom0");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_drive_letter() {
|
||||
assert_eq!(normalize_path("D:"), "\\\\.\\D:");
|
||||
assert_eq!(normalize_path("E:\\"), "\\\\.\\E:");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_already_prefixed() {
|
||||
assert_eq!(normalize_path("\\\\.\\D:"), "\\\\.\\D:");
|
||||
assert_eq!(normalize_path("\\\\.\\CdRom0"), "\\\\.\\CdRom0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_cdrom() {
|
||||
assert_eq!(normalize_path("CdRom0"), "\\\\.\\CdRom0");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user