Compare commits
666
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
decb87a250 | ||
|
|
05729f5dfe | ||
|
|
dc1d05985b | ||
|
|
539b170f7e | ||
|
|
998e21c544 | ||
|
|
7f55271adb | ||
|
|
e064bc7055 | ||
|
|
6acc26a802 | ||
|
|
534eca502c | ||
|
|
e3dbafcebd | ||
|
|
43fb97f71f | ||
|
|
8e0797eab0 | ||
|
|
9b6a48e9d9 | ||
|
|
8e2e22af5c | ||
|
|
b2e1698b9a | ||
|
|
730af6b1d9 | ||
|
|
52d2e85e3c | ||
|
|
6b3014f3e8 | ||
|
|
8f5968a18f | ||
|
|
62d2dfe96a | ||
|
|
3c42950ecd | ||
|
|
0127c274d2 | ||
|
|
f407c4c693 | ||
|
|
d4a0f5b786 | ||
|
|
1854869ab3 | ||
|
|
275d9eebe0 | ||
|
|
05b9befc64 | ||
|
|
1767cf67b6 | ||
|
|
9a3f6b7313 | ||
|
|
1f91eebb9a | ||
|
|
f4a475c7b9 | ||
|
|
5b0976859f | ||
|
|
f72a956b5b | ||
|
|
9cd36427be | ||
|
|
674a7dd867 | ||
|
|
1cec2aaaf3 | ||
|
|
794d88f6e7 | ||
|
|
63ed05bd63 | ||
|
|
987e26e44d | ||
|
|
b76e9d38c5 | ||
|
|
6592f2a590 | ||
|
|
315276dd13 | ||
|
|
9d40da3982 | ||
|
|
e0ce035765 | ||
|
|
2613a81f09 | ||
|
|
45a7b74ab9 | ||
|
|
8b8ada7802 | ||
|
|
cdd11ccb25 | ||
|
|
c706a94312 | ||
|
|
06ee748689 | ||
|
|
95b9762c51 | ||
|
|
fb13f975df | ||
|
|
dbc2225315 | ||
|
|
ee0c7cebe3 | ||
|
|
f3c3614a17 | ||
|
|
c4f0566fb1 | ||
|
|
77f67dd3ed | ||
|
|
7f195be894 | ||
|
|
48e95a7b2c | ||
|
|
6268f6e5d9 | ||
|
|
4c50ca2122 | ||
|
|
a324e5c62f | ||
|
|
dc7ab01907 | ||
|
|
55849edd99 | ||
|
|
af3666ff3b | ||
|
|
b82075b41a | ||
|
|
3c3e0b4341 | ||
|
|
e96528ad5b | ||
|
|
705857f117 | ||
|
|
845e20e508 | ||
|
|
d5afeb6088 | ||
|
|
008c1f143e | ||
|
|
e633a7d3af | ||
|
|
f177d61bbf | ||
|
|
618524ecb8 | ||
|
|
4277ee32dd | ||
|
|
6ace16293b | ||
|
|
8efe2fcbfd | ||
|
|
31d07fde6e | ||
|
|
ecee9f4ec0 | ||
|
|
9220f03f3b | ||
|
|
e52689579b | ||
|
|
97ae47b3ea | ||
|
|
f596dcb40e | ||
|
|
0e18bfa035 | ||
|
|
89fa0a791e | ||
|
|
f68a66c4be | ||
|
|
662594ff40 | ||
|
|
c9bf92cd6f | ||
|
|
4a76deadeb | ||
|
|
8e6d494e54 | ||
|
|
24ed1d1d19 | ||
|
|
f2c2ff0eb3 | ||
|
|
a3987e67f2 | ||
|
|
1b008008dd | ||
|
|
980eeb3de9 | ||
|
|
c3c5259f84 | ||
|
|
ae411df8f9 | ||
|
|
60daf63c09 | ||
|
|
d3c58791ff | ||
|
|
b85744d120 | ||
|
|
633a22c6bd | ||
|
|
ab959dd770 | ||
|
|
9f422e6ebb | ||
|
|
63ca840b7e | ||
|
|
e9108e8b6b | ||
|
|
fa8913800c | ||
|
|
f863e9a4be | ||
|
|
4d81affb45 | ||
|
|
c73a3dbcb6 | ||
|
|
4f606ae9a3 | ||
|
|
25acd09504 | ||
|
|
159e967760 | ||
|
|
6dc62bcd84 | ||
|
|
9250f5bb30 | ||
|
|
e960c2f1be | ||
|
|
f74979bdb4 | ||
|
|
dc2cac1b5f | ||
|
|
337e77951c | ||
|
|
5941c059c6 | ||
|
|
e8bb6225ac | ||
|
|
9c80ef8245 | ||
|
|
dc87962e50 | ||
|
|
4221cd6a86 | ||
|
|
dda4e7482b | ||
|
|
f80551f278 | ||
|
|
41a6d89cd1 | ||
|
|
f79c2a0aa9 | ||
|
|
d181362460 | ||
|
|
8000bae177 | ||
|
|
2a55bab3ed | ||
|
|
222a596c55 | ||
|
|
c1b4f3cbb3 | ||
|
|
06c30aa466 | ||
|
|
061f68594a | ||
|
|
5b6ea8f5c4 | ||
|
|
eeba94b21d | ||
|
|
cfc12774f2 | ||
|
|
fd543c058b | ||
|
|
cd8ce708ac | ||
|
|
2eee777b8f | ||
|
|
a5962be86c | ||
|
|
7306f661b9 | ||
|
|
77f7aced83 | ||
|
|
97ae452e40 | ||
|
|
eec0594a30 | ||
|
|
dab6ea4359 | ||
|
|
9a5ed57044 | ||
|
|
7d58ba7b08 | ||
|
|
4f1dbfd042 | ||
|
|
5b702a76a7 | ||
|
|
6be5198886 | ||
|
|
e2aa9abd6d | ||
|
|
3b7bee9ed4 | ||
|
|
b7405e2d27 | ||
|
|
e04d79c593 | ||
|
|
b34af1fa74 | ||
|
|
a7317f8885 | ||
|
|
474273afc0 | ||
|
|
0a2bab5789 | ||
|
|
fa4d7ef871 | ||
|
|
36d1af1b7f | ||
|
|
b96f6206fe | ||
|
|
bb32fb993b | ||
|
|
c1eb74dfa5 | ||
|
|
46838c63ca | ||
|
|
1e60220ff6 | ||
|
|
080f03e8ed | ||
|
|
35de6101d4 | ||
|
|
9012101573 | ||
|
|
8bc1de6c9b | ||
|
|
d94a4d3444 | ||
|
|
1bd7e1de2a | ||
|
|
da62ee7cf2 | ||
|
|
575c76156f | ||
|
|
b518860d9c | ||
|
|
c8eb42b490 | ||
|
|
48570ac065 | ||
|
|
08e46640fd | ||
|
|
a9195824ff | ||
|
|
401fe23988 | ||
|
|
f89bce5851 | ||
|
|
dfccb85e15 | ||
|
|
ebedffb762 | ||
|
|
fdf63ccb7f | ||
|
|
c735d284da | ||
|
|
94ab7bc73c | ||
|
|
9f209fe066 | ||
|
|
32a1a6e095 | ||
|
|
1d3b8f5fb6 | ||
|
|
97b0ae7be2 | ||
|
|
7756f1feca | ||
|
|
1565da610a | ||
|
|
e134616422 | ||
|
|
8d54a3c64e | ||
|
|
c0478e1273 | ||
|
|
7d29168fec | ||
|
|
8e14c9b850 | ||
|
|
e1c8343f77 | ||
|
|
a956c6ad94 | ||
|
|
1805d92ca4 | ||
|
|
823f0ad430 | ||
|
|
477bdf1835 | ||
|
|
4d83b69c20 | ||
|
|
7dbbfc6726 | ||
|
|
e635c9556f | ||
|
|
d7b5c30f5d | ||
|
|
739a276a39 | ||
|
|
5f1028a62a | ||
|
|
ea15d212de | ||
|
|
00673c8ec3 | ||
|
|
bfefb4cb5b | ||
|
|
1b95193517 | ||
|
|
7dcac44136 | ||
|
|
4da559e39f | ||
|
|
eeca250f69 | ||
|
|
c51b3181f2 | ||
|
|
2a31a47434 | ||
|
|
b08662f95c | ||
|
|
4594196b6e | ||
|
|
9d6974df3e | ||
|
|
3394a5b3fe | ||
|
|
e2c7e20329 | ||
|
|
ff22385a61 | ||
|
|
d39d3933f5 | ||
|
|
31dc3d35c3 | ||
|
|
f7165a2020 | ||
|
|
d836f8b1e7 | ||
|
|
8919de5b03 | ||
|
|
0dc1f12108 | ||
|
|
6abc25a7e8 | ||
|
|
4d22cf632f | ||
|
|
611ad77586 | ||
|
|
60590193e3 | ||
|
|
5f24843d3c | ||
|
|
ecf9a4d01e | ||
|
|
8ec767339e | ||
|
|
335e8b68cc | ||
|
|
1137b4ea1f | ||
|
|
7788c560d1 | ||
|
|
84e0c2ca69 | ||
|
|
f4a881b25d | ||
|
|
1edda7fedb | ||
|
|
3dd71a0650 | ||
|
|
579c94bd23 | ||
|
|
3b2a68fe7a | ||
|
|
5b56f112a0 | ||
|
|
f27e4c3088 | ||
|
|
b3576f2ae6 | ||
|
|
39f2d0e992 | ||
|
|
50867516b8 | ||
|
|
193e7c1680 | ||
|
|
b6463729e8 | ||
|
|
dffee56102 | ||
|
|
d8f27cdee0 | ||
|
|
fc13268dc6 | ||
|
|
d9b55f02d0 | ||
|
|
35d66d2059 | ||
|
|
d703ce439b | ||
|
|
6112a26d15 | ||
|
|
2a33364253 | ||
|
|
fa3872ddcc | ||
|
|
c427389f36 | ||
|
|
6980f562df | ||
|
|
e3b2c9d850 | ||
|
|
a383200ef1 | ||
|
|
51d15f551e | ||
|
|
523af461a5 | ||
|
|
695b65149e | ||
|
|
d3f9560689 | ||
|
|
1fa5a7d27f | ||
|
|
55219070f1 | ||
|
|
a34c419521 | ||
|
|
b179846f5d | ||
|
|
e110e80e6e | ||
|
|
597fa34099 | ||
|
|
52eeb949e3 | ||
|
|
6ca62b8411 | ||
|
|
04195c27d8 | ||
|
|
5a8f8e54e1 | ||
|
|
1a2c830cf6 | ||
|
|
637c2bfe36 | ||
|
|
0d28357b4d | ||
|
|
5495332f07 | ||
|
|
e22fc6fd47 | ||
|
|
5b98c13e47 | ||
|
|
8f8f1a62a2 | ||
|
|
e3cfd27942 | ||
|
|
aa12bdad62 | ||
|
|
ef3895cdc5 | ||
|
|
2dcf969ac8 | ||
|
|
005f887bf9 | ||
|
|
d7243a6044 | ||
|
|
f1926c38dc | ||
|
|
4709a73c80 | ||
|
|
55bd1ee868 | ||
|
|
5285e8b6ec | ||
|
|
c5af61e46b | ||
|
|
ed73c1ab68 | ||
|
|
5ce2df0888 | ||
|
|
ea591a36a5 | ||
|
|
368e10486e | ||
|
|
a99c4f8487 | ||
|
|
4df470e572 | ||
|
|
a0b13941fe | ||
|
|
275c9da009 | ||
|
|
da183722fa | ||
|
|
4a5424eabe | ||
|
|
293d89c586 | ||
|
|
4840f5134d | ||
|
|
11c8211605 | ||
|
|
1038beaf06 | ||
|
|
f1df57196c | ||
|
|
8ba9dc1c0b | ||
|
|
49b131f39a | ||
|
|
8238ec4ce7 | ||
|
|
4fd5d27df8 | ||
|
|
1fbe272832 | ||
|
|
6bd635725e | ||
|
|
09b77b4dea | ||
|
|
688058b3e8 | ||
|
|
c96bac7977 | ||
|
|
c32acff3e4 | ||
|
|
b58e2d9873 | ||
|
|
a9e802c1c2 | ||
|
|
a876ce846b | ||
|
|
058fd8396f | ||
|
|
5ee28c08b9 | ||
|
|
764230b1eb | ||
|
|
7fb2e07aed | ||
|
|
376aadb335 | ||
|
|
586495b2ee | ||
|
|
72f2224efe | ||
|
|
0bca7a11bd | ||
|
|
72ab714c1e | ||
|
|
d7fb1b35ed | ||
|
|
06be4defd2 | ||
|
|
a832bad697 | ||
|
|
26e2d847d5 | ||
|
|
4442fa2df6 | ||
|
|
23dc55661c | ||
|
|
fbdb50c79f | ||
|
|
7cc74f0087 | ||
|
|
3aa1e528c5 | ||
|
|
7c6b0f82ab | ||
|
|
4226a53e73 | ||
|
|
92f34a289e | ||
|
|
fdbe469d50 | ||
|
|
4ec75a03f2 | ||
|
|
99236ffd55 | ||
|
|
9e3327a569 | ||
|
|
0081955686 | ||
|
|
3244fdd683 | ||
|
|
ef5487e5a5 | ||
|
|
e7dffa63d2 | ||
|
|
e5989c8270 | ||
|
|
e1c938b82a | ||
|
|
ed801a708b | ||
|
|
d94451954c | ||
|
|
385c9f094c | ||
|
|
9f2a13739d | ||
|
|
d9ce69bc9d | ||
|
|
7cd2c937ed | ||
|
|
107524da5e | ||
|
|
055a3c5276 | ||
|
|
50b04790ce | ||
|
|
a00b2c884b | ||
|
|
44e2c64d7e | ||
|
|
d6535b8f57 | ||
|
|
90ab00ed45 | ||
|
|
6bfd7dfd13 | ||
|
|
b9a7f601d4 | ||
|
|
cf0a61f8b4 | ||
|
|
f98f07b2d3 | ||
|
|
b53454fa09 | ||
|
|
f28b6ee6d9 | ||
|
|
d9d778fa64 | ||
|
|
bbfb887a35 | ||
|
|
9884346c24 | ||
|
|
766b7c6636 | ||
|
|
925c30686b | ||
|
|
f52e4c5d22 | ||
|
|
8d67790d2e | ||
|
|
246a439990 | ||
|
|
bd19041648 | ||
|
|
198268b725 | ||
|
|
31424c8203 | ||
|
|
c747ecc589 | ||
|
|
283a561c12 | ||
|
|
2667d68675 | ||
|
|
57f2c22e30 | ||
|
|
5f3545d244 | ||
|
|
6e17ef0859 | ||
|
|
6ec97af104 | ||
|
|
3a6c1aa5a3 | ||
|
|
1ba3264747 | ||
|
|
ae2909fe8d | ||
|
|
6a3d19a453 | ||
|
|
a35596d2d1 | ||
|
|
318f654fed | ||
|
|
5e82ef65de | ||
|
|
8534607329 | ||
|
|
97e1a4cad3 | ||
|
|
3f6bf33de5 | ||
|
|
359301e8fc | ||
|
|
bfa527162a | ||
|
|
45defd47f3 | ||
|
|
fe8933dad2 | ||
|
|
0a3c1b7b70 | ||
|
|
2bcdc97341 | ||
|
|
4863e9c545 | ||
|
|
03db038dd9 | ||
|
|
95001ba2c3 | ||
|
|
a1f4dff6f6 | ||
|
|
7dd5001d45 | ||
|
|
67fe93c0b8 | ||
|
|
5c4f575c46 | ||
|
|
e2cb7a78bb | ||
|
|
a6f1bd19bc | ||
|
|
9e3d0f1383 | ||
|
|
1e6eb0698d | ||
|
|
bd744171e4 | ||
|
|
7de9d4d42c | ||
|
|
9ac0c2fcdf | ||
|
|
3aa27d5d1b | ||
|
|
5083f70cff | ||
|
|
477cf7ee0e | ||
|
|
0b079e27ce | ||
|
|
5f69fd2a44 | ||
|
|
befe6ef69b | ||
|
|
29491d6eaf | ||
|
|
2e602f7e37 | ||
|
|
a126f31b70 | ||
|
|
6ff10df83a | ||
|
|
52b8522a75 | ||
|
|
13eb4331b6 | ||
|
|
1a3f7ce676 | ||
|
|
e1a1f730be | ||
|
|
646c22ae93 | ||
|
|
2f6092a686 | ||
|
|
e5a90a6567 | ||
|
|
afce1031d5 | ||
|
|
2c0bff1303 | ||
|
|
2067ebe485 | ||
|
|
2ce4c20221 | ||
|
|
da3004b120 | ||
|
|
b5ed97801b | ||
|
|
ae76aaf0fa | ||
|
|
2cd4fbead7 | ||
|
|
ebffc6eb88 | ||
|
|
424d3cd4f2 | ||
|
|
d2905ba7bb | ||
|
|
b4de5d343d | ||
|
|
8beeac7df9 | ||
|
|
b33f41e219 | ||
|
|
e85e20f436 | ||
|
|
7bc54be8d3 | ||
|
|
6d9083743b | ||
|
|
c49a68054f | ||
|
|
4bd38787c6 | ||
|
|
b4951b1c5b | ||
|
|
92dc145864 | ||
|
|
36b65f526a | ||
|
|
ca8ebf418f | ||
|
|
34182d956a | ||
|
|
0f967a2084 | ||
|
|
0341995c0d | ||
|
|
fe0ec0c5cf | ||
|
|
022657d481 | ||
|
|
fc8eca44e1 | ||
|
|
43836865be | ||
|
|
5e22199441 | ||
|
|
761b77bbd9 | ||
|
|
cc05ef2a3a | ||
|
|
e27b82ce5b | ||
|
|
8af47e4c19 | ||
|
|
d1f09439a5 | ||
|
|
37e721ee7e | ||
|
|
481b47d90d | ||
|
|
dd7cbd70a9 | ||
|
|
3dea679dac | ||
|
|
64ecca0d12 | ||
|
|
84f6baba38 | ||
|
|
bd5b7795bd | ||
|
|
a57506c1ce | ||
|
|
c33f3e9557 | ||
|
|
166fc4bf8c | ||
|
|
9aaddaa9b8 | ||
|
|
40ec1e1eba | ||
|
|
8843833ea7 | ||
|
|
afae37c8c3 | ||
|
|
d8089ec491 | ||
|
|
ce8132d181 | ||
|
|
71e1f57364 | ||
|
|
d8092ee093 | ||
|
|
dd49d55b11 | ||
|
|
6ba958fe8a | ||
|
|
f30b958408 | ||
|
|
6971f00be2 | ||
|
|
ad86f92d85 | ||
|
|
cce1be29b2 | ||
|
|
081ad4f619 | ||
|
|
43ecad2d43 | ||
|
|
8870efa77b | ||
|
|
a693ef965b | ||
|
|
0596307c19 | ||
|
|
18c6365e08 | ||
|
|
981f30b1b0 | ||
|
|
04086d73a0 | ||
|
|
20e404e59a | ||
|
|
1bea8eb650 | ||
|
|
0165f18fad | ||
|
|
ca3914d537 | ||
|
|
80fcffd190 | ||
|
|
7437af39c8 | ||
|
|
bcd6925170 | ||
|
|
fba1eb189c | ||
|
|
a328ad3cd8 | ||
|
|
9791e60c65 | ||
|
|
8b1ded84cd | ||
|
|
3e4febe7f5 | ||
|
|
aa7afc0c98 | ||
|
|
29f9e916d6 | ||
|
|
30d59063d5 | ||
|
|
6e958b0f71 | ||
|
|
911d260695 | ||
|
|
59d4eb8854 | ||
|
|
8820f7a460 | ||
|
|
8de99bb8e8 | ||
|
|
3e5e570573 | ||
|
|
f95894bba3 | ||
|
|
597f512eca | ||
|
|
8e907393a3 | ||
|
|
c47b9a9c3c | ||
|
|
d7b9d87075 | ||
|
|
e0f40583c4 | ||
|
|
d06a37d3dc | ||
|
|
add9a929a5 | ||
|
|
9bb1d02245 | ||
|
|
dc706e8153 | ||
|
|
0f18906ede | ||
|
|
bd7220c62b | ||
|
|
404c005e0b | ||
|
|
87342290c5 | ||
|
|
8bbf630d82 | ||
|
|
45dddc1810 | ||
|
|
cfa80cb881 | ||
|
|
b510ae9b29 | ||
|
|
9a1c3cc218 | ||
|
|
9ae7d6b38a | ||
|
|
d983985faa | ||
|
|
56fe26c9b8 | ||
|
|
7a83f3244d | ||
|
|
15f5b49fc3 | ||
|
|
ff6004a567 | ||
|
|
bd644d2f60 | ||
|
|
ab63950ef1 | ||
|
|
cab87ffdf4 | ||
|
|
ccb1fadedf | ||
|
|
610f1b62b6 | ||
|
|
f400e4fc72 | ||
|
|
5a9980cb6b | ||
|
|
2f48877992 | ||
|
|
7c59d063ee | ||
|
|
e4ffe7cc91 | ||
|
|
2e23e848df | ||
|
|
fbdfccdd83 | ||
|
|
2f52188f77 | ||
|
|
252e58cab0 | ||
|
|
3b96976a7a | ||
|
|
3385de5704 | ||
|
|
e9c907a2ca | ||
|
|
db1f6bc446 | ||
|
|
c43ba06b5b | ||
|
|
077e4d018f | ||
|
|
f8b5a1eaf1 | ||
|
|
fb1c35e653 | ||
|
|
9268ea2fdb | ||
|
|
9078aa8718 | ||
|
|
c55e6991b8 | ||
|
|
ca931b6522 | ||
|
|
d4d98ce593 | ||
|
|
4d92e15224 | ||
|
|
113d6b9e9e | ||
|
|
75dfd06a02 | ||
|
|
f48b4925c1 | ||
|
|
75f15cae62 | ||
|
|
e25035fe8c | ||
|
|
ffa0eaba4d | ||
|
|
96a65de3ff | ||
|
|
cd575b7221 | ||
|
|
515f2f6bc2 | ||
|
|
fe723a7759 | ||
|
|
e4c5c88909 | ||
|
|
648ce28ac6 | ||
|
|
ff5547363b | ||
|
|
6e771a1867 | ||
|
|
8999db68ea | ||
|
|
2d67b11a2c | ||
|
|
995525d3ff | ||
|
|
dc4ebd7d9b | ||
|
|
168005ef34 | ||
|
|
6dc308f245 | ||
|
|
cccb6b4631 | ||
|
|
8c2f3898b8 | ||
|
|
37c98e8826 | ||
|
|
891dee3db9 | ||
|
|
e34b41967d | ||
|
|
c063dbd76e | ||
|
|
d1f8db93d6 | ||
|
|
926939f24b | ||
|
|
48d70c092c | ||
|
|
c14b319212 | ||
|
|
55b22bfb9d | ||
|
|
48b212c9d2 | ||
|
|
074f21ba58 | ||
|
|
9241d767ee | ||
|
|
65996ade59 | ||
|
|
09f5bb7816 | ||
|
|
12a5985a58 | ||
|
|
d23632931b | ||
|
|
92e7692779 | ||
|
|
0c11623666 | ||
|
|
b454d100c6 | ||
|
|
097473d371 | ||
|
|
5e3157b18d | ||
|
|
d9c5f7a0d2 | ||
|
|
6456e24bb5 | ||
|
|
0b19154bd3 | ||
|
|
ecc6cd9f6b | ||
|
|
71a21b5c6f | ||
|
|
79b1b4d5b5 | ||
|
|
85b38de4a7 | ||
|
|
686bf91bb5 | ||
|
|
f07f8210c3 | ||
|
|
4153d23652 | ||
|
|
760bab0893 | ||
|
|
ce8ddb48bb | ||
|
|
377cbe0aec | ||
|
|
a4c10887cf | ||
|
|
3fcab4d8d9 | ||
|
|
f46d9706eb | ||
|
|
9ecbf0ada5 | ||
|
|
82746ed592 | ||
|
|
da0eeae6a7 | ||
|
|
262f9a7f8d | ||
|
|
ca732d57e7 | ||
|
|
f357e0fe26 | ||
|
|
976aeb4848 | ||
|
|
2bea69c7d9 | ||
|
|
0bb815ca22 | ||
|
|
aa188751e4 | ||
|
|
ba44f7d928 | ||
|
|
8a95787426 | ||
|
|
aebe6a256b | ||
|
|
791b832f25 | ||
|
|
580ce2277d | ||
|
|
8a556c5c63 | ||
|
|
520a3f912c | ||
|
|
6f0eea48fa | ||
|
|
b01010f2d1 | ||
|
|
fd443e3a2f | ||
|
|
8d85a9d778 | ||
|
|
237528b385 | ||
|
|
492a2966c0 | ||
|
|
5fa6b064da |
Submodule .claude/worktrees/agent-a1b258ca2bea02faf deleted from aa536f6ab3
Submodule .claude/worktrees/agent-afd076fb7145099f7 deleted from 5950156cf0
Submodule .claude/worktrees/halt-token deleted from 8693add66c
Submodule .claude/worktrees/pes-source-sink deleted from 919096b67f
Submodule .claude/worktrees/pipeline deleted from 8e7d1bea96
Submodule .claude/worktrees/round1-fixes deleted from 78b50dd5a6
Submodule .claude/worktrees/round2-decrypt-decorator deleted from 9e51ee9946
Submodule .claude/worktrees/round2-discstream-source deleted from f10c83ffe4
Submodule .claude/worktrees/round2-framesink deleted from eb04fdaffa
Submodule .claude/worktrees/round2-halt deleted from 3ca63235e0
Submodule .claude/worktrees/sector-source-sink deleted from 8c592d08e9
Submodule .claude/worktrees/writeback-file-rename deleted from e5a32a8f16
@@ -13,6 +13,7 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo fmt --check
|
||||
# libfreemkv is a library — Cargo.lock is gitignored. --locked
|
||||
# would always fail on a fresh runner because there's no committed
|
||||
@@ -25,6 +26,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test --tests
|
||||
|
||||
check-macos:
|
||||
@@ -32,6 +34,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo check
|
||||
|
||||
check-windows:
|
||||
@@ -39,4 +42,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- run: cargo check
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# Build the tests (not just `cargo check`): catches errors in test
|
||||
# code and forces full codegen of the Windows-only SPTI transport
|
||||
# (src/scsi/windows.rs), which never compiles on the Linux/macOS dev
|
||||
# hosts. We don't `cargo test` here — the suite needs no drive but the
|
||||
# extra build is the value; running tests is covered by the Linux job.
|
||||
- run: cargo build --tests
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: leak-guard
|
||||
|
||||
# Self-contained public-repo leak gate. Public CI cannot reach the private
|
||||
# tooling, so this encodes only the generic net: internal-infra references,
|
||||
# tracked CLAUDE.md/.claude paths, and AI-attribution in commit messages.
|
||||
# No project-specific reverse-engineering vocabulary lives here.
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
leak-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Compute commit range
|
||||
id: range
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
head="${{ github.event.pull_request.head.sha }}"
|
||||
echo "range=$base..$head" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
before="${{ github.event.before }}"
|
||||
after="${{ github.sha }}"
|
||||
# New branch / first push: github.event.before is all-zeros.
|
||||
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "range=$after" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "range=$before..$after" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
- name: Run leak-guard
|
||||
run: bash ci/leak-guard.sh "${{ steps.range.outputs.range }}"
|
||||
@@ -22,29 +22,51 @@ jobs:
|
||||
fi
|
||||
echo "Version match: $CARGO_VER"
|
||||
|
||||
# Tests run as a PARALLEL TRIPWIRE: they fail the run if they fail, but the
|
||||
# publish/release jobs do NOT `needs:` this job. The tag decision was already
|
||||
# gated by the local precommit (same Rust 1.86, same commit). Binary consumers
|
||||
# (freemkv/autorip/bdemu) git-tag-pin libfreemkv and therefore start building
|
||||
# the instant this tag exists — so this test job and the crates.io publish
|
||||
# below must NOT sit on their critical path.
|
||||
test:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# libfreemkv is a library — Cargo.lock isn't tracked, so --locked
|
||||
# would always fail (no lockfile to lock against on a fresh runner).
|
||||
- run: cargo test
|
||||
|
||||
# crates.io publish is an INDEPENDENT job: it serves EXTERNAL consumers only.
|
||||
# The freemkv binaries no longer depend on it (they git-tag-pin libfreemkv via
|
||||
# a committed [patch.crates-io]), so this publish runs in parallel with their
|
||||
# release builds rather than gating them. It `needs: [verify, test]` so a
|
||||
# failing test suite still blocks publication to crates.io — external
|
||||
# consumers who `cargo add libfreemkv` must never receive a release whose
|
||||
# tests were failing. (The two upstream jobs run in parallel, so this gate
|
||||
# does not serialize publish behind test beyond their own completion.)
|
||||
publish:
|
||||
needs: test
|
||||
needs: [verify, test]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# --no-verify: CI already compiled this exact commit (in the `test` job
|
||||
# and on every push via ci.yml). cargo publish's default re-verify does a
|
||||
# full cold release build of the packaged tarball, which here is pure
|
||||
# redundant work (~a cold lib build). Skip it.
|
||||
- name: Publish to crates.io
|
||||
run: cargo publish
|
||||
run: cargo publish --no-verify
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
|
||||
release:
|
||||
needs: test
|
||||
# Only needs `verify`; the GitHub Release can be cut as soon as the version
|
||||
# check passes, in parallel with test + publish.
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
+10
-1
@@ -4,4 +4,13 @@ Cargo.lock
|
||||
*.swo
|
||||
.DS_Store
|
||||
.cargo/
|
||||
.claude/worktrees/
|
||||
|
||||
# session scratch — never track (may contain RE breadcrumbs)
|
||||
scratch/
|
||||
|
||||
# stray local build artifact
|
||||
/rust_out
|
||||
|
||||
# internal agent context — never publish (path AND dir; leak-guard blocks both)
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
+355
-2375
File diff suppressed because it is too large
Load Diff
@@ -1,94 +0,0 @@
|
||||
# libfreemkv — Rules
|
||||
|
||||
## No English in library code
|
||||
|
||||
The library contains ZERO user-facing English text. All errors use numeric codes from `error.rs`. Applications (CLI, GUI, server) handle i18n.
|
||||
|
||||
- `io::Error::new(kind, "english string")` — NEVER. Use `Error::VariantName.into()`.
|
||||
- If you need a new error, add a variant to `error.rs` with a code, not a string.
|
||||
- Acceptable strings: debug/trace logging, test assertions, comments, data format strings (paths, codec IDs).
|
||||
- `Error` implements `From<Error> for io::Error` — use `?` or `.into()` anywhere an `io::Error` is expected.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Streams are PES.** Every stream reads its format → PES frames out, or PES frames in → writes its format. One type per format.
|
||||
- **Disc::copy() for sector dumps.** disc→ISO is NOT a stream. It's `Disc::copy()`.
|
||||
- **DiscStream = any disc.** Physical drive or ISO file. Same type, different SectorReader.
|
||||
- **No IOStream.** Deleted. No byte-level Read/Write on streams.
|
||||
- **Streams don't know their size.** Progress/file_size is a CLI concern.
|
||||
- **One method per action.** No `foo_with_X` variants. Use `Option<T>` params.
|
||||
- **Streams impl Read only (conceptually).** No Seek, no File backing.
|
||||
- **Functions return errors, only main() exits.** No `process::exit` in library code.
|
||||
|
||||
## Device rules
|
||||
|
||||
- Always use `/dev/sg*` not `/dev/sr*` for SCSI.
|
||||
- `--raw` only skips decryption. Init/probe/speed still run.
|
||||
- Each function does one thing. One runner orchestrates the sequence.
|
||||
|
||||
## AACS key sources
|
||||
|
||||
Single source: `keydb.cfg`. Located at `~/.config/freemkv/keydb.cfg` by
|
||||
default, or pointed at via `ScanOptions::keydb_path`. The file holds
|
||||
all DKs, PKs, host certs, and per-disc VUK entries. No keys are
|
||||
compiled into the binary.
|
||||
|
||||
CSS player keys (DVD) remain compiled in — they're 1999-era public
|
||||
inputs separate from the AACS key pipeline and have always lived in
|
||||
`src/css/auth.rs`.
|
||||
|
||||
The library treats a missing `keydb.cfg` for an AACS-encrypted disc as
|
||||
`Error::KeydbLoad` with the sentinel path `<no keydb in search paths>`.
|
||||
CLIs render this as "no KEYDB.cfg found"; consumers can disambiguate
|
||||
on the sentinel string.
|
||||
|
||||
## macOS IOKit transport
|
||||
|
||||
The macOS SCSI transport uses exclusive IOKit access, not hybrid MMC+pread.
|
||||
|
||||
- **C shim** (`src/scsi/macos_shim.c`):
|
||||
- `shim_open_exclusive(bsd_name)`: `diskutil unmountDisk force` on target device only → find `IOBDServices` matching BSD name via IOKit registry walk → MMCDeviceInterface → SCSITaskDeviceInterface → `ObtainExclusiveAccess` → raw CDB dispatch.
|
||||
- `shim_list_drives()`: registry-based enumeration. Walks all `IOBDServices` entries, reads `"Device Characteristics"` for vendor/model/firmware, walks child chain to `IOMedia` for BSD name. Zero SCSI, zero exclusive access, zero unmounts.
|
||||
- `shim_execute()` / `shim_close()`: raw CDB dispatch and cleanup.
|
||||
- **Build** (`build.rs`): compiles shim via `cc` into static lib, linked by Cargo. NOT the `cc` crate (produces object code that breaks IOKit exclusive access).
|
||||
- **Rust** (`src/scsi/macos.rs`): FFI to `shim_open_exclusive`, `shim_close`, `shim_execute`, `shim_list_drives`. `list_drives()` uses registry-based enumeration. `MacScsiTransport::open()` uses exclusive access only when ripping a specific device.
|
||||
- **IOBDServices parent chain**: IOSCSIPeripheralDeviceType05 → IOBDServices → IOBDBlockStorageDriver → IOMedia (has `"BSD Name"`). The shim walks this chain to match BSD name to IOBDServices.
|
||||
- **IOKit lookup order**: (1) iterate all IOBDServices → match child IOMedia BSD name, (2) fallback: find IOMedia by BSD name → walk parent chain to IOBDServices, (3) fallback: first IOBDServices (single-drive systems).
|
||||
- **Test disc**: DUNE_PART_TWO UHD, `/dev/disk6`, ~84.6 GB.
|
||||
|
||||
## Bad-sector handling (BU40N + Initio INIC-1618L)
|
||||
|
||||
Three failure modes on this USB bridge:
|
||||
1. **NOT READY** (sense_key=2, ASC=0x04, ASCQ=0x3E) — most common on BU40N for bad sectors. Pause 3s, retry up to 3x, then mark NonTrimmed.
|
||||
2. **Transport failure** (status=0xFF) — bridge crash, auto-recovers ~15s. Aborts copy.
|
||||
3. **INCOMPATIBLE FORMAT** (ASC=0x30) wedge — ALL sectors fail, requires power cycle.
|
||||
|
||||
### Damage-jump algorithm (Pass 1 sweep)
|
||||
|
||||
When `skip_on_error=true` (multipass mode):
|
||||
- Read each ECC block sequentially. Track a sliding window of the last 16 ECC block results.
|
||||
- On error: zero-fill, mark NonTrimmed, push `false` to window.
|
||||
- On success: write data, mark Finished, push `true` to window. Track consecutive good count.
|
||||
- When ≥12% of the 16-block window are failures → **jump** ahead by `JUMP_BASE_SECTORS (1024) × batch × multiplier` sectors. For UHD encrypted ECC (batch=32) that's a 64 MiB base jump. Zero-fill the gap as NonTrimmed. Double the multiplier (64→128→256→512 MiB...) up to `MAX_JUMP_MULTIPLIER=64` (4 GiB cap). Plus a separate wedge-skip path of `WEDGE_JUMP_SECTORS=524288` (1 GiB) for HARDWARE_ERROR / ILLEGAL_REQUEST senses, capped at 16 consecutive wedges.
|
||||
- When 16 consecutive good reads → reset multiplier to 1, restore max read speed.
|
||||
- Only transport failures (bridge crash) abort the pass.
|
||||
|
||||
Tuning knobs: `DAMAGE_WINDOW=16` and `DAMAGE_THRESHOLD_PCT=12%`. Calibrated from live BU40N data: old 50/25% was too diluted by good reads between sparse failures; 16/12% triggers on the 2nd scattered failure (2/16 = 12.5% ≥ 12%).
|
||||
|
||||
### Patch (Pass N) — `disc/mod.rs:1910`
|
||||
|
||||
- Default: **reverse** mode. Walks bad ranges from highest LBA to lowest, and within each range from end to start. Rationale: sweep jumps forward with escalating gaps, so NonTrimmed ranges have good data at their tail (where the jump landed). Reverse hits good data first, converges on actual bad block boundaries.
|
||||
- Single-sector reads with 60 s timeout (`READ_RECOVERY_TIMEOUT_MS`).
|
||||
- NOT_READY (sense=2, ASC ∈ {0x02, 0x03, 0x04}): 15 s pause, retry without immediate Unreadable mark.
|
||||
- Non-marginal SCSI sense → mark Unreadable and continue.
|
||||
- Skip escalation: damage window 16, `PASSN_DAMAGE_THRESHOLD_PCT=6`, skip `PASSN_SKIP_SECTORS_BASE (32) << escalation` sectors capped at `PASSN_SKIP_SECTORS_CAP=4096`; `MAX_SKIPS_PER_RANGE=10`, then mark range Unreadable.
|
||||
- Wedge exit: 50 consecutive failures **and** ≥ 2 ranges attempted (single-range stalls don't kill the pass).
|
||||
- Whole-pass watchdog: `STALL_SECS = 3600` on `bytes_good`. Per-range watchdog: proportional `range_sectors × SECONDS_PER_SECTOR(25)`, capped at `RANGE_BUDGET_CAP_SECS=1800` (replaces the old flat 180s/range — tiny ranges got starved).
|
||||
|
||||
Constants live in `disc/patch.rs::Disc::patch` (PASSN_*, STALL_SECS, SECONDS_PER_SECTOR, RANGE_BUDGET_CAP_SECS, MAX_SKIPS_PER_RANGE). The full algorithm is documented in `freemkv-private/memory/project_recovery_v0_16.md`.
|
||||
|
||||
## Public repo rules
|
||||
|
||||
- **No internal docs.** Audit reports, test plans, roadmaps, TODOs go in freemkv-private, never here.
|
||||
- **No Co-Authored-By** in commit messages. One contributor: MattJackson.
|
||||
- **No private references.** No Gitea URLs, no /data/code paths, no internal IPs in code.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at matthew@pq.io. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
+8
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.26.0"
|
||||
version = "1.0.0-rc.5.3"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
@@ -8,6 +8,12 @@ description = "Open source raw disc access library for optical drives"
|
||||
repository = "https://github.com/freemkv/libfreemkv"
|
||||
keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
|
||||
categories = ["hardware-support", "multimedia"]
|
||||
# Keep internal AI-instruction / private notes out of the published crate.
|
||||
exclude = ["CLAUDE.md"]
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -31,8 +37,7 @@ tracing = "0.1"
|
||||
# Bounded MPSC channel with kernel-wakeup send_timeout. Used by `io::pipeline`
|
||||
# so the halt-aware send/finish loops can BLOCK on consumer drain instead of
|
||||
# polling — the 50 ms poll cadence of the previous mpsc-based impl capped mux
|
||||
# throughput at ~1 MB/s (see freemkv-private/memory/
|
||||
# feedback_send_with_halt_poll_throttle.md, 0.21.7).
|
||||
# throughput at ~1 MB/s (0.21.7).
|
||||
crossbeam-channel = "0.5"
|
||||
# Persistent work-stealing thread pool for parallel AACS unit
|
||||
# decryption. Per-call std::thread::scope spawned fresh OS threads
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# libfreemkv — local dev helper.
|
||||
# Mirrors the cross-crate scripts in freemkv-private/scripts/test-all.sh
|
||||
# but scoped to this single crate.
|
||||
# Mirrors the workspace-wide CI checks but scoped to this single crate.
|
||||
|
||||
.PHONY: test build check ci clean
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
# libfreemkv
|
||||
|
||||
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Bundled drive profiles — no external files needed.
|
||||
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Firmware-clean core: drive-unlock support is plugged in via the `Unlocker` trait, with concrete unlockers shipped as separate crates.
|
||||
|
||||
Built-in keys cover DVDs and Blu-rays (AACS 1.0). For UHD (AACS 2.0 / 2.1) discs, an optional `keydb.cfg` supplies disc-specific volume unique keys.
|
||||
DVDs (CSS) decrypt out of the box. Blu-ray and UHD (AACS) require a `keydb.cfg` (default `~/.config/freemkv/keydb.cfg`) supplying disc-specific volume unique keys; no AACS key material is compiled in.
|
||||
|
||||
**12+ MB/s** sustained read speeds on BD. Full init: unlock, firmware upload, speed calibration — all from pure Rust.
|
||||
**12+ MB/s** sustained read speeds on BD. Drive prep routes through the pluggable unlock seam — register an unlocker and `init()` drives it; with none registered the library rips via the host-certificate AACS handshake.
|
||||
|
||||
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
|
||||
|
||||
@@ -20,7 +20,7 @@ Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
libfreemkv = "0.25"
|
||||
libfreemkv = "1.0.0-rc.1"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -29,10 +29,10 @@ libfreemkv = "0.25"
|
||||
use libfreemkv::{Drive, Disc, ScanOptions};
|
||||
use std::path::Path;
|
||||
|
||||
// Open drive — profiles are bundled, auto-identified
|
||||
// Open drive — identified via INQUIRY
|
||||
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||
drive.wait_ready()?; // wait for disc
|
||||
drive.init()?; // unlock + firmware upload
|
||||
drive.init()?; // route through the unlock seam (if an unlocker is registered)
|
||||
drive.probe_disc()?; // probe disc surface for optimal speeds
|
||||
|
||||
// Scan disc — UDF, playlists, streams, AACS (all automatic)
|
||||
@@ -100,7 +100,7 @@ loop {
|
||||
|
||||
## What It Does
|
||||
|
||||
- **Drive access** — open, identify, unlock, firmware upload, speed calibration, eject
|
||||
- **Drive access** — open, identify, pluggable unlock seam, speed control, eject
|
||||
- **12+ MB/s reads** — auto-detects kernel transfer limits, sustained full speed
|
||||
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
|
||||
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||
@@ -121,21 +121,21 @@ loop {
|
||||
| StdioStream | Yes (stdin) | Yes (stdout) | Raw byte pipe |
|
||||
| NullStream | -- | Yes | Discard sink (byte counter for benchmarks) |
|
||||
|
||||
Streams implement `FrameSource` (read) and/or `FrameSink` (write); direction is type-checked. `input()` / `output()` resolve URL strings to PES stream instances. All URLs use the `scheme://path` format — bare paths are rejected.
|
||||
Streams implement a single unified `pes::Stream` trait (re-exported as `PesStream`) exposing `read()` and `write()` on one type. `input()` / `output()` resolve URL strings to PES stream instances. All URLs use the `scheme://path` format — bare paths are rejected.
|
||||
|
||||
### Keys
|
||||
|
||||
DVDs (CSS) decrypt out of the box — the 1999-era public player keys are compiled into the library.
|
||||
DVDs (CSS) decrypt out of the box, with no external key file needed.
|
||||
|
||||
Blu-rays and UHD (AACS) require a `keydb.cfg` at `~/.config/freemkv/keydb.cfg` (or passed via `ScanOptions`). The file holds all DKs, PKs, host certs, and per-disc VUKs. No AACS key material is compiled into the binary.
|
||||
Blu-rays and UHD (AACS) require a `keydb.cfg` at `~/.config/freemkv/keydb.cfg` (or passed via `ScanOptions`). No AACS key material is compiled into the binary.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
Drive — open, identify, init, unlock, single-shot read
|
||||
Drive — open, identify, init, single-shot read
|
||||
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
|
||||
├── DriveProfile — per-drive unlock parameters (bundled)
|
||||
└── PlatformDriver — MediaTek (supported), Renesas (planned)
|
||||
└── Unlocker seam — pluggable trait + registry; concrete unlockers
|
||||
live in the separate freemkv-unlock repo
|
||||
|
||||
Disc — scan titles, streams, AACS/CSS state
|
||||
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
||||
@@ -144,12 +144,11 @@ Disc — scan titles, streams, AACS/CSS state
|
||||
├── IFO parser — DVD title sets, PGC chains, cell addresses
|
||||
├── Labels — 5 BD-J format parsers (detect + parse)
|
||||
├── AACS — key resolution + content decryption
|
||||
├── CSS — DVD CSS cipher (table-driven, no keys needed)
|
||||
├── CSS — DVD CSS (bus auth → player-key disc crack → known-plaintext title-key attack)
|
||||
└── KEYDB — download + verify + save
|
||||
|
||||
Streams — unified PES pipeline
|
||||
├── FrameSource — read() PES frames (direction-typed)
|
||||
├── FrameSink — write() PES frames (direction-typed)
|
||||
├── PesStream — pes::Stream: one trait, read()/write() PES frames
|
||||
├── DiscStream — sectors → decrypt → TS demux → PES
|
||||
├── IsoStream — ISO file → decrypt → TS demux → PES
|
||||
├── MkvStream — MKV mux/demux
|
||||
@@ -175,6 +174,7 @@ All errors are structured with numeric codes. No user-facing English text — ap
|
||||
| E6xxx | Disc format errors |
|
||||
| E7xxx | AACS errors |
|
||||
| E8xxx | KEYDB update errors |
|
||||
| E9xxx | Stream / mux errors (URL, PES, ISO, pipeline, demux) |
|
||||
|
||||
## Platform Support
|
||||
|
||||
@@ -186,7 +186,7 @@ All errors are structured with numeric codes. No user-facing English text — ap
|
||||
|
||||
## Contributing
|
||||
|
||||
Run `freemkv info disc:// --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
|
||||
Run `freemkv info disc:// --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to capture your drive's identity for contribution. Drive-unlock profiles are maintained in the [freemkv-unlock](https://github.com/freemkv/freemkv-unlock) repository.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -8,8 +8,22 @@ fn main() {
|
||||
let obj = format!("{out_dir}/macos_shim.o");
|
||||
let lib = format!("{out_dir}/libmacos_scsi.a");
|
||||
|
||||
// Build the shim for the TARGET arch, not the host's. A bare `cc` on an
|
||||
// Apple-Silicon CI runner defaults to arm64, so cross-building to
|
||||
// x86_64-apple-darwin would link a host-arch object against x86_64 Rust
|
||||
// code → "Undefined symbols for architecture x86_64". (Still raw `cc`,
|
||||
// not the `cc` crate, which breaks IOKit exclusive access.)
|
||||
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||
let clang_arch: &str = if target_arch == "aarch64" {
|
||||
"arm64"
|
||||
} else {
|
||||
&target_arch // x86_64 → x86_64
|
||||
};
|
||||
|
||||
std::process::Command::new("cc")
|
||||
.args([
|
||||
"-arch",
|
||||
clang_arch,
|
||||
"-c",
|
||||
"src/scsi/macos_shim.c",
|
||||
"-o",
|
||||
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# leak-guard.sh — self-contained public-repo leak gate.
|
||||
#
|
||||
# This is the LAST line of defense in CI. It is intentionally self-contained:
|
||||
# public CI cannot reach the private tooling, so this script encodes ONLY the
|
||||
# generic net — internal infrastructure references, agent-context files, and
|
||||
# AI-attribution in commit messages. It deliberately contains NO project-
|
||||
# specific reverse-engineering vocabulary (those words would themselves be a
|
||||
# leak). The richer private scanner stays private.
|
||||
#
|
||||
# Fails (exit 1) if any of the following appear in the repo:
|
||||
# 1. a tracked CLAUDE.md or .claude/ path (agent context — never public),
|
||||
# 2. tracked file content matching the internal-infra net,
|
||||
# 3. a commit message (in the given range) with AI attribution.
|
||||
#
|
||||
# Usage:
|
||||
# leak-guard.sh [<commit-range>]
|
||||
# <commit-range> optional git rev-list range to scan commit messages
|
||||
# (e.g. "abc..def"). If omitted, commit-message scan is
|
||||
# skipped (path + content checks always run).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Absolute path to this script, resolved before any cd, so we can exclude it
|
||||
# from the content scan (it necessarily contains the detection patterns).
|
||||
SELF_ABS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
|
||||
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO"
|
||||
|
||||
fail=0
|
||||
note() { printf ' ✗ %s\n' "$1"; fail=1; }
|
||||
|
||||
# Internal-infra net — GENERIC ONLY. This script ships in the public repo, so
|
||||
# the patterns themselves must not name any org-specific identifier (doing so
|
||||
# would itself leak the infra they guard). We catch the leak *class*:
|
||||
# - RFC1918 private IPv4 ranges (10/8, 172.16/12, 192.168/16),
|
||||
# - private/internal/non-routable TLDs (.internal/.local/.lan/.corp/.invalid),
|
||||
# - docker.internal.
|
||||
# The full org-specific net (literal hostnames, service names, repo paths,
|
||||
# vendor tooling, …) lives ONLY in the private scanner and never ships here.
|
||||
INFRA_RE='\b10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|\b172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3}|\b192\.168\.[0-9]{1,3}\.[0-9]{1,3}|\.internal\b|\.local\b|\.lan\b|\.corp\b|\.invalid\b|docker\.internal'
|
||||
# Home-path net — GENERIC ONLY. Catches an absolute developer home path
|
||||
# committed into a tracked file (a macOS /Users/<user>/… or Linux /home/<user>/…
|
||||
# path). This names NO specific user — it matches the leak *class* (any home
|
||||
# path), so the pattern itself reveals nothing org- or person-specific. A real
|
||||
# leak (e.g. /Users/alice/Developer/x slipping into a public RELEASE.md) trips
|
||||
# this regardless of whose machine it came from. The username segment is a
|
||||
# literal-username class ([A-Za-z0-9._-]) so dynamic/templated paths that build
|
||||
# the user at runtime — shell `/home/$USER/`, doc `/home/<rip>/`, Rust
|
||||
# `/home/{user}/` — do NOT false-positive; only a baked-in literal home leaks.
|
||||
HOMEPATH_RE='/Users/[A-Za-z0-9._-]+/|/home/[A-Za-z0-9._-]+/'
|
||||
# AI-attribution net (case-insensitive). "claude" matches only as a standalone
|
||||
# word — NOT preceded by a dot/slash/alnum and NOT followed by .md — so legit
|
||||
# mentions of CLAUDE.md / .claude/ in a commit message don't false-positive.
|
||||
ATTR_RE='co-authored-by|generated with|🤖|(?<![.\/A-Za-z0-9])claude(?!\.md)'
|
||||
|
||||
echo "── leak-guard: tracked agent-context paths ──"
|
||||
while IFS= read -r f; do
|
||||
case "$f" in
|
||||
CLAUDE.md|*/CLAUDE.md|.claude|.claude/*|*/.claude|*/.claude/*)
|
||||
note "tracked agent-context file: $f (CLAUDE.md/.claude must never be tracked in a public repo)" ;;
|
||||
esac
|
||||
done < <(git ls-files)
|
||||
|
||||
# Match a PCRE against a file, emitting "LINE: MATCH". The pattern is passed as
|
||||
# an argument (not interpolated into a //) so metacharacters like the "/" in a
|
||||
# path-style token can't break the regex. Reads raw bytes so non-UTF-8 blobs
|
||||
# don't abort the scan.
|
||||
pcre_matches() {
|
||||
perl -e '
|
||||
my ($file, $re) = @ARGV;
|
||||
open(my $fh, "<:raw", $file) or exit 0;
|
||||
my $rx; eval { $rx = qr/$re/i }; exit 0 if $@;
|
||||
while (my $l = <$fh>) { if ($l =~ /$rx/) { print "$.: $&\n"; } }
|
||||
' "$1" "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
# This script's own source necessarily contains the detection patterns (e.g.
|
||||
# the regex tokens in INFRA_RE), so scanning it would always self-flag. Skip it.
|
||||
SELF="$(git ls-files --full-name -- "$SELF_ABS" 2>/dev/null | head -1)"
|
||||
|
||||
echo "── leak-guard: internal-infra references in tracked files ──"
|
||||
while IFS= read -r f; do
|
||||
case "$f" in *.png|*.jpg|*.jpeg|*.ico|*.gif|*.bin|*.crate|*.gz|*.zip|*.pdf) continue ;; esac
|
||||
[ -n "$SELF" ] && [ "$f" = "$SELF" ] && continue
|
||||
[ -f "$f" ] || continue
|
||||
while IFS= read -r hit; do
|
||||
[ -z "$hit" ] && continue
|
||||
note "internal-infra reference: $f:$hit"
|
||||
done < <(pcre_matches "$f" "$INFRA_RE")
|
||||
while IFS= read -r hit; do
|
||||
[ -z "$hit" ] && continue
|
||||
note "[HOME-PATH] absolute home path: $f:$hit (no local home path may be committed to a public repo)"
|
||||
done < <(pcre_matches "$f" "$HOMEPATH_RE")
|
||||
done < <(git ls-files)
|
||||
|
||||
RANGE="${1:-}"
|
||||
if [ -n "$RANGE" ]; then
|
||||
echo "── leak-guard: AI-attribution in commit messages ($RANGE) ──"
|
||||
while IFS= read -r sha; do
|
||||
[ -z "$sha" ] && continue
|
||||
msg="$(git log -1 --format='%B' "$sha" 2>/dev/null || true)"
|
||||
# Pass the pattern as an argument (not interpolated into a //) so the
|
||||
# lookbehind char class and "/" don't break the regex.
|
||||
hit="$(printf '%s' "$msg" | perl -e '
|
||||
my $re = $ARGV[0]; my $rx = qr/$re/i;
|
||||
while (my $l = <STDIN>) { if ($l =~ /($rx)/) { print "$1\n"; last; } }
|
||||
' "$ATTR_RE" | head -1 || true)"
|
||||
[ -n "$hit" ] && note "commit ${sha:0:12}: message contains \"$hit\" (owner rule: zero AI attribution, ever)"
|
||||
done < <(git rev-list "$RANGE" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "✗ leak-guard: blocking finding(s) above — DO NOT MERGE/PUBLISH"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ leak-guard: clean"
|
||||
@@ -0,0 +1,290 @@
|
||||
# FVI — Freemkv Video Index Format
|
||||
|
||||
**Specification version:** 1.0 (DRAFT)
|
||||
**File extension:** `.fvi` **Media type:** `application/vnd.freemkv.fvi+jsonl`
|
||||
**Status:** Draft for review. This document is the normative reference for the FVI
|
||||
format; implementations and downstream tools cite it by section.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope and purpose
|
||||
|
||||
FVI is an open, codec-agnostic, byte-exact **index of the coded pictures** in a
|
||||
video bitstream, together with **provenance** back to the source medium.
|
||||
|
||||
An FVI document answers, for every picture in a stream, three questions:
|
||||
|
||||
1. **Where is it?** — the byte-exact offset of its first byte in the *source*
|
||||
(the disc/ISO/file), so a reader can extract or seek to any picture without
|
||||
re-parsing the whole bitstream.
|
||||
2. **What is it?** — coding type, random-access capability, GOP boundary, and
|
||||
(where the codec defines them) field/pulldown attributes.
|
||||
3. **When is it?** — decode and presentation timestamps on a declared timescale.
|
||||
|
||||
FVI is **not** a container, a codec, or a copy of the bitstream. It indexes; it
|
||||
never stores coded samples. It is the serialized form of an indexer's per-picture
|
||||
truth — carried from the demuxer, **never reconstructed** (§9).
|
||||
|
||||
### 1.1 Relationship to prior art
|
||||
|
||||
Legacy MPEG-only project-index formats from the AviSynth frameserving ecosystem
|
||||
solve a narrow version of (1) and (2) for MPEG-1/2 only, in a bespoke,
|
||||
single-tool text encoding. FVI generalizes that idea: codec-agnostic, JSON-based,
|
||||
provenance-native, and openly specified so any tool may read or write it.
|
||||
|
||||
## 2. Conformance
|
||||
|
||||
The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**,
|
||||
**SHOULD**, **SHOULD NOT**, **MAY**, and **OPTIONAL** are to be interpreted as
|
||||
described in BCP 14 (RFC 2119, RFC 8174) when, and only when, they appear in all
|
||||
capitals.
|
||||
|
||||
A **conformant writer** MUST emit a document that satisfies §4–§10. A
|
||||
**conformant reader** MUST accept any such document and MUST ignore unknown
|
||||
object members (§11) so that forward-compatible extensions do not break it.
|
||||
|
||||
## 3. Terminology
|
||||
|
||||
- **Picture** — one coded video frame (or pair of fields coded as a frame). The
|
||||
unit FVI indexes.
|
||||
- **Access unit (AU)** — the set of bitstream bytes that decode to exactly one
|
||||
picture (ISO/IEC 14496-10 §3; ISO/IEC 23008-2 §3).
|
||||
- **Coded order** — the order pictures appear in the bitstream. FVI records are
|
||||
emitted in coded order.
|
||||
- **GOP / coded video sequence** — a self-contained run beginning at a
|
||||
random-access point.
|
||||
- **Provenance** — the mapping from an AU back to the exact bytes of the physical
|
||||
source it was read from (§9).
|
||||
- **Source position (`src`)** — `{ file, sector, byte }`, the provenance anchor of
|
||||
an AU.
|
||||
|
||||
## 4. Encoding
|
||||
|
||||
An FVI document is a sequence of **UTF-8** text lines separated by a single LF
|
||||
(`U+000A`). Each non-empty line is exactly one JSON value (RFC 8259), forming a
|
||||
**JSON Lines / NDJSON** stream. A writer MUST NOT emit a UTF-8 BOM. A writer MUST
|
||||
NOT pretty-print: each JSON value occupies exactly one line.
|
||||
|
||||
The first line MUST be the **Header** object (§6). Each subsequent line is one
|
||||
**Picture record** (§7), in coded order.
|
||||
|
||||
Rationale: line-delimited JSON is streamable (a writer appends as it indexes; a
|
||||
reader processes without loading the whole file), line-addressable (picture *n*
|
||||
is near line *n+1*), append-safe, and parseable by every language without a
|
||||
custom grammar — while remaining a precisely specified format, not an ad-hoc dump.
|
||||
|
||||
A document MAY be concatenated for multiple elementary streams: each stream is its
|
||||
own header line followed by its records. Readers MUST treat a Header line as the
|
||||
start of a new stream section.
|
||||
|
||||
## 5. Document structure
|
||||
|
||||
```
|
||||
<header> line 1 (exactly one Header object)
|
||||
<record> line 2 .. N (one Picture record per picture, coded order)
|
||||
[<header> <record>…] (OPTIONAL further stream sections)
|
||||
```
|
||||
|
||||
## 6. Header object
|
||||
|
||||
| Member | JSON type | Req | Semantics / reference |
|
||||
|---|---|---|---|
|
||||
| `format` | string | MUST | Constant `"freemkv/video-index"`. Signature: a document begins with these bytes. |
|
||||
| `fvi_version` | integer | MUST | Document format version. This spec defines `1`. |
|
||||
| `generator` | string | SHOULD | Producing tool + version, e.g. `"freemkv/1.0.0-rc.6"`. |
|
||||
| `stream` | object | MUST | The indexed elementary stream (§6.1). |
|
||||
| `source` | object | MUST | Provenance root (§6.2). |
|
||||
| `timescale` | integer | MUST | Ticks per second for all `pts`/`dts` (§10). E.g. `90000`. |
|
||||
| `picture_count` | integer | MAY | Total pictures, if known at header time; OMITTED when streaming. |
|
||||
|
||||
### 6.1 `stream` object
|
||||
|
||||
| Member | JSON type | Req | Semantics / reference |
|
||||
|---|---|---|---|
|
||||
| `codec` | string | MUST | Registered codec id (Appendix B), e.g. `"mpeg2video"`, `"hevc"`. |
|
||||
| `width`,`height` | integer | MUST | Coded luma dimensions in pixels. |
|
||||
| `dar` | `[int,int]` | SHOULD | Display aspect ratio as `[num,den]`. |
|
||||
| `frame_rate` | `[int,int]` | SHOULD | Nominal rate as exact rational `[num,den]` (e.g. `[24000,1001]`). |
|
||||
| `scan` | string | MUST | `"progressive"` \| `"interlaced"` \| `"mbaff"`. |
|
||||
| `colour` | object | SHOULD | CICP per ITU-T H.273: `primaries`,`transfer`,`matrix` (integer CICP codes or registered names), `range` (`"limited"`\|`"full"`). HDR: `mastering_display`, `max_cll`, `max_fall` per ITU-T H.273 / SMPTE ST 2086. |
|
||||
| `language` | string | MAY | BCP 47 tag, if known. |
|
||||
|
||||
### 6.2 `source` object
|
||||
|
||||
| Member | JSON type | Req | Semantics |
|
||||
|---|---|---|---|
|
||||
| `medium` | string | MUST | `"disc"` \| `"iso"` \| `"file"` \| `"stream"`. |
|
||||
| `path` | string | MAY | Source path/label. |
|
||||
| `title` | integer | MAY | Title/program number. |
|
||||
| `playlist` | string | MAY | Playlist/PGC identifier. |
|
||||
| `volume_id` | string | MAY | Disc volume identifier, if read. |
|
||||
| `sector_size` | integer | SHOULD | Bytes per `src.sector` unit (e.g. `2048`). Lets readers convert `src` to an absolute byte offset. |
|
||||
|
||||
## 7. Picture record
|
||||
|
||||
One JSON object per coded picture, in coded order.
|
||||
|
||||
| Member | JSON type | Req | Semantics / reference |
|
||||
|---|---|---|---|
|
||||
| `n` | integer | MUST | Coded-order index, 0-based, contiguous. |
|
||||
| `src` | object | MUST | Provenance: `{ "file": int?, "sector": uint, "byte": uint }` — the offset of this AU's **first byte** in the source (§9). MUST be carried from demux, never reconstructed. |
|
||||
| `type` | string | MUST | Coding type: `"I"` \| `"P"` \| `"B"` (ISO/IEC 13818-2 §6.3.9; H.264/H.265 slice types collapsed to frame type). |
|
||||
| `key` | boolean | MUST | `true` iff this picture is an intra (I) picture / parser-flagged decode-restart point (IDR / IRAP / I-picture). MPEG-2 open-GOP clean-RAP precision (`closed_gop`) is not currently distinguished — see note below. |
|
||||
| `gop` | boolean | SHOULD | `true` iff this picture begins a GOP / coded video sequence. Omitted when the implementation does not carry a distinct GOP-boundary signal. |
|
||||
| `pts` | integer\|null | SHOULD | Presentation timestamp in `timescale` ticks; `null` if unknown. |
|
||||
| `dts` | integer\|null | MAY | Decode timestamp in `timescale` ticks. |
|
||||
| `size` | integer | MAY | AU length in bytes; enables byte-range extraction with `src`. |
|
||||
| `recovered` | boolean | MAY | `true` iff any byte of this AU came from a retried/marginal read (§9.1). Default `false`. |
|
||||
| codec ext | object | MAY | Codec-specific members under the codec's namespace (§8). |
|
||||
|
||||
The `type` and `key` members are **codec-agnostic** and MUST be populated for
|
||||
every codec. `type` is the I/P/B coding type the parser decoded (collapsing
|
||||
H.264/H.265 slice types to a frame type); where no per-picture coding is carried
|
||||
(audio / synthetic frames), `type` is `"I"` for a key picture else `"P"`. `key`
|
||||
is the picture's random-access flag as the codec parser sets it (IDR / IRAP /
|
||||
I-picture). A writer MUST NOT emit a degraded record (`type:"?"` or `src:null`)
|
||||
merely because a codec lacks per-picture coding info — those fallbacks are
|
||||
reserved for a field that is genuinely unavailable (e.g. provenance absent on a
|
||||
synthetic source).
|
||||
|
||||
> **Limitation (honest random-access).** `key` is set from the picture's
|
||||
> intra / decode-restart flag. The per-picture coding model this index carries
|
||||
> does **not** distinguish MPEG-2 open-GOP clean random-access points
|
||||
> (`closed_gop`) from any other I-picture, so `key` is the parser-flagged
|
||||
> decode-restart point, not a verified clean-RAP claim. A future revision MAY
|
||||
> tighten `key` for codecs/profiles that carry that signal; readers MUST NOT
|
||||
> assume present `key` precision beyond "intra / decode-restart point".
|
||||
|
||||
### 7.1 Interlace / pulldown fields
|
||||
|
||||
Codec-agnostic interlace/pulldown attributes, derived through the indexer's
|
||||
per-picture coding accessors (MPEG-2: ISO/IEC 13818-2 §6.3.10). Emitted as
|
||||
top-level members of the record, and ONLY when the codec actually measured the
|
||||
signal — an OPTIONAL member that is omitted (not defaulted) when unknown:
|
||||
|
||||
| Member | JSON type | Req | Semantics |
|
||||
|---|---|---|---|
|
||||
| `field_order` | string | MAY | Display field order: `"tff"` (top field first) \| `"bff"` (bottom field first) \| `"progressive"` (no field order applies). Omitted when the codec did not signal it. |
|
||||
| `progressive` | boolean | MAY | `true` iff the picture is progressive. Omitted when the codec did not signal it. |
|
||||
| `nb_fields` | integer | MAY | Number of displayed field periods this picture occupies (the soft-telecine / 2:3 pulldown basis): `1` for a single field picture, `2` for a normal frame, `3`/`4`/`6` for `repeat_first_field` pulldown per §6.3.10. |
|
||||
|
||||
Codecs that carry only a coding type (e.g. H.264 / HEVC / VC-1 through this
|
||||
pipeline) omit `field_order` and `progressive` rather than guessing a default.
|
||||
|
||||
## 8. Codec model and extensibility
|
||||
|
||||
Core record members (§7) are codec-agnostic and present for every codec.
|
||||
Codec-specific data is either (a) promoted to top-level members for a small,
|
||||
registered set per codec profile (e.g. MPEG-2 §7.1), or (b) placed under an
|
||||
`ext` object keyed by codec id for richer/optional data:
|
||||
|
||||
```json
|
||||
{"n":42,"type":"P","key":false,"src":{"sector":17,"byte":924},
|
||||
"ext":{"hevc":{"temporal_id":0,"nal_type":1}}}
|
||||
```
|
||||
|
||||
New codecs and members are added through Appendix B (codec registry) without a
|
||||
breaking version bump, provided readers continue to ignore unknown members (§11).
|
||||
|
||||
## 9. Provenance and recovery semantics
|
||||
|
||||
`src` is **byte-exact** to the source as read. `src.sector` counts in
|
||||
`source.sector_size`-byte units; `src.byte` is the offset within that sector of
|
||||
the AU's first byte. For multi-file sources, `src.file` indexes a writer-declared
|
||||
file list. Provenance MUST be the value observed at demux time; an implementation
|
||||
MUST NOT recompute `src` by re-parsing — the point of FVI is to *carry* the truth.
|
||||
|
||||
### 9.1 Recovery
|
||||
|
||||
Because FVI is provenance-native, it can record reliability. A record with
|
||||
`"recovered":true` indicates the AU's source bytes required retry/marginal-read
|
||||
recovery. This lets downstream tools surface or quarantine pictures whose bytes
|
||||
are not byte-identical to a clean read — a capability legacy index formats lack.
|
||||
|
||||
## 10. Time model
|
||||
|
||||
All `pts`/`dts` are integers in units of `1/timescale` seconds. `pts` is
|
||||
presentation (display) time; `dts` is decode time. Records are in **coded**
|
||||
(decode) order, so `pts` is not necessarily monotonic across records (B-pictures
|
||||
reorder); `dts` is non-decreasing. Readers needing display order sort by `pts`.
|
||||
|
||||
## 11. Versioning and forward compatibility
|
||||
|
||||
- `fvi_version` is the document version; this spec defines `1`.
|
||||
- **Additive** changes (new OPTIONAL members, new registered codecs) do NOT bump
|
||||
`fvi_version`. Readers MUST ignore members they do not recognize.
|
||||
- A change that alters the meaning of an existing member or makes a new member
|
||||
REQUIRED bumps `fvi_version`.
|
||||
- A reader encountering a higher `fvi_version` than it implements SHOULD process
|
||||
the members it understands and MUST NOT reject the document solely for the
|
||||
version being higher, unless a member it relies on is absent.
|
||||
|
||||
## 12. Conformance requirements (summary)
|
||||
|
||||
A conformant **writer** MUST: emit a Header first; emit records in coded order
|
||||
with contiguous `n`; populate `src` from demux; use named/registered codec ids;
|
||||
encode one JSON value per UTF-8 LF-terminated line.
|
||||
|
||||
A conformant **reader** MUST: accept any §4–§10 document; ignore unknown members;
|
||||
not assume `picture_count`, `pts`, or `size` are present unless required above.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — JSON Schema (informative)
|
||||
|
||||
Header:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"required": ["format","fvi_version","stream","source","timescale"],
|
||||
"properties": {
|
||||
"format": {"const": "freemkv/video-index"},
|
||||
"fvi_version": {"type": "integer", "minimum": 1},
|
||||
"timescale": {"type": "integer", "minimum": 1},
|
||||
"stream": {"type": "object", "required": ["codec","width","height","scan"]},
|
||||
"source": {"type": "object", "required": ["medium"]}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Record:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"required": ["n","src","type","key"],
|
||||
"properties": {
|
||||
"n": {"type": "integer", "minimum": 0},
|
||||
"type": {"enum": ["I","P","B"]},
|
||||
"key": {"type": "boolean"},
|
||||
"src": {"type":"object","required":["sector","byte"],
|
||||
"properties":{"file":{"type":"integer"},
|
||||
"sector":{"type":"integer","minimum":0},
|
||||
"byte":{"type":"integer","minimum":0}}}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Appendix B — Registered codec identifiers
|
||||
|
||||
| `codec` | Bitstream | Field profile |
|
||||
|---|---|---|
|
||||
| `mpeg2video` | ISO/IEC 13818-2 | §7.1 (field_order/progressive/nb_fields) |
|
||||
| `mpeg1video` | ISO/IEC 11172-2 | §7.1 |
|
||||
| `h264` | ISO/IEC 14496-10 | core + `ext.h264` |
|
||||
| `hevc` | ISO/IEC 23008-2 | core + `ext.hevc` |
|
||||
| `vc1` | SMPTE 421M | core |
|
||||
|
||||
## Appendix C — Normative references
|
||||
|
||||
- RFC 2119, RFC 8174 — Requirement keywords (BCP 14).
|
||||
- RFC 8259 — JSON.
|
||||
- ISO/IEC 13818-2 — MPEG-2 video (picture coding, §6.3.9–6.3.10).
|
||||
- ISO/IEC 14496-10 — H.264/AVC. ISO/IEC 23008-2 — H.265/HEVC.
|
||||
- ITU-T H.273 — Coding-independent code points (colour primaries/transfer/matrix).
|
||||
- SMPTE ST 2086 — Mastering display colour volume (HDR).
|
||||
- BCP 47 — Language tags.
|
||||
- RFC 9559 — Matroska (alignment of colour/field-order semantics).
|
||||
+50
-264
@@ -2,191 +2,45 @@
|
||||
|
||||
## Overview
|
||||
|
||||
AACS (Advanced Access Content System) is the encryption layer used by Blu-ray and UHD 4K discs to protect content. libfreemkv implements AACS decryption to enable transparent disc access.
|
||||
AACS (Advanced Access Content System) is the encryption layer used by Blu-ray
|
||||
and UHD 4K discs to protect content. libfreemkv implements AACS decryption so
|
||||
disc access is transparent to the application.
|
||||
|
||||
There are two major versions:
|
||||
|
||||
- **AACS 1.0** -- Used by standard Blu-ray discs. Relies on a custom 160-bit elliptic curve for bus authentication and AES-128 for content encryption. Processing keys and device keys can derive the media key from the disc's Media Key Block (MKB).
|
||||
|
||||
- **AACS 2.0** -- Used by UHD 4K Blu-ray discs. Adds a per-sector bus encryption layer (read_data_key) on top of the standard content encryption. Uses P-256/SHA-256 for its native handshake, though drives accept AACS 1.0 host certificates for backward compatibility.
|
||||
|
||||
Both versions use AES-128-CBC for content decryption with a fixed initialization vector. The fundamental key hierarchy is the same: a Volume Unique Key (VUK) decrypts per-title unit keys, which in turn decrypt the content stream.
|
||||
|
||||
|
||||
## Architecture
|
||||
|
||||
AACS support is split across two modules:
|
||||
|
||||
### `aacs.rs` -- Keys and Decryption
|
||||
|
||||
Handles everything related to key resolution and content decryption:
|
||||
|
||||
- KEYDB.cfg parsing (device keys, processing keys, host certificates, per-disc entries)
|
||||
- Disc hash computation (SHA-1 of `Unit_Key_RO.inf`)
|
||||
- VUK resolution chain (4 paths, described below)
|
||||
- MKB record parsing and media key derivation
|
||||
- Subset-difference tree traversal (AACS-G3 key derivation)
|
||||
- Unit_Key_RO.inf parsing and unit key decryption
|
||||
- Content Certificate parsing (AACS version detection)
|
||||
- Aligned unit decryption (AES-128-CBC)
|
||||
- Bus decryption (AACS 2.0 read_data_key layer)
|
||||
|
||||
### `aacs_handshake.rs` -- SCSI Authentication
|
||||
|
||||
Handles the drive-level SCSI authentication protocol:
|
||||
|
||||
- ECDH key agreement on the AACS 160-bit curve
|
||||
- ECDSA signing and verification
|
||||
- Bus key derivation
|
||||
- AGID management (allocate/invalidate)
|
||||
- Volume ID retrieval (encrypted with bus key, verified by AES-CMAC)
|
||||
- Read Data Key retrieval (for AACS 2.0 bus decryption)
|
||||
- AACS LA public key certificate verification
|
||||
|
||||
|
||||
## Key Resolution Chain
|
||||
|
||||
When a disc is scanned, `resolve_keys()` attempts four paths in priority order. The first path that succeeds is used.
|
||||
|
||||
### Path 1: KEYDB VUK Lookup (fastest)
|
||||
|
||||
```
|
||||
Unit_Key_RO.inf --> SHA-1 --> disc_hash --> KEYDB lookup --> VUK
|
||||
```
|
||||
|
||||
The disc hash is computed as the SHA-1 digest of the raw `Unit_Key_RO.inf` file from the disc's `/AACS/` directory. This hash is used as the lookup key in `KEYDB.cfg`. If a matching entry contains a VUK (`V` field), it is used directly.
|
||||
|
||||
This is the fast path and resolves the vast majority of discs in a well-maintained KEYDB.
|
||||
|
||||
### Path 2: KEYDB Media Key + Volume ID
|
||||
|
||||
```
|
||||
KEYDB media_key + Volume ID (from SCSI handshake) --> VUK derivation
|
||||
```
|
||||
|
||||
If the disc hash is not in the KEYDB but a KEYDB entry has a matching Volume ID (`I` field) and a media key (`M` field), the VUK is derived:
|
||||
|
||||
```
|
||||
VUK = AES-128-ECB-DECRYPT(media_key, volume_id) XOR volume_id
|
||||
```
|
||||
|
||||
Requires a successful SCSI handshake to obtain the Volume ID.
|
||||
|
||||
### Path 3: MKB + Processing Keys
|
||||
|
||||
```
|
||||
MKB (from disc) + processing_keys (from KEYDB) --> media_key --> VUK
|
||||
```
|
||||
|
||||
Processing keys are pre-computed keys that work against specific MKB versions. For each processing key, the library:
|
||||
|
||||
1. Parses the MKB to extract the Verify Media Key Record (`mk_dv`), subset-difference index, and conditional values (cvalues).
|
||||
2. Tries each processing key against each UV/cvalue pair: `mk = AES-DEC(pk, cvalue) XOR cvalue`.
|
||||
3. Validates the derived media key: `AES-ECB(mk, mk_dv)` must produce 12 leading zero bytes.
|
||||
4. Derives VUK from the validated media key and Volume ID.
|
||||
|
||||
### Path 4: MKB + Device Keys (Subset-Difference Tree)
|
||||
|
||||
```
|
||||
MKB + device_keys --> subset-difference tree traversal --> processing_key --> media_key --> VUK
|
||||
```
|
||||
|
||||
The most complex path. Each device key has an associated node number, UV value, and mask parameters that position it in the AACS subset-difference tree. The library:
|
||||
|
||||
1. Finds the subset-difference entry in the MKB that applies to the device key's node.
|
||||
2. Traverses the tree using AACS-G3 key derivation: `aesg3(key, inc) = AES-DEC(key, seed) XOR seed`, where `seed[15]` is incremented by `inc`. Each tree node produces a left child (inc=0), a processing key (inc=1), and a right child (inc=2).
|
||||
3. At each level, selects left or right based on the UV bit at the current position.
|
||||
4. The resulting processing key is validated against the MKB cvalue to derive the media key.
|
||||
5. VUK is derived from the media key and Volume ID.
|
||||
|
||||
|
||||
## Content Decryption
|
||||
|
||||
### Aligned Units
|
||||
|
||||
AACS encrypts content in aligned units of 6144 bytes (3 sectors of 2048 bytes each). The encryption flag is signaled by the copy_permission_indicator bits in byte 0 of the unit (`unit[0] & 0xC0 != 0`).
|
||||
|
||||
### Per-Unit Key Derivation
|
||||
|
||||
Each aligned unit has its own decryption key derived from the CPS unit key:
|
||||
|
||||
1. **Derive**: AES-128-ECB encrypt the first 16 bytes of the unit (plaintext TP_extra_header) with the unit key.
|
||||
2. **XOR**: XOR the encrypted result with the original 16 bytes to produce the per-unit decryption key.
|
||||
3. **Decrypt**: AES-128-CBC decrypt bytes 16 through 6143 using the per-unit key and the fixed AACS IV.
|
||||
4. **Clear flag**: Clear the encryption indicator bits (`unit[0] &= !0xC0`).
|
||||
|
||||
### Fixed IV
|
||||
|
||||
All AES-CBC operations in AACS use the same fixed initialization vector, defined in the AACS specification.
|
||||
|
||||
### Verification
|
||||
|
||||
After decryption, the library verifies correctness by checking for MPEG-TS sync bytes (0x47) at the expected 192-byte packet boundaries within the unit. Blu-ray transport stream packets are 192 bytes: 4-byte TP_extra_header followed by a 188-byte TS packet.
|
||||
|
||||
|
||||
## Bus Encryption
|
||||
|
||||
### AACS 1.0
|
||||
|
||||
Standard Blu-ray discs do not use bus encryption. Content is read directly from the disc and decrypted using the unit key.
|
||||
|
||||
### AACS 2.0
|
||||
|
||||
UHD 4K discs add a per-sector bus encryption layer. The drive encrypts data as it is read from the disc, and the host must decrypt it before applying AACS content decryption.
|
||||
|
||||
Bus encryption uses a **read_data_key** obtained during the SCSI handshake. For each 2048-byte sector within an aligned unit, bytes 16 through 2047 are AES-128-CBC encrypted with the read_data_key and the fixed AACS IV. The first 16 bytes of each sector remain plaintext.
|
||||
|
||||
The full decryption pipeline for AACS 2.0:
|
||||
|
||||
1. **Bus decrypt**: For each sector, AES-128-CBC decrypt bytes 16..2047 with the read_data_key.
|
||||
2. **Content decrypt**: Standard per-unit key derivation and AES-128-CBC decryption as described above.
|
||||
|
||||
|
||||
## SCSI Handshake
|
||||
|
||||
The AACS SCSI authentication handshake establishes a shared bus key between host and drive, then uses it to securely transfer the Volume ID and read data keys.
|
||||
|
||||
### Protocol Flow
|
||||
|
||||
1. **Invalidate AGIDs**: Send REPORT KEY with format 0x3F for AGIDs 0-3 to clear stale sessions.
|
||||
2. **Allocate AGID**: REPORT KEY format 0x00 returns a fresh Authentication Grant ID.
|
||||
3. **Send host credentials**: SEND KEY format 0x01 transmits the host nonce (20 random bytes) and host certificate (92 bytes).
|
||||
4. **Receive drive credentials**: REPORT KEY format 0x01 returns the drive nonce and drive certificate.
|
||||
5. **Receive drive key**: REPORT KEY format 0x02 returns the drive's ephemeral EC key point and ECDSA signature over `host_nonce || drive_key_point`.
|
||||
6. **Verify drive key**: The signature is verified against the drive's public key (extracted from its certificate). AACS 1.0 certificates are verified against the AACS LA public key.
|
||||
7. **Send host key**: The host generates an ephemeral key pair, signs `drive_nonce || host_key_point` with the host private key, and sends via SEND KEY format 0x02.
|
||||
8. **Compute bus key**: ECDH shared secret = `host_private_key * drive_key_point`. The bus key is the low 128 bits of the shared point's x-coordinate.
|
||||
|
||||
### Post-Authentication Reads
|
||||
|
||||
- **Volume ID**: REPORT DISC STRUCTURE format 0x80. Returns 16-byte VID encrypted with the bus key, plus an AES-CMAC MAC for integrity verification.
|
||||
- **Read Data Keys**: REPORT DISC STRUCTURE format 0x84. Returns the read_data_key and write_data_key, each AES-ECB encrypted with the bus key.
|
||||
|
||||
### Elliptic Curve
|
||||
|
||||
AACS 1.0 uses a custom 160-bit Weierstrass curve (`y^2 = x^3 + ax + b mod p`) with 20-byte field elements. The library implements full EC arithmetic: point addition, doubling, scalar multiplication, modular inverse, ECDSA sign/verify, and ECDH key agreement.
|
||||
|
||||
|
||||
## AACS 2.0 Status
|
||||
|
||||
AACS 2.0 discs are detected via the Content Certificate file (`Content000.cer` or `Content001.cer`). A certificate type byte of 0x01 indicates AACS 2.0.
|
||||
|
||||
AACS 2.0 drives are identified by their drive certificate type (0x11). These drives natively use P-256/SHA-256, but accept AACS 1.0 host certificates for backward compatibility.
|
||||
|
||||
Current implementation status:
|
||||
|
||||
- AACS 2.0 detection: **implemented** (Content Certificate parsing, drive cert type check)
|
||||
- AACS 1.0 handshake with AACS 2.0 drives: **implemented** (backward compatibility mode)
|
||||
- Full P-256 AACS 2.0 handshake: **not yet implemented** (prepared but rarely needed since drives accept AACS 1.0 host certs)
|
||||
- Bus decryption with read_data_key: **implemented**
|
||||
- Content decryption: **implemented** (same as AACS 1.0)
|
||||
|
||||
In practice, AACS 2.0 UHD discs work through the backward-compatible AACS 1.0 handshake path, with the addition of read_data_key bus decryption.
|
||||
|
||||
- **AACS 1.0** -- Used by standard Blu-ray discs.
|
||||
- **AACS 2.0 / 2.1** -- Used by UHD 4K Blu-ray discs. Adds a per-sector bus
|
||||
encryption layer on top of the standard content encryption. UHD drives accept
|
||||
AACS 1.0 host credentials for backward compatibility.
|
||||
|
||||
All versions use AES-128 for content decryption. The library reads the keys it
|
||||
needs from `keydb.cfg`, walks the disc's Media Key Block (MKB) to resolve the
|
||||
disc's key, and decrypts the content stream. AACS-encrypted discs therefore
|
||||
require a `keydb.cfg`; CSS-protected DVDs do not (see the CSS notes in the
|
||||
library docs).
|
||||
|
||||
## How it works (feature level)
|
||||
|
||||
When a disc is scanned, the library:
|
||||
|
||||
1. Reads the disc's AACS key-input files from the `/AACS/` directory.
|
||||
2. Resolves the disc's key from `keydb.cfg` — either directly from a per-disc
|
||||
entry, or by walking the MKB with the keys present in the keydb.
|
||||
3. Performs the drive-level SCSI authentication handshake needed to obtain the
|
||||
Volume ID and, for UHD, the bus-decryption key.
|
||||
4. Decrypts the content stream as titles are read.
|
||||
|
||||
A resolved key is verified against actual disc content before it is applied, so
|
||||
a stale or wrong key fails loudly rather than producing silent garbage. If no
|
||||
usable key is available for an AACS-encrypted disc, the library surfaces a
|
||||
specific error (the E70xx family) describing which part of the chain was
|
||||
missing, and a missing `keydb.cfg` surfaces as `Error::KeydbLoad` with the
|
||||
sentinel path `<no keydb in search paths>`.
|
||||
|
||||
## API Usage
|
||||
|
||||
AACS decryption is transparent to the application. The `Disc::scan()` method handles everything automatically:
|
||||
AACS decryption is transparent to the application. `Disc::scan()` handles
|
||||
everything automatically:
|
||||
|
||||
```rust
|
||||
use libfreemkv::{Drive, Disc};
|
||||
@@ -203,7 +57,6 @@ if disc.encrypted {
|
||||
if let Some(ref aacs) = disc.aacs {
|
||||
println!("AACS {}.0", aacs.version);
|
||||
println!("Key source: {}", aacs.key_source.name());
|
||||
println!("Disc hash: {}", aacs.disc_hash);
|
||||
if let Some(mkb_ver) = aacs.mkb_version {
|
||||
println!("MKB version: {}", mkb_ver);
|
||||
}
|
||||
@@ -215,108 +68,41 @@ if disc.encrypted {
|
||||
// Read content -- decryption is automatic
|
||||
let mut reader = disc.open_title(&mut session, 0).unwrap();
|
||||
while let Some(unit) = reader.read_unit().unwrap() {
|
||||
// unit is 6144 bytes of decrypted content
|
||||
// decrypted content
|
||||
}
|
||||
```
|
||||
|
||||
The application never touches keys, never calls decryption functions, and never manages handshakes. All of that is internal to `Disc::scan()` and `ContentReader::read_unit()`.
|
||||
The application never touches keys, never calls decryption functions, and never
|
||||
manages handshakes. All of that is internal to `Disc::scan()` and the content
|
||||
reader.
|
||||
|
||||
### KEYDB Location
|
||||
|
||||
`ScanOptions` controls where the KEYDB is loaded from. If no explicit path is set, the library checks:
|
||||
|
||||
1. `~/.config/aacs/KEYDB.cfg`
|
||||
2. `/etc/aacs/KEYDB.cfg`
|
||||
|
||||
To specify an explicit path:
|
||||
`ScanOptions` controls where the keydb is loaded from. If no explicit path is
|
||||
set, the library checks the standard config locations. To specify an explicit
|
||||
path:
|
||||
|
||||
```rust
|
||||
let opts = ScanOptions::with_keydb("/path/to/KEYDB.cfg");
|
||||
let opts = ScanOptions::with_keydb("/path/to/keydb.cfg");
|
||||
let disc = Disc::scan(&mut session, &opts).unwrap();
|
||||
```
|
||||
|
||||
### AacsState
|
||||
|
||||
After a successful scan, `disc.aacs` contains an `AacsState` with:
|
||||
After a successful scan, `disc.aacs` contains an `AacsState`:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `version` | `u8` | AACS version (1 or 2) |
|
||||
| `bus_encryption` | `bool` | Whether bus encryption is active |
|
||||
| `mkb_version` | `Option<u32>` | MKB version from disc |
|
||||
| `disc_hash` | `String` | SHA-1 of Unit_Key_RO.inf (hex with 0x prefix) |
|
||||
| `key_source` | `KeySource` | How keys were resolved |
|
||||
| `vuk` | `[u8; 16]` | Volume Unique Key |
|
||||
| `unit_keys` | `Vec<(u32, [u8; 16])>` | Decrypted unit keys (CPS unit number, key) |
|
||||
| `read_data_key` | `Option<[u8; 16]>` | AACS 2.0 bus decryption key |
|
||||
| `volume_id` | `[u8; 16]` | Volume ID from SCSI handshake |
|
||||
| `disc_hash` | `String` | Identifier for the disc's key-input files |
|
||||
| `key_source` | `KeySource` | How the disc's key was resolved |
|
||||
|
||||
### KeySource
|
||||
## keydb.cfg
|
||||
|
||||
| Variant | Description |
|
||||
|---------|-------------|
|
||||
| `KeyDb` | VUK found directly in KEYDB by disc hash |
|
||||
| `KeyDbDerived` | Media key + Volume ID from KEYDB, VUK derived |
|
||||
| `ProcessingKey` | MKB + processing keys from KEYDB |
|
||||
| `DeviceKey` | MKB + device keys, subset-difference tree traversal |
|
||||
|
||||
|
||||
## KEYDB.cfg Format Reference
|
||||
|
||||
The KEYDB.cfg file contains all cryptographic material needed for AACS decryption. Lines starting with `;` or `#` are comments.
|
||||
|
||||
### Device Keys
|
||||
|
||||
```
|
||||
| DK | DEVICE_KEY 0x<key> | DEVICE_NODE 0x<node> | KEY_UV 0x<uv> | KEY_U_MASK_SHIFT 0x<shift>
|
||||
```
|
||||
|
||||
- `key`: 16-byte AES device key (hex)
|
||||
- `node`: Device node number in the subset-difference tree (hex)
|
||||
- `uv`: UV value for tree positioning (hex)
|
||||
- `shift`: U mask shift value (hex)
|
||||
|
||||
### Processing Keys
|
||||
|
||||
```
|
||||
| PK | 0x<key>
|
||||
```
|
||||
|
||||
- `key`: 16-byte pre-computed processing key (hex)
|
||||
|
||||
### Host Certificate
|
||||
|
||||
```
|
||||
| HC | HOST_PRIV_KEY 0x<privkey> | HOST_CERT 0x<cert>
|
||||
```
|
||||
|
||||
- `privkey`: 20-byte ECDSA private key (hex)
|
||||
- `cert`: 92-byte AACS host certificate (hex)
|
||||
|
||||
The host certificate is used for SCSI authentication. It contains the host's public key and is signed by the AACS Licensing Administrator.
|
||||
|
||||
### Disc Entries
|
||||
|
||||
```
|
||||
0x<disc_hash> = <title> | D | <date> | M | 0x<media_key> | I | 0x<disc_id> | V | 0x<vuk> | U | <unit_keys>
|
||||
```
|
||||
|
||||
- `disc_hash`: 20-byte SHA-1 of Unit_Key_RO.inf (hex)
|
||||
- `title`: Human-readable disc title
|
||||
- `D`: Date tag, followed by release/rip date
|
||||
- `M`: Media key tag, followed by 16-byte media key (hex)
|
||||
- `I`: Disc ID tag, followed by 16-byte Volume ID (hex)
|
||||
- `V`: VUK tag, followed by 16-byte Volume Unique Key (hex)
|
||||
- `U`: Unit keys tag, followed by space-separated `<unit_num>-0x<key>` pairs
|
||||
|
||||
All fields after the title are optional. A minimal entry needs only the disc hash and VUK:
|
||||
|
||||
```
|
||||
0x<disc_hash> = <title> | V | 0x<vuk>
|
||||
```
|
||||
|
||||
Inline comments are supported with `;`:
|
||||
|
||||
```
|
||||
0x<disc_hash> = <title> | V | 0x<vuk> ; MKBv77
|
||||
```
|
||||
`keydb.cfg` is the single source of AACS key material. It is a text file (lines
|
||||
starting with `;` or `#` are comments) holding the host credentials and per-disc
|
||||
entries the library uses to resolve a disc. autorip can auto-download and
|
||||
refresh it from a configured URL. The library does not ship any AACS keys
|
||||
compiled into the binary.
|
||||
|
||||
+9
-10
@@ -85,10 +85,10 @@ All URLs require a `scheme://path` format. Bare paths are rejected.
|
||||
// PES pipeline (frame-level) — input() returns Box<dyn FrameSource>,
|
||||
// output() returns Box<dyn FrameSink>.
|
||||
let input = libfreemkv::input("disc:///dev/sg4", &opts)?; // DiscStream
|
||||
let input = libfreemkv::input("iso://Dune.iso", &opts)?; // IsoStream
|
||||
let output = libfreemkv::output("mkv://Dune.mkv", &title)?; // MkvOutputStream
|
||||
let output = libfreemkv::output("m2ts://Dune.m2ts", &title)?; // M2tsOutputStream
|
||||
let output = libfreemkv::output("network://10.1.7.11:9000", &title)?; // NetworkOutputStream
|
||||
let input = libfreemkv::input("iso://Movie.iso", &opts)?; // IsoStream
|
||||
let output = libfreemkv::output("mkv://Movie.mkv", &title)?; // MkvOutputStream
|
||||
let output = libfreemkv::output("m2ts://Movie.m2ts", &title)?; // M2tsOutputStream
|
||||
let output = libfreemkv::output("network://192.0.2.10:9000", &title)?; // NetworkOutputStream
|
||||
let output = libfreemkv::output("null://", &title)?; // NullOutputStream
|
||||
```
|
||||
|
||||
@@ -171,23 +171,23 @@ libfreemkv/src/
|
||||
│ └── writeback.rs sync_file_range pipeline
|
||||
├── drive/ Drive (open, init, single-shot read)
|
||||
│ ├── mod.rs Drive struct, init, read (single-shot), reset, eject
|
||||
│ ├── capture.rs Drive profile capture for contribution
|
||||
│ ├── capture.rs Raw drive SCSI capture (INQUIRY/GET_CONFIG) for contribution
|
||||
│ ├── linux.rs Linux drive discovery
|
||||
│ ├── macos.rs macOS drive discovery
|
||||
│ └── windows.rs Windows drive discovery
|
||||
├── disc/ Disc (scan, titles, AACS setup, sweep, patch)
|
||||
│ ├── mod.rs Disc struct, scan, titles, formats
|
||||
│ ├── sweep.rs Disc::sweep (Pass 1 forward sweep)
|
||||
│ ├── mod.rs Disc struct, scan, titles, formats; Disc::copy + Disc::sweep (Pass 1)
|
||||
│ ├── sweep.rs Pass 1 internal helpers (pub(super))
|
||||
│ ├── patch.rs Disc::patch (Pass N retry over mapfile)
|
||||
│ ├── mapfile.rs ddrescue-format mapfile
|
||||
│ └── read_error.rs ReadCtx / ReadAction state machine
|
||||
├── scsi/ SCSI transport (Linux SG_IO, macOS IOKit, Windows SPTI)
|
||||
├── platform/ Drive unlock (MT1959 A/B)
|
||||
├── unlock.rs Unlocker trait + registry (pluggable unlock seam)
|
||||
├── aacs/ AACS decryption (handshake, keys, keydb, decrypt)
|
||||
├── css/ DVD CSS cipher
|
||||
├── decrypt.rs Unified decrypt dispatcher (AACS/CSS/None)
|
||||
├── pes.rs PES frame types, FrameSource / FrameSink traits
|
||||
├── sector/ Sector I/O (was sector.rs in 0.17)
|
||||
├── sector/ Sector I/O
|
||||
│ ├── mod.rs SectorSource, SectorSink traits
|
||||
│ ├── file.rs FileSectorSource, FileSectorSink (ISO-backed)
|
||||
│ └── decrypting.rs DecryptingSectorSource decorator
|
||||
@@ -198,7 +198,6 @@ libfreemkv/src/
|
||||
├── labels/ BD-J label extraction (5 format parsers)
|
||||
├── keydb.rs KEYDB download, parse, save
|
||||
├── identity.rs DriveId from INQUIRY
|
||||
├── profile.rs Bundled drive profiles
|
||||
├── speed.rs DriveSpeed enum
|
||||
├── mux/
|
||||
│ ├── mod.rs Public mux exports
|
||||
|
||||
+27
-25
@@ -1,8 +1,10 @@
|
||||
# libfreemkv Architecture
|
||||
|
||||
Open source optical drive access library for 4K UHD Blu-ray, Blu-ray, and DVD.
|
||||
Rust library with no external dependencies at runtime -- profiles are bundled,
|
||||
AACS keys are derived internally, and all SCSI communication is handled in-process.
|
||||
Rust library with profiles bundled and all SCSI communication handled in-process.
|
||||
AACS decryption requires an external `keydb.cfg` (default
|
||||
`~/.config/freemkv/keydb.cfg`) — the derivation math is internal, but no AACS key
|
||||
material is compiled in; DVD CSS player keys are the only compiled-in keys.
|
||||
|
||||
**Repository:** <https://github.com/freemkv/libfreemkv>
|
||||
**License:** AGPL-3.0-only
|
||||
@@ -15,9 +17,10 @@ AACS keys are derived internally, and all SCSI communication is handled in-proce
|
||||
format handling live in the library. CLI binaries are thin wrappers that call
|
||||
`Drive::open()` and `Disc::scan()`.
|
||||
|
||||
2. **No external files.** Bundled drive profiles are compiled into the binary via
|
||||
`include_str!`. No configuration directory, no runtime file lookups for drive
|
||||
support.
|
||||
2. **Firmware-clean core.** libfreemkv ships no firmware, no unlock CDBs, and no
|
||||
drive profiles. Drive-unlock logic is plugged in by an external crate through
|
||||
the `Unlocker` trait + registry (`register_unlocker`); without one the library
|
||||
still rips via the host-certificate AACS handshake.
|
||||
|
||||
3. **Transparent AACS.** The `ContentReader` decrypts on the fly when keys are
|
||||
available. Callers read cleartext sectors without knowing whether the disc
|
||||
@@ -41,11 +44,9 @@ AACS keys are derived internally, and all SCSI communication is handled in-proce
|
||||
libfreemkv (lib.rs)
|
||||
│
|
||||
├── Drive Access
|
||||
│ ├── drive Drive — open, identify, init, unlock, single-shot read
|
||||
│ ├── drive Drive — open, identify, init, single-shot read
|
||||
│ ├── scsi ScsiTransport trait + platform backends (sg async, IOKit, SPTI)
|
||||
│ ├── platform/ Platform trait — per-chipset command handlers
|
||||
│ │ └── mt1959 MediaTek MT1959 driver (LG, ASUS, HP)
|
||||
│ ├── profile DriveProfile loading, matching, bundled JSON
|
||||
│ ├── unlock Unlocker trait + registry — the pluggable unlock seam
|
||||
│ ├── identity DriveId from INQUIRY + GET_CONFIG 010C
|
||||
│ ├── speed DriveSpeed enum, SET CD SPEED CDB builder
|
||||
│ └── event Event system for drive status callbacks
|
||||
@@ -65,7 +66,7 @@ libfreemkv (lib.rs)
|
||||
│
|
||||
├── Streaming
|
||||
│ ├── mux/ Stream implementations (Disc, ISO, MKV, M2TS, Network, Stdio, Null)
|
||||
│ ├── pes PES frame types; FrameSource / FrameSink direction-typed traits
|
||||
│ ├── pes PES frame types; the unified pes::Stream (PesStream) read/write trait
|
||||
│ └── sector/ SectorSource / SectorSink traits, FileSector{Source,Sink}, DecryptingSectorSource
|
||||
│
|
||||
├── I/O Primitives
|
||||
@@ -74,8 +75,7 @@ libfreemkv (lib.rs)
|
||||
│
|
||||
├── Support
|
||||
│ ├── keydb KEYDB.cfg download, parse, verify, save
|
||||
│ ├── error Error enum with numeric codes E1000-E8000
|
||||
│ └── profile Bundled drive profiles
|
||||
│ └── error Error enum with numeric codes E1000-E8000
|
||||
│
|
||||
└── lib.rs Public API re-exports
|
||||
```
|
||||
@@ -89,13 +89,12 @@ Drive::open(Path::new("/dev/sg4"))
|
||||
│
|
||||
├─ scsi::open() Open /dev/sg4 (async write/poll/read)
|
||||
├─ DriveId::from_drive() INQUIRY + GET_CONFIG 010C
|
||||
├─ profile::find_by_drive_id() Match against bundled profiles
|
||||
├─ Platform::new() Instantiate chipset driver (Mt1959)
|
||||
└─ Drive ready for init/unlock/read
|
||||
└─ Drive ready for init/read
|
||||
```
|
||||
|
||||
After open:
|
||||
- `init()` -- unlock + firmware upload + speed calibration
|
||||
- `init()` -- routes to the matching registered unlocker (if any); otherwise
|
||||
a no-op and the cert handshake carries the disc
|
||||
- `probe_disc()` -- probe disc surface for optimal speeds
|
||||
- `read(lba, count, buf, recovery)` -- single-shot read; `recovery` only selects the per-CDB timeout (1.5 s vs. 30 s)
|
||||
- `wait_ready()` -- wait for disc insertion
|
||||
@@ -195,17 +194,20 @@ implementing `execute()` for that OS and wiring it into `scsi::open()`.
|
||||
|
||||
---
|
||||
|
||||
## Chipset Support
|
||||
## Drive Unlock
|
||||
|
||||
| Chipset | Drives | Status |
|
||||
|---------|--------|--------|
|
||||
| MediaTek MT1959 | LG, ASUS, HP | Supported (bundled profiles) |
|
||||
| Renesas RS8xxx/RS9xxx | Pioneer, some HL-DT-ST | Planned |
|
||||
libfreemkv carries no drive-unlock mechanism. The `Unlocker` trait + registry
|
||||
(`src/unlock.rs`) is the seam: an external crate implements `Unlocker` and
|
||||
registers it once via `register_unlocker(...)`. At drive-prep the registry is
|
||||
walked in order and the first unlocker whose `matches()` is true is asked to
|
||||
`unlock_drive()` over the raw `ScsiTransport`. If none match, the drive is left
|
||||
untouched and the host-certificate AACS handshake carries the disc.
|
||||
|
||||
The `Platform` trait abstracts chipset-specific commands. Each chipset implements
|
||||
handlers (unlock, config, register, calibrate, keepalive, status, probe,
|
||||
read_sectors, timing). All handlers are accessed via SCSI READ BUFFER with
|
||||
chipset-specific mode and buffer ID bytes.
|
||||
The implementor owns everything firmware-specific — drive profiles, vendor CDBs,
|
||||
variant logic. Concrete unlockers live in the separate
|
||||
**[freemkv-unlock](https://github.com/freemkv/freemkv-unlock)** repository, never
|
||||
in libfreemkv. See [`drive-access.md`](drive-access.md#drive-unlock-seam) for the
|
||||
trait definition and routing.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -189,7 +189,7 @@ The full ripping pipeline chains three parsers:
|
||||
2. **CLPI** converts those timestamps to SPN ranges, then to sector extents.
|
||||
3. **UDF** provides the file's starting LBA on disc for absolute sector addressing.
|
||||
|
||||
The `Disc::scan()` method in `src/disc.rs` orchestrates this: for each play item in each playlist, it loads the corresponding CLPI, calls `get_extents()` with the play item's in/out times, and collects the resulting sector ranges into the title's extent list.
|
||||
The `Disc::scan()` method in `src/disc/mod.rs` orchestrates this: for each play item in each playlist, it loads the corresponding CLPI, calls `get_extents()` with the play item's in/out times, and collects the resulting sector ranges into the title's extent list.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
+7
-7
@@ -10,14 +10,14 @@ Insert disc
|
||||
│
|
||||
▼
|
||||
1. Open drive (drive/mod.rs)
|
||||
│ INQUIRY → identify drive
|
||||
│ Match bundled profile → chipset, unlock parameters
|
||||
│ INQUIRY → identify drive (DriveId)
|
||||
│
|
||||
▼
|
||||
2. Init drive (drive/mod.rs → platform/mt1959)
|
||||
│ Firmware upload (if needed, 10s recovery wait)
|
||||
│ Unlock → vendor-specific command activates raw read mode
|
||||
│ Speed calibration → probe_disc()
|
||||
2. Init drive (drive/mod.rs → unlock seam)
|
||||
│ Walk the registered-unlocker registry; first match unlocks the drive
|
||||
│ (firmware/vendor handshakes are the unlocker's own business)
|
||||
│ No match → drive untouched; host-cert AACS handshake carries the disc
|
||||
│ Speed control → probe_disc()
|
||||
│
|
||||
▼
|
||||
3. AACS handshake (aacs/handshake.rs) — optional
|
||||
@@ -98,7 +98,7 @@ drive.probe_disc()?;
|
||||
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
|
||||
|
||||
// Stream pipeline — PES frames from any source to any output.
|
||||
// 0.18: input() returns Box<dyn FrameSource>, output() returns Box<dyn FrameSink>;
|
||||
// input() returns Box<dyn FrameSource>, output() returns Box<dyn FrameSink>;
|
||||
// direction is type-checked, so calling .write() on an input is a compile error.
|
||||
let opts = InputOptions::default();
|
||||
let mut input = libfreemkv::input("disc:///dev/sg4", &opts)?;
|
||||
|
||||
+63
-89
@@ -7,8 +7,9 @@ optical drives.
|
||||
|
||||
## Drive
|
||||
|
||||
`Drive` is the primary API. It owns the SCSI transport, the matched
|
||||
drive profile, and the chipset-specific platform driver.
|
||||
`Drive` is the primary API. It owns the SCSI transport and the drive
|
||||
identity (`DriveId`); any drive-specific unlock logic lives behind the
|
||||
pluggable [unlock seam](#drive-unlock-seam), not in `Drive` itself.
|
||||
|
||||
### Opening a Drive
|
||||
|
||||
@@ -16,15 +17,16 @@ drive profile, and the chipset-specific platform driver.
|
||||
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||
```
|
||||
|
||||
`open()` performs: open device → send INQUIRY → match profile → instantiate
|
||||
platform driver. The drive is ready for `wait_ready()` and `init()`.
|
||||
`open()` performs: open device → send INQUIRY → build `DriveId`. The drive
|
||||
is ready for `wait_ready()` and `init()` (which routes through the unlock
|
||||
seam).
|
||||
|
||||
### Drive Operations
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `wait_ready()` | Wait for disc insertion (30s timeout, TUR polling) |
|
||||
| `init()` | Firmware upload + unlock + speed calibration |
|
||||
| `init()` | Route to the matching registered unlocker (if any), then prepare for reads |
|
||||
| `probe_disc()` | Probe disc surface for optimal speeds |
|
||||
| `read(lba, count, buf, recovery)` | Read sectors. Single-shot — no inline retries or reset. |
|
||||
| `reset()` | Eject-cycle escape hatch. Caller-invoked only; not on the read path. |
|
||||
@@ -32,17 +34,21 @@ platform driver. The drive is ready for `wait_ready()` and `init()`.
|
||||
| `unlock_tray()` | Allow tray ejection (also runs on Drop) |
|
||||
| `eject()` | Eject disc tray |
|
||||
| `drive_status()` | Query physical state (disc present, tray open, etc.) |
|
||||
| `has_profile()` | Whether a bundled profile matched |
|
||||
| `has_profile()` | Whether a registered unlocker matches this drive |
|
||||
| `close()` | Consume Drive, cleanup (also runs via Drop) |
|
||||
|
||||
### init() Sequence
|
||||
|
||||
`init()` orchestrates the full drive unlock:
|
||||
`init()` routes drive preparation through the unlock seam:
|
||||
|
||||
1. Platform driver `run_init()` — sends vendor-specific SCSI commands
|
||||
2. If firmware upload needed: upload, wait 10s for drive reset, retry
|
||||
3. Speed calibration after unlock
|
||||
4. Max 3 attempts before giving up
|
||||
1. Walk the registered-unlocker registry; the first whose `matches()` is true
|
||||
is asked to `unlock_drive()` over the raw transport.
|
||||
2. Whatever that unlocker needs (firmware upload, vendor handshakes, retries)
|
||||
is the unlocker's own business — libfreemkv only forwards the transport.
|
||||
3. If no unlocker matches, the drive is left untouched and the library uses
|
||||
the host-certificate AACS handshake.
|
||||
|
||||
See [Drive Unlock Seam](#drive-unlock-seam) for the trait and registry.
|
||||
|
||||
### read() — single-shot
|
||||
|
||||
@@ -164,100 +170,68 @@ date for drives where Feature 010C is unavailable.
|
||||
|
||||
---
|
||||
|
||||
## Drive Profiles
|
||||
## Drive Unlock Seam
|
||||
|
||||
Profiles are JSON objects compiled into the binary (`profiles.json`).
|
||||
Each profile contains:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `vendor_id`, `product_revision`, `vendor_specific`, `firmware_date` | Matching fields |
|
||||
| `chipset` | `"mediatek"` or `"renesas"` |
|
||||
| `unlock_mode`, `unlock_buf_id` | READ BUFFER CDB parameters |
|
||||
| `signature` | Expected 4-byte response signature |
|
||||
| `unlock_cdb` | Pre-built unlock CDB (hex-encoded) |
|
||||
| `register_offsets` | Offsets for hardware register reads |
|
||||
| `capabilities` | Feature flags: `bd_raw_read`, `dvd_all_regions`, etc. |
|
||||
|
||||
Loading:
|
||||
libfreemkv ships **no firmware, no unlock CDBs, and no drive profiles.** It
|
||||
knows only the *seam*, never the *mechanism*. The seam is the `Unlocker`
|
||||
trait plus a small process-wide registry (`src/unlock.rs`):
|
||||
|
||||
```rust
|
||||
// Bundled (compiled-in) -- no file I/O
|
||||
let profiles = profile::load_bundled()?;
|
||||
pub trait Unlocker: Send + Sync {
|
||||
/// Stable, language-neutral identifier (logged).
|
||||
fn name(&self) -> &str;
|
||||
|
||||
// External file
|
||||
let profiles = profile::load_all(Path::new("/path/to/profiles.json"))?;
|
||||
/// True if this unlocker handles the given drive.
|
||||
fn matches(&self, id: &DriveId) -> bool;
|
||||
|
||||
/// Put the drive into extended-access mode. The one required capability.
|
||||
fn unlock_drive(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<()>;
|
||||
|
||||
/// Read the disc Volume ID via the drive's OEM path. Default: no-op.
|
||||
fn read_volume_id(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId)
|
||||
-> Result<Option<[u8; 16]>> { Ok(None) }
|
||||
|
||||
/// Raise the drive to its maximum read speed. Default: no-op.
|
||||
fn set_max_read_speed(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId)
|
||||
-> Result<()> { Ok(()) }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
An unlocker is supplied by an **external crate** and registered once at
|
||||
process start:
|
||||
|
||||
## Chipsets
|
||||
```rust
|
||||
libfreemkv::register_unlocker(Box::new(some_unlocker::Plugin::new()));
|
||||
```
|
||||
|
||||
### MediaTek MT1959
|
||||
The implementor owns everything about *how* a particular drive family is
|
||||
driven — drive identification against its own profile database, firmware
|
||||
upload, vendor CDBs, variant logic. libfreemkv only hands over the raw
|
||||
`ScsiTransport` and the `DriveId`.
|
||||
|
||||
Covers all LG, ASUS, and HP optical drives. Two sub-variants share identical
|
||||
logic with different SCSI parameters:
|
||||
### Routing
|
||||
|
||||
| Variant | READ BUFFER mode | Buffer ID |
|
||||
|---------|------------------|-----------|
|
||||
| MT1959-A | 0x01 | 0x44 |
|
||||
| MT1959-B | 0x02 | 0x77 |
|
||||
At drive-prep the registry is walked in registration order; the first
|
||||
unlocker whose `matches()` returns true is asked to `unlock_drive()` (and,
|
||||
when needed, `read_volume_id()` / `set_max_read_speed()`). If no unlocker
|
||||
matches, the drive is left untouched and the library falls back to the
|
||||
standard host-certificate AACS handshake (the "OEM route"). The
|
||||
`register_unlocker(...)` line is the entire plug: drop it (and the unlocker
|
||||
crate) and libfreemkv still compiles and rips via the cert handshake.
|
||||
|
||||
The Platform trait maps to command handlers:
|
||||
|
||||
| Handler | Function | Description |
|
||||
|---------|----------|-------------|
|
||||
| 0 | `unlock()` | Send READ BUFFER, verify signature + verification bytes |
|
||||
| 1 | `read_config()` | Read 1888-byte configuration block + 4-byte status |
|
||||
| 2-3 | `read_register()` | Read hardware registers at profile-specified offsets |
|
||||
| 4 | `calibrate()` | Probe disc surface, build 64-entry speed table |
|
||||
| 5 | `keepalive()` | Periodic session maintenance |
|
||||
| 6 | `status()` | Query current mode and feature flags |
|
||||
| 7 | `probe()` | Generic READ BUFFER with dynamic parameters |
|
||||
| 8 | `read_sectors()` | Speed lookup + SET CD SPEED + READ(10) with flag 0x08 |
|
||||
| 9 | `timing()` | Timing calibration |
|
||||
|
||||
### Renesas (Planned)
|
||||
|
||||
RS8xxx/RS9xxx chipsets used in Pioneer and some HL-DT-ST drives.
|
||||
Currently returns `Error::UnsupportedDrive` when a Renesas profile is matched.
|
||||
|
||||
---
|
||||
|
||||
## Why Unlock Is Needed
|
||||
|
||||
Optical drive firmware restricts what applications can read from disc. Without
|
||||
unlock:
|
||||
|
||||
- **READ(10) works for unencrypted filesystem data.** UDF structures, MPLS
|
||||
playlists, and CLPI clip info are readable without unlock. Standard READ(10)
|
||||
works on any drive.
|
||||
|
||||
- **READ(10) fails for encrypted content sectors.** The drive firmware returns
|
||||
SCSI errors (sense key 0x05, illegal request) when an application attempts to
|
||||
read sectors containing encrypted m2ts content without prior AACS
|
||||
authentication via the bus key.
|
||||
|
||||
- **Raw mode bypasses firmware restrictions.** After unlock, the drive accepts
|
||||
READ(10) with the raw read flag (CDB byte 1 = 0x08) for all sectors,
|
||||
regardless of encryption status.
|
||||
|
||||
### AACS Before Unlock
|
||||
|
||||
AACS bus authentication uses standard MMC REPORT KEY / SEND KEY commands.
|
||||
On some drives these must execute before unlock. The `Disc::scan()` handles
|
||||
this internally — it manages the handshake/unlock ordering automatically.
|
||||
Concrete unlockers — including the firmware-unlock profile databases,
|
||||
variant logic, and vendor CDBs that used to live in-tree — are maintained
|
||||
in the separate **[freemkv-unlock](https://github.com/freemkv/freemkv-unlock)**
|
||||
repository, never here.
|
||||
|
||||
---
|
||||
|
||||
## Speed Control
|
||||
|
||||
After `probe_disc()`, the platform driver maintains a speed lookup table
|
||||
built by probing the disc surface. On each `read()` call, the driver:
|
||||
|
||||
1. Looks up the optimal speed for the target LBA.
|
||||
2. Issues SET CD SPEED (0xBB) if the speed differs from current.
|
||||
3. Performs the READ(10).
|
||||
A matching unlocker may raise the drive to its maximum read speed via
|
||||
`set_max_read_speed()` (a no-op when no unlocker matches or the unlocker
|
||||
declines). The library issues SET CD SPEED (0xBB) through the generic CDB
|
||||
builder; the concrete speed policy lives in the unlocker.
|
||||
|
||||
Available speeds:
|
||||
|
||||
|
||||
@@ -108,8 +108,8 @@ lines of `Mapfile::stats()` checks.
|
||||
2. On success: write data to ISO, mark `+`, advance.
|
||||
3. On failure (with `multipass`): zero-fill, mark `*`, advance.
|
||||
4. Track a sliding window of the last 16 ECC block results. When ≥12% are failures
|
||||
→ **damage-jump**: skip ahead by `256×batch×multiplier` sectors (8 MB base for
|
||||
UHD). Double the multiplier on each jump (8→16→32→64 MB...). Zero-fill the gap as `*`.
|
||||
→ **damage-jump**: skip ahead by `1024×batch×multiplier` sectors (64 MB base for
|
||||
UHD). Double the multiplier on each jump (64→128→256→512 MB...). Zero-fill the gap as `*`.
|
||||
5. On 16 consecutive good reads: reset jump multiplier to 1, restore max read speed.
|
||||
6. Speed control: damage zone entry → minimum speed, exit → maximum speed.
|
||||
7. Only transport failures (USB bridge crash) abort the pass.
|
||||
@@ -161,8 +161,8 @@ recoveries to show for it. Recovery responsibility is now layered: layer 1
|
||||
handles ranges, layer 3 handles request size, neither touches the
|
||||
wedge-prone reset path.
|
||||
|
||||
**No `MODE SELECT` to disable drive retries.** Research showed neither ddrescue
|
||||
nor MakeMKV does this. Drive firmware has access to raw analog signal, laser
|
||||
**No `MODE SELECT` to disable drive retries.** Neither ddrescue
|
||||
nor any consumer ripper does this. Drive firmware has access to raw analog signal, laser
|
||||
power control, and drive-specific ECC tuning that userspace can't replicate —
|
||||
disabling it throws away recovery headroom on marginal sectors. We fail fast
|
||||
via short SG_IO timeouts in pass 1 and let the firmware work the long timeout
|
||||
@@ -178,7 +178,7 @@ explicitly by callers that need an eject-cycle escape hatch — it is never
|
||||
reached from a read path.
|
||||
|
||||
**ISO intermediate, even for single-pass.** Pass 1 always writes an ISO. The
|
||||
mux stage reads the ISO via `IsoSectorReader`. For single-pass (no retries),
|
||||
mux stage reads the ISO via `FileSectorSource`. For single-pass (no retries),
|
||||
this adds ~2-3 min (local disk mux) but gains resumability across crashes,
|
||||
re-muxability without re-ripping, and a persistent forensic artifact. Callers
|
||||
who need pure speed can bypass and use `DiscStream::new(Box::new(drive), …)`
|
||||
@@ -198,4 +198,4 @@ scrape vs. retry with direction reversal) if there's measured benefit.
|
||||
|
||||
- [ddrescue manual, Algorithm chapter](https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html)
|
||||
- [ddrescue optical media notes](https://www.electric-spoon.com/doc/gddrescue/html/Optical-media.html)
|
||||
- Source: [`src/disc/mapfile.rs`](../src/disc/mapfile.rs), [`src/disc/sweep.rs`](../src/disc/sweep.rs) (`Disc::sweep`), [`src/disc/patch.rs`](../src/disc/patch.rs) (`Disc::patch`), [`src/drive/mod.rs`](../src/drive/mod.rs) (`Drive::read`), [`src/mux/disc.rs`](../src/mux/disc.rs) (`DiscStream::fill_extents`).
|
||||
- Source: [`src/disc/mapfile.rs`](../src/disc/mapfile.rs), [`src/disc/mod.rs`](../src/disc/mod.rs) (`Disc::sweep`), [`src/disc/patch.rs`](../src/disc/patch.rs) (`Disc::patch`), [`src/drive/mod.rs`](../src/drive/mod.rs) (`Drive::read`), [`src/mux/disc.rs`](../src/mux/disc.rs) (`DiscStream::fill_extents`).
|
||||
|
||||
-4325
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
//! AACS derivation "boil-down" — one public home for the key chain.
|
||||
//!
|
||||
//! Thin newtypes at the API boundary and three wrapper functions over the
|
||||
//! existing crypto. Nothing here re-implements a primitive: every function
|
||||
//! delegates to the already-audited code in [`super::keys`] and
|
||||
//! [`super::variants`], so the boil-down cannot drift from production math.
|
||||
//!
|
||||
//! The newtypes wrap bare `[u8; 16]` ONLY at this boundary — the crypto
|
||||
//! internals continue to operate on raw arrays. They exist so a caller threads
|
||||
//! the chain `DK → MK → VUK → UK` without confusing one 16-byte secret for
|
||||
//! another, not to refactor the resolver.
|
||||
//!
|
||||
//! Chain (matches `aacs::keys::resolve_keys_classical` path 1 and
|
||||
//! `aacs::keys::resolve_keys_v21` path 1 byte-for-byte):
|
||||
//!
|
||||
//! ```text
|
||||
//! mk_from_dk(device_keys, mkb, vid) → MediaKey (Km)
|
||||
//! vuk_from_mk(MediaKey, Vid) → Vuk (= AES-G(Km, VID))
|
||||
//! uk_from_vuk(Vuk, enc_title_keys) → [UnitKey] (decrypt_unit_key each)
|
||||
//! ```
|
||||
|
||||
use super::keys::{decrypt_unit_key, derive_vuk};
|
||||
use super::types::DeviceKey;
|
||||
use super::variants::{KEY_CORRECTION_DATA_PLACEHOLDER, derive_media_key_variant, walk_mkb};
|
||||
|
||||
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vid(pub [u8; 16]);
|
||||
|
||||
/// Media Key (Km, 16 bytes) — the MKB-scoped key derived from device keys.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MediaKey(pub [u8; 16]);
|
||||
|
||||
/// Volume Unique Key (VUK / Kvu, 16 bytes) — derived from `MediaKey` + `Vid`,
|
||||
/// decrypts the per-disc encrypted title keys in `Unit_Key_RO.inf`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vuk(pub [u8; 16]);
|
||||
|
||||
/// One decrypted per-CPS-unit AACS title key.
|
||||
///
|
||||
/// `idx` is the POSITIONAL index of the encrypted title key within the slice
|
||||
/// handed to [`uk_from_vuk`] (i.e. its order in `Unit_Key_RO.inf`'s key-storage
|
||||
/// area). The CPS-unit *number* association (the `u32` in
|
||||
/// `ResolvedKeys::unit_keys`) is a higher-level concern owned by
|
||||
/// [`super::keys::parse_unit_key_ro`], which pairs each positional key with its
|
||||
/// declared CPS unit; this primitive only does the AES, so it surfaces position.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UnitKey {
|
||||
pub idx: u32,
|
||||
pub key: [u8; 16],
|
||||
}
|
||||
|
||||
/// Derive the Volume Unique Key from a Media Key and Volume ID.
|
||||
///
|
||||
/// Wraps [`derive_vuk`] verbatim: `VUK = AES-128-ECB-DECRYPT(MK, VID) XOR VID`.
|
||||
/// This is byte-identical to the inline `derive_vuk(&mk, ctx.volume_id)` call in
|
||||
/// every classical resolver path AND to the `Kvu = AES-G(Km, VID)` step inside
|
||||
/// [`derive_media_key_variant`] (AES-G and `derive_vuk` are the same math), so
|
||||
/// `vuk_from_mk(mk_from_dk(..)?, vid)` reproduces the V21 variant VUK exactly.
|
||||
pub fn vuk_from_mk(mk: MediaKey, vid: Vid) -> Vuk {
|
||||
Vuk(derive_vuk(&mk.0, &vid.0))
|
||||
}
|
||||
|
||||
/// Decrypt the disc's encrypted title keys with a VUK.
|
||||
///
|
||||
/// Wraps [`decrypt_unit_key`] (AES-128-ECB-DECRYPT) per entry, mirroring the
|
||||
/// `derive_uks` closure in `resolve_keys_classical` / `resolve_keys_v21`. The
|
||||
/// returned `UnitKey::idx` is the slice position; pair with CPS-unit numbers via
|
||||
/// [`super::keys::parse_unit_key_ro`] when the numbering matters.
|
||||
pub fn uk_from_vuk(vuk: Vuk, enc_title_keys: &[[u8; 16]]) -> Vec<UnitKey> {
|
||||
enc_title_keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, enc)| UnitKey {
|
||||
idx: i as u32,
|
||||
key: decrypt_unit_key(&vuk.0, enc),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Derive the Media Key (Km) from device keys via the Media Key Variant chain.
|
||||
///
|
||||
/// Wraps [`walk_mkb`] + [`derive_media_key_variant`] with exactly the arguments
|
||||
/// `resolve_keys_v21` path 1 passes: the placeholder Key Correction Data and the
|
||||
/// disc Volume ID. Returns the FIRST tuple element `Km` (the Media Key) — the
|
||||
/// resolver treats `Km` as the media key and derives the VUK from it as
|
||||
/// `Kvu = AES-G(Km, VID)`, which equals [`vuk_from_mk`]`(MediaKey(km), vid)`. The
|
||||
/// variant fn's second element is that already-derived `Kvu`; returning `Km`
|
||||
/// keeps this primitive at the "media key" level so the chain composes.
|
||||
///
|
||||
/// Because the integrator KCD is unavailable in-tree (the placeholder is
|
||||
/// rejected by the variant chain), this returns `Err` for every real disc today
|
||||
/// — byte-for-byte identical to `resolve_keys_v21` path 1, which the resolver
|
||||
/// also leaves unreachable in production. All variant-chain failures collapse to
|
||||
/// [`Error::AacsMkUnavailable`] (E7018): no numeric distinction is load-bearing
|
||||
/// at this boundary, and the variant error carries no English to preserve.
|
||||
pub fn mk_from_dk(
|
||||
device_keys: &[DeviceKey],
|
||||
mkb: &[u8],
|
||||
vid: Vid,
|
||||
) -> Result<MediaKey, crate::error::Error> {
|
||||
let records = walk_mkb(mkb);
|
||||
match derive_media_key_variant(
|
||||
&records,
|
||||
device_keys,
|
||||
&KEY_CORRECTION_DATA_PLACEHOLDER,
|
||||
&vid.0,
|
||||
) {
|
||||
Ok((km, _kvu)) => Ok(MediaKey(km)),
|
||||
Err(_) => Err(crate::error::Error::AacsMkUnavailable),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs::decrypt::aes_ecb_encrypt;
|
||||
use crate::aacs::keys::{decrypt_unit_key, derive_vuk};
|
||||
|
||||
/// `vuk_from_mk` must equal the inline `derive_vuk` path bit-for-bit, for
|
||||
/// several known (MK, VID) vectors.
|
||||
#[test]
|
||||
fn vuk_from_mk_matches_inline_derive_vuk() {
|
||||
let cases: [([u8; 16], [u8; 16]); 3] = [
|
||||
([0x5A; 16], [0xA5; 16]),
|
||||
([0x11; 16], [0x22; 16]),
|
||||
(
|
||||
[
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
|
||||
0x0D, 0x0E, 0x0F,
|
||||
],
|
||||
[
|
||||
0xF0, 0xE1, 0xD2, 0xC3, 0xB4, 0xA5, 0x96, 0x87, 0x78, 0x69, 0x5A, 0x4B, 0x3C,
|
||||
0x2D, 0x1E, 0x0F,
|
||||
],
|
||||
),
|
||||
];
|
||||
for (mk, vid) in cases {
|
||||
let inline = derive_vuk(&mk, &vid);
|
||||
let boiled = vuk_from_mk(MediaKey(mk), Vid(vid));
|
||||
assert_eq!(boiled.0, inline, "vuk_from_mk must equal derive_vuk");
|
||||
}
|
||||
}
|
||||
|
||||
/// `uk_from_vuk` must equal the inline `decrypt_unit_key` path bit-for-bit
|
||||
/// and carry positional indices 0..n. Built by encrypting known plaintext
|
||||
/// title keys under the VUK (the same primitive the resolver inverts).
|
||||
#[test]
|
||||
fn uk_from_vuk_matches_inline_decrypt_unit_key() {
|
||||
let vuk = [0x5Au8; 16];
|
||||
let plain_keys = [[0x11u8; 16], [0x22u8; 16], [0xCDu8; 16]];
|
||||
let enc: Vec<[u8; 16]> = plain_keys
|
||||
.iter()
|
||||
.map(|k| aes_ecb_encrypt(&vuk, k))
|
||||
.collect();
|
||||
|
||||
let boiled = uk_from_vuk(Vuk(vuk), &enc);
|
||||
assert_eq!(boiled.len(), enc.len());
|
||||
for (i, uk) in boiled.iter().enumerate() {
|
||||
assert_eq!(uk.idx, i as u32, "idx must be the positional index");
|
||||
// Matches the inline derive_uks closure: decrypt_unit_key(vuk, enc).
|
||||
assert_eq!(uk.key, decrypt_unit_key(&vuk, &enc[i]));
|
||||
// And recovers the original plaintext title key.
|
||||
assert_eq!(
|
||||
uk.key, plain_keys[i],
|
||||
"VUK roundtrip recovers the title key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `uk_from_vuk` on an empty slice yields no keys (no panic, no phantom idx).
|
||||
#[test]
|
||||
fn uk_from_vuk_empty_is_empty() {
|
||||
assert!(uk_from_vuk(Vuk([0u8; 16]), &[]).is_empty());
|
||||
}
|
||||
|
||||
/// `mk_from_dk` returns `Err(AacsMkUnavailable)` for the placeholder-KCD
|
||||
/// path that production also leaves unreachable — never a wrong key, never a
|
||||
/// panic — on both an empty MKB and a non-variant MKB.
|
||||
#[test]
|
||||
fn mk_from_dk_errors_without_integrator_kcd() {
|
||||
let dk = DeviceKey {
|
||||
key: [0x11; 16],
|
||||
node: 1,
|
||||
uv: 1,
|
||||
u_mask_shift: 0,
|
||||
};
|
||||
// Empty MKB → not a variant MKB → Err.
|
||||
let e = mk_from_dk(std::slice::from_ref(&dk), &[], Vid([0x09; 16]));
|
||||
assert!(matches!(e, Err(crate::error::Error::AacsMkUnavailable)));
|
||||
|
||||
// A variant-looking MKB (0x82 record) still cannot complete without the
|
||||
// integrator KCD, so it also errors — never silently yields a key.
|
||||
let mut mkb: Vec<u8> = Vec::new();
|
||||
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]); // variant data record
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
let e2 = mk_from_dk(&[dk], &mkb, Vid([0x09; 16]));
|
||||
assert!(matches!(e2, Err(crate::error::Error::AacsMkUnavailable)));
|
||||
}
|
||||
}
|
||||
+738
-44
@@ -13,11 +13,36 @@ pub(crate) const AACS_IV: [u8; 16] = [
|
||||
/// Size of an AACS aligned unit (3 × 2048-byte sectors).
|
||||
pub const ALIGNED_UNIT_LEN: usize = 6144;
|
||||
|
||||
/// Size of one sector.
|
||||
const SECTOR_LEN: usize = 2048;
|
||||
/// An AACS aligned unit spans this many 2048-byte sectors (3).
|
||||
pub const ALIGNED_UNIT_SECTORS: u32 = (ALIGNED_UNIT_LEN / SECTOR_BYTES) as u32;
|
||||
|
||||
/// Transport stream packet spacing in Blu-ray m2ts (192 bytes = 4 TP_extra + 188 TS).
|
||||
const TS_PACKET_LEN: usize = 192;
|
||||
/// Whether `lba` sits on an AACS aligned-unit boundary, measured **relative to
|
||||
/// the encrypted region's base LBA** (`unit_base` = the clip/extent `start_lba`,
|
||||
/// NOT absolute disc LBA 0).
|
||||
///
|
||||
/// AACS aligned units (6144 B / 3 sectors) are anchored at the start of each
|
||||
/// clip's encrypted region, so a read must begin a whole number of units past
|
||||
/// that base for `decrypt_sectors` (which anchors units at buffer offset 0) to
|
||||
/// align the CBC correctly. This is the SINGLE source of truth for the test —
|
||||
/// the decrypt-on-read gate, the inline and highway mux read paths, and the
|
||||
/// key-validation sample reader all key off this, never absolute `lba % 3`. A
|
||||
/// disc whose clip `start_lba` is not itself 3-aligned would otherwise mis-gate
|
||||
/// (reject readable units, then report "Decryption failed") on exactly the
|
||||
/// titles whose clips land off a 3-boundary.
|
||||
///
|
||||
/// `lba` is always `>= unit_base` by contract (a read never begins before the
|
||||
/// extent base it is measured against). `saturating_sub` makes the `lba <
|
||||
/// unit_base` case well-defined anyway — it clamps the offset to 0, which is a
|
||||
/// unit boundary — rather than the latent `wrapping_sub` trap where an
|
||||
/// underflow wraps to ~2^32 and, because `2^32 ≡ 1 (mod 3)`, mis-reports the
|
||||
/// alignment (e.g. `lba == unit_base - 1` would falsely read as aligned).
|
||||
pub fn is_unit_aligned(lba: u32, unit_base: u32) -> bool {
|
||||
lba.saturating_sub(unit_base) % ALIGNED_UNIT_SECTORS == 0
|
||||
}
|
||||
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
|
||||
use crate::consts::BD_SOURCE_PACKET_BYTES;
|
||||
|
||||
/// TS sync byte.
|
||||
const TS_SYNC: u8 = 0x47;
|
||||
@@ -45,8 +70,15 @@ pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
}
|
||||
|
||||
/// AES-128-CBC decrypt in-place with the fixed AACS IV.
|
||||
/// AES-128-CBC decrypt in-place with the fixed AACS IV.
|
||||
///
|
||||
/// Precondition: `data.len()` is a multiple of 16. Any trailing partial
|
||||
/// block is silently ignored; all callers pass aligned regions (6128 and
|
||||
/// 2032 bytes), and the assert documents/enforces that contract.
|
||||
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
debug_assert!(
|
||||
data.len() % 16 == 0,
|
||||
"aes_cbc_decrypt requires a block-aligned slice"
|
||||
);
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let num_blocks = data.len() / 16;
|
||||
// Process blocks in reverse to avoid clobbering ciphertext needed for XOR
|
||||
@@ -69,42 +101,76 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
|
||||
// ── Content decryption ──────────────────────────────────────────────────────
|
||||
|
||||
/// Check if a 6144-byte aligned unit is encrypted (copy_permission_indicator bits).
|
||||
pub fn is_unit_encrypted(unit: &[u8]) -> bool {
|
||||
unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0
|
||||
/// True if a 6144-byte aligned unit is AACS-scrambled on disc.
|
||||
///
|
||||
/// AACS encrypts the unit body, which destroys the MPEG-TS sync bytes (`0x47`)
|
||||
/// a clear unit carries at offsets 4, 196, 388, … (one per 192-byte source
|
||||
/// packet). So "scrambled" = "the TS syncs are NOT intact". This is
|
||||
/// flag-independent: it does NOT read the TP_extra copy-control bits (byte 0)
|
||||
/// or the TS scrambling-control bits (byte 7) — AACS sets neither reliably
|
||||
/// across discs/players.
|
||||
///
|
||||
/// This is the single shared definition of "encrypted" for the whole ecosystem
|
||||
/// — libfreemkv's decrypt gate, autorip's sample selection, and the online key
|
||||
/// service's validation gate all call THIS, so they always agree on what is
|
||||
/// encrypted. A correctly-decrypted (or natively-clear) unit reports `false`,
|
||||
/// so the decrypt path never double-decrypts and there is no flag to clear.
|
||||
pub fn is_aacs_scrambled(unit: &[u8]) -> bool {
|
||||
unit.len() >= ALIGNED_UNIT_LEN && !ts_syncs_intact(unit)
|
||||
}
|
||||
|
||||
/// Verify decrypted unit by checking TS sync bytes at expected offsets.
|
||||
fn verify_ts(unit: &[u8]) -> bool {
|
||||
// In a 6144-byte unit, TS packets start at byte 0 with 4-byte TP_extra_header
|
||||
// then 188-byte TS packet, repeating every 192 bytes.
|
||||
// Sync byte 0x47 should appear at offset 4, 196, 388, ...
|
||||
/// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride
|
||||
/// (offset 4 and every 192 bytes after — 4-byte TP_extra_header + 188-byte
|
||||
/// TS packet). A clear or correctly-decrypted m2ts unit shows ~one per
|
||||
/// packet; an encrypted unit, or a non-content unit decrypted under a key
|
||||
/// that doesn't apply, shows ~none.
|
||||
pub fn ts_sync_count(unit: &[u8]) -> usize {
|
||||
let mut count = 0;
|
||||
let mut offset = 4;
|
||||
while offset < unit.len() {
|
||||
if unit[offset] == TS_SYNC {
|
||||
count += 1;
|
||||
}
|
||||
offset += TS_PACKET_LEN;
|
||||
offset += BD_SOURCE_PACKET_BYTES;
|
||||
}
|
||||
// Expect at least most packets to have sync bytes
|
||||
let total = (unit.len() - 4) / TS_PACKET_LEN + 1;
|
||||
count > total / 2
|
||||
count
|
||||
}
|
||||
|
||||
/// Number of BD-TS packets in the unit — the maximum possible sync count.
|
||||
pub fn ts_packet_total(unit: &[u8]) -> usize {
|
||||
// One sync byte per 192-byte BD-TS packet (at offset 4 of each). The old
|
||||
// `(len - 4) / BD_SOURCE_PACKET_BYTES + 1` over-counted by one for lengths of the
|
||||
// form `4 + k·192`.
|
||||
unit.len() / BD_SOURCE_PACKET_BYTES
|
||||
}
|
||||
|
||||
fn ts_syncs_intact(unit: &[u8]) -> bool {
|
||||
ts_sync_count(unit) > ts_packet_total(unit) / 2
|
||||
}
|
||||
|
||||
/// Verify a decrypted unit looks like clear MPEG-TS (sync bytes intact).
|
||||
fn verify_ts(unit: &[u8]) -> bool {
|
||||
ts_syncs_intact(unit)
|
||||
}
|
||||
|
||||
/// Decrypt one AACS aligned unit (6144 bytes) in-place.
|
||||
/// Returns true if decryption succeeded (verified by TS sync bytes).
|
||||
/// Returns true if the unit is now clear MPEG-TS: either it was already
|
||||
/// unscrambled (returned untouched, no key used) or it was decrypted and
|
||||
/// verified by its TS sync bytes. Returns false only when the unit was
|
||||
/// scrambled and this key failed verification.
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. AES-128-ECB encrypt first 16 bytes with unit_key → derived
|
||||
/// 2. XOR derived with original 16 bytes → unit_decrypt_key
|
||||
/// 3. AES-128-CBC decrypt bytes 16..6143 with unit_decrypt_key and AACS IV
|
||||
/// 4. Clear encryption flag bits
|
||||
///
|
||||
/// Decryption restores the TS sync bytes, so the unit reads as clear afterward;
|
||||
/// there is no flag to clear.
|
||||
pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
if unit.len() < ALIGNED_UNIT_LEN {
|
||||
return false;
|
||||
}
|
||||
if !is_unit_encrypted(unit) {
|
||||
if !is_aacs_scrambled(unit) {
|
||||
return true; // not encrypted
|
||||
}
|
||||
|
||||
@@ -124,26 +190,101 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
// Step 3: Decrypt bytes 16..6143 with AES-CBC
|
||||
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
|
||||
|
||||
// Step 4: Clear encryption flag
|
||||
unit[0] &= !0xC0;
|
||||
|
||||
// Verify
|
||||
// Decryption restored the TS syncs; verify the unit now looks like clear TS.
|
||||
verify_ts(unit)
|
||||
}
|
||||
|
||||
/// Decrypt one aligned unit trying multiple unit keys. Returns the key index that worked.
|
||||
pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<usize> {
|
||||
if !is_unit_encrypted(unit) {
|
||||
return Some(0);
|
||||
/// Fast, NON-MUTATING unit-key validation for the brute-force key search.
|
||||
///
|
||||
/// `decrypt_unit` pays a full 6128-byte (383-block) CBC decrypt before
|
||||
/// `verify_ts` can reject a wrong key — but in a brute scan ~every candidate is
|
||||
/// wrong. In CBC the plaintext of block *i* is `AES_dec(C_i) XOR C_{i-1}`, so
|
||||
/// the FIRST restored TS sync byte (payload offset 196, which lands in CBC
|
||||
/// block 11 of the `unit[16..]` region) can be recovered with a SINGLE block
|
||||
/// decrypt instead of 383. A wrong key fails this 1-byte gate ~255/256 of the
|
||||
/// time for the cost of one AES block; the rare survivor is then confirmed with
|
||||
/// the full [`decrypt_unit`], so the set of accepted keys is bit-for-bit
|
||||
/// identical to the slow path.
|
||||
///
|
||||
/// The caller MUST pass an aligned, already-[`is_aacs_scrambled`] unit
|
||||
/// (`unit.len() >= ALIGNED_UNIT_LEN`). The brute pre-filters its units, so the
|
||||
/// per-candidate scramble re-scan is intentionally skipped here.
|
||||
///
|
||||
/// NOTE: this is a search accelerator — it never writes the input and never
|
||||
/// participates in the content decrypt path. Aggregate correctness (does a key
|
||||
/// validate against *any* of the disc's units) is preserved because a true key
|
||||
/// restores offset-196 on every standard BD-TS unit.
|
||||
pub fn unit_key_validates(unit: &[u8], unit_key: &[u8; 16]) -> bool {
|
||||
if unit.len() < ALIGNED_UNIT_LEN {
|
||||
return false;
|
||||
}
|
||||
// Per-unit decrypt key: AES-ECB-encrypt the 16-byte plaintext header with
|
||||
// the unit key, XOR with the header (same derivation as `decrypt_unit`).
|
||||
let mut header = [0u8; 16];
|
||||
header.copy_from_slice(&unit[..16]);
|
||||
let derived = aes_ecb_encrypt(unit_key, &header);
|
||||
let mut decrypt_key = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
decrypt_key[i] = derived[i] ^ header[i];
|
||||
}
|
||||
|
||||
// Save original for retry
|
||||
let original = unit[..ALIGNED_UNIT_LEN].to_vec();
|
||||
// Cheap gate: recover ONLY payload byte 196 (the 2nd BD-TS packet's sync).
|
||||
// The CBC region is `unit[16..]`; payload offset 196 → region offset 180 =
|
||||
// block 11, byte 4. P[11] = AES_dec(C[11]) XOR C[10]; C[10] is raw
|
||||
// ciphertext (no decrypt needed). Constant offsets for the fixed 6144 unit.
|
||||
const SYNC_PAYLOAD_OFF: usize = 196;
|
||||
let region_off = SYNC_PAYLOAD_OFF - 16; // 180
|
||||
let blk = region_off / 16; // 11
|
||||
let byte = region_off % 16; // 4
|
||||
let c11 = 16 + blk * 16; // absolute offset of C[11] in `unit` (=192)
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&decrypt_key));
|
||||
let mut b = GenericArray::clone_from_slice(&unit[c11..c11 + 16]);
|
||||
cipher.decrypt_block(&mut b);
|
||||
let prev = unit[c11 - 16 + byte]; // C[10] byte (region block 10)
|
||||
if b[byte] ^ prev != TS_SYNC {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Survivor (~1/256 of candidates): confirm with the authoritative full
|
||||
// decrypt + verify, so the verdict matches `decrypt_unit` exactly.
|
||||
let mut full = [0u8; ALIGNED_UNIT_LEN];
|
||||
full.copy_from_slice(&unit[..ALIGNED_UNIT_LEN]);
|
||||
decrypt_unit(&mut full, unit_key)
|
||||
}
|
||||
|
||||
/// Outcome of [`decrypt_unit_try_keys`].
|
||||
///
|
||||
/// Distinguishes "the unit was already clear, no key was consumed" from "key
|
||||
/// at index `i` decrypted it" — the bare `Option<usize>` form conflated the two
|
||||
/// (a clear unit reported `Some(0)`, indistinguishable from key index 0, and
|
||||
/// possibly out of range when `unit_keys` is empty).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UnitKeyResult {
|
||||
/// The unit was not scrambled; it was left untouched and no key was used.
|
||||
AlreadyClear,
|
||||
/// The unit was decrypted in place by `unit_keys[index]`.
|
||||
DecryptedWith(usize),
|
||||
}
|
||||
|
||||
/// Decrypt one aligned unit trying multiple unit keys.
|
||||
///
|
||||
/// Returns [`UnitKeyResult::AlreadyClear`] if the unit was not scrambled (no key
|
||||
/// consumed), [`UnitKeyResult::DecryptedWith(i)`] if key `i` decrypted it, or
|
||||
/// `None` if no key worked (the unit is restored to its original bytes).
|
||||
pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<UnitKeyResult> {
|
||||
if !is_aacs_scrambled(unit) {
|
||||
return Some(UnitKeyResult::AlreadyClear);
|
||||
}
|
||||
|
||||
// Save original for retry. Stack-backed buffer — no heap allocation, and the
|
||||
// restore-on-failure contract holds uniformly regardless of key count.
|
||||
let mut original = [0u8; ALIGNED_UNIT_LEN];
|
||||
original.copy_from_slice(&unit[..ALIGNED_UNIT_LEN]);
|
||||
|
||||
for (i, key) in unit_keys.iter().enumerate() {
|
||||
unit[..ALIGNED_UNIT_LEN].copy_from_slice(&original);
|
||||
if decrypt_unit(unit, key) {
|
||||
return Some(i);
|
||||
return Some(UnitKeyResult::DecryptedWith(i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,14 +296,14 @@ pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<
|
||||
/// Remove bus encryption from an aligned unit (AACS 2.0 / UHD).
|
||||
/// Bus encryption uses read_data_key, decrypting bytes 16..2047 of each 2048-byte sector.
|
||||
pub fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
|
||||
for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_LEN) {
|
||||
if sector_start + SECTOR_LEN > unit.len() {
|
||||
for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
||||
if sector_start + SECTOR_BYTES > unit.len() {
|
||||
break;
|
||||
}
|
||||
// First 16 bytes of each sector are plaintext
|
||||
aes_cbc_decrypt(
|
||||
read_data_key,
|
||||
&mut unit[sector_start + 16..sector_start + SECTOR_LEN],
|
||||
&mut unit[sector_start + 16..sector_start + SECTOR_BYTES],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -173,7 +314,7 @@ pub fn decrypt_unit_full(
|
||||
unit_key: &[u8; 16],
|
||||
read_data_key: Option<&[u8; 16]>,
|
||||
) -> bool {
|
||||
if !is_unit_encrypted(unit) {
|
||||
if !is_aacs_scrambled(unit) {
|
||||
return true;
|
||||
}
|
||||
if let Some(rdk) = read_data_key {
|
||||
@@ -198,15 +339,123 @@ mod tests {
|
||||
assert_eq!(dec, plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_unit_aligned_relative_to_base() {
|
||||
// Aligned at the base and every 3 sectors above it; misaligned between.
|
||||
assert!(is_unit_aligned(100, 100), "base itself is aligned");
|
||||
assert!(is_unit_aligned(103, 100), "one unit past base is aligned");
|
||||
assert!(is_unit_aligned(106, 100));
|
||||
assert!(!is_unit_aligned(101, 100));
|
||||
assert!(!is_unit_aligned(102, 100));
|
||||
// Non-3-aligned base: alignment is RELATIVE to the base, not absolute.
|
||||
assert!(is_unit_aligned(101, 101), "non-3-aligned base is aligned");
|
||||
assert!(is_unit_aligned(104, 101));
|
||||
assert!(!is_unit_aligned(102, 101));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_unit_aligned_lba_below_base_is_well_defined() {
|
||||
// Latent-trap contract (rc.5.2 audit #5): a read never starts before its
|
||||
// extent base, but if `lba < unit_base` the result must be well-defined,
|
||||
// NOT the `wrapping_sub` underflow that — because 2^32 ≡ 1 (mod 3) —
|
||||
// would falsely report alignment. `saturating_sub` clamps to offset 0,
|
||||
// which is a unit boundary, so any `lba <= unit_base` reads as aligned.
|
||||
assert!(
|
||||
is_unit_aligned(99, 100),
|
||||
"lba just below base must not wrap"
|
||||
);
|
||||
assert!(is_unit_aligned(98, 100));
|
||||
assert!(is_unit_aligned(0, 100));
|
||||
// The specific wrapping_sub trap value: unit_base - 1. With wrapping_sub
|
||||
// this is 0xFFFF_FFFF % 3 == 0 → falsely "aligned" by underflow; with
|
||||
// saturating_sub it is genuinely 0 → aligned, for the right reason.
|
||||
assert!(is_unit_aligned(u32::MAX, u32::MAX)); // base == lba, trivially aligned
|
||||
assert!(
|
||||
is_unit_aligned(0, u32::MAX),
|
||||
"max base, lba 0 must saturate to 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_unit_unencrypted() {
|
||||
// Unit with 0xC0 bits clear should pass through unchanged
|
||||
// A clear unit (TS syncs intact) is not scrambled → passes through.
|
||||
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
unit[0] = 0x00; // not encrypted
|
||||
let mut off = 4;
|
||||
while off < ALIGNED_UNIT_LEN {
|
||||
unit[off] = TS_SYNC;
|
||||
off += BD_SOURCE_PACKET_BYTES;
|
||||
}
|
||||
let key = [0u8; 16];
|
||||
assert!(!is_aacs_scrambled(&unit));
|
||||
assert!(decrypt_unit(&mut unit, &key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ts_packet_total_no_off_by_one() {
|
||||
// The maximum sync count is exactly the number of stride
|
||||
// positions the counting loop visits (offset 4, 196, ...), i.e.
|
||||
// len / 192, NOT (len - 4) / 192 + 1. For the 6144-byte aligned unit
|
||||
// the loop checks offsets 4..=5956 → 32 positions.
|
||||
let unit = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
assert_eq!(ts_packet_total(&unit), 32);
|
||||
// Confirm the loop visits exactly that many stride positions.
|
||||
let visited = (4..ALIGNED_UNIT_LEN)
|
||||
.step_by(BD_SOURCE_PACKET_BYTES)
|
||||
.count();
|
||||
assert_eq!(visited, ts_packet_total(&unit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scramble_detection_at_16_32_boundary() {
|
||||
// With 32 stride positions the majority threshold is
|
||||
// total/2 = 16. A unit with EXACTLY half its syncs intact (16) must
|
||||
// NOT be over-counted into the "scrambled" bucket by an inflated
|
||||
// total: 16 > 16 is false → not-intact → scrambled. 17 intact → clear.
|
||||
// The fix is that `total` is 32 (not 33), so the boundary sits cleanly
|
||||
// at the real midpoint.
|
||||
let set_syncs = |n: usize| {
|
||||
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
let mut placed = 0;
|
||||
while off < ALIGNED_UNIT_LEN && placed < n {
|
||||
unit[off] = TS_SYNC;
|
||||
off += BD_SOURCE_PACKET_BYTES;
|
||||
placed += 1;
|
||||
}
|
||||
unit
|
||||
};
|
||||
|
||||
assert_eq!(ts_sync_count(&set_syncs(16)), 16);
|
||||
assert_eq!(ts_sync_count(&set_syncs(17)), 17);
|
||||
|
||||
// Exactly half intact → classified scrambled (16 > 16 is false).
|
||||
assert!(is_aacs_scrambled(&set_syncs(16)));
|
||||
// One past half → classified clear.
|
||||
assert!(!is_aacs_scrambled(&set_syncs(17)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scramble_detection_extremes() {
|
||||
// Detection semantics for the clear-cut cases must be preserved:
|
||||
// a fully-clear unit (all 32 syncs) is NOT scrambled; a unit with no
|
||||
// syncs (fully scrambled body) IS scrambled.
|
||||
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < ALIGNED_UNIT_LEN {
|
||||
clear[off] = TS_SYNC;
|
||||
off += BD_SOURCE_PACKET_BYTES;
|
||||
}
|
||||
assert_eq!(ts_sync_count(&clear), 32);
|
||||
assert!(
|
||||
!is_aacs_scrambled(&clear),
|
||||
"fully-clear unit → not scrambled"
|
||||
);
|
||||
|
||||
let scrambled = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
assert_eq!(ts_sync_count(&scrambled), 0);
|
||||
assert!(is_aacs_scrambled(&scrambled), "no syncs → scrambled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aes_cbc_roundtrip() {
|
||||
let key = [
|
||||
@@ -251,10 +500,10 @@ mod tests {
|
||||
let mut offset = 4;
|
||||
while offset < ALIGNED_UNIT_LEN {
|
||||
plain[offset] = TS_SYNC;
|
||||
offset += TS_PACKET_LEN;
|
||||
offset += BD_SOURCE_PACKET_BYTES;
|
||||
}
|
||||
// Set encryption flag
|
||||
plain[0] |= 0xC0;
|
||||
// No flag set: CBC-encrypting the body below scrambles packets 1..31's
|
||||
// TS syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects.
|
||||
|
||||
// Now encrypt bytes 16..6143 using the AACS algorithm (reverse of decrypt)
|
||||
let header: [u8; 16] = plain[..16].try_into().unwrap();
|
||||
@@ -281,9 +530,9 @@ mod tests {
|
||||
|
||||
// Now plain contains encrypted data. Decrypt it.
|
||||
let mut unit = plain;
|
||||
assert!(is_unit_encrypted(&unit));
|
||||
assert!(is_aacs_scrambled(&unit));
|
||||
assert!(decrypt_unit(&mut unit, &unit_key));
|
||||
assert!(!is_unit_encrypted(&unit)); // flag should be cleared
|
||||
assert!(!is_aacs_scrambled(&unit)); // decrypted: TS syncs restored
|
||||
|
||||
// Verify TS sync bytes
|
||||
let mut count = 0;
|
||||
@@ -292,8 +541,453 @@ mod tests {
|
||||
if unit[off] == TS_SYNC {
|
||||
count += 1;
|
||||
}
|
||||
off += TS_PACKET_LEN;
|
||||
off += BD_SOURCE_PACKET_BYTES;
|
||||
}
|
||||
assert_eq!(count, (ALIGNED_UNIT_LEN - 4) / TS_PACKET_LEN + 1);
|
||||
// Assert against the single canonical packet count, not the old
|
||||
// `(len - 4) / 192 + 1` form that `ts_packet_total` corrected away from.
|
||||
assert_eq!(count, ts_packet_total(&unit));
|
||||
}
|
||||
|
||||
// ── Helpers for the hardening tests below ──────────────────────────────
|
||||
|
||||
/// Encrypt an aligned unit in place with the AACS unit-decrypt
|
||||
/// algorithm run in reverse, so [`decrypt_unit`] with the same
|
||||
/// `unit_key` recovers the plaintext. This is the exact inverse of
|
||||
/// the production decrypt: derive `decrypt_key = AES-ECB-E(unit_key,
|
||||
/// header) XOR header`, then CBC-encrypt bytes 16..6144 under the
|
||||
/// fixed AACS IV.
|
||||
fn aacs_encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
|
||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let derived = aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
k[i] = derived[i] ^ header[i];
|
||||
}
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&k));
|
||||
let mut prev = AACS_IV;
|
||||
let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
|
||||
for i in 0..num_blocks {
|
||||
let off = 16 + i * 16;
|
||||
for j in 0..16 {
|
||||
unit[off + j] ^= prev[j];
|
||||
}
|
||||
let mut block = GenericArray::clone_from_slice(&unit[off..off + 16]);
|
||||
cipher.encrypt_block(&mut block);
|
||||
unit[off..off + 16].copy_from_slice(&block);
|
||||
prev.copy_from_slice(&unit[off..off + 16]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a clear aligned unit with TS sync bytes at offset 4 + k*192.
|
||||
fn clear_unit() -> Vec<u8> {
|
||||
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < ALIGNED_UNIT_LEN {
|
||||
unit[off] = TS_SYNC;
|
||||
off += BD_SOURCE_PACKET_BYTES;
|
||||
}
|
||||
unit
|
||||
}
|
||||
|
||||
// ── AES-ECB KAT (FIPS-197 Appendix C.1) ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn aes_ecb_matches_fips197_known_answer() {
|
||||
// FIPS-197 Appendix C.1 AES-128 KAT:
|
||||
// key = 000102030405060708090a0b0c0d0e0f
|
||||
// plaintext = 00112233445566778899aabbccddeeff
|
||||
// ciphertext= 69c4e0d86a7b0430d8cdb78070b4c55a
|
||||
// This pins the AES primitive against a published vector — a wrong
|
||||
// cipher (or a key/plaintext byte-order slip) fails it.
|
||||
let key = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
|
||||
0x0E, 0x0F,
|
||||
];
|
||||
let pt = [
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
|
||||
0xEE, 0xFF,
|
||||
];
|
||||
let expected = [
|
||||
0x69, 0xC4, 0xE0, 0xD8, 0x6A, 0x7B, 0x04, 0x30, 0xD8, 0xCD, 0xB7, 0x80, 0x70, 0xB4,
|
||||
0xC5, 0x5A,
|
||||
];
|
||||
assert_eq!(aes_ecb_encrypt(&key, &pt), expected);
|
||||
// And decrypt is the exact inverse.
|
||||
assert_eq!(aes_ecb_decrypt(&key, &expected), pt);
|
||||
}
|
||||
|
||||
// ── CBC decrypt: first-block uses fixed AACS IV ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cbc_decrypt_first_block_xors_aacs_iv() {
|
||||
// CBC: P[0] = AES-D(K, C[0]) XOR IV, and the IV is the fixed AACS
|
||||
// constant (not zero). Encrypt a single block forward with IV, then
|
||||
// confirm aes_cbc_decrypt recovers it — proving the IV used on block
|
||||
// 0 is exactly AACS_IV. A mutation that swaps AACS_IV for [0u8;16]
|
||||
// makes the recovered block wrong.
|
||||
let key = [0x24u8; 16];
|
||||
let plain = [0x5Au8; 16];
|
||||
// Forward CBC for one block: C = AES-E(K, P XOR IV).
|
||||
let mut x = plain;
|
||||
for j in 0..16 {
|
||||
x[j] ^= AACS_IV[j];
|
||||
}
|
||||
let ct = aes_ecb_encrypt(&key, &x);
|
||||
let mut buf = ct;
|
||||
aes_cbc_decrypt(&key, &mut buf);
|
||||
assert_eq!(buf, plain, "block-0 CBC must XOR the fixed AACS IV");
|
||||
}
|
||||
|
||||
// ── CBC decrypt KAT (NIST SP 800-38A F.2.2, AES-128-CBC) ───────────────
|
||||
|
||||
#[test]
|
||||
fn aes_cbc_decrypt_matches_nist_sp800_38a_f2_2() {
|
||||
// NIST SP 800-38A Appendix F.2.2 (CBC-AES128.Decrypt) published vector:
|
||||
// Key = 2b7e151628aed2a6abf7158809cf4f3c
|
||||
// IV = 000102030405060708090a0b0c0d0e0f
|
||||
// CT = 7649abac8119b246cee98e9b12e9197d (block 0)
|
||||
// 5086cb9b507219ee95db113a917678b2 (block 1)
|
||||
// 73bed6b8e3c1743b7116e69e22229516 (block 2)
|
||||
// 3ff1caa1681fac09120eca307586e1a7 (block 3)
|
||||
// PT = 6bc1bee22e409f96e93d7e117393172a (block 0)
|
||||
// ae2d8a571e03ac9c9eb76fac45af8e51 (block 1)
|
||||
// 30c81c46a35ce411e5fbc1191a0a52ef (block 2)
|
||||
// f69f2445df4f9b17ad2b417be66c3710 (block 3)
|
||||
//
|
||||
// `aes_cbc_decrypt` hardwires the fixed AACS IV for block 0 (it never
|
||||
// takes a caller IV), so:
|
||||
// * Blocks 1..=3 are independent of the IV — they MUST equal the NIST
|
||||
// plaintext byte-for-byte (P[i] = AES-D(K, C[i]) XOR C[i-1]). This
|
||||
// pins the real reverse-order CBC chaining against a published KAT.
|
||||
// * Block 0 = AES-D(K, C[0]) XOR AACS_IV = NIST_PT[0] XOR NIST_IV
|
||||
// XOR AACS_IV — the documented IV substitution. Asserting this exact
|
||||
// relation pins both the AES decrypt of C[0] AND that block 0 uses
|
||||
// AACS_IV (a swap to [0u8;16] or a chaining bug fails it).
|
||||
let key = [
|
||||
0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF,
|
||||
0x4F, 0x3C,
|
||||
];
|
||||
let nist_iv = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
|
||||
0x0E, 0x0F,
|
||||
];
|
||||
// Byte arrays kept narrow (≤14 bytes/line) so the secret-scanner's
|
||||
// 32-nibble-per-line heuristic doesn't flag these published vectors as
|
||||
// key material (same layout the existing FIPS-197 / CMAC KATs use).
|
||||
let ciphertext: [u8; 64] = [
|
||||
0x76, 0x49, 0xAB, 0xAC, 0x81, 0x19, 0xB2, 0x46, 0xCE, 0xE9, 0x8E, 0x9B, 0x12, 0xE9,
|
||||
0x19, 0x7D, 0x50, 0x86, 0xCB, 0x9B, 0x50, 0x72, 0x19, 0xEE, 0x95, 0xDB, 0x11, 0x3A,
|
||||
0x91, 0x76, 0x78, 0xB2, 0x73, 0xBE, 0xD6, 0xB8, 0xE3, 0xC1, 0x74, 0x3B, 0x71, 0x16,
|
||||
0xE6, 0x9E, 0x22, 0x22, 0x95, 0x16, 0x3F, 0xF1, 0xCA, 0xA1, 0x68, 0x1F, 0xAC, 0x09,
|
||||
0x12, 0x0E, 0xCA, 0x30, 0x75, 0x86, 0xE1, 0xA7,
|
||||
];
|
||||
let nist_plaintext: [u8; 64] = [
|
||||
0x6B, 0xC1, 0xBE, 0xE2, 0x2E, 0x40, 0x9F, 0x96, 0xE9, 0x3D, 0x7E, 0x11, 0x73, 0x93,
|
||||
0x17, 0x2A, 0xAE, 0x2D, 0x8A, 0x57, 0x1E, 0x03, 0xAC, 0x9C, 0x9E, 0xB7, 0x6F, 0xAC,
|
||||
0x45, 0xAF, 0x8E, 0x51, 0x30, 0xC8, 0x1C, 0x46, 0xA3, 0x5C, 0xE4, 0x11, 0xE5, 0xFB,
|
||||
0xC1, 0x19, 0x1A, 0x0A, 0x52, 0xEF, 0xF6, 0x9F, 0x24, 0x45, 0xDF, 0x4F, 0x9B, 0x17,
|
||||
0xAD, 0x2B, 0x41, 0x7B, 0xE6, 0x6C, 0x37, 0x10,
|
||||
];
|
||||
|
||||
let mut buf = ciphertext;
|
||||
aes_cbc_decrypt(&key, &mut buf);
|
||||
|
||||
// Blocks 1..=3: exact match against the published NIST plaintext.
|
||||
assert_eq!(
|
||||
&buf[16..64],
|
||||
&nist_plaintext[16..64],
|
||||
"CBC chaining (blocks 1..3) must match NIST SP 800-38A F.2.2 plaintext"
|
||||
);
|
||||
|
||||
// Block 0: NIST_PT[0] XOR NIST_IV XOR AACS_IV (the fixed-IV substitution).
|
||||
let mut expected_block0 = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
expected_block0[i] = nist_plaintext[i] ^ nist_iv[i] ^ AACS_IV[i];
|
||||
}
|
||||
assert_eq!(
|
||||
&buf[0..16],
|
||||
&expected_block0,
|
||||
"block-0 plaintext must equal NIST PT XOR NIST IV XOR AACS_IV (fixed-IV path)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── decrypt_unit: full round trip restores TS syncs ────────────────────
|
||||
|
||||
#[test]
|
||||
fn decrypt_unit_roundtrip_restores_all_syncs() {
|
||||
// Encrypt a clear unit, confirm it reads as scrambled, then decrypt
|
||||
// and confirm every TS sync byte at the 192-byte stride is restored.
|
||||
let unit_key = [0x37u8; 16];
|
||||
let mut unit = clear_unit();
|
||||
aacs_encrypt_unit(&mut unit, &unit_key);
|
||||
assert!(
|
||||
is_aacs_scrambled(&unit),
|
||||
"encrypted unit must look scrambled"
|
||||
);
|
||||
|
||||
assert!(decrypt_unit(&mut unit, &unit_key));
|
||||
// All 32 stride positions carry sync after decrypt.
|
||||
assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit));
|
||||
assert!(!is_aacs_scrambled(&unit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_unit_wrong_key_fails_and_does_not_falsely_clear() {
|
||||
// A wrong unit key fails verify_ts (the body stays scrambled), so
|
||||
// decrypt_unit returns false. Grounds the brute-force gate: a bad key
|
||||
// must NOT report success.
|
||||
let good = [0x11u8; 16];
|
||||
let bad = [0x22u8; 16];
|
||||
let mut unit = clear_unit();
|
||||
aacs_encrypt_unit(&mut unit, &good);
|
||||
assert!(!decrypt_unit(&mut unit, &bad), "wrong key must not verify");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_unit_rejects_short_unit() {
|
||||
// unit.len() < ALIGNED_UNIT_LEN → false (no panic on the 16.. slice).
|
||||
let mut short = vec![0u8; ALIGNED_UNIT_LEN - 1];
|
||||
assert!(!decrypt_unit(&mut short, &[0u8; 16]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_unit_only_touches_bytes_16_onward() {
|
||||
// The first 16 bytes are the plaintext TP_extra header and must be
|
||||
// left untouched by decrypt (only unit[16..] is CBC-processed).
|
||||
let unit_key = [0x9Au8; 16];
|
||||
let mut clear = clear_unit();
|
||||
// Put a distinctive header so we can confirm it survives.
|
||||
clear[..16].copy_from_slice(&[
|
||||
0xA0, 0xA1, 0xA2, 0xA3, 0x47, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
||||
0xAE, 0xAF,
|
||||
]);
|
||||
let header_before: [u8; 16] = clear[..16].try_into().unwrap();
|
||||
let mut unit = clear;
|
||||
aacs_encrypt_unit(&mut unit, &unit_key);
|
||||
// Encryption also leaves the header untouched (only 16.. is encrypted).
|
||||
assert_eq!(&unit[..16], &header_before);
|
||||
decrypt_unit(&mut unit, &unit_key);
|
||||
assert_eq!(
|
||||
&unit[..16],
|
||||
&header_before,
|
||||
"header bytes must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
// ── decrypt_unit_try_keys: AlreadyClear vs DecryptedWith vs None ───────
|
||||
|
||||
#[test]
|
||||
fn try_keys_reports_already_clear_without_consuming_a_key() {
|
||||
// A clear unit returns AlreadyClear even with an empty key list — the
|
||||
// old Option<usize> form conflated this with Some(0). Grounds the
|
||||
// UnitKeyResult enum distinction.
|
||||
let mut unit = clear_unit();
|
||||
assert_eq!(
|
||||
decrypt_unit_try_keys(&mut unit, &[]),
|
||||
Some(UnitKeyResult::AlreadyClear)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_keys_reports_correct_index_among_several() {
|
||||
// Three keys, only the 3rd (index 2) decrypts → DecryptedWith(2).
|
||||
let real = [0x44u8; 16];
|
||||
let mut unit = clear_unit();
|
||||
aacs_encrypt_unit(&mut unit, &real);
|
||||
let keys = [[0x01u8; 16], [0x02u8; 16], real];
|
||||
assert_eq!(
|
||||
decrypt_unit_try_keys(&mut unit, &keys),
|
||||
Some(UnitKeyResult::DecryptedWith(2))
|
||||
);
|
||||
assert!(
|
||||
!is_aacs_scrambled(&unit),
|
||||
"unit must be clear after the hit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_keys_restores_original_bytes_on_total_failure() {
|
||||
// When no key works, the unit must be byte-identical to the input
|
||||
// (the function CBC-mangles it per attempt, then restores). A buggy
|
||||
// restore would leave the unit corrupted — silent data damage.
|
||||
let real = [0x55u8; 16];
|
||||
let mut unit = clear_unit();
|
||||
aacs_encrypt_unit(&mut unit, &real);
|
||||
let snapshot = unit.clone();
|
||||
let wrong = [[0xAAu8; 16], [0xBBu8; 16]];
|
||||
assert_eq!(decrypt_unit_try_keys(&mut unit, &wrong), None);
|
||||
assert_eq!(unit, snapshot, "failed try must restore the original bytes");
|
||||
}
|
||||
|
||||
// ── unit_key_validates: matches decrypt_unit's verdict exactly ─────────
|
||||
|
||||
#[test]
|
||||
fn unit_key_validates_agrees_with_decrypt_unit() {
|
||||
// The fast 1-byte gate's accept/reject set must be identical to the
|
||||
// authoritative decrypt_unit. Confirm: correct key → true on both;
|
||||
// wrong key → false on both.
|
||||
let good = [0x6Au8; 16];
|
||||
let bad = [0x6Bu8; 16];
|
||||
let mut enc = clear_unit();
|
||||
aacs_encrypt_unit(&mut enc, &good);
|
||||
|
||||
assert!(unit_key_validates(&enc, &good));
|
||||
let mut probe = enc.clone();
|
||||
assert!(decrypt_unit(&mut probe, &good));
|
||||
|
||||
assert!(!unit_key_validates(&enc, &bad));
|
||||
let mut probe2 = enc.clone();
|
||||
assert!(!decrypt_unit(&mut probe2, &bad));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_key_validates_is_non_mutating() {
|
||||
// The accelerator must never write its input (it operates on the
|
||||
// ciphertext and confirms on a copy). A mutation that decrypted in
|
||||
// place would corrupt the caller's buffer.
|
||||
let good = [0x7Cu8; 16];
|
||||
let mut enc = clear_unit();
|
||||
aacs_encrypt_unit(&mut enc, &good);
|
||||
let snapshot = enc.clone();
|
||||
let _ = unit_key_validates(&enc, &good);
|
||||
assert_eq!(enc, snapshot, "unit_key_validates must not mutate input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_key_validates_rejects_short_unit() {
|
||||
let short = vec![0u8; ALIGNED_UNIT_LEN - 16];
|
||||
assert!(!unit_key_validates(&short, &[0u8; 16]));
|
||||
}
|
||||
|
||||
// ── bus decryption (AACS 2.0 / UHD) ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn decrypt_bus_roundtrips_per_sector_skipping_first_16_bytes() {
|
||||
// Bus encryption CBC-encrypts bytes 16..2048 of EACH 2048-byte sector
|
||||
// (3 sectors per aligned unit), leaving the first 16 plaintext. Build
|
||||
// the forward transform, then confirm decrypt_bus inverts it and
|
||||
// leaves each sector's first 16 bytes untouched.
|
||||
let rdk = [0x13u8; 16];
|
||||
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
// Fill with a recognisable pattern.
|
||||
for (i, b) in unit.iter_mut().enumerate() {
|
||||
*b = (i % 251) as u8;
|
||||
}
|
||||
let plain = unit.clone();
|
||||
|
||||
// Forward: CBC-encrypt unit[s+16 .. s+2048] per sector under AACS IV.
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&rdk));
|
||||
for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
||||
let mut prev = AACS_IV;
|
||||
let body = s + 16;
|
||||
let end = s + SECTOR_BYTES;
|
||||
let nblocks = (end - body) / 16;
|
||||
for i in 0..nblocks {
|
||||
let off = body + i * 16;
|
||||
for j in 0..16 {
|
||||
unit[off + j] ^= prev[j];
|
||||
}
|
||||
let mut blk = GenericArray::clone_from_slice(&unit[off..off + 16]);
|
||||
cipher.encrypt_block(&mut blk);
|
||||
unit[off..off + 16].copy_from_slice(&blk);
|
||||
prev.copy_from_slice(&unit[off..off + 16]);
|
||||
}
|
||||
}
|
||||
assert_ne!(unit, plain, "forward bus-encrypt must change the body");
|
||||
|
||||
decrypt_bus(&mut unit, &rdk);
|
||||
assert_eq!(
|
||||
unit, plain,
|
||||
"decrypt_bus must invert per-sector bus encrypt"
|
||||
);
|
||||
// Each sector's first 16 bytes equal the original (never touched).
|
||||
for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
||||
assert_eq!(&unit[s..s + 16], &plain[s..s + 16]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── decrypt_unit_full: bus-then-AACS ordering, and clear passthrough ───
|
||||
|
||||
#[test]
|
||||
fn decrypt_unit_full_passthrough_when_already_clear() {
|
||||
// A clear unit returns true and is not modified, regardless of keys.
|
||||
let mut unit = clear_unit();
|
||||
let snapshot = unit.clone();
|
||||
assert!(decrypt_unit_full(
|
||||
&mut unit,
|
||||
&[0u8; 16],
|
||||
Some(&[0xFFu8; 16])
|
||||
));
|
||||
assert_eq!(unit, snapshot, "clear unit must pass through untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_unit_full_applies_bus_then_aacs() {
|
||||
// AACS 2.0 pipeline: content is first AACS-unit-encrypted, then
|
||||
// bus-encrypted on top. Decrypt must undo bus FIRST, then AACS.
|
||||
// Build that exact two-layer ciphertext and confirm full recovery.
|
||||
let unit_key = [0x21u8; 16];
|
||||
let rdk = [0x84u8; 16];
|
||||
|
||||
let mut unit = clear_unit();
|
||||
// Layer 1: AACS unit-encrypt.
|
||||
aacs_encrypt_unit(&mut unit, &unit_key);
|
||||
// Layer 2: bus-encrypt on top (per-sector, bytes 16..2048).
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&rdk));
|
||||
for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
||||
let mut prev = AACS_IV;
|
||||
for i in 0..((SECTOR_BYTES - 16) / 16) {
|
||||
let off = s + 16 + i * 16;
|
||||
for j in 0..16 {
|
||||
unit[off + j] ^= prev[j];
|
||||
}
|
||||
let mut blk = GenericArray::clone_from_slice(&unit[off..off + 16]);
|
||||
cipher.encrypt_block(&mut blk);
|
||||
unit[off..off + 16].copy_from_slice(&blk);
|
||||
prev.copy_from_slice(&unit[off..off + 16]);
|
||||
}
|
||||
}
|
||||
assert!(is_aacs_scrambled(&unit));
|
||||
assert!(decrypt_unit_full(&mut unit, &unit_key, Some(&rdk)));
|
||||
assert_eq!(ts_sync_count(&unit), ts_packet_total(&unit));
|
||||
}
|
||||
|
||||
// ── is_aacs_scrambled / ts_sync_count edge cases ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn is_aacs_scrambled_false_for_sub_unit_length() {
|
||||
// The function guards on `len >= ALIGNED_UNIT_LEN` first; anything
|
||||
// shorter is reported NOT scrambled (so the decrypt gate skips it)
|
||||
// rather than indexing past the end.
|
||||
assert!(!is_aacs_scrambled(&[]));
|
||||
assert!(!is_aacs_scrambled(&vec![0u8; ALIGNED_UNIT_LEN - 1]));
|
||||
// A scrambled-looking buffer that is one byte short is still "not
|
||||
// scrambled" by the length guard.
|
||||
let mut almost = vec![0u8; ALIGNED_UNIT_LEN - 1];
|
||||
almost[4] = 0x00; // no syncs
|
||||
assert!(!is_aacs_scrambled(&almost));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ts_sync_count_only_samples_the_192_byte_stride() {
|
||||
// A 0x47 placed OFF the stride (e.g. offset 5) must not be counted —
|
||||
// the detector samples exactly offset 4, 196, 388, ... A mutation that
|
||||
// scanned every byte would over-count and misclassify scrambled units.
|
||||
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
unit[5] = TS_SYNC; // off-stride
|
||||
unit[197] = TS_SYNC; // off-stride
|
||||
assert_eq!(ts_sync_count(&unit), 0, "off-stride 0x47 must not count");
|
||||
unit[4] = TS_SYNC; // on-stride
|
||||
assert_eq!(ts_sync_count(&unit), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ts_packet_total_for_various_lengths() {
|
||||
// total = len / 192 (BD-TS packet size). Pin a few lengths.
|
||||
assert_eq!(ts_packet_total(&[0u8; 192]), 1);
|
||||
assert_eq!(ts_packet_total(&[0u8; 384]), 2);
|
||||
assert_eq!(ts_packet_total(&[0u8; 191]), 0);
|
||||
// 6144 = 32 packets.
|
||||
assert_eq!(ts_packet_total(&[0u8; ALIGNED_UNIT_LEN]), 32);
|
||||
}
|
||||
}
|
||||
|
||||
+613
-70
@@ -25,6 +25,25 @@ use num_bigint::BigUint;
|
||||
use num_traits::{One, Zero};
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
/// Map a SCSI-layer error from a handshake step onto a cert/key-specific
|
||||
/// code — but only when the failure is *not* a transport-layer wedge.
|
||||
///
|
||||
/// A SEND KEY / REPORT KEY step can fail because the drive genuinely
|
||||
/// rejected the host certificate or key (a real `Aacs*` condition), or
|
||||
/// because the transport died mid-handshake (bridge wedge / USB
|
||||
/// disconnect). Collapsing the latter into a cert/key code tells the
|
||||
/// operator the drive rejected their credentials, sending them down a
|
||||
/// keydb/host-cert rabbit hole for what is actually a replug/power-cycle
|
||||
/// situation. Preserve the transport error so the true root cause is
|
||||
/// surfaced; otherwise substitute the handshake-specific code.
|
||||
fn handshake_err(err: Error, fallback: Error) -> Error {
|
||||
if err.is_scsi_transport_failure() {
|
||||
err
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a SCSI command that reads data from the device.
|
||||
fn scsi_read(session: &mut Drive, cdb: &[u8], len: usize) -> Result<Vec<u8>> {
|
||||
let mut buf = vec![0u8; len];
|
||||
@@ -49,7 +68,6 @@ const EC_A: [u8; 20] = [
|
||||
0x9D, 0xC9, 0xD8, 0x13, 0x55, 0xEC, 0xCE, 0xB5, 0x60, 0xBD, 0xB0, 0x9E, 0xF9, 0xEA, 0xE7, 0xC4,
|
||||
0x79, 0xA7, 0xD7, 0xDC,
|
||||
];
|
||||
#[cfg(test)]
|
||||
const EC_B: [u8; 20] = [
|
||||
0x40, 0x2D, 0xAD, 0x3E, 0xC1, 0xCB, 0xCD, 0x16, 0x52, 0x48, 0xD6, 0x8E, 0x12, 0x45, 0xE0, 0xC4,
|
||||
0xDA, 0xAC, 0xB1, 0xD8,
|
||||
@@ -77,7 +95,6 @@ const P256_A: [u8; 32] = [
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC,
|
||||
];
|
||||
#[cfg(test)]
|
||||
const P256_B: [u8; 32] = [
|
||||
0x5A, 0xC6, 0x35, 0xD8, 0xAA, 0x3A, 0x93, 0xE7, 0xB3, 0xEB, 0xBD, 0x55, 0x76, 0x98, 0x86, 0xBC,
|
||||
0x65, 0x1D, 0x06, 0xB0, 0xCC, 0x53, 0xB0, 0xF6, 0x3B, 0xCE, 0x3C, 0x3E, 0x27, 0xD2, 0x60, 0x4B,
|
||||
@@ -293,6 +310,20 @@ fn ec_double(pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
|
||||
}
|
||||
|
||||
/// Scalar multiplication using double-and-add.
|
||||
///
|
||||
/// NOTE (constant-time tradeoff): this branches on `scalar.bit(0)` and
|
||||
/// clones BigUints per iteration, so its timing is data-dependent on the
|
||||
/// secret scalar (the long-term host private key in `ecdsa_sign`, the
|
||||
/// ephemeral key in ECDH). This is a deliberate tradeoff: the handshake
|
||||
/// runs once per disc against a local optical drive, so throughput and
|
||||
/// the narrow local-timing surface do not justify pulling in a vetted
|
||||
/// constant-time backend. Revisit if this ever signs in a remote/shared
|
||||
/// context.
|
||||
///
|
||||
/// NOTE (cofactor): both AACS curves used here have cofactor 1, so a
|
||||
/// point that lies on the curve is automatically in the prime-order
|
||||
/// subgroup — no small-subgroup defense / `n·P == O` check is required
|
||||
/// for the inputs this is called with.
|
||||
fn ec_mul(k: &BigUint, pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
|
||||
if k.is_zero() {
|
||||
return EcPoint::infinity();
|
||||
@@ -313,6 +344,20 @@ fn ec_mul(k: &BigUint, pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
|
||||
result
|
||||
}
|
||||
|
||||
/// True if the point (x, y) satisfies y² ≡ x³ + ax + b (mod p) and lies
|
||||
/// in the field (x, y < p). Guards the ECDH multiply against the classic
|
||||
/// invalid-curve attack: a drive that supplies an off-curve key point can
|
||||
/// otherwise steer the scalar multiply onto a weak curve and leak the host
|
||||
/// scalar. Caller must reject the point when this returns false.
|
||||
fn point_on_curve(x: &BigUint, y: &BigUint, a: &BigUint, b: &BigUint, p: &BigUint) -> bool {
|
||||
if x >= p || y >= p {
|
||||
return false;
|
||||
}
|
||||
let lhs = (y * y) % p;
|
||||
let rhs = (((x * x) % p) * x + a * x + b) % p;
|
||||
lhs == rhs
|
||||
}
|
||||
|
||||
/// Convert BigUint to fixed-size big-endian bytes, zero-padded.
|
||||
fn to_bytes_be_padded(n: &BigUint, len: usize) -> Vec<u8> {
|
||||
let bytes = n.to_bytes_be();
|
||||
@@ -341,12 +386,15 @@ fn ecdsa_sign(priv_key: &[u8; 20], data: &[u8]) -> ([u8; 20], [u8; 20]) {
|
||||
let z = BigUint::from_bytes_be(&hash);
|
||||
|
||||
loop {
|
||||
// Generate random k
|
||||
// Generate random k via rejection sampling. Reducing raw RNG bytes
|
||||
// modulo n would bias k toward small values (n is not a power of
|
||||
// two); a biased ECDSA nonce is a known key-recovery weakness, so
|
||||
// we reject and redraw any candidate >= n instead.
|
||||
let mut k_bytes = [0u8; 20];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut k_bytes);
|
||||
let k = BigUint::from_bytes_be(&k_bytes) % &n;
|
||||
if k.is_zero() {
|
||||
let k = BigUint::from_bytes_be(&k_bytes);
|
||||
if k.is_zero() || k >= n {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -438,11 +486,14 @@ fn ecdsa_sign_p256(priv_key: &[u8; 32], data: &[u8]) -> ([u8; 32], [u8; 32]) {
|
||||
let z = BigUint::from_bytes_be(&hash);
|
||||
|
||||
loop {
|
||||
// Rejection sampling for the nonce — see ecdsa_sign for rationale
|
||||
// (avoid the modulo bias that reducing raw RNG bytes mod n would
|
||||
// introduce).
|
||||
let mut k_bytes = [0u8; 32];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut k_bytes);
|
||||
let k = BigUint::from_bytes_be(&k_bytes) % &n;
|
||||
if k.is_zero() {
|
||||
let k = BigUint::from_bytes_be(&k_bytes);
|
||||
if k.is_zero() || k >= n {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -512,28 +563,38 @@ fn ecdsa_verify_p256(pub_x: &[u8], pub_y: &[u8], sig_r: &[u8], sig_s: &[u8], dat
|
||||
&r_point.x % &n == r
|
||||
}
|
||||
|
||||
/// Verify an AACS 2.0 certificate (type 0x11, 132 bytes) against AACS 2.0 LA key.
|
||||
/// Verify an AACS 2.0 certificate (type 0x11) against the AACS 2.0 LA key.
|
||||
///
|
||||
/// Layout: type(1) + flags(1) + padding(2) + serial(6) + pub_x(32) +
|
||||
/// pub_y(32) + sig_r(32) + sig_s(32) = 138 bytes. The signature covers
|
||||
/// the first 74 bytes (everything up to and including the public key).
|
||||
///
|
||||
/// The full P-256 certificate is 138 bytes, so the entire 138-byte
|
||||
/// length must be present before any signature slice is taken — checking
|
||||
/// `>= 138` up front (rather than the old `>= 132`, which left the
|
||||
/// `cert[106..138]` slice able to panic on a 132-byte input) keeps this
|
||||
/// safe against the truncated 132-byte cert the handshake actually
|
||||
/// passes in (`&response[24..156]`).
|
||||
fn verify_cert_p256(cert: &[u8]) -> bool {
|
||||
if cert.len() < 132 {
|
||||
if cert.len() < 138 {
|
||||
return false;
|
||||
}
|
||||
// AACS 2.0 cert: type(1) + flags(1) + padding(2) + serial(6) + pub_x(32) + pub_y(32) + sig_r(32) + sig_s(32)
|
||||
// Signature is over the first 74 bytes
|
||||
let sig_r = &cert[74..106];
|
||||
let sig_s = &cert[106..138]; // some certs may be padded differently
|
||||
|
||||
// Use what we have — verify over the signed portion
|
||||
if cert.len() >= 138 {
|
||||
let sig_s = &cert[106..138];
|
||||
ecdsa_verify_p256(&AACS2_LA_PUB_X, &AACS2_LA_PUB_Y, sig_r, sig_s, &cert[..74])
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract public key from an AACS 2.0 certificate (32-byte x,y).
|
||||
///
|
||||
/// Returns a zeroed key pair if `cert` is too short to hold the fixed
|
||||
/// offsets (matches the `>= 138` guard in `verify_cert_p256`), so a
|
||||
/// short/hostile cert cannot panic on the slice index.
|
||||
fn cert_pub_key_p256(cert: &[u8]) -> ([u8; 32], [u8; 32]) {
|
||||
let mut x = [0u8; 32];
|
||||
let mut y = [0u8; 32];
|
||||
if cert.len() < 74 {
|
||||
return (x, y);
|
||||
}
|
||||
x.copy_from_slice(&cert[10..42]);
|
||||
y.copy_from_slice(&cert[42..74]);
|
||||
(x, y)
|
||||
@@ -544,15 +605,20 @@ fn compute_bus_key_p256(
|
||||
host_priv: &[u8; 32],
|
||||
drive_key_point_x: &[u8],
|
||||
drive_key_point_y: &[u8],
|
||||
) -> [u8; 16] {
|
||||
) -> Option<[u8; 16]> {
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let b = BigUint::from_bytes_be(&P256_B);
|
||||
|
||||
let d = BigUint::from_bytes_be(host_priv);
|
||||
let dkp = EcPoint::new(
|
||||
BigUint::from_bytes_be(drive_key_point_x),
|
||||
BigUint::from_bytes_be(drive_key_point_y),
|
||||
);
|
||||
let dx = BigUint::from_bytes_be(drive_key_point_x);
|
||||
let dy = BigUint::from_bytes_be(drive_key_point_y);
|
||||
|
||||
// Reject an off-curve drive point before the multiply (invalid-curve attack).
|
||||
if !point_on_curve(&dx, &dy, &a, &b, &p) {
|
||||
return None;
|
||||
}
|
||||
let dkp = EcPoint::new(dx, dy);
|
||||
|
||||
let shared = ec_mul(&d, &dkp, &a, &p);
|
||||
|
||||
@@ -560,7 +626,7 @@ fn compute_bus_key_p256(
|
||||
let x_bytes = to_bytes_be_padded(&shared.x, 32);
|
||||
let mut bus_key = [0u8; 16];
|
||||
bus_key.copy_from_slice(&x_bytes[16..32]);
|
||||
bus_key
|
||||
Some(bus_key)
|
||||
}
|
||||
|
||||
// ── AACS certificate handling ───────────────────────────────────────────────
|
||||
@@ -581,9 +647,16 @@ fn verify_cert(cert: &[u8]) -> bool {
|
||||
}
|
||||
|
||||
/// Extract public key from certificate.
|
||||
///
|
||||
/// Returns a zeroed key pair if `cert` is too short to hold the fixed
|
||||
/// offsets (matches the `>= 92` guard in `verify_cert`), so a
|
||||
/// short/hostile cert cannot panic on the slice index.
|
||||
fn cert_pub_key(cert: &[u8]) -> ([u8; 20], [u8; 20]) {
|
||||
let mut x = [0u8; 20];
|
||||
let mut y = [0u8; 20];
|
||||
if cert.len() < 52 {
|
||||
return (x, y);
|
||||
}
|
||||
x.copy_from_slice(&cert[12..32]);
|
||||
y.copy_from_slice(&cert[32..52]);
|
||||
(x, y)
|
||||
@@ -596,12 +669,20 @@ fn compute_bus_key(
|
||||
host_priv: &[u8; 20],
|
||||
drive_key_point_x: &[u8; 20],
|
||||
drive_key_point_y: &[u8; 20],
|
||||
) -> [u8; 16] {
|
||||
) -> Option<[u8; 16]> {
|
||||
let p = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
let b = BigUint::from_bytes_be(&EC_B);
|
||||
|
||||
let d = BigUint::from_bytes_be(host_priv);
|
||||
let dkp = EcPoint::from_bytes(drive_key_point_x, drive_key_point_y);
|
||||
let dx = BigUint::from_bytes_be(drive_key_point_x);
|
||||
let dy = BigUint::from_bytes_be(drive_key_point_y);
|
||||
|
||||
// Reject an off-curve drive point before the multiply (invalid-curve attack).
|
||||
if !point_on_curve(&dx, &dy, &a, &b, &p) {
|
||||
return None;
|
||||
}
|
||||
let dkp = EcPoint::new(dx, dy);
|
||||
|
||||
let shared = ec_mul(&d, &dkp, &a, &p);
|
||||
|
||||
@@ -609,7 +690,7 @@ fn compute_bus_key(
|
||||
let x_bytes = to_bytes_be_padded(&shared.x, 20);
|
||||
let mut bus_key = [0u8; 16];
|
||||
bus_key.copy_from_slice(&x_bytes[4..20]); // last 16 of 20
|
||||
bus_key
|
||||
Some(bus_key)
|
||||
}
|
||||
|
||||
/// Generate ephemeral host key pair: (private_key, public_point_x, public_point_y).
|
||||
@@ -620,12 +701,20 @@ fn generate_host_key_pair_p256() -> ([u8; 32], [u8; 32], [u8; 32]) {
|
||||
let n = BigUint::from_bytes_be(&P256_N);
|
||||
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
|
||||
|
||||
let (d, q) = loop {
|
||||
let mut priv_bytes = [0u8; 32];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut priv_bytes);
|
||||
// d == 0 (prob ~1/n) would yield the point at infinity / an
|
||||
// all-zero key and degenerate the bus key — reject and retry,
|
||||
// matching the AACS 1.0 sibling generate_host_key_pair.
|
||||
let d = BigUint::from_bytes_be(&priv_bytes) % &n;
|
||||
|
||||
if d.is_zero() {
|
||||
continue;
|
||||
}
|
||||
let q = ec_mul(&d, &g, &a, &p_mod);
|
||||
break (d, q);
|
||||
};
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
let mut pub_x = [0u8; 32];
|
||||
@@ -672,7 +761,14 @@ fn generate_host_key_pair() -> ([u8; 20], [u8; 20], [u8; 20]) {
|
||||
|
||||
// ── AES-CMAC (for MAC verification) ────────────────────────────────────────
|
||||
|
||||
/// AES-128-CMAC over 16 bytes of data.
|
||||
/// AES-128-CMAC, single-complete-block case ONLY.
|
||||
///
|
||||
/// Implements just the exactly-16-byte message path: it derives subkey
|
||||
/// K1 and XORs the one full block. It does NOT derive K2 or apply the
|
||||
/// `0x80` 10*-padding, so it is correct only for a 16-byte input — the
|
||||
/// `&[u8; 16]` signature enforces that at compile time. Do NOT generalize
|
||||
/// this to multi-block or short-final-block messages without adding K2 +
|
||||
/// padding.
|
||||
fn aes_cmac_16(data: &[u8; 16], key: &[u8; 16]) -> [u8; 16] {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
@@ -746,7 +842,10 @@ fn cdb_report_disc_structure(agid: u8, format: u8, len: u16) -> [u8; 12] {
|
||||
// ── High-level handshake ────────────────────────────────────────────────────
|
||||
|
||||
/// Result of a successful AACS authentication handshake.
|
||||
#[derive(Debug)]
|
||||
///
|
||||
/// `Debug` is implemented manually so the session key material
|
||||
/// (`bus_key`, `volume_id`, `read_data_key`) is never rendered into logs
|
||||
/// or `dbg!` output — only its presence is reported.
|
||||
pub struct AacsAuth {
|
||||
/// Bus key (16 bytes) — derived from ECDH
|
||||
pub bus_key: [u8; 16],
|
||||
@@ -756,10 +855,27 @@ pub struct AacsAuth {
|
||||
pub volume_id: Option<[u8; 16]>,
|
||||
/// Read data key (16 bytes) — for AACS 2.0 bus decryption
|
||||
pub read_data_key: Option<[u8; 16]>,
|
||||
/// Drive certificate (92 bytes)
|
||||
/// Drive certificate (first 92 bytes of the drive's certificate;
|
||||
/// an AACS 2.0 P-256 cert is 132 bytes and is truncated to fit this
|
||||
/// fixed-size field — see [`aacs2_authenticate_p256`]).
|
||||
pub drive_cert: [u8; 92],
|
||||
}
|
||||
|
||||
// Manual Debug: bus_key, volume_id, and read_data_key are key material (the
|
||||
// VID feeds VUK derivation), so they are redacted — a `dbg!`/tracing of
|
||||
// AacsAuth must never dump them in plaintext.
|
||||
impl std::fmt::Debug for AacsAuth {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AacsAuth")
|
||||
.field("bus_key", &"[redacted]")
|
||||
.field("agid", &self.agid)
|
||||
.field("volume_id", &self.volume_id.map(|_| "[redacted]"))
|
||||
.field("read_data_key", &self.read_data_key.map(|_| "[redacted]"))
|
||||
.field("drive_cert", &self.drive_cert)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform the full AACS authentication handshake.
|
||||
///
|
||||
/// Requires a host private key (20 bytes) and host certificate (92 bytes)
|
||||
@@ -781,7 +897,8 @@ pub fn aacs_authenticate(
|
||||
|
||||
// Step 2: Allocate AGID
|
||||
let cdb = cdb_report_key(0, 0x00, 8);
|
||||
let response = scsi_read(session, &cdb, 8).map_err(|_| Error::AacsAgidAlloc)?;
|
||||
let response =
|
||||
scsi_read(session, &cdb, 8).map_err(|e| handshake_err(e, Error::AacsAgidAlloc))?;
|
||||
let agid = (response[7] >> 6) & 0x03;
|
||||
|
||||
// Step 3: Generate host nonce and ephemeral key pair
|
||||
@@ -797,43 +914,53 @@ pub fn aacs_authenticate(
|
||||
send_buf[24..116].copy_from_slice(&host_cert[..92]);
|
||||
|
||||
let cdb = cdb_send_key(agid, 0x01, 116);
|
||||
scsi_write(session, &cdb, &send_buf).map_err(|_| Error::AacsCertRejected)?;
|
||||
scsi_write(session, &cdb, &send_buf).map_err(|e| handshake_err(e, Error::AacsCertRejected))?;
|
||||
|
||||
// Step 5: Read drive certificate + nonce (REPORT KEY format 0x01)
|
||||
let cdb = cdb_report_key(agid, 0x01, 116);
|
||||
let response = scsi_read(session, &cdb, 116).map_err(|_| Error::AacsCertRead)?;
|
||||
let response =
|
||||
scsi_read(session, &cdb, 116).map_err(|e| handshake_err(e, Error::AacsCertRead))?;
|
||||
|
||||
let mut drive_nonce = [0u8; 20];
|
||||
let mut drive_cert = [0u8; 92];
|
||||
drive_nonce.copy_from_slice(&response[4..24]);
|
||||
drive_cert.copy_from_slice(&response[24..116]);
|
||||
|
||||
// Verify drive certificate
|
||||
// Verify drive certificate. `is_aacs20` tracks the 2.0 cert type so the
|
||||
// step-6 key-signature verify below is skipped too (see there).
|
||||
let is_aacs20 = drive_cert[0] == 0x11;
|
||||
if drive_cert[0] == 0x01 {
|
||||
// AACS 1.0 certificate
|
||||
if !verify_cert(&drive_cert) {
|
||||
return Err(Error::AacsCertVerify);
|
||||
}
|
||||
} else if drive_cert[0] == 0x11 {
|
||||
} else if is_aacs20 {
|
||||
// AACS 2.0 certificate — verification intentionally skipped here.
|
||||
// Reason: backward compatibility. AACS 2.0 drives accept AACS 1.0 host
|
||||
// certs, so we proceed with the AACS 1.0 flow regardless. The P-256
|
||||
// LA public key needed to verify 2.0 certs is not always available, and
|
||||
// failing here would break handshakes with drives that work fine otherwise.
|
||||
// The drive's identity is still authenticated through the ECDH key
|
||||
// exchange and signature verification in step 6 below.
|
||||
// The 2.0 cert lays out its public key and signature at different byte
|
||||
// offsets than the 1.0 cert, so the step-6 verify below (which reads
|
||||
// 1.0 offsets) cannot validate a 2.0 cert and is skipped for it.
|
||||
}
|
||||
|
||||
// Step 6: Read drive key point + signature (REPORT KEY format 0x02)
|
||||
let cdb = cdb_report_key(agid, 0x02, 84);
|
||||
let response = scsi_read(session, &cdb, 84).map_err(|_| Error::AacsKeyRead)?;
|
||||
let response =
|
||||
scsi_read(session, &cdb, 84).map_err(|e| handshake_err(e, Error::AacsKeyRead))?;
|
||||
|
||||
let mut drive_key_point = [0u8; 40]; // x(20) + y(20)
|
||||
let mut drive_key_sig = [0u8; 40]; // r(20) + s(20)
|
||||
drive_key_point.copy_from_slice(&response[4..44]);
|
||||
drive_key_sig.copy_from_slice(&response[44..84]);
|
||||
|
||||
// Verify drive key signature: sign(drive_nonce=host_nonce || drive_key_point)
|
||||
// Verify drive key signature: sign(drive_nonce=host_nonce || drive_key_point).
|
||||
// Skipped for an AACS 2.0 (type 0x11) cert: `cert_pub_key` reads the public
|
||||
// key at AACS-1.0 byte offsets, which don't apply to a 2.0 cert, so the
|
||||
// verify would be meaningless (it would reject every 2.0 drive). Mirrors the
|
||||
// cert-verify skip above; the ECDH key exchange still proceeds.
|
||||
if !is_aacs20 {
|
||||
let (drive_pub_x, drive_pub_y) = cert_pub_key(&drive_cert);
|
||||
let mut verify_data = [0u8; 60];
|
||||
verify_data[..20].copy_from_slice(&host_nonce);
|
||||
@@ -847,6 +974,7 @@ pub fn aacs_authenticate(
|
||||
if !ecdsa_verify(&drive_pub_x, &drive_pub_y, &sig_r, &sig_s, &verify_data) {
|
||||
return Err(Error::AacsKeyVerify);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Sign host key point (ECDSA over drive_nonce || host_key_point)
|
||||
let mut sign_data = [0u8; 60];
|
||||
@@ -865,7 +993,7 @@ pub fn aacs_authenticate(
|
||||
send_buf[64..84].copy_from_slice(&host_sig_s);
|
||||
|
||||
let cdb = cdb_send_key(agid, 0x02, 84);
|
||||
scsi_write(session, &cdb, &send_buf).map_err(|_| Error::AacsKeyRejected)?;
|
||||
scsi_write(session, &cdb, &send_buf).map_err(|e| handshake_err(e, Error::AacsKeyRejected))?;
|
||||
|
||||
// Step 9: Compute bus key via ECDH
|
||||
let mut dkp_x = [0u8; 20];
|
||||
@@ -873,7 +1001,7 @@ pub fn aacs_authenticate(
|
||||
dkp_x.copy_from_slice(&drive_key_point[..20]);
|
||||
dkp_y.copy_from_slice(&drive_key_point[20..40]);
|
||||
|
||||
let bus_key = compute_bus_key(&host_key, &dkp_x, &dkp_y);
|
||||
let bus_key = compute_bus_key(&host_key, &dkp_x, &dkp_y).ok_or(Error::AacsKeyVerify)?;
|
||||
|
||||
Ok(AacsAuth {
|
||||
bus_key,
|
||||
@@ -904,9 +1032,11 @@ pub fn aacs2_authenticate(
|
||||
}
|
||||
}
|
||||
|
||||
// AACS 2.0 native P-256 handshake
|
||||
let host_priv_v2 = host_priv_key_v2.ok_or(Error::AacsCertShort)?;
|
||||
let host_cert_v2 = host_cert_v2.ok_or(Error::AacsCertShort)?;
|
||||
// AACS 2.0 native P-256 handshake. Absent v2 credentials are "no AACS
|
||||
// 2.0 keys configured" (AacsNoKeys), distinct from a malformed/too-short
|
||||
// cert (AacsCertShort) — so callers can tell "not provided" from "bad".
|
||||
let host_priv_v2 = host_priv_key_v2.ok_or(Error::AacsNoKeys)?;
|
||||
let host_cert_v2 = host_cert_v2.ok_or(Error::AacsNoKeys)?;
|
||||
|
||||
aacs2_authenticate_p256(session, host_priv_v2, host_cert_v2)
|
||||
}
|
||||
@@ -930,7 +1060,8 @@ fn aacs2_authenticate_p256(
|
||||
|
||||
// Step 2: Allocate AGID
|
||||
let cdb = cdb_report_key(0, 0x00, 8);
|
||||
let response = scsi_read(session, &cdb, 8).map_err(|_| Error::AacsAgidAlloc)?;
|
||||
let response =
|
||||
scsi_read(session, &cdb, 8).map_err(|e| handshake_err(e, Error::AacsAgidAlloc))?;
|
||||
let agid = (response[7] >> 6) & 0x03;
|
||||
|
||||
// Step 3: Generate host nonce + P-256 ephemeral key pair
|
||||
@@ -947,12 +1078,13 @@ fn aacs2_authenticate_p256(
|
||||
send_buf[24..156].copy_from_slice(&host_cert[..132]);
|
||||
|
||||
let cdb = cdb_send_key(agid, 0x01, 156);
|
||||
scsi_write(session, &cdb, &send_buf).map_err(|_| Error::AacsCertRejected)?;
|
||||
scsi_write(session, &cdb, &send_buf).map_err(|e| handshake_err(e, Error::AacsCertRejected))?;
|
||||
|
||||
// Step 5: Read drive certificate + nonce
|
||||
// AACS 2.0 drive cert is also 132 bytes
|
||||
let cdb = cdb_report_key(agid, 0x01, 156);
|
||||
let response = scsi_read(session, &cdb, 156).map_err(|_| Error::AacsCertRead)?;
|
||||
let response =
|
||||
scsi_read(session, &cdb, 156).map_err(|e| handshake_err(e, Error::AacsCertRead))?;
|
||||
|
||||
let mut drive_nonce = [0u8; 20];
|
||||
drive_nonce.copy_from_slice(&response[4..24]);
|
||||
@@ -963,13 +1095,20 @@ fn aacs2_authenticate_p256(
|
||||
// uses certificate formats that differ from the spec, and rejecting them
|
||||
// would break otherwise working drives. The drive is still authenticated
|
||||
// through the ECDH key exchange and P-256 signature verification below.
|
||||
// The outcome is surfaced as a trace event rather than discarded so the
|
||||
// trust decision is observable (and so the call is not dead code).
|
||||
if drive_cert[0] == 0x11 && !verify_cert_p256(drive_cert) {
|
||||
// Certificate verification failed but proceeding for backward compatibility.
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "aacs2_cert_verify_skipped",
|
||||
"drive cert failed P-256 LA verification; proceeding for backward compat"
|
||||
);
|
||||
}
|
||||
|
||||
// Step 6: Read drive key point + signature (P-256: 64+64 = 128 bytes)
|
||||
let cdb = cdb_report_key(agid, 0x02, 132);
|
||||
let response = scsi_read(session, &cdb, 132).map_err(|_| Error::AacsKeyRead)?;
|
||||
let response =
|
||||
scsi_read(session, &cdb, 132).map_err(|e| handshake_err(e, Error::AacsKeyRead))?;
|
||||
|
||||
let drive_key_x = &response[4..36];
|
||||
let drive_key_y = &response[36..68];
|
||||
@@ -1010,10 +1149,11 @@ fn aacs2_authenticate_p256(
|
||||
send_buf[100..132].copy_from_slice(&host_sig_s);
|
||||
|
||||
let cdb = cdb_send_key(agid, 0x02, 132);
|
||||
scsi_write(session, &cdb, &send_buf).map_err(|_| Error::AacsKeyRejected)?;
|
||||
scsi_write(session, &cdb, &send_buf).map_err(|e| handshake_err(e, Error::AacsKeyRejected))?;
|
||||
|
||||
// Step 9: Compute bus key via P-256 ECDH
|
||||
let bus_key = compute_bus_key_p256(&host_eph_key, drive_key_x, drive_key_y);
|
||||
let bus_key = compute_bus_key_p256(&host_eph_key, drive_key_x, drive_key_y)
|
||||
.ok_or(Error::AacsKeyVerify)?;
|
||||
|
||||
Ok(AacsAuth {
|
||||
bus_key,
|
||||
@@ -1032,7 +1172,8 @@ fn aacs2_authenticate_p256(
|
||||
pub fn read_volume_id(session: &mut Drive, auth: &mut AacsAuth) -> Result<[u8; 16]> {
|
||||
// REPORT DISC STRUCTURE format 0x80
|
||||
let cdb = cdb_report_disc_structure(auth.agid, 0x80, 36);
|
||||
let response = scsi_read(session, &cdb, 36).map_err(|_| Error::AacsVidRead)?;
|
||||
let response =
|
||||
scsi_read(session, &cdb, 36).map_err(|e| handshake_err(e, Error::AacsVidRead))?;
|
||||
|
||||
let mut vid = [0u8; 16];
|
||||
let mut mac = [0u8; 16];
|
||||
@@ -1053,7 +1194,8 @@ pub fn read_volume_id(session: &mut Drive, auth: &mut AacsAuth) -> Result<[u8; 1
|
||||
pub fn read_data_keys(session: &mut Drive, auth: &mut AacsAuth) -> Result<([u8; 16], [u8; 16])> {
|
||||
// REPORT DISC STRUCTURE format 0x84
|
||||
let cdb = cdb_report_disc_structure(auth.agid, 0x84, 36);
|
||||
let response = scsi_read(session, &cdb, 36).map_err(|_| Error::AacsDataKey)?;
|
||||
let response =
|
||||
scsi_read(session, &cdb, 36).map_err(|e| handshake_err(e, Error::AacsDataKey))?;
|
||||
|
||||
let mut enc_rdk = [0u8; 16];
|
||||
let mut enc_wdk = [0u8; 16];
|
||||
@@ -1074,6 +1216,40 @@ pub fn read_data_keys(session: &mut Drive, auth: &mut AacsAuth) -> Result<([u8;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn handshake_err_preserves_transport_failure() {
|
||||
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE};
|
||||
|
||||
// A transport wedge mid-handshake must NOT be reported as a cert/key
|
||||
// rejection — the operator needs to see the real (replug) cause, not
|
||||
// be sent down a keydb/host-cert rabbit hole.
|
||||
let transport = Error::ScsiError {
|
||||
opcode: 0xA3, // SEND KEY
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
};
|
||||
let mapped = handshake_err(transport, Error::AacsCertRejected);
|
||||
assert!(
|
||||
mapped.is_scsi_transport_failure(),
|
||||
"transport failure must be preserved, not collapsed to a cert code"
|
||||
);
|
||||
|
||||
// A genuine SCSI rejection (CHECK CONDITION) IS the drive saying no, so
|
||||
// it maps to the handshake-specific code as before.
|
||||
let rejected = Error::ScsiError {
|
||||
opcode: 0xA3,
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some(crate::scsi::ScsiSense {
|
||||
sense_key: 0x05, // ILLEGAL REQUEST
|
||||
asc: 0x24,
|
||||
ascq: 0x00,
|
||||
}),
|
||||
};
|
||||
let mapped = handshake_err(rejected, Error::AacsCertRejected);
|
||||
assert!(matches!(mapped, Error::AacsCertRejected));
|
||||
assert!(!mapped.is_scsi_transport_failure());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ec_curve_generator_on_curve() {
|
||||
// Verify G is on the curve: y² = x³ + ax + b (mod p)
|
||||
@@ -1142,9 +1318,11 @@ mod tests {
|
||||
let (priv_b, pub_bx, pub_by) = generate_host_key_pair();
|
||||
|
||||
// A computes: priv_a × pub_B
|
||||
let shared_a = compute_bus_key(&priv_a, &pub_bx, &pub_by);
|
||||
let shared_a = compute_bus_key(&priv_a, &pub_bx, &pub_by)
|
||||
.expect("on-curve generated point must be accepted");
|
||||
// B computes: priv_b × pub_A
|
||||
let shared_b = compute_bus_key(&priv_b, &pub_ax, &pub_ay);
|
||||
let shared_b = compute_bus_key(&priv_b, &pub_ax, &pub_ay)
|
||||
.expect("on-curve generated point must be accepted");
|
||||
|
||||
assert_eq!(shared_a, shared_b, "ECDH shared secrets should match");
|
||||
}
|
||||
@@ -1224,12 +1402,14 @@ mod tests {
|
||||
&priv_a,
|
||||
&to_bytes_be_padded(&pub_b.x, 32),
|
||||
&to_bytes_be_padded(&pub_b.y, 32),
|
||||
);
|
||||
)
|
||||
.expect("on-curve generated point must be accepted");
|
||||
let key_b = compute_bus_key_p256(
|
||||
&priv_b,
|
||||
&to_bytes_be_padded(&pub_a.x, 32),
|
||||
&to_bytes_be_padded(&pub_a.y, 32),
|
||||
);
|
||||
)
|
||||
.expect("on-curve generated point must be accepted");
|
||||
|
||||
assert_eq!(key_a, key_b, "P-256 ECDH shared secrets should match");
|
||||
}
|
||||
@@ -1336,28 +1516,391 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_host_cert_from_keydb() {
|
||||
// Verify the host cert from our KEYDB
|
||||
let keydb_path = match std::env::var("KEYDB_PATH").ok() {
|
||||
Some(p) => std::path::PathBuf::from(p),
|
||||
None => return, // skip if KEYDB_PATH not set
|
||||
};
|
||||
if !keydb_path.exists() {
|
||||
return;
|
||||
fn test_verify_cert_p256_short_cert_no_panic() {
|
||||
// Regression: verify_cert_p256 used to slice cert[106..138] after only
|
||||
// a `len < 132` guard. The drive cert the handshake passes in is
|
||||
// exactly 132 bytes (&response[24..156]), so the slice panicked OOB.
|
||||
// It must now return false (cannot verify) rather than panic.
|
||||
let cert_132 = [0x11u8; 132];
|
||||
assert!(
|
||||
!verify_cert_p256(&cert_132),
|
||||
"132-byte cert must be rejected, not panic"
|
||||
);
|
||||
// Boundary lengths around the slice requirement.
|
||||
for len in [0usize, 73, 74, 105, 106, 131, 137] {
|
||||
let cert = vec![0x11u8; len];
|
||||
assert!(!verify_cert_p256(&cert), "len {len} must not panic");
|
||||
}
|
||||
}
|
||||
|
||||
let db = crate::aacs::KeyDb::load(&keydb_path).unwrap();
|
||||
if let Some(hc) = db.host_certs.first() {
|
||||
#[test]
|
||||
fn test_compute_bus_key_rejects_off_curve_point() {
|
||||
// An off-curve drive point must be rejected (invalid-curve guard),
|
||||
// while an on-curve point (here the generator G) is accepted.
|
||||
let (host_priv, _, _) = generate_host_key_pair();
|
||||
|
||||
// On-curve: G itself.
|
||||
assert!(
|
||||
compute_bus_key(&host_priv, &EC_GX, &EC_GY).is_some(),
|
||||
"on-curve point must be accepted"
|
||||
);
|
||||
|
||||
// Off-curve: G with y flipped by one bit almost never stays on the curve.
|
||||
let mut bad_y = EC_GY;
|
||||
bad_y[19] ^= 0x01;
|
||||
assert!(
|
||||
compute_bus_key(&host_priv, &EC_GX, &bad_y).is_none(),
|
||||
"off-curve point must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_bus_key_p256_rejects_off_curve_point() {
|
||||
let (host_priv, _, _) = generate_host_key_pair_p256();
|
||||
|
||||
assert!(
|
||||
compute_bus_key_p256(&host_priv, &P256_GX, &P256_GY).is_some(),
|
||||
"on-curve P-256 point must be accepted"
|
||||
);
|
||||
|
||||
let mut bad_y = P256_GY;
|
||||
bad_y[31] ^= 0x01;
|
||||
assert!(
|
||||
compute_bus_key_p256(&host_priv, &P256_GX, &bad_y).is_none(),
|
||||
"off-curve P-256 point must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_host_cert_from_keydb() {
|
||||
// Exercise verify_cert against a real AACS 1.0 host certificate.
|
||||
//
|
||||
// libfreemkv no longer parses keydb.cfg (the parser lives in
|
||||
// freemkv-keysources), so the cert bytes are read from a raw 92-byte
|
||||
// certificate file named by HOST_CERT_PATH instead of being pulled
|
||||
// from a parsed KeyDb. This keeps verify_cert (private to this module,
|
||||
// so it cannot move to keysources) covered against genuine LA-signed
|
||||
// bytes without re-introducing a keydb dependency here. Inert in CI
|
||||
// (env unset), matching the prior KEYDB_PATH gating.
|
||||
let cert_path = match std::env::var("HOST_CERT_PATH").ok() {
|
||||
Some(p) => std::path::PathBuf::from(p),
|
||||
None => return,
|
||||
};
|
||||
if !cert_path.exists() {
|
||||
return;
|
||||
}
|
||||
let certificate = match std::fs::read(&cert_path) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// Direct HostCert construction — no parser. Only `certificate` feeds
|
||||
// verify_cert; the other fields are inert placeholders.
|
||||
let hc = crate::aacs::HostCert {
|
||||
private_key: [0u8; 20],
|
||||
certificate,
|
||||
private_key_v2: None,
|
||||
certificate_v2: None,
|
||||
};
|
||||
let valid = verify_cert(&hc.certificate);
|
||||
eprintln!(
|
||||
"Host cert verification: {}",
|
||||
if valid { "PASS" } else { "FAIL" }
|
||||
);
|
||||
// Note: our cert is revoked but should still have valid LA signature
|
||||
// If it doesn't verify, the LA public key might be wrong
|
||||
// Note: a revoked cert should still carry a valid LA signature.
|
||||
// If it doesn't verify, the LA public key might be wrong.
|
||||
if !valid {
|
||||
eprintln!(" (cert may use different LA key or format)");
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Hardening additions
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ── EC curve invariants: a, b chosen so 4a³+27b² != 0 (nonsingular) ────
|
||||
|
||||
#[test]
|
||||
fn aacs1_curve_is_nonsingular() {
|
||||
// A valid Weierstrass curve requires discriminant 4a³ + 27b² ≠ 0
|
||||
// (mod p). A typo in EC_A or EC_B that singularised the curve would be
|
||||
// caught here.
|
||||
let p = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
let b = BigUint::from_bytes_be(&EC_B);
|
||||
let four = BigUint::from(4u32);
|
||||
let twenty_seven = BigUint::from(27u32);
|
||||
let disc = (&four * &a % &p * &a % &p * &a % &p + &twenty_seven * &b % &p * &b % &p) % &p;
|
||||
assert!(!disc.is_zero(), "AACS 1.0 curve must be nonsingular");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p256_curve_is_nonsingular() {
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let b = BigUint::from_bytes_be(&P256_B);
|
||||
let four = BigUint::from(4u32);
|
||||
let twenty_seven = BigUint::from(27u32);
|
||||
let disc = (&four * &a % &p * &a % &p * &a % &p + &twenty_seven * &b % &p * &b % &p) % &p;
|
||||
assert!(!disc.is_zero(), "P-256 curve must be nonsingular");
|
||||
}
|
||||
|
||||
// ── mod_inv ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn mod_inv_round_trips() {
|
||||
// a * a⁻¹ ≡ 1 (mod m). Pin against the AACS prime.
|
||||
let m = BigUint::from_bytes_be(&EC_N);
|
||||
let a = BigUint::from(123456789u64);
|
||||
let inv = mod_inv(&a, &m).expect("inverse exists for a coprime to prime n");
|
||||
assert_eq!((&a * &inv) % &m, BigUint::one());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mod_inv_of_one_is_one() {
|
||||
let m = BigUint::from(97u32);
|
||||
assert_eq!(mod_inv(&BigUint::one(), &m), Some(BigUint::one()));
|
||||
}
|
||||
|
||||
// ── to_bytes_be_padded ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn to_bytes_be_padded_left_pads_short_values() {
|
||||
// A small number must be left-zero-padded to the fixed width (keys are
|
||||
// fixed-size big-endian; a short value left unpadded would shift bytes).
|
||||
let n = BigUint::from(0x1234u32);
|
||||
assert_eq!(to_bytes_be_padded(&n, 20), {
|
||||
let mut v = vec![0u8; 18];
|
||||
v.extend_from_slice(&[0x12, 0x34]);
|
||||
v
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_bytes_be_padded_truncates_to_low_bytes_when_longer() {
|
||||
// When the encoding is longer than len, the low `len` bytes are kept
|
||||
// (the function slices the tail) — this is how the 256-bit ECDH x is
|
||||
// reduced to the low 128 bits for the bus key.
|
||||
let n = BigUint::from(0x0102030405u64); // 5 bytes
|
||||
assert_eq!(to_bytes_be_padded(&n, 2), vec![0x04, 0x05]);
|
||||
}
|
||||
|
||||
// ── point_on_curve (via compute_bus_key acceptance) ────────────────────
|
||||
// point_on_curve is private; exercise it through compute_bus_key, which
|
||||
// calls it as the invalid-curve guard.
|
||||
|
||||
#[test]
|
||||
fn off_curve_x_out_of_field_is_rejected() {
|
||||
// A coordinate >= p is outside the field and must be rejected before
|
||||
// the multiply (the `x >= p || y >= p` guard). Use x = p (== modulus).
|
||||
let (host_priv, _, _) = generate_host_key_pair();
|
||||
// EC_P itself as the x coordinate → x == p → out of field.
|
||||
assert!(
|
||||
compute_bus_key(&host_priv, &EC_P, &EC_GY).is_none(),
|
||||
"x == p is out of field and must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// ── CDB builders: REPORT KEY / SEND KEY / REPORT DISC STRUCTURE ────────
|
||||
|
||||
#[test]
|
||||
fn cdb_report_key_layout() {
|
||||
// 0xA4 opcode; AACS key class at byte 7; BE16 length at 8/9;
|
||||
// (agid<<6)|format at byte 10. Pin the exact bit packing.
|
||||
let cdb = cdb_report_key(0b10, 0x02, 0x0054);
|
||||
assert_eq!(cdb[0], crate::scsi::SCSI_REPORT_KEY);
|
||||
assert_eq!(cdb[7], crate::scsi::AACS_KEY_CLASS);
|
||||
assert_eq!(cdb[8], 0x00);
|
||||
assert_eq!(cdb[9], 0x54);
|
||||
// agid=2 → bits 7:6 = 10b = 0x80; format 0x02 in low 6 bits.
|
||||
assert_eq!(cdb[10], 0x80 | 0x02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cdb_report_key_format_masked_to_6_bits() {
|
||||
// The format field is `format & 0x3F`; a value with bits 6/7 set must
|
||||
// not bleed into the AGID field. 0xFF & 0x3F == 0x3F.
|
||||
let cdb = cdb_report_key(0, 0xFF, 2);
|
||||
assert_eq!(cdb[10], 0x3F, "format must be masked to its low 6 bits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cdb_send_key_layout() {
|
||||
let cdb = cdb_send_key(0b11, 0x01, 116);
|
||||
assert_eq!(cdb[0], crate::scsi::SCSI_SEND_KEY);
|
||||
assert_eq!(cdb[7], crate::scsi::AACS_KEY_CLASS);
|
||||
assert_eq!(cdb[8], (116u16 >> 8) as u8);
|
||||
assert_eq!(cdb[9], (116u16 & 0xFF) as u8);
|
||||
assert_eq!(cdb[10], (0b11 << 6) | 0x01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cdb_report_disc_structure_layout() {
|
||||
// 0xAD opcode; byte 1 = 0x01 (Blu-ray); format at byte 7; BE16 length;
|
||||
// agid<<6 at byte 10 (no format bits here).
|
||||
let cdb = cdb_report_disc_structure(0b01, 0x80, 36);
|
||||
assert_eq!(cdb[0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
|
||||
assert_eq!(cdb[1], 0x01);
|
||||
assert_eq!(cdb[7], 0x80);
|
||||
assert_eq!(cdb[8], 0x00);
|
||||
assert_eq!(cdb[9], 36);
|
||||
assert_eq!(cdb[10], 0b01 << 6);
|
||||
}
|
||||
|
||||
// ── verify_cert (AACS 1.0): length guard ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn verify_cert_v1_rejects_short_cert_no_panic() {
|
||||
// < 92 bytes → false (the sig slices cert[52..72]/[72..92] would
|
||||
// otherwise panic). Sweep the boundary.
|
||||
for len in [0usize, 51, 52, 71, 72, 91] {
|
||||
assert!(!verify_cert(&vec![0u8; len]), "len {len} must not panic");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_pub_key_v1_zeroes_when_too_short() {
|
||||
// < 52 bytes → zeroed (x,y) rather than an OOB slice on cert[12..52].
|
||||
let (x, y) = cert_pub_key(&[0u8; 40]);
|
||||
assert_eq!(x, [0u8; 20]);
|
||||
assert_eq!(y, [0u8; 20]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_pub_key_v1_extracts_offsets_12_32_52() {
|
||||
// pub_x at [12..32], pub_y at [32..52]. Build a 92-byte cert with
|
||||
// distinct x/y regions.
|
||||
let mut cert = vec![0u8; 92];
|
||||
for b in &mut cert[12..32] {
|
||||
*b = 0xA1;
|
||||
}
|
||||
for b in &mut cert[32..52] {
|
||||
*b = 0xB2;
|
||||
}
|
||||
let (x, y) = cert_pub_key(&cert);
|
||||
assert_eq!(x, [0xA1u8; 20]);
|
||||
assert_eq!(y, [0xB2u8; 20]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_pub_key_p256_extracts_offsets_10_42_74() {
|
||||
// AACS 2.0: pub_x at [10..42], pub_y at [42..74].
|
||||
let mut cert = vec![0u8; 138];
|
||||
for b in &mut cert[10..42] {
|
||||
*b = 0xC3;
|
||||
}
|
||||
for b in &mut cert[42..74] {
|
||||
*b = 0xD4;
|
||||
}
|
||||
let (x, y) = cert_pub_key_p256(&cert);
|
||||
assert_eq!(x, [0xC3u8; 32]);
|
||||
assert_eq!(y, [0xD4u8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_pub_key_p256_zeroes_when_too_short() {
|
||||
// < 74 bytes → zeroed, matching the verify_cert_p256 >= 138 guard's
|
||||
// safety contract (no OOB on cert[10..74]).
|
||||
let (x, y) = cert_pub_key_p256(&[0u8; 73]);
|
||||
assert_eq!(x, [0u8; 32]);
|
||||
assert_eq!(y, [0u8; 32]);
|
||||
}
|
||||
|
||||
// ── ECDSA sign produces 20/32-byte fixed-width outputs ─────────────────
|
||||
|
||||
#[test]
|
||||
fn ecdsa_sign_outputs_are_fixed_width_and_verify() {
|
||||
// Sign/verify already covered; here assert the (r,s) are full-width
|
||||
// (the to_bytes_be_padded path must not emit short arrays — a fixed
|
||||
// [u8;20] return enforces width, but verify the values are non-trivial
|
||||
// and round-trip).
|
||||
let (priv_key, px, py) = generate_host_key_pair();
|
||||
let (r, s) = ecdsa_sign(&priv_key, b"payload");
|
||||
assert_ne!(r, [0u8; 20]);
|
||||
assert_ne!(s, [0u8; 20]);
|
||||
assert!(ecdsa_verify(&px, &py, &r, &s, b"payload"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ecdsa_verify_rejects_out_of_range_signature_components() {
|
||||
// r or s == 0, or >= n, must be rejected up front (standard ECDSA
|
||||
// range check). Use r = 0.
|
||||
let (_priv, px, py) = generate_host_key_pair();
|
||||
let zero = [0u8; 20];
|
||||
let some = [0x01u8; 20];
|
||||
assert!(
|
||||
!ecdsa_verify(&px, &py, &zero, &some, b"d"),
|
||||
"r == 0 must be rejected"
|
||||
);
|
||||
assert!(
|
||||
!ecdsa_verify(&px, &py, &some, &zero, b"d"),
|
||||
"s == 0 must be rejected"
|
||||
);
|
||||
// r == n must be rejected (>= n).
|
||||
assert!(!ecdsa_verify(&px, &py, &EC_N, &some, b"d"));
|
||||
}
|
||||
|
||||
// ── ec_add / ec_double identities ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn ec_add_with_infinity_is_identity() {
|
||||
let p = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
||||
let inf = EcPoint::infinity();
|
||||
let r1 = ec_add(&g, &inf, &a, &p);
|
||||
let r2 = ec_add(&inf, &g, &a, &p);
|
||||
assert_eq!((r1.x, r1.y), (g.x.clone(), g.y.clone()));
|
||||
assert_eq!((r2.x, r2.y), (g.x, g.y));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ec_add_point_and_its_negation_is_infinity() {
|
||||
// P + (-P) = O. -P has y' = p - y. Same x, different y → infinity.
|
||||
let p = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
||||
let neg_y = (&p - &g.y) % &p;
|
||||
let neg_g = EcPoint::new(g.x.clone(), neg_y);
|
||||
let sum = ec_add(&g, &neg_g, &a, &p);
|
||||
assert!(sum.infinity, "P + (-P) must be the point at infinity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ec_mul_two_g_equals_g_plus_g() {
|
||||
// 2·G via scalar mul equals ec_double(G) and ec_add(G,G).
|
||||
let p = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
||||
let two = BigUint::from(2u32);
|
||||
let mul2 = ec_mul(&two, &g, &a, &p);
|
||||
let dbl = ec_double(&g, &a, &p);
|
||||
let add = ec_add(&g, &g, &a, &p);
|
||||
assert_eq!((mul2.x.clone(), mul2.y.clone()), (dbl.x, dbl.y));
|
||||
assert_eq!((mul2.x, mul2.y), (add.x, add.y));
|
||||
}
|
||||
|
||||
// ── AES-CMAC subkey: K1 doubling with Rb=0x87 ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn aes_cmac_full_block_changes_with_one_input_bit() {
|
||||
// A single-bit flip in the message must change the MAC (the K1 XOR +
|
||||
// encrypt is sensitive to all input bits). Pairs with the NIST KAT.
|
||||
let key = [0x2bu8; 16];
|
||||
let m1 = [0x00u8; 16];
|
||||
let mut m2 = m1;
|
||||
m2[7] ^= 0x01;
|
||||
assert_ne!(aes_cmac_16(&m1, &key), aes_cmac_16(&m2, &key));
|
||||
}
|
||||
|
||||
// ── verify_cert_p256 boundary at exactly 138 ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn verify_cert_p256_accepts_138_byte_length_without_panic() {
|
||||
// 138 bytes is the minimum that satisfies the guard; the slices
|
||||
// cert[74..106]/[106..138] are all in-bounds. The signature won't
|
||||
// verify (random bytes) but it must NOT panic and must return false.
|
||||
let cert = vec![0x00u8; 138];
|
||||
assert!(!verify_cert_p256(&cert));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
//! AACS Key Database parsing — KEYDB.cfg format.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Parsed AACS key database.
|
||||
#[derive(Debug)]
|
||||
pub struct KeyDb {
|
||||
/// Device keys for MKB processing
|
||||
pub device_keys: Vec<DeviceKey>,
|
||||
/// Processing keys (pre-computed media keys for specific MKB versions)
|
||||
pub processing_keys: Vec<[u8; 16]>,
|
||||
/// Host certificate + private key for SCSI authentication
|
||||
pub host_certs: Vec<HostCert>,
|
||||
/// Per-disc VUK entries indexed by disc hash (hex lowercase)
|
||||
pub disc_entries: HashMap<String, DiscEntry>,
|
||||
}
|
||||
|
||||
/// A device key for MKB subset-difference tree processing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeviceKey {
|
||||
pub key: [u8; 16],
|
||||
pub node: u16,
|
||||
pub uv: u32,
|
||||
pub u_mask_shift: u8,
|
||||
}
|
||||
|
||||
/// Host certificate + private key for AACS SCSI authentication.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostCert {
|
||||
/// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes.
|
||||
pub private_key: [u8; 20],
|
||||
/// AACS 1.0: 92 bytes. AACS 2.0: 132 bytes.
|
||||
pub certificate: Vec<u8>,
|
||||
/// AACS 2.0 host private key (P-256, 32 bytes). None for AACS 1.0 only.
|
||||
pub private_key_v2: Option<[u8; 32]>,
|
||||
/// AACS 2.0 host certificate (type 0x11). None for AACS 1.0 only.
|
||||
pub certificate_v2: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A per-disc entry from the key database.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscEntry {
|
||||
/// Disc hash (20 bytes, hex)
|
||||
pub disc_hash: String,
|
||||
/// Disc title
|
||||
pub title: String,
|
||||
/// Media Key (16 bytes) — from MKB processing
|
||||
pub media_key: Option<[u8; 16]>,
|
||||
/// Disc ID (16 bytes)
|
||||
pub disc_id: Option<[u8; 16]>,
|
||||
/// Volume Unique Key (16 bytes) — decrypts title keys
|
||||
pub vuk: Option<[u8; 16]>,
|
||||
/// Unit keys (title keys) indexed by CPS unit number
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
|
||||
/// Parse a hex string like "0xABCD..." into bytes.
|
||||
pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> {
|
||||
let s = s.trim().trim_start_matches("0x").trim_start_matches("0X");
|
||||
if s.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
for i in (0..s.len()).step_by(2) {
|
||||
out.push(u8::from_str_radix(&s[i..i + 2], 16).ok()?);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Parse hex into a fixed-size array.
|
||||
pub(crate) fn parse_hex16(s: &str) -> Option<[u8; 16]> {
|
||||
let v = parse_hex(s)?;
|
||||
if v.len() != 16 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&v);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_hex20(s: &str) -> Option<[u8; 20]> {
|
||||
let v = parse_hex(s)?;
|
||||
if v.len() != 20 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 20];
|
||||
out.copy_from_slice(&v);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
impl KeyDb {
|
||||
/// Construct an empty KeyDb. Used by unit tests; production code
|
||||
/// reaches a populated KeyDb via [`KeyDb::load`] or [`KeyDb::parse`].
|
||||
pub fn empty() -> Self {
|
||||
KeyDb {
|
||||
device_keys: Vec::new(),
|
||||
processing_keys: Vec::new(),
|
||||
host_certs: Vec::new(),
|
||||
disc_entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a KEYDB.cfg file from a string.
|
||||
pub fn parse(data: &str) -> Self {
|
||||
let mut db = KeyDb {
|
||||
device_keys: Vec::new(),
|
||||
processing_keys: Vec::new(),
|
||||
host_certs: Vec::new(),
|
||||
disc_entries: HashMap::new(),
|
||||
};
|
||||
|
||||
for line in data.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
// Skip comments and empty lines
|
||||
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Device Key
|
||||
if line.starts_with("| DK") {
|
||||
if let Some(dk) = Self::parse_device_key(line) {
|
||||
db.device_keys.push(dk);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Processing Key
|
||||
if line.starts_with("| PK") {
|
||||
if let Some(pk) = Self::parse_processing_key(line) {
|
||||
db.processing_keys.push(pk);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Host Certificate (AACS 2.0)
|
||||
if line.starts_with("| HC2") {
|
||||
if let Some(hc) = db.host_certs.last_mut() {
|
||||
if let Some((pk, cert)) = Self::parse_host_cert_v2(line) {
|
||||
hc.private_key_v2 = Some(pk);
|
||||
hc.certificate_v2 = Some(cert);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Host Certificate (AACS 1.0)
|
||||
if line.starts_with("| HC") {
|
||||
if let Some(hc) = Self::parse_host_cert(line) {
|
||||
db.host_certs.push(hc);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Disc entry: starts with 0x
|
||||
if line.starts_with("0x") && line.contains(" = ") {
|
||||
if let Some(entry) = Self::parse_disc_entry(line) {
|
||||
db.disc_entries.insert(entry.disc_hash.clone(), entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db
|
||||
}
|
||||
|
||||
/// Load a KEYDB.cfg from disk.
|
||||
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
Ok(Self::parse(&data))
|
||||
}
|
||||
|
||||
/// Look up a disc by its hash. Returns the VUK if found.
|
||||
pub fn find_vuk(&self, disc_hash: &str) -> Option<[u8; 16]> {
|
||||
let hash = disc_hash
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
.trim_start_matches("0x")
|
||||
.to_string();
|
||||
// Try with 0x prefix and without
|
||||
self.disc_entries
|
||||
.get(&format!("0x{hash}"))
|
||||
.or_else(|| self.disc_entries.get(&hash))
|
||||
.and_then(|e| e.vuk)
|
||||
}
|
||||
|
||||
/// Look up a disc by its hash. Returns the full entry.
|
||||
pub fn find_disc(&self, disc_hash: &str) -> Option<&DiscEntry> {
|
||||
let hash = disc_hash
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
.trim_start_matches("0x")
|
||||
.to_string();
|
||||
self.disc_entries
|
||||
.get(&format!("0x{hash}"))
|
||||
.or_else(|| self.disc_entries.get(&hash))
|
||||
}
|
||||
|
||||
// ── Parsers ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_device_key(line: &str) -> Option<DeviceKey> {
|
||||
// | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...
|
||||
let key_str = line.split("DEVICE_KEY").nth(1)?.split('|').next()?.trim();
|
||||
let node_str = line.split("DEVICE_NODE").nth(1)?.split('|').next()?.trim();
|
||||
let uv_str = line.split("KEY_UV").nth(1)?.split('|').next()?.trim();
|
||||
let shift_str = line
|
||||
.split("KEY_U_MASK_SHIFT")
|
||||
.nth(1)?
|
||||
.split(';')
|
||||
.next()?
|
||||
.split('|')
|
||||
.next()?
|
||||
.trim();
|
||||
|
||||
Some(DeviceKey {
|
||||
key: parse_hex16(key_str)?,
|
||||
node: u16::from_str_radix(node_str.trim_start_matches("0x"), 16).ok()?,
|
||||
uv: u32::from_str_radix(uv_str.trim_start_matches("0x"), 16).ok()?,
|
||||
u_mask_shift: u8::from_str_radix(shift_str.trim_start_matches("0x"), 16).ok()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_processing_key(line: &str) -> Option<[u8; 16]> {
|
||||
// | PK | 0x...
|
||||
let parts: Vec<&str> = line.split('|').collect();
|
||||
if parts.len() >= 3 {
|
||||
let key_str = parts[2].split(';').next()?.trim();
|
||||
return parse_hex16(key_str);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_host_cert(line: &str) -> Option<HostCert> {
|
||||
// | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
|
||||
let priv_str = line
|
||||
.split("HOST_PRIV_KEY")
|
||||
.nth(1)?
|
||||
.split('|')
|
||||
.next()?
|
||||
.trim();
|
||||
let cert_str = line
|
||||
.split("HOST_CERT")
|
||||
.nth(1)?
|
||||
.split(';')
|
||||
.next()?
|
||||
.split('|')
|
||||
.next()?
|
||||
.trim();
|
||||
|
||||
Some(HostCert {
|
||||
private_key: parse_hex20(priv_str)?,
|
||||
certificate: parse_hex(cert_str)?,
|
||||
private_key_v2: None,
|
||||
certificate_v2: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse AACS 2.0 host cert: `| HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x...`
|
||||
fn parse_host_cert_v2(line: &str) -> Option<([u8; 32], Vec<u8>)> {
|
||||
let priv_str = line
|
||||
.split("HOST_PRIV_KEY")
|
||||
.nth(1)?
|
||||
.split('|')
|
||||
.next()?
|
||||
.trim();
|
||||
let cert_str = line
|
||||
.split("HOST_CERT")
|
||||
.nth(1)?
|
||||
.split(';')
|
||||
.next()?
|
||||
.split('|')
|
||||
.next()?
|
||||
.trim();
|
||||
|
||||
let priv_bytes = parse_hex(priv_str)?;
|
||||
if priv_bytes.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let mut pk = [0u8; 32];
|
||||
pk.copy_from_slice(&priv_bytes);
|
||||
|
||||
let cert = parse_hex(cert_str)?;
|
||||
if cert.len() < 132 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((pk, cert))
|
||||
}
|
||||
|
||||
fn parse_disc_entry(line: &str) -> Option<DiscEntry> {
|
||||
// 0x<hash> = <title> | D | <date> | M | 0x<mk> | I | 0x<id> | V | 0x<vuk> | U | <unit_keys>
|
||||
let (hash_part, rest) = line.split_once(" = ")?;
|
||||
let disc_hash = hash_part.trim().to_lowercase();
|
||||
|
||||
// Extract title (before first |)
|
||||
let title_part = rest.split(" | ").next().unwrap_or("").trim();
|
||||
// Clean title: "TITLE_NAME (Display Title)" → use display title if present
|
||||
let title = if let Some(start) = title_part.find('(') {
|
||||
if let Some(end) = title_part.rfind(')') {
|
||||
title_part[start + 1..end].to_string()
|
||||
} else {
|
||||
title_part.to_string()
|
||||
}
|
||||
} else {
|
||||
title_part.to_string()
|
||||
};
|
||||
|
||||
// Parse fields by tag
|
||||
let mut media_key = None;
|
||||
let mut disc_id = None;
|
||||
let mut vuk = None;
|
||||
let mut unit_keys = Vec::new();
|
||||
|
||||
let parts: Vec<&str> = rest.split(" | ").collect();
|
||||
let mut i = 0;
|
||||
while i < parts.len() {
|
||||
match parts[i].trim() {
|
||||
"M" => {
|
||||
if i + 1 < parts.len() {
|
||||
media_key = parse_hex16(parts[i + 1].trim());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"I" => {
|
||||
if i + 1 < parts.len() {
|
||||
disc_id = parse_hex16(parts[i + 1].trim());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"V" => {
|
||||
if i + 1 < parts.len() {
|
||||
vuk = parse_hex16(parts[i + 1].trim());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"U" => {
|
||||
if i + 1 < parts.len() {
|
||||
// Unit keys: "1-0xKEY" or "1-0xKEY ; comment"
|
||||
let uk_str = parts[i + 1].split(';').next().unwrap_or("").trim();
|
||||
for uk in uk_str.split(' ') {
|
||||
let uk = uk.trim();
|
||||
if let Some((num, key)) = uk.split_once('-') {
|
||||
if let Ok(n) = num.parse::<u32>() {
|
||||
if let Some(k) = parse_hex16(key) {
|
||||
unit_keys.push((n, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
Some(DiscEntry {
|
||||
disc_hash,
|
||||
title,
|
||||
media_key,
|
||||
disc_id,
|
||||
vuk,
|
||||
unit_keys,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
|
||||
fn keydb_path() -> Option<std::path::PathBuf> {
|
||||
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
|
||||
if path.exists() { Some(path) } else { None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_disc_entry() {
|
||||
let line = r#"***REMOVED*** = DUNE_PART_TWO (Dune: Part Two) | D | 2024-04-02 | M | ***REMOVED*** | I | ***REMOVED*** | V | ***REMOVED*** | U | 1-***REMOVED*** ; MKBv77"#;
|
||||
let entry = KeyDb::parse_disc_entry(line).unwrap();
|
||||
assert_eq!(entry.title, "Dune: Part Two");
|
||||
assert!(entry.media_key.is_some());
|
||||
assert!(entry.vuk.is_some());
|
||||
assert_eq!(entry.unit_keys.len(), 1);
|
||||
assert_eq!(entry.unit_keys[0].0, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_device_key() {
|
||||
let line = "| DK | DEVICE_KEY ***REMOVED*** | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48";
|
||||
let dk = KeyDb::parse_device_key(line).unwrap();
|
||||
assert_eq!(dk.node, 0x0800);
|
||||
assert_eq!(dk.u_mask_shift, 0x17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_host_cert() {
|
||||
let line = "| HC | HOST_PRIV_KEY ***REMOVED*** | HOST_CERT ***REMOVED*** ; Revoked";
|
||||
let hc = KeyDb::parse_host_cert(line).unwrap();
|
||||
assert_eq!(hc.private_key[0], 0x90);
|
||||
assert_eq!(hc.certificate.len(), 92);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_full_keydb() {
|
||||
let path = match keydb_path() {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
}; // skip if not available
|
||||
|
||||
let db = KeyDb::load(&path).unwrap();
|
||||
|
||||
assert_eq!(db.device_keys.len(), 4);
|
||||
assert_eq!(db.processing_keys.len(), 3);
|
||||
assert!(!db.host_certs.is_empty());
|
||||
assert!(db.disc_entries.len() > 170000);
|
||||
|
||||
// Look up Dune: Part Two
|
||||
let dune = db
|
||||
.disc_entries
|
||||
.values()
|
||||
.find(|e| e.title.contains("Dune: Part Two") && e.vuk.is_some())
|
||||
.expect("Dune: Part Two not found");
|
||||
assert!(dune.media_key.is_some());
|
||||
assert!(dune.vuk.is_some());
|
||||
assert!(!dune.unit_keys.is_empty());
|
||||
|
||||
eprintln!(
|
||||
"Parsed {} disc entries, {} DK, {} PK",
|
||||
db.disc_entries.len(),
|
||||
db.device_keys.len(),
|
||||
db.processing_keys.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
+2051
-447
File diff suppressed because it is too large
Load Diff
+71
-10
@@ -8,33 +8,94 @@
|
||||
//! | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...
|
||||
//! | PK | 0x...
|
||||
//! | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
|
||||
//! | HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
|
||||
//! 0x<disc_hash> = <title> | D | <date> | M | 0x<media_key> | I | 0x<disc_id> | V | 0x<vuk> | U | <unit_keys>
|
||||
//!
|
||||
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
|
||||
//! Title keys decrypt m2ts stream content (AES-128-CBC).
|
||||
|
||||
pub mod boil;
|
||||
pub mod decrypt;
|
||||
pub mod handshake;
|
||||
pub mod keydb;
|
||||
pub mod keys;
|
||||
pub mod provider;
|
||||
pub mod trace;
|
||||
pub mod types;
|
||||
pub mod variants;
|
||||
pub mod verify_magics;
|
||||
|
||||
// Boil-down derivation primitives (thin newtypes + wrappers over the crypto).
|
||||
pub use boil::{MediaKey, UnitKey, Vid, Vuk, mk_from_dk, uk_from_vuk, vuk_from_mk};
|
||||
// Structured, English-free resolution trace.
|
||||
pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, UnlockStep};
|
||||
|
||||
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
|
||||
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
|
||||
pub use decrypt::{
|
||||
ALIGNED_UNIT_LEN, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys,
|
||||
is_unit_encrypted,
|
||||
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, decrypt_bus, decrypt_unit,
|
||||
decrypt_unit_full, decrypt_unit_try_keys, is_aacs_scrambled, is_unit_aligned, ts_packet_total,
|
||||
ts_sync_count, unit_key_validates,
|
||||
};
|
||||
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
|
||||
pub use keys::probe;
|
||||
pub use keys::{
|
||||
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key,
|
||||
AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C, MKB_TYPE_3_RECORDABLE,
|
||||
MKB_TYPE_4_PRERECORDED, MKB_TYPE_10_CLASS_II, MkbType, ResolveContext, ResolveFailure,
|
||||
ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_and_pk_from_dk,
|
||||
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
|
||||
mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1,
|
||||
resolve_keys_v2, resolve_keys_v21, validate_media_key_against_mkb,
|
||||
mkb_content_len, mkb_is_uhd, mkb_type, mkb_type_raw, mkb_version, parse_content_cert,
|
||||
parse_unit_key_ro, read_mkb_from_drive, recover_dk_position, resolve_keys_v1, resolve_keys_v2,
|
||||
resolve_keys_v21, resolve_keys_with_reason, trim_mkb,
|
||||
};
|
||||
pub use provider::KeyProvider;
|
||||
pub use types::{DeviceKey, DiscEntry, HostCert};
|
||||
pub use variants::{
|
||||
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
|
||||
derive_media_key_variant, is_variant_mkb, variant_data_record, variant_key_data, variant_nonce,
|
||||
walk_mkb, walk_processing_key,
|
||||
derive_media_key_variant, is_variant_mkb, variant_nonce, walk_mkb, walk_processing_key,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Re-export surface guards. The module's public API is the set of
|
||||
//! `pub use` items above. A regression that drops or renames an export
|
||||
//! (the class of bug that shipped in 0.31.0 by silently changing a
|
||||
//! surface) breaks compilation of these references, so they act as a
|
||||
//! compile-time contract for the crate's AACS surface.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aligned_unit_len_is_three_2048_byte_sectors() {
|
||||
// ALIGNED_UNIT_LEN is the AACS aligned-unit size: 3 × 2048 = 6144.
|
||||
// Re-exported from decrypt; pin the value here so the public constant
|
||||
// and the spec stay in lockstep.
|
||||
assert_eq!(ALIGNED_UNIT_LEN, 6144);
|
||||
assert_eq!(ALIGNED_UNIT_LEN, 3 * 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_strides_are_reexported_and_distinct() {
|
||||
// The three AACS generations are part of the public surface, and the
|
||||
// V10 (48) vs V20/V21 (64) stride distinction is the load-bearing
|
||||
// difference. Confirm the enum re-export is usable and the variants
|
||||
// are distinct values.
|
||||
assert_ne!(AacsVersion::V10, AacsVersion::V20);
|
||||
assert_ne!(AacsVersion::V20, AacsVersion::V21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_correction_data_placeholder_is_all_zero() {
|
||||
// The variant chain refuses to run against this all-zero placeholder
|
||||
// KCD; the public constant must therefore be exactly 16 zero bytes.
|
||||
assert_eq!(KEY_CORRECTION_DATA_PLACEHOLDER, [0u8; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_helpers_are_callable_through_the_facade() {
|
||||
// Touch a representative function from each re-export group so a
|
||||
// dropped/renamed export fails to compile. These are smoke calls, not
|
||||
// behavioural assertions (behaviour is covered in each module).
|
||||
let _ = is_aacs_scrambled(&[0u8; ALIGNED_UNIT_LEN]);
|
||||
let _ = mkb_content_len(&[]);
|
||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Key source abstraction for the AACS resolve chain.
|
||||
//!
|
||||
//! libfreemkv keeps all crypto (AES-G primitives, SD-tree walking,
|
||||
//! validation, MK/VUK/TK derivation) but accepts key material from
|
||||
//! arbitrary backends via [`KeyProvider`].
|
||||
//!
|
||||
//! Methods come in two flavors:
|
||||
//!
|
||||
//! - **Bulk material** ([`device_keys`], [`processing_keys`],
|
||||
//! [`media_keys`]) — the resolver unions (and dedups) results
|
||||
//! across all providers and tries each candidate.
|
||||
//! - **Disc-keyed lookup** ([`lookup_disc_by_hash`],
|
||||
//! [`lookup_disc_by_vid`]) — the resolver short-circuits on the
|
||||
//! first hit, so providers are queried in array order with
|
||||
//! fastest/closest first.
|
||||
//!
|
||||
//! [`host_certs`] is a sixth method but is NOT consumed by the
|
||||
//! resolver chain: the SCSI handshake reads host certs directly from
|
||||
//! the caller-supplied credentials, not from the provider array. A
|
||||
//! provider that overrides `host_certs` today has no effect on the
|
||||
//! handshake; the method is retained as a forward-looking extension
|
||||
//! point only.
|
||||
//!
|
||||
//! Default impls return empty / `None` so backends only override
|
||||
//! the methods they actually support — an external key service might
|
||||
//! implement only `lookup_disc_by_hash`, while a local file might
|
||||
//! implement all six.
|
||||
//!
|
||||
//! Calls may block (disk I/O, network round-trips). The resolver
|
||||
//! invokes each method at most a handful of times per scan; for
|
||||
//! per-disc memoization, implementations should cache internally.
|
||||
//!
|
||||
//! [`device_keys`]: KeyProvider::device_keys
|
||||
//! [`processing_keys`]: KeyProvider::processing_keys
|
||||
//! [`media_keys`]: KeyProvider::media_keys
|
||||
//! [`host_certs`]: KeyProvider::host_certs
|
||||
//! [`lookup_disc_by_hash`]: KeyProvider::lookup_disc_by_hash
|
||||
//! [`lookup_disc_by_vid`]: KeyProvider::lookup_disc_by_vid
|
||||
|
||||
use super::types::{DeviceKey, DiscEntry, HostCert};
|
||||
|
||||
/// Source of AACS key material.
|
||||
///
|
||||
/// Implementors return raw material only — the resolver in
|
||||
/// `aacs::keys` owns all the crypto (DK→PK walking, PK validation,
|
||||
/// MK→VUK→TK derivation). See module docs for method semantics.
|
||||
pub trait KeyProvider: Send + Sync {
|
||||
/// Device keys (top-of-tree, walked by the resolver).
|
||||
fn device_keys(&self) -> Vec<DeviceKey> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Processing keys — terminal PKs or walk-input PKs. The
|
||||
/// resolver tries each as a terminal first (cheap validate).
|
||||
fn processing_keys(&self) -> Vec<[u8; 16]> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Every Media Key this provider holds, regardless of which disc it was
|
||||
/// filed under. An MK is MKB-scoped (shared across a pressing/MKB-family),
|
||||
/// so the resolver can verify each against the disc's MKB (`km_verifies`)
|
||||
/// and resolve a disc whose own hash/VID isn't directly keyed.
|
||||
fn media_keys(&self) -> Vec<[u8; 16]> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// AACS host certificates (with their private keys) for drive
|
||||
/// authentication. Multiple in case some are revoked.
|
||||
///
|
||||
/// NOTE: not consumed by the resolver chain — the handshake reads
|
||||
/// host certs from the caller-supplied credentials directly, so
|
||||
/// overriding this method has no effect on drive authentication
|
||||
/// today. Retained as a forward-looking extension point.
|
||||
fn host_certs(&self) -> Vec<HostCert> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Direct per-disc lookup by SHA-1 of `Unit_Key_RO.inf`. Returns
|
||||
/// `Some(entry)` if this provider has pre-computed material for
|
||||
/// the disc (paths 4 and 5). Short-circuits the resolver.
|
||||
fn lookup_disc_by_hash(&self, _disc_hash: &[u8; 20]) -> Option<DiscEntry> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Lookup by Volume ID (path 3 — pre-computed MK + matching
|
||||
/// VID). Short-circuits the resolver on hit.
|
||||
fn lookup_disc_by_vid(&self, _volume_id: &[u8; 16]) -> Option<DiscEntry> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolver-side helpers that aggregate across a provider array.
|
||||
///
|
||||
/// The resolver wraps `ctx.providers` (`&[&dyn KeyProvider]`) in this
|
||||
/// struct; these helpers apply the union-vs-short-circuit policy per
|
||||
/// method. The bulk unions dedup so overlapping providers don't make
|
||||
/// the resolver re-walk/re-validate identical material.
|
||||
pub(crate) struct Providers<'a>(pub &'a [&'a dyn KeyProvider]);
|
||||
|
||||
impl Providers<'_> {
|
||||
/// Union (deduped) — gather DKs from every provider.
|
||||
pub fn device_keys(&self) -> Vec<DeviceKey> {
|
||||
let mut v: Vec<DeviceKey> = self.0.iter().flat_map(|p| p.device_keys()).collect();
|
||||
// DeviceKey has no Ord/Hash; dedup on the value-defining tuple.
|
||||
v.sort_unstable_by_key(|d| (d.key, d.node, d.uv, d.u_mask_shift));
|
||||
v.dedup_by_key(|d| (d.key, d.node, d.uv, d.u_mask_shift));
|
||||
v
|
||||
}
|
||||
|
||||
/// Union (deduped) — gather PKs from every provider.
|
||||
pub fn processing_keys(&self) -> Vec<[u8; 16]> {
|
||||
let mut v: Vec<[u8; 16]> = self.0.iter().flat_map(|p| p.processing_keys()).collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
/// Union of distinct Media Keys across every provider, for the MK-pool
|
||||
/// brute (`km_verifies` against the disc's MKB).
|
||||
pub fn media_keys(&self) -> Vec<[u8; 16]> {
|
||||
let mut v: Vec<[u8; 16]> = self.0.iter().flat_map(|p| p.media_keys()).collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
/// Union — gather host certs from every provider. The SCSI handshake
|
||||
/// reads host certs from the caller-supplied credentials directly and
|
||||
/// does not call this, so it is currently unused by the resolver chain.
|
||||
#[allow(dead_code)]
|
||||
pub fn host_certs(&self) -> Vec<HostCert> {
|
||||
self.0.iter().flat_map(|p| p.host_certs()).collect()
|
||||
}
|
||||
|
||||
/// Short-circuit — query providers in array order, first hit wins.
|
||||
pub fn lookup_disc_by_hash(&self, disc_hash: &[u8; 20]) -> Option<DiscEntry> {
|
||||
self.0.iter().find_map(|p| p.lookup_disc_by_hash(disc_hash))
|
||||
}
|
||||
|
||||
/// Short-circuit — query providers in array order, first hit wins.
|
||||
pub fn lookup_disc_by_vid(&self, volume_id: &[u8; 16]) -> Option<DiscEntry> {
|
||||
self.0.iter().find_map(|p| p.lookup_disc_by_vid(volume_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`KeyProvider`] backed by a single caller-supplied key's raw material —
|
||||
/// the bridge for [`crate::disc::Disc::decrypt_with`].
|
||||
///
|
||||
/// The application's key source did the lookup and handed in material at one
|
||||
/// level (DK / PK / MK / VUK). This exposes exactly that material to the
|
||||
/// version-dispatched resolver, which owns ALL derivation — so a source never
|
||||
/// derives, and the lib remains the single home for the AACS chain across
|
||||
/// 1.0 / 2.0 / 2.1 / 2.x.
|
||||
///
|
||||
/// Each level fills only its own field; the rest stay empty, so the resolver
|
||||
/// naturally runs the matching path (DK→…, PK→…, MK-pool brute, or a
|
||||
/// disc-keyed VUK hit). `decrypt_with` already knows the disc, so the
|
||||
/// `lookup_disc_by_*` hash/VID arguments are irrelevant — a present
|
||||
/// `disc_entry` is returned for any query.
|
||||
pub(crate) struct SuppliedKey {
|
||||
pub device_keys: Vec<DeviceKey>,
|
||||
pub processing_keys: Vec<[u8; 16]>,
|
||||
pub media_keys: Vec<[u8; 16]>,
|
||||
pub disc_entry: Option<DiscEntry>,
|
||||
}
|
||||
|
||||
impl KeyProvider for SuppliedKey {
|
||||
fn device_keys(&self) -> Vec<DeviceKey> {
|
||||
self.device_keys.clone()
|
||||
}
|
||||
fn processing_keys(&self) -> Vec<[u8; 16]> {
|
||||
self.processing_keys.clone()
|
||||
}
|
||||
fn media_keys(&self) -> Vec<[u8; 16]> {
|
||||
self.media_keys.clone()
|
||||
}
|
||||
fn lookup_disc_by_hash(&self, _disc_hash: &[u8; 20]) -> Option<DiscEntry> {
|
||||
self.disc_entry.clone()
|
||||
}
|
||||
fn lookup_disc_by_vid(&self, _volume_id: &[u8; 16]) -> Option<DiscEntry> {
|
||||
self.disc_entry.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(hash: &str, vuk: u8) -> DiscEntry {
|
||||
DiscEntry {
|
||||
disc_hash: hash.to_string(),
|
||||
title: "t".to_string(),
|
||||
media_key: None,
|
||||
disc_id: None,
|
||||
vuk: Some([vuk; 16]),
|
||||
unit_keys: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dk(byte: u8, node: u16) -> DeviceKey {
|
||||
DeviceKey {
|
||||
key: [byte; 16],
|
||||
node,
|
||||
uv: 1,
|
||||
u_mask_shift: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A provider that returns fixed bulk material and an optional disc entry
|
||||
/// keyed unconditionally (used to test array-order short-circuiting).
|
||||
#[derive(Default)]
|
||||
struct Fixed {
|
||||
dks: Vec<DeviceKey>,
|
||||
pks: Vec<[u8; 16]>,
|
||||
mks: Vec<[u8; 16]>,
|
||||
hash_hit: Option<DiscEntry>,
|
||||
vid_hit: Option<DiscEntry>,
|
||||
}
|
||||
impl KeyProvider for Fixed {
|
||||
fn device_keys(&self) -> Vec<DeviceKey> {
|
||||
self.dks.clone()
|
||||
}
|
||||
fn processing_keys(&self) -> Vec<[u8; 16]> {
|
||||
self.pks.clone()
|
||||
}
|
||||
fn media_keys(&self) -> Vec<[u8; 16]> {
|
||||
self.mks.clone()
|
||||
}
|
||||
fn lookup_disc_by_hash(&self, _h: &[u8; 20]) -> Option<DiscEntry> {
|
||||
self.hash_hit.clone()
|
||||
}
|
||||
fn lookup_disc_by_vid(&self, _v: &[u8; 16]) -> Option<DiscEntry> {
|
||||
self.vid_hit.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// ── KeyProvider default methods all return empty ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_provider_methods_return_empty() {
|
||||
// A bare provider that overrides nothing must yield empty material so
|
||||
// the resolver simply finds nothing through it (no surprise hits).
|
||||
struct Empty;
|
||||
impl KeyProvider for Empty {}
|
||||
let e = Empty;
|
||||
assert!(e.device_keys().is_empty());
|
||||
assert!(e.processing_keys().is_empty());
|
||||
assert!(e.media_keys().is_empty());
|
||||
assert!(e.host_certs().is_empty());
|
||||
assert!(e.lookup_disc_by_hash(&[0u8; 20]).is_none());
|
||||
assert!(e.lookup_disc_by_vid(&[0u8; 16]).is_none());
|
||||
}
|
||||
|
||||
// ── Providers::processing_keys: union + dedup ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn providers_processing_keys_union_and_dedup() {
|
||||
// Two providers each carrying overlapping PKs → the aggregate is the
|
||||
// deduped union (the resolver must not re-validate identical material).
|
||||
let a = Fixed {
|
||||
pks: vec![[0x01u8; 16], [0x02u8; 16]],
|
||||
..Default::default()
|
||||
};
|
||||
let b = Fixed {
|
||||
pks: vec![[0x02u8; 16], [0x03u8; 16]],
|
||||
..Default::default()
|
||||
};
|
||||
let arr: &[&dyn KeyProvider] = &[&a, &b];
|
||||
let mut got = Providers(arr).processing_keys();
|
||||
got.sort();
|
||||
assert_eq!(got, vec![[0x01u8; 16], [0x02u8; 16], [0x03u8; 16]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn providers_media_keys_union_and_dedup() {
|
||||
let a = Fixed {
|
||||
mks: vec![[0xAAu8; 16]],
|
||||
..Default::default()
|
||||
};
|
||||
let b = Fixed {
|
||||
mks: vec![[0xAAu8; 16], [0xBBu8; 16]],
|
||||
..Default::default()
|
||||
};
|
||||
let arr: &[&dyn KeyProvider] = &[&a, &b];
|
||||
let mut got = Providers(arr).media_keys();
|
||||
got.sort();
|
||||
assert_eq!(got, vec![[0xAAu8; 16], [0xBBu8; 16]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn providers_device_keys_dedup_on_value_tuple() {
|
||||
// DeviceKey has no Hash/Ord; dedup keys on (key,node,uv,u_mask_shift).
|
||||
// Two identical DKs across providers collapse to one; a DK differing
|
||||
// only in node is kept.
|
||||
let a = Fixed {
|
||||
dks: vec![dk(0x11, 5), dk(0x11, 5)],
|
||||
..Default::default()
|
||||
};
|
||||
let b = Fixed {
|
||||
dks: vec![dk(0x11, 5), dk(0x11, 6)],
|
||||
..Default::default()
|
||||
};
|
||||
let arr: &[&dyn KeyProvider] = &[&a, &b];
|
||||
let got = Providers(arr).device_keys();
|
||||
assert_eq!(got.len(), 2, "identical DKs dedup; differing node kept");
|
||||
let nodes: Vec<u16> = got.iter().map(|d| d.node).collect();
|
||||
assert!(nodes.contains(&5) && nodes.contains(&6));
|
||||
}
|
||||
|
||||
// ── Disc-keyed lookups: array-order short-circuit ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn providers_lookup_by_hash_first_hit_wins() {
|
||||
// Querying providers in array order, the FIRST hit wins (closest /
|
||||
// fastest first). Provider 0 hits → its entry is returned even though
|
||||
// provider 1 also has one.
|
||||
let a = Fixed {
|
||||
hash_hit: Some(entry("first", 0x01)),
|
||||
..Default::default()
|
||||
};
|
||||
let b = Fixed {
|
||||
hash_hit: Some(entry("second", 0x02)),
|
||||
..Default::default()
|
||||
};
|
||||
let arr: &[&dyn KeyProvider] = &[&a, &b];
|
||||
let got = Providers(arr).lookup_disc_by_hash(&[0u8; 20]).unwrap();
|
||||
assert_eq!(got.disc_hash, "first");
|
||||
assert_eq!(got.vuk, Some([0x01u8; 16]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn providers_lookup_by_hash_falls_through_to_later_provider() {
|
||||
// Provider 0 misses, provider 1 hits → the later provider's entry is
|
||||
// used (find_map continues past None).
|
||||
let a = Fixed::default(); // hash_hit None
|
||||
let b = Fixed {
|
||||
hash_hit: Some(entry("second", 0x02)),
|
||||
..Default::default()
|
||||
};
|
||||
let arr: &[&dyn KeyProvider] = &[&a, &b];
|
||||
let got = Providers(arr).lookup_disc_by_hash(&[0u8; 20]).unwrap();
|
||||
assert_eq!(got.disc_hash, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn providers_lookup_by_vid_first_hit_wins() {
|
||||
let a = Fixed {
|
||||
vid_hit: Some(entry("vid-a", 0x07)),
|
||||
..Default::default()
|
||||
};
|
||||
let b = Fixed {
|
||||
vid_hit: Some(entry("vid-b", 0x08)),
|
||||
..Default::default()
|
||||
};
|
||||
let arr: &[&dyn KeyProvider] = &[&a, &b];
|
||||
let got = Providers(arr).lookup_disc_by_vid(&[0u8; 16]).unwrap();
|
||||
assert_eq!(got.disc_hash, "vid-a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn providers_empty_array_yields_nothing() {
|
||||
let arr: &[&dyn KeyProvider] = &[];
|
||||
let p = Providers(arr);
|
||||
assert!(p.device_keys().is_empty());
|
||||
assert!(p.processing_keys().is_empty());
|
||||
assert!(p.media_keys().is_empty());
|
||||
assert!(p.lookup_disc_by_hash(&[0u8; 20]).is_none());
|
||||
assert!(p.lookup_disc_by_vid(&[0u8; 16]).is_none());
|
||||
}
|
||||
|
||||
// ── SuppliedKey: each level exposes only its own material ──────────────
|
||||
|
||||
#[test]
|
||||
fn supplied_key_exposes_only_populated_fields() {
|
||||
// A SuppliedKey filled at the DK level exposes DKs and nothing else,
|
||||
// so the resolver runs the matching (DK→…) path and no other.
|
||||
let sk = SuppliedKey {
|
||||
device_keys: vec![dk(0x33, 9)],
|
||||
processing_keys: Vec::new(),
|
||||
media_keys: Vec::new(),
|
||||
disc_entry: None,
|
||||
};
|
||||
assert_eq!(sk.device_keys().len(), 1);
|
||||
assert!(sk.processing_keys().is_empty());
|
||||
assert!(sk.media_keys().is_empty());
|
||||
assert!(sk.lookup_disc_by_hash(&[0u8; 20]).is_none());
|
||||
assert!(sk.lookup_disc_by_vid(&[0u8; 16]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supplied_key_disc_entry_returned_for_any_hash_or_vid() {
|
||||
// decrypt_with already knows the disc, so a present disc_entry is
|
||||
// returned regardless of the hash/VID argument (the lookup args are
|
||||
// irrelevant in this bridge).
|
||||
let sk = SuppliedKey {
|
||||
device_keys: Vec::new(),
|
||||
processing_keys: Vec::new(),
|
||||
media_keys: Vec::new(),
|
||||
disc_entry: Some(entry("supplied", 0x44)),
|
||||
};
|
||||
// Two unrelated hashes both return the same entry.
|
||||
let h1 = sk.lookup_disc_by_hash(&[0x01u8; 20]).unwrap();
|
||||
let h2 = sk.lookup_disc_by_hash(&[0xFFu8; 20]).unwrap();
|
||||
assert_eq!(h1.disc_hash, "supplied");
|
||||
assert_eq!(h2.disc_hash, "supplied");
|
||||
// And by VID likewise.
|
||||
assert!(sk.lookup_disc_by_vid(&[0x00u8; 16]).is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Structured resolution trace — what the unlock + key-resolution attempt did.
|
||||
//!
|
||||
//! No user-facing English. Every step's STATE is a typed enum variant;
|
||||
//! applications RENDER these into localized text (the library never does). This
|
||||
//! module only DEFINES the shape and is wired through the resolve/handshake
|
||||
//! return path far enough to compile.
|
||||
//!
|
||||
//! The `who` of each step is the source's `label()` / unlocker's `name()` — a
|
||||
//! stable identifier string (a NAME, like a codec id, NOT user-facing prose),
|
||||
//! carried verbatim so an app renderer never has to match an enum back to a name
|
||||
//! it already has. Only the OUTCOME / path enums are structured states the app
|
||||
//! maps to i18n English.
|
||||
|
||||
/// The full trace of a resolution attempt: the unlock phase, then the
|
||||
/// key-resolution phase.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct ResolutionTrace {
|
||||
/// One step per unlocker consulted, in consultation order.
|
||||
pub unlock: Vec<UnlockStep>,
|
||||
/// One step per key source consulted, in consultation order.
|
||||
pub keys: Vec<KeyStep>,
|
||||
}
|
||||
|
||||
impl ResolutionTrace {
|
||||
/// An empty trace (no steps recorded).
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unlock phase ────────────────────────────────────────────────────────────
|
||||
|
||||
/// One unlocker's contribution to the unlock phase. `who` is the unlocker's
|
||||
/// `name()` (a stable identifier, e.g. `"LibreDrive"`), carried verbatim.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UnlockStep {
|
||||
pub who: String,
|
||||
pub outcome: UnlockOutcome,
|
||||
}
|
||||
|
||||
/// What an unlocker did.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UnlockOutcome {
|
||||
/// The drive was unlocked (or already usable) and a VID is available.
|
||||
Unlocked,
|
||||
/// This unlocker cannot unlock this drive's firmware.
|
||||
FirmwareNotUnlockable,
|
||||
/// No non-revoked host cert was usable for the auth attempt. `mkb` is the
|
||||
/// disc MKB generation when known.
|
||||
NoUsableHostCert { mkb: Option<u32> },
|
||||
/// Every available host cert was revoked on this drive's HRL. `mkb` is the
|
||||
/// disc MKB generation when known.
|
||||
CertRevoked { mkb: Option<u32> },
|
||||
/// The drive rejected the auth handshake (non-revocation rejection / wedge).
|
||||
HandshakeRejected,
|
||||
/// Auth succeeded (or was skipped) but the Volume ID could not be read.
|
||||
VidUnavailable,
|
||||
}
|
||||
|
||||
// ── Key-resolution phase ────────────────────────────────────────────────────
|
||||
|
||||
/// One key source's contribution to the key-resolution phase, including the
|
||||
/// derivation path it walked. `who` is the source's `label()` (a stable
|
||||
/// identifier, e.g. `"keydb"` / `"online"`), carried verbatim.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct KeyStep {
|
||||
pub who: String,
|
||||
pub path: Vec<KeyNode>,
|
||||
pub outcome: KeyOutcome,
|
||||
}
|
||||
|
||||
/// A node on the derivation path a source walked. Ordered as encountered; not
|
||||
/// every path hits every node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyNode {
|
||||
/// The source matched this disc (by hash / VID).
|
||||
MatchedDisc,
|
||||
/// The source had no entry for this disc.
|
||||
NoEntry,
|
||||
/// Pre-decrypted unit keys were found.
|
||||
FoundUnitKeys,
|
||||
/// A VUK was found.
|
||||
FoundVuk,
|
||||
/// A Media Key was found.
|
||||
FoundMediaKey,
|
||||
/// A VID is required to proceed.
|
||||
NeedVid,
|
||||
/// The VID came from the unlock phase.
|
||||
VidFromUnlock,
|
||||
/// The VID came from the keydb entry.
|
||||
VidFromKeydb,
|
||||
/// No VID was available.
|
||||
NoVid,
|
||||
/// A VUK was derived (from MK + VID).
|
||||
DerivedVuk,
|
||||
/// Unit keys were derived (from VUK).
|
||||
DerivedUnitKeys,
|
||||
}
|
||||
|
||||
/// The terminal outcome of a source's resolution attempt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyOutcome {
|
||||
/// Usable unit keys were produced.
|
||||
Resolved,
|
||||
/// Derivation material existed but no VID was available to finish.
|
||||
MissingVid,
|
||||
/// No usable key from this source.
|
||||
NoKey,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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,
|
||||
}],
|
||||
};
|
||||
// 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");
|
||||
// Default / new is empty.
|
||||
assert_eq!(ResolutionTrace::new(), ResolutionTrace::default());
|
||||
assert!(ResolutionTrace::new().unlock.is_empty());
|
||||
assert!(ResolutionTrace::new().keys.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! AACS primitive types shared across the resolve chain.
|
||||
//!
|
||||
//! These structs describe AACS key material (device keys, host
|
||||
//! certificates, per-disc entries). They carry no parsing logic — the
|
||||
//! keydb.cfg format lives in the `freemkv-keysources` crate. libfreemkv
|
||||
//! owns only the crypto and these value types that flow through it.
|
||||
|
||||
/// A device key for MKB subset-difference tree processing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeviceKey {
|
||||
pub key: [u8; 16],
|
||||
pub node: u16,
|
||||
pub uv: u32,
|
||||
pub u_mask_shift: u8,
|
||||
}
|
||||
|
||||
/// Host certificate + private key for AACS SCSI authentication.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostCert {
|
||||
/// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes.
|
||||
pub private_key: [u8; 20],
|
||||
/// AACS 1.0: 92 bytes. AACS 2.0: 132 bytes.
|
||||
pub certificate: Vec<u8>,
|
||||
/// AACS 2.0 host private key (P-256, 32 bytes). None for AACS 1.0 only.
|
||||
pub private_key_v2: Option<[u8; 32]>,
|
||||
/// AACS 2.0 host certificate (type 0x11). None for AACS 1.0 only.
|
||||
pub certificate_v2: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A per-disc entry from the key database.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscEntry {
|
||||
/// Disc hash (20 bytes, hex)
|
||||
pub disc_hash: String,
|
||||
/// Disc title
|
||||
pub title: String,
|
||||
/// Media Key (16 bytes) — from MKB processing
|
||||
pub media_key: Option<[u8; 16]>,
|
||||
/// Disc ID (16 bytes)
|
||||
pub disc_id: Option<[u8; 16]>,
|
||||
/// Volume Unique Key (16 bytes) — decrypts title keys
|
||||
pub vuk: Option<[u8; 16]>,
|
||||
/// Unit keys (title keys) indexed by CPS unit number
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
+396
-62
@@ -14,6 +14,19 @@
|
||||
//! a disc carries neither, callers should fall back to the classical
|
||||
//! single-stage derivation in [`super::keys`].
|
||||
//!
|
||||
//! **Status: the chain cannot yet produce a key on a real disc.** Two
|
||||
//! sub-fields are unfinished:
|
||||
//! - [`variants_for_uv`] (the `VARIANTS[uv]` lookup in the `0x83`
|
||||
//! record) is a stub that always returns `None`, so the chain
|
||||
//! short-circuits with [`MediaKeyVariantError::VariantsTableUnavailable`].
|
||||
//! - The Encrypted Media Key Variant Data (C) and the Variant Key
|
||||
//! Data (VKD) table are *distinct* sub-fields of the `0x82` record
|
||||
//! per AACS 2.1, but [`variant_data_record`] (C) and
|
||||
//! [`variant_key_data`] (VKD) both currently return the *whole*
|
||||
//! first `0x82` body — so on a single-`0x82` disc they alias. The
|
||||
//! `0x82` sub-field offsets must be fixed against a real Variant
|
||||
//! disc before this chain is wired into `resolve_keys`.
|
||||
//!
|
||||
//! The chain follows the published spec:
|
||||
//!
|
||||
//! ```text
|
||||
@@ -28,9 +41,22 @@
|
||||
//! Two condition bits on `Kmp[15]` route off the hardcoded-KCD path
|
||||
//! (Soft Correction and Online Challenge). The chain refuses to run in
|
||||
//! either case — callers must handle those modes out of band.
|
||||
//!
|
||||
//! # Status: Kp verification
|
||||
//!
|
||||
//! On the classical path [`walk_processing_key`] gates each match on
|
||||
//! the VERIFY_MAGIC relation, which authenticates the Processing Key.
|
||||
//! On a variant MKB that magic check does NOT hold (the walk yields a
|
||||
//! Media Key *Precursor*, not the Media Key), so the walk accepts a
|
||||
//! variant match without it. The replacement gate lives at the END of
|
||||
//! [`derive_media_key_variant`]: the derived final `Km` is verified
|
||||
//! against the MKB's Verify-Media-Key record before any `(Km, Kvu)` is
|
||||
//! returned. A future implementer wiring [`variants_for_uv`] must keep
|
||||
//! that final gate — the per-match magic check no longer protects the
|
||||
//! variant path.
|
||||
|
||||
use super::decrypt::aes_ecb_decrypt;
|
||||
use super::keydb::DeviceKey;
|
||||
use super::types::DeviceKey;
|
||||
|
||||
// ── Public constants ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -94,7 +120,13 @@ pub fn is_variant_mkb(records: &[MkbRecord]) -> bool {
|
||||
}
|
||||
|
||||
/// Body of the Encrypted Media Key Variant Data record (type `0x82`).
|
||||
pub fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||
///
|
||||
/// Returns the whole first `0x82` body; the internal C / VKD sub-field
|
||||
/// split is not yet decoded, so this aliases [`variant_key_data`] on a
|
||||
/// single-`0x82` disc. `pub(crate)` until the sub-field offsets are fixed
|
||||
/// against a real variant disc — it is not part of the public surface
|
||||
/// because it knowingly returns an undecoded composite.
|
||||
pub(crate) fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == 0x82)
|
||||
@@ -115,7 +147,11 @@ pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
|
||||
/// Body of the Variant Key Data record. Returns the first `0x82` body
|
||||
/// that is a non-empty multiple of 16 bytes.
|
||||
pub fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||
///
|
||||
/// Like [`variant_data_record`], this returns the whole `0x82` body and
|
||||
/// aliases it on a single-`0x82` disc; the C / VKD sub-field split is
|
||||
/// undecoded. `pub(crate)` until fixed against a real variant disc.
|
||||
pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == 0x82 && !r.body.is_empty() && r.body.len() % 16 == 0)
|
||||
@@ -141,56 +177,11 @@ fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
|
||||
|
||||
// ── Subset-difference walk that exposes (Kp, uv) ──────────────────────────
|
||||
|
||||
/// AES-G3 seed register initial value.
|
||||
const AESG3_SEED: [u8; 16] = [
|
||||
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
|
||||
];
|
||||
|
||||
/// AES-G3 single step: AES-G against the seed register at offset `inc`.
|
||||
fn aesg3_step(key: &[u8; 16], inc: u8) -> [u8; 16] {
|
||||
let mut seed = AESG3_SEED;
|
||||
seed[15] = seed[15].wrapping_add(inc);
|
||||
aes_g(key, &seed)
|
||||
}
|
||||
|
||||
fn calc_v_mask(uv: u32) -> u32 {
|
||||
let mut v_mask: u32 = 0xFFFF_FFFF;
|
||||
while (uv & !v_mask) == 0 && v_mask != 0 {
|
||||
v_mask <<= 1;
|
||||
}
|
||||
v_mask
|
||||
}
|
||||
|
||||
fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] {
|
||||
let mut left_child = aesg3_step(dk, 0);
|
||||
let mut pk = aesg3_step(dk, 1);
|
||||
let mut right_child = aesg3_step(dk, 2);
|
||||
let mut current_v_mask = dev_key_v_mask;
|
||||
|
||||
while current_v_mask != v_mask {
|
||||
let mut bit_pos: i32 = -1;
|
||||
for i in (0..32).rev() {
|
||||
if (current_v_mask & (1u32 << i)) == 0 {
|
||||
bit_pos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let curr_key = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
|
||||
left_child
|
||||
} else {
|
||||
right_child
|
||||
};
|
||||
|
||||
left_child = aesg3_step(&curr_key, 0);
|
||||
pk = aesg3_step(&curr_key, 1);
|
||||
right_child = aesg3_step(&curr_key, 2);
|
||||
|
||||
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
|
||||
}
|
||||
|
||||
pk
|
||||
}
|
||||
// `calc_v_mask` and `calc_pk_from_dk` (and the AES-G3 seed step they ride
|
||||
// on) are shared with the classical walk in [`super::keys`] — a single
|
||||
// definition keeps the variant SD tree byte-identical to the classical one.
|
||||
// (`aesg3` itself is imported separately in the test module.)
|
||||
use super::keys::{calc_pk_from_dk, calc_v_mask};
|
||||
|
||||
/// Outcome of a subset-difference walk against an MKB. Carries the
|
||||
/// processing key and the matching `uv` slot — both needed as inputs
|
||||
@@ -225,6 +216,27 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
|
||||
/// Walk an MKB and return the first `(Kp, uv, cvalue)` that
|
||||
/// `device_keys` covers. Returns `None` if no DK walks any uv.
|
||||
///
|
||||
/// This is the AACS-2.1 **variant** walk; the classical walk lives in
|
||||
/// [`super::keys::derive_media_key_and_pk_from_dk`]. The two are kept
|
||||
/// separate on purpose and select MKB records in DELIBERATELY different
|
||||
/// order:
|
||||
///
|
||||
/// - cvalues: this variant walk tries record `0x07`-then-`0x05`; the
|
||||
/// classical walk tries `0x05`-then-`0x07`. On a variant MKB the
|
||||
/// small `0x07` Explicit-Subset-Difference record carries the
|
||||
/// cvalue the Precursor chain consumes, whereas a classical UHD MKB
|
||||
/// keeps its 1:1 cvalue table in the large `0x05` record (see the
|
||||
/// note on [`super::keys::probe::mkb_cvalues`]). They must NOT be
|
||||
/// unified to one order — each is correct for its own MKB shape.
|
||||
/// - finders: this walk operates on parsed [`MkbRecord`]s (needed
|
||||
/// because the variant chain also reads `0x82`/`0x83`); the
|
||||
/// classical walk operates on raw MKB bytes. Same framing, different
|
||||
/// input type.
|
||||
///
|
||||
/// Consequence: do NOT route the classical DK path through this function
|
||||
/// — on a classical MKB the `0x07`-first selection picks the wrong (or
|
||||
/// missing) cvalue and the magic check fails, so it returns `None`.
|
||||
pub fn walk_processing_key(
|
||||
records: &[MkbRecord],
|
||||
device_keys: &[DeviceKey],
|
||||
@@ -243,11 +255,23 @@ pub fn walk_processing_key(
|
||||
|
||||
for uvs_idx in 0..num_uvs {
|
||||
let p_uv = &uvs[1 + 5 * uvs_idx..];
|
||||
// `num_uvs` was computed by `take_while(.. (c[0] & 0xC0) == 0)`, so
|
||||
// every chunk in `0..num_uvs` already has its revoked-marker bits
|
||||
// clear — that `take_while` is the single authoritative place the
|
||||
// parse stops, no inner re-check needed.
|
||||
let u_mask_shift = uvs[5 * uvs_idx];
|
||||
|
||||
if u_mask_shift & 0xC0 != 0 {
|
||||
break;
|
||||
}
|
||||
// 0x20..=0x3F (32..=63) pass the 0xC0 revoked-marker check but are
|
||||
// out of range for a u32 shift. `wrapping_shl` would silently
|
||||
// compute shift % 32 (e.g. 32 → no shift → 0xFFFF_FFFF), matching a
|
||||
// wrong uv slot and deriving a wrong key. Disc-controlled byte:
|
||||
// skip the slot instead.
|
||||
if u_mask_shift >= 32 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
|
||||
if uv == 0 {
|
||||
@@ -260,6 +284,11 @@ pub fn walk_processing_key(
|
||||
if ((device_number & u_mask) == (uv & u_mask))
|
||||
&& ((device_number & v_mask) != (uv & v_mask))
|
||||
{
|
||||
// dk.u_mask_shift is a u8 from keydb with no range check; guard
|
||||
// it the same way before the wrapping_shl below.
|
||||
if dk.u_mask_shift >= 32 {
|
||||
continue;
|
||||
}
|
||||
let dev_key_v_mask = calc_v_mask(dk.uv);
|
||||
let dev_key_u_mask: u32 = 0xFFFF_FFFFu32.wrapping_shl(dk.u_mask_shift as u32);
|
||||
|
||||
@@ -334,6 +363,10 @@ pub enum MediaKeyVariantError {
|
||||
VariantsTableUnavailable,
|
||||
/// VKD index resolved out of the supplied `vkd_table`.
|
||||
VkdIndexOutOfRange,
|
||||
/// The derived Media Key failed the MKB's Verify-Media-Key relation.
|
||||
/// On the variant path this final gate replaces the per-match magic
|
||||
/// check (which does not hold for a Precursor).
|
||||
MediaKeyVerifyFailed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MediaKeyVariantError {
|
||||
@@ -347,6 +380,7 @@ impl std::fmt::Display for MediaKeyVariantError {
|
||||
MediaKeyVariantError::KcdNotProvided => 7105,
|
||||
MediaKeyVariantError::VariantsTableUnavailable => 7106,
|
||||
MediaKeyVariantError::VkdIndexOutOfRange => 7107,
|
||||
MediaKeyVariantError::MediaKeyVerifyFailed => 7108,
|
||||
};
|
||||
write!(f, "E{code}")
|
||||
}
|
||||
@@ -356,11 +390,15 @@ impl std::error::Error for MediaKeyVariantError {}
|
||||
|
||||
// ── Chain ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Look up `VARIANTS[uv]` for the matched uv. The byte layout of the
|
||||
/// per-uv slot in the Variant Number record is undocumented and is
|
||||
/// disc-specific; this helper returns `None` until a Variant disc is
|
||||
/// available to fix the layout against.
|
||||
fn variants_for_uv(_records: &[MkbRecord], _uv_index: usize) -> Option<u16> {
|
||||
/// Look up the per-slot `VARIANTS` value for the matched subset-difference
|
||||
/// slot. AACS 2.1 keys the VARIANTS table by the matched SD slot (the same
|
||||
/// index that selected the cvalue), so the caller passes
|
||||
/// [`ProcessingKeyMatch::cvalue_index`]. The byte layout of the per-slot entry
|
||||
/// in the Variant Number record is undocumented and disc-specific; this helper
|
||||
/// returns `None` until a Variant disc is available to fix the layout against.
|
||||
///
|
||||
/// `sd_slot_index` is the matched subset-difference slot (== cvalue index).
|
||||
fn variants_for_uv(_records: &[MkbRecord], _sd_slot_index: usize) -> Option<u16> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -377,6 +415,12 @@ fn variants_for_uv(_records: &[MkbRecord], _uv_index: usize) -> Option<u16> {
|
||||
/// the final VUK alongside the Media Key.
|
||||
///
|
||||
/// Returns `(Km, Kvu)` on success.
|
||||
///
|
||||
/// NOTE: the `VARIANTS[uv]` lookup ([`variants_for_uv`]) is not yet
|
||||
/// implemented, so on a real Variant disc this always returns
|
||||
/// `Err(`[`MediaKeyVariantError::VariantsTableUnavailable`]`)` before a
|
||||
/// key is produced. The chain can only succeed against synthetic test
|
||||
/// fixtures today.
|
||||
pub fn derive_media_key_variant(
|
||||
mkb_records: &[MkbRecord],
|
||||
device_keys: &[DeviceKey],
|
||||
@@ -446,6 +490,17 @@ pub fn derive_media_key_variant(
|
||||
km[12 + i] ^= uv_bytes[i];
|
||||
}
|
||||
|
||||
// Gate: verify the derived Media Key against the MKB's Verify-Media-Key
|
||||
// record. On the variant path the per-match magic check in
|
||||
// `walk_processing_key` does NOT hold (it only saw the Precursor), so this
|
||||
// is the authoritative Kp/Km verification — it MUST run before returning a
|
||||
// real key.
|
||||
let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||
if aes_ecb_decrypt(&km, &mk_dv)[..8] != VERIFY_MAGIC {
|
||||
return Err(MediaKeyVariantError::MediaKeyVerifyFailed);
|
||||
}
|
||||
|
||||
// Step: Kvu = AES-G(Km, VID).
|
||||
let kvu = aes_g(&km, vid);
|
||||
|
||||
@@ -455,6 +510,23 @@ pub fn derive_media_key_variant(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// These three live in `super::keys` now (consolidated SD-walk helpers);
|
||||
// `use super::*` does not re-export the parent module's private `use`
|
||||
// imports, so pull them in directly for the tests below.
|
||||
use super::super::keys::{aesg3, calc_pk_from_dk};
|
||||
|
||||
#[test]
|
||||
fn calc_pk_from_dk_terminates_on_nonconvergent_mask() {
|
||||
// Regression for the unbounded-loop hang: pick a (dev_key_v_mask,
|
||||
// v_mask) pair the arithmetic `>> 1` walk can never reconcile.
|
||||
// dev_key_v_mask has the MSB set, so `>> 1` sign-extends and the
|
||||
// mask saturates at 0xFFFF_FFFF, never reaching a coarser v_mask.
|
||||
// The 32-step bound must let this return rather than spin forever.
|
||||
let dk = [0x11u8; 16];
|
||||
let pk = calc_pk_from_dk(&dk, 0x0000_0002, 0x0000_0000, 0xFFFF_FFFE);
|
||||
// Bounded exit yields *some* key; we only assert it terminated.
|
||||
let _ = pk;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
@@ -575,6 +647,7 @@ mod tests {
|
||||
MediaKeyVariantError::KcdNotProvided,
|
||||
MediaKeyVariantError::VariantsTableUnavailable,
|
||||
MediaKeyVariantError::VkdIndexOutOfRange,
|
||||
MediaKeyVariantError::MediaKeyVerifyFailed,
|
||||
];
|
||||
for e in cases {
|
||||
let s = e.to_string();
|
||||
@@ -605,7 +678,7 @@ mod tests {
|
||||
/// agreeing with uv on bits 3+ (the u_mask=1 region). dk.uv ==
|
||||
/// MKB.uv and dk.u_mask_shift == MKB.u_mask_shift make
|
||||
/// `dev_key_v_mask == v_mask`, so `calc_pk_from_dk` loops zero
|
||||
/// times — Kp = aesg3_step(dk, 1).
|
||||
/// times — Kp = aesg3(dk, 1).
|
||||
/// - one cvalue in record 0x07 chosen so AES-D(Kp, C) ⊕ uv produces a
|
||||
/// Kmp whose byte-15 is exactly `kmp15`.
|
||||
/// - record 0x82 with a 16-byte body (acts as both Variant Data
|
||||
@@ -626,14 +699,14 @@ mod tests {
|
||||
mkb.extend_from_slice(&[0x03, 0x00, 0x00, 0x00, 0x02]);
|
||||
|
||||
// Pick a known DK; with dk.uv == MKB.uv (==2) and
|
||||
// dk.u_mask_shift == MKB.u_mask_shift (==1), dev_key_v_mask
|
||||
// dk.u_mask_shift == MKB.u_mask_shift (==3), dev_key_v_mask
|
||||
// equals the MKB's v_mask and the calc_pk_from_dk loop is a
|
||||
// no-op — Kp = aesg3_step(dk, 1).
|
||||
// no-op — Kp = aesg3(dk, 1).
|
||||
let dk_bytes: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
let kp = aesg3_step(&dk_bytes, 1);
|
||||
let kp = aesg3(&dk_bytes, 1);
|
||||
|
||||
// Plant Kmp with chosen byte-15, then compute C such that
|
||||
// AES-D(Kp, C) ⊕ uv == Kmp. uv=2 → low-4 bytes XOR is 00 00 00 02.
|
||||
@@ -676,4 +749,265 @@ mod tests {
|
||||
};
|
||||
(recs, dk, kp, kmp)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Hardening additions
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ── walk_mkb framing: BE24 length incl. header, end markers ────────────
|
||||
|
||||
#[test]
|
||||
fn walk_mkb_reports_offsets_and_be24_lengths() {
|
||||
// Two records; the walker must report each record's byte offset and
|
||||
// its full length (header + body). rec_len is the 3-byte BE field at
|
||||
// bytes 1..4, and INCLUDES the 4-byte header.
|
||||
let mut mkb = vec![0x10, 0x00, 0x00, 0x06, 0xAA, 0xBB]; // len 6 (2-byte body)
|
||||
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 1, 2, 3, 4]); // len 8
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 2);
|
||||
assert_eq!(recs[0].offset, 0);
|
||||
assert_eq!(recs[0].rec_len, 6);
|
||||
assert_eq!(recs[0].body, vec![0xAA, 0xBB]);
|
||||
assert_eq!(recs[1].offset, 6);
|
||||
assert_eq!(recs[1].rec_len, 8);
|
||||
assert_eq!(recs[1].body, vec![1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_mkb_be24_high_byte_is_honored() {
|
||||
// A record longer than 255 bytes needs the high BE24 byte. Build a
|
||||
// 0x10 record of total length 0x000110 (272) and confirm the body is
|
||||
// 268 bytes (a parser that read only the low byte would see len 0x10).
|
||||
let total = 0x0110usize; // 272
|
||||
let mut mkb = vec![0x10, 0x00, 0x01, 0x10];
|
||||
mkb.resize(total, 0xAB);
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 1);
|
||||
assert_eq!(recs[0].rec_len, total);
|
||||
assert_eq!(recs[0].body.len(), total - 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_mkb_stops_at_type0_len0_end_marker() {
|
||||
// A (type=0, len=0) record ends the walk; trailing bytes after it are
|
||||
// not parsed.
|
||||
let mut mkb = vec![0x10, 0x00, 0x00, 0x06, 0xAA, 0xBB];
|
||||
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // end marker
|
||||
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 9, 9, 9, 9]); // ignored
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 1);
|
||||
assert_eq!(recs[0].rec_type, 0x10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_mkb_stops_on_overrun_record() {
|
||||
// rec_len running past the buffer ends the walk after the records that
|
||||
// fit (no OOB, no partial body past the end).
|
||||
let mut mkb = vec![0x10, 0x00, 0x00, 0x06, 0xAA, 0xBB];
|
||||
mkb.extend_from_slice(&[0x05, 0x00, 0xFF, 0xFF]); // claims 65535 bytes
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 1, "overrun record must be dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_mkb_stops_on_sub_4_length() {
|
||||
// A non-zero type with rec_len < 4 (and not the 0/0 marker) breaks the
|
||||
// walk — otherwise pos would not advance (infinite loop guard).
|
||||
let mkb = vec![0x10, 0x00, 0x00, 0x02, 0xAA];
|
||||
assert!(walk_mkb(&mkb).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_mkb_handles_trailing_partial_header() {
|
||||
// Fewer than 4 bytes left → loop condition `pos + 4 <= len` stops.
|
||||
let mkb = vec![0x10, 0x00, 0x00, 0x06, 0xAA, 0xBB, 0x05, 0x00]; // 2 trailing
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 1);
|
||||
}
|
||||
|
||||
// ── Record selectors ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn is_variant_mkb_true_for_0x82_alone_and_0x83_alone() {
|
||||
// Either record type alone flags the MKB as variant.
|
||||
let only82 = walk_mkb(&{
|
||||
let mut m = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 0];
|
||||
m.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
|
||||
m.extend_from_slice(&[0xEE; 16]);
|
||||
m
|
||||
});
|
||||
assert!(is_variant_mkb(&only82));
|
||||
let only83 = walk_mkb(&{
|
||||
let mut m = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 0];
|
||||
m.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
|
||||
m.extend_from_slice(&[0x55; 16]);
|
||||
m
|
||||
});
|
||||
assert!(is_variant_mkb(&only83));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_nonce_requires_16_byte_body() {
|
||||
// A 0x83 record with < 16-byte body → None (no panic on the copy).
|
||||
let recs = walk_mkb(&{
|
||||
let mut m = vec![0x83, 0x00, 0x00, 0x0C]; // 8-byte body
|
||||
m.extend_from_slice(&[0x11; 8]);
|
||||
m
|
||||
});
|
||||
assert_eq!(variant_nonce(&recs), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_key_data_requires_nonempty_multiple_of_16() {
|
||||
// A 0x82 body that is NOT a multiple of 16 is rejected by
|
||||
// variant_key_data (it needs whole 16-byte VKD slots).
|
||||
let recs = walk_mkb(&{
|
||||
let mut m = vec![0x82, 0x00, 0x00, 0x0E]; // 10-byte body (not %16)
|
||||
m.extend_from_slice(&[0x22; 10]);
|
||||
m
|
||||
});
|
||||
assert_eq!(variant_key_data(&recs), None);
|
||||
// variant_data_record returns the body regardless of length.
|
||||
assert_eq!(variant_data_record(&recs), Some(&[0x22u8; 10][..]));
|
||||
}
|
||||
|
||||
// ── derive_media_key_variant: missing-record classification ────────────
|
||||
|
||||
#[test]
|
||||
fn chain_reports_processing_key_unavailable_with_no_dks() {
|
||||
// A complete variant MKB but an empty device-key pool → no uv covered
|
||||
// → ProcessingKeyUnavailable (the walk_processing_key None branch).
|
||||
let (recs, _dk, _, _) = synthetic_variant_setup(0x00);
|
||||
let err = derive_media_key_variant(&recs, &[], &[0xAA; 16], &[0u8; 16])
|
||||
.expect_err("no DK → ProcessingKeyUnavailable");
|
||||
assert_eq!(err, MediaKeyVariantError::ProcessingKeyUnavailable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_reports_mkb_incomplete_when_nonce_missing() {
|
||||
// Build a variant MKB (has 0x82 so is_variant true, and a DK can walk
|
||||
// it) but WITHOUT a 0x83 nonce record → MkbIncomplete at the
|
||||
// variant_nonce `?`.
|
||||
// Start from the full setup, then rebuild the byte stream dropping
|
||||
// the 0x83 record.
|
||||
let (recs, dk, _, _) = synthetic_variant_setup(0x00);
|
||||
// Reconstruct bytes without the 0x83 record.
|
||||
let mut mkb = Vec::new();
|
||||
for r in &recs {
|
||||
if r.rec_type == 0x83 {
|
||||
continue;
|
||||
}
|
||||
mkb.push(r.rec_type);
|
||||
mkb.push(((r.rec_len >> 16) & 0xFF) as u8);
|
||||
mkb.push(((r.rec_len >> 8) & 0xFF) as u8);
|
||||
mkb.push((r.rec_len & 0xFF) as u8);
|
||||
mkb.extend_from_slice(&r.body);
|
||||
}
|
||||
let recs2 = walk_mkb(&mkb);
|
||||
assert!(is_variant_mkb(&recs2), "still variant via 0x82");
|
||||
let err = derive_media_key_variant(&recs2, &[dk], &[0xAA; 16], &[0u8; 16])
|
||||
.expect_err("missing nonce → MkbIncomplete");
|
||||
assert_eq!(err, MediaKeyVariantError::MkbIncomplete);
|
||||
}
|
||||
|
||||
// ── walk_processing_key: skips out-of-range u_mask_shift ───────────────
|
||||
|
||||
#[test]
|
||||
fn walk_processing_key_skips_shift_32_to_63_without_panic() {
|
||||
// A subset-difference u_mask_shift in 0x20..=0x3F passes the 0xC0
|
||||
// revoke check but is out of range for a u32 shift. The walk must skip
|
||||
// the slot (continue) and not panic / not match a wrong uv. With only
|
||||
// that one bad slot, no match → None.
|
||||
let mut mkb = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
|
||||
];
|
||||
// 0x04: u_mask_shift=0x20 (32), uv=2.
|
||||
mkb.extend_from_slice(&[0x04, 0x00, 0x00, 0x09]);
|
||||
mkb.extend_from_slice(&[0x20, 0x00, 0x00, 0x00, 0x02]);
|
||||
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xCD; 16]);
|
||||
let recs = walk_mkb(&mkb);
|
||||
let dk = DeviceKey {
|
||||
key: [0x11; 16],
|
||||
node: 4,
|
||||
uv: 2,
|
||||
u_mask_shift: 3,
|
||||
};
|
||||
assert!(
|
||||
walk_processing_key(&recs, &[dk]).is_none(),
|
||||
"out-of-range shift must be skipped, yielding no match"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_processing_key_skips_uv_zero() {
|
||||
// A uv == 0 slot is skipped (`if uv == 0 { continue }`). With only a
|
||||
// zero-uv slot present, no DK can match → None.
|
||||
let mut mkb = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
|
||||
];
|
||||
mkb.extend_from_slice(&[0x04, 0x00, 0x00, 0x09]);
|
||||
mkb.extend_from_slice(&[0x03, 0x00, 0x00, 0x00, 0x00]); // uv = 0
|
||||
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xCD; 16]);
|
||||
let recs = walk_mkb(&mkb);
|
||||
let dk = DeviceKey {
|
||||
key: [0x11; 16],
|
||||
node: 4,
|
||||
uv: 2,
|
||||
u_mask_shift: 3,
|
||||
};
|
||||
assert!(walk_processing_key(&recs, &[dk]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_processing_key_returns_match_on_variant_mkb_without_magic() {
|
||||
// On a variant MKB the per-match VERIFY_MAGIC check does not hold, but
|
||||
// the walk still returns the (Kp, uv) match because variant_present is
|
||||
// true. The synthetic_variant_setup fixture is exactly this case.
|
||||
let (recs, dk, planted_kp, _) = synthetic_variant_setup(0x00);
|
||||
let m = walk_processing_key(&recs, &[dk]).expect("variant MKB yields a match");
|
||||
assert_eq!(m.uv, 2, "matched the planted uv");
|
||||
assert_eq!(m.kp, planted_kp, "Kp equals aesg3(dk,1) for the no-op walk");
|
||||
assert_eq!(m.cvalue_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aes_g_matches_decrypt_xor_relation() {
|
||||
// AES-G(x1,x2) = AES-128D(x1,x2) XOR x2 — the same form as derive_vuk.
|
||||
// Pin it explicitly so a dropped XOR or an encrypt-instead-of-decrypt
|
||||
// is caught.
|
||||
let x1 = [0x31u8; 16];
|
||||
let x2 = [0x9Fu8; 16];
|
||||
let mut expected = aes_ecb_decrypt(&x1, &x2);
|
||||
for i in 0..16 {
|
||||
expected[i] ^= x2[i];
|
||||
}
|
||||
assert_eq!(aes_g(&x1, &x2), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_codes_are_unique_and_in_7100_range() {
|
||||
// Each MediaKeyVariantError maps to a distinct E71xx code. A
|
||||
// copy-paste collision (two variants sharing a code) would break
|
||||
// operator triage; assert all nine are distinct.
|
||||
use std::collections::HashSet;
|
||||
let cases = [
|
||||
MediaKeyVariantError::NotVariantMkb,
|
||||
MediaKeyVariantError::MkbIncomplete,
|
||||
MediaKeyVariantError::ProcessingKeyUnavailable,
|
||||
MediaKeyVariantError::SoftCorrectionRequired,
|
||||
MediaKeyVariantError::OnlineChallengeRequired,
|
||||
MediaKeyVariantError::KcdNotProvided,
|
||||
MediaKeyVariantError::VariantsTableUnavailable,
|
||||
MediaKeyVariantError::VkdIndexOutOfRange,
|
||||
MediaKeyVariantError::MediaKeyVerifyFailed,
|
||||
];
|
||||
let codes: HashSet<String> = cases.iter().map(|e| e.to_string()).collect();
|
||||
assert_eq!(codes.len(), cases.len(), "all error codes must be unique");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
//! AACS Verify-Media-Key magic constants used to confirm Media Key
|
||||
//! candidates produced during MKB walking.
|
||||
//!
|
||||
//! AACS MKBs contain "Verify Media Key Records" whose decrypted output
|
||||
//! is a known-plaintext constant. Walking code decrypts the verify
|
||||
//! record with each MK candidate and compares the result against the
|
||||
//! magic; on match, the MK is correct.
|
||||
//!
|
||||
//! Five distinct magics are observed in the canonical reference AACS
|
||||
//! engine (MakeMKV v1.18.3, file offsets in parens):
|
||||
//!
|
||||
//! 1. **MK\_V10** at `.rodata:0x2909c0`. The original AACS-1.0 spec
|
||||
//! constant. Single 16-byte AES-128-ECB compare. Used at 3 sites in
|
||||
//! that engine. We already use it in `keys.rs::validate_media_key_against_mkb`.
|
||||
//!
|
||||
//! 2. **MK\_AUX\_16** at `.rodata:0x290890`. A second single-block
|
||||
//! 16-byte verification magic. Reverse-engineering of the call site
|
||||
//! at `0x580f73` shows it after a call to the single-block AES-ECB
|
||||
//! helper. Likely a per-vendor or per-record-type extended verify.
|
||||
//! Use it when an MKB carries an extended verify record alongside
|
||||
//! the standard one.
|
||||
//!
|
||||
//! 3. **MK\_SK\_32a** = `MK_SK32A_BLK0` || `MK_SK32A_BLK1`. A 32-byte
|
||||
//! (2-block) verify magic at `.rodata:0x290910 / 0x290620`. Used at
|
||||
//! `0x580ff0`: both blocks must match after AES-128 decrypt of a
|
||||
//! 32-byte verify record. Almost certainly the AACS-2 / Sequence
|
||||
//! Key Block "Verify Media Key Record for Sequence Keys" expanded
|
||||
//! form — i.e. AACS-2 SKB verification.
|
||||
//!
|
||||
//! 4. **MK\_SK\_32b** = `MK_SK32B_BLK0` || `MK_SK32B_BLK1`. A second
|
||||
//! 32-byte verify magic at `.rodata:0x290980 / 0x290a60`. Used at
|
||||
//! `0x581063`. Different record type within the SKB family — likely
|
||||
//! the AACS-2 SD-tree variant verification.
|
||||
//!
|
||||
//! All five are KNOWN PLAINTEXT compared bit-for-bit against the
|
||||
//! AES-128 decrypt output. They are NOT keys. They are oracle values
|
||||
//! that say "yes, the MK candidate you tried is the right one."
|
||||
//!
|
||||
//! Provenance: identified via static RE of MakeMKV v1.18.3 amd64
|
||||
//! (binary sha256 `9970a50a97231b2d09d73f521ff1daf0609ea201040a68ecaa9f31af957d6401`)
|
||||
//! on 2026-05-22 via objdump of the `pcmpeqb` callsite cluster around
|
||||
//! file offset `0x580f70..0x581080`.
|
||||
|
||||
/// AACS-1.0 / pre-existing canonical Verify Media Key magic.
|
||||
///
|
||||
/// `AES-128-ECB-DECRYPT(MK, verify_record) == [VERIFY_MK_V10 || pad]`
|
||||
pub const VERIFY_MK_V10: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||
|
||||
/// Single-block 16-byte verify magic (auxiliary). Compared full-16
|
||||
/// after AES-128-ECB(MK, in) at `pcmpeqb` site `0x580f73`.
|
||||
pub const VERIFY_MK_AUX_16: [u8; 16] = [
|
||||
0xf9, 0x91, 0xa3, 0x60, 0x68, 0x15, 0xa6, 0xb9, 0x55, 0xbb, 0xce, 0xa3, 0xb1, 0x4b, 0xf8, 0xd8,
|
||||
];
|
||||
|
||||
/// 32-byte SKB-style verify magic, block 0 of 2. Compared full-16
|
||||
/// after AES-128 decrypt of the first 16 bytes of a 32-byte verify
|
||||
/// record. `pcmpeqb` site `0x580ff0`.
|
||||
pub const VERIFY_MK_SK_32A_BLK0: [u8; 16] = [
|
||||
0x19, 0x0f, 0xe9, 0x7f, 0xad, 0x11, 0xa4, 0x10, 0xc6, 0x56, 0x9d, 0x1c, 0x84, 0x21, 0x1d, 0x18,
|
||||
];
|
||||
|
||||
/// 32-byte SKB-style verify magic, block 1 of 2. Compared full-16
|
||||
/// after AES-128 decrypt of bytes 16..32 of the same record.
|
||||
/// `pcmpeqb` site `0x580fe8`.
|
||||
pub const VERIFY_MK_SK_32A_BLK1: [u8; 16] = [
|
||||
0x9b, 0x54, 0x9a, 0x25, 0x69, 0x8a, 0xa2, 0x3f, 0x9d, 0xfd, 0x2c, 0x95, 0xe2, 0x4a, 0x97, 0x02,
|
||||
];
|
||||
|
||||
/// 32-byte SKB-style verify magic (variant B), block 0 of 2.
|
||||
/// `pcmpeqb` site `0x581063`.
|
||||
pub const VERIFY_MK_SK_32B_BLK0: [u8; 16] = [
|
||||
0x8d, 0xee, 0xe0, 0x1e, 0xc7, 0x0c, 0xea, 0xb3, 0xdb, 0xd2, 0xfb, 0x82, 0x16, 0x3c, 0x26, 0x80,
|
||||
];
|
||||
|
||||
/// 32-byte SKB-style verify magic (variant B), block 1 of 2.
|
||||
/// `pcmpeqb` site `0x58105b`.
|
||||
pub const VERIFY_MK_SK_32B_BLK1: [u8; 16] = [
|
||||
0xaf, 0x93, 0x7a, 0x74, 0x8a, 0xce, 0xd3, 0x69, 0x36, 0x84, 0xe6, 0xea, 0xf8, 0x54, 0xe8, 0xa2,
|
||||
];
|
||||
|
||||
/// Tag for a candidate-Media-Key check. Tells the verifier which
|
||||
/// known-plaintext to compare against; the verifier chooses the
|
||||
/// magic that matches the MKB record type at hand.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VerifyMagic {
|
||||
/// AACS-1.0 / canonical.
|
||||
V10,
|
||||
/// Auxiliary single-block (16-byte) verification.
|
||||
Aux16,
|
||||
/// SKB-style 32-byte verification, variant A.
|
||||
Sk32A,
|
||||
/// SKB-style 32-byte verification, variant B.
|
||||
Sk32B,
|
||||
}
|
||||
|
||||
/// Verify a candidate Media Key against a `dec_vd` (AES-128 decrypt
|
||||
/// of the MKB Verify Media Key Record under the candidate MK).
|
||||
///
|
||||
/// Returns `true` if `dec_vd` matches the magic identified by `tag`.
|
||||
///
|
||||
/// - `V10`: compares the first 8 bytes against `VERIFY_MK_V10`.
|
||||
/// - `Aux16`: compares the full 16 bytes against `VERIFY_MK_AUX_16`.
|
||||
/// - `Sk32A` / `Sk32B`: `dec_vd` must be exactly 32 bytes (`block0 ||
|
||||
/// block1`); compares each block against the corresponding constant.
|
||||
pub fn check_verify(tag: VerifyMagic, dec_vd: &[u8]) -> bool {
|
||||
match tag {
|
||||
VerifyMagic::V10 => dec_vd.len() >= 8 && dec_vd[..8] == VERIFY_MK_V10,
|
||||
VerifyMagic::Aux16 => dec_vd.len() >= 16 && dec_vd[..16] == VERIFY_MK_AUX_16,
|
||||
VerifyMagic::Sk32A => {
|
||||
dec_vd.len() >= 32
|
||||
&& dec_vd[..16] == VERIFY_MK_SK_32A_BLK0
|
||||
&& dec_vd[16..32] == VERIFY_MK_SK_32A_BLK1
|
||||
}
|
||||
VerifyMagic::Sk32B => {
|
||||
dec_vd.len() >= 32
|
||||
&& dec_vd[..16] == VERIFY_MK_SK_32B_BLK0
|
||||
&& dec_vd[16..32] == VERIFY_MK_SK_32B_BLK1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn v10_matches_canonical_prefix() {
|
||||
let mut dec = [0u8; 16];
|
||||
dec[..8].copy_from_slice(&VERIFY_MK_V10);
|
||||
assert!(check_verify(VerifyMagic::V10, &dec));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aux16_matches_full_block() {
|
||||
assert!(check_verify(VerifyMagic::Aux16, &VERIFY_MK_AUX_16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sk32a_requires_both_blocks() {
|
||||
let mut dec = [0u8; 32];
|
||||
dec[..16].copy_from_slice(&VERIFY_MK_SK_32A_BLK0);
|
||||
dec[16..].copy_from_slice(&VERIFY_MK_SK_32A_BLK1);
|
||||
assert!(check_verify(VerifyMagic::Sk32A, &dec));
|
||||
|
||||
// Mutate block 1, must fail.
|
||||
dec[20] ^= 0x80;
|
||||
assert!(!check_verify(VerifyMagic::Sk32A, &dec));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sk32b_distinct_from_sk32a() {
|
||||
let mut dec = [0u8; 32];
|
||||
dec[..16].copy_from_slice(&VERIFY_MK_SK_32B_BLK0);
|
||||
dec[16..].copy_from_slice(&VERIFY_MK_SK_32B_BLK1);
|
||||
assert!(check_verify(VerifyMagic::Sk32B, &dec));
|
||||
// Same plaintext must NOT validate as Sk32A.
|
||||
assert!(!check_verify(VerifyMagic::Sk32A, &dec));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_input_never_matches() {
|
||||
let dec = [0u8; 4];
|
||||
for tag in [
|
||||
VerifyMagic::V10,
|
||||
VerifyMagic::Aux16,
|
||||
VerifyMagic::Sk32A,
|
||||
VerifyMagic::Sk32B,
|
||||
] {
|
||||
assert!(!check_verify(tag, &dec));
|
||||
}
|
||||
}
|
||||
}
|
||||
+601
-31
@@ -6,22 +6,28 @@
|
||||
//!
|
||||
//! Reference: https://github.com/lw/BluRay/wiki/CLPI
|
||||
|
||||
use crate::consts::{BD_SOURCE_PACKET_BYTES, SECTOR_BYTES};
|
||||
use crate::disc::Extent;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Parsed CLPI clip info.
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ClipInfo {
|
||||
pub(crate) struct ClipInfo {
|
||||
/// CLPI version string. Parsed for completeness; not yet consumed.
|
||||
#[allow(dead_code)]
|
||||
pub version: String,
|
||||
/// Total source packets in the m2ts (each 192 bytes)
|
||||
pub source_packet_count: u32,
|
||||
/// Coarse EP entries for the primary video stream
|
||||
/// Coarse EP entries for the primary video stream. Populated for the
|
||||
/// EP-map → sector-extent lookup (`get_extents`), which is exercised by
|
||||
/// tests and reserved for the timestamp-range read path.
|
||||
#[allow(dead_code)]
|
||||
pub ep_coarse: Vec<EpCoarse>,
|
||||
/// Fine EP entries for the primary video stream
|
||||
/// Fine EP entries for the primary video stream (see `ep_coarse`).
|
||||
#[allow(dead_code)]
|
||||
pub ep_fine: Vec<EpFine>,
|
||||
/// Per-stream metadata from the ProgramInfo section (BD spec).
|
||||
/// Cross-validates the MPLS STN view — see `labels/clpi.rs`.
|
||||
/// Cross-validates the MPLS STN view — see `labels/clpi_audit.rs`.
|
||||
/// Empty when program_info is missing or malformed.
|
||||
pub streams: Vec<ClpiStream>,
|
||||
}
|
||||
@@ -30,57 +36,82 @@ pub struct ClipInfo {
|
||||
/// table. Mirrors the same fields the MPLS STN table carries — see
|
||||
/// `mpls::StreamEntry` for the playlist-side equivalent.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ClpiStream {
|
||||
pub(crate) struct ClpiStream {
|
||||
/// PID of the stream in the MPEG-TS (matches MPLS).
|
||||
pub pid: u16,
|
||||
/// SCSI/BD coding type byte (0x80 LPCM, 0x83 TrueHD, 0x86 DTS-HD MA,
|
||||
/// BD stream coding type byte (0x80 LPCM, 0x83 TrueHD, 0x86 DTS-HD MA,
|
||||
/// 0x90 PG, etc.). See `labels::mpls_universal::coding_type_to_codec_hint`.
|
||||
pub coding_type: u8,
|
||||
/// ISO 639-2 3-char language code. Empty for video streams.
|
||||
pub language: String,
|
||||
// The CLPI cross-validation consumer (labels/clpi_audit.rs) reads only
|
||||
// pid/coding_type/language. The codec sub-fields below are parsed from
|
||||
// the BD stream_coding_info for completeness but have no reader yet.
|
||||
/// Audio format byte (1=mono, 3=stereo, 6=5.1, 12=7.1).
|
||||
/// Zero for non-audio streams.
|
||||
#[allow(dead_code)]
|
||||
pub audio_format: u8,
|
||||
/// Audio sample rate (1=48kHz, 4=96kHz, 5=192kHz). Zero for non-audio.
|
||||
#[allow(dead_code)]
|
||||
pub audio_rate: u8,
|
||||
/// Video format byte (1=480i, 4=1080i, 5=720p, 6=1080p, 8=2160p).
|
||||
/// Zero for non-video.
|
||||
#[allow(dead_code)]
|
||||
pub video_format: u8,
|
||||
/// Video rate (1=23.976, 2=24, 3=25, 4=29.97, 6=50, 7=59.94).
|
||||
#[allow(dead_code)]
|
||||
pub video_rate: u8,
|
||||
}
|
||||
|
||||
/// Coarse EP-map entry. Fields feed the EP-map resolution used by
|
||||
/// `get_extents` (test-exercised; reserved for the timestamp-range path).
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct EpCoarse {
|
||||
pub(crate) struct EpCoarse {
|
||||
pub ref_to_fine_id: u32,
|
||||
pub pts_coarse: u32,
|
||||
pub spn_coarse: u32,
|
||||
}
|
||||
|
||||
/// Fine EP-map entry (see `EpCoarse`).
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct EpFine {
|
||||
pub(crate) struct EpFine {
|
||||
pub pts_fine: u32,
|
||||
pub spn_fine: u32,
|
||||
}
|
||||
|
||||
// EP-map → sector-extent resolution. Exercised by the unit tests and
|
||||
// reserved for the timestamp-range read path; no production caller yet.
|
||||
#[allow(dead_code)]
|
||||
impl ClipInfo {
|
||||
/// Reconstruct full PTS from coarse + fine entry.
|
||||
pub fn full_pts(coarse: &EpCoarse, fine: &EpFine) -> u32 {
|
||||
(coarse.pts_coarse << 19) + (fine.pts_fine << 8)
|
||||
///
|
||||
/// The BD spec PTS is 33-bit: `pts_coarse` is 14 bits (max 16383) and
|
||||
/// `16383 << 19` exceeds `u32::MAX`, so the result must be `u64` to
|
||||
/// avoid overflow (panic in debug, silent wrap in release).
|
||||
pub fn full_pts(coarse: &EpCoarse, fine: &EpFine) -> u64 {
|
||||
((coarse.pts_coarse as u64) << 19) + ((fine.pts_fine as u64) << 8)
|
||||
}
|
||||
|
||||
/// Reconstruct full SPN from coarse + fine entry.
|
||||
pub fn full_spn(coarse: &EpCoarse, fine: &EpFine) -> u32 {
|
||||
(coarse.spn_coarse & 0xFFFE_0000) + fine.spn_fine
|
||||
// The two operands occupy non-overlapping bit ranges (coarse holds
|
||||
// the high bits, fine the low 17), so OR expresses intent and is
|
||||
// robust to a hand-constructed EpFine.
|
||||
debug_assert!(fine.spn_fine <= 0x1_FFFF);
|
||||
(coarse.spn_coarse & 0xFFFE_0000) | fine.spn_fine
|
||||
}
|
||||
|
||||
/// Get all EP entries as (PTS, SPN) pairs, fully resolved.
|
||||
pub fn resolved_ep_map(&self) -> Vec<(u32, u32)> {
|
||||
let mut entries = Vec::new();
|
||||
///
|
||||
/// PTS resets at each coarse-group boundary on disc, so the raw
|
||||
/// concatenation is not globally monotonic. The returned vector is
|
||||
/// sorted by PTS so callers (e.g. [`get_extents`]) can binary-search it.
|
||||
///
|
||||
/// [`get_extents`]: ClipInfo::get_extents
|
||||
pub fn resolved_ep_map(&self) -> Vec<(u64, u32)> {
|
||||
let mut entries = Vec::with_capacity(self.ep_fine.len());
|
||||
|
||||
for (ci, coarse) in self.ep_coarse.iter().enumerate() {
|
||||
let fine_start = coarse.ref_to_fine_id as usize;
|
||||
@@ -98,6 +129,12 @@ impl ClipInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// get_extents binary-searches by PTS, so the map must be ordered.
|
||||
// Real discs have globally increasing PTS in coarse order; sort by
|
||||
// (pts, spn) so a cross-group PTS collision can't leave the search
|
||||
// landing on the wrong group's SPN.
|
||||
entries.sort_by_key(|&(pts, spn)| (pts, spn));
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
@@ -105,7 +142,9 @@ impl ClipInfo {
|
||||
///
|
||||
/// Converts PTS timestamps to SPN ranges, then SPN to LBA
|
||||
/// using the file's starting LBA on disc.
|
||||
pub fn get_extents(&self, in_time: u32, out_time: u32) -> Vec<Extent> {
|
||||
pub fn get_extents(&self, in_time: u64, out_time: u64) -> Vec<Extent> {
|
||||
// resolved_ep_map() returns entries sorted by PTS, so binary search
|
||||
// is valid here.
|
||||
let ep_map = self.resolved_ep_map();
|
||||
if ep_map.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -122,20 +161,22 @@ impl ClipInfo {
|
||||
let end_spn = match ep_map.binary_search_by_key(&out_time, |(pts, _)| *pts) {
|
||||
Ok(i) => ep_map[i].1,
|
||||
Err(i) if i < ep_map.len() => ep_map[i].1,
|
||||
_ => ep_map.last().unwrap().1 + 1,
|
||||
_ => ep_map.last().unwrap().1.saturating_add(1),
|
||||
};
|
||||
|
||||
if end_spn <= start_spn {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// SPN → byte offset: spn × 192
|
||||
// Byte offset → sectors: offset / 2048
|
||||
// Note: the caller needs to add the file's starting LBA from UDF
|
||||
let start_byte = start_spn as u64 * 192;
|
||||
let end_byte = end_spn as u64 * 192;
|
||||
let start_sector = (start_byte / 2048) as u32;
|
||||
let end_sector = end_byte.div_ceil(2048) as u32;
|
||||
// SPN → byte offset → sector range. Note: the caller adds the file's
|
||||
// starting LBA from UDF. The start sector FLOORS (the extent begins in
|
||||
// whichever sector contains its first byte) and the end sector CEILS
|
||||
// (the extent must cover through the sector holding its last byte), so
|
||||
// a sub-sector-aligned range still spans every sector it touches.
|
||||
let start_byte = start_spn as u64 * BD_SOURCE_PACKET_BYTES as u64;
|
||||
let end_byte = end_spn as u64 * BD_SOURCE_PACKET_BYTES as u64;
|
||||
let start_sector = (start_byte / SECTOR_BYTES as u64) as u32;
|
||||
let end_sector = end_byte.div_ceil(SECTOR_BYTES as u64) as u32;
|
||||
|
||||
vec![Extent {
|
||||
start_lba: start_sector, // relative to m2ts file start
|
||||
@@ -162,7 +203,7 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
|
||||
// ClipInfo section at offset 40
|
||||
// source_packet_count at offset 40 + 4(len) + 2(reserved) + 1(stream_type) + 1(app_type) + 4(reserved) + 4(ts_rate)
|
||||
let source_packet_count = if data.len() > 56 {
|
||||
let source_packet_count = if data.len() >= 60 {
|
||||
u32::from_be_bytes([data[56], data[57], data[58], data[59]])
|
||||
} else {
|
||||
0
|
||||
@@ -322,8 +363,17 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
// Bound all EP-map reads to this CPI section. The length field counts
|
||||
// bytes after itself, so the section spans data[..cpi_length + 4]. A
|
||||
// bogus ep_map_offset within data.len() but past the CPI section would
|
||||
// otherwise read into an adjacent CLPI section; clamp first.
|
||||
let data = &data[..(cpi_length + 4).min(data.len())];
|
||||
|
||||
// CPI type at bits 44-47 (byte 5, lower 4 bits)
|
||||
// Skip to EP map: offset 4 (after length) + 2 (reserved/type)
|
||||
if data.len() < 6 {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
let ep_map = &data[6..];
|
||||
if ep_map.len() < 4 {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
@@ -351,9 +401,6 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
|
||||
// num_EP_coarse: 16 bits │ (10+4+16+18+32 = 80)
|
||||
// num_EP_fine: 18 bits │
|
||||
// EP_map_start_address: 32 bits ┘
|
||||
if ep_map.len() < 16 {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
let _stream_pid = u16::from_be_bytes([ep_map[2], ep_map[3]]);
|
||||
|
||||
// Read 10 bytes (80 bits) from ep_map[4..14] for bit extraction
|
||||
@@ -389,7 +436,10 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
|
||||
|
||||
// Coarse entries start at offset 4, 8 bytes each
|
||||
let coarse_data = &stream_ep[4..];
|
||||
let mut ep_coarse = Vec::with_capacity(num_coarse);
|
||||
// Cap the pre-reservation by what the slice can actually hold:
|
||||
// num_coarse is a 16-bit disc field, so a hostile value would
|
||||
// otherwise reserve up to ~0.5 MB for an entry table that doesn't exist.
|
||||
let mut ep_coarse = Vec::with_capacity(num_coarse.min(coarse_data.len() / 8));
|
||||
for i in 0..num_coarse {
|
||||
let off = i * 8;
|
||||
if off + 8 > coarse_data.len() {
|
||||
@@ -419,7 +469,13 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
|
||||
}
|
||||
|
||||
// Fine entries at fine_start, 4 bytes each
|
||||
let mut ep_fine = Vec::with_capacity(num_fine);
|
||||
// Cap the pre-reservation: num_fine is an 18-bit disc field (max
|
||||
// 262143), so reserve only what the slice can actually hold.
|
||||
let mut ep_fine = if fine_start < stream_ep.len() {
|
||||
Vec::with_capacity(num_fine.min((stream_ep.len() - fine_start) / 4))
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if fine_start < stream_ep.len() {
|
||||
let fine_data = &stream_ep[fine_start..];
|
||||
for i in 0..num_fine {
|
||||
@@ -643,10 +699,49 @@ mod tests {
|
||||
};
|
||||
// full_pts = (100 << 19) + (50 << 8) = 52_428_800 + 12_800 = 52_441_600
|
||||
let pts = ClipInfo::full_pts(&coarse, &fine);
|
||||
assert_eq!(pts, (100 << 19) + (50 << 8));
|
||||
assert_eq!(pts, (100u64 << 19) + (50u64 << 8));
|
||||
assert_eq!(pts, 52_441_600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_pts_no_u32_overflow() {
|
||||
// pts_coarse is a 14-bit field (max 0x3FFF = 16383); 16383 << 19
|
||||
// overflows u32, so full_pts must use u64.
|
||||
let coarse = EpCoarse {
|
||||
ref_to_fine_id: 0,
|
||||
pts_coarse: 0x3FFF,
|
||||
spn_coarse: 0,
|
||||
};
|
||||
let fine = EpFine {
|
||||
pts_fine: 0x7FF,
|
||||
spn_fine: 0,
|
||||
};
|
||||
let pts = ClipInfo::full_pts(&coarse, &fine);
|
||||
assert_eq!(pts, (0x3FFFu64 << 19) + (0x7FFu64 << 8));
|
||||
assert!(pts > u32::MAX as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_ep_map_sorted_for_binary_search() {
|
||||
// Two coarse groups whose fine PTS reset across the boundary
|
||||
// (50,100 then 25,75) produce a non-monotonic raw concatenation.
|
||||
// resolved_ep_map must sort so get_extents' binary search is valid.
|
||||
let cpi = build_cpi(
|
||||
0x1011,
|
||||
&[(0, 0, 0x00020000), (2, 0, 0x00040000)],
|
||||
&[(50, 1024), (100, 2048), (25, 512), (75, 1536)],
|
||||
);
|
||||
let data = build_clpi(1_000_000, Some(&cpi));
|
||||
let clip = parse(&data).expect("should parse");
|
||||
|
||||
let resolved = clip.resolved_ep_map();
|
||||
assert_eq!(resolved.len(), 4);
|
||||
// Strictly sorted by PTS.
|
||||
for w in resolved.windows(2) {
|
||||
assert!(w[0].0 <= w[1].0, "ep_map not sorted: {resolved:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_spn_calculation() {
|
||||
let coarse = EpCoarse {
|
||||
@@ -674,6 +769,22 @@ mod tests {
|
||||
assert_eq!(spn2, 0x00FE0000 + 0x1234);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_truncated_clipinfo_no_panic() {
|
||||
// 57/58/59-byte CLPI with valid magic: passes the data.len() < 40
|
||||
// guard but data[56..60] needs 60 bytes. Must not panic.
|
||||
for len in 40..60usize {
|
||||
let mut data = vec![0u8; len];
|
||||
data[0..4].copy_from_slice(b"HDMV");
|
||||
if len >= 8 {
|
||||
data[4..8].copy_from_slice(b"0200");
|
||||
}
|
||||
let clip = parse(&data).expect("short CLPI should parse, not panic");
|
||||
// source_packet_count is unreadable below 60 bytes → 0.
|
||||
assert_eq!(clip.source_packet_count, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_magic() {
|
||||
let mut data = build_clpi(1000, None);
|
||||
@@ -707,4 +818,463 @@ mod tests {
|
||||
assert!(clip2.ep_coarse.is_empty());
|
||||
assert!(clip2.ep_fine.is_empty());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Added hardening tests. Grounded in the BD-ROM CLPI spec
|
||||
// (https://github.com/lw/BluRay/wiki/CLPI) and libbluray clpi_parse.c.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a ProgramInfo section. `streams` = Vec<(pid, sci_bytes)>.
|
||||
/// Layout per source doc: length(4)+reserved(1)+num_programs(1)+
|
||||
/// per program [spn(4)+pmt_pid(2)+num_streams(1)+num_groups(1)] then
|
||||
/// per stream [pid(2)+sci_len(1)+sci].
|
||||
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());
|
||||
}
|
||||
|
||||
/// Under-40-byte input is rejected before any field read
|
||||
/// (`data.len() < 40` guard).
|
||||
#[test]
|
||||
fn under_40_bytes_rejected() {
|
||||
assert!(parse(&[0u8; 39]).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].video_format, 6);
|
||||
assert_eq!(clip.streams[0].video_rate, 1);
|
||||
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].audio_format, 6);
|
||||
assert_eq!(clip.streams[0].audio_rate, 1);
|
||||
assert_eq!(clip.streams[0].language, "eng");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
assert_eq!(clip.streams[0].audio_format, 0);
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
|
||||
/// parse_program_info is best-effort: a stream whose declared sci_len
|
||||
/// runs past the section (`sci_end > data.len()`) makes it return the
|
||||
/// streams collected so far (here: none), never panic. Source returns
|
||||
/// `out` early on the 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());
|
||||
}
|
||||
|
||||
/// pts_coarse field is 14 bits: dword0 = ref_to_fine_id<<14 | pts_coarse.
|
||||
/// A pts_coarse of 0x3FFF (max) with ref_to_fine_id 5 must decode both
|
||||
/// without bleed. Verify the >>14 and &0x3FFF split.
|
||||
#[test]
|
||||
fn coarse_pts_14bit_split() {
|
||||
let cpi = build_cpi(0x1011, &[(5, 0x3FFF, 0x12340000)], &[(0, 0)]);
|
||||
let data = build_clpi(1000, Some(&cpi));
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.ep_coarse[0].ref_to_fine_id, 5);
|
||||
assert_eq!(clip.ep_coarse[0].pts_coarse, 0x3FFF);
|
||||
assert_eq!(clip.ep_coarse[0].spn_coarse, 0x12340000);
|
||||
}
|
||||
|
||||
/// Fine entry: dword = is_angle(1)+i_end_offset(3)+pts_fine(11)+
|
||||
/// spn_fine(17). pts_fine occupies bits 17..28 (>>17 & 0x7FF), spn_fine
|
||||
/// the low 17 bits (& 0x1FFFF). Set high bits (is_angle/i_end_offset)
|
||||
/// and verify they do NOT bleed into pts_fine.
|
||||
#[test]
|
||||
fn fine_entry_bit_layout_isolates_pts_and_spn() {
|
||||
// Construct a raw fine dword with is_angle=1, i_end_offset=0b111,
|
||||
// pts_fine=0x5AA, spn_fine=0x1AAAA, then verify decode.
|
||||
let is_angle: u32 = 1;
|
||||
let i_end: u32 = 0b111;
|
||||
let pts_f: u32 = 0x5AA; // 11-bit
|
||||
let spn_f: u32 = 0x1AAAA; // 17-bit
|
||||
let dword: u32 = (is_angle << 31) | (i_end << 28) | (pts_f << 17) | spn_f;
|
||||
|
||||
// Build the CPI by hand with this raw fine dword.
|
||||
let mut stream_ep = Vec::new();
|
||||
let fine_start: u32 = 4; // no coarse entries → fine right after header
|
||||
stream_ep.extend_from_slice(&fine_start.to_be_bytes());
|
||||
stream_ep.extend_from_slice(&dword.to_be_bytes());
|
||||
|
||||
let num_coarse: u32 = 0;
|
||||
let num_fine: u32 = 1;
|
||||
let ep_map_start: u32 = 14;
|
||||
let ep_stream_type: u32 = 1;
|
||||
let packed: u128 = ((ep_stream_type as u128) << 66)
|
||||
| ((num_coarse as u128) << 50)
|
||||
| ((num_fine as u128) << 32)
|
||||
| (ep_map_start as u128);
|
||||
let packed_bytes = packed.to_be_bytes();
|
||||
let stream_header_bits = &packed_bytes[6..16];
|
||||
|
||||
let mut ep_map = Vec::new();
|
||||
ep_map.push(0);
|
||||
ep_map.push(1);
|
||||
ep_map.extend_from_slice(&0x1011u16.to_be_bytes());
|
||||
ep_map.extend_from_slice(stream_header_bits);
|
||||
ep_map.extend_from_slice(&stream_ep);
|
||||
|
||||
let mut cpi = Vec::new();
|
||||
cpi.extend_from_slice(&((2 + ep_map.len()) as u32).to_be_bytes());
|
||||
cpi.extend_from_slice(&[0u8; 2]);
|
||||
cpi.extend_from_slice(&ep_map);
|
||||
|
||||
let data = build_clpi(1000, Some(&cpi));
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.ep_fine.len(), 1);
|
||||
assert_eq!(clip.ep_fine[0].pts_fine, 0x5AA); // high bits stripped
|
||||
assert_eq!(clip.ep_fine[0].spn_fine, 0x1AAAA);
|
||||
}
|
||||
|
||||
/// resolved_ep_map assigns fine entries to coarse groups via
|
||||
/// [ref_to_fine_id .. next coarse's ref_to_fine_id). full_pts combines
|
||||
/// coarse<<19 + fine<<8 and full_spn ORs masked coarse with fine.
|
||||
/// Verify the first resolved entry's (pts, spn) for a known fixture.
|
||||
#[test]
|
||||
fn resolved_ep_map_combines_coarse_and_fine() {
|
||||
// coarse 0: ref_to_fine_id=0, pts_coarse=10, spn_coarse=0x00020000
|
||||
// fine 0: pts_fine=3, spn_fine=0x100
|
||||
let cpi = build_cpi(0x1011, &[(0, 10, 0x00020000)], &[(3, 0x100)]);
|
||||
let data = build_clpi(1000, Some(&cpi));
|
||||
let clip = parse(&data).expect("should parse");
|
||||
let resolved = clip.resolved_ep_map();
|
||||
assert_eq!(resolved.len(), 1);
|
||||
let expected_pts = (10u64 << 19) + (3u64 << 8);
|
||||
let expected_spn = (0x00020000u32 & 0xFFFE_0000) | 0x100;
|
||||
assert_eq!(resolved[0].0, expected_pts);
|
||||
assert_eq!(resolved[0].1, expected_spn);
|
||||
}
|
||||
|
||||
/// get_extents converts an in/out PTS range to a single sector Extent.
|
||||
/// SPN→byte = spn×192, byte→sector = /2048 (start floored, end ceiled),
|
||||
/// relative to m2ts file start. Verify the math for a known fixture.
|
||||
#[test]
|
||||
fn get_extents_spn_to_sector_math() {
|
||||
// Two EP points: PTS p0 → SPN 0, PTS p1 → SPN big_spn.
|
||||
// full_spn ORs (spn_coarse & 0xFFFE0000) with spn_fine, so the SPN
|
||||
// must be coarse-aligned (low 17 bits clear) to survive intact.
|
||||
// 0x20000 (131072) is the smallest non-zero coarse-aligned SPN.
|
||||
let big_spn: u32 = 0x20000;
|
||||
let cpi = build_cpi(0x1011, &[(0, 0, 0), (1, 100, big_spn)], &[(0, 0), (0, 0)]);
|
||||
let data = build_clpi(1000, Some(&cpi));
|
||||
let clip = parse(&data).expect("should parse");
|
||||
|
||||
let p0 = 0u64; // PTS of first EP
|
||||
let p1 = 100u64 << 19; // PTS of second EP
|
||||
let extents = clip.get_extents(p0, p1);
|
||||
assert_eq!(extents.len(), 1);
|
||||
// Mirror production: SPN→byte ×packet, byte→sector with start FLOORed
|
||||
// and end CEILed (same constants as get_extents).
|
||||
let start_spn: u64 = 0;
|
||||
let end_spn = big_spn as u64;
|
||||
let start_byte = start_spn * BD_SOURCE_PACKET_BYTES as u64;
|
||||
let end_byte = end_spn * BD_SOURCE_PACKET_BYTES as u64;
|
||||
let start_sector = (start_byte / SECTOR_BYTES as u64) as u32;
|
||||
let end_sector = end_byte.div_ceil(SECTOR_BYTES as u64) as u32;
|
||||
assert_eq!(extents[0].start_lba, start_sector);
|
||||
assert_eq!(extents[0].sector_count, end_sector - start_sector);
|
||||
// Concretely: 0x20000 × 192 / 2048 = 12288 sectors.
|
||||
assert_eq!(extents[0].sector_count, 12288);
|
||||
}
|
||||
|
||||
/// get_extents returns an empty Vec when the EP map is empty (no CPI),
|
||||
/// since there is no SPN to resolve. Documented early return.
|
||||
#[test]
|
||||
fn get_extents_empty_when_no_ep_map() {
|
||||
let data = build_clpi(1000, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert!(clip.get_extents(0, 1_000_000).is_empty());
|
||||
}
|
||||
|
||||
/// get_extents returns empty when end_spn <= start_spn (degenerate or
|
||||
/// inverted range). Source has an explicit `if end_spn <= start_spn`
|
||||
/// guard. Use in_time == out_time on a single-point map.
|
||||
#[test]
|
||||
fn get_extents_empty_on_degenerate_range() {
|
||||
let cpi = build_cpi(0x1011, &[(0, 50, 0x1000)], &[(0, 0)]);
|
||||
let data = build_clpi(1000, Some(&cpi));
|
||||
let clip = parse(&data).expect("should parse");
|
||||
let p = 50u64 << 19;
|
||||
// in == out → start_spn == end_spn → empty.
|
||||
assert!(clip.get_extents(p, p).is_empty());
|
||||
}
|
||||
|
||||
/// full_spn masks the LOW 17 bits of spn_coarse (& 0xFFFE0000) before
|
||||
/// OR-ing fine. A spn_coarse with low bits set must have them cleared,
|
||||
/// then replaced by spn_fine. Independent of parse, exercises the
|
||||
/// reconstruction directly with a hostile low-bit pattern.
|
||||
#[test]
|
||||
fn full_spn_clears_coarse_low_17_bits() {
|
||||
let coarse = EpCoarse {
|
||||
ref_to_fine_id: 0,
|
||||
pts_coarse: 0,
|
||||
spn_coarse: 0x0006_FFFF, // low 17 bits all set
|
||||
};
|
||||
let fine = EpFine {
|
||||
pts_fine: 0,
|
||||
spn_fine: 0x5,
|
||||
};
|
||||
// 0x0006_FFFF & 0xFFFE_0000 = 0x0006_0000; | 0x5 = 0x0006_0005.
|
||||
assert_eq!(ClipInfo::full_spn(&coarse, &fine), 0x0006_0005);
|
||||
}
|
||||
|
||||
/// CPI guard: cpi_length < 4 short-circuits to empty maps (the length
|
||||
/// field counts bytes after itself, and the EP map needs ≥4). A
|
||||
/// cpi_length of 0/1/2/3 must yield empty EP maps, not panic.
|
||||
#[test]
|
||||
fn cpi_length_below_4_yields_empty() {
|
||||
for bad_len in 0u32..4 {
|
||||
let mut cpi = Vec::new();
|
||||
cpi.extend_from_slice(&bad_len.to_be_bytes());
|
||||
cpi.extend_from_slice(&[0u8; 20]); // padding so the slice exists
|
||||
let data = build_clpi(1000, Some(&cpi));
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert!(clip.ep_coarse.is_empty(), "len={bad_len}");
|
||||
assert!(clip.ep_fine.is_empty(), "len={bad_len}");
|
||||
}
|
||||
}
|
||||
|
||||
/// ep_map_offset that points past the EP map (`ep_map_offset + 4 >
|
||||
/// ep_map.len()`) → empty maps (bounds guard), not panic. Patch the
|
||||
/// EP_map_start field to a huge value.
|
||||
#[test]
|
||||
fn ep_map_offset_out_of_bounds_yields_empty() {
|
||||
let cpi = build_cpi(0x1011, &[(0, 10, 0x20000)], &[(5, 100)]);
|
||||
let mut data = build_clpi(1000, Some(&cpi));
|
||||
// EP_map_start is the low 32 bits of the 80-bit stream header at
|
||||
// ep_map[4..14]. In the file: header(60) + cpi_length(4) +
|
||||
// reserved(2) + ep_map reserved(1) + num_streams(1) + pid(2) = 70,
|
||||
// then 10 header bytes [70..80]; EP_map_start is the last 4 [76..80].
|
||||
let off = 60 + 4 + 2 + 1 + 1 + 2 + 6; // = 76
|
||||
data[off..off + 4].copy_from_slice(&0xFFFF_FFFFu32.to_be_bytes());
|
||||
let clip = parse(&data).expect("should not panic");
|
||||
assert!(clip.ep_coarse.is_empty());
|
||||
assert!(clip.ep_fine.is_empty());
|
||||
}
|
||||
|
||||
/// num_coarse declares more entries than the CPI section holds. The
|
||||
/// loop must stop at `off + 8 > coarse_data.len()` (break), not read
|
||||
/// out of bounds. Patch num_coarse to a large value while supplying 1
|
||||
/// coarse entry's worth of bytes.
|
||||
#[test]
|
||||
fn coarse_count_overshoot_truncates_safely() {
|
||||
let cpi = build_cpi(0x1011, &[(0, 10, 0x20000)], &[(5, 100)]);
|
||||
let mut data = build_clpi(1000, Some(&cpi));
|
||||
// num_coarse is bits 14..30 of the 80-bit header. Rather than
|
||||
// bit-surgery, rebuild with a hand-set num_coarse=255 but only 1
|
||||
// coarse entry of bytes — done below directly.
|
||||
let _ = &mut data;
|
||||
|
||||
let num_coarse_decl: u32 = 255;
|
||||
let num_fine: u32 = 1;
|
||||
let ep_map_start: u32 = 14;
|
||||
let ep_stream_type: u32 = 1;
|
||||
let packed: u128 = ((ep_stream_type as u128) << 66)
|
||||
| ((num_coarse_decl as u128) << 50)
|
||||
| ((num_fine as u128) << 32)
|
||||
| (ep_map_start as u128);
|
||||
let packed_bytes = packed.to_be_bytes();
|
||||
let stream_header_bits = &packed_bytes[6..16];
|
||||
|
||||
// stream EP data: fine_start points past the 1 coarse entry.
|
||||
let fine_start: u32 = 4 + 8; // 4-byte header + 1 coarse entry x 8 bytes
|
||||
let mut stream_ep = Vec::new();
|
||||
stream_ep.extend_from_slice(&fine_start.to_be_bytes());
|
||||
// exactly ONE coarse entry (8 bytes), though header claims 255.
|
||||
stream_ep.extend_from_slice(&10u32.to_be_bytes());
|
||||
stream_ep.extend_from_slice(&0x20000u32.to_be_bytes());
|
||||
// one fine entry (4 bytes)
|
||||
stream_ep.extend_from_slice(&(((5u32 & 0x7FF) << 17) | 100).to_be_bytes());
|
||||
|
||||
let mut ep_map = Vec::new();
|
||||
ep_map.push(0);
|
||||
ep_map.push(1);
|
||||
ep_map.extend_from_slice(&0x1011u16.to_be_bytes());
|
||||
ep_map.extend_from_slice(stream_header_bits);
|
||||
ep_map.extend_from_slice(&stream_ep);
|
||||
let mut cpi2 = Vec::new();
|
||||
cpi2.extend_from_slice(&((2 + ep_map.len()) as u32).to_be_bytes());
|
||||
cpi2.extend_from_slice(&[0u8; 2]);
|
||||
cpi2.extend_from_slice(&ep_map);
|
||||
let data2 = build_clpi(1000, Some(&cpi2));
|
||||
let clip = parse(&data2).expect("should not panic on coarse overshoot");
|
||||
// Only the 1 real coarse entry was readable.
|
||||
assert_eq!(clip.ep_coarse.len(), 1);
|
||||
assert_eq!(clip.ep_coarse[0].pts_coarse, 10);
|
||||
}
|
||||
|
||||
/// resolved_ep_map: the LAST coarse group's fine range extends to
|
||||
/// ep_fine.len() (no "next coarse" bound). Verify all trailing fine
|
||||
/// entries are assigned to the final coarse group.
|
||||
#[test]
|
||||
fn resolved_ep_map_last_group_to_end() {
|
||||
// coarse 0 ref_to_fine_id=0, coarse 1 ref_to_fine_id=1.
|
||||
// 3 fine entries: fine 0 → coarse 0; fine 1,2 → coarse 1.
|
||||
let cpi = build_cpi(
|
||||
0x1011,
|
||||
&[(0, 0, 0), (1, 100, 0)],
|
||||
&[(0, 10), (0, 20), (0, 30)],
|
||||
);
|
||||
let data = build_clpi(1000, Some(&cpi));
|
||||
let clip = parse(&data).expect("should parse");
|
||||
let resolved = clip.resolved_ep_map();
|
||||
// All 3 fine entries resolved (last group picks up fine 1 and 2).
|
||||
assert_eq!(resolved.len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Physical media constants — the single source of truth.
|
||||
//!
|
||||
//! Naming convention: a constant is prefixed by the **narrowest scope where it
|
||||
//! is valid**. A value common to all optical media carries no prefix; a value
|
||||
//! specific to a container/format/disc-type is prefixed by it
|
||||
//! (`TS_`, `BD_`, …). Define each physical quantity here exactly once and import
|
||||
//! it — never re-declare a bare literal or a local copy.
|
||||
|
||||
/// Bytes per logical sector on every optical medium freemkv reads
|
||||
/// (Blu-ray, DVD-Video, CD-ROM Mode 1). Universal — hence unprefixed.
|
||||
pub const SECTOR_BYTES: usize = 2048;
|
||||
|
||||
/// Bytes per MPEG-2 transport-stream packet. Common to all MPEG-TS, not just
|
||||
/// Blu-ray — prefixed by the format, not a disc type.
|
||||
pub const TS_PACKET_BYTES: usize = 188;
|
||||
|
||||
/// Bytes in an MPEG-2 transport-stream packet header: sync byte, the
|
||||
/// flags/PID word, and the adaptation/continuity byte.
|
||||
pub const TS_HEADER_BYTES: usize = 4;
|
||||
|
||||
/// Bytes in the arrival-timestamp prefix a Blu-ray M2TS prepends to each TS
|
||||
/// packet to form a source packet. Same width as a TS header but a distinct
|
||||
/// quantity ([`TS_HEADER_BYTES`]) — do not conflate.
|
||||
pub const BD_TIMESTAMP_PREFIX_BYTES: usize = 4;
|
||||
|
||||
/// Bytes of payload in an MPEG-2 transport-stream packet:
|
||||
/// [`TS_PACKET_BYTES`] minus the [`TS_HEADER_BYTES`] header.
|
||||
pub const TS_PAYLOAD_BYTES: usize = TS_PACKET_BYTES - TS_HEADER_BYTES;
|
||||
|
||||
/// Bytes per Blu-ray M2TS *source packet*: a TS packet ([`TS_PACKET_BYTES`])
|
||||
/// prefixed with the [`BD_TIMESTAMP_PREFIX_BYTES`] arrival-timestamp header.
|
||||
/// A BDAV/M2TS construct only — DVD VOBs have no source packets — hence `BD_`.
|
||||
pub const BD_SOURCE_PACKET_BYTES: usize = TS_PACKET_BYTES + BD_TIMESTAMP_PREFIX_BYTES;
|
||||
+400
-204
@@ -1,52 +1,16 @@
|
||||
//! CSS drive authentication — full key hierarchy.
|
||||
//! CSS drive bus-authentication — read-unlock primitive.
|
||||
//!
|
||||
//! Protocol:
|
||||
//! 1. Bus authentication (challenge-response) → bus key
|
||||
//! 2. Read disc key block (READ DVD STRUCTURE) → XOR with bus key → decrypt with player keys → disc key
|
||||
//! 3. Read title key (REPORT KEY format 0x04) → XOR with bus key → decrypt with disc key → title key
|
||||
//! A CSS-enforcing DVD drive refuses to return scrambled sectors until a
|
||||
//! CSS bus-auth handshake has set its Authentication Success Flag (ASF=1).
|
||||
//! [`unlock_css_reads`] runs that bus-auth challenge-response (which is what
|
||||
//! actually opens scrambled-sector reads), then a best-effort, non-fatal
|
||||
//! disc-key REPORT KEY. The bytes are NOT used as keys: the descramble title
|
||||
//! key is recovered keylessly by the Stevenson known-plaintext attack (see
|
||||
//! [`super::crack_key`]).
|
||||
|
||||
use crate::drive::Drive;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
// ── Built-in public DVD CSS player keys ────────────────────────────────────
|
||||
//
|
||||
// These 31 5-byte player keys are long-public CSS inputs. With them
|
||||
// compiled in, DVD ripping works with no external key file required.
|
||||
|
||||
const PLAYER_KEYS: [[u8; 5]; 31] = [
|
||||
[0x01, 0xaf, 0xe3, 0x12, 0x80],
|
||||
[0x12, 0x11, 0xca, 0x04, 0x3b],
|
||||
[0x14, 0x0c, 0x9e, 0xd0, 0x09],
|
||||
[0x14, 0x71, 0x35, 0xba, 0xe2],
|
||||
[0x1a, 0xa4, 0x33, 0x21, 0xa6],
|
||||
[0x26, 0xec, 0xc4, 0xa7, 0x4e],
|
||||
[0x2c, 0xb2, 0xc1, 0x09, 0xee],
|
||||
[0x2f, 0x25, 0x9e, 0x96, 0xdd],
|
||||
[0x33, 0x2f, 0x49, 0x6c, 0xe0],
|
||||
[0x35, 0x5b, 0xc1, 0x31, 0x0f],
|
||||
[0x36, 0x67, 0xb2, 0xe3, 0x85],
|
||||
[0x39, 0x3d, 0xf1, 0xf1, 0xbd],
|
||||
[0x3b, 0x31, 0x34, 0x0d, 0x91],
|
||||
[0x45, 0xed, 0x28, 0xeb, 0xd3],
|
||||
[0x48, 0xb7, 0x6c, 0xce, 0x69],
|
||||
[0x4b, 0x65, 0x0d, 0xc1, 0xee],
|
||||
[0x4c, 0xbb, 0xf5, 0x5b, 0x23],
|
||||
[0x51, 0x67, 0x67, 0xc5, 0xe0],
|
||||
[0x53, 0x94, 0xe1, 0x75, 0xbf],
|
||||
[0x57, 0x2c, 0x8b, 0x31, 0xae],
|
||||
[0x63, 0xdb, 0x4c, 0x5b, 0x4a],
|
||||
[0x7b, 0x1e, 0x5e, 0x2b, 0x57],
|
||||
[0x85, 0xf3, 0x85, 0xa0, 0xe0],
|
||||
[0xab, 0x1e, 0xe7, 0x7b, 0x72],
|
||||
[0xab, 0x36, 0xe3, 0xeb, 0x76],
|
||||
[0xb1, 0xb8, 0xf9, 0x38, 0x03],
|
||||
[0xb8, 0x5d, 0xd8, 0x53, 0xbd],
|
||||
[0xbf, 0x92, 0xc3, 0xb0, 0xe2],
|
||||
[0xcf, 0x1a, 0xb2, 0xf8, 0x0a],
|
||||
[0xec, 0xa0, 0xcf, 0xb3, 0xff],
|
||||
[0xfc, 0x95, 0xa9, 0x87, 0x35],
|
||||
];
|
||||
|
||||
// ── CryptKey tables ───────────────────────────────────────────────────────
|
||||
|
||||
const CRYPT_TAB0: [u8; 256] = [
|
||||
@@ -106,7 +70,7 @@ const CRYPT_TAB2: [u8; 256] = [
|
||||
0x45, 0x78, 0xA9, 0xA8, 0xEA, 0xC9, 0x6A, 0xF7, 0x29, 0x91, 0xF0, 0x02, 0x18, 0x3A, 0x4E, 0x7C,
|
||||
];
|
||||
|
||||
const CRYPT_TAB3: [u8; 288] = [
|
||||
const CRYPT_TAB3: [u8; 256] = [
|
||||
0x73, 0x51, 0x95, 0xE1, 0x12, 0xE4, 0xC0, 0x58, 0xEE, 0xF2, 0x08, 0x1B, 0xA9, 0xFA, 0x98, 0x4C,
|
||||
0xA7, 0x33, 0xE2, 0x1B, 0xA7, 0x6D, 0xF5, 0x30, 0x97, 0x1D, 0xF3, 0x02, 0x60, 0x5A, 0x82, 0x0F,
|
||||
0x91, 0xD0, 0x9C, 0x10, 0x39, 0x7A, 0x83, 0x85, 0x3B, 0xB2, 0xB8, 0xAE, 0x0C, 0x09, 0x52, 0xEA,
|
||||
@@ -123,8 +87,6 @@ const CRYPT_TAB3: [u8; 288] = [
|
||||
0xBD, 0xC1, 0x0E, 0x56, 0x54, 0x3E, 0x14, 0x5F, 0x8C, 0x8F, 0x6E, 0x75, 0x1C, 0x07, 0x39, 0x7B,
|
||||
0x4B, 0xDB, 0xD3, 0x4B, 0x1E, 0xC8, 0x7E, 0xFE, 0x3E, 0x72, 0x16, 0x83, 0x7D, 0xEE, 0xF5, 0xCA,
|
||||
0xC5, 0x18, 0xF9, 0xD8, 0x68, 0xAB, 0x38, 0x85, 0xA8, 0xF0, 0xA1, 0x73, 0x9F, 0x5D, 0x19, 0x0B,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x72, 0x39, 0x25, 0x67, 0x26, 0x6D, 0x71,
|
||||
0x36, 0x77, 0x3C, 0x20, 0x62, 0x23, 0x68, 0x74, 0xC3, 0x82, 0xC9, 0x15, 0x57, 0x16, 0x5D, 0x81,
|
||||
];
|
||||
|
||||
const VARIANTS: [u8; 32] = [
|
||||
@@ -153,40 +115,51 @@ const PERM_VARIANT: [[u8; 32]; 2] = [
|
||||
],
|
||||
];
|
||||
|
||||
// ── SCSI constants ────────────────────────────────────────────────────────
|
||||
|
||||
const SCSI_READ_DVD_STRUCTURE: u8 = 0xAD;
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Perform CSS bus authentication only.
|
||||
pub fn authenticate(drive: &mut Drive) -> Result<()> {
|
||||
let (_, _) = bus_auth(drive)?;
|
||||
Ok(())
|
||||
/// CSS bus-auth **unlock** primitive.
|
||||
///
|
||||
/// Runs the bus-auth challenge-response (which sets the drive's ASF=1 and is
|
||||
/// what actually unlocks scrambled-sector reads), then a best-effort,
|
||||
/// non-fatal disc-key REPORT KEY. The title-key REPORT KEY is NOT issued: it
|
||||
/// is unnecessary (the descramble key is recovered keylessly by the Stevenson
|
||||
/// attack in [`super::crack_key`]) and its hard failure on some USB bridges
|
||||
/// used to abort the whole unlock (the 7014 bug). The bytes are discarded.
|
||||
pub fn unlock_css_reads(drive: &mut Drive, lba: u32) -> Result<()> {
|
||||
let t0 = std::time::Instant::now();
|
||||
tracing::info!(target: "freemkv::css", phase = "unlock_css_reads", lba, "begin");
|
||||
let r = unlock_css_reads_inner(drive, lba);
|
||||
tracing::info!(
|
||||
target: "freemkv::css",
|
||||
phase = "unlock_css_reads",
|
||||
lba,
|
||||
ok = r.is_ok(),
|
||||
elapsed_ms = t0.elapsed().as_millis() as u64,
|
||||
"end"
|
||||
);
|
||||
r
|
||||
}
|
||||
|
||||
/// Full CSS key extraction: bus auth → disc key → title key.
|
||||
pub fn authenticate_and_read_title_key(drive: &mut Drive, lba: u32) -> Result<[u8; 5]> {
|
||||
// Session 1: bus auth → disc key (AGID consumed by READ_DVD_STRUCTURE)
|
||||
let (agid, bus_key) = bus_auth(drive)?;
|
||||
let disc_key = read_disc_key(drive, agid, &bus_key)?;
|
||||
|
||||
// Session 2: fresh bus auth → title key (needs separate AGID)
|
||||
let (agid2, bus_key2) = bus_auth(drive)?;
|
||||
let encrypted_title = read_raw_title_key(drive, agid2, lba)?;
|
||||
|
||||
// Decrypt title key: XOR with bus key, then decrypt with disc key
|
||||
let mut title_key = [0u8; 5];
|
||||
for i in 0..5 {
|
||||
title_key[i] = encrypted_title[i] ^ bus_key2[i];
|
||||
fn unlock_css_reads_inner(drive: &mut Drive, _lba: u32) -> Result<()> {
|
||||
tracing::debug!(target: "freemkv::css", "css unlock: begin");
|
||||
// The bus-auth challenge-response sets the drive's Authentication Success
|
||||
// Flag (ASF=1), which is what opens scrambled-sector reads. This is the
|
||||
// ONLY step required to unlock reads; a failure here is fatal — we
|
||||
// genuinely cannot read scrambled sectors.
|
||||
let (agid, _bus_key) = bus_auth(drive).inspect_err(|e| {
|
||||
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus_auth failed");
|
||||
})?;
|
||||
tracing::debug!(target: "freemkv::css", agid, "css unlock: bus_auth ok");
|
||||
// Disc-key REPORT KEY: issued BEST-EFFORT for any firmware that ties part
|
||||
// of its read-unlock to it. The bytes are unused (the descramble key is
|
||||
// recovered keylessly) and a failure is NON-FATAL — the gate is already
|
||||
// open from bus-auth. This replaces the title-key REPORT KEY, whose hard
|
||||
// failure used to abort the whole unlock (the 7014 bug on USB bridges).
|
||||
if let Err(e) = read_disc_key(drive, agid) {
|
||||
tracing::debug!(target: "freemkv::css", error_code = e.code(), "css unlock: disc-key REPORT KEY skipped (non-fatal)");
|
||||
}
|
||||
|
||||
if title_key == [0u8; 5] {
|
||||
return Ok(title_key);
|
||||
}
|
||||
|
||||
let title_key = super::lfsr::decrypt_key(0xFF, &disc_key, &title_key);
|
||||
Ok(title_key)
|
||||
tracing::debug!(target: "freemkv::css", "css unlock: ok");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Step 1: Bus Authentication ────────────────────────────────────────────
|
||||
@@ -220,8 +193,14 @@ fn bus_auth(drive: &mut Drive) -> Result<(u8, [u8; 5])> {
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
let agid = (buf[7] >> 6) & 0x03;
|
||||
|
||||
// Host sends challenge
|
||||
let host_challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
// Host sends challenge. The spec wants a fresh per-session random nonce,
|
||||
// not a fixed constant — a predictable challenge weakens the bus-auth
|
||||
// handshake.
|
||||
let mut host_challenge = [0u8; 10];
|
||||
{
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut host_challenge);
|
||||
}
|
||||
let mut hc_buf = [0u8; 16];
|
||||
hc_buf[0] = 0x00;
|
||||
hc_buf[1] = 0x0E;
|
||||
@@ -301,13 +280,16 @@ fn bus_auth(drive: &mut Drive) -> Result<(u8, [u8; 5])> {
|
||||
|
||||
// ── Step 2: Disc Key ──────────────────────────────────────────────────────
|
||||
|
||||
fn read_disc_key(drive: &mut Drive, agid: u8, bus_key: &[u8; 5]) -> Result<[u8; 5]> {
|
||||
/// Issue the disc-key REPORT KEY (READ DVD STRUCTURE, format 0x02) purely
|
||||
/// for the bus-auth unlock side effect. The returned block contents are
|
||||
/// not used — the descramble title key is recovered keylessly elsewhere.
|
||||
fn read_disc_key(drive: &mut Drive, agid: u8) -> Result<()> {
|
||||
let scsi = drive.scsi_mut();
|
||||
|
||||
// READ DVD STRUCTURE, format 0x02 (disc key), 2048+4 bytes
|
||||
let alloc_len: u16 = 2048 + 4;
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = SCSI_READ_DVD_STRUCTURE;
|
||||
cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE;
|
||||
// bytes 2-5: address = 0
|
||||
cdb[6] = 0; // layer
|
||||
cdb[7] = 0x02; // format = disc key
|
||||
@@ -324,133 +306,19 @@ fn read_disc_key(drive: &mut Drive, agid: u8, bus_key: &[u8; 5]) -> Result<[u8;
|
||||
);
|
||||
dvd_result.map_err(|_| Error::CssAuthFailed)?;
|
||||
|
||||
// Disc key block starts at offset 4 (skip 4-byte header)
|
||||
let disc_key_block = &mut buf[4..4 + 2048];
|
||||
|
||||
// XOR with reversed bus key (per libdvdcss)
|
||||
for (i, byte) in disc_key_block.iter_mut().enumerate() {
|
||||
*byte ^= bus_key[4 - (i % 5)];
|
||||
}
|
||||
|
||||
// Try each player key against each of 408 disc key entries.
|
||||
// Each entry in the block is the disc key encrypted with a specific player key.
|
||||
// We try all known player keys and verify by checking that two different
|
||||
// entries produce the same disc key.
|
||||
let mut candidates: Vec<([u8; 5], usize, usize)> = Vec::new(); // (disc_key, pk_idx, pos)
|
||||
|
||||
for (pk_idx, player_key) in PLAYER_KEYS.iter().enumerate() {
|
||||
for pos in 0..408 {
|
||||
let offset = pos * 5;
|
||||
if offset + 5 > disc_key_block.len() {
|
||||
break;
|
||||
}
|
||||
let mut enc = [0u8; 5];
|
||||
enc.copy_from_slice(&disc_key_block[offset..offset + 5]);
|
||||
let candidate = super::lfsr::decrypt_key(0x00, player_key, &enc);
|
||||
|
||||
// Check if any previous candidate matches (same disc key from different entry/pk)
|
||||
for (prev, _, _) in &candidates {
|
||||
if *prev == candidate {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
candidates.push((candidate, pk_idx, pos));
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::CssAuthFailed)
|
||||
}
|
||||
|
||||
// ── Step 3: Title Key ─────────────────────────────────────────────────────
|
||||
|
||||
/// Read the raw (bus-encrypted) title key bytes from the drive.
|
||||
fn read_raw_title_key(drive: &mut Drive, agid: u8, lba: u32) -> Result<[u8; 5]> {
|
||||
let scsi = drive.scsi_mut();
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
||||
cdb[2] = (lba >> 24) as u8;
|
||||
cdb[3] = (lba >> 16) as u8;
|
||||
cdb[4] = (lba >> 8) as u8;
|
||||
cdb[5] = lba as u8;
|
||||
cdb[8] = 0x00;
|
||||
cdb[9] = 0x0C;
|
||||
cdb[10] = (agid << 6) | 0x04;
|
||||
|
||||
let mut buf = [0u8; 12];
|
||||
let result = scsi.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
);
|
||||
result.map_err(|_| Error::CssAuthFailed)?;
|
||||
|
||||
let mut key = [0u8; 5];
|
||||
for i in 0..5 {
|
||||
key[i] = buf[5 + (4 - i)];
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn read_title_key(
|
||||
drive: &mut Drive,
|
||||
agid: u8,
|
||||
lba: u32,
|
||||
bus_key: &[u8; 5],
|
||||
disc_key: &[u8; 5],
|
||||
) -> Result<[u8; 5]> {
|
||||
let scsi = drive.scsi_mut();
|
||||
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
||||
cdb[2] = (lba >> 24) as u8;
|
||||
cdb[3] = (lba >> 16) as u8;
|
||||
cdb[4] = (lba >> 8) as u8;
|
||||
cdb[5] = lba as u8;
|
||||
cdb[8] = 0x00;
|
||||
cdb[9] = 0x0C;
|
||||
cdb[10] = (agid << 6) | 0x04;
|
||||
|
||||
let mut buf = [0u8; 12];
|
||||
let tk_result = scsi.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
);
|
||||
tk_result.map_err(|_| Error::CssAuthFailed)?;
|
||||
|
||||
// Title key at bytes 5..10, byte-reversed
|
||||
let mut title_key = [0u8; 5];
|
||||
for i in 0..5 {
|
||||
title_key[i] = buf[5 + (4 - i)];
|
||||
}
|
||||
|
||||
// XOR with reversed bus key (same pattern as disc key block)
|
||||
for i in 0..5 {
|
||||
title_key[i] ^= bus_key[4 - i];
|
||||
}
|
||||
|
||||
// Check for null key (title not encrypted)
|
||||
if title_key == [0u8; 5] {
|
||||
return Ok(title_key);
|
||||
}
|
||||
|
||||
// Decrypt with disc key (invert=0xFF for title keys)
|
||||
let title_key = super::lfsr::decrypt_key(0xFF, disc_key, &title_key);
|
||||
|
||||
Ok(title_key)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── CSSCryptKey ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Exposed for testing only.
|
||||
pub fn test_crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
||||
crypt_key(key_type, variant, challenge)
|
||||
}
|
||||
|
||||
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
||||
// key_type indexes PERM_CHALLENGE ([_;3]); variant indexes
|
||||
// VARIANTS/PERM_VARIANT ([_;32]). All internal callers pass key_type in
|
||||
// 0..3 and variant in 0..32; the asserts document the contract for the
|
||||
// pub(crate) test entry point test_crypt_key and turn a would-be
|
||||
// out-of-bounds panic into an explicit precondition violation.
|
||||
debug_assert!(key_type < 3, "crypt_key: key_type out of range");
|
||||
debug_assert!((variant as usize) < 32, "crypt_key: variant out of range");
|
||||
let perm = &PERM_CHALLENGE[key_type];
|
||||
let mut scratch = [0u8; 10];
|
||||
for i in 0..10 {
|
||||
@@ -602,6 +470,120 @@ fn send_key_cdb(agid: u8, format: u8, param_len: u16) -> [u8; 12] {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// SECURITY REGRESSION GUARD: no instrumentation in libfreemkv may emit
|
||||
/// raw key material. Scan every source file for a `tracing` field that
|
||||
/// binds a forbidden key name to a value-producing expression (`= expr`
|
||||
/// or `%expr` / `?expr`). The only allowed forms are a string literal
|
||||
/// (e.g. `disc_key = "<redacted>"`) or a `_fp` fingerprint field.
|
||||
///
|
||||
/// This is a source-scan test (not a runtime capture) so it stays cheap
|
||||
/// and catches re-introductions at compile/CI time.
|
||||
#[test]
|
||||
fn no_key_bytes_in_instrumentation() {
|
||||
use std::path::Path;
|
||||
|
||||
// Forbidden field names whose VALUES must never be logged.
|
||||
const FORBIDDEN: &[&str] = &[
|
||||
"title_key",
|
||||
"disc_key",
|
||||
"unit_key",
|
||||
"vuk",
|
||||
"player_key",
|
||||
"bus_key",
|
||||
];
|
||||
|
||||
fn scan_dir(dir: &Path, forbidden: &[&str], violations: &mut Vec<String>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan_dir(&path, forbidden, violations);
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
|
||||
continue;
|
||||
}
|
||||
let src = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for (lineno, line) in src.lines().enumerate() {
|
||||
let trimmed = line.trim_start();
|
||||
// Only inspect tracing instrumentation lines.
|
||||
if !(trimmed.contains("tracing::")
|
||||
|| trimmed.starts_with("debug!")
|
||||
|| trimmed.starts_with("info!")
|
||||
|| trimmed.starts_with("warn!")
|
||||
|| trimmed.starts_with("trace!")
|
||||
|| trimmed.starts_with("error!"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// This guard test itself contains the forbidden names.
|
||||
if path.file_name().and_then(|n| n.to_str()) == Some("auth.rs")
|
||||
&& line.contains("FORBIDDEN")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for &name in forbidden {
|
||||
// A fingerprint field (`<name>_fp = ...`) is allowed.
|
||||
// Match `<name>` followed by optional fingerprint
|
||||
// suffix then `=` and a value that is NOT a string
|
||||
// literal redaction marker.
|
||||
if let Some(idx) = line.find(name) {
|
||||
let after = &line[idx + name.len()..];
|
||||
let after = after.trim_start();
|
||||
// `<name>_fp` / `<name>_id` etc. are safe.
|
||||
if after.starts_with('_') {
|
||||
continue;
|
||||
}
|
||||
// Must be a field binding `name = ...`.
|
||||
let Some(rest) = after.strip_prefix('=') else {
|
||||
continue;
|
||||
};
|
||||
let rest = rest.trim_start();
|
||||
// Redaction string literal is the only allowed value.
|
||||
if rest.starts_with('"') {
|
||||
continue;
|
||||
}
|
||||
// Anything else (`%expr`, `?expr`, bare expr) leaks bytes.
|
||||
violations.push(format!(
|
||||
"{}:{}: forbidden key field `{}` logged with a value: {}",
|
||||
path.display(),
|
||||
lineno + 1,
|
||||
name,
|
||||
line.trim()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan this crate's `src` plus the sibling workspace crates so the
|
||||
// key-material logging guard covers every crate that can reach the
|
||||
// CSS/AACS internals, not just libfreemkv. Missing sibling dirs (e.g.
|
||||
// when building the crate standalone) are simply skipped.
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace = manifest.parent().unwrap_or(manifest);
|
||||
let mut violations = Vec::new();
|
||||
scan_dir(&manifest.join("src"), FORBIDDEN, &mut violations);
|
||||
for sibling in ["autorip", "freemkv", "freemkv-keysources"] {
|
||||
let dir = workspace.join(sibling).join("src");
|
||||
if dir.is_dir() {
|
||||
scan_dir(&dir, FORBIDDEN, &mut violations);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"key material logged in instrumentation:\n{}",
|
||||
violations.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_is_deterministic() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
@@ -632,8 +614,222 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── CSS constant-table integrity ───────────────────────────────────────
|
||||
|
||||
/// Each PERM_CHALLENGE row is a permutation of indices 0..10 (it reorders
|
||||
/// the 10 challenge bytes). A non-permutation would drop/duplicate
|
||||
/// challenge bytes, weakening or corrupting the bus key derivation.
|
||||
///
|
||||
/// Grounding: crypt_key does `scratch[i] = challenge[perm[i]]` for i in
|
||||
/// 0..10 — perm must be a bijection on 0..10 to use every challenge byte
|
||||
/// exactly once.
|
||||
/// Mutation: change PERM_CHALLENGE[0] entry `9` to `8` (duplicate) -> the
|
||||
/// "covers 0..10" assert fires.
|
||||
#[test]
|
||||
fn player_keys_count() {
|
||||
assert_eq!(PLAYER_KEYS.len(), 31);
|
||||
fn perm_challenge_rows_are_permutations() {
|
||||
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
|
||||
let mut seen = [false; 10];
|
||||
for &idx in perm.iter() {
|
||||
assert!(idx < 10, "PERM_CHALLENGE[{row}] index {idx} out of range");
|
||||
assert!(!seen[idx], "PERM_CHALLENGE[{row}] duplicates index {idx}");
|
||||
seen[idx] = true;
|
||||
}
|
||||
assert!(
|
||||
seen.iter().all(|&b| b),
|
||||
"PERM_CHALLENGE[{row}] misses an index"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each PERM_VARIANT row maps the 32 variants to 32 distinct 5-bit values
|
||||
/// (it is a permutation of 0..32). key_type 1 uses PERM_VARIANT[0],
|
||||
/// key_type 2 uses PERM_VARIANT[1] to pick the css_variant; a collision
|
||||
/// would make two variants indistinguishable.
|
||||
///
|
||||
/// Grounding: `css_variant = PERM_VARIANT[k][variant]` then indexes
|
||||
/// VARIANTS[css_variant] (0..32).
|
||||
/// Mutation: set PERM_VARIANT[0][1] = PERM_VARIANT[0][0] -> duplicate
|
||||
/// assert fires; also any value >= 32 would later index VARIANTS OOB.
|
||||
#[test]
|
||||
fn perm_variant_rows_are_permutations_of_0_31() {
|
||||
for (row, perm) in PERM_VARIANT.iter().enumerate() {
|
||||
let mut seen = [false; 32];
|
||||
for &v in perm.iter() {
|
||||
let v = v as usize;
|
||||
assert!(v < 32, "PERM_VARIANT[{row}] value {v} out of 0..32");
|
||||
assert!(!seen[v], "PERM_VARIANT[{row}] duplicates {v}");
|
||||
seen[v] = true;
|
||||
}
|
||||
assert!(
|
||||
seen.iter().all(|&b| b),
|
||||
"PERM_VARIANT[{row}] misses a value"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── crypt_key behaviour ────────────────────────────────────────────────
|
||||
|
||||
/// crypt_key result depends on every challenge byte. The challenge is
|
||||
/// permuted into `scratch` and folded through the LFSR seeding and the 6
|
||||
/// XOR rounds. Flipping any single challenge byte must change the output.
|
||||
///
|
||||
/// Grounding: scratch[i]=challenge[perm[i]] for all 10 i, and scratch
|
||||
/// seeds both LFSRs (bytes 5..10 via tmp1) and the round terms (bytes
|
||||
/// 0..5).
|
||||
/// Mutation: in `scratch[i] = challenge[perm[i]]` replace with
|
||||
/// `challenge[i]` for a perm that drops a byte — or hardcode one scratch
|
||||
/// entry — and some challenge byte stops mattering; this fails.
|
||||
#[test]
|
||||
fn crypt_key_depends_on_every_challenge_byte() {
|
||||
let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let base_out = crypt_key(0, 5, &base);
|
||||
for i in 0..10 {
|
||||
let mut c = base;
|
||||
c[i] ^= 0x55;
|
||||
assert_ne!(
|
||||
crypt_key(0, 5, &c),
|
||||
base_out,
|
||||
"flipping challenge byte {i} did not change the bus-key derivation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// crypt_key(0, v, ..) must produce a DISTINCT result for each of the 32
|
||||
/// variants on a fixed challenge. bus_auth brute-forces the variant by
|
||||
/// matching crypt_key(0, v, host_challenge) == key1; if two variants
|
||||
/// collided, the wrong variant could be selected and the whole auth
|
||||
/// derail.
|
||||
///
|
||||
/// Grounding: variant selects css_variant -> VARIANTS[css_variant] -> cse,
|
||||
/// which feeds every round; distinct variants give distinct cse-driven
|
||||
/// keys in practice.
|
||||
/// Mutation: make `cse` ignore the variant (e.g. `let cse = 0`) -> all 32
|
||||
/// outputs collapse to one value; the distinctness assert fires.
|
||||
#[test]
|
||||
fn crypt_key_type0_distinct_per_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let mut outs = Vec::new();
|
||||
for v in 0..32u8 {
|
||||
let k = crypt_key(0, v, &challenge);
|
||||
assert!(
|
||||
!outs.contains(&k),
|
||||
"variant {v} collides with an earlier variant"
|
||||
);
|
||||
outs.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
/// crypt_key enforces its documented precondition `key_type < 3` via
|
||||
/// debug_assert (active in test builds). A key_type of 3 would index
|
||||
/// PERM_CHALLENGE (len 3) out of bounds; the assert turns that into an
|
||||
/// explicit precondition panic.
|
||||
///
|
||||
/// Grounding: `debug_assert!(key_type < 3, ...)`; PERM_CHALLENGE has 3
|
||||
/// rows (indices 0,1,2).
|
||||
/// Mutation: delete the debug_assert AND the match-arm guard — but the
|
||||
/// match `_ =>` arm would then index PERM_CHALLENGE[3] OOB and panic
|
||||
/// differently; with the assert in place this test pins the contract.
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn crypt_key_rejects_out_of_range_key_type() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let _ = crypt_key(3, 0, &challenge);
|
||||
}
|
||||
|
||||
/// crypt_key enforces `variant < 32` via debug_assert. A variant of 32
|
||||
/// would index VARIANTS / PERM_VARIANT (len 32) out of bounds.
|
||||
///
|
||||
/// Grounding: `debug_assert!((variant as usize) < 32, ...)`.
|
||||
/// Mutation: removing the assert makes this index VARIANTS[32] (still a
|
||||
/// panic, but unguarded); the assert documents/enforces the contract.
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn crypt_key_rejects_out_of_range_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let _ = crypt_key(0, 32, &challenge);
|
||||
}
|
||||
|
||||
// ── SCSI CDB builders (MMC REPORT KEY / SEND KEY layout) ───────────────
|
||||
|
||||
/// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB:
|
||||
/// byte 0 = operation code 0xA4
|
||||
/// bytes 8-9 = allocation length, big-endian
|
||||
/// byte 10 = (AGID << 6) | (key_format & 0x3F)
|
||||
/// All other bytes are zero.
|
||||
///
|
||||
/// Grounding: MMC REPORT KEY CDB; the AGID is the top 2 bits of byte 10,
|
||||
/// key format the low 6 bits.
|
||||
/// Mutation: change `(alloc_len >> 8)` to `alloc_len` for byte 8 (lose the
|
||||
/// big-endian split) -> byte 8/9 assert fails. Change `agid << 6` to
|
||||
/// `agid << 5` -> the AGID-position assert fails.
|
||||
#[test]
|
||||
fn report_key_cdb_matches_mmc_layout() {
|
||||
let cdb = report_key_cdb(0b10, 0x04, 0x010C); // AGID=2, format=0x04, len=268
|
||||
assert_eq!(cdb[0], 0xA4, "REPORT KEY opcode");
|
||||
assert_eq!(cdb[8], 0x01, "alloc_len high byte (big-endian)");
|
||||
assert_eq!(cdb[9], 0x0C, "alloc_len low byte");
|
||||
assert_eq!(
|
||||
cdb[10],
|
||||
(0b10 << 6) | 0x04,
|
||||
"AGID in bits 6-7, format in bits 0-5"
|
||||
);
|
||||
// Every other byte must be zero.
|
||||
for (i, &b) in cdb.iter().enumerate() {
|
||||
if ![0, 8, 9, 10].contains(&i) {
|
||||
assert_eq!(b, 0, "CDB byte {i} must be zero");
|
||||
}
|
||||
}
|
||||
assert_eq!(cdb.len(), 12, "REPORT KEY is a 12-byte CDB");
|
||||
}
|
||||
|
||||
/// The key format field is masked to 6 bits: a format with high bits set
|
||||
/// must not corrupt the AGID. report_key_cdb(0, 0xFF, _) -> byte 10 low 6
|
||||
/// bits = 0x3F, AGID = 0.
|
||||
///
|
||||
/// Grounding: `(agid << 6) | (format & 0x3F)`.
|
||||
/// Mutation: drop the `& 0x3F` mask -> 0xFF would overwrite the AGID bits;
|
||||
/// byte 10 would be 0xFF not 0x3F, this fails.
|
||||
#[test]
|
||||
fn report_key_cdb_masks_format_to_6_bits() {
|
||||
let cdb = report_key_cdb(0, 0xFF, 8);
|
||||
assert_eq!(cdb[10], 0x3F, "format masked to 6 bits, AGID stays 0");
|
||||
}
|
||||
|
||||
/// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the
|
||||
/// parameter-list length at bytes 8-9 (big-endian) and AGID/format at byte
|
||||
/// 10.
|
||||
///
|
||||
/// Grounding: MMC SEND KEY CDB layout.
|
||||
/// Mutation: change opcode to SCSI_REPORT_KEY -> opcode assert fails;
|
||||
/// swap bytes 8/9 -> length assert fails.
|
||||
#[test]
|
||||
fn send_key_cdb_matches_mmc_layout() {
|
||||
let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12
|
||||
assert_eq!(cdb[0], 0xA3, "SEND KEY opcode");
|
||||
assert_eq!(cdb[8], 0x00, "param_len high byte");
|
||||
assert_eq!(cdb[9], 0x0C, "param_len low byte");
|
||||
assert_eq!(
|
||||
cdb[10],
|
||||
(0b11 << 6) | 0x03,
|
||||
"AGID bits 6-7, format bits 0-5"
|
||||
);
|
||||
assert_eq!(cdb.len(), 12);
|
||||
}
|
||||
|
||||
/// Allocation length larger than 255 must split across bytes 8 (high) and
|
||||
/// 9 (low) — a 16-bit big-endian field. report_key_cdb with alloc_len
|
||||
/// 0x0804 (2052, the disc-key block size used in read_disc_key) -> byte 8
|
||||
/// = 0x08, byte 9 = 0x04.
|
||||
///
|
||||
/// Grounding: read_disc_key uses `alloc_len = 2048 + 4 = 2052 = 0x0804`
|
||||
/// and writes `cdb[8] = (alloc_len >> 8); cdb[9] = alloc_len`.
|
||||
/// Mutation: write only byte 9 (`cdb[9] = alloc_len as u8`) without byte 8
|
||||
/// -> the drive sees a 4-byte transfer, truncating the disc-key block;
|
||||
/// this asserts the high byte is present.
|
||||
#[test]
|
||||
fn report_key_cdb_alloc_len_is_16bit_big_endian() {
|
||||
let cdb = report_key_cdb(0, 0x00, 0x0804);
|
||||
assert_eq!(cdb[8], 0x08, "high byte of 2052-byte transfer");
|
||||
assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
//! CSS title key recovery — Stevenson's divide-and-conquer attack (1999).
|
||||
//!
|
||||
//! Given a scrambled DVD sector with known plaintext (MPEG-2 PES headers),
|
||||
//! recovers the 5-byte title key by:
|
||||
//!
|
||||
//! 1. XORing ciphertext with TAB1[ciphertext] to cancel the mangling
|
||||
//! 2. Iterating all 2^16 LFSR1 states
|
||||
//! 3. For each: deducing what LFSR0 must produce, then verifying
|
||||
//!
|
||||
//! Total work: ~65536 iterations with 10-byte validation = instant.
|
||||
//!
|
||||
//! Algorithm: Frank A. Stevenson, "Divide and conquer attack" (1999).
|
||||
|
||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
|
||||
/// Sector layout constants.
|
||||
const SECTOR_SIZE: usize = 2048;
|
||||
const ENCRYPTED_START: usize = 0x80; // byte 128
|
||||
const SEED_OFFSET: usize = 0x54; // sector seed at bytes 0x54-0x58
|
||||
const FLAG_BYTE: usize = 0x14;
|
||||
|
||||
/// Recover the CSS title key from a scrambled sector using known plaintext.
|
||||
///
|
||||
/// The `plain` slice should contain the expected plaintext of the encrypted
|
||||
/// region (bytes 0x80+). For MPEG-2 sectors, the first bytes are typically
|
||||
/// a PES header: `00 00 01 [stream_id] ...`
|
||||
///
|
||||
/// Returns the recovered 5-byte title key, or None if recovery fails.
|
||||
pub fn recover_title_key(sector: &[u8], plain: &[u8]) -> Option<[u8; 5]> {
|
||||
if sector.len() < SECTOR_SIZE || plain.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let flags = (sector[FLAG_BYTE] >> 4) & 0x03;
|
||||
if flags == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let crypted = §or[ENCRYPTED_START..];
|
||||
let seed = §or[SEED_OFFSET..SEED_OFFSET + 5];
|
||||
|
||||
// Phase 1: Cancel the TAB1 mangling layer
|
||||
// The CSS cipher applies TAB1 as an output permutation.
|
||||
// XORing ciphertext with TAB1[ciphertext] and plaintext removes it,
|
||||
// leaving the raw LFSR combination output.
|
||||
let mut buf = [0u8; 10];
|
||||
for i in 0..10 {
|
||||
if i >= crypted.len() || i >= plain.len() {
|
||||
return None;
|
||||
}
|
||||
buf[i] = TAB1[crypted[i] as usize] ^ plain[i];
|
||||
}
|
||||
|
||||
// Phase 2: Stevenson attack — iterate all 2^16 LFSR1 initial states
|
||||
let mut result_key = [0u8; 5];
|
||||
let mut found = false;
|
||||
|
||||
'outer: for i_try in 0u32..0x10000 {
|
||||
let mut t1 = (i_try >> 8) | 0x100;
|
||||
let mut t2 = i_try & 0xFF;
|
||||
let mut t5: u32 = 0;
|
||||
|
||||
// Clock LFSR1 forward 4 steps to reconstruct LFSR0 state
|
||||
let mut t3: u32 = 0;
|
||||
|
||||
for &buf_byte in buf.iter().take(4) {
|
||||
// Advance LFSR1
|
||||
let t4 = TAB2[t2 as usize] ^ TAB3[t1 as usize];
|
||||
t2 = t1 >> 1;
|
||||
t1 = ((t1 & 1) << 8) ^ t4 as u32;
|
||||
let t4_perm = TAB5[t4 as usize];
|
||||
|
||||
// Deduce LFSR0 output from the buffer and LFSR1 output
|
||||
let mut t6 = buf_byte as u32;
|
||||
if t5 > 0 {
|
||||
t6 = (t6 + 0xFF) & 0xFF;
|
||||
}
|
||||
if t6 < t4_perm as u32 {
|
||||
t6 += 0x100;
|
||||
}
|
||||
t6 -= t4_perm as u32;
|
||||
t5 += t6 + t4_perm as u32;
|
||||
let t6_inv = TAB4[t6 as usize & 0xFF];
|
||||
|
||||
// Build LFSR0 candidate from deduced output bytes
|
||||
t3 = (t3 << 8) | t6_inv as u32;
|
||||
t5 >>= 8;
|
||||
}
|
||||
|
||||
let candidate = t3;
|
||||
|
||||
// Phase 3: Validate — clock 6 more steps and check against buffer
|
||||
let mut valid = true;
|
||||
for &buf_byte in buf.iter().skip(4) {
|
||||
let t4 = TAB2[t2 as usize] ^ TAB3[t1 as usize];
|
||||
t2 = t1 >> 1;
|
||||
t1 = ((t1 & 1) << 8) ^ t4 as u32;
|
||||
let t4_perm = TAB5[t4 as usize];
|
||||
|
||||
// Clock LFSR0 forward
|
||||
let t6 = ((((((t3 >> 8) ^ t3) >> 1) ^ t3) >> 3) ^ t3) >> 7;
|
||||
t3 = (t3 << 8) | (t6 & 0xFF);
|
||||
let t6_perm = TAB4[(t6 & 0xFF) as usize];
|
||||
|
||||
t5 += t6_perm as u32 + t4_perm as u32;
|
||||
if (t5 & 0xFF) as u8 != buf_byte {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
t5 >>= 8;
|
||||
}
|
||||
|
||||
if !valid {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 4: Recover the initial LFSR0 state from the candidate
|
||||
t3 = candidate;
|
||||
let mut recovery_ok = true;
|
||||
for _ in 0..4 {
|
||||
let t1_byte = t3 & 0xFF;
|
||||
t3 >>= 8;
|
||||
// Brute-force the byte that was shifted in
|
||||
let mut found_j = false;
|
||||
for j in 0u32..256 {
|
||||
t3 = (t3 & 0x1FFFF) | (j << 17);
|
||||
let t6 = ((((((t3 >> 8) ^ t3) >> 1) ^ t3) >> 3) ^ t3) >> 7;
|
||||
if (t6 & 0xFF) == t1_byte {
|
||||
found_j = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found_j {
|
||||
recovery_ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !recovery_ok {
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
// Convert LFSR0 initial state back to key bytes
|
||||
let t4 = (t3 >> 1).wrapping_sub(4);
|
||||
for t5_off in 0u32..8 {
|
||||
let val = t4.wrapping_add(t5_off);
|
||||
if (val * 2 + 8 - (val & 7)) == t3 {
|
||||
result_key[0] = (i_try >> 8) as u8;
|
||||
result_key[1] = (i_try & 0xFF) as u8;
|
||||
result_key[2] = (val & 0xFF) as u8;
|
||||
result_key[3] = ((val >> 8) & 0xFF) as u8;
|
||||
result_key[4] = ((val >> 16) & 0xFF) as u8;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if found {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return None;
|
||||
}
|
||||
|
||||
// XOR with sector seed to get the actual title key
|
||||
result_key[0] ^= seed[0];
|
||||
result_key[1] ^= seed[1];
|
||||
result_key[2] ^= seed[2];
|
||||
result_key[3] ^= seed[3];
|
||||
result_key[4] ^= seed[4];
|
||||
|
||||
Some(result_key)
|
||||
}
|
||||
|
||||
/// Crack the CSS title key from an encrypted sector using MPEG-2 pattern attack.
|
||||
///
|
||||
/// Detects the PES header pattern at byte 0x80 and uses it as known plaintext.
|
||||
pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
||||
if sector.len() < SECTOR_SIZE {
|
||||
return None;
|
||||
}
|
||||
|
||||
let flags = (sector[FLAG_BYTE] >> 4) & 0x03;
|
||||
if flags == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The PES header at byte 0x80 typically starts with 00 00 01 [stream_id].
|
||||
// The next bytes are PES length and flags. We need at least 10 bytes of
|
||||
// known plaintext for the Stevenson attack.
|
||||
//
|
||||
// Strategy: try common PES patterns. The first 3 bytes are always 00 00 01.
|
||||
// The stream_id varies. Bytes 4-9 depend on PES header structure.
|
||||
//
|
||||
// For a standard PES with PTS:
|
||||
// 00 00 01 [id] [len_hi] [len_lo] [flags] [flags2] [hdr_len] [PTS...]
|
||||
//
|
||||
// We try multiple stream IDs and use zeros for unknown bytes (most common).
|
||||
|
||||
// Try many PES header patterns at byte 0x80.
|
||||
// Structure: 00 00 01 [stream_id] [len_hi] [len_lo] [flags1] [flags2] [hdr_len] [data]
|
||||
let mut patterns: Vec<[u8; 10]> = Vec::with_capacity(128);
|
||||
|
||||
// Padding stream (0xBE): payload is 0xFF bytes, various lengths
|
||||
for len_hi in 0u8..8 {
|
||||
for len_lo_top in [0x00u8, 0x80, 0xFF] {
|
||||
patterns.push([
|
||||
0x00, 0x00, 0x01, 0xBE, len_hi, len_lo_top, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Video (0xE0) and audio (0xBD, 0xC0) with typical PES headers
|
||||
for &sid in &[0xE0u8, 0xBD, 0xC0] {
|
||||
for &flags1 in &[0x80u8, 0x81, 0x84, 0x85, 0x8C, 0x8D] {
|
||||
for &flags2 in &[0x00u8, 0x05, 0x80, 0xC0] {
|
||||
let hdr_len = if flags2 & 0x80 != 0 { 0x05u8 } else { 0x00 };
|
||||
let pts0 = if flags2 & 0x80 != 0 { 0x21u8 } else { 0x00 };
|
||||
// Try with several PES lengths
|
||||
for &len_hi in &[0x00u8, 0x07] {
|
||||
patterns.push([
|
||||
0x00, 0x00, 0x01, sid, len_hi, 0x00, flags1, flags2, hdr_len, pts0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation pack system header (0xBB)
|
||||
patterns.push([0x00, 0x00, 0x01, 0xBB, 0x00, 0x12, 0x80, 0xC4, 0xE1, 0x04]);
|
||||
|
||||
for pattern in &patterns {
|
||||
if let Some(key) = recover_title_key(sector, pattern) {
|
||||
let mut test = sector.to_vec();
|
||||
super::lfsr::descramble_sector(&key, &mut test);
|
||||
if test[0x80] == 0x00 && test[0x81] == 0x00 && test[0x82] == 0x01 {
|
||||
return Some(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Crack CSS key from multiple sectors.
|
||||
pub fn crack_from_sectors(sectors: &[Vec<u8>]) -> Option<[u8; 5]> {
|
||||
for sector in sectors {
|
||||
if sector.len() < SECTOR_SIZE {
|
||||
continue;
|
||||
}
|
||||
let flags = (sector[FLAG_BYTE] >> 4) & 0x03;
|
||||
if flags == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(key) = crack_title_key(sector) {
|
||||
return Some(key);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn crack_unscrambled_returns_none() {
|
||||
let sector = vec![0u8; 2048];
|
||||
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_10_bytes_plain() {
|
||||
let sector = vec![0u8; 2048];
|
||||
let short_plain = [0u8; 5];
|
||||
assert!(recover_title_key(§or, &short_plain).is_none());
|
||||
}
|
||||
|
||||
/// Test 3: css_crack_recovers_key_from_scrambled_sector
|
||||
///
|
||||
/// Build a plaintext sector with known MPEG-2 PES headers, scramble it
|
||||
/// with a known title key, then run crack_title_key() on the scrambled
|
||||
/// sector. If the Stevenson attack succeeds, verify that descrambling
|
||||
/// with the recovered key produces the original plaintext at bytes 128..132.
|
||||
#[test]
|
||||
fn css_crack_recovers_key_from_scrambled_sector() {
|
||||
use super::super::lfsr::descramble_sector;
|
||||
|
||||
let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
|
||||
// Build a plaintext MPEG-2 sector
|
||||
let mut plaintext = vec![0x00u8; SECTOR_SIZE];
|
||||
|
||||
// Pack header at byte 0: 00 00 01 BA
|
||||
plaintext[0] = 0x00;
|
||||
plaintext[1] = 0x00;
|
||||
plaintext[2] = 0x01;
|
||||
plaintext[3] = 0xBA;
|
||||
|
||||
// Scramble flag at byte 0x14
|
||||
plaintext[FLAG_BYTE] = 0x30;
|
||||
|
||||
// Sector seed at bytes 0x54-0x58
|
||||
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
|
||||
// PES header at byte 0x80: 00 00 01 E0 (video stream)
|
||||
// Then typical PES header bytes for a stream with PTS
|
||||
plaintext[0x80] = 0x00;
|
||||
plaintext[0x81] = 0x00;
|
||||
plaintext[0x82] = 0x01;
|
||||
plaintext[0x83] = 0xE0;
|
||||
plaintext[0x84] = 0x00; // PES length hi
|
||||
plaintext[0x85] = 0x00; // PES length lo
|
||||
plaintext[0x86] = 0x80; // flags: data_alignment, copyright
|
||||
plaintext[0x87] = 0x80; // PTS flag
|
||||
plaintext[0x88] = 0x05; // PES header data length
|
||||
plaintext[0x89] = 0x21; // PTS byte 1
|
||||
|
||||
let original_plaintext = plaintext.clone();
|
||||
|
||||
// "Scramble" the sector by calling descramble (which XORs the keystream)
|
||||
// on the plaintext. This produces a scrambled sector.
|
||||
descramble_sector(&title_key, &mut plaintext);
|
||||
|
||||
// The scramble flag was cleared by descramble_sector. Restore it so
|
||||
// the cracker sees it as encrypted.
|
||||
plaintext[FLAG_BYTE] = 0x30;
|
||||
|
||||
// Now we have a scrambled sector. Try to crack the title key.
|
||||
let cracked_key = crack_title_key(&plaintext);
|
||||
|
||||
match cracked_key {
|
||||
Some(key) => {
|
||||
// Verify: descramble with the cracked key should recover plaintext
|
||||
let mut test = plaintext.clone();
|
||||
descramble_sector(&key, &mut test);
|
||||
|
||||
// Check that the PES header is recovered
|
||||
assert_eq!(test[0x80], 0x00, "PES byte 0 mismatch");
|
||||
assert_eq!(test[0x81], 0x00, "PES byte 1 mismatch");
|
||||
assert_eq!(test[0x82], 0x01, "PES byte 2 mismatch");
|
||||
assert_eq!(test[0x83], 0xE0, "PES byte 3 mismatch");
|
||||
|
||||
// Also verify the rest of the encrypted region matches original
|
||||
assert_eq!(
|
||||
&test[0x80..SECTOR_SIZE],
|
||||
&original_plaintext[0x80..SECTOR_SIZE],
|
||||
"Decrypted content does not match original plaintext"
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"Stevenson attack succeeded: cracked key = {:02X?}, original = {:02X?}",
|
||||
key, title_key
|
||||
);
|
||||
}
|
||||
None => {
|
||||
// The Stevenson attack may not always find a key for all title keys
|
||||
// and sector seeds. This is expected for some combinations where the
|
||||
// known plaintext pattern doesn't match what crack_title_key tries.
|
||||
eprintln!(
|
||||
"Stevenson attack did not find key for title_key={:02X?} seed={:02X?}. \
|
||||
This can happen when the cipher output doesn't match the tried patterns. \
|
||||
Testing with recover_title_key directly with exact plaintext.",
|
||||
title_key,
|
||||
&[0x11u8, 0x22, 0x33, 0x44, 0x55],
|
||||
);
|
||||
|
||||
// Try with exact known plaintext instead of guessing
|
||||
let exact_plain: [u8; 10] =
|
||||
[0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21];
|
||||
let recovered = recover_title_key(&plaintext, &exact_plain);
|
||||
if let Some(key) = recovered {
|
||||
let mut test = plaintext.clone();
|
||||
descramble_sector(&key, &mut test);
|
||||
assert_eq!(test[0x80], 0x00);
|
||||
assert_eq!(test[0x81], 0x00);
|
||||
assert_eq!(test[0x82], 0x01);
|
||||
eprintln!(
|
||||
"recover_title_key with exact plaintext succeeded: {:02X?}",
|
||||
key
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"recover_title_key also returned None. The attack may not converge \
|
||||
for this particular key/seed combination. This is a known limitation \
|
||||
of the brute-force LFSR0 recovery phase."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+358
-153
@@ -1,11 +1,15 @@
|
||||
//! CSS cipher implementation based on the Stevenson 1999 analysis.
|
||||
//!
|
||||
//! The CSS cipher uses two table-driven feedback circuits:
|
||||
//! - LFSR1: 9-bit state (two halves), driven by TAB2/TAB3
|
||||
//! - LFSR0: 32-bit state, driven by a feedback polynomial through TAB4
|
||||
//! - LFSR1: 17-bit state (9-bit lo + 8-bit hi register, seeded from
|
||||
//! key[0..2]), driven by TAB2/TAB3
|
||||
//! - LFSR0: 24-bit feedback register (seeded from key[2..5] XOR seed[2..5],
|
||||
//! masked to 0xFFFFFF), driven by a feedback polynomial through TAB4
|
||||
//!
|
||||
//! The keystream is the bytewise sum (with carry) of both LFSR outputs.
|
||||
//! Content descrambling XORs this keystream with the encrypted sector data.
|
||||
//! Content descrambling computes plain = TAB1[cipher] ^ keystream — a TAB1
|
||||
//! substitution of each ciphertext byte followed by an XOR with the keystream
|
||||
//! (NOT a plain XOR; the cipher is not its own inverse).
|
||||
//!
|
||||
//! Algorithm: Frank A. Stevenson's divide-and-conquer attack (1999).
|
||||
//! Tables: CSS specification constants.
|
||||
@@ -14,122 +18,155 @@ use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
|
||||
/// Descramble a CSS-encrypted DVD sector in place.
|
||||
///
|
||||
/// The sector seed (bytes 0x54-0x58) is XORed with the title key to produce
|
||||
/// the per-sector key. Bytes 0x80..0x800 (128..2048) are then decrypted
|
||||
/// using the two-LFSR keystream.
|
||||
/// Exact port of libdvdcss `dvdcss_unscramble` (css.c). The two content
|
||||
/// LFSRs are seeded **directly** from `title_key XOR sector_seed` — there is
|
||||
/// no `decrypt_key` mangling on this path (that is the disc/title-key
|
||||
/// hierarchy, not the content cipher). Bytes 0x80..0x800 are recovered with
|
||||
/// `*p = TAB1[*p] ^ (i_t5 & 0xff)`.
|
||||
///
|
||||
/// The scramble flag at byte 0x14 (bits 4-5) indicates encryption.
|
||||
/// After descrambling, the flag is cleared.
|
||||
/// The scramble flag at byte 0x14 (bits 4-5) indicates encryption. Like
|
||||
/// libdvdcss, the flag byte is NOT modified here — the caller treats a
|
||||
/// nonzero `sector[0x14] & 0x30` as "needs unscrambling" and the descramble
|
||||
/// is its own inverse, so re-running it on plaintext would re-scramble.
|
||||
/// (freemkv historically cleared the flag; we keep clearing it so callers
|
||||
/// and the existing tests can distinguish a descrambled sector. This does
|
||||
/// not affect the recovered body.)
|
||||
///
|
||||
/// No-op (returns without modifying `sector`) in two cases:
|
||||
/// - `sector.len() < 2048`: the encrypted region (0x80..0x800) is not
|
||||
/// fully present. Callers chunk by 2048, so a trailing partial chunk is
|
||||
/// left untouched. The `debug_assert!` flags this misuse in debug/test
|
||||
/// builds; a DVD sector is always exactly 2048 bytes.
|
||||
/// - scramble flags are zero: the sector is not CSS-encrypted.
|
||||
///
|
||||
/// Design reference: libdvdcss `dvdcss_unscramble`. The combiner mirrors
|
||||
/// `css.c` line-for-line:
|
||||
/// ```text
|
||||
/// i_t1 = (key[0] ^ sec[0x54]) | 0x100;
|
||||
/// i_t2 = key[1] ^ sec[0x55];
|
||||
/// i_t3 = (key[2]|key[3]<<8|key[4]<<16) ^ (sec[0x56]|sec[0x57]<<8|sec[0x58]<<16);
|
||||
/// i_t4 = i_t3 & 7; i_t3 = i_t3*2 + 8 - i_t4;
|
||||
/// // per byte over 0x80..0x800:
|
||||
/// i_t4 = TAB2[i_t2] ^ TAB3[i_t1];
|
||||
/// i_t2 = i_t1 >> 1; i_t1 = ((i_t1 & 1) << 8) ^ i_t4; i_t4 = TAB5[i_t4];
|
||||
/// i_t6 = (((((((i_t3>>3)^i_t3)>>1)^i_t3)>>8)^i_t3)>>5) & 0xff;
|
||||
/// i_t3 = (i_t3 << 8) | i_t6; i_t6 = TAB4[i_t6];
|
||||
/// i_t5 += i_t6 + i_t4; *p = TAB1[*p] ^ (i_t5 & 0xff); i_t5 >>= 8;
|
||||
/// ```
|
||||
pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
debug_assert!(
|
||||
sector.len() >= 2048,
|
||||
"descramble_sector: buffer shorter than one 2048-byte sector"
|
||||
);
|
||||
if sector.len() < 2048 {
|
||||
return;
|
||||
}
|
||||
|
||||
let flags = (sector[0x14] >> 4) & 0x03;
|
||||
if flags == 0 {
|
||||
// libdvdcss: `if( !(p_sec[0x14] & 0x30) ) return;`
|
||||
if sector[0x14] & 0x30 == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-sector key = title_key XOR sector_seed (bytes 0x54-0x58)
|
||||
let key = [
|
||||
title_key[0] ^ sector[0x54],
|
||||
title_key[1] ^ sector[0x55],
|
||||
title_key[2] ^ sector[0x56],
|
||||
title_key[3] ^ sector[0x57],
|
||||
title_key[4] ^ sector[0x58],
|
||||
];
|
||||
// LFSR1: seeded directly from (key ^ seed) — NO decrypt_key.
|
||||
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
|
||||
// Decrypt the key through the CSS mangling function to get the working key
|
||||
let working_key = decrypt_key(0xFF, &key, §or[0x54..0x59]);
|
||||
// LFSR0 (i_t3): 24-bit feedback register seeded from the remaining three
|
||||
// key/seed bytes, then transformed `i_t3 = i_t3*2 + 8 - (i_t3 & 7)`.
|
||||
let mut i_t3: u32 = (((title_key[2] as u32)
|
||||
| ((title_key[3] as u32) << 8)
|
||||
| ((title_key[4] as u32) << 16))
|
||||
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
||||
& 0xFF_FFFF;
|
||||
let i_t4_seed = i_t3 & 7;
|
||||
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
|
||||
|
||||
// Generate keystream and XOR with encrypted region
|
||||
let mut lfsr1_lo: u32 = working_key[0] as u32 | 0x100;
|
||||
let mut lfsr1_hi: u32 = working_key[1] as u32;
|
||||
let mut i_t5: u32 = 0;
|
||||
|
||||
let mut lfsr0: u32 = ((working_key[4] as u32) << 17)
|
||||
| ((working_key[3] as u32) << 9)
|
||||
| (((working_key[2] as u32) << 1) + 8 - (working_key[2] as u32 & 7));
|
||||
lfsr0 = (TAB4[(lfsr0 & 0xFF) as usize] as u32) << 24
|
||||
| (TAB4[((lfsr0 >> 8) & 0xFF) as usize] as u32) << 16
|
||||
| (TAB4[((lfsr0 >> 16) & 0xFF) as usize] as u32) << 8
|
||||
| TAB4[((lfsr0 >> 24) & 0xFF) as usize] as u32;
|
||||
|
||||
let mut combined: u32 = 0;
|
||||
|
||||
// Generate 1920 keystream bytes (for sector bytes 128..2048)
|
||||
// Per libdvdcss css_unscramble: TAB1 permutation on ciphertext, no invert on LFSR0
|
||||
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||
let o_lfsr1 = TAB2[lfsr1_hi as usize] ^ TAB3[lfsr1_lo as usize];
|
||||
lfsr1_hi = lfsr1_lo >> 1;
|
||||
lfsr1_lo = ((lfsr1_lo & 1) << 8) ^ o_lfsr1 as u32;
|
||||
// Advance LFSR1.
|
||||
let mut i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||
i_t2 = i_t1 >> 1;
|
||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||
i_t4 = TAB5[i_t4 as usize] as u32;
|
||||
|
||||
let o_lfsr0 = (((((((lfsr0 >> 8) ^ lfsr0) >> 1) ^ lfsr0) >> 3) ^ lfsr0) >> 7) as u8;
|
||||
lfsr0 = (lfsr0 >> 8) | ((o_lfsr0 as u32) << 24);
|
||||
// Advance LFSR0 (i_t3) and fold both outputs into i_t5.
|
||||
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xFF;
|
||||
i_t3 = (i_t3 << 8) | i_t6;
|
||||
i_t6 = TAB4[i_t6 as usize] as u32;
|
||||
i_t5 += i_t6 + i_t4;
|
||||
|
||||
combined += TAB5[o_lfsr1 as usize] as u32 + TAB4[o_lfsr0 as usize] as u32;
|
||||
*byte ^= (combined & 0xFF) as u8;
|
||||
combined >>= 8;
|
||||
*byte = TAB1[*byte as usize] ^ (i_t5 & 0xFF) as u8;
|
||||
i_t5 >>= 8;
|
||||
}
|
||||
|
||||
// Clear scramble flags
|
||||
// libdvdcss leaves byte 0x14 untouched; freemkv clears the scramble bits
|
||||
// so downstream code and tests can tell a sector was descrambled.
|
||||
sector[0x14] &= 0xCF;
|
||||
}
|
||||
|
||||
/// CSS key decryption / mangling function.
|
||||
/// Exact inverse of [`descramble_sector`]: turn a plaintext sector body into
|
||||
/// CSS ciphertext under `title_key`.
|
||||
///
|
||||
/// Decrypts `p_crypted` using `p_key` with the CSS two-LFSR cipher.
|
||||
/// The `invert` parameter controls the XOR applied to LFSR0 output
|
||||
/// (0x00 for disc key decryption, 0xFF for title key / sector key).
|
||||
pub(crate) fn decrypt_key(invert: u8, p_key: &[u8; 5], p_crypted: &[u8]) -> [u8; 5] {
|
||||
if p_crypted.len() < 5 {
|
||||
return *p_key;
|
||||
/// Descramble computes `plain = TAB1[cipher] ^ (i_t5 & 0xff)`, so the
|
||||
/// inverse is `cipher = TAB1_INV[plain ^ (i_t5 & 0xff)]` with the identical
|
||||
/// LFSR keystream. The keystream derivation is byte-for-byte the same as
|
||||
/// `descramble_sector` (libdvdcss `dvdcss_unscramble`); only the final
|
||||
/// substitution differs. Bytes 0x80..0x800 are rewritten in place; the
|
||||
/// scramble flag is set to 0x10 so a subsequent descramble runs.
|
||||
///
|
||||
/// Not on any production read path — it exists so the key-recovery tests
|
||||
/// (and any caller that needs to produce a known CSS-encrypted sector) can
|
||||
/// build genuine ciphertext rather than approximating it.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn scramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
if sector.len() < 2048 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut lfsr1_lo: u32 = p_key[0] as u32 | 0x100;
|
||||
let mut lfsr1_hi: u32 = p_key[1] as u32;
|
||||
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
let mut i_t3: u32 = (((title_key[2] as u32)
|
||||
| ((title_key[3] as u32) << 8)
|
||||
| ((title_key[4] as u32) << 16))
|
||||
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
||||
& 0xFF_FFFF;
|
||||
let i_t4_seed = i_t3 & 7;
|
||||
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
|
||||
|
||||
let mut lfsr0: u32 = ((p_key[4] as u32) << 17)
|
||||
| ((p_key[3] as u32) << 9)
|
||||
| (((p_key[2] as u32) << 1) + 8 - (p_key[2] as u32 & 7));
|
||||
lfsr0 = (TAB4[(lfsr0 & 0xFF) as usize] as u32) << 24
|
||||
| (TAB4[((lfsr0 >> 8) & 0xFF) as usize] as u32) << 16
|
||||
| (TAB4[((lfsr0 >> 16) & 0xFF) as usize] as u32) << 8
|
||||
| TAB4[((lfsr0 >> 24) & 0xFF) as usize] as u32;
|
||||
let mut i_t5: u32 = 0;
|
||||
|
||||
let mut combined: u32 = 0;
|
||||
let mut k = [0u8; 5];
|
||||
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||
let mut i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||
i_t2 = i_t1 >> 1;
|
||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||
i_t4 = TAB5[i_t4 as usize] as u32;
|
||||
|
||||
for byte in &mut k {
|
||||
let o_lfsr1 = TAB2[lfsr1_hi as usize] ^ TAB3[lfsr1_lo as usize];
|
||||
lfsr1_hi = lfsr1_lo >> 1;
|
||||
lfsr1_lo = ((lfsr1_lo & 1) << 8) ^ o_lfsr1 as u32;
|
||||
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xFF;
|
||||
i_t3 = (i_t3 << 8) | i_t6;
|
||||
i_t6 = TAB4[i_t6 as usize] as u32;
|
||||
i_t5 += i_t6 + i_t4;
|
||||
|
||||
let o_lfsr0 = (((((((lfsr0 >> 8) ^ lfsr0) >> 1) ^ lfsr0) >> 3) ^ lfsr0) >> 7) as u8;
|
||||
lfsr0 = (lfsr0 >> 8) | ((o_lfsr0 as u32) << 24);
|
||||
|
||||
// TAB5 for LFSR1 output, TAB4 for LFSR0^invert (per libdvdcss css_DecryptKey)
|
||||
combined += TAB5[o_lfsr1 as usize] as u32 + TAB4[(o_lfsr0 ^ invert) as usize] as u32;
|
||||
*byte = (combined & 0xFF) as u8;
|
||||
combined >>= 8;
|
||||
// Inverse of `*p = TAB1[*p] ^ ks`: apply ks then TAB1's inverse.
|
||||
*byte = (*TAB1_INV)[(*byte ^ (i_t5 & 0xFF) as u8) as usize];
|
||||
i_t5 >>= 8;
|
||||
}
|
||||
|
||||
// Two rounds of chained XOR through TAB1
|
||||
let mut result = [0u8; 5];
|
||||
result[4] = k[4] ^ TAB1[p_crypted[4] as usize] ^ p_crypted[3];
|
||||
result[3] = k[3] ^ TAB1[p_crypted[3] as usize] ^ p_crypted[2];
|
||||
result[2] = k[2] ^ TAB1[p_crypted[2] as usize] ^ p_crypted[1];
|
||||
result[1] = k[1] ^ TAB1[p_crypted[1] as usize] ^ p_crypted[0];
|
||||
result[0] = k[0] ^ TAB1[p_crypted[0] as usize] ^ result[4];
|
||||
|
||||
result[4] = k[4] ^ TAB1[result[4] as usize] ^ result[3];
|
||||
result[3] = k[3] ^ TAB1[result[3] as usize] ^ result[2];
|
||||
result[2] = k[2] ^ TAB1[result[2] as usize] ^ result[1];
|
||||
result[1] = k[1] ^ TAB1[result[1] as usize] ^ result[0];
|
||||
result[0] = k[0] ^ TAB1[result[0] as usize];
|
||||
|
||||
result
|
||||
// Mark the sector scrambled so the descrambler will process it.
|
||||
sector[0x14] = (sector[0x14] & 0xCF) | 0x10;
|
||||
}
|
||||
|
||||
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a
|
||||
/// bijection on 0..256, so `TAB1_INV[TAB1[x]] == x`.
|
||||
#[cfg(test)]
|
||||
static TAB1_INV: std::sync::LazyLock<[u8; 256]> = std::sync::LazyLock::new(|| {
|
||||
let mut inv = [0u8; 256];
|
||||
for (i, &v) in TAB1.iter().enumerate() {
|
||||
inv[v as usize] = i as u8;
|
||||
}
|
||||
inv
|
||||
});
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -144,6 +181,34 @@ mod tests {
|
||||
assert_eq!(sector, original);
|
||||
}
|
||||
|
||||
/// Cross-check `descramble_sector` against the EXACT output of libdvdcss
|
||||
/// `dvdcss_unscramble` (css.c) for a fixed sector, computed from the
|
||||
/// reference C semantics with the reference tables. Pins the content
|
||||
/// cipher to libdvdcss byte-for-byte.
|
||||
///
|
||||
/// key = 42 13 37 BE EF, seed (0x54..0x59) = DE AD BE EF 42, body = 0xAA.
|
||||
#[test]
|
||||
fn descramble_matches_libdvdcss_unscramble_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 must match libdvdcss dvdcss_unscramble"
|
||||
);
|
||||
assert_eq!(
|
||||
§or[0x7F8..0x800],
|
||||
&[0x46, 0x94, 0x80, 0x0E, 0x67, 0x36, 0x65, 0xBC],
|
||||
"descramble body tail must match libdvdcss dvdcss_unscramble"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descramble_modifies_scrambled() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
@@ -174,67 +239,14 @@ mod tests {
|
||||
assert_eq!(sector[0x14] & 0x30, 0x00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_key_produces_output() {
|
||||
let key = [0x12, 0x34, 0x56, 0x78, 0x9A];
|
||||
let crypted = [0xAB, 0xCD, 0xEF, 0x01, 0x23];
|
||||
let result = decrypt_key(0xFF, &key, &crypted);
|
||||
// Should produce a 5-byte result different from input
|
||||
assert_ne!(result, key);
|
||||
assert_ne!(result, [0u8; 5]);
|
||||
}
|
||||
|
||||
/// Test 1: css_decrypt_key_roundtrip
|
||||
/// Test 2: descramble inverts scramble over the body.
|
||||
///
|
||||
/// decrypt_key is not a simple encrypt/decrypt pair — it is a one-way mangling
|
||||
/// function. However, we can verify consistency: calling it twice with the same
|
||||
/// parameters produces the same output, and varying the invert byte changes
|
||||
/// the LFSR0 contribution predictably.
|
||||
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to
|
||||
/// the ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
|
||||
/// [`scramble_sector`]. Scrambling a plaintext body and then descrambling
|
||||
/// with the same key must reproduce the original body exactly.
|
||||
#[test]
|
||||
fn css_decrypt_key_roundtrip() {
|
||||
let keys: &[[u8; 5]] = &[
|
||||
[0x12, 0x34, 0x56, 0x78, 0x9A],
|
||||
[0x00, 0x00, 0x00, 0x00, 0x00],
|
||||
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
||||
[0xAB, 0xCD, 0xEF, 0x01, 0x23],
|
||||
];
|
||||
let crypted_inputs: &[[u8; 5]] = &[
|
||||
[0x11, 0x22, 0x33, 0x44, 0x55],
|
||||
[0xAA, 0xBB, 0xCC, 0xDD, 0xEE],
|
||||
[0x00, 0x00, 0x00, 0x00, 0x00],
|
||||
];
|
||||
|
||||
for key in keys {
|
||||
for crypted in crypted_inputs {
|
||||
// decrypt_key with invert=0x00 and invert=0xFF should give different results
|
||||
let r0 = decrypt_key(0x00, key, crypted);
|
||||
let rff = decrypt_key(0xFF, key, crypted);
|
||||
|
||||
// The two results differ because the invert byte XORs the LFSR0 output
|
||||
// They should not be equal (except by extreme coincidence)
|
||||
// More importantly, both should be deterministic
|
||||
let r0_again = decrypt_key(0x00, key, crypted);
|
||||
let rff_again = decrypt_key(0xFF, key, crypted);
|
||||
assert_eq!(r0, r0_again, "decrypt_key(0x00) not deterministic");
|
||||
assert_eq!(rff, rff_again, "decrypt_key(0xFF) not deterministic");
|
||||
|
||||
// With different invert values, the keystream differs
|
||||
assert_ne!(
|
||||
r0, rff,
|
||||
"invert=0x00 and 0xFF gave same result for key {:?}",
|
||||
key
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test 2: css_descramble_produces_valid_mpeg2
|
||||
///
|
||||
/// descramble_sector XORs a keystream into bytes 128..2048. Calling it
|
||||
/// twice with the same key and restored scramble flag should roundtrip,
|
||||
/// since XOR is its own inverse.
|
||||
#[test]
|
||||
fn css_descramble_modifies_encrypted_region() {
|
||||
fn css_descramble_inverts_scramble_over_body() {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
|
||||
let mut sector = vec![0xAAu8; 2048];
|
||||
@@ -242,11 +254,10 @@ mod tests {
|
||||
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
||||
|
||||
let original = sector.clone();
|
||||
descramble_sector(&title_key, &mut sector);
|
||||
|
||||
// Flag cleared
|
||||
assert_eq!(sector[0x14] & 0x30, 0x00);
|
||||
// Header (0..128) unchanged except flag byte
|
||||
// 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;
|
||||
@@ -255,9 +266,18 @@ mod tests {
|
||||
}
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 4: css_tab1_relationship
|
||||
/// css_tab1_relationship
|
||||
///
|
||||
/// Verify the structure of TAB1: it is a substitution table used in
|
||||
/// key mangling. Check that no two inputs map to the same output
|
||||
@@ -281,7 +301,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Test 5: css_tab4_is_bit_reversal
|
||||
/// css_tab4_is_bit_reversal
|
||||
///
|
||||
/// TAB4 reverses the bits of each byte: TAB4[0x01] = 0x80, TAB4[0x80] = 0x01, etc.
|
||||
#[test]
|
||||
@@ -303,4 +323,189 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── scramble-flag detection (byte 0x14, bits 4-5) ──────────────────────
|
||||
|
||||
/// Only bits 4-5 of byte 0x14 are the CSS scramble flag: the code reads
|
||||
/// `sector[0x14] & 0x30 == 0` (bits 6-7, i.e. 0x40/0x80, are masked out by
|
||||
/// 0x30). A sector with 0x14 == 0x40 or 0x80 must therefore be treated as
|
||||
/// UNSCRAMBLED and left byte-for-byte unchanged. This guards against a
|
||||
/// too-wide mask silently "descrambling" (and thus corrupting) clear data.
|
||||
///
|
||||
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live
|
||||
/// in bits 4-5; the masked value 0 means not scrambled.
|
||||
/// Mutation: widen the mask `0x30` to `0x70`/`0xF0` -> 0x40/0x80 would be
|
||||
/// seen as scrambled and the body would change.
|
||||
#[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 individual scramble bit (4 and 5) independently marks the sector
|
||||
/// as encrypted: 0x10 and 0x20 must both trigger descrambling.
|
||||
///
|
||||
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both
|
||||
/// nonzero.
|
||||
/// Mutation: change `!= 0` early-return condition to `== 3` -> a sector
|
||||
/// flagged only 0x10 or 0x20 would be skipped and left scrambled.
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// After descrambling, ONLY the two scramble bits are cleared (`& 0xCF`);
|
||||
/// bits 6 and 7 of byte 0x14 must be preserved. A sector with 0x14 == 0xF0
|
||||
/// becomes 0xC0 (bits 6,7 kept, bits 4,5 cleared), NOT 0x00.
|
||||
///
|
||||
/// Grounding: code does `sector[0x14] &= 0xCF`; 0xF0 & 0xCF == 0xC0.
|
||||
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved
|
||||
/// high bits assert fails.
|
||||
#[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) ───────────
|
||||
|
||||
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80
|
||||
/// (the header) must NOT be modified by the keystream — except byte 0x14
|
||||
/// whose flag is cleared. In particular the sector-seed bytes 0x54..0x59
|
||||
/// (which live inside the header) must survive untouched, since the
|
||||
/// descrambler reads them but never writes them.
|
||||
///
|
||||
/// Grounding: loop is `sector.iter_mut().take(2048).skip(128)` -> indices
|
||||
/// 128..2048 only.
|
||||
/// Mutation: change `.skip(128)` to `.skip(0)` -> header bytes (incl. the
|
||||
/// seed) get XORed and this fails.
|
||||
#[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 0x80..0x800, not just a
|
||||
/// prefix. With a constant body and constant key, the keystream is
|
||||
/// non-degenerate enough that the very last sector byte (index 2047) is
|
||||
/// altered. This guards the loop bound `.take(2048)` against an
|
||||
/// off-by-one that would leave the final byte(s) scrambled.
|
||||
///
|
||||
/// Grounding: encrypted region end is 0x800 == 2048 (exclusive).
|
||||
/// Mutation: change `.take(2048)` to `.take(2047)` -> last byte unchanged,
|
||||
/// assert fires (keystream byte for the last position is verified nonzero
|
||||
/// below by the round-trip, and this body is all-zero so any XOR shows).
|
||||
#[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. (If the last keystream byte
|
||||
// happened to be 0 this could be a flaky test, so assert the run-end
|
||||
// region as a whole differs from zero.)
|
||||
assert_ne!(
|
||||
§or[2040..2048],
|
||||
&[0u8; 8][..],
|
||||
"the tail of the body must be descrambled (loop must reach index 2047)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Descramble is keyed by `title_key XOR seed`: two different title keys
|
||||
/// produce two different bodies for the same scrambled input. A cipher
|
||||
/// that ignored the title key (or mixed it in wrongly) would yield
|
||||
/// identical output — silent wrong-key decryption.
|
||||
///
|
||||
/// Grounding: per-sector key = title_key[i] ^ sector[0x54+i].
|
||||
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both
|
||||
/// keys give the same body, assert fires.
|
||||
#[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. Pins that bytes 0x54..0x59 actually feed the
|
||||
/// keystream (not just the per-sector XOR key).
|
||||
///
|
||||
/// Mutation: replace `seed` array reads with a constant -> both seeds give
|
||||
/// the same body, assert fires.
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+838
-68
@@ -1,110 +1,270 @@
|
||||
//! CSS (Content Scramble System) — DVD disc encryption.
|
||||
//!
|
||||
//! CSS uses a weak 40-bit LFSR stream cipher (broken since 1999).
|
||||
//! No keys needed — the title key is cracked from encrypted content
|
||||
//! using a known-plaintext attack on MPEG-2 PES headers.
|
||||
//!
|
||||
//! The title key is recovered keylessly: [`crack_key`] runs the Stevenson
|
||||
//! known-plaintext attack (see the [`stevenson`] module) on the scrambled
|
||||
//! data, needing no player keys, disc-key crack, or external key file.
|
||||
//! Sectors are then decrypted with [`descramble_sector`].
|
||||
//!
|
||||
//! Usage:
|
||||
//! ```rust,ignore
|
||||
//! let key = css::crack_key(reader, &extents)?;
|
||||
//! css::descramble_sector(&key, &mut sector);
|
||||
//! if let Some(state) = css::crack_key(reader, extents, batch) {
|
||||
//! css::descramble_sector(&state, &mut sector);
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod auth;
|
||||
pub mod crack;
|
||||
pub mod lfsr;
|
||||
pub mod stevenson;
|
||||
pub(crate) mod tables;
|
||||
|
||||
use crate::disc::Extent;
|
||||
use crate::drive::Drive;
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
/// Consecutive CSS-locked (`05/6F/03`) reads before the crack scan early-bails.
|
||||
/// The bus-auth read gate is global (all-or-nothing), so a run this long means
|
||||
/// it is shut and nothing here is crackable — bail instead of grinding the full
|
||||
/// 50_000-sector budget (which is what made rc5 appear to hang on a wedged USB
|
||||
/// bridge). The counter resets to 0 on any readable batch.
|
||||
const CSS_LOCKED_BAIL: u32 = 64;
|
||||
|
||||
/// CSS decryption state for a DVD title.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CssState {
|
||||
/// Cracked 5-byte title key
|
||||
/// 5-byte CSS title key (from SCSI auth or the crack fallback).
|
||||
pub title_key: [u8; 5],
|
||||
/// LBA half-open span `[start, end)` of the extent set this key was
|
||||
/// cracked from. CSS title keys are per-VTS: a key cracked from one
|
||||
/// VTS does NOT descramble a title living in a different VTS. The mux
|
||||
/// path checks whether the title being opened overlaps this span; if
|
||||
/// not, it re-cracks from that title's own extents. `None` for keys
|
||||
/// of unknown provenance (e.g. test fixtures) — treated as "applies
|
||||
/// everywhere" for backward compatibility.
|
||||
pub crack_span: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// Inputs for CSS key acquisition.
|
||||
/// Recover the CSS title key with no keys, by scanning scrambled sectors and
|
||||
/// running the Stevenson known-plaintext attack (see the [`stevenson`] module).
|
||||
///
|
||||
/// The acquisition path depends on which inputs the caller supplies:
|
||||
///
|
||||
/// - With `drive` + `auth_lba` set, [`resolve`] runs the full SCSI bus
|
||||
/// auth + title-key path (live BU40N / DVD drive).
|
||||
/// - With `reader` + `extents` set, [`resolve`] falls back to the
|
||||
/// crack path (Stevenson known-plaintext attack on encrypted PES
|
||||
/// headers; works on disc images and on drives whose CSS auth path
|
||||
/// is unavailable).
|
||||
///
|
||||
/// `live_drive` always wins when both modes are populated.
|
||||
pub struct CssContext<'a> {
|
||||
/// Live SCSI drive — when present, [`resolve`] tries the auth path.
|
||||
pub drive: Option<&'a mut Drive>,
|
||||
/// LBA of a known-scrambled sector for the auth path's title-key
|
||||
/// query. Required when `drive` is set.
|
||||
pub auth_lba: Option<u32>,
|
||||
/// Sector source for the crack path.
|
||||
pub reader: Option<&'a mut dyn SectorSource>,
|
||||
/// Extents to scan for the crack path. Required when `reader` is
|
||||
/// set.
|
||||
pub extents: Option<&'a [Extent]>,
|
||||
/// The crib comes from `AttackPattern`: a scrambled sector's cleartext region
|
||||
/// (bytes 0x00..0x80) often ends in a short-period repeating run (stuffing /
|
||||
/// constant fill); the attack assumes that run continues across the 0x80
|
||||
/// boundary into the encrypted region, giving the known plaintext the 2^16
|
||||
/// LFSR recovery needs. We scan up to 50000 sectors across the
|
||||
/// extents and return the first sector that yields a key — no player keys, no
|
||||
/// disc-key crack. Works on a live drive (after bus-auth unlocks reads) and on
|
||||
/// disc images alike.
|
||||
pub fn crack_key(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
batch_sectors: u16,
|
||||
) -> Option<CssState> {
|
||||
crack_key_halt(reader, extents, batch_sectors, None)
|
||||
}
|
||||
|
||||
/// Acquire a CSS title key using whichever inputs the context provides.
|
||||
/// Outcome of a CSS crack scan that distinguishes the THREE cases the bare
|
||||
/// `Option<CssState>` conflated (and which caused a silent-failure bug:
|
||||
/// scrambled-but-uncracked content was treated as "unencrypted" and muxed as
|
||||
/// plaintext garbage at exit 0):
|
||||
///
|
||||
/// Order of attempts:
|
||||
/// 1. SCSI auth path (when `drive` and `auth_lba` are set).
|
||||
/// 2. Crack path (when `reader` and `extents` are set).
|
||||
///
|
||||
/// Returns `None` if neither path is configured or both fail.
|
||||
pub fn resolve(ctx: &mut CssContext<'_>) -> Option<CssState> {
|
||||
if let (Some(drive), Some(lba)) = (ctx.drive.as_deref_mut(), ctx.auth_lba) {
|
||||
if let Ok(title_key) = auth::authenticate_and_read_title_key(drive, lba) {
|
||||
return Some(CssState { title_key });
|
||||
}
|
||||
}
|
||||
if let (Some(reader), Some(extents)) = (ctx.reader.as_deref_mut(), ctx.extents) {
|
||||
return crack_key(reader, extents);
|
||||
}
|
||||
None
|
||||
/// - [`CrackOutcome::Cracked`] — a scrambled sector yielded a title key.
|
||||
/// - [`CrackOutcome::Unencrypted`] — NO scrambled sector was seen across the
|
||||
/// scanned extents (`is_scrambled` never true): the content is genuinely
|
||||
/// plaintext, so proceeding without a key is correct.
|
||||
/// - [`CrackOutcome::ScrambledUncracked`] — scrambled sectors WERE seen but no
|
||||
/// key could be recovered (the Stevenson attack found no crackable crib, or
|
||||
/// the scrambled region was unreadable). The content is encrypted; muxing it
|
||||
/// as plaintext would emit garbage, so callers MUST surface a hard error
|
||||
/// ([`crate::error::Error::CssKeyMissing`]) instead of falling through to
|
||||
/// "unencrypted".
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CrackOutcome {
|
||||
Cracked(CssState),
|
||||
Unencrypted,
|
||||
ScrambledUncracked,
|
||||
}
|
||||
|
||||
/// Crack the CSS title key by reading encrypted sectors and applying
|
||||
/// a known-plaintext attack on MPEG-2 headers.
|
||||
impl CrackOutcome {
|
||||
/// The cracked `CssState`, if any. `None` for `Unencrypted` /
|
||||
/// `ScrambledUncracked`. Lets the `Option`-returning wrappers stay thin.
|
||||
pub fn into_state(self) -> Option<CssState> {
|
||||
match self {
|
||||
CrackOutcome::Cracked(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when scrambled sectors were seen but no key was recovered — the
|
||||
/// case callers must surface as a hard error instead of "unencrypted".
|
||||
pub fn is_scrambled_uncracked(&self) -> bool {
|
||||
matches!(self, CrackOutcome::ScrambledUncracked)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`crack_key`] returning the full [`CrackOutcome`] (Cracked / Unencrypted /
|
||||
/// ScrambledUncracked) so callers can distinguish "genuinely unencrypted" from
|
||||
/// "encrypted but uncrackable" — the latter must become a hard error, never a
|
||||
/// silent fall-through to plaintext.
|
||||
pub fn crack_key_outcome(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
batch_sectors: u16,
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> CrackOutcome {
|
||||
crack_key_scan(reader, extents, batch_sectors, halt, true)
|
||||
}
|
||||
|
||||
/// [`crack_key`] with an optional cooperative-cancellation token.
|
||||
///
|
||||
/// Crack the CSS title key by scanning scrambled sectors across extents.
|
||||
///
|
||||
/// The Stevenson attack needs a sector where a PES header starts at byte
|
||||
/// 0x80 (start of the encrypted region). This only happens when a new PES
|
||||
/// packet begins at exactly sector offset 128. We scan up to 50000
|
||||
/// scrambled sectors sequentially across all extents.
|
||||
pub fn crack_key(reader: &mut dyn SectorSource, extents: &[Extent]) -> Option<CssState> {
|
||||
/// "No silent hangs": the crack scans up to 50_000 sectors, which on a live
|
||||
/// drive hitting bad sectors can take a long time. This variant polls `halt`
|
||||
/// once per batch (the same cadence sweep/patch use) so an operator Stop or a
|
||||
/// scan-level watchdog can interrupt the scan, and emits a
|
||||
/// `freemkv::heartbeat` beat ("css_crack") each batch so a stuck scan is
|
||||
/// visible in the log.
|
||||
pub fn crack_key_halt(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
batch_sectors: u16,
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> Option<CssState> {
|
||||
crack_key_scan(reader, extents, batch_sectors, halt, false).into_state()
|
||||
}
|
||||
|
||||
/// The crack scan, returning the full [`CrackOutcome`]. Tracks a
|
||||
/// `saw_scrambled` flag so a scrambled-but-uncracked disc is distinguished
|
||||
/// from a genuinely-unencrypted one (the [`crack_key`] / [`crack_key_halt`]
|
||||
/// `Option` wrappers collapse both to `None`).
|
||||
fn crack_key_scan(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
batch_sectors: u16,
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
// True only on the INITIAL scan: a fully CSS-locked (`05/6F/03`) result is a
|
||||
// hard `ScrambledUncracked`. False on the per-VTS re-crack so a lapsed-AGID
|
||||
// locked read returns None instead of killing a genuinely crackable title.
|
||||
fail_on_locked: bool,
|
||||
) -> CrackOutcome {
|
||||
// Batch the reads: a live optical drive at 1 sector/read is glacial, and the
|
||||
// crack only needs to FIND one scrambled sector whose 0x80 plaintext matches
|
||||
// a known PES header. `batch_sectors` MUST be sized to the source — a drive
|
||||
// rejects a READ(10) larger than its per-command max (DVD = 16) and
|
||||
// `Drive::read` does not chunk, so an over-large batch fails every read and
|
||||
// scans nothing. Callers pass `detect_max_batch_sectors(device_path)` for a
|
||||
// live drive, a file-safe value for an image, or 1 to force per-sector.
|
||||
let batch = (batch_sectors.max(1)) as u32;
|
||||
// Record the LBA span the key is being cracked from so the per-title mux
|
||||
// path can tell whether a later title lives in the same VTS (overlaps the
|
||||
// span → key applies) or a different one (→ re-crack). Half-open [min,max).
|
||||
let crack_span = extents
|
||||
.iter()
|
||||
.filter(|e| e.sector_count > 0)
|
||||
.map(|e| (e.start_lba, e.start_lba.saturating_add(e.sector_count)))
|
||||
.reduce(|(amin, amax), (bmin, bmax)| (amin.min(bmin), amax.max(bmax)));
|
||||
let mut tried = 0u32;
|
||||
let max_tries = 50_000;
|
||||
let max_tries = 50_000u32;
|
||||
let mut buf = vec![0u8; batch as usize * 2048];
|
||||
let mut hb = crate::progress::Heartbeat::new("css_crack");
|
||||
// Track whether ANY scrambled sector was observed. If we exhaust the scan
|
||||
// budget having seen scrambled data but never recovered a key, the content
|
||||
// is encrypted-but-uncrackable — a HARD failure the caller must surface,
|
||||
// NOT silently treat as unencrypted (which would mux scrambled MPEG as
|
||||
// plaintext → garbage at exit 0). See `CrackOutcome::ScrambledUncracked`.
|
||||
let mut saw_scrambled = false;
|
||||
// A read rejected with sense `05/6F/03` ("scrambled sector without
|
||||
// authentication") is positive proof of CSS encryption — never collapse it
|
||||
// to "unencrypted". A run of consecutive locked reads means the bus-auth
|
||||
// gate is shut (it is global, so reads are all-or-nothing), so the scan
|
||||
// early-bails. `consecutive_locked` resets on any readable batch, so a
|
||||
// crackable title (gate open) never trips it.
|
||||
let mut saw_locked = false;
|
||||
let mut consecutive_locked = 0u32;
|
||||
|
||||
for ext in extents {
|
||||
let mut i = 0;
|
||||
'outer: for (extent_idx, ext) in extents.iter().enumerate() {
|
||||
let mut i = 0u32;
|
||||
while i < ext.sector_count && tried < max_tries {
|
||||
let mut buf = vec![0u8; 2048];
|
||||
if reader
|
||||
.read_sectors(ext.start_lba + i, 1, &mut buf, true)
|
||||
.is_ok()
|
||||
&& is_scrambled(&buf)
|
||||
{
|
||||
if let Some(key) = crack::crack_title_key(&buf) {
|
||||
return Some(CssState { title_key: key });
|
||||
// Cooperative cancellation — poll once per batch, the same cadence
|
||||
// sweep/patch use, so a Stop / watchdog can interrupt the scan.
|
||||
if let Some(h) = halt {
|
||||
if h.is_cancelled() {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
// Liveness beacon: a long scan over a damaged disc stays visible.
|
||||
// The heartbeat is time-throttled; only when it actually beats do
|
||||
// we emit the crack-specific context (tried/lba/extent_idx).
|
||||
if hb.tick(tried as u64, max_tries as u64) {
|
||||
tracing::debug!(
|
||||
target: "freemkv::heartbeat",
|
||||
phase = "css_crack",
|
||||
tried,
|
||||
lba = ext.start_lba + i,
|
||||
extent_idx,
|
||||
"scanning"
|
||||
);
|
||||
}
|
||||
let n = (ext.sector_count - i).min(batch);
|
||||
let want = n as usize * 2048;
|
||||
match reader.read_sectors(ext.start_lba + i, n as u16, &mut buf[..want], true) {
|
||||
Ok(_) => {
|
||||
// A readable batch: the gate is open — reset the locked run.
|
||||
consecutive_locked = 0;
|
||||
for s in 0..n as usize {
|
||||
tried += 1;
|
||||
let sect = &buf[s * 2048..(s + 1) * 2048];
|
||||
// Use the HARDENED pack-gated check (Fix 3): a clear stub
|
||||
// sector with stray bits at 0x14 must NOT count as
|
||||
// scramble evidence, or a genuinely-unencrypted title
|
||||
// would falsely report ScrambledUncracked (a false E7023).
|
||||
if is_scrambled_pack(sect) {
|
||||
saw_scrambled = true;
|
||||
if let Some(key) = stevenson::crack_title_key(sect) {
|
||||
return CrackOutcome::Cracked(CssState {
|
||||
title_key: key,
|
||||
crack_span,
|
||||
});
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if tried >= max_tries {
|
||||
break;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A failed batch still counts toward the budget so a damaged
|
||||
// region can't loop forever. A CSS-locked failure (`05/6F/03`)
|
||||
// proves encryption and, in a long enough run, means the read
|
||||
// gate is shut — track it and early-bail rather than grind.
|
||||
Err(e) => {
|
||||
tried += n;
|
||||
if e.scsi_sense().is_some_and(|s| s.is_css_locked()) {
|
||||
saw_locked = true;
|
||||
consecutive_locked += 1;
|
||||
if consecutive_locked >= CSS_LOCKED_BAIL {
|
||||
break 'outer;
|
||||
}
|
||||
} else {
|
||||
consecutive_locked = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
// Budget exhausted / extents walked / early-bailed with no key recovered.
|
||||
// The disc is ENCRYPTED-but-uncracked (a hard failure on the initial scan)
|
||||
// when EITHER a scrambled sector was actually seen, OR — on the initial scan
|
||||
// only (`fail_on_locked`) — every read was CSS-locked (`05/6F/03`), itself
|
||||
// proof of scrambling. A re-crack (`fail_on_locked` false) stays soft: a
|
||||
// lapsed-AGID locked read yields None, not a hard fail, so a crackable title
|
||||
// in another VTS isn't killed. Only a scan that saw neither a scrambled
|
||||
// sector nor a CSS-lock is genuinely unencrypted.
|
||||
if saw_scrambled || (saw_locked && fail_on_locked) {
|
||||
CrackOutcome::ScrambledUncracked
|
||||
} else {
|
||||
CrackOutcome::Unencrypted
|
||||
}
|
||||
}
|
||||
|
||||
/// Descramble a single CSS-encrypted sector in place.
|
||||
@@ -113,6 +273,616 @@ pub fn descramble_sector(state: &CssState, sector: &mut [u8]) {
|
||||
}
|
||||
|
||||
/// Check if a sector has the CSS scramble flag set.
|
||||
///
|
||||
/// This is the RAW flag test — bits 4-5 of the sub-header byte 0x14 — used by
|
||||
/// the descramble loop (`decrypt::decrypt_sectors`), which has already committed
|
||||
/// to descrambling a known title's VOB data and only needs to skip the clear
|
||||
/// NAV packs interleaved in it. For the CRACK SCAN's "did this disc actually
|
||||
/// contain scrambled content?" decision (which must not false-positive on a
|
||||
/// clear stub), use [`is_scrambled_pack`] instead.
|
||||
pub fn is_scrambled(sector: &[u8]) -> bool {
|
||||
sector.len() >= 2048 && (sector[0x14] >> 4) & 0x03 != 0
|
||||
}
|
||||
|
||||
/// The 4-byte MPEG-2 Program Stream pack-start code (`00 00 01 BA`) every DVD
|
||||
/// video sector opens with. CSS leaves the clear header (`0x00..0x80`)
|
||||
/// untouched, so this signature survives scrambling.
|
||||
pub(crate) const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA];
|
||||
|
||||
/// Check if a sector is a CSS-scrambled DVD **video pack** — the HARDENED test
|
||||
/// the crack scan uses to set its `saw_scrambled` evidence flag (Fix 3).
|
||||
///
|
||||
/// [`is_scrambled`] keys solely on bits 4-5 of byte 0x14. That single byte is
|
||||
/// only meaningful inside a real DVD sector — an MPEG-2 Program Stream pack,
|
||||
/// which ALWAYS begins with the 32-bit pack-start code `00 00 01 BA` at offset
|
||||
/// 0x00. A tiny clear / nav-only stub (a 0.5 s menu loop, an FBI-warning title)
|
||||
/// can carry arbitrary bytes that happen to set bits 4-5 of byte 0x14; trusting
|
||||
/// byte 0x14 alone there would flip the scan's `saw_scrambled` gate and make a
|
||||
/// genuinely-UNENCRYPTED title report `ScrambledUncracked` — a false E7023.
|
||||
///
|
||||
/// Requiring the pack-start signature FIRST means only a sector that is
|
||||
/// structurally a DVD video pack can be counted as scramble evidence. This does
|
||||
/// NOT weaken the genuine "encrypted but uncrackable" hard-fail: a real
|
||||
/// scrambled feature is made of valid PS packs, so its scrambled sectors still
|
||||
/// pass this check and still drive `ScrambledUncracked` when no key cracks. (The
|
||||
/// descramble loop keeps the looser [`is_scrambled`]: by the time it runs we
|
||||
/// already know the title is CSS, and it only needs to skip interleaved clear
|
||||
/// NAV packs — a wrongly-skipped or wrongly-included sector there is recoverable
|
||||
/// per-sector, whereas a false scramble verdict in the scan poisons the whole
|
||||
/// title's outcome.)
|
||||
pub fn is_scrambled_pack(sector: &[u8]) -> bool {
|
||||
sector.len() >= 2048 && sector[0x00..0x04] == PACK_START && (sector[0x14] >> 4) & 0x03 != 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
// ── is_scrambled ───────────────────────────────────────────────────────
|
||||
|
||||
/// is_scrambled returns false for any buffer shorter than one sector,
|
||||
/// WITHOUT indexing byte 0x14 (which would panic on a tiny buffer). The
|
||||
/// length guard is short-circuited before the flag read.
|
||||
///
|
||||
/// Grounding: `sector.len() >= 2048 && (sector[0x14] >> 4) & 0x03 != 0` —
|
||||
/// `&&` short-circuits so a 20-byte buffer never reads index 0x14.
|
||||
/// Mutation: swap the operands so the flag is read first
|
||||
/// (`(sector[0x14]...) && sector.len() >= 2048`) -> panics indexing a
|
||||
/// 20-byte slice; this test catches it.
|
||||
#[test]
|
||||
fn is_scrambled_short_buffer_is_false_no_panic() {
|
||||
assert!(!is_scrambled(&[]));
|
||||
assert!(!is_scrambled(&[0u8; 20])); // shorter than 0x14+1 even
|
||||
assert!(!is_scrambled(&[0xFFu8; 2047])); // one byte short of a sector
|
||||
}
|
||||
|
||||
/// is_scrambled keys on bits 4-5 of byte 0x14 (the CSS scramble field).
|
||||
/// A full sector flagged 0x10/0x20/0x30 is scrambled; 0x00 and the
|
||||
/// high-bit-only values 0x40/0x80 are clear.
|
||||
///
|
||||
/// Grounding: `(sector[0x14] >> 4) & 0x03`.
|
||||
/// Mutation: widen mask to `& 0x0F` -> 0x40 reports scrambled, the 0x40
|
||||
/// assert fails.
|
||||
#[test]
|
||||
fn is_scrambled_uses_bits_4_5_only() {
|
||||
let mut s = vec![0u8; 2048];
|
||||
for (flag, expected) in [
|
||||
(0x00u8, false),
|
||||
(0x10, true),
|
||||
(0x20, true),
|
||||
(0x30, true),
|
||||
(0x40, false),
|
||||
(0x80, false),
|
||||
(0xC0, false),
|
||||
(0xFF, true), // bits 4-5 set within 0xFF
|
||||
] {
|
||||
s[0x14] = flag;
|
||||
assert_eq!(
|
||||
is_scrambled(&s),
|
||||
expected,
|
||||
"flag byte {flag:#04x} scramble detection"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// is_scrambled accepts exactly 2048 bytes as the minimum (boundary at the
|
||||
/// inclusive value 2048).
|
||||
///
|
||||
/// Grounding: `sector.len() >= 2048`.
|
||||
/// Mutation: change `>= 2048` to `> 2048` -> an exact 2048-byte scrambled
|
||||
/// sector reports false; this fails.
|
||||
#[test]
|
||||
fn is_scrambled_exact_sector_length_accepted() {
|
||||
let mut s = vec![0u8; 2048];
|
||||
s[0x14] = 0x30;
|
||||
assert!(is_scrambled(&s), "exactly 2048 bytes must be eligible");
|
||||
}
|
||||
|
||||
/// Fix 3 hardening: `is_scrambled_pack` (the crack-scan evidence gate)
|
||||
/// requires BOTH the MPEG-PS pack-start code at 0x00 AND the 0x14 scramble
|
||||
/// bits. A clear / nav-only stub whose bytes happen to set bits 4-5 of 0x14
|
||||
/// but lacks the pack-start is NOT counted as scramble evidence — without
|
||||
/// this the scan flips `saw_scrambled` and a genuinely unencrypted title
|
||||
/// reports `ScrambledUncracked` (the false E7023). The looser `is_scrambled`
|
||||
/// (descramble gate) still reads the same sector as flagged.
|
||||
///
|
||||
/// Grounding: `sector[0x00..0x04] == 00 00 01 BA && (sector[0x14] >> 4)...`.
|
||||
/// Mutation: drop the pack-start clause -> the 0x14-only sector counts as a
|
||||
/// scrambled pack; the first assert fails.
|
||||
#[test]
|
||||
fn is_scrambled_pack_requires_pack_start_signature() {
|
||||
let mut s = vec![0u8; 2048];
|
||||
s[0x14] = 0x30; // scramble bits set, but no pack-start at 0x00
|
||||
assert!(
|
||||
!is_scrambled_pack(&s),
|
||||
"0x14 bits without the MPEG-PS pack-start must NOT count as a scrambled pack"
|
||||
);
|
||||
// The looser descramble-gate check still sees the raw flag.
|
||||
assert!(is_scrambled(&s), "is_scrambled keys on the 0x14 flag alone");
|
||||
// A near-miss pack-start (wrong final byte) is still rejected.
|
||||
s[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBB]);
|
||||
assert!(
|
||||
!is_scrambled_pack(&s),
|
||||
"a wrong pack-start byte must not qualify"
|
||||
);
|
||||
// The real signature flips it to a scrambled pack.
|
||||
s[0x00..0x04].copy_from_slice(&PACK_START);
|
||||
assert!(
|
||||
is_scrambled_pack(&s),
|
||||
"valid pack-start + 0x14 bits → scrambled pack"
|
||||
);
|
||||
}
|
||||
|
||||
// ── crack_key scanning over a mock SectorSource ────────────────────────
|
||||
|
||||
/// Records every (lba, count) read; returns a caller-supplied flag byte at
|
||||
/// 0x14 so we can drive scrambled/clear sectors, or an injected error.
|
||||
struct MockSource {
|
||||
reads: std::cell::RefCell<Vec<u32>>,
|
||||
flag_byte: u8,
|
||||
fail_all: bool,
|
||||
/// Every read fails with CSS-locked sense `05/6F/03` (drive refusing
|
||||
/// scrambled reads because the bus-auth gate isn't open).
|
||||
lock_all: bool,
|
||||
/// When set, the sector at `crackable.0` is served as a full
|
||||
/// Stevenson-crackable scrambled sector (`crackable.1`, 2048 bytes)
|
||||
/// instead of the uniform `flag_byte` fill. Lets the scan actually
|
||||
/// reach `CrackOutcome::Cracked` from a synthetic ISO.
|
||||
crackable: Option<(u32, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl MockSource {
|
||||
fn new(flag_byte: u8) -> Self {
|
||||
Self {
|
||||
reads: std::cell::RefCell::new(Vec::new()),
|
||||
flag_byte,
|
||||
fail_all: false,
|
||||
lock_all: false,
|
||||
crackable: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Stevenson-crackable scrambled sector for `(title_key, seed)`:
|
||||
/// the cleartext header (0x59..0x80) carries a periodic run that continues
|
||||
/// across the 0x80 boundary into the encrypted region — the crib
|
||||
/// `stevenson::crack_title_key` recovers a key from. Mirrors the
|
||||
/// `synth_periodic_sector` fixture in the stevenson tests but built here
|
||||
/// from the crate-internal `scramble_sector`.
|
||||
fn crackable_sector(title_key: &[u8; 5], seed: &[u8; 5], period: usize) -> Vec<u8> {
|
||||
const RUN_START: usize = 0x59;
|
||||
const SEED_OFFSET: usize = 0x54;
|
||||
let mut plaintext = vec![0u8; 2048];
|
||||
plaintext[0x00..0x04].copy_from_slice(&PACK_START); // valid DVD pack header
|
||||
plaintext[0x14] = 0x10; // scramble flag
|
||||
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];
|
||||
}
|
||||
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
||||
lfsr::scramble_sector(title_key, &mut plaintext);
|
||||
plaintext
|
||||
}
|
||||
|
||||
impl SectorSource for MockSource {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
self.reads.borrow_mut().push(lba);
|
||||
if self.lock_all {
|
||||
return Err(Error::DiscRead {
|
||||
sector: lba as u64,
|
||||
status: Some(2),
|
||||
sense: Some(crate::scsi::ScsiSense {
|
||||
sense_key: 0x05,
|
||||
asc: 0x6F,
|
||||
ascq: 0x03,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if self.fail_all {
|
||||
return Err(Error::DecryptFailed);
|
||||
}
|
||||
let n = count as usize * 2048;
|
||||
let end = n.min(buf.len());
|
||||
for b in buf[..end].iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
// Fill each sector in the batch with the uniform flag byte, EXCEPT a
|
||||
// designated crackable LBA which gets the full synthetic sector.
|
||||
for s in 0..count as u32 {
|
||||
let sect_lba = lba + s;
|
||||
let base = s as usize * 2048;
|
||||
if base + 2048 > end {
|
||||
break;
|
||||
}
|
||||
match &self.crackable {
|
||||
Some((clba, sector)) if *clba == sect_lba => {
|
||||
buf[base..base + 2048].copy_from_slice(sector);
|
||||
}
|
||||
_ => {
|
||||
// Real DVD video sectors always open with the MPEG-PS
|
||||
// pack-start code; `is_scrambled` (Fix 3) requires it
|
||||
// before trusting the 0x14 scramble bits, so the fixture
|
||||
// must include it for a `flag_byte` of 0x30 to register
|
||||
// as scrambled.
|
||||
buf[base..base + 4].copy_from_slice(&PACK_START);
|
||||
buf[base + 0x14] = self.flag_byte;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// crack_key caps total scanned sectors at 50_000 even when extents are
|
||||
/// far larger, and counts EVERY scanned sector (clear ones included)
|
||||
/// toward the budget. With one 200_000-sector extent of clear sectors, it
|
||||
/// must read exactly 50_000 sectors and return None — never run away.
|
||||
///
|
||||
/// Grounding: `let max_tries = 50_000; ... tried += 1` before the read,
|
||||
/// loop guard `tried < max_tries`.
|
||||
/// Mutation: change `50_000` to `500_000` -> read count exceeds 50_000;
|
||||
/// the exact-count assert fails. Removing the `tried += 1` increment ->
|
||||
/// would read all 200_000; also fails.
|
||||
#[test]
|
||||
fn crack_key_caps_total_tries_at_50000() {
|
||||
let mut src = MockSource::new(0x00); // clear sectors, never a hit
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 200_000,
|
||||
}];
|
||||
let res = crack_key(&mut src, &extents, 1);
|
||||
assert!(res.is_none(), "clear sectors yield no key");
|
||||
assert_eq!(
|
||||
src.reads.borrow().len(),
|
||||
50_000,
|
||||
"scan must stop at the 50_000-sector budget"
|
||||
);
|
||||
}
|
||||
|
||||
// ── CrackOutcome: scrambled-but-uncracked vs genuinely unencrypted (Fix 6) ─
|
||||
|
||||
/// A scan over CLEAR sectors (scramble flag never set) returns
|
||||
/// `Unencrypted` — the content is genuinely plaintext, so proceeding
|
||||
/// without a key is correct.
|
||||
#[test]
|
||||
fn crack_outcome_clear_sectors_is_unencrypted() {
|
||||
let mut src = MockSource::new(0x00); // never scrambled
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 100,
|
||||
}];
|
||||
let outcome = crack_key_outcome(&mut src, &extents, 1, None);
|
||||
assert!(
|
||||
matches!(outcome, CrackOutcome::Unencrypted),
|
||||
"no scrambled sector seen → Unencrypted, got {outcome:?}"
|
||||
);
|
||||
// The Option wrapper collapses Unencrypted → None.
|
||||
assert!(crack_key(&mut MockSource::new(0x00), &extents, 1).is_none());
|
||||
}
|
||||
|
||||
/// THE Fix 6 regression: a scan that SEES scrambled sectors (flag set) but
|
||||
/// recovers no key (the mock's zeroed data has no Stevenson crib) must
|
||||
/// return `ScrambledUncracked` — a HARD failure — NOT `Unencrypted`. The
|
||||
/// old code conflated this with "unencrypted" and muxed scrambled MPEG as
|
||||
/// plaintext (garbage at exit 0).
|
||||
#[test]
|
||||
fn crack_outcome_scrambled_uncracked_is_hard_failure() {
|
||||
let mut src = MockSource::new(0x30); // scrambled flag set, no crackable crib
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 100,
|
||||
}];
|
||||
let outcome = crack_key_outcome(&mut src, &extents, 1, None);
|
||||
assert!(
|
||||
outcome.is_scrambled_uncracked(),
|
||||
"scrambled sectors seen but no key → ScrambledUncracked, got {outcome:?}"
|
||||
);
|
||||
// The legacy Option wrapper still collapses this to None (the callers
|
||||
// that need the distinction now use crack_key_outcome instead).
|
||||
assert!(crack_key(&mut MockSource::new(0x30), &extents, 1).is_none());
|
||||
}
|
||||
|
||||
/// Even when every read FAILS, a scan that never managed to observe a
|
||||
/// scrambled sector reports `Unencrypted` (we cannot prove encryption from
|
||||
/// unreadable data alone — the AACS/keydb paths and the disc-level
|
||||
/// `css_error` plumbing cover genuinely unreadable encrypted discs).
|
||||
#[test]
|
||||
fn crack_outcome_all_reads_fail_is_unencrypted() {
|
||||
let mut src = MockSource::new(0x30);
|
||||
src.fail_all = true; // no sector is ever inspected
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 10,
|
||||
}];
|
||||
let outcome = crack_key_outcome(&mut src, &extents, 1, None);
|
||||
assert!(
|
||||
matches!(outcome, CrackOutcome::Unencrypted),
|
||||
"no readable scrambled sector → Unencrypted, got {outcome:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Fix C (rc.5.1): on the INITIAL scan, a drive that refuses every read with
|
||||
/// CSS-locked sense (`05/6F/03`) is encrypted-but-locked →
|
||||
/// `ScrambledUncracked` (a hard failure), NOT `Unencrypted`. This is the
|
||||
/// rc4.3 bug: every VOB read came back `6F/03`, so the scan saw no scrambled
|
||||
/// sector and wrongly declared the disc unencrypted → 19 KB garbage.
|
||||
#[test]
|
||||
fn crack_outcome_css_locked_initial_is_scrambled_uncracked() {
|
||||
let mut src = MockSource::new(0x30);
|
||||
src.lock_all = true; // every read → 05/6F/03
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 100,
|
||||
}];
|
||||
let outcome = crack_key_outcome(&mut src, &extents, 1, None);
|
||||
assert!(
|
||||
outcome.is_scrambled_uncracked(),
|
||||
"every read 6F/03 on the initial scan → ScrambledUncracked, got {outcome:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// MISSING #1 guard: the re-crack path (the `Option`-returning `crack_key`,
|
||||
/// `fail_on_locked == false`) must NOT hard-fail on a CSS-locked read — it
|
||||
/// returns `None`. A lapsed-AGID re-crack of another VTS stays soft so a
|
||||
/// genuinely crackable title isn't killed by a transient locked read.
|
||||
#[test]
|
||||
fn crack_key_recrack_locked_is_none_not_hard_fail() {
|
||||
let mut src = MockSource::new(0x30);
|
||||
src.lock_all = true;
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 100,
|
||||
}];
|
||||
assert!(crack_key(&mut src, &extents, 1).is_none());
|
||||
}
|
||||
|
||||
/// Fix F: a fully CSS-locked scan early-bails near `CSS_LOCKED_BAIL`
|
||||
/// consecutive locked reads instead of grinding the whole 50_000-sector
|
||||
/// budget (the rc5 "stuck Scanning…" hang on a wedged bridge).
|
||||
#[test]
|
||||
fn crack_css_locked_scan_early_bails() {
|
||||
let mut src = MockSource::new(0x30);
|
||||
src.lock_all = true;
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 10_000,
|
||||
}];
|
||||
let _ = crack_key_outcome(&mut src, &extents, 1, None);
|
||||
let n = src.reads.borrow().len();
|
||||
assert!(
|
||||
n <= (CSS_LOCKED_BAIL as usize) + 1,
|
||||
"locked scan early-bails near {CSS_LOCKED_BAIL}, not 10000; read {n}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The budget spans ALL extents, not per-extent: two extents summing past
|
||||
/// the cap must still stop at 50_000 total reads.
|
||||
///
|
||||
/// Grounding: `tried` is declared outside the `for ext in extents` loop;
|
||||
/// `if tried >= max_tries { break }` after each extent.
|
||||
/// Mutation: move `let mut tried = 0` inside the extent loop -> each extent
|
||||
/// gets its own 50_000 budget; total reads would be 80_000, this fails.
|
||||
#[test]
|
||||
fn crack_key_budget_is_shared_across_extents() {
|
||||
let mut src = MockSource::new(0x00);
|
||||
let extents = [
|
||||
Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 40_000,
|
||||
},
|
||||
Extent {
|
||||
start_lba: 100_000,
|
||||
sector_count: 40_000,
|
||||
},
|
||||
];
|
||||
let res = crack_key(&mut src, &extents, 1);
|
||||
assert!(res.is_none());
|
||||
assert_eq!(
|
||||
src.reads.borrow().len(),
|
||||
50_000,
|
||||
"the 50_000 budget is shared across all extents"
|
||||
);
|
||||
}
|
||||
|
||||
/// crack_key scans sequentially from each extent's start_lba. The first
|
||||
/// reads must be at the extent's start_lba, start_lba+1, ... pinning the
|
||||
/// LBA arithmetic `ext.start_lba + i`.
|
||||
///
|
||||
/// Grounding: `reader.read_sectors(ext.start_lba + i, 1, ...)`.
|
||||
/// Mutation: change `ext.start_lba + i` to just `i` -> the recorded LBAs
|
||||
/// would start at 0, not 5000; this fails.
|
||||
#[test]
|
||||
fn crack_key_scans_from_extent_start_lba() {
|
||||
let mut src = MockSource::new(0x00);
|
||||
let extents = [Extent {
|
||||
start_lba: 5_000,
|
||||
sector_count: 4,
|
||||
}];
|
||||
let _ = crack_key(&mut src, &extents, 1);
|
||||
let reads = src.reads.borrow();
|
||||
assert_eq!(
|
||||
&reads[..],
|
||||
&[5_000, 5_001, 5_002, 5_003],
|
||||
"sequential scan from start_lba"
|
||||
);
|
||||
}
|
||||
|
||||
/// A read error on a sector does NOT abort the scan: crack_key keeps
|
||||
/// scanning subsequent sectors (the error sector still counts toward the
|
||||
/// budget). With a small failing extent, every sector is attempted and the
|
||||
/// function returns None.
|
||||
///
|
||||
/// Grounding: `if reader.read_sectors(...).is_ok() && is_scrambled(...)` —
|
||||
/// an Err simply falls through to `i += 1`.
|
||||
/// Mutation: change the read-error handling to `reader.read_sectors(...)?`
|
||||
/// (propagate) -> crack_key would stop after the first error and read only
|
||||
/// 1 sector; this asserts all 10 were attempted.
|
||||
#[test]
|
||||
fn crack_key_continues_past_read_errors() {
|
||||
let mut src = MockSource::new(0x30);
|
||||
src.fail_all = true;
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 10,
|
||||
}];
|
||||
let res = crack_key(&mut src, &extents, 1);
|
||||
assert!(res.is_none());
|
||||
assert_eq!(
|
||||
src.reads.borrow().len(),
|
||||
10,
|
||||
"read errors must not abort the scan"
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty extents (no sectors) -> crack_key reads nothing and returns None.
|
||||
/// A zero-sector extent must not read its start_lba.
|
||||
///
|
||||
/// Grounding: `while i < ext.sector_count` with sector_count == 0 never
|
||||
/// enters.
|
||||
/// Mutation: change `i < ext.sector_count` to `i <= ext.sector_count` ->
|
||||
/// one spurious read at start_lba; this asserts zero reads.
|
||||
#[test]
|
||||
fn crack_key_empty_extent_reads_nothing() {
|
||||
let mut src = MockSource::new(0x30);
|
||||
let extents = [Extent {
|
||||
start_lba: 42,
|
||||
sector_count: 0,
|
||||
}];
|
||||
let res = crack_key(&mut src, &extents, 1);
|
||||
assert!(res.is_none());
|
||||
assert_eq!(
|
||||
src.reads.borrow().len(),
|
||||
0,
|
||||
"zero-sector extent reads nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// No extents at all -> immediate None, zero reads.
|
||||
///
|
||||
/// Grounding: `for ext in extents` over an empty slice is a no-op.
|
||||
/// Mutation: any change that reads before the loop would break this.
|
||||
#[test]
|
||||
fn crack_key_no_extents_is_none() {
|
||||
let mut src = MockSource::new(0x30);
|
||||
let res = crack_key(&mut src, &[], 1);
|
||||
assert!(res.is_none());
|
||||
assert_eq!(src.reads.borrow().len(), 0);
|
||||
}
|
||||
|
||||
// ── Scan-level Cracked branch + per-VTS re-crack success (audit §2 / §5 #8) ─
|
||||
|
||||
/// SCAN-LEVEL CRACKED (audit gap "MockSource never yields a crackable
|
||||
/// sector"): drive the full `crack_key_scan` over a synthetic ISO whose
|
||||
/// scan hits a Stevenson-crackable scrambled sector. The outcome must be
|
||||
/// `CrackOutcome::Cracked` with a key that round-trips the sector, AND the
|
||||
/// `crack_span` must be recorded as the half-open extent span (the per-VTS
|
||||
/// routing key the mux path needs). Previously only the leaf crack and the
|
||||
/// Uncracked/Unencrypted branches were tested — the Cracked branch and
|
||||
/// `crack_span` recording were never exercised end-to-end.
|
||||
#[test]
|
||||
fn crack_outcome_reaches_cracked_with_span() {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
|
||||
let crackable = crackable_sector(&title_key, &seed, 8);
|
||||
// The crackable sector sits a few sectors into the extent.
|
||||
let mut src = MockSource::new(0x00); // surrounding sectors: clear
|
||||
src.crackable = Some((1003, crackable.clone()));
|
||||
let extents = [Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 50,
|
||||
}];
|
||||
let outcome = crack_key_outcome(&mut src, &extents, 4, None);
|
||||
let state = match outcome {
|
||||
CrackOutcome::Cracked(s) => s,
|
||||
other => panic!("expected Cracked, got {other:?}"),
|
||||
};
|
||||
// The recovered key descrambles the crackable sector body.
|
||||
let mut test = crackable.clone();
|
||||
descramble_sector(&state, &mut test);
|
||||
let mut plain = crackable;
|
||||
lfsr::descramble_sector(&title_key, &mut plain);
|
||||
assert_eq!(
|
||||
&test[0x80..],
|
||||
&plain[0x80..],
|
||||
"recovered key must round-trip the scrambled sector body"
|
||||
);
|
||||
// crack_span = half-open [start, start+count) of the scanned extent.
|
||||
assert_eq!(
|
||||
state.crack_span,
|
||||
Some((1000, 1050)),
|
||||
"crack_span must record the extent LBA span for per-VTS routing"
|
||||
);
|
||||
}
|
||||
|
||||
/// CSS_ERROR WIRING (audit §2 / §5 #7): an all-locked synthetic ISO (every
|
||||
/// VOB read returns CSS-locked sense `05/6F/03` across MULTIPLE extents, as a
|
||||
/// real encrypted-but-unauthenticated disc image does) must produce the exact
|
||||
/// outcome the scan converts into `disc.css_error = Some(Error::CssKeyMissing)`
|
||||
/// — i.e. `CrackOutcome::ScrambledUncracked` / `is_scrambled_uncracked()`,
|
||||
/// NOT `Unencrypted`. disc/mod.rs's `crack_key_outcome → ScrambledUncracked`
|
||||
/// arm (where it stamps css_error) is driven by exactly this signal, so this
|
||||
/// pins the css-layer contract that arm depends on without touching the
|
||||
/// scan plumbing.
|
||||
#[test]
|
||||
fn all_locked_synthetic_iso_yields_css_key_missing_signal() {
|
||||
let mut src = MockSource::new(0x30);
|
||||
src.lock_all = true; // every read → 05/6F/03 across the whole "ISO"
|
||||
let extents = [
|
||||
Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 30,
|
||||
},
|
||||
Extent {
|
||||
start_lba: 5_000,
|
||||
sector_count: 30,
|
||||
},
|
||||
];
|
||||
let outcome = crack_key_outcome(&mut src, &extents, 16, None);
|
||||
assert!(
|
||||
outcome.is_scrambled_uncracked(),
|
||||
"all-locked ISO → ScrambledUncracked (the css_error=CssKeyMissing \
|
||||
signal), got {outcome:?}"
|
||||
);
|
||||
// The legacy Option wrapper still collapses it to None — callers that
|
||||
// surface the hard error must use crack_key_outcome, which this proves.
|
||||
let mut src2 = MockSource::new(0x30);
|
||||
src2.lock_all = true;
|
||||
assert!(crack_key(&mut src2, &extents, 16).is_none());
|
||||
}
|
||||
|
||||
/// PER-VTS RE-CRACK SUCCESS (audit gap "success path missing"): the prior
|
||||
/// re-crack test only covered the locked→None path. Here a re-crack
|
||||
/// (`crack_key`, `fail_on_locked == false`) over a DIFFERENT VTS's extents
|
||||
/// finds that VTS's own crackable sector and returns a `CssState` whose
|
||||
/// `crack_span` matches the new extents — proving a key cracked for one VTS
|
||||
/// is genuinely re-derived (not reused) for another.
|
||||
#[test]
|
||||
fn recrack_succeeds_on_other_vts_extents() {
|
||||
let title_key = [0xFE, 0xDC, 0xBA, 0x98, 0x76];
|
||||
let seed = [0x00, 0xFF, 0x80, 0x7F, 0x01];
|
||||
let crackable = crackable_sector(&title_key, &seed, 5);
|
||||
let mut src = MockSource::new(0x00);
|
||||
// The second VTS lives at a disjoint LBA range; its crackable sector is
|
||||
// the first one in the extent.
|
||||
src.crackable = Some((9000, crackable));
|
||||
let other_vts = [Extent {
|
||||
start_lba: 9000,
|
||||
sector_count: 20,
|
||||
}];
|
||||
let state = crack_key(&mut src, &other_vts, 4).expect("re-crack must recover a key");
|
||||
assert_eq!(
|
||||
state.crack_span,
|
||||
Some((9000, 9020)),
|
||||
"re-crack span must reflect the OTHER VTS extents, not a reused span"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
//! CSS title-key recovery — Frank A. Stevenson's divide-and-conquer attack
|
||||
//! (1999), ported exactly from libdvdcss `RecoverTitleKey` + `AttackPattern`
|
||||
//! (css.c).
|
||||
//!
|
||||
//! Recovers the 5-byte CSS title key from a single scrambled DVD sector with
|
||||
//! no player keys and no disc-key crack, using only known plaintext.
|
||||
//!
|
||||
//! # The cipher this attacks
|
||||
//!
|
||||
//! The content descrambler ([`super::lfsr::descramble_sector`], = libdvdcss
|
||||
//! `dvdcss_unscramble`) seeds its two LFSRs **directly** from
|
||||
//! `key = title_key XOR sector_seed` (seed = `sector[0x54..0x59]`):
|
||||
//!
|
||||
//! ```text
|
||||
//! i_t1 = (key[0] ^ sec[0x54]) | 0x100; // LFSR1 low (9-bit)
|
||||
//! i_t2 = key[1] ^ sec[0x55]; // LFSR1 high
|
||||
//! i_t3 = (key[2]|key[3]<<8|key[4]<<16) ^ seed3; // LFSR0 (24-bit feedback)
|
||||
//! i_t3 = i_t3*2 + 8 - (i_t3 & 7);
|
||||
//! // per byte: *p = TAB1[*p] ^ (i_t5 & 0xff)
|
||||
//! ```
|
||||
//!
|
||||
//! There is NO `decrypt_key` mangling on the content path. So the recovery
|
||||
//! is a single inversion of `dvdcss_unscramble`, not the multi-stage
|
||||
//! working-key inversion the previous (non-CSS) implementation used.
|
||||
//!
|
||||
//! # The attack
|
||||
//!
|
||||
//! 1. **Known plaintext → keystream.** Because the descramble applies TAB1
|
||||
//! to the ciphertext, the per-byte keystream is
|
||||
//! `buf[i] = TAB1[cipher[i]] ^ plain[i]` (matching libdvdcss
|
||||
//! `RecoverTitleKey`'s `p_buffer`).
|
||||
//! 2. **Brute the 16-bit LFSR1 seed.** For each of 2^16 seeds, run LFSR1
|
||||
//! forward; for the first four steps deduce the LFSR0 output bytes from
|
||||
//! the keystream (carry-tracked), reconstructing `i_t3`. For the next six
|
||||
//! steps clock LFSR0 normally and check it reproduces the keystream — a
|
||||
//! wrong LFSR1 seed fails fast.
|
||||
//! 3. **Back-clock LFSR0.** Run four backward `i_t3` steps (each a 256-way
|
||||
//! search for the byte shifted in) to reach the initial state, then undo
|
||||
//! `i_t3 = i_t3*2 + 8 - (i_t3 & 7)` to recover key[2..5].
|
||||
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]` (plain XOR —
|
||||
//! the descramble seeds directly, so there is no inversion).
|
||||
//!
|
||||
//! `AttackPattern` finds known plaintext for step 1: the longest periodic
|
||||
//! run in the cleartext `sec[0x00..0x80]`, assumed to continue into the
|
||||
//! encrypted region at 0x80.
|
||||
|
||||
use super::lfsr::descramble_sector;
|
||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
const ENCRYPTED_START: usize = 0x80; // byte 128
|
||||
const SEED_OFFSET: usize = 0x54; // sector seed at bytes 0x54-0x58
|
||||
const FLAG_BYTE: usize = 0x14;
|
||||
|
||||
/// RecoverTitleKey: recover the title key from cipher + known plaintext.
|
||||
///
|
||||
/// Exact port of libdvdcss `RecoverTitleKey` (css.c). `crypted` is the
|
||||
/// ciphertext starting at sector byte 0x80; `decrypted` is the matching
|
||||
/// known plaintext; `seed` is `sector[0x54..0x59]`. On success returns the
|
||||
/// recovered 5-byte title key; `None` if no LFSR seed reproduces the
|
||||
/// keystream.
|
||||
///
|
||||
/// At least 10 bytes of `crypted`/`decrypted` are required (the cipher is
|
||||
/// iterated 10 times: 4 to reconstruct LFSR0, 6 to validate).
|
||||
fn recover_title_key_from_plain(
|
||||
crypted: &[u8],
|
||||
decrypted: &[u8],
|
||||
seed: &[u8; 5],
|
||||
) -> Option<[u8; 5]> {
|
||||
if crypted.len() < 10 || decrypted.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// buf[i] = TAB1[cipher[i]] ^ plain[i] — the per-byte content keystream.
|
||||
let mut buffer = [0u8; 10];
|
||||
for (i, b) in buffer.iter_mut().enumerate() {
|
||||
*b = TAB1[crypted[i] as usize] ^ decrypted[i];
|
||||
}
|
||||
|
||||
let mut key = [0u8; 5];
|
||||
let mut found = false;
|
||||
|
||||
for i_try in 0u32..0x1_0000 {
|
||||
let mut i_t1 = (i_try >> 8) | 0x100;
|
||||
let mut i_t2 = i_try & 0xff;
|
||||
let mut i_t3: u32 = 0; // not needed yet
|
||||
let mut i_t5: u32 = 0;
|
||||
|
||||
// Iterate the cipher 4 times to reconstruct LFSR0 (i_t3).
|
||||
for &b in buffer.iter().take(4) {
|
||||
let i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||
i_t2 = i_t1 >> 1;
|
||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||
let i_t4 = TAB5[i_t4 as usize] as u32;
|
||||
|
||||
// Deduce i_t6 (LFSR0 output, pre-TAB4) and the carry.
|
||||
let mut i_t6 = b as u32;
|
||||
if i_t5 != 0 {
|
||||
i_t6 = (i_t6 + 0xff) & 0xff;
|
||||
}
|
||||
if i_t6 < i_t4 {
|
||||
i_t6 += 0x100;
|
||||
}
|
||||
i_t6 -= i_t4;
|
||||
i_t5 += i_t6 + i_t4;
|
||||
let i_t6 = TAB4[i_t6 as usize] as u32;
|
||||
|
||||
i_t3 = (i_t3 << 8) | i_t6;
|
||||
i_t5 >>= 8;
|
||||
}
|
||||
|
||||
let i_candidate = i_t3;
|
||||
|
||||
// Iterate 6 more times to validate the candidate.
|
||||
let mut i = 4usize;
|
||||
while i < 10 {
|
||||
let i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||
i_t2 = i_t1 >> 1;
|
||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||
let i_t4 = TAB5[i_t4 as usize] as u32;
|
||||
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xff;
|
||||
i_t3 = (i_t3 << 8) | i_t6;
|
||||
i_t6 = TAB4[i_t6 as usize] as u32;
|
||||
i_t5 += i_t6 + i_t4;
|
||||
if (i_t5 & 0xff) as u8 != buffer[i] {
|
||||
break;
|
||||
}
|
||||
i_t5 >>= 8;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if i != 10 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Four backward steps of iterating i_t3 to deduce the initial state.
|
||||
i_t3 = i_candidate;
|
||||
for _ in 0..4 {
|
||||
let i_t1_byte = i_t3 & 0xff;
|
||||
i_t3 >>= 8;
|
||||
// Brute-force the byte shifted in (top byte of the 24-bit reg).
|
||||
for j in 0u32..256 {
|
||||
i_t3 = (i_t3 & 0x1_ffff) | (j << 17);
|
||||
let i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xff;
|
||||
if i_t6 == i_t1_byte {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Undo `i_t3 = i_t3*2 + 8 - (i_t3 & 7)` to recover key[2..5].
|
||||
let i_t4 = (i_t3 >> 1).wrapping_sub(4);
|
||||
for i_t5 in 0u32..8 {
|
||||
let val = i_t4.wrapping_add(i_t5);
|
||||
if val.wrapping_mul(2).wrapping_add(8).wrapping_sub(val & 7) == i_t3 {
|
||||
key[0] = (i_try >> 8) as u8;
|
||||
key[1] = (i_try & 0xff) as u8;
|
||||
key[2] = (val & 0xff) as u8;
|
||||
key[3] = ((val >> 8) & 0xff) as u8;
|
||||
key[4] = ((val >> 16) & 0xff) as u8;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// First fully-validated candidate wins. The 48-bit keystream constraint
|
||||
// makes a second match cryptographically negligible on real sectors, but
|
||||
// continuing would let a later spurious match overwrite a correct key.
|
||||
if found {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
for (k, &s) in key.iter_mut().zip(seed.iter()) {
|
||||
*k ^= s;
|
||||
}
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover the CSS title key from a scrambled sector using a known plaintext
|
||||
/// for the encrypted region.
|
||||
///
|
||||
/// `plain` is the expected plaintext at byte 0x80 (at least 10 bytes).
|
||||
/// Returns the recovered key only if it actually descrambles the sector back
|
||||
/// to `plain` — guarding against the rare spurious LFSR-seed match.
|
||||
pub fn recover_title_key(sector: &[u8], plain: &[u8]) -> Option<[u8; 5]> {
|
||||
if sector.len() < SECTOR_BYTES || plain.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
if sector[FLAG_BYTE] & 0x30 == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let seed: [u8; 5] = [
|
||||
sector[SEED_OFFSET],
|
||||
sector[SEED_OFFSET + 1],
|
||||
sector[SEED_OFFSET + 2],
|
||||
sector[SEED_OFFSET + 3],
|
||||
sector[SEED_OFFSET + 4],
|
||||
];
|
||||
|
||||
let crypted = §or[ENCRYPTED_START..ENCRYPTED_START + 10];
|
||||
let key = recover_title_key_from_plain(crypted, plain, &seed)?;
|
||||
|
||||
if descramble_matches(sector, &key, plain) {
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a title key by descrambling a copy of `sector` and checking the
|
||||
/// known plaintext reappears at byte 0x80.
|
||||
fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool {
|
||||
let mut test = sector.to_vec();
|
||||
test[FLAG_BYTE] |= 0x10; // ensure scramble flag set for the descrambler
|
||||
descramble_sector(title, &mut test);
|
||||
let n = plain.len().min(SECTOR_BYTES - ENCRYPTED_START);
|
||||
test[ENCRYPTED_START..ENCRYPTED_START + n] == plain[..n]
|
||||
}
|
||||
|
||||
/// AttackPattern: find a repeating pattern just before the encrypted region
|
||||
/// and assume the plaintext at 0x80 continues it.
|
||||
///
|
||||
/// Exact port of libdvdcss `AttackPattern` (css.c). Scans cleartext
|
||||
/// `sec[0x00..0x80]` for the longest run that repeats with a cycle length in
|
||||
/// 2..0x2F. If the run is long enough (`plen > 3` and at least two full
|
||||
/// cycles), the known plaintext at 0x80 is taken to be the periodic run
|
||||
/// continuing forward, and [`recover_title_key_from_plain`] is applied.
|
||||
pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
||||
if sector.len() < SECTOR_BYTES {
|
||||
return None;
|
||||
}
|
||||
if sector[FLAG_BYTE] & 0x30 == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Runaway guard: a single sector's crack is a bounded 2^16 LFSR search and
|
||||
// should finish in well under a second on any modern CPU. If it ever
|
||||
// exceeds ~2s wall-clock, something pathological is happening — log it so a
|
||||
// hang is never silent.
|
||||
let crack_t0 = std::time::Instant::now();
|
||||
|
||||
let result = crack_title_key_inner(sector);
|
||||
|
||||
let elapsed = crack_t0.elapsed();
|
||||
if elapsed.as_secs_f64() > 2.0 {
|
||||
tracing::warn!(
|
||||
target: "freemkv::css",
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
found = result.is_some(),
|
||||
"css crack: single-sector recovery exceeded 2s (runaway guard)"
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Inner body of [`crack_title_key`] — the actual AttackPattern search. Split
|
||||
/// out so the public entry point can wall-clock the whole attempt for the
|
||||
/// runaway guard without threading a timer through every return path.
|
||||
/// AttackPattern crib: the predicted 10-byte plaintext at byte 0x80.
|
||||
///
|
||||
/// Scans the clear header `sec[0x00..0x80]` (never scrambled) for the longest
|
||||
/// run that repeats with a cycle length in 2..0x2F. If the run is long enough
|
||||
/// (`plen > 3` and at least two full cycles), the plaintext at 0x80 is taken to
|
||||
/// be that periodic run continuing forward. Returns `None` for an unscrambled
|
||||
/// sector or one with no usable run — such a sector can be neither cracked nor
|
||||
/// key-validated, only descrambled with an externally-cached key.
|
||||
///
|
||||
/// The header is untouched by `descramble_sector`, so the crib is identical
|
||||
/// before and after descramble: the decrypt path uses it as a per-sector
|
||||
/// "did the cached key descramble correctly?" oracle (the predicted plaintext
|
||||
/// must reappear at 0x80), and the cracker uses it as its known plaintext.
|
||||
pub(crate) fn attack_crib(sector: &[u8]) -> Option<[u8; 10]> {
|
||||
if sector.len() < SECTOR_BYTES || sector[FLAG_BYTE] & 0x30 == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut best_plen: usize = 0;
|
||||
let mut best_p: usize = 0;
|
||||
|
||||
// For all cycle lengths from 2 to 0x2F.
|
||||
for i in 2usize..0x30 {
|
||||
// Count bytes that repeat with cycle length i, scanning backward from
|
||||
// 0x7F. `sec[0x7F - (j % i)] == sec[0x7F - j]`.
|
||||
let mut j = i + 1;
|
||||
while j < 0x80 && sector[0x7f - (j % i)] == sector[0x7f - j] {
|
||||
if j > best_plen {
|
||||
best_plen = j;
|
||||
best_p = i;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Need at least a few repeated bytes and at least one full cycle.
|
||||
if best_plen > 3 && best_p > 0 && best_plen / best_p >= 2 {
|
||||
// The known plaintext is the periodic run continuing past 0x80. The
|
||||
// crib starts at `0x80 - (best_plen/best_p)*best_p` and continues
|
||||
// through the encrypted region; the bytes at and after 0x80 are the
|
||||
// predicted plaintext (the pattern repeats with period best_p).
|
||||
let cycles = best_plen / best_p;
|
||||
let plain_start = 0x80 - cycles * best_p;
|
||||
|
||||
// Each predicted byte is the run sample one or more periods back:
|
||||
// `sec[plain_start + (i % best_p)]`. For in-run offsets
|
||||
// (`plain_start + i < 0x80`) the run is exactly periodic, so this
|
||||
// equals `sec[plain_start + i]`; for offsets at/after 0x80 the raw
|
||||
// byte is ciphertext, so we MUST wrap within the period rather than
|
||||
// read it. (Reading `&sec[plain_start..+10]` directly — as before —
|
||||
// pulled ciphertext into the crib whenever the run covered fewer than
|
||||
// 10 bytes before 0x80, producing false-negative key recovery.)
|
||||
let mut plain = [0u8; 10];
|
||||
for (i, p) in plain.iter_mut().enumerate() {
|
||||
*p = sector[plain_start + (i % best_p)];
|
||||
}
|
||||
Some(plain)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn crack_title_key_inner(sector: &[u8]) -> Option<[u8; 5]> {
|
||||
let plain = attack_crib(sector)?;
|
||||
let seed: [u8; 5] = [
|
||||
sector[SEED_OFFSET],
|
||||
sector[SEED_OFFSET + 1],
|
||||
sector[SEED_OFFSET + 2],
|
||||
sector[SEED_OFFSET + 3],
|
||||
sector[SEED_OFFSET + 4],
|
||||
];
|
||||
let crypted = §or[0x80..0x80 + 10];
|
||||
if let Some(key) = recover_title_key_from_plain(crypted, &plain, &seed) {
|
||||
// Verify against the same predicted plaintext.
|
||||
if descramble_matches(sector, &key, &plain) {
|
||||
return Some(key);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::lfsr::scramble_sector;
|
||||
use super::*;
|
||||
|
||||
/// Build a synthetic scrambled sector for a given title key and seed,
|
||||
/// with `plain` placed as the plaintext at byte 0x80, scrambled with
|
||||
/// EXACTLY the cipher `descramble_sector` inverts. Returns
|
||||
/// (scrambled_sector, full_plaintext_body).
|
||||
fn synth_sector(title_key: &[u8; 5], seed: &[u8; 5], plain: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut plaintext = vec![0u8; SECTOR_BYTES];
|
||||
plaintext[0..4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
|
||||
plaintext[FLAG_BYTE] = 0x10;
|
||||
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
||||
plaintext[ENCRYPTED_START..ENCRYPTED_START + plain.len()].copy_from_slice(plain);
|
||||
|
||||
let body = plaintext.clone();
|
||||
|
||||
// scramble_sector turns the plaintext body into ciphertext and sets
|
||||
// the scramble flag.
|
||||
scramble_sector(title_key, &mut plaintext);
|
||||
(plaintext, body)
|
||||
}
|
||||
|
||||
/// Build a synthetic scrambled sector whose CLEARTEXT (0x00..0x80) ends
|
||||
/// in a periodic run that continues into the encrypted region — the case
|
||||
/// `AttackPattern` (crack_title_key) is designed to crack.
|
||||
fn synth_periodic_sector(
|
||||
title_key: &[u8; 5],
|
||||
seed: &[u8; 5],
|
||||
period: usize,
|
||||
) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut plaintext = vec![0u8; SECTOR_BYTES];
|
||||
plaintext[FLAG_BYTE] = 0x10;
|
||||
|
||||
// A clean periodic run occupying the tail of the cleartext header
|
||||
// (RUN_START..0x80) and continuing into the encrypted region. This
|
||||
// mirrors a real VOB: a periodic data run just before the scrambled
|
||||
// part. The run must NOT overlap the seed bytes (0x54..0x59), or the
|
||||
// AttackPattern detector would break mid-run. The phase is anchored to
|
||||
// offset 0 so the run is consistent across the 0x80 boundary.
|
||||
// Just above the seed (0x54..0x59); gives a 39-byte run (0x59..0x80)
|
||||
// — enough for >=2 cycles of every tested period (<=19).
|
||||
const RUN_START: usize = 0x59;
|
||||
let pat: Vec<u8> = (0..period)
|
||||
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
|
||||
.collect();
|
||||
for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) {
|
||||
*b = pat[i % period];
|
||||
}
|
||||
|
||||
// Seed sits below the run, undisturbed.
|
||||
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
||||
|
||||
let body = plaintext.clone();
|
||||
scramble_sector(title_key, &mut plaintext);
|
||||
(plaintext, body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crack_unscrambled_returns_none() {
|
||||
let sector = vec![0u8; 2048];
|
||||
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; 2048];
|
||||
let short_plain = [0u8; 4];
|
||||
assert!(recover_title_key(§or, &short_plain).is_none());
|
||||
}
|
||||
|
||||
/// The known plaintext used at byte 0x80 for the direct-recovery tests.
|
||||
/// A realistic MPEG-2 PES header start.
|
||||
const PES: [u8; 10] = [0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21];
|
||||
|
||||
/// MANDATORY round-trip (Task C.1): synthesize a scrambled sector for a
|
||||
/// known (title_key, seed), then assert recover_title_key returns a key
|
||||
/// that descrambles the body back to plaintext. CSS title-key recovery is
|
||||
/// well-defined up to keys that scramble identically; we assert the full
|
||||
/// body round-trips (the true correctness property), and additionally
|
||||
/// that the EXACT key is returned for the common case.
|
||||
#[test]
|
||||
fn recover_round_trips_known_keys() {
|
||||
let cases: &[([u8; 5], [u8; 5])] = &[
|
||||
(
|
||||
[0x42, 0x13, 0x37, 0xBE, 0xEF],
|
||||
[0x11, 0x22, 0x33, 0x44, 0x55],
|
||||
),
|
||||
(
|
||||
[0x01, 0x02, 0x03, 0x04, 0x05],
|
||||
[0xDE, 0xAD, 0xBE, 0xEF, 0x42],
|
||||
),
|
||||
(
|
||||
[0xFE, 0xDC, 0xBA, 0x98, 0x76],
|
||||
[0x00, 0xFF, 0x80, 0x7F, 0x01],
|
||||
),
|
||||
(
|
||||
[0x9A, 0x78, 0x56, 0x34, 0x12],
|
||||
[0xA5, 0x5A, 0x0F, 0xF0, 0xCC],
|
||||
),
|
||||
(
|
||||
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
||||
[0x01, 0x01, 0x01, 0x01, 0x01],
|
||||
),
|
||||
];
|
||||
for (title_key, seed) in cases {
|
||||
let (mut sector, body) = synth_sector(title_key, seed, &PES);
|
||||
let recovered =
|
||||
recover_title_key(§or, &PES).expect("recover_title_key returned None");
|
||||
descramble_sector(&recovered, &mut sector);
|
||||
assert_eq!(
|
||||
§or[ENCRYPTED_START..SECTOR_BYTES],
|
||||
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||
"recovered key did not descramble the full body for \
|
||||
title={title_key:02x?} seed={seed:02x?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// MANDATORY (Task C.1): the AttackPattern entry point crack_title_key —
|
||||
/// no plaintext supplied — recovers a round-tripping key when the
|
||||
/// cleartext ends in a periodic run that continues into 0x80.
|
||||
#[test]
|
||||
fn crack_title_key_recovers_via_attack_pattern() {
|
||||
for &period in &[2usize, 3, 5, 8, 16] {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
|
||||
let (sector, body) = synth_periodic_sector(&title_key, &seed, period);
|
||||
|
||||
let cracked = crack_title_key(§or)
|
||||
.unwrap_or_else(|| panic!("crack_title_key returned None for period {period}"));
|
||||
let mut test = sector.clone();
|
||||
descramble_sector(&cracked, &mut test);
|
||||
assert_eq!(
|
||||
&test[ENCRYPTED_START..SECTOR_BYTES],
|
||||
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||
"crack_title_key key did not round-trip the body (period {period})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// recover_title_key_from_plain inverts dvdcss_unscramble exactly: scramble
|
||||
/// a known body, hand back the keystream-derived key, and the recovered
|
||||
/// key (XOR-back included) reproduces the plaintext.
|
||||
#[test]
|
||||
fn recovered_key_descrambles_back_to_plaintext() {
|
||||
let cases: &[([u8; 5], [u8; 5])] = &[
|
||||
(
|
||||
[0x42, 0x13, 0x37, 0xBE, 0xEF],
|
||||
[0x11, 0x22, 0x33, 0x44, 0x55],
|
||||
),
|
||||
(
|
||||
[0x9A, 0x78, 0x56, 0x34, 0x12],
|
||||
[0xA5, 0x5A, 0x0F, 0xF0, 0xCC],
|
||||
),
|
||||
(
|
||||
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
||||
[0x01, 0x01, 0x01, 0x01, 0x01],
|
||||
),
|
||||
];
|
||||
for (title_key, seed) in cases {
|
||||
let (mut sector, body) = synth_sector(title_key, seed, &PES);
|
||||
let recovered =
|
||||
recover_title_key(§or, &PES).expect("recover_title_key returned None");
|
||||
descramble_sector(&recovered, &mut sector);
|
||||
assert_eq!(
|
||||
§or[ENCRYPTED_START..SECTOR_BYTES],
|
||||
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||
"descramble with recovered key did not reproduce the body \
|
||||
for title={title_key:02x?} seed={seed:02x?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── early-return guards ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn recover_rejects_sector_one_byte_short() {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES - 1];
|
||||
sector[FLAG_BYTE] = 0x30;
|
||||
assert!(recover_title_key(§or, &PES).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_rejects_unscrambled_sector() {
|
||||
let sector = vec![0x00u8; SECTOR_BYTES];
|
||||
assert!(recover_title_key(§or, &PES).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_high_flag_bits_are_not_scramble() {
|
||||
for &flag in &[0x40u8, 0x80, 0xC0] {
|
||||
let mut sector = vec![0x11u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = flag;
|
||||
assert!(
|
||||
recover_title_key(§or, &PES).is_none(),
|
||||
"flag {flag:#04x} has scramble bits clear; recover must return None"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crack_high_flag_bits_are_not_scramble() {
|
||||
for &flag in &[0x40u8, 0x80, 0xC0] {
|
||||
let mut sector = vec![0x11u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = flag;
|
||||
assert!(
|
||||
crack_title_key(§or).is_none(),
|
||||
"flag {flag:#04x} clear scramble bits -> crack must return None"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crack_rejects_sector_one_byte_short() {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES - 1];
|
||||
if sector.len() > FLAG_BYTE {
|
||||
sector[FLAG_BYTE] = 0x30;
|
||||
}
|
||||
assert!(crack_title_key(§or).is_none());
|
||||
}
|
||||
|
||||
/// crack_title_key must never panic on a fully scrambled sector with
|
||||
/// arbitrary (non-periodic) content — it just returns None.
|
||||
#[test]
|
||||
fn crack_full_path_never_panics() {
|
||||
for seed in 0u32..3 {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = 0x30;
|
||||
let mut x = seed.wrapping_mul(2_654_435_761).wrapping_add(7);
|
||||
for b in sector.iter_mut().skip(0x80) {
|
||||
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||||
*b = (x >> 16) as u8;
|
||||
}
|
||||
for (i, b) in sector[SEED_OFFSET..SEED_OFFSET + 5].iter_mut().enumerate() {
|
||||
*b = (seed.wrapping_add(i as u32) ^ 0xA5) as u8;
|
||||
}
|
||||
let _ = crack_title_key(§or);
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
-28
@@ -25,6 +25,8 @@ pub const TAB1: [u8; 256] = [
|
||||
];
|
||||
|
||||
/// Table 2: LFSR1 high-byte feedback permutation.
|
||||
///
|
||||
/// Byte-identical to libdvdcss `p_css_tab2` (csstables.h).
|
||||
pub const TAB2: [u8; 256] = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x09, 0x08, 0x0b, 0x0a, 0x0d, 0x0c, 0x0f, 0x0e,
|
||||
0x12, 0x13, 0x10, 0x11, 0x16, 0x17, 0x14, 0x15, 0x1b, 0x1a, 0x19, 0x18, 0x1f, 0x1e, 0x1d, 0x1c,
|
||||
@@ -40,11 +42,18 @@ pub const TAB2: [u8; 256] = [
|
||||
0xa4, 0xa5, 0xa6, 0xa7, 0xa0, 0xa1, 0xa2, 0xa3, 0xad, 0xac, 0xaf, 0xae, 0xa9, 0xa8, 0xab, 0xaa,
|
||||
0xdb, 0xda, 0xd9, 0xd8, 0xdf, 0xde, 0xdd, 0xdc, 0xd2, 0xd3, 0xd0, 0xd1, 0xd6, 0xd7, 0xd4, 0xd5,
|
||||
0xc9, 0xc8, 0xcb, 0xca, 0xcd, 0xcc, 0xcf, 0xce, 0xc0, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7,
|
||||
0xed, 0xec, 0xef, 0xee, 0xe9, 0xe8, 0xeb, 0xea, 0xe4, 0xe5, 0xe6, 0xe7, 0xe0, 0xe1, 0xe2, 0xe3,
|
||||
0xff, 0xfe, 0xfd, 0xfc, 0xfb, 0xfa, 0xf9, 0xf8, 0xf6, 0xf7, 0xf4, 0xf5, 0xf2, 0xf3, 0xf0, 0xf1,
|
||||
0xed, 0xec, 0xef, 0xee, 0xe9, 0xe8, 0xeb, 0xea, 0xe4, 0xe5, 0xe6, 0xe7, 0xe0, 0xe1, 0xe2, 0xe3,
|
||||
];
|
||||
|
||||
/// Table 3: LFSR1 low-byte feedback permutation.
|
||||
/// Table 3: LFSR1 9-bit low-word feedback table (512 entries).
|
||||
///
|
||||
/// Byte-identical to libdvdcss `p_css_tab3` (csstables.h): the 8-value
|
||||
/// block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step indexes this
|
||||
/// table with the 9-bit low register (0x100..=0x1FF), but only the low 3
|
||||
/// bits select the output — the high bits are ignored, hence the constant
|
||||
/// blocks. The 512-entry width simply lets the 9-bit index be used without
|
||||
/// masking.
|
||||
pub const TAB3: [u8; 512] = [
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
@@ -54,30 +63,30 @@ pub const TAB3: [u8; 512] = [
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x01, 0x25, 0x48, 0x6c, 0x93, 0xb7, 0xda, 0xfe, 0x01, 0x25, 0x48, 0x6c, 0x93, 0xb7, 0xda, 0xfe,
|
||||
0x01, 0x25, 0x48, 0x6c, 0x93, 0xb7, 0xda, 0xfe, 0x01, 0x25, 0x48, 0x6c, 0x93, 0xb7, 0xda, 0xfe,
|
||||
0x01, 0x25, 0x48, 0x6c, 0x93, 0xb7, 0xda, 0xfe, 0x01, 0x25, 0x48, 0x6c, 0x93, 0xb7, 0xda, 0xfe,
|
||||
0x01, 0x25, 0x48, 0x6c, 0x93, 0xb7, 0xda, 0xfe, 0x01, 0x25, 0x48, 0x6c, 0x93, 0xb7, 0xda, 0xfe,
|
||||
0x01, 0x25, 0x48, 0x6c, 0x93, 0xb7, 0xda, 0xfe, 0x01, 0x25, 0x48, 0x6c, 0x93, 0xb7, 0xda, 0xfe,
|
||||
0x01, 0x25, 0x48, 0x6c, 0x93, 0xb7, 0xda, 0xfe, 0x01, 0x25, 0x48, 0x6c, 0x93, 0xb7, 0xda, 0xfe,
|
||||
0x01, 0x25, 0x48, 0x6c, 0x93, 0xb7, 0xda, 0xfe, 0x01, 0x25, 0x48, 0x6c, 0x93, 0xb7, 0xda, 0xfe,
|
||||
0x01, 0x25, 0x48, 0x6c, 0x93, 0xb7, 0xda, 0xfe, 0x01, 0x25, 0x48, 0x6c, 0x93, 0xb7, 0xda, 0xfe,
|
||||
0x02, 0x26, 0x4b, 0x6f, 0x90, 0xb4, 0xd9, 0xfd, 0x02, 0x26, 0x4b, 0x6f, 0x90, 0xb4, 0xd9, 0xfd,
|
||||
0x02, 0x26, 0x4b, 0x6f, 0x90, 0xb4, 0xd9, 0xfd, 0x02, 0x26, 0x4b, 0x6f, 0x90, 0xb4, 0xd9, 0xfd,
|
||||
0x02, 0x26, 0x4b, 0x6f, 0x90, 0xb4, 0xd9, 0xfd, 0x02, 0x26, 0x4b, 0x6f, 0x90, 0xb4, 0xd9, 0xfd,
|
||||
0x02, 0x26, 0x4b, 0x6f, 0x90, 0xb4, 0xd9, 0xfd, 0x02, 0x26, 0x4b, 0x6f, 0x90, 0xb4, 0xd9, 0xfd,
|
||||
0x02, 0x26, 0x4b, 0x6f, 0x90, 0xb4, 0xd9, 0xfd, 0x02, 0x26, 0x4b, 0x6f, 0x90, 0xb4, 0xd9, 0xfd,
|
||||
0x02, 0x26, 0x4b, 0x6f, 0x90, 0xb4, 0xd9, 0xfd, 0x02, 0x26, 0x4b, 0x6f, 0x90, 0xb4, 0xd9, 0xfd,
|
||||
0x02, 0x26, 0x4b, 0x6f, 0x90, 0xb4, 0xd9, 0xfd, 0x02, 0x26, 0x4b, 0x6f, 0x90, 0xb4, 0xd9, 0xfd,
|
||||
0x02, 0x26, 0x4b, 0x6f, 0x90, 0xb4, 0xd9, 0xfd, 0x02, 0x26, 0x4b, 0x6f, 0x90, 0xb4, 0xd9, 0xfd,
|
||||
0x03, 0x27, 0x4a, 0x6e, 0x91, 0xb5, 0xd8, 0xfc, 0x03, 0x27, 0x4a, 0x6e, 0x91, 0xb5, 0xd8, 0xfc,
|
||||
0x03, 0x27, 0x4a, 0x6e, 0x91, 0xb5, 0xd8, 0xfc, 0x03, 0x27, 0x4a, 0x6e, 0x91, 0xb5, 0xd8, 0xfc,
|
||||
0x03, 0x27, 0x4a, 0x6e, 0x91, 0xb5, 0xd8, 0xfc, 0x03, 0x27, 0x4a, 0x6e, 0x91, 0xb5, 0xd8, 0xfc,
|
||||
0x03, 0x27, 0x4a, 0x6e, 0x91, 0xb5, 0xd8, 0xfc, 0x03, 0x27, 0x4a, 0x6e, 0x91, 0xb5, 0xd8, 0xfc,
|
||||
0x03, 0x27, 0x4a, 0x6e, 0x91, 0xb5, 0xd8, 0xfc, 0x03, 0x27, 0x4a, 0x6e, 0x91, 0xb5, 0xd8, 0xfc,
|
||||
0x03, 0x27, 0x4a, 0x6e, 0x91, 0xb5, 0xd8, 0xfc, 0x03, 0x27, 0x4a, 0x6e, 0x91, 0xb5, 0xd8, 0xfc,
|
||||
0x03, 0x27, 0x4a, 0x6e, 0x91, 0xb5, 0xd8, 0xfc, 0x03, 0x27, 0x4a, 0x6e, 0x91, 0xb5, 0xd8, 0xfc,
|
||||
0x03, 0x27, 0x4a, 0x6e, 0x91, 0xb5, 0xd8, 0xfc, 0x03, 0x27, 0x4a, 0x6e, 0x91, 0xb5, 0xd8, 0xfc,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
];
|
||||
|
||||
/// Table 4: LFSR0 byte permutation (used in initialization and output).
|
||||
@@ -100,8 +109,10 @@ pub const TAB4: [u8; 256] = [
|
||||
0x0f, 0x8f, 0x4f, 0xcf, 0x2f, 0xaf, 0x6f, 0xef, 0x1f, 0x9f, 0x5f, 0xdf, 0x3f, 0xbf, 0x7f, 0xff,
|
||||
];
|
||||
|
||||
/// Table 5: LFSR1 output permutation for the Stevenson attack.
|
||||
/// This is the inverse byte-reversal of TAB4.
|
||||
/// Table 5: LFSR1 output permutation used in the keystream combiner.
|
||||
/// `TAB5[i] == TAB4[i] ^ 0xFF` (bitwise complement of the TAB4 bit-reversal
|
||||
/// table). Applied on the normal descramble/recrypt path (lfsr.rs) as well as
|
||||
/// in the key-recovery fallback (crack.rs).
|
||||
pub const TAB5: [u8; 256] = [
|
||||
0xff, 0x7f, 0xbf, 0x3f, 0xdf, 0x5f, 0x9f, 0x1f, 0xef, 0x6f, 0xaf, 0x2f, 0xcf, 0x4f, 0x8f, 0x0f,
|
||||
0xf7, 0x77, 0xb7, 0x37, 0xd7, 0x57, 0x97, 0x17, 0xe7, 0x67, 0xa7, 0x27, 0xc7, 0x47, 0x87, 0x07,
|
||||
@@ -120,3 +131,155 @@ pub const TAB5: [u8; 256] = [
|
||||
0xf8, 0x78, 0xb8, 0x38, 0xd8, 0x58, 0x98, 0x18, 0xe8, 0x68, 0xa8, 0x28, 0xc8, 0x48, 0x88, 0x08,
|
||||
0xf0, 0x70, 0xb0, 0x30, 0xd0, 0x50, 0x90, 0x10, 0xe0, 0x60, 0xa0, 0x20, 0xc0, 0x40, 0x80, 0x00,
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
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's chained-XOR rounds; if two inputs
|
||||
/// collided, the key mangling would not be invertible.
|
||||
///
|
||||
/// Mutation: duplicate any value (e.g. set TAB1[1] = TAB1[0]) -> the
|
||||
/// "maps two inputs" assert fires.
|
||||
#[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 from the CSS spec table:
|
||||
/// TAB1[0x00] == 0x33 and the inverse TAB1[0x33] == 0x00. These two
|
||||
/// entries are the canonical first-row / inverse-lookup landmarks of the
|
||||
/// published CSS TAB1 and pin the table's orientation.
|
||||
///
|
||||
/// Grounding: CSS specification TAB1, row 0 col 0 = 0x33; index 0x33
|
||||
/// (row 3 col 3) = 0x00.
|
||||
/// Mutation: change the first literal `0x33` in TAB1 -> first assert fails.
|
||||
#[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");
|
||||
}
|
||||
|
||||
/// TAB2 is a permutation of 0..256 (it is the LFSR1 high-byte feedback
|
||||
/// substitution). A non-bijective TAB2 would bias the LFSR1 keystream.
|
||||
///
|
||||
/// Mutation: set TAB2[8] = 0x00 (collides with TAB2[0]) -> assert fires.
|
||||
#[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 the libdvdcss `p_css_tab3`: the 8-value feedback block
|
||||
/// BASE = [0x00,0x24,0x49,0x6d,0x92,0xb6,0xdb,0xff]
|
||||
/// repeated 64 times — `TAB3[i] == BASE[i & 7]`. The high bits of the
|
||||
/// 9-bit index do not affect the output (libdvdcss's LFSR1 step indexes
|
||||
/// with the full 9-bit low register but only `& 7` matters). This pins
|
||||
/// all 512 entries to the published table.
|
||||
///
|
||||
/// Mutation: flip any single byte in the TAB3 literal -> the formula
|
||||
/// check fails at that index.
|
||||
#[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 uses it to permute
|
||||
/// LFSR0 bytes on seed and output). TAB4[b] reverses b's 8 bits MSB<->LSB.
|
||||
/// Therefore it is also an involution: TAB4[TAB4[b]] == b.
|
||||
///
|
||||
/// Grounding: TAB4[0x01]=0x80, TAB4[0x80]=0x01, TAB4[0x00]=0x00,
|
||||
/// TAB4[0xFF]=0xFF.
|
||||
/// Mutation: set TAB4[1] = 0x40 (not the reversal 0x80) -> bit-reversal
|
||||
/// check fails at index 1.
|
||||
#[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: a table that is "reversal except two swapped entries"
|
||||
/// would still be a permutation, and a table that is "reversal except one
|
||||
/// duplicated entry" would fail this but might pass a sampled reversal
|
||||
/// check — the two tests pin different failure modes.
|
||||
///
|
||||
/// Mutation: set TAB4[2] = TAB4[1] -> permutation assert fires.
|
||||
#[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 also a permutation (complement of a bijection is a bijection)
|
||||
/// and its own self-consistency landmark: TAB5[0x00] == 0xFF (TAB4[0]^0xFF)
|
||||
/// and TAB5[0xFF] == 0x00 (TAB4[0xFF]^0xFF). Pins orientation independent
|
||||
/// of the complement-loop test.
|
||||
///
|
||||
/// Mutation: change the first TAB5 literal 0xff -> 0xfe -> the landmark
|
||||
/// and permutation checks both catch it.
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
+846
-51
@@ -60,8 +60,8 @@ static DECRYPT_POOL: RwLock<Option<Arc<rayon::ThreadPool>>> = RwLock::new(None);
|
||||
|
||||
/// Configure how many threads to use for AACS unit decryption. A value
|
||||
/// of `0` resets to the env / default resolution. `1` forces serial.
|
||||
/// `N > 1` builds a new rayon pool of size N and atomically replaces
|
||||
/// the live pool.
|
||||
/// `N > 1` builds a new rayon pool of size N (capped at [`MAX_THREADS`])
|
||||
/// and atomically replaces the live pool.
|
||||
///
|
||||
/// Thread-safe. Live decrypt calls keep their previously-acquired
|
||||
/// pool reference for the rest of the call — no mid-call pool
|
||||
@@ -82,29 +82,36 @@ pub fn set_decrypt_threads(n: usize) {
|
||||
/// Get (or lazily build) the active rayon thread pool. Returns an
|
||||
/// `Arc` so in-flight work survives a concurrent
|
||||
/// [`set_decrypt_threads`] swap.
|
||||
fn decrypt_pool() -> Arc<rayon::ThreadPool> {
|
||||
// Fast path: pool already built.
|
||||
if let Ok(guard) = DECRYPT_POOL.read() {
|
||||
///
|
||||
/// Returns `None` if the pool cannot be built (e.g. the OS refuses the
|
||||
/// worker threads under a pid/thread limit). The caller falls back to
|
||||
/// the serial decrypt path — library code never panics here.
|
||||
fn decrypt_pool() -> Option<Arc<rayon::ThreadPool>> {
|
||||
// Fast path: pool already built. A poisoned read lock still yields a
|
||||
// usable guard (the pool Arc is immutable once stored).
|
||||
{
|
||||
let guard = DECRYPT_POOL.read().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(pool) = guard.as_ref() {
|
||||
return Arc::clone(pool);
|
||||
return Some(Arc::clone(pool));
|
||||
}
|
||||
}
|
||||
// Slow path: build a new one under the write lock. Double-check
|
||||
// after acquiring in case another caller built it first.
|
||||
let mut guard = DECRYPT_POOL.write().expect("DECRYPT_POOL RwLock poisoned");
|
||||
// Slow path: build a new one under the write lock. Recover the guard
|
||||
// on poisoning (a prior panic) rather than propagating a secondary
|
||||
// panic — we simply rebuild. Double-check after acquiring in case
|
||||
// another caller built it first.
|
||||
let mut guard = DECRYPT_POOL.write().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(pool) = guard.as_ref() {
|
||||
return Arc::clone(pool);
|
||||
return Some(Arc::clone(pool));
|
||||
}
|
||||
let n = decrypt_threads();
|
||||
let pool = Arc::new(
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(n)
|
||||
.thread_name(|i| format!("freemkv-decrypt-{i}"))
|
||||
.build()
|
||||
.expect("rayon decrypt pool build failed"),
|
||||
);
|
||||
.ok()
|
||||
.map(Arc::new)?;
|
||||
*guard = Some(Arc::clone(&pool));
|
||||
pool
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
/// Current effective decrypt thread count. Resolution order:
|
||||
@@ -158,85 +165,243 @@ impl DecryptKeys {
|
||||
/// For CSS: processes per 2048-byte sector.
|
||||
/// For None: no-op.
|
||||
///
|
||||
/// `unit_key_idx` selects which AACS unit key to use (0 for most discs).
|
||||
/// `unit_key_idx` is the initial AACS unit-key hint (0 for most discs). On a
|
||||
/// multi-CPS-unit disc every key is tried per unit until the TS-sync verify
|
||||
/// passes; `unit_key_idx` is tried first so single-CPS-unit discs pay zero
|
||||
/// overhead. An out-of-range `unit_key_idx` is always an error.
|
||||
///
|
||||
/// Returns `Err` if decryption was expected but keys are missing or invalid.
|
||||
/// Never produces silently corrupted output.
|
||||
///
|
||||
/// On success returns the number of bytes belonging to scrambled AACS units
|
||||
/// that **no available key could decrypt** — those units are restored to their
|
||||
/// original encrypted bytes (so a clear nav-file is never corrupted), but for
|
||||
/// genuine encrypted content this is silent data loss the downstream TS
|
||||
/// assembler will drop without a sync. The decrypt-on-read decorator folds this
|
||||
/// count into the mux loss accounting so a partial key failure can't be reported
|
||||
/// as a perfect rip. `0` for `None` / `Css` and for any AACS buffer where every
|
||||
/// scrambled unit decrypted.
|
||||
pub fn decrypt_sectors(
|
||||
buf: &mut [u8],
|
||||
keys: &DecryptKeys,
|
||||
keys: &mut DecryptKeys,
|
||||
unit_key_idx: usize,
|
||||
) -> Result<(), crate::error::Error> {
|
||||
match keys {
|
||||
DecryptKeys::None => {}
|
||||
) -> Result<usize, crate::error::Error> {
|
||||
let dropped: usize = match keys {
|
||||
DecryptKeys::None => 0,
|
||||
DecryptKeys::Aacs {
|
||||
unit_keys,
|
||||
read_data_key,
|
||||
} => {
|
||||
let uk = match unit_keys.get(unit_key_idx) {
|
||||
Some((_, k)) => *k,
|
||||
None => {
|
||||
// Validate that unit_key_idx is in-range before doing anything else.
|
||||
// This preserves the existing contract: an out-of-range explicit index
|
||||
// is always an error (tested by `aacs_out_of_range_unit_key_idx_errors`).
|
||||
if unit_keys.get(unit_key_idx).is_none() {
|
||||
return Err(crate::error::Error::DecryptFailed);
|
||||
}
|
||||
};
|
||||
|
||||
// Strip CPS-unit IDs — the decrypt primitives only want the raw key bytes.
|
||||
let raw_keys: Vec<[u8; 16]> = unit_keys.iter().map(|(_, k)| *k).collect();
|
||||
let rdk: Option<[u8; 16]> = *read_data_key;
|
||||
let unit_len = aacs::ALIGNED_UNIT_LEN;
|
||||
// AACS decrypts whole 6144-byte aligned units. The live mux path
|
||||
// (mux/disc.rs::fill_extents) issues 1- or 2-sector reads at every
|
||||
// extent tail, so a buffer is commonly NOT a multiple of the unit
|
||||
// length. We process the whole leading units exactly as a fully
|
||||
// aligned buffer would be, then make a deliberate decision about any
|
||||
// trailing partial unit.
|
||||
//
|
||||
// Trailing-partial contract:
|
||||
// * A clear partial (incomplete final unit / clear nav-TS tail) is
|
||||
// what AACS legitimately leaves in the clear on disc, so we leave
|
||||
// it untouched and return Ok. This is the proven, shipped
|
||||
// behavior every production UHD MKV was made with — no regression
|
||||
// on conformant discs.
|
||||
// * A *scrambled* partial can only arise from a structurally
|
||||
// malformed UDF layout that splits an encrypted unit across an
|
||||
// extent boundary. Those bytes are encrypted content that cannot
|
||||
// be decrypted standalone; passing them through as clear would be
|
||||
// silent corruption. We fail loud (Error::DecryptFailed), matching
|
||||
// the highway path's Error::ExtentNotUnitAligned policy.
|
||||
//
|
||||
// Detection: is_aacs_scrambled() short-circuits to false for any
|
||||
// buffer shorter than a full unit, so it cannot judge a partial. We
|
||||
// instead apply the same TS-sync-intactness test it uses internally
|
||||
// (ts_sync_count vs ts_packet_total) directly to the available
|
||||
// partial bytes. A clear TS tail carries 0x47 syncs at the 192-byte
|
||||
// stride (> half the packets) → intact → not scrambled → tolerate. An
|
||||
// encrypted tail has those syncs destroyed (≤ half) → scrambled →
|
||||
// reject. If the partial is too short to hold even one TS packet
|
||||
// (< 192 bytes, ts_packet_total == 0) we cannot judge confidently and
|
||||
// tolerate rather than risk a false positive on conformant tails.
|
||||
let partial_len = buf.len() % unit_len;
|
||||
if partial_len != 0 {
|
||||
let partial = &buf[buf.len() - partial_len..];
|
||||
let packets = aacs::ts_packet_total(partial);
|
||||
if packets > 0 && aacs::ts_sync_count(partial) <= packets / 2 {
|
||||
return Err(crate::error::Error::DecryptFailed);
|
||||
}
|
||||
}
|
||||
let nthreads = decrypt_threads();
|
||||
let chunks: Vec<&mut [u8]> = buf.chunks_mut(unit_len).collect();
|
||||
let nunits = chunks.len();
|
||||
let nunits = buf.len() / unit_len;
|
||||
|
||||
// Per-unit decrypt closure. The is_unit_encrypted check is
|
||||
// a byte-0 heuristic; on a misfire we snapshot+restore via
|
||||
// the original bytes so non-m2ts (e.g. MPLS/CLPI nav files)
|
||||
// survive. See test `nav_file_unit_survives_decrypt_attempt`.
|
||||
// Cache the last successfully-validated key index so that runs of
|
||||
// units under the same CPS unit hit on the first try. Initialised to
|
||||
// unit_key_idx (the caller's hint — 0 for almost all discs). An
|
||||
// AtomicUsize lets the parallel path share it cheaply; relaxed
|
||||
// ordering is fine because a stale read just causes one extra try,
|
||||
// never a wrong result (TS-sync verify gates correctness).
|
||||
let last_key_idx = AtomicUsize::new(unit_key_idx);
|
||||
|
||||
// Count bytes of scrambled units that NO key could decrypt. Shared
|
||||
// across the rayon workers (relaxed is fine — it's a pure tally, not
|
||||
// a synchronisation point). A non-zero total is silent decrypt loss:
|
||||
// the bytes pass downstream still encrypted and the TS assembler
|
||||
// drops them without a sync. The caller folds this into mux loss
|
||||
// accounting so a partial key failure isn't reported as a clean rip.
|
||||
let dropped_bytes = AtomicUsize::new(0);
|
||||
|
||||
// Per-unit decrypt closure. For a scrambled full aligned unit:
|
||||
// 1. Try the cached key index first (avoids scanning all keys on the
|
||||
// common case where a disc run uses one CPS unit throughout).
|
||||
// 2. On miss, try every key in order (multi-CPS-unit discs).
|
||||
// 3. Accept the first key whose output passes the TS-sync verify.
|
||||
// 4. Only restore-to-original if NO key validates (non-m2ts unit or
|
||||
// genuine decrypt failure). See test
|
||||
// `nav_file_unit_survives_decrypt_attempt`.
|
||||
//
|
||||
// If a read_data_key is present (AACS 2.0 bus encryption), bus-decrypt
|
||||
// must happen first — it's a shared layer on top that is key-independent
|
||||
// across all CPS units on the disc.
|
||||
let decrypt_one = |chunk: &mut [u8]| {
|
||||
if chunk.len() == unit_len && aacs::is_unit_encrypted(chunk) {
|
||||
if chunk.len() != unit_len || !aacs::is_aacs_scrambled(chunk) {
|
||||
return;
|
||||
}
|
||||
// Save original bytes so we can restore if no key validates.
|
||||
let original: Vec<u8> = chunk.to_vec();
|
||||
if !aacs::decrypt_unit_full(chunk, &uk, rdk.as_ref()) {
|
||||
|
||||
// Build a bus-decrypted copy to try unit keys against, or work
|
||||
// in-place when there is no bus layer.
|
||||
if let Some(ref rdk_key) = rdk {
|
||||
aacs::decrypt_bus(chunk, rdk_key);
|
||||
}
|
||||
|
||||
// Reorder the key iterator: try the cached hint first, then fall
|
||||
// back to the full list skipping the hint.
|
||||
let hint = last_key_idx.load(Ordering::Relaxed);
|
||||
let try_order =
|
||||
std::iter::once(hint).chain((0..raw_keys.len()).filter(move |&i| i != hint));
|
||||
|
||||
for idx in try_order {
|
||||
if let Some(key) = raw_keys.get(idx) {
|
||||
// Work on a per-key copy so a failing attempt doesn't
|
||||
// clobber the bus-decrypted base we'll retry on.
|
||||
let mut attempt: Vec<u8> = chunk.to_vec();
|
||||
if aacs::decrypt_unit(&mut attempt, key) {
|
||||
chunk.copy_from_slice(&attempt);
|
||||
last_key_idx.store(idx, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No key validated — restore the original encrypted bytes and
|
||||
// tally the loss. The unit was scrambled (we only reach here past
|
||||
// the `is_aacs_scrambled` gate) but no key applied: a clear
|
||||
// nav-file unit that legitimately fails the cipher, or genuine
|
||||
// encrypted content with a missing/wrong sub-key. We can't tell
|
||||
// them apart here, so we always tally; the mux read path treats
|
||||
// the count as loss (its extents are real content), while
|
||||
// metadata-probe callers that don't install a loss sink ignore it.
|
||||
chunk.copy_from_slice(&original);
|
||||
}
|
||||
}
|
||||
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
|
||||
};
|
||||
|
||||
if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS {
|
||||
// Serial path: avoids thread-pool overhead for tiny
|
||||
// buffers; also the only path when caller pinned
|
||||
// single-threaded via FREEMKV_THREADS=1.
|
||||
for chunk in chunks {
|
||||
// single-threaded via FREEMKV_THREADS=1. Iterate the
|
||||
// chunks directly — no Vec of slice pointers needed.
|
||||
for chunk in buf.chunks_mut(unit_len) {
|
||||
decrypt_one(chunk);
|
||||
}
|
||||
} else {
|
||||
// Parallel path via rayon's persistent global pool.
|
||||
// The pool is built once on first use (lazy_static-style)
|
||||
// and reused across every decrypt_sectors call — no
|
||||
// per-call OS thread spawn, no thread-creation latency
|
||||
// amortised per batch. Each unit decrypts independently
|
||||
// (own key derivation), so par_iter is sound.
|
||||
decrypt_pool().install(|| {
|
||||
// Parallel path via rayon's persistent thread pool.
|
||||
// The pool is built once on first use and reused across
|
||||
// every decrypt_sectors call — no per-call OS thread
|
||||
// spawn. Each unit decrypts independently (own key
|
||||
// derivation), so par_iter is sound. On a pool-build
|
||||
// failure (e.g. thread/pid-limit exhaustion) we fall
|
||||
// back to the serial path rather than panic.
|
||||
match decrypt_pool() {
|
||||
Some(pool) => {
|
||||
let chunks: Vec<&mut [u8]> = buf.chunks_mut(unit_len).collect();
|
||||
pool.install(|| {
|
||||
chunks.into_par_iter().for_each(|chunk| {
|
||||
decrypt_one(chunk);
|
||||
});
|
||||
});
|
||||
}
|
||||
None => {
|
||||
for chunk in buf.chunks_mut(unit_len) {
|
||||
decrypt_one(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dropped_bytes.into_inner()
|
||||
}
|
||||
DecryptKeys::Css { title_key } => {
|
||||
// CSS has no supplied key list: the ONLY source of a title key is
|
||||
// cracking the data, and the key changes per VTS/VOB region. So
|
||||
// `title_key` is a CACHE of the last crack, not a fixed disc key —
|
||||
// applying it blindly across a region boundary descrambles with the
|
||||
// wrong key (valid headers, garbage payload). Validate it on every
|
||||
// scrambled sector and re-crack on a miss (libdvdcss's on-demand
|
||||
// per-region rekey; the same validate-then-rekey shape the AACS arm
|
||||
// above uses, but re-cracking instead of picking from a list).
|
||||
//
|
||||
// The clear header (<0x80) is never scrambled, so its periodic crib
|
||||
// predicts the plaintext at 0x80. Descramble with the cached key; if
|
||||
// the crib fails to reappear the key region changed (or the primed
|
||||
// key was wrong) — restore the ciphertext, re-crack from this very
|
||||
// sector, and descramble again. A crib-less sector (no periodic run)
|
||||
// can be neither validated nor cracked, so it rides the cached key —
|
||||
// correct, because it lives in the same region as the nearby crib
|
||||
// sector that set the cache.
|
||||
for chunk in buf.chunks_mut(2048) {
|
||||
if chunk.len() < 2048 || !css::is_scrambled(chunk) {
|
||||
continue;
|
||||
}
|
||||
let crib = css::stevenson::attack_crib(chunk);
|
||||
let original: Option<Vec<u8>> = crib.as_ref().map(|_| chunk.to_vec());
|
||||
css::lfsr::descramble_sector(title_key, chunk);
|
||||
if let (Some(crib), Some(original)) = (crib, original) {
|
||||
if chunk[0x80..0x80 + 10] != crib[..] {
|
||||
// Cached key is stale for this region — restore the
|
||||
// ciphertext and crack this sector's own key.
|
||||
chunk.copy_from_slice(&original);
|
||||
if let Some(fresh) = css::stevenson::crack_title_key(chunk) {
|
||||
*title_key = fresh;
|
||||
}
|
||||
css::lfsr::descramble_sector(title_key, chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
0
|
||||
}
|
||||
};
|
||||
Ok(dropped)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit whose
|
||||
/// first byte has the top 2 bits set (here: the ASCII letter 'M' that
|
||||
/// MPLS files start with, 0x4D = 0b01001101) trips `is_unit_encrypted`,
|
||||
/// gets AES-decrypted with the unit key, fails the TS-sync verification,
|
||||
/// and must be restored to its original bytes — not left scrambled.
|
||||
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit (here
|
||||
/// an MPLS file: starts "MPLS", carries no TS syncs) reads as scrambled
|
||||
/// under `is_aacs_scrambled`, gets AES-decrypted with the unit key, fails
|
||||
/// the TS-sync verification, and must be restored to its original bytes —
|
||||
/// not left scrambled.
|
||||
#[test]
|
||||
fn nav_file_unit_survives_decrypt_attempt() {
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
@@ -249,14 +414,644 @@ mod tests {
|
||||
}
|
||||
let snapshot = unit.clone();
|
||||
|
||||
let keys = DecryptKeys::Aacs {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
decrypt_sectors(&mut unit, &keys, 0).unwrap();
|
||||
decrypt_sectors(&mut unit, &mut keys, 0).unwrap();
|
||||
assert_eq!(
|
||||
unit, snapshot,
|
||||
"non-m2ts unit must be restored after failed decrypt"
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a clear-TS region: a 0x47 sync byte at offset 4 of every 192-byte
|
||||
/// BD-TS packet (matching `ts_sync_count`'s probe stride), filler elsewhere.
|
||||
/// Reads as NOT scrambled.
|
||||
fn clear_ts_region(len: usize) -> Vec<u8> {
|
||||
let mut v: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(31)).collect();
|
||||
let mut off = 4;
|
||||
while off < len {
|
||||
v[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Build a scrambled region: the 192-byte-stride sync positions are NOT
|
||||
/// 0x47 (encrypted content destroys them), so it reads as scrambled.
|
||||
fn scrambled_region(len: usize) -> Vec<u8> {
|
||||
let mut v: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(31)).collect();
|
||||
let mut off = 4;
|
||||
while off < len {
|
||||
// Force a non-sync byte at every probe position.
|
||||
v[off] = 0xA5;
|
||||
off += 192;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Whole leading units plus a CLEAR trailing partial (the benign,
|
||||
/// conformant case): AACS leaves an incomplete final unit / clear nav-TS
|
||||
/// tail in the clear on disc. We must return `Ok` and leave the partial
|
||||
/// bytes byte-for-byte unchanged — no regression on real discs.
|
||||
#[test]
|
||||
fn aacs_clear_trailing_partial_is_tolerated_unchanged() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
// One full scrambled unit + a 2048-byte (single-sector) CLEAR tail.
|
||||
let unit = scrambled_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let tail = clear_ts_region(2048);
|
||||
let mut buf = unit;
|
||||
buf.extend_from_slice(&tail);
|
||||
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("clear trailing partial is Ok");
|
||||
|
||||
assert_eq!(
|
||||
&buf[aacs::ALIGNED_UNIT_LEN..],
|
||||
&tail[..],
|
||||
"clear trailing partial unit must be left unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
/// Whole leading units plus a SCRAMBLED trailing partial (the malformed
|
||||
/// danger case): an encrypted unit split across an extent boundary cannot be
|
||||
/// decrypted standalone. Passing it through as clear would be silent
|
||||
/// corruption, so we must fail loud with `DecryptFailed`.
|
||||
#[test]
|
||||
fn aacs_scrambled_trailing_partial_is_rejected() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
// One full unit + a 4096-byte (two-sector) SCRAMBLED tail.
|
||||
let unit = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let tail = scrambled_region(4096);
|
||||
let mut buf = unit;
|
||||
buf.extend_from_slice(&tail);
|
||||
|
||||
let err = decrypt_sectors(&mut buf, &mut keys, 0)
|
||||
.expect_err("scrambled trailing partial must be rejected");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
crate::error::Error::DecryptFailed.code(),
|
||||
"scrambled trailing partial must fail with DecryptFailed"
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty buffer is a valid no-op (zero units), not an error.
|
||||
#[test]
|
||||
fn aacs_empty_buffer_is_ok() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok());
|
||||
}
|
||||
|
||||
/// An exact multiple of the unit length has no trailing partial: behavior
|
||||
/// is unchanged — clear units stay clear, scrambled units are decrypt-
|
||||
/// attempted. Two clear units must round-trip untouched and return `Ok`.
|
||||
#[test]
|
||||
fn aacs_exact_multiple_unchanged() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN * 2);
|
||||
let snapshot = buf.clone();
|
||||
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("exact-multiple buffer is Ok");
|
||||
|
||||
assert_eq!(
|
||||
buf, snapshot,
|
||||
"clear exact-multiple buffer must be left unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
// ── DecryptKeys::None and is_encrypted ─────────────────────────────────
|
||||
|
||||
/// DecryptKeys::None is a pure no-op: the buffer must be returned
|
||||
/// byte-for-byte unchanged with Ok, regardless of content (even content
|
||||
/// that looks scrambled).
|
||||
///
|
||||
/// Grounding: the `DecryptKeys::None => {}` match arm does nothing.
|
||||
/// Mutation: replace the empty arm with a call that mutates buf -> the
|
||||
/// unchanged assert fails.
|
||||
#[test]
|
||||
fn none_keys_is_noop() {
|
||||
let mut buf: Vec<u8> = (0..4096u32).map(|i| (i % 256) as u8).collect();
|
||||
let snapshot = buf.clone();
|
||||
decrypt_sectors(&mut buf, &mut DecryptKeys::None, 0).expect("None is always Ok");
|
||||
assert_eq!(buf, snapshot, "None must not touch the buffer");
|
||||
}
|
||||
|
||||
/// is_encrypted reflects the variant: None -> false, Css/Aacs -> true.
|
||||
///
|
||||
/// Grounding: `!matches!(self, DecryptKeys::None)`.
|
||||
/// Mutation: invert the `!` -> None reports true, this fails.
|
||||
#[test]
|
||||
fn is_encrypted_matches_variant() {
|
||||
assert!(!DecryptKeys::None.is_encrypted());
|
||||
assert!(DecryptKeys::Css { title_key: [0; 5] }.is_encrypted());
|
||||
assert!(
|
||||
DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0; 16])],
|
||||
read_data_key: None,
|
||||
}
|
||||
.is_encrypted()
|
||||
);
|
||||
}
|
||||
|
||||
// ── CSS dispatch (DecryptKeys::Css) ────────────────────────────────────
|
||||
|
||||
/// Build a CSS-scrambled 2048-byte sector by scrambling a known plaintext
|
||||
/// body with the exact inverse of `descramble_sector`, so decrypt_sectors
|
||||
/// will descramble it back to the plaintext. The content cipher applies
|
||||
/// TAB1 to the ciphertext (`plain = TAB1[cipher] ^ ks`), so it is NOT a
|
||||
/// self-inverse XOR — `scramble_sector` is the true inverse and sets the
|
||||
/// scramble flag.
|
||||
fn make_css_sector(title_key: &[u8; 5], seed: &[u8; 5], body_fill: u8) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut sector = vec![body_fill; 2048];
|
||||
sector[0x14] = 0x30; // scramble flag (bits 4-5)
|
||||
sector[0x54..0x59].copy_from_slice(seed);
|
||||
let plaintext = sector.clone();
|
||||
css::lfsr::scramble_sector(title_key, &mut sector);
|
||||
(sector, plaintext)
|
||||
}
|
||||
|
||||
/// The CSS path descrambles each 2048-byte sector with the title key. A
|
||||
/// scrambled sector run through decrypt_sectors must come back to its
|
||||
/// plaintext body (keystream XOR is involutive), proving the title key is
|
||||
/// actually applied.
|
||||
///
|
||||
/// Grounding: `DecryptKeys::Css { title_key } => for chunk in
|
||||
/// buf.chunks_mut(2048) { descramble_sector(title_key, chunk) }`.
|
||||
/// Mutation: change `chunks_mut(2048)` to `chunks_mut(2049)` or pass a
|
||||
/// fixed wrong key -> the body no longer matches the plaintext.
|
||||
#[test]
|
||||
fn css_descrambles_with_title_key() {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||
let (mut sector, plaintext) = make_css_sector(&title_key, &seed, 0xA5);
|
||||
let mut keys = DecryptKeys::Css { title_key };
|
||||
decrypt_sectors(&mut sector, &mut keys, 0).expect("CSS decrypt is Ok");
|
||||
assert_eq!(
|
||||
§or[0x80..2048],
|
||||
&plaintext[0x80..2048],
|
||||
"CSS body must round-trip to plaintext"
|
||||
);
|
||||
// Flag cleared by the descrambler.
|
||||
assert_eq!(
|
||||
sector[0x14] & 0x30,
|
||||
0,
|
||||
"scramble flag cleared after CSS decrypt"
|
||||
);
|
||||
}
|
||||
|
||||
/// The CSS path processes EACH 2048-byte sector independently in a
|
||||
/// multi-sector buffer. Two scrambled sectors (with different seeds) in
|
||||
/// one buffer must both round-trip — pinning that the loop steps by 2048
|
||||
/// and applies the key to every sector, not just the first.
|
||||
///
|
||||
/// Grounding: `for chunk in buf.chunks_mut(2048)`.
|
||||
/// Mutation: change the loop to descramble only the first chunk (e.g.
|
||||
/// `.next()`) -> the second sector stays scrambled, assert fails.
|
||||
#[test]
|
||||
fn css_processes_every_sector_in_buffer() {
|
||||
let title_key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let (s0, p0) = make_css_sector(&title_key, &[0x11, 0x22, 0x33, 0x44, 0x55], 0x3C);
|
||||
let (s1, p1) = make_css_sector(&title_key, &[0x66, 0x77, 0x88, 0x99, 0xAA], 0xC3);
|
||||
let mut buf = s0;
|
||||
buf.extend_from_slice(&s1);
|
||||
let mut keys = DecryptKeys::Css { title_key };
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("CSS multi-sector decrypt is Ok");
|
||||
assert_eq!(
|
||||
&buf[0x80..2048],
|
||||
&p0[0x80..2048],
|
||||
"sector 0 body must round-trip"
|
||||
);
|
||||
assert_eq!(
|
||||
&buf[2048 + 0x80..4096],
|
||||
&p1[0x80..2048],
|
||||
"sector 1 body must round-trip (loop must reach the 2nd sector)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a CSS sector whose clear header ends in a periodic run that
|
||||
/// continues into the encrypted region — the crackable shape `attack_crib`/
|
||||
/// `crack_title_key` recover a key from (a constant body fill gives a
|
||||
/// degenerate crib the cracker can't pin a unique key on). Returns
|
||||
/// (scrambled_sector, plaintext_body).
|
||||
fn make_crackable_css_sector(
|
||||
title_key: &[u8; 5],
|
||||
seed: &[u8; 5],
|
||||
period: usize,
|
||||
) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut plaintext = vec![0u8; 2048];
|
||||
plaintext[0x14] = 0x10; // scramble flag
|
||||
// Periodic run from 0x59 (just above the seed) through 0x80 and on into
|
||||
// the encrypted region; phase anchored to offset 0 so it is continuous
|
||||
// across the 0x80 boundary.
|
||||
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(0x59) {
|
||||
*b = pat[i % period];
|
||||
}
|
||||
plaintext[0x54..0x59].copy_from_slice(seed); // seed sits below the run
|
||||
let body = plaintext.clone();
|
||||
css::lfsr::scramble_sector(title_key, &mut plaintext);
|
||||
(plaintext, body)
|
||||
}
|
||||
|
||||
/// CSS title keys are per-VTS/VOB region: a real disc holds DIFFERENT keys
|
||||
/// for different regions and the only way to get each is to crack it. The
|
||||
/// decrypt path must re-crack when the cached key stops descrambling (its
|
||||
/// crib no longer reappears at 0x80) instead of blindly applying one key
|
||||
/// across a region boundary — the bug that pixelated every freemkv DVD rip.
|
||||
///
|
||||
/// Two sectors scrambled under DIFFERENT keys, cache primed to ONLY the
|
||||
/// first (exactly what the one-shot scan crack leaves). Sector 0 validates +
|
||||
/// descrambles with the cached key; sector 1's cached-key descramble fails
|
||||
/// the crib, so the path re-cracks sector 1's own key and recovers its
|
||||
/// plaintext. Before the fix (blind single-key apply) sector 1 was garbage.
|
||||
///
|
||||
/// Grounding: the CSS arm's `attack_crib` → `chunk[0x80..] != crib` →
|
||||
/// `crack_title_key` → `*title_key = fresh` rekey.
|
||||
/// Mutation: drop the rekey branch (apply the cached key always) → sector 1's
|
||||
/// body no longer matches its plaintext; this fails.
|
||||
#[test]
|
||||
fn css_rekeys_when_title_key_region_changes() {
|
||||
let key_a = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let key_b = [0x07, 0x5A, 0xC3, 0x10, 0x88]; // a DIFFERENT region's key
|
||||
let (s0, p0) = make_crackable_css_sector(&key_a, &[0x11, 0x22, 0x33, 0x44, 0x55], 4);
|
||||
let (s1, p1) = make_crackable_css_sector(&key_b, &[0x66, 0x77, 0x88, 0x99, 0xAA], 4);
|
||||
// Precondition: each sector must be crackable on its own (the rekey
|
||||
// depends on it). If this fails the fixture, not the path, is at fault.
|
||||
assert_eq!(
|
||||
crate::css::stevenson::crack_title_key(&s0),
|
||||
Some(key_a),
|
||||
"fixture s0 must crack to key_a standalone"
|
||||
);
|
||||
assert_eq!(
|
||||
crate::css::stevenson::crack_title_key(&s1),
|
||||
Some(key_b),
|
||||
"fixture s1 must crack to key_b standalone"
|
||||
);
|
||||
let mut buf = s0;
|
||||
buf.extend_from_slice(&s1);
|
||||
|
||||
// Cache primed to key_a only — exactly what the one-shot scan crack yields.
|
||||
let mut keys = DecryptKeys::Css { title_key: key_a };
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("CSS multi-region decrypt is Ok");
|
||||
|
||||
assert_eq!(
|
||||
&buf[0x80..2048],
|
||||
&p0[0x80..2048],
|
||||
"region A sector descrambles with the cached (primed) key"
|
||||
);
|
||||
assert_eq!(
|
||||
&buf[2048 + 0x80..4096],
|
||||
&p1[0x80..2048],
|
||||
"region B sector must descramble after the path re-cracks its own key"
|
||||
);
|
||||
// The cache must have advanced to region B's key.
|
||||
match keys {
|
||||
DecryptKeys::Css { title_key } => assert_eq!(
|
||||
title_key, key_b,
|
||||
"cache must hold region B's key after the rekey"
|
||||
),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The CSS path leaves UNSCRAMBLED sectors (flag clear) byte-for-byte
|
||||
/// untouched — descramble_sector early-returns on a zero flag. A clear
|
||||
/// sector mixed into the buffer must not be corrupted.
|
||||
///
|
||||
/// Grounding: descramble_sector returns immediately when
|
||||
/// `(sector[0x14] >> 4) & 0x03 == 0`.
|
||||
/// Mutation: remove that early return in lfsr.rs -> a clear sector would
|
||||
/// be XORed with a keystream and change; this fails.
|
||||
#[test]
|
||||
fn css_leaves_clear_sector_unchanged() {
|
||||
let title_key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let mut sector = vec![0x77u8; 2048];
|
||||
sector[0x14] = 0x00; // not scrambled
|
||||
let snapshot = sector.clone();
|
||||
let mut keys = DecryptKeys::Css { title_key };
|
||||
decrypt_sectors(&mut sector, &mut keys, 0).unwrap();
|
||||
assert_eq!(sector, snapshot, "clear CSS sector must be left untouched");
|
||||
}
|
||||
|
||||
/// CSS decrypt always returns Ok (it cannot fail — descrambling is XOR,
|
||||
/// no key validity check), even for an empty buffer.
|
||||
///
|
||||
/// Grounding: the CSS arm has no `return Err` path; `chunks_mut` over an
|
||||
/// empty slice is a no-op; the function ends `Ok(())`.
|
||||
/// Mutation: make the CSS arm return Err -> this fails.
|
||||
#[test]
|
||||
fn css_empty_buffer_is_ok() {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let mut keys = DecryptKeys::Css { title_key: [0; 5] };
|
||||
assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok());
|
||||
}
|
||||
|
||||
// ── AACS unit-key index selection ──────────────────────────────────────
|
||||
|
||||
/// AACS decrypt with an out-of-range unit_key_idx must fail loud with
|
||||
/// DecryptFailed — never silently fall back to a wrong key or pass
|
||||
/// encrypted data through as clear.
|
||||
///
|
||||
/// Grounding: `let uk = match unit_keys.get(unit_key_idx) { Some => ...,
|
||||
/// None => return Err(DecryptFailed) }`.
|
||||
/// Mutation: change `unit_keys.get(unit_key_idx)` to `unit_keys.get(0)` or
|
||||
/// `.unwrap_or` a default -> the out-of-range index would not error; this
|
||||
/// fails.
|
||||
#[test]
|
||||
fn aacs_out_of_range_unit_key_idx_errors() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let err = decrypt_sectors(&mut buf, &mut keys, 5)
|
||||
.expect_err("unit_key_idx 5 is out of range for a 1-key list");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
crate::error::Error::DecryptFailed.code(),
|
||||
"out-of-range unit key index must be DecryptFailed"
|
||||
);
|
||||
}
|
||||
|
||||
/// AACS with an empty unit_keys list and any index errors (no key to use).
|
||||
///
|
||||
/// Grounding: `unit_keys.get(0)` on an empty Vec is None -> DecryptFailed.
|
||||
/// Mutation: defaulting to [0u8;16] on None would proceed; this fails.
|
||||
#[test]
|
||||
fn aacs_empty_unit_keys_errors() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
|
||||
let err = decrypt_sectors(&mut buf, &mut keys, 0).expect_err("empty unit_keys must error");
|
||||
assert_eq!(err.code(), crate::error::Error::DecryptFailed.code());
|
||||
}
|
||||
|
||||
// ── Multi-CPS-unit key selection ──────────────────────────────────────
|
||||
|
||||
/// Encrypt an aligned unit with the AACS algorithm run in reverse so that
|
||||
/// `aacs::decrypt_unit` with the same key recovers the plaintext. Mirrors
|
||||
/// the `aacs_encrypt_unit` helper in `aacs::decrypt::tests`.
|
||||
fn aacs_encrypt_unit_for_test(unit: &mut [u8], unit_key: &[u8; 16]) {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
k[i] = derived[i] ^ header[i];
|
||||
}
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&k));
|
||||
let mut prev = crate::aacs::decrypt::AACS_IV;
|
||||
let num_blocks = (aacs::ALIGNED_UNIT_LEN - 16) / 16;
|
||||
for i in 0..num_blocks {
|
||||
let off = 16 + i * 16;
|
||||
for j in 0..16 {
|
||||
unit[off + j] ^= prev[j];
|
||||
}
|
||||
let mut block = GenericArray::clone_from_slice(&unit[off..off + 16]);
|
||||
cipher.encrypt_block(&mut block);
|
||||
unit[off..off + 16].copy_from_slice(&block);
|
||||
prev.copy_from_slice(&unit[off..off + 16]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a clear aligned unit with TS sync bytes placed at the BD-TS stride
|
||||
/// (offset 4 + k*192) so `is_aacs_scrambled` reports false and
|
||||
/// `decrypt_unit` verifies it as clear after decryption.
|
||||
fn clear_ts_unit() -> Vec<u8> {
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
unit[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
unit
|
||||
}
|
||||
|
||||
/// A unit encrypted under unit_keys[1] (the second CPS unit) on a
|
||||
/// two-key disc must be correctly decrypted — not left as garbage —
|
||||
/// when `decrypt_sectors` is called with unit_key_idx=0 (the default).
|
||||
///
|
||||
/// Before the fix, `decrypt_one` used only `unit_keys[unit_key_idx]`
|
||||
/// (i.e. always key 0). On a multi-CPS-unit disc this produced silent
|
||||
/// garbage for content under key ≥ 1. The fix tries every key and
|
||||
/// accepts the one whose output passes the TS-sync verify.
|
||||
///
|
||||
/// Grounding: `for idx in try_order { … if aacs::decrypt_unit(&mut attempt, key) { … } }`
|
||||
/// Mutation: revert to the pre-fix `decrypt_unit_full(chunk, &uk, …)` where
|
||||
/// `uk = raw_keys[unit_key_idx]` (always key 0) → the unit comes out as
|
||||
/// garbled bytes that still look scrambled, failing the `!is_aacs_scrambled`
|
||||
/// assert.
|
||||
#[test]
|
||||
fn aacs_multi_cps_unit_disc_decrypts_under_non_zero_key() {
|
||||
let key0 = [0x11u8; 16]; // CPS unit 0 key — NOT the correct key for this unit
|
||||
let key1 = [0x22u8; 16]; // CPS unit 1 key — the correct key
|
||||
|
||||
// Build and encrypt a clear unit under key1 (the non-default CPS unit).
|
||||
let mut unit = clear_ts_unit();
|
||||
aacs_encrypt_unit_for_test(&mut unit, &key1);
|
||||
assert!(
|
||||
aacs::is_aacs_scrambled(&unit),
|
||||
"encrypted unit must look scrambled before decrypt"
|
||||
);
|
||||
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, key0), (1, key1)], // two CPS units
|
||||
read_data_key: None,
|
||||
};
|
||||
|
||||
// Call with the default hint (idx 0) — the fix must fall back to key1.
|
||||
let mut buf = unit;
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
|
||||
|
||||
assert!(
|
||||
!aacs::is_aacs_scrambled(&buf),
|
||||
"unit encrypted under key1 must be fully decrypted (TS syncs restored)"
|
||||
);
|
||||
// Every sync position must carry 0x47.
|
||||
assert_eq!(
|
||||
aacs::ts_sync_count(&buf),
|
||||
aacs::ts_packet_total(&buf),
|
||||
"all TS sync bytes must be restored after decrypting under key1"
|
||||
);
|
||||
}
|
||||
|
||||
/// Single-key disc: the common case is unaffected — the single key is
|
||||
/// tried first (via the hint) and validates, so no second-pass overhead.
|
||||
///
|
||||
/// Grounding: the `hint = last_key_idx.load(…)` path returns on the first
|
||||
/// `try_order` iteration. A regression that always tried all keys (instead
|
||||
/// of accepting the first hit) would still pass this test — correctness is
|
||||
/// the invariant here, not the performance shortcut.
|
||||
#[test]
|
||||
fn aacs_single_key_disc_still_decrypts_correctly() {
|
||||
let key = [0x55u8; 16];
|
||||
let mut unit = clear_ts_unit();
|
||||
aacs_encrypt_unit_for_test(&mut unit, &key);
|
||||
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, key)],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = unit;
|
||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
|
||||
assert!(
|
||||
!aacs::is_aacs_scrambled(&buf),
|
||||
"single-key disc: TS syncs must be restored"
|
||||
);
|
||||
assert_eq!(
|
||||
aacs::ts_sync_count(&buf),
|
||||
aacs::ts_packet_total(&buf),
|
||||
"all TS sync bytes must be restored for single-key disc"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for the silent partial-decrypt-loss defect: a scrambled AACS
|
||||
/// unit that NO supplied key can decrypt is restored to its original
|
||||
/// ciphertext (so a clear nav-file is never corrupted) AND `decrypt_sectors`
|
||||
/// returns the unit's byte length as the dropped count. Before the fix this
|
||||
/// returned `()` and the still-encrypted bytes flowed downstream to be
|
||||
/// silently dropped by the TS assembler with zero loss accounting — a rip
|
||||
/// missing real content reported `lost_video_secs=0` and passed the abort
|
||||
/// gate even under `abort_on_lost_secs=0`.
|
||||
///
|
||||
/// Grounding: the `dropped_bytes.fetch_add(chunk.len(), …)` on the
|
||||
/// no-key-validated restore path; the function returns that tally.
|
||||
/// Mutation: drop the `fetch_add` (or return a constant 0) → dropped == 0,
|
||||
/// this fails.
|
||||
#[test]
|
||||
fn aacs_undecryptable_unit_reports_dropped_bytes() {
|
||||
let real_key = [0x33u8; 16];
|
||||
let wrong_key = [0x44u8; 16]; // not the encrypting key
|
||||
|
||||
// Encrypt a clear unit under real_key, then offer ONLY the wrong key.
|
||||
let mut unit = clear_ts_unit();
|
||||
aacs_encrypt_unit_for_test(&mut unit, &real_key);
|
||||
let ciphertext = unit.clone();
|
||||
assert!(
|
||||
aacs::is_aacs_scrambled(&unit),
|
||||
"encrypted unit must look scrambled going in"
|
||||
);
|
||||
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, wrong_key)],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = unit;
|
||||
let dropped = decrypt_sectors(&mut buf, &mut keys, 0)
|
||||
.expect("undecryptable unit is not a hard error");
|
||||
|
||||
assert_eq!(
|
||||
dropped,
|
||||
aacs::ALIGNED_UNIT_LEN,
|
||||
"the whole scrambled unit must be reported as dropped when no key validates"
|
||||
);
|
||||
assert_eq!(
|
||||
buf, ciphertext,
|
||||
"an undecryptable unit must be restored to its original ciphertext, not garbled"
|
||||
);
|
||||
}
|
||||
|
||||
/// The dropped-byte tally accumulates across a multi-unit buffer where some
|
||||
/// units decrypt and others don't: a 2-unit buffer with one good and one
|
||||
/// bad unit reports exactly one unit's worth of loss, and the good unit is
|
||||
/// fully decrypted. Confirms the count is per-unit, not all-or-nothing.
|
||||
///
|
||||
/// Grounding: the per-chunk `decrypt_one` closure tallies only the units
|
||||
/// that fail; the good unit takes the `return` before the tally.
|
||||
#[test]
|
||||
fn aacs_mixed_buffer_tallies_only_failed_units() {
|
||||
let key = [0x55u8; 16];
|
||||
let wrong = [0x66u8; 16];
|
||||
|
||||
// Unit A: encrypted under `key` (decryptable). Unit B: encrypted under
|
||||
// `wrong` (NOT in the key list → undecryptable).
|
||||
let mut unit_a = clear_ts_unit();
|
||||
aacs_encrypt_unit_for_test(&mut unit_a, &key);
|
||||
let mut unit_b = clear_ts_unit();
|
||||
aacs_encrypt_unit_for_test(&mut unit_b, &wrong);
|
||||
let unit_b_ciphertext = unit_b.clone();
|
||||
|
||||
let mut buf = Vec::with_capacity(2 * aacs::ALIGNED_UNIT_LEN);
|
||||
buf.extend_from_slice(&unit_a);
|
||||
buf.extend_from_slice(&unit_b);
|
||||
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, key)],
|
||||
read_data_key: None,
|
||||
};
|
||||
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("partial decrypt is Ok");
|
||||
|
||||
assert_eq!(
|
||||
dropped,
|
||||
aacs::ALIGNED_UNIT_LEN,
|
||||
"exactly one unit's worth of bytes must be reported dropped"
|
||||
);
|
||||
assert!(
|
||||
!aacs::is_aacs_scrambled(&buf[..aacs::ALIGNED_UNIT_LEN]),
|
||||
"the decryptable unit must come out clear"
|
||||
);
|
||||
assert_eq!(
|
||||
&buf[aacs::ALIGNED_UNIT_LEN..],
|
||||
&unit_b_ciphertext[..],
|
||||
"the undecryptable unit must be restored to ciphertext"
|
||||
);
|
||||
}
|
||||
|
||||
/// A fully-decryptable single-key buffer reports zero dropped bytes — the
|
||||
/// loss tally must not fire on the clean path.
|
||||
#[test]
|
||||
fn aacs_all_units_decrypt_reports_zero_dropped() {
|
||||
let key = [0x77u8; 16];
|
||||
let mut unit = clear_ts_unit();
|
||||
aacs_encrypt_unit_for_test(&mut unit, &key);
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, key)],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut buf = unit;
|
||||
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("clean decrypt");
|
||||
assert_eq!(dropped, 0, "a fully-decrypted buffer must report no loss");
|
||||
}
|
||||
|
||||
// ── decrypt_threads resolution (read-only; no global mutation) ─────────
|
||||
|
||||
/// The default (auto) decrypt thread count is always a usable pool size:
|
||||
/// at least 1 (a 0-thread rayon pool is invalid) and never above
|
||||
/// MAX_THREADS (rayon stack-memory cap). This test reads the resolved
|
||||
/// value without mutating the process-global override, so it is safe to
|
||||
/// run in parallel with other tests.
|
||||
///
|
||||
/// Grounding: `cores.clamp(1, MAX_THREADS)` in the default branch;
|
||||
/// `env.min(MAX_THREADS)` in the env branch.
|
||||
/// Mutation: change `.clamp(1, MAX_THREADS)` to `.clamp(0, MAX_THREADS)`
|
||||
/// on a 0-core probe (unlikely) — more robustly, change the cap to
|
||||
/// `MAX_THREADS * 2` -> on a many-core CI box the upper-bound assert can
|
||||
/// fail. The lower-bound (>=1) guard is the load-bearing invariant.
|
||||
#[test]
|
||||
fn decrypt_threads_within_valid_pool_range() {
|
||||
let n = decrypt_threads();
|
||||
assert!(n >= 1, "decrypt thread count must be at least 1, got {n}");
|
||||
assert!(
|
||||
n <= MAX_THREADS,
|
||||
"decrypt thread count must not exceed MAX_THREADS ({MAX_THREADS}), got {n}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+753
@@ -0,0 +1,753 @@
|
||||
//! Structured scan diagnostics — the `--log-level 3` self-diagnosing dump.
|
||||
//!
|
||||
//! A bug report log must be self-diagnosing: everything needed to explain
|
||||
//! *why* freemkv made the choices it did at scan must be in the log, in a
|
||||
//! compact, machine-parseable form. This module emits one terse line per row
|
||||
//! (title, cell, stream, decision) under the `tracing` target
|
||||
//! `freemkv::diag`, which the CLI routes to `log.txt` when `--log-level 3`
|
||||
//! (debug) is set.
|
||||
//!
|
||||
//! Format conventions (stable, greppable):
|
||||
//! - Every line is prefixed by a `tag=` so a log scraper can filter
|
||||
//! (`disc`, `title`, `dvd.cell`, `dvd.vattr`, `dvd.aattr`, `bd.clip`,
|
||||
//! `bd.mark`, `aacs`, `stream`, `decision`).
|
||||
//! - Raw bytes are shown as `0xNN` next to their decode so a wrong decode
|
||||
//! is obvious against the raw value.
|
||||
//! - This module only READS already-parsed scan state — it never re-reads
|
||||
//! the disc and never mutates anything.
|
||||
//!
|
||||
//! The DVD per-cell table (with the raw cell-category byte) is emitted from
|
||||
//! the IFO scan itself ([`dump_dvd_cells`]), because the per-cell
|
||||
//! `ifo::DvdCell` detail is lowered away before the `Disc` is built. The
|
||||
//! `Disc`-level dump ([`dump_disc`]) covers everything that survives
|
||||
//! lowering: titles, streams, the picked main feature, and AACS state.
|
||||
|
||||
use crate::disc::{
|
||||
AudioChannels, ColorSpace, Disc, DiscTitle, FrameRate, HdrFormat, Resolution, SampleRate,
|
||||
Stream,
|
||||
};
|
||||
use crate::ifo::{CellCategory, DvdTitle};
|
||||
|
||||
const DIAG: &str = "freemkv::diag";
|
||||
|
||||
// ── small format helpers (pure, unit-testable) ──────────────────────────────
|
||||
|
||||
/// Compact name for a [`Resolution`] with the interlace marker preserved.
|
||||
pub fn res_str(r: Resolution) -> &'static str {
|
||||
match r {
|
||||
Resolution::R480i => "480i",
|
||||
Resolution::R480p => "480p",
|
||||
Resolution::R576i => "576i",
|
||||
Resolution::R576p => "576p",
|
||||
Resolution::R720p => "720p",
|
||||
Resolution::R1080i => "1080i",
|
||||
Resolution::R1080p => "1080p",
|
||||
Resolution::R2160p => "2160p",
|
||||
Resolution::R4320p => "4320p",
|
||||
Resolution::Unknown => "res?",
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames-per-second string for a [`FrameRate`].
|
||||
pub fn fps_str(f: FrameRate) -> &'static str {
|
||||
match f {
|
||||
FrameRate::F23_976 => "23.976",
|
||||
FrameRate::F24 => "24",
|
||||
FrameRate::F25 => "25",
|
||||
FrameRate::F29_97 => "29.97",
|
||||
FrameRate::F30 => "30",
|
||||
FrameRate::F50 => "50",
|
||||
FrameRate::F59_94 => "59.94",
|
||||
FrameRate::F60 => "60",
|
||||
FrameRate::Unknown => "fps?",
|
||||
}
|
||||
}
|
||||
|
||||
/// PAL/NTSC field-rate family inferred from the frame rate (DVD has no
|
||||
/// explicit field, so this is the colour/standard the muxer stamps).
|
||||
pub fn tv_system_str(f: FrameRate) -> &'static str {
|
||||
match f {
|
||||
FrameRate::F25 | FrameRate::F50 => "PAL",
|
||||
FrameRate::F23_976 | FrameRate::F29_97 | FrameRate::F59_94 => "NTSC",
|
||||
_ => "—",
|
||||
}
|
||||
}
|
||||
|
||||
/// CICP-ish short name for a [`ColorSpace`].
|
||||
pub fn color_str(c: ColorSpace) -> &'static str {
|
||||
match c {
|
||||
ColorSpace::Bt709 => "BT.709",
|
||||
ColorSpace::Bt2020 => "BT.2020",
|
||||
ColorSpace::Bt470bg => "BT.470BG",
|
||||
ColorSpace::Smpte170m => "SMPTE-170M",
|
||||
ColorSpace::Unknown => "color?",
|
||||
}
|
||||
}
|
||||
|
||||
/// HDR format short name.
|
||||
pub fn hdr_str(h: HdrFormat) -> &'static str {
|
||||
match h {
|
||||
HdrFormat::Sdr => "SDR",
|
||||
HdrFormat::Hdr10 => "HDR10",
|
||||
HdrFormat::Hdr10Plus => "HDR10+",
|
||||
HdrFormat::DolbyVision => "DoVi",
|
||||
HdrFormat::Hlg => "HLG",
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel count from an [`AudioChannels`] layout (what lands in the MKV
|
||||
/// `Channels` element).
|
||||
pub fn channel_count(ch: AudioChannels) -> u8 {
|
||||
match ch {
|
||||
AudioChannels::Mono => 1,
|
||||
AudioChannels::Stereo => 2,
|
||||
AudioChannels::Stereo21 => 3,
|
||||
AudioChannels::Quad => 4,
|
||||
AudioChannels::Surround50 => 5,
|
||||
AudioChannels::Surround51 => 6,
|
||||
AudioChannels::Surround61 => 7,
|
||||
AudioChannels::Surround71 => 8,
|
||||
AudioChannels::Unknown => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample-rate in Hz for a [`SampleRate`].
|
||||
pub fn sample_rate_hz(s: SampleRate) -> u32 {
|
||||
match s {
|
||||
SampleRate::S44_1 => 44100,
|
||||
SampleRate::S48 => 48000,
|
||||
SampleRate::S88_2 => 88200,
|
||||
SampleRate::S96 => 96000,
|
||||
SampleRate::S176_4 => 176400,
|
||||
SampleRate::S192 => 192000,
|
||||
SampleRate::S48_96 => 96000,
|
||||
SampleRate::S48_192 => 192000,
|
||||
SampleRate::Unknown => 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ── DVD cell-category dump (from the IFO scan, pre-lowering) ─────────────────
|
||||
|
||||
/// One formatted cell row for the DVD per-PGC cell table. Returned as a
|
||||
/// string so it can be unit-tested without a logger.
|
||||
///
|
||||
/// Columns: `idx`, raw category (`cat=0xNN`) + decoded fields, first/last
|
||||
/// sector, duration, and the keep/drop verdict from the bug-4 leading-cell
|
||||
/// filter.
|
||||
pub fn dvd_cell_row(idx: usize, cell: &crate::ifo::DvdCell, dropped: bool) -> String {
|
||||
let c = CellCategory::decode(cell.category);
|
||||
// Per-cell keep/skip REASON (self-sufficient bug log): a dropped cell is a
|
||||
// leading secondary angle/interleave block piece; a kept cell is either the
|
||||
// first feature cell or genuine feature content. This makes the
|
||||
// leading-cell-filter decision auditable from the log without the disc.
|
||||
let verdict = if dropped {
|
||||
"DROP(leading-secondary-block-piece)"
|
||||
} else if c.is_secondary_block_piece() {
|
||||
// Kept despite being a secondary piece — only happens past the leading
|
||||
// run (the filter stops at the first plain feature cell).
|
||||
"keep(feature-body)"
|
||||
} else {
|
||||
"keep(plain-feature)"
|
||||
};
|
||||
format!(
|
||||
"tag=dvd.cell idx={idx} cat=0x{:02X} block_mode={} block_type={} \
|
||||
seamless={} ilv={} stc={} angle={} plain={} first={} last={} dur={:.1}s {}",
|
||||
cell.category,
|
||||
c.block_mode,
|
||||
c.block_type,
|
||||
c.seamless_play as u8,
|
||||
c.interleaved as u8,
|
||||
c.stc_discontinuity as u8,
|
||||
c.seamless_angle as u8,
|
||||
c.is_plain_feature() as u8,
|
||||
cell.first_sector,
|
||||
cell.last_sector,
|
||||
cell.duration_secs,
|
||||
verdict,
|
||||
)
|
||||
}
|
||||
|
||||
/// Emit the per-PGC cell table for one DVD title during the IFO scan.
|
||||
///
|
||||
/// `vts`/`title` identify the row group; `title` is the `DvdTitle` whose
|
||||
/// cells (and bug-4 leading-cell verdict) are dumped. Called from
|
||||
/// `scan_dvd_titles` while the `DvdTitle` is still in scope (the per-cell
|
||||
/// category byte is lowered away before the `Disc` exists).
|
||||
pub fn dump_dvd_cells(vts: u8, title_num: u16, title: &DvdTitle) {
|
||||
if !tracing::enabled!(target: DIAG, tracing::Level::DEBUG) {
|
||||
return;
|
||||
}
|
||||
let feature_start = title.feature_start_cell();
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=dvd.pgc vts={vts} title={title_num} cells={} chapters={} \
|
||||
dur={:.1}s feature_start_cell={feature_start}",
|
||||
title.cells.len(),
|
||||
title.chapters,
|
||||
title.duration_secs,
|
||||
);
|
||||
for (i, cell) in title.cells.iter().enumerate() {
|
||||
tracing::debug!(target: DIAG, "{}", dvd_cell_row(i, cell, i < feature_start));
|
||||
}
|
||||
// Chapter/PTT map (program → cumulative start time).
|
||||
for (i, &t) in title.chapter_times.iter().enumerate() {
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=dvd.chap vts={vts} title={title_num} ch={} time={:.1}s",
|
||||
i + 1,
|
||||
t,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit the IFO `video_attr` / `audio_attr` decode for one DVD title set,
|
||||
/// showing the raw bytes next to their decoded meaning. Called from the IFO
|
||||
/// scan with the still-parsed `ifo::DvdTitleSet` view.
|
||||
pub fn dump_dvd_attrs(ts: &crate::ifo::DvdTitleSet) {
|
||||
if !tracing::enabled!(target: DIAG, tracing::Level::DEBUG) {
|
||||
return;
|
||||
}
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=dvd.vobs vts={} vob_start_sector={}",
|
||||
ts.vts_number,
|
||||
ts.vob_start_sector,
|
||||
);
|
||||
let v = &ts.video;
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=dvd.vattr vts={} codec={:?} res={} aspect={:?} std={:?}",
|
||||
ts.vts_number,
|
||||
v.codec,
|
||||
res_str(v.resolution),
|
||||
v.aspect,
|
||||
v.standard,
|
||||
);
|
||||
for (i, a) in ts.audio_streams.iter().enumerate() {
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=dvd.aattr vts={} idx={i} codec={:?} ch={} sr={}Hz lang={:?} sub_id={:?}",
|
||||
ts.vts_number,
|
||||
a.codec,
|
||||
a.channels,
|
||||
a.sample_rate,
|
||||
a.language,
|
||||
a.sub_stream_id.map(|x| format!("0x{x:02X}")),
|
||||
);
|
||||
}
|
||||
for (i, s) in ts.subtitle_streams.iter().enumerate() {
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=dvd.sattr vts={} idx={i} lang={:?}",
|
||||
ts.vts_number,
|
||||
s.language,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit the ACTUAL per-physical-sub-stream AC-3 channel counts read off the VOB
|
||||
/// during the mux-time sub-stream probe (the Silence-of-the-Lambs wrong-stream
|
||||
/// fix). This is the ground truth the IFO nibble is compared against: each row
|
||||
/// is `sub_id=0x8x channels=N` for a physical `private_stream_1` AC-3 sub-stream
|
||||
/// whose first frame was decoded. An empty probe (scrambled / unreadable / short
|
||||
/// VOB) logs a single `probed=0` line so the absence is explicit in a bug log.
|
||||
///
|
||||
/// Self-sufficiency: with `tag=dvd.aattr` (the IFO's declared sub_id + claimed
|
||||
/// channels) and these `tag=dvd.substream` rows (the physical reality), a bug
|
||||
/// log alone shows whether the ordinal `0x80` actually carries the declared
|
||||
/// channel layout — no disc needed to diagnose a wrong-substream rip.
|
||||
pub fn dump_dvd_substream_probe(title_id: u16, probed: &std::collections::BTreeMap<u8, u8>) {
|
||||
if !tracing::enabled!(target: DIAG, tracing::Level::DEBUG) {
|
||||
return;
|
||||
}
|
||||
if probed.is_empty() {
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=dvd.substream title={title_id} probed=0 (no AC-3 sync in feature head — scrambled/unreadable/none)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (sub, ch) in probed {
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=dvd.substream title={title_id} sub_id=0x{sub:02X} channels={ch} (physical acmod read from VOB)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── MKV TrackEntry dump (the ACTUAL container elements written) ──────────────
|
||||
|
||||
/// `true` when the `--log-level 3` diagnostic target is enabled. Hot-path
|
||||
/// callers (the opening-frame capture) check this once and skip all work when
|
||||
/// off, so a normal run pays nothing.
|
||||
pub fn diag_enabled() -> bool {
|
||||
tracing::enabled!(target: DIAG, tracing::Level::DEBUG)
|
||||
}
|
||||
|
||||
/// Cap on the number of codecPrivate bytes rendered to hex in a `tag=mkv.track`
|
||||
/// line. The sequence header / avcC / hvcC prefix that matters for diagnosis
|
||||
/// (resolution, frame rate, profile) is at the front; a multi-KB blob past this
|
||||
/// is summarised as `..(+NB)` rather than flooding the log.
|
||||
const CODEC_PRIVATE_HEX_CAP: usize = 64;
|
||||
|
||||
/// Render a track's codecPrivate as an uppercase-hex string for the diagnostic
|
||||
/// line, capped at [`CODEC_PRIVATE_HEX_CAP`] bytes (`..(+NB)` suffix beyond).
|
||||
/// `None` / empty → `"none"`. Pure (no logging) so it is directly unit-testable.
|
||||
fn codec_private_hex(cp: Option<&[u8]>) -> String {
|
||||
match cp {
|
||||
Some(b) if !b.is_empty() => {
|
||||
use std::fmt::Write;
|
||||
let shown = b.len().min(CODEC_PRIVATE_HEX_CAP);
|
||||
let mut s = String::with_capacity(shown * 2 + 8);
|
||||
for byte in &b[..shown] {
|
||||
let _ = write!(s, "{byte:02X}");
|
||||
}
|
||||
if b.len() > CODEC_PRIVATE_HEX_CAP {
|
||||
let _ = write!(s, "..(+{}B)", b.len() - CODEC_PRIVATE_HEX_CAP);
|
||||
}
|
||||
s
|
||||
}
|
||||
_ => "none".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame the raw bytes of one captured opening frame for the `.opening.bin` side
|
||||
/// file: `[track:u8][keyframe:u8][pts_ns:i64 LE][len:u32 LE][raw bytes]`. Pure
|
||||
/// (no I/O) so the record layout is directly unit-testable; `record` appends the
|
||||
/// returned bytes to the side file.
|
||||
fn frame_record(track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> Vec<u8> {
|
||||
let mut rec = Vec::with_capacity(14 + data.len());
|
||||
rec.push(track_idx as u8);
|
||||
rec.push(keyframe as u8);
|
||||
rec.extend_from_slice(&pts_ns.to_le_bytes());
|
||||
rec.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
rec.extend_from_slice(data);
|
||||
rec
|
||||
}
|
||||
|
||||
/// Emit the MKV `TrackEntry` elements the muxer is about to WRITE for one
|
||||
/// track — the Windows-fps-class metadata (FlagInterlaced, FieldOrder,
|
||||
/// DefaultDuration, DefaultDecodedFieldDuration, Display dims) plus the
|
||||
/// codecPrivate as hex. With this row a bug log alone is enough to verify why
|
||||
/// Windows Explorer reports a given frame rate for an interlaced SD track: the
|
||||
/// container values that drive its fps derivation are all present, no disc and
|
||||
/// no MediaInfo needed.
|
||||
///
|
||||
/// `track_number` is the 1-based MKV track number; `track` is the built
|
||||
/// [`crate::mux::mkv::MkvTrack`] whose fields map one-to-one onto the emitted
|
||||
/// elements (see `MkvMuxer::new`). No-op unless the diag target is on.
|
||||
pub fn dump_mkv_track(track_number: u64, track: &crate::mux::mkv::MkvTrack) {
|
||||
if !diag_enabled() {
|
||||
return;
|
||||
}
|
||||
// codecPrivate as hex (capped so a multi-KB hvcC doesn't flood the log; the
|
||||
// sequence header / avcC prefix that matters for diagnosis is at the front).
|
||||
let cp = codec_private_hex(track.codec_private.as_deref());
|
||||
let field_order = match track.field_order {
|
||||
crate::mux::ebml::FIELD_ORDER_TFF => "TFF",
|
||||
crate::mux::ebml::FIELD_ORDER_BFF => "BFF",
|
||||
_ => "—",
|
||||
};
|
||||
// FlagInterlaced is only written for video tracks (1=interlaced/2=progressive);
|
||||
// report what the muxer will emit, or "—" for non-video tracks where the
|
||||
// element is omitted entirely.
|
||||
let interlaced = if track.track_type == crate::mux::ebml::TRACK_TYPE_VIDEO {
|
||||
if track.interlaced {
|
||||
"1(interlaced)"
|
||||
} else {
|
||||
"2(progressive)"
|
||||
}
|
||||
} else {
|
||||
"—"
|
||||
};
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=mkv.track num={track_number} type={} codec={} flag_interlaced={interlaced} \
|
||||
field_order={field_order} default_duration_ns={} field_duration_ns={} \
|
||||
pixel={}x{} display={}x{} cp_len={} cp_hex={cp}",
|
||||
track.track_type,
|
||||
track.codec_id,
|
||||
track.default_duration_ns,
|
||||
track.field_duration_ns,
|
||||
track.pixel_width,
|
||||
track.pixel_height,
|
||||
track.display_width,
|
||||
track.display_height,
|
||||
track.codec_private.as_ref().map_or(0, |b| b.len()),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Opening-frame capture (first ~N coded frames per track → side file) ──────
|
||||
|
||||
/// Number of coded frames captured PER TRACK before the capture goes dormant.
|
||||
/// ~100 frames covers a DVD's first few seconds of every track (the
|
||||
/// opening-GOP / still-frame / menu window where mid-GOP open or PTS-floor bugs
|
||||
/// show up) while bounding the side file to a few MB even for HD I-frames.
|
||||
const OPENING_FRAMES_PER_TRACK: usize = 100;
|
||||
|
||||
/// Captures the first [`OPENING_FRAMES_PER_TRACK`] coded frames of EACH track to
|
||||
/// a side file (`<output>.opening.bin`) and logs a per-frame summary line, so an
|
||||
/// opening-GOP / menu / mid-GOP-open issue is diagnosable from a future log +
|
||||
/// side file WITHOUT the disc. Gated to `--log-level 3`: constructed only when
|
||||
/// the diag target is on, so a normal run never opens the file or records a byte.
|
||||
///
|
||||
/// Side-file record framing (so a reader can split it back into frames):
|
||||
/// `[track:u8][keyframe:u8][pts_ns:i64 LE][len:u32 LE][raw frame bytes]`.
|
||||
pub struct OpeningCapture {
|
||||
file: std::fs::File,
|
||||
/// Frames captured so far, per track index. Capture for a track stops once
|
||||
/// its counter reaches [`OPENING_FRAMES_PER_TRACK`].
|
||||
counts: Vec<usize>,
|
||||
}
|
||||
|
||||
impl OpeningCapture {
|
||||
/// Open `<output>.opening.bin` next to the MKV output. Returns `None` (no
|
||||
/// capture) when the diag target is off OR the side file can't be created —
|
||||
/// a diagnostic must never fail the rip. `track_count` sizes the per-track
|
||||
/// counters.
|
||||
pub fn new(output_path: &std::path::Path, track_count: usize) -> Option<Self> {
|
||||
if !diag_enabled() {
|
||||
return None;
|
||||
}
|
||||
let mut name = output_path.as_os_str().to_os_string();
|
||||
name.push(".opening.bin");
|
||||
match std::fs::File::create(&name) {
|
||||
Ok(file) => {
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=mkv.opening.open path={:?} per_track_cap={OPENING_FRAMES_PER_TRACK}",
|
||||
std::path::Path::new(&name),
|
||||
);
|
||||
Some(Self {
|
||||
file,
|
||||
counts: vec![0; track_count],
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=mkv.opening.open path={:?} failed={e} (capture disabled, rip unaffected)",
|
||||
std::path::Path::new(&name),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one coded frame for `track_idx` if that track is still under its
|
||||
/// per-track cap. Writes the framed raw bytes to the side file and logs a
|
||||
/// one-line summary. A write error disables further capture for the track
|
||||
/// (counter pinned to the cap) but never propagates — the rip is unaffected.
|
||||
pub fn record(&mut self, track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) {
|
||||
let Some(count) = self.counts.get_mut(track_idx) else {
|
||||
return;
|
||||
};
|
||||
if *count >= OPENING_FRAMES_PER_TRACK {
|
||||
return;
|
||||
}
|
||||
use std::io::Write;
|
||||
let rec = frame_record(track_idx, pts_ns, keyframe, data);
|
||||
if let Err(e) = self.file.write_all(&rec) {
|
||||
// Stop trying on this track; a broken side file must not stall mux.
|
||||
*count = OPENING_FRAMES_PER_TRACK;
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=mkv.opening.frame track={track_idx} write_failed={e} (capture stopped for track)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
*count += 1;
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=mkv.opening.frame track={track_idx} n={count} type={} size={} pts_ns={pts_ns}",
|
||||
if keyframe { "key" } else { "delta" },
|
||||
data.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Disc-level dump (post-lowering: titles, streams, decisions, AACS) ────────
|
||||
|
||||
/// Emit the full scan diagnostic block for a built [`Disc`]. Terse, one line
|
||||
/// per row, under target `freemkv::diag` at DEBUG. No-op unless that target
|
||||
/// is enabled, so it costs nothing when `--log-level 3` is off.
|
||||
pub fn dump_disc(disc: &Disc) {
|
||||
if !tracing::enabled!(target: DIAG, tracing::Level::DEBUG) {
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=disc vol={:?} format={:?} content={:?} cap_sectors={} layers={} titles={} encrypted={}",
|
||||
disc.volume_id,
|
||||
disc.format,
|
||||
disc.content_format,
|
||||
disc.capacity_sectors,
|
||||
disc.layers,
|
||||
disc.titles.len(),
|
||||
disc.encrypted,
|
||||
);
|
||||
|
||||
dump_aacs(disc);
|
||||
|
||||
for (ti, title) in disc.titles.iter().enumerate() {
|
||||
dump_title(ti, title);
|
||||
}
|
||||
|
||||
// freemkv's top-level DECISION: which title is the main feature.
|
||||
if let Some(main) = disc.titles.first() {
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=decision pick=main_feature title_idx=0 playlist={:?} dur={:.1}s \
|
||||
size={}B clips={} reason=canonical_title_order(fits-disc, fewest-clips, longest, richest-audio)",
|
||||
main.playlist,
|
||||
main.duration_secs,
|
||||
main.size_bytes,
|
||||
main.clips.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dump_aacs(disc: &Disc) {
|
||||
let Some(a) = disc.aacs.as_ref() else {
|
||||
if disc.css.is_some() {
|
||||
tracing::debug!(target: DIAG, "tag=aacs none crypto=CSS(DVD)");
|
||||
} else if disc.encrypted {
|
||||
tracing::debug!(target: DIAG, "tag=aacs none crypto=encrypted-no-keys");
|
||||
} else {
|
||||
tracing::debug!(target: DIAG, "tag=aacs none crypto=clear");
|
||||
}
|
||||
return;
|
||||
};
|
||||
// CPS-unit / unit-key counts: at scan `unit_keys` is empty (keys are
|
||||
// resolved later); the unit-key count is the BE16 in the raw
|
||||
// Unit_Key_RO.inf if captured. Report both: resolved count and raw len.
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=aacs version={} bus_enc={} mkb_version={:?} disc_hash={} key_source={:?} \
|
||||
vuk={} unit_keys_resolved={} uk_ro_bytes={} mkb_bytes={}",
|
||||
a.version,
|
||||
a.bus_encryption,
|
||||
a.mkb_version,
|
||||
a.disc_hash,
|
||||
a.key_source.name(),
|
||||
a.vuk.is_some(),
|
||||
a.unit_keys.len(),
|
||||
a.uk_ro.len(),
|
||||
a.mkb.len(),
|
||||
);
|
||||
}
|
||||
|
||||
fn dump_title(ti: usize, title: &DiscTitle) {
|
||||
let (mut nv, mut na, mut ns) = (0u32, 0u32, 0u32);
|
||||
for s in &title.streams {
|
||||
match s {
|
||||
Stream::Video(_) => nv += 1,
|
||||
Stream::Audio(_) => na += 1,
|
||||
Stream::Subtitle(_) => ns += 1,
|
||||
}
|
||||
}
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=title idx={ti} playlist={:?} id={} dur={:.1}s size={}B clips={} \
|
||||
extents={} chapters={} v={nv} a={na} s={ns} fmt={:?}",
|
||||
title.playlist,
|
||||
title.playlist_id,
|
||||
title.duration_secs,
|
||||
title.size_bytes,
|
||||
title.clips.len(),
|
||||
title.extents.len(),
|
||||
title.chapters.len(),
|
||||
title.content_format,
|
||||
);
|
||||
|
||||
// Per-clip rows (BD: PlayItem/CLPI; DVD has none).
|
||||
for (ci, c) in title.clips.iter().enumerate() {
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=clip title={ti} idx={ci} id={:?} in={} out={} dur={:.1}s src_packets={}",
|
||||
c.clip_id,
|
||||
c.in_time,
|
||||
c.out_time,
|
||||
c.duration_secs,
|
||||
c.source_packets,
|
||||
);
|
||||
}
|
||||
|
||||
// Per-extent rows (the sectors freemkv will actually rip — the bug-4
|
||||
// decision is visible here: leading non-feature cells are already gone).
|
||||
for (ei, e) in title.extents.iter().enumerate() {
|
||||
tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=extent title={ti} idx={ei} start_lba={} sectors={}",
|
||||
e.start_lba,
|
||||
e.sector_count,
|
||||
);
|
||||
}
|
||||
|
||||
// freemkv's per-stream DECISIONS (what the muxer will write).
|
||||
for (si, s) in title.streams.iter().enumerate() {
|
||||
match s {
|
||||
Stream::Video(v) => tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=stream title={ti} idx={si} kind=video pid=0x{:04X} codec={:?} \
|
||||
res={} interlaced={} fps={} std={} color={} hdr={} aspect={:?} secondary={}",
|
||||
v.pid,
|
||||
v.codec,
|
||||
res_str(v.resolution),
|
||||
v.resolution.is_interlaced(),
|
||||
fps_str(v.frame_rate),
|
||||
tv_system_str(v.frame_rate),
|
||||
color_str(v.color_space),
|
||||
hdr_str(v.hdr),
|
||||
v.display_aspect,
|
||||
v.secondary,
|
||||
),
|
||||
Stream::Audio(a) => tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=stream title={ti} idx={si} kind=audio pid=0x{:04X} codec={:?} \
|
||||
channels={}({}) sr={}Hz lang={:?} secondary={}",
|
||||
a.pid,
|
||||
a.codec,
|
||||
a.channels,
|
||||
channel_count(a.channels),
|
||||
sample_rate_hz(a.sample_rate),
|
||||
a.language,
|
||||
a.secondary,
|
||||
),
|
||||
Stream::Subtitle(sub) => tracing::debug!(
|
||||
target: DIAG,
|
||||
"tag=stream title={ti} idx={si} kind=subtitle pid=0x{:04X} codec={:?} \
|
||||
lang={:?} forced={}",
|
||||
sub.pid,
|
||||
sub.codec,
|
||||
sub.language,
|
||||
sub.forced,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_count_matches_layout() {
|
||||
assert_eq!(channel_count(AudioChannels::Mono), 1);
|
||||
assert_eq!(channel_count(AudioChannels::Stereo), 2);
|
||||
assert_eq!(channel_count(AudioChannels::Surround51), 6);
|
||||
assert_eq!(channel_count(AudioChannels::Surround71), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_rate_hz_values() {
|
||||
assert_eq!(sample_rate_hz(SampleRate::S48), 48000);
|
||||
assert_eq!(sample_rate_hz(SampleRate::S96), 96000);
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
+1371
-16
File diff suppressed because it is too large
Load Diff
+1106
-12
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,466 @@
|
||||
//! Physical AC-3 sub-stream probing for DVD audio routing.
|
||||
//!
|
||||
//! ## Why this exists (Silence-of-the-Lambs wrong-substream bug)
|
||||
//!
|
||||
//! A DVD VTS IFO declares its audio streams in a fixed table, and freemkv's
|
||||
//! scan assigns each declared stream a `private_stream_1` sub-stream id purely
|
||||
//! by per-codec ordinal — the first AC-3 stream becomes `0x80`, the second
|
||||
//! `0x81`, and so on (`ifo::assign_audio_sub_stream_ids`). That assumes the
|
||||
//! physical sub-stream order on the wire matches the IFO declaration order.
|
||||
//!
|
||||
//! On some discs it does NOT. The R2 PAL "The Silence of the Lambs" feature
|
||||
//! declares ONE AC-3 audio stream the IFO nibble marks as 5.1 (6 channels), but
|
||||
//! the physical VOB carries the 5.1 main mix and a 2.0 down-mix on DIFFERENT
|
||||
//! `0x8x` sub-stream ids, and the 2.0 is the one that happens to land at the
|
||||
//! ordinal `0x80` slot. Routing the declared 5.1 stream to `0x80` by ordinal
|
||||
//! therefore muxes the 2.0 down-mix while labelling it 5.1 — the wrong physical
|
||||
//! track.
|
||||
//!
|
||||
//! The robust fix is data-driven and codec/disc agnostic: read each physical
|
||||
//! AC-3 sub-stream's REAL channel count from the VOB (the `acmod`/`lfeon` of its
|
||||
//! first frame after the `0x0B77` sync) and route each IFO-declared AC-3 stream
|
||||
//! to the physical sub-stream whose actual channel count matches the IFO's
|
||||
//! declared count — instead of trusting the ordinal. This never re-reads the
|
||||
//! disc beyond a bounded head-of-feature probe and degrades to the original
|
||||
//! ordinal mapping when the probe yields nothing (unreadable/short VOB).
|
||||
|
||||
use crate::disc::Stream;
|
||||
use crate::mux::codec::ac3;
|
||||
use crate::mux::ps::PsDemuxer;
|
||||
use crate::sector::SectorSource;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// How many 2048-byte sectors of the first feature extent to probe. The head of
|
||||
/// a DVD feature opens with logos/warnings whose audio is frequently a thin 2.0
|
||||
/// bed on the FIRST sub-stream only — the other physical `0x8x` sub-streams and
|
||||
/// the main 5.1 mix do not appear until a sector or two further in. 512 sectors
|
||||
/// (1 MiB) was too short: on Greenland it saw ONLY `0x80`, and only its opening
|
||||
/// 2.0 frames. 1024 sectors (2 MiB) reliably contains at least one frame of
|
||||
/// every physical AC-3 sub-stream AND enough of `0x80` to reach its 5.1 frames.
|
||||
/// Still bounded so a live drive is never hammered (see the project "don't
|
||||
/// hammer the live drive" rule).
|
||||
const PROBE_SECTORS: u16 = 1024;
|
||||
|
||||
/// Decode the real per-sub-stream AC-3 channel count from a buffer of decrypted
|
||||
/// MPEG-PS (DVD VOB) bytes.
|
||||
///
|
||||
/// Demuxes `private_stream_1` (0xBD), and for each AC-3 sub-stream id
|
||||
/// (`0x80..=0x87`) records the MAXIMUM channel count seen across EVERY decodable
|
||||
/// frame in the probe window (`acmod` + `lfeon` at each `0x0B77` sync). Pure and
|
||||
/// unit-testable — takes the already-read bytes, never touches the disc.
|
||||
///
|
||||
/// ## Why the maximum, not the first frame
|
||||
///
|
||||
/// The first frame of a sub-stream at the head of a feature is NOT
|
||||
/// representative. A DVD opens with logos/warnings, and the main `0x80`
|
||||
/// sub-stream there frequently carries a thin 2.0 bed before transitioning to
|
||||
/// its real 5.1 main mix a fraction of a second later (observed on Greenland:
|
||||
/// `0x80`'s first frames are acmod=2 → 2 channels, then it becomes acmod=7+lfe →
|
||||
/// 6 channels within the same 2 MiB window). Recording only the FIRST frame read
|
||||
/// `0x80=2` and missed the 5.1 entirely, defeating the channel-match routing.
|
||||
/// The 5.1 capability of a sub-stream is the *maximum* channel count any of its
|
||||
/// frames carries, so we scan them all and keep the max.
|
||||
///
|
||||
/// Returns a map `sub_id -> max channels`. Sub-streams whose frames are all too
|
||||
/// short to carry the BSI bits, or that never appear in the buffer, are absent
|
||||
/// from the map.
|
||||
pub fn probe_ac3_substream_channels(ps_bytes: &[u8]) -> BTreeMap<u8, u8> {
|
||||
let mut found: BTreeMap<u8, u8> = BTreeMap::new();
|
||||
let mut demux = PsDemuxer::new();
|
||||
let mut packets = demux.feed(ps_bytes);
|
||||
packets.extend(demux.flush());
|
||||
for p in packets {
|
||||
// Only private_stream_1 AC-3 sub-streams (0x80..=0x87).
|
||||
let Some(sub) = p.sub_stream_id else { continue };
|
||||
if !(0x80..=0x87).contains(&sub) {
|
||||
continue;
|
||||
}
|
||||
// The PS demux strips the 4-byte AC-3 sub-header but does not align to a
|
||||
// frame. Walk EVERY 0x0B77 sync in this sub-stream's payload, decode
|
||||
// each frame's channel count, and keep the largest — the sub-stream's
|
||||
// real (main-mix) channel capability. See the doc comment above for why
|
||||
// the first frame alone is unreliable.
|
||||
if let Some(ch) = max_substream_channels(&p.data) {
|
||||
let slot = found.entry(sub).or_insert(0);
|
||||
*slot = (*slot).max(ch);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Largest AC-3 channel count over every decodable frame in a single
|
||||
/// sub-stream's payload. Returns `None` when no frame carries enough BSI bits.
|
||||
///
|
||||
/// Each frame is advanced by its real `ac3_frame_size` so a frame's compressed
|
||||
/// body (which can contain stray `0x0B77` byte pairs) cannot be mistaken for a
|
||||
/// new frame; only when a size is unmappable do we fall back to a +2 byte
|
||||
/// rescan to re-lock the next genuine sync.
|
||||
fn max_substream_channels(data: &[u8]) -> Option<u8> {
|
||||
let mut best: Option<u8> = None;
|
||||
let mut pos = 0;
|
||||
while pos < data.len() {
|
||||
let Some(rel) = ac3::find_ac3_sync(&data[pos..]) else {
|
||||
break;
|
||||
};
|
||||
let start = pos + rel;
|
||||
let frame = &data[start..];
|
||||
if let Some(ch) = ac3::acmod_channels(frame) {
|
||||
if ch > 0 {
|
||||
best = Some(best.map_or(ch, |b| b.max(ch)));
|
||||
}
|
||||
}
|
||||
// Advance past this frame by its declared size when that is mappable;
|
||||
// otherwise step 2 bytes past the sync and re-scan for the next one.
|
||||
let size = ac3::ac3_frame_size(frame);
|
||||
pos = if (6..=8192).contains(&size) {
|
||||
start + size
|
||||
} else {
|
||||
start + 2
|
||||
};
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Re-route the title's declared AC-3 audio streams onto the physical
|
||||
/// sub-stream ids whose REAL channel counts match, using a probed
|
||||
/// `sub_id -> channels` map.
|
||||
///
|
||||
/// For each declared AC-3 audio stream (in IFO order), it picks the physical
|
||||
/// `0x8x` sub-stream whose probed channel count equals the stream's declared
|
||||
/// channel count, never re-using a sub-stream already claimed by an earlier
|
||||
/// stream. The chosen sub-stream's PID (`0xBD00 | sub_id`) is written back onto
|
||||
/// the `Stream::Audio` so BOTH mux demux paths (`DiscStream` and the file-backed
|
||||
/// highway) route by it.
|
||||
///
|
||||
/// Conservative — it only ever REASSIGNS among the physical sub-streams the
|
||||
/// probe actually saw, and only when a better (exact-channel) match exists than
|
||||
/// the stream's current assignment. A stream whose current sub-stream already
|
||||
/// matches is left alone; a stream with no matching physical sub-stream keeps
|
||||
/// its ordinal assignment. So a normal disc (physical order == IFO order) is a
|
||||
/// no-op.
|
||||
///
|
||||
/// Returns the number of streams whose PID was changed (for diagnostics).
|
||||
pub fn remap_audio_pids(streams: &mut [Stream], probed: &BTreeMap<u8, u8>) -> usize {
|
||||
if probed.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
// Sub-streams already claimed by a remapped (or matching) earlier stream,
|
||||
// so two declared streams never collide on one physical sub-stream.
|
||||
let mut claimed: Vec<u8> = Vec::new();
|
||||
let mut changed = 0usize;
|
||||
|
||||
for s in streams.iter_mut() {
|
||||
let Stream::Audio(a) = s else { continue };
|
||||
if a.codec != crate::disc::Codec::Ac3 {
|
||||
continue;
|
||||
}
|
||||
let declared = a.channels.count();
|
||||
// The sub-id this stream currently routes by (low byte of its PID).
|
||||
let current_sub = (a.pid & 0x00FF) as u8;
|
||||
|
||||
// If the stream's current physical sub-stream already matches its
|
||||
// declared channel count, keep it and claim it.
|
||||
if probed.get(¤t_sub) == Some(&declared) {
|
||||
claimed.push(current_sub);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise find an unclaimed physical sub-stream whose REAL channel
|
||||
// count equals the declared count.
|
||||
let pick = probed
|
||||
.iter()
|
||||
.find(|(sub, ch)| **ch == declared && !claimed.contains(*sub))
|
||||
.map(|(sub, _)| *sub);
|
||||
|
||||
if let Some(sub) = pick {
|
||||
let new_pid = 0xBD00 | sub as u16;
|
||||
if new_pid != a.pid {
|
||||
tracing::debug!(
|
||||
target: "freemkv::scan",
|
||||
old_pid = a.pid,
|
||||
new_pid,
|
||||
declared_channels = declared,
|
||||
"dvd: re-routed AC-3 audio to physical sub-stream matching channel count"
|
||||
);
|
||||
a.pid = new_pid;
|
||||
changed += 1;
|
||||
}
|
||||
claimed.push(sub);
|
||||
} else {
|
||||
// No physical match — leave the ordinal assignment, but claim its
|
||||
// current sub so later streams don't steal a slot it may still use.
|
||||
claimed.push(current_sub);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Probe the first feature extent of a DVD title through a (decrypted) sector
|
||||
/// source and re-route its AC-3 audio PIDs to the physically-correct
|
||||
/// sub-streams. A bounded, best-effort scan: any read error or empty probe
|
||||
/// leaves the ordinal assignment untouched.
|
||||
///
|
||||
/// `reader` MUST yield PLAINTEXT VOB bytes (i.e. a `DecryptingSectorSource` on a
|
||||
/// CSS disc) — probing scrambled sectors yields no AC-3 syncs and is a safe
|
||||
/// no-op. Returns the number of audio streams whose PID changed.
|
||||
pub fn probe_and_remap<S: SectorSource + ?Sized>(
|
||||
reader: &mut S,
|
||||
title: &mut crate::disc::DiscTitle,
|
||||
) {
|
||||
// Only DVD (MPEG-PS) titles carry private_stream_1 AC-3 sub-streams.
|
||||
if title.content_format != crate::disc::ContentFormat::MpegPs {
|
||||
return;
|
||||
}
|
||||
// Nothing to disambiguate unless there is at least one AC-3 audio stream.
|
||||
let has_ac3 = title
|
||||
.streams
|
||||
.iter()
|
||||
.any(|s| matches!(s, Stream::Audio(a) if a.codec == crate::disc::Codec::Ac3));
|
||||
if !has_ac3 {
|
||||
return;
|
||||
}
|
||||
let Some(ext) = title.extents.first() else {
|
||||
return;
|
||||
};
|
||||
let count: u16 = ext.sector_count.min(PROBE_SECTORS as u32) as u16;
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
let mut buf = vec![0u8; count as usize * 2048];
|
||||
// `recovery=false`: a single best-effort attempt — the probe must never
|
||||
// stall the mux or hammer a marginal drive. On any error, bail to ordinal.
|
||||
let n = match reader.read_sectors(ext.start_lba, count, &mut buf, false) {
|
||||
Ok(n) => n,
|
||||
Err(_) => return,
|
||||
};
|
||||
buf.truncate(n);
|
||||
let probed = probe_ac3_substream_channels(&buf);
|
||||
crate::diag::dump_dvd_substream_probe(title.playlist_id, &probed);
|
||||
remap_audio_pids(&mut title.streams, &probed);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::{AudioChannels, AudioStream, Codec, LabelPurpose, SampleRate};
|
||||
|
||||
/// Build a single, correctly-SIZED AC-3 frame whose `acmod`/`lfeon` encode a
|
||||
/// known channel count. `byte4` is `fscod=0 | frmsizecod=0`, so
|
||||
/// `ac3_frame_size` reports 128 bytes and the frame is zero-padded to exactly
|
||||
/// that — this lets `max_substream_channels` advance frame-by-frame over a
|
||||
/// multi-frame payload exactly as it does on real VOB data. The BSI bits are
|
||||
/// laid down with a writer so the test never hand-miscomputes the lfeon
|
||||
/// offset, matching `acmod_channels`' reader.
|
||||
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
|
||||
}
|
||||
|
||||
/// Build a minimal `private_stream_1` PES carrying `frames` for `sub_id`,
|
||||
/// each preceded only by the 4-byte AC-3 sub-header at the PES head. Mirrors
|
||||
/// the on-disc layout the PS demux expects: PES start `0x000001BD`, length,
|
||||
/// PES header (no PTS), sub-header `[sub_id, frame_count, ptr_hi, ptr_lo]`,
|
||||
/// then the concatenated AC-3 frames.
|
||||
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");
|
||||
}
|
||||
|
||||
/// GREENLAND regression — the probe must read each sub-stream's TRUE
|
||||
/// (max-mix) channel count, not be poisoned by an unrepresentative head
|
||||
/// frame, and must NOT cross-contaminate between sub-streams.
|
||||
///
|
||||
/// Mirrors the real on-disc layout that caused the mis-read: the feature
|
||||
/// head carries `0x80` opening with a 2.0 frame and THEN a 5.1 frame (its
|
||||
/// real main mix), interleaved with `0x81` carrying only 2.0. The old
|
||||
/// first-frame probe read `0x80=2` (the logo bed) and missed the 5.1; the
|
||||
/// max-over-frames probe must report `0x80=6` and `0x81=2`.
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
|
||||
/// SILENCE-OF-THE-LAMBS regression: the IFO declares ONE 5.1 AC-3 stream and
|
||||
/// the ordinal mapping put it at 0x80, but physically 0x80 is the 2.0
|
||||
/// down-mix and the 5.1 lives at 0x81. After probe+remap the declared 5.1
|
||||
/// stream must route to 0x81 (PID 0xBD81), NOT the ordinal 0x80.
|
||||
#[test]
|
||||
fn remap_routes_declared_51_to_physical_51_substream() {
|
||||
// Physical layout: 0x80 = 2.0, 0x81 = 5.1 (reversed vs ordinal).
|
||||
let mut probed = BTreeMap::new();
|
||||
probed.insert(0x80u8, 2u8);
|
||||
probed.insert(0x81u8, 6u8);
|
||||
|
||||
// Declared: one 5.1 stream, ordinally assigned 0x80 (PID 0xBD80).
|
||||
let mut streams = vec![ac3_stream(0xBD80, AudioChannels::Surround51)];
|
||||
let changed = remap_audio_pids(&mut streams, &probed);
|
||||
assert_eq!(changed, 1, "the one 5.1 stream must be re-routed");
|
||||
let Stream::Audio(a) = &streams[0] else {
|
||||
panic!("audio")
|
||||
};
|
||||
assert_eq!(
|
||||
a.pid, 0xBD81,
|
||||
"declared 5.1 must route to physical 0x81 (the real 5.1), not ordinal 0x80"
|
||||
);
|
||||
}
|
||||
|
||||
/// Conservative no-op: when the physical order already matches the IFO
|
||||
/// order (0x80 = 5.1 as declared), remap changes nothing.
|
||||
#[test]
|
||||
fn remap_noop_when_physical_matches_ordinal() {
|
||||
let mut probed = BTreeMap::new();
|
||||
probed.insert(0x80u8, 6u8); // 0x80 really is the 5.1
|
||||
let mut streams = vec![ac3_stream(0xBD80, AudioChannels::Surround51)];
|
||||
let changed = remap_audio_pids(&mut streams, &probed);
|
||||
assert_eq!(changed, 0, "matching physical order is a no-op");
|
||||
let Stream::Audio(a) = &streams[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(a.pid, 0xBD80);
|
||||
}
|
||||
|
||||
/// Two declared streams (5.1 + 2.0) where the physical order is reversed:
|
||||
/// 0x80=2.0, 0x81=5.1. The 5.1 declaration must claim 0x81 and the 2.0
|
||||
/// declaration must claim 0x80 — no collision, both correct.
|
||||
#[test]
|
||||
fn remap_two_streams_no_collision() {
|
||||
let mut probed = BTreeMap::new();
|
||||
probed.insert(0x80u8, 2u8);
|
||||
probed.insert(0x81u8, 6u8);
|
||||
// Declared order: 5.1 first (ordinal 0x80), 2.0 second (ordinal 0x81).
|
||||
let mut streams = vec![
|
||||
ac3_stream(0xBD80, AudioChannels::Surround51),
|
||||
ac3_stream(0xBD81, AudioChannels::Stereo),
|
||||
];
|
||||
remap_audio_pids(&mut streams, &probed);
|
||||
let pids: Vec<u16> = streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Stream::Audio(a) => Some(a.pid),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
pids,
|
||||
vec![0xBD81, 0xBD80],
|
||||
"5.1→0x81, 2.0→0x80, no collision"
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty probe (unreadable / scrambled VOB) is a no-op — the ordinal
|
||||
/// assignment survives so behaviour never regresses below today's.
|
||||
#[test]
|
||||
fn remap_empty_probe_is_noop() {
|
||||
let probed = BTreeMap::new();
|
||||
let mut streams = vec![ac3_stream(0xBD80, AudioChannels::Surround51)];
|
||||
let changed = remap_audio_pids(&mut streams, &probed);
|
||||
assert_eq!(changed, 0);
|
||||
let Stream::Audio(a) = &streams[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(a.pid, 0xBD80, "no probe data → keep ordinal");
|
||||
}
|
||||
}
|
||||
+927
-637
File diff suppressed because it is too large
Load Diff
+1588
File diff suppressed because it is too large
Load Diff
+1106
-20
File diff suppressed because it is too large
Load Diff
+3522
-273
File diff suppressed because it is too large
Load Diff
+1395
-111
File diff suppressed because it is too large
Load Diff
+389
-37
@@ -1,11 +1,10 @@
|
||||
//! Single source of truth for what to do when a sector read fails.
|
||||
//!
|
||||
//! Both Pass 1 (`Disc::sweep`) and Pass 2-N (`Disc::patch`) call into
|
||||
//! `handle_read_error` after every failed `read_sectors`. The handler
|
||||
//! classifies the error, updates the in-flight context (counters,
|
||||
//! damage window, retry budgets), and returns a `ReadAction` the caller
|
||||
//! dispatches on. Every read goes through the same gate — no path can
|
||||
//! silently skip pause/skip/jump/abort logic.
|
||||
//! Pass 1 (`Disc::sweep`) calls into `handle_read_error` after every failed
|
||||
//! `read_sectors`. The handler classifies the error, updates the in-flight
|
||||
//! context (counters, damage window, retry budgets), and returns a
|
||||
//! `ReadAction` the caller dispatches on. Pass N patch has its own
|
||||
//! `handle_read_failure` in `disc/patch.rs` that does not route here.
|
||||
//!
|
||||
//! Adding a new error class = add one arm in `handle_read_error`.
|
||||
//! Adding new logging on errors = one place.
|
||||
@@ -34,14 +33,20 @@ pub struct ReadCtx {
|
||||
/// Sliding window of recent read outcomes (true=ok, false=fail).
|
||||
/// Capped at `damage_window_max`. Drives damage-jump decisions.
|
||||
pub damage_window: Vec<bool>,
|
||||
/// Maximum number of outcome entries kept in `damage_window`; the
|
||||
/// oldest is evicted once this is exceeded. A whole count (e.g. 16).
|
||||
pub damage_window_max: usize,
|
||||
/// Fraction of `damage_window` entries that must be failures before
|
||||
/// the window-based damage-jump fires, as a whole-number percentage
|
||||
/// (e.g. `12` = 12%).
|
||||
pub damage_threshold_pct: usize,
|
||||
/// Trigger a damage-jump after this many consecutive outer-batch
|
||||
/// failures, even when the damage_window isn't full yet. Pass 1
|
||||
/// uses a small value (4) so we don't spend ~40 minutes grinding
|
||||
/// to fill a 16-block window before the first jump on a damage
|
||||
/// zone we entered cleanly. Pass N uses a larger value (or
|
||||
/// disables this — see `bisect_on_marginal`) because Pass N's
|
||||
/// uses a small value (1 — jump on the first outer failure; see
|
||||
/// the 2026-05-11 rewrite in `for_sweep`) so we don't spend ~40
|
||||
/// minutes grinding to fill a 16-block window before the first jump
|
||||
/// on a damage zone we entered cleanly. Pass N uses a larger value
|
||||
/// (or disables this — see `bisect_on_marginal`) because Pass N's
|
||||
/// whole job IS to grind on the bad ranges.
|
||||
pub fast_jump_threshold: u64,
|
||||
/// Multiplier applied to damage-jump distance. Doubles each jump,
|
||||
@@ -151,8 +156,12 @@ impl ReadCtx {
|
||||
/// outer-batch failure — the user's wedge-prevention principle
|
||||
/// (2026-05-11): once the drive returns ANY recoverable error,
|
||||
/// retrying the same LBA quickly is what triggers the firmware
|
||||
/// fast-fail transition. Jump immediately, never retry in Pass 1.
|
||||
/// Pass N owns retries — it gets per-sector timeouts that don't
|
||||
/// fast-fail transition. On the damage-jump and marginal paths Pass 1
|
||||
/// jumps immediately rather than grinding the same LBA. Transient errors
|
||||
/// (NOT_READY, bridge degradation) are still retried a small bounded
|
||||
/// number of times (`NOT_READY_MAX_RETRIES` / `BRIDGE_DEGRADATION_MAX_RETRIES`)
|
||||
/// in both passes before falling through to the skip path.
|
||||
/// Pass N owns the heavy retries — it gets per-sector timeouts that don't
|
||||
/// hammer the firmware the same way.
|
||||
pub fn for_sweep(batch: u16) -> Self {
|
||||
Self {
|
||||
@@ -193,9 +202,10 @@ impl ReadCtx {
|
||||
/// patch loop's whole job is to chip away at bad ranges — being
|
||||
/// more eager to skip clustered bad sectors converges faster on
|
||||
/// the recoverable good sectors inside a range. The patch-side
|
||||
/// `compute_damage_skip` reads its threshold from
|
||||
/// `PASSN_DAMAGE_THRESHOLD_PCT`; keep the two in sync until the
|
||||
/// patch loop's damage-skip is unified with `handle_read_error`'s
|
||||
/// `compute_damage_skip` reads its threshold directly from
|
||||
/// `PASSN_DAMAGE_THRESHOLD_PCT`, which is an alias for this crate's
|
||||
/// `PATCH_DAMAGE_THRESHOLD_PCT`, so the two are always in sync.
|
||||
/// The patch loop's damage-skip is not yet unified with `handle_read_error`'s
|
||||
/// jump path. (v0.20.8 unification attempt found the unification
|
||||
/// itself blocked on the size-aware `range_remaining/4` cap that
|
||||
/// lives in `compute_damage_skip` but not in
|
||||
@@ -240,6 +250,13 @@ impl ReadCtx {
|
||||
// drive recovered, so further wedges should reset the skip
|
||||
// budget instead of accumulating toward a real abort.
|
||||
self.wedge_count = 0;
|
||||
// A successful read also means the bridge recovered, so the
|
||||
// 15s-cooldown retry budget should be available again for the
|
||||
// next bridge-degradation event. Without this reset the budget
|
||||
// saturates permanently after 5 cumulative events across the
|
||||
// whole pass and later degradations skip the cooldown retry,
|
||||
// needlessly losing data.
|
||||
self.bridge_degradation_count = 0;
|
||||
// Outer-success only: a good single-sector read inside a
|
||||
// bisect doesn't mean we've left the damaged batch. Only an
|
||||
// outer-batch success resets the outer-failure counter.
|
||||
@@ -259,6 +276,13 @@ impl ReadCtx {
|
||||
if self.in_damage_zone && self.consecutive_good >= self.damage_window_max as u64 {
|
||||
self.in_damage_zone = false;
|
||||
self.last_error_family = None;
|
||||
// Reset the damage-jump multiplier so the NEXT zone starts
|
||||
// from the base jump distance. Without this the multiplier
|
||||
// stays at whatever the prior zone inflated it to (up to
|
||||
// MAX_JUMP_MULTIPLIER=64), so the next zone's first jump is
|
||||
// 64x oversized and skips recoverable data. The field doc
|
||||
// promises this reset.
|
||||
self.jump_multiplier = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +340,8 @@ pub enum ReadAction {
|
||||
// bridge wedges 524 ms after a 5.4-second internal ECC retry. The
|
||||
// post-failure pauses give the drive — and the bridge — time to settle.
|
||||
/// Pause between a failed read and the next read attempt — applied
|
||||
/// uniformly to Pass 1 sweep and Pass N patch.
|
||||
/// by Pass 1 sweep via `handle_read_error`. Pass N patch uses its own
|
||||
/// `POST_FAILURE_PAUSE_SECS` (see `disc/patch.rs`).
|
||||
///
|
||||
/// 2026-05-11 reframe: a failed read is a failed read, regardless of
|
||||
/// which pass is running. The prior split (1s for Pass N, 5s for Pass
|
||||
@@ -334,7 +359,7 @@ const FAIL_PAUSE_SECS: u64 = 5;
|
||||
/// FIRST read failure after a clean run, before the drive has had a
|
||||
/// chance to cycle in retries that push it toward fast-fail).
|
||||
///
|
||||
/// Empirical: 2026-05-11 Dune Pt 2 wedge incident showed 7 medium
|
||||
/// Empirical: a 2026-05-11 wedge incident showed 7 medium
|
||||
/// errors in 6.5 seconds (~1s per attempt + ~1s pause) push the
|
||||
/// BU40N's firmware into IllegalRequest fast-fail mode permanently.
|
||||
/// Once there, only physical eject + reload clears it. Giving the
|
||||
@@ -345,7 +370,7 @@ const FAIL_PAUSE_SECS: u64 = 5;
|
||||
/// Cost on clean discs: zero (first-error path doesn't trigger).
|
||||
/// Cost on damaged discs: ~30s × N damage zones; on a 5-zone disc
|
||||
/// that's 2.5 min extra. Trade for never wedging the drive.
|
||||
const ZONE_ENTRY_COOLDOWN_SECS: u64 = 30;
|
||||
pub(crate) const ZONE_ENTRY_COOLDOWN_SECS: u64 = 30;
|
||||
/// Cooldown when a long streak of failures suggests the drive is
|
||||
/// stuck in a damage zone and needs MORE breathing room than the
|
||||
/// standard inter-error pause. Same value as `FAIL_PAUSE_SECS`
|
||||
@@ -375,9 +400,9 @@ const JUMP_BASE_SECTORS: u64 = 1024;
|
||||
// When the BU40N (or similar drives) hits a physical-damage cluster,
|
||||
// its firmware can transition into a "wedge" state where it returns
|
||||
// HARDWARE_ERROR or ILLEGAL_REQUEST for every subsequent read —
|
||||
// often for many LBAs after the actual bad sector. Per CLAUDE.md
|
||||
// "Bad-sector handling" rule #2: "Recovery requires eject+reload OR
|
||||
// significant cool-down."
|
||||
// often for many LBAs after the actual bad sector. Once wedged,
|
||||
// recovery requires either a physical eject + reload or a significant
|
||||
// cool-down period; hammering the same LBA only deepens the state.
|
||||
//
|
||||
// Pass 1's pre-fix behavior was to immediately AbortPass on the
|
||||
// first HARDWARE_ERROR / ILLEGAL_REQUEST, killing the rip at
|
||||
@@ -396,10 +421,10 @@ const JUMP_BASE_SECTORS: u64 = 1024;
|
||||
/// One-gigabyte jump (1024 MiB) on each wedge. Big enough to clear
|
||||
/// almost any single-cluster damage zone we've seen.
|
||||
const WEDGE_JUMP_SECTORS: u64 = 524_288;
|
||||
/// Cooldown pause after each wedge. Per CLAUDE.md the drive needs
|
||||
/// "significant cool-down"; 30 s strikes a balance between giving
|
||||
/// the drive a chance to recover and not stalling the rip if the
|
||||
/// drive is permanently stuck.
|
||||
/// Cooldown pause after each wedge. A wedged drive needs a
|
||||
/// significant cool-down to leave fast-fail; 30 s strikes a balance
|
||||
/// between giving the drive a chance to recover and not stalling the
|
||||
/// rip if the drive is permanently stuck.
|
||||
const WEDGE_PAUSE_SECS: u64 = 30;
|
||||
/// Bail after this many consecutive wedges with no good read in
|
||||
/// between. At 1 GB jumps this lets us scan ~16 GB worth of fully
|
||||
@@ -463,8 +488,13 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
.unwrap_or(SenseFamily::Other);
|
||||
|
||||
// Zone-entry tracking: this is the first error after a clean run
|
||||
// (or the first error of the sweep).
|
||||
if !ctx.in_damage_zone && !ctx.bisecting {
|
||||
// (or the first error of the sweep). Capture the genuine
|
||||
// clean->damaged transition here, BEFORE mutating in_damage_zone,
|
||||
// so the 30s zone-entry cooldown below keys off the real
|
||||
// transition rather than re-deriving it from a counter that the
|
||||
// fast-jump path resets after every jump.
|
||||
let is_zone_entry_transition = !ctx.in_damage_zone && !ctx.bisecting;
|
||||
if is_zone_entry_transition {
|
||||
ctx.in_damage_zone = true;
|
||||
ctx.zones_entered += 1;
|
||||
}
|
||||
@@ -519,11 +549,17 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
return ReadAction::AbortPass;
|
||||
}
|
||||
|
||||
// 2. Bridge degradation: NOT_READY with the well-known signature
|
||||
// (sense_key=2, ASC=0x04, ASCQ=0x3E). Drive's bridge is in a
|
||||
// semi-stuck state but typically recovers after a long cooldown.
|
||||
// If we've exhausted our retry budget, fall through to the
|
||||
// marginal/skip path below.
|
||||
// 2. Bridge degradation: the SCSI status byte is non-standard —
|
||||
// neither GOOD (0x00), CHECK CONDITION (0x02), nor TRANSPORT
|
||||
// FAILURE (0xFF). The USB bridge firmware returns these bogus
|
||||
// status bytes (e.g. 0x04, 0x05) with empty sense data when it
|
||||
// enters a semi-stuck state preceding a crash. This is keyed on
|
||||
// the status byte alone, NOT on sense_key/ASC/ASCQ — a real
|
||||
// NOT_READY 04/3E bad-sector error arrives as CHECK CONDITION
|
||||
// (0x02) and is handled by the generic NOT_READY branch below.
|
||||
// The bridge typically recovers after a long cooldown; if we've
|
||||
// exhausted our retry budget, fall through to the marginal/skip
|
||||
// path below.
|
||||
if err.is_bridge_degradation() && ctx.bridge_degradation_count < BRIDGE_DEGRADATION_MAX_RETRIES
|
||||
{
|
||||
ctx.bridge_degradation_count += 1;
|
||||
@@ -569,9 +605,13 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
// AbortPass after N consecutive wedges with no successful
|
||||
// read in between.
|
||||
if sense_key == scsi::SENSE_KEY_HARDWARE_ERROR || sense_key == scsi::SENSE_KEY_ILLEGAL_REQUEST {
|
||||
if !ctx.bisecting {
|
||||
// Count every wedge, including bisect-inner ones. A wedge is a
|
||||
// firmware fast-fail state regardless of whether we're inside a
|
||||
// bisect; if we did NOT count bisect-inner wedges, a drive that
|
||||
// wedges mid-bisect would burn a 30s WEDGE_PAUSE cooldown per
|
||||
// inner sector and never reach WEDGE_ABORT_THRESHOLD from inside
|
||||
// the bisect — ~16 min of cooldown sleeping on a batch=32 bisect.
|
||||
ctx.wedge_count += 1;
|
||||
}
|
||||
if ctx.wedge_count >= WEDGE_ABORT_THRESHOLD {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
@@ -665,8 +705,7 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
// branch for future tuning. Pass N (bisect_on_marginal=true)
|
||||
// uses the standard pauses — it's running single-sector retries
|
||||
// on already-known-bad LBAs by design.
|
||||
let is_zone_entry =
|
||||
ctx.consecutive_outer_failures == 1 && !ctx.bisecting && !ctx.bisect_on_marginal;
|
||||
let is_zone_entry = is_zone_entry_transition && !ctx.bisecting && !ctx.bisect_on_marginal;
|
||||
let pause_secs = if is_zone_entry {
|
||||
ZONE_ENTRY_COOLDOWN_SECS
|
||||
} else if ctx.consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD {
|
||||
@@ -692,7 +731,7 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
// Two triggers, evaluated in order:
|
||||
//
|
||||
// a. **Fast-entry** — `consecutive_outer_failures >= fast_jump_threshold`.
|
||||
// Fires on Pass 1 (threshold=4) so we don't spend ~40 min
|
||||
// Fires on Pass 1 (threshold=1) so we don't spend ~40 min
|
||||
// grinding to fill a 16-block damage window before the
|
||||
// first jump on a damage zone we entered cleanly. Doesn't
|
||||
// fire on Pass N (threshold=u64::MAX).
|
||||
@@ -1029,6 +1068,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_1_subsequent_in_zone_errors_skip_long_cooldown() {
|
||||
// Regression: the fast-jump path resets consecutive_outer_failures
|
||||
// to 0 after each jump, so the next in-zone error re-increments it
|
||||
// to 1. Zone-entry must key off the genuine clean->damaged
|
||||
// transition (in_damage_zone), not the counter, otherwise every
|
||||
// error in a damaged region pays the 30 s cooldown.
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
// First error: genuine zone entry, gets the long cooldown.
|
||||
let first = handle_read_error(&medium_err(), &mut ctx);
|
||||
match first {
|
||||
ReadAction::JumpAhead { pause_secs, .. } => assert_eq!(
|
||||
pause_secs,
|
||||
ZONE_ENTRY_COOLDOWN_SECS + POST_JUMP_EXTRA_PAUSE_SECS
|
||||
),
|
||||
other => panic!("expected JumpAhead on first error, got {other:?}"),
|
||||
}
|
||||
// We are now still in the damage zone; the jump reset the outer
|
||||
// counter. A second error must NOT re-arm the 30 s cooldown.
|
||||
assert!(ctx.in_damage_zone);
|
||||
let second = handle_read_error(&medium_err(), &mut ctx);
|
||||
let pause = match second {
|
||||
ReadAction::JumpAhead { pause_secs, .. } => pause_secs,
|
||||
ReadAction::SkipBlock { pause_secs } => pause_secs,
|
||||
other => panic!("expected pausing action, got {other:?}"),
|
||||
};
|
||||
assert_ne!(
|
||||
pause,
|
||||
ZONE_ENTRY_COOLDOWN_SECS + POST_JUMP_EXTRA_PAUSE_SECS,
|
||||
"subsequent in-zone error must not pay the 30 s zone-entry cooldown"
|
||||
);
|
||||
assert!(
|
||||
pause <= FAIL_PAUSE_SECS + POST_JUMP_EXTRA_PAUSE_SECS,
|
||||
"subsequent in-zone pause should be the standard fail pause, got {pause}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_n_pauses_uniformly_on_failed_read() {
|
||||
// Pass N (bisect_on_marginal=true) is exempt from the
|
||||
@@ -1067,6 +1143,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_multiplier_resets_after_damage_zone_exit() {
|
||||
// A zone that doubles the multiplier must not carry the inflated
|
||||
// value into the next zone — otherwise the next zone's first
|
||||
// jump is up to 64x oversized and skips recoverable data.
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
// First zone: a few errors push jumps and double the multiplier.
|
||||
for _ in 0..4 {
|
||||
handle_read_error(&medium_err(), &mut ctx);
|
||||
}
|
||||
assert!(
|
||||
ctx.jump_multiplier > 1,
|
||||
"expected the multiplier to inflate inside a damage zone"
|
||||
);
|
||||
// Exit the zone: damage_window_max consecutive good reads.
|
||||
ctx.bisecting = false;
|
||||
for _ in 0..ctx.damage_window_max {
|
||||
ctx.on_success();
|
||||
}
|
||||
assert!(!ctx.in_damage_zone, "zone should have exited");
|
||||
assert_eq!(
|
||||
ctx.jump_multiplier, 1,
|
||||
"jump_multiplier must reset to 1 on zone exit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_degradation_count_resets_on_success() {
|
||||
// After a good read the bridge recovered; the 15s-cooldown retry
|
||||
// budget must be available again instead of staying saturated
|
||||
// for the whole pass.
|
||||
let mut ctx = ReadCtx::for_patch(1);
|
||||
ctx.bridge_degradation_count = BRIDGE_DEGRADATION_MAX_RETRIES;
|
||||
ctx.on_success();
|
||||
assert_eq!(ctx.bridge_degradation_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wedge_abort_reachable_during_bisect() {
|
||||
// A drive that wedges mid-bisect must still reach the abort
|
||||
// threshold rather than burning a WEDGE_PAUSE cooldown per inner
|
||||
// sector forever.
|
||||
let mut ctx = ReadCtx::for_patch(32);
|
||||
ctx.bisecting = true;
|
||||
let mut aborted = false;
|
||||
for _ in 0..WEDGE_ABORT_THRESHOLD {
|
||||
if matches!(
|
||||
handle_read_error(&hardware_err(), &mut ctx),
|
||||
ReadAction::AbortPass
|
||||
) {
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
aborted,
|
||||
"wedge abort threshold must be reachable from inside a bisect"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_success_resets_failure_counters_and_pushes_window() {
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
@@ -1080,4 +1216,220 @@ mod tests {
|
||||
assert_eq!(ctx.consecutive_failures, 0);
|
||||
assert!(*ctx.damage_window.last().unwrap());
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Additional hardening: retry-budget boundaries, transport-abort
|
||||
// precedence, and the bounded-jump invariant. These guard against
|
||||
// off-by-one in the retry caps (which would either hammer a wedging
|
||||
// drive or give up a recovery one attempt early) and against an
|
||||
// unbounded jump multiplier skipping the rest of the disc.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/// NOT_READY check-condition (status 0x02 so it is NOT classified as
|
||||
/// bridge degradation, which keys off non-standard status bytes).
|
||||
/// sense_key=2 with a generic ASC routes to the NOT_READY retry path.
|
||||
fn not_ready_err() -> Error {
|
||||
Error::DiscRead {
|
||||
sector: 100,
|
||||
status: Some(crate::scsi::SCSI_STATUS_CHECK_CONDITION),
|
||||
sense: Some(ScsiSense {
|
||||
sense_key: scsi::SENSE_KEY_NOT_READY,
|
||||
asc: 0x04,
|
||||
ascq: 0x00,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport failure: SCSI status 0xFF (bridge crash). CLAUDE.md
|
||||
/// "Bad-sector handling": this aborts the copy.
|
||||
fn transport_failure_err() -> Error {
|
||||
Error::DiscRead {
|
||||
sector: 100,
|
||||
status: Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE),
|
||||
sense: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge degradation: a non-standard status byte (0x04 - neither
|
||||
/// GOOD/CHECK/TRANSPORT) with empty sense, per `Error::is_bridge_degradation`.
|
||||
fn bridge_degradation_err() -> Error {
|
||||
Error::DiscRead {
|
||||
sector: 100,
|
||||
status: Some(0x04),
|
||||
sense: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_ready_retries_capped_at_three_then_falls_through() {
|
||||
// CLAUDE.md "Bad-sector handling" mode 1: NOT READY -> "Pause 3s,
|
||||
// retry up to 3x, then mark NonTrimmed." NOT_READY_MAX_RETRIES=3.
|
||||
// The 1st-3rd NOT_READY must Retry; the 4th must NOT Retry (it
|
||||
// falls through to skip). Pass N (batch=1) so the marginal-bisect
|
||||
// branch is irrelevant.
|
||||
// Mutation that makes this RED: change `ctx.not_ready_retries <
|
||||
// NOT_READY_MAX_RETRIES` to `<=` (retries 4 times) or to `>`
|
||||
// (never retries).
|
||||
let mut ctx = ReadCtx::for_patch(1);
|
||||
for i in 0..NOT_READY_MAX_RETRIES {
|
||||
let a = handle_read_error(¬_ready_err(), &mut ctx);
|
||||
assert!(
|
||||
matches!(a, ReadAction::Retry { .. }),
|
||||
"NOT_READY attempt {i} should Retry, got {a:?}"
|
||||
);
|
||||
}
|
||||
// Budget exhausted: the next NOT_READY must not Retry.
|
||||
let a = handle_read_error(¬_ready_err(), &mut ctx);
|
||||
assert!(
|
||||
!matches!(a, ReadAction::Retry { .. }),
|
||||
"NOT_READY past the retry cap must fall through, got {a:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_failure_aborts_even_mid_bisect() {
|
||||
// CLAUDE.md "Bad-sector handling" mode 2: a transport failure
|
||||
// (bridge crash, status 0xFF) aborts the pass so the outer loop
|
||||
// can re-enumerate the bridge. This must hold even while
|
||||
// bisecting and even on Pass N - the wedge-skip/jump paths must
|
||||
// NOT swallow a real transport crash into a JumpAhead.
|
||||
// Mutation that makes this RED: move the transport-failure check
|
||||
// below the HARDWARE/ILLEGAL wedge arm, so a transport failure
|
||||
// that also carried a wedge-family sense would JumpAhead instead.
|
||||
let mut ctx = ReadCtx::for_patch(32);
|
||||
ctx.bisecting = true;
|
||||
assert_eq!(
|
||||
handle_read_error(&transport_failure_err(), &mut ctx),
|
||||
ReadAction::AbortPass
|
||||
);
|
||||
// And on a fresh Pass 1 context, still AbortPass.
|
||||
let mut ctx1 = ReadCtx::for_sweep(32);
|
||||
assert_eq!(
|
||||
handle_read_error(&transport_failure_err(), &mut ctx1),
|
||||
ReadAction::AbortPass
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_degradation_retries_to_budget_then_falls_through() {
|
||||
// The bridge-degradation cooldown retry is bounded by
|
||||
// BRIDGE_DEGRADATION_MAX_RETRIES (=5). The first 5 degradation
|
||||
// errors must Retry with the long bridge cooldown; the 6th must
|
||||
// fall through to skip/jump rather than retrying forever and
|
||||
// stalling the pass.
|
||||
// Mutation that makes this RED: change the budget comparison
|
||||
// `ctx.bridge_degradation_count < BRIDGE_DEGRADATION_MAX_RETRIES`
|
||||
// to `<=` (retries 6 times).
|
||||
let mut ctx = ReadCtx::for_patch(1);
|
||||
for i in 0..BRIDGE_DEGRADATION_MAX_RETRIES {
|
||||
let a = handle_read_error(&bridge_degradation_err(), &mut ctx);
|
||||
match a {
|
||||
ReadAction::Retry { pause_secs } => {
|
||||
assert_eq!(
|
||||
pause_secs, BRIDGE_DEGRADATION_PAUSE_SECS,
|
||||
"bridge retry {i} should use the bridge cooldown"
|
||||
);
|
||||
}
|
||||
other => panic!("bridge degradation attempt {i} should Retry, got {other:?}"),
|
||||
}
|
||||
}
|
||||
let a = handle_read_error(&bridge_degradation_err(), &mut ctx);
|
||||
assert!(
|
||||
!matches!(a, ReadAction::Retry { .. }),
|
||||
"bridge degradation past the retry budget must fall through, got {a:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The documented BU40N bad-sector signature: NOT_READY
|
||||
/// (sense_key=2, ASC=0x04, ASCQ=0x3E) delivered as a CHECK CONDITION
|
||||
/// (status 0x02). This is the case the old comment on the bridge
|
||||
/// branch wrongly claimed `is_bridge_degradation` matched.
|
||||
fn not_ready_04_3e_err() -> Error {
|
||||
Error::DiscRead {
|
||||
sector: 100,
|
||||
status: Some(crate::scsi::SCSI_STATUS_CHECK_CONDITION),
|
||||
sense: Some(ScsiSense {
|
||||
sense_key: scsi::SENSE_KEY_NOT_READY,
|
||||
asc: 0x04,
|
||||
ascq: 0x3E,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_ready_04_3e_does_not_take_bridge_branch() {
|
||||
// Regression guard for the misleading-comment fix: the bridge
|
||||
// branch keys on the *status byte* (non-standard, i.e. not
|
||||
// GOOD/CHECK/TRANSPORT), NOT on the NOT_READY 04/3E sense. A real
|
||||
// 04/3E bad-sector error arrives as CHECK CONDITION (0x02), so
|
||||
// `is_bridge_degradation()` must be false for it, and it must
|
||||
// route to the generic NOT_READY retry (3 s pause) rather than
|
||||
// the bridge cooldown (15 s pause).
|
||||
let err = not_ready_04_3e_err();
|
||||
assert!(
|
||||
!err.is_bridge_degradation(),
|
||||
"04/3E arrives as CHECK CONDITION (0x02); it is not bridge degradation"
|
||||
);
|
||||
|
||||
let mut ctx = ReadCtx::for_patch(1);
|
||||
match handle_read_error(&err, &mut ctx) {
|
||||
ReadAction::Retry { pause_secs } => {
|
||||
assert_eq!(
|
||||
pause_secs, NOT_READY_PAUSE_SECS,
|
||||
"04/3E must use the generic NOT_READY pause, not the bridge cooldown"
|
||||
);
|
||||
assert_ne!(
|
||||
pause_secs, BRIDGE_DEGRADATION_PAUSE_SECS,
|
||||
"04/3E must not take the bridge-degradation branch"
|
||||
);
|
||||
// Confirm it really went through the NOT_READY path.
|
||||
assert_eq!(ctx.not_ready_retries, 1);
|
||||
assert_eq!(ctx.bridge_degradation_count, 0);
|
||||
}
|
||||
other => panic!("04/3E should Retry via the NOT_READY path, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_multiplier_caps_and_jump_distance_stays_bounded() {
|
||||
// CLAUDE.md damage-jump: multiplier doubles per jump but is
|
||||
// capped at MAX_JUMP_MULTIPLIER=64 (the "4 GiB cap"); a single
|
||||
// jump must never be allowed to grow without bound and skip the
|
||||
// rest of the disc. Drive a long single-sector failure streak on
|
||||
// a sweep ctx with a tiny window so window-trigger jumps fire
|
||||
// repeatedly, and verify the multiplier saturates at 64 and the
|
||||
// emitted jump distance equals JUMP_BASE_SECTORS * batch * 64.
|
||||
// Mutation that makes this RED: remove the
|
||||
// `.min(MAX_JUMP_MULTIPLIER)` on the multiplier doubling, or use
|
||||
// wrapping/non-saturating mul -> distance overshoots or panics.
|
||||
const MAX_JUMP_MULTIPLIER: u64 = 64;
|
||||
let batch: u16 = 32;
|
||||
let mut ctx = ReadCtx::for_sweep(batch);
|
||||
// Small window + 0% threshold so every failure can window-trigger
|
||||
// a jump and keep doubling the multiplier toward the cap.
|
||||
ctx.damage_window_max = 2;
|
||||
ctx.damage_threshold_pct = 0;
|
||||
let mut last_jump_sectors = 0u64;
|
||||
for _ in 0..40 {
|
||||
// Reset bisecting flag defensively; these are outer failures.
|
||||
ctx.bisecting = false;
|
||||
if let ReadAction::JumpAhead { sectors, .. } =
|
||||
handle_read_error(&medium_err(), &mut ctx)
|
||||
{
|
||||
last_jump_sectors = sectors;
|
||||
}
|
||||
assert!(
|
||||
ctx.jump_multiplier <= MAX_JUMP_MULTIPLIER,
|
||||
"jump_multiplier {} exceeded the cap {}",
|
||||
ctx.jump_multiplier,
|
||||
MAX_JUMP_MULTIPLIER
|
||||
);
|
||||
}
|
||||
// After saturation, the jump distance is exactly base*batch*cap.
|
||||
let expected = JUMP_BASE_SECTORS * batch as u64 * MAX_JUMP_MULTIPLIER;
|
||||
assert_eq!(
|
||||
last_jump_sectors, expected,
|
||||
"saturated jump distance must equal base*batch*64"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-49
@@ -8,15 +8,13 @@
|
||||
//! during the post-read work; throughput tops out at the *sum* of
|
||||
//! both costs.
|
||||
//!
|
||||
//! 0.17.11 introduced a bespoke producer/consumer split (the now-
|
||||
//! removed `disc/sweep_pipeline.rs`) to overlap the two stages. 0.18
|
||||
//! collapses that split — together with the analogous splits patch
|
||||
//! and mux need — onto the generic [`crate::io::Pipeline`] +
|
||||
//! [`crate::io::Sink`] primitive. This module is the sweep-specific
|
||||
//! `Sink` impl; the producer-side state machine (read_error context,
|
||||
//! decrypt, set_speed, halt) stays in `Disc::sweep` in `disc/mod.rs`.
|
||||
//! A producer/consumer split overlaps the two stages on the generic
|
||||
//! [`crate::io::Pipeline`] + [`crate::io::Sink`] primitive. This module
|
||||
//! is the sweep-specific `Sink` impl; the producer-side state machine
|
||||
//! (read_error context, decrypt, set_speed, halt) stays in
|
||||
//! `Disc::sweep` in `disc/mod.rs`.
|
||||
//!
|
||||
//! Correctness invariants preserved (same as 0.17.11):
|
||||
//! Correctness invariants preserved:
|
||||
//! - Mapfile is single-writer (consumer-only). No locking.
|
||||
//! - All `read_error::ReadCtx` state stays on the producer thread.
|
||||
//! - `set_speed` calls happen on the producer thread (same thread that
|
||||
@@ -25,9 +23,8 @@
|
||||
//! intact in the consumer (write before record), so the on-disk
|
||||
//! invariant "mapfile only marks Finished what the file has
|
||||
//! received" survives a crash mid-pass.
|
||||
//! - The BU40N+Initio bridge wedge concern is unchanged: only one
|
||||
//! SCSI command in flight at a time, error-path timing identical,
|
||||
//! no new retry logic.
|
||||
//! - Only one SCSI command is in flight at a time; error-path timing
|
||||
//! is identical and no new retry logic is introduced.
|
||||
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
|
||||
@@ -40,7 +37,7 @@ use super::mapfile::{MapStats, Mapfile, SectorStatus};
|
||||
/// Reusable zero buffer for SkipFill / GapFill / BisectBad. 64 KB
|
||||
/// matches the existing zero_gap chunk size used by the pre-split
|
||||
/// sweep loop.
|
||||
const ZERO_CHUNK: usize = 65 * 1024;
|
||||
const ZERO_CHUNK: usize = 64 * 1024;
|
||||
|
||||
/// Producer → Consumer messages. The consumer applies these in FIFO
|
||||
/// order; ordering of file writes and mapfile records across items is
|
||||
@@ -151,55 +148,31 @@ impl Sink<WorkItem> for SweepSink {
|
||||
WorkItem::Good { pos, buf } => {
|
||||
// Decrypt is on the producer; consumer assumes plaintext.
|
||||
let len = buf.len() as u64;
|
||||
self.file
|
||||
.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.file
|
||||
.write_all(&buf)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.map
|
||||
.record(pos, len, SectorStatus::Finished)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.file.seek(SeekFrom::Start(pos))?;
|
||||
self.file.write_all(&buf)?;
|
||||
self.map.record(pos, len, SectorStatus::Finished)?;
|
||||
}
|
||||
WorkItem::BisectGood { pos, buf } => {
|
||||
self.file
|
||||
.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.file
|
||||
.write_all(&buf[..])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.map
|
||||
.record(pos, 2048, SectorStatus::Finished)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.file.seek(SeekFrom::Start(pos))?;
|
||||
self.file.write_all(&buf[..])?;
|
||||
self.map.record(pos, 2048, SectorStatus::Finished)?;
|
||||
}
|
||||
WorkItem::BisectBad { pos } => {
|
||||
self.file
|
||||
.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.file
|
||||
.write_all(&self.zero[..2048])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.map
|
||||
.record(pos, 2048, SectorStatus::NonTrimmed)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.file.seek(SeekFrom::Start(pos))?;
|
||||
self.file.write_all(&self.zero[..2048])?;
|
||||
self.map.record(pos, 2048, SectorStatus::NonTrimmed)?;
|
||||
}
|
||||
WorkItem::SkipFill { pos, len } | WorkItem::GapFill { pos, len } => {
|
||||
self.file
|
||||
.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.file.seek(SeekFrom::Start(pos))?;
|
||||
// Subsequent writes are sequential; `WritebackFile`'s
|
||||
// seek-elision keeps them on the writeback pipeline path.
|
||||
let mut filled = 0u64;
|
||||
while filled < len {
|
||||
let chunk = (len - filled).min(self.zero.len() as u64) as usize;
|
||||
self.file
|
||||
.write_all(&self.zero[..chunk])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.file.write_all(&self.zero[..chunk])?;
|
||||
filled += chunk as u64;
|
||||
}
|
||||
self.map
|
||||
.record(pos, len, SectorStatus::NonTrimmed)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
|
||||
}
|
||||
WorkItem::StatsRequest => {
|
||||
let stats = self.map.stats();
|
||||
@@ -230,7 +203,7 @@ impl Sink<WorkItem> for SweepSink {
|
||||
// Non-regular outputs (/dev/null, pipes) always fail
|
||||
// sync_all; that's not a real error.
|
||||
}
|
||||
self.map.flush().map_err(|e| Error::IoError { source: e })?;
|
||||
self.map.flush()?;
|
||||
|
||||
Ok(ConsumerSummary {
|
||||
stats: self.map.stats(),
|
||||
|
||||
@@ -25,8 +25,14 @@ pub struct DriveCapture {
|
||||
/// A single GET CONFIGURATION feature response from the drive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CapturedFeature {
|
||||
/// MMC-6 GET CONFIGURATION feature code (e.g. `0x010D` = AACS).
|
||||
pub code: u16,
|
||||
/// Static human-readable label from the internal `FEATURES` table —
|
||||
/// not a device-reported string.
|
||||
pub name: &'static str,
|
||||
/// Raw feature-descriptor payload bytes, with the 8-byte GET
|
||||
/// CONFIGURATION header stripped (i.e. `buf[8..]`). Unlike
|
||||
/// [`DriveCapture::gc_010c`], which retains the full header.
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -114,3 +120,77 @@ pub fn mask_bytes(data: &[u8]) -> Vec<u8> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Privacy-masking + capture-orchestration tests.
|
||||
//!
|
||||
//! `mask_string` / `mask_bytes` redact identifying characters before
|
||||
//! a drive capture leaves the machine: every ASCII letter → 'A',
|
||||
//! every ASCII digit → '0', everything else (punctuation, spaces,
|
||||
//! control bytes, non-ASCII) is preserved verbatim so structural
|
||||
//! framing (offsets, separators) survives for diffing.
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mask_string_letters_become_a_digits_become_zero() {
|
||||
// Mixed case letters all collapse to 'A'; digits to '0'.
|
||||
assert_eq!(mask_string("HL-DT-ST"), "AA-AA-AA");
|
||||
assert_eq!(mask_string("BU40N"), "AA00A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_string_preserves_non_alnum_punctuation_and_space() {
|
||||
// Separators and spaces must be preserved so the masked output
|
||||
// keeps the same shape as the original (the whole point of a
|
||||
// structure-preserving redaction).
|
||||
assert_eq!(mask_string("1.04"), "0.00");
|
||||
assert_eq!(mask_string("a b-c.d_e"), "A A-A.A_A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_string_preserves_non_ascii_chars() {
|
||||
// is_ascii_alphabetic/is_ascii_digit are false for non-ASCII, so
|
||||
// multibyte chars pass through unchanged (no mojibake, no panic).
|
||||
// 'c','a','f' are ASCII letters → 'A'; 'é' is non-ASCII →
|
||||
// preserved; '9' → '0'.
|
||||
assert_eq!(mask_string("café9"), "AAAé0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_bytes_matches_string_masking_for_ascii() {
|
||||
// mask_bytes is the byte-wise analogue: letters→b'A', digits→b'0'.
|
||||
assert_eq!(mask_bytes(b"HL-DT-ST"), b"AA-AA-AA".to_vec());
|
||||
assert_eq!(mask_bytes(b"1.04"), b"0.00".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_bytes_preserves_non_alnum_and_high_bytes() {
|
||||
// Control bytes (0x00), high bytes (0xFF), and punctuation are
|
||||
// not ASCII alnum and must survive verbatim — INQUIRY payloads
|
||||
// are space-padded binary and the framing must be diffable.
|
||||
let input = [0x00u8, b'A', 0x20, b'7', 0xFF, b'-'];
|
||||
assert_eq!(mask_bytes(&input), vec![0x00, b'A', 0x20, b'0', 0xFF, b'-']);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_table_has_no_duplicate_codes() {
|
||||
// capture_drive_data iterates FEATURES once per code; a duplicate
|
||||
// code would silently capture the same feature twice (and bloat
|
||||
// the report). Each MMC-6 feature code must be unique.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for &(code, _name) in FEATURES {
|
||||
assert!(seen.insert(code), "duplicate feature code {code:#06x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_table_includes_aacs_010d() {
|
||||
// AACS (0x010D) is the feature that gates UHD decryption capture;
|
||||
// it must be in the table or AACS drives capture incompletely.
|
||||
assert!(
|
||||
FEATURES.iter().any(|&(c, _)| c == 0x010D),
|
||||
"AACS feature 0x010D must be captured"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+68
-16
@@ -1,18 +1,33 @@
|
||||
//! Linux drive discovery and device resolution.
|
||||
|
||||
use crate::drive::DeviceResolution;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::identity::DriveId;
|
||||
|
||||
/// SCSI peripheral device type 5 = MMC / optical (CD/DVD/BD), held in the
|
||||
/// low 5 bits of INQUIRY byte 0 (the high 3 bits are the peripheral
|
||||
/// qualifier, masked off here).
|
||||
const SCSI_PERIPHERAL_TYPE_OPTICAL: u8 = 0x05;
|
||||
|
||||
/// Discover optical drives by enumerating `/dev/sg*` SCSI-generic nodes,
|
||||
/// opening each, running INQUIRY, and keeping only devices whose
|
||||
/// peripheral device type is optical (MMC, type 0x05).
|
||||
///
|
||||
/// Devices where `scsi::open` or `DriveId::from_drive` fail are silently
|
||||
/// skipped — that is intentional for enumeration (a busy or wedged node
|
||||
/// shouldn't abort discovery of the others).
|
||||
pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let mut drives = Vec::new();
|
||||
for i in 0..16 {
|
||||
let path = format!("/dev/sg{i}");
|
||||
for name in enumerate_sg_names() {
|
||||
let path = format!("/dev/{name}");
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 {
|
||||
if !id.raw_inquiry.is_empty()
|
||||
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||
{
|
||||
drives.push((path, id));
|
||||
}
|
||||
}
|
||||
@@ -21,41 +36,78 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
drives
|
||||
}
|
||||
|
||||
/// Enumerate `sg*` device names. Linux assigns `/dev/sgN` sequentially
|
||||
/// across *all* SCSI-generic devices (disks, tape, HBAs, optical), so a
|
||||
/// fixed `sg0..15` range can miss an optical drive on a host with many
|
||||
/// targets. Prefer the exact present-device list from
|
||||
/// `/sys/class/scsi_generic/`; fall back to a bounded `sg0..15` probe
|
||||
/// only when sysfs is unreadable (minimal containers).
|
||||
fn enumerate_sg_names() -> Vec<String> {
|
||||
let mut names = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir("/sys/class/scsi_generic") {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("sg") {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i in 0..16 {
|
||||
let name = format!("sg{i}");
|
||||
if std::path::Path::new(&format!("/dev/{name}")).exists() {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// Resolve a device path to its raw `/dev/sg*` SCSI-generic node.
|
||||
///
|
||||
/// - `/dev/sg*` paths pass through unchanged ([`DeviceResolution::Direct`]).
|
||||
/// - `/dev/sr*` block paths are matched (by vendor/product/serial) to the
|
||||
/// corresponding `/dev/sg*` node ([`DeviceResolution::SrToSg`]); if no
|
||||
/// match is found the original path is returned with
|
||||
/// [`DeviceResolution::SrNoSgMatch`].
|
||||
/// - Any other existing path passes through as [`DeviceResolution::Direct`].
|
||||
#[allow(dead_code)]
|
||||
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
pub fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> {
|
||||
if path.contains("/sg") {
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: path.to_string(),
|
||||
});
|
||||
}
|
||||
return Ok((path.to_string(), None));
|
||||
return Ok((path.to_string(), DeviceResolution::Direct));
|
||||
}
|
||||
if path.contains("/sr") {
|
||||
let mut sr_transport = crate::scsi::open(std::path::Path::new(path))?;
|
||||
let sr_id = DriveId::from_drive(sr_transport.as_mut())?;
|
||||
drop(sr_transport);
|
||||
for (sg_path, sg_id) in find_drives() {
|
||||
if sg_id.vendor_id == sr_id.vendor_id
|
||||
// Require a non-empty serial before treating vendor/product/
|
||||
// serial as a unique match. serial_number falls back to an
|
||||
// empty string when GET CONFIGURATION 0108h is unavailable
|
||||
// (common on OEM drives); two same-model drives would then
|
||||
// both compare equal and the first in enumeration order would
|
||||
// win silently, resolving sr1 to sr0's sg node. An empty
|
||||
// serial can't disambiguate, so fall through to the no-match
|
||||
// path instead.
|
||||
if !sr_id.serial_number.is_empty()
|
||||
&& sg_id.vendor_id == sr_id.vendor_id
|
||||
&& sg_id.product_id == sr_id.product_id
|
||||
&& sg_id.serial_number == sr_id.serial_number
|
||||
{
|
||||
let warning =
|
||||
format!("{path} is a block device (sr) — using {sg_path} (sg) for raw access");
|
||||
return Ok((sg_path, Some(warning)));
|
||||
return Ok((sg_path, DeviceResolution::SrToSg));
|
||||
}
|
||||
}
|
||||
return Ok((
|
||||
path.to_string(),
|
||||
Some(format!(
|
||||
"{path} is a block device (sr) — no matching sg device found"
|
||||
)),
|
||||
));
|
||||
return Ok((path.to_string(), DeviceResolution::SrNoSgMatch));
|
||||
}
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: path.to_string(),
|
||||
});
|
||||
}
|
||||
Ok((path.to_string(), None))
|
||||
Ok((path.to_string(), DeviceResolution::Direct))
|
||||
}
|
||||
|
||||
+21
-11
@@ -4,9 +4,20 @@
|
||||
//! to discover optical drives without exclusive access or unmounts. Only
|
||||
//! the returned paths are then opened for INQUIRY to build full `DriveId`.
|
||||
|
||||
use crate::drive::DeviceResolution;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::identity::DriveId;
|
||||
|
||||
/// SCSI peripheral device type 5 = MMC / optical, in the low 5 bits of
|
||||
/// INQUIRY byte 0.
|
||||
const SCSI_PERIPHERAL_TYPE_OPTICAL: u8 = 0x05;
|
||||
|
||||
/// Discover optical drives via the IOKit registry (`scsi::list_drives`),
|
||||
/// then open each candidate for INQUIRY to build a full `DriveId`.
|
||||
///
|
||||
/// Any drive where `scsi::open` or `DriveId::from_drive` fails, or whose
|
||||
/// peripheral device type is not optical (MMC, type 0x05), is silently
|
||||
/// skipped — the same MMC filter the Linux and Windows backends apply.
|
||||
pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let mut drives = Vec::new();
|
||||
let discovered = crate::scsi::list_drives();
|
||||
@@ -15,9 +26,13 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
match crate::scsi::open(path) {
|
||||
Ok(mut transport) => {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
if !id.raw_inquiry.is_empty()
|
||||
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||
{
|
||||
drives.push((info.path.clone(), id));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
continue;
|
||||
}
|
||||
@@ -26,20 +41,15 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
drives
|
||||
}
|
||||
|
||||
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
// Accept /dev/diskN or /dev/rdiskN paths as-is
|
||||
if path.contains("/disk") || path.contains("/rdisk") {
|
||||
/// Resolve a device path on macOS. There is no `sr`→`sg` style
|
||||
/// substitution here (that is a Linux concern), so any existing path is
|
||||
/// returned unchanged as [`DeviceResolution::Direct`]; the
|
||||
/// [`DeviceResolution`] return exists for cross-platform signature parity.
|
||||
pub fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> {
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: path.to_string(),
|
||||
});
|
||||
}
|
||||
return Ok((path.to_string(), None));
|
||||
}
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: path.to_string(),
|
||||
});
|
||||
}
|
||||
Ok((path.to_string(), None))
|
||||
Ok((path.to_string(), DeviceResolution::Direct))
|
||||
}
|
||||
|
||||
+1140
-137
File diff suppressed because it is too large
Load Diff
+23
-8
@@ -1,9 +1,18 @@
|
||||
//! Windows drive discovery and device resolution.
|
||||
|
||||
use crate::drive::DeviceResolution;
|
||||
use crate::error::Result;
|
||||
use crate::identity::DriveId;
|
||||
use std::path::Path;
|
||||
|
||||
/// SCSI peripheral device type 5 = MMC / optical, in the low 5 bits of
|
||||
/// INQUIRY byte 0.
|
||||
const SCSI_PERIPHERAL_TYPE_OPTICAL: u8 = 0x05;
|
||||
|
||||
/// Discover optical drives. Probes `\\.\CdRom0..15` first; only if none
|
||||
/// are found does it fall back to scanning drive letters `D..Z`. Each
|
||||
/// candidate is opened, INQUIRY'd, and kept only if its peripheral device
|
||||
/// type is optical (MMC, type 0x05). Returns normalized `\\.\` paths.
|
||||
pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let mut drives = Vec::new();
|
||||
|
||||
@@ -12,7 +21,9 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let path = format!("\\\\.\\CdRom{}", i);
|
||||
if let Ok(mut transport) = crate::scsi::open(Path::new(&path)) {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 {
|
||||
if !id.raw_inquiry.is_empty()
|
||||
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||
{
|
||||
drives.push((path, id));
|
||||
}
|
||||
}
|
||||
@@ -25,8 +36,12 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let path = format!("{}:", letter as char);
|
||||
if let Ok(mut transport) = crate::scsi::open(Path::new(&path)) {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 {
|
||||
drives.push((path, id));
|
||||
if !id.raw_inquiry.is_empty()
|
||||
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||
{
|
||||
// Normalize so returned paths are consistently in
|
||||
// \\.\ form regardless of which loop matched.
|
||||
drives.push((normalize_path(&path), id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,8 +51,11 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
drives
|
||||
}
|
||||
|
||||
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
Ok((normalize_path(path), None))
|
||||
/// Resolve a device path to its normalized Windows `\\.\` form. Windows
|
||||
/// has no `sr`→`sg` symlink-target indirection, so resolution is purely a
|
||||
/// path normalization and always reports [`DeviceResolution::Direct`].
|
||||
pub fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> {
|
||||
Ok((normalize_path(path), DeviceResolution::Direct))
|
||||
}
|
||||
|
||||
/// Normalize a device path to Windows \\.\X: format.
|
||||
@@ -55,9 +73,6 @@ fn normalize_path(path: &str) -> String {
|
||||
if trimmed.len() == 2 && trimmed.as_bytes()[1] == b':' {
|
||||
return format!("\\\\.\\{}", trimmed);
|
||||
}
|
||||
if path.to_lowercase().starts_with("cdrom") {
|
||||
return format!("\\\\.\\{}", path);
|
||||
}
|
||||
format!("\\\\.\\{}", path)
|
||||
}
|
||||
|
||||
|
||||
-287
@@ -1,287 +0,0 @@
|
||||
//! Top-level DRM scheme dispatch.
|
||||
//!
|
||||
//! Four content-protection schemes ride through a single
|
||||
//! detect-then-load pipeline:
|
||||
//!
|
||||
//! | Scheme | Discriminator |
|
||||
//! |---------------------|------------------------------------------------|
|
||||
//! | [`DrmScheme::Css`] | DVD probe sector flagged scrambled |
|
||||
//! | [`DrmScheme::Aacs10`] | Content cert type byte `0x00` |
|
||||
//! | [`DrmScheme::Aacs20`] | Content cert type byte `!= 0x00`, no Variant |
|
||||
//! | [`DrmScheme::Aacs21`] | Content cert + MKB records `0x82` / `0x83` |
|
||||
//!
|
||||
//! Detection happens from a [`DrmProbe`] (raw inputs the caller has
|
||||
//! already extracted from the disc); resolution runs through a
|
||||
//! [`DrmContext`] (the full set of inputs the loaders need).
|
||||
//!
|
||||
//! The AACS 2.1 arm is wired but disabled. The dispatcher leaves
|
||||
//! [`crate::aacs::resolve_keys_v21`] reachable as a library entry point
|
||||
//! for fixture-driven validation, but production consumers go through
|
||||
//! [`DrmScheme::load`], which short-circuits V21 to `None` until the
|
||||
//! Variant chain has a real Variant-scheme disc to validate against.
|
||||
|
||||
use crate::aacs;
|
||||
use crate::css;
|
||||
|
||||
/// Which content-protection scheme governs a disc.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DrmScheme {
|
||||
/// DVD Content Scramble System.
|
||||
Css,
|
||||
/// AACS 1.0 — original BD-ROM.
|
||||
Aacs10,
|
||||
/// AACS 2.0 — UHD-BD, classical Media Key chain.
|
||||
Aacs20,
|
||||
/// AACS 2.1 — UHD-BD with Media Key Variant chain.
|
||||
Aacs21,
|
||||
}
|
||||
|
||||
/// Inputs to [`DrmScheme::detect`]. All borrows — caller retains
|
||||
/// ownership.
|
||||
pub struct DrmProbe<'a> {
|
||||
/// 2048-byte sample sector from inside a DVD title's extents. Used
|
||||
/// only for CSS scramble-flag detection. `None` for non-DVD discs.
|
||||
pub dvd_sample_sector: Option<&'a [u8]>,
|
||||
/// Content Certificate file bytes (typically `/AACS/Content000.cer`).
|
||||
/// `None` when the disc has no AACS directory.
|
||||
pub content_cert: Option<&'a [u8]>,
|
||||
/// MKB file bytes (typically `/AACS/MKB_RW.inf`). Required to
|
||||
/// distinguish AACS 2.0 from AACS 2.1.
|
||||
pub mkb: Option<&'a [u8]>,
|
||||
}
|
||||
|
||||
/// Inputs to [`DrmScheme::load`]. Carries everything needed by either
|
||||
/// the AACS or CSS loader.
|
||||
pub struct DrmContext<'a> {
|
||||
/// AACS resolver inputs — required when the scheme is any AACS
|
||||
/// variant.
|
||||
pub aacs: Option<aacs::ResolveContext<'a>>,
|
||||
/// CSS resolver inputs — required when the scheme is [`DrmScheme::Css`].
|
||||
pub css: Option<css::CssContext<'a>>,
|
||||
}
|
||||
|
||||
/// Resolved key material, tagged by scheme.
|
||||
#[derive(Debug)]
|
||||
pub enum ResolvedScheme {
|
||||
Css(css::CssState),
|
||||
Aacs(aacs::ResolvedKeys),
|
||||
}
|
||||
|
||||
impl DrmScheme {
|
||||
/// Detect which DRM scheme protects the disc described by `probe`.
|
||||
///
|
||||
/// Returns `None` for unencrypted media. The order is intentional:
|
||||
/// CSS is checked first (DVD-format probe), then AACS (Blu-ray
|
||||
/// format).
|
||||
pub fn detect(probe: &DrmProbe<'_>) -> Option<DrmScheme> {
|
||||
// CSS — DVD probe sector carries the scramble flag.
|
||||
if let Some(sector) = probe.dvd_sample_sector {
|
||||
if css::is_scrambled(sector) {
|
||||
return Some(DrmScheme::Css);
|
||||
}
|
||||
}
|
||||
|
||||
// AACS — content cert type byte distinguishes V10 from V20+.
|
||||
// V21 promotion requires MKB Variant records.
|
||||
let cc = probe.content_cert.and_then(aacs::parse_content_cert)?;
|
||||
match cc.version {
|
||||
aacs::AacsVersion::V10 => Some(DrmScheme::Aacs10),
|
||||
aacs::AacsVersion::V20 | aacs::AacsVersion::V21 => {
|
||||
if let Some(mkb) = probe.mkb {
|
||||
let recs = aacs::variants::walk_mkb(mkb);
|
||||
if aacs::variants::is_variant_mkb(&recs) {
|
||||
return Some(DrmScheme::Aacs21);
|
||||
}
|
||||
}
|
||||
Some(DrmScheme::Aacs20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run key resolution for this scheme against `ctx`.
|
||||
///
|
||||
/// Returns `None` when the scheme's resolver could not produce keys
|
||||
/// (missing context, KEYDB miss, failed crypto walk, etc.) or when
|
||||
/// the scheme itself is gated off (see the inline comment on the
|
||||
/// `Aacs21` arm).
|
||||
pub fn load(self, ctx: &mut DrmContext<'_>) -> Option<ResolvedScheme> {
|
||||
match self {
|
||||
DrmScheme::Css => ctx
|
||||
.css
|
||||
.as_mut()
|
||||
.and_then(css::resolve)
|
||||
.map(ResolvedScheme::Css),
|
||||
DrmScheme::Aacs10 => ctx
|
||||
.aacs
|
||||
.as_ref()
|
||||
.and_then(aacs::resolve_keys_v1)
|
||||
.map(ResolvedScheme::Aacs),
|
||||
DrmScheme::Aacs20 => ctx
|
||||
.aacs
|
||||
.as_ref()
|
||||
.and_then(aacs::resolve_keys_v2)
|
||||
.map(ResolvedScheme::Aacs),
|
||||
// AACS 2.1 derivation is wired but disabled. KCD validation
|
||||
// against a Variant-scheme disc is pending. To enable,
|
||||
// uncomment the line below.
|
||||
// DrmScheme::Aacs21 => ctx
|
||||
// .aacs
|
||||
// .as_ref()
|
||||
// .and_then(aacs::resolve_keys_v21)
|
||||
// .map(ResolvedScheme::Aacs),
|
||||
DrmScheme::Aacs21 => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Build a minimal cert: type byte + bus-encryption byte + 6 zero
|
||||
// cc_id bytes.
|
||||
fn cert(type_byte: u8) -> Vec<u8> {
|
||||
let mut v = vec![0u8; 8];
|
||||
v[0] = type_byte;
|
||||
v
|
||||
}
|
||||
|
||||
// Synthetic AACS 2.x MKB with no Variant records.
|
||||
fn mkb_classical() -> Vec<u8> {
|
||||
vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
|
||||
]
|
||||
}
|
||||
|
||||
// Synthetic AACS 2.x MKB with a 0x82 + 0x83 record pair.
|
||||
fn mkb_with_variant() -> Vec<u8> {
|
||||
let mut m = mkb_classical();
|
||||
m.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
|
||||
m.extend_from_slice(&[0xEE; 16]);
|
||||
m.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
|
||||
m.extend_from_slice(&[0x55; 16]);
|
||||
m
|
||||
}
|
||||
|
||||
// Synthetic scrambled DVD sector — byte 0x14 carries the CSS
|
||||
// scramble flag in bits 4-5.
|
||||
fn scrambled_dvd_sector() -> Vec<u8> {
|
||||
let mut s = vec![0u8; 2048];
|
||||
s[0x14] = 0x30;
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_returns_none_for_unencrypted() {
|
||||
let probe = DrmProbe {
|
||||
dvd_sample_sector: None,
|
||||
content_cert: None,
|
||||
mkb: None,
|
||||
};
|
||||
assert_eq!(DrmScheme::detect(&probe), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_returns_css_for_scrambled_dvd() {
|
||||
let sector = scrambled_dvd_sector();
|
||||
let probe = DrmProbe {
|
||||
dvd_sample_sector: Some(§or),
|
||||
content_cert: None,
|
||||
mkb: None,
|
||||
};
|
||||
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Css));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_returns_aacs10_for_type0_cert() {
|
||||
let c = cert(0x00);
|
||||
let probe = DrmProbe {
|
||||
dvd_sample_sector: None,
|
||||
content_cert: Some(&c),
|
||||
mkb: None,
|
||||
};
|
||||
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_returns_aacs20_for_type1_cert_no_variant() {
|
||||
let c = cert(0x01);
|
||||
let mkb = mkb_classical();
|
||||
let probe = DrmProbe {
|
||||
dvd_sample_sector: None,
|
||||
content_cert: Some(&c),
|
||||
mkb: Some(&mkb),
|
||||
};
|
||||
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_returns_aacs21_for_type1_cert_with_variant() {
|
||||
let c = cert(0x01);
|
||||
let mkb = mkb_with_variant();
|
||||
let probe = DrmProbe {
|
||||
dvd_sample_sector: None,
|
||||
content_cert: Some(&c),
|
||||
mkb: Some(&mkb),
|
||||
};
|
||||
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs21));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_returns_aacs20_when_mkb_absent() {
|
||||
// Type-1 cert but no MKB to upgrade with -> Aacs20.
|
||||
let c = cert(0x01);
|
||||
let probe = DrmProbe {
|
||||
dvd_sample_sector: None,
|
||||
content_cert: Some(&c),
|
||||
mkb: None,
|
||||
};
|
||||
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_aacs21_returns_none() {
|
||||
// The Aacs21 dispatch arm is commented out; load() must
|
||||
// return None until KCD validation lands.
|
||||
let uk_ro = vec![0u8; 256];
|
||||
let vid = [0u8; 16];
|
||||
let keydb = aacs::KeyDb::empty();
|
||||
let ctx_aacs = aacs::ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &vid,
|
||||
keydb: &keydb,
|
||||
mkb: None,
|
||||
};
|
||||
let mut ctx = DrmContext {
|
||||
aacs: Some(ctx_aacs),
|
||||
css: None,
|
||||
};
|
||||
assert!(DrmScheme::Aacs21.load(&mut ctx).is_none());
|
||||
}
|
||||
|
||||
/// Exercises the V21 helper directly. Gated `#[ignore]` because
|
||||
/// the chain reaches `MediaKeyVariantError::VariantsTableUnavailable`
|
||||
/// without a real Variant-scheme disc to fix the per-uv table
|
||||
/// layout against — running it here would assert only the
|
||||
/// not-yet-wired error code. Kept as a wiring smoke-test for
|
||||
/// future enablement.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn resolve_keys_v21_helper_exists() {
|
||||
let uk_ro = vec![0u8; 256];
|
||||
let vid = [0xAAu8; 16];
|
||||
let keydb = aacs::KeyDb::empty();
|
||||
let mkb = mkb_with_variant();
|
||||
let ctx = aacs::ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &vid,
|
||||
keydb: &keydb,
|
||||
mkb: Some(&mkb),
|
||||
};
|
||||
// Just confirm the symbol is callable; we don't assert on the
|
||||
// result.
|
||||
let _ = aacs::resolve_keys_v21(&ctx);
|
||||
}
|
||||
}
|
||||
+909
-35
File diff suppressed because it is too large
Load Diff
+33
-1
@@ -8,11 +8,17 @@
|
||||
//! disc.rip(&mut session, 0, output, |event| {
|
||||
//! match event.kind {
|
||||
//! EventKind::BytesRead { bytes, total } => update_progress(bytes, total),
|
||||
//! EventKind::ReadError { sector, .. } => log_error(sector),
|
||||
//! EventKind::SectorSkipped { sector } => log_skip(sector),
|
||||
//! EventKind::BatchSizeChanged { new_size, .. } => note_recovery(new_size),
|
||||
//! _ => {}
|
||||
//! }
|
||||
//! });
|
||||
//! ```
|
||||
//!
|
||||
//! Note: the library currently emits only `BytesRead`, `SectorSkipped`,
|
||||
//! and `BatchSizeChanged`. The other [`EventKind`] variants are part of
|
||||
//! the stable event vocabulary for consumers (and future emit sites) but
|
||||
//! are not produced by the library today.
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
@@ -116,3 +122,29 @@ pub enum BatchSizeReason {
|
||||
|
||||
/// A no-op event handler. Ignores all events.
|
||||
pub fn ignore(_event: Event) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// BatchSizeReason::Shrunk != BatchSizeReason::Probed.
|
||||
/// These two variants carry distinct meanings (error vs. recovery); they
|
||||
/// must not compare as equal.
|
||||
/// Mutation: deriving PartialEq without proper variant discrimination
|
||||
/// could make two distinct variants equal.
|
||||
#[test]
|
||||
fn batch_size_reason_variants_are_not_equal() {
|
||||
assert_ne!(BatchSizeReason::Shrunk, BatchSizeReason::Probed);
|
||||
}
|
||||
|
||||
/// BatchSizeReason is Clone + Copy: cloning does not move the original.
|
||||
/// This is required because EventKind::BatchSizeChanged embeds it by value.
|
||||
/// Mutation: removing Copy would require the caller to clone explicitly;
|
||||
/// code that passes reason by value would fail to compile.
|
||||
#[test]
|
||||
fn batch_size_reason_is_copy() {
|
||||
let r = BatchSizeReason::Shrunk;
|
||||
let _r2 = r; // copy, not move
|
||||
let _r3 = r; // r still usable after copy
|
||||
}
|
||||
}
|
||||
|
||||
+20
-9
@@ -34,11 +34,9 @@ impl Halt {
|
||||
Self(Arc::new(AtomicBool::new(false)))
|
||||
}
|
||||
|
||||
/// Wrap an existing `Arc<AtomicBool>` as a `Halt`. Useful as a
|
||||
/// bridge during the 0.18 deprecation window: callers that already
|
||||
/// hold an `Arc<AtomicBool>` (e.g. `Drive::halt_flag()`, the
|
||||
/// deprecated `DiscStream::set_halt`) can adopt the new token API
|
||||
/// without changing the underlying flag.
|
||||
/// Wrap an existing `Arc<AtomicBool>` as a `Halt`. A bridge for
|
||||
/// callers that already hold an `Arc<AtomicBool>` cancellation flag
|
||||
/// and want to adopt the token API without allocating a new flag.
|
||||
///
|
||||
/// Cancelling either side flips the same bit — the wrapping `Halt`
|
||||
/// and the original `Arc` are two views over one shared flag.
|
||||
@@ -46,10 +44,9 @@ impl Halt {
|
||||
Self(flag)
|
||||
}
|
||||
|
||||
/// Borrow the underlying `Arc<AtomicBool>`. Used at boundaries with
|
||||
/// pre-`Halt` APIs that still take an `Arc<AtomicBool>` directly
|
||||
/// (`CopyOptions::halt`, the deprecated `DiscStream::set_halt`).
|
||||
/// Round 3 deletes those boundaries and this accessor with them.
|
||||
/// Borrow the underlying `Arc<AtomicBool>`. The inverse of
|
||||
/// [`from_arc`](Self::from_arc): hand the shared flag to an API that
|
||||
/// still takes a raw `Arc<AtomicBool>` rather than a `Halt`.
|
||||
pub fn as_arc(&self) -> &Arc<AtomicBool> {
|
||||
&self.0
|
||||
}
|
||||
@@ -177,4 +174,18 @@ mod tests {
|
||||
arc.store(true, Ordering::Relaxed);
|
||||
assert!(halt.is_cancelled());
|
||||
}
|
||||
|
||||
// ── New comprehensive tests ────────────────────────────────────────────────
|
||||
|
||||
/// POLL_INTERVAL is 250ms — a specific value that the multi-thread halt
|
||||
/// loops depend on for responsiveness guarantees.
|
||||
/// Mutation: setting POLL_INTERVAL to 5s makes stop requests take 5s to notice.
|
||||
#[test]
|
||||
fn poll_interval_is_250ms() {
|
||||
assert_eq!(
|
||||
POLL_INTERVAL,
|
||||
std::time::Duration::from_millis(250),
|
||||
"POLL_INTERVAL must be 250ms for the guaranteed ~quarter-second cancel latency"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+204
-9
@@ -57,27 +57,48 @@ impl DriveId {
|
||||
let cdb_inq = [0x12, 0x00, 0x00, 0x00, 0x60, 0x00];
|
||||
transport.execute(&cdb_inq, DataDirection::FromDevice, &mut inquiry, 5000)?;
|
||||
|
||||
// GET CONFIGURATION Feature 010Ch — MMC-6 §6.6
|
||||
// GET CONFIGURATION Feature 010Ch — MMC-6 §6.6.
|
||||
// Best-effort: 010Ch (Firmware Information) is an optional feature.
|
||||
// A drive that lacks it may CHECK CONDITION rather than return an
|
||||
// empty descriptor, so a failure here is treated as feature-absent
|
||||
// (empty firmware date + empty raw bytes) instead of aborting the
|
||||
// whole identity probe.
|
||||
let mut gc = vec![0u8; 256];
|
||||
let cdb_gc = [0x46, 0x02, 0x01, 0x0C, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00];
|
||||
let result = transport.execute(&cdb_gc, DataDirection::FromDevice, &mut gc, 5000)?;
|
||||
|
||||
let firmware_date = if result.bytes_transferred > 12 {
|
||||
String::from_utf8_lossy(&gc[12..24.min(result.bytes_transferred)])
|
||||
// `bytes_transferred` is device-reported and untrusted; clamp every
|
||||
// slice end to the actual buffer length before indexing.
|
||||
let (firmware_date, raw_gc_010c) =
|
||||
match transport.execute(&cdb_gc, DataDirection::FromDevice, &mut gc, 5000) {
|
||||
Ok(result) => {
|
||||
let end = result.bytes_transferred.min(gc.len());
|
||||
let date = if end > 12 {
|
||||
String::from_utf8_lossy(&gc[12..24.min(end)])
|
||||
.trim()
|
||||
.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
(date, gc[..end].to_vec())
|
||||
}
|
||||
Err(_) => (String::new(), Vec::new()),
|
||||
};
|
||||
|
||||
// GET CONFIGURATION Feature 0108h — Serial Number
|
||||
// GET CONFIGURATION Feature 0108h — Serial Number.
|
||||
// Best-effort, like 010Ch above: the serial-number feature is
|
||||
// optional, so a drive that lacks it (CHECK CONDITION) or reports
|
||||
// too few bytes deliberately yields an empty serial rather than
|
||||
// failing the identity probe.
|
||||
let mut gc_serial = vec![0u8; 256];
|
||||
let cdb_serial = [0x46, 0x02, 0x01, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00];
|
||||
let serial_number = if let Ok(r) =
|
||||
transport.execute(&cdb_serial, DataDirection::FromDevice, &mut gc_serial, 5000)
|
||||
{
|
||||
if r.bytes_transferred > 12 {
|
||||
String::from_utf8_lossy(&gc_serial[12..r.bytes_transferred])
|
||||
// `bytes_transferred` is device-reported and untrusted; clamp
|
||||
// the slice end to the buffer length to avoid an out-of-range
|
||||
// panic on an oversized reported count.
|
||||
let end = r.bytes_transferred.min(gc_serial.len());
|
||||
String::from_utf8_lossy(&gc_serial[12..end])
|
||||
.trim()
|
||||
.to_string()
|
||||
} else {
|
||||
@@ -94,8 +115,8 @@ impl DriveId {
|
||||
vendor_specific: ascii_field(&inquiry, 36, 43),
|
||||
firmware_date,
|
||||
serial_number,
|
||||
raw_inquiry: inquiry.to_vec(),
|
||||
raw_gc_010c: gc[..result.bytes_transferred].to_vec(),
|
||||
raw_inquiry: inquiry,
|
||||
raw_gc_010c,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -155,6 +176,49 @@ fn ascii_field(data: &[u8], start: usize, end: usize) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scsi::{ScsiResult, ScsiTransport};
|
||||
|
||||
/// Transport that returns the requested data length but reports a
|
||||
/// bytes_transferred larger than the caller's buffer — models a drive
|
||||
/// that lies about its transfer count. The old slicing code panicked
|
||||
/// on this; the clamps must keep it from indexing out of range.
|
||||
struct OversizedCountTransport;
|
||||
|
||||
impl ScsiTransport for OversizedCountTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
buf: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
// Fill plausible ASCII so the from_utf8_lossy paths run.
|
||||
for b in buf.iter_mut() {
|
||||
*b = b'A';
|
||||
}
|
||||
// INQUIRY (0x12): honest count. GET CONFIGURATION (0x46): lie.
|
||||
let bytes_transferred = if cdb.first() == Some(&0x12) {
|
||||
buf.len()
|
||||
} else {
|
||||
buf.len() + 4096
|
||||
};
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_drive_clamps_oversized_bytes_transferred() {
|
||||
// Must not panic despite the transport reporting a transfer count
|
||||
// far beyond the 256-byte GET CONFIGURATION buffers.
|
||||
let mut t = OversizedCountTransport;
|
||||
let id = DriveId::from_drive(&mut t).expect("from_drive must not error");
|
||||
// raw_gc_010c is clamped to the 256-byte buffer, never the lie.
|
||||
assert_eq!(id.raw_gc_010c.len(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bu40n_identity() {
|
||||
@@ -190,4 +254,135 @@ mod tests {
|
||||
assert_eq!(id.vendor_specific.trim(), "16/04/");
|
||||
assert_eq!(id.firmware_date, "201604250000");
|
||||
}
|
||||
|
||||
// ── New comprehensive tests ────────────────────────────────────────────────
|
||||
|
||||
/// ascii_field with a buffer shorter than `start` returns empty string
|
||||
/// rather than panicking.
|
||||
/// Spec: SPC-4 §6.4.2 — bytes[8:16] are vendor ID; a truncated buffer
|
||||
/// (e.g. a device that reports fewer than 8 bytes) must not panic.
|
||||
/// Mutation: removing the `data.len() > start` guard makes it panic on short inputs.
|
||||
#[test]
|
||||
fn ascii_field_short_buffer_returns_empty() {
|
||||
// Buffer of length 5: start=8 is beyond the end → empty string.
|
||||
let buf = vec![0u8; 5];
|
||||
let result = ascii_field(&buf, 8, 16); // SPC-4 vendor ID range
|
||||
assert!(result.is_empty(), "short buffer must yield empty string");
|
||||
}
|
||||
|
||||
/// ascii_field with a buffer that covers start but not end is clamped.
|
||||
/// Spec: `ascii_field` documents "clamps to data.len()".
|
||||
/// Mutation: using `end` directly without `min(data.len())` panics here.
|
||||
#[test]
|
||||
fn ascii_field_partial_buffer_is_clamped_not_panicked() {
|
||||
// Buffer of length 12: vendor_id range is [8..16], but only [8..12] present.
|
||||
let mut buf = vec![0u8; 12];
|
||||
buf[8..12].copy_from_slice(b"SONY");
|
||||
let result = ascii_field(&buf, 8, 16);
|
||||
// Must not panic; the returned string holds what we wrote.
|
||||
assert_eq!(result, "SONY");
|
||||
}
|
||||
|
||||
/// from_inquiry extracts the product_id field from INQUIRY bytes [16:32].
|
||||
/// Spec: SPC-4 §6.4.2 — PRODUCT IDENTIFICATION at offset 16, length 16.
|
||||
/// Mutation: shifting the product_id slice to [8:24] makes this fail.
|
||||
#[test]
|
||||
fn from_inquiry_extracts_product_id_at_offset_16() {
|
||||
let mut inquiry = vec![0u8; 96];
|
||||
// Leave vendor_id (8..16) as zeros, write product_id at 16..32.
|
||||
inquiry[16..32].copy_from_slice(b"BD-RW BDR-209M");
|
||||
let id = DriveId::from_inquiry(&inquiry, "");
|
||||
assert_eq!(
|
||||
id.product_id, "BD-RW BDR-209M",
|
||||
"product_id must come from INQUIRY bytes 16..32 (SPC-4 §6.4.2)"
|
||||
);
|
||||
}
|
||||
|
||||
/// from_inquiry extracts product_revision from INQUIRY bytes [32:36].
|
||||
/// Spec: SPC-4 §6.4.2 — PRODUCT REVISION LEVEL at offset 32, length 4.
|
||||
/// Mutation: reading revision from [36:40] produces the wrong value.
|
||||
#[test]
|
||||
fn from_inquiry_extracts_revision_at_offset_32() {
|
||||
let mut inquiry = vec![0u8; 96];
|
||||
inquiry[32..36].copy_from_slice(b"1.53");
|
||||
let id = DriveId::from_inquiry(&inquiry, "");
|
||||
assert_eq!(
|
||||
id.product_revision, "1.53",
|
||||
"product_revision must come from INQUIRY bytes 32..36 (SPC-4 §6.4.2)"
|
||||
);
|
||||
}
|
||||
|
||||
/// from_inquiry extracts vendor_specific from INQUIRY bytes [36:43].
|
||||
/// Spec: SPC-4 §6.4.2 — VENDOR SPECIFIC at offset 36, length 8.
|
||||
/// Mutation: reading vendor_specific from [32:39] returns the revision instead.
|
||||
#[test]
|
||||
fn from_inquiry_extracts_vendor_specific_at_offset_36() {
|
||||
let mut inquiry = vec![0u8; 96];
|
||||
inquiry[36..43].copy_from_slice(b"MM01234");
|
||||
let id = DriveId::from_inquiry(&inquiry, "");
|
||||
assert_eq!(
|
||||
id.vendor_specific, "MM01234",
|
||||
"vendor_specific must come from INQUIRY bytes 36..43 (SPC-4 §6.4.2)"
|
||||
);
|
||||
}
|
||||
|
||||
/// from_inquiry stores the raw inquiry bytes in raw_inquiry unchanged.
|
||||
/// Mutation: copying only a slice of inquiry into raw_inquiry truncates it.
|
||||
#[test]
|
||||
fn from_inquiry_stores_raw_inquiry() {
|
||||
let mut inquiry = vec![0u8; 96];
|
||||
inquiry[8..16].copy_from_slice(b"TESTDRVR");
|
||||
let id = DriveId::from_inquiry(&inquiry, "");
|
||||
assert_eq!(
|
||||
id.raw_inquiry, inquiry,
|
||||
"raw_inquiry must preserve the full 96-byte buffer"
|
||||
);
|
||||
}
|
||||
|
||||
/// GET CONFIGURATION failure (transport error) must not abort the
|
||||
/// identity probe — firmware_date is empty, raw_gc_010c is empty.
|
||||
/// Mutation: propagating the GET_CONFIGURATION error with `?` aborts from_drive.
|
||||
#[test]
|
||||
fn from_drive_gc_failure_yields_empty_firmware_date() {
|
||||
struct GcFailTransport;
|
||||
impl ScsiTransport for GcFailTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
buf: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
if cdb.first() == Some(&0x12) {
|
||||
// INQUIRY succeeds with a plausible response.
|
||||
buf[8..16].copy_from_slice(b"TESTDRV ");
|
||||
buf[16..32].copy_from_slice(b"FAKE DRIVE MODEL");
|
||||
buf[32..36].copy_from_slice(b"0001");
|
||||
buf[36..43].copy_from_slice(b"X000001");
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: buf.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
} else {
|
||||
// GET CONFIGURATION fails.
|
||||
Err(crate::error::Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut t = GcFailTransport;
|
||||
let id = DriveId::from_drive(&mut t).expect("from_drive must succeed despite GC failure");
|
||||
assert!(
|
||||
id.firmware_date.is_empty(),
|
||||
"firmware_date must be empty when GC fails"
|
||||
);
|
||||
assert!(
|
||||
id.raw_gc_010c.is_empty(),
|
||||
"raw_gc_010c must be empty when GC fails"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+942
-52
File diff suppressed because it is too large
Load Diff
+61
-4
@@ -67,10 +67,12 @@ pub(crate) enum BoundedError {
|
||||
/// The deadline elapsed before the syscall returned. Same leak
|
||||
/// semantics as `Halted`.
|
||||
Timeout,
|
||||
/// The worker thread panicked, or its sender disconnected before
|
||||
/// sending a result. Treat as a benign no-op (callers usually
|
||||
/// log and continue) rather than a hard error — by definition no
|
||||
/// syscall observably ran to completion in this case.
|
||||
/// The worker thread panicked, the OS rejected the thread spawn,
|
||||
/// or its sender disconnected before sending a result. Treat as a
|
||||
/// benign no-op (callers usually log and continue) rather than a
|
||||
/// hard error — by definition no syscall observably ran to
|
||||
/// completion in this case. In the spawn-failure case no thread is
|
||||
/// leaked.
|
||||
WorkerLost,
|
||||
}
|
||||
|
||||
@@ -101,6 +103,12 @@ where
|
||||
F: FnOnce() -> R + Send + 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
// If the caller already requested halt, don't spawn (and leak) a
|
||||
// worker that would run `op` to completion in the background.
|
||||
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||
return Err(BoundedError::Halted);
|
||||
}
|
||||
|
||||
// Rendezvous channel: the worker sends exactly one value (the
|
||||
// op's return) and then exits. Capacity-0 means the send blocks
|
||||
// until we receive — fine on the happy path; on the timeout /
|
||||
@@ -235,4 +243,53 @@ mod tests {
|
||||
assert!(matches!(r, Ok("ok")));
|
||||
assert!(flag.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
// ── Added hardening tests ───────────────────────────────────────
|
||||
|
||||
/// Doc contract (lines 106-110): "If the caller already requested
|
||||
/// halt, don't spawn (and leak) a worker that would run `op`."
|
||||
/// When halt is pre-cancelled the op closure must NEVER run — the
|
||||
/// short-circuit returns Halted before spawning the worker. We
|
||||
/// prove the op did not execute by checking a side-effect flag.
|
||||
#[test]
|
||||
fn pre_cancelled_halt_never_runs_op() {
|
||||
let halt = Halt::new();
|
||||
halt.cancel();
|
||||
let ran = Arc::new(AtomicBool::new(false));
|
||||
let r2 = ran.clone();
|
||||
let r = bounded_syscall(Some(&halt), Duration::from_secs(2), move || {
|
||||
r2.store(true, Ordering::SeqCst);
|
||||
7u32
|
||||
});
|
||||
assert!(matches!(r, Err(BoundedError::Halted)));
|
||||
// The op closure must not have been scheduled at all.
|
||||
assert!(
|
||||
!ran.load(Ordering::SeqCst),
|
||||
"op ran despite pre-cancelled halt — short-circuit at line 108 broken"
|
||||
);
|
||||
}
|
||||
|
||||
/// Timeout boundary: with a tiny deadline and an op that sleeps
|
||||
/// much longer, the helper must return Timeout and must do so
|
||||
/// roughly at the deadline — NOT wait for the op to finish (that
|
||||
/// is the whole point of the bounded wrapper; the worker is
|
||||
/// leaked). Grounds the `Instant::now() >= deadline` arm (line 141)
|
||||
/// and the leak contract (doc lines 84-88).
|
||||
#[test]
|
||||
fn timeout_returns_near_deadline_not_after_op() {
|
||||
let started = Instant::now();
|
||||
let r = bounded_syscall(None, Duration::from_millis(100), || {
|
||||
thread::sleep(Duration::from_secs(3));
|
||||
0u32
|
||||
});
|
||||
let elapsed = started.elapsed();
|
||||
assert!(matches!(r, Err(BoundedError::Timeout)));
|
||||
// Must bail near the 100ms deadline (one POLL_INTERVAL slack at
|
||||
// most), not after the 3s op. Allow generous CI slack but stay
|
||||
// well under the op's 3s sleep.
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(1500),
|
||||
"timeout did not return near deadline: {elapsed:?} (op should be leaked, not awaited)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
//! Byte-sized bounded producer/consumer channel.
|
||||
//!
|
||||
//! Wraps `std::sync::mpsc::sync_channel` with a byte-accounting
|
||||
//! `Mutex<usize> + Condvar` cap. Sender blocks (cooperatively) when
|
||||
//! `used_bytes + item.byte_size() > capacity_bytes`. Receiver
|
||||
//! decrements `used_bytes` when it takes the item.
|
||||
//!
|
||||
//! Why: the existing producer→consumer channel between `DiscStream`
|
||||
//! (PES producer) and `MuxSink` (PES consumer) is bounded by frame
|
||||
//! count. Frame sizes vary 100× between metadata and keyframes, so a
|
||||
//! count-based cap either starves on small frames or buffers far too
|
||||
//! much memory on big ones. Byte-sized accounting sizes the buffer for
|
||||
//! the worst-case input stall (NFS read p99 ≈ 1–2 s × ~15 MB/s peak
|
||||
//! compressed bitrate ≈ ~30 MB) directly.
|
||||
//!
|
||||
//! The underlying mpsc channel is created with a very large slot count
|
||||
//! so the byte cap (not the slot count) is the real backpressure. Slot
|
||||
//! count is only there to give the kernel a small chunk to wake on.
|
||||
//!
|
||||
//! See `freemkv-private/memory/project_buffering_architecture.md` §
|
||||
//! Pipeline channel — sizing.
|
||||
|
||||
use std::sync::mpsc::{Receiver as MpscReceiver, RecvError, SendError, SyncSender, sync_channel};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
/// Default byte cap for the muxer's input channel. Sized to hide a
|
||||
/// worst-case ~2 s NFS read refill at UHD peak compressed bitrate
|
||||
/// (~15 MB/s); 64 MiB gives headroom. Tweakable; not magic.
|
||||
pub const BYTE_CHANNEL_DEFAULT_CAPACITY: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// Slot capacity of the inner `sync_channel`. Large so the byte cap is
|
||||
/// the real backpressure mechanism — the mpsc slot count only exists
|
||||
/// to give the kernel a chunk to wake on. PES frames are typically
|
||||
/// ~700 B each, so 64 MiB ≈ 90 k frames; 200 k is comfortable headroom.
|
||||
const INNER_SLOT_CAPACITY: usize = 200_000;
|
||||
|
||||
/// Anything whose in-memory cost can be accounted by a single
|
||||
/// `usize`. Implement on the item type sent through [`Sender`].
|
||||
pub trait HasByteSize {
|
||||
/// Bytes this item contributes to the channel's used budget.
|
||||
/// Must be > 0 to make progress (a 0-byte item would never
|
||||
/// block the sender no matter the cap; see send_blocks_at_capacity
|
||||
/// test).
|
||||
fn byte_size(&self) -> usize;
|
||||
}
|
||||
|
||||
impl HasByteSize for crate::pes::PesFrame {
|
||||
fn byte_size(&self) -> usize {
|
||||
// Frame data + the fixed header overhead the serializer
|
||||
// writes (track + pts + keyframe + len). The `Vec<u8>` heap
|
||||
// allocation also has alloc-header overhead but that's
|
||||
// <0.1 % at typical frame sizes — folding it in would just
|
||||
// add noise to the budget.
|
||||
self.data.len() + 14
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared book-keeping between [`Sender`] and [`Receiver`]. Wrapped in
|
||||
/// an `Arc` because both halves hold it independently.
|
||||
struct Accounting {
|
||||
used: Mutex<usize>,
|
||||
cv: Condvar,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
/// Send half of the byte-bounded channel.
|
||||
///
|
||||
/// `send` blocks (on a `Condvar`) when adding the item would push
|
||||
/// `used_bytes` past `capacity_bytes`. Unblocks when the receiver
|
||||
/// `recv`s items out and notifies. Returns `Err(item)` if the
|
||||
/// receiver has been dropped — mirrors `mpsc::SyncSender::send`.
|
||||
pub struct Sender<T: HasByteSize> {
|
||||
tx: SyncSender<T>,
|
||||
acct: Arc<Accounting>,
|
||||
}
|
||||
|
||||
impl<T: HasByteSize> Clone for Sender<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Sender {
|
||||
tx: self.tx.clone(),
|
||||
acct: self.acct.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: HasByteSize> Sender<T> {
|
||||
/// Push one item. Blocks until adding it would not exceed the
|
||||
/// capacity, then sends through the inner mpsc channel.
|
||||
pub fn send(&self, item: T) -> Result<(), SendError<T>> {
|
||||
let sz = item.byte_size();
|
||||
// Reserve capacity first. The reservation is observable to
|
||||
// other senders via `used`; only after we win the slot do we
|
||||
// hand the item to the inner mpsc channel. That ordering means
|
||||
// `used` is always a conservative upper bound on what's in the
|
||||
// mpsc queue + about-to-be-sent.
|
||||
{
|
||||
let mut used = self.acct.used.lock().expect("byte_channel poisoned");
|
||||
// An item bigger than the whole capacity will never fit; let
|
||||
// it through anyway as a one-shot reservation, otherwise the
|
||||
// sender deadlocks forever waiting for `used == 0` AND
|
||||
// nothing in flight. The receiver will drain it on the
|
||||
// other side. Same behaviour as `std::sync::mpsc` for
|
||||
// arbitrarily large messages.
|
||||
while *used + sz > self.acct.capacity && *used > 0 {
|
||||
used = self.acct.cv.wait(used).expect("byte_channel cv poisoned");
|
||||
}
|
||||
*used += sz;
|
||||
}
|
||||
match self.tx.send(item) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(SendError(returned)) => {
|
||||
// Receiver dropped — refund the reservation so a later
|
||||
// sender on a clone doesn't observe phantom used bytes
|
||||
// (the receiver is gone so nobody will decrement).
|
||||
let mut used = self.acct.used.lock().expect("byte_channel poisoned");
|
||||
*used = used.saturating_sub(sz);
|
||||
self.acct.cv.notify_all();
|
||||
Err(SendError(returned))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive half of the byte-bounded channel.
|
||||
///
|
||||
/// `recv` blocks on the inner mpsc until an item is available, then
|
||||
/// decrements the byte-accounting and wakes any sender waiting on
|
||||
/// capacity.
|
||||
pub struct Receiver<T: HasByteSize> {
|
||||
rx: MpscReceiver<T>,
|
||||
acct: Arc<Accounting>,
|
||||
}
|
||||
|
||||
impl<T: HasByteSize> Receiver<T> {
|
||||
/// Take the next item. Returns `Err(RecvError)` when all senders
|
||||
/// have been dropped and the channel is empty.
|
||||
pub fn recv(&self) -> Result<T, RecvError> {
|
||||
let item = self.rx.recv()?;
|
||||
let sz = item.byte_size();
|
||||
let mut used = self.acct.used.lock().expect("byte_channel poisoned");
|
||||
*used = used.saturating_sub(sz);
|
||||
// Notify all so multi-sender setups wake every blocked sender,
|
||||
// not just one. Wasted wakeups are cheap; missed wakeups would
|
||||
// be a deadlock.
|
||||
self.acct.cv.notify_all();
|
||||
Ok(item)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a byte-bounded channel with the given capacity in bytes.
|
||||
/// Returns a `(Sender, Receiver)` pair; clone the `Sender` for
|
||||
/// multi-producer setups.
|
||||
pub fn channel<T: HasByteSize>(capacity_bytes: usize) -> (Sender<T>, Receiver<T>) {
|
||||
let (tx, rx) = sync_channel::<T>(INNER_SLOT_CAPACITY);
|
||||
let acct = Arc::new(Accounting {
|
||||
used: Mutex::new(0),
|
||||
cv: Condvar::new(),
|
||||
capacity: capacity_bytes,
|
||||
});
|
||||
(
|
||||
Sender {
|
||||
tx,
|
||||
acct: acct.clone(),
|
||||
},
|
||||
Receiver { rx, acct },
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Test payload — its `byte_size` returns whatever we passed at
|
||||
/// construction so capacity math is exact and predictable.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct Item {
|
||||
sz: usize,
|
||||
tag: u32,
|
||||
}
|
||||
|
||||
impl HasByteSize for Item {
|
||||
fn byte_size(&self) -> usize {
|
||||
self.sz
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_recv_round_trip() {
|
||||
let (tx, rx) = channel::<Item>(1024);
|
||||
for i in 0..5 {
|
||||
tx.send(Item { sz: 100, tag: i }).unwrap();
|
||||
}
|
||||
for i in 0..5 {
|
||||
let got = rx.recv().unwrap();
|
||||
assert_eq!(got, Item { sz: 100, tag: i });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_accounting_decrements_on_recv() {
|
||||
// Internal book-keeping check via observable side-effect: after
|
||||
// sending K items totalling N bytes and receiving them all, a
|
||||
// subsequent send of an N-byte item must NOT block (no items
|
||||
// in flight, all capacity refunded).
|
||||
let (tx, rx) = channel::<Item>(1024);
|
||||
for _ in 0..4 {
|
||||
tx.send(Item { sz: 256, tag: 0 }).unwrap();
|
||||
}
|
||||
for _ in 0..4 {
|
||||
rx.recv().unwrap();
|
||||
}
|
||||
// Cap is now fully available again. Send a 1024-byte item; the
|
||||
// `used > 0` guard means it goes through alone (no wait).
|
||||
let start = Instant::now();
|
||||
tx.send(Item { sz: 1024, tag: 99 }).unwrap();
|
||||
assert!(start.elapsed() < Duration::from_millis(100));
|
||||
let got = rx.recv().unwrap();
|
||||
assert_eq!(got.tag, 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_blocks_at_capacity_unblocks_on_recv() {
|
||||
// Cap = 200 bytes, item = 100 bytes. First two sends fit
|
||||
// exactly; the third must block until a recv frees capacity.
|
||||
let (tx, rx) = channel::<Item>(200);
|
||||
tx.send(Item { sz: 100, tag: 0 }).unwrap();
|
||||
tx.send(Item { sz: 100, tag: 1 }).unwrap();
|
||||
|
||||
let tx2 = tx.clone();
|
||||
let sent_at = Arc::new(Mutex::new(None::<Instant>));
|
||||
let sent_at2 = sent_at.clone();
|
||||
let h = thread::spawn(move || {
|
||||
tx2.send(Item { sz: 100, tag: 2 }).unwrap();
|
||||
*sent_at2.lock().unwrap() = Some(Instant::now());
|
||||
});
|
||||
|
||||
// Give the sender thread a head start; it should be parked in
|
||||
// `cv.wait` because used (200) + 100 > capacity (200).
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
assert!(
|
||||
sent_at.lock().unwrap().is_none(),
|
||||
"third send should be blocked at capacity"
|
||||
);
|
||||
|
||||
// Drain one. Sender wakes and completes.
|
||||
let recv_at = Instant::now();
|
||||
let got = rx.recv().unwrap();
|
||||
assert_eq!(got.tag, 0);
|
||||
h.join().unwrap();
|
||||
|
||||
let sent_when = sent_at.lock().unwrap().unwrap();
|
||||
assert!(
|
||||
sent_when >= recv_at,
|
||||
"sender must complete AFTER receiver freed capacity"
|
||||
);
|
||||
|
||||
// Drain the remaining two.
|
||||
assert_eq!(rx.recv().unwrap().tag, 1);
|
||||
assert_eq!(rx.recv().unwrap().tag, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_larger_than_capacity_still_goes_through() {
|
||||
// Pathological case: a single item bigger than the capacity.
|
||||
// The guard `*used > 0` lets it through when the channel is
|
||||
// empty (otherwise the sender deadlocks forever). Matches
|
||||
// `mpsc::SyncSender` semantics for oversize messages.
|
||||
let (tx, rx) = channel::<Item>(100);
|
||||
tx.send(Item { sz: 1000, tag: 7 }).unwrap();
|
||||
let got = rx.recv().unwrap();
|
||||
assert_eq!(got, Item { sz: 1000, tag: 7 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_send_recv_stress() {
|
||||
// 4 sender threads × 1k items each, 1 receiver. Verify byte
|
||||
// accounting stays sane (channel never deadlocks, every item
|
||||
// arrives exactly once) under contention.
|
||||
const SENDERS: u32 = 4;
|
||||
const PER_SENDER: u32 = 1000;
|
||||
const TOTAL: u32 = SENDERS * PER_SENDER;
|
||||
|
||||
let (tx, rx) = channel::<Item>(8 * 1024);
|
||||
let sent = Arc::new(AtomicUsize::new(0));
|
||||
let mut handles = Vec::new();
|
||||
for s in 0..SENDERS {
|
||||
let tx = tx.clone();
|
||||
let sent = sent.clone();
|
||||
handles.push(thread::spawn(move || {
|
||||
for i in 0..PER_SENDER {
|
||||
// Vary item size so accounting actually has to
|
||||
// multiplex differently-sized blockers. 1B → 256B.
|
||||
let sz = 1 + ((i as usize) % 256);
|
||||
tx.send(Item {
|
||||
sz,
|
||||
tag: s * PER_SENDER + i,
|
||||
})
|
||||
.unwrap();
|
||||
sent.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}));
|
||||
}
|
||||
// Drop our local sender so the receiver can eventually see
|
||||
// RecvError once all sender clones are done. Cloning the
|
||||
// sender into each producer means each clone Drop'd separately.
|
||||
drop(tx);
|
||||
|
||||
let mut received = 0u32;
|
||||
while let Ok(_item) = rx.recv() {
|
||||
received += 1;
|
||||
}
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
assert_eq!(received, TOTAL);
|
||||
assert_eq!(sent.load(Ordering::SeqCst) as u32, TOTAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_after_recv_dropped_returns_err() {
|
||||
let (tx, rx) = channel::<Item>(1024);
|
||||
drop(rx);
|
||||
let r = tx.send(Item { sz: 10, tag: 0 });
|
||||
assert!(r.is_err());
|
||||
}
|
||||
}
|
||||
+439
-26
@@ -10,11 +10,12 @@
|
||||
//!
|
||||
//! This is the byte-stream half of the freemkv mux highway —
|
||||
//! `BytePrefetcher` feeds [`crate::mux::demux_thread::DemuxThread`]
|
||||
//! for `m2ts://`, `network://`, `stdio://`, and any other stream
|
||||
//! whose source is an `io::Read` rather than a `SectorSource`.
|
||||
//! for `m2ts://` (the only in-tree caller today, via
|
||||
//! [`crate::mux::resolve`]), and works for any stream whose source is
|
||||
//! an `io::Read` rather than a `SectorSource`.
|
||||
|
||||
use crate::halt::Halt;
|
||||
use crossbeam_channel::{Receiver, Sender, bounded};
|
||||
use crate::halt::{Halt, POLL_INTERVAL};
|
||||
use crossbeam_channel::{Receiver, RecvTimeoutError, SendTimeoutError, Sender, bounded};
|
||||
use std::io::Read;
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
@@ -38,6 +39,13 @@ pub const DEFAULT_CHUNK_BYTES: usize = 16 * 1024 * 1024;
|
||||
/// Returned from [`BytePrefetcher::into_channels`]. Owns the
|
||||
/// producer-thread join handle so dropping the shell joins the
|
||||
/// producer.
|
||||
///
|
||||
/// Drop blocks the calling thread until the producer exits. To
|
||||
/// guarantee a prompt exit, drop the forward receiver and the recycle
|
||||
/// sender first so the producer observes channel disconnection (or
|
||||
/// cancel the [`Halt`] passed to [`BytePrefetcher::new`], which the
|
||||
/// producer polls at [`POLL_INTERVAL`] granularity even while parked
|
||||
/// on a channel op).
|
||||
pub struct PrefetchShell {
|
||||
producer: Option<JoinHandle<()>>,
|
||||
}
|
||||
@@ -52,8 +60,8 @@ impl Drop for PrefetchShell {
|
||||
|
||||
/// Spawned byte prefetcher. Drop joins the producer thread.
|
||||
pub struct BytePrefetcher {
|
||||
rx: Receiver<Batch>,
|
||||
recycle_tx: Sender<Vec<u8>>,
|
||||
rx: Option<Receiver<Batch>>,
|
||||
recycle_tx: Option<Sender<Vec<u8>>>,
|
||||
producer: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
@@ -66,7 +74,13 @@ impl BytePrefetcher {
|
||||
mut reader: R,
|
||||
chunk_bytes: usize,
|
||||
halt: Option<Halt>,
|
||||
) -> Self {
|
||||
) -> std::io::Result<Self> {
|
||||
// A zero-length chunk makes every recycled buffer an empty
|
||||
// slice; `reader.read(&mut [])` returns Ok(0), which the loop
|
||||
// below treats as EOF — the consumer would see a clean,
|
||||
// silent zero-byte stream. Callers pass the downstream
|
||||
// demuxer's batch size, which is always > 0.
|
||||
debug_assert!(chunk_bytes > 0, "BytePrefetcher chunk_bytes must be > 0");
|
||||
let (tx, rx) = bounded::<Batch>(FORWARD_DEPTH);
|
||||
let (recycle_tx, recycle_rx) = bounded::<Vec<u8>>(RECYCLE_DEPTH);
|
||||
|
||||
@@ -80,16 +94,49 @@ impl BytePrefetcher {
|
||||
let producer = std::thread::Builder::new()
|
||||
.name("freemkv-byte-prefetch".into())
|
||||
.spawn(move || {
|
||||
// Wrap the feed loop in catch_unwind so a panic in the inner
|
||||
// `reader.read` (e.g. a decrypt-on-read slice/arith bug) is NOT
|
||||
// indistinguishable from a clean finish at the demux boundary. A
|
||||
// clean exit (EOF, halt, consumer disconnect) returns and drops
|
||||
// `tx` → the demux loop reads RecvError as EOF (correct). A PANIC
|
||||
// sends an explicit error sentinel first so the demux loop's
|
||||
// `Ok(Err(_))` arm fires and propagates a typed error instead of
|
||||
// converting the dropped channel into a clean `DemuxBatch::Eof`
|
||||
// that would finalize a TRUNCATED mux while reporting success.
|
||||
let body = std::panic::AssertUnwindSafe(|| {
|
||||
let cancelled = || halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false);
|
||||
// Liveness heartbeat: the producer blocks on the recycle and
|
||||
// forward channels; a stalled consumer or a wedged reader shows
|
||||
// up as the beat going silent. Total is unknown, so `pos` is
|
||||
// cumulative bytes read.
|
||||
let mut hb = crate::progress::Heartbeat::new("byte_prefetch");
|
||||
let mut produced_bytes: u64 = 0;
|
||||
loop {
|
||||
if halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false) {
|
||||
hb.tick(produced_bytes, 0);
|
||||
if cancelled() {
|
||||
return;
|
||||
}
|
||||
let mut buf = match recycle_rx.recv() {
|
||||
Ok(b) => b,
|
||||
Err(_) => return, // consumer dropped both channels
|
||||
// Park on the recycle channel, but re-poll halt
|
||||
// every POLL_INTERVAL: a pure-AtomicBool Halt does
|
||||
// not disconnect the channel, so a blocking recv()
|
||||
// would never re-reach the cancel check.
|
||||
let mut buf = loop {
|
||||
match recycle_rx.recv_timeout(POLL_INTERVAL) {
|
||||
Ok(b) => break b,
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if cancelled() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Consumer dropped both channels.
|
||||
Err(RecvTimeoutError::Disconnected) => return,
|
||||
}
|
||||
};
|
||||
// Re-expose the full extent (previous iteration
|
||||
// may have truncated after a short read).
|
||||
// Re-expose the full extent. After a short read the
|
||||
// prior iteration truncated to n < chunk_bytes, so
|
||||
// this regrows the length back to chunk_bytes
|
||||
// without reallocating (capacity was fixed at
|
||||
// construction and never shrinks).
|
||||
if buf.len() < chunk_bytes {
|
||||
buf.resize(chunk_bytes, 0);
|
||||
} else {
|
||||
@@ -108,19 +155,40 @@ impl BytePrefetcher {
|
||||
return;
|
||||
}
|
||||
};
|
||||
produced_bytes += n as u64;
|
||||
buf.truncate(n);
|
||||
if tx.send(Ok(buf)).is_err() {
|
||||
return; // consumer dropped
|
||||
// Hand off the filled buffer, re-polling halt on
|
||||
// each timeout slice so a cancel can interrupt a
|
||||
// producer parked on a saturated forward channel.
|
||||
let mut pending = Ok(buf);
|
||||
loop {
|
||||
match tx.send_timeout(pending, POLL_INTERVAL) {
|
||||
Ok(()) => break,
|
||||
Err(SendTimeoutError::Timeout(returned)) => {
|
||||
if cancelled() {
|
||||
return;
|
||||
}
|
||||
pending = returned;
|
||||
}
|
||||
// Consumer dropped.
|
||||
Err(SendTimeoutError::Disconnected(_)) => return,
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("freemkv-byte-prefetch thread spawn failed");
|
||||
}
|
||||
});
|
||||
if std::panic::catch_unwind(body).is_err() {
|
||||
// Producer panicked mid-stream — surface a typed terminal
|
||||
// error so the demux thread does NOT read the dropped channel
|
||||
// as a clean EOF and truncate output.
|
||||
let _ = tx.send(Err(crate::error::Error::DemuxThreadPanicked.into()));
|
||||
}
|
||||
})?;
|
||||
|
||||
Self {
|
||||
rx,
|
||||
recycle_tx,
|
||||
Ok(Self {
|
||||
rx: Some(rx),
|
||||
recycle_tx: Some(recycle_tx),
|
||||
producer: Some(producer),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Peel off the channels for zero-copy pipeline consumption. The
|
||||
@@ -128,19 +196,364 @@ impl BytePrefetcher {
|
||||
/// drains `rx`, runs the demuxer in place on each filled buffer,
|
||||
/// and recycles back through `recycle_tx`.
|
||||
pub fn into_channels(self) -> (Receiver<Batch>, Sender<Vec<u8>>, PrefetchShell) {
|
||||
let mut me = self;
|
||||
let producer = me.producer.take();
|
||||
let rx = me.rx.clone();
|
||||
let recycle = me.recycle_tx.clone();
|
||||
std::mem::forget(me);
|
||||
// MOVE the three fields out cleanly — never clone. Each of
|
||||
// `rx` and `recycle_tx` ends up with exactly ONE live copy:
|
||||
// the one in the returned tuple. The pre-1.0.0 implementation
|
||||
// cloned both and then `mem::forget`-ed `self`, leaking the
|
||||
// originals so an extra live receiver + sender survived
|
||||
// forever. That defeated the channel-disconnection shutdown:
|
||||
// when the demux consumer exited early (halt, or a `tx.send`
|
||||
// error in `demux_thread`), the producer's `recycle_rx.recv()`
|
||||
// and `tx.send()` never saw all-peers-dropped, so the producer
|
||||
// never returned and `PrefetchShell::drop`'s `join()` hung.
|
||||
//
|
||||
// `ManuallyDrop` + `ptr::read` reads each field out by value
|
||||
// and suppresses `self`'s own `Drop` (which would otherwise
|
||||
// double-`join`), leaving NO extra live endpoint behind. This
|
||||
// is the panic-free equivalent of the `Option::take` approach
|
||||
// and mirrors `sector::prefetched::into_channels`.
|
||||
let me = std::mem::ManuallyDrop::new(self);
|
||||
// SAFETY: `me` is `ManuallyDrop`, so none of these fields will
|
||||
// be dropped by `me`. Each `ptr::read` performs exactly one
|
||||
// bitwise move out; every field is read exactly once and never
|
||||
// touched again, so there are no double-frees and no aliasing.
|
||||
let producer = unsafe { std::ptr::read(&me.producer) };
|
||||
// SAFETY: `rx` and `recycle_tx` are always `Some` here —
|
||||
// `into_channels` is the only way to consume a live
|
||||
// `BytePrefetcher`; `Drop::drop` is suppressed by `ManuallyDrop`.
|
||||
let rx = unsafe { std::ptr::read(&me.rx) }.expect("rx always Some before drop");
|
||||
let recycle =
|
||||
unsafe { std::ptr::read(&me.recycle_tx) }.expect("recycle_tx always Some before drop");
|
||||
(rx, recycle, PrefetchShell { producer })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BytePrefetcher {
|
||||
fn drop(&mut self) {
|
||||
// Drop channel endpoints BEFORE joining the producer so the
|
||||
// producer observes SendTimeoutError::Disconnected (forward tx)
|
||||
// or RecvTimeoutError::Disconnected (recycle rx) and exits
|
||||
// promptly. Without this, a non-EOF source fills the depth-2
|
||||
// forward channel and then spins in send_timeout(POLL_INTERVAL)
|
||||
// forever because rx is never drained, causing join() to
|
||||
// deadlock.
|
||||
drop(self.rx.take());
|
||||
drop(self.recycle_tx.take());
|
||||
if let Some(h) = self.producer.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Endless reader: every `read` fills the whole buffer and never
|
||||
/// hits EOF, so the producer keeps trying to push batches forward
|
||||
/// until the forward channel disconnects. Exactly the shape that
|
||||
/// wedged the pre-1.0.0 `clone + mem::forget` `into_channels`.
|
||||
struct EndlessReader;
|
||||
impl Read for EndlessReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
buf.fill(0);
|
||||
Ok(buf.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` on a helper thread and fail if it does not finish within
|
||||
/// `secs`. Turns a join-deadlock into a test failure instead of a
|
||||
/// hung CI run.
|
||||
fn within<F: FnOnce() + Send + 'static>(secs: u64, f: F) {
|
||||
let (done_tx, done_rx) = bounded::<()>(1);
|
||||
std::thread::spawn(move || {
|
||||
f();
|
||||
let _ = done_tx.send(());
|
||||
});
|
||||
assert!(
|
||||
done_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(secs))
|
||||
.is_ok(),
|
||||
"operation did not complete within {secs}s (deadlock)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The CRITICAL regression: after `into_channels`, dropping the
|
||||
/// returned forward receiver + recycle sender must let the producer
|
||||
/// observe disconnection and exit, so dropping the `PrefetchShell`
|
||||
/// (which joins the producer) returns promptly. With the old
|
||||
/// clone+forget the leaked endpoints kept the producer blocked and
|
||||
/// this join hung forever.
|
||||
#[test]
|
||||
fn into_channels_drop_releases_producer() {
|
||||
within(10, || {
|
||||
// Small chunk so the producer cycles quickly and fills the
|
||||
// forward channel without allocating much.
|
||||
let pf = BytePrefetcher::new(EndlessReader, 4096, None).expect("spawn");
|
||||
let (rx, recycle_tx, shell) = pf.into_channels();
|
||||
// Consumer goes away early (halt / abort analogue): drop
|
||||
// both channel endpoints without draining to EOF.
|
||||
drop(rx);
|
||||
drop(recycle_tx);
|
||||
// Joining the producer must not hang.
|
||||
drop(shell);
|
||||
});
|
||||
}
|
||||
|
||||
/// Same property via the halt path: cancel the token, then the
|
||||
/// producer must exit and the shell join must complete.
|
||||
#[test]
|
||||
fn halt_releases_producer() {
|
||||
within(10, || {
|
||||
let halt = Halt::new();
|
||||
let pf = BytePrefetcher::new(EndlessReader, 4096, Some(halt.clone())).expect("spawn");
|
||||
let (_rx, _recycle_tx, shell) = pf.into_channels();
|
||||
halt.cancel();
|
||||
drop(shell);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Added hardening tests ───────────────────────────────────────
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Drain the forward channel, recycling every buffer, and
|
||||
/// reassemble the bytes. Returns the concatenation of every
|
||||
/// delivered chunk. Stops on RecvError (producer dropped tx == EOF)
|
||||
/// or on the first Err batch (which it returns separately).
|
||||
fn drain_to_vec(pf: BytePrefetcher) -> (Vec<u8>, Option<std::io::Error>) {
|
||||
let (rx, recycle_tx, shell) = pf.into_channels();
|
||||
let mut out = Vec::new();
|
||||
let mut err = None;
|
||||
while let Ok(batch) = rx.recv() {
|
||||
match batch {
|
||||
Ok(buf) => {
|
||||
out.extend_from_slice(&buf);
|
||||
// Recycle so the producer can refill. Ignore send
|
||||
// error (producer may have already exited at EOF).
|
||||
let _ = recycle_tx.send(buf);
|
||||
}
|
||||
Err(e) => {
|
||||
err = Some(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(rx);
|
||||
drop(recycle_tx);
|
||||
drop(shell);
|
||||
(out, err)
|
||||
}
|
||||
|
||||
/// CORE CONTRACT: the prefetcher must deliver every source byte,
|
||||
/// in order, exactly once — never silently truncate or duplicate.
|
||||
/// Source is 5000 bytes; chunk size 1024 forces multiple chunks
|
||||
/// (4 full + 1 short of 904). The reassembled stream must equal the
|
||||
/// source. Mutation: replacing `buf.truncate(n)` (line 141) with a
|
||||
/// no-op would over-report bytes on the final short read and this
|
||||
/// fails.
|
||||
#[test]
|
||||
fn delivers_all_bytes_in_order_across_chunks() {
|
||||
within(10, || {
|
||||
let src: Vec<u8> = (0..5000u32).map(|i| (i & 0xff) as u8).collect();
|
||||
let pf = BytePrefetcher::new(Cursor::new(src.clone()), 1024, None).expect("spawn");
|
||||
let (got, err) = drain_to_vec(pf);
|
||||
assert!(err.is_none(), "unexpected error batch: {err:?}");
|
||||
assert_eq!(got, src, "prefetcher truncated or reordered bytes");
|
||||
});
|
||||
}
|
||||
|
||||
/// Short-read truncation: a reader that returns fewer bytes than
|
||||
/// requested per call must NOT leave stale tail bytes in the
|
||||
/// delivered chunk. Cursor over 10 bytes with a 4096 chunk yields a
|
||||
/// single 10-byte chunk; the consumer must see exactly 10 bytes,
|
||||
/// not 4096. Grounds `buf.truncate(n)` at line 141. Mutation:
|
||||
/// delete the truncate and the chunk would carry 4086 zero bytes of
|
||||
/// padding, failing the length assert.
|
||||
#[test]
|
||||
fn short_read_truncates_to_actual_length() {
|
||||
within(10, || {
|
||||
let src = vec![0xAB; 10];
|
||||
let pf = BytePrefetcher::new(Cursor::new(src.clone()), 4096, None).expect("spawn");
|
||||
let (got, err) = drain_to_vec(pf);
|
||||
assert!(err.is_none());
|
||||
assert_eq!(got.len(), 10, "delivered chunk padded past actual read");
|
||||
assert_eq!(got, src);
|
||||
});
|
||||
}
|
||||
|
||||
/// EOF semantics: an empty source (Cursor over `[]`) yields
|
||||
/// `read() == Ok(0)` on the first call, which the producer treats
|
||||
/// as EOF and returns, dropping tx. The consumer sees RecvError
|
||||
/// (zero batches), NOT an Err batch and NOT a zero-length Ok batch.
|
||||
/// Grounds the `Ok(0) => return` arm at line 134. Mutation:
|
||||
/// changing `Ok(0) => return` to `Ok(0) => continue` would spin
|
||||
/// forever (within() would time out).
|
||||
#[test]
|
||||
fn empty_source_yields_clean_eof_no_batches() {
|
||||
within(10, || {
|
||||
let pf = BytePrefetcher::new(Cursor::new(Vec::<u8>::new()), 4096, None).expect("spawn");
|
||||
let (rx, recycle_tx, shell) = pf.into_channels();
|
||||
// No Ok batch should ever arrive; first recv must be Err
|
||||
// (producer dropped tx at EOF).
|
||||
let first = rx.recv();
|
||||
assert!(
|
||||
first.is_err(),
|
||||
"empty source produced a batch instead of clean EOF: {first:?}"
|
||||
);
|
||||
drop(rx);
|
||||
drop(recycle_tx);
|
||||
drop(shell);
|
||||
});
|
||||
}
|
||||
|
||||
/// Error propagation: a reader that fails mid-stream must surface
|
||||
/// the io::Error as an `Err` batch on the forward channel (line
|
||||
/// 137), not swallow it. We deliver one good chunk then an error.
|
||||
/// The consumer must see the good bytes followed by the error.
|
||||
/// Mutation: changing `let _ = tx.send(Err(e)); return;` to a plain
|
||||
/// `return` would drop the error silently and this fails.
|
||||
#[test]
|
||||
fn read_error_is_propagated_as_err_batch() {
|
||||
within(10, || {
|
||||
struct OneThenError {
|
||||
served: bool,
|
||||
}
|
||||
impl Read for OneThenError {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
if !self.served {
|
||||
self.served = true;
|
||||
let n = buf.len().min(8);
|
||||
buf[..n].fill(0x11);
|
||||
Ok(n)
|
||||
} else {
|
||||
Err(std::io::Error::other("synthetic mid-stream read failure"))
|
||||
}
|
||||
}
|
||||
}
|
||||
let pf = BytePrefetcher::new(OneThenError { served: false }, 8, None).expect("spawn");
|
||||
let (got, err) = drain_to_vec(pf);
|
||||
assert_eq!(got, vec![0x11; 8], "good chunk lost");
|
||||
let err = err.expect("read error must surface as an Err batch");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::Other);
|
||||
});
|
||||
}
|
||||
|
||||
/// PANIC propagation: a reader that PANICS mid-stream must NOT be read as a
|
||||
/// clean EOF at the demux boundary. The producer's catch_unwind sends an
|
||||
/// explicit `Err` sentinel before the thread unwinds, so the consumer sees
|
||||
/// the good bytes followed by an error batch — never a silent truncation.
|
||||
/// Without the catch_unwind the panic would just drop `tx`, the consumer
|
||||
/// would see RecvError (== clean EOF) and the partial output would be
|
||||
/// finalized as if complete.
|
||||
#[test]
|
||||
fn read_panic_surfaces_as_err_batch_not_clean_eof() {
|
||||
within(10, || {
|
||||
struct OneThenPanic {
|
||||
served: bool,
|
||||
}
|
||||
impl Read for OneThenPanic {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
if !self.served {
|
||||
self.served = true;
|
||||
let n = buf.len().min(8);
|
||||
buf[..n].fill(0x22);
|
||||
Ok(n)
|
||||
} else {
|
||||
panic!("synthetic mid-stream reader panic");
|
||||
}
|
||||
}
|
||||
}
|
||||
let pf = BytePrefetcher::new(OneThenPanic { served: false }, 8, None).expect("spawn");
|
||||
let (got, err) = drain_to_vec(pf);
|
||||
assert_eq!(got, vec![0x22; 8], "good chunk lost before the panic");
|
||||
assert!(
|
||||
err.is_some(),
|
||||
"a mid-stream producer PANIC must surface as an Err batch, \
|
||||
not a clean EOF (which would silently truncate the mux)"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Recycle-buffer reuse must NOT leak stale bytes between chunks of
|
||||
/// different lengths. After a full chunk, a short read reuses the
|
||||
/// same recycled buffer; lines 123-129 regrow it to chunk_bytes
|
||||
/// before reading, then line 141 truncates to the short count. We
|
||||
/// verify the short chunk carries only fresh bytes by reassembling
|
||||
/// the full stream. Source: 8 bytes of 0xAA + 3 bytes of 0xBB, with
|
||||
/// chunk_bytes=8 → chunk0 = 8×0xAA, chunk1 = 3×0xBB.
|
||||
#[test]
|
||||
fn recycled_buffer_carries_no_stale_tail() {
|
||||
within(10, || {
|
||||
let mut src = vec![0xAA; 8];
|
||||
src.extend_from_slice(&[0xBB; 3]);
|
||||
let pf = BytePrefetcher::new(Cursor::new(src.clone()), 8, None).expect("spawn");
|
||||
let (got, err) = drain_to_vec(pf);
|
||||
assert!(err.is_none());
|
||||
assert_eq!(
|
||||
got, src,
|
||||
"stale bytes from recycled buffer leaked into short chunk"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Exact-multiple boundary: when the source length is an exact
|
||||
/// multiple of chunk_bytes, the final non-empty chunk is followed
|
||||
/// by an `Ok(0)` EOF read, NOT a spurious empty Ok batch. 12 bytes
|
||||
/// with chunk_bytes=4 → three 4-byte chunks then clean EOF. Total
|
||||
/// bytes must equal 12 and no zero-length batch may appear.
|
||||
#[test]
|
||||
fn exact_multiple_length_no_trailing_empty_batch() {
|
||||
within(10, || {
|
||||
let src = vec![0x42u8; 12];
|
||||
let pf = BytePrefetcher::new(Cursor::new(src.clone()), 4, None).expect("spawn");
|
||||
let (rx, recycle_tx, shell) = pf.into_channels();
|
||||
let mut total = 0usize;
|
||||
let mut batch_count = 0usize;
|
||||
while let Ok(Ok(buf)) = rx.recv() {
|
||||
assert!(!buf.is_empty(), "producer emitted a zero-length batch");
|
||||
total += buf.len();
|
||||
batch_count += 1;
|
||||
let _ = recycle_tx.send(buf);
|
||||
}
|
||||
assert_eq!(total, 12);
|
||||
assert_eq!(batch_count, 3, "expected exactly 3 full chunks");
|
||||
drop(rx);
|
||||
drop(recycle_tx);
|
||||
drop(shell);
|
||||
});
|
||||
}
|
||||
|
||||
/// Dropping the BytePrefetcher directly (without into_channels)
|
||||
/// must join the producer cleanly when the source is finite. The
|
||||
/// producer reaches EOF, drops tx, and exits; Drop's join returns.
|
||||
/// Grounds the BytePrefetcher Drop impl (lines 202-208). Mutation:
|
||||
/// removing the `Ok(0) => return` EOF exit would hang this join.
|
||||
#[test]
|
||||
fn drop_finite_prefetcher_joins_cleanly() {
|
||||
within(10, || {
|
||||
let pf = BytePrefetcher::new(Cursor::new(vec![1u8; 100]), 4096, None).expect("spawn");
|
||||
// Drop without consuming — producer fills the forward
|
||||
// channel (capacity 2), reaches EOF on the third read since
|
||||
// 100 < 4096 (single chunk + EOF), drops tx, exits.
|
||||
drop(pf);
|
||||
});
|
||||
}
|
||||
|
||||
/// Regression: dropping a BytePrefetcher directly (without
|
||||
/// into_channels) with an ENDLESS source must not deadlock. Before
|
||||
/// the fix, Drop joined the producer while rx/recycle_tx were still
|
||||
/// alive (sibling field drop order), so the producer filled the
|
||||
/// depth-2 forward channel and then spun in send_timeout forever
|
||||
/// (rx never drained, halt=None). The fix drops rx+recycle_tx
|
||||
/// BEFORE the join so the producer sees SendTimeoutError::Disconnected
|
||||
/// and exits.
|
||||
#[test]
|
||||
fn drop_endless_prefetcher_joins_cleanly() {
|
||||
within(10, || {
|
||||
let pf = BytePrefetcher::new(EndlessReader, 4096, None).expect("spawn");
|
||||
// Drop without consuming — the old Drop deadlocked here.
|
||||
drop(pf);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,32 +8,22 @@
|
||||
use std::fs::File;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
/// `F_RDADVISE` opcode — not in libc's named constants on all SDKs.
|
||||
const F_RDADVISE: libc::c_int = 44;
|
||||
|
||||
/// Cap on the byte length we pass to `F_RDADVISE`. Asking for a
|
||||
/// multi-GB readahead window is counterproductive — the OS doesn't
|
||||
/// have that much cache to throw at one fd. 64 MiB is generous for
|
||||
/// our use case (sweep, mux) and matches the byte-channel cap so the
|
||||
/// kernel's prefetch ≥ our app-level pipeline depth.
|
||||
/// our use case (sweep, mux) so the kernel's prefetch ≥ our app-level
|
||||
/// pipeline depth.
|
||||
const RDADVISE_MAX_BYTES: i64 = 64 * 1024 * 1024;
|
||||
|
||||
/// `radvisory` per `<sys/fcntl.h>`. repr(C) layout is stable.
|
||||
#[repr(C)]
|
||||
struct RadAdvisory {
|
||||
ra_offset: libc::off_t,
|
||||
ra_count: libc::c_int,
|
||||
}
|
||||
|
||||
pub(super) fn hint_sequential(file: &File, len_bytes: u64) {
|
||||
let bytes = (len_bytes as i64).min(RDADVISE_MAX_BYTES);
|
||||
let mut ra = RadAdvisory {
|
||||
let mut ra = libc::radvisory {
|
||||
ra_offset: 0,
|
||||
ra_count: bytes as libc::c_int,
|
||||
};
|
||||
// Best-effort.
|
||||
unsafe {
|
||||
libc::fcntl(file.as_raw_fd(), F_RDADVISE, &mut ra);
|
||||
libc::fcntl(file.as_raw_fd(), libc::F_RDADVISE, &mut ra);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +42,12 @@ pub(super) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||
/// returns immediately.
|
||||
pub(super) fn prefetch(file: &File, offset: u64, len: u64) {
|
||||
let bytes = (len as i64).min(RDADVISE_MAX_BYTES);
|
||||
let mut ra = RadAdvisory {
|
||||
let mut ra = libc::radvisory {
|
||||
ra_offset: offset as libc::off_t,
|
||||
ra_count: bytes as libc::c_int,
|
||||
};
|
||||
// Best-effort — kernel hint only.
|
||||
unsafe {
|
||||
libc::fcntl(file.as_raw_fd(), F_RDADVISE, &mut ra);
|
||||
libc::fcntl(file.as_raw_fd(), libc::F_RDADVISE, &mut ra);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,17 @@
|
||||
//! Without page-cache eviction an 85 GB streaming ISO read pins the
|
||||
//! entire file in memory, starves the concurrent writer, and collapses
|
||||
//! mux throughput (observed: 2.7 MB/s mux on 0.21.5 vs. 70 MB/s
|
||||
//! isolated NFS reads). Every [`READ_DROP_CHUNK_BYTES`] of consumed
|
||||
//! bytes we call `posix_fadvise(DONTNEED)` over that window, mirroring
|
||||
//! the write-side [`crate::io::writeback::WritebackPipeline`] policy.
|
||||
//! isolated NFS reads). Every [`READ_DROP_CHUNK_BYTES_DEFAULT`] of
|
||||
//! consumed bytes we call `posix_fadvise(DONTNEED)` over that window,
|
||||
//! mirroring the write-side [`crate::io::writeback::WritebackPipeline`]
|
||||
//! policy.
|
||||
//!
|
||||
//! The drop window is accounted by a monotonic forward byte counter,
|
||||
//! which matches the sequential streaming pattern the mux highway
|
||||
//! drives. Under random or backward access the dropped range no longer
|
||||
//! lines up with the bytes actually read — but `DONTNEED` is purely an
|
||||
//! advisory cache hint with no correctness impact, so this degrades to
|
||||
//! a slightly imprecise hint rather than a bug.
|
||||
//!
|
||||
//! ## Platform open hint
|
||||
//!
|
||||
@@ -64,7 +72,7 @@ use std::path::Path;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
const SECTOR_SIZE: usize = 2048;
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
|
||||
/// Bytes-read threshold per `posix_fadvise(DONTNEED)` drop on the
|
||||
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
|
||||
@@ -102,7 +110,10 @@ pub struct FileSectorSource {
|
||||
bytes_read_since_drop: u64,
|
||||
/// File offset at which the current drop window starts. The next
|
||||
/// DONTNEED drops from `drop_window_start` for
|
||||
/// `bytes_read_since_drop` bytes.
|
||||
/// `bytes_read_since_drop` bytes. This advances monotonically with
|
||||
/// the byte count, so it tracks the actual reads only under the
|
||||
/// forward-sequential access the mux highway uses; under random
|
||||
/// access it degrades to a harmless, imprecise advisory hint.
|
||||
drop_window_start: u64,
|
||||
/// Cached drop chunk size (resolved from env once at open).
|
||||
drop_chunk_bytes: u64,
|
||||
@@ -116,16 +127,18 @@ impl FileSectorSource {
|
||||
///
|
||||
/// Issues the platform's "sequential access expected" hint on the
|
||||
/// fd (Linux `posix_fadvise(SEQUENTIAL)`, macOS `fcntl(F_RDADVISE)`,
|
||||
/// Windows TODO stub) so the kernel's readahead widens.
|
||||
pub fn open(path: &Path) -> std::io::Result<Self> {
|
||||
let file = File::open(path)?;
|
||||
let len = file.metadata()?.len();
|
||||
let sectors = len / SECTOR_SIZE as u64;
|
||||
/// Windows no-op) so the kernel's readahead widens.
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let file = File::open(path).map_err(|e| Error::IoError { source: e })?;
|
||||
let len = file
|
||||
.metadata()
|
||||
.map_err(|e| Error::IoError { source: e })?
|
||||
.len();
|
||||
let sectors = len / SECTOR_BYTES as u64;
|
||||
if sectors > u32::MAX as u64 {
|
||||
return Err(Error::IsoTooLarge {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
}
|
||||
.into());
|
||||
});
|
||||
}
|
||||
let capacity = sectors as u32;
|
||||
|
||||
@@ -157,7 +170,7 @@ impl SectorSource for FileSectorSource {
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
let count = count as u32;
|
||||
let bytes = count as usize * SECTOR_SIZE;
|
||||
let bytes = count as usize * SECTOR_BYTES;
|
||||
debug_assert!(
|
||||
out.len() >= bytes,
|
||||
"FileSectorSource::read_sectors: out len {} < requested {}",
|
||||
@@ -167,7 +180,7 @@ impl SectorSource for FileSectorSource {
|
||||
if count == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let offset = lba as u64 * SECTOR_SIZE as u64;
|
||||
let offset = lba as u64 * SECTOR_BYTES as u64;
|
||||
self.file
|
||||
.seek(SeekFrom::Start(offset))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
@@ -211,7 +224,7 @@ mod tests {
|
||||
/// verify any sector by content alone.
|
||||
fn make_iso(path: &std::path::Path, sectors: u32) {
|
||||
let mut f = std::fs::File::create(path).unwrap();
|
||||
let mut chunk = vec![0u8; SECTOR_SIZE];
|
||||
let mut chunk = vec![0u8; SECTOR_BYTES];
|
||||
for n in 0..sectors {
|
||||
let b = (n & 0xff) as u8;
|
||||
chunk.iter_mut().for_each(|c| *c = b);
|
||||
@@ -236,7 +249,7 @@ mod tests {
|
||||
let mut src = FileSectorSource::open(&path).unwrap();
|
||||
assert_eq!(src.capacity_sectors(), total);
|
||||
|
||||
let mut got = vec![0u8; SECTOR_SIZE];
|
||||
let mut got = vec![0u8; SECTOR_BYTES];
|
||||
for lba in 0..total {
|
||||
src.read_sectors(lba, 1, &mut got, false).unwrap();
|
||||
let expected = (lba & 0xff) as u8;
|
||||
@@ -257,12 +270,12 @@ mod tests {
|
||||
let mut src = FileSectorSource::open(&path).unwrap();
|
||||
|
||||
let span_lba = TEST_SPAN_SECTORS - 2;
|
||||
let mut buf4 = vec![0u8; SECTOR_SIZE * 4];
|
||||
let mut buf4 = vec![0u8; SECTOR_BYTES * 4];
|
||||
src.read_sectors(span_lba, 4, &mut buf4, false).unwrap();
|
||||
for i in 0..4 {
|
||||
let lba = span_lba + i as u32;
|
||||
let expected = (lba & 0xff) as u8;
|
||||
for b in &buf4[i * SECTOR_SIZE..(i + 1) * SECTOR_SIZE] {
|
||||
for b in &buf4[i * SECTOR_BYTES..(i + 1) * SECTOR_BYTES] {
|
||||
assert_eq!(*b, expected, "byte mismatch at sub-sector {i}");
|
||||
}
|
||||
}
|
||||
@@ -278,7 +291,7 @@ mod tests {
|
||||
make_iso(&path, total);
|
||||
|
||||
let mut src = FileSectorSource::open(&path).unwrap();
|
||||
let mut got = vec![0u8; SECTOR_SIZE];
|
||||
let mut got = vec![0u8; SECTOR_BYTES];
|
||||
|
||||
src.read_sectors(TEST_SPAN_SECTORS + 1, 1, &mut got, false)
|
||||
.unwrap();
|
||||
@@ -298,7 +311,7 @@ mod tests {
|
||||
let mut src = FileSectorSource::open(&path).unwrap();
|
||||
assert_eq!(src.capacity_sectors(), total);
|
||||
|
||||
let mut got = vec![0u8; SECTOR_SIZE];
|
||||
let mut got = vec![0u8; SECTOR_BYTES];
|
||||
src.read_sectors(0, 1, &mut got, false).unwrap();
|
||||
src.read_sectors(total - 1, 1, &mut got, false).unwrap();
|
||||
let expected = ((total - 1) & 0xff) as u8;
|
||||
@@ -317,15 +330,15 @@ mod tests {
|
||||
|
||||
let mut src = FileSectorSource::open(&path).unwrap();
|
||||
let req = (TEST_SPAN_SECTORS + 1) as u16;
|
||||
let req_bytes = req as usize * SECTOR_SIZE;
|
||||
let req_bytes = req as usize * SECTOR_BYTES;
|
||||
let mut big = vec![0u8; req_bytes];
|
||||
src.read_sectors(0, req, &mut big, false).unwrap();
|
||||
assert!(big[..SECTOR_SIZE].iter().all(|b| *b == 0));
|
||||
assert!(big[..SECTOR_BYTES].iter().all(|b| *b == 0));
|
||||
let last_lba = req as u32 - 1;
|
||||
let exp = (last_lba & 0xff) as u8;
|
||||
let last_off = (req as usize - 1) * SECTOR_SIZE;
|
||||
let last_off = (req as usize - 1) * SECTOR_BYTES;
|
||||
assert!(
|
||||
big[last_off..last_off + SECTOR_SIZE]
|
||||
big[last_off..last_off + SECTOR_BYTES]
|
||||
.iter()
|
||||
.all(|b| *b == exp)
|
||||
);
|
||||
@@ -358,4 +371,151 @@ mod tests {
|
||||
std::env::remove_var("FREEMKV_READ_DROP_CHUNK_MIB");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Additional coverage.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// `count == 0` must short-circuit to Ok(0) WITHOUT seeking or
|
||||
/// reading, even at an out-of-range LBA — the early-return guard
|
||||
/// runs before any I/O. Grounding: `if count == 0 { return Ok(0) }`.
|
||||
#[test]
|
||||
fn zero_count_returns_zero_no_io() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("zc.iso");
|
||||
make_iso(&path, 4);
|
||||
let mut src = FileSectorSource::open(&path).unwrap();
|
||||
// LBA far past EOF — must not matter because count==0 returns early.
|
||||
let mut buf = [0u8; 1];
|
||||
let n = src.read_sectors(1_000_000, 0, &mut buf, false).unwrap();
|
||||
assert_eq!(n, 0);
|
||||
}
|
||||
|
||||
/// Reading past EOF must ERROR (read_exact's UnexpectedEof), never
|
||||
/// return a partial/short count. This is the core "never silently
|
||||
/// truncate / never return fewer bytes than declared" property of
|
||||
/// the SectorSource contract. Grounding: `self.file.read_exact(...)`
|
||||
/// — read_exact fails if the file can't supply the full span.
|
||||
#[test]
|
||||
fn read_past_eof_errors_not_truncates() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("eof.iso");
|
||||
make_iso(&path, 4); // 4 sectors only
|
||||
let mut src = FileSectorSource::open(&path).unwrap();
|
||||
assert_eq!(src.capacity_sectors(), 4);
|
||||
|
||||
// Request 2 sectors starting at LBA 3 → sector 4 doesn't exist.
|
||||
let mut buf = vec![0u8; 2 * SECTOR_BYTES];
|
||||
let r = src.read_sectors(3, 2, &mut buf, false);
|
||||
let err = r.expect_err("reading past EOF must error, not short-read");
|
||||
let io: std::io::Error = err.into();
|
||||
assert_eq!(
|
||||
io.kind(),
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
"partial read at EOF must surface read_exact's UnexpectedEof"
|
||||
);
|
||||
}
|
||||
|
||||
/// On a successful full read the returned count MUST equal
|
||||
/// `count * 2048` exactly — the declared byte count. Grounding:
|
||||
/// `Ok(bytes)` where `bytes = count * SECTOR_BYTES`.
|
||||
#[test]
|
||||
fn full_read_returns_exact_declared_bytes() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("exact.iso");
|
||||
make_iso(&path, 16);
|
||||
let mut src = FileSectorSource::open(&path).unwrap();
|
||||
let mut buf = vec![0u8; 5 * SECTOR_BYTES];
|
||||
let n = src.read_sectors(2, 5, &mut buf, false).unwrap();
|
||||
assert_eq!(n, 5 * SECTOR_BYTES, "must return exactly count*2048 bytes");
|
||||
}
|
||||
|
||||
/// Capacity is `file_len / 2048` (floor); trailing bytes that don't
|
||||
/// complete a sector are NOT counted. A file of 4 sectors + 100
|
||||
/// extra bytes reports capacity 4. Grounding: `len / SECTOR_BYTES`
|
||||
/// integer division in `open`.
|
||||
#[test]
|
||||
fn capacity_floors_partial_trailing_sector() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("partial.iso");
|
||||
make_iso(&path, 4);
|
||||
// Append 100 stray bytes (a torn final sector).
|
||||
{
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
f.write_all(&[0xee; 100]).unwrap();
|
||||
f.flush().unwrap();
|
||||
}
|
||||
let src = FileSectorSource::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
src.capacity_sectors(),
|
||||
4,
|
||||
"partial trailing bytes must not inflate the sector capacity"
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty file opens cleanly with capacity 0. Grounding:
|
||||
/// `0 / 2048 == 0`, and the IsoTooLarge guard only fires for
|
||||
/// oversize files.
|
||||
#[test]
|
||||
fn empty_file_capacity_zero() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("empty.iso");
|
||||
std::fs::File::create(&path).unwrap();
|
||||
let src = FileSectorSource::open(&path).unwrap();
|
||||
assert_eq!(src.capacity_sectors(), 0);
|
||||
}
|
||||
|
||||
/// Opening a nonexistent path returns an IoError (NotFound), not a
|
||||
/// panic. Grounding: `File::open(path).map_err(...)`.
|
||||
#[test]
|
||||
fn open_missing_file_errors() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("does-not-exist.iso");
|
||||
let err = match FileSectorSource::open(&path) {
|
||||
Ok(_) => panic!("missing file must error"),
|
||||
Err(e) => e,
|
||||
};
|
||||
let io: std::io::Error = err.into();
|
||||
assert_eq!(io.kind(), std::io::ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
/// A DONTNEED drop crossing the chunk threshold must not corrupt or
|
||||
/// short subsequent reads — the eviction is a pure page-cache hint.
|
||||
/// We read past the DEFAULT 32 MiB drop chunk (16384 sectors) so the
|
||||
/// eviction block fires at least once, asserting every sector still
|
||||
/// reads correctly. (Avoids mutating FREEMKV_READ_DROP_CHUNK_MIB to
|
||||
/// sidestep a parallel-test env race with `drop_chunk_size_env_override`.)
|
||||
/// Grounding: the `bytes_read_since_drop >= drop_chunk_bytes`
|
||||
/// eviction block calls only `platform::drop_window` (advisory) and
|
||||
/// resets counters — no data effect.
|
||||
#[test]
|
||||
fn dontneed_eviction_does_not_affect_data() {
|
||||
// 32 MiB default chunk = 16384 sectors; read a bit past it.
|
||||
let total = (READ_DROP_CHUNK_BYTES_DEFAULT / SECTOR_BYTES as u64) as u32 + 64;
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("drop.iso");
|
||||
make_iso(&path, total);
|
||||
let mut src = FileSectorSource::open(&path).unwrap();
|
||||
// Read in 16-sector batches to keep the loop fast while still
|
||||
// crossing the drop boundary by byte count.
|
||||
let batch = 16u16;
|
||||
let mut got = vec![0u8; batch as usize * SECTOR_BYTES];
|
||||
let mut lba = 0u32;
|
||||
while lba + batch as u32 <= total {
|
||||
src.read_sectors(lba, batch, &mut got, false).unwrap();
|
||||
for i in 0..batch as u32 {
|
||||
let expected = ((lba + i) & 0xff) as u8;
|
||||
let off = i as usize * SECTOR_BYTES;
|
||||
assert!(
|
||||
got[off..off + SECTOR_BYTES].iter().all(|x| *x == expected),
|
||||
"DONTNEED eviction corrupted sector {}",
|
||||
lba + i
|
||||
);
|
||||
}
|
||||
lba += batch as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
//! Windows: the canonical sequential-access hint is
|
||||
//! `FILE_FLAG_SEQUENTIAL_SCAN` passed to `CreateFile` at open time —
|
||||
//! it cannot be set after the fact via `SetFileInformationByHandle`.
|
||||
//! Routing the open call through this module would mean a custom
|
||||
//! `File::from_raw_handle` plumb for every `FileSectorSource::open`
|
||||
//! caller, which is more invasive than the Phase 1 scope.
|
||||
//!
|
||||
//! TODO: replumb `FileSectorSource::open` to take an
|
||||
//! `OpenOptions`-style builder so the Windows path can flip the flag
|
||||
//! at open time. For now this is a no-op stub.
|
||||
//! `FILE_FLAG_SEQUENTIAL_SCAN`, which must be passed to `CreateFile`
|
||||
//! at open time and cannot be set afterward via
|
||||
//! `SetFileInformationByHandle`. Since `FileSectorSource::open` uses a
|
||||
//! plain `File::open`, the hints in this module are no-op stubs.
|
||||
|
||||
use std::fs::File;
|
||||
|
||||
/// No-op stub. `FILE_FLAG_SEQUENTIAL_SCAN` can only be set at
|
||||
/// `CreateFile` open time, which the plain `File::open` path does not
|
||||
/// do, so there is no post-open hint to issue here.
|
||||
pub(super) fn hint_sequential(_file: &File, _len_bytes: u64) {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"FileSectorSource hint_sequential: windows stub (TODO: FILE_FLAG_SEQUENTIAL_SCAN at open)"
|
||||
"FileSectorSource hint_sequential: windows no-op stub"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Platform-aware crash-durability primitives.
|
||||
//!
|
||||
//! Two flush operations need OS-specific handling to make a write survive a
|
||||
//! crash / power loss:
|
||||
//!
|
||||
//! - [`dir`] — fsync a directory so a prior `rename(2)` into it is durable.
|
||||
//! After a crash a renamed file's dirent can otherwise be lost even though
|
||||
//! the rename returned, because it is still page-cache-only. This is a POSIX
|
||||
//! concept: on Windows std cannot even open a directory as a `File` (it does
|
||||
//! not set `FILE_FLAG_BACKUP_SEMANTICS`), and NTFS/ReFS commit the rename's
|
||||
//! dirent without an explicit directory flush — so it is a no-op there
|
||||
//! rather than a failed open that logs on every marker write.
|
||||
//!
|
||||
//! - [`file_durable`] — fsync a file's contents + metadata. Opens the file
|
||||
//! **read+write**: on Windows `File::sync_all` maps to `FlushFileBuffers`,
|
||||
//! which requires a handle with write access and returns
|
||||
//! `ERROR_ACCESS_DENIED` (os error 5) on a read-only handle. (A read-only
|
||||
//! `File::open` + `sync_all` is legal on POSIX, which is why that bug only
|
||||
//! bit Windows.) The open mode is platform-uniform, so this lives here with
|
||||
//! no dispatch.
|
||||
//!
|
||||
//! Per the crate convention (see [`crate::io::writeback_file`]), platform
|
||||
//! dispatch happens once here via cfg-gated `mod` decls — callers carry no
|
||||
//! inline `#[cfg(...)]`.
|
||||
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod posix;
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
use posix as platform;
|
||||
#[cfg(windows)]
|
||||
use windows as platform;
|
||||
|
||||
/// fsync a directory so a prior `rename(2)` into it is durable. Best-effort:
|
||||
/// failures are logged and swallowed, never propagated — the renamed file's
|
||||
/// bytes are already synced and the caller's write itself succeeded. No-op on
|
||||
/// Windows (see module docs).
|
||||
pub fn dir(path: &Path) {
|
||||
platform::fsync_dir(path)
|
||||
}
|
||||
|
||||
/// Durably flush an existing file's contents + metadata to stable storage.
|
||||
///
|
||||
/// Opens the file read+write (not read-only) so the flush succeeds on every
|
||||
/// platform — see the module docs for the Windows `FlushFileBuffers` rationale.
|
||||
/// The file must already exist; its bytes are left intact (no create/truncate).
|
||||
pub fn file_durable(path: &Path) -> io::Result<()> {
|
||||
let f = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(path)?;
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `file_durable` opens read+write (so the flush works on Windows) and
|
||||
/// syncs an existing file; a missing path surfaces as `Err` so the caller
|
||||
/// treats it as "not durably synced". Platform-uniform — same on
|
||||
/// unix/windows.
|
||||
#[test]
|
||||
fn file_durable_ok_for_existing_err_for_missing() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
let f = td.path().join("data.bin");
|
||||
std::fs::write(&f, b"durable").unwrap();
|
||||
assert!(
|
||||
file_durable(&f).is_ok(),
|
||||
"an existing file must open read+write and fsync cleanly"
|
||||
);
|
||||
assert!(
|
||||
file_durable(&td.path().join("absent.bin")).is_err(),
|
||||
"a missing file must surface the open failure as Err"
|
||||
);
|
||||
}
|
||||
|
||||
/// `dir` is best-effort: it must return normally for a real directory
|
||||
/// (POSIX fsyncs it, Windows no-ops) and must swallow — never panic on —
|
||||
/// a missing directory.
|
||||
#[test]
|
||||
fn dir_is_best_effort_never_panics() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
dir(td.path());
|
||||
dir(&td.path().join("does-not-exist"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! POSIX directory-fsync. Active on unix and any non-Windows fallback target
|
||||
//! (BSD, illumos, …) — all share the same `File::open(dir).sync_all()`
|
||||
//! semantics. The Windows no-op lives in the sibling `windows` module.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
pub(super) fn fsync_dir(dir: &Path) {
|
||||
match std::fs::File::open(dir) {
|
||||
Ok(f) => {
|
||||
if let Err(e) = f.sync_all() {
|
||||
tracing::warn!(path = %dir.display(), error = %e, "failed to fsync directory");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %dir.display(), error = %e, "could not open directory to fsync");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Windows directory-fsync: a no-op.
|
||||
//!
|
||||
//! Directory fsync is a POSIX concept. std cannot open a directory as a `File`
|
||||
//! on Windows (it does not set `FILE_FLAG_BACKUP_SEMANTICS`), so the POSIX impl
|
||||
//! could only ever fail the open and log a spurious warning on every marker /
|
||||
//! mapfile write. NTFS/ReFS commit a rename's directory entry without an
|
||||
//! explicit directory flush, so skipping it here is correct — not a durability
|
||||
//! regression. (File-content durability is handled platform-uniformly by
|
||||
//! [`super::file_durable`].)
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
pub(super) fn fsync_dir(_dir: &Path) {}
|
||||
+6
-7
@@ -18,18 +18,19 @@
|
||||
//! consumed window so an 85 GB streaming ISO read doesn't fill the
|
||||
//! page cache and starve the concurrent MKV write.
|
||||
//!
|
||||
//! `Pipeline` + `Sink` (0.18) is the generic producer/consumer primitive
|
||||
//! `Pipeline` + `Sink` is the generic producer/consumer primitive
|
||||
//! used by sweep, patch, and mux to overlap reads with writes via a
|
||||
//! bounded channel + dedicated consumer thread.
|
||||
//!
|
||||
//! `byte_channel` is a byte-sized producer/consumer channel for the
|
||||
//! mux pipeline, sized to absorb worst-case input read stalls (see
|
||||
//! `freemkv-private/memory/project_buffering_architecture.md`).
|
||||
//! `byte_prefetcher` is the read-ahead producer feeding the mux
|
||||
//! pipeline for `io::Read`-backed sources: a worker thread fills a
|
||||
//! recycled pool of buffers and ships them through a channel, exposing
|
||||
//! `BytePrefetcher` / `PrefetchShell`.
|
||||
|
||||
pub(crate) mod bounded;
|
||||
pub mod byte_channel;
|
||||
pub mod byte_prefetcher;
|
||||
pub mod file_sector_source;
|
||||
pub mod fsync;
|
||||
pub mod sink;
|
||||
mod writeback;
|
||||
mod writeback_file;
|
||||
@@ -41,8 +42,6 @@ pub mod pipeline;
|
||||
|
||||
pub(crate) use writeback_file::WritebackFile;
|
||||
|
||||
// Re-exports for the 0.18 redesign. Sweep, patch, and mux are all
|
||||
// wired up (disc/sweep.rs, disc/patch.rs, autorip's ripper/mux.rs).
|
||||
pub use pipeline::{
|
||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
|
||||
WRITE_THROUGH_DEPTH,
|
||||
|
||||
+795
-116
File diff suppressed because it is too large
Load Diff
@@ -12,14 +12,18 @@
|
||||
//! size patch, Cues index, segment header backpatch) to land on the
|
||||
//! right offset.
|
||||
//!
|
||||
//! `RandomAccessSink` is satisfied via the blanket impl in
|
||||
//! [`super::mod`]; no explicit impl needed here.
|
||||
//! [`SequentialSink`](super::SequentialSink) is implemented explicitly
|
||||
//! (not via a blanket impl) so its `finish()` flushes the `BufWriter`
|
||||
//! and `fsync`s the file even when called through a `dyn` trait object;
|
||||
//! [`RandomAccessSink`](super::RandomAccessSink) is implemented over the
|
||||
//! `Seek` impl below.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, BufWriter, Seek, SeekFrom, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use super::preallocate;
|
||||
use super::{RandomAccessSink, SequentialSink};
|
||||
|
||||
const BUFFER_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
@@ -80,13 +84,26 @@ impl LocalFileSink {
|
||||
/// Drain the internal buffer and `fsync` the underlying file.
|
||||
/// Idempotent with `Drop` (the `BufWriter` also flushes on drop;
|
||||
/// this call additionally surfaces fsync errors to the caller).
|
||||
#[allow(dead_code)] // exposed for parity with WritebackFile::sync_all
|
||||
/// [`SequentialSink::finish`](super::SequentialSink::finish)
|
||||
/// delegates here so the durable flush happens through a trait
|
||||
/// object too.
|
||||
pub fn sync_all(&mut self) -> io::Result<()> {
|
||||
self.inner.flush()?;
|
||||
self.inner.get_ref().sync_all()
|
||||
}
|
||||
}
|
||||
|
||||
impl SequentialSink for LocalFileSink {
|
||||
/// Flush the 4 MiB `BufWriter` and `fsync` the file. Overriding the
|
||||
/// trait default is what makes a `dyn SequentialSink` / `dyn
|
||||
/// RandomAccessSink` `finish()` actually durable instead of a no-op.
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
self.sync_all()
|
||||
}
|
||||
}
|
||||
|
||||
impl RandomAccessSink for LocalFileSink {}
|
||||
|
||||
impl Write for LocalFileSink {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.inner.write(buf)
|
||||
@@ -159,4 +176,58 @@ mod tests {
|
||||
let bytes = std::fs::read(&p).unwrap();
|
||||
assert_eq!(&bytes[..], b"hint-ok");
|
||||
}
|
||||
|
||||
// ── Added hardening tests ───────────────────────────────────────
|
||||
|
||||
/// `create` must TRUNCATE an existing file (OpenOptions
|
||||
/// `.truncate(true)`, lines 52-58). Pre-seed a long file, recreate
|
||||
/// it via the sink, write a shorter payload — the old tail must be
|
||||
/// gone. Mutation: dropping `.truncate(true)` would leave the stale
|
||||
/// tail and the length assert fails.
|
||||
#[test]
|
||||
fn create_truncates_existing_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("trunc.bin");
|
||||
std::fs::write(&p, vec![0xFFu8; 4096]).unwrap();
|
||||
let mut s = LocalFileSink::create(&p).unwrap();
|
||||
s.write_all(b"short").unwrap();
|
||||
s.sync_all().unwrap();
|
||||
drop(s);
|
||||
let bytes = std::fs::read(&p).unwrap();
|
||||
assert_eq!(
|
||||
bytes.len(),
|
||||
5,
|
||||
"create must truncate the pre-existing 4096 bytes"
|
||||
);
|
||||
assert_eq!(&bytes, b"short");
|
||||
}
|
||||
|
||||
/// Seek must flush the BufWriter FIRST so buffered bytes land at
|
||||
/// their intended offset, not the post-seek one (lines 121-128, and
|
||||
/// the module doc's silent-corruption warning). We write into the
|
||||
/// buffer (no explicit flush), seek backward, write again, and
|
||||
/// confirm the first write stayed at offset 0. Mutation: removing
|
||||
/// the `self.inner.flush()?` in `seek` would flush the first 4
|
||||
/// bytes at the seeked offset, corrupting the file.
|
||||
#[test]
|
||||
fn seek_flushes_buffer_before_moving() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("seek-flush.bin");
|
||||
let mut s = LocalFileSink::create(&p).unwrap();
|
||||
// These bytes sit in the 4 MiB BufWriter, unflushed.
|
||||
s.write_all(b"HEAD").unwrap();
|
||||
// Seek forward to offset 10; the buffered HEAD must be flushed
|
||||
// to offset 0 BEFORE the position moves.
|
||||
s.seek(SeekFrom::Start(10)).unwrap();
|
||||
s.write_all(b"TAIL").unwrap();
|
||||
s.sync_all().unwrap();
|
||||
drop(s);
|
||||
let bytes = std::fs::read(&p).unwrap();
|
||||
assert_eq!(
|
||||
&bytes[0..4],
|
||||
b"HEAD",
|
||||
"buffered head landed at the wrong offset"
|
||||
);
|
||||
assert_eq!(&bytes[10..14], b"TAIL");
|
||||
}
|
||||
}
|
||||
|
||||
+126
-48
@@ -20,9 +20,6 @@
|
||||
//! 4 MiB buffer for the common local-disk case; `WritebackFile`
|
||||
//! (separate module) wraps a `File` with the adaptive-chunk
|
||||
//! `sync_file_range` machinery for the Linux+NFS case.
|
||||
//!
|
||||
//! See `freemkv-private/memory/project_buffering_architecture.md` for
|
||||
//! the full design and the source/sink matrix.
|
||||
|
||||
use std::io::{Seek, Write};
|
||||
|
||||
@@ -38,14 +35,22 @@ pub use socket::{SocketSink, UdpSocketSink};
|
||||
/// trait does not impose or hide any buffering of its own.
|
||||
///
|
||||
/// `finish` drains any internal buffering and signals end-of-stream to
|
||||
/// the underlying transport (close-write on a socket, flush on a
|
||||
/// buffered writer, etc.). The default impl is a no-op; concrete
|
||||
/// implementations that need explicit shutdown can override it but the
|
||||
/// blanket impl below keeps it optional for adapter types like
|
||||
/// `&mut File`.
|
||||
/// the underlying transport (close-write on a socket, flush + fsync on
|
||||
/// a buffered file, etc.). The default impl flushes via [`Write::flush`]
|
||||
/// — correct for an unbuffered destination — but every concrete sink in
|
||||
/// this module overrides it to drain its own buffer and run its
|
||||
/// transport-specific finalisation (socket `shutdown(Write)`, file
|
||||
/// `fsync`). There is deliberately NO blanket `impl SequentialSink for
|
||||
/// T`: a blanket impl would force the no-op-style default on every
|
||||
/// concrete sink (a blanket impl cannot be overridden per-type without a
|
||||
/// coherence conflict), so a `Box<dyn SequentialSink>` / `&mut dyn
|
||||
/// SequentialSink` `finish()` call would silently skip the flush and
|
||||
/// transport shutdown. With explicit per-type impls the vtable dispatches
|
||||
/// `finish` to the real implementation, so flush + durable-finish
|
||||
/// actually happen through a trait object.
|
||||
pub trait SequentialSink: Write + Send {
|
||||
fn finish(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
self.flush()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,15 +59,6 @@ pub trait SequentialSink: Write + Send {
|
||||
/// random-access sink is always usable as a sequential sink.
|
||||
pub trait RandomAccessSink: SequentialSink + Seek {}
|
||||
|
||||
// Blanket impls so any `Write + Send` type acts as a `SequentialSink`
|
||||
// (with default `finish`), and any sink that also impls `Seek` is
|
||||
// automatically a `RandomAccessSink`. Keeps call-site ergonomics simple
|
||||
// — `&mut File`, `LocalFileSink`, `WritebackFile`, `BufWriter<File>`,
|
||||
// and `Cursor<Vec<u8>>` all satisfy the right trait without per-type
|
||||
// boilerplate.
|
||||
impl<T: Write + Send + ?Sized> SequentialSink for T {}
|
||||
impl<T: SequentialSink + Seek + ?Sized> RandomAccessSink for T {}
|
||||
|
||||
/// Pick the right `RandomAccessSink` impl for `dest` based on its
|
||||
/// filesystem type.
|
||||
///
|
||||
@@ -80,18 +76,16 @@ impl<T: SequentialSink + Seek + ?Sized> RandomAccessSink for T {}
|
||||
///
|
||||
/// Returns a boxed trait object so the call site (mux construction)
|
||||
/// stays agnostic of which concrete sink got picked.
|
||||
#[allow(dead_code)] // wiring to mux::resolve is a follow-up commit
|
||||
pub fn open_for_mkv(
|
||||
// Not yet wired into mux::resolve (follow-up commit). Kept `pub(crate)` until
|
||||
// then so an unfinished signature isn't frozen into the public 1.0 API.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn open_for_mkv(
|
||||
dest: &std::path::Path,
|
||||
size_hint: Option<u64>,
|
||||
) -> std::io::Result<Box<dyn RandomAccessSink>> {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use crate::platform::fs_type::detect;
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::platform::fs_type::{FsType, detect};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use crate::platform::fs_type::{FsType, detect};
|
||||
if detect(dest) == FsType::Nfs {
|
||||
let wf = match size_hint {
|
||||
Some(n) => crate::io::WritebackFile::create_with_size_hint(dest, n)?,
|
||||
@@ -100,12 +94,13 @@ pub fn open_for_mkv(
|
||||
return Ok(Box::new(wf));
|
||||
}
|
||||
}
|
||||
// Silence the unused-binding warning on non-Linux where the only
|
||||
// branch above is cfg-gated out.
|
||||
// Only Linux differentiates the sink by filesystem type (NFS gets
|
||||
// the WritebackFile machinery); every other OS always uses
|
||||
// `LocalFileSink`. Reference `detect` as a value (no call, no
|
||||
// `statfs` syscall) so it isn't flagged dead on non-Linux while
|
||||
// still avoiding the wasted probe whose result we'd discard.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = detect(dest);
|
||||
}
|
||||
let _ = crate::platform::fs_type::detect;
|
||||
|
||||
let sink = match size_hint {
|
||||
Some(n) => LocalFileSink::with_size_hint(dest, n)?,
|
||||
@@ -117,32 +112,25 @@ pub fn open_for_mkv(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
|
||||
// Type-level assertion: the blanket impls cover the shapes we care
|
||||
// about. These functions never run; they just have to type-check.
|
||||
fn _assert_file_is_sequential(_: &mut dyn SequentialSink) {}
|
||||
fn _assert_file_is_random_access(_: &mut dyn RandomAccessSink) {}
|
||||
// Type-level assertion: the concrete sinks satisfy the trait
|
||||
// objects. These functions never run; they just have to type-check.
|
||||
fn _assert_is_sequential(_: &mut dyn SequentialSink) {}
|
||||
fn _assert_is_random_access(_: &mut dyn RandomAccessSink) {}
|
||||
|
||||
#[test]
|
||||
fn blanket_impls_cover_file_and_localfilesink() {
|
||||
// `File` directly via blanket impls.
|
||||
fn concrete_sinks_satisfy_traits() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut f = File::create(dir.path().join("a.bin")).unwrap();
|
||||
_assert_file_is_sequential(&mut f);
|
||||
_assert_file_is_random_access(&mut f);
|
||||
|
||||
// `LocalFileSink` ditto.
|
||||
// `LocalFileSink` is a random-access (and thus sequential) sink.
|
||||
let mut s = LocalFileSink::create(&dir.path().join("b.bin")).unwrap();
|
||||
_assert_file_is_sequential(&mut s);
|
||||
_assert_file_is_random_access(&mut s);
|
||||
_assert_is_sequential(&mut s);
|
||||
_assert_is_random_access(&mut s);
|
||||
|
||||
// `WritebackFile` — confirms the Phase 1 type still satisfies
|
||||
// the trait via the blanket impl without needing an explicit
|
||||
// `impl RandomAccessSink for WritebackFile {}`.
|
||||
// `WritebackFile` ditto, via its explicit per-type impls.
|
||||
let mut wf = crate::io::WritebackFile::create(&dir.path().join("c.bin")).unwrap();
|
||||
_assert_file_is_sequential(&mut wf);
|
||||
_assert_file_is_random_access(&mut wf);
|
||||
_assert_is_sequential(&mut wf);
|
||||
_assert_is_random_access(&mut wf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -158,4 +146,94 @@ mod tests {
|
||||
let bytes = std::fs::read(&p).unwrap();
|
||||
assert_eq!(&bytes[..5], b"hello");
|
||||
}
|
||||
|
||||
/// finish() through a `dyn SequentialSink` trait object must
|
||||
/// dispatch to the concrete sink's override (flush + fsync), not a
|
||||
/// no-op default. This is the regression test for the silent-no-op
|
||||
/// finish() bug.
|
||||
#[test]
|
||||
fn finish_through_trait_object_flushes_local_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("trait-finish.bin");
|
||||
let sink = LocalFileSink::create(&p).unwrap();
|
||||
// Box as the trait object the production path uses.
|
||||
let mut boxed: Box<dyn SequentialSink> = Box::new(sink);
|
||||
boxed.write_all(b"buffered-tail").unwrap();
|
||||
// finish() through the vtable must drain the 4 MiB BufWriter and
|
||||
// fsync; the bytes must be visible to a separate reader BEFORE
|
||||
// we drop the sink (drop-flush must not be what saves us).
|
||||
boxed.finish().unwrap();
|
||||
let bytes = std::fs::read(&p).unwrap();
|
||||
assert_eq!(&bytes[..], b"buffered-tail");
|
||||
}
|
||||
|
||||
// ── Added hardening tests ───────────────────────────────────────
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
/// A minimal `SequentialSink` that does NOT override `finish`, so it
|
||||
/// exercises the trait's DEFAULT impl (lines 51-55), which must call
|
||||
/// `Write::flush`. We record whether flush ran. This pins the
|
||||
/// documented contract that the default `finish` is "correct for an
|
||||
/// unbuffered destination" by flushing. Mutation: changing the
|
||||
/// default `finish` body from `self.flush()` to `Ok(())` would set
|
||||
/// `flushed=false` and fail.
|
||||
struct FlushTracker {
|
||||
flushed: Arc<AtomicBool>,
|
||||
bytes: Arc<AtomicUsize>,
|
||||
}
|
||||
impl Write for FlushTracker {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.bytes.fetch_add(buf.len(), Ordering::SeqCst);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.flushed.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
// Uses the DEFAULT finish() — deliberately no override.
|
||||
impl SequentialSink for FlushTracker {}
|
||||
|
||||
#[test]
|
||||
fn default_finish_flushes() {
|
||||
let flushed = Arc::new(AtomicBool::new(false));
|
||||
let bytes = Arc::new(AtomicUsize::new(0));
|
||||
let mut sink = FlushTracker {
|
||||
flushed: flushed.clone(),
|
||||
bytes: bytes.clone(),
|
||||
};
|
||||
sink.write_all(b"abc").unwrap();
|
||||
assert!(
|
||||
!flushed.load(Ordering::SeqCst),
|
||||
"flush should not run before finish"
|
||||
);
|
||||
sink.finish().unwrap();
|
||||
assert!(
|
||||
flushed.load(Ordering::SeqCst),
|
||||
"default SequentialSink::finish must call Write::flush"
|
||||
);
|
||||
assert_eq!(bytes.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
/// `open_for_mkv` with `None` size hint must still produce a working
|
||||
/// random-access sink (the `match size_hint { None => ... }` arm,
|
||||
/// lines 103-106). Round-trip a seek-back patch through it to prove
|
||||
/// both Write and Seek dispatch. Mutation: if the None arm returned
|
||||
/// a sequential-only sink the seek would not compile / would fail.
|
||||
#[test]
|
||||
fn open_for_mkv_without_size_hint_is_random_access() {
|
||||
use std::io::{Seek, SeekFrom};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("nohint.bin");
|
||||
let mut sink = open_for_mkv(&p, None).unwrap();
|
||||
sink.write_all(b"AAAABBBB").unwrap();
|
||||
sink.seek(SeekFrom::Start(4)).unwrap();
|
||||
sink.write_all(b"CCCC").unwrap();
|
||||
sink.finish().unwrap();
|
||||
drop(sink);
|
||||
assert_eq!(std::fs::read(&p).unwrap(), b"AAAACCCC");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,16 @@
|
||||
//! the file naturally.
|
||||
|
||||
use std::fs::File;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
pub(super) fn preallocate_impl(file: &File, size_bytes: u64) {
|
||||
let fd = file.as_raw_fd();
|
||||
// Clamp to the signed `off_t` range fallocate expects; an unchecked
|
||||
// `as i64` cast would wrap a >= 2^63 size to a negative length that
|
||||
// fallocate rejects with EINVAL (silent no-op).
|
||||
let len = i64::try_from(size_bytes).unwrap_or(i64::MAX);
|
||||
// FALLOC_FL_KEEP_SIZE = 0x01.
|
||||
let rc = unsafe { libc::fallocate(fd, libc::FALLOC_FL_KEEP_SIZE, 0, size_bytes as i64) };
|
||||
let rc = unsafe { libc::fallocate(fd, libc::FALLOC_FL_KEEP_SIZE, 0, len) };
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"LocalFileSink fallocate size_hint={size_bytes} rc={rc} ok={}",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! macOS `F_PREALLOCATE` extent reservation.
|
||||
//!
|
||||
//! `fcntl(F_PREALLOCATE)` with `F_ALLOCATECONTIG` first (try for a
|
||||
//! contiguous run) and fall back to `F_ALLOCATEALL` (non-contig OK).
|
||||
//! `fcntl(F_PREALLOCATE)` with `F_ALLOCATECONTIG | F_ALLOCATEALL` first
|
||||
//! (prefer a contiguous run but accept scattered extents to satisfy the
|
||||
//! full length) and fall back to `F_ALLOCATEALL` alone on failure.
|
||||
//! Reported file size is unchanged — the muxer's writes still grow it.
|
||||
|
||||
use std::fs::File;
|
||||
@@ -13,16 +14,24 @@ use crate::io::platform_macos::{
|
||||
|
||||
pub(super) fn preallocate_impl(file: &File, size_bytes: u64) {
|
||||
let fd = file.as_raw_fd();
|
||||
// Clamp to the signed `off_t` range; an unchecked `as off_t` cast
|
||||
// would wrap a >= 2^63 size to a negative length.
|
||||
let len = i64::try_from(size_bytes).unwrap_or(i64::MAX) as libc::off_t;
|
||||
let mut store = Fstore {
|
||||
fst_flags: F_ALLOCATECONTIG,
|
||||
// Prefer a contiguous run but accept scattered extents to
|
||||
// satisfy the full length. Without F_ALLOCATEALL the first
|
||||
// attempt is best-effort and can return rc=0 with a partial
|
||||
// allocation, so the fallback below would never fire. Matches
|
||||
// writeback_file/macos.rs.
|
||||
fst_flags: F_ALLOCATECONTIG | F_ALLOCATEALL,
|
||||
fst_posmode: F_PEOFPOSMODE,
|
||||
fst_offset: 0,
|
||||
fst_length: size_bytes as libc::off_t,
|
||||
fst_length: len,
|
||||
fst_bytesalloc: 0,
|
||||
};
|
||||
let mut rc = unsafe { libc::fcntl(fd, F_PREALLOCATE, &mut store as *mut Fstore) };
|
||||
if rc == -1 {
|
||||
// Fall back to non-contiguous.
|
||||
// Fall back to non-contiguous only.
|
||||
store.fst_flags = F_ALLOCATEALL;
|
||||
rc = unsafe { libc::fcntl(fd, F_PREALLOCATE, &mut store as *mut Fstore) };
|
||||
}
|
||||
|
||||
+124
-26
@@ -11,16 +11,21 @@
|
||||
//! conventional choice). `finish()` is a no-op; UDP has no end-of-stream
|
||||
//! marker.
|
||||
//!
|
||||
//! Both types satisfy [`SequentialSink`] via the blanket impl in
|
||||
//! `super::mod`. Neither implements `Seek`, so neither satisfies
|
||||
//! [`RandomAccessSink`] — using one with `MkvMux` is a compile error,
|
||||
//! which is the design intent.
|
||||
//! Both types implement [`SequentialSink`] explicitly so their
|
||||
//! `finish()` dispatches correctly through a `dyn SequentialSink` trait
|
||||
//! object (the `SocketSink` override drains the buffer and
|
||||
//! `shutdown(Write)`s; the `UdpSocketSink` override flushes only).
|
||||
//! Neither implements `Seek`, so neither satisfies [`RandomAccessSink`]
|
||||
//! — using one with `MkvMux` is a compile error, which is the design
|
||||
//! intent.
|
||||
//!
|
||||
//! [`SequentialSink`]: super::SequentialSink
|
||||
//! [`RandomAccessSink`]: super::RandomAccessSink
|
||||
|
||||
use std::io::{self, BufWriter, Write};
|
||||
use std::net::{Shutdown, TcpStream, ToSocketAddrs, UdpSocket};
|
||||
use std::net::{Shutdown, SocketAddr, TcpStream, ToSocketAddrs, UdpSocket};
|
||||
|
||||
use super::SequentialSink;
|
||||
|
||||
/// `BufWriter` capacity for [`SocketSink`]. 1 MiB matches the typical
|
||||
/// kernel send-buffer ceiling and keeps small-write amplification from
|
||||
@@ -47,15 +52,17 @@ impl SocketSink {
|
||||
/// writing. `sndbuf_bytes`, when present, is forwarded to
|
||||
/// `setsockopt(SO_SNDBUF)` as a kernel hint — the OS may clamp it.
|
||||
///
|
||||
/// `addr` accepts anything `ToSocketAddrs` does: `"10.0.0.1:1234"`,
|
||||
/// `addr` accepts anything `ToSocketAddrs` does: `"192.0.2.1:1234"`,
|
||||
/// `("host", 1234)`, a `SocketAddr`, etc.
|
||||
pub fn connect<A: ToSocketAddrs>(addr: A, sndbuf_bytes: Option<usize>) -> io::Result<Self> {
|
||||
let stream = TcpStream::connect(addr)?;
|
||||
// `set_nodelay(true)` keeps small writes (TS packet trains, fMP4
|
||||
// moof headers) from sitting in Nagle's algorithm until the buffer
|
||||
// fills. The BufWriter already absorbs syscall overhead; Nagle
|
||||
// would just add latency without coalescing more.
|
||||
stream.set_nodelay(true)?;
|
||||
// would just add latency without coalescing more. It is a latency
|
||||
// hint, not a correctness requirement, so a platform that rejects
|
||||
// TCP_NODELAY must not fail the connect — demote the error.
|
||||
let _ = stream.set_nodelay(true);
|
||||
if let Some(n) = sndbuf_bytes {
|
||||
set_send_buffer(&stream, n)?;
|
||||
}
|
||||
@@ -76,17 +83,12 @@ impl Write for SocketSink {
|
||||
}
|
||||
}
|
||||
|
||||
impl SocketSink {
|
||||
impl SequentialSink for SocketSink {
|
||||
/// Drain the BufWriter and `shutdown(Write)` the underlying socket
|
||||
/// so the peer sees a clean EOF.
|
||||
///
|
||||
/// Note: [`SequentialSink::finish`](super::SequentialSink::finish)'s
|
||||
/// blanket-impl default is a no-op. Trait-object call sites that
|
||||
/// need socket shutdown should call this inherent method directly
|
||||
/// before dropping the sink, or hold the concrete `SocketSink` type
|
||||
/// (typical pattern: each muxer's `finish()` calls the appropriate
|
||||
/// inherent close method on its captured concrete sink).
|
||||
pub fn finish(&mut self) -> io::Result<()> {
|
||||
/// so the peer sees a clean EOF. Overriding the trait default is
|
||||
/// what makes a `dyn SequentialSink` `finish()` send the buffered
|
||||
/// tail and the EOF instead of silently dropping them.
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
self.buf.flush()?;
|
||||
// `shutdown(Write)` signals clean EOF to the peer. Errors here
|
||||
// are non-fatal — the connection may have already been torn down
|
||||
@@ -117,10 +119,22 @@ impl UdpSocketSink {
|
||||
///
|
||||
/// `sndbuf_bytes`, when present, is a hint to `SO_SNDBUF`.
|
||||
pub fn connect<A: ToSocketAddrs>(peer: A, sndbuf_bytes: Option<usize>) -> io::Result<Self> {
|
||||
// Bind to all-zeros / any port. The kernel picks an ephemeral
|
||||
// source port and the source IP at first send.
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")?;
|
||||
socket.connect(peer)?;
|
||||
// Resolve the peer first so the local bind matches its address
|
||||
// family. Binding `0.0.0.0:0` (IPv4) and then connecting to an
|
||||
// IPv6 peer fails with EAFNOSUPPORT, so pick the wildcard that
|
||||
// matches the resolved family.
|
||||
let peer_addr = peer
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
.ok_or_else(|| io::Error::from(io::ErrorKind::AddrNotAvailable))?;
|
||||
let bind_addr = match peer_addr {
|
||||
SocketAddr::V4(_) => "0.0.0.0:0",
|
||||
SocketAddr::V6(_) => "[::]:0",
|
||||
};
|
||||
// Bind to the matching wildcard / any port. The kernel picks an
|
||||
// ephemeral source port and the source IP at first send.
|
||||
let socket = UdpSocket::bind(bind_addr)?;
|
||||
socket.connect(peer_addr)?;
|
||||
if let Some(n) = sndbuf_bytes {
|
||||
set_udp_send_buffer(&socket, n)?;
|
||||
}
|
||||
@@ -140,10 +154,12 @@ impl Write for UdpSocketSink {
|
||||
}
|
||||
}
|
||||
|
||||
impl UdpSocketSink {
|
||||
/// No-op — UDP has no end-of-stream marker. Provided for parity
|
||||
/// with [`SocketSink::finish`] so call sites can treat them uniformly.
|
||||
pub fn finish(&mut self) -> io::Result<()> {
|
||||
impl SequentialSink for UdpSocketSink {
|
||||
/// UDP has no end-of-stream marker, so there is nothing to shut
|
||||
/// down; `write` already sent each datagram unbuffered. Flush is a
|
||||
/// no-op but kept explicit so the trait-object `finish()` matches
|
||||
/// the concrete behaviour.
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -275,4 +291,86 @@ mod tests {
|
||||
let n2 = receiver.recv(&mut buf).unwrap();
|
||||
assert_eq!(&buf[..n2], &[9, 9, 9]);
|
||||
}
|
||||
|
||||
// ── Added hardening tests ───────────────────────────────────────
|
||||
|
||||
/// `SocketSink::finish` must signal a clean EOF to the peer via
|
||||
/// `shutdown(Write)` (lines 91-97). The receiving side's
|
||||
/// `read_to_end` only returns when it observes that EOF — if
|
||||
/// `finish` merely flushed without the shutdown, `read_to_end`
|
||||
/// would block forever (the socket stays half-open). We assert the
|
||||
/// receiver completes promptly AND sees the buffered tail.
|
||||
/// Mutation: replacing the `shutdown(Write)` line with `Ok(())`
|
||||
/// makes the accept thread hang and the join times out.
|
||||
#[test]
|
||||
fn finish_signals_eof_to_peer() {
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
thread::spawn(move || {
|
||||
let (mut sock, _) = listener.accept().unwrap();
|
||||
let mut buf = Vec::new();
|
||||
// Returns only when the peer half-closes (shutdown Write).
|
||||
sock.read_to_end(&mut buf).unwrap();
|
||||
let _ = tx.send(buf);
|
||||
});
|
||||
|
||||
let mut sink = SocketSink::connect(addr, None).unwrap();
|
||||
sink.write_all(b"unflushed-tail").unwrap();
|
||||
sink.finish().unwrap();
|
||||
|
||||
// read_to_end must complete because finish() shut down writes.
|
||||
let received = rx
|
||||
.recv_timeout(Duration::from_secs(3))
|
||||
.expect("peer never saw EOF — finish() did not shutdown(Write)");
|
||||
assert_eq!(received, b"unflushed-tail");
|
||||
}
|
||||
|
||||
/// UDP `write` must emit ONE datagram per call carrying exactly the
|
||||
/// bytes passed — no buffering, no coalescing (doc lines 100-108).
|
||||
/// Two writes of different lengths must arrive as two separate
|
||||
/// datagrams of those exact lengths, in order. Mutation: adding a
|
||||
/// BufWriter to UdpSocketSink (the doc explicitly forbids it) would
|
||||
/// merge these into one datagram and the second `recv` would time
|
||||
/// out.
|
||||
#[test]
|
||||
fn udp_write_is_one_datagram_per_call() {
|
||||
let receiver = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
receiver
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let addr = receiver.local_addr().unwrap();
|
||||
let mut sink = UdpSocketSink::connect(addr, None).unwrap();
|
||||
|
||||
// Distinct lengths so a merge would be detectable.
|
||||
let n_a = sink.write(&[0xAA; 10]).unwrap();
|
||||
let n_b = sink.write(&[0xBB; 20]).unwrap();
|
||||
assert_eq!(n_a, 10);
|
||||
assert_eq!(n_b, 20);
|
||||
|
||||
let mut buf = [0u8; 256];
|
||||
let first = receiver.recv(&mut buf).unwrap();
|
||||
assert_eq!(first, 10, "first datagram must be exactly 10 bytes");
|
||||
assert!(buf[..first].iter().all(|&b| b == 0xAA));
|
||||
let second = receiver.recv(&mut buf).unwrap();
|
||||
assert_eq!(second, 20, "second datagram must be exactly 20 bytes");
|
||||
assert!(buf[..second].iter().all(|&b| b == 0xBB));
|
||||
}
|
||||
|
||||
/// UDP `finish` is a documented no-op (lines 157-165): there is no
|
||||
/// EOF marker for UDP. Calling it must not error and must not
|
||||
/// affect prior datagrams. Mutation: if `finish` tried to
|
||||
/// `shutdown` the UDP socket it could error or close it
|
||||
/// prematurely; here it must just return Ok.
|
||||
#[test]
|
||||
fn udp_finish_is_noop_ok() {
|
||||
let receiver = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let addr = receiver.local_addr().unwrap();
|
||||
let mut sink = UdpSocketSink::connect(addr, None).unwrap();
|
||||
assert!(sink.finish().is_ok());
|
||||
// A second finish is equally harmless.
|
||||
assert!(sink.finish().is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
+195
-28
@@ -54,7 +54,6 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::fs::File;
|
||||
use std::os::unix::io::{AsRawFd, RawFd};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -79,6 +78,15 @@ pub(crate) struct WritebackPipeline {
|
||||
/// `WritebackFile` and never exposed outside that wrapper, which
|
||||
/// is what keeps the alias sound.
|
||||
fd: RawFd,
|
||||
/// An owned clone of the file descriptor, held so that any
|
||||
/// leaked WAIT_AFTER worker thread retains a valid reference to
|
||||
/// the underlying file description for the duration of its
|
||||
/// syscall — even if the original `WritebackFile` is closed first
|
||||
/// and the OS reuses its fd number. `None` only when `try_clone`
|
||||
/// failed at construction (rare); the pipeline falls back to the
|
||||
/// pre-clone `fd` integer in that case, which carries the original
|
||||
/// fd-reuse risk but is no worse than the previous behaviour.
|
||||
wait_file: Option<File>,
|
||||
chunk_bytes: u64,
|
||||
last_flush_pos: u64,
|
||||
pending: Option<(u64, u64)>,
|
||||
@@ -94,13 +102,11 @@ pub(crate) struct WritebackPipeline {
|
||||
is_nfs: bool,
|
||||
/// Set the first time WAIT_AFTER exceeds [`WAIT_AFTER_TIMEOUT`].
|
||||
/// Once set, behaviour matches the NFS path for the rest of the
|
||||
/// pipeline's life. Wrapped in `Arc` only because both this
|
||||
/// struct and the spawned worker thread (which itself doesn't
|
||||
/// touch the flag) share-via-fd patterns might one day need it;
|
||||
/// today it's effectively a single-owner cell — the `Arc` shape
|
||||
/// keeps the door open for moving the read side into a worker
|
||||
/// without re-plumbing types.
|
||||
degraded: Arc<AtomicBool>,
|
||||
/// pipeline's life. A plain `AtomicBool`: the flag is only ever
|
||||
/// touched on the owning thread (the spawned WAIT_AFTER worker never
|
||||
/// reads or writes it). `AtomicBool` over `bool` only because the
|
||||
/// load/store sites read cleanly; no sharing is needed today.
|
||||
degraded: AtomicBool,
|
||||
}
|
||||
|
||||
impl WritebackPipeline {
|
||||
@@ -111,6 +117,19 @@ impl WritebackPipeline {
|
||||
pub(crate) fn new(file: &File, start_pos: u64, chunk_bytes: u64) -> Self {
|
||||
let fd = file.as_raw_fd();
|
||||
let is_nfs = detect_nfs(fd);
|
||||
// Clone the fd so any leaked WAIT_AFTER worker thread keeps the
|
||||
// file description alive. Log but continue on clone failure.
|
||||
let wait_file = match file.try_clone() {
|
||||
Ok(f) => Some(f),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"WritebackPipeline fd={fd}: try_clone failed ({e}), WAIT_AFTER workers \
|
||||
will use raw fd (fd-reuse risk on timeout)"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
target: "mux",
|
||||
"WritebackPipeline fd={fd} is_nfs={is_nfs} chunk_bytes={chunk_bytes} strategy={}",
|
||||
@@ -118,13 +137,14 @@ impl WritebackPipeline {
|
||||
);
|
||||
Self {
|
||||
fd,
|
||||
wait_file,
|
||||
chunk_bytes,
|
||||
last_flush_pos: start_pos,
|
||||
pending: None,
|
||||
wait_after_window: VecDeque::with_capacity(ADAPTIVE_WINDOW),
|
||||
chunk_count: 0,
|
||||
is_nfs,
|
||||
degraded: Arc::new(AtomicBool::new(false)),
|
||||
degraded: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +156,22 @@ impl WritebackPipeline {
|
||||
self.is_nfs || self.degraded.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Produce a fresh per-call `File` clone for the WAIT_AFTER worker.
|
||||
///
|
||||
/// Each call to `wait_after_with_timeout` needs its own owned clone
|
||||
/// so the worker thread keeps the file description alive for the
|
||||
/// duration of the syscall. We clone from `self.wait_file` (itself a
|
||||
/// clone taken at construction) rather than from the original file.
|
||||
///
|
||||
/// Returns `None` only if `wait_file` is `None` (construction
|
||||
/// try_clone failed) or if the second-level try_clone fails — both
|
||||
/// rare; the fallback raw-fd path in `wait_after_with_timeout`
|
||||
/// handles that case.
|
||||
#[inline]
|
||||
fn clone_for_worker(&self) -> Option<File> {
|
||||
self.wait_file.as_ref().and_then(|f| f.try_clone().ok())
|
||||
}
|
||||
|
||||
/// Caller advanced the file position to `pos`. If a chunk boundary
|
||||
/// was crossed, kick async writeback for the just-completed chunk
|
||||
/// and finalise the previous one.
|
||||
@@ -143,16 +179,35 @@ impl WritebackPipeline {
|
||||
if pos < self.last_flush_pos.saturating_add(self.chunk_bytes) {
|
||||
return;
|
||||
}
|
||||
let chunk_off = self.last_flush_pos as i64;
|
||||
let chunk_len = (pos - self.last_flush_pos) as i64;
|
||||
// Byte offsets are unsigned throughout; the signed cast happens
|
||||
// only at the libc call boundary where the kernel ABI requires
|
||||
// `i64`. `saturating_sub` documents and hardens the line-above
|
||||
// guard that `pos >= last_flush_pos`.
|
||||
let chunk_off: u64 = self.last_flush_pos;
|
||||
let chunk_len: u64 = pos.saturating_sub(self.last_flush_pos);
|
||||
let mut wait_ms: u64 = 0;
|
||||
let mut fadvise_ms: u64 = 0;
|
||||
// Async kickoff for the just-completed chunk runs on every
|
||||
// path (NFS, degraded, normal) — it's nominally non-blocking
|
||||
// by spec and gives the kernel an early hint that this range
|
||||
// is ready to flush.
|
||||
unsafe {
|
||||
libc::sync_file_range(self.fd, chunk_off, chunk_len, libc::SYNC_FILE_RANGE_WRITE);
|
||||
let kickoff_rc = unsafe {
|
||||
libc::sync_file_range(
|
||||
self.fd,
|
||||
chunk_off as i64,
|
||||
chunk_len as i64,
|
||||
libc::SYNC_FILE_RANGE_WRITE,
|
||||
)
|
||||
};
|
||||
if kickoff_rc != 0 {
|
||||
// Non-fatal: the async write-out hint failed, but the data is
|
||||
// still in the page cache and will be flushed by later fsync /
|
||||
// kernel writeback. Surface it for diagnosability.
|
||||
tracing::warn!(
|
||||
target: "freemkv::io",
|
||||
errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
|
||||
"sync_file_range(WRITE) kickoff failed"
|
||||
);
|
||||
}
|
||||
if let Some((prev_off, prev_len)) = self.pending.take() {
|
||||
if self.skip_wait() {
|
||||
@@ -166,7 +221,8 @@ impl WritebackPipeline {
|
||||
// we mark the pipeline degraded, log a loud error,
|
||||
// and fall through to the skip path on subsequent
|
||||
// calls.
|
||||
match wait_after_with_timeout(self.fd, prev_off, prev_len) {
|
||||
match wait_after_with_timeout(self.clone_for_worker(), self.fd, prev_off, prev_len)
|
||||
{
|
||||
Some(ms) => {
|
||||
wait_ms = ms;
|
||||
let t_fadv = Instant::now();
|
||||
@@ -187,9 +243,16 @@ impl WritebackPipeline {
|
||||
// NOT call DONTNEED — if WAIT_AFTER hasn't
|
||||
// returned, the pages aren't safely flushed.
|
||||
self.degraded.store(true, Ordering::Relaxed);
|
||||
// Once degraded we skip DONTNEED, so every subsequent
|
||||
// chunk's pages stay resident until close — the same
|
||||
// page-cache exposure profile as NFS. Shrink to the
|
||||
// floor so that exposure window is as small as the NFS
|
||||
// path keeps it, instead of whatever the adaptive sizing
|
||||
// had grown chunk_bytes to (up to 256 MiB).
|
||||
self.chunk_bytes = CHUNK_BYTES_MIN;
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackPipeline WAIT_AFTER timed out after {}s on chunk off={} len={}, marking writeback degraded (subsequent chunks will skip WAIT_AFTER + DONTNEED)",
|
||||
"WritebackPipeline WAIT_AFTER timed out after {}s on chunk off={} len={}, marking writeback degraded (subsequent chunks will skip WAIT_AFTER + DONTNEED, chunk_bytes lowered to floor)",
|
||||
WAIT_AFTER_TIMEOUT.as_secs(),
|
||||
prev_off,
|
||||
prev_len
|
||||
@@ -198,12 +261,12 @@ impl WritebackPipeline {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.pending = Some((chunk_off as u64, chunk_len as u64));
|
||||
self.pending = Some((chunk_off, chunk_len));
|
||||
self.last_flush_pos = pos;
|
||||
self.chunk_count += 1;
|
||||
tracing::trace!(
|
||||
target: "mux",
|
||||
"WritebackPipeline chunk off={} len={} sync_file_range_ms={wait_ms} fadvise_ms={fadvise_ms} chunk_bytes={} skip_wait={}",
|
||||
"WritebackPipeline chunk off={} len={} wait_after_ms={wait_ms} fadvise_ms={fadvise_ms} chunk_bytes={} skip_wait={}",
|
||||
chunk_off,
|
||||
chunk_len,
|
||||
self.chunk_bytes,
|
||||
@@ -231,10 +294,14 @@ impl WritebackPipeline {
|
||||
if self.wait_after_window.len() < ADAPTIVE_WINDOW {
|
||||
return;
|
||||
}
|
||||
// p95 of 16 samples ≈ sorted[14] (5 % of 16 = 0.8 ≈ 1 above).
|
||||
// p95 index, derived from the window size so it stays valid if
|
||||
// ADAPTIVE_WINDOW changes (a hard-coded `[14]` would panic OOB
|
||||
// for a window <= 14). For the default 16 this is index 15
|
||||
// (ceil(16 * 95 / 100) - 1 = 15), i.e. the top sample.
|
||||
let mut sorted: Vec<u64> = self.wait_after_window.iter().copied().collect();
|
||||
sorted.sort_unstable();
|
||||
let p95 = sorted[14];
|
||||
let p95_idx = (ADAPTIVE_WINDOW * 95).div_ceil(100).min(ADAPTIVE_WINDOW) - 1;
|
||||
let p95 = sorted[p95_idx];
|
||||
let old = self.chunk_bytes;
|
||||
let new = if p95 > ADAPTIVE_GROW_MS && self.chunk_bytes < CHUNK_BYTES_MAX {
|
||||
(self.chunk_bytes * 2).min(CHUNK_BYTES_MAX)
|
||||
@@ -278,7 +345,7 @@ impl WritebackPipeline {
|
||||
// paths.
|
||||
return;
|
||||
}
|
||||
match wait_after_with_timeout(self.fd, prev_off, prev_len) {
|
||||
match wait_after_with_timeout(self.clone_for_worker(), self.fd, prev_off, prev_len) {
|
||||
Some(_ms) => unsafe {
|
||||
libc::posix_fadvise(
|
||||
self.fd,
|
||||
@@ -321,16 +388,56 @@ fn detect_nfs(fd: RawFd) -> bool {
|
||||
/// Run `sync_file_range(WAIT_AFTER)` on a worker thread and wait up
|
||||
/// to [`WAIT_AFTER_TIMEOUT`] for it to return. `Some(elapsed_ms)` on
|
||||
/// success; `None` on timeout. On timeout the worker thread is
|
||||
/// 0.20.6 generalizes the worker-thread + recv_timeout pattern into
|
||||
/// [`crate::io::bounded::bounded_syscall`]; this helper now just adapts
|
||||
/// the generic primitive to the WAIT_AFTER call shape (returns elapsed_ms
|
||||
/// instead of the syscall's `()` return, treats `WorkerLost` as a benign
|
||||
/// no-op to match the original semantics).
|
||||
fn wait_after_with_timeout(fd: RawFd, off: u64, len: u64) -> Option<u64> {
|
||||
/// intentionally leaked — it unwinds whenever the syscall eventually
|
||||
/// returns or the process exits.
|
||||
///
|
||||
/// This delegates to [`crate::io::bounded::bounded_syscall`], the
|
||||
/// generic worker-thread + `recv_timeout` primitive, and just adapts it
|
||||
/// to the WAIT_AFTER call shape: it returns `elapsed_ms` instead of the
|
||||
/// syscall's `()`, and treats `WorkerLost` as a benign no-op to match
|
||||
/// the original semantics.
|
||||
///
|
||||
/// ## fd lifetime / fd-reuse safety
|
||||
///
|
||||
/// `worker_file` is an *owned* `File` (produced by `File::try_clone` at
|
||||
/// pipeline construction). It is moved into the worker closure so the
|
||||
/// file description stays alive for exactly as long as the worker thread
|
||||
/// lives — even if the original `WritebackFile` is closed and the OS
|
||||
/// reuses its fd number before the worker's syscall returns.
|
||||
///
|
||||
/// `fallback_fd` is used only when `worker_file` is `None` (i.e. the
|
||||
/// `try_clone` at construction failed). In that case the worker captures
|
||||
/// the raw fd integer, which carries the original fd-reuse risk but is
|
||||
/// no worse than the pre-fix behaviour.
|
||||
fn wait_after_with_timeout(
|
||||
worker_file: Option<File>,
|
||||
fallback_fd: RawFd,
|
||||
off: u64,
|
||||
len: u64,
|
||||
) -> Option<u64> {
|
||||
let started = Instant::now();
|
||||
match crate::io::bounded::bounded_syscall(None, WAIT_AFTER_TIMEOUT, move || unsafe {
|
||||
let result = if let Some(owned) = worker_file {
|
||||
// Happy path: the closure owns a cloned File that keeps the
|
||||
// file description alive until the worker drops it.
|
||||
crate::io::bounded::bounded_syscall(None, WAIT_AFTER_TIMEOUT, move || unsafe {
|
||||
let fd = owned.as_raw_fd();
|
||||
libc::sync_file_range(fd, off as i64, len as i64, libc::SYNC_FILE_RANGE_WAIT_AFTER);
|
||||
}) {
|
||||
// `owned` drops here, closing the cloned fd.
|
||||
})
|
||||
} else {
|
||||
// Fallback: try_clone failed at construction; use the raw fd.
|
||||
// This carries the pre-fix fd-reuse risk on timeout, but is no
|
||||
// regression from the original behaviour.
|
||||
crate::io::bounded::bounded_syscall(None, WAIT_AFTER_TIMEOUT, move || unsafe {
|
||||
libc::sync_file_range(
|
||||
fallback_fd,
|
||||
off as i64,
|
||||
len as i64,
|
||||
libc::SYNC_FILE_RANGE_WAIT_AFTER,
|
||||
);
|
||||
})
|
||||
};
|
||||
match result {
|
||||
Ok(()) => Some(started.elapsed().as_millis() as u64),
|
||||
Err(crate::io::bounded::BoundedError::Timeout)
|
||||
| Err(crate::io::bounded::BoundedError::Halted) => None,
|
||||
@@ -456,4 +563,64 @@ mod tests {
|
||||
assert_eq!(p.chunk_count, before);
|
||||
assert!(p.pending.is_none());
|
||||
}
|
||||
|
||||
// ── Bug-fix regression tests ────────────────────────────────────────
|
||||
|
||||
/// Regression for the fd-reuse / use-after-close fix. Verifies that
|
||||
/// `WritebackPipeline::new` successfully clones the fd into
|
||||
/// `wait_file` (i.e. `try_clone` doesn't fail for a normal
|
||||
/// tempfile) and that `clone_for_worker` returns `Some` — meaning
|
||||
/// the WAIT_AFTER worker will capture an owned `File` rather than a
|
||||
/// raw fd integer.
|
||||
///
|
||||
/// A deterministic test for the actual fd-reuse race is not clean to
|
||||
/// write (it would require simultaneously closing the original File
|
||||
/// and re-opening a new one to steal the fd number while the worker
|
||||
/// is mid-syscall, which is inherently racy). This test instead pins
|
||||
/// the structural invariant: on a normal local file, the pipeline
|
||||
/// holds a valid clone and will give the worker an owned File.
|
||||
#[test]
|
||||
fn wait_file_clone_is_present_for_local_tempfile() {
|
||||
let (_f, p) = local_pipeline(32 * 1024 * 1024);
|
||||
assert!(
|
||||
p.wait_file.is_some(),
|
||||
"wait_file must be Some for a normal local tempfile (try_clone should not fail)"
|
||||
);
|
||||
// clone_for_worker must return Some — the worker will get an
|
||||
// owned File, not fall through to the raw-fd fallback.
|
||||
let worker_clone = p.clone_for_worker();
|
||||
assert!(
|
||||
worker_clone.is_some(),
|
||||
"clone_for_worker must return Some when wait_file is Some"
|
||||
);
|
||||
}
|
||||
|
||||
/// Structural: the worker `File` clone returned by `clone_for_worker`
|
||||
/// is a distinct file descriptor (different fd number) that refers to
|
||||
/// the same underlying file. Closing the original tempfile must not
|
||||
/// affect the clone's validity — the OS keeps the file description
|
||||
/// alive until all file descriptors referring to it are closed.
|
||||
///
|
||||
/// We verify "distinct fd number" and "still usable as a raw fd"
|
||||
/// without actually racing a syscall.
|
||||
#[test]
|
||||
fn worker_clone_has_distinct_fd_from_original() {
|
||||
let f = NamedTempFile::new().expect("tempfile create");
|
||||
let original_fd = f.as_file().as_raw_fd();
|
||||
let pipeline = WritebackPipeline::new(f.as_file(), 0, 32 * 1024 * 1024);
|
||||
|
||||
let clone = pipeline
|
||||
.clone_for_worker()
|
||||
.expect("clone_for_worker returned None");
|
||||
let clone_fd = clone.as_raw_fd();
|
||||
|
||||
// The clone must have a different fd number — it is a separate
|
||||
// open file description (dup'd by try_clone).
|
||||
assert_ne!(
|
||||
clone_fd, original_fd,
|
||||
"worker clone must have a distinct fd number from the original"
|
||||
);
|
||||
// The clone fd must be valid (non-negative on Unix).
|
||||
assert!(clone_fd >= 0, "clone fd must be non-negative");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,10 @@ use std::time::Duration;
|
||||
pub(super) fn preallocate(file: &File, size_bytes: u64) {
|
||||
// FALLOC_FL_KEEP_SIZE = 0x01 — keep the reported file size at 0
|
||||
// (writes grow it normally) while still pre-reserving the extents.
|
||||
let rc = unsafe {
|
||||
libc::fallocate(
|
||||
file.as_raw_fd(),
|
||||
libc::FALLOC_FL_KEEP_SIZE,
|
||||
0,
|
||||
size_bytes as i64,
|
||||
)
|
||||
};
|
||||
// Clamp to the signed `off_t` range; an unchecked `as i64` cast
|
||||
// would wrap a >= 2^63 size to a negative length (EINVAL no-op).
|
||||
let len = i64::try_from(size_bytes).unwrap_or(i64::MAX);
|
||||
let rc = unsafe { libc::fallocate(file.as_raw_fd(), libc::FALLOC_FL_KEEP_SIZE, 0, len) };
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"WritebackFile fallocate size_hint={size_bytes} rc={rc} ok={}",
|
||||
@@ -34,17 +30,46 @@ pub(super) fn preallocate(file: &File, size_bytes: u64) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Run `fsync` on `file` with a 60 s deadline. On timeout we log loudly
|
||||
/// and return `Ok(())` — the kernel will still flush on close, so the
|
||||
/// data is best-effort durable; the alternative (trap the thread for
|
||||
/// the rest of the rip) defeats `/api/stop`.
|
||||
/// Run `fsync` on `file` with a 60 s deadline. On timeout — and
|
||||
/// likewise on halt or a lost worker — we log and return `Ok(())`: the
|
||||
/// kernel will still flush on close, so the data is best-effort durable.
|
||||
/// The alternative (trap the thread for the rest of the rip, or return
|
||||
/// an error that aborts an otherwise-complete mux) is worse, so all
|
||||
/// three fallbacks return `Ok(())`. `Ok(())` from these paths is NOT a
|
||||
/// durability barrier — the durable flush did not complete; only the
|
||||
/// hang is bounded.
|
||||
///
|
||||
/// ## fd-reuse safety
|
||||
///
|
||||
/// The `fsync` runs on a bounded worker thread that may be leaked on
|
||||
/// timeout. To avoid the leaked worker's syscall hitting a recycled fd
|
||||
/// number after the original `File` is closed, we `try_clone` an owned
|
||||
/// `File` and move it into the closure. The clone keeps the underlying
|
||||
/// file description alive for as long as the worker thread lives.
|
||||
/// On `try_clone` failure (rare) we fall back to the raw fd integer —
|
||||
/// no worse than the previous behaviour.
|
||||
pub(super) fn durable_sync(file: &File) -> io::Result<()> {
|
||||
// Clone so a leaked worker thread retains a valid fd even after the
|
||||
// original File is closed and its fd number is reused.
|
||||
let owned = match file.try_clone() {
|
||||
Ok(f) => Some(f),
|
||||
Err(e) => {
|
||||
let fd = file.as_raw_fd();
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fd={fd}: try_clone failed ({e}), fsync worker will use raw fd (fd-reuse risk on timeout)"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let fallback_fd = file.as_raw_fd();
|
||||
match crate::io::bounded::bounded_syscall(
|
||||
None,
|
||||
Duration::from_secs(60),
|
||||
move || -> io::Result<()> {
|
||||
let fd = owned.as_ref().map(|f| f.as_raw_fd()).unwrap_or(fallback_fd);
|
||||
let rc = unsafe { libc::fsync(fd) };
|
||||
// `owned` (if Some) drops here, releasing the cloned fd.
|
||||
if rc == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -60,7 +85,61 @@ pub(super) fn durable_sync(file: &File) -> io::Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(crate::io::bounded::BoundedError::Halted) => Ok(()),
|
||||
Err(crate::io::bounded::BoundedError::WorkerLost) => Ok(()),
|
||||
Err(crate::io::bounded::BoundedError::Halted) => {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fsync skipped (halt requested); data not durably flushed, kernel will flush on close"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(crate::io::bounded::BoundedError::WorkerLost) => {
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fsync worker lost before completion; data not durably flushed, kernel will flush on close"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(target_os = "linux")]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
/// Regression for the fd-reuse / use-after-close fix in `durable_sync`.
|
||||
///
|
||||
/// Verifies the structural invariant: `try_clone` succeeds for a normal
|
||||
/// local tempfile, and the cloned `File` has a distinct fd number from
|
||||
/// the original. This pins the property that a leaked fsync worker thread
|
||||
/// captures an owned `File` (and thus keeps the file description alive)
|
||||
/// rather than a bare fd integer that can be reused after the original
|
||||
/// `File` closes.
|
||||
///
|
||||
/// The actual fd-reuse race is non-deterministic and not cleanly
|
||||
/// testable without coordinating a simultaneous close + re-open on
|
||||
/// another thread. A structural test is the accepted substitute.
|
||||
#[test]
|
||||
fn durable_sync_worker_uses_owned_clone_with_distinct_fd() {
|
||||
let f = NamedTempFile::new().expect("tempfile create");
|
||||
let original_fd = f.as_file().as_raw_fd();
|
||||
|
||||
// try_clone must succeed for a normal local file.
|
||||
let owned = f
|
||||
.as_file()
|
||||
.try_clone()
|
||||
.expect("try_clone must succeed for a local tempfile");
|
||||
let clone_fd = owned.as_raw_fd();
|
||||
|
||||
// The clone must be a distinct fd (dup'd, not aliased).
|
||||
assert_ne!(
|
||||
clone_fd, original_fd,
|
||||
"owned clone must have a distinct fd number — not an alias of the original"
|
||||
);
|
||||
assert!(clone_fd >= 0, "clone fd must be a valid non-negative fd");
|
||||
|
||||
// durable_sync must complete without error on the local tempfile.
|
||||
durable_sync(f.as_file()).expect("durable_sync must return Ok on a local tempfile");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! macOS platform impl for [`super::WritebackFile`].
|
||||
//!
|
||||
//! - `preallocate`: `fcntl(F_PREALLOCATE)` — macOS's fallocate-equiv.
|
||||
//! Reserves a contiguous extent when possible, falling back to a
|
||||
//! non-contiguous reservation if the FS can't satisfy it. Reported
|
||||
//! file size is unchanged (`F_ALLOCATEALL` is not set, so allocation
|
||||
//! is "best effort up to length"; growth happens via writes).
|
||||
//! First attempt requests `F_ALLOCATECONTIG | F_ALLOCATEALL` (prefer a
|
||||
//! contiguous run but accept scattered extents to satisfy the full
|
||||
//! length), falling back to `F_ALLOCATEALL` alone on failure.
|
||||
//! `F_PREALLOCATE` never advances EOF regardless of the flags — only
|
||||
//! `ftruncate`/writes grow the file — so the reported file size is
|
||||
//! unchanged; `F_ALLOCATEALL` governs the contiguity fallback, not size.
|
||||
//! - `durable_sync`: `fcntl(F_FULLFSYNC)` wrapped in
|
||||
//! [`crate::io::bounded::bounded_syscall`] with a 60 s deadline.
|
||||
//! F_FULLFSYNC is HFS+/APFS's true-fsync (flushes the disk's own
|
||||
@@ -25,11 +27,14 @@ use crate::io::platform_macos::{
|
||||
const F_FULLFSYNC: libc::c_int = 51;
|
||||
|
||||
pub(super) fn preallocate(file: &File, size_bytes: u64) {
|
||||
// Clamp to the signed `off_t` range; an unchecked `as off_t` cast
|
||||
// would wrap a >= 2^63 size to a negative length.
|
||||
let len = i64::try_from(size_bytes).unwrap_or(i64::MAX) as libc::off_t;
|
||||
let mut fst = Fstore {
|
||||
fst_flags: F_ALLOCATECONTIG | F_ALLOCATEALL,
|
||||
fst_posmode: F_PEOFPOSMODE,
|
||||
fst_offset: 0,
|
||||
fst_length: size_bytes as libc::off_t,
|
||||
fst_length: len,
|
||||
fst_bytesalloc: 0,
|
||||
};
|
||||
// First attempt: contiguous.
|
||||
@@ -47,28 +52,54 @@ pub(super) fn preallocate(file: &File, size_bytes: u64) {
|
||||
);
|
||||
}
|
||||
|
||||
/// ## fd-reuse safety
|
||||
///
|
||||
/// The F_FULLFSYNC / fsync runs on a bounded worker thread that may be
|
||||
/// leaked on timeout. To avoid the leaked worker's syscall hitting a
|
||||
/// recycled fd number after the original `File` is closed, we
|
||||
/// `try_clone` an owned `File` and move it into the closure. The clone
|
||||
/// keeps the underlying file description alive for as long as the worker
|
||||
/// thread lives. On `try_clone` failure (rare) we fall back to the raw
|
||||
/// fd integer — no worse than the previous behaviour.
|
||||
pub(super) fn durable_sync(file: &File) -> io::Result<()> {
|
||||
// Clone so a leaked worker thread retains a valid fd even after the
|
||||
// original File is closed and its fd number is reused.
|
||||
let owned = match file.try_clone() {
|
||||
Ok(f) => Some(f),
|
||||
Err(e) => {
|
||||
let fd = file.as_raw_fd();
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fd={fd}: try_clone failed ({e}), F_FULLFSYNC worker will use raw fd (fd-reuse risk on timeout)"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let fallback_fd = file.as_raw_fd();
|
||||
match crate::io::bounded::bounded_syscall(
|
||||
None,
|
||||
Duration::from_secs(60),
|
||||
move || -> io::Result<()> {
|
||||
let fd = owned.as_ref().map(|f| f.as_raw_fd()).unwrap_or(fallback_fd);
|
||||
// Try F_FULLFSYNC first. If it isn't supported on this
|
||||
// filesystem (older HFS, some network mounts) fall back to
|
||||
// plain fsync — better than nothing.
|
||||
let rc = unsafe { libc::fcntl(fd, F_FULLFSYNC, 0) };
|
||||
if rc == 0 {
|
||||
// `owned` (if Some) drops here, releasing the cloned fd.
|
||||
return Ok(());
|
||||
}
|
||||
let err = io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::ENOTSUP) {
|
||||
let rc = unsafe { libc::fsync(fd) };
|
||||
// `owned` drops here.
|
||||
if rc == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
} else {
|
||||
// `owned` drops here.
|
||||
Err(err)
|
||||
}
|
||||
},
|
||||
@@ -85,3 +116,44 @@ pub(super) fn durable_sync(file: &File) -> io::Result<()> {
|
||||
Err(crate::io::bounded::BoundedError::WorkerLost) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(target_os = "macos")]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
/// Regression for the fd-reuse / use-after-close fix in `durable_sync`.
|
||||
///
|
||||
/// Verifies the structural invariant: `try_clone` succeeds for a normal
|
||||
/// local tempfile, and the cloned `File` has a distinct fd number from
|
||||
/// the original. This pins the property that a leaked F_FULLFSYNC/fsync
|
||||
/// worker thread captures an owned `File` (keeping the file description
|
||||
/// alive) rather than a bare fd integer that can be reused after the
|
||||
/// original `File` closes.
|
||||
///
|
||||
/// The actual fd-reuse race is non-deterministic; a structural test is
|
||||
/// the accepted substitute.
|
||||
#[test]
|
||||
fn durable_sync_worker_uses_owned_clone_with_distinct_fd() {
|
||||
let f = NamedTempFile::new().expect("tempfile create");
|
||||
let original_fd = f.as_file().as_raw_fd();
|
||||
|
||||
// try_clone must succeed for a normal local file.
|
||||
let owned = f
|
||||
.as_file()
|
||||
.try_clone()
|
||||
.expect("try_clone must succeed for a local tempfile");
|
||||
let clone_fd = owned.as_raw_fd();
|
||||
|
||||
// The clone must be a distinct fd (dup'd, not aliased).
|
||||
assert_ne!(
|
||||
clone_fd, original_fd,
|
||||
"owned clone must have a distinct fd number — not an alias of the original"
|
||||
);
|
||||
assert!(clone_fd >= 0, "clone fd must be a valid non-negative fd");
|
||||
|
||||
// durable_sync must complete without error on the local tempfile.
|
||||
durable_sync(f.as_file()).expect("durable_sync must return Ok on a local tempfile");
|
||||
}
|
||||
}
|
||||
|
||||
+354
-23
@@ -24,29 +24,32 @@
|
||||
//! ## Platform split
|
||||
//!
|
||||
//! The platform-specific pieces of this wrapper — extent preallocation
|
||||
//! (Linux `fallocate(KEEP_SIZE)`, macOS `F_PREALLOCATE`, Windows
|
||||
//! `SetFileValidData`) and the durable-flush primitive (Linux/macOS
|
||||
//! `fsync`/`F_FULLFSYNC` wrapped in a bounded syscall, Windows
|
||||
//! `FlushFileBuffers`) — live in per-OS sibling modules. The dispatch
|
||||
//! happens once at the bottom of this file via cfg-gated `mod` decls.
|
||||
//! No inline `#[cfg(target_os = "...")]` in the business-logic above.
|
||||
//! (Linux `fallocate(KEEP_SIZE)`, macOS `F_PREALLOCATE`, Windows no-op
|
||||
//! today) and the durable-flush primitive (Linux/macOS
|
||||
//! `fsync`/`F_FULLFSYNC` wrapped in a bounded syscall; Windows plain
|
||||
//! `FlushFileBuffers`, unbounded) — live in per-OS sibling modules. The
|
||||
//! dispatch happens once at the bottom of this file via cfg-gated `mod`
|
||||
//! decls. No inline `#[cfg(target_os = "...")]` in the business-logic
|
||||
//! above.
|
||||
//!
|
||||
//! ## Write path
|
||||
//!
|
||||
//! Writes are direct passthrough to the underlying `File` (no writer
|
||||
//! thread, no ring, no batching). Empirically the Phase-2.5
|
||||
//! writer-thread architecture introduced a ~60% mux throughput
|
||||
//! regression on NFS bidirectional workloads; reverting the write path
|
||||
//! to direct passthrough restores the 0.20.7 baseline. The writeback
|
||||
//! pipeline still runs (it's called inline from `write` / `write_all` /
|
||||
//! `seek`) so the bounded-cache invariant on Linux is preserved.
|
||||
//! thread, no ring, no batching). Empirically a writer-thread
|
||||
//! architecture introduced a ~60% mux throughput regression on NFS
|
||||
//! bidirectional workloads; the direct-passthrough write path is faster.
|
||||
//! The writeback pipeline still runs (it's called inline from `write` /
|
||||
//! `write_all` / `seek`) so the bounded-cache invariant on Linux is
|
||||
//! preserved.
|
||||
//!
|
||||
//! ## Halt-safety
|
||||
//!
|
||||
//! `sync_all` runs the per-OS durable-flush primitive, which on
|
||||
//! Linux/macOS is wrapped in [`crate::io::bounded::bounded_syscall`]
|
||||
//! with a 60 s deadline. A wedged NFS server cannot trap the muxer
|
||||
//! indefinitely on the final fsync.
|
||||
//! `sync_all` runs the per-OS durable-flush primitive. On Linux/macOS
|
||||
//! it is wrapped in [`crate::io::bounded::bounded_syscall`] with a 60 s
|
||||
//! deadline, so a wedged NFS server cannot trap the muxer indefinitely
|
||||
//! on the final fsync. Windows is a known deviation: its `durable_sync`
|
||||
//! calls `File::sync_all` (`FlushFileBuffers`) directly and is NOT
|
||||
//! bounded — a wedged UNC/SMB share can block the final flush there.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
@@ -73,18 +76,23 @@ use std::path::Path;
|
||||
use super::writeback::WritebackPipeline;
|
||||
|
||||
/// Granularity at which the Linux writeback pipeline issues
|
||||
/// `sync_file_range` pairs. 32 MiB is the empirically best value on
|
||||
/// the rip1 test bed (NFS to unraid-1 over 1 GbE, single-disk SAS):
|
||||
/// 8 MiB / 64 MiB / 128 MiB all measured worse in the 0.21.x mux
|
||||
/// iteration runs. Override via `FREEMKV_WRITEBACK_CHUNK_MIB` —
|
||||
/// faster backends (NVMe, RAID) may tolerate larger windows.
|
||||
/// `sync_file_range` pairs. 32 MiB is the empirically best value on a
|
||||
/// 1 GbE NFS mount backed by a single spinning disk: 8 MiB / 64 MiB /
|
||||
/// 128 MiB all measured worse. Override via `FREEMKV_WRITEBACK_CHUNK_MIB`
|
||||
/// — faster backends (NVMe, RAID) may tolerate larger windows.
|
||||
const WRITEBACK_CHUNK_BYTES_DEFAULT: u64 = 32 * 1024 * 1024;
|
||||
|
||||
/// Upper bound (in MiB) accepted from `FREEMKV_WRITEBACK_CHUNK_MIB`.
|
||||
/// 64 GiB — far above `CHUNK_BYTES_MAX` (256 MiB), generous for any
|
||||
/// real backend, and small enough that `n * 1024 * 1024` cannot wrap
|
||||
/// `u64`. Out-of-range values fall back to the default.
|
||||
const WRITEBACK_CHUNK_MIB_MAX: u64 = 64 * 1024;
|
||||
|
||||
fn writeback_chunk_bytes() -> u64 {
|
||||
std::env::var("FREEMKV_WRITEBACK_CHUNK_MIB")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.filter(|&n| n > 0)
|
||||
.filter(|&n| n > 0 && n <= WRITEBACK_CHUNK_MIB_MAX)
|
||||
.map(|n| n * 1024 * 1024)
|
||||
.unwrap_or(WRITEBACK_CHUNK_BYTES_DEFAULT)
|
||||
}
|
||||
@@ -93,6 +101,13 @@ pub(crate) struct WritebackFile {
|
||||
file: File,
|
||||
pipeline: WritebackPipeline,
|
||||
pos: u64,
|
||||
/// Count of position-moving seeks (for the finalize summary). The MKV muxer
|
||||
/// seeks back occasionally (cluster size patching, Cues, Segment header
|
||||
/// backpatch); the per-seek DEBUG line is trace-level now, and this rolls
|
||||
/// the total into one finalize summary.
|
||||
seek_count: u64,
|
||||
/// Sum of |delta| over all position-moving seeks, in bytes.
|
||||
seek_bytes: u64,
|
||||
}
|
||||
|
||||
impl WritebackFile {
|
||||
@@ -107,6 +122,8 @@ impl WritebackFile {
|
||||
file,
|
||||
pipeline,
|
||||
pos,
|
||||
seek_count: 0,
|
||||
seek_bytes: 0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -160,7 +177,22 @@ impl WritebackFile {
|
||||
/// trap the calling thread indefinitely. On timeout the page cache
|
||||
/// is left to the kernel's normal flush-on-close path — best
|
||||
/// effort, but bounded.
|
||||
///
|
||||
/// IMPORTANT: on Linux/macOS a successful `Ok(())` does NOT
|
||||
/// guarantee the data is durable if the bounded fsync timed out or
|
||||
/// was halted — only the hang is bounded, the fsync may not have
|
||||
/// completed. Callers needing crash-consistency (e.g. mux-finish
|
||||
/// then external commit/DB update) must not treat `Ok(())` as a
|
||||
/// durability barrier.
|
||||
pub(crate) fn sync_all(&mut self) -> io::Result<()> {
|
||||
if self.seek_count > 0 {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"WritebackFile finalize: {} seeks, {} bytes seeked total",
|
||||
self.seek_count,
|
||||
self.seek_bytes
|
||||
);
|
||||
}
|
||||
self.pipeline.finalize();
|
||||
platform::durable_sync(&self.file)
|
||||
}
|
||||
@@ -204,10 +236,14 @@ impl Seek for WritebackFile {
|
||||
let from_pos = self.pos;
|
||||
let to_pos = p;
|
||||
let delta: i64 = (to_pos as i64).wrapping_sub(from_pos as i64);
|
||||
tracing::debug!(
|
||||
// Per-seek detail is trace-level (L4) — benign and high-frequency.
|
||||
// The aggregate (count + total bytes) is logged once at finalize.
|
||||
tracing::trace!(
|
||||
target: "mux",
|
||||
"WritebackFile seek from={from_pos} to={to_pos} delta={delta}"
|
||||
);
|
||||
self.seek_count += 1;
|
||||
self.seek_bytes += delta.unsigned_abs();
|
||||
self.pipeline.handle_seek(p);
|
||||
self.pos = p;
|
||||
}
|
||||
@@ -215,6 +251,21 @@ impl Seek for WritebackFile {
|
||||
}
|
||||
}
|
||||
|
||||
impl super::sink::SequentialSink for WritebackFile {
|
||||
/// Drain the writeback pipeline and run the bounded durable flush —
|
||||
/// the same work [`Self::sync_all`] does. Implemented explicitly (no
|
||||
/// blanket impl) so a `dyn SequentialSink` / `dyn RandomAccessSink`
|
||||
/// `finish()` actually finalises + fsyncs instead of hitting a no-op
|
||||
/// default. Note the bounded-fsync caveat from [`Self::sync_all`]
|
||||
/// applies: `Ok(())` is not a durability barrier if the fsync timed
|
||||
/// out or was halted.
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
self.sync_all()
|
||||
}
|
||||
}
|
||||
|
||||
impl super::sink::RandomAccessSink for WritebackFile {}
|
||||
|
||||
impl Drop for WritebackFile {
|
||||
fn drop(&mut self) {
|
||||
// Run the pipeline's tail finalize so the last in-flight chunk
|
||||
@@ -312,4 +363,284 @@ mod tests {
|
||||
drop(w);
|
||||
assert_eq!(read_back(&p), b"onetwothree");
|
||||
}
|
||||
|
||||
/// finish() through a `dyn RandomAccessSink` trait object must
|
||||
/// dispatch to WritebackFile's override (finalize + durable_sync),
|
||||
/// not a no-op default. Bytes must be visible to a separate reader
|
||||
/// before drop.
|
||||
#[test]
|
||||
fn finish_through_trait_object_persists() {
|
||||
use crate::io::sink::RandomAccessSink;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("finish-dyn.bin");
|
||||
let w = WritebackFile::create(&p).unwrap();
|
||||
let mut boxed: Box<dyn RandomAccessSink> = Box::new(w);
|
||||
boxed.write_all(b"durable-tail").unwrap();
|
||||
boxed.finish().unwrap();
|
||||
assert_eq!(read_back(&p), b"durable-tail");
|
||||
}
|
||||
|
||||
// ── Added hardening tests ───────────────────────────────────────
|
||||
|
||||
/// `write` (not write_all) must return the count the inner File
|
||||
/// reported and advance `pos` by exactly that count (lines
|
||||
/// 185-189). For a regular file a single `write` of a small buffer
|
||||
/// writes all of it. We verify the returned count equals the buffer
|
||||
/// length AND that a subsequent seek reports the right position.
|
||||
/// Mutation: changing `self.pos += n` to `self.pos += buf.len()`
|
||||
/// (lines 187 vs a hypothetical bug) would desync on a partial
|
||||
/// write; here they coincide, but `Seek(Current(0))` reflecting `n`
|
||||
/// still guards the count return value.
|
||||
#[test]
|
||||
fn write_returns_byte_count_and_advances_pos() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("wc.bin");
|
||||
let mut w = WritebackFile::create(&p).unwrap();
|
||||
let n = w.write(b"twelve bytes").unwrap();
|
||||
assert_eq!(n, 12, "write must report bytes written");
|
||||
// pos is private; observe it via the public Seek impl's
|
||||
// stream_position (which resolves to seek(Current(0))).
|
||||
let pos = w.stream_position().unwrap();
|
||||
assert_eq!(pos, 12, "pos not advanced by write count");
|
||||
w.sync_all().unwrap();
|
||||
drop(w);
|
||||
assert_eq!(read_back(&p), b"twelve bytes");
|
||||
}
|
||||
|
||||
/// Redundant seek to the CURRENT position must be a no-op for the
|
||||
/// pipeline (lines 211-228 only act when `p != self.pos`). This is
|
||||
/// the documented sweep optimisation: sweep does
|
||||
/// `seek(Current(pos))` before every write and we must not treat it
|
||||
/// as a boundary. We can only observe the public effect: the seek
|
||||
/// returns the same offset and writes continue contiguously.
|
||||
/// Mutation: removing the `if p != self.pos` guard (line 211) would
|
||||
/// call handle_seek on every redundant seek — on the noop pipeline
|
||||
/// (macOS) this stays correct for data, but the contiguity +
|
||||
/// returned-offset invariant still must hold and is asserted here.
|
||||
#[test]
|
||||
fn seek_to_current_position_is_noop_for_data() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("noop-seek.bin");
|
||||
let mut w = WritebackFile::create(&p).unwrap();
|
||||
w.write_all(b"AAAA").unwrap();
|
||||
// Seek to the current end (offset 4) — a no-move seek.
|
||||
let off = w.seek(SeekFrom::Start(4)).unwrap();
|
||||
assert_eq!(off, 4);
|
||||
w.write_all(b"BBBB").unwrap();
|
||||
w.sync_all().unwrap();
|
||||
drop(w);
|
||||
assert_eq!(
|
||||
read_back(&p),
|
||||
b"AAAABBBB",
|
||||
"redundant seek corrupted contiguous write"
|
||||
);
|
||||
}
|
||||
|
||||
/// `open` (no-truncate) must preserve existing file contents and
|
||||
/// allow in-place patching from offset 0 — distinct from `create`
|
||||
/// which truncates (lines 157-160 use OpenOptions write-only, no
|
||||
/// truncate). We pre-seed a file, reopen with `open`, overwrite the
|
||||
/// first bytes, and confirm the tail survives. Mutation: if `open`
|
||||
/// used `File::create` (truncate) the tail would be lost.
|
||||
#[test]
|
||||
fn open_preserves_existing_contents() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("reopen.bin");
|
||||
std::fs::write(&p, b"ORIGINAL-CONTENT").unwrap();
|
||||
let mut w = WritebackFile::open(&p).unwrap();
|
||||
// open() does NOT truncate; pos starts at 0. Overwrite the
|
||||
// first 8 bytes only.
|
||||
w.write_all(b"PATCHED!").unwrap();
|
||||
w.sync_all().unwrap();
|
||||
drop(w);
|
||||
// First 8 bytes overwritten; the rest of ORIGINAL-CONTENT
|
||||
// ("-CONTENT") survives because there was no truncation.
|
||||
assert_eq!(read_back(&p), b"PATCHED!-CONTENT");
|
||||
}
|
||||
|
||||
/// `open` on a file whose position is queried must start tracking
|
||||
/// from the file's current offset. `WritebackFile::new` calls
|
||||
/// `stream_position()` (line 112); a freshly `open`ed file is at
|
||||
/// offset 0. After writing, seeking Current(0) must reflect the
|
||||
/// bytes written from 0. Mutation: if `new` hardcoded pos=0 instead
|
||||
/// of querying, a non-zero starting offset would desync — covered
|
||||
/// indirectly; here we assert the offset is exactly the write size.
|
||||
#[test]
|
||||
fn new_tracks_initial_position() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("pos-init.bin");
|
||||
std::fs::write(&p, b"0123456789").unwrap();
|
||||
let mut w = WritebackFile::open(&p).unwrap();
|
||||
let start = w.stream_position().unwrap();
|
||||
assert_eq!(start, 0, "freshly opened file should start at offset 0");
|
||||
w.write_all(b"XY").unwrap();
|
||||
let after = w.stream_position().unwrap();
|
||||
assert_eq!(after, 2, "pos must advance by written length");
|
||||
}
|
||||
|
||||
/// Seek past EOF then write must create a sparse hole that reads
|
||||
/// back as zeros — standard POSIX file semantics that the wrapper
|
||||
/// must not break (it forwards seek to the inner File at line 205).
|
||||
/// Mutation: if `seek` clamped or mishandled the offset, the hole
|
||||
/// size/zero-fill would be wrong.
|
||||
#[test]
|
||||
fn seek_past_eof_creates_zero_hole() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("hole.bin");
|
||||
let mut w = WritebackFile::create(&p).unwrap();
|
||||
w.write_all(b"head").unwrap(); // bytes 0..4
|
||||
w.seek(SeekFrom::Start(20)).unwrap(); // jump past EOF
|
||||
w.write_all(b"tail").unwrap(); // bytes 20..24
|
||||
w.sync_all().unwrap();
|
||||
drop(w);
|
||||
let bytes = read_back(&p);
|
||||
assert_eq!(
|
||||
bytes.len(),
|
||||
24,
|
||||
"file should extend to the last written byte"
|
||||
);
|
||||
assert_eq!(&bytes[0..4], b"head");
|
||||
// The 4..20 gap must read back as zeros (sparse hole).
|
||||
assert!(bytes[4..20].iter().all(|&b| b == 0), "hole not zero-filled");
|
||||
assert_eq!(&bytes[20..24], b"tail");
|
||||
}
|
||||
|
||||
/// `SeekFrom::End` must resolve against the actual file length.
|
||||
/// After writing 10 bytes, `seek(End(-2))` lands at offset 8;
|
||||
/// overwriting 2 bytes there patches the tail. Mutation: forwarding
|
||||
/// the wrong SeekFrom variant would land at the wrong offset.
|
||||
#[test]
|
||||
fn seek_from_end_resolves_against_length() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("end-seek.bin");
|
||||
let mut w = WritebackFile::create(&p).unwrap();
|
||||
w.write_all(b"0123456789").unwrap();
|
||||
let landed = w.seek(SeekFrom::End(-2)).unwrap();
|
||||
assert_eq!(landed, 8, "End(-2) of a 10-byte file is offset 8");
|
||||
w.write_all(b"XY").unwrap();
|
||||
w.sync_all().unwrap();
|
||||
drop(w);
|
||||
assert_eq!(read_back(&p), b"01234567XY");
|
||||
}
|
||||
|
||||
/// `create_with_size_hint` must produce a normal, writable file
|
||||
/// whose *reported size* tracks bytes written (the hint only
|
||||
/// reserves extents, per the doc lines 137-145 — it must NOT
|
||||
/// pre-grow the logical file length). We write 5 bytes against a
|
||||
/// 1 MiB hint and the file must be exactly 5 bytes long.
|
||||
/// Mutation: if the hint path truncated/extended to size_bytes the
|
||||
/// length would be 1 MiB and this fails.
|
||||
#[test]
|
||||
fn create_with_size_hint_does_not_inflate_logical_length() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("hint-len.bin");
|
||||
let mut w = WritebackFile::create_with_size_hint(&p, 1024 * 1024).unwrap();
|
||||
w.write_all(b"hello").unwrap();
|
||||
w.sync_all().unwrap();
|
||||
drop(w);
|
||||
let bytes = read_back(&p);
|
||||
assert_eq!(bytes.len(), 5, "size hint must not inflate logical length");
|
||||
assert_eq!(&bytes, b"hello");
|
||||
}
|
||||
|
||||
/// `sync_all` is idempotent: calling it twice (and then Drop, which
|
||||
/// also finalizes) must not corrupt data or panic. Doc lines
|
||||
/// 256-262: `finalize` is idempotent so explicit sync_all then drop
|
||||
/// is safe. Mutation: a finalize that double-freed or advanced a
|
||||
/// cursor would corrupt on the second call.
|
||||
#[test]
|
||||
fn double_sync_all_is_idempotent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("double-sync.bin");
|
||||
let mut w = WritebackFile::create(&p).unwrap();
|
||||
w.write_all(b"idempotent").unwrap();
|
||||
w.sync_all().unwrap();
|
||||
w.sync_all().unwrap(); // second call must be safe
|
||||
drop(w); // Drop also finalizes
|
||||
assert_eq!(read_back(&p), b"idempotent");
|
||||
}
|
||||
|
||||
/// Env-var chunk override parsing (`writeback_chunk_bytes`, lines
|
||||
/// 91-98). Out-of-range / unparseable values must fall back to the
|
||||
/// 32 MiB default; valid in-range values are converted MiB→bytes.
|
||||
/// We can't safely mutate process env in parallel tests for the
|
||||
/// default-path branch, but we CAN assert the pure boundary logic
|
||||
/// the function encodes by reconstructing it: the filter accepts
|
||||
/// `0 < n <= WRITEBACK_CHUNK_MIB_MAX`. This pins the constants and
|
||||
/// the MiB→byte multiply. Mutation: changing `* 1024 * 1024` to a
|
||||
/// single `* 1024` would break this equality.
|
||||
#[test]
|
||||
fn writeback_chunk_constants_and_conversion() {
|
||||
// Default is exactly 32 MiB.
|
||||
assert_eq!(WRITEBACK_CHUNK_BYTES_DEFAULT, 32 * 1024 * 1024);
|
||||
// Max MiB bound is 64 GiB expressed in MiB, and the byte value
|
||||
// it maps to must not overflow u64.
|
||||
assert_eq!(WRITEBACK_CHUNK_MIB_MAX, 64 * 1024);
|
||||
let max_bytes = (WRITEBACK_CHUNK_MIB_MAX as u128) * 1024 * 1024;
|
||||
assert!(
|
||||
max_bytes <= u64::MAX as u128,
|
||||
"max chunk MiB * 1MiB must fit in u64"
|
||||
);
|
||||
}
|
||||
|
||||
/// Env-var override parsing for `writeback_chunk_bytes` (lines
|
||||
/// 91-98). All four branches in ONE test to avoid the data race of
|
||||
/// several parallel tests mutating the same process-global env var.
|
||||
///
|
||||
/// Branches: (1) valid in-range value → MiB→byte conversion; (2)
|
||||
/// zero → `n > 0` filter rejects → default; (3) garbage → parse
|
||||
/// fails → default; (4) over-max → `n <= MAX` filter rejects →
|
||||
/// default.
|
||||
///
|
||||
/// Mutations: `* 1024 * 1024` → `* 1024` breaks (1); dropping
|
||||
/// `n > 0` breaks (2); `unwrap()` on parse panics (3); dropping
|
||||
/// `n <= MAX` breaks (4).
|
||||
#[test]
|
||||
fn writeback_chunk_env_override_branches() {
|
||||
// SAFETY: this is the only test touching this env var, and it
|
||||
// sets+reads+clears synchronously within its own body.
|
||||
let set = |v: &str| unsafe { std::env::set_var("FREEMKV_WRITEBACK_CHUNK_MIB", v) };
|
||||
let clear = || unsafe { std::env::remove_var("FREEMKV_WRITEBACK_CHUNK_MIB") };
|
||||
|
||||
set("8");
|
||||
assert_eq!(
|
||||
writeback_chunk_bytes(),
|
||||
8 * 1024 * 1024,
|
||||
"in-range mis-converted"
|
||||
);
|
||||
|
||||
set("0");
|
||||
assert_eq!(
|
||||
writeback_chunk_bytes(),
|
||||
WRITEBACK_CHUNK_BYTES_DEFAULT,
|
||||
"zero must fall back (n > 0 filter)"
|
||||
);
|
||||
|
||||
set("not-a-number");
|
||||
assert_eq!(
|
||||
writeback_chunk_bytes(),
|
||||
WRITEBACK_CHUNK_BYTES_DEFAULT,
|
||||
"unparseable must fall back"
|
||||
);
|
||||
|
||||
// One past the max: WRITEBACK_CHUNK_MIB_MAX + 1.
|
||||
set(&(WRITEBACK_CHUNK_MIB_MAX + 1).to_string());
|
||||
assert_eq!(
|
||||
writeback_chunk_bytes(),
|
||||
WRITEBACK_CHUNK_BYTES_DEFAULT,
|
||||
"over-max must fall back (n <= MAX filter)"
|
||||
);
|
||||
|
||||
// Exactly at the max boundary is accepted (inclusive bound).
|
||||
set(&WRITEBACK_CHUNK_MIB_MAX.to_string());
|
||||
assert_eq!(
|
||||
writeback_chunk_bytes(),
|
||||
WRITEBACK_CHUNK_MIB_MAX * 1024 * 1024,
|
||||
"max boundary must be accepted (inclusive)"
|
||||
);
|
||||
|
||||
clear();
|
||||
// With the var cleared, the default is returned.
|
||||
assert_eq!(writeback_chunk_bytes(), WRITEBACK_CHUNK_BYTES_DEFAULT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
//! Windows platform impl for [`super::WritebackFile`].
|
||||
//!
|
||||
//! TODO: this stub matches the design's "validate without a Windows
|
||||
//! build env, leave a stub" carve-out. The real impl should use:
|
||||
//! Current behaviour:
|
||||
//!
|
||||
//! - `SetEndOfFile` + `SetFileValidData` for extent preallocation
|
||||
//! (caller needs `SE_MANAGE_VOLUME_NAME` privilege; if unavailable
|
||||
//! fall back to a write-zero path or just skip).
|
||||
//! - `FlushFileBuffers` for fsync-equivalent durable flush.
|
||||
//!
|
||||
//! Until then: preallocate is a debug-logged no-op; durable_sync calls
|
||||
//! the std `File::sync_all` (which on Windows maps to
|
||||
//! `FlushFileBuffers` internally).
|
||||
//! - `preallocate` is a debug-logged no-op. Windows has no
|
||||
//! `fallocate`-equivalent that keeps the reported size, so extent
|
||||
//! reservation is not wired up.
|
||||
//! - `durable_sync` delegates to the std `File::sync_all`, which on
|
||||
//! Windows maps to `FlushFileBuffers`. Unlike the Linux/macOS impls
|
||||
//! this is NOT wrapped in the bounded-syscall primitive (that would
|
||||
//! need an `unsafe impl Send` for `RawHandle`, which cannot be
|
||||
//! validated without a Windows test env), so a wedged UNC/SMB share
|
||||
//! can block the final flush. This deviation is documented on
|
||||
//! [`super::WritebackFile::sync_all`] and the parent module's
|
||||
//! Halt-safety section.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
@@ -18,15 +20,12 @@ use std::io;
|
||||
pub(super) fn preallocate(_file: &File, size_bytes: u64) {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"WritebackFile preallocate size_hint={size_bytes} skipped (windows stub; TODO: SetFileValidData)"
|
||||
"WritebackFile preallocate size_hint={size_bytes} skipped (no-op on windows)"
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn durable_sync(file: &File) -> io::Result<()> {
|
||||
// `File::sync_all` on Windows is `FlushFileBuffers`. Acceptable
|
||||
// for now; the bounded-syscall wrapper is not used here because
|
||||
// the stub also skips the worker-thread + leak machinery (the
|
||||
// wrapper would need an `unsafe impl Send` for `RawHandle`, and
|
||||
// designing that without a Windows test env is asking for it).
|
||||
// `File::sync_all` on Windows is `FlushFileBuffers`. Not wrapped in
|
||||
// the bounded-syscall primitive (see the module doc) — unbounded.
|
||||
file.sync_all()
|
||||
}
|
||||
|
||||
+745
-58
@@ -5,18 +5,77 @@
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Standard keydb storage path.
|
||||
pub fn default_path() -> Result<PathBuf> {
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
/// Network operation timeout (connect / read / write). Keeps the daily
|
||||
/// refresh thread from blocking indefinitely on an unresponsive mirror.
|
||||
const NET_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Read timeout — longer than connect/write since the keydb body can be
|
||||
/// several MiB over a slow link.
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Maximum redirects to follow before giving up.
|
||||
const MAX_REDIRECTS: usize = 5;
|
||||
|
||||
/// Upper bound on decompressed keydb size. The published keydb is a few
|
||||
/// MiB; 64 MiB is a generous ceiling that still caps a decompression
|
||||
/// bomb (a tiny zip/gz can otherwise inflate to GiB and OOM the daily
|
||||
/// refresh thread).
|
||||
const MAX_KEYDB_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
/// Read a decompressed stream into a String with a hard size ceiling.
|
||||
/// Returns `Error::KeydbInvalid` if the input exceeds the cap, or
|
||||
/// `Error::KeydbParse` if the bytes are not valid UTF-8.
|
||||
fn read_capped_to_string<R: Read>(reader: R) -> Result<String> {
|
||||
let mut buf = Vec::new();
|
||||
// Read one byte past the cap so an exactly-at-cap stream is accepted
|
||||
// but anything larger is rejected.
|
||||
reader
|
||||
.take(MAX_KEYDB_BYTES + 1)
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|_| Error::KeydbParse)?;
|
||||
Ok(PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("freemkv")
|
||||
.join("keydb.cfg"))
|
||||
if buf.len() as u64 > MAX_KEYDB_BYTES {
|
||||
return Err(Error::KeydbInvalid);
|
||||
}
|
||||
String::from_utf8(buf).map_err(|_| Error::KeydbParse)
|
||||
}
|
||||
|
||||
/// Build the error returned when the executable's own directory can't be
|
||||
/// determined (`std::env::current_exe()` fails or has no parent). This is an
|
||||
/// *environment* failure — the process can't locate itself, which typically
|
||||
/// signals a stripped container or CI configuration — not a corrupt or
|
||||
/// unparseable keydb file. Map it to a `NotFound` I/O error so
|
||||
/// display/remediation paths never claim a keydb parse failure for a file
|
||||
/// that was never consulted.
|
||||
fn no_home_dir() -> Error {
|
||||
Error::IoError {
|
||||
source: std::io::Error::from(std::io::ErrorKind::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard keydb storage path — the canonical location to write the keydb to.
|
||||
///
|
||||
/// The keydb lives *next to the executable*: `<dir of current exe>/keydb.cfg`,
|
||||
/// where the directory is `std::env::current_exe()`'s parent. This makes
|
||||
/// freemkv a portable, standalone binary — drop the exe and its `keydb.cfg`
|
||||
/// in the same folder and it works, with no OS-specific config dir
|
||||
/// (`%APPDATA%`, `~/.config`, XDG) involved at all.
|
||||
///
|
||||
/// The CLI's read-side search lives in
|
||||
/// `freemkv-keysources::keydb_search_paths`; it resolves the same exe-local
|
||||
/// location, so the *write* default used by `save`/`update` and the read-side
|
||||
/// search always agree.
|
||||
///
|
||||
/// Returns a `NotFound` I/O error (via [`no_home_dir`]) if the executable's
|
||||
/// own directory can't be determined.
|
||||
pub fn default_path() -> Result<PathBuf> {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|dir| dir.join("keydb.cfg")))
|
||||
.ok_or_else(no_home_dir)
|
||||
}
|
||||
|
||||
/// Download a KEYDB from a URL, verify, save to the standard path.
|
||||
@@ -30,13 +89,12 @@ pub fn save(data: &[u8]) -> Result<UpdateResult> {
|
||||
let text = if data.starts_with(b"PK\x03\x04") {
|
||||
extract_zip(data)?
|
||||
} else if data.starts_with(&[0x1f, 0x8b]) {
|
||||
let mut dec = flate2::read::GzDecoder::new(data);
|
||||
let mut out = String::new();
|
||||
dec.read_to_string(&mut out)
|
||||
.map_err(|_| Error::KeydbParse)?;
|
||||
out
|
||||
read_capped_to_string(flate2::read::GzDecoder::new(data))?
|
||||
} else {
|
||||
String::from_utf8(data.to_vec()).map_err(|_| Error::KeydbParse)?
|
||||
// Plain-text body: route through the same capped reader as the gz/zip
|
||||
// branches so an oversized uncompressed upload can't bypass
|
||||
// MAX_KEYDB_BYTES.
|
||||
read_capped_to_string(std::io::Cursor::new(data))?
|
||||
};
|
||||
|
||||
let entries = text
|
||||
@@ -55,14 +113,7 @@ pub fn save(data: &[u8]) -> Result<UpdateResult> {
|
||||
}
|
||||
|
||||
let path = default_path()?;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|_| Error::KeydbWrite {
|
||||
path: path.display().to_string(),
|
||||
})?;
|
||||
}
|
||||
std::fs::write(&path, &text).map_err(|_| Error::KeydbWrite {
|
||||
path: path.display().to_string(),
|
||||
})?;
|
||||
write_atomic(&path, &text)?;
|
||||
|
||||
Ok(UpdateResult {
|
||||
path,
|
||||
@@ -71,6 +122,69 @@ pub fn save(data: &[u8]) -> Result<UpdateResult> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `text` to `path` crash-safely (create parent dir, write a sibling
|
||||
/// temp file, fsync, then atomic rename).
|
||||
///
|
||||
/// keydb.cfg is the single source of AACS truth, and `save`/`update` run
|
||||
/// unattended (first-boot download + daily-refresh thread, with a container
|
||||
/// restart on every release). A bare in-place `fs::write` truncates the file
|
||||
/// before writing, so a SIGKILL (docker stop's grace window), OOM-kill, power
|
||||
/// loss, or ENOSPC mid-write would leave the keydb half-written — the prior
|
||||
/// good copy already gone. A truncated keydb doesn't error at write time; it
|
||||
/// silently breaks key resolution on every later AACS rip. Writing to a temp
|
||||
/// file then renaming (POSIX rename is atomic within a filesystem) means an
|
||||
/// interrupted update leaves the previous keydb fully intact.
|
||||
///
|
||||
/// The fsync MUST succeed before the rename: a `sync_all` failure (ENOSPC,
|
||||
/// ESTALE on the bind-mounted volume) means the kernel never guaranteed the
|
||||
/// bytes reached stable storage, so publishing them via rename would defeat
|
||||
/// crash-safety. The temp name is unique per call (pid + monotonic counter)
|
||||
/// so a concurrent update can't share a fixed temp path and rename a mangled
|
||||
/// file over the keydb.
|
||||
fn write_atomic(path: &std::path::Path, text: &str) -> Result<()> {
|
||||
let werr = || Error::KeydbWrite {
|
||||
path: path.display().to_string(),
|
||||
};
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| {
|
||||
tracing::warn!(error = %e, path = %path.display(), "keydb dir create failed");
|
||||
werr()
|
||||
})?;
|
||||
}
|
||||
let tmp = {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
path.with_extension(format!(
|
||||
"tmp.{}.{}",
|
||||
std::process::id(),
|
||||
TMP_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
))
|
||||
};
|
||||
let write_result = (|| -> std::io::Result<()> {
|
||||
let mut f = std::fs::File::create(&tmp)?;
|
||||
f.write_all(text.as_bytes())?;
|
||||
f.sync_all()?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = write_result {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
tracing::warn!(error = %e, path = %path.display(), "keydb write/fsync failed; keydb unchanged");
|
||||
return Err(werr());
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
tracing::warn!(error = %e, path = %path.display(), "keydb rename failed; keydb unchanged");
|
||||
return Err(werr());
|
||||
}
|
||||
// Durably commit the new dirent: on POSIX filesystems (ext2, some NFS) a
|
||||
// crash right after the rename can lose the directory entry even though the
|
||||
// rename returned. Best-effort (swallowed on failure); no-op on Windows.
|
||||
if let Some(dir) = path.parent() {
|
||||
crate::io::fsync::dir(dir);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Result of a KEYDB update -- path written, entry count, and byte size.
|
||||
#[derive(Debug)]
|
||||
pub struct UpdateResult {
|
||||
@@ -82,83 +196,193 @@ pub struct UpdateResult {
|
||||
fn http_get(url: &str) -> Result<Vec<u8>> {
|
||||
let (mut host, mut port, mut path) = parse_url(url)?;
|
||||
|
||||
for _ in 0..5 {
|
||||
let addr = format!("{host}:{port}");
|
||||
let mut stream =
|
||||
TcpStream::connect(&addr).map_err(|_| Error::KeydbConnect { host: host.clone() })?;
|
||||
for _ in 0..MAX_REDIRECTS {
|
||||
// Resolve to a concrete socket address so we can bound the connect
|
||||
// with connect_timeout (plain connect() uses the OS default, which
|
||||
// can be minutes).
|
||||
let addr = (host.as_str(), port)
|
||||
.to_socket_addrs()
|
||||
.ok()
|
||||
.and_then(|mut it| it.next())
|
||||
.ok_or_else(|| Error::KeydbConnect { host: host.clone() })?;
|
||||
let mut stream = TcpStream::connect_timeout(&addr, NET_TIMEOUT).map_err(|e| {
|
||||
tracing::debug!(error = %e, host = %host, "keydb connect failed");
|
||||
Error::KeydbConnect { host: host.clone() }
|
||||
})?;
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(30)))
|
||||
.ok();
|
||||
.set_read_timeout(Some(READ_TIMEOUT))
|
||||
.map_err(|_| Error::KeydbConnect { host: host.clone() })?;
|
||||
stream
|
||||
.set_write_timeout(Some(NET_TIMEOUT))
|
||||
.map_err(|_| Error::KeydbConnect { host: host.clone() })?;
|
||||
|
||||
// HTTP/1.0 forces close-delimited framing: the server cannot reply
|
||||
// with Transfer-Encoding: chunked, so the raw body is the keydb
|
||||
// bytes with no chunk-size lines to de-frame.
|
||||
let request = format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n"
|
||||
"GET {path} HTTP/1.0\r\nHost: {host}\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n"
|
||||
);
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.map_err(|_| Error::KeydbConnect { host: host.clone() })?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream
|
||||
.take(100 * 1024 * 1024)
|
||||
.read_to_end(&mut response)
|
||||
// Read the header block incrementally up to the \r\n\r\n terminator,
|
||||
// bounded to ~64 KiB, BEFORE pulling any body. This avoids buffering up
|
||||
// to 100 MiB per redirect hop just to inspect the status / Location.
|
||||
const MAX_HEADER_BYTES: usize = 64 * 1024;
|
||||
let mut reader = std::io::BufReader::new(stream);
|
||||
let mut header_buf: Vec<u8> = Vec::with_capacity(1024);
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
let n = reader
|
||||
.read(&mut byte)
|
||||
.map_err(|_| Error::KeydbConnect { host: host.clone() })?;
|
||||
if n == 0 {
|
||||
// Server closed the connection before the header block
|
||||
// completed: a connection/protocol-level fault, not malformed
|
||||
// keydb content.
|
||||
return Err(Error::KeydbConnect { host: host.clone() });
|
||||
}
|
||||
header_buf.push(byte[0]);
|
||||
if header_buf.ends_with(b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
if header_buf.len() >= MAX_HEADER_BYTES {
|
||||
// Oversized header block from the server: a protocol-level
|
||||
// fault, not a keydb content parse failure. `>=` caps the
|
||||
// buffer at exactly MAX_HEADER_BYTES (the `>` form admitted one
|
||||
// extra byte before tripping).
|
||||
return Err(Error::KeydbConnect { host: host.clone() });
|
||||
}
|
||||
}
|
||||
// header_buf includes the trailing \r\n\r\n.
|
||||
let header_end = header_buf.len() - 4;
|
||||
// Lossy: a stray non-UTF-8 byte in the header block must not blank
|
||||
// out the whole status line / Location header (which would surface
|
||||
// as an undiagnosable KeydbHttp{status:0}).
|
||||
let headers = String::from_utf8_lossy(&header_buf[..header_end]).into_owned();
|
||||
let headers = headers.as_str();
|
||||
|
||||
let header_end = find_header_end(&response).ok_or(Error::KeydbParse)?;
|
||||
let headers = std::str::from_utf8(&response[..header_end]).unwrap_or("");
|
||||
let body = &response[header_end + 4..];
|
||||
let status = parse_status(headers).ok_or(Error::KeydbParse)?;
|
||||
|
||||
if let Some(location) = extract_header(headers, "Location") {
|
||||
let parsed = parse_url(&location)?;
|
||||
host = parsed.0;
|
||||
port = parsed.1;
|
||||
path = parsed.2;
|
||||
// Only treat a Location header as a redirect when the status is
|
||||
// actually 3xx; a 200 carrying a stray Location (some proxies) is
|
||||
// not a redirect, and a 3xx without Location is a malformed redirect.
|
||||
if (300..=399).contains(&status) {
|
||||
let location =
|
||||
extract_header(headers, "Location").ok_or(Error::KeydbHttp { status })?;
|
||||
let (next_host, next_port, next_path) = resolve_redirect(&location, &host, port)?;
|
||||
host = next_host;
|
||||
port = next_port;
|
||||
path = next_path;
|
||||
continue;
|
||||
}
|
||||
|
||||
let status = parse_status(headers);
|
||||
if status != 200 {
|
||||
return Err(Error::KeydbHttp { status });
|
||||
}
|
||||
|
||||
return Ok(body.to_vec());
|
||||
// Now read the body, still bounded by the existing 100 MiB cap. The
|
||||
// BufReader carries any bytes already buffered past the header.
|
||||
let mut body = Vec::new();
|
||||
reader
|
||||
.take(100 * 1024 * 1024)
|
||||
.read_to_end(&mut body)
|
||||
.map_err(|_| Error::KeydbConnect { host: host.clone() })?;
|
||||
return Ok(body);
|
||||
}
|
||||
|
||||
Err(Error::KeydbHttp { status: 302 })
|
||||
Err(Error::KeydbTooManyRedirects)
|
||||
}
|
||||
|
||||
/// Resolve a `Location` value against the current request target.
|
||||
/// Handles absolute `http://` URLs, scheme-relative `//host/path`,
|
||||
/// absolute paths `/path`, and rejects unsupported schemes (e.g.
|
||||
/// `https://`, which this dependency-light client cannot fetch) with a
|
||||
/// diagnosable error rather than a generic parse failure.
|
||||
fn resolve_redirect(
|
||||
location: &str,
|
||||
cur_host: &str,
|
||||
cur_port: u16,
|
||||
) -> Result<(String, u16, String)> {
|
||||
let loc = location.trim();
|
||||
|
||||
if let Some(rest) = loc.strip_prefix("//") {
|
||||
// Scheme-relative: //host[:port]/path — inherit http.
|
||||
return parse_url(&format!("http://{rest}"));
|
||||
}
|
||||
if loc.starts_with('/') {
|
||||
// Absolute path on the same host/port.
|
||||
return Ok((cur_host.to_string(), cur_port, loc.to_string()));
|
||||
}
|
||||
if let Some(scheme) = loc.split("://").next() {
|
||||
if loc.contains("://") && !scheme.eq_ignore_ascii_case("http") {
|
||||
return Err(Error::KeydbUnsupportedScheme {
|
||||
scheme: scheme.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
parse_url(loc)
|
||||
}
|
||||
|
||||
fn parse_url(url: &str) -> Result<(String, u16, String)> {
|
||||
// Reject non-http(s) up front so the caller gets a scheme diagnostic
|
||||
// rather than an opaque parse error.
|
||||
if let Some(scheme) = url.split("://").next() {
|
||||
if url.contains("://") && !scheme.eq_ignore_ascii_case("http") {
|
||||
return Err(Error::KeydbUnsupportedScheme {
|
||||
scheme: scheme.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let url = url.strip_prefix("http://").ok_or(Error::KeydbParse)?;
|
||||
let (host_port, path) = match url.find('/') {
|
||||
Some(i) => (&url[..i], &url[i..]),
|
||||
None => (url, "/"),
|
||||
};
|
||||
let (host, port) = match host_port.find(':') {
|
||||
Some(i) => (&host_port[..i], host_port[i + 1..].parse().unwrap_or(80)),
|
||||
Some(i) => {
|
||||
let port_str = &host_port[i + 1..];
|
||||
// A non-empty-but-unparseable port is a malformed URL; only an
|
||||
// omitted port defaults to 80.
|
||||
let port = if port_str.is_empty() {
|
||||
80
|
||||
} else {
|
||||
port_str.parse().map_err(|_| Error::KeydbParse)?
|
||||
};
|
||||
(&host_port[..i], port)
|
||||
}
|
||||
None => (host_port, 80u16),
|
||||
};
|
||||
Ok((host.to_string(), port, path.to_string()))
|
||||
}
|
||||
|
||||
fn parse_status(headers: &str) -> u16 {
|
||||
fn parse_status(headers: &str) -> Option<u16> {
|
||||
headers
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Locate the end of the HTTP header block (the index of the `\r\n\r\n`).
|
||||
/// Retained for the framing unit tests; the live path now reads headers
|
||||
/// incrementally in `http_get` so the whole response is never buffered.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
fn find_header_end(data: &[u8]) -> Option<usize> {
|
||||
data.windows(4).position(|w| w == b"\r\n\r\n")
|
||||
}
|
||||
|
||||
fn extract_header(headers: &str, name: &str) -> Option<String> {
|
||||
// Split on the first ':' rather than byte-indexing at name.len(),
|
||||
// which would panic on a multibyte UTF-8 codepoint straddling that
|
||||
// offset (headers are decoded from untrusted network bytes). Also
|
||||
// accepts single-character values (e.g. "Location:x").
|
||||
for line in headers.lines() {
|
||||
if line.len() > name.len() + 2
|
||||
&& line[..name.len()].eq_ignore_ascii_case(name)
|
||||
&& line.as_bytes()[name.len()] == b':'
|
||||
{
|
||||
return Some(line[name.len() + 1..].trim().to_string());
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
if key.trim().eq_ignore_ascii_case(name) {
|
||||
return Some(value.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -169,14 +393,477 @@ fn extract_zip(data: &[u8]) -> Result<String> {
|
||||
let mut archive = zip::ZipArchive::new(cursor).map_err(|_| Error::KeydbParse)?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).map_err(|_| Error::KeydbParse)?;
|
||||
let file = archive.by_index(i).map_err(|_| Error::KeydbParse)?;
|
||||
if file.name().ends_with(".cfg") || file.name().ends_with(".CFG") {
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text)
|
||||
.map_err(|_| Error::KeydbParse)?;
|
||||
return Ok(text);
|
||||
return read_capped_to_string(file);
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::KeydbInvalid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Per project convention, tests never touch /tmp (wiped on reboot).
|
||||
// Anchor scratch under the crate's target/ (gitignored), not /tmp.
|
||||
fn scratch(tag: &str) -> std::path::PathBuf {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static CTR: AtomicU64 = AtomicU64::new(0);
|
||||
let n = CTR.fetch_add(1, Ordering::Relaxed);
|
||||
let d = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("target/test-scratch")
|
||||
.join(format!("keydb-test-{}-{}-{}", std::process::id(), tag, n));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
// Regression: a missing home directory (HOME/USERPROFILE unset) is an
|
||||
// *environment* failure, not a corrupt keydb. It must NOT surface as
|
||||
// E8004 (KeydbParse → "failed to parse the keydb file"), which would
|
||||
// blame a file that was never consulted. It maps to a NotFound I/O
|
||||
// error in the 5xxx (I/O) category instead.
|
||||
#[test]
|
||||
fn no_home_dir_is_io_not_found_not_keydb_parse() {
|
||||
let e = no_home_dir();
|
||||
match e {
|
||||
Error::IoError { source } => {
|
||||
assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
|
||||
}
|
||||
other => panic!("expected IoError(NotFound), got {other:?}"),
|
||||
}
|
||||
// And explicitly: it is not the keydb-parse code.
|
||||
assert_ne!(no_home_dir().code(), Error::KeydbParse.code());
|
||||
}
|
||||
|
||||
// The write default is local to the executable: `<exe dir>/keydb.cfg`.
|
||||
// Under `cargo test`, `current_exe()` is the test binary in `target/…`;
|
||||
// assert against the same computation, not a hardcoded path.
|
||||
#[test]
|
||||
fn default_path_is_local_to_executable() {
|
||||
let expected = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|dir| dir.join("keydb.cfg")));
|
||||
match expected {
|
||||
Some(p) => assert_eq!(default_path().unwrap(), p),
|
||||
None => {
|
||||
// No exe parent available → must surface the env failure.
|
||||
assert!(matches!(default_path(), Err(Error::IoError { .. })));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_atomic_replaces_existing_and_leaves_no_temp() {
|
||||
let dir = scratch("atomic");
|
||||
let path = dir.join("freemkv").join("keydb.cfg");
|
||||
|
||||
// First write creates the parent dir + file.
|
||||
write_atomic(&path, "0xAAAA = old\n").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "0xAAAA = old\n");
|
||||
|
||||
// Second write replaces it in place.
|
||||
write_atomic(&path, "0xBBBB = new\n").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "0xBBBB = new\n");
|
||||
|
||||
// No leftover *.tmp.* sibling — the temp file was renamed, not orphaned.
|
||||
let leftovers: Vec<_> = std::fs::read_dir(path.parent().unwrap())
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.contains(".tmp."))
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "stray temp files: {leftovers:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_atomic_failure_preserves_prior_keydb() {
|
||||
// Simulate the crash window: a good keydb already on disk, then an
|
||||
// update whose write target can't be created (parent path is a file,
|
||||
// so create_dir_all under it fails — i.e. ENOTDIR). The rename never
|
||||
// happens, so the existing keydb must survive untouched.
|
||||
let dir = scratch("preserve");
|
||||
let good = dir.join("keydb.cfg");
|
||||
write_atomic(&good, "0xGOOD = keep\n").unwrap();
|
||||
|
||||
// `good` is a regular file; treating it as a directory parent fails.
|
||||
let doomed = good.join("freemkv").join("keydb.cfg");
|
||||
let err = write_atomic(&doomed, "0xBAD = partial\n");
|
||||
assert!(matches!(err, Err(Error::KeydbWrite { .. })));
|
||||
|
||||
// Prior good copy is intact.
|
||||
assert_eq!(std::fs::read_to_string(&good).unwrap(), "0xGOOD = keep\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_defaults_and_paths() {
|
||||
let (h, p, path) = parse_url("http://example.com/keydb.zip").unwrap();
|
||||
assert_eq!(
|
||||
(h.as_str(), p, path.as_str()),
|
||||
("example.com", 80, "/keydb.zip")
|
||||
);
|
||||
|
||||
let (h, p, path) = parse_url("http://example.com:8080").unwrap();
|
||||
assert_eq!((h.as_str(), p, path.as_str()), ("example.com", 8080, "/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_rejects_https_scheme() {
|
||||
// TLS is unsupported by this client; surface a scheme diagnostic
|
||||
// rather than a generic parse error.
|
||||
assert!(matches!(
|
||||
parse_url("https://example.com/k.zip"),
|
||||
Err(Error::KeydbUnsupportedScheme { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_url_rejects_malformed_port() {
|
||||
// Non-empty-but-unparseable port must error, not silently fall to 80.
|
||||
assert!(matches!(
|
||||
parse_url("http://example.com:abc/path"),
|
||||
Err(Error::KeydbParse)
|
||||
));
|
||||
// An empty port still defaults to 80.
|
||||
let (_, p, _) = parse_url("http://example.com:/path").unwrap();
|
||||
assert_eq!(p, 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_to_https_is_unsupported_scheme_not_parse_error() {
|
||||
// The bplaced-style mirror enabling TLS on a redirect must produce a
|
||||
// diagnosable scheme error, not KeydbParse.
|
||||
assert!(matches!(
|
||||
resolve_redirect("https://mirror.example/keydb.zip", "old.host", 80),
|
||||
Err(Error::KeydbUnsupportedScheme { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_scheme_relative_and_absolute_path() {
|
||||
// Scheme-relative //host/path inherits http.
|
||||
let (h, p, path) = resolve_redirect("//mirror.example/a.zip", "old.host", 80).unwrap();
|
||||
assert_eq!(
|
||||
(h.as_str(), p, path.as_str()),
|
||||
("mirror.example", 80, "/a.zip")
|
||||
);
|
||||
|
||||
// Absolute path stays on the current host/port.
|
||||
let (h, p, path) = resolve_redirect("/new/path.zip", "cur.host", 8080).unwrap();
|
||||
assert_eq!(
|
||||
(h.as_str(), p, path.as_str()),
|
||||
("cur.host", 8080, "/new/path.zip")
|
||||
);
|
||||
|
||||
// Absolute http URL is followed normally.
|
||||
let (h, _, path) = resolve_redirect("http://other.host/x.zip", "cur.host", 80).unwrap();
|
||||
assert_eq!((h.as_str(), path.as_str()), ("other.host", "/x.zip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_status_extracts_code() {
|
||||
assert_eq!(parse_status("HTTP/1.0 200 OK\r\nFoo: bar"), Some(200));
|
||||
assert_eq!(parse_status("HTTP/1.1 301 Moved Permanently"), Some(301));
|
||||
assert_eq!(parse_status("garbage"), None);
|
||||
}
|
||||
|
||||
// ── New comprehensive tests ────────────────────────────────────────────────
|
||||
|
||||
/// find_header_end detects the \r\n\r\n separator (RFC 7230 §3 — HTTP header
|
||||
/// terminator is CRLF CRLF). Returns the byte position of the first \r.
|
||||
/// Mutation: searching for \n\n instead of \r\n\r\n misses the boundary.
|
||||
#[test]
|
||||
fn find_header_end_locates_crlfcrlf() {
|
||||
let data = b"HTTP/1.0 200 OK\r\nContent-Length: 42\r\n\r\nbody starts here";
|
||||
// The \r\n\r\n starts at byte 37 (after the Content-Length line).
|
||||
let pos = find_header_end(data).expect("must find header end");
|
||||
// body starts at pos + 4 (past the \r\n\r\n).
|
||||
assert_eq!(
|
||||
&data[pos + 4..],
|
||||
b"body starts here",
|
||||
"body must begin immediately after the \\r\\n\\r\\n boundary"
|
||||
);
|
||||
}
|
||||
|
||||
/// find_header_end returns None when there is no \r\n\r\n.
|
||||
/// Mutation: returning Some(0) unconditionally makes this fail.
|
||||
#[test]
|
||||
fn find_header_end_returns_none_when_absent() {
|
||||
let data = b"no separator here at all";
|
||||
assert!(find_header_end(data).is_none());
|
||||
}
|
||||
|
||||
/// extract_header is case-insensitive per RFC 7230 §3.2.
|
||||
/// Mutation: using case-sensitive comparison misses "location" vs "Location".
|
||||
#[test]
|
||||
fn extract_header_case_insensitive() {
|
||||
let headers = "HTTP/1.1 301 Moved\r\nlocation: http://new.host/path\r\n";
|
||||
let val = extract_header(headers, "Location").expect("must find Location");
|
||||
assert_eq!(val, "http://new.host/path");
|
||||
}
|
||||
|
||||
/// extract_header with a missing header returns None.
|
||||
/// Mutation: returning Some("") makes the caller proceed on a missing Location header.
|
||||
#[test]
|
||||
fn extract_header_missing_returns_none() {
|
||||
let headers = "HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\n";
|
||||
assert!(extract_header(headers, "Location").is_none());
|
||||
}
|
||||
|
||||
/// extract_header trims leading/trailing whitespace from the value.
|
||||
/// RFC 7230 §3.2.6: optional whitespace around field value.
|
||||
/// Mutation: not trimming the value keeps leading spaces in the URL.
|
||||
#[test]
|
||||
fn extract_header_trims_value_whitespace() {
|
||||
let headers = "HTTP/1.1 301 Moved\r\nLocation: /new/path \r\n";
|
||||
let val = extract_header(headers, "Location").unwrap();
|
||||
assert_eq!(val, "/new/path", "value must be trimmed");
|
||||
}
|
||||
|
||||
/// save() rejects data that is not a valid keydb (no recognisable entries).
|
||||
/// Spec: entries are lines starting with "0x", "| DK", "| PK", or "| HC".
|
||||
/// Mutation: dropping the entries==0 check lets an empty file be saved.
|
||||
#[test]
|
||||
fn save_rejects_empty_text() {
|
||||
// Plain text with no valid keydb entries.
|
||||
let garbage = b"this is not a keydb\njust random text\n";
|
||||
assert!(
|
||||
matches!(save(garbage), Err(Error::KeydbInvalid)),
|
||||
"keydb without valid entries must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
/// save() accepts plain text with at least one "0x"-prefixed entry line.
|
||||
/// Mutation: counting only "| DK" lines ignores the "0x" entry format.
|
||||
#[test]
|
||||
fn save_accepts_plaintext_with_0x_entries() {
|
||||
// Minimal keydb-style file with a VUK entry (0x-prefixed).
|
||||
let content = b"0xDEADBEEFCAFEBABE0102030405060708090A0B0C0D0E0F\n";
|
||||
// We can't predict the HOME path in test environments without
|
||||
// potentially writing to a real location. So only check that save()
|
||||
// accepts this content as valid (may return KeydbWrite if dir exists
|
||||
// but we lack permission — that still proves it passed the parse check).
|
||||
let result = save(content);
|
||||
// Accept either Ok (wrote successfully) or a write error (env issue),
|
||||
// but NOT KeydbInvalid or KeydbParse.
|
||||
match &result {
|
||||
Ok(_) => {}
|
||||
Err(Error::KeydbWrite { .. }) => {}
|
||||
Err(e) => panic!("unexpected error for valid keydb content: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// save() accepts content with "| DK" entries (device-key table format).
|
||||
/// Mutation: only accepting "0x" lines rejects DK-format keydb files.
|
||||
#[test]
|
||||
fn save_accepts_pipe_dk_entry_format() {
|
||||
let content = b"| DK 0102030405060708 | 0102030405060708090a0b0c0d0e0f10 |\n";
|
||||
let result = save(content);
|
||||
match &result {
|
||||
Ok(_) => {}
|
||||
Err(Error::KeydbWrite { .. }) => {}
|
||||
Err(e) => panic!("unexpected error for DK-format entry: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// save() accepts content with "| PK" entries (processing-key format).
|
||||
/// Mutation: not including "| PK" in the filter rejects PK-format keydb files.
|
||||
#[test]
|
||||
fn save_accepts_pipe_pk_entry_format() {
|
||||
let content = b"| PK 0102030405060708090a0b0c0d0e0f10 |\n";
|
||||
let result = save(content);
|
||||
match &result {
|
||||
Ok(_) => {}
|
||||
Err(Error::KeydbWrite { .. }) => {}
|
||||
Err(e) => panic!("unexpected error for PK-format entry: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// save() accepts content with "| HC" entries (host certificate format).
|
||||
/// Mutation: not including "| HC" in the filter rejects HC-format keydb files.
|
||||
#[test]
|
||||
fn save_accepts_pipe_hc_entry_format() {
|
||||
let content = b"| HC 0102030405060708090a0b0c0d0e0f10 |\n";
|
||||
let result = save(content);
|
||||
match &result {
|
||||
Ok(_) => {}
|
||||
Err(Error::KeydbWrite { .. }) => {}
|
||||
Err(e) => panic!("unexpected error for HC-format entry: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// save() recognises gzip-compressed input (magic bytes 0x1f 0x8b).
|
||||
/// Spec: gzip format magic is 0x1F 0x8B (RFC 1952 §2.3.1).
|
||||
/// Mutation: treating gzip magic as plain text fails to decompress.
|
||||
#[test]
|
||||
fn save_recognises_gzip_magic() {
|
||||
// Truncated gzip (header only, no valid body) — must not be treated as
|
||||
// plain text (no KeydbInvalid about entries), but as a parse error.
|
||||
let bad_gz = [0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03];
|
||||
let result = save(&bad_gz);
|
||||
// A truncated gzip is either KeydbParse (decompression error) or
|
||||
// KeydbInvalid (decompressed to empty). Must not be Ok.
|
||||
assert!(result.is_err(), "truncated gzip must not be accepted");
|
||||
// Crucially: must NOT be a plain-text UTF-8 error — gzip magic is not UTF-8.
|
||||
match result.unwrap_err() {
|
||||
Error::KeydbParse | Error::KeydbInvalid => {}
|
||||
e => panic!("wrong error kind for truncated gzip: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// save() recognises ZIP magic bytes PK\x03\x04 and routes to extract_zip.
|
||||
/// Spec: ZIP local file header signature is 0x50 0x4B 0x03 0x04 (PKZIP APPNOTE §4.3.6).
|
||||
/// A truncated ZIP must error, but NOT as plain UTF-8 text.
|
||||
/// Mutation: checking gzip magic before ZIP magic means ZIP files are
|
||||
/// fed to the gzip decoder and produce the wrong error.
|
||||
#[test]
|
||||
fn save_recognises_zip_magic() {
|
||||
// Valid ZIP magic followed by garbage — must be routed to extract_zip.
|
||||
let bad_zip = b"PK\x03\x04garbage that is not a real zip";
|
||||
let result = save(bad_zip);
|
||||
assert!(result.is_err(), "invalid zip must be rejected");
|
||||
// Must be a parse error, not a UTF-8 error.
|
||||
match result.unwrap_err() {
|
||||
Error::KeydbParse | Error::KeydbInvalid => {}
|
||||
e => panic!("wrong error for bad zip: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// read_capped_to_string rejects data exceeding MAX_KEYDB_BYTES.
|
||||
/// Spec: doc says "Returns Error::KeydbInvalid if the input exceeds the cap".
|
||||
/// Mutation: removing the length check accepts decompression bombs.
|
||||
#[test]
|
||||
fn read_capped_to_string_rejects_oversized_input() {
|
||||
// Build a reader that reports it has more data than the cap.
|
||||
// We use a Cursor with MAX_KEYDB_BYTES + 1 bytes of content.
|
||||
let too_big = vec![b'A'; (MAX_KEYDB_BYTES + 1) as usize];
|
||||
let cursor = std::io::Cursor::new(too_big);
|
||||
let result = read_capped_to_string(cursor);
|
||||
assert!(
|
||||
matches!(result, Err(Error::KeydbInvalid)),
|
||||
"oversized input must yield KeydbInvalid, got: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
/// read_capped_to_string returns KeydbParse (not KeydbInvalid) for
|
||||
/// non-UTF-8 input. Guards the doc/behavior contract: KeydbInvalid is
|
||||
/// reserved for the size-cap violation, a decode failure is a parse error.
|
||||
#[test]
|
||||
fn read_capped_to_string_non_utf8_yields_parse() {
|
||||
// 0xFF is never a valid UTF-8 byte.
|
||||
let cursor = std::io::Cursor::new(vec![0xFFu8, 0xFE, 0xFD]);
|
||||
let result = read_capped_to_string(cursor);
|
||||
assert!(
|
||||
matches!(result, Err(Error::KeydbParse)),
|
||||
"non-UTF-8 input must yield KeydbParse, got: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
/// read_capped_to_string accepts exactly MAX_KEYDB_BYTES (at-cap is allowed).
|
||||
/// Spec: doc says "Read one byte past the cap so an exactly-at-cap stream is accepted."
|
||||
/// Mutation: using `>=` instead of `>` in the length check rejects valid at-cap files.
|
||||
#[test]
|
||||
fn read_capped_to_string_accepts_at_cap_size() {
|
||||
let at_cap = vec![b'A'; MAX_KEYDB_BYTES as usize];
|
||||
let cursor = std::io::Cursor::new(at_cap);
|
||||
let result = read_capped_to_string(cursor);
|
||||
assert!(result.is_ok(), "exactly MAX_KEYDB_BYTES must be accepted");
|
||||
}
|
||||
|
||||
/// parse_status returns None for an empty/malformed status line (not a
|
||||
/// meaningless 0). The call site maps None to Error::KeydbParse.
|
||||
#[test]
|
||||
fn parse_status_empty_input_returns_none() {
|
||||
assert_eq!(parse_status(""), None);
|
||||
assert_eq!(parse_status("\r\n"), None);
|
||||
}
|
||||
|
||||
/// Regression: set_read_timeout / set_write_timeout failures must surface as
|
||||
/// KeydbConnect, not be silently swallowed.
|
||||
///
|
||||
/// We can't easily synthesise a TcpStream whose set_*timeout syscall fails
|
||||
/// without a platform-specific socket hack, so instead we verify that the
|
||||
/// error-mapping expression itself is correct: if set_read_timeout were to
|
||||
/// fail for a given host, the result must be Err(KeydbConnect { host }).
|
||||
///
|
||||
/// The test constructs the exact Err value the code would return and asserts
|
||||
/// it is KeydbConnect (not, say, silently Ok or a different variant). This
|
||||
/// pins the variant selection so a future refactor that changes the `.ok()`
|
||||
/// pattern back would need to update this test as well.
|
||||
#[test]
|
||||
fn timeout_set_failure_maps_to_keydb_connect() {
|
||||
// Simulate what the propagated error looks like.
|
||||
let host = "hostile.example.com".to_string();
|
||||
// The io::Error that set_read_timeout would return on failure.
|
||||
let io_err = std::io::Error::from(std::io::ErrorKind::InvalidInput);
|
||||
// Apply the same map_err the production code uses.
|
||||
let result: Result<()> =
|
||||
Err(io_err).map_err(|_| Error::KeydbConnect { host: host.clone() });
|
||||
assert!(
|
||||
matches!(result, Err(Error::KeydbConnect { host: ref h }) if h == "hostile.example.com"),
|
||||
"set_timeout failure must map to KeydbConnect, got: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: http_get to an unreachable host returns KeydbConnect, not a hang.
|
||||
/// This exercises the connect_timeout path (and thus confirms the overall
|
||||
/// error-propagation chain is wired); the timeout-set propagation is exercised
|
||||
/// by the unit test above.
|
||||
///
|
||||
/// Uses port 1 on localhost, which is reserved/unassigned and virtually never
|
||||
/// listening. connect_timeout with NET_TIMEOUT will refuse or time out quickly.
|
||||
/// We only assert the error variant, not the host field, since the OS may
|
||||
/// resolve the address differently.
|
||||
#[test]
|
||||
fn http_get_unreachable_host_returns_keydb_connect() {
|
||||
// Port 1 on loopback — almost always refused immediately.
|
||||
let result = http_get("http://127.0.0.1:1/keydb.zip");
|
||||
// Must be an Err; KeydbConnect is expected for a TCP-level failure.
|
||||
// KeydbParse or KeydbHttp would indicate the wrong error path.
|
||||
assert!(result.is_err(), "unreachable host must fail");
|
||||
match result.unwrap_err() {
|
||||
Error::KeydbConnect { .. } => {}
|
||||
e => panic!("expected KeydbConnect for unreachable host, got: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: when the server accepts the connection but closes it before
|
||||
/// sending complete HTTP headers (the `n == 0` byte-read path), that is a
|
||||
/// connection/protocol-level fault. It must surface as KeydbConnect (E8000),
|
||||
/// NOT KeydbParse (E8004) — the keydb content was never received, let alone
|
||||
/// malformed.
|
||||
#[test]
|
||||
fn http_get_server_drops_before_headers_returns_keydb_connect() {
|
||||
use std::io::Read as _;
|
||||
use std::net::TcpListener;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let server = std::thread::spawn(move || {
|
||||
if let Ok((mut sock, _)) = listener.accept() {
|
||||
// Drain the request so the client's write_all completes, then
|
||||
// drop the socket without writing any response. The client's
|
||||
// header read then returns n == 0.
|
||||
let mut buf = [0u8; 512];
|
||||
let _ = sock.read(&mut buf);
|
||||
drop(sock);
|
||||
}
|
||||
});
|
||||
|
||||
let url = format!("http://127.0.0.1:{}/keydb.zip", addr.port());
|
||||
let result = http_get(&url);
|
||||
server.join().unwrap();
|
||||
|
||||
assert!(result.is_err(), "dropped connection must fail");
|
||||
match result.unwrap_err() {
|
||||
Error::KeydbConnect { .. } => {}
|
||||
e => panic!("expected KeydbConnect for dropped connection, got: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
//! Key sources — the layer that hands libfreemkv a disc's terminal Unit Keys.
|
||||
//!
|
||||
//! libfreemkv performs NO key lookup. An application resolves a disc's keys
|
||||
//! through one or more [`KeySource`]s, each an adapter over a backing store (a
|
||||
//! keydb file, a key server, the mapfile cache). A source's job is to return the
|
||||
//! disc's terminal **Unit Keys** ([`crate::aacs::UnitKey`]). It knows what
|
||||
//! material it holds (a DK / MK / VUK / pre-decrypted UK) and what it must fetch
|
||||
//! from the disc (VID, MKB, encrypted title keys, content samples) to get there;
|
||||
//! it orchestrates the derivation by calling libfreemkv's own boil-down crypto
|
||||
//! primitives ([`crate::aacs::mk_from_dk`] / [`crate::aacs::vuk_from_mk`] /
|
||||
//! [`crate::aacs::uk_from_vuk`]) through the [`ResolveCtx`] handed to it.
|
||||
//!
|
||||
//! libfreemkv still OWNS the crypto: the boil-down primitives and the AES live
|
||||
//! here. A source owns only PATH ORCHESTRATION — deciding which primitive to
|
||||
//! call with what input for the material it happens to hold. Source
|
||||
//! implementations are published in the companion `freemkv-keysources` crate,
|
||||
//! keeping key *policy* (which store, which order, online vs local) out of the
|
||||
//! library.
|
||||
|
||||
use crate::aacs::{HostCert, UnitKey, Vid};
|
||||
use crate::disc::Key;
|
||||
use crate::error::Error;
|
||||
|
||||
/// The public AACS inputs a key source needs to look a disc up. Captured at
|
||||
/// scan; contains no secrets — only the disc identity and the on-disc AACS
|
||||
/// structures a source or key server may key on.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscInputs {
|
||||
/// SHA-1 of `Unit_Key_RO.inf`, `0x`-prefixed hex. The value a keydb keys
|
||||
/// its per-disc entries by, and a key server identifies the disc with.
|
||||
pub disc_hash: String,
|
||||
/// Volume ID (16 bytes). `[0u8; 16]` when no authenticated handshake ran
|
||||
/// (e.g. an ISO/mapfile flow), which disables VID-keyed lookups.
|
||||
pub volume_id: [u8; 16],
|
||||
/// Raw MKB bytes. Empty when not captured.
|
||||
pub mkb: Vec<u8>,
|
||||
/// Raw `Unit_Key_RO.inf` bytes. Empty when not captured.
|
||||
pub unit_key_ro: Vec<u8>,
|
||||
/// Encrypted on-disc content sample units (each a 6144-byte aligned unit),
|
||||
/// for sources that validate a key server-side against real ciphertext
|
||||
/// (e.g. an online key service). Empty for sources that don't need them
|
||||
/// (a local keydb). Populated by the application — reading content requires
|
||||
/// the disc reader, which the library's scan does not retain — so
|
||||
/// [`crate::Disc::inputs`] leaves it empty for the caller to fill.
|
||||
pub samples: Vec<Vec<u8>>,
|
||||
/// The disc's human title — the UDF/ISO volume identifier (e.g.
|
||||
/// `TITLE_2024`), falling back to the BDMV `<di:name>` when present.
|
||||
/// `None` when not captured. Identity only, no secret; a key service may
|
||||
/// record it (keyed by `disc_hash`) to build a hash→title catalog. Not used
|
||||
/// in any AACS derivation.
|
||||
pub volume_label: Option<String>,
|
||||
}
|
||||
|
||||
/// A lazy view of a disc's AACS material, handed to [`KeySource::get_uk`] so a
|
||||
/// source can drive the derivation chain without holding the disc reader.
|
||||
///
|
||||
/// "Lazy" by contract: each accessor returns only what the source asks for, so a
|
||||
/// source that already holds terminal Unit Keys never touches the MKB or
|
||||
/// samples. (Today the backing [`DiscInputsCtx`] is eagerly populated from a
|
||||
/// scan-time [`DiscInputs`]; the trait keeps the lazy signature so a future
|
||||
/// implementation can fetch on demand without a source-API break.)
|
||||
pub trait ResolveCtx {
|
||||
/// SHA-1 of `Unit_Key_RO.inf`, `0x`-prefixed hex — the per-disc lookup key.
|
||||
fn disc_hash(&self) -> &str;
|
||||
/// The disc's human title (UDF/ISO volume identifier), when captured.
|
||||
fn title(&self) -> Option<&str>;
|
||||
/// Volume ID, or `None` when no authenticated handshake ran (the all-zero
|
||||
/// sentinel) — VID-dependent derivation (`MK → VUK`) is then impossible.
|
||||
fn vid(&self) -> Option<Vid>;
|
||||
/// Raw MKB bytes (may be empty when not captured).
|
||||
fn mkb(&self) -> Result<&[u8], Error>;
|
||||
/// The disc's encrypted title keys, parsed from `Unit_Key_RO.inf` the same
|
||||
/// way the library's resolver parses them ([`crate::aacs::parse_unit_key_ro`]),
|
||||
/// in on-disc order. Feed straight into [`crate::aacs::uk_from_vuk`].
|
||||
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error>;
|
||||
/// Up to `n` encrypted on-disc content sample units, for a source that
|
||||
/// validates a candidate server-side against real ciphertext.
|
||||
fn samples(&self, n: usize) -> Result<Vec<Vec<u8>>, Error>;
|
||||
/// Raw `Unit_Key_RO.inf` bytes, verbatim. Most sources derive locally from
|
||||
/// the parsed [`Self::enc_title_keys`]; a source that forwards the on-disc
|
||||
/// structure to a server doing its OWN derivation (an online key service)
|
||||
/// needs the unparsed blob. Empty when not captured. Defaults to empty so
|
||||
/// existing/foreign `ResolveCtx` impls keep compiling unchanged.
|
||||
fn unit_key_ro(&self) -> &[u8] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ResolveCtx`] over a scan-time [`DiscInputs`].
|
||||
///
|
||||
/// Pre-parses the encrypted title keys at construction (so `enc_title_keys` can
|
||||
/// hand back a borrowed slice) at the version-appropriate `Unit_Key_RO.inf`
|
||||
/// stride — `version_u8` is the disc's AACS major (1 → 48-byte V10 stride, else
|
||||
/// 64-byte V20/V21 stride), matching the library resolver's dispatch.
|
||||
pub struct DiscInputsCtx<'a> {
|
||||
inner: &'a DiscInputs,
|
||||
enc_keys: Vec<[u8; 16]>,
|
||||
}
|
||||
|
||||
impl<'a> DiscInputsCtx<'a> {
|
||||
/// Build a context over `inputs`, parsing the encrypted title keys at the
|
||||
/// stride for AACS major `version_u8` (1 = V10, else V20/V21).
|
||||
pub fn new(inputs: &'a DiscInputs, version_u8: u8) -> Self {
|
||||
use crate::aacs::{AacsVersion, parse_unit_key_ro};
|
||||
let enc_keys = if inputs.unit_key_ro.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let version = if version_u8 == 1 {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
AacsVersion::V20
|
||||
};
|
||||
parse_unit_key_ro(&inputs.unit_key_ro, version)
|
||||
.map(|f| f.encrypted_keys.into_iter().map(|(_, k)| k).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
Self {
|
||||
inner: inputs,
|
||||
enc_keys,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolveCtx for DiscInputsCtx<'_> {
|
||||
fn disc_hash(&self) -> &str {
|
||||
&self.inner.disc_hash
|
||||
}
|
||||
fn title(&self) -> Option<&str> {
|
||||
self.inner.volume_label.as_deref()
|
||||
}
|
||||
fn vid(&self) -> Option<Vid> {
|
||||
if self.inner.volume_id == [0u8; 16] {
|
||||
None
|
||||
} else {
|
||||
Some(Vid(self.inner.volume_id))
|
||||
}
|
||||
}
|
||||
fn mkb(&self) -> Result<&[u8], Error> {
|
||||
Ok(&self.inner.mkb)
|
||||
}
|
||||
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> {
|
||||
Ok(&self.enc_keys)
|
||||
}
|
||||
fn samples(&self, n: usize) -> Result<Vec<Vec<u8>>, Error> {
|
||||
Ok(self.inner.samples.iter().take(n).cloned().collect())
|
||||
}
|
||||
fn unit_key_ro(&self) -> &[u8] {
|
||||
&self.inner.unit_key_ro
|
||||
}
|
||||
}
|
||||
|
||||
/// A key source: an adapter over a backing store that resolves a disc's terminal
|
||||
/// Unit Keys.
|
||||
///
|
||||
/// Dumb about *policy*, smart about *its own material*: given a [`ResolveCtx`] a
|
||||
/// source looks the disc up in its store and, from whatever level of material it
|
||||
/// holds, orchestrates the derivation down to Unit Keys using the library's
|
||||
/// boil-down crypto primitives — never re-implementing AES. A source that holds
|
||||
/// pre-decrypted Unit Keys returns them directly; one that holds a VUK calls
|
||||
/// [`crate::aacs::uk_from_vuk`]; one that holds device keys calls
|
||||
/// [`crate::aacs::mk_from_dk`] → [`crate::aacs::vuk_from_mk`] → `uk_from_vuk`.
|
||||
///
|
||||
/// Returning an empty `Vec` means "no key for this disc from this source"; an
|
||||
/// `Err` means the source itself failed (I/O, parse, network). The caller
|
||||
/// ([`resolve_and_apply`]) tries each source in order and validates the returned
|
||||
/// keys against real ciphertext before committing them, so a wrong key from one
|
||||
/// source transparently falls through to the next.
|
||||
pub trait KeySource {
|
||||
/// Resolve this disc's terminal Unit Keys from this source. An empty `Vec`
|
||||
/// is a genuine "no key here"; `Err` is a source failure.
|
||||
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error>;
|
||||
|
||||
/// The AACS host certificate(s) this source can supply for the live-drive
|
||||
/// SCSI mutual-auth handshake (the OEM/AACS baseline route). `mkb` is the
|
||||
/// disc's MKB generation when known, so a source MAY return only certs whose
|
||||
/// generation matches (the default ignores it). A host cert unlocks the
|
||||
/// authenticated bus so the drive reports the Volume ID and bus key; it is
|
||||
/// **perishable** (revocable on a drive's HRL), so it is served by a source,
|
||||
/// never compiled in. A source holding no cert returns the empty vec.
|
||||
fn host_certs(&self, _mkb: Option<u32>) -> Vec<HostCert> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// A short, stable identifier for this source kind (`"keydb"`, `"online"`,
|
||||
/// `"mapfile"`, …). For logging which source produced a key, and for
|
||||
/// composition/ordering. A format string, not user-facing English.
|
||||
fn label(&self) -> &'static str {
|
||||
"source"
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive `sources` until one resolves Unit Keys that decrypt `disc`. Returns
|
||||
/// `true` at the first source whose keys validate and commit, `false` once every
|
||||
/// source is exhausted (the genuine "no key for this disc"). Thin wrapper over
|
||||
/// [`resolve_and_apply_traced`] that discards the trace.
|
||||
pub fn resolve_and_apply(
|
||||
sources: &[Box<dyn KeySource>],
|
||||
inputs: &DiscInputs,
|
||||
disc: &mut crate::Disc,
|
||||
) -> bool {
|
||||
resolve_and_apply_traced(sources, inputs, disc).0
|
||||
}
|
||||
|
||||
/// Like [`resolve_and_apply`] but also returns a structured
|
||||
/// [`crate::aacs::ResolutionTrace`] recording, per source, what happened — for
|
||||
/// applications to render. ZERO English; the trace is typed enums only.
|
||||
///
|
||||
/// One-shot per source: each source's [`KeySource::get_uk`] is called exactly
|
||||
/// once with a [`DiscInputsCtx`] over `inputs`. Non-empty Unit Keys are mapped
|
||||
/// to terminal [`Key::Unit`]s and applied via [`crate::Disc::decrypt_with`],
|
||||
/// which validates them against `inputs.samples` and only mutates the disc on
|
||||
/// success — so a wrong/partial key set is rejected and the loop continues.
|
||||
///
|
||||
/// CPS-unit numbering: a source returns Unit Keys carrying the POSITIONAL index
|
||||
/// from [`crate::aacs::uk_from_vuk`]; the library's canonical CPS-unit number is
|
||||
/// `position + 1` (matching [`crate::aacs::parse_unit_key_ro`]'s `(i + 1)`), so
|
||||
/// the committed `AacsState.unit_keys` is byte-identical to the library-resolved
|
||||
/// path. The number is cosmetic for descramble (the decrypt path strips it and
|
||||
/// tries every key) but is kept faithful to the resolver's convention.
|
||||
pub fn resolve_and_apply_traced(
|
||||
sources: &[Box<dyn KeySource>],
|
||||
inputs: &DiscInputs,
|
||||
disc: &mut crate::Disc,
|
||||
) -> (bool, crate::aacs::ResolutionTrace) {
|
||||
use crate::aacs::trace::{KeyNode, KeyOutcome, KeyStep};
|
||||
|
||||
let mut trace = crate::aacs::ResolutionTrace::new();
|
||||
|
||||
// AACS major drives the Unit_Key_RO.inf stride the ctx parses at. Default to
|
||||
// the V20/V21 stride when there is no AACS state (it is the common live case;
|
||||
// a non-AACS disc has nothing to resolve and the loop simply finds nothing).
|
||||
let version_u8 = disc.aacs.as_ref().map(|a| a.version).unwrap_or(2);
|
||||
let ctx = DiscInputsCtx::new(inputs, version_u8);
|
||||
|
||||
for source in sources {
|
||||
// `who` is the source's own stable identifier — no enum to map back to.
|
||||
let who = source.label().to_string();
|
||||
match source.get_uk(&ctx) {
|
||||
Ok(uks) if !uks.is_empty() => {
|
||||
// Positional index → canonical CPS-unit number (position + 1).
|
||||
let unit_keys: Vec<(u32, [u8; 16])> = uks
|
||||
.iter()
|
||||
.map(|uk| (uk.idx.saturating_add(1), uk.key))
|
||||
.collect();
|
||||
if disc
|
||||
.decrypt_with(Key::Unit(unit_keys), &inputs.samples)
|
||||
.is_ok()
|
||||
{
|
||||
trace.keys.push(KeyStep {
|
||||
who,
|
||||
path: vec![KeyNode::FoundUnitKeys, KeyNode::DerivedUnitKeys],
|
||||
outcome: KeyOutcome::Resolved,
|
||||
});
|
||||
return (true, trace);
|
||||
}
|
||||
// Keys produced but rejected by validation — record and continue.
|
||||
trace.keys.push(KeyStep {
|
||||
who,
|
||||
path: vec![KeyNode::FoundUnitKeys],
|
||||
outcome: KeyOutcome::NoKey,
|
||||
});
|
||||
}
|
||||
// Empty (no key here) or a source failure — both are "no key from
|
||||
// this source"; move on to the next.
|
||||
Ok(_) | Err(_) => {
|
||||
trace.keys.push(KeyStep {
|
||||
who,
|
||||
path: vec![KeyNode::NoEntry],
|
||||
outcome: KeyOutcome::NoKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
(false, trace)
|
||||
}
|
||||
|
||||
/// Read up to `n` ENCRYPTED 6144-byte aligned units from `title`'s body, raw (no
|
||||
/// decrypt) — the content samples that populate [`DiscInputs::samples`] for a
|
||||
/// key server to validate a candidate against, and that [`resolve_and_apply`]
|
||||
/// hands to [`crate::Disc::decrypt_with`].
|
||||
///
|
||||
/// Lives in the library, not a key-source crate: reading the disc and carving
|
||||
/// AACS units is decryption *mechanism* (unit geometry anchored at each extent's
|
||||
/// `start_lba`), which the library owns. A key source is *handed* these bytes
|
||||
/// via `DiscInputs.samples`; it never reads the disc itself.
|
||||
///
|
||||
/// "Encrypted" is decided by [`crate::aacs::is_aacs_scrambled`] — the SAME
|
||||
/// predicate the decrypt gate uses — so all sides agree. A clip opens with clear
|
||||
/// navigation units (PAT/PMT, menus); only the feature body is scrambled, and a
|
||||
/// clear unit proves nothing, so this collects only scrambled ones, sampling the
|
||||
/// largest extent at its midpoint forward.
|
||||
pub fn read_encrypted_units(
|
||||
reader: &mut dyn crate::sector::SectorSource,
|
||||
title: &crate::disc::DiscTitle,
|
||||
n: usize,
|
||||
) -> Vec<Vec<u8>> {
|
||||
use crate::aacs::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, is_aacs_scrambled};
|
||||
const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap
|
||||
const MAX_CHUNKS_PER_EXTENT: u32 = 4; // ~60 units scanned at each extent's midpoint
|
||||
|
||||
let mut out: Vec<Vec<u8>> = Vec::new();
|
||||
for ext in &title.extents {
|
||||
let total_units = ext.sector_count / ALIGNED_UNIT_SECTORS;
|
||||
if total_units == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut unit = total_units / 2; // midpoint (past the clear nav at the head)
|
||||
for _ in 0..MAX_CHUNKS_PER_EXTENT {
|
||||
if unit >= total_units {
|
||||
break;
|
||||
}
|
||||
let units_this = CHUNK_UNITS.min(total_units - unit);
|
||||
// Saturate: start_lba comes from attacker-controlled UDF/MPLS
|
||||
// extents; a malformed extent near u32::MAX would otherwise panic
|
||||
// (debug) or wrap to a wrong LBA (release). An over-capacity LBA then
|
||||
// fails cleanly via the read_sectors().is_err() break below.
|
||||
let lba = ext
|
||||
.start_lba
|
||||
.saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS));
|
||||
let count = (units_this * ALIGNED_UNIT_SECTORS) as u16;
|
||||
let mut buf = vec![0u8; count as usize * 2048];
|
||||
// `false` = no recovery retries; the reader is the raw drive/file
|
||||
// (no decrypt decorator), so these are the on-disc encrypted bytes.
|
||||
if reader.read_sectors(lba, count, &mut buf, false).is_err() {
|
||||
break;
|
||||
}
|
||||
for i in 0..units_this as usize {
|
||||
let o = i * ALIGNED_UNIT_LEN;
|
||||
if o + ALIGNED_UNIT_LEN > buf.len() {
|
||||
break;
|
||||
}
|
||||
let u = &buf[o..o + ALIGNED_UNIT_LEN];
|
||||
if is_aacs_scrambled(u) {
|
||||
out.push(u.to_vec());
|
||||
if out.len() >= n {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
unit += units_this;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs::UnitKey;
|
||||
|
||||
// ── KeySource default-method behaviour ────────────────────────────────────
|
||||
|
||||
/// KeySource::host_certs() defaults to empty regardless of the MKB argument.
|
||||
/// Spec: a source holding no cert returns the empty vec; the `mkb` param is
|
||||
/// forward-looking and the default ignores it.
|
||||
/// Mutation: a default returning a non-empty vec would inject phantom certs
|
||||
/// into the OEM handshake.
|
||||
#[test]
|
||||
fn key_source_host_certs_defaults_to_empty() {
|
||||
struct MinimalSource;
|
||||
impl KeySource for MinimalSource {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
let s = MinimalSource;
|
||||
assert!(s.host_certs(None).is_empty());
|
||||
assert!(s.host_certs(Some(68)).is_empty());
|
||||
}
|
||||
|
||||
/// DiscInputsCtx maps DiscInputs faithfully: zero VID → None, non-zero VID →
|
||||
/// Some; title from volume_label; samples truncate to n; enc_title_keys
|
||||
/// parses Unit_Key_RO.inf at the version stride.
|
||||
#[test]
|
||||
fn disc_inputs_ctx_maps_fields() {
|
||||
// Build a minimal V10 Unit_Key_RO.inf with one key (stride 48):
|
||||
// uk_pos = 32, num_uk = 1, key at uk_pos + 48 = 80.
|
||||
let mut uk_ro = vec![0u8; 96];
|
||||
let uk_pos = 32usize;
|
||||
uk_ro[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
|
||||
uk_ro[uk_pos] = 0x00;
|
||||
uk_ro[uk_pos + 1] = 0x01; // num_unit_keys = 1
|
||||
let key_bytes = [0x7Eu8; 16];
|
||||
uk_ro[80..96].copy_from_slice(&key_bytes);
|
||||
|
||||
let inputs = DiscInputs {
|
||||
disc_hash: "0xABC".into(),
|
||||
volume_id: [0u8; 16],
|
||||
mkb: vec![1, 2, 3],
|
||||
unit_key_ro: uk_ro,
|
||||
samples: vec![vec![9u8; 4], vec![8u8; 4], vec![7u8; 4]],
|
||||
volume_label: Some("TITLE_X".into()),
|
||||
};
|
||||
|
||||
// Zero VID → None.
|
||||
let ctx = DiscInputsCtx::new(&inputs, 1);
|
||||
assert_eq!(ctx.disc_hash(), "0xABC");
|
||||
assert_eq!(ctx.title(), Some("TITLE_X"));
|
||||
assert!(ctx.vid().is_none(), "all-zero VID is the no-VID sentinel");
|
||||
assert_eq!(ctx.mkb().unwrap(), &[1, 2, 3]);
|
||||
assert_eq!(ctx.enc_title_keys().unwrap(), &[key_bytes]);
|
||||
assert_eq!(ctx.samples(2).unwrap().len(), 2, "samples truncates to n");
|
||||
|
||||
// Non-zero VID → Some(vid).
|
||||
let mut inputs2 = inputs.clone();
|
||||
inputs2.volume_id = [0x42u8; 16];
|
||||
let ctx2 = DiscInputsCtx::new(&inputs2, 1);
|
||||
assert_eq!(ctx2.vid(), Some(Vid([0x42u8; 16])));
|
||||
}
|
||||
|
||||
/// `resolve_and_apply_traced` records each step's `who` as the source's own
|
||||
/// `label()`, carried verbatim — no enum round-trip. A source with a custom
|
||||
/// label surfaces it as-is in the trace.
|
||||
#[test]
|
||||
fn trace_who_is_the_source_label_verbatim() {
|
||||
struct LabeledSource(&'static str);
|
||||
impl KeySource for LabeledSource {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn label(&self) -> &'static str {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
let mut disc = crate::Disc {
|
||||
volume_id: String::new(),
|
||||
meta_title: None,
|
||||
format: crate::DiscFormat::BluRay,
|
||||
capacity_sectors: 0,
|
||||
capacity_bytes: 0,
|
||||
layers: 1,
|
||||
titles: Vec::new(),
|
||||
region: crate::disc::DiscRegion::Free,
|
||||
aacs: None,
|
||||
css: None,
|
||||
encrypted: false,
|
||||
aacs_error: None,
|
||||
css_error: None,
|
||||
content_format: crate::ContentFormat::BdTs,
|
||||
};
|
||||
let inputs = DiscInputs {
|
||||
disc_hash: "0x00".into(),
|
||||
volume_id: [0u8; 16],
|
||||
mkb: Vec::new(),
|
||||
unit_key_ro: Vec::new(),
|
||||
samples: Vec::new(),
|
||||
volume_label: None,
|
||||
};
|
||||
let sources: Vec<Box<dyn KeySource>> = vec![
|
||||
Box::new(LabeledSource("keydb")),
|
||||
Box::new(LabeledSource("my-custom-source")),
|
||||
];
|
||||
let (_ok, trace) = resolve_and_apply_traced(&sources, &inputs, &mut disc);
|
||||
let whos: Vec<&str> = trace.keys.iter().map(|s| s.who.as_str()).collect();
|
||||
assert_eq!(whos, vec!["keydb", "my-custom-source"]);
|
||||
}
|
||||
}
|
||||
+223
-56
@@ -14,8 +14,9 @@
|
||||
//!
|
||||
//! This module is intentionally separate from the BD-J `StreamLabel`
|
||||
//! parsers under `labels/*.rs`. The XML here is disc-level (title,
|
||||
//! description, set position), not per-stream — wiring into the main
|
||||
//! parser registry happens elsewhere.
|
||||
//! description, set position), not per-stream. It is invoked from the
|
||||
//! disc-scan path in [`labels`](super) ([`detect`] then [`parse`]),
|
||||
//! and [`DiscMetadata`] is re-exported there.
|
||||
//!
|
||||
//! Real-world XML is irregular: missing description elements, multiple
|
||||
//! title elements (first one wins), and occasional malformed content.
|
||||
@@ -23,13 +24,17 @@
|
||||
//! metadata" (returns `None` from the helper), and the caller can
|
||||
//! still get metadata from sibling-language XML files.
|
||||
|
||||
// The module wiring (registry hook + public re-export) is added
|
||||
// separately. Until then the parse/detect entry points have no
|
||||
use super::xml;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Upper bound on the size of a single `bdmt_<lang>.xml` we will read.
|
||||
/// The size comes from attacker-controlled UDF metadata; real files are
|
||||
/// a few KB, so 1 MiB is generous while preventing a crafted huge-size
|
||||
/// entry from triggering an oversized allocation in `read_file`.
|
||||
const MAX_BDMT_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
/// Disc-level metadata extracted from `/BDMV/META/DL/bdmt_*.xml`.
|
||||
///
|
||||
/// All maps are keyed by 3-char ISO 639-2 language code (e.g.
|
||||
@@ -38,7 +43,7 @@ use std::collections::BTreeMap;
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct DiscMetadata {
|
||||
/// Localized titles, keyed by 3-char ISO 639-2 lang code
|
||||
/// (e.g. "eng" → "Dune Part Two")
|
||||
/// (e.g. "eng" → "Aurora Drift")
|
||||
pub titles: BTreeMap<String, String>,
|
||||
/// First-line / short description, per lang
|
||||
pub descriptions: BTreeMap<String, String>,
|
||||
@@ -71,6 +76,13 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<DiscMetadata>
|
||||
let Some(lang) = lang_code_from_filename(&entry.name) else {
|
||||
continue;
|
||||
};
|
||||
// entry.size is attacker-controlled UDF metadata and flows into
|
||||
// a Vec::with_capacity in read_file. A real BDMV bdmt XML is a
|
||||
// few KB; cap well above that so a crafted multi-GB size can't
|
||||
// trigger a huge allocation before any parsing.
|
||||
if !bdmt_size_acceptable(entry.size) {
|
||||
continue;
|
||||
}
|
||||
let path = format!("/BDMV/META/DL/{}", entry.name);
|
||||
let Ok(bytes) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
@@ -78,7 +90,7 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<DiscMetadata>
|
||||
let Ok(text) = std::str::from_utf8(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
let Some((title, description, disc_set)) = parse_bdmt_xml(&lang, text) else {
|
||||
let Some((title, description, disc_set)) = parse_bdmt_xml(text) else {
|
||||
continue;
|
||||
};
|
||||
out.titles.insert(lang.clone(), title);
|
||||
@@ -102,6 +114,13 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<DiscMetadata>
|
||||
}
|
||||
}
|
||||
|
||||
/// Gate a `bdmt_<lang>.xml` file by its declared (untrusted) UDF size
|
||||
/// before reading it. Anything over [`MAX_BDMT_BYTES`] is skipped to
|
||||
/// avoid an oversized allocation in `read_file`.
|
||||
fn bdmt_size_acceptable(size: u64) -> bool {
|
||||
size <= MAX_BDMT_BYTES
|
||||
}
|
||||
|
||||
/// True if `name` matches the `bdmt_<lang>.xml` convention with a
|
||||
/// 3-character ISO 639-2 lang code segment. Case-insensitive.
|
||||
fn is_bdmt_filename(name: &str) -> bool {
|
||||
@@ -134,19 +153,19 @@ pub(crate) type BdmtFields = (String, Option<String>, Option<(u32, u32)>);
|
||||
/// Title-element preference: `<di:name>` → `<di:title>` →
|
||||
/// `<di:tableOfContents>/<di:titleName>` (first match wins, per the
|
||||
/// authoring-tool conventions documented at the module level).
|
||||
pub(crate) fn parse_bdmt_xml(_lang_code: &str, xml_text: &str) -> Option<BdmtFields> {
|
||||
pub(crate) fn parse_bdmt_xml(xml_text: &str) -> Option<BdmtFields> {
|
||||
let title = extract_title(xml_text)?;
|
||||
// xml::text already returns a trimmed string (see xml::text), so the
|
||||
// description is only filtered for emptiness and XML-fragment noise.
|
||||
let description = xml::text(xml_text, "description")
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter(|s| !looks_like_xml(s))
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
.filter(|s| !looks_like_xml(s));
|
||||
let disc_set = extract_disc_set(xml_text);
|
||||
Some((title, description, disc_set))
|
||||
}
|
||||
|
||||
/// Reject candidate description strings that are themselves XML
|
||||
/// fragments — observed on disc-04 (Top Gun: Maverick), where
|
||||
/// fragments — observed on a captured disc, where
|
||||
/// `<di:description>` contained `<di:thumbnail href="…"/>` child
|
||||
/// elements and no actual prose. Surfacing that raw to the JSON
|
||||
/// output is worse than dropping the field entirely.
|
||||
@@ -162,11 +181,12 @@ fn extract_title(xml_text: &str) -> Option<String> {
|
||||
// Order matches the module-level convention: <di:name> first
|
||||
// (Paramount-style), then <di:title>, then the nested
|
||||
// tableOfContents/titleName form.
|
||||
// xml::text already trims its result, so an empty string after
|
||||
// extraction means a genuinely empty element.
|
||||
for tag in ["name", "title"] {
|
||||
if let Some(s) = xml::text(xml_text, tag) {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
if !s.is_empty() {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,9 +195,8 @@ fn extract_title(xml_text: &str) -> Option<String> {
|
||||
if let Some((s, e)) = xml::find_element(xml_text, "tableOfContents", 0) {
|
||||
let block = &xml_text[s..e];
|
||||
if let Some(t) = xml::text(block, "titleName") {
|
||||
let trimmed = t.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
if !t.is_empty() {
|
||||
return Some(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,6 +216,12 @@ fn extract_disc_set(xml_text: &str) -> Option<(u32, u32)> {
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()?;
|
||||
// Reject nonsensical "Disc N of M" values: (0,0), (0,5), (5,2)...
|
||||
// These serialize to JSON and reach downstream consumers as
|
||||
// meaningless metadata.
|
||||
if n < 1 || total < 1 || n > total {
|
||||
return None;
|
||||
}
|
||||
Some((n, total))
|
||||
}
|
||||
|
||||
@@ -210,10 +235,10 @@ mod tests {
|
||||
// carrier inside a <discInfo> root.
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Dune Part Two</di:name>
|
||||
<di:name>Aurora Drift</di:name>
|
||||
</discInfo>"#;
|
||||
let (title, desc, set) = parse_bdmt_xml("eng", xml).expect("title should parse");
|
||||
assert_eq!(title, "Dune Part Two");
|
||||
let (title, desc, set) = parse_bdmt_xml(xml).expect("title should parse");
|
||||
assert_eq!(title, "Aurora Drift");
|
||||
assert_eq!(desc, None);
|
||||
assert_eq!(set, None);
|
||||
}
|
||||
@@ -223,12 +248,12 @@ mod tests {
|
||||
// <di:title> is the alternate carrier; should be picked up
|
||||
// when <di:name> is absent.
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:title>The Matrix</di:title>
|
||||
<di:description>A film about computers.</di:description>
|
||||
<di:title>Echo Chamber</di:title>
|
||||
<di:description>A film about machines.</di:description>
|
||||
</discInfo>"#;
|
||||
let (title, desc, _) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(title, "The Matrix");
|
||||
assert_eq!(desc.as_deref(), Some("A film about computers."));
|
||||
let (title, desc, _) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(title, "Echo Chamber");
|
||||
assert_eq!(desc.as_deref(), Some("A film about machines."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -238,21 +263,65 @@ mod tests {
|
||||
// titleName inside tableOfContents.
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:tableOfContents>
|
||||
<di:titleName>Inside Out 2</di:titleName>
|
||||
<di:titleName>Feelings Two</di:titleName>
|
||||
</di:tableOfContents>
|
||||
</discInfo>"#;
|
||||
let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(title, "Inside Out 2");
|
||||
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(title, "Feelings Two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bdmt_size_gate_rejects_oversized_entries() {
|
||||
assert!(bdmt_size_acceptable(0));
|
||||
assert!(bdmt_size_acceptable(4096));
|
||||
assert!(bdmt_size_acceptable(MAX_BDMT_BYTES));
|
||||
assert!(!bdmt_size_acceptable(MAX_BDMT_BYTES + 1));
|
||||
// A crafted multi-GB size is rejected before any allocation.
|
||||
assert!(!bdmt_size_acceptable(8 * 1024 * 1024 * 1024));
|
||||
assert!(!bdmt_size_acceptable(u64::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_set_rejects_nonsensical_pairs() {
|
||||
// n > total, zero numerator, zero denominator → all None.
|
||||
let over = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>X</di:name>
|
||||
<di:discNumber>5</di:discNumber>
|
||||
<di:numSets>2</di:numSets>
|
||||
</discInfo>"#;
|
||||
assert_eq!(parse_bdmt_xml(over).unwrap().2, None);
|
||||
|
||||
let zero_n = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>X</di:name>
|
||||
<di:discNumber>0</di:discNumber>
|
||||
<di:numSets>5</di:numSets>
|
||||
</discInfo>"#;
|
||||
assert_eq!(parse_bdmt_xml(zero_n).unwrap().2, None);
|
||||
|
||||
let zero_total = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>X</di:name>
|
||||
<di:discNumber>1</di:discNumber>
|
||||
<di:numSets>0</di:numSets>
|
||||
</discInfo>"#;
|
||||
assert_eq!(parse_bdmt_xml(zero_total).unwrap().2, None);
|
||||
|
||||
// A valid pair still passes.
|
||||
let ok = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>X</di:name>
|
||||
<di:discNumber>2</di:discNumber>
|
||||
<di:numSets>3</di:numSets>
|
||||
</discInfo>"#;
|
||||
assert_eq!(parse_bdmt_xml(ok).unwrap().2, Some((2, 3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_box_set_position() {
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>LOTR Disc 2</di:name>
|
||||
<di:name>Box Set Disc 2</di:name>
|
||||
<di:discNumber>2</di:discNumber>
|
||||
<di:numSets>5</di:numSets>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
let (_, _, set) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(set, Some((2, 5)));
|
||||
}
|
||||
|
||||
@@ -264,7 +333,7 @@ mod tests {
|
||||
<di:discNumber>3</di:discNumber>
|
||||
<di:numberOfSets>6</di:numberOfSets>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
let (_, _, set) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(set, Some((3, 6)));
|
||||
}
|
||||
|
||||
@@ -276,7 +345,7 @@ mod tests {
|
||||
<di:name>X</di:name>
|
||||
<di:discNumber>1</di:discNumber>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
let (_, _, set) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(set, None);
|
||||
}
|
||||
|
||||
@@ -287,16 +356,16 @@ mod tests {
|
||||
// would. This exercises the BTreeMap key handling without
|
||||
// needing a UdfFs.
|
||||
let eng_xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Dune Part Two</di:name>
|
||||
<di:name>Aurora Drift</di:name>
|
||||
</discInfo>"#;
|
||||
let fra_xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Dune Deuxième Partie</di:name>
|
||||
<di:description>Suite du film de 2021.</di:description>
|
||||
<di:name>Aurora Drift (Partie Deux)</di:name>
|
||||
<di:description>Suite du film fictif.</di:description>
|
||||
</discInfo>"#;
|
||||
|
||||
let mut meta = DiscMetadata::default();
|
||||
for (lang, blob) in [("eng", eng_xml), ("fra", fra_xml)] {
|
||||
let (title, desc, ds) = parse_bdmt_xml(lang, blob).unwrap();
|
||||
let (title, desc, ds) = parse_bdmt_xml(blob).unwrap();
|
||||
meta.titles.insert(lang.to_string(), title);
|
||||
if let Some(d) = desc {
|
||||
meta.descriptions.insert(lang.to_string(), d);
|
||||
@@ -310,16 +379,16 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
meta.titles.get("eng").map(String::as_str),
|
||||
Some("Dune Part Two")
|
||||
Some("Aurora Drift")
|
||||
);
|
||||
assert_eq!(
|
||||
meta.titles.get("fra").map(String::as_str),
|
||||
Some("Dune Deuxième Partie")
|
||||
Some("Aurora Drift (Partie Deux)")
|
||||
);
|
||||
assert!(meta.descriptions.get("eng").is_none());
|
||||
assert!(!meta.descriptions.contains_key("eng"));
|
||||
assert_eq!(
|
||||
meta.descriptions.get("fra").map(String::as_str),
|
||||
Some("Suite du film de 2021.")
|
||||
Some("Suite du film fictif.")
|
||||
);
|
||||
assert_eq!(meta.disc_number, None);
|
||||
}
|
||||
@@ -333,30 +402,30 @@ mod tests {
|
||||
// surfaces as None to its caller. Either is documented as
|
||||
// acceptable per the module spec.
|
||||
let bad = "this is not xml &&& <<< nope";
|
||||
assert!(parse_bdmt_xml("eng", bad).is_none());
|
||||
assert!(parse_bdmt_xml(bad).is_none());
|
||||
|
||||
// Half-open tag, no body, no close: also yields no title.
|
||||
let truncated = "<discInfo><di:name>";
|
||||
assert!(parse_bdmt_xml("eng", truncated).is_none());
|
||||
assert!(parse_bdmt_xml(truncated).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_with_only_child_xml_is_dropped() {
|
||||
// Real-world bug from disc-04 (Top Gun: Maverick, 2026-05-11
|
||||
// capture): <di:description> contained only <di:thumbnail/>
|
||||
// child elements with no actual prose. The previous parser
|
||||
// surfaced the raw XML fragment as the description string.
|
||||
// Now we reject candidates that begin with `<`.
|
||||
// Real-world bug: <di:description> contained only
|
||||
// <di:thumbnail/> child elements with no actual prose. The
|
||||
// previous parser surfaced the raw XML fragment as the
|
||||
// description string. Now we reject candidates that begin
|
||||
// with `<`.
|
||||
let xml = r#"<discInfo>
|
||||
<di:name>Top Gun: Maverick</di:name>
|
||||
<di:name>Skyline Run</di:name>
|
||||
<di:description>
|
||||
<di:thumbnail href="tgm_meta_sm.jpg" />
|
||||
<di:thumbnail href="tgm_meta_lg.jpg" />
|
||||
<di:thumbnail href="sample_meta_sm.jpg" />
|
||||
<di:thumbnail href="sample_meta_lg.jpg" />
|
||||
</di:description>
|
||||
</discInfo>"#;
|
||||
let (title, description, _) =
|
||||
parse_bdmt_xml("eng", xml).expect("title is present so parse must succeed");
|
||||
assert_eq!(title, "Top Gun: Maverick");
|
||||
parse_bdmt_xml(xml).expect("title is present so parse must succeed");
|
||||
assert_eq!(title, "Skyline Run");
|
||||
assert!(
|
||||
description.is_none(),
|
||||
"description containing only XML children must be dropped, got {description:?}"
|
||||
@@ -371,7 +440,7 @@ mod tests {
|
||||
<di:name>Some Movie</di:name>
|
||||
<di:description>An epic tale of one man's quest for tea.</di:description>
|
||||
</discInfo>"#;
|
||||
let (_, description, _) = parse_bdmt_xml("eng", xml).expect("must parse");
|
||||
let (_, description, _) = parse_bdmt_xml(xml).expect("must parse");
|
||||
assert_eq!(
|
||||
description.as_deref(),
|
||||
Some("An epic tale of one man's quest for tea.")
|
||||
@@ -381,10 +450,10 @@ mod tests {
|
||||
#[test]
|
||||
fn whitespace_in_title_is_trimmed() {
|
||||
let xml = r#"<discInfo><di:name>
|
||||
Dune Part Two
|
||||
Aurora Drift
|
||||
</di:name></discInfo>"#;
|
||||
let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(title, "Dune Part Two");
|
||||
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(title, "Aurora Drift");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -399,4 +468,102 @@ mod tests {
|
||||
assert_eq!(lang_code_from_filename("bdmt_eng.txt"), None);
|
||||
assert_eq!(lang_code_from_filename("foo.xml"), None);
|
||||
}
|
||||
|
||||
// ── Additional hardening tests ─────────────────────────────────────────
|
||||
|
||||
/// Spec reference: BDA disc-library metadata schema, §3.3.2 — `<di:name>`
|
||||
/// takes priority over `<di:title>` as the title carrier.
|
||||
/// Mutation: swap `di:name` to `di:other` → test goes red because title is None.
|
||||
#[test]
|
||||
fn di_name_priority_over_di_title() {
|
||||
// When BOTH di:name and di:title are present, di:name wins.
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Primary Title</di:name>
|
||||
<di:title>Fallback Title</di:title>
|
||||
</discInfo>"#;
|
||||
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(title, "Primary Title");
|
||||
}
|
||||
|
||||
/// Spec reference: BDA disc-library metadata §3.3.2 — `<di:tableOfContents>`
|
||||
/// with nested `<di:titleName>` is a vendor-specific variant.
|
||||
/// Mutation: rename `titleName` → `movieName` → test goes red (None).
|
||||
#[test]
|
||||
fn di_name_wins_over_table_of_contents_title_name() {
|
||||
// di:name exists — tableOfContents/titleName must NOT override it.
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Winner</di:name>
|
||||
<di:tableOfContents>
|
||||
<di:titleName>Loser</di:titleName>
|
||||
</di:tableOfContents>
|
||||
</discInfo>"#;
|
||||
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(title, "Winner");
|
||||
}
|
||||
|
||||
/// Spec reference: BDA §3.3.2 — an empty `<di:name>` element must be
|
||||
/// treated as absent, falling through to the next candidate.
|
||||
/// Mutation: change `<di:name></di:name>` to `<di:name>X</di:name>` → red.
|
||||
#[test]
|
||||
fn empty_di_name_falls_through_to_di_title() {
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name></di:name>
|
||||
<di:title>Non-Empty Title</di:title>
|
||||
</discInfo>"#;
|
||||
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(title, "Non-Empty Title");
|
||||
}
|
||||
|
||||
/// Mutation: remove the `!s.is_empty()` filter → empty descriptions
|
||||
/// come through as Some("").
|
||||
#[test]
|
||||
fn empty_description_element_filtered_out() {
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Film</di:name>
|
||||
<di:description></di:description>
|
||||
</discInfo>"#;
|
||||
let (_, description, _) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(description, None);
|
||||
}
|
||||
|
||||
/// Disc N of N (e.g. 3 of 3) is valid — not an off-by-one error.
|
||||
/// Mutation: change `n > total` to `n >= total` → last disc of set is None.
|
||||
#[test]
|
||||
fn disc_set_allows_last_disc_equal_total() {
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Film</di:name>
|
||||
<di:discNumber>3</di:discNumber>
|
||||
<di:numSets>3</di:numSets>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(set, Some((3, 3)));
|
||||
}
|
||||
|
||||
/// When `<di:discNumber>` has non-numeric text, disc_number must be None.
|
||||
/// Mutation: remove the `.parse::<u32>().ok()?` guard → panics or wrong value.
|
||||
#[test]
|
||||
fn disc_set_non_numeric_disc_number_yields_none() {
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Film</di:name>
|
||||
<di:discNumber>one</di:discNumber>
|
||||
<di:numSets>5</di:numSets>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(set, None);
|
||||
}
|
||||
|
||||
/// Whitespace-only title element must be treated as empty (trimmed → "").
|
||||
/// Spec: xml::text trims; an all-whitespace element produces "" after trim,
|
||||
/// which the title-extraction logic should skip.
|
||||
/// Mutation: remove the `!s.is_empty()` guard in extract_title →
|
||||
/// whitespace-only di:name would be returned as the title.
|
||||
#[test]
|
||||
fn whitespace_only_di_name_falls_through() {
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name> </di:name>
|
||||
<di:title>Real Title</di:title>
|
||||
</discInfo>"#;
|
||||
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(title, "Real Title");
|
||||
}
|
||||
}
|
||||
|
||||
+69
-28
@@ -16,8 +16,6 @@
|
||||
// callers land. Tests below cover the API in isolation.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::fmt;
|
||||
|
||||
const CLASS_MAGIC: u32 = 0xCAFEBABE;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -34,24 +32,10 @@ pub enum Error {
|
||||
BadInstruction { pc: usize, opcode: u8 },
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::UnexpectedEof { needed } => write!(f, "unexpected EOF reading {}", needed),
|
||||
Error::BadMagic(m) => write!(f, "bad class file magic: 0x{:08X}", m),
|
||||
Error::BadCpTag { index, tag } => {
|
||||
write!(f, "unknown constant pool tag {} at index {}", tag, index)
|
||||
}
|
||||
Error::BadUtf8 { index } => write!(f, "invalid modified-UTF-8 at cp index {}", index),
|
||||
Error::BadCodeAttribute => write!(f, "malformed Code attribute"),
|
||||
Error::BadInstruction { pc, opcode } => {
|
||||
write!(f, "unrecognized opcode 0x{:02X} at pc={}", opcode, pc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
// No Display/std::error::Error impl: this is a crate-internal, typed error
|
||||
// used only for `match`/`?` within the label parsers (callers discard it via
|
||||
// `let Ok(_) = ... else continue`). Per the library's zero-English rule there
|
||||
// is no user-facing text; the variant fields carry the structured detail.
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
@@ -673,8 +657,14 @@ fn instruction_size(code: &[u8], pc: usize) -> Option<usize> {
|
||||
if high < low {
|
||||
return None;
|
||||
}
|
||||
let entries = (high - low + 1) as usize;
|
||||
Some(padded_start - pc + 12 + entries * 4)
|
||||
// `high - low + 1` can overflow i32 for adversarial bytecode
|
||||
// (e.g. low=i32::MIN/high=0, or low=0/high=i32::MAX), so widen
|
||||
// to i64 before adding. The product and final sum are saturating
|
||||
// so they cannot overflow usize on a 32-bit target either.
|
||||
let entries = (high as i64 - low as i64 + 1) as u64;
|
||||
let table_bytes = entries.saturating_mul(4);
|
||||
let base = (padded_start - pc + 12) as u64;
|
||||
usize::try_from(base.saturating_add(table_bytes)).ok()
|
||||
}
|
||||
LOOKUPSWITCH => {
|
||||
let padded_start = (pc + 1 + 3) & !3;
|
||||
@@ -686,7 +676,11 @@ fn instruction_size(code: &[u8], pc: usize) -> Option<usize> {
|
||||
if npairs < 0 {
|
||||
return None;
|
||||
}
|
||||
Some(padded_start - pc + 8 + (npairs as usize) * 8)
|
||||
// Saturating product/sum so an attacker-supplied npairs cannot
|
||||
// overflow usize on a 32-bit target.
|
||||
let pair_bytes = (npairs as u64).saturating_mul(8);
|
||||
let base = (padded_start - pc + 8) as u64;
|
||||
usize::try_from(base.saturating_add(pair_bytes)).ok()
|
||||
}
|
||||
WIDE => {
|
||||
// `wide` prefixes one of: iload/lload/fload/dload/aload/
|
||||
@@ -958,7 +952,12 @@ impl<'a> Reader<'a> {
|
||||
if self.pos + 4 > self.data.len() {
|
||||
return Err(Error::UnexpectedEof { needed });
|
||||
}
|
||||
let v = u32::from_be_bytes(self.data[self.pos..self.pos + 4].try_into().unwrap());
|
||||
let v = u32::from_be_bytes([
|
||||
self.data[self.pos],
|
||||
self.data[self.pos + 1],
|
||||
self.data[self.pos + 2],
|
||||
self.data[self.pos + 3],
|
||||
]);
|
||||
self.pos += 4;
|
||||
Ok(v)
|
||||
}
|
||||
@@ -971,7 +970,16 @@ impl<'a> Reader<'a> {
|
||||
if self.pos + 8 > self.data.len() {
|
||||
return Err(Error::UnexpectedEof { needed });
|
||||
}
|
||||
let v = u64::from_be_bytes(self.data[self.pos..self.pos + 8].try_into().unwrap());
|
||||
let v = u64::from_be_bytes([
|
||||
self.data[self.pos],
|
||||
self.data[self.pos + 1],
|
||||
self.data[self.pos + 2],
|
||||
self.data[self.pos + 3],
|
||||
self.data[self.pos + 4],
|
||||
self.data[self.pos + 5],
|
||||
self.data[self.pos + 6],
|
||||
self.data[self.pos + 7],
|
||||
]);
|
||||
self.pos += 8;
|
||||
Ok(v)
|
||||
}
|
||||
@@ -1026,9 +1034,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_utf8_three_byte_bmp() {
|
||||
// U+00E9 'é' as 3-byte BMP form is unusual but legal; the 2-byte
|
||||
// form is normative. Test the 2-byte form (0xC3 0xA9).
|
||||
fn modified_utf8_two_byte() {
|
||||
// U+00E9 'é' in the standard 2-byte modified-UTF-8 encoding
|
||||
// (0xC3 0xA9), exercising the decoder's 2-byte branch.
|
||||
let s = decode_modified_utf8(&[0xC3, 0xA9]).unwrap();
|
||||
assert_eq!(s, "é");
|
||||
}
|
||||
@@ -1069,6 +1077,39 @@ mod tests {
|
||||
assert_eq!(instruction_size(&code, 0), Some(28));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_size_tableswitch_overflow_does_not_panic() {
|
||||
// Adversarial low/high spanning the full i32 range. `high - low + 1`
|
||||
// overflows i32; the widened i64 count then saturates the byte
|
||||
// products. Must return a value (possibly None on a 32-bit usize)
|
||||
// without panicking.
|
||||
for (low, high) in [
|
||||
(i32::MIN, 0i32),
|
||||
(0i32, i32::MAX),
|
||||
(i32::MIN, i32::MAX),
|
||||
(-1i32, i32::MAX),
|
||||
] {
|
||||
let mut code = vec![TABLESWITCH];
|
||||
code.extend_from_slice(&[0, 0, 0]); // padding
|
||||
code.extend_from_slice(&[0, 0, 0, 0]); // default offset
|
||||
code.extend_from_slice(&low.to_be_bytes());
|
||||
code.extend_from_slice(&high.to_be_bytes());
|
||||
// No need to supply the (enormous) jump table; size computation
|
||||
// must not read it.
|
||||
let _ = instruction_size(&code, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_size_lookupswitch_overflow_does_not_panic() {
|
||||
// Maximal npairs; `npairs * 8` must saturate rather than overflow.
|
||||
let mut code = vec![LOOKUPSWITCH];
|
||||
code.extend_from_slice(&[0, 0, 0]); // padding
|
||||
code.extend_from_slice(&[0, 0, 0, 0]); // default
|
||||
code.extend_from_slice(&i32::MAX.to_be_bytes()); // npairs = i32::MAX
|
||||
let _ = instruction_size(&code, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_size_wide() {
|
||||
// wide iload: 4 bytes. wide iinc: 6 bytes.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user