Compare commits
765
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
125a8e5bf0 | ||
|
|
741c1ea11d | ||
|
|
e713b26b87 | ||
|
|
fcbd667add | ||
|
|
88c03152e2 | ||
|
|
c8e7ad5e56 | ||
|
|
f122f08628 | ||
|
|
b2c3540989 | ||
|
|
3a4307def6 | ||
|
|
917026d566 | ||
|
|
6b0bcbb43f | ||
|
|
516ff4e581 | ||
|
|
c27009d443 | ||
|
|
789d988314 | ||
|
|
1e0dc4c514 | ||
|
|
629ed32e9e | ||
|
|
ac3b3fcfa4 | ||
|
|
0c8153304e | ||
|
|
e94319c099 | ||
|
|
a93da78621 | ||
|
|
cf13838f12 | ||
|
|
9519628954 | ||
|
|
21f8cec419 | ||
|
|
d5d72c4e22 | ||
|
|
c781fb7193 | ||
|
|
541ca2139f | ||
|
|
2dd98c3e32 | ||
|
|
ceaa1da369 | ||
|
|
fe14a2d5e5 | ||
|
|
8f6a92ccd4 | ||
|
|
29f76aad68 | ||
|
|
840ba8390c | ||
|
|
e803905265 | ||
|
|
11d4c33477 | ||
|
|
8d775cd341 | ||
|
|
bc07011bcb | ||
|
|
d65b776a8e | ||
|
|
ff7f3028a5 | ||
|
|
25a7b131b4 | ||
|
|
0cab32a08a | ||
|
|
22f0f5eb6f | ||
|
|
2013ef8c44 | ||
|
|
acaae3d0a4 | ||
|
|
65c6835363 | ||
|
|
0f0c496a4d | ||
|
|
923b9edbf4 | ||
|
|
caf1b03fd4 | ||
|
|
9cc422bd00 | ||
|
|
5b39a0af2b | ||
|
|
bd0a21bdb4 | ||
|
|
c8822ddea3 | ||
|
|
7a7ab2c9d8 | ||
|
|
7a0ef5412c | ||
|
|
8c38cb0918 | ||
|
|
b36896564f | ||
|
|
e648bde94b | ||
|
|
c86fa9bfc6 | ||
|
|
2ba6274eae | ||
|
|
3bdb6f8b1a | ||
|
|
80e8523db2 | ||
|
|
ab8f09645f | ||
|
|
326d17c2f4 | ||
|
|
bac105a022 | ||
|
|
cfcc524367 | ||
|
|
03820c68f8 | ||
|
|
f682405973 | ||
|
|
93fbfac6f0 | ||
|
|
a7c8ee09b0 | ||
|
|
263950622f | ||
|
|
9e6af4a729 | ||
|
|
be08e3938b | ||
|
|
cb7d78ac6a | ||
|
|
039a8f9f19 | ||
|
|
c5515d310f | ||
|
|
789b699f95 | ||
|
|
71b4b09c93 | ||
|
|
067fd207d5 | ||
|
|
e6180a429b | ||
|
|
d715a0943a | ||
|
|
9a7be7a1a5 | ||
|
|
a731e7b26b | ||
|
|
4d6f5c0a98 | ||
|
|
cad5929afe | ||
|
|
59681dfd4b | ||
|
|
eba34f4c20 | ||
|
|
49e5627a69 | ||
|
|
fb8405385e | ||
|
|
f23338b5dd | ||
|
|
a7bd574c34 | ||
|
|
f49ef023cf | ||
|
|
ba5e4fdafa | ||
|
|
ced89133cc | ||
|
|
aefd6b6342 | ||
|
|
78f78d285e | ||
|
|
80ecb671fd | ||
|
|
c49a180ce7 | ||
|
|
afa218fc8f | ||
|
|
835cc990ad | ||
|
|
d8c323bf9f | ||
|
|
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 |
@@ -6,16 +6,46 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- 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
|
||||||
|
# lockfile to lock against. The binary crates (freemkv, autorip,
|
||||||
|
# bdemu) track Cargo.lock and DO use --locked.
|
||||||
|
- run: cargo clippy -- -D warnings
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@1.86.0
|
||||||
- run: cargo test
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo test --tests
|
||||||
|
|
||||||
check-macos:
|
check-macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@1.86.0
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
- run: cargo check
|
- run: cargo check
|
||||||
|
|
||||||
|
check-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: dtolnay/rust-toolchain@1.86.0
|
||||||
|
- 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 }}"
|
||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
verify:
|
verify:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Verify Cargo.toml version matches tag
|
- name: Verify Cargo.toml version matches tag
|
||||||
run: |
|
run: |
|
||||||
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
|
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
|
||||||
@@ -22,30 +22,54 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "Version match: $CARGO_VER"
|
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:
|
test:
|
||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- 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
|
- 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:
|
publish:
|
||||||
needs: test
|
needs: [verify, test]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- 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
|
- name: Publish to crates.io
|
||||||
run: cargo publish
|
run: cargo publish --no-verify
|
||||||
env:
|
env:
|
||||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||||
|
|
||||||
release:
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ jobs:
|
|||||||
update:
|
update:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
|
token: ${{ secrets.ORG_DISPATCH_TOKEN }}
|
||||||
|
|
||||||
- name: Update version in README
|
- name: Update version in README
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+11
@@ -3,3 +3,14 @@ Cargo.lock
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.cargo/
|
||||||
|
|
||||||
|
# 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/
|
||||||
|
|||||||
+646
@@ -0,0 +1,646 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [1.2.1] — 2026-07-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **DVD DTS audio no longer muxes with non-monotonic timestamps.** A DVD
|
||||||
|
Program Stream packs several DTS core frames into one PES packet; the parser
|
||||||
|
stamped every access unit with that single PES timestamp and no per-frame
|
||||||
|
duration, so consecutive frames collided on one PTS and a strict decode/remux
|
||||||
|
(ffmpeg) rejected the track — `non monotonically increasing dts to muxer`.
|
||||||
|
The DTS parser now derives each core frame's duration from its header
|
||||||
|
(`(NBLKS+1)*32` samples ÷ the `SFREQ` sample rate) and re-bases to each PES's
|
||||||
|
own container timestamp, advancing by a frame duration only *within* a single
|
||||||
|
PES — so the track stays monotonic and does not drift past its real length on
|
||||||
|
a feature-long title. The UHD DTS-HD MA path (one access unit per PES) is
|
||||||
|
unaffected: each unit keeps its own PES timestamp, preserving the 1.2.0 per-PES
|
||||||
|
attribution. Completes the DVD DTS fix begun in 1.2.0 (which corrected the
|
||||||
|
silent-track routing, exposing this timing bug). Note: genuinely corrupt
|
||||||
|
source DTS frames — valid framing, bad audio blocks — are passed through
|
||||||
|
faithfully; freemkv never fabricates or drops audio it can't prove is bad.
|
||||||
|
|
||||||
|
## [1.2.0] — 2026-07-01
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
The disc's AACS version is now carried through the key-resolution path as the
|
||||||
|
single source of truth for the `Unit_Key_RO` stride (AACS-1.0 = 48-byte,
|
||||||
|
AACS-2.x = 64-byte), so keys are always read at the disc's own layout. That
|
||||||
|
threaded one new value through three public signatures. In-tree consumers
|
||||||
|
(`freemkv`, `autorip`, `freemkv-keysources`) are updated; external callers must
|
||||||
|
adjust:
|
||||||
|
|
||||||
|
- **`DiscInputs` gains a `version: u8` field** (between `volume_id` and `mkb`).
|
||||||
|
Code constructing it with a struct literal must add the field. It is normally
|
||||||
|
obtained from `Disc::inputs()`, not constructed by hand.
|
||||||
|
- **`keysource::DiscInputsCtx::new` takes one argument, not two** — the version
|
||||||
|
is now read from `inputs.version` (`new(inputs)` instead of
|
||||||
|
`new(inputs, version)`).
|
||||||
|
- **`disc::read_aacs_inputs` / `read_aacs_inputs_from_drive` return a 3-tuple**
|
||||||
|
`(inf, mkb, version)` instead of `(inf, mkb)`.
|
||||||
|
- **`PassProgress` is no longer `Copy` and gains a `located: LocatedProgress`
|
||||||
|
field.** It now carries a `Vec` (the rendered bad-range drilldown), so it's
|
||||||
|
`Clone` only — still built once per throttled emission and passed by reference
|
||||||
|
to `Progress::report`. Struct-literal constructors must add the field (empty:
|
||||||
|
`located: Default::default()`). New public types `LocatedRange` /
|
||||||
|
`LocatedProgress`.
|
||||||
|
|
||||||
|
These are source-breaking for external crates.io consumers. Shipped under a
|
||||||
|
minor bump (1.2.0): libfreemkv's surface is not yet frozen and the only known
|
||||||
|
consumers are the in-tree toolchain crates.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Pass-N marginal-sector recovery specialists.** The patch pass gained a
|
||||||
|
roster of parameterized recovery techniques — read speed (max/min), cache
|
||||||
|
bypass (FUA), and traversal (linear fwd/rev, bisect, cache-prime, oscillate,
|
||||||
|
per-sector speed-sweep) — each targeting a distinct physical failure mode of
|
||||||
|
marginal media. A per-rip **decayed (EWMA) scorecard** grades every technique
|
||||||
|
by its recent recovery rate and re-orders them best-first, so the engine
|
||||||
|
hardcodes no conclusion: a technique that fits *this* disc floats to the front
|
||||||
|
and one that doesn't self-deprioritises (but is never dropped). Every read is
|
||||||
|
wedge-safe and deadline-bounded; the existing fast/deep recovery behavior is
|
||||||
|
unchanged (the specialists are additive, tried only on the hardened residue).
|
||||||
|
- **Opt-in flat-pool recovery scheduler (`FREEMKV_PATCH_FLAT`).** Collapses the
|
||||||
|
breadth-first recovery tiers into one flat pool so every technique gets a shot
|
||||||
|
at each bad range immediately, scorecard-ordered — a data-driven bandit for a
|
||||||
|
hardened residual (e.g. a late resume) where the tiered ladder would spend a
|
||||||
|
long time on cheap techniques before reaching the specialists. Unset keeps the
|
||||||
|
proven tier ladder as the default.
|
||||||
|
- **`PassProgress` is the complete, mapfile-free progress contract.** Every
|
||||||
|
emission now carries the fully-rendered "where is the damage" drilldown
|
||||||
|
(`located`): the bad ranges annotated with chapter + movie-time offset, the
|
||||||
|
main-feature at-risk time, the section count and the largest gap — computed by
|
||||||
|
the library from its in-memory mapfile + title. A client (autorip, a future
|
||||||
|
GUI/CLI) renders the disc map + at-risk time straight from it and never parses
|
||||||
|
the mapfile, so a mapfile→mapdb change is invisible to clients. Adds
|
||||||
|
`disc::locate_ranges`, the one-shot `disc::progress_snapshot_from_mapfile`
|
||||||
|
(builds a snapshot from a mapfile on disk so a boundary/verdict paint stays
|
||||||
|
mapfile-free client-side), and `consts::MILLIS_PER_SEC`.
|
||||||
|
|
||||||
|
- **`PatchOptions::fast_capture` — breadth-first patch recovery.** A fast-capture
|
||||||
|
pass reads each bad range once at the full batch and leaves every failed block
|
||||||
|
`NonTrimmed` for a later pass — no bisect, no re-read, no per-sector grind — so
|
||||||
|
a first retry pass grabs the readable blocks (a sweep's good skip-ahead
|
||||||
|
overshoot) of EVERY range before any single range's slow per-sector recovery.
|
||||||
|
No data is dropped: a failed block stays `NonTrimmed` (retried by a granular
|
||||||
|
pass), never `Unreadable`. A transport fault still aborts. `Disc::copy`'s
|
||||||
|
internal patch leaves it `false` (single-call full recovery).
|
||||||
|
|
||||||
|
- **Mux loss concealment — a logged gap still produces a decode-clean file.**
|
||||||
|
When a unit genuinely cannot be decrypted on the mux read path (a key the disc
|
||||||
|
never yielded, after the rip's own decrypt-verify already failed loud and
|
||||||
|
re-read), the mux no longer passes ciphertext downstream or emits a broken
|
||||||
|
frame. The undecryptable aligned unit is concealed as NULL transport-stream
|
||||||
|
packets (PID 0x1FFF, invisible to every real stream), and the codec layer
|
||||||
|
**drops forward to the next keyframe** so no frame with a dangling reference
|
||||||
|
reaches the muxer. An ffmpeg deep scan of the result is clean — no missing
|
||||||
|
references, no partial frames. The loss is tallied and logged, never silently
|
||||||
|
dropped, and the mux always completes. Audio and subtitle tracks have no
|
||||||
|
cross-frame references, so only the directly-affected frames are dropped there.
|
||||||
|
Decrypt-verify remains a **rip** gate (fail loud → re-read), never a mux gate.
|
||||||
|
- **`Disc::unlocker_matrix()` — registry-driven unlocker did-work report.** Returns
|
||||||
|
each registered unlocker's name alongside a `did_work` flag recording whether it
|
||||||
|
performed authentication steps during the current rip. Callers (autorip, the CLI)
|
||||||
|
surface this so an operator can confirm at a glance which unlock paths —
|
||||||
|
LibreDrive firmware, AACS, CSS — actually ran, with no hardcoded names on the
|
||||||
|
caller side.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **One hex parser.** All hex parsing (keys, IDs, key-source inputs) routes
|
||||||
|
through a single `libfreemkv::hex` parser instead of several ad-hoc decoders,
|
||||||
|
so length/odd-nibble/invalid-digit handling is identical everywhere.
|
||||||
|
- **Robust encrypted-unit sampling + a single MKB framing walker.** Up-front
|
||||||
|
AACS sampling tolerates content layouts that previously yielded too few
|
||||||
|
encrypted units to resolve a key, and the Media Key Block is now walked by one
|
||||||
|
framing routine shared across the in-band and out-of-band readers (no
|
||||||
|
divergent record-stride logic). AACS resolution hardened around these paths.
|
||||||
|
- **One reader, one `DiscInputs`.** `Disc::inputs()` is now the single, complete
|
||||||
|
source of a disc's AACS inputs (inf, MKB, VID, disc_hash, version), and
|
||||||
|
`read_aacs_inputs*` returns the version alongside inf+MKB. Both the CLI and
|
||||||
|
autorip resolve through `Disc::inputs()`; the duplicate out-of-band readers
|
||||||
|
(autorip's `key_files()`/`volume_id()`) and the stale mapfile-VID read are
|
||||||
|
removed. AACS file paths and the AACS major versions are now named constants
|
||||||
|
(`aacs::PATH_*`, `aacs::AACS_MAJOR_*`, `AacsVersion::major`/`from_major`) so a
|
||||||
|
fallback or stride change lives in exactly one place.
|
||||||
|
- **Pass-N recovery rebuilt as a bounded, never-hang handler chain.** The 1.1.0
|
||||||
|
patch loop retried each bad range sector-by-sector until a per-range budget was
|
||||||
|
exhausted, with no escape from a wedged drive short of the watchdog firing after
|
||||||
|
tens of minutes. 1.2.0 replaces that with a two-tier handler chain dispatched
|
||||||
|
breadth-first, largest bad range first:
|
||||||
|
- **Jump** (lead tier): reads each range in large forward-skipping batches to
|
||||||
|
quickly locate readable islands — clearing a multi-gigabyte dead spot in
|
||||||
|
seconds rather than sector-by-sector.
|
||||||
|
- **Bisect** (trailing tier): binary-searches the boundaries of each remaining
|
||||||
|
bad block, converging to within a single sector of the last-readable LBA.
|
||||||
|
Boundary-probe reads are exempt from the early-yield stall so the boundary
|
||||||
|
walk always completes.
|
||||||
|
- **Handler scorecard**: handlers that make progress stay at the front of the
|
||||||
|
rotation per rip; an idle handler is ranked last so proven performers lead.
|
||||||
|
- **Wedge detection**: a pass-level streak counter tracks consecutive
|
||||||
|
wedge-family senses (HARDWARE ERROR / ILLEGAL REQUEST) across section
|
||||||
|
boundaries. At the threshold the pass aborts and a soft un-wedge
|
||||||
|
(`Drive::spin_cycle()` — START STOP UNIT, no eject) runs before the next retry
|
||||||
|
pass, instead of grinding at near-zero throughput until the pass watchdog
|
||||||
|
fires.
|
||||||
|
|
||||||
|
No data is dropped: a block that neither handler recovers in a pass stays
|
||||||
|
`NonTrimmed` for the next pass.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **DVD DTS/LPCM audio tracks no longer mux silent.** On DVD-Video the
|
||||||
|
`private_stream_1` sub-stream id's low nibble is the audio-stream *number*
|
||||||
|
(shared across codecs), not a per-codec ordinal. A DTS or LPCM track that
|
||||||
|
wasn't the disc's first audio stream got a sub-id one too low, so the demux
|
||||||
|
routing key (`0xBD00 | sub_id`) never matched and every packet was dropped —
|
||||||
|
the track appeared in the container but played silent (AC-3 at position 0
|
||||||
|
worked by coincidence). Audio sub-stream ids are now assigned by positional
|
||||||
|
stream number, so a DTS 5.0 track after an AC-3 5.1 track routes correctly.
|
||||||
|
- **ISO mux no longer drops real video at content-fragment tails.** A title's
|
||||||
|
encrypted content can end mid-AACS-unit, with the disc zero-padding the rest
|
||||||
|
of the 6144-byte aligned unit to the next fragment. The decrypt-verify
|
||||||
|
demanded the TS sync byte on *all 32* source packets, so it rejected such a
|
||||||
|
tail unit over its legitimate padding — discarding the real video packets it
|
||||||
|
contained. On a flawless rip this surfaced as a small phantom "loss" at mux
|
||||||
|
(and, once retries were exhausted, a truncated MKV). Unit acceptance is now
|
||||||
|
**padding-aware**: only packets whose *source* (pre-decrypt) bytes are
|
||||||
|
non-zero must restore their TS sync; the zero padding is excluded from the
|
||||||
|
check and emitted as clean zeros. A full content unit still requires all 32
|
||||||
|
(unchanged — no wrong-key relaxation), and a unit whose *non-zero* tail fails
|
||||||
|
to decrypt is still rejected as a genuine bad read.
|
||||||
|
- **ISO online key resolution now sends the Media Key Block.** Capturing a
|
||||||
|
disc's AACS inputs at scan read the MKB with a full `read_file` of the
|
||||||
|
~128 MiB `MKB_RO`/`MKB_RW` allocation, which fails on file-backed readers —
|
||||||
|
leaving the MKB empty, so `Disc::inputs()` shipped `mkb=0` to an online key
|
||||||
|
service and the request was rejected (no key → no decrypt). Scan now reads the
|
||||||
|
MKB through the same bounded prefix-grow + trim reader as the out-of-band
|
||||||
|
path, so `Disc::inputs()` is the single complete source of AACS inputs — one
|
||||||
|
reader for every caller.
|
||||||
|
- **Read-time key-fetch parses `Unit_Key_RO.inf` at the disc's own AACS stride.**
|
||||||
|
The on-demand fetch (for a CPS unit not sampled up front) hardcoded the V20
|
||||||
|
64-byte stride, so an AACS-1.0 (V10) disc whose key arrived as a VUK derived
|
||||||
|
the wrong unit keys. `DiscInputs` now carries the disc's `version`, and the
|
||||||
|
context parses at the matching stride — the disc is the single source of truth
|
||||||
|
for its own stride (no separate version argument to drift).
|
||||||
|
- **A dry key-fetch for one unit no longer blocks fetching a different unit.**
|
||||||
|
A global "fetch spent" latch meant that once the key service returned nothing
|
||||||
|
for one CPS unit's ciphertext, no further unit was ever asked — so a multi-CPS
|
||||||
|
disc could strand a unit whose key the service *would* have served. Replaced
|
||||||
|
with a per-unit "already-asked-dry" set (still bounded by the fetch budget).
|
||||||
|
- **`verify::push_ranges` uses saturating arithmetic** so a corrupt-disc LBA near
|
||||||
|
`u32::MAX` can't panic (matches `udf::merge_ranges`).
|
||||||
|
- **Audio no longer corrupts at a stream discontinuity.** At a transport-stream
|
||||||
|
discontinuity — a continuity-counter break, an adaptation-field
|
||||||
|
discontinuity_indicator, or a concealed-loss gap — the AC-3 / DTS / TrueHD
|
||||||
|
parsers held a *truncated* partial access unit and spliced the post-gap bytes
|
||||||
|
onto it, manufacturing a corrupt frame (ffmpeg "exponent out of range" /
|
||||||
|
"Failed to decode block code(s)" / "Invalid data found") and, for TrueHD, a
|
||||||
|
non-monotonic timestamp band on multi-segment titles. The video path already
|
||||||
|
resynced via the keyframe gate; the audio parsers now do too — on a
|
||||||
|
discontinuity they drop the un-completable partial and resync on the next
|
||||||
|
syncword, rebasing the timestamp from the post-gap PES. A discontinuity becomes
|
||||||
|
a clean single-frame gap instead of a corrupt splice. Audio has no inter-frame
|
||||||
|
references, so dropping the truncated partial is the complete fix; the approach
|
||||||
|
matches FFmpeg's parser layer and GStreamer's `tsdemux`.
|
||||||
|
- **Drive-prep firmware unlock skipped for DVD discs.** An
|
||||||
|
`if disc_is_dvd() { return }` guard in `Drive::init()` (present since
|
||||||
|
1.0.0-rc.1) bypassed the entire drive-prep unlock step for DVDs. That unlock is
|
||||||
|
what removes riplock and raises the drive to maximum read speed — a drive-level,
|
||||||
|
disc-independent feature — so every DVD rip ran at riplock speed (~0.4× rated,
|
||||||
|
multi-hour ETA). The guard is removed; all disc types now go through the full
|
||||||
|
drive-prep sequence. UHD and Blu-ray were unaffected (they already ran through
|
||||||
|
the unlock path).
|
||||||
|
|
||||||
|
## [1.1.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Post-read decrypt-verify gate.** Every AACS unit read off the disc is now
|
||||||
|
buffered, re-aligned to its clip-file 6144-byte unit grid, and verified
|
||||||
|
(CPI flag → decrypt → strict all-32 TS-sync, matching libaacs `_verify_ts`)
|
||||||
|
before it is signed off as good. A unit that no held or freshly-fetched key
|
||||||
|
decrypts is treated exactly like a bad read — re-read by
|
||||||
|
the patch pass, terminal loss only if truly unrecoverable — closing the
|
||||||
|
"silent bad read" class where a sector reads OK but its ciphertext is subtly
|
||||||
|
wrong. **Fail-safe:** it only ever downgrades a unit it is *confident* is bad;
|
||||||
|
every uncertainty (no keys, a merely-missing key, an unread/zero-filled sector,
|
||||||
|
a non-AACS disc) leaves the read byte-for-byte as before. Gated by a
|
||||||
|
compile-time kill-switch (`POST_READ_VERIFY`), and container-pluggable (BD/UHD
|
||||||
|
transport stream today, with an HD-DVD program-stream seam in place).
|
||||||
|
- **Every error is now `Error: E<code> <message>`, with an Error Codes
|
||||||
|
reference.** User-facing errors show their code so you can look it up, and a
|
||||||
|
new **Error Codes** page lists every code with its message, cause, and next
|
||||||
|
steps. A contract test guarantees every error variant has a code, a message in
|
||||||
|
all seven languages, and a Codes-page entry. Messages are source-agnostic
|
||||||
|
("key source", never a specific database).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **AACS decrypt acceptance is now standards-strict.** A key is accepted only
|
||||||
|
when the decrypted unit has the TS sync byte on *all* 32 source packets
|
||||||
|
(libaacs `_verify_ts`), replacing a majority-vote heuristic where a wrong key
|
||||||
|
could coincidentally restore enough syncs to pass and silently corrupt a unit.
|
||||||
|
- keydb download/save moved out of the library into freemkv-keysources;
|
||||||
|
libfreemkv no longer has any keydb I/O (it already held no keys).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **AACS content-certificate bus-encryption flag read from the wrong bit.** The
|
||||||
|
flag is bit 7 of byte 1 (libaacs `p[1] >> 7`) but was read as bit 0, so a
|
||||||
|
bus-encrypted disc parsed as *not* bus-encrypted — defeating the fail-loud
|
||||||
|
guard that refuses to decrypt bus-wrapped data to garbage when no bus key was
|
||||||
|
obtained. Also corrected the cc_id offset (byte 14) and the AACS2 type marker
|
||||||
|
(`0x10`). Confirmed against real retail content certificates.
|
||||||
|
|
||||||
|
- **DVD rips now start on the movie, not the disc menu.** A VTS title VOB's
|
||||||
|
start sector was read from the IFO as a VTS-relative pointer but used as an
|
||||||
|
absolute disc address, so a DVD title's read extents began `ifo_lba` sectors
|
||||||
|
too early — the rip opened on the disc's menu / VMGI region and only drifted
|
||||||
|
into the feature minutes later (Silence of the Lambs, for example, showed
|
||||||
|
several minutes of the main menu before the movie). The title VOB is now
|
||||||
|
rebased to its absolute on-disc location, so the rip begins at the first frame
|
||||||
|
of the feature. Aspect ratio and chapter timing were already correct; only the
|
||||||
|
starting sector was wrong. (Covered by a new absolute-placement regression
|
||||||
|
test.)
|
||||||
|
- **Container metadata correctness.** Unknown colorimetry now emits the CICP
|
||||||
|
"unspecified" code point (2) consistently across the MKV track and the FVI
|
||||||
|
sidecar (previously 0); PGS subtitle wipes use the NORMAL composition state
|
||||||
|
rather than a full epoch reset; and FVI source-byte offsets are written
|
||||||
|
within-sector per the format spec.
|
||||||
|
- **Multi-extent AACS alignment in `dir://` extraction.** AACS encrypts in
|
||||||
|
aligned units of 3 sectors (6 KiB), and the decrypt-on-read gate accepts a read
|
||||||
|
only when its LBA is unit-aligned against a base. The `dir://` file-tree
|
||||||
|
extractor set that base once, to the file's first extent. A fragmented file
|
||||||
|
(Long-AD / continuation-ICB allocation) has later extents starting at arbitrary
|
||||||
|
LBAs whose distance from the first extent is generally not a multiple of 3
|
||||||
|
sectors, so the first read of every later extent failed the gate, returned a
|
||||||
|
decrypt error, and the whole extent was written as a zero-filled hole — even
|
||||||
|
though the sectors were readable. The unit base is now re-anchored per extent
|
||||||
|
(matching the mux read paths), so each extent gates on its own unit grid.
|
||||||
|
Decryption math is unchanged. Same class as the rc.5.2 clip-anchor fix.
|
||||||
|
- **Distinct "no key" reasons.** When AACS key resolution has usable material
|
||||||
|
(device or processing keys) but cannot obtain the disc's Volume ID — needed to
|
||||||
|
derive the unit key — freemkv now reports a distinct "AACS Volume ID
|
||||||
|
unavailable" error (E7017) instead of collapsing it into the generic "no key"
|
||||||
|
error (E7022), which is now reserved for a genuine absence of any key material.
|
||||||
|
No key derivation or descramble logic changed — only the reason reported on a
|
||||||
|
resolution failure.
|
||||||
|
- **autorip keydb writes go to the right path.** Auto-download, daily refresh,
|
||||||
|
the "Update KEYDB" button, and the startup existence-check now resolve to the
|
||||||
|
service's config path (matching where reads look); they previously used the
|
||||||
|
CLI's executable-local default.
|
||||||
|
- **Crash-safety hardening** in `dir://` extraction and keydb writes (fsync of
|
||||||
|
files and parent directories around rename).
|
||||||
|
- **Windows-reserved filenames** (`CON`, `NUL`, `COM1`…) inside a disc's file
|
||||||
|
tree are safely renamed on extraction instead of aborting the walk.
|
||||||
|
- **`--version` now matches the build stamped into MKVs.** The CLI's `--version`
|
||||||
|
string and the `MuxingApp` / `WritingApp` fields written into every MKV now
|
||||||
|
derive from a single libfreemkv constant — the package version plus the git
|
||||||
|
short hash (e.g. `freemkv 1.1.0 (g835cc99)`). The muxer previously kept
|
||||||
|
its own copy of that string, so the two could drift; a binary and the files it
|
||||||
|
produces can no longer report different versions.
|
||||||
|
- **DTS-HD Master Audio: a false core-sync inside the lossless extension no
|
||||||
|
longer splits an audio frame.** A byte pattern in the extension substream that
|
||||||
|
resembled the `0x7FFE8001` core sync word could truncate the lossless
|
||||||
|
extension and produce decode errors on the affected frames. The extension
|
||||||
|
substream is now sized exactly from its header, so that pattern is skipped as
|
||||||
|
data.
|
||||||
|
- **TrueHD: decode timestamps no longer step backward.** In a case where the
|
||||||
|
source PES timing lagged the audio access-unit cadence, the muxed decode
|
||||||
|
timestamp could regress (non-monotonic-DTS warnings to the muxer); the running
|
||||||
|
timestamp is now clamped so it never goes backward.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- 58 new tests across the toolchain (AACS key resolution, the unlocker seam, the
|
||||||
|
key sources, DVD/CSS, `dir://` routing, and autorip keydb resolution).
|
||||||
|
|
||||||
|
## [1.0.0-rc.5.3]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`dir://` output** — write a decrypted `VIDEO_TS` / `BDMV` file tree straight
|
||||||
|
from a disc or ISO instead of a single muxed file.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Source-agnostic key errors** — decryption messages no longer assume a local
|
||||||
|
key database is *the* key source.
|
||||||
|
- **The default `keydb.cfg` location is next to the executable** (portable CLI);
|
||||||
|
the autorip service keeps its container path.
|
||||||
|
- **Simpler flags** — dropped `-k` (use `--keydb`) and removed `--device` (the
|
||||||
|
drive is named in the source URL, e.g. `disc:///dev/sgN`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Fail loud on missing keys or bad input** instead of silently writing an
|
||||||
|
undecrypted file.
|
||||||
|
|
||||||
|
## [1.0.0-rc.5.2]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Reverted the rc.5.1 `DefaultDecodedFieldDuration` experiment for interlaced
|
||||||
|
SD-DVD.** rc.5.1 added a 20 ms `DefaultDecodedFieldDuration` field element to
|
||||||
|
the 576i/480i track header on the theory that Windows derives fps from it.
|
||||||
|
Captured evidence showed that element made Windows Explorer report 12.5 fps
|
||||||
|
(half) and MediaInfo flip the track to "Frame rate mode: Variable", while
|
||||||
|
MakeMKV's rip of the same disc omits it. The element is therefore no longer
|
||||||
|
written (`MkvTrack::video` now passes `field_duration_ns == 0`); the track
|
||||||
|
keeps `FlagInterlaced=1` + `FieldOrder=TFF` and the full-frame 40 ms
|
||||||
|
`DefaultDuration` (`1/DefaultDuration` = 25 fps), matching MakeMKV. How a given
|
||||||
|
player or shell handler chooses to display interlaced fps is not guaranteed.
|
||||||
|
- **Correct AC-3 audio track selected on DVDs with non-standard sub-stream
|
||||||
|
ordering.** freemkv assigned each declared audio stream a physical sub-stream
|
||||||
|
by ordinal (`0x80+n`), assuming the IFO's first stream lives at `0x80`. On
|
||||||
|
discs where the 5.1 main mix sits on a different sub-stream and `0x80` carries
|
||||||
|
a 2.0 down-mix (e.g. Silence of the Lambs), the 2.0 was muxed under a "5.1"
|
||||||
|
label. freemkv now probes each physical sub-stream's actual channel count from
|
||||||
|
the disc — scanning every AC-3 frame and taking the maximum, so a brief 2.0
|
||||||
|
logo bed at the feature head can't mask the real 5.1 — and routes each declared
|
||||||
|
stream onto the sub-stream that genuinely matches.
|
||||||
|
- **"Decryption failed" on large AACS Blu-ray titles fixed.** AACS encrypts in
|
||||||
|
aligned units of 3 sectors (6 KiB); the unit-alignment gate measured `lba % 3`
|
||||||
|
against absolute disc LBA 0, but the unit grid is actually anchored at each
|
||||||
|
clip's encrypted-region start. A clip whose start is not 3-sector-aligned had
|
||||||
|
its readable units wrongly rejected — failing the feature/large titles of some
|
||||||
|
discs while short clips passed. The gate is now clip-anchored.
|
||||||
|
- **Single-pass disc→MKV recovers marginal/transient sectors before failing.**
|
||||||
|
The direct-to-MKV path now gives the drive its full ECC recovery budget on a
|
||||||
|
bad sector (matching the multipass rip) instead of reporting a read failure a
|
||||||
|
multipass rip would have recovered.
|
||||||
|
- **4K decode glitches at non-seamless clip joins fixed (Top Gun class).**
|
||||||
|
Titles assembled from clips joined at non-seamless boundaries no longer drop
|
||||||
|
reference frames at the join ("Could not find ref" stutter); the splice
|
||||||
|
keyframe is rewritten so the decoder discards only the genuinely-dangling
|
||||||
|
leading pictures.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`freemkv-keysources` is now a pure key lookup.** The encrypted content-sample
|
||||||
|
reader and the candidate-key resolution loop moved into libfreemkv (they read
|
||||||
|
the disc and validate keys — decryption mechanism, not lookup). A key source
|
||||||
|
now only looks a key up and hands it back. Downstream API: use
|
||||||
|
`libfreemkv::read_encrypted_units` / `libfreemkv::resolve_and_apply` (was
|
||||||
|
`freemkv_keysources::read_sample_units` / `…::resolve_and_apply`).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`--log-level 3` is now self-sufficient for MKV/opening-frame diagnosis.**
|
||||||
|
The diagnostic pass now (a) dumps the ACTUAL MKV `TrackEntry` elements written
|
||||||
|
per track (`tag=mkv.track`: FlagInterlaced, FieldOrder, DefaultDuration,
|
||||||
|
DefaultDecodedFieldDuration via field-duration, Display dims, codecPrivate as
|
||||||
|
hex) so the Windows-fps-class metadata is verifiable from a log alone, and
|
||||||
|
(b) captures the first ~100 coded frames per track (raw bytes) to a
|
||||||
|
`<output>.opening.bin` side file with a per-frame summary line
|
||||||
|
(`tag=mkv.opening.frame`: track, key/delta, size, PTS) so opening-GOP / menu
|
||||||
|
issues are diagnosable from a future log without the disc. Both are gated to
|
||||||
|
log-level 3; a normal run opens no side file and records nothing.
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
|
||||||
|
- **DVD opening-GOP / still-frame open handling is correct (no change needed).**
|
||||||
|
The hypothesis that the opening pictures get the wrong (last-seen) sequence
|
||||||
|
header or have their PTS floored to t=0 was traced and ruled out: the
|
||||||
|
codecPrivate is the FIRST sequence header (read once at headers-ready, before
|
||||||
|
any later AU), DVD VOBU structure guarantees each title opens on a sequence
|
||||||
|
header + I-frame (no mid-GOP open), the parser back-anchors leading
|
||||||
|
still-frames to the disc's real timeline, and the muxer anchors its timestamp
|
||||||
|
base on the opening keyframe's real PTS so the t=0 floor never corrupts it.
|
||||||
|
Regression tests pin all three.
|
||||||
|
|
||||||
|
## [1.0.0-rc.5.1]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **CSS reads unlocked on enforcing drives.** CSS-protected DVDs on
|
||||||
|
drives that enforce CSS authentication previously produced an empty MKV
|
||||||
|
at exit 0, or hung indefinitely. The read path now issues the bus-auth
|
||||||
|
handshake (`css::auth::unlock_css_reads`) to unlock scrambled-sector
|
||||||
|
reads before attempting any data transfer, so the drive gates lift
|
||||||
|
correctly.
|
||||||
|
- **Keyless title-key recovery always runs.** The Stevenson known-plaintext
|
||||||
|
attack (`css::crack_key` / `src/css/stevenson.rs`) now recovers the
|
||||||
|
title key even when the bus-auth scan detects a CSS drive, removing a
|
||||||
|
code path that fell through to locked reads on certain disc/drive
|
||||||
|
combinations. A wrong key still fails cleanly (confirmed by a sector
|
||||||
|
descramble check) rather than producing silent garbage.
|
||||||
|
- **Early bail on undecryptable discs.** When CSS authentication succeeds
|
||||||
|
but no valid title key can be recovered, the mux path now terminates
|
||||||
|
with a clear error code instead of writing an empty (or zero-byte)
|
||||||
|
output file.
|
||||||
|
- **DVD audio channel count from AC-3 bitstream.** The audio channel count
|
||||||
|
is now parsed from the AC-3 elementary-stream bitfield rather than from
|
||||||
|
the IFO audio attributes, so the reported channel count always matches the
|
||||||
|
actual muxed audio even when the IFO attribute disagrees. Passthrough only
|
||||||
|
— no downmix is performed. (Selecting the correct audio sub-stream on discs
|
||||||
|
with non-standard ordering is a separate item — see Known issues.)
|
||||||
|
- **Interlaced MKV frame rate on Windows.** Interlaced content (576i/480i)
|
||||||
|
now emits a `DefaultDecodedFieldDuration` element in the MKV track
|
||||||
|
header, which Windows Media Foundation and Explorer use to derive the
|
||||||
|
display frame rate. Without it, players reported an incorrect or zero
|
||||||
|
frame rate on interlaced tracks.
|
||||||
|
- **Per-track `BPS` bitrate tags populated.** The `BPS` tag is written for
|
||||||
|
each track so players and shell extensions (Windows Explorer, MPC-HC,
|
||||||
|
etc.) can display the per-stream bitrate without reading the full file.
|
||||||
|
- **Interlaced field order corrected to TFF.** 576i tracks were written
|
||||||
|
with a bottom-field-first (BFF) container flag that disagreed with the
|
||||||
|
top-field-first order carried in the MPEG-2 stream; the MKV `FieldOrder`
|
||||||
|
element now matches the stream (TFF) so deinterlacers use the correct
|
||||||
|
field parity.
|
||||||
|
- **DVD first-play menu no longer prepended to the feature.** The title
|
||||||
|
VOBS base sector was read from the VTS menu-VOBS pointer (`vtsm_vobs`,
|
||||||
|
offset 0xC0) instead of the title-VOBS pointer (`vtstt_vobs`, 0xC4), so on
|
||||||
|
a disc that authors a per-title menu the entire menu VOB — e.g. a studio
|
||||||
|
first-play "the parental level has been set, press yes" prompt — was
|
||||||
|
prepended to the movie and every cell extent shifted back. The rip now
|
||||||
|
opens on the feature's first frame.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **AACS handshake skipped on DVDs.** The AACS authentication sequence is
|
||||||
|
no longer attempted on DVD discs (it never applied to CSS-encrypted
|
||||||
|
media); attempting it on a DVD drive was a no-op at best and surfaced
|
||||||
|
spurious errors at worst.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Structured disc diagnostics at `--log-level 3`.** A new diagnostic
|
||||||
|
pass emits structured log events at INFO level when the log level is 3
|
||||||
|
or higher: DVD PGC/cell layout and IFO video/audio attributes; BD/UHD
|
||||||
|
playlist, clip, and AACS metadata. Provides a single-command snapshot
|
||||||
|
for diagnosing mux or authentication issues without instrumenting the
|
||||||
|
source.
|
||||||
|
- **Reduced per-operation log spam.** Mux-read and seek operations are
|
||||||
|
demoted to TRACE (were DEBUG); benign navigation-packet drops are
|
||||||
|
summarized as a single counter at the end of the title rather than
|
||||||
|
logged per-packet.
|
||||||
|
|
||||||
|
### Known issues
|
||||||
|
|
||||||
|
- **Wrong audio track on discs with non-standard substream ordering.**
|
||||||
|
Audio sub-stream ids are assigned by per-codec ordinal rather than read
|
||||||
|
from the IFO/PGC stream-number table, so a disc whose physical substream
|
||||||
|
order diverges from the convention may select the wrong audio track
|
||||||
|
(e.g. a 2.0 stream in place of 5.1). Diagnose with
|
||||||
|
`freemkv info disc://… --log-level 3`; fix tracked for the next release.
|
||||||
|
|
||||||
|
## [1.0.0-rc.4.2]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Windows durability.** New platform-aware `io::fsync` module: directory
|
||||||
|
fsync is a no-op on Windows (std cannot open a directory there, which
|
||||||
|
logged a spurious warning on every mapfile write — including from the
|
||||||
|
CLI), and a shared `file_durable` helper opens files read+write before
|
||||||
|
`sync_all` so the flush succeeds on Windows, where `FlushFileBuffers`
|
||||||
|
rejects a read-only handle with `ERROR_ACCESS_DENIED`.
|
||||||
|
|
||||||
|
## [1.0.0-rc.4] — UNRELEASED
|
||||||
|
|
||||||
|
An audit-driven round of correctness, durability, and Windows-transport
|
||||||
|
fixes. No API changes; behavior is more conservative on damaged media and
|
||||||
|
on partial decryption.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Decrypt-time loss is accounted for.** A partial AACS/CSS decryption
|
||||||
|
failure can no longer pass as a perfect rip — skipped/undecryptable
|
||||||
|
bytes are folded into the loss total — and partial CPS-unit (per-title)
|
||||||
|
key coverage is rejected in the AACS validation gate instead of
|
||||||
|
producing partly-garbage output.
|
||||||
|
- **Durable writes.** `keydb.cfg` is written atomically (temp file +
|
||||||
|
fsync + rename), and the mapfile fsyncs its parent directory after the
|
||||||
|
rename so a resume checkpoint survives a crash.
|
||||||
|
- **Truthful error causes.** A server-dropped keydb download is
|
||||||
|
classified as a connection error, not a parse error; a missing home
|
||||||
|
directory maps to "not found" rather than a keydb-parse failure; the
|
||||||
|
I/O error from opening an AACS-inputs ISO is preserved; and a
|
||||||
|
transport failure is preserved through the AACS auth handshake instead
|
||||||
|
of being relabeled.
|
||||||
|
- A failed `READ CAPACITY` now warns instead of silently using a
|
||||||
|
zero-sector disc.
|
||||||
|
- A leaked pipeline consumer can no longer finalize an abandoned output.
|
||||||
|
- **Windows SCSI.** `ScsiPassThroughDirect` is packed to match the
|
||||||
|
`ntddscsi.h` layout, `StorageAdapterDescriptor.BusType` width is
|
||||||
|
corrected (`u8` → `u32`), oversized read batches on non-sysfs
|
||||||
|
(Windows) drives are bounded, `IOCTL_STORAGE_RESET_DEVICE` failures are
|
||||||
|
surfaced, and a device reset only sleeps on success.
|
||||||
|
- Mux now tracks skipped bytes so a partly-read title reports accurate
|
||||||
|
loss.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The per-read `Drive::read` trace event was demoted to TRACE so a debug
|
||||||
|
log isn't flooded by per-sector reads.
|
||||||
|
|
||||||
|
## [1.0.0-rc.2]
|
||||||
|
|
||||||
|
Second release candidate for 1.0. libfreemkv is the core library: disc scan,
|
||||||
|
multipass sector recovery, content decryption (CSS, AACS 1.0/2.0), and the
|
||||||
|
threaded mux pipeline that turns a disc or ISO into an MKV. This candidate adds
|
||||||
|
keyless DVD/CSS support and correct DVD video, on top of security and recovery
|
||||||
|
hardening.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Keyless DVD/CSS title-key recovery.** A CSS-protected DVD decrypts with no
|
||||||
|
key database — the title key is recovered directly from the scrambled disc
|
||||||
|
data via the Stevenson known-plaintext attack (ported from libdvdcss) and
|
||||||
|
validated by descrambling a sector and confirming the known plaintext
|
||||||
|
reappears, so a wrong key fails cleanly instead of producing silent garbage
|
||||||
|
(`src/css/stevenson.rs`). `Disc::scan_image` recovers the same title key from
|
||||||
|
a raw, still-scrambled CSS ISO, so a raw image can be muxed without
|
||||||
|
pre-decryption.
|
||||||
|
- **MPEG-2 Program-Stream access-unit reassembler** (`src/mux/codec/mpeg2.rs`).
|
||||||
|
Buffers elementary-stream bytes across PES packets and emits exactly one
|
||||||
|
coded picture per MKV block, with presentation timestamps reconstructed from
|
||||||
|
the stream — fixing corrupted DVD video. Bounded buffer so a malformed stream
|
||||||
|
cannot exhaust memory.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Self-contained keyframes: the active param sets (HEVC VPS/SPS/PPS, H.264
|
||||||
|
SPS/PPS, VC-1 sequence/entry headers) are re-asserted at every keyframe and
|
||||||
|
any mid-title param-set change is emitted in-band, fixing whole-segment
|
||||||
|
HEVC/H.264/VC-1 corruption when a source stops repeating or reverts a param
|
||||||
|
set.
|
||||||
|
- Block timestamps use presentation order keyed on track type, so B-frame video
|
||||||
|
(including a Dolby Vision enhancement layer) keeps its true presentation
|
||||||
|
timestamps instead of decode-order timecodes.
|
||||||
|
- Mux unit alignment is scheme-aware (AACS vs CSS/none), so DVD extents are no
|
||||||
|
longer rejected for unit misalignment.
|
||||||
|
- MKV output records `freemkv <version>` in the Muxing/Writing application
|
||||||
|
fields, so every output file is traceable to its build.
|
||||||
|
- Subtitle `BlockDuration` values are scaled by the segment timecode scale, so
|
||||||
|
display durations are correct when the scale is not 1 ms.
|
||||||
|
- The NOT_READY retry pause in the patch (Pass N) loop is halt-responsive: a
|
||||||
|
stop request interrupts the drive-recovery wait immediately instead of
|
||||||
|
blocking shutdown.
|
||||||
|
- Bounded the keydb decompressed-plaintext reader (caps a malformed or
|
||||||
|
zip-bombed download).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A `READ(10)` that returns GOOD status with a residual underrun is treated as a
|
||||||
|
failed read (routed to retry) instead of committing stale buffer data —
|
||||||
|
closing a silent-corruption hole in the sweep and patch paths.
|
||||||
|
- `raw_command` on Linux masks the `DRIVER_SENSE` bit before treating a result
|
||||||
|
as an error, preventing false transport errors on commands that return sense
|
||||||
|
alongside a GOOD response.
|
||||||
|
- `READ CAPACITY (10)` rejects the "capacity exceeds 32-bit" sentinel instead of
|
||||||
|
silently wrapping to 0 and misreporting disc size.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Content keys (CSS disc/title keys, AACS unit/volume keys) are redacted in log
|
||||||
|
output (logged as `<redacted>` with a 1-byte fingerprint); a test guards
|
||||||
|
against any key field being logged with a raw value.
|
||||||
|
- The macOS SCSI shim uses `posix_spawn` directly instead of `system()` / `sh
|
||||||
|
-c`, eliminating a command-injection vector on the device-path string.
|
||||||
|
|
||||||
|
## [1.0.0-rc.1]
|
||||||
|
|
||||||
|
First release candidate for 1.0 — the first tagged 1.0 milestone of the core
|
||||||
|
library. Established the full feature set: multipass sector recovery, content
|
||||||
|
decryption (CSS, AACS 1.0/2.0) from `keydb.cfg`, disc parsing, and the threaded
|
||||||
|
mux pipeline (see "Pre-1.0 development" for the consolidated feature list).
|
||||||
|
|
||||||
|
## Pre-1.0 development
|
||||||
|
|
||||||
|
Versions 0.x were the iterative development series leading up to 1.0. The
|
||||||
|
highlights, condensed:
|
||||||
|
|
||||||
|
- **Multipass recovery engine.** Pass 1 sweeps the whole disc sequentially,
|
||||||
|
tolerating bad sectors with an adaptive damage-jump algorithm (mark the bad
|
||||||
|
range, keep going). Pass N retries the bad ranges with per-sector recovery
|
||||||
|
timeouts, reverse-direction reads, and range bisection. A mapfile tracks
|
||||||
|
per-sector state across passes so a rip can resume.
|
||||||
|
- **Drive and SCSI layer.** Single-shot, synchronous SG_IO transport on Linux
|
||||||
|
(with IOKit on macOS and SPTI on Windows), full SCSI sense decoding, and
|
||||||
|
drive enumeration / presence probes. Single-shot reads by design — recovery
|
||||||
|
lives in the multipass orchestration, not inline in the read path.
|
||||||
|
- **Content decryption.** CSS for DVDs and AACS 1.0/2.0 for Blu-ray and UHD,
|
||||||
|
with keys read from `keydb.cfg`. A single decrypting decorator wraps the
|
||||||
|
sector source so decryption is one audited surface, and a resolved key is
|
||||||
|
verified against disc content before it is applied.
|
||||||
|
- **Disc parsing.** UDF, MPLS/CLPI (Blu-ray), and IFO (DVD) parsing for title
|
||||||
|
and extent assembly, with bounds checks on values derived from untrusted disc
|
||||||
|
input. Canonical main-title selection picks the real feature over a
|
||||||
|
play-all virtual playlist on branching discs.
|
||||||
|
- **Mux pipeline (the "highway").** A three-stage threaded pipeline —
|
||||||
|
read+decrypt, demux, codec parse — with a recycled buffer pool, taking
|
||||||
|
file-backed mux from ~60 MB/s to several hundred MB/s warm-cache. Codec
|
||||||
|
parsers for HEVC, H.264, VC-1, MPEG-2, TrueHD, DTS(-HD), and PGS feed an
|
||||||
|
EBML/Matroska writer.
|
||||||
|
- **I/O stack.** Bounded-cache writeback (`sync_file_range` +
|
||||||
|
`posix_fadvise(DONTNEED)`) keeps the kernel dirty-page cache bounded on long
|
||||||
|
sequential writes, and time-batched mapfile persistence keeps NFS-staged rips
|
||||||
|
fast.
|
||||||
|
- **Library hygiene.** No user-facing English in the library — all errors are
|
||||||
|
numeric codes handled by the application layer. A large spec-grounded,
|
||||||
|
mutation-verified test suite guards the silent-corruption surfaces. Rust 2024
|
||||||
|
edition; release builds use thin LTO.
|
||||||
@@ -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
|
||||||
+46
-12
@@ -1,38 +1,72 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "libfreemkv"
|
name = "libfreemkv"
|
||||||
version = "0.3.0"
|
version = "1.2.1"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
rust-version = "1.86"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
description = "Open source raw disc access library for optical drives"
|
description = "Open source raw disc access library for optical drives"
|
||||||
repository = "https://github.com/freemkv/libfreemkv"
|
repository = "https://github.com/freemkv/libfreemkv"
|
||||||
keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
|
keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
|
||||||
categories = ["hardware-support", "multimedia"]
|
categories = ["hardware-support", "multimedia"]
|
||||||
|
# Keep internal AI-instruction / private notes out of the published crate.
|
||||||
|
exclude = ["CLAUDE.md"]
|
||||||
|
# OFF crates.io: libfreemkv git-deps freemkv-unlock (firmware, never published),
|
||||||
|
# so libfreemkv itself can only be consumed by git tag. Clients git-tag-pin it.
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
|
sha2 = "0.10"
|
||||||
aes = "0.8"
|
aes = "0.8"
|
||||||
cbc = "0.1"
|
cbc = "0.1"
|
||||||
flate2 = "1"
|
# Interim path dep for local cross-repo dev; the release script re-pins this to
|
||||||
|
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
|
||||||
|
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
||||||
|
freemkv-unlock = { path = "../freemkv-unlock" }
|
||||||
num-bigint = "0.4"
|
num-bigint = "0.4"
|
||||||
num-traits = "0.2"
|
num-traits = "0.2"
|
||||||
num-integer = "0.1"
|
num-integer = "0.1"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
cmac = "0.7"
|
cmac = "0.7"
|
||||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||||
|
base64 = "0.22.1"
|
||||||
|
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
|
||||||
|
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
|
||||||
|
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
||||||
|
tracing = "0.1"
|
||||||
|
# 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 (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
|
||||||
|
# and that overhead dominated for typical batch sizes (60 units).
|
||||||
|
# rayon's global pool initialises once on first use.
|
||||||
|
rayon = "1"
|
||||||
|
# SIMD-accelerated bytestring search. Drives the HEVC/H.264 start-code
|
||||||
|
# scan in `mux::codec::h264::find_start_code` — naive byte-by-byte
|
||||||
|
# walk is ~500 MB/s single-thread on x86_64; memchr's vectorised
|
||||||
|
# `memmem::find` for the 3-byte `00 00 01` needle hits ~5 GB/s on
|
||||||
|
# AVX2-capable hosts.
|
||||||
|
memchr = "2"
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
[[bin]]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
name = "freemkv-info"
|
libc = "0.2"
|
||||||
path = "src/bin/freemkv_info.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
[dev-dependencies]
|
||||||
name = "freemkv-test"
|
tempfile = "3"
|
||||||
path = "src/bin/freemkv_test.rs"
|
|
||||||
|
[[bench]]
|
||||||
|
name = "sgio_read"
|
||||||
|
harness = false
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "aacs-test"
|
|
||||||
path = "src/bin/aacs_test.rs"
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# libfreemkv — local dev helper.
|
||||||
|
# Mirrors the workspace-wide CI checks but scoped to this single crate.
|
||||||
|
|
||||||
|
.PHONY: test build check ci clean
|
||||||
|
|
||||||
|
test:
|
||||||
|
cargo test --tests
|
||||||
|
|
||||||
|
build:
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
check:
|
||||||
|
cargo fmt --check
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
|
ci: check build test
|
||||||
|
|
||||||
|
clean:
|
||||||
|
cargo clean
|
||||||
@@ -1,68 +1,161 @@
|
|||||||
[](https://crates.io/crates/libfreemkv)
|
|
||||||
[](https://docs.rs/libfreemkv)
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
# libfreemkv
|
# libfreemkv
|
||||||
|
|
||||||
Rust library for 4K UHD / Blu-ray optical drives. Drive access, disc scanning, AACS decryption, 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. Drive-level unlocking is handled internally; consumers work with disc access and decryption only.
|
||||||
|
|
||||||
**[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)**
|
DVDs (CSS) decrypt out of the box. Blu-ray and UHD (AACS) require a `keydb.cfg` (default `~/.config/freemkv/keydb.cfg`) supplying disc-specific volume unique keys; no AACS key material is compiled in.
|
||||||
|
|
||||||
|
**12+ MB/s** sustained read speeds on BD. Drive prep (`init()`) handles unlocking internally via the `freemkv-unlock` crate — clients never see it; when no drive unlock applies, the library rips via the host-certificate AACS handshake.
|
||||||
|
|
||||||
|
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
|
||||||
|
|
||||||
|
**[Source & API](https://github.com/freemkv/libfreemkv)** · **[Technical Docs](docs/)**
|
||||||
|
|
||||||
Part of the [freemkv](https://github.com/freemkv) project.
|
Part of the [freemkv](https://github.com/freemkv) project.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
Consumed by git tag (not published to crates.io):
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
libfreemkv = "0.3"
|
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "vX.Y.Z" }
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use libfreemkv::{DriveSession, Disc, ScanOptions};
|
use libfreemkv::{Drive, Disc, ScanOptions};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
// Open drive — profiles are bundled, auto-identified
|
// Open drive — identified via INQUIRY
|
||||||
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
|
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||||
|
drive.wait_ready()?; // wait for disc
|
||||||
|
drive.init()?; // unlock + prep (handled internally)
|
||||||
|
drive.probe_disc()?; // probe disc surface for optimal speeds
|
||||||
|
|
||||||
// Scan disc — UDF, playlists, streams, AACS (all automatic)
|
// Scan disc — UDF, playlists, streams, AACS (all automatic)
|
||||||
let disc = Disc::scan(&mut session, &ScanOptions::default())?;
|
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
|
||||||
|
|
||||||
for title in &disc.titles {
|
for title in &disc.titles {
|
||||||
println!("{} — {} streams", title.duration_display(), title.streams.len());
|
println!("{} — {} streams", title.duration_display(), title.streams.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read content (decrypted transparently if AACS keys available)
|
// Stream pipeline — read PES frames from any source, write to any output
|
||||||
let mut reader = disc.open_title(&mut session, 0)?;
|
let opts = libfreemkv::InputOptions::default();
|
||||||
while let Some(unit) = reader.read_unit()? {
|
let mut input = libfreemkv::input("iso://Disc.iso", &opts)?;
|
||||||
// 6144 bytes of content per aligned unit
|
let title = input.info().clone();
|
||||||
|
let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?;
|
||||||
|
while let Ok(Some(frame)) = input.read() {
|
||||||
|
output.write(&frame)?;
|
||||||
}
|
}
|
||||||
|
output.finish()?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-pass recovery rip
|
||||||
|
|
||||||
|
For damaged discs the library exposes two flat verbs — `Disc::sweep` for the
|
||||||
|
forward Pass 1 and `Disc::patch` for retrying bad ranges. The library never
|
||||||
|
loops; the multipass policy is the caller's job. See
|
||||||
|
[`docs/rip-recovery.md`](docs/rip-recovery.md).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use libfreemkv::{SweepOptions, PatchOptions};
|
||||||
|
use libfreemkv::disc::{mapfile, mapfile_path_for};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
let iso = Path::new("disc.iso");
|
||||||
|
|
||||||
|
// Pass 1: disc → ISO. Skip-on-error, zero-fill, write the sidecar mapfile.
|
||||||
|
disc.sweep(&mut drive, iso, &SweepOptions {
|
||||||
|
decrypt: true,
|
||||||
|
resume: false,
|
||||||
|
batch_sectors: None,
|
||||||
|
skip_on_error: true,
|
||||||
|
progress: None,
|
||||||
|
halt: None,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Pass 2..N: retry every non-finished range. Idempotent.
|
||||||
|
loop {
|
||||||
|
let map = mapfile::Mapfile::load(&mapfile_path_for(iso))?;
|
||||||
|
let stats = map.stats();
|
||||||
|
if stats.bytes_pending + stats.bytes_unreadable == 0 { break; }
|
||||||
|
|
||||||
|
let outcome = disc.patch(&mut drive, iso, &PatchOptions {
|
||||||
|
decrypt: true,
|
||||||
|
block_sectors: None,
|
||||||
|
full_recovery: true,
|
||||||
|
reverse: true,
|
||||||
|
wedged_threshold: 50,
|
||||||
|
progress: None,
|
||||||
|
halt: None,
|
||||||
|
})?;
|
||||||
|
if outcome.bytes_recovered_this_pass == 0 { break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mux from the ISO via the normal stream pipeline (no drive involvement).
|
||||||
```
|
```
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
- **Drive access** — open, identify, unlock for raw reads
|
- **Drive access** — open, identify, internal unlock + prep, speed control, eject
|
||||||
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info, BD-J labels
|
- **12+ MB/s reads** — auto-detects kernel transfer limits, sustained full speed
|
||||||
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0)
|
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
|
||||||
- **Content reading** — sector reads with automatic decryption
|
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||||
|
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0 bus decryption)
|
||||||
|
- **KEYDB updates** — download, verify, save from any HTTP URL (zero deps, raw TCP)
|
||||||
|
- **Content reading** — adaptive batch reads with automatic decryption
|
||||||
|
- **Stream I/O** — unified stream pipeline for reading and writing any format
|
||||||
|
|
||||||
AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption.
|
### Streams
|
||||||
|
|
||||||
|
| Stream | Input | Output | Transport |
|
||||||
|
|--------|-------|--------|-----------|
|
||||||
|
| DiscStream | Yes | -- | Optical drive via SCSI |
|
||||||
|
| IsoStream | Yes | -- | Blu-ray ISO image file (read via stream pipeline; written via `Disc::sweep()`) |
|
||||||
|
| MkvStream | Yes | Yes | Matroska container |
|
||||||
|
| M2tsStream | Yes | Yes | BD transport stream with FMKV metadata header |
|
||||||
|
| NetworkStream | Yes (listen) | Yes (connect) | TCP with FMKV metadata header |
|
||||||
|
| StdioStream | Yes (stdin) | Yes (stdout) | Raw byte pipe |
|
||||||
|
| NullStream | -- | Yes | Discard sink (byte counter for benchmarks) |
|
||||||
|
|
||||||
|
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, with no external key file needed.
|
||||||
|
|
||||||
|
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
|
## Architecture
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DriveSession — open, identify, unlock, read sectors
|
Drive — open, identify, init, single-shot read
|
||||||
├── ScsiTransport — SG_IO (Linux), IOKit (macOS planned)
|
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
|
||||||
├── DriveProfile — per-drive unlock parameters (bundled)
|
└── unlock_bridge — private seam to the freemkv-unlock crate
|
||||||
└── Platform — MediaTek (supported), Renesas (planned)
|
(firmware / AACS cert / CSS bus-auth unlockers)
|
||||||
|
|
||||||
Disc — scan titles, streams, AACS state
|
Disc — scan titles, streams, AACS/CSS state
|
||||||
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
||||||
├── MPLS parser — playlists → titles + clips + streams
|
├── MPLS parser — playlists → titles + clips + streams
|
||||||
├── CLPI parser — clip info → EP map → sector extents
|
├── CLPI parser — clip info → EP map → sector extents
|
||||||
├── JAR parser — BD-J audio track labels
|
├── IFO parser — DVD title sets, PGC chains, cell addresses
|
||||||
└── AACS — key resolution + content decryption
|
├── Labels — 5 BD-J format parsers (detect + parse)
|
||||||
|
├── AACS — key resolution + content decryption
|
||||||
|
├── CSS — DVD CSS (bus auth → player-key disc crack → known-plaintext title-key attack)
|
||||||
|
└── KEYDB — download + verify + save
|
||||||
|
|
||||||
|
Streams — unified PES pipeline
|
||||||
|
├── 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
|
||||||
|
├── M2tsStream — BD transport stream
|
||||||
|
├── NetworkStream — TCP with FMKV metadata header
|
||||||
|
├── StdioStream — stdin/stdout pipe
|
||||||
|
└── NullStream — discard sink
|
||||||
```
|
```
|
||||||
|
|
||||||
See [docs/](docs/) for detailed technical documentation on each module.
|
See [docs/](docs/) for detailed technical documentation on each module.
|
||||||
@@ -80,18 +173,20 @@ All errors are structured with numeric codes. No user-facing English text — ap
|
|||||||
| E5xxx | I/O errors |
|
| E5xxx | I/O errors |
|
||||||
| E6xxx | Disc format errors |
|
| E6xxx | Disc format errors |
|
||||||
| E7xxx | AACS errors |
|
| E7xxx | AACS errors |
|
||||||
|
| E8xxx | KEYDB update errors |
|
||||||
|
| E9xxx | Stream / mux errors (URL, PES, ISO, pipeline, demux) |
|
||||||
|
|
||||||
## Platform Support
|
## Platform Support
|
||||||
|
|
||||||
| Platform | Status | Backend |
|
| Platform | Status | Backend |
|
||||||
|----------|--------|---------|
|
|----------|--------|---------|
|
||||||
| Linux | Supported | SG_IO ioctl |
|
| Linux | Supported | SG_IO ioctl |
|
||||||
| macOS | Planned | IOKit |
|
| macOS | Supported | IOKit SCSITask |
|
||||||
| Windows | Planned | SPTI |
|
| Windows | Supported | SPTI |
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Run `freemkv info --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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
# Troubleshooting Guide
|
||||||
|
|
||||||
|
Common problems and solutions for optical drive ripping with freemkv.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. USB-SATA Bridge Issues
|
||||||
|
|
||||||
|
This is the single most common source of problems when ripping discs over USB.
|
||||||
|
|
||||||
|
### Symptoms
|
||||||
|
|
||||||
|
- The drive disappears mid-rip. The ripping tool reports the device is gone, and `ls /dev/sg*` no longer shows it.
|
||||||
|
- The device re-enumerates under a different name: `sg4` becomes `sg5`, then `sg7`, then `sg11` after each USB port reset.
|
||||||
|
- `dmesg` shows USB port resets: `usb X-Y: reset high-speed USB device`, `xhci_hcd 0000:00:14.0: Cannot enable. Maybe the USB cable is bad?`, or `usb-storage: device reset failed`.
|
||||||
|
- The SCSI layer reports `host_status=7` (Linux USB transport error) in sense data.
|
||||||
|
- The drive works fine for reading data discs or burning, but crashes when hitting damaged sectors during a rip.
|
||||||
|
- After the crash, the drive is completely invisible until physically unplugged and reconnected.
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
|
||||||
|
USB-SATA bridges translate between the USB Mass Storage protocol (BOT or UAS) and the drive's native SATA interface. When the optical drive encounters an unreadable sector, it returns a SCSI CHECK CONDITION with sense key 0x03 (MEDIUM ERROR). Some bridge chipsets -- particularly the Initio INIC-36xx family -- have buggy firmware that mishandles this error response.
|
||||||
|
|
||||||
|
Specific failure modes:
|
||||||
|
|
||||||
|
- **Incorrect residue reporting.** The bridge claims a different number of bytes transferred than what actually occurred. The Linux USB storage driver sees this discrepancy as a protocol violation and resets the port to recover. The `US_FL_IGNORE_RESIDUE` quirk exists specifically for this class of bug (see `drivers/usb/storage/transport.c` in the Linux kernel).
|
||||||
|
- **Bridge firmware crash.** On some Initio bridges, a malformed SCSI error response from the drive causes the bridge MCU to hang entirely. The USB controller sees the device stop responding and initiates a port reset. The bridge recovers (it re-enumerates), but the rip is dead -- all state is lost.
|
||||||
|
- **Sense data corruption.** The bridge forwards garbled or truncated sense data to the host, which the SCSI midlayer cannot parse, leading to a transport reset.
|
||||||
|
|
||||||
|
This is a hardware + firmware problem, not a software bug. The same drive connected via direct SATA does not exhibit these symptoms.
|
||||||
|
|
||||||
|
### Known Affected Bridges
|
||||||
|
|
||||||
|
| Chipset | USB IDs | Notes |
|
||||||
|
|---------|---------|-------|
|
||||||
|
| Initio INIC-3609 | `13fd:3609` | Very common in cheap SATA-to-USB enclosures. Highly problematic. |
|
||||||
|
| Initio INIC-3619 | `13fd:3940` | Same firmware family as INIC-3609. |
|
||||||
|
| Initio INIC-3069 | `13fd:0840` | Older variant, same residue bug. |
|
||||||
|
| ASMedia ASM1051 | `174c:5106` | Early ASM SATA bridge. Residue issues on error paths. |
|
||||||
|
| JMicron JMB36x | `152d:0561` | Some firmware versions. Not all JMicroon chips are affected. |
|
||||||
|
|
||||||
|
If your drive came in a pre-built external enclosure (Vantec, Sabrent, OWC, etc.), it almost certainly uses one of these bridge chips internally.
|
||||||
|
|
||||||
|
### The Fix: USB Storage Quirk
|
||||||
|
|
||||||
|
Apply the `US_FL_IGNORE_RESIDUE` kernel quirk for your bridge. This tells the Linux USB storage driver to ignore the residue field in SCSI response frames, preventing the port reset on mismatched byte counts.
|
||||||
|
|
||||||
|
**Step 1: Identify your bridge's vendor:product ID.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lsusb
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for your drive's entry. Example output:
|
||||||
|
|
||||||
|
```
|
||||||
|
Bus 002 Device 005: ID 13fd:0840 Initio Corporation INIC-3609
|
||||||
|
```
|
||||||
|
|
||||||
|
Here the vendor ID is `13fd` and the product ID is `0840`.
|
||||||
|
|
||||||
|
**Step 2: Apply the quirk at runtime.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "13fd:0840:i" > /sys/module/usb_storage/parameters/quirks
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `13fd:0840` with your device's actual IDs. The `:i` flag means `US_FL_IGNORE_RESIDUE`.
|
||||||
|
|
||||||
|
You can combine multiple flags. Common additions:
|
||||||
|
|
||||||
|
- `:i` -- ignore residue (`US_FL_IGNORE_RESIDUE`)
|
||||||
|
- `:u` -- force BOT mode instead of UAS, for bridges with UAS bugs
|
||||||
|
|
||||||
|
**Step 3: Reconnect the drive.** Unplug and replug the USB cable, or bind/unbind the device. The quirk is applied per-module-load, so existing sessions may need the drive reconnected.
|
||||||
|
|
||||||
|
### Making It Persistent
|
||||||
|
|
||||||
|
Add the quirk to your kernel boot parameters so it survives reboots.
|
||||||
|
|
||||||
|
Edit `/etc/default/grub` (GRUB) and add to `GRUB_CMDLINE_LINUX_DEFAULT`:
|
||||||
|
|
||||||
|
```
|
||||||
|
GRUB_CMDLINE_LINUX_DEFAULT="quiet usb_storage.quirks=13fd:0840:i"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then rebuild the GRUB config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo update-grub
|
||||||
|
```
|
||||||
|
|
||||||
|
For systemd-boot, add to your loader entry or `/etc/kernel/cmdline`:
|
||||||
|
|
||||||
|
```
|
||||||
|
usb_storage.quirks=13fd:0840:i
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple devices can be separated by commas:
|
||||||
|
|
||||||
|
```
|
||||||
|
usb_storage.quirks=13fd:0840:i,174c:5106:u
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended Bridges
|
||||||
|
|
||||||
|
If you are buying a USB-SATA adapter or enclosure for optical drive use:
|
||||||
|
|
||||||
|
| Bridge | USB IDs | Notes |
|
||||||
|
|--------|---------|-------|
|
||||||
|
| ASMedia ASM1153 | `174c:1153` | Reliable. Widely available in SATA-USB 3.0 cables. |
|
||||||
|
| JMicron JMS578 | `152d:0578` | Good firmware. Supports UASP. |
|
||||||
|
| Icy Box IB-AC640-C3 | N/A | Uses a known-good bridge internally. Plug-and-play. |
|
||||||
|
|
||||||
|
Avoid any enclosure or adapter listing an Initio chipset.
|
||||||
|
|
||||||
|
### Best Solution: Direct SATA
|
||||||
|
|
||||||
|
Connect your optical drive directly to a motherboard SATA port. This eliminates the USB-SATA bridge entirely and is the most reliable configuration:
|
||||||
|
|
||||||
|
- No USB protocol overhead or translation errors.
|
||||||
|
- No bridge firmware bugs.
|
||||||
|
- No port resets or re-enumeration.
|
||||||
|
- Full SATA error recovery handled natively by the kernel's libata driver.
|
||||||
|
- Sustained read speeds are limited only by the drive, not the USB bus.
|
||||||
|
|
||||||
|
If your machine has a free SATA port, use it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Damaged Disc Handling
|
||||||
|
|
||||||
|
### Symptoms
|
||||||
|
|
||||||
|
- SCSI MEDIUM ERROR (sense key 0x03) at specific LBAs. `dmesg` shows `sr X:0:0:0: [srY] Unrecoverable read error` or similar.
|
||||||
|
- Read speed drops to near zero when approaching a damaged area.
|
||||||
|
- The drive makes audible retrying noises (laser repositioning, spindle speed changes).
|
||||||
|
- On USB-connected drives: the bridge crashes (see section 1 above) when the drive returns the error.
|
||||||
|
|
||||||
|
### How freemkv Handles This
|
||||||
|
|
||||||
|
freemkv uses a three-layer recovery model. See [`docs/rip-recovery.md`](docs/rip-recovery.md) for full details.
|
||||||
|
|
||||||
|
- **Pass 1 (Disc::copy):** Fast sweep with 64 KB reads. On failure, zero-fills the block and skips forward. Writes a ddrescue-format mapfile for later retry.
|
||||||
|
- **Pass 2+ (Disc::patch):** Targeted re-reads of bad ranges with a long 30-second timeout per CDB. The drive firmware performs its own ECC and laser power retries within that window.
|
||||||
|
- **In-stream (DiscStream):** Adaptive batch halving -- reduces request size on failure to isolate bad sectors within a larger block.
|
||||||
|
|
||||||
|
This means a disc with some bad sectors will still produce a usable ISO. The damaged areas are zero-filled in pass 1 and retried in subsequent passes. Structure-protected sectors (deliberate unreadable regions from copy protection) will never yield, which is expected.
|
||||||
|
|
||||||
|
### The Drive Taint Issue (LG BU40N)
|
||||||
|
|
||||||
|
Some drives, notably the LG BU40N, exhibit a "taint" behavior after encountering MEDIUM ERRORs:
|
||||||
|
|
||||||
|
1. The drive hits a damaged sector and returns a MEDIUM ERROR.
|
||||||
|
2. From that point forward, **all subsequent reads fail** -- even reads to sectors that were previously successful.
|
||||||
|
3. The only recovery is to physically unplug and reconnect the drive (or power-cycle it).
|
||||||
|
|
||||||
|
This is not a freemkv bug. It is a drive firmware behavior triggered by the interaction between the drive's internal error recovery and the USB-SATA bridge's handling of the error response. The drive firmware enters a degraded state that it does not recover from without a power cycle.
|
||||||
|
|
||||||
|
Workarounds:
|
||||||
|
|
||||||
|
- **Use a direct SATA connection.** This eliminates the bridge interaction that triggers the taint.
|
||||||
|
- **Use a different bridge.** The ASM1153 and JMS578 are less likely to trigger this behavior.
|
||||||
|
- **Accept the partial ISO.** freemkv's skip-forward recovery will zero-fill the unreadable blocks and continue. The resulting ISO may be playable with minor glitches in the affected areas.
|
||||||
|
- **Physical replug between retry passes.** If running multi-pass patch, replug the drive between passes to clear the taint state.
|
||||||
|
|
||||||
|
freemkv deliberately does not attempt inline SCSI resets or eject cycles to recover from this state, because those operations were found to make the problem worse on affected hardware (see the design rationale in [`docs/rip-recovery.md`](docs/rip-recovery.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Drive Not Detected
|
||||||
|
|
||||||
|
### Check Hardware Visibility
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lsusb
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the drive appears in the USB device list. If it does not show up, the drive is not visible to the host at all -- check cables, power, and USB port.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls /dev/sg*
|
||||||
|
```
|
||||||
|
|
||||||
|
On Linux, optical drives appear as `/dev/sg*` devices (the SCSI Generic interface). freemkv uses `/dev/sg*`, not `/dev/sr*`. If `lsusb` shows the device but no `/dev/sg*` entry exists, the `sg` kernel module may not be loaded:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo modprobe sg
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Kernel Messages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dmesg | grep -i usb | tail -30
|
||||||
|
dmesg | grep -i sg | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
- USB enumeration errors or failed port resets.
|
||||||
|
- `sg_add` messages confirming the sg device was registered.
|
||||||
|
- Permission denied or access errors.
|
||||||
|
|
||||||
|
### Permission Issues
|
||||||
|
|
||||||
|
On most Linux distributions, `/dev/sg*` devices are owned by `root:disk` or `root:cdrom` with restricted permissions. Running freemkv as an unprivileged user will fail with permission errors.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
- Add your user to the appropriate group:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo usermod -aG disk $USER
|
||||||
|
```
|
||||||
|
|
||||||
|
Then log out and back in for the change to take effect. On some distributions the group is `cdrom` or `optical` instead of `disk`.
|
||||||
|
|
||||||
|
- Run with elevated privileges:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo freemkv ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- Install a udev rule for persistent per-device permissions. Create `/etc/udev/rules.d/99-sg-optical.rules`:
|
||||||
|
|
||||||
|
```
|
||||||
|
SUBSYSTEM=="scsi_generic", ATTRS{type}=="5", MODE="0666"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reload udev rules:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo udevadm control --reload-rules && sudo udevadm trigger
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spin-Up Delay
|
||||||
|
|
||||||
|
Optical drives take 30-60 seconds to spin up and become ready after hot-plug or disc insertion. During this window, SCSI commands may return NOT READY or timeout.
|
||||||
|
|
||||||
|
freemkv's `Drive::wait_ready()` handles this automatically by polling with TEST UNIT READY until the drive responds. If you are writing your own code using the library, always call `wait_ready()` before `init()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||||
|
drive.wait_ready()?; // blocks until disc is ready, up to 30s
|
||||||
|
drive.init()?;
|
||||||
|
```
|
||||||
|
|
||||||
|
If the drive was just plugged in, wait a full minute before concluding it is not detected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. How to Identify Your USB-SATA Bridge
|
||||||
|
|
||||||
|
If you are experiencing the issues described in section 1, you need to know which bridge chipset your adapter or enclosure uses.
|
||||||
|
|
||||||
|
### Step 1: Find the Device
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lsusb
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for entries matching your drive or enclosure. Bridges may appear under their own manufacturer name or as a generic SATA device. Common examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
Bus 002 Device 005: ID 13fd:0840 Initio Corporation
|
||||||
|
Bus 002 Device 006: ID 174c:1153 ASMedia Technology Inc. ASM1153
|
||||||
|
Bus 002 Device 007: ID 152d:0578 JMicron Technology Corp. JMS578
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Match the IDs
|
||||||
|
|
||||||
|
| Vendor | Product ID | Chipset | Status |
|
||||||
|
|--------|-----------|---------|--------|
|
||||||
|
| `13fd` | `3609` | Initio INIC-3609 | Affected. Apply quirk. |
|
||||||
|
| `13fd` | `3940` | Initio INIC-3619 | Affected. Apply quirk. |
|
||||||
|
| `13fd` | `0840` | Initio INIC-3069 | Affected. Apply quirk. |
|
||||||
|
| `174c` | `5106` | ASMedia ASM1051 | Affected (early firmware). Apply quirk. |
|
||||||
|
| `174c` | `1153` | ASMedia ASM1153 | Good. No quirk needed. |
|
||||||
|
| `152d` | `0561` | JMicron JMB36x | Affected (some firmware). Apply quirk if issues occur. |
|
||||||
|
| `152d` | `0578` | JMicron JMS578 | Good. No quirk needed. |
|
||||||
|
|
||||||
|
### Step 3: Check dmesg for the Bridge Name
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dmesg | grep -i "usb-storage\|uas\|initio\|asmedia\|jmicron"
|
||||||
|
```
|
||||||
|
|
||||||
|
This often reveals the bridge chipset even when `lsusb` shows a generic name.
|
||||||
|
|
||||||
|
### Step 4: If the Enclosure Is Sealed
|
||||||
|
|
||||||
|
Many external drive enclosures (Vantec NexStar, Sabrent, OWC, etc.) do not advertise the bridge chipset on the packaging. In this case:
|
||||||
|
|
||||||
|
1. Check `lsusb` while the enclosure is connected.
|
||||||
|
2. Search the vendor:product ID online -- there are community-maintained lists of which chipsets popular enclosures use.
|
||||||
|
3. If you cannot determine the chipset and are experiencing bridge crashes, assume it is an Initio and apply the quirk with its IDs.
|
||||||
|
4. The definitive test: connect the bare drive to a motherboard SATA port. If the problems disappear, the bridge was the cause.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. General Debugging Checklist
|
||||||
|
|
||||||
|
When something goes wrong during a rip, gather this information before filing an issue:
|
||||||
|
|
||||||
|
1. **freemkv version:** `freemkv --version` or the crate version in `Cargo.toml`.
|
||||||
|
2. **Drive model:** from the drive label, or from `freemkv info`.
|
||||||
|
3. **Connection type:** USB (with bridge chipset if known) or direct SATA.
|
||||||
|
4. **Operating system and kernel:** `uname -a`.
|
||||||
|
5. **Kernel messages during the failure:** `dmesg | tail -50` immediately after the crash.
|
||||||
|
6. **SCSI device:** which `/dev/sg*` the drive was on, and whether it changed after the failure.
|
||||||
|
7. **The disc:** title, format (BD/DVD/UHD), condition.
|
||||||
|
|
||||||
|
Include all of the above in bug reports. SCSI transport errors that resolve with the `US_FL_IGNORE_RESIDUE` quirk or by switching to direct SATA are bridge firmware bugs, not freemkv bugs.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Mimics ISO dump exactly — read + write + progress
|
||||||
|
use libfreemkv::Drive;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let device = std::env::args()
|
||||||
|
.skip(1)
|
||||||
|
.find(|a| !a.starts_with('-'))
|
||||||
|
.unwrap_or_else(|| match libfreemkv::find_drive() {
|
||||||
|
Some(d) => d.device_path().to_string(),
|
||||||
|
None => {
|
||||||
|
eprintln!("No drives found");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut drive = Drive::open(Path::new(&device)).unwrap_or_else(|e| {
|
||||||
|
eprintln!("Cannot open {}: {}", device, e);
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
eprintln!("wait_ready...");
|
||||||
|
let _ = drive.wait_ready();
|
||||||
|
eprintln!("read_capacity...");
|
||||||
|
let cap = drive.read_capacity().unwrap();
|
||||||
|
eprintln!("capacity: {} sectors", cap);
|
||||||
|
|
||||||
|
let batch = libfreemkv::disc::detect_max_batch_sectors(drive.device_path());
|
||||||
|
let mut buf = vec![0u8; batch as usize * 2048];
|
||||||
|
|
||||||
|
// Open /dev/null writer like ISO dump does
|
||||||
|
let file = std::fs::File::create("/dev/null").unwrap();
|
||||||
|
let mut writer = std::io::BufWriter::with_capacity(4 * 1024 * 1024, file);
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"Reading 1000 batches ({:.1} MB) with write + progress...",
|
||||||
|
1000.0 * batch as f64 * 2048.0 / 1_048_576.0
|
||||||
|
);
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut ok = 0u32;
|
||||||
|
let mut fail = 0u32;
|
||||||
|
let mut bytes: u64 = 0;
|
||||||
|
|
||||||
|
// Recovery flag: true matches pre-0.11.13 bench behavior — full SCSI
|
||||||
|
// ECC retry loop on errors (slower, what the rip path used before the
|
||||||
|
// adaptive batch sizer landed). Flip to `false` for the fast-fail path
|
||||||
|
// that current rips use; benches are configurable via this constant.
|
||||||
|
const READ_WITH_RECOVERY: bool = true;
|
||||||
|
|
||||||
|
for i in 0..1000u32 {
|
||||||
|
let lba = i * batch as u32;
|
||||||
|
match drive.read(lba, batch, &mut buf, READ_WITH_RECOVERY) {
|
||||||
|
Ok(_) => {
|
||||||
|
writer.write_all(&buf).unwrap();
|
||||||
|
ok += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
fail += 1;
|
||||||
|
if fail <= 5 {
|
||||||
|
eprintln!(" FAIL LBA {}: {}", lba, e);
|
||||||
|
}
|
||||||
|
buf.fill(0);
|
||||||
|
writer.write_all(&buf).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bytes += buf.len() as u64;
|
||||||
|
|
||||||
|
if i % 50 == 0 && i > 0 {
|
||||||
|
let elapsed = start.elapsed().as_secs_f64();
|
||||||
|
let mb = bytes as f64 / 1_048_576.0;
|
||||||
|
eprint!("\r {:.1} MB | {:.1} MB/s ", mb, mb / elapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = start.elapsed().as_secs_f64();
|
||||||
|
let mb = ok as f64 * batch as f64 * 2048.0 / 1_048_576.0;
|
||||||
|
eprintln!(
|
||||||
|
"\n{} ok, {} fail, {:.1} MB in {:.1}s = {:.1} MB/s",
|
||||||
|
ok,
|
||||||
|
fail,
|
||||||
|
mb,
|
||||||
|
elapsed,
|
||||||
|
mb / elapsed
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
fn main() {
|
||||||
|
emit_git_suffix();
|
||||||
|
|
||||||
|
let target = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||||
|
if target == "macos" {
|
||||||
|
println!("cargo:rustc-link-lib=framework=IOKit");
|
||||||
|
println!("cargo:rustc-link-lib=framework=CoreFoundation");
|
||||||
|
|
||||||
|
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||||
|
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",
|
||||||
|
&obj,
|
||||||
|
"-framework",
|
||||||
|
"IOKit",
|
||||||
|
"-framework",
|
||||||
|
"CoreFoundation",
|
||||||
|
"-Wall",
|
||||||
|
"-O2",
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
.expect("failed to compile macos_shim.c");
|
||||||
|
|
||||||
|
std::process::Command::new("ar")
|
||||||
|
.args(["rcs", &lib, &obj])
|
||||||
|
.status()
|
||||||
|
.expect("failed to create static lib");
|
||||||
|
|
||||||
|
println!("cargo:rustc-link-search=native={out_dir}");
|
||||||
|
println!("cargo:rustc-link-lib=static=macos_scsi");
|
||||||
|
println!("cargo:rerun-if-changed=src/scsi/macos_shim.c");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bake the git short hash into the build as `GIT_SUFFIX` so any muxed MKV or
|
||||||
|
/// FVI index is traceable to the exact source revision (e.g. ` (g835cc99)`).
|
||||||
|
/// Empty when git or the repo is unavailable (e.g. a crates.io tarball build),
|
||||||
|
/// leaving just the package version. Always emitted so `env!("GIT_SUFFIX")`
|
||||||
|
/// resolves on every target.
|
||||||
|
fn emit_git_suffix() {
|
||||||
|
// Version label for the muxing-app / FVI generator tag. `FREEMKV_BUILD_LABEL`
|
||||||
|
// overrides the Cargo package version when set (non-empty) — used to stamp a
|
||||||
|
// pre-release/test build without bumping Cargo.toml and disturbing the
|
||||||
|
// tag-pinned [patch] version matching. Unset → the package version.
|
||||||
|
let version = std::env::var("FREEMKV_BUILD_LABEL")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.or_else(|| std::env::var("CARGO_PKG_VERSION").ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
println!("cargo:rustc-env=FREEMKV_VERSION={version}");
|
||||||
|
println!("cargo:rerun-if-env-changed=FREEMKV_BUILD_LABEL");
|
||||||
|
|
||||||
|
let suffix = git_short_hash()
|
||||||
|
.map(|h| format!(" (g{h})"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
println!("cargo:rustc-env=GIT_SUFFIX={suffix}");
|
||||||
|
|
||||||
|
// Re-run when HEAD (or the branch it points at) moves so the stamp stays
|
||||||
|
// current without a clean rebuild.
|
||||||
|
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||||
|
if let Ok(head) = std::fs::read_to_string(".git/HEAD") {
|
||||||
|
if let Some(ref_path) = head.strip_prefix("ref: ") {
|
||||||
|
println!("cargo:rerun-if-changed=.git/{}", ref_path.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_short_hash() -> Option<String> {
|
||||||
|
let out = std::process::Command::new("git")
|
||||||
|
.args(["rev-parse", "--short=7", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let h = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
||||||
|
if h.is_empty() { None } else { Some(h) }
|
||||||
|
}
|
||||||
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,302 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
## 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"`<br>`"interlaced"`<br>`"mbaff"` |
|
||||||
|
| `colour` | object | SHOULD | CICP per ITU-T H.273: `primaries`, `transfer`, `matrix` (integer CICP codes or registered names)<br>`range`: `"limited"` \| `"full"`<br>HDR: `mastering_display`, `max_cll`, `max_fall` per ITU-T H.273 / SMPTE ST 2086. |
|
||||||
|
| `language` | string | MAY | BCP 47 tag, if known. |
|
||||||
|
|
||||||
|
### 6.2 `source` object
|
||||||
|
|
||||||
|
| Member | JSON type | Req | Semantics / reference |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `medium` | string | MUST | `"disc"`<br>`"iso"`<br>`"file"`<br>`"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:<br>`"I"`<br>`"P"`<br>`"B"`<br>_ISO/IEC 13818-2 §6.3.9; H.264/H.265 slice types collapsed to frame type._ |
|
||||||
|
| `key` | boolean | MUST | `true` iff this picture is an intra (I) picture / parser-flagged decode-restart point (IDR / IRAP / I-picture).<br>_MPEG-2 open-GOP clean-RAP precision (`closed_gop`) is not currently distinguished — see note below._ |
|
||||||
|
| `gop` | boolean | SHOULD | `true` iff this picture begins a GOP / coded video sequence.<br>_Omitted when the implementation does not carry a distinct GOP-boundary signal._ |
|
||||||
|
| `pts` | integer\|null | SHOULD | Presentation timestamp in `timescale` ticks; `null` if unknown. |
|
||||||
|
| `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).<br>_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 / reference |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `field_order` | string | MAY | Display field order:<br>`"tff"` — top field first<br>`"bff"` — bottom field first<br>`"progressive"` — no field order applies<br>_Omitted when the codec did not signal it._ |
|
||||||
|
| `progressive` | boolean | MAY | `true` iff the picture is progressive.<br>_Omitted when the codec did not signal it._ |
|
||||||
|
| `nb_fields` | integer | MAY | Number of displayed field periods this picture occupies (the soft-telecine / 2:3 pulldown basis):<br>`1` for a single field picture<br>`2` for a normal frame<br>`3`/`4`/`6` for `repeat_first_field` pulldown per §6.3.10 |
|
||||||
|
|
||||||
|
Codecs that carry only a coding type (e.g. H.264 / HEVC / VC-1 through this
|
||||||
|
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).
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# libfreemkv Documentation
|
||||||
|
|
||||||
|
Technical documentation for [libfreemkv](https://github.com/freemkv/libfreemkv), the open source optical drive library.
|
||||||
|
|
||||||
|
## Start Here
|
||||||
|
|
||||||
|
**[Disc to Rip: End-to-End Flow](disc-to-rip.md)** — How the library goes from a disc in the drive to decrypted content. Read this first.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
| Document | What it covers |
|
||||||
|
|----------|---------------|
|
||||||
|
| [Architecture](architecture.md) | Module map, design principles, error codes, platform support |
|
||||||
|
| [Drive Access](drive-access.md) | Drive, SCSI transport, profiles, unlock, why raw mode is needed |
|
||||||
|
| [Rip Recovery](rip-recovery.md) | Three-layer recovery model: Disc::patch, single-shot Drive::read, DiscStream batch halving |
|
||||||
|
| [AACS Encryption](aacs.md) | Key resolution (4 paths), content decryption, bus encryption, SCSI handshake |
|
||||||
|
| [UDF Filesystem](udf.md) | UDF 2.50 with metadata partitions, pointer chain, how files are read from disc |
|
||||||
|
| [MPLS Playlists](mpls.md) | Playlist format, play items, STN stream table, coding types |
|
||||||
|
| [CLPI Clip Info](clpi.md) | EP map (coarse + fine entries), timestamp-to-sector mapping, extent calculation |
|
||||||
|
| [API Design](api-design.md) | Stream API design, PES pipeline, input/output resolution |
|
||||||
|
|
||||||
|
## Reading Order
|
||||||
|
|
||||||
|
If you want to understand the whole library:
|
||||||
|
|
||||||
|
1. **[Disc to Rip](disc-to-rip.md)** — the big picture
|
||||||
|
2. **[Architecture](architecture.md)** — how modules fit together
|
||||||
|
3. **[Drive Access](drive-access.md)** — how we talk to hardware
|
||||||
|
4. **[UDF](udf.md)** → **[MPLS](mpls.md)** → **[CLPI](clpi.md)** — how disc content is structured
|
||||||
|
5. **[AACS](aacs.md)** — how encryption works and how we break it
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
Generated API docs are on [docs.rs/libfreemkv](https://docs.rs/libfreemkv).
|
||||||
+55
-267
@@ -2,206 +2,61 @@
|
|||||||
|
|
||||||
## Overview
|
## 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:
|
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 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
|
||||||
- **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.
|
encryption layer on top of the standard content encryption. UHD drives accept
|
||||||
|
AACS 1.0 host credentials 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.
|
|
||||||
|
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
|
||||||
## Architecture
|
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
|
||||||
AACS support is split across two modules:
|
library docs).
|
||||||
|
|
||||||
### `aacs.rs` -- Keys and Decryption
|
## How it works (feature level)
|
||||||
|
|
||||||
Handles everything related to key resolution and content decryption:
|
When a disc is scanned, the library:
|
||||||
|
|
||||||
- KEYDB.cfg parsing (device keys, processing keys, host certificates, per-disc entries)
|
1. Reads the disc's AACS key-input files from the `/AACS/` directory.
|
||||||
- Disc hash computation (SHA-1 of `Unit_Key_RO.inf`)
|
2. Resolves the disc's key from `keydb.cfg` — either directly from a per-disc
|
||||||
- VUK resolution chain (4 paths, described below)
|
entry, or by walking the MKB with the keys present in the keydb.
|
||||||
- MKB record parsing and media key derivation
|
3. Performs the drive-level SCSI authentication handshake needed to obtain the
|
||||||
- Subset-difference tree traversal (AACS-G3 key derivation)
|
Volume ID and, for UHD, the bus-decryption key.
|
||||||
- Unit_Key_RO.inf parsing and unit key decryption
|
4. Decrypts the content stream as titles are read.
|
||||||
- Content Certificate parsing (AACS version detection)
|
|
||||||
- Aligned unit decryption (AES-128-CBC)
|
A resolved key is verified against actual disc content before it is applied, so
|
||||||
- Bus decryption (AACS 2.0 read_data_key layer)
|
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
|
||||||
### `aacs_handshake.rs` -- SCSI Authentication
|
specific error (the E70xx family) describing which part of the chain was
|
||||||
|
missing, and a missing `keydb.cfg` surfaces as `Error::KeydbLoad` with the
|
||||||
Handles the drive-level SCSI authentication protocol:
|
sentinel path `<no keydb in search paths>`.
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
|
|
||||||
## API Usage
|
## 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
|
```rust
|
||||||
use libfreemkv::{DriveSession, Disc};
|
use libfreemkv::{Drive, Disc};
|
||||||
use libfreemkv::disc::ScanOptions;
|
use libfreemkv::disc::ScanOptions;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
|
let mut drive = Drive::open(Path::new("/dev/sg4")).unwrap();
|
||||||
let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
drive.wait_ready().unwrap();
|
||||||
|
drive.init().unwrap();
|
||||||
|
let disc = Disc::scan(&mut drive, &ScanOptions::default()).unwrap();
|
||||||
|
|
||||||
// Check encryption state
|
// Check encryption state
|
||||||
if disc.encrypted {
|
if disc.encrypted {
|
||||||
if let Some(ref aacs) = disc.aacs {
|
if let Some(ref aacs) = disc.aacs {
|
||||||
println!("AACS {}.0", aacs.version);
|
println!("AACS {}.0", aacs.version);
|
||||||
println!("Key source: {}", aacs.key_source.name());
|
println!("Key source: {}", aacs.key_source.name());
|
||||||
println!("Disc hash: {}", aacs.disc_hash);
|
|
||||||
if let Some(mkb_ver) = aacs.mkb_version {
|
if let Some(mkb_ver) = aacs.mkb_version {
|
||||||
println!("MKB version: {}", mkb_ver);
|
println!("MKB version: {}", mkb_ver);
|
||||||
}
|
}
|
||||||
@@ -213,108 +68,41 @@ if disc.encrypted {
|
|||||||
// Read content -- decryption is automatic
|
// Read content -- decryption is automatic
|
||||||
let mut reader = disc.open_title(&mut session, 0).unwrap();
|
let mut reader = disc.open_title(&mut session, 0).unwrap();
|
||||||
while let Some(unit) = reader.read_unit().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
|
### KEYDB Location
|
||||||
|
|
||||||
`ScanOptions` controls where the KEYDB is loaded from. If no explicit path is set, the library checks:
|
`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
|
||||||
1. `~/.config/aacs/KEYDB.cfg`
|
path:
|
||||||
2. `/etc/aacs/KEYDB.cfg`
|
|
||||||
|
|
||||||
To specify an explicit path:
|
|
||||||
|
|
||||||
```rust
|
```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();
|
let disc = Disc::scan(&mut session, &opts).unwrap();
|
||||||
```
|
```
|
||||||
|
|
||||||
### AacsState
|
### AacsState
|
||||||
|
|
||||||
After a successful scan, `disc.aacs` contains an `AacsState` with:
|
After a successful scan, `disc.aacs` contains an `AacsState`:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `version` | `u8` | AACS version (1 or 2) |
|
| `version` | `u8` | AACS version (1 or 2) |
|
||||||
| `bus_encryption` | `bool` | Whether bus encryption is active |
|
| `bus_encryption` | `bool` | Whether bus encryption is active |
|
||||||
| `mkb_version` | `Option<u32>` | MKB version from disc |
|
| `mkb_version` | `Option<u32>` | MKB version from disc |
|
||||||
| `disc_hash` | `String` | SHA-1 of Unit_Key_RO.inf (hex with 0x prefix) |
|
| `disc_hash` | `String` | Identifier for the disc's key-input files |
|
||||||
| `key_source` | `KeySource` | How keys were resolved |
|
| `key_source` | `KeySource` | How the disc's key was 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 |
|
|
||||||
|
|
||||||
### KeySource
|
## keydb.cfg
|
||||||
|
|
||||||
| Variant | Description |
|
`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
|
||||||
| `KeyDb` | VUK found directly in KEYDB by disc hash |
|
entries the library uses to resolve a disc. autorip can auto-download and
|
||||||
| `KeyDbDerived` | Media key + Volume ID from KEYDB, VUK derived |
|
refresh it from a configured URL. The library does not ship any AACS keys
|
||||||
| `ProcessingKey` | MKB + processing keys from KEYDB |
|
compiled into the binary.
|
||||||
| `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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# libfreemkv API Design
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. Lib provides building blocks. App composes them.
|
||||||
|
2. No English text in lib. Error codes only. App handles i18n.
|
||||||
|
3. No display logic in lib. App decides what to show.
|
||||||
|
4. Streams are the pipeline. Each stage wraps the next.
|
||||||
|
5. Lib fires events. App listens.
|
||||||
|
|
||||||
|
## Core API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Open drive — explicit steps, app prints between them
|
||||||
|
let mut drive = Drive::open(path)?;
|
||||||
|
drive.wait_ready()?;
|
||||||
|
drive.init()?;
|
||||||
|
drive.probe_disc()?;
|
||||||
|
|
||||||
|
// Scan disc
|
||||||
|
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
|
||||||
|
|
||||||
|
// Browse
|
||||||
|
disc.titles // Vec<DiscTitle>
|
||||||
|
disc.format // BD / UHD / DVD
|
||||||
|
disc.capacity_gb()
|
||||||
|
```
|
||||||
|
|
||||||
|
## PES Pipeline (primary API)
|
||||||
|
|
||||||
|
The PES pipeline is the main way to move content. All streams produce/consume
|
||||||
|
PES frames. The pipeline just reads frames and writes frames.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// URL-based — any source to any destination
|
||||||
|
let opts = InputOptions::default();
|
||||||
|
let mut input = libfreemkv::input("disc:///dev/sg4", &opts)?;
|
||||||
|
let title = input.info().clone();
|
||||||
|
let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?;
|
||||||
|
|
||||||
|
while let Ok(Some(frame)) = input.read() {
|
||||||
|
output.write(&frame)?;
|
||||||
|
}
|
||||||
|
output.finish()?;
|
||||||
|
```
|
||||||
|
|
||||||
|
The `FrameSource` and `FrameSink` traits — direction is type-checked, so
|
||||||
|
calling `read()` on a write-only sink (or `write()` on a read-only source)
|
||||||
|
is a compile error rather than a runtime fault:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait FrameSource: Send {
|
||||||
|
fn read(&mut self) -> Result<Option<PesFrame>, Error>;
|
||||||
|
fn info(&self) -> &DiscTitle;
|
||||||
|
fn codec_private(&self, track: usize) -> Option<Vec<u8>> { None }
|
||||||
|
fn headers_ready(&self) -> bool { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait FrameSink: Send {
|
||||||
|
fn write(&mut self, frame: &PesFrame) -> Result<(), Error>;
|
||||||
|
fn finish(self: Box<Self>) -> Result<(), Error>;
|
||||||
|
fn info(&self) -> &DiscTitle;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Streams
|
||||||
|
|
||||||
|
All streams implement `FrameSource` (read) and/or `FrameSink` (write); the
|
||||||
|
directional split prevents runtime "wrong-direction" errors. URL-based
|
||||||
|
resolvers open any stream by string.
|
||||||
|
|
||||||
|
| Stream | Input | Output | URL | Transport |
|
||||||
|
|--------|-------|--------|-----|-----------|
|
||||||
|
| DiscStream | Yes | -- | `disc://` `disc:///dev/sg4` | Optical drive via SCSI |
|
||||||
|
| IsoStream | Yes | Yes | `iso://path.iso` | Blu-ray ISO image |
|
||||||
|
| MkvStream | Yes | Yes | `mkv://path` | Matroska container |
|
||||||
|
| M2tsStream | Yes | Yes | `m2ts://path` | BD-TS with FMKV metadata header |
|
||||||
|
| NetworkStream | Yes (listen) | Yes (connect) | `network://host:port` | TCP with FMKV metadata header |
|
||||||
|
| StdioStream | Yes (stdin) | Yes (stdout) | `stdio://` | Raw byte pipe |
|
||||||
|
| NullStream | -- | Yes | `null://` | Discard sink (byte counter) |
|
||||||
|
|
||||||
|
All URLs require a `scheme://path` format. Bare paths are rejected.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 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://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
|
||||||
|
```
|
||||||
|
|
||||||
|
### FMKV Metadata Header
|
||||||
|
|
||||||
|
M2tsStream and NetworkStream embed a JSON metadata header before the BD-TS data:
|
||||||
|
|
||||||
|
```
|
||||||
|
[8B magic "FMKV\0\0\0\0"][4B JSON length][JSON metadata][padding to 192B boundary][BD-TS data...]
|
||||||
|
```
|
||||||
|
|
||||||
|
The header carries title name, duration, codec_privates, and full stream layout
|
||||||
|
(PIDs, codecs, languages, labels). This allows the receiving end to set up
|
||||||
|
demuxing and track metadata without scanning the TS.
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
Lib fires events during operations. App provides a callback. No display, no text.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Event {
|
||||||
|
pub kind: EventKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum EventKind {
|
||||||
|
// Init / scan
|
||||||
|
DriveOpened { device: String },
|
||||||
|
DriveReady,
|
||||||
|
InitComplete { success: bool },
|
||||||
|
ProbeComplete { success: bool },
|
||||||
|
ScanComplete { titles: usize },
|
||||||
|
|
||||||
|
// Read pipeline
|
||||||
|
BytesRead { bytes: u64, total: u64 },
|
||||||
|
ReadError { sector: u64, error: Error },
|
||||||
|
SpeedChange { speed_kbs: u16 },
|
||||||
|
ExtentStart { index: usize, start_sector: u64, sector_count: u64 },
|
||||||
|
SectorSkipped { sector: u64 },
|
||||||
|
BatchSizeChanged { new_size: u16, reason: BatchSizeReason },
|
||||||
|
Complete { bytes: u64, errors: u32 },
|
||||||
|
|
||||||
|
// Kept for forward-compat; not emitted in 0.13.6+
|
||||||
|
Retry { attempt: u32 },
|
||||||
|
SectorRecovered { sector: u64 },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Emission notes:
|
||||||
|
|
||||||
|
- `BytesRead { bytes, total }` is emitted from `DiscStream::fill_extents`
|
||||||
|
after each successful sector read. `bytes` is the cumulative running
|
||||||
|
total; `total` is the precomputed extent sum (0 if unknown).
|
||||||
|
- `SpeedChange` is emitted from the public `Drive::set_speed` API path.
|
||||||
|
It is no longer emitted from a recovery hot loop (recovery loop removed
|
||||||
|
in 0.13.6).
|
||||||
|
- `BatchSizeChanged` fires from the `DiscStream` adaptive sizer on shrink
|
||||||
|
(read failed at a larger size) and on probe-up (clean-read streak hit
|
||||||
|
the threshold). Consumers use it to display a "recovering" state
|
||||||
|
distinct from "ripping normally".
|
||||||
|
- `Retry` and `SectorRecovered` are NOT emitted in 0.13.6+. They were
|
||||||
|
tied to the inline `Drive::read` recovery phases that were removed; the
|
||||||
|
variants are kept for forward compatibility so consumers' match arms
|
||||||
|
don't need conditional compilation.
|
||||||
|
|
||||||
|
Events report what happened. App decides what to do. GUI shows a dialog. CLI
|
||||||
|
prints a line. Server logs to file.
|
||||||
|
|
||||||
|
## File Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
libfreemkv/src/
|
||||||
|
├── lib.rs Public exports
|
||||||
|
├── error.rs Error codes (no English)
|
||||||
|
├── event.rs Event types for callbacks
|
||||||
|
├── halt.rs Halt cancellation token (Arc<AtomicBool> wrapper)
|
||||||
|
├── io/ Pipeline + WritebackFile primitives
|
||||||
|
│ ├── mod.rs Re-exports WritebackFile, Pipeline, Sink, Flow
|
||||||
|
│ ├── pipeline.rs Generic Pipeline<I, R> + Sink trait
|
||||||
|
│ ├── writeback_file.rs WritebackFile (was crate::io::Writer)
|
||||||
|
│ └── writeback.rs sync_file_range pipeline
|
||||||
|
├── drive/ Drive (open, init, single-shot read)
|
||||||
|
│ ├── mod.rs Drive struct, init, read (single-shot), reset, eject
|
||||||
|
│ ├── capture.rs Raw drive SCSI capture (INQUIRY/GET_CONFIG) for contribution
|
||||||
|
│ ├── linux.rs Linux drive discovery
|
||||||
|
│ ├── macos.rs macOS drive discovery
|
||||||
|
│ └── windows.rs Windows drive discovery
|
||||||
|
├── disc/ Disc (scan, titles, AACS setup, sweep, patch)
|
||||||
|
│ ├── mod.rs Disc struct, scan, titles, formats; Disc::copy + Disc::sweep (Pass 1)
|
||||||
|
│ ├── sweep.rs Pass 1 internal helpers (pub(super))
|
||||||
|
│ ├── patch.rs Disc::patch (Pass N retry over mapfile)
|
||||||
|
│ ├── mapfile.rs ddrescue-format mapfile
|
||||||
|
│ └── read_error.rs ReadCtx / ReadAction state machine
|
||||||
|
├── scsi/ SCSI transport (Linux SG_IO, macOS IOKit, Windows SPTI)
|
||||||
|
├── unlock.rs Unlocker trait + registry (pluggable unlock seam)
|
||||||
|
├── aacs/ AACS decryption (handshake, keys, keydb, decrypt)
|
||||||
|
├── css/ DVD CSS cipher
|
||||||
|
├── decrypt.rs Unified decrypt dispatcher (AACS/CSS/None)
|
||||||
|
├── pes.rs PES frame types, FrameSource / FrameSink traits
|
||||||
|
├── sector/ Sector I/O
|
||||||
|
│ ├── mod.rs SectorSource, SectorSink traits
|
||||||
|
│ ├── file.rs FileSectorSource, FileSectorSink (ISO-backed)
|
||||||
|
│ └── decrypting.rs DecryptingSectorSource decorator
|
||||||
|
├── udf.rs UDF 2.50 filesystem parser
|
||||||
|
├── mpls.rs MPLS playlist parser
|
||||||
|
├── clpi.rs CLPI clip info parser
|
||||||
|
├── ifo.rs DVD IFO parser
|
||||||
|
├── labels/ BD-J label extraction (5 format parsers)
|
||||||
|
├── keydb.rs KEYDB download, parse, save
|
||||||
|
├── identity.rs DriveId from INQUIRY
|
||||||
|
├── speed.rs DriveSpeed enum
|
||||||
|
├── mux/
|
||||||
|
│ ├── mod.rs Public mux exports
|
||||||
|
│ ├── resolve.rs URL parser + input/output (Box<dyn FrameSource/Sink>)
|
||||||
|
│ ├── meta.rs FMKV header format
|
||||||
|
│ ├── disc.rs DiscStream (optical drive → PES)
|
||||||
|
│ ├── iso.rs IsoStream (ISO image read)
|
||||||
|
│ ├── isowriter.rs ISO image writer (UDF, AVDP, multi-extent)
|
||||||
|
│ ├── mkvstream.rs MkvStream (bidirectional Matroska)
|
||||||
|
│ ├── mkvout.rs MkvOutputStream (PES → MKV)
|
||||||
|
│ ├── m2ts.rs M2tsStream (BD-TS)
|
||||||
|
│ ├── pesout.rs PES output streams (M2ts, Network, Stdio, Null)
|
||||||
|
│ ├── network.rs NetworkStream (TCP + FMKV header)
|
||||||
|
│ ├── stdio.rs StdioStream (stdin/stdout pipe)
|
||||||
|
│ ├── null.rs NullStream (discard + byte counter)
|
||||||
|
│ ├── lookahead.rs LookaheadBuffer (codec header scanning)
|
||||||
|
│ ├── ts.rs BD-TS demuxer + PAT/PMT scanner
|
||||||
|
│ ├── tsreader.rs TS reader utilities
|
||||||
|
│ ├── tsmux.rs TS muxer (PES → BD-TS packets)
|
||||||
|
│ ├── ps.rs MPEG-2 PS demuxer (DVD)
|
||||||
|
│ ├── ebml.rs EBML read/write primitives
|
||||||
|
│ ├── mkv.rs MKV muxer (tracks, clusters, cues)
|
||||||
|
│ └── codec/ Frame parsers (H.264, HEVC, MPEG-2, VC-1, AC3, EAC3, DTS, TrueHD, LPCM, PGS)
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
freemkv/src/
|
||||||
|
├── main.rs CLI dispatcher (URL routing)
|
||||||
|
├── pipe.rs PES pipeline — source → dest copy
|
||||||
|
├── disc_info.rs Disc/file info display
|
||||||
|
├── info.rs Drive info + profile submission
|
||||||
|
├── strings.rs i18n string table
|
||||||
|
├── output.rs Verbosity-filtered output
|
||||||
|
└── build.rs Bundled locale code generation
|
||||||
|
```
|
||||||
+90
-49
@@ -1,8 +1,10 @@
|
|||||||
# libfreemkv Architecture
|
# libfreemkv Architecture
|
||||||
|
|
||||||
Open source optical drive access library for 4K UHD Blu-ray, Blu-ray, and DVD.
|
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,
|
Rust library with profiles bundled and all SCSI communication handled in-process.
|
||||||
AACS keys are derived internally, and all SCSI communication is 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>
|
**Repository:** <https://github.com/freemkv/libfreemkv>
|
||||||
**License:** AGPL-3.0-only
|
**License:** AGPL-3.0-only
|
||||||
@@ -13,22 +15,27 @@ AACS keys are derived internally, and all SCSI communication is handled in-proce
|
|||||||
|
|
||||||
1. **CLI is dumb.** All drive communication, disc parsing, AACS decryption, and
|
1. **CLI is dumb.** All drive communication, disc parsing, AACS decryption, and
|
||||||
format handling live in the library. CLI binaries are thin wrappers that call
|
format handling live in the library. CLI binaries are thin wrappers that call
|
||||||
`DriveSession::open()` and `Disc::scan()`.
|
`Drive::open()` and `Disc::scan()`.
|
||||||
|
|
||||||
2. **No external files.** 206 drive profiles are compiled into the binary via
|
2. **Firmware-clean core.** libfreemkv ships no firmware, no unlock CDBs, and no
|
||||||
`include_str!`. No configuration directory, no runtime file lookups for drive
|
drive profiles. Drive-unlock logic is plugged in by an external crate through
|
||||||
support.
|
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
|
3. **Transparent AACS.** The `ContentReader` decrypts on the fly when keys are
|
||||||
available. Callers read cleartext sectors without knowing whether the disc
|
available. Callers read cleartext sectors without knowing whether the disc
|
||||||
was encrypted.
|
was encrypted.
|
||||||
|
|
||||||
4. **Structured errors, no English.** Every error has a numeric code (E1000-E7000).
|
4. **Structured errors, no English.** Every error has a numeric code (E1000-E8000).
|
||||||
The library never formats user-facing messages -- applications do that.
|
The library never formats user-facing messages -- applications do that.
|
||||||
|
|
||||||
5. **Library-agnostic.** No concept of "supported" vs "unsupported" drives at a
|
5. **Library-agnostic.** No concept of "supported" vs "unsupported" drives at a
|
||||||
policy level. If a profile exists, the library uses it.
|
policy level. If a profile exists, the library uses it.
|
||||||
|
|
||||||
|
6. **Streams are dumb pipes.** Streams read/write PES frames. They don't know
|
||||||
|
about encryption, transport format, or source type. Decrypt is a stream-internal
|
||||||
|
concern; the pipeline just moves frames.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Module Map
|
## Module Map
|
||||||
@@ -37,26 +44,40 @@ AACS keys are derived internally, and all SCSI communication is handled in-proce
|
|||||||
libfreemkv (lib.rs)
|
libfreemkv (lib.rs)
|
||||||
│
|
│
|
||||||
├── Drive Access
|
├── Drive Access
|
||||||
│ ├── drive DriveSession — open, identify, unlock, read
|
│ ├── drive Drive — open, identify, init, single-shot read
|
||||||
│ ├── scsi ScsiTransport trait + SG_IO implementation
|
│ ├── scsi ScsiTransport trait + platform backends (sg async, IOKit, SPTI)
|
||||||
│ ├── platform/ Platform trait — per-chipset command handlers
|
│ ├── unlock Unlocker trait + registry — the pluggable unlock seam
|
||||||
│ │ └── mt1959 MediaTek MT1959 driver (LG, ASUS, hp)
|
|
||||||
│ ├── profile DriveProfile loading, matching, bundled JSON
|
|
||||||
│ ├── identity DriveId from INQUIRY + GET_CONFIG 010C
|
│ ├── identity DriveId from INQUIRY + GET_CONFIG 010C
|
||||||
│ └── speed DriveSpeed enum, SET CD SPEED CDB builder
|
│ ├── speed DriveSpeed enum, SET CD SPEED CDB builder
|
||||||
|
│ └── event Event system for drive status callbacks
|
||||||
│
|
│
|
||||||
├── Disc Scanning
|
├── Disc Scanning
|
||||||
│ ├── disc Disc::scan() — titles, streams, extents, AACS setup
|
│ ├── disc Disc::scan() — titles, streams, extents, AACS setup
|
||||||
│ ├── udf UDF 2.50 filesystem reader (metadata partitions)
|
│ ├── udf UDF 2.50 filesystem reader (metadata partitions)
|
||||||
│ ├── mpls MPLS playlist parser — clips, streams, STN table
|
│ ├── mpls MPLS playlist parser — clips, streams, STN table
|
||||||
│ ├── clpi CLPI clip info parser — EP map, sector extents
|
│ ├── clpi CLPI clip info parser — EP map, sector extents
|
||||||
│ └── jar BD-J JAR label extraction (audio/subtitle names)
|
│ ├── ifo DVD IFO parser — title sets, PGC chains, cell addresses
|
||||||
|
│ └── labels/ BD-J label extraction (5 formats: Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||||
│
|
│
|
||||||
├── Encryption
|
├── Encryption
|
||||||
│ ├── aacs KEYDB parsing, VUK lookup, MKB processing, unit decryption
|
│ ├── aacs/ AACS handshake, KEYDB, VUK lookup, MKB, unit decryption
|
||||||
│ └── aacs_handshake ECDH bus authentication, Volume ID, Read Data Key
|
│ ├── css DVD CSS cipher — table-driven, no external keys needed
|
||||||
|
│ └── decrypt decrypt_sectors() — unified AACS/CSS/None dispatcher
|
||||||
│
|
│
|
||||||
└── error Error enum with numeric codes E1000-E7000
|
├── Streaming
|
||||||
|
│ ├── mux/ Stream implementations (Disc, ISO, MKV, M2TS, Network, Stdio, Null)
|
||||||
|
│ ├── pes PES frame types; the unified pes::Stream (PesStream) read/write trait
|
||||||
|
│ └── sector/ SectorSource / SectorSink traits, FileSector{Source,Sink}, DecryptingSectorSource
|
||||||
|
│
|
||||||
|
├── I/O Primitives
|
||||||
|
│ ├── halt Halt cancellation token (one Arc<AtomicBool>, cloneable)
|
||||||
|
│ └── io/ Pipeline<I, R> + Sink trait + WritebackFile (bounded-cache writer)
|
||||||
|
│
|
||||||
|
├── Support
|
||||||
|
│ ├── keydb KEYDB.cfg download, parse, verify, save
|
||||||
|
│ └── error Error enum with numeric codes E1000-E8000
|
||||||
|
│
|
||||||
|
└── lib.rs Public API re-exports
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -64,27 +85,34 @@ libfreemkv (lib.rs)
|
|||||||
## Drive Access Flow
|
## Drive Access Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
DriveSession::open("/dev/sr0")
|
Drive::open(Path::new("/dev/sg4"))
|
||||||
│
|
│
|
||||||
├─ scsi::open() Open /dev/sr0 via SG_IO
|
├─ scsi::open() Open /dev/sg4 (async write/poll/read)
|
||||||
├─ DriveId::from_drive() INQUIRY + GET_CONFIG 010C
|
├─ DriveId::from_drive() INQUIRY + GET_CONFIG 010C
|
||||||
├─ profile::find_by_drive_id() Match against 206 bundled profiles
|
└─ Drive ready for init/read
|
||||||
├─ Platform::new() Instantiate chipset driver (Mt1959)
|
|
||||||
└─ Platform::unlock() Activate raw disc access mode
|
|
||||||
```
|
```
|
||||||
|
|
||||||
After open, the session provides:
|
After open:
|
||||||
- `read_sectors(lba, count, buf)` -- raw sector reads (through platform driver)
|
- `init()` -- routes to the matching registered unlocker (if any); otherwise
|
||||||
- `read_disc(lba, count, buf)` -- standard READ(10) for filesystem data
|
a no-op and the cert handshake carries the disc
|
||||||
- `scsi_execute(cdb, dir, buf, timeout)` -- arbitrary SCSI commands
|
- `probe_disc()` -- probe disc surface for optimal speeds
|
||||||
- `status()`, `calibrate()`, `read_config()`, `read_register()`
|
- `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
|
||||||
|
- `eject()` -- eject tray
|
||||||
|
|
||||||
|
Recovery is layered above `Drive::read`, not inside it. Layer 1
|
||||||
|
(`Disc::patch`) handles bad-range retry by replaying the ddrescue mapfile.
|
||||||
|
Layer 3 (`DiscStream::fill_extents` adaptive batch sizer) handles in-loop
|
||||||
|
request-size adaptation. Inline recovery (gentle retry → SCSI reset → retry)
|
||||||
|
was removed in 0.13.6 — see [`rip-recovery.md`](rip-recovery.md) and
|
||||||
|
the stop-wedge postmortem (2026-04-25).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Disc Scanning Flow
|
## Disc Scanning Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
Disc::scan(&mut session, &ScanOptions)
|
Disc::scan(&mut drive, &ScanOptions)
|
||||||
│
|
│
|
||||||
├─ READ CAPACITY Get disc size in sectors
|
├─ READ CAPACITY Get disc size in sectors
|
||||||
├─ udf::read_filesystem() Parse UDF 2.50 (AVDP → VDS → metadata → FSD → root)
|
├─ udf::read_filesystem() Parse UDF 2.50 (AVDP → VDS → metadata → FSD → root)
|
||||||
@@ -92,14 +120,24 @@ Disc::scan(&mut session, &ScanOptions)
|
|||||||
│ ├─ mpls::parse() Extract play items, STN streams
|
│ ├─ mpls::parse() Extract play items, STN streams
|
||||||
│ └─ For each clip:
|
│ └─ For each clip:
|
||||||
│ └─ clpi::parse() EP map → sector extents for the clip's time range
|
│ └─ clpi::parse() EP map → sector extents for the clip's time range
|
||||||
|
├─ labels::detect() Parse BD-J JARs for stream labels
|
||||||
├─ Detect AACS Check for /AACS directory on disc
|
├─ Detect AACS Check for /AACS directory on disc
|
||||||
└─ Disc::setup_aacs() Handshake + KEYDB → VUK → unit keys (if encrypted)
|
└─ Disc::setup_aacs() Handshake + KEYDB → VUK → unit keys (if encrypted)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For DVD:
|
||||||
|
```
|
||||||
|
Disc::scan_dvd(&mut drive, &ScanOptions)
|
||||||
|
│
|
||||||
|
├─ ifo::parse() Parse VIDEO_TS.IFO — title sets, PGC chains
|
||||||
|
├─ CSS detection Check disc structure flag
|
||||||
|
└─ CSS key cracking Table-driven, no KEYDB needed
|
||||||
|
```
|
||||||
|
|
||||||
The result is a `Disc` with:
|
The result is a `Disc` with:
|
||||||
- `titles: Vec<Title>` -- sorted by duration, each with streams and sector extents
|
- `titles: Vec<DiscTitle>` -- sorted by duration, each with streams, sector extents, codec_privates
|
||||||
- `aacs: Option<AacsState>` -- decryption keys if available
|
- `decrypt_keys()` -- DecryptKeys for content decryption
|
||||||
- `encrypted: bool` -- whether the disc uses AACS
|
- `encrypted: bool` -- whether the disc uses AACS/CSS
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -114,13 +152,14 @@ Four key resolution paths, tried in order:
|
|||||||
| 3 | Processing Keys + MKB → Media Key → VUK | Medium |
|
| 3 | Processing Keys + MKB → Media Key → VUK | Medium |
|
||||||
| 4 | Device Keys + MKB subset-difference tree → VUK | Slow |
|
| 4 | Device Keys + MKB subset-difference tree → VUK | Slow |
|
||||||
|
|
||||||
The AACS handshake (`aacs_handshake`) performs ECDH key agreement over the
|
The AACS handshake (`aacs/handshake`) performs ECDH key agreement over the
|
||||||
AACS 1.0 160-bit elliptic curve to obtain:
|
AACS 1.0 160-bit elliptic curve to obtain:
|
||||||
- **Volume ID** -- needed for VUK derivation (paths 2-4)
|
- **Volume ID** -- needed for VUK derivation (paths 2-4)
|
||||||
- **Read Data Key** -- needed for AACS 2.0 (UHD) bus decryption
|
- **Read Data Key** -- needed for AACS 2.0 (UHD) bus decryption
|
||||||
|
|
||||||
Content decryption uses AES-128-CBC on 6144-byte aligned units. The
|
Content decryption uses AES-128-CBC on 6144-byte aligned units. The
|
||||||
`ContentReader` handles this transparently.
|
`ContentReader` handles this transparently. Streams that read sectors
|
||||||
|
(DiscStream, IsoStream) decrypt internally — the pipeline sees clean bytes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -138,6 +177,7 @@ is baked into the library.
|
|||||||
| E5xxx | I/O errors | `IoError` (wraps `std::io::Error`) |
|
| E5xxx | I/O errors | `IoError` (wraps `std::io::Error`) |
|
||||||
| E6xxx | Disc format errors | `DiscError` (UDF, MPLS, CLPI parse failures) |
|
| E6xxx | Disc format errors | `DiscError` (UDF, MPLS, CLPI parse failures) |
|
||||||
| E7xxx | AACS errors | `AacsError` (key resolution, handshake, decryption) |
|
| E7xxx | AACS errors | `AacsError` (key resolution, handshake, decryption) |
|
||||||
|
| E8xxx | KEYDB errors | `KeydbError` (download, parse, save) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -145,26 +185,29 @@ is baked into the library.
|
|||||||
|
|
||||||
| Platform | Transport | Status |
|
| Platform | Transport | Status |
|
||||||
|----------|-----------|--------|
|
|----------|-----------|--------|
|
||||||
| Linux | SG_IO ioctl on `/dev/sr*` | Implemented |
|
| Linux | async sg write/poll/read on `/dev/sg*` | Supported |
|
||||||
| macOS | IOKit SCSI passthrough | Planned |
|
| macOS | IOKit SCSITask | Supported |
|
||||||
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Planned |
|
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Supported |
|
||||||
|
|
||||||
The `ScsiTransport` trait abstracts the platform. Adding a new platform requires
|
The `ScsiTransport` trait abstracts the platform. Adding a new platform requires
|
||||||
implementing `execute()` for that OS and wiring it into `scsi::open()`.
|
implementing `execute()` for that OS and wiring it into `scsi::open()`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Chipset Support
|
## Drive Unlock
|
||||||
|
|
||||||
| Chipset | Drives | Status |
|
libfreemkv carries no drive-unlock mechanism. The `Unlocker` trait + registry
|
||||||
|---------|--------|--------|
|
(`src/unlock.rs`) is the seam: an external crate implements `Unlocker` and
|
||||||
| MediaTek MT1959 | LG, ASUS, hp | Implemented (206 profiles) |
|
registers it once via `register_unlocker(...)`. At drive-prep the registry is
|
||||||
| Renesas RS8xxx/RS9xxx | Pioneer, some HL-DT-ST | Planned |
|
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
|
The implementor owns everything firmware-specific — drive profiles, vendor CDBs,
|
||||||
10 handlers (unlock, config, register, calibrate, keepalive, status, probe,
|
variant logic. Concrete unlockers live in the separate
|
||||||
read_sectors, timing). All handlers are accessed via SCSI READ BUFFER with
|
**[freemkv-unlock](https://github.com/freemkv/freemkv-unlock)** repository, never
|
||||||
chipset-specific mode and buffer ID bytes.
|
in libfreemkv. See [`drive-access.md`](drive-access.md#drive-unlock-seam) for the
|
||||||
|
trait definition and routing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -174,7 +217,5 @@ chipset-specific mode and buffer ID bytes.
|
|||||||
cargo build --release
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux builds produce a static library and two binaries (`freemkv-info`,
|
Produces a Rust library crate. The `libc` dependency is unix-only (gated).
|
||||||
`freemkv-test`). The `libc` dependency is Linux-only. On non-Linux platforms,
|
All three platforms build and pass CI.
|
||||||
the library compiles but `scsi::open()` returns a platform-not-supported error
|
|
||||||
until the IOKit/SPTI backends are implemented.
|
|
||||||
|
|||||||
+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.
|
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.
|
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
|
## References
|
||||||
|
|
||||||
|
|||||||
+59
-38
@@ -9,12 +9,18 @@ This is the starting point for understanding the library.
|
|||||||
Insert disc
|
Insert disc
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
1. Open drive (drive.rs)
|
1. Open drive (drive/mod.rs)
|
||||||
│ INQUIRY → identify drive
|
│ INQUIRY → identify drive (DriveId)
|
||||||
│ Match bundled profile → chipset, unlock parameters
|
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
2. AACS handshake (aacs_handshake.rs) — optional, separate transport
|
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
|
||||||
│ Allocate AGID
|
│ Allocate AGID
|
||||||
│ Exchange certificates + nonces (ECDH)
|
│ Exchange certificates + nonces (ECDH)
|
||||||
│ Derive bus key
|
│ Derive bus key
|
||||||
@@ -22,11 +28,6 @@ Insert disc
|
|||||||
│ (fails gracefully if drive doesn't support AACS for this disc)
|
│ (fails gracefully if drive doesn't support AACS for this disc)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
3. Unlock drive (drive.rs → platform/mt1959.rs)
|
|
||||||
│ Vendor-specific command activates raw read mode
|
|
||||||
│ Required — drive firmware blocks all reads without it
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
4. Read UDF filesystem (udf.rs)
|
4. Read UDF filesystem (udf.rs)
|
||||||
│ Sector 256: AVDP → find Volume Descriptor Sequence
|
│ Sector 256: AVDP → find Volume Descriptor Sequence
|
||||||
│ VDS: Partition Descriptor (physical start) + Logical Volume (metadata start)
|
│ VDS: Partition Descriptor (physical start) + Logical Volume (metadata start)
|
||||||
@@ -35,80 +36,100 @@ Insert disc
|
|||||||
│ → docs/udf.md
|
│ → docs/udf.md
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
5. Read AACS files from disc (aacs.rs)
|
5. Read AACS files from disc (aacs/mod.rs)
|
||||||
│ AACS/Unit_Key_RO.inf → SHA1 = disc hash
|
│ AACS/Unit_Key_RO.inf → SHA1 = disc hash
|
||||||
│ AACS/Content000.cer → AACS version (1.0 or 2.0), bus encryption flag
|
│ AACS/Content000.cer → AACS version (1.0 or 2.0), bus encryption flag
|
||||||
│ MKB via SCSI → for key derivation fallback
|
│ MKB via SCSI → for key derivation fallback
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
6. Resolve AACS keys (aacs.rs → resolve_keys)
|
6. Resolve encryption keys (decrypt.rs → resolve_encryption)
|
||||||
|
│ BD AACS:
|
||||||
│ Path 1: disc hash → KEYDB.cfg → VUK (fast, 99% of discs)
|
│ Path 1: disc hash → KEYDB.cfg → VUK (fast, 99% of discs)
|
||||||
│ Path 2: KEYDB media key + Volume ID → VUK
|
│ Path 2: KEYDB media key + Volume ID → VUK
|
||||||
│ Path 3: MKB + processing keys → media key → VUK
|
│ Path 3: MKB + processing keys → media key → VUK
|
||||||
│ Path 4: MKB + device keys → subset-difference tree → VUK
|
│ Path 4: MKB + device keys → subset-difference tree → VUK
|
||||||
│ VUK → decrypt unit keys from Unit_Key_RO.inf
|
│ VUK → decrypt unit keys from Unit_Key_RO.inf
|
||||||
|
│ DVD CSS:
|
||||||
|
│ Table-driven cipher — no KEYDB needed
|
||||||
│ → docs/aacs.md
|
│ → docs/aacs.md
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
7. Parse playlists (mpls.rs)
|
7. Parse playlists (mpls.rs) — BD/UHD only
|
||||||
│ BDMV/PLAYLIST/*.mpls → titles with play items
|
│ BDMV/PLAYLIST/*.mpls → titles with play items
|
||||||
│ Each play item: clip ID, in/out timestamps
|
│ Each play item: clip ID, in/out timestamps
|
||||||
│ STN table: video, audio, subtitle streams with codec + language
|
│ STN table: video, audio, subtitle streams with codec + language
|
||||||
│ → docs/mpls.md
|
│ → docs/mpls.md
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
8. Parse clip info (clpi.rs)
|
8. Parse clip info (clpi.rs) — BD/UHD only
|
||||||
│ BDMV/CLIPINF/*.clpi → EP map (timestamp → sector mapping)
|
│ BDMV/CLIPINF/*.clpi → EP map (timestamp → sector mapping)
|
||||||
│ Coarse + fine entries → full PTS and SPN
|
│ Coarse + fine entries → full PTS and SPN
|
||||||
│ SPN → byte offset → sector extents for reading
|
│ SPN → byte offset → sector extents for reading
|
||||||
│ → docs/clpi.md
|
│ → docs/clpi.md
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
9. Parse BD-J labels (jar.rs) — optional
|
9. Parse BD-J labels (labels/) — optional
|
||||||
│ BDMV/JAR/*.jar → Java class constant pool strings
|
│ BDMV/JAR/*.jar → Java class constant pool strings
|
||||||
|
│ 5 format parsers: Paramount, Criterion, Pixelogic, CTRM, Deluxe
|
||||||
│ Audio track labels: "English Descriptive Audio", "French 5.1", etc.
|
│ Audio track labels: "English Descriptive Audio", "French 5.1", etc.
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
10. Read + decrypt content (disc.rs → ContentReader)
|
10. Stream content (mux/disc.rs → DiscStream)
|
||||||
│ For each aligned unit (6144 bytes = 3 sectors):
|
│ Read sectors → decrypt → TS demux → PES frames
|
||||||
│ Read 3 sectors from disc
|
│ Or: read sectors → decrypt → raw bytes (for ISO output)
|
||||||
│ If AACS 2.0: bus decrypt (read_data_key, per-sector AES-CBC)
|
│ Drive::read() is single-shot. DiscStream::fill_extents adapts the
|
||||||
│ If encrypted: unit decrypt (per-unit key derivation + AES-CBC)
|
│ batch size on failure (halve / probe-up). Bad-range retry is layer
|
||||||
│ Output decrypted content
|
│ 1 above this — Disc::patch re-runs against the mapfile.
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Decrypted m2ts stream → ready for muxing/backup
|
PES frames → output stream (MKV, M2TS, network, etc.)
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Summary
|
## API Summary
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// Steps 1 + 3 (open + unlock)
|
// Open + init drive
|
||||||
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
|
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||||
|
drive.wait_ready()?;
|
||||||
|
drive.init()?;
|
||||||
|
drive.probe_disc()?;
|
||||||
|
|
||||||
// Steps 2 + 4-9 (AACS + scan)
|
// Scan disc (UDF + playlists + AACS — all automatic)
|
||||||
let disc = Disc::scan(&mut session, &ScanOptions::with_keydb("keydb.cfg"))?;
|
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
|
||||||
|
|
||||||
// Step 10 (read + decrypt)
|
// Stream pipeline — PES frames from any source to any output.
|
||||||
let mut reader = disc.open_title(&mut session, 0)?;
|
// input() returns Box<dyn FrameSource>, output() returns Box<dyn FrameSink>;
|
||||||
while let Some(unit) = reader.read_unit()? {
|
// direction is type-checked, so calling .write() on an input is a compile error.
|
||||||
output.write_all(&unit)?;
|
let opts = InputOptions::default();
|
||||||
|
let mut input = libfreemkv::input("disc:///dev/sg4", &opts)?;
|
||||||
|
let title = input.info().clone();
|
||||||
|
let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?;
|
||||||
|
while let Ok(Some(frame)) = input.read() {
|
||||||
|
output.write(&frame)?;
|
||||||
}
|
}
|
||||||
|
output.finish()?;
|
||||||
```
|
```
|
||||||
|
|
||||||
Three lines. Everything else is internal.
|
|
||||||
|
|
||||||
## Module Reference
|
## Module Reference
|
||||||
|
|
||||||
| Module | Doc | Purpose |
|
| Module | Doc | Purpose |
|
||||||
|--------|-----|---------|
|
|--------|-----|---------|
|
||||||
| drive.rs | [drive-access.md](drive-access.md) | Open, identify, unlock, read |
|
| drive/ | [drive-access.md](drive-access.md) | Open, identify, init, unlock, single-shot read |
|
||||||
| scsi.rs | [drive-access.md](drive-access.md) | Platform SCSI transport |
|
| scsi/ | [drive-access.md](drive-access.md) | Platform SCSI transport (Linux, macOS, Windows) |
|
||||||
| udf.rs | [udf.md](udf.md) | UDF 2.50 filesystem |
|
| udf.rs | [udf.md](udf.md) | UDF 2.50 filesystem |
|
||||||
| mpls.rs | [mpls.md](mpls.md) | MPLS playlists + STN streams |
|
| mpls.rs | [mpls.md](mpls.md) | MPLS playlists + STN streams |
|
||||||
| clpi.rs | [clpi.md](clpi.md) | CLPI clip info + EP map |
|
| clpi.rs | [clpi.md](clpi.md) | CLPI clip info + EP map |
|
||||||
| aacs.rs | [aacs.md](aacs.md) | Key resolution + content decrypt |
|
| ifo.rs | -- | DVD IFO parser |
|
||||||
| aacs_handshake.rs | [aacs.md](aacs.md) | SCSI bus authentication |
|
| aacs/ | [aacs.md](aacs.md) | Key resolution + content decrypt + bus handshake |
|
||||||
| disc.rs | -- | High-level scan + read API |
|
| css/ | -- | DVD CSS cipher |
|
||||||
| jar.rs | -- | BD-J audio track labels |
|
| decrypt.rs | -- | Unified decrypt dispatcher (AACS/CSS/None) |
|
||||||
| error.rs | -- | Error codes (E1xxx-E7xxx) |
|
| disc/ | [rip-recovery.md](rip-recovery.md) | Disc::scan + Disc::sweep + Disc::patch + mapfile |
|
||||||
|
| labels/ | -- | BD-J stream labels (5 format parsers) |
|
||||||
|
| mux/ | -- | Stream implementations (7 stream types) |
|
||||||
|
| pes.rs | -- | PES frame types + FrameSource / FrameSink traits |
|
||||||
|
| sector/ | -- | SectorSource / SectorSink + DecryptingSectorSource decorator |
|
||||||
|
| io/ | -- | Pipeline<I, R> + Sink trait + WritebackFile |
|
||||||
|
| halt.rs | -- | Halt cancellation token |
|
||||||
|
| keydb.rs | -- | KEYDB download, parse, save |
|
||||||
|
| error.rs | -- | Error codes (E1xxx-E8xxx) |
|
||||||
|
| event.rs | -- | Drive event system |
|
||||||
|
|||||||
+143
-154
@@ -5,51 +5,78 @@ optical drives.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## DriveSession
|
## Drive
|
||||||
|
|
||||||
`DriveSession` is the primary API. It owns the SCSI transport, the matched
|
`Drive` is the primary API. It owns the SCSI transport and the drive
|
||||||
drive profile, and the chipset-specific platform driver.
|
identity (`DriveId`); any drive-specific unlock logic lives behind the
|
||||||
|
pluggable [unlock seam](#drive-unlock-seam), not in `Drive` itself.
|
||||||
|
|
||||||
### Opening a Drive
|
### Opening a Drive
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// Full open: identify → match profile → unlock
|
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||||
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
|
|
||||||
|
|
||||||
// No-unlock open: identify → match profile only
|
|
||||||
let mut session = DriveSession::open_no_unlock(Path::new("/dev/sr0"))?;
|
|
||||||
|
|
||||||
// Explicit profile (skip auto-detection)
|
|
||||||
let mut session = DriveSession::open_with_profile(Path::new("/dev/sr0"), profile)?;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**`open()`** performs the full sequence: open device, send INQUIRY, match
|
`open()` performs: open device → send INQUIRY → build `DriveId`. The drive
|
||||||
profile, instantiate platform driver, and unlock. Unlock failures are silently
|
is ready for `wait_ready()` and `init()` (which routes through the unlock
|
||||||
ignored (unencrypted discs do not need it). After `open()`, both raw sector
|
seam).
|
||||||
reads and standard READ(10) work immediately.
|
|
||||||
|
|
||||||
**`open_no_unlock()`** skips the unlock step. This is required when AACS bus
|
### Drive Operations
|
||||||
authentication must happen before unlock. The handshake uses standard SCSI
|
|
||||||
commands that work without raw mode. After authentication completes, the caller
|
|
||||||
can invoke `session.unlock()` manually.
|
|
||||||
|
|
||||||
**`open_with_profile()`** bypasses profile auto-detection. Useful for testing
|
|
||||||
or when a custom profile is loaded from an external source.
|
|
||||||
|
|
||||||
### Session Operations
|
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `unlock()` | Activate raw disc access mode via platform driver |
|
| `wait_ready()` | Wait for disc insertion (30s timeout, TUR polling) |
|
||||||
| `is_unlocked()` | Check if raw mode is active |
|
| `init()` | Route to the matching registered unlocker (if any), then prepare for reads |
|
||||||
| `calibrate()` | Build speed lookup table for the current disc |
|
| `probe_disc()` | Probe disc surface for optimal speeds |
|
||||||
| `read_sectors(lba, count, buf)` | Raw sector read (requires unlock + calibrate) |
|
| `read(lba, count, buf, recovery)` | Read sectors. Single-shot — no inline retries or reset. |
|
||||||
| `read_disc(lba, count, buf)` | Standard READ(10) with 5s timeout |
|
| `reset()` | Eject-cycle escape hatch. Caller-invoked only; not on the read path. |
|
||||||
| `status()` | Query drive status and feature flags |
|
| `lock_tray()` | Prevent tray ejection during rip |
|
||||||
| `read_config()` | Read drive configuration block (1888 bytes) |
|
| `unlock_tray()` | Allow tray ejection (also runs on Drop) |
|
||||||
| `read_register(index)` | Read 16-byte hardware register |
|
| `eject()` | Eject disc tray |
|
||||||
| `probe(sub_cmd, addr, len)` | Generic READ BUFFER with caller parameters |
|
| `drive_status()` | Query physical state (disc present, tray open, etc.) |
|
||||||
| `scsi_execute(cdb, dir, buf, timeout)` | Send an arbitrary SCSI CDB |
|
| `has_profile()` | Whether a registered unlocker matches this drive |
|
||||||
|
| `close()` | Consume Drive, cleanup (also runs via Drop) |
|
||||||
|
|
||||||
|
### init() Sequence
|
||||||
|
|
||||||
|
`init()` routes drive preparation through the unlock seam:
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
`Drive::read(lba, count, buf, recovery)` is the single read method. It issues
|
||||||
|
exactly one READ(10) CDB and returns the result. The `recovery` parameter only
|
||||||
|
selects the per-CDB timeout:
|
||||||
|
|
||||||
|
| `recovery` | Timeout | Used by |
|
||||||
|
|------------|----------|------------------------------------------|
|
||||||
|
| `false` | 1.5 s | `Disc::sweep` fast skip-forward pass, `DiscStream::fill_extents` |
|
||||||
|
| `true` | 30 s | `Disc::patch` retry pass over the mapfile |
|
||||||
|
|
||||||
|
On any SCSI failure or timeout, `read` returns `Err(DiscRead)` immediately.
|
||||||
|
There are no inline retries, no SCSI reset, no Phase 1/2/3 escalation.
|
||||||
|
|
||||||
|
Recovery is layered above `Drive::read`:
|
||||||
|
|
||||||
|
- **Layer 1 — `Disc::patch`** loops over the ddrescue mapfile and re-issues
|
||||||
|
`read(.., recovery=true)` against each non-`+` range.
|
||||||
|
- **Layer 3 — `DiscStream::fill_extents`** halves the request size on
|
||||||
|
failure, retries at the same LBA, and probes back up on a clean-read
|
||||||
|
streak.
|
||||||
|
|
||||||
|
Inline recovery (5× gentle retry → close + reset + reopen → 5× more) was
|
||||||
|
removed in 0.13.6. See the stop-wedge postmortem (2026-04-25)
|
||||||
|
for rationale: the inline reset wedged drive firmware on the LG BU40N (Initio
|
||||||
|
USB-SATA bridge) without ever recovering a sector. See
|
||||||
|
[`rip-recovery.md`](rip-recovery.md) for the full three-layer model.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -58,7 +85,7 @@ or when a custom profile is loaded from an external source.
|
|||||||
### Trait
|
### Trait
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub trait ScsiTransport {
|
pub trait ScsiTransport: Send {
|
||||||
fn execute(
|
fn execute(
|
||||||
&mut self,
|
&mut self,
|
||||||
cdb: &[u8],
|
cdb: &[u8],
|
||||||
@@ -72,17 +99,43 @@ pub trait ScsiTransport {
|
|||||||
All drive communication goes through this trait. The library never opens file
|
All drive communication goes through this trait. The library never opens file
|
||||||
descriptors or calls ioctls outside of a `ScsiTransport` implementation.
|
descriptors or calls ioctls outside of a `ScsiTransport` implementation.
|
||||||
|
|
||||||
### Linux: SG_IO
|
### Platform Backends
|
||||||
|
|
||||||
The `SgIoTransport` implementation:
|
| Platform | Implementation | Device |
|
||||||
|
|----------|---------------|--------|
|
||||||
|
| Linux | `SgIoTransport` — async `write`/`poll`/`read` on `/dev/sg*` | `/dev/sg*` |
|
||||||
|
| macOS | `MacScsiTransport` — IOKit SCSITask | IOKit service |
|
||||||
|
|
||||||
1. Opens the device path with `O_RDWR | O_NONBLOCK`.
|
The Linux backend uses the sg driver's asynchronous interface: `write()` submits
|
||||||
2. Constructs an `sg_io_hdr` struct with the CDB, data buffer, and timeout.
|
the command, `poll()` waits with an enforceable wall-clock timeout, `read()`
|
||||||
3. Calls `ioctl(fd, SG_IO, &hdr)`.
|
retrieves the result. If `poll()` times out, the fd is abandoned (closed in a
|
||||||
4. Returns `ScsiResult` with status, bytes transferred, and sense data.
|
background thread) and a fresh fd opened — the kernel's USB error recovery
|
||||||
|
cannot block us. Opens with `O_RDWR | O_NONBLOCK`.
|
||||||
|
|
||||||
On non-zero SCSI status, the transport parses sense key, ASC, and ASCQ from the
|
The macOS backend uses a C shim (`macos_shim.c`) for IOKit exclusive access.
|
||||||
sense buffer and returns `Error::ScsiError`.
|
The shim handles:
|
||||||
|
1. `shim_open_exclusive(bsd_name)` — unmounts the target device via `diskutil`,
|
||||||
|
then walks the IOKit registry to find the `IOBDServices` matching the
|
||||||
|
requested BSD name (IOBDServices → IOBDBlockStorageDriver → IOMedia → "BSD Name"),
|
||||||
|
then creates MMCDeviceInterface → SCSITaskDeviceInterface → ObtainExclusiveAccess.
|
||||||
|
2. `shim_list_drives()` — registry-based enumeration with zero SCSI, zero exclusive
|
||||||
|
access, zero unmounts. Reads IOBDServices "Device Characteristics" for
|
||||||
|
vendor/model/firmware and child IOMedia "BSD Name" for the device path.
|
||||||
|
3. `shim_execute()` / `shim_close()` — raw CDB dispatch and cleanup.
|
||||||
|
|
||||||
|
On non-zero SCSI status, the transport parses sense key from the sense buffer
|
||||||
|
and returns `Error::ScsiError`.
|
||||||
|
|
||||||
|
`SgIoTransport::reset` (Linux) does pure userspace state cleanup: an open +
|
||||||
|
close pair to make the kernel cancel any SG_IO commands queued against a
|
||||||
|
previous fd, a 2 s sleep to let the kernel finish that cancellation, then a
|
||||||
|
fresh fd to send ALLOW MEDIUM REMOVAL to clear any stale tray lock. It does
|
||||||
|
NOT issue `SG_SCSI_RESET` or escalate via STOP+START UNIT. Both were tried
|
||||||
|
in 0.13.0–0.13.5 against the LG BU40N (Initio USB-SATA bridge); both failed
|
||||||
|
to recover wedged drives and made the wedge worse. The macOS reset (which
|
||||||
|
had been a no-op) was removed entirely in 0.13.6, and the top-level
|
||||||
|
`scsi::reset()` / `reset_with_timeout()` / `reset_blocking()` wrappers were
|
||||||
|
removed at the same time (no callers).
|
||||||
|
|
||||||
### CDB Builders
|
### CDB Builders
|
||||||
|
|
||||||
@@ -117,132 +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`,
|
libfreemkv ships **no firmware, no unlock CDBs, and no drive profiles.** It
|
||||||
206 entries). Each profile contains:
|
knows only the *seam*, never the *mechanism*. The seam is the `Unlocker`
|
||||||
|
trait plus a small process-wide registry (`src/unlock.rs`):
|
||||||
| 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:
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// Bundled (compiled-in) -- no file I/O
|
pub trait Unlocker: Send + Sync {
|
||||||
let profiles = profile::load_bundled()?;
|
/// Stable, language-neutral identifier (logged).
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
|
||||||
// External file
|
/// True if this unlocker handles the given drive.
|
||||||
let profiles = profile::load_all(Path::new("/path/to/profiles.json"))?;
|
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
|
|
||||||
|
|
||||||
### MediaTek MT1959
|
|
||||||
|
|
||||||
Covers all LG, ASUS, and hp optical drives. Two sub-variants share identical
|
|
||||||
logic with different SCSI parameters:
|
|
||||||
|
|
||||||
| Variant | READ BUFFER mode | Buffer ID |
|
|
||||||
|---------|------------------|-----------|
|
|
||||||
| MT1959-A | 0x01 | 0x44 |
|
|
||||||
| MT1959-B | 0x02 | 0x77 |
|
|
||||||
|
|
||||||
The Platform trait maps to 10 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. The `read_disc()`
|
|
||||||
method uses standard READ(10) and 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.
|
|
||||||
|
|
||||||
- **The kernel sr driver blocks block-device reads.** On Linux, the kernel's
|
|
||||||
SCSI CD-ROM driver (`sr`) refuses to expose encrypted disc content through
|
|
||||||
`/dev/sr0` as a block device. Even if you open the block device directly,
|
|
||||||
reads to encrypted regions fail.
|
|
||||||
|
|
||||||
- **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. This is how raw sector ripping works.
|
|
||||||
|
|
||||||
### open() vs open_no_unlock()
|
|
||||||
|
|
||||||
AACS bus authentication uses standard MMC REPORT KEY / SEND KEY commands.
|
|
||||||
These must execute before unlock because:
|
|
||||||
|
|
||||||
1. The AACS handshake establishes a bus key via ECDH.
|
|
||||||
2. The bus key encrypts the Volume ID and Read Data Key responses.
|
|
||||||
3. The Volume ID is needed to derive the Volume Unique Key (VUK).
|
|
||||||
4. The VUK is needed to decrypt unit keys from `Unit_Key_RO.inf`.
|
|
||||||
|
|
||||||
If `open()` unlocks first, some drives reject the subsequent AACS commands.
|
|
||||||
The correct sequence for encrypted discs is:
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// 1. Open without unlock
|
libfreemkv::register_unlocker(Box::new(some_unlocker::Plugin::new()));
|
||||||
let mut session = DriveSession::open_no_unlock(device)?;
|
|
||||||
|
|
||||||
// 2. AACS handshake (uses standard SCSI, no unlock needed)
|
|
||||||
let auth = aacs_handshake::aacs_authenticate(&mut session, &key, &cert)?;
|
|
||||||
let vid = aacs_handshake::read_volume_id(&mut session, &mut auth)?;
|
|
||||||
|
|
||||||
// 3. Now unlock for raw reads
|
|
||||||
session.unlock()?;
|
|
||||||
session.calibrate()?;
|
|
||||||
|
|
||||||
// 4. Read and decrypt content
|
|
||||||
session.read_sectors(lba, count, &mut buf)?;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
In practice, `Disc::scan()` handles this internally. The default `open()` call
|
The implementor owns everything about *how* a particular drive family is
|
||||||
unlocks immediately and is correct for most use cases -- the scan re-opens a
|
driven — drive identification against its own profile database, firmware
|
||||||
second session with `open_no_unlock()` for the AACS handshake when needed.
|
upload, vendor CDBs, variant logic. libfreemkv only hands over the raw
|
||||||
|
`ScsiTransport` and the `DriveId`.
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
## Speed Control
|
||||||
|
|
||||||
After `calibrate()`, the platform driver maintains a 64-entry speed lookup table
|
A matching unlocker may raise the drive to its maximum read speed via
|
||||||
built by probing the disc surface. On each `read_sectors()` call, the driver:
|
`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
|
||||||
1. Looks up the optimal speed for the target LBA in the table.
|
builder; the concrete speed policy lives in the unlocker.
|
||||||
2. Issues SET CD SPEED (0xBB) if the speed differs from current.
|
|
||||||
3. Performs the READ(10).
|
|
||||||
|
|
||||||
Available speeds:
|
Available speeds:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# Rip recovery — three-layer architecture
|
||||||
|
|
||||||
|
`libfreemkv` supports a multi-stage rip model for damaged or protection-bearing
|
||||||
|
discs: a fast forward sweep that tolerates read failures, in-loop request-size
|
||||||
|
adaptation that survives transient drive trouble without bailing, and targeted
|
||||||
|
retry passes against a persistent bad-range map. The stream pipeline
|
||||||
|
(`DiscStream` + `input`/`output`) operates against the resulting ISO image, so
|
||||||
|
the mux stage never touches the drive.
|
||||||
|
|
||||||
|
Recovery is layered cleanly. Each layer has one responsibility and does not
|
||||||
|
reach into the others.
|
||||||
|
|
||||||
|
| Layer | Where it lives | What it does |
|
||||||
|
|-------|---------------|--------------|
|
||||||
|
| 1 — Bad-range retry | `Disc::patch` (one pass over the mapfile per call) | Re-reads non-`+` ranges with the long timeout. Idempotent; caller invokes N times. |
|
||||||
|
| 2 — Single-shot primitive | `Drive::read` in `src/drive/mod.rs` | One CDB, one timeout, one result. No inline retries, no SCSI reset. |
|
||||||
|
| 3 — In-loop request adaptation | `DiscStream::fill_extents` adaptive batch sizer | Halves the batch on failure, retries at the same LBA, walks back up on a clean-read streak. |
|
||||||
|
|
||||||
|
The library exposes flat verbs; the caller drives the multipass loop. Autorip
|
||||||
|
runs `Disc::sweep` once, then loops `Disc::patch` until either the mapfile is
|
||||||
|
clean or the configured retry budget is exhausted, then hands the ISO off to
|
||||||
|
the mux pipeline. The `freemkv` CLI does the same shape with a
|
||||||
|
terminal-output progress sink. Layer 3 runs inside any consumer of
|
||||||
|
`DiscStream` (direct PES pipeline, ISO playback, etc.) without caller
|
||||||
|
involvement.
|
||||||
|
|
||||||
|
Three primitives compose the disc-side flow:
|
||||||
|
|
||||||
|
| Primitive | What it does |
|
||||||
|
|---------------------------|-----------------------------------------------------------------------|
|
||||||
|
| `Disc::sweep` | disc → ISO, one forward pass. Writes a sidecar `.mapfile`. Opt-in skip-on-error. |
|
||||||
|
| `Disc::patch` | Re-reads bad ranges from the drive. One pass per call; caller invokes N times. |
|
||||||
|
| `DiscStream` (ISO source) | Reads sectors from the ISO, feeds decrypt → demux → codec → mux. |
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### Mapfile
|
||||||
|
|
||||||
|
Format: [ddrescue](https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html)-compatible
|
||||||
|
plain text, greppable, tool-interoperable. Flushed to disk on every `record()`
|
||||||
|
so a crashed rip loses at most one block.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Rescue Logfile. Created by libfreemkv v0.13.6
|
||||||
|
# Current pos / status / pass / pass_time
|
||||||
|
0x000000000 ? 1 0
|
||||||
|
# pos size status
|
||||||
|
0x000000000 0x12a35d000 +
|
||||||
|
0x12a35d000 0x000003000 -
|
||||||
|
0x12a360000 0x009c4a000 +
|
||||||
|
0x12d00a000 0x000064000 *
|
||||||
|
```
|
||||||
|
|
||||||
|
Status characters match ddrescue:
|
||||||
|
|
||||||
|
| Char | Meaning |
|
||||||
|
|------|----------------------------------------------------|
|
||||||
|
| `?` | Not yet attempted |
|
||||||
|
| `*` | Fast-pass failed; needs edge-trim |
|
||||||
|
| `/` | Trimmed; interior needs sector scrape |
|
||||||
|
| `-` | Unreadable this session |
|
||||||
|
| `+` | Finished (good) |
|
||||||
|
|
||||||
|
Position and size are hex byte offsets into the ISO.
|
||||||
|
|
||||||
|
### `SweepOptions` and `PatchOptions`
|
||||||
|
|
||||||
|
The library no longer dispatches between sweep and patch internally — the
|
||||||
|
caller picks the verb explicitly per pass. The two option structs are flat
|
||||||
|
and have no overlap:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
SweepOptions {
|
||||||
|
decrypt: true,
|
||||||
|
resume: false,
|
||||||
|
batch_sectors: None,
|
||||||
|
skip_on_error: true, // damage-jump + zero-fill on read failure
|
||||||
|
progress: Some(&reporter),
|
||||||
|
halt: Some(flag),
|
||||||
|
}
|
||||||
|
|
||||||
|
PatchOptions {
|
||||||
|
decrypt: true,
|
||||||
|
block_sectors: None,
|
||||||
|
full_recovery: true,
|
||||||
|
reverse: true, // walk bad ranges high → low LBA
|
||||||
|
wedged_threshold: 50,
|
||||||
|
progress: Some(&reporter),
|
||||||
|
halt: Some(flag),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Caller-orchestrated dispatch (the policy `Disc::copy` used to embed):
|
||||||
|
|
||||||
|
- No mapfile → `sweep` (fresh Pass 1).
|
||||||
|
- Mapfile with `?` ranges → `sweep` with `resume: true`.
|
||||||
|
- Mapfile covers full disc, only `*` / `/` / `-` ranges → `patch`.
|
||||||
|
- Mapfile clean → done; no further pass needed.
|
||||||
|
|
||||||
|
Each consumer (autorip, `freemkv` CLI) implements the loop in roughly five
|
||||||
|
lines of `Mapfile::stats()` checks.
|
||||||
|
|
||||||
|
## Algorithm
|
||||||
|
|
||||||
|
### Pass 1 — fast sweep (`Disc::sweep`)
|
||||||
|
|
||||||
|
1. Read one ECC block (32 sectors for UHD, 16 for BD/DVD) at the current LBA.
|
||||||
|
2. On success: write data to ISO, mark `+`, advance.
|
||||||
|
3. On failure (with `multipass`): zero-fill, mark `*`, advance.
|
||||||
|
4. Track a sliding window of the last 16 ECC block results. When ≥12% are failures
|
||||||
|
→ **damage-jump**: skip ahead by `1024×batch×multiplier` sectors (64 MB base for
|
||||||
|
UHD). Double the multiplier on each jump (64→128→256→512 MB...). Zero-fill the gap as `*`.
|
||||||
|
5. On 16 consecutive good reads: reset jump multiplier to 1, restore max read speed.
|
||||||
|
6. Speed control: damage zone entry → minimum speed, exit → maximum speed.
|
||||||
|
7. Only transport failures (USB bridge crash) abort the pass.
|
||||||
|
|
||||||
|
Pass 1 completes when every byte has been visited (either `+` or `*`).
|
||||||
|
|
||||||
|
### Pass 2+ — patch (`Disc::patch`)
|
||||||
|
|
||||||
|
`Disc::patch` reads the mapfile and iterates every non-`+` range. Default: **reverse** mode
|
||||||
|
(walks ranges from highest LBA to lowest, within each range from end to start).
|
||||||
|
|
||||||
|
1. Issue a single-sector read with 60 s timeout (`recovery=true`). Drive firmware
|
||||||
|
does its own ECC recovery inside that window.
|
||||||
|
2. On success: write the good bytes into the ISO, mark `+`.
|
||||||
|
3. On failure with non-marginal SCSI sense: bail immediately (drive won't produce data).
|
||||||
|
4. On failure with marginal sense: mark `-`, continue.
|
||||||
|
5. Update the mapfile after every block — crash-safe resume.
|
||||||
|
6. Wedged-drive exit: 50 consecutive failures with zero recovery → bail this pass.
|
||||||
|
|
||||||
|
### In-stream — adaptive batch halving (`DiscStream::fill_extents`)
|
||||||
|
|
||||||
|
When a consumer reads a `DiscStream` directly (no ISO intermediate),
|
||||||
|
`fill_extents` runs an adaptive sizer in front of `Drive::read`:
|
||||||
|
|
||||||
|
1. Try the current preferred batch size (e.g. 32 sectors, one BD ECC block).
|
||||||
|
2. On failure: halve the batch and retry at the same LBA. Emit
|
||||||
|
`EventKind::BatchSizeChanged { reason: Shrunk }`.
|
||||||
|
3. On a clean-read streak: probe back up toward the preferred size. Emit
|
||||||
|
`EventKind::BatchSizeChanged { reason: Probed }`.
|
||||||
|
4. If a single-sector read fails: skip (zero-fill, emit
|
||||||
|
`EventKind::SectorSkipped`) when `skip_errors` is set, otherwise return
|
||||||
|
`Err(DiscRead)`.
|
||||||
|
|
||||||
|
This is layer 3. It exists so a transient single-sector glitch in a 32-sector
|
||||||
|
batch can be isolated and read individually without the caller needing to
|
||||||
|
implement retry logic.
|
||||||
|
|
||||||
|
## Design choices
|
||||||
|
|
||||||
|
**`Drive::read` is single-shot.** No inline retry phases, no SCSI reset,
|
||||||
|
no eject cycle. The `recovery` flag controls only the per-CDB timeout
|
||||||
|
(1.5 s vs. 30 s); on any failure it returns `Err(DiscRead)` immediately.
|
||||||
|
Inline recovery (5× gentle retry → close + SCSI reset + reopen → 5× more)
|
||||||
|
was removed in 0.13.6. See the stop-wedge postmortem (2026-04-25) for rationale:
|
||||||
|
the inline reset on the LG BU40N (Initio USB-SATA bridge)
|
||||||
|
wedged drive firmware below the bridge without ever recovering a sector,
|
||||||
|
and the gentle-retry phase produced long stretches of 0 KB/s with no
|
||||||
|
recoveries to show for it. Recovery responsibility is now layered: layer 1
|
||||||
|
handles ranges, layer 3 handles request size, neither touches the
|
||||||
|
wedge-prone reset path.
|
||||||
|
|
||||||
|
**No `MODE SELECT` to disable drive retries.** Neither ddrescue
|
||||||
|
nor any consumer ripper does this. Drive firmware has access to raw analog signal, laser
|
||||||
|
power control, and drive-specific ECC tuning that userspace can't replicate —
|
||||||
|
disabling it throws away recovery headroom on marginal sectors. We fail fast
|
||||||
|
via short SG_IO timeouts in pass 1 and let the firmware work the long timeout
|
||||||
|
in pass 2 / patch.
|
||||||
|
|
||||||
|
**No SCSI reset from any retry path.** `SgIoTransport::reset` (Linux) is
|
||||||
|
trimmed to a kernel SG_IO state flush plus ALLOW MEDIUM REMOVAL — the
|
||||||
|
`SG_SCSI_RESET` ioctl and STOP/START UNIT escalation were removed in 0.13.6.
|
||||||
|
The macOS reset (which had been a no-op) was removed entirely. The top-level
|
||||||
|
`scsi::reset()` / `reset_with_timeout()` / `reset_blocking()` wrappers were
|
||||||
|
also removed (no callers). The remaining `Drive::reset()` is only invoked
|
||||||
|
explicitly by callers that need an eject-cycle escape hatch — it is never
|
||||||
|
reached from a read path.
|
||||||
|
|
||||||
|
**ISO intermediate, even for single-pass.** Pass 1 always writes an ISO. The
|
||||||
|
mux stage reads the ISO via `FileSectorSource`. For single-pass (no retries),
|
||||||
|
this adds ~2-3 min (local disk mux) but gains resumability across crashes,
|
||||||
|
re-muxability without re-ripping, and a persistent forensic artifact. Callers
|
||||||
|
who need pure speed can bypass and use `DiscStream::new(Box::new(drive), …)`
|
||||||
|
directly — the lib doesn't forbid it, and layer 3 (adaptive batch halving)
|
||||||
|
still applies there.
|
||||||
|
|
||||||
|
**Mapfile in ddrescue format.** Plain text so users can `less` it, `diff` it,
|
||||||
|
or feed it to ddrescue's own tooling. Crash-safe (flush-per-record). Entries
|
||||||
|
coalesce on adjacent same-status ranges so files stay small.
|
||||||
|
|
||||||
|
**Patches target `-`, `*`, `/`, and `?` alike.** The status state machine is
|
||||||
|
ddrescue's but `patch` collapses the distinction — it just tries every
|
||||||
|
non-finished range with the long timeout. Future work can specialize (trim vs.
|
||||||
|
scrape vs. retry with direction reversal) if there's measured benefit.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ddrescue manual, Algorithm chapter](https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html)
|
||||||
|
- [ddrescue optical media notes](https://www.electric-spoon.com/doc/gddrescue/html/Optical-media.html)
|
||||||
|
- Source: [`src/disc/mapfile.rs`](../src/disc/mapfile.rs), [`src/disc/mod.rs`](../src/disc/mod.rs) (`Disc::sweep`), [`src/disc/patch.rs`](../src/disc/patch.rs) (`Disc::patch`), [`src/drive/mod.rs`](../src/drive/mod.rs) (`Drive::read`), [`src/mux/disc.rs`](../src/mux/disc.rs) (`DiscStream::fill_extents`).
|
||||||
@@ -120,6 +120,14 @@ Each directory read involves two sector reads: one for the ICB, then one or more
|
|||||||
|
|
||||||
`read_file()` reads a file by navigating the directory tree, reading the file's ICB to get its data extent, then reading the data sector by sector from the **physical partition** (partition_start + LBA, not metadata_start).
|
`read_file()` reads a file by navigating the directory tree, reading the file's ICB to get its data extent, then reading the data sector by sector from the **physical partition** (partition_start + LBA, not metadata_start).
|
||||||
|
|
||||||
|
## Buffered Sector Reads
|
||||||
|
|
||||||
|
USB optical drives have ~500ms round-trip latency per SCSI command. Since `read_filesystem()` and `read_file()` issue one SCSI READ per sector, a full disc scan can require hundreds of commands -- taking 10+ minutes on USB.
|
||||||
|
|
||||||
|
`Disc::scan()` wraps the drive in a `BufferedSectorReader` before reading. On a single-sector read, the buffer prefetches a batch of sectors (sized from the kernel's `max_hw_sectors_kb` for the device) and caches them. Subsequent reads to nearby LBAs return from cache with zero SCSI overhead. After parsing the UDF directory structure, the entire metadata partition is pre-read into the cache, so all ICB lookups during title scanning and encryption resolution are instant.
|
||||||
|
|
||||||
|
The buffer is transparent -- `read_filesystem()`, `read_file()`, and all downstream code still call `read_sectors(lba, 1, buf)` as before. The batching happens inside the `SectorSource` implementation.
|
||||||
|
|
||||||
### UDF Filename Encoding
|
### UDF Filename Encoding
|
||||||
|
|
||||||
UDF filenames use a compression ID as the first byte:
|
UDF filenames use a compression ID as the first byte:
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Minimal ISO dumper — find exact stall point
|
||||||
|
use libfreemkv::Drive;
|
||||||
|
use std::io::{BufWriter, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if args.len() < 3 {
|
||||||
|
eprintln!("Usage: iso_dump <device> <output>");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut drive = Drive::open(Path::new(&args[1])).unwrap();
|
||||||
|
drive.wait_ready().unwrap();
|
||||||
|
let _ = drive.init();
|
||||||
|
let _ = drive.probe_disc();
|
||||||
|
|
||||||
|
// AACS handshake — required to read past the protected area
|
||||||
|
eprint!("Scanning disc... ");
|
||||||
|
let _ = libfreemkv::Disc::scan(&mut drive, &libfreemkv::ScanOptions::default());
|
||||||
|
eprintln!("OK");
|
||||||
|
|
||||||
|
let cap = drive.read_capacity().unwrap();
|
||||||
|
let batch = libfreemkv::disc::detect_max_batch_sectors(drive.device_path());
|
||||||
|
|
||||||
|
eprintln!("Device: {} | {} sectors | batch {}", args[1], cap, batch);
|
||||||
|
|
||||||
|
let file = std::fs::File::create(&args[2]).unwrap();
|
||||||
|
let mut w = BufWriter::with_capacity(4 * 1024 * 1024, file);
|
||||||
|
let mut buf = vec![0u8; batch as usize * 2048];
|
||||||
|
let mut lba: u32 = 0;
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut last = Instant::now();
|
||||||
|
let mut bytes: u64 = 0;
|
||||||
|
let mut last_bytes: u64 = 0;
|
||||||
|
|
||||||
|
while lba < cap {
|
||||||
|
let count = ((cap - lba) as u16).min(batch);
|
||||||
|
let n = count as usize * 2048;
|
||||||
|
|
||||||
|
// Tiny yield between reads — test if pacing prevents firmware throttle
|
||||||
|
std::thread::yield_now();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let ok = drive.read(lba, count, &mut buf[..n], true).is_ok();
|
||||||
|
let read_ms = t0.elapsed().as_millis();
|
||||||
|
|
||||||
|
// Flag slow reads
|
||||||
|
if read_ms > 2000 {
|
||||||
|
eprintln!("\n SLOW READ: LBA {} took {}ms (ok={})", lba, read_ms, ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
buf[..n].fill(0);
|
||||||
|
}
|
||||||
|
w.write_all(&buf[..n]).unwrap();
|
||||||
|
lba += count as u32;
|
||||||
|
bytes += n as u64;
|
||||||
|
|
||||||
|
if last.elapsed().as_millis() >= 1000 {
|
||||||
|
let delta = bytes - last_bytes;
|
||||||
|
let speed = delta as f64 / last.elapsed().as_secs_f64() / 1_048_576.0;
|
||||||
|
let avg = bytes as f64 / start.elapsed().as_secs_f64() / 1_048_576.0;
|
||||||
|
let pct = bytes as f64 / (cap as f64 * 2048.0) * 100.0;
|
||||||
|
eprint!(
|
||||||
|
"\r {:.1}% LBA {} | {:.0} MB/s (avg {:.0}) | {:.1} GB ",
|
||||||
|
pct,
|
||||||
|
lba,
|
||||||
|
speed,
|
||||||
|
avg,
|
||||||
|
bytes as f64 / 1e9
|
||||||
|
);
|
||||||
|
last_bytes = bytes;
|
||||||
|
last = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.flush().unwrap();
|
||||||
|
eprintln!(
|
||||||
|
"\nDone: {:.1} GB in {:.0}s",
|
||||||
|
bytes as f64 / 1e9,
|
||||||
|
start.elapsed().as_secs_f64()
|
||||||
|
);
|
||||||
|
}
|
||||||
-3093
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
max_width = 100
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
//! AACS derivation "boil-down" — one public home for the key chain.
|
||||||
|
//!
|
||||||
|
//! Thin newtypes at the API boundary and three wrapper functions over the
|
||||||
|
//! existing crypto. Nothing here re-implements a primitive: every function
|
||||||
|
//! delegates to the already-audited code in [`super::keys`] and
|
||||||
|
//! [`super::variants`], so the boil-down cannot drift from production math.
|
||||||
|
//!
|
||||||
|
//! The newtypes wrap bare `[u8; 16]` ONLY at this boundary — the crypto
|
||||||
|
//! internals continue to operate on raw arrays. They exist so a caller threads
|
||||||
|
//! the chain `DK → MK → VUK → UK` without confusing one 16-byte secret for
|
||||||
|
//! another, not to refactor the resolver.
|
||||||
|
//!
|
||||||
|
//! Chain (matches `aacs::keys::resolve_keys_classical` path 1 and
|
||||||
|
//! `aacs::keys::resolve_keys_v21` path 1 byte-for-byte):
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! mk_from_dk(device_keys, mkb, vid) → MediaKey (Km)
|
||||||
|
//! mk_from_pk(processing_keys, mkb) → MediaKey (Km)
|
||||||
|
//! vuk_from_mk(MediaKey, Vid) → Vuk (= AES-G(Km, VID))
|
||||||
|
//! uk_from_vuk(Vuk, enc_title_keys) → [UnitKey] (decrypt_unit_key each)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! `mk_from_dk` and `mk_from_pk` are two entry points to the SAME Media Key,
|
||||||
|
//! both via the MKB's Subset-Difference cvalue tables: the device-key path
|
||||||
|
//! recovers its Processing Key at the matching SD node and walks on to the MK;
|
||||||
|
//! the processing-key path starts from a precomputed PK. Neither needs a VID
|
||||||
|
//! (the VID enters at `vuk_from_mk`).
|
||||||
|
|
||||||
|
use super::keys::{
|
||||||
|
decrypt_unit_key, derive_media_key_and_pk_from_dk, derive_media_key_from_pk, derive_vuk,
|
||||||
|
};
|
||||||
|
use super::types::DeviceKey;
|
||||||
|
|
||||||
|
/// 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]);
|
||||||
|
|
||||||
|
/// Processing Key (Kp, 16 bytes) — an MKB Subset-Difference key that yields the
|
||||||
|
/// Media Key. A leaked/precomputed PK in the keydb, or the intermediate PK a
|
||||||
|
/// device-key walk derives at its matching SD node.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ProcessingKey(pub [u8; 16]);
|
||||||
|
|
||||||
|
/// One decrypted per-CPS-unit AACS title key.
|
||||||
|
///
|
||||||
|
/// `idx` is the POSITIONAL index of the encrypted title key within the slice
|
||||||
|
/// handed to [`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 positioned device keys via the MKB's
|
||||||
|
/// Subset-Difference tables.
|
||||||
|
///
|
||||||
|
/// Wraps [`derive_media_key_and_pk_from_dk`] — the real SD walk the resolver
|
||||||
|
/// runs: each positioned device key is placed against the MKB's subset-diff /
|
||||||
|
/// cvalue records, recovering its Processing Key at the matching node and
|
||||||
|
/// continuing to the Media Key. Reachable for real discs whenever a device key
|
||||||
|
/// applies to the MKB. No VID is involved here — it enters at [`vuk_from_mk`].
|
||||||
|
///
|
||||||
|
/// Returns [`Error::AacsMkUnavailable`] (E7018) when no supplied device key
|
||||||
|
/// resolves the MKB — the same terminal error as [`mk_from_pk`]; no numeric
|
||||||
|
/// distinction is load-bearing at this boundary.
|
||||||
|
///
|
||||||
|
/// [`Error::AacsMkUnavailable`]: crate::error::Error::AacsMkUnavailable
|
||||||
|
pub fn mk_from_dk(device_keys: &[DeviceKey], mkb: &[u8]) -> Result<MediaKey, crate::error::Error> {
|
||||||
|
// Positioned device keys drive the real Subset-Difference MKB walk
|
||||||
|
// ([`derive_media_key_and_pk_from_dk`], the same walk the resolver runs). The
|
||||||
|
// old Media-Key-Variant path needed integrator Key Correction Data absent
|
||||||
|
// in-tree, so it Err'd for EVERY real disc (dead for both consumers —
|
||||||
|
// freemkv-keysources' DK fallback and the kdb harvester). No VID is needed
|
||||||
|
// for the Media Key; it enters only at [`vuk_from_mk`].
|
||||||
|
match derive_media_key_and_pk_from_dk(mkb, device_keys) {
|
||||||
|
Some((km, _pk)) => Ok(MediaKey(km)),
|
||||||
|
None => Err(crate::error::Error::AacsMkUnavailable),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the Media Key (Km) from one or more Processing Keys and the disc MKB.
|
||||||
|
///
|
||||||
|
/// Wraps [`derive_media_key_from_pk`] — the Subset-Difference PK→MK walk: each
|
||||||
|
/// processing key is validated (and tree-walked) against the MKB's cvalue tables
|
||||||
|
/// (records `0x04`/`0x05`) until one yields the Media Key whose verify record
|
||||||
|
/// (`0x81`/`0x86`) matches. Unlike [`mk_from_dk`] this path is reachable for
|
||||||
|
/// real discs — a leaked/precomputed AACS Processing Key in the keydb resolves
|
||||||
|
/// the Media Key directly. No VID is involved at this step; the VID enters at
|
||||||
|
/// [`vuk_from_mk`].
|
||||||
|
///
|
||||||
|
/// Returns [`Error::AacsMkUnavailable`] (E7018) when no processing key resolves
|
||||||
|
/// the MKB — the same terminal error as [`mk_from_dk`]; no numeric distinction
|
||||||
|
/// is load-bearing at this boundary.
|
||||||
|
///
|
||||||
|
/// [`Error::AacsMkUnavailable`]: crate::error::Error::AacsMkUnavailable
|
||||||
|
pub fn mk_from_pk(
|
||||||
|
processing_keys: &[[u8; 16]],
|
||||||
|
mkb: &[u8],
|
||||||
|
) -> Result<MediaKey, crate::error::Error> {
|
||||||
|
match derive_media_key_from_pk(mkb, processing_keys) {
|
||||||
|
Some(km) => Ok(MediaKey(km)),
|
||||||
|
None => Err(crate::error::Error::AacsMkUnavailable),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A candidate key at any rung of the AACS ladder, handed to [`resolve_candidate`].
|
||||||
|
///
|
||||||
|
/// Each variant carries the module's existing newtype for that rung (a `Dk` is a
|
||||||
|
/// POSITIONED [`DeviceKey`] — recover an unpositioned one with
|
||||||
|
/// [`super::keys::recover_dk_position`] first).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum KeyCandidate {
|
||||||
|
Uk(UnitKey),
|
||||||
|
Vuk(Vuk),
|
||||||
|
Mk(MediaKey),
|
||||||
|
Pk(ProcessingKey),
|
||||||
|
Dk(DeviceKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The AACS key chain derived from a candidate, from [`resolve_candidate`].
|
||||||
|
///
|
||||||
|
/// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every
|
||||||
|
/// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (positional
|
||||||
|
/// order); the caller runs [`super::decrypt::unit_key_validates`] to find which
|
||||||
|
/// one actually opens the disc. Rungs above the candidate are `None` (a `Vuk`
|
||||||
|
/// candidate has no `mk`/`pk`/`dk`; a `Uk` candidate has only `unit_keys`).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ResolvedChain {
|
||||||
|
/// Every unit key derived from the VUK, as `(cps_unit_number, key)` — the
|
||||||
|
/// CPS-unit numbers come from `Unit_Key_RO.inf` (via `parse_unit_key_ro`), so
|
||||||
|
/// a consumer maps `UK → CPS unit` directly. Same shape as
|
||||||
|
/// [`super::keys::ResolvedKeys::unit_keys`]. A `Uk` candidate yields exactly
|
||||||
|
/// itself, keyed by its own `idx`.
|
||||||
|
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||||
|
pub vuk: Option<Vuk>,
|
||||||
|
pub mk: Option<MediaKey>,
|
||||||
|
pub pk: Option<ProcessingKey>,
|
||||||
|
/// The positioned device key (for a `Dk` candidate).
|
||||||
|
pub dk: Option<DeviceKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the full AACS key chain from a candidate key of ANY ladder rung.
|
||||||
|
///
|
||||||
|
/// Runs the deterministic derivation DOWNWARD to the disc's terminal unit keys:
|
||||||
|
/// `DK → MK → VUK → UKs`, `PK → MK → VUK → UKs`, `MK → VUK → UKs`,
|
||||||
|
/// `VUK → UKs`, or `UK → itself`. Composes the module's own boil steps
|
||||||
|
/// ([`mk_from_pk`], [`vuk_from_mk`], [`uk_from_vuk`]) and parses
|
||||||
|
/// `Unit_Key_RO.inf` at the version the disc's MKB declares (48-byte stride for
|
||||||
|
/// AACS-1.0, 64 for AACS-2.x), so a multi-CPS disc yields all its unit keys from
|
||||||
|
/// the one candidate.
|
||||||
|
///
|
||||||
|
/// PURE DERIVATION: no sampling, no validation, no position recovery. Every step
|
||||||
|
/// is deterministic AES, so the returned keys are only as sound as the input
|
||||||
|
/// candidate — validate `unit_keys` against a real encrypted unit with
|
||||||
|
/// [`super::decrypt::unit_key_validates`] to prove the candidate opens the disc.
|
||||||
|
///
|
||||||
|
/// Returns `None` only when derivation itself cannot proceed: a PK its MKB
|
||||||
|
/// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs
|
||||||
|
/// one, or an unparseable/empty `Unit_Key_RO.inf`.
|
||||||
|
pub fn resolve_candidate(
|
||||||
|
candidate: &KeyCandidate,
|
||||||
|
mkb: &[u8],
|
||||||
|
unit_key_ro: &[u8],
|
||||||
|
vid: Option<Vid>,
|
||||||
|
) -> Option<ResolvedChain> {
|
||||||
|
use super::keys::{AacsVersion, derive_media_key_and_pk_from_dk, mkb_type, parse_unit_key_ro};
|
||||||
|
|
||||||
|
// Boil a VUK → all unit keys, each paired with its declared CPS-unit number.
|
||||||
|
// `.inf` parsing lives here: derive the stride version from the disc's own
|
||||||
|
// MKB, then defer the VUK→unit-keys step to the shared `derive_unit_keys`
|
||||||
|
// (the one place both resolvers and this path decrypt the title keys).
|
||||||
|
let boil = |vuk: Vuk| -> Option<Vec<(u32, [u8; 16])>> {
|
||||||
|
let version = mkb_type(mkb)
|
||||||
|
.map(|t| t.generation())
|
||||||
|
.unwrap_or(AacsVersion::V10);
|
||||||
|
let ukf = parse_unit_key_ro(unit_key_ro, version)?;
|
||||||
|
if ukf.encrypted_keys.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(super::keys::derive_unit_keys(&ukf, &vuk.0))
|
||||||
|
};
|
||||||
|
|
||||||
|
match candidate {
|
||||||
|
KeyCandidate::Uk(uk) => Some(ResolvedChain {
|
||||||
|
unit_keys: vec![(uk.idx, uk.key)],
|
||||||
|
vuk: None,
|
||||||
|
mk: None,
|
||||||
|
pk: None,
|
||||||
|
dk: None,
|
||||||
|
}),
|
||||||
|
KeyCandidate::Vuk(v) => Some(ResolvedChain {
|
||||||
|
unit_keys: boil(*v)?,
|
||||||
|
vuk: Some(*v),
|
||||||
|
mk: None,
|
||||||
|
pk: None,
|
||||||
|
dk: None,
|
||||||
|
}),
|
||||||
|
KeyCandidate::Mk(mk) => {
|
||||||
|
let vuk = vuk_from_mk(*mk, vid?);
|
||||||
|
Some(ResolvedChain {
|
||||||
|
unit_keys: boil(vuk)?,
|
||||||
|
vuk: Some(vuk),
|
||||||
|
mk: Some(*mk),
|
||||||
|
pk: None,
|
||||||
|
dk: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
KeyCandidate::Pk(pk) => {
|
||||||
|
let mk = mk_from_pk(std::slice::from_ref(&pk.0), mkb).ok()?;
|
||||||
|
let vuk = vuk_from_mk(mk, vid?);
|
||||||
|
Some(ResolvedChain {
|
||||||
|
unit_keys: boil(vuk)?,
|
||||||
|
vuk: Some(vuk),
|
||||||
|
mk: Some(mk),
|
||||||
|
pk: Some(*pk),
|
||||||
|
dk: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
KeyCandidate::Dk(dk) => {
|
||||||
|
let (km, pk) = derive_media_key_and_pk_from_dk(mkb, std::slice::from_ref(dk))?;
|
||||||
|
let mk = MediaKey(km);
|
||||||
|
let vuk = vuk_from_mk(mk, vid?);
|
||||||
|
Some(ResolvedChain {
|
||||||
|
unit_keys: boil(vuk)?,
|
||||||
|
vuk: Some(vuk),
|
||||||
|
mk: Some(mk),
|
||||||
|
pk: Some(ProcessingKey(pk)),
|
||||||
|
dk: Some(dk.clone()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)` when the MKB has no
|
||||||
|
/// processable Subset-Difference tables (empty MKB, or one with no
|
||||||
|
/// mk_dv/cvalues/subdiff records) — never a wrong key, never a panic.
|
||||||
|
#[test]
|
||||||
|
fn mk_from_dk_errors_on_unprocessable_mkb() {
|
||||||
|
let dk = DeviceKey {
|
||||||
|
key: [0x11; 16],
|
||||||
|
node: 1,
|
||||||
|
uv: 1,
|
||||||
|
u_mask_shift: 0,
|
||||||
|
};
|
||||||
|
// Empty MKB → no SD records to walk → Err.
|
||||||
|
let e = mk_from_dk(std::slice::from_ref(&dk), &[]);
|
||||||
|
assert!(matches!(e, Err(crate::error::Error::AacsMkUnavailable)));
|
||||||
|
|
||||||
|
// An MKB with no complete Subset-Difference tables (mk_dv / cvalues /
|
||||||
|
// subdiff) cannot yield a Media Key, so the real walk also errors —
|
||||||
|
// never silently yields a key.
|
||||||
|
let mut mkb: Vec<u8> = Vec::new();
|
||||||
|
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]); // stray data record only
|
||||||
|
mkb.extend_from_slice(&[0xAB; 16]);
|
||||||
|
let e2 = mk_from_dk(&[dk], &mkb);
|
||||||
|
assert!(matches!(e2, Err(crate::error::Error::AacsMkUnavailable)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a 4-byte MKB record header (type + 3-byte big-endian total length,
|
||||||
|
/// header included) and append `body`. Mirrors the MKB record framing the
|
||||||
|
/// parser expects; no crypto.
|
||||||
|
fn mkb_record(rec_type: u8, body: &[u8]) -> Vec<u8> {
|
||||||
|
let total = 4 + body.len();
|
||||||
|
let mut rec = Vec::with_capacity(total);
|
||||||
|
rec.push(rec_type);
|
||||||
|
rec.push(((total >> 16) & 0xFF) as u8);
|
||||||
|
rec.push(((total >> 8) & 0xFF) as u8);
|
||||||
|
rec.push((total & 0xFF) as u8);
|
||||||
|
rec.extend_from_slice(body);
|
||||||
|
rec
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `mk_from_pk` resolves a planted Processing Key against a synthetic MKB and
|
||||||
|
/// drives the FULL boil chain PK → MK → VUK → UK. The MKB is built with the
|
||||||
|
/// same (pk, cv, mk_dv, uv) construction the production SD walk validates, so
|
||||||
|
/// this proves a PK entry yields real Unit Keys — not just an `Ok`.
|
||||||
|
#[test]
|
||||||
|
fn mk_from_pk_drives_full_chain_to_uks() {
|
||||||
|
let pk: [u8; 16] = [
|
||||||
|
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||||
|
0xFF, 0x00,
|
||||||
|
];
|
||||||
|
let mk: [u8; 16] = [
|
||||||
|
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
||||||
|
0xAE, 0xAF,
|
||||||
|
];
|
||||||
|
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
|
||||||
|
|
||||||
|
// cv = AES-E(pk, mk_raw), where mk_raw is mk with the last-4-bytes-uv XOR
|
||||||
|
// pre-undone, so the validate step XORs uv back in and recovers mk.
|
||||||
|
let mut mk_raw = mk;
|
||||||
|
for a in 0..4 {
|
||||||
|
mk_raw[12 + a] ^= uv[a];
|
||||||
|
}
|
||||||
|
let cv = aes_ecb_encrypt(&pk, &mk_raw);
|
||||||
|
|
||||||
|
// mk_dv = AES-E(mk, magic||pad): AES-D(mk, mk_dv) starts with the AACS
|
||||||
|
// verify sentinel.
|
||||||
|
let mut vd = [0x11u8; 16];
|
||||||
|
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||||
|
let mk_dv = aes_ecb_encrypt(&mk, &vd);
|
||||||
|
|
||||||
|
// Synthetic MKB: type/version (0x10), verify record (0x86 = mk_dv),
|
||||||
|
// one-entry SD index (0x04 = [u_mask_shift=0][uv]), one-entry cvalue
|
||||||
|
// table (0x05 = cv).
|
||||||
|
let mut sd = vec![0u8];
|
||||||
|
sd.extend_from_slice(&uv);
|
||||||
|
let mut mkb = Vec::new();
|
||||||
|
mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
|
||||||
|
mkb.extend_from_slice(&mkb_record(0x86, &mk_dv));
|
||||||
|
mkb.extend_from_slice(&mkb_record(0x04, &sd));
|
||||||
|
mkb.extend_from_slice(&mkb_record(0x05, &cv));
|
||||||
|
|
||||||
|
// PK → MK.
|
||||||
|
let got_mk = mk_from_pk(std::slice::from_ref(&pk), &mkb).expect("planted PK resolves MK");
|
||||||
|
assert_eq!(got_mk, MediaKey(mk), "mk_from_pk recovers the planted MK");
|
||||||
|
|
||||||
|
// MK → VUK → UK over an encrypted title key.
|
||||||
|
let vid = Vid([0x42u8; 16]);
|
||||||
|
let plain_uk = [0x7Eu8; 16];
|
||||||
|
let vuk = vuk_from_mk(got_mk, vid);
|
||||||
|
let enc = aes_ecb_encrypt(&vuk.0, &plain_uk);
|
||||||
|
let uks = uk_from_vuk(vuk, std::slice::from_ref(&enc));
|
||||||
|
assert_eq!(uks.len(), 1);
|
||||||
|
assert_eq!(uks[0].key, plain_uk, "PK chain recovers the title key");
|
||||||
|
|
||||||
|
// A corrupt PK resolves nothing.
|
||||||
|
let mut bad = pk;
|
||||||
|
bad[0] ^= 0xFF;
|
||||||
|
assert!(matches!(
|
||||||
|
mk_from_pk(std::slice::from_ref(&bad), &mkb),
|
||||||
|
Err(crate::error::Error::AacsMkUnavailable)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal AACS-1.0 (48-byte stride) `Unit_Key_RO.inf` with `n` encrypted
|
||||||
|
/// unit keys — `parse_unit_key_ro` numbers CPS units 1..=n.
|
||||||
|
fn synth_inf(encs: &[[u8; 16]]) -> Vec<u8> {
|
||||||
|
let uk_pos = 32usize;
|
||||||
|
let stride = 48usize;
|
||||||
|
let n = encs.len();
|
||||||
|
let total = uk_pos + 48 + n.saturating_sub(1) * stride + 16;
|
||||||
|
let mut inf = vec![0u8; total.max(20)];
|
||||||
|
inf[..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
|
||||||
|
inf[uk_pos..uk_pos + 2].copy_from_slice(&(n as u16).to_be_bytes());
|
||||||
|
for (i, k) in encs.iter().enumerate() {
|
||||||
|
let o = uk_pos + 48 + i * stride;
|
||||||
|
inf[o..o + 16].copy_from_slice(k);
|
||||||
|
}
|
||||||
|
inf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A VUK candidate boils to ALL the disc's unit keys, each paired with its
|
||||||
|
/// declared CPS-unit number, and each key equals the VUK-decrypt of its slot.
|
||||||
|
#[test]
|
||||||
|
fn resolve_candidate_vuk_returns_all_cps_units() {
|
||||||
|
let vuk = Vuk([0x33u8; 16]);
|
||||||
|
let encs = [[0x11u8; 16], [0x22u8; 16], [0x44u8; 16]];
|
||||||
|
let inf = synth_inf(&encs);
|
||||||
|
let r = resolve_candidate(&KeyCandidate::Vuk(vuk), &[], &inf, None).expect("vuk derives");
|
||||||
|
let cps: Vec<u32> = r.unit_keys.iter().map(|(c, _)| *c).collect();
|
||||||
|
assert_eq!(
|
||||||
|
cps,
|
||||||
|
vec![1, 2, 3],
|
||||||
|
"every CPS unit surfaced, numbered from the inf"
|
||||||
|
);
|
||||||
|
for ((_, key), enc) in r.unit_keys.iter().zip(encs.iter()) {
|
||||||
|
assert_eq!(
|
||||||
|
*key,
|
||||||
|
decrypt_unit_key(&vuk.0, enc),
|
||||||
|
"key = VUK-decrypt of its slot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(r.vuk, Some(vuk));
|
||||||
|
assert!(r.mk.is_none() && r.pk.is_none() && r.dk.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bare UK candidate is terminal — it returns itself keyed by its own idx.
|
||||||
|
#[test]
|
||||||
|
fn resolve_candidate_uk_is_itself() {
|
||||||
|
let uk = UnitKey {
|
||||||
|
idx: 2,
|
||||||
|
key: [0x9u8; 16],
|
||||||
|
};
|
||||||
|
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None).expect("uk is terminal");
|
||||||
|
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
|
||||||
|
assert!(r.vuk.is_none() && r.mk.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MK/PK/DK paths derive the VUK from a VID; without one, derivation stops.
|
||||||
|
#[test]
|
||||||
|
fn resolve_candidate_mk_requires_vid() {
|
||||||
|
let r = resolve_candidate(&KeyCandidate::Mk(MediaKey([1u8; 16])), &[], &[], None);
|
||||||
|
assert!(r.is_none(), "MK path returns None without a VID");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1501
File diff suppressed because it is too large
Load Diff
@@ -1,789 +0,0 @@
|
|||||||
//! AACS bus authentication handshake — ECDH key agreement + bus key derivation.
|
|
||||||
//!
|
|
||||||
//! Implements the AACS SCSI authentication protocol to obtain:
|
|
||||||
//! - Volume ID (VID) — needed for VUK derivation
|
|
||||||
//! - Read Data Key — needed for AACS 2.0 (UHD) bus decryption
|
|
||||||
//!
|
|
||||||
//! Flow:
|
|
||||||
//! 1. Invalidate AGIDs → allocate fresh AGID
|
|
||||||
//! 2. Send host certificate + nonce
|
|
||||||
//! 3. Receive drive certificate + nonce
|
|
||||||
//! 4. Receive drive key point + signature, verify
|
|
||||||
//! 5. Sign host key point, send
|
|
||||||
//! 6. ECDH: host_priv × drive_key_point → bus key (low 128 bits of x)
|
|
||||||
//! 7. Read VID or Read Data Keys (encrypted with bus key)
|
|
||||||
//!
|
|
||||||
//! Supports:
|
|
||||||
//! - AACS 1.0: custom 160-bit curve, SHA-1, 20-byte keys
|
|
||||||
//! - AACS 2.0: drives accept AACS 1.0 host certs for backward compatibility
|
|
||||||
//! (full P-256/SHA-256 AACS 2.0 handshake prepared but rarely needed)
|
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
|
||||||
use crate::drive::DriveSession;
|
|
||||||
use crate::scsi::DataDirection;
|
|
||||||
use num_bigint::BigUint;
|
|
||||||
use num_traits::{One, Zero};
|
|
||||||
use sha1::{Sha1, Digest};
|
|
||||||
|
|
||||||
/// Execute a SCSI command that reads data from the device.
|
|
||||||
fn scsi_read(session: &mut DriveSession, cdb: &[u8], len: usize) -> Result<Vec<u8>> {
|
|
||||||
let mut buf = vec![0u8; len];
|
|
||||||
session.scsi_execute(cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
|
|
||||||
Ok(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute a SCSI command that writes data to the device.
|
|
||||||
fn scsi_write(session: &mut DriveSession, cdb: &[u8], data: &[u8]) -> Result<()> {
|
|
||||||
let mut buf = data.to_vec();
|
|
||||||
session.scsi_execute(cdb, DataDirection::ToDevice, &mut buf, 5_000)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── AACS 1.0 elliptic curve parameters (160-bit) ───────────────────────────
|
|
||||||
|
|
||||||
const EC_P: [u8; 20] = [
|
|
||||||
0x9D, 0xC9, 0xD8, 0x13, 0x55, 0xEC, 0xCE, 0xB5, 0x60, 0xBD,
|
|
||||||
0xB0, 0x9E, 0xF9, 0xEA, 0xE7, 0xC4, 0x79, 0xA7, 0xD7, 0xDF,
|
|
||||||
];
|
|
||||||
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,
|
|
||||||
];
|
|
||||||
const EC_N: [u8; 20] = [
|
|
||||||
0x9D, 0xC9, 0xD8, 0x13, 0x55, 0xEC, 0xCE, 0xB5, 0x60, 0xBD,
|
|
||||||
0xC4, 0x4F, 0x54, 0x81, 0x7B, 0x2C, 0x7F, 0x5A, 0xB0, 0x17,
|
|
||||||
];
|
|
||||||
const EC_GX: [u8; 20] = [
|
|
||||||
0x2E, 0x64, 0xFC, 0x22, 0x57, 0x83, 0x51, 0xE6, 0xF4, 0xCC,
|
|
||||||
0xA7, 0xEB, 0x81, 0xD0, 0xA4, 0xBD, 0xC5, 0x4C, 0xCE, 0xC6,
|
|
||||||
];
|
|
||||||
const EC_GY: [u8; 20] = [
|
|
||||||
0x09, 0x14, 0xA2, 0x5D, 0xD0, 0x54, 0x42, 0x88, 0x9D, 0xB4,
|
|
||||||
0x55, 0xC7, 0xF2, 0x3C, 0x9A, 0x07, 0x07, 0xF5, 0xCB, 0xB9,
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── AACS LA (Licensing Administrator) public key for cert verification ──────
|
|
||||||
|
|
||||||
const AACS_LA_PUB_X: [u8; 20] = [
|
|
||||||
0x01, 0xF3, 0x5D, 0xAB, 0xD8, 0xAE, 0x5F, 0x40, 0x56, 0x5E,
|
|
||||||
0x30, 0xC8, 0x8A, 0x60, 0x42, 0x82, 0x07, 0x61, 0xDF, 0x93,
|
|
||||||
];
|
|
||||||
const AACS_LA_PUB_Y: [u8; 20] = [
|
|
||||||
0x44, 0x87, 0xB5, 0xAC, 0x07, 0x10, 0x8D, 0x10, 0x5B, 0xA5,
|
|
||||||
0xB9, 0xE3, 0x2F, 0x3B, 0xBB, 0xFC, 0x0C, 0x2C, 0xBC, 0xD1,
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── Elliptic curve arithmetic over GF(p) ───────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct EcPoint {
|
|
||||||
x: BigUint,
|
|
||||||
y: BigUint,
|
|
||||||
infinity: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EcPoint {
|
|
||||||
fn infinity() -> Self {
|
|
||||||
EcPoint { x: BigUint::zero(), y: BigUint::zero(), infinity: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn new(x: BigUint, y: BigUint) -> Self {
|
|
||||||
EcPoint { x, y, infinity: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_bytes(x_bytes: &[u8], y_bytes: &[u8]) -> Self {
|
|
||||||
EcPoint::new(BigUint::from_bytes_be(x_bytes), BigUint::from_bytes_be(y_bytes))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Modular inverse using extended Euclidean algorithm.
|
|
||||||
fn mod_inv(a: &BigUint, m: &BigUint) -> Option<BigUint> {
|
|
||||||
use num_bigint::BigInt;
|
|
||||||
use num_traits::Signed;
|
|
||||||
|
|
||||||
let a = BigInt::from(a.clone());
|
|
||||||
let m = BigInt::from(m.clone());
|
|
||||||
|
|
||||||
let (mut old_r, mut r) = (a, m.clone());
|
|
||||||
let (mut old_s, mut s) = (BigInt::one(), BigInt::zero());
|
|
||||||
|
|
||||||
while !r.is_zero() {
|
|
||||||
let q = &old_r / &r;
|
|
||||||
let temp_r = r.clone();
|
|
||||||
r = old_r - &q * &r;
|
|
||||||
old_r = temp_r;
|
|
||||||
let temp_s = s.clone();
|
|
||||||
s = old_s - &q * &s;
|
|
||||||
old_s = temp_s;
|
|
||||||
}
|
|
||||||
|
|
||||||
if old_r != BigInt::one() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if old_s.is_negative() {
|
|
||||||
old_s += &m;
|
|
||||||
}
|
|
||||||
Some(old_s.to_biguint().unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// EC point addition on curve y² = x³ + ax + b (mod p).
|
|
||||||
fn ec_add(p1: &EcPoint, p2: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
|
|
||||||
if p1.infinity { return p2.clone(); }
|
|
||||||
if p2.infinity { return p1.clone(); }
|
|
||||||
|
|
||||||
if p1.x == p2.x {
|
|
||||||
if p1.y == p2.y && !p1.y.is_zero() {
|
|
||||||
return ec_double(p1, a, p);
|
|
||||||
}
|
|
||||||
return EcPoint::infinity();
|
|
||||||
}
|
|
||||||
|
|
||||||
// λ = (y2 - y1) / (x2 - x1) mod p
|
|
||||||
let dy = if p2.y >= p1.y {
|
|
||||||
(&p2.y - &p1.y) % p
|
|
||||||
} else {
|
|
||||||
(p - (&p1.y - &p2.y) % p) % p
|
|
||||||
};
|
|
||||||
let dx = if p2.x >= p1.x {
|
|
||||||
(&p2.x - &p1.x) % p
|
|
||||||
} else {
|
|
||||||
(p - (&p1.x - &p2.x) % p) % p
|
|
||||||
};
|
|
||||||
|
|
||||||
let dx_inv = mod_inv(&dx, p).unwrap();
|
|
||||||
let lam = (&dy * &dx_inv) % p;
|
|
||||||
|
|
||||||
// x3 = λ² - x1 - x2 mod p
|
|
||||||
let x3 = {
|
|
||||||
let lam2 = (&lam * &lam) % p;
|
|
||||||
let sum = (&p1.x + &p2.x) % p;
|
|
||||||
if lam2 >= sum {
|
|
||||||
(lam2 - sum) % p
|
|
||||||
} else {
|
|
||||||
(p - (sum - lam2) % p) % p
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// y3 = λ(x1 - x3) - y1 mod p
|
|
||||||
let y3 = {
|
|
||||||
let diff = if p1.x >= x3 {
|
|
||||||
(&p1.x - &x3) % p
|
|
||||||
} else {
|
|
||||||
(p - (&x3 - &p1.x) % p) % p
|
|
||||||
};
|
|
||||||
let prod = (&lam * &diff) % p;
|
|
||||||
if prod >= p1.y {
|
|
||||||
(prod - &p1.y) % p
|
|
||||||
} else {
|
|
||||||
(p - (&p1.y - prod) % p) % p
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
EcPoint::new(x3, y3)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// EC point doubling.
|
|
||||||
fn ec_double(pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
|
|
||||||
if pt.infinity || pt.y.is_zero() {
|
|
||||||
return EcPoint::infinity();
|
|
||||||
}
|
|
||||||
|
|
||||||
// λ = (3x² + a) / (2y) mod p
|
|
||||||
let three = BigUint::from(3u32);
|
|
||||||
let two = BigUint::from(2u32);
|
|
||||||
|
|
||||||
let numerator = (&three * &pt.x * &pt.x + a) % p;
|
|
||||||
let denominator = (&two * &pt.y) % p;
|
|
||||||
let denom_inv = mod_inv(&denominator, p).unwrap();
|
|
||||||
let lam = (&numerator * &denom_inv) % p;
|
|
||||||
|
|
||||||
// x3 = λ² - 2x mod p
|
|
||||||
let x3 = {
|
|
||||||
let lam2 = (&lam * &lam) % p;
|
|
||||||
let two_x = (&two * &pt.x) % p;
|
|
||||||
if lam2 >= two_x {
|
|
||||||
(lam2 - two_x) % p
|
|
||||||
} else {
|
|
||||||
(p - (two_x - lam2) % p) % p
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// y3 = λ(x - x3) - y mod p
|
|
||||||
let y3 = {
|
|
||||||
let diff = if pt.x >= x3 {
|
|
||||||
(&pt.x - &x3) % p
|
|
||||||
} else {
|
|
||||||
(p - (&x3 - &pt.x) % p) % p
|
|
||||||
};
|
|
||||||
let prod = (&lam * &diff) % p;
|
|
||||||
if prod >= pt.y {
|
|
||||||
(prod - &pt.y) % p
|
|
||||||
} else {
|
|
||||||
(p - (&pt.y - prod) % p) % p
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
EcPoint::new(x3, y3)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scalar multiplication using double-and-add.
|
|
||||||
fn ec_mul(k: &BigUint, pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
|
|
||||||
if k.is_zero() {
|
|
||||||
return EcPoint::infinity();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut result = EcPoint::infinity();
|
|
||||||
let mut base = pt.clone();
|
|
||||||
let mut scalar = k.clone();
|
|
||||||
|
|
||||||
while !scalar.is_zero() {
|
|
||||||
if scalar.bit(0) {
|
|
||||||
result = ec_add(&result, &base, a, p);
|
|
||||||
}
|
|
||||||
base = ec_double(&base, a, p);
|
|
||||||
scalar >>= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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();
|
|
||||||
if bytes.len() >= len {
|
|
||||||
bytes[bytes.len() - len..].to_vec()
|
|
||||||
} else {
|
|
||||||
let mut padded = vec![0u8; len - bytes.len()];
|
|
||||||
padded.extend_from_slice(&bytes);
|
|
||||||
padded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ECDSA ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// ECDSA sign: sign SHA-1(data) with private key on AACS curve.
|
|
||||||
/// Returns (r, s) each 20 bytes.
|
|
||||||
fn ecdsa_sign(priv_key: &[u8; 20], data: &[u8]) -> ([u8; 20], [u8; 20]) {
|
|
||||||
let p = BigUint::from_bytes_be(&EC_P);
|
|
||||||
let a = BigUint::from_bytes_be(&EC_A);
|
|
||||||
let n = BigUint::from_bytes_be(&EC_N);
|
|
||||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
|
||||||
let d = BigUint::from_bytes_be(priv_key);
|
|
||||||
|
|
||||||
// Hash the data
|
|
||||||
let hash = Sha1::digest(data);
|
|
||||||
let z = BigUint::from_bytes_be(&hash);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
// Generate random k
|
|
||||||
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() { continue; }
|
|
||||||
|
|
||||||
// R = k × G
|
|
||||||
let r_point = ec_mul(&k, &g, &a, &p);
|
|
||||||
let r = &r_point.x % &n;
|
|
||||||
if r.is_zero() { continue; }
|
|
||||||
|
|
||||||
// s = k⁻¹(z + r·d) mod n
|
|
||||||
let k_inv = match mod_inv(&k, &n) {
|
|
||||||
Some(v) => v,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
let s = (&k_inv * ((&z + &r * &d) % &n)) % &n;
|
|
||||||
if s.is_zero() { continue; }
|
|
||||||
|
|
||||||
let r_bytes = to_bytes_be_padded(&r, 20);
|
|
||||||
let s_bytes = to_bytes_be_padded(&s, 20);
|
|
||||||
|
|
||||||
let mut r_out = [0u8; 20];
|
|
||||||
let mut s_out = [0u8; 20];
|
|
||||||
r_out.copy_from_slice(&r_bytes);
|
|
||||||
s_out.copy_from_slice(&s_bytes);
|
|
||||||
|
|
||||||
return (r_out, s_out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ECDSA verify: verify signature (r, s) against SHA-1(data) using public key.
|
|
||||||
fn ecdsa_verify(pub_x: &[u8; 20], pub_y: &[u8; 20], sig_r: &[u8; 20], sig_s: &[u8; 20], data: &[u8]) -> bool {
|
|
||||||
let p = BigUint::from_bytes_be(&EC_P);
|
|
||||||
let a = BigUint::from_bytes_be(&EC_A);
|
|
||||||
let n = BigUint::from_bytes_be(&EC_N);
|
|
||||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
|
||||||
let q = EcPoint::from_bytes(pub_x, pub_y);
|
|
||||||
|
|
||||||
let r = BigUint::from_bytes_be(sig_r);
|
|
||||||
let s = BigUint::from_bytes_be(sig_s);
|
|
||||||
|
|
||||||
if r.is_zero() || r >= n || s.is_zero() || s >= n {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hash = Sha1::digest(data);
|
|
||||||
let z = BigUint::from_bytes_be(&hash);
|
|
||||||
|
|
||||||
let s_inv = match mod_inv(&s, &n) {
|
|
||||||
Some(v) => v,
|
|
||||||
None => return false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let u1 = (&z * &s_inv) % &n;
|
|
||||||
let u2 = (&r * &s_inv) % &n;
|
|
||||||
|
|
||||||
let p1 = ec_mul(&u1, &g, &a, &p);
|
|
||||||
let p2 = ec_mul(&u2, &q, &a, &p);
|
|
||||||
let r_point = ec_add(&p1, &p2, &a, &p);
|
|
||||||
|
|
||||||
if r_point.infinity {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
&r_point.x % &n == r
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── AACS certificate handling ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Verify an AACS certificate (92 bytes) against the AACS LA public key.
|
|
||||||
fn verify_cert(cert: &[u8]) -> bool {
|
|
||||||
if cert.len() < 92 { return false; }
|
|
||||||
// Certificate format: type(1) + flags(1) + padding(2) + serial(6) + pub_x(20) + pub_y(20) + sig_r(20) + sig_s(20)
|
|
||||||
// Signature is over the first 52 bytes
|
|
||||||
let mut sig_r = [0u8; 20];
|
|
||||||
let mut sig_s = [0u8; 20];
|
|
||||||
sig_r.copy_from_slice(&cert[52..72]);
|
|
||||||
sig_s.copy_from_slice(&cert[72..92]);
|
|
||||||
|
|
||||||
ecdsa_verify(&AACS_LA_PUB_X, &AACS_LA_PUB_Y, &sig_r, &sig_s, &cert[..52])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract public key from certificate.
|
|
||||||
fn cert_pub_key(cert: &[u8]) -> ([u8; 20], [u8; 20]) {
|
|
||||||
let mut x = [0u8; 20];
|
|
||||||
let mut y = [0u8; 20];
|
|
||||||
x.copy_from_slice(&cert[12..32]);
|
|
||||||
y.copy_from_slice(&cert[32..52]);
|
|
||||||
(x, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bus key derivation (ECDH) ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Compute bus key via ECDH: bus_key = low 128 bits of (host_priv × drive_key_point).x
|
|
||||||
fn compute_bus_key(host_priv: &[u8; 20], drive_key_point_x: &[u8; 20], drive_key_point_y: &[u8; 20]) -> [u8; 16] {
|
|
||||||
let p = BigUint::from_bytes_be(&EC_P);
|
|
||||||
let a = BigUint::from_bytes_be(&EC_A);
|
|
||||||
|
|
||||||
let d = BigUint::from_bytes_be(host_priv);
|
|
||||||
let dkp = EcPoint::from_bytes(drive_key_point_x, drive_key_point_y);
|
|
||||||
|
|
||||||
let shared = ec_mul(&d, &dkp, &a, &p);
|
|
||||||
|
|
||||||
// Bus key = lowest 128 bits (last 16 bytes) of x-coordinate
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate ephemeral host key pair: (private_key, public_point_x, public_point_y).
|
|
||||||
fn generate_host_key_pair() -> ([u8; 20], [u8; 20], [u8; 20]) {
|
|
||||||
let p_mod = BigUint::from_bytes_be(&EC_P);
|
|
||||||
let a = BigUint::from_bytes_be(&EC_A);
|
|
||||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
|
||||||
|
|
||||||
let mut priv_bytes = [0u8; 20];
|
|
||||||
use rand::RngCore;
|
|
||||||
rand::thread_rng().fill_bytes(&mut priv_bytes);
|
|
||||||
let d = BigUint::from_bytes_be(&priv_bytes);
|
|
||||||
|
|
||||||
let q = ec_mul(&d, &g, &a, &p_mod);
|
|
||||||
|
|
||||||
let qx = to_bytes_be_padded(&q.x, 20);
|
|
||||||
let qy = to_bytes_be_padded(&q.y, 20);
|
|
||||||
|
|
||||||
let mut pub_x = [0u8; 20];
|
|
||||||
let mut pub_y = [0u8; 20];
|
|
||||||
pub_x.copy_from_slice(&qx);
|
|
||||||
pub_y.copy_from_slice(&qy);
|
|
||||||
|
|
||||||
(priv_bytes, pub_x, pub_y)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── AES-CMAC (for MAC verification) ────────────────────────────────────────
|
|
||||||
|
|
||||||
/// AES-128-CMAC over 16 bytes of data.
|
|
||||||
fn aes_cmac_16(data: &[u8; 16], key: &[u8; 16]) -> [u8; 16] {
|
|
||||||
use aes::Aes128;
|
|
||||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
|
||||||
|
|
||||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
|
||||||
|
|
||||||
// For single-block CMAC:
|
|
||||||
// 1. Generate subkey K1
|
|
||||||
let mut l = GenericArray::clone_from_slice(&[0u8; 16]);
|
|
||||||
cipher.encrypt_block(&mut l);
|
|
||||||
|
|
||||||
let mut k1 = [0u8; 16];
|
|
||||||
let carry = (l[0] >> 7) & 1;
|
|
||||||
for i in 0..15 {
|
|
||||||
k1[i] = (l[i] << 1) | (l[i + 1] >> 7);
|
|
||||||
}
|
|
||||||
k1[15] = l[15] << 1;
|
|
||||||
if carry == 1 {
|
|
||||||
k1[15] ^= 0x87; // Rb for AES-128
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. XOR data with K1, encrypt
|
|
||||||
let mut block = [0u8; 16];
|
|
||||||
for i in 0..16 {
|
|
||||||
block[i] = data[i] ^ k1[i];
|
|
||||||
}
|
|
||||||
let mut ga = GenericArray::clone_from_slice(&block);
|
|
||||||
cipher.encrypt_block(&mut ga);
|
|
||||||
|
|
||||||
let mut mac = [0u8; 16];
|
|
||||||
mac.copy_from_slice(&ga);
|
|
||||||
mac
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SCSI command builders ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Build REPORT KEY CDB (0xA4).
|
|
||||||
fn cdb_report_key(agid: u8, format: u8, len: u16) -> [u8; 12] {
|
|
||||||
let mut cdb = [0u8; 12];
|
|
||||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
|
||||||
cdb[7] = crate::scsi::AACS_KEY_CLASS;
|
|
||||||
cdb[8] = (len >> 8) as u8;
|
|
||||||
cdb[9] = (len & 0xFF) as u8;
|
|
||||||
cdb[10] = (agid << 6) | (format & 0x3F);
|
|
||||||
cdb
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build SEND KEY CDB (0xA3).
|
|
||||||
fn cdb_send_key(agid: u8, format: u8, len: u16) -> [u8; 12] {
|
|
||||||
let mut cdb = [0u8; 12];
|
|
||||||
cdb[0] = crate::scsi::SCSI_SEND_KEY;
|
|
||||||
cdb[7] = crate::scsi::AACS_KEY_CLASS;
|
|
||||||
cdb[8] = (len >> 8) as u8;
|
|
||||||
cdb[9] = (len & 0xFF) as u8;
|
|
||||||
cdb[10] = (agid << 6) | (format & 0x3F);
|
|
||||||
cdb
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build REPORT DISC STRUCTURE CDB (0xAD).
|
|
||||||
fn cdb_report_disc_structure(agid: u8, format: u8, len: u16) -> [u8; 12] {
|
|
||||||
let mut cdb = [0u8; 12];
|
|
||||||
cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE;
|
|
||||||
cdb[1] = 0x01; // Blu-ray
|
|
||||||
cdb[7] = format;
|
|
||||||
cdb[8] = (len >> 8) as u8;
|
|
||||||
cdb[9] = (len & 0xFF) as u8;
|
|
||||||
cdb[10] = agid << 6;
|
|
||||||
cdb
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── High-level handshake ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Result of a successful AACS authentication handshake.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct AacsAuth {
|
|
||||||
/// Bus key (16 bytes) — derived from ECDH
|
|
||||||
pub bus_key: [u8; 16],
|
|
||||||
/// AGID used for this session
|
|
||||||
pub agid: u8,
|
|
||||||
/// Volume ID (16 bytes) — read after auth
|
|
||||||
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)
|
|
||||||
pub drive_cert: [u8; 92],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Perform the full AACS authentication handshake.
|
|
||||||
///
|
|
||||||
/// Requires a host private key (20 bytes) and host certificate (92 bytes)
|
|
||||||
/// from the KEYDB.cfg HC entry.
|
|
||||||
pub fn aacs_authenticate(
|
|
||||||
session: &mut DriveSession,
|
|
||||||
host_priv_key: &[u8; 20],
|
|
||||||
host_cert: &[u8],
|
|
||||||
) -> Result<AacsAuth> {
|
|
||||||
if host_cert.len() < 92 {
|
|
||||||
return Err(Error::AacsError { detail: "host certificate too short".into() });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Invalidate all AGIDs
|
|
||||||
for agid in 0..4u8 {
|
|
||||||
let cdb = cdb_report_key(agid, 0x3F, 2);
|
|
||||||
let _ = scsi_read(session, &cdb, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Allocate AGID
|
|
||||||
let cdb = cdb_report_key(0, 0x00, 8);
|
|
||||||
let response = scsi_read(session, &cdb, 8)
|
|
||||||
.map_err(|e| Error::AacsError { detail: format!("failed to allocate AGID: {}", e) })?;
|
|
||||||
let agid = (response[7] >> 6) & 0x03;
|
|
||||||
|
|
||||||
// Step 3: Generate host nonce and ephemeral key pair
|
|
||||||
let mut host_nonce = [0u8; 20];
|
|
||||||
use rand::RngCore;
|
|
||||||
rand::thread_rng().fill_bytes(&mut host_nonce);
|
|
||||||
let (host_key, host_key_point_x, host_key_point_y) = generate_host_key_pair();
|
|
||||||
|
|
||||||
// Step 4: Send host certificate + nonce (SEND KEY format 0x01)
|
|
||||||
let mut send_buf = [0u8; 116];
|
|
||||||
send_buf[1] = 0x72; // data length
|
|
||||||
send_buf[4..24].copy_from_slice(&host_nonce);
|
|
||||||
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::AacsError { detail: "drive rejected host certificate".into() })?;
|
|
||||||
|
|
||||||
// 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::AacsError { detail: "failed to read drive certificate".into() })?;
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
// Detect AACS 2.0 drive certificate (type 0x11)
|
|
||||||
// AACS 2.0 drives use P-256/SHA-256 natively but accept AACS 1.0 host certs
|
|
||||||
// for backward compatibility. We proceed with AACS 1.0 handshake.
|
|
||||||
if drive_cert[0] == 0x11 {
|
|
||||||
// AACS 2.0 drive detected — falling back to AACS 1.0 handshake
|
|
||||||
// (full P-256 AACS 2.0 handshake not yet implemented)
|
|
||||||
// The drive should still accept our AACS 1.0 host certificate.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify drive certificate (AACS 1.0 LA signature)
|
|
||||||
if drive_cert[0] == 0x01 && !verify_cert(&drive_cert) {
|
|
||||||
return Err(Error::AacsError { detail: "drive certificate verification failed".into() });
|
|
||||||
}
|
|
||||||
// Skip verification for AACS 2.0 certs (different LA key, P-256 curve)
|
|
||||||
|
|
||||||
// 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::AacsError { detail: "failed to read drive key".into() })?;
|
|
||||||
|
|
||||||
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)
|
|
||||||
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);
|
|
||||||
verify_data[20..60].copy_from_slice(&drive_key_point);
|
|
||||||
|
|
||||||
let mut sig_r = [0u8; 20];
|
|
||||||
let mut sig_s = [0u8; 20];
|
|
||||||
sig_r.copy_from_slice(&drive_key_sig[..20]);
|
|
||||||
sig_s.copy_from_slice(&drive_key_sig[20..40]);
|
|
||||||
|
|
||||||
if !ecdsa_verify(&drive_pub_x, &drive_pub_y, &sig_r, &sig_s, &verify_data) {
|
|
||||||
return Err(Error::AacsError { detail: "drive key signature verification failed".into() });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 7: Sign host key point (ECDSA over drive_nonce || host_key_point)
|
|
||||||
let mut sign_data = [0u8; 60];
|
|
||||||
sign_data[..20].copy_from_slice(&drive_nonce);
|
|
||||||
sign_data[20..40].copy_from_slice(&host_key_point_x);
|
|
||||||
sign_data[40..60].copy_from_slice(&host_key_point_y);
|
|
||||||
|
|
||||||
let (host_sig_r, host_sig_s) = ecdsa_sign(host_priv_key, &sign_data);
|
|
||||||
|
|
||||||
// Step 8: Send host key point + signature (SEND KEY format 0x02)
|
|
||||||
let mut send_buf = [0u8; 84];
|
|
||||||
send_buf[1] = 0x52;
|
|
||||||
send_buf[4..24].copy_from_slice(&host_key_point_x);
|
|
||||||
send_buf[24..44].copy_from_slice(&host_key_point_y);
|
|
||||||
send_buf[44..64].copy_from_slice(&host_sig_r);
|
|
||||||
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::AacsError { detail: "drive rejected host key".into() })?;
|
|
||||||
|
|
||||||
// Step 9: Compute bus key via ECDH
|
|
||||||
let mut dkp_x = [0u8; 20];
|
|
||||||
let mut dkp_y = [0u8; 20];
|
|
||||||
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);
|
|
||||||
|
|
||||||
Ok(AacsAuth {
|
|
||||||
bus_key,
|
|
||||||
agid,
|
|
||||||
volume_id: None,
|
|
||||||
read_data_key: None,
|
|
||||||
drive_cert,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read Volume ID after successful authentication.
|
|
||||||
pub fn read_volume_id(session: &mut DriveSession, 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::AacsError { detail: "failed to read Volume ID".into() })?;
|
|
||||||
|
|
||||||
let mut vid = [0u8; 16];
|
|
||||||
let mut mac = [0u8; 16];
|
|
||||||
vid.copy_from_slice(&response[4..20]);
|
|
||||||
mac.copy_from_slice(&response[20..36]);
|
|
||||||
|
|
||||||
// Verify MAC: AES-CMAC(VID, bus_key) should equal mac
|
|
||||||
let calc_mac = aes_cmac_16(&vid, &auth.bus_key);
|
|
||||||
if calc_mac != mac {
|
|
||||||
return Err(Error::AacsError { detail: "VID MAC verification failed".into() });
|
|
||||||
}
|
|
||||||
|
|
||||||
auth.volume_id = Some(vid);
|
|
||||||
Ok(vid)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read data keys after successful authentication (for AACS 2.0 bus encryption).
|
|
||||||
pub fn read_data_keys(session: &mut DriveSession, 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::AacsError { detail: "failed to read data keys".into() })?;
|
|
||||||
|
|
||||||
let mut enc_rdk = [0u8; 16];
|
|
||||||
let mut enc_wdk = [0u8; 16];
|
|
||||||
enc_rdk.copy_from_slice(&response[4..20]);
|
|
||||||
enc_wdk.copy_from_slice(&response[20..36]);
|
|
||||||
|
|
||||||
// Decrypt with bus key (AES-ECB)
|
|
||||||
let read_data_key = super::aes_ecb_decrypt(&auth.bus_key, &enc_rdk);
|
|
||||||
let write_data_key = super::aes_ecb_decrypt(&auth.bus_key, &enc_wdk);
|
|
||||||
|
|
||||||
auth.read_data_key = Some(read_data_key);
|
|
||||||
Ok((read_data_key, write_data_key))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ec_curve_generator_on_curve() {
|
|
||||||
// Verify G is on the curve: y² = x³ + ax + b (mod p)
|
|
||||||
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 gx = BigUint::from_bytes_be(&EC_GX);
|
|
||||||
let gy = BigUint::from_bytes_be(&EC_GY);
|
|
||||||
|
|
||||||
let lhs = (&gy * &gy) % &p;
|
|
||||||
let rhs = (&gx * &gx * &gx + &a * &gx + &b) % &p;
|
|
||||||
assert_eq!(lhs, rhs, "Generator point is not on the curve");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ec_mul_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);
|
|
||||||
|
|
||||||
// 1 × G = G
|
|
||||||
let result = ec_mul(&BigUint::one(), &g, &a, &p);
|
|
||||||
assert_eq!(result.x, g.x);
|
|
||||||
assert_eq!(result.y, g.y);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ec_mul_order() {
|
|
||||||
// n × G = O (point at infinity)
|
|
||||||
let p = BigUint::from_bytes_be(&EC_P);
|
|
||||||
let a = BigUint::from_bytes_be(&EC_A);
|
|
||||||
let n = BigUint::from_bytes_be(&EC_N);
|
|
||||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
|
||||||
|
|
||||||
let result = ec_mul(&n, &g, &a, &p);
|
|
||||||
assert!(result.infinity, "n × G should be point at infinity");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ecdsa_sign_verify() {
|
|
||||||
// Generate a key pair and test sign/verify
|
|
||||||
let (priv_key, pub_x, pub_y) = generate_host_key_pair();
|
|
||||||
let data = b"test data for AACS ECDSA";
|
|
||||||
|
|
||||||
let (sig_r, sig_s) = ecdsa_sign(&priv_key, data);
|
|
||||||
assert!(ecdsa_verify(&pub_x, &pub_y, &sig_r, &sig_s, data),
|
|
||||||
"ECDSA signature should verify");
|
|
||||||
|
|
||||||
// Verify with wrong data fails
|
|
||||||
assert!(!ecdsa_verify(&pub_x, &pub_y, &sig_r, &sig_s, b"wrong data"),
|
|
||||||
"ECDSA should fail with wrong data");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ecdh_shared_secret() {
|
|
||||||
// Two parties should derive the same shared point
|
|
||||||
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 (priv_a, pub_ax, pub_ay) = generate_host_key_pair();
|
|
||||||
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);
|
|
||||||
// B computes: priv_b × pub_A
|
|
||||||
let shared_b = compute_bus_key(&priv_b, &pub_ax, &pub_ay);
|
|
||||||
|
|
||||||
assert_eq!(shared_a, shared_b, "ECDH shared secrets should match");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_aes_cmac() {
|
|
||||||
// Basic CMAC test — at minimum verify it produces consistent output
|
|
||||||
let key = [0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6,
|
|
||||||
0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c];
|
|
||||||
let data = [0u8; 16];
|
|
||||||
let mac1 = aes_cmac_16(&data, &key);
|
|
||||||
let mac2 = aes_cmac_16(&data, &key);
|
|
||||||
assert_eq!(mac1, mac2);
|
|
||||||
assert_ne!(mac1, [0u8; 16]); // shouldn't be all zeros
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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; }
|
|
||||||
|
|
||||||
let db = crate::aacs::KeyDb::load(&keydb_path).unwrap();
|
|
||||||
if let Some(hc) = &db.host_cert {
|
|
||||||
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
|
|
||||||
if !valid {
|
|
||||||
eprintln!(" (cert may use different LA key or format)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//! Host-certificate collection — the one libfreemkv-side concern left from the
|
||||||
|
//! old in-tree AACS handshake. The cert mutual-auth itself now lives in the
|
||||||
|
//! `freemkv-unlock` AACS unlocker; libfreemkv only gathers the certs (a
|
||||||
|
//! keysource concern) and hands them across the seam.
|
||||||
|
|
||||||
|
/// Union the host certificates a scan can offer the drive: the explicit
|
||||||
|
/// `DriveCredentials`, then each key source's `host_certs(mkb)`. Host certs are
|
||||||
|
/// keysource-served, never compiled in. `mkb` lets a source pick a
|
||||||
|
/// generation-appropriate cert (the default impl ignores it).
|
||||||
|
pub fn collect_host_certs(
|
||||||
|
opts: &crate::disc::ScanOptions,
|
||||||
|
mkb: Option<u32>,
|
||||||
|
) -> Vec<crate::aacs::HostCert> {
|
||||||
|
let mut host_certs: Vec<crate::aacs::HostCert> = Vec::new();
|
||||||
|
if let Some(c) = &opts.credentials {
|
||||||
|
host_certs.extend(c.host_certs.iter().cloned());
|
||||||
|
}
|
||||||
|
for src in &opts.key_sources {
|
||||||
|
host_certs.extend(src.host_certs(mkb));
|
||||||
|
}
|
||||||
|
host_certs
|
||||||
|
}
|
||||||
+3126
File diff suppressed because it is too large
Load Diff
+85
-1409
File diff suppressed because it is too large
Load Diff
@@ -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, product-neutral identifier), 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])>,
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,105 +0,0 @@
|
|||||||
//! aacs-test — Test AACS handshake against a real drive.
|
|
||||||
//!
|
|
||||||
//! Usage: aacs-test /dev/sr0 /path/to/keydb.cfg
|
|
||||||
|
|
||||||
use std::env;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let args: Vec<String> = env::args().collect();
|
|
||||||
if args.len() < 3 {
|
|
||||||
eprintln!("Usage: aacs-test <device> <keydb_path>");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let device = Path::new(&args[1]);
|
|
||||||
let keydb_path = Path::new(&args[2]);
|
|
||||||
|
|
||||||
println!("aacs-test v{}", env!("CARGO_PKG_VERSION"));
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Open drive WITHOUT unlock — AACS auth must happen before raw mode
|
|
||||||
print!("Opening {} (no unlock)... ", device.display());
|
|
||||||
let mut session = match libfreemkv::DriveSession::open_no_unlock(device) {
|
|
||||||
Ok(s) => { println!("OK"); s }
|
|
||||||
Err(e) => { println!("FAILED: {}", e); std::process::exit(1); }
|
|
||||||
};
|
|
||||||
println!(" Drive: {} {}", session.profile.drive_id.trim(), session.profile.chipset.name());
|
|
||||||
|
|
||||||
// Load KEYDB
|
|
||||||
print!("Loading KEYDB... ");
|
|
||||||
let keydb = match libfreemkv::aacs::KeyDb::load(keydb_path) {
|
|
||||||
Ok(db) => {
|
|
||||||
println!("OK ({} disc entries, {} DK, {} PK)",
|
|
||||||
db.disc_entries.len(), db.device_keys.len(), db.processing_keys.len());
|
|
||||||
db
|
|
||||||
}
|
|
||||||
Err(e) => { println!("FAILED: {}", e); std::process::exit(1); }
|
|
||||||
};
|
|
||||||
|
|
||||||
let host_cert = match &keydb.host_cert {
|
|
||||||
Some(hc) => {
|
|
||||||
println!(" Host cert: {} bytes, priv_key[0]=0x{:02x}",
|
|
||||||
hc.certificate.len(), hc.private_key[0]);
|
|
||||||
hc
|
|
||||||
}
|
|
||||||
None => { println!(" No host cert in KEYDB"); std::process::exit(1); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// AACS handshake
|
|
||||||
println!();
|
|
||||||
print!("AACS authenticate... ");
|
|
||||||
let mut auth = match libfreemkv::aacs::handshake::aacs_authenticate(
|
|
||||||
&mut session,
|
|
||||||
&host_cert.private_key,
|
|
||||||
&host_cert.certificate,
|
|
||||||
) {
|
|
||||||
Ok(a) => {
|
|
||||||
println!("OK");
|
|
||||||
println!(" Bus key: {:02x?}", &a.bus_key);
|
|
||||||
println!(" AGID: {}", a.agid);
|
|
||||||
println!(" Drive cert type: 0x{:02x}", a.drive_cert[0]);
|
|
||||||
a
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
println!("FAILED: {}", e);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Read Volume ID
|
|
||||||
print!("Reading Volume ID... ");
|
|
||||||
match libfreemkv::aacs::handshake::read_volume_id(&mut session, &mut auth) {
|
|
||||||
Ok(vid) => {
|
|
||||||
println!("OK");
|
|
||||||
println!(" VID: {:02x?}", vid);
|
|
||||||
|
|
||||||
// Try to find matching disc in KEYDB
|
|
||||||
let matched = keydb.disc_entries.values()
|
|
||||||
.find(|e| e.disc_id == Some(vid));
|
|
||||||
if let Some(entry) = matched {
|
|
||||||
println!(" KEYDB match: {} (hash {})", entry.title, entry.disc_hash);
|
|
||||||
if let Some(vuk) = entry.vuk {
|
|
||||||
println!(" VUK: {:02x?}", vuk);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
println!(" No exact VID match in KEYDB");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => println!("FAILED: {}", e),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read data keys (AACS 2.0)
|
|
||||||
print!("Reading data keys... ");
|
|
||||||
match libfreemkv::aacs::handshake::read_data_keys(&mut session, &mut auth) {
|
|
||||||
Ok((rdk, wdk)) => {
|
|
||||||
println!("OK (AACS 2.0 bus encryption)");
|
|
||||||
println!(" Read data key: {:02x?}", rdk);
|
|
||||||
println!(" Write data key: {:02x?}", wdk);
|
|
||||||
}
|
|
||||||
Err(e) => println!("not available: {} (likely AACS 1.0)", e),
|
|
||||||
}
|
|
||||||
|
|
||||||
println!();
|
|
||||||
println!("Done.");
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
//! freemkv-info — Drive identification and compatibility checker.
|
|
||||||
//!
|
|
||||||
//! Sends standard SCSI INQUIRY and GET CONFIGURATION commands to an optical drive,
|
|
||||||
//! displays drive identity and compatibility status, and optionally outputs raw
|
|
||||||
//! response data for profile contribution.
|
|
||||||
//!
|
|
||||||
//! Usage:
|
|
||||||
//! freemkv-info /dev/sr0
|
|
||||||
//! freemkv-info /dev/sr0 --raw
|
|
||||||
//! freemkv-info /dev/sr0 --json
|
|
||||||
|
|
||||||
use std::env;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::process;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let args: Vec<String> = env::args().collect();
|
|
||||||
|
|
||||||
if args.len() < 2 {
|
|
||||||
eprintln!("freemkv-info — Drive identification and compatibility checker");
|
|
||||||
eprintln!();
|
|
||||||
eprintln!("Usage: freemkv-info <device> [options]");
|
|
||||||
eprintln!();
|
|
||||||
eprintln!(" <device> Optical drive device (e.g. /dev/sr0)");
|
|
||||||
eprintln!(" --raw Output raw SCSI response hex (for profile contribution)");
|
|
||||||
eprintln!(" --json Output machine-readable JSON");
|
|
||||||
eprintln!(" --profiles Path to profiles directory (default: ./profiles)");
|
|
||||||
eprintln!();
|
|
||||||
eprintln!("Examples:");
|
|
||||||
eprintln!(" freemkv-info /dev/sr0");
|
|
||||||
eprintln!(" freemkv-info /dev/sr0 --raw > my_drive.txt");
|
|
||||||
process::exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let device = Path::new(&args[1]);
|
|
||||||
let raw_mode = args.iter().any(|a| a == "--raw");
|
|
||||||
let json_mode = args.iter().any(|a| a == "--json");
|
|
||||||
let profiles_dir = args.iter()
|
|
||||||
.position(|a| a == "--profiles")
|
|
||||||
.and_then(|i| args.get(i + 1))
|
|
||||||
.map(|s| s.as_str())
|
|
||||||
.unwrap_or("profiles");
|
|
||||||
|
|
||||||
// Open SCSI transport
|
|
||||||
let mut transport = match libfreemkv::scsi::open(device) {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Error: Cannot open {}: {}", device.display(), e);
|
|
||||||
process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// INQUIRY
|
|
||||||
let inquiry = match libfreemkv::scsi::inquiry(transport.as_mut()) {
|
|
||||||
Ok(i) => i,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Error: INQUIRY failed: {}", e);
|
|
||||||
process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// GET CONFIGURATION feature 0x010C
|
|
||||||
let gc_010c = libfreemkv::scsi::get_config_010c(transport.as_mut()).ok();
|
|
||||||
|
|
||||||
if json_mode {
|
|
||||||
print_json(&inquiry, &gc_010c);
|
|
||||||
} else if raw_mode {
|
|
||||||
print_raw(&inquiry, &gc_010c);
|
|
||||||
} else {
|
|
||||||
print_human(&inquiry, &gc_010c, profiles_dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_human(
|
|
||||||
inquiry: &libfreemkv::scsi::InquiryResult,
|
|
||||||
gc_010c: &Option<Vec<u8>>,
|
|
||||||
profiles_dir: &str,
|
|
||||||
) {
|
|
||||||
println!("freemkv-info v{}", env!("CARGO_PKG_VERSION"));
|
|
||||||
println!();
|
|
||||||
println!("Drive: {} {} {}", inquiry.vendor_id, inquiry.model, inquiry.firmware);
|
|
||||||
println!("INQUIRY: additional_length=0x{:02X} ({})",
|
|
||||||
inquiry.raw.get(4).unwrap_or(&0),
|
|
||||||
inquiry.raw.get(4).unwrap_or(&0));
|
|
||||||
|
|
||||||
if let Some(gc) = gc_010c {
|
|
||||||
let data_hex: String = gc.iter().map(|b| format!("{:02x}", b)).collect();
|
|
||||||
println!("Feature 0x010C: {}", data_hex);
|
|
||||||
} else {
|
|
||||||
println!("Feature 0x010C: not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to match profile
|
|
||||||
if let Ok(profiles) = libfreemkv::profile::load_all(Path::new(profiles_dir)) {
|
|
||||||
let matched = profiles.iter().find(|p| {
|
|
||||||
p.drive_id.contains(&inquiry.vendor_id)
|
|
||||||
&& p.drive_id.contains(&inquiry.model)
|
|
||||||
});
|
|
||||||
|
|
||||||
println!();
|
|
||||||
match matched {
|
|
||||||
Some(p) => {
|
|
||||||
println!("Profile: FOUND ({})", p.chipset.name());
|
|
||||||
println!("Raw Read: Supported");
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
println!("Profile: NOT FOUND");
|
|
||||||
println!("Raw Read: Unknown — run with --raw and submit a profile request");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
println!();
|
|
||||||
println!("Profile: No profiles directory found at '{}'", profiles_dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_raw(
|
|
||||||
inquiry: &libfreemkv::scsi::InquiryResult,
|
|
||||||
gc_010c: &Option<Vec<u8>>,
|
|
||||||
) {
|
|
||||||
println!("# freemkv-info raw output");
|
|
||||||
println!("# Submit this file to https://github.com/freemkv/libfreemkv/issues");
|
|
||||||
println!();
|
|
||||||
println!("vendor: {}", inquiry.vendor_id);
|
|
||||||
println!("model: {}", inquiry.model);
|
|
||||||
println!("firmware: {}", inquiry.firmware);
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Full INQUIRY hex
|
|
||||||
println!("inquiry_hex: {}", hex_encode(&inquiry.raw));
|
|
||||||
println!("inquiry_length: {}", inquiry.raw.len());
|
|
||||||
|
|
||||||
// GET CONFIG 0x010C
|
|
||||||
if let Some(gc) = gc_010c {
|
|
||||||
println!("get_config_010c_hex: {}", hex_encode(gc));
|
|
||||||
println!("get_config_010c_length: {}", gc.len());
|
|
||||||
} else {
|
|
||||||
println!("get_config_010c_hex: ERROR");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_json(
|
|
||||||
inquiry: &libfreemkv::scsi::InquiryResult,
|
|
||||||
gc_010c: &Option<Vec<u8>>,
|
|
||||||
) {
|
|
||||||
let json = serde_json::json!({
|
|
||||||
"vendor": inquiry.vendor_id,
|
|
||||||
"model": inquiry.model,
|
|
||||||
"firmware": inquiry.firmware,
|
|
||||||
"inquiry_hex": hex_encode(&inquiry.raw),
|
|
||||||
"inquiry_length": inquiry.raw.len(),
|
|
||||||
"get_config_010c_hex": gc_010c.as_ref().map(|g| hex_encode(g)),
|
|
||||||
});
|
|
||||||
println!("{}", serde_json::to_string_pretty(&json).unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hex_encode(data: &[u8]) -> String {
|
|
||||||
data.iter().map(|b| format!("{:02x}", b)).collect()
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
//! freemkv-test — Quick verification that raw disc access works.
|
|
||||||
//!
|
|
||||||
//! Enables raw read mode, calibrates speed, reads a few test sectors.
|
|
||||||
//! Use this to verify your drive and profile are working correctly.
|
|
||||||
//!
|
|
||||||
//! Usage:
|
|
||||||
//! freemkv-test /dev/sr0
|
|
||||||
//! freemkv-test /dev/sr0 --profiles ./profiles
|
|
||||||
|
|
||||||
use std::env;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::process;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let args: Vec<String> = env::args().collect();
|
|
||||||
|
|
||||||
if args.len() < 2 {
|
|
||||||
eprintln!("freemkv-test — Verify raw disc access works");
|
|
||||||
eprintln!();
|
|
||||||
eprintln!("Usage: freemkv-test <device> [--profiles <dir>]");
|
|
||||||
process::exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let device = Path::new(&args[1]);
|
|
||||||
|
|
||||||
println!("freemkv-test v{}", env!("CARGO_PKG_VERSION"));
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Open drive session (uses bundled profiles)
|
|
||||||
print!("Opening {}... ", device.display());
|
|
||||||
let mut session = match libfreemkv::DriveSession::open(device) {
|
|
||||||
Ok(s) => { println!("OK"); s }
|
|
||||||
Err(e) => { println!("FAILED: {}", e); process::exit(1); }
|
|
||||||
};
|
|
||||||
|
|
||||||
println!(" Drive ID: {}", session.profile.drive_id);
|
|
||||||
println!(" Chipset: {}", session.profile.chipset.name());
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Enable raw read mode
|
|
||||||
print!("Unlocking drive... ");
|
|
||||||
match session.unlock() {
|
|
||||||
Ok(()) => println!("OK"),
|
|
||||||
Err(e) => { println!("FAILED: {}", e); process::exit(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check status
|
|
||||||
print!("Checking status... ");
|
|
||||||
match session.status() {
|
|
||||||
Ok(status) => {
|
|
||||||
if status.unlocked {
|
|
||||||
println!("OK (active)");
|
|
||||||
} else {
|
|
||||||
println!("WARNING: drive reported as locked");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => println!("SKIP ({})", e),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calibrate speed
|
|
||||||
print!("Calibrating speed... ");
|
|
||||||
match session.calibrate() {
|
|
||||||
Ok(()) => println!("OK"),
|
|
||||||
Err(e) => println!("SKIP ({})", e),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read test sectors
|
|
||||||
let test_lbas: &[u32] = &[0, 100, 1000, 10000];
|
|
||||||
let mut buf = vec![0u8; 2048];
|
|
||||||
let mut pass = 0;
|
|
||||||
let mut fail = 0;
|
|
||||||
|
|
||||||
for &lba in test_lbas {
|
|
||||||
print!("Reading sector {}... ", lba);
|
|
||||||
match session.read_sectors(lba, 1, &mut buf) {
|
|
||||||
Ok(n) if n == 2048 => {
|
|
||||||
let nonzero = buf.iter().filter(|&&b| b != 0).count();
|
|
||||||
println!("OK ({} bytes, {} non-zero)", n, nonzero);
|
|
||||||
pass += 1;
|
|
||||||
}
|
|
||||||
Ok(n) => {
|
|
||||||
println!("PARTIAL ({} bytes)", n);
|
|
||||||
fail += 1;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
println!("FAILED: {}", e);
|
|
||||||
fail += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
println!();
|
|
||||||
if fail == 0 {
|
|
||||||
println!("All {} checks passed. Drive is fully functional.", pass);
|
|
||||||
} else {
|
|
||||||
println!("{} passed, {} failed.", pass, fail);
|
|
||||||
process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1068
-37
File diff suppressed because it is too large
Load Diff
+125
@@ -0,0 +1,125 @@
|
|||||||
|
//! 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.
|
||||||
|
///
|
||||||
|
/// `usize` because its dominant use is buffer sizing and slice indexing, where
|
||||||
|
/// Rust *requires* `usize` (`vec![0u8; SECTOR_BYTES]`, `buf.len() < SECTOR_BYTES`).
|
||||||
|
/// For byte-offset / capacity arithmetic — which is `u64` because a disc can
|
||||||
|
/// exceed 4 GiB — use [`SECTOR_BYTES_U64`] instead of casting at each site.
|
||||||
|
pub const SECTOR_BYTES: usize = 2048;
|
||||||
|
|
||||||
|
/// [`SECTOR_BYTES`] as `u64`, for byte-offset and capacity arithmetic. The
|
||||||
|
/// single `usize → u64` boundary cast lives here, once, so offset math across
|
||||||
|
/// the workspace reads as `sectors * SECTOR_BYTES_U64` with no per-site cast.
|
||||||
|
pub const SECTOR_BYTES_U64: u64 = SECTOR_BYTES as u64;
|
||||||
|
|
||||||
|
/// Milliseconds per second. For turning a byte count ÷ bytes-per-second into a
|
||||||
|
/// movie-time figure (`bytes / bps * MILLIS_PER_SEC`) without a bare `1000.0`.
|
||||||
|
pub const MILLIS_PER_SEC: f64 = 1_000.0;
|
||||||
|
|
||||||
|
/// Bytes per MPEG-2 transport-stream packet. Common to all MPEG-TS, not just
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
/// Elementary-stream coding-type codes — the single source of truth for the
|
||||||
|
/// byte that identifies a stream's codec.
|
||||||
|
///
|
||||||
|
/// This is one registry used in two places that share the same value space:
|
||||||
|
/// the MPEG-TS PMT `stream_type` (ISO/IEC 13818-1 Table 2-34) and the Blu-ray
|
||||||
|
/// STN/CLPI `stream_coding_type` (BD-ROM Part 3). The standardized video codes
|
||||||
|
/// (`0x02`, `0x1B`, `0x24`) are ISO assignments (ISO/IEC 13818-1 Table 2-34);
|
||||||
|
/// `0xEA` (VC-1) is a BD-ROM convention in the ISO user-private range. The
|
||||||
|
/// `0x80..=0xA2` audio/graphics codes also sit in the user-private range and follow the
|
||||||
|
/// Blu-ray Disc Association / ATSC A/52 convention. Because every consumer
|
||||||
|
/// reads or writes this single byte, the family is unprefixed — the scope is
|
||||||
|
/// "any elementary stream freemkv parses or muxes".
|
||||||
|
///
|
||||||
|
/// Each constant is `u8`: the spec defines an 8-bit field and the code compares
|
||||||
|
/// it directly against a byte read from the buffer, so no casts are needed.
|
||||||
|
pub mod coding_type {
|
||||||
|
/// MPEG-2 video (ISO/IEC 13818-1 Table 2-34).
|
||||||
|
pub const MPEG2_VIDEO: u8 = 0x02;
|
||||||
|
/// H.264 / AVC video (ISO/IEC 13818-1 Table 2-34).
|
||||||
|
pub const H264: u8 = 0x1B;
|
||||||
|
/// HEVC / H.265 video (ISO/IEC 13818-1 Table 2-34, 2015 amendment).
|
||||||
|
pub const HEVC: u8 = 0x24;
|
||||||
|
/// SMPTE VC-1 video (BD-ROM convention, ISO user-private range).
|
||||||
|
pub const VC1: u8 = 0xEA;
|
||||||
|
|
||||||
|
/// LPCM audio (BD-ROM convention).
|
||||||
|
pub const LPCM: u8 = 0x80;
|
||||||
|
/// Dolby Digital (AC-3) audio (BD-ROM / ATSC A/52 convention).
|
||||||
|
pub const AC3: u8 = 0x81;
|
||||||
|
/// DTS audio (BD-ROM convention).
|
||||||
|
pub const DTS: u8 = 0x82;
|
||||||
|
/// Dolby TrueHD audio (BD-ROM convention).
|
||||||
|
pub const TRUEHD: u8 = 0x83;
|
||||||
|
/// Dolby Digital Plus (E-AC-3 / AC-3+) audio (BD-ROM convention).
|
||||||
|
pub const AC3_PLUS: u8 = 0x84;
|
||||||
|
/// DTS-HD High Resolution audio (BD-ROM Part 3-1).
|
||||||
|
pub const DTS_HD_HR: u8 = 0x85;
|
||||||
|
/// DTS-HD Master Audio (lossless) (BD-ROM Part 3-1).
|
||||||
|
pub const DTS_HD_MA: u8 = 0x86;
|
||||||
|
|
||||||
|
/// Presentation Graphics — PG subtitle stream (BD-ROM HDMV).
|
||||||
|
pub const PG: u8 = 0x90;
|
||||||
|
/// Interactive Graphics — IG / BD-J menu overlay, NOT a subtitle (BD-ROM HDMV).
|
||||||
|
pub const IG: u8 = 0x91;
|
||||||
|
/// Text subtitle stream (BD-ROM HDMV).
|
||||||
|
pub const TEXT_SUBTITLE: u8 = 0x92;
|
||||||
|
|
||||||
|
/// Secondary Dolby Digital Plus audio (BD-ROM convention).
|
||||||
|
pub const AC3_PLUS_SECONDARY: u8 = 0xA1;
|
||||||
|
/// Secondary DTS-HD audio (lossless MA, not lossy HR) (BD-ROM convention).
|
||||||
|
pub const DTS_HD_SECONDARY: u8 = 0xA2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MPEG PES `stream_id` codes — the byte after the `00 00 01` start-code prefix
|
||||||
|
/// that identifies an elementary stream's role in a PES packet (ISO/IEC
|
||||||
|
/// 13818-1 Table 2-22). Shared by the program-stream demuxer and the TS/M2TS
|
||||||
|
/// muxers, so defined here once. Each is `u8` (matches the byte on the wire).
|
||||||
|
pub mod pes_stream_id {
|
||||||
|
/// Video stream (`110x xxxx`; freemkv emits the base id `0xE0`).
|
||||||
|
pub const VIDEO: u8 = 0xE0;
|
||||||
|
/// private_stream_1 — AC-3 / DTS / LPCM / PGS subtitle payloads.
|
||||||
|
pub const PRIVATE_STREAM_1: u8 = 0xBD;
|
||||||
|
/// padding_stream — stuffing bytes only, no payload to demux.
|
||||||
|
pub const PADDING_STREAM: u8 = 0xBE;
|
||||||
|
/// private_stream_2 — DVD navigation (PCI/DSI); carries no muxable ES.
|
||||||
|
pub const PRIVATE_STREAM_2: u8 = 0xBF;
|
||||||
|
|
||||||
|
/// Highest video stream_id — the `110x xxxx` video range tops out at 0xEF.
|
||||||
|
pub const VIDEO_MAX: u8 = 0xEF;
|
||||||
|
|
||||||
|
/// Inclusive range of every PES `stream_id` that carries demuxable payload:
|
||||||
|
/// [`PRIVATE_STREAM_1`] (0xBD) through [`VIDEO_MAX`] (0xEF) — i.e. private
|
||||||
|
/// stream 1/2, padding, MPEG audio (0xC0-0xDF) and video (0xE0-0xEF). The
|
||||||
|
/// pack (0xBA), system-header (0xBB) and program-end (0xB9) codes sit below
|
||||||
|
/// this range and are deliberately excluded: they're structural, not ES.
|
||||||
|
pub const PAYLOAD_RANGE: core::ops::RangeInclusive<u8> = PRIVATE_STREAM_1..=VIDEO_MAX;
|
||||||
|
}
|
||||||
+511
@@ -0,0 +1,511 @@
|
|||||||
|
//! CSS cipher implementation based on the Stevenson 1999 analysis.
|
||||||
|
//!
|
||||||
|
//! The CSS cipher uses two table-driven feedback circuits:
|
||||||
|
//! - LFSR1: 17-bit state (9-bit lo + 8-bit hi register, seeded from
|
||||||
|
//! key[0..2]), driven by TAB2/TAB3
|
||||||
|
//! - LFSR0: 24-bit feedback register (seeded from key[2..5] XOR seed[2..5],
|
||||||
|
//! masked to 0xFFFFFF), driven by a feedback polynomial through TAB4
|
||||||
|
//!
|
||||||
|
//! The keystream is the bytewise sum (with carry) of both LFSR outputs.
|
||||||
|
//! Content descrambling computes plain = TAB1[cipher] ^ keystream — a TAB1
|
||||||
|
//! substitution of each ciphertext byte followed by an XOR with the keystream
|
||||||
|
//! (NOT a plain XOR; the cipher is not its own inverse).
|
||||||
|
//!
|
||||||
|
//! Algorithm: Frank A. Stevenson's divide-and-conquer attack (1999).
|
||||||
|
//! Tables: CSS specification constants.
|
||||||
|
|
||||||
|
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||||
|
|
||||||
|
/// Descramble a CSS-encrypted DVD sector in place.
|
||||||
|
///
|
||||||
|
/// Exact port of libdvdcss `dvdcss_unscramble` (css.c). The two content
|
||||||
|
/// LFSRs are seeded **directly** from `title_key XOR sector_seed` — there is
|
||||||
|
/// no `decrypt_key` mangling on this path (that is the disc/title-key
|
||||||
|
/// hierarchy, not the content cipher). Bytes 0x80..0x800 are recovered with
|
||||||
|
/// `*p = TAB1[*p] ^ (i_t5 & 0xff)`.
|
||||||
|
///
|
||||||
|
/// The 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// libdvdcss: `if( !(p_sec[0x14] & 0x30) ) return;`
|
||||||
|
if sector[0x14] & 0x30 == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LFSR1: seeded directly from (key ^ seed) — NO decrypt_key.
|
||||||
|
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||||
|
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
let mut i_t5: u32 = 0;
|
||||||
|
|
||||||
|
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||||
|
// Advance LFSR1.
|
||||||
|
let mut i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||||
|
i_t2 = i_t1 >> 1;
|
||||||
|
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||||
|
i_t4 = TAB5[i_t4 as usize] as u32;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
*byte = TAB1[*byte as usize] ^ (i_t5 & 0xFF) as u8;
|
||||||
|
i_t5 >>= 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// libdvdcss leaves byte 0x14 untouched; freemkv clears the scramble bits
|
||||||
|
// so downstream code and tests can tell a sector was descrambled.
|
||||||
|
sector[0x14] &= 0xCF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact inverse of [`descramble_sector`]: turn a plaintext sector body into
|
||||||
|
/// CSS ciphertext under `title_key`.
|
||||||
|
///
|
||||||
|
/// Descramble computes `plain = TAB1[cipher] ^ (i_t5 & 0xff)`, so the
|
||||||
|
/// inverse is `cipher = TAB1_INV[plain ^ (i_t5 & 0xff)]` with the identical
|
||||||
|
/// LFSR keystream. The keystream derivation is byte-for-byte the same as
|
||||||
|
/// `descramble_sector` (libdvdcss `dvdcss_unscramble`); only the final
|
||||||
|
/// substitution differs. Bytes 0x80..0x800 are rewritten in place; the
|
||||||
|
/// scramble flag is set to 0x10 so a subsequent descramble runs.
|
||||||
|
///
|
||||||
|
/// 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 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 i_t5: u32 = 0;
|
||||||
|
|
||||||
|
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||||
|
let mut i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||||
|
i_t2 = i_t1 >> 1;
|
||||||
|
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||||
|
i_t4 = TAB5[i_t4 as usize] as u32;
|
||||||
|
|
||||||
|
let mut 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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn descramble_skips_unscrambled() {
|
||||||
|
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||||
|
let mut sector = vec![0xAA; 2048];
|
||||||
|
sector[0x14] = 0x00;
|
||||||
|
let original = sector.clone();
|
||||||
|
descramble_sector(&key, &mut sector);
|
||||||
|
assert_eq!(sector, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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];
|
||||||
|
let mut sector = vec![0xAA; 2048];
|
||||||
|
sector[0x14] = 0x30; // scramble flag set
|
||||||
|
// Set a sector seed
|
||||||
|
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||||
|
let original = sector.clone();
|
||||||
|
descramble_sector(&key, &mut sector);
|
||||||
|
// Header (0..128) unchanged except byte 0x14 (flag cleared)
|
||||||
|
for i in 0..128 {
|
||||||
|
if i == 0x14 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert_eq!(sector[i], original[i], "header byte {} changed", i);
|
||||||
|
}
|
||||||
|
// Encrypted region should be different
|
||||||
|
assert_ne!(§or[128..256], &original[128..256]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn descramble_clears_flags() {
|
||||||
|
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||||
|
let mut sector = vec![0x00; 2048];
|
||||||
|
sector[0x14] = 0x30;
|
||||||
|
sector[0x54..0x59].copy_from_slice(&[0x00; 5]);
|
||||||
|
descramble_sector(&key, &mut sector);
|
||||||
|
assert_eq!(sector[0x14] & 0x30, 0x00);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test 2: descramble inverts scramble over the body.
|
||||||
|
///
|
||||||
|
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to
|
||||||
|
/// the ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
|
||||||
|
/// [`scramble_sector`]. Scrambling a plaintext body and then descrambling
|
||||||
|
/// with the same key must reproduce the original body exactly.
|
||||||
|
#[test]
|
||||||
|
fn css_descramble_inverts_scramble_over_body() {
|
||||||
|
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
|
|
||||||
|
let mut sector = vec![0xAAu8; 2048];
|
||||||
|
sector[0x14] = 0x30; // scramble flag
|
||||||
|
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
||||||
|
|
||||||
|
let original = sector.clone();
|
||||||
|
|
||||||
|
// Scramble the plaintext body into ciphertext.
|
||||||
|
scramble_sector(&title_key, &mut sector);
|
||||||
|
// Header (0..128) unchanged except the flag byte (set by scramble).
|
||||||
|
for i in 0..128 {
|
||||||
|
if i == 0x14 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert_eq!(sector[i], original[i], "header byte {} changed", i);
|
||||||
|
}
|
||||||
|
// Encrypted region modified
|
||||||
|
assert_ne!(§or[128..256], &original[128..256]);
|
||||||
|
|
||||||
|
// Descramble restores the plaintext body byte-for-byte.
|
||||||
|
descramble_sector(&title_key, &mut sector);
|
||||||
|
assert_eq!(sector[0x14] & 0x30, 0x00, "flag cleared after descramble");
|
||||||
|
assert_eq!(
|
||||||
|
§or[128..2048],
|
||||||
|
&original[128..2048],
|
||||||
|
"descramble(scramble(body)) did not restore the body"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// css_tab1_relationship
|
||||||
|
///
|
||||||
|
/// Verify the structure of TAB1: it is a substitution table used in
|
||||||
|
/// key mangling. Check that no two inputs map to the same output
|
||||||
|
/// (TAB1 is a permutation of 0..255).
|
||||||
|
#[test]
|
||||||
|
fn css_tab1_is_permutation() {
|
||||||
|
let mut seen = [false; 256];
|
||||||
|
for tab1_val in &TAB1 {
|
||||||
|
let v = *tab1_val as usize;
|
||||||
|
assert!(!seen[v], "TAB1 maps two inputs to {:#04x}", v);
|
||||||
|
seen[v] = true;
|
||||||
|
}
|
||||||
|
// Check involution property: TAB1[TAB1[x]] should map back predictably
|
||||||
|
// TAB1 is not necessarily a strict involution, but we verify the
|
||||||
|
// composition TAB1[TAB1[x]] is also a permutation
|
||||||
|
let mut seen2 = [false; 256];
|
||||||
|
for i in 0..256 {
|
||||||
|
let v = TAB1[TAB1[i] as usize] as usize;
|
||||||
|
assert!(!seen2[v], "TAB1[TAB1[x]] maps two inputs to {:#04x}", v);
|
||||||
|
seen2[v] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// css_tab4_is_bit_reversal
|
||||||
|
///
|
||||||
|
/// TAB4 reverses the bits of each byte: TAB4[0x01] = 0x80, TAB4[0x80] = 0x01, etc.
|
||||||
|
#[test]
|
||||||
|
fn css_tab4_is_bit_reversal() {
|
||||||
|
for i in 0u16..256 {
|
||||||
|
let expected = (0..8).fold(0u8, |acc, bit| acc | (((i as u8 >> bit) & 1) << (7 - bit)));
|
||||||
|
assert_eq!(
|
||||||
|
TAB4[i as usize], expected,
|
||||||
|
"TAB4[{:#04x}] = {:#04x}, expected {:#04x} (bit reversal)",
|
||||||
|
i, TAB4[i as usize], expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Also verify TAB4 is an involution: TAB4[TAB4[x]] == x
|
||||||
|
for i in 0..256 {
|
||||||
|
assert_eq!(
|
||||||
|
TAB4[TAB4[i] as usize], i as u8,
|
||||||
|
"TAB4 is not an involution at {:#04x}",
|
||||||
|
i
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── scramble-flag detection (byte 0x14, bits 4-5) ──────────────────────
|
||||||
|
|
||||||
|
/// Only bits 4-5 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+887
@@ -0,0 +1,887 @@
|
|||||||
|
//! CSS (Content Scramble System) — DVD disc encryption.
|
||||||
|
//!
|
||||||
|
//! CSS uses a weak 40-bit LFSR stream cipher (broken since 1999).
|
||||||
|
//!
|
||||||
|
//! 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
|
||||||
|
//! if let Some(state) = css::crack_key(reader, extents, batch) {
|
||||||
|
//! css::descramble_sector(&state, &mut sector);
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod lfsr;
|
||||||
|
pub mod stevenson;
|
||||||
|
pub(crate) mod tables;
|
||||||
|
|
||||||
|
use crate::disc::Extent;
|
||||||
|
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 {
|
||||||
|
/// 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)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recover the CSS title key with no keys, by scanning scrambled sectors and
|
||||||
|
/// running the Stevenson known-plaintext attack (see the [`stevenson`] module).
|
||||||
|
///
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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):
|
||||||
|
///
|
||||||
|
/// - [`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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
///
|
||||||
|
/// "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_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;
|
||||||
|
|
||||||
|
'outer: for (extent_idx, ext) in extents.iter().enumerate() {
|
||||||
|
let mut i = 0u32;
|
||||||
|
while i < ext.sector_count && tried < max_tries {
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tried >= max_tries {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
pub fn descramble_sector(state: &CssState, sector: &mut [u8]) {
|
||||||
|
lfsr::descramble_sector(&state.title_key, sector);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,593 @@
|
|||||||
|
//! CSS title-key recovery — Frank A. Stevenson's divide-and-conquer attack
|
||||||
|
//! (1999), ported exactly from libdvdcss `RecoverTitleKey` + `AttackPattern`
|
||||||
|
//! (css.c).
|
||||||
|
//!
|
||||||
|
//! Recovers the 5-byte CSS title key from a single scrambled DVD sector with
|
||||||
|
//! no player keys and no disc-key crack, using only known plaintext.
|
||||||
|
//!
|
||||||
|
//! # The cipher this attacks
|
||||||
|
//!
|
||||||
|
//! The content descrambler ([`super::lfsr::descramble_sector`], = libdvdcss
|
||||||
|
//! `dvdcss_unscramble`) seeds its two LFSRs **directly** from
|
||||||
|
//! `key = title_key XOR sector_seed` (seed = `sector[0x54..0x59]`):
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! i_t1 = (key[0] ^ sec[0x54]) | 0x100; // LFSR1 low (9-bit)
|
||||||
|
//! i_t2 = key[1] ^ sec[0x55]; // LFSR1 high
|
||||||
|
//! i_t3 = (key[2]|key[3]<<8|key[4]<<16) ^ seed3; // LFSR0 (24-bit feedback)
|
||||||
|
//! i_t3 = i_t3*2 + 8 - (i_t3 & 7);
|
||||||
|
//! // per byte: *p = TAB1[*p] ^ (i_t5 & 0xff)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! There is NO `decrypt_key` mangling on the content path. So the recovery
|
||||||
|
//! is a single inversion of `dvdcss_unscramble`, not the multi-stage
|
||||||
|
//! working-key inversion the previous (non-CSS) implementation used.
|
||||||
|
//!
|
||||||
|
//! # The attack
|
||||||
|
//!
|
||||||
|
//! 1. **Known plaintext → keystream.** Because the descramble applies TAB1
|
||||||
|
//! to the ciphertext, the per-byte keystream is
|
||||||
|
//! `buf[i] = TAB1[cipher[i]] ^ plain[i]` (matching libdvdcss
|
||||||
|
//! `RecoverTitleKey`'s `p_buffer`).
|
||||||
|
//! 2. **Brute the 16-bit LFSR1 seed.** For each of 2^16 seeds, run LFSR1
|
||||||
|
//! forward; for the first four steps deduce the LFSR0 output bytes from
|
||||||
|
//! the keystream (carry-tracked), reconstructing `i_t3`. For the next six
|
||||||
|
//! steps clock LFSR0 normally and check it reproduces the keystream — a
|
||||||
|
//! wrong LFSR1 seed fails fast.
|
||||||
|
//! 3. **Back-clock LFSR0.** Run four backward `i_t3` steps (each a 256-way
|
||||||
|
//! search for the byte shifted in) to reach the initial state, then undo
|
||||||
|
//! `i_t3 = i_t3*2 + 8 - (i_t3 & 7)` to recover key[2..5].
|
||||||
|
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]` (plain XOR —
|
||||||
|
//! the descramble seeds directly, so there is no inversion).
|
||||||
|
//!
|
||||||
|
//! `AttackPattern` finds known plaintext for step 1: the longest periodic
|
||||||
|
//! run in the cleartext `sec[0x00..0x80]`, assumed to continue into the
|
||||||
|
//! encrypted region at 0x80.
|
||||||
|
|
||||||
|
use super::lfsr::descramble_sector;
|
||||||
|
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||||
|
|
||||||
|
use crate::consts::SECTOR_BYTES;
|
||||||
|
const ENCRYPTED_START: usize = 0x80; // byte 128
|
||||||
|
const SEED_OFFSET: usize = 0x54; // sector seed at bytes 0x54-0x58
|
||||||
|
const FLAG_BYTE: usize = 0x14;
|
||||||
|
|
||||||
|
/// RecoverTitleKey: recover the title key from cipher + known plaintext.
|
||||||
|
///
|
||||||
|
/// Exact port of libdvdcss `RecoverTitleKey` (css.c). `crypted` is the
|
||||||
|
/// ciphertext starting at sector byte 0x80; `decrypted` is the matching
|
||||||
|
/// known plaintext; `seed` is `sector[0x54..0x59]`. On success returns the
|
||||||
|
/// recovered 5-byte title key; `None` if no LFSR seed reproduces the
|
||||||
|
/// keystream.
|
||||||
|
///
|
||||||
|
/// At least 10 bytes of `crypted`/`decrypted` are required (the cipher is
|
||||||
|
/// iterated 10 times: 4 to reconstruct LFSR0, 6 to validate).
|
||||||
|
fn recover_title_key_from_plain(
|
||||||
|
crypted: &[u8],
|
||||||
|
decrypted: &[u8],
|
||||||
|
seed: &[u8; 5],
|
||||||
|
) -> Option<[u8; 5]> {
|
||||||
|
if crypted.len() < 10 || decrypted.len() < 10 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// buf[i] = TAB1[cipher[i]] ^ plain[i] — the per-byte content keystream.
|
||||||
|
let mut buffer = [0u8; 10];
|
||||||
|
for (i, b) in buffer.iter_mut().enumerate() {
|
||||||
|
*b = TAB1[crypted[i] as usize] ^ decrypted[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut key = [0u8; 5];
|
||||||
|
let mut found = false;
|
||||||
|
|
||||||
|
for i_try in 0u32..0x1_0000 {
|
||||||
|
let mut i_t1 = (i_try >> 8) | 0x100;
|
||||||
|
let mut i_t2 = i_try & 0xff;
|
||||||
|
let mut i_t3: u32 = 0; // not needed yet
|
||||||
|
let mut i_t5: u32 = 0;
|
||||||
|
|
||||||
|
// Iterate the cipher 4 times to reconstruct LFSR0 (i_t3).
|
||||||
|
for &b in buffer.iter().take(4) {
|
||||||
|
let i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||||
|
i_t2 = i_t1 >> 1;
|
||||||
|
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||||
|
let i_t4 = TAB5[i_t4 as usize] as u32;
|
||||||
|
|
||||||
|
// Deduce i_t6 (LFSR0 output, pre-TAB4) and the carry.
|
||||||
|
let mut i_t6 = b as u32;
|
||||||
|
if i_t5 != 0 {
|
||||||
|
i_t6 = (i_t6 + 0xff) & 0xff;
|
||||||
|
}
|
||||||
|
if i_t6 < i_t4 {
|
||||||
|
i_t6 += 0x100;
|
||||||
|
}
|
||||||
|
i_t6 -= i_t4;
|
||||||
|
i_t5 += i_t6 + i_t4;
|
||||||
|
let i_t6 = TAB4[i_t6 as usize] as u32;
|
||||||
|
|
||||||
|
i_t3 = (i_t3 << 8) | i_t6;
|
||||||
|
i_t5 >>= 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
let i_candidate = i_t3;
|
||||||
|
|
||||||
|
// Iterate 6 more times to validate the candidate.
|
||||||
|
let mut i = 4usize;
|
||||||
|
while i < 10 {
|
||||||
|
let i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||||
|
i_t2 = i_t1 >> 1;
|
||||||
|
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||||
|
let i_t4 = TAB5[i_t4 as usize] as u32;
|
||||||
|
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xff;
|
||||||
|
i_t3 = (i_t3 << 8) | i_t6;
|
||||||
|
i_t6 = TAB4[i_t6 as usize] as u32;
|
||||||
|
i_t5 += i_t6 + i_t4;
|
||||||
|
if (i_t5 & 0xff) as u8 != buffer[i] {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i_t5 >>= 8;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if i != 10 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four backward steps of iterating i_t3 to deduce the initial state.
|
||||||
|
i_t3 = i_candidate;
|
||||||
|
for _ in 0..4 {
|
||||||
|
let i_t1_byte = i_t3 & 0xff;
|
||||||
|
i_t3 >>= 8;
|
||||||
|
// Brute-force the byte shifted in (top byte of the 24-bit reg).
|
||||||
|
for j in 0u32..256 {
|
||||||
|
i_t3 = (i_t3 & 0x1_ffff) | (j << 17);
|
||||||
|
let i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xff;
|
||||||
|
if i_t6 == i_t1_byte {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo `i_t3 = i_t3*2 + 8 - (i_t3 & 7)` to recover key[2..5].
|
||||||
|
let i_t4 = (i_t3 >> 1).wrapping_sub(4);
|
||||||
|
for i_t5 in 0u32..8 {
|
||||||
|
let val = i_t4.wrapping_add(i_t5);
|
||||||
|
if val.wrapping_mul(2).wrapping_add(8).wrapping_sub(val & 7) == i_t3 {
|
||||||
|
key[0] = (i_try >> 8) as u8;
|
||||||
|
key[1] = (i_try & 0xff) as u8;
|
||||||
|
key[2] = (val & 0xff) as u8;
|
||||||
|
key[3] = ((val >> 8) & 0xff) as u8;
|
||||||
|
key[4] = ((val >> 16) & 0xff) as u8;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// First fully-validated candidate wins. The 48-bit keystream constraint
|
||||||
|
// makes a second match cryptographically negligible on real sectors, but
|
||||||
|
// continuing would let a later spurious match overwrite a correct key.
|
||||||
|
if found {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if found {
|
||||||
|
for (k, &s) in key.iter_mut().zip(seed.iter()) {
|
||||||
|
*k ^= s;
|
||||||
|
}
|
||||||
|
Some(key)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recover the CSS title key from a scrambled sector using a known plaintext
|
||||||
|
/// for the encrypted region.
|
||||||
|
///
|
||||||
|
/// `plain` is the expected plaintext at byte 0x80 (at least 10 bytes).
|
||||||
|
/// Returns the recovered key only if it actually descrambles the sector back
|
||||||
|
/// to `plain` — guarding against the rare spurious LFSR-seed match.
|
||||||
|
pub fn recover_title_key(sector: &[u8], plain: &[u8]) -> Option<[u8; 5]> {
|
||||||
|
if sector.len() < SECTOR_BYTES || plain.len() < 10 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if sector[FLAG_BYTE] & 0x30 == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seed: [u8; 5] = [
|
||||||
|
sector[SEED_OFFSET],
|
||||||
|
sector[SEED_OFFSET + 1],
|
||||||
|
sector[SEED_OFFSET + 2],
|
||||||
|
sector[SEED_OFFSET + 3],
|
||||||
|
sector[SEED_OFFSET + 4],
|
||||||
|
];
|
||||||
|
|
||||||
|
let crypted = §or[ENCRYPTED_START..ENCRYPTED_START + 10];
|
||||||
|
let key = recover_title_key_from_plain(crypted, plain, &seed)?;
|
||||||
|
|
||||||
|
if descramble_matches(sector, &key, plain) {
|
||||||
|
Some(key)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a title key by descrambling a copy of `sector` and checking the
|
||||||
|
/// known plaintext reappears at byte 0x80.
|
||||||
|
fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool {
|
||||||
|
let mut test = sector.to_vec();
|
||||||
|
test[FLAG_BYTE] |= 0x10; // ensure scramble flag set for the descrambler
|
||||||
|
descramble_sector(title, &mut test);
|
||||||
|
let n = plain.len().min(SECTOR_BYTES - ENCRYPTED_START);
|
||||||
|
test[ENCRYPTED_START..ENCRYPTED_START + n] == plain[..n]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AttackPattern: find a repeating pattern just before the encrypted region
|
||||||
|
/// and assume the plaintext at 0x80 continues it.
|
||||||
|
///
|
||||||
|
/// Functionally-equivalent port of libdvdcss `AttackPattern` (css.c) — finds the
|
||||||
|
/// same periodic cribs on real DVD data, though its byte-comparison anchor
|
||||||
|
/// differs from the C on phase-misaligned runs. Scans cleartext
|
||||||
|
/// `sec[0x00..0x80]` for the longest run that repeats with a cycle length in
|
||||||
|
/// 2..0x2F. If the run is long enough (`plen > 3` and at least two full
|
||||||
|
/// cycles), the known plaintext at 0x80 is taken to be the periodic run
|
||||||
|
/// continuing forward, and [`recover_title_key_from_plain`] is applied.
|
||||||
|
pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
||||||
|
if sector.len() < SECTOR_BYTES {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if sector[FLAG_BYTE] & 0x30 == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runaway guard: a single sector's crack is a bounded 2^16 LFSR search and
|
||||||
|
// should finish in well under a second on any modern CPU. If it ever
|
||||||
|
// exceeds ~2s wall-clock, something pathological is happening — log it so a
|
||||||
|
// hang is never silent.
|
||||||
|
let crack_t0 = std::time::Instant::now();
|
||||||
|
|
||||||
|
let result = crack_title_key_inner(sector);
|
||||||
|
|
||||||
|
let elapsed = crack_t0.elapsed();
|
||||||
|
if elapsed.as_secs_f64() > 2.0 {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::css",
|
||||||
|
elapsed_ms = elapsed.as_millis() as u64,
|
||||||
|
found = result.is_some(),
|
||||||
|
"css crack: single-sector recovery exceeded 2s (runaway guard)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inner body of [`crack_title_key`] — the actual AttackPattern search. Split
|
||||||
|
/// out so the public entry point can wall-clock the whole attempt for the
|
||||||
|
/// runaway guard without threading a timer through every return path.
|
||||||
|
/// AttackPattern crib: the predicted 10-byte plaintext at byte 0x80.
|
||||||
|
///
|
||||||
|
/// Scans the clear header `sec[0x00..0x80]` (never scrambled) for the longest
|
||||||
|
/// run that repeats with a cycle length in 2..0x2F. If the run is long enough
|
||||||
|
/// (`plen > 3` and at least two full cycles), the plaintext at 0x80 is taken to
|
||||||
|
/// be that periodic run continuing forward. Returns `None` for an unscrambled
|
||||||
|
/// sector or one with no usable run — such a sector can be neither cracked nor
|
||||||
|
/// key-validated, only descrambled with an externally-cached key.
|
||||||
|
///
|
||||||
|
/// The header is untouched by `descramble_sector`, so the crib is identical
|
||||||
|
/// before and after descramble: the decrypt path uses it as a per-sector
|
||||||
|
/// "did the cached key descramble correctly?" oracle (the predicted plaintext
|
||||||
|
/// must reappear at 0x80), and the cracker uses it as its known plaintext.
|
||||||
|
pub(crate) fn attack_crib(sector: &[u8]) -> Option<[u8; 10]> {
|
||||||
|
if sector.len() < SECTOR_BYTES || sector[FLAG_BYTE] & 0x30 == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut best_plen: usize = 0;
|
||||||
|
let mut best_p: usize = 0;
|
||||||
|
|
||||||
|
// For all cycle lengths from 2 to 0x2F.
|
||||||
|
for i in 2usize..0x30 {
|
||||||
|
// Count bytes that repeat with cycle length i, scanning backward from
|
||||||
|
// 0x7F. `sec[0x7F - (j % i)] == sec[0x7F - j]`.
|
||||||
|
let mut j = i + 1;
|
||||||
|
while j < 0x80 && sector[0x7f - (j % i)] == sector[0x7f - j] {
|
||||||
|
if j > best_plen {
|
||||||
|
best_plen = j;
|
||||||
|
best_p = i;
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need at least a few repeated bytes and at least one full cycle.
|
||||||
|
if best_plen > 3 && best_p > 0 && best_plen / best_p >= 2 {
|
||||||
|
// The known plaintext is the periodic run continuing past 0x80. The
|
||||||
|
// crib starts at `0x80 - (best_plen/best_p)*best_p` and continues
|
||||||
|
// through the encrypted region; the bytes at and after 0x80 are the
|
||||||
|
// predicted plaintext (the pattern repeats with period best_p).
|
||||||
|
let cycles = best_plen / best_p;
|
||||||
|
let plain_start = 0x80 - cycles * best_p;
|
||||||
|
|
||||||
|
// Each predicted byte is the run sample one or more periods back:
|
||||||
|
// `sec[plain_start + (i % best_p)]`. For in-run offsets
|
||||||
|
// (`plain_start + i < 0x80`) the run is exactly periodic, so this
|
||||||
|
// equals `sec[plain_start + i]`; for offsets at/after 0x80 the raw
|
||||||
|
// byte is ciphertext, so we MUST wrap within the period rather than
|
||||||
|
// read it. (Reading `&sec[plain_start..+10]` directly — as before —
|
||||||
|
// pulled ciphertext into the crib whenever the run covered fewer than
|
||||||
|
// 10 bytes before 0x80, producing false-negative key recovery.)
|
||||||
|
let mut plain = [0u8; 10];
|
||||||
|
for (i, p) in plain.iter_mut().enumerate() {
|
||||||
|
*p = sector[plain_start + (i % best_p)];
|
||||||
|
}
|
||||||
|
Some(plain)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn crack_title_key_inner(sector: &[u8]) -> Option<[u8; 5]> {
|
||||||
|
let plain = attack_crib(sector)?;
|
||||||
|
let seed: [u8; 5] = [
|
||||||
|
sector[SEED_OFFSET],
|
||||||
|
sector[SEED_OFFSET + 1],
|
||||||
|
sector[SEED_OFFSET + 2],
|
||||||
|
sector[SEED_OFFSET + 3],
|
||||||
|
sector[SEED_OFFSET + 4],
|
||||||
|
];
|
||||||
|
let crypted = §or[0x80..0x80 + 10];
|
||||||
|
if let Some(key) = recover_title_key_from_plain(crypted, &plain, &seed) {
|
||||||
|
// Verify against the same predicted plaintext.
|
||||||
|
if descramble_matches(sector, &key, &plain) {
|
||||||
|
return Some(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::super::lfsr::scramble_sector;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Build a synthetic scrambled sector for a given title key and seed,
|
||||||
|
/// with `plain` placed as the plaintext at byte 0x80, scrambled with
|
||||||
|
/// EXACTLY the cipher `descramble_sector` inverts. Returns
|
||||||
|
/// (scrambled_sector, full_plaintext_body).
|
||||||
|
fn synth_sector(title_key: &[u8; 5], seed: &[u8; 5], plain: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||||
|
let mut plaintext = vec![0u8; SECTOR_BYTES];
|
||||||
|
plaintext[0..4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
|
||||||
|
plaintext[FLAG_BYTE] = 0x10;
|
||||||
|
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
||||||
|
plaintext[ENCRYPTED_START..ENCRYPTED_START + plain.len()].copy_from_slice(plain);
|
||||||
|
|
||||||
|
let body = plaintext.clone();
|
||||||
|
|
||||||
|
// scramble_sector turns the plaintext body into ciphertext and sets
|
||||||
|
// the scramble flag.
|
||||||
|
scramble_sector(title_key, &mut plaintext);
|
||||||
|
(plaintext, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a synthetic scrambled sector whose CLEARTEXT (0x00..0x80) ends
|
||||||
|
/// in a periodic run that continues into the encrypted region — the case
|
||||||
|
/// `AttackPattern` (crack_title_key) is designed to crack.
|
||||||
|
fn synth_periodic_sector(
|
||||||
|
title_key: &[u8; 5],
|
||||||
|
seed: &[u8; 5],
|
||||||
|
period: usize,
|
||||||
|
) -> (Vec<u8>, Vec<u8>) {
|
||||||
|
let mut plaintext = vec![0u8; SECTOR_BYTES];
|
||||||
|
plaintext[FLAG_BYTE] = 0x10;
|
||||||
|
|
||||||
|
// A clean periodic run occupying the tail of the cleartext header
|
||||||
|
// (RUN_START..0x80) and continuing into the encrypted region. This
|
||||||
|
// mirrors a real VOB: a periodic data run just before the scrambled
|
||||||
|
// part. The run must NOT overlap the seed bytes (0x54..0x59), or the
|
||||||
|
// AttackPattern detector would break mid-run. The phase is anchored to
|
||||||
|
// offset 0 so the run is consistent across the 0x80 boundary.
|
||||||
|
// Just above the seed (0x54..0x59); gives a 39-byte run (0x59..0x80)
|
||||||
|
// — enough for >=2 cycles of every tested period (<=19).
|
||||||
|
const RUN_START: usize = 0x59;
|
||||||
|
let pat: Vec<u8> = (0..period)
|
||||||
|
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
|
||||||
|
.collect();
|
||||||
|
for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) {
|
||||||
|
*b = pat[i % period];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed sits below the run, undisturbed.
|
||||||
|
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
||||||
|
|
||||||
|
let body = plaintext.clone();
|
||||||
|
scramble_sector(title_key, &mut plaintext);
|
||||||
|
(plaintext, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crack_unscrambled_returns_none() {
|
||||||
|
let sector = vec![0u8; SECTOR_BYTES];
|
||||||
|
assert!(crack_title_key(§or).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crack_too_short_returns_none() {
|
||||||
|
let sector = vec![0u8; 100];
|
||||||
|
assert!(crack_title_key(§or).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recover_needs_min_plain() {
|
||||||
|
let sector = vec![0u8; SECTOR_BYTES];
|
||||||
|
let short_plain = [0u8; 4];
|
||||||
|
assert!(recover_title_key(§or, &short_plain).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The known plaintext used at byte 0x80 for the direct-recovery tests.
|
||||||
|
/// A realistic MPEG-2 PES header start.
|
||||||
|
const PES: [u8; 10] = [0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21];
|
||||||
|
|
||||||
|
/// MANDATORY round-trip (Task C.1): synthesize a scrambled sector for a
|
||||||
|
/// known (title_key, seed), then assert recover_title_key returns a key
|
||||||
|
/// that descrambles the body back to plaintext. CSS title-key recovery is
|
||||||
|
/// well-defined up to keys that scramble identically; we assert the full
|
||||||
|
/// body round-trips (the true correctness property), and additionally
|
||||||
|
/// that the EXACT key is returned for the common case.
|
||||||
|
#[test]
|
||||||
|
fn recover_round_trips_known_keys() {
|
||||||
|
let cases: &[([u8; 5], [u8; 5])] = &[
|
||||||
|
(
|
||||||
|
[0x42, 0x13, 0x37, 0xBE, 0xEF],
|
||||||
|
[0x11, 0x22, 0x33, 0x44, 0x55],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[0x01, 0x02, 0x03, 0x04, 0x05],
|
||||||
|
[0xDE, 0xAD, 0xBE, 0xEF, 0x42],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[0xFE, 0xDC, 0xBA, 0x98, 0x76],
|
||||||
|
[0x00, 0xFF, 0x80, 0x7F, 0x01],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[0x9A, 0x78, 0x56, 0x34, 0x12],
|
||||||
|
[0xA5, 0x5A, 0x0F, 0xF0, 0xCC],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
||||||
|
[0x01, 0x01, 0x01, 0x01, 0x01],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (title_key, seed) in cases {
|
||||||
|
let (mut sector, body) = synth_sector(title_key, seed, &PES);
|
||||||
|
let recovered =
|
||||||
|
recover_title_key(§or, &PES).expect("recover_title_key returned None");
|
||||||
|
descramble_sector(&recovered, &mut sector);
|
||||||
|
assert_eq!(
|
||||||
|
§or[ENCRYPTED_START..SECTOR_BYTES],
|
||||||
|
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||||
|
"recovered key did not descramble the full body for \
|
||||||
|
title={title_key:02x?} seed={seed:02x?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MANDATORY (Task C.1): the AttackPattern entry point crack_title_key —
|
||||||
|
/// no plaintext supplied — recovers a round-tripping key when the
|
||||||
|
/// cleartext ends in a periodic run that continues into 0x80.
|
||||||
|
#[test]
|
||||||
|
fn crack_title_key_recovers_via_attack_pattern() {
|
||||||
|
for &period in &[2usize, 3, 5, 8, 16] {
|
||||||
|
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
|
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
|
||||||
|
let (sector, body) = synth_periodic_sector(&title_key, &seed, period);
|
||||||
|
|
||||||
|
let cracked = crack_title_key(§or)
|
||||||
|
.unwrap_or_else(|| panic!("crack_title_key returned None for period {period}"));
|
||||||
|
let mut test = sector.clone();
|
||||||
|
descramble_sector(&cracked, &mut test);
|
||||||
|
assert_eq!(
|
||||||
|
&test[ENCRYPTED_START..SECTOR_BYTES],
|
||||||
|
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||||
|
"crack_title_key key did not round-trip the body (period {period})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// recover_title_key_from_plain inverts dvdcss_unscramble exactly: scramble
|
||||||
|
/// a known body, hand back the keystream-derived key, and the recovered
|
||||||
|
/// key (XOR-back included) reproduces the plaintext.
|
||||||
|
#[test]
|
||||||
|
fn recovered_key_descrambles_back_to_plaintext() {
|
||||||
|
let cases: &[([u8; 5], [u8; 5])] = &[
|
||||||
|
(
|
||||||
|
[0x42, 0x13, 0x37, 0xBE, 0xEF],
|
||||||
|
[0x11, 0x22, 0x33, 0x44, 0x55],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[0x9A, 0x78, 0x56, 0x34, 0x12],
|
||||||
|
[0xA5, 0x5A, 0x0F, 0xF0, 0xCC],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
||||||
|
[0x01, 0x01, 0x01, 0x01, 0x01],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (title_key, seed) in cases {
|
||||||
|
let (mut sector, body) = synth_sector(title_key, seed, &PES);
|
||||||
|
let recovered =
|
||||||
|
recover_title_key(§or, &PES).expect("recover_title_key returned None");
|
||||||
|
descramble_sector(&recovered, &mut sector);
|
||||||
|
assert_eq!(
|
||||||
|
§or[ENCRYPTED_START..SECTOR_BYTES],
|
||||||
|
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||||
|
"descramble with recovered key did not reproduce the body \
|
||||||
|
for title={title_key:02x?} seed={seed:02x?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── early-return guards ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recover_rejects_sector_one_byte_short() {
|
||||||
|
let mut sector = vec![0u8; SECTOR_BYTES - 1];
|
||||||
|
sector[FLAG_BYTE] = 0x30;
|
||||||
|
assert!(recover_title_key(§or, &PES).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recover_rejects_unscrambled_sector() {
|
||||||
|
let sector = vec![0x00u8; SECTOR_BYTES];
|
||||||
|
assert!(recover_title_key(§or, &PES).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recover_high_flag_bits_are_not_scramble() {
|
||||||
|
for &flag in &[0x40u8, 0x80, 0xC0] {
|
||||||
|
let mut sector = vec![0x11u8; SECTOR_BYTES];
|
||||||
|
sector[FLAG_BYTE] = flag;
|
||||||
|
assert!(
|
||||||
|
recover_title_key(§or, &PES).is_none(),
|
||||||
|
"flag {flag:#04x} has scramble bits clear; recover must return None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crack_high_flag_bits_are_not_scramble() {
|
||||||
|
for &flag in &[0x40u8, 0x80, 0xC0] {
|
||||||
|
let mut sector = vec![0x11u8; SECTOR_BYTES];
|
||||||
|
sector[FLAG_BYTE] = flag;
|
||||||
|
assert!(
|
||||||
|
crack_title_key(§or).is_none(),
|
||||||
|
"flag {flag:#04x} clear scramble bits -> crack must return None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crack_rejects_sector_one_byte_short() {
|
||||||
|
let mut sector = vec![0u8; SECTOR_BYTES - 1];
|
||||||
|
if sector.len() > FLAG_BYTE {
|
||||||
|
sector[FLAG_BYTE] = 0x30;
|
||||||
|
}
|
||||||
|
assert!(crack_title_key(§or).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// crack_title_key must never panic on a fully scrambled sector with
|
||||||
|
/// arbitrary (non-periodic) content — it just returns None.
|
||||||
|
#[test]
|
||||||
|
fn crack_full_path_never_panics() {
|
||||||
|
for seed in 0u32..3 {
|
||||||
|
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||||
|
sector[FLAG_BYTE] = 0x30;
|
||||||
|
let mut x = seed.wrapping_mul(2_654_435_761).wrapping_add(7);
|
||||||
|
for b in sector.iter_mut().skip(0x80) {
|
||||||
|
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||||||
|
*b = (x >> 16) as u8;
|
||||||
|
}
|
||||||
|
for (i, b) in sector[SEED_OFFSET..SEED_OFFSET + 5].iter_mut().enumerate() {
|
||||||
|
*b = (seed.wrapping_add(i as u32) ^ 0xA5) as u8;
|
||||||
|
}
|
||||||
|
let _ = crack_title_key(§or);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
//! CSS specification tables — mathematical constants defining the cipher.
|
||||||
|
//!
|
||||||
|
//! These 5 tables are the fixed permutations and substitutions of the
|
||||||
|
//! Content Scramble System. They are mathematical constants derived from
|
||||||
|
//! the CSS specification, published in academic literature since 1999.
|
||||||
|
|
||||||
|
/// Table 1: byte substitution used in key mangling and sector seed processing.
|
||||||
|
pub const TAB1: [u8; 256] = [
|
||||||
|
0x33, 0x73, 0x3b, 0x26, 0x63, 0x23, 0x6b, 0x76, 0x3e, 0x7e, 0x36, 0x2b, 0x6e, 0x2e, 0x66, 0x7b,
|
||||||
|
0xd3, 0x93, 0xdb, 0x06, 0x43, 0x03, 0x4b, 0x96, 0xde, 0x9e, 0xd6, 0x0b, 0x4e, 0x0e, 0x46, 0x9b,
|
||||||
|
0x57, 0x17, 0x5f, 0x82, 0xc7, 0x87, 0xcf, 0x12, 0x5a, 0x1a, 0x52, 0x8f, 0xca, 0x8a, 0xc2, 0x1f,
|
||||||
|
0xd9, 0x99, 0xd1, 0x00, 0x49, 0x09, 0x41, 0x90, 0xd8, 0x98, 0xd0, 0x01, 0x48, 0x08, 0x40, 0x91,
|
||||||
|
0x3d, 0x7d, 0x35, 0x24, 0x6d, 0x2d, 0x65, 0x74, 0x3c, 0x7c, 0x34, 0x25, 0x6c, 0x2c, 0x64, 0x75,
|
||||||
|
0xdd, 0x9d, 0xd5, 0x04, 0x4d, 0x0d, 0x45, 0x94, 0xdc, 0x9c, 0xd4, 0x05, 0x4c, 0x0c, 0x44, 0x95,
|
||||||
|
0x59, 0x19, 0x51, 0x80, 0xc9, 0x89, 0xc1, 0x10, 0x58, 0x18, 0x50, 0x81, 0xc8, 0x88, 0xc0, 0x11,
|
||||||
|
0xd7, 0x97, 0xdf, 0x02, 0x47, 0x07, 0x4f, 0x92, 0xda, 0x9a, 0xd2, 0x0f, 0x4a, 0x0a, 0x42, 0x9f,
|
||||||
|
0x53, 0x13, 0x5b, 0x86, 0xc3, 0x83, 0xcb, 0x16, 0x5e, 0x1e, 0x56, 0x8b, 0xce, 0x8e, 0xc6, 0x1b,
|
||||||
|
0xb3, 0xf3, 0xbb, 0xa6, 0xe3, 0xa3, 0xeb, 0xf6, 0xbe, 0xfe, 0xb6, 0xab, 0xee, 0xae, 0xe6, 0xfb,
|
||||||
|
0x37, 0x77, 0x3f, 0x22, 0x67, 0x27, 0x6f, 0x72, 0x3a, 0x7a, 0x32, 0x2f, 0x6a, 0x2a, 0x62, 0x7f,
|
||||||
|
0xb9, 0xf9, 0xb1, 0xa0, 0xe9, 0xa9, 0xe1, 0xf0, 0xb8, 0xf8, 0xb0, 0xa1, 0xe8, 0xa8, 0xe0, 0xf1,
|
||||||
|
0x5d, 0x1d, 0x55, 0x84, 0xcd, 0x8d, 0xc5, 0x14, 0x5c, 0x1c, 0x54, 0x85, 0xcc, 0x8c, 0xc4, 0x15,
|
||||||
|
0xbd, 0xfd, 0xb5, 0xa4, 0xed, 0xad, 0xe5, 0xf4, 0xbc, 0xfc, 0xb4, 0xa5, 0xec, 0xac, 0xe4, 0xf5,
|
||||||
|
0x39, 0x79, 0x31, 0x20, 0x69, 0x29, 0x61, 0x70, 0x38, 0x78, 0x30, 0x21, 0x68, 0x28, 0x60, 0x71,
|
||||||
|
0xb7, 0xf7, 0xbf, 0xa2, 0xe7, 0xa7, 0xef, 0xf2, 0xba, 0xfa, 0xb2, 0xaf, 0xea, 0xaa, 0xe2, 0xff,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Table 2: LFSR1 high-byte feedback permutation.
|
||||||
|
///
|
||||||
|
/// Byte-identical to libdvdcss `p_css_tab2` (csstables.h).
|
||||||
|
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,
|
||||||
|
0x24, 0x25, 0x26, 0x27, 0x20, 0x21, 0x22, 0x23, 0x2d, 0x2c, 0x2f, 0x2e, 0x29, 0x28, 0x2b, 0x2a,
|
||||||
|
0x36, 0x37, 0x34, 0x35, 0x32, 0x33, 0x30, 0x31, 0x3f, 0x3e, 0x3d, 0x3c, 0x3b, 0x3a, 0x39, 0x38,
|
||||||
|
0x49, 0x48, 0x4b, 0x4a, 0x4d, 0x4c, 0x4f, 0x4e, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47,
|
||||||
|
0x5b, 0x5a, 0x59, 0x58, 0x5f, 0x5e, 0x5d, 0x5c, 0x52, 0x53, 0x50, 0x51, 0x56, 0x57, 0x54, 0x55,
|
||||||
|
0x6d, 0x6c, 0x6f, 0x6e, 0x69, 0x68, 0x6b, 0x6a, 0x64, 0x65, 0x66, 0x67, 0x60, 0x61, 0x62, 0x63,
|
||||||
|
0x7f, 0x7e, 0x7d, 0x7c, 0x7b, 0x7a, 0x79, 0x78, 0x76, 0x77, 0x74, 0x75, 0x72, 0x73, 0x70, 0x71,
|
||||||
|
0x92, 0x93, 0x90, 0x91, 0x96, 0x97, 0x94, 0x95, 0x9b, 0x9a, 0x99, 0x98, 0x9f, 0x9e, 0x9d, 0x9c,
|
||||||
|
0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x89, 0x88, 0x8b, 0x8a, 0x8d, 0x8c, 0x8f, 0x8e,
|
||||||
|
0xb6, 0xb7, 0xb4, 0xb5, 0xb2, 0xb3, 0xb0, 0xb1, 0xbf, 0xbe, 0xbd, 0xbc, 0xbb, 0xba, 0xb9, 0xb8,
|
||||||
|
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,
|
||||||
|
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 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,
|
||||||
|
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,
|
||||||
|
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).
|
||||||
|
pub const TAB4: [u8; 256] = [
|
||||||
|
0x00, 0x80, 0x40, 0xc0, 0x20, 0xa0, 0x60, 0xe0, 0x10, 0x90, 0x50, 0xd0, 0x30, 0xb0, 0x70, 0xf0,
|
||||||
|
0x08, 0x88, 0x48, 0xc8, 0x28, 0xa8, 0x68, 0xe8, 0x18, 0x98, 0x58, 0xd8, 0x38, 0xb8, 0x78, 0xf8,
|
||||||
|
0x04, 0x84, 0x44, 0xc4, 0x24, 0xa4, 0x64, 0xe4, 0x14, 0x94, 0x54, 0xd4, 0x34, 0xb4, 0x74, 0xf4,
|
||||||
|
0x0c, 0x8c, 0x4c, 0xcc, 0x2c, 0xac, 0x6c, 0xec, 0x1c, 0x9c, 0x5c, 0xdc, 0x3c, 0xbc, 0x7c, 0xfc,
|
||||||
|
0x02, 0x82, 0x42, 0xc2, 0x22, 0xa2, 0x62, 0xe2, 0x12, 0x92, 0x52, 0xd2, 0x32, 0xb2, 0x72, 0xf2,
|
||||||
|
0x0a, 0x8a, 0x4a, 0xca, 0x2a, 0xaa, 0x6a, 0xea, 0x1a, 0x9a, 0x5a, 0xda, 0x3a, 0xba, 0x7a, 0xfa,
|
||||||
|
0x06, 0x86, 0x46, 0xc6, 0x26, 0xa6, 0x66, 0xe6, 0x16, 0x96, 0x56, 0xd6, 0x36, 0xb6, 0x76, 0xf6,
|
||||||
|
0x0e, 0x8e, 0x4e, 0xce, 0x2e, 0xae, 0x6e, 0xee, 0x1e, 0x9e, 0x5e, 0xde, 0x3e, 0xbe, 0x7e, 0xfe,
|
||||||
|
0x01, 0x81, 0x41, 0xc1, 0x21, 0xa1, 0x61, 0xe1, 0x11, 0x91, 0x51, 0xd1, 0x31, 0xb1, 0x71, 0xf1,
|
||||||
|
0x09, 0x89, 0x49, 0xc9, 0x29, 0xa9, 0x69, 0xe9, 0x19, 0x99, 0x59, 0xd9, 0x39, 0xb9, 0x79, 0xf9,
|
||||||
|
0x05, 0x85, 0x45, 0xc5, 0x25, 0xa5, 0x65, 0xe5, 0x15, 0x95, 0x55, 0xd5, 0x35, 0xb5, 0x75, 0xf5,
|
||||||
|
0x0d, 0x8d, 0x4d, 0xcd, 0x2d, 0xad, 0x6d, 0xed, 0x1d, 0x9d, 0x5d, 0xdd, 0x3d, 0xbd, 0x7d, 0xfd,
|
||||||
|
0x03, 0x83, 0x43, 0xc3, 0x23, 0xa3, 0x63, 0xe3, 0x13, 0x93, 0x53, 0xd3, 0x33, 0xb3, 0x73, 0xf3,
|
||||||
|
0x0b, 0x8b, 0x4b, 0xcb, 0x2b, 0xab, 0x6b, 0xeb, 0x1b, 0x9b, 0x5b, 0xdb, 0x3b, 0xbb, 0x7b, 0xfb,
|
||||||
|
0x07, 0x87, 0x47, 0xc7, 0x27, 0xa7, 0x67, 0xe7, 0x17, 0x97, 0x57, 0xd7, 0x37, 0xb7, 0x77, 0xf7,
|
||||||
|
0x0f, 0x8f, 0x4f, 0xcf, 0x2f, 0xaf, 0x6f, 0xef, 0x1f, 0x9f, 0x5f, 0xdf, 0x3f, 0xbf, 0x7f, 0xff,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
0xfb, 0x7b, 0xbb, 0x3b, 0xdb, 0x5b, 0x9b, 0x1b, 0xeb, 0x6b, 0xab, 0x2b, 0xcb, 0x4b, 0x8b, 0x0b,
|
||||||
|
0xf3, 0x73, 0xb3, 0x33, 0xd3, 0x53, 0x93, 0x13, 0xe3, 0x63, 0xa3, 0x23, 0xc3, 0x43, 0x83, 0x03,
|
||||||
|
0xfd, 0x7d, 0xbd, 0x3d, 0xdd, 0x5d, 0x9d, 0x1d, 0xed, 0x6d, 0xad, 0x2d, 0xcd, 0x4d, 0x8d, 0x0d,
|
||||||
|
0xf5, 0x75, 0xb5, 0x35, 0xd5, 0x55, 0x95, 0x15, 0xe5, 0x65, 0xa5, 0x25, 0xc5, 0x45, 0x85, 0x05,
|
||||||
|
0xf9, 0x79, 0xb9, 0x39, 0xd9, 0x59, 0x99, 0x19, 0xe9, 0x69, 0xa9, 0x29, 0xc9, 0x49, 0x89, 0x09,
|
||||||
|
0xf1, 0x71, 0xb1, 0x31, 0xd1, 0x51, 0x91, 0x11, 0xe1, 0x61, 0xa1, 0x21, 0xc1, 0x41, 0x81, 0x01,
|
||||||
|
0xfe, 0x7e, 0xbe, 0x3e, 0xde, 0x5e, 0x9e, 0x1e, 0xee, 0x6e, 0xae, 0x2e, 0xce, 0x4e, 0x8e, 0x0e,
|
||||||
|
0xf6, 0x76, 0xb6, 0x36, 0xd6, 0x56, 0x96, 0x16, 0xe6, 0x66, 0xa6, 0x26, 0xc6, 0x46, 0x86, 0x06,
|
||||||
|
0xfa, 0x7a, 0xba, 0x3a, 0xda, 0x5a, 0x9a, 0x1a, 0xea, 0x6a, 0xaa, 0x2a, 0xca, 0x4a, 0x8a, 0x0a,
|
||||||
|
0xf2, 0x72, 0xb2, 0x32, 0xd2, 0x52, 0x92, 0x12, 0xe2, 0x62, 0xa2, 0x22, 0xc2, 0x42, 0x82, 0x02,
|
||||||
|
0xfc, 0x7c, 0xbc, 0x3c, 0xdc, 0x5c, 0x9c, 0x1c, 0xec, 0x6c, 0xac, 0x2c, 0xcc, 0x4c, 0x8c, 0x0c,
|
||||||
|
0xf4, 0x74, 0xb4, 0x34, 0xd4, 0x54, 0x94, 0x14, 0xe4, 0x64, 0xa4, 0x24, 0xc4, 0x44, 0x84, 0x04,
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1374
File diff suppressed because it is too large
Load Diff
+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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
-750
@@ -1,750 +0,0 @@
|
|||||||
//! Disc structure — scan titles, streams, and sector ranges from a Blu-ray disc.
|
|
||||||
//!
|
|
||||||
//! This is the high-level API for disc content. The CLI calls this,
|
|
||||||
//! never parses MPLS/CLPI/UDF directly.
|
|
||||||
//!
|
|
||||||
//! Usage:
|
|
||||||
//! let disc = Disc::scan(&mut session)?;
|
|
||||||
//! for title in disc.titles() { ... }
|
|
||||||
//! for stream in title.streams() { ... }
|
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
|
||||||
use crate::drive::DriveSession;
|
|
||||||
use crate::udf;
|
|
||||||
use crate::mpls;
|
|
||||||
use crate::clpi;
|
|
||||||
|
|
||||||
// ─── Public types ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// A scanned Blu-ray disc.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Disc {
|
|
||||||
/// Disc capacity in sectors
|
|
||||||
pub capacity_sectors: u32,
|
|
||||||
/// Titles sorted by duration (longest first), then playlist name
|
|
||||||
pub titles: Vec<Title>,
|
|
||||||
/// AACS state — None if disc is unencrypted or keys unavailable
|
|
||||||
pub aacs: Option<AacsState>,
|
|
||||||
/// Whether this disc requires AACS decryption
|
|
||||||
pub encrypted: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A title (one MPLS playlist).
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Title {
|
|
||||||
/// Playlist filename (e.g. "00800.mpls")
|
|
||||||
pub playlist: String,
|
|
||||||
/// Playlist number (e.g. 800)
|
|
||||||
pub playlist_id: u16,
|
|
||||||
/// Duration in seconds
|
|
||||||
pub duration_secs: f64,
|
|
||||||
/// Total size in bytes
|
|
||||||
pub size_bytes: u64,
|
|
||||||
/// Number of clips
|
|
||||||
pub clip_count: usize,
|
|
||||||
/// All streams (video, audio, subtitle, etc.)
|
|
||||||
pub streams: Vec<Stream>,
|
|
||||||
/// Sector extents for ripping (clip LBA ranges)
|
|
||||||
pub extents: Vec<Extent>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A stream within a title.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Stream {
|
|
||||||
/// Stream type
|
|
||||||
pub kind: StreamKind,
|
|
||||||
/// MPEG-TS packet ID
|
|
||||||
pub pid: u16,
|
|
||||||
/// Codec
|
|
||||||
pub codec: Codec,
|
|
||||||
/// ISO 639-2 language code (e.g. "eng", "fra")
|
|
||||||
pub language: String,
|
|
||||||
/// Video resolution (e.g. "2160p", "1080p")
|
|
||||||
pub resolution: String,
|
|
||||||
/// Frame rate (e.g. "23.976")
|
|
||||||
pub frame_rate: String,
|
|
||||||
/// Channel layout (e.g. "5.1", "7.1", "stereo")
|
|
||||||
pub channels: String,
|
|
||||||
/// Sample rate (e.g. "48kHz")
|
|
||||||
pub sample_rate: String,
|
|
||||||
/// HDR format
|
|
||||||
pub hdr: HdrFormat,
|
|
||||||
/// Color space
|
|
||||||
pub color_space: ColorSpace,
|
|
||||||
/// Whether this is a secondary/enhancement stream
|
|
||||||
pub secondary: bool,
|
|
||||||
/// Extra label (e.g. "Dolby Vision EL")
|
|
||||||
pub label: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stream type.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub enum StreamKind {
|
|
||||||
Video,
|
|
||||||
Audio,
|
|
||||||
Subtitle,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Video/audio codec.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub enum Codec {
|
|
||||||
// Video
|
|
||||||
Hevc,
|
|
||||||
H264,
|
|
||||||
Vc1,
|
|
||||||
Mpeg2,
|
|
||||||
// Audio
|
|
||||||
TrueHd,
|
|
||||||
DtsHdMa,
|
|
||||||
DtsHdHr,
|
|
||||||
Dts,
|
|
||||||
Ac3,
|
|
||||||
Ac3Plus,
|
|
||||||
Lpcm,
|
|
||||||
// Subtitle
|
|
||||||
Pgs,
|
|
||||||
// Unknown
|
|
||||||
Unknown(u8),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// HDR format.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub enum HdrFormat {
|
|
||||||
Sdr,
|
|
||||||
Hdr10,
|
|
||||||
DolbyVision,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Color space.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub enum ColorSpace {
|
|
||||||
Bt709,
|
|
||||||
Bt2020,
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A contiguous range of sectors on disc.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct Extent {
|
|
||||||
pub start_lba: u32,
|
|
||||||
pub sector_count: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Display helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
impl Codec {
|
|
||||||
pub fn name(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Codec::Hevc => "HEVC",
|
|
||||||
Codec::H264 => "H.264",
|
|
||||||
Codec::Vc1 => "VC-1",
|
|
||||||
Codec::Mpeg2 => "MPEG-2",
|
|
||||||
Codec::TrueHd => "TrueHD",
|
|
||||||
Codec::DtsHdMa => "DTS-HD MA",
|
|
||||||
Codec::DtsHdHr => "DTS-HD HR",
|
|
||||||
Codec::Dts => "DTS",
|
|
||||||
Codec::Ac3 => "AC-3",
|
|
||||||
Codec::Ac3Plus => "AC-3+",
|
|
||||||
Codec::Lpcm => "LPCM",
|
|
||||||
Codec::Pgs => "PGS",
|
|
||||||
Codec::Unknown(_) => "Unknown",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_coding_type(ct: u8) -> Self {
|
|
||||||
match ct {
|
|
||||||
0x24 => Codec::Hevc,
|
|
||||||
0x1B => Codec::H264,
|
|
||||||
0xEA => Codec::Vc1,
|
|
||||||
0x02 => Codec::Mpeg2,
|
|
||||||
0x83 => Codec::TrueHd,
|
|
||||||
0x86 => Codec::DtsHdMa,
|
|
||||||
0x85 => Codec::DtsHdHr,
|
|
||||||
0x82 => Codec::Dts,
|
|
||||||
0x81 => Codec::Ac3,
|
|
||||||
0x84 | 0xA1 => Codec::Ac3Plus,
|
|
||||||
0x80 => Codec::Lpcm,
|
|
||||||
0xA2 => Codec::DtsHdHr,
|
|
||||||
0x90 | 0x91 => Codec::Pgs,
|
|
||||||
ct => Codec::Unknown(ct),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HdrFormat {
|
|
||||||
pub fn name(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
HdrFormat::Sdr => "SDR",
|
|
||||||
HdrFormat::Hdr10 => "HDR10",
|
|
||||||
HdrFormat::DolbyVision => "Dolby Vision",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ColorSpace {
|
|
||||||
pub fn name(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
ColorSpace::Bt709 => "BT.709",
|
|
||||||
ColorSpace::Bt2020 => "BT.2020",
|
|
||||||
ColorSpace::Unknown => "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Title {
|
|
||||||
/// Duration formatted as "Xh Ym"
|
|
||||||
pub fn duration_display(&self) -> String {
|
|
||||||
let hrs = (self.duration_secs / 3600.0) as u32;
|
|
||||||
let mins = ((self.duration_secs % 3600.0) / 60.0) as u32;
|
|
||||||
format!("{}h {:02}m", hrs, mins)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Size in GB
|
|
||||||
pub fn size_gb(&self) -> f64 {
|
|
||||||
self.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Total sectors across all extents
|
|
||||||
pub fn total_sectors(&self) -> u64 {
|
|
||||||
self.extents.iter().map(|e| e.sector_count as u64).sum()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Stream {
|
|
||||||
/// Human-readable one-line description.
|
|
||||||
pub fn display(&self) -> String {
|
|
||||||
match self.kind {
|
|
||||||
StreamKind::Video => {
|
|
||||||
let mut parts = vec![self.codec.name().to_string()];
|
|
||||||
if !self.resolution.is_empty() { parts.push(self.resolution.clone()); }
|
|
||||||
if !self.frame_rate.is_empty() { parts.push(format!("{}fps", self.frame_rate)); }
|
|
||||||
if self.hdr != HdrFormat::Sdr { parts.push(self.hdr.name().to_string()); }
|
|
||||||
if self.color_space != ColorSpace::Unknown && self.color_space != ColorSpace::Bt709 {
|
|
||||||
parts.push(self.color_space.name().to_string());
|
|
||||||
}
|
|
||||||
if self.secondary { parts.push(format!("[{}]", self.label)); }
|
|
||||||
parts.join(" ")
|
|
||||||
}
|
|
||||||
StreamKind::Audio => {
|
|
||||||
let mut parts = vec![self.codec.name().to_string()];
|
|
||||||
if !self.channels.is_empty() { parts.push(self.channels.clone()); }
|
|
||||||
if !self.sample_rate.is_empty() { parts.push(self.sample_rate.clone()); }
|
|
||||||
if !self.language.is_empty() { parts.push(format!("({})", self.language)); }
|
|
||||||
if self.secondary { parts.push("[secondary]".to_string()); }
|
|
||||||
parts.join(" ")
|
|
||||||
}
|
|
||||||
StreamKind::Subtitle => {
|
|
||||||
let mut parts = vec![self.codec.name().to_string()];
|
|
||||||
if !self.language.is_empty() { parts.push(format!("({})", self.language)); }
|
|
||||||
parts.join(" ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Kind as a display string
|
|
||||||
pub fn kind_name(&self) -> &'static str {
|
|
||||||
match self.kind {
|
|
||||||
StreamKind::Video => "Video",
|
|
||||||
StreamKind::Audio => "Audio",
|
|
||||||
StreamKind::Subtitle => "Subtitle",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── AACS state ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// AACS decryption state for a disc.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct AacsState {
|
|
||||||
/// AACS version (1 or 2)
|
|
||||||
pub version: u8,
|
|
||||||
/// Whether bus encryption is enabled (always true for AACS 2.0 / UHD)
|
|
||||||
pub bus_encryption: bool,
|
|
||||||
/// MKB version from disc (e.g. 68, 77)
|
|
||||||
pub mkb_version: Option<u32>,
|
|
||||||
/// Disc hash (SHA1 of Unit_Key_RO.inf) — hex string with 0x prefix
|
|
||||||
pub disc_hash: String,
|
|
||||||
/// How keys were resolved
|
|
||||||
pub key_source: KeySource,
|
|
||||||
/// Volume Unique Key (16 bytes)
|
|
||||||
pub vuk: [u8; 16],
|
|
||||||
/// Decrypted unit keys (CPS unit number, key)
|
|
||||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
|
||||||
/// Read data key for AACS 2.0 bus decryption — None for AACS 1.0
|
|
||||||
pub read_data_key: Option<[u8; 16]>,
|
|
||||||
/// Volume ID (16 bytes) — from SCSI handshake
|
|
||||||
pub volume_id: [u8; 16],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How AACS keys were resolved.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub enum KeySource {
|
|
||||||
/// VUK found directly in KEYDB by disc hash
|
|
||||||
KeyDb,
|
|
||||||
/// Media key + Volume ID from KEYDB → derived VUK
|
|
||||||
KeyDbDerived,
|
|
||||||
/// MKB + processing keys → media key → VUK
|
|
||||||
ProcessingKey,
|
|
||||||
/// MKB + device keys → subset-difference tree → VUK
|
|
||||||
DeviceKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KeySource {
|
|
||||||
pub fn name(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
KeySource::KeyDb => "KEYDB",
|
|
||||||
KeySource::KeyDbDerived => "KEYDB (derived)",
|
|
||||||
KeySource::ProcessingKey => "MKB + processing key",
|
|
||||||
KeySource::DeviceKey => "MKB + device key",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Disc scanning ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Standard KEYDB.cfg search locations (compatible with libaacs).
|
|
||||||
const KEYDB_SEARCH_PATHS: &[&str] = &[
|
|
||||||
".config/aacs/KEYDB.cfg", // relative to $HOME
|
|
||||||
];
|
|
||||||
const KEYDB_SYSTEM_PATH: &str = "/etc/aacs/KEYDB.cfg";
|
|
||||||
|
|
||||||
/// Options for disc scanning.
|
|
||||||
pub struct ScanOptions {
|
|
||||||
/// Path to KEYDB.cfg for AACS key lookup.
|
|
||||||
/// If None, searches standard locations ($HOME/.config/aacs/ and /etc/aacs/).
|
|
||||||
pub keydb_path: Option<std::path::PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ScanOptions {
|
|
||||||
fn default() -> Self {
|
|
||||||
ScanOptions { keydb_path: None }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ScanOptions {
|
|
||||||
/// Create options with a specific KEYDB path.
|
|
||||||
pub fn with_keydb(path: impl Into<std::path::PathBuf>) -> Self {
|
|
||||||
ScanOptions { keydb_path: Some(path.into()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve KEYDB path: explicit path first, then standard locations.
|
|
||||||
fn resolve_keydb(&self) -> Option<std::path::PathBuf> {
|
|
||||||
if let Some(p) = &self.keydb_path {
|
|
||||||
if p.exists() { return Some(p.clone()); }
|
|
||||||
}
|
|
||||||
if let Some(home) = std::env::var_os("HOME") {
|
|
||||||
for relative in KEYDB_SEARCH_PATHS {
|
|
||||||
let p = std::path::PathBuf::from(&home).join(relative);
|
|
||||||
if p.exists() { return Some(p); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let p = std::path::PathBuf::from(KEYDB_SYSTEM_PATH);
|
|
||||||
if p.exists() { return Some(p); }
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Disc {
|
|
||||||
/// Disc capacity in GB
|
|
||||||
pub fn capacity_gb(&self) -> f64 {
|
|
||||||
self.capacity_sectors as f64 * 2048.0 / (1024.0 * 1024.0 * 1024.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scan a disc — parse filesystem, playlists, streams, and set up AACS decryption.
|
|
||||||
///
|
|
||||||
/// This is the main entry point. After scan(), the Disc is ready:
|
|
||||||
/// - titles are populated with streams
|
|
||||||
/// - AACS keys are derived (if KEYDB available)
|
|
||||||
/// - content can be read and decrypted transparently
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// use libfreemkv::{DriveSession, Disc};
|
|
||||||
/// use libfreemkv::disc::ScanOptions;
|
|
||||||
/// use std::path::Path;
|
|
||||||
///
|
|
||||||
/// let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
|
|
||||||
/// let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
|
||||||
/// for title in &disc.titles {
|
|
||||||
/// println!("{} — {} streams", title.duration_display(), title.streams.len());
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub fn scan(session: &mut DriveSession, opts: &ScanOptions) -> Result<Self> {
|
|
||||||
// Step 1: Read capacity
|
|
||||||
let capacity = Self::read_capacity(session)?;
|
|
||||||
|
|
||||||
// Step 2: Parse UDF filesystem
|
|
||||||
let udf_fs = udf::read_filesystem(session)?;
|
|
||||||
|
|
||||||
// Step 3: Find and parse MPLS playlists
|
|
||||||
let mut titles = Vec::new();
|
|
||||||
if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") {
|
|
||||||
for entry in &playlist_dir.entries {
|
|
||||||
if !entry.is_dir && entry.name.to_lowercase().ends_with(".mpls") {
|
|
||||||
let path = format!("/BDMV/PLAYLIST/{}", entry.name);
|
|
||||||
if let Ok(mpls_data) = udf_fs.read_file(session, &path) {
|
|
||||||
if let Some(title) = Self::parse_playlist(session, &udf_fs, &entry.name, &mpls_data) {
|
|
||||||
titles.push(title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort: longest first
|
|
||||||
titles.sort_by(|a, b| b.duration_secs.partial_cmp(&a.duration_secs).unwrap_or(std::cmp::Ordering::Equal));
|
|
||||||
|
|
||||||
// Step 4: Detect AACS encryption
|
|
||||||
let encrypted = udf_fs.find_dir("/AACS").is_some()
|
|
||||||
|| udf_fs.find_dir("/BDMV/AACS").is_some();
|
|
||||||
|
|
||||||
// Step 5: If encrypted and KEYDB available, authenticate and derive keys
|
|
||||||
let aacs = if encrypted {
|
|
||||||
if let Some(keydb_path) = opts.resolve_keydb() {
|
|
||||||
match Self::setup_aacs(session, &keydb_path) {
|
|
||||||
Ok(state) => Some(state),
|
|
||||||
Err(_) => None, // keys not found, continue without decryption
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Disc {
|
|
||||||
capacity_sectors: capacity,
|
|
||||||
titles,
|
|
||||||
aacs,
|
|
||||||
encrypted,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set up AACS decryption for this disc.
|
|
||||||
/// Call after scan() to enable transparent content decryption.
|
|
||||||
pub fn setup_aacs(
|
|
||||||
session: &mut DriveSession,
|
|
||||||
keydb_path: &std::path::Path,
|
|
||||||
) -> Result<AacsState> {
|
|
||||||
use crate::aacs::{self, KeyDb};
|
|
||||||
use crate::aacs::handshake;
|
|
||||||
|
|
||||||
// Load KEYDB
|
|
||||||
let keydb = KeyDb::load(keydb_path).map_err(|e| Error::AacsError {
|
|
||||||
detail: format!("failed to load KEYDB: {}", e),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Step 1: Try SCSI handshake for Volume ID + read_data_key
|
|
||||||
// Open a separate transport (AACS auth must happen before raw mode).
|
|
||||||
// If handshake fails (drive doesn't support AACS layer, e.g. raw-mode drives),
|
|
||||||
// fall back to disc-hash-only KEYDB lookup.
|
|
||||||
let device_path = session.device_path().to_string();
|
|
||||||
let mut vid: Option<[u8; 16]> = None;
|
|
||||||
let mut read_data_key: Option<[u8; 16]> = None;
|
|
||||||
|
|
||||||
if !device_path.is_empty() {
|
|
||||||
if let Ok(mut aacs_session) = DriveSession::open_no_unlock(std::path::Path::new(&device_path)) {
|
|
||||||
if let Ok(hc) = keydb.host_cert.as_ref().ok_or(()) {
|
|
||||||
if let Ok(mut auth) = handshake::aacs_authenticate(
|
|
||||||
&mut aacs_session, &hc.private_key, &hc.certificate,
|
|
||||||
) {
|
|
||||||
vid = handshake::read_volume_id(&mut aacs_session, &mut auth).ok();
|
|
||||||
read_data_key = handshake::read_data_keys(&mut aacs_session, &mut auth)
|
|
||||||
.ok().map(|(rdk, _)| rdk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Handshake failure is not fatal — we can still resolve via disc hash
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Read Unit_Key_RO.inf from disc via UDF (uses the unlocked main session)
|
|
||||||
let udf_fs = udf::read_filesystem(session)?;
|
|
||||||
let uk_ro_data = udf_fs.read_file(session, "/AACS/Unit_Key_RO.inf")
|
|
||||||
.or_else(|_| udf_fs.read_file(session, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
|
||||||
.map_err(|_| Error::AacsError {
|
|
||||||
detail: "failed to read Unit_Key_RO.inf from disc".into(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Step 3: Read Content Certificate (optional — for AACS version detection)
|
|
||||||
let cc_data = udf_fs.read_file(session, "/AACS/Content000.cer")
|
|
||||||
.or_else(|_| udf_fs.read_file(session, "/AACS/Content001.cer"))
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// Step 4: Resolve keys
|
|
||||||
// If we have VID from handshake, use full 4-path chain.
|
|
||||||
// If no VID (handshake failed), use disc-hash-only KEYDB lookup.
|
|
||||||
let mkb_data = aacs::read_mkb_from_drive(session).ok();
|
|
||||||
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
|
|
||||||
|
|
||||||
// Use a zero VID placeholder if handshake failed — resolve_keys
|
|
||||||
// will still work via disc hash (path 1)
|
|
||||||
let vid_for_resolve = vid.unwrap_or([0u8; 16]);
|
|
||||||
|
|
||||||
let resolved = aacs::resolve_keys(
|
|
||||||
&uk_ro_data,
|
|
||||||
cc_data.as_deref(),
|
|
||||||
&vid_for_resolve,
|
|
||||||
&keydb,
|
|
||||||
mkb_data.as_deref(),
|
|
||||||
).ok_or_else(|| Error::AacsError {
|
|
||||||
detail: "failed to resolve AACS keys — disc not in KEYDB".into(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let key_source = match resolved.key_source {
|
|
||||||
1 => KeySource::KeyDb,
|
|
||||||
2 => KeySource::KeyDbDerived,
|
|
||||||
3 => KeySource::ProcessingKey,
|
|
||||||
4 => KeySource::DeviceKey,
|
|
||||||
_ => KeySource::KeyDb,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(AacsState {
|
|
||||||
version: if resolved.aacs2 { 2 } else { 1 },
|
|
||||||
bus_encryption: resolved.bus_encryption,
|
|
||||||
mkb_version: mkb_ver,
|
|
||||||
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
|
|
||||||
key_source,
|
|
||||||
vuk: resolved.vuk,
|
|
||||||
unit_keys: resolved.unit_keys,
|
|
||||||
read_data_key,
|
|
||||||
volume_id: vid.unwrap_or([0u8; 16]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal helpers ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn read_capacity(session: &mut DriveSession) -> Result<u32> {
|
|
||||||
let cdb = [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
|
||||||
let mut buf = [0u8; 8];
|
|
||||||
session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000)?;
|
|
||||||
let lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
|
||||||
Ok(lba + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_playlist(
|
|
||||||
session: &mut DriveSession,
|
|
||||||
udf_fs: &udf::UdfFs,
|
|
||||||
filename: &str,
|
|
||||||
data: &[u8],
|
|
||||||
) -> Option<Title> {
|
|
||||||
let parsed = mpls::parse(data).ok()?;
|
|
||||||
|
|
||||||
// Calculate duration from play items
|
|
||||||
let duration_ticks: u64 = parsed.play_items.iter()
|
|
||||||
.map(|pi| (pi.out_time.saturating_sub(pi.in_time)) as u64)
|
|
||||||
.sum();
|
|
||||||
let duration_secs = duration_ticks as f64 / 45000.0;
|
|
||||||
|
|
||||||
// Skip very short playlists (< 30 seconds)
|
|
||||||
if duration_secs < 30.0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse each clip for EP map → sector extents
|
|
||||||
let mut extents = Vec::new();
|
|
||||||
let mut total_size: u64 = 0;
|
|
||||||
let clip_count = parsed.play_items.len();
|
|
||||||
|
|
||||||
for play_item in &parsed.play_items {
|
|
||||||
let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id);
|
|
||||||
if let Ok(clpi_data) = udf_fs.read_file(session, &clpi_path) {
|
|
||||||
if let Ok(clip_info) = clpi::parse(&clpi_data) {
|
|
||||||
// Use EP map to get sector extents for this clip's time range
|
|
||||||
let clip_extents = clip_info.get_extents(play_item.in_time, play_item.out_time);
|
|
||||||
for ext in &clip_extents {
|
|
||||||
total_size += ext.sector_count as u64 * 2048;
|
|
||||||
}
|
|
||||||
extents.extend(clip_extents);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build streams from STN table
|
|
||||||
let streams: Vec<Stream> = parsed.streams.iter().map(|s| {
|
|
||||||
let kind = match s.stream_type {
|
|
||||||
1 => StreamKind::Video,
|
|
||||||
2 => StreamKind::Audio,
|
|
||||||
3 => StreamKind::Subtitle,
|
|
||||||
_ => StreamKind::Video,
|
|
||||||
};
|
|
||||||
let codec = Codec::from_coding_type(s.coding_type);
|
|
||||||
Stream {
|
|
||||||
kind,
|
|
||||||
pid: s.pid,
|
|
||||||
codec,
|
|
||||||
language: s.language.clone(),
|
|
||||||
resolution: format_resolution(s.video_format, s.video_rate),
|
|
||||||
frame_rate: format_framerate(s.video_rate),
|
|
||||||
channels: format_channels(s.audio_format),
|
|
||||||
sample_rate: format_samplerate(s.audio_rate),
|
|
||||||
hdr: HdrFormat::Sdr,
|
|
||||||
color_space: ColorSpace::Unknown,
|
|
||||||
secondary: false,
|
|
||||||
label: String::new(),
|
|
||||||
}
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
let playlist_num = filename.trim_end_matches(".mpls").trim_end_matches(".MPLS");
|
|
||||||
let playlist_id = playlist_num.parse::<u16>().unwrap_or(0);
|
|
||||||
|
|
||||||
Some(Title {
|
|
||||||
playlist: filename.to_string(),
|
|
||||||
playlist_id,
|
|
||||||
duration_secs,
|
|
||||||
size_bytes: total_size,
|
|
||||||
clip_count,
|
|
||||||
streams,
|
|
||||||
extents,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Decrypted reader ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// A reader that reads m2ts content, decrypting transparently if needed.
|
|
||||||
pub struct ContentReader<'a> {
|
|
||||||
session: &'a mut DriveSession,
|
|
||||||
aacs: Option<&'a AacsState>,
|
|
||||||
extents: Vec<Extent>,
|
|
||||||
current_extent: usize,
|
|
||||||
current_offset: u32, // sectors into current extent
|
|
||||||
unit_key_idx: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Disc {
|
|
||||||
/// Open a title for reading. Decryption is automatic — if the disc
|
|
||||||
/// is encrypted and keys were found during scan(), content is decrypted
|
|
||||||
/// on the fly. Unencrypted discs pass through unchanged.
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// # use libfreemkv::{DriveSession, Disc};
|
|
||||||
/// # use libfreemkv::disc::ScanOptions;
|
|
||||||
/// # use std::path::Path;
|
|
||||||
/// # let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
|
|
||||||
/// let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
|
||||||
/// 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
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub fn open_title<'a>(&'a self, session: &'a mut DriveSession, title_idx: usize) -> Result<ContentReader<'a>> {
|
|
||||||
let title = self.titles.get(title_idx).ok_or_else(|| Error::DiscError {
|
|
||||||
detail: format!("title index {} out of range (have {})", title_idx, self.titles.len()),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(ContentReader {
|
|
||||||
session,
|
|
||||||
aacs: self.aacs.as_ref(),
|
|
||||||
extents: title.extents.clone(),
|
|
||||||
current_extent: 0,
|
|
||||||
current_offset: 0,
|
|
||||||
unit_key_idx: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> ContentReader<'a> {
|
|
||||||
/// Read the next aligned unit (6144 bytes).
|
|
||||||
/// Automatically decrypted if AACS keys are available.
|
|
||||||
/// Returns None when all extents are exhausted.
|
|
||||||
pub fn read_unit(&mut self) -> Result<Option<Vec<u8>>> {
|
|
||||||
if self.current_extent >= self.extents.len() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let extent = &self.extents[self.current_extent];
|
|
||||||
let lba = extent.start_lba + self.current_offset;
|
|
||||||
|
|
||||||
// Read 3 sectors (one aligned unit)
|
|
||||||
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
|
|
||||||
for i in 0..3u32 {
|
|
||||||
let offset = (i as usize) * 2048;
|
|
||||||
let mut sector = [0u8; 2048];
|
|
||||||
session_read_sector(self.session, lba + i, &mut sector)?;
|
|
||||||
unit[offset..offset + 2048].copy_from_slice(§or);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt if needed
|
|
||||||
if let Some(aacs) = &self.aacs {
|
|
||||||
if crate::aacs::is_unit_encrypted(&unit) {
|
|
||||||
let uk = aacs.unit_keys.get(self.unit_key_idx)
|
|
||||||
.map(|(_, k)| *k)
|
|
||||||
.unwrap_or([0u8; 16]);
|
|
||||||
|
|
||||||
crate::aacs::decrypt_unit_full(
|
|
||||||
&mut unit,
|
|
||||||
&uk,
|
|
||||||
aacs.read_data_key.as_ref(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Advance position
|
|
||||||
self.current_offset += 3;
|
|
||||||
if self.current_offset >= extent.sector_count {
|
|
||||||
self.current_extent += 1;
|
|
||||||
self.current_offset = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(unit))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn session_read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8; 2048]) -> Result<()> {
|
|
||||||
let cdb = [
|
|
||||||
crate::scsi::SCSI_READ_10, 0x00,
|
|
||||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
|
||||||
0x00, 0x00, 0x01, 0x00,
|
|
||||||
];
|
|
||||||
session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, buf, 10_000)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Format helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn format_resolution(video_format: u8, _video_rate: u8) -> String {
|
|
||||||
match video_format {
|
|
||||||
1 => "480i".into(),
|
|
||||||
2 => "576i".into(),
|
|
||||||
3 => "480p".into(),
|
|
||||||
4 => "1080i".into(),
|
|
||||||
5 => "720p".into(),
|
|
||||||
6 => "1080p".into(),
|
|
||||||
7 => "576p".into(),
|
|
||||||
8 => "2160p".into(),
|
|
||||||
_ => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_framerate(video_rate: u8) -> String {
|
|
||||||
match video_rate {
|
|
||||||
1 => "23.976".into(),
|
|
||||||
2 => "24".into(),
|
|
||||||
3 => "25".into(),
|
|
||||||
4 => "29.97".into(),
|
|
||||||
6 => "50".into(),
|
|
||||||
7 => "59.94".into(),
|
|
||||||
_ => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_channels(audio_format: u8) -> String {
|
|
||||||
match audio_format {
|
|
||||||
1 => "mono".into(),
|
|
||||||
3 => "stereo".into(),
|
|
||||||
6 => "5.1".into(),
|
|
||||||
12 => "7.1".into(),
|
|
||||||
_ if audio_format > 0 => format!("{}ch", audio_format),
|
|
||||||
_ => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_samplerate(audio_rate: u8) -> String {
|
|
||||||
match audio_rate {
|
|
||||||
1 => "48kHz".into(),
|
|
||||||
4 => "96kHz".into(),
|
|
||||||
5 => "192kHz".into(),
|
|
||||||
12 => "48/192kHz".into(),
|
|
||||||
14 => "48/96kHz".into(),
|
|
||||||
_ => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1597
File diff suppressed because it is too large
Load Diff
+1323
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1101
File diff suppressed because it is too large
Load Diff
+1661
File diff suppressed because it is too large
Load Diff
+1670
File diff suppressed because it is too large
Load Diff
+6561
File diff suppressed because it is too large
Load Diff
+1705
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
|||||||
|
//! `Disc::sweep`'s consumer-side `Sink<WorkItem>`.
|
||||||
|
//!
|
||||||
|
//! Background: the original sweep loop runs strictly serialised —
|
||||||
|
//! SCSI read → decrypt → seek + write → mapfile.record → next iter.
|
||||||
|
//! On a healthy disc the SCSI read costs ~5-12 ms per 64 KB batch and
|
||||||
|
//! the post-read work (decrypt 1-3 ms + file write + mapfile fsync
|
||||||
|
//! 5-15 ms) adds another batch's worth of latency. The drive idles
|
||||||
|
//! during the post-read work; throughput tops out at the *sum* of
|
||||||
|
//! both costs.
|
||||||
|
//!
|
||||||
|
//! A producer/consumer split overlaps the two stages on the generic
|
||||||
|
//! [`crate::io::Pipeline`] + [`crate::io::Sink`] primitive. This module
|
||||||
|
//! is the sweep-specific `Sink` impl; the producer-side state machine
|
||||||
|
//! (read_error context, decrypt, set_speed, halt) stays in
|
||||||
|
//! `Disc::sweep` in `disc/mod.rs`.
|
||||||
|
//!
|
||||||
|
//! Correctness invariants preserved:
|
||||||
|
//! - Mapfile is single-writer (consumer-only). No locking.
|
||||||
|
//! - All `read_error::ReadCtx` state stays on the producer thread.
|
||||||
|
//! - `set_speed` calls happen on the producer thread (same thread that
|
||||||
|
//! owns the `SectorSource`). No new SCSI concurrency.
|
||||||
|
//! - Per-iteration ordering of file-write → mapfile-record is kept
|
||||||
|
//! intact in the consumer (write before record), so the on-disk
|
||||||
|
//! invariant "mapfile only marks Finished what the file has
|
||||||
|
//! received" survives a crash mid-pass.
|
||||||
|
//! - Only one SCSI command is in flight at a time; error-path timing
|
||||||
|
//! is identical and no new retry logic is introduced.
|
||||||
|
|
||||||
|
use std::io::{Seek, SeekFrom, Write};
|
||||||
|
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::io::{Flow, Sink};
|
||||||
|
|
||||||
|
use super::mapfile::{MapStats, Mapfile, SectorStatus};
|
||||||
|
|
||||||
|
/// Reusable zero buffer for SkipFill / GapFill / BisectBad. 64 KB
|
||||||
|
/// matches the existing zero_gap chunk size used by the pre-split
|
||||||
|
/// sweep loop.
|
||||||
|
const ZERO_CHUNK: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// Producer → Consumer messages. The consumer applies these in FIFO
|
||||||
|
/// order; ordering of file writes and mapfile records across items is
|
||||||
|
/// preserved.
|
||||||
|
pub(super) enum WorkItem {
|
||||||
|
/// Successful batch read. Producer has already decrypted `buf` if
|
||||||
|
/// `opts.decrypt` was set. Consumer writes `buf` at `pos` and
|
||||||
|
/// records the range as `Finished`.
|
||||||
|
Good { pos: u64, buf: Vec<u8> },
|
||||||
|
|
||||||
|
/// Bisect inner-loop good single sector (already decrypted by the
|
||||||
|
/// producer). 2048 bytes.
|
||||||
|
BisectGood { pos: u64, buf: Box<[u8; 2048]> },
|
||||||
|
|
||||||
|
/// Bisect inner-loop bad single sector. Consumer writes 2048
|
||||||
|
/// zeros at `pos` and records the sector as `NonTrimmed`.
|
||||||
|
BisectBad { pos: u64 },
|
||||||
|
|
||||||
|
/// Whole-batch zero-fill (failed batch on `SkipBlock`, or the
|
||||||
|
/// failed batch portion of `JumpAhead`). Consumer streams zeros
|
||||||
|
/// across `[pos, pos+len)` and records the range as `NonTrimmed`.
|
||||||
|
SkipFill { pos: u64, len: u64 },
|
||||||
|
|
||||||
|
/// Gap fill following a `JumpAhead`. Same effect as `SkipFill`;
|
||||||
|
/// distinguished only so future logging / instrumentation can
|
||||||
|
/// tell them apart without parsing a flag.
|
||||||
|
GapFill { pos: u64, len: u64 },
|
||||||
|
|
||||||
|
/// Post-read verify downgrade. The producer's `UnitVerifier` found that the
|
||||||
|
/// just-`Finished` clip unit at `[pos, pos+len)` is confidently undecryptable
|
||||||
|
/// (a silent bad read). The consumer re-records the range as `NonTrimmed` so
|
||||||
|
/// the patch pass re-reads it — the ISO bytes (ciphertext) already written by
|
||||||
|
/// the preceding `Good` are left in place for the patch to overwrite. FIFO
|
||||||
|
/// pipe ordering guarantees this arrives AFTER the `Good` that wrote them.
|
||||||
|
MarkBad { pos: u64, len: u64 },
|
||||||
|
|
||||||
|
/// Producer wants the latest mapfile stats for the progress
|
||||||
|
/// callback. Consumer responds on `prog_tx` with a fresh
|
||||||
|
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
|
||||||
|
/// drained the previous snapshot, the new one is silently
|
||||||
|
/// dropped — the producer's local cache stays current enough.
|
||||||
|
StatsRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the consumer sends back to the producer for the progress
|
||||||
|
/// callback.
|
||||||
|
pub(super) struct ProgressSnapshot {
|
||||||
|
pub stats: MapStats,
|
||||||
|
pub bad_ranges: Vec<(u64, u64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Final summary returned by the consumer thread on shutdown — what
|
||||||
|
/// `SweepSink::close` produces, surfaced to the producer via
|
||||||
|
/// `Pipeline::finish`.
|
||||||
|
pub(super) struct ConsumerSummary {
|
||||||
|
pub stats: MapStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain any pending progress snapshots from the consumer. Returns
|
||||||
|
/// the most recent one, if any. The producer caches it and uses it
|
||||||
|
/// for subsequent progress callbacks until a fresh one arrives.
|
||||||
|
pub(super) fn try_recv_progress(rx: &Receiver<ProgressSnapshot>) -> Option<ProgressSnapshot> {
|
||||||
|
let mut latest = None;
|
||||||
|
while let Ok(snap) = rx.try_recv() {
|
||||||
|
latest = Some(snap);
|
||||||
|
}
|
||||||
|
latest
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Sink<WorkItem>` for sweep. Owns the writeback file + mapfile +
|
||||||
|
/// progress back-channel. `apply` carries the file-write +
|
||||||
|
/// mapfile.record per item; `close` drains the writeback pipeline,
|
||||||
|
/// fsyncs the ISO, and flushes the mapfile.
|
||||||
|
pub(super) struct SweepSink {
|
||||||
|
file: crate::io::WritebackFile,
|
||||||
|
map: Mapfile,
|
||||||
|
/// `sync_all`-on-failure-is-an-error iff the output is a regular
|
||||||
|
/// file. `/dev/null` and pipes always fail `sync_all`; that's not
|
||||||
|
/// a real error.
|
||||||
|
is_regular: bool,
|
||||||
|
/// Back-channel for `StatsRequest` responses. The producer caches
|
||||||
|
/// the latest snapshot and uses it for the progress callback;
|
||||||
|
/// dropped sends on a full channel are by design.
|
||||||
|
prog_tx: SyncSender<ProgressSnapshot>,
|
||||||
|
/// Reusable zero buffer for SkipFill / GapFill / BisectBad. Held
|
||||||
|
/// in the sink so each apply call doesn't reallocate.
|
||||||
|
zero: Box<[u8; ZERO_CHUNK]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SweepSink {
|
||||||
|
/// Construct a new `SweepSink` plus the matching progress
|
||||||
|
/// receiver. Channel depth on the back-channel is `1` — the
|
||||||
|
/// producer's cache is the source of truth between snapshots.
|
||||||
|
pub(super) fn new(
|
||||||
|
file: crate::io::WritebackFile,
|
||||||
|
map: Mapfile,
|
||||||
|
is_regular: bool,
|
||||||
|
) -> (Self, Receiver<ProgressSnapshot>) {
|
||||||
|
let (prog_tx, prog_rx) = sync_channel::<ProgressSnapshot>(1);
|
||||||
|
let sink = SweepSink {
|
||||||
|
file,
|
||||||
|
map,
|
||||||
|
is_regular,
|
||||||
|
prog_tx,
|
||||||
|
zero: Box::new([0u8; ZERO_CHUNK]),
|
||||||
|
};
|
||||||
|
(sink, prog_rx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sink<WorkItem> for SweepSink {
|
||||||
|
type Output = ConsumerSummary;
|
||||||
|
|
||||||
|
fn apply(&mut self, item: WorkItem) -> Result<Flow, Error> {
|
||||||
|
match item {
|
||||||
|
WorkItem::Good { pos, buf } => {
|
||||||
|
// Decrypt is on the producer; consumer assumes plaintext.
|
||||||
|
let len = buf.len() as u64;
|
||||||
|
self.file.seek(SeekFrom::Start(pos))?;
|
||||||
|
self.file.write_all(&buf)?;
|
||||||
|
self.map.record(pos, len, SectorStatus::Finished)?;
|
||||||
|
}
|
||||||
|
WorkItem::BisectGood { pos, buf } => {
|
||||||
|
self.file.seek(SeekFrom::Start(pos))?;
|
||||||
|
self.file.write_all(&buf[..])?;
|
||||||
|
self.map.record(pos, 2048, SectorStatus::Finished)?;
|
||||||
|
}
|
||||||
|
WorkItem::BisectBad { pos } => {
|
||||||
|
self.file.seek(SeekFrom::Start(pos))?;
|
||||||
|
self.file.write_all(&self.zero[..2048])?;
|
||||||
|
self.map.record(pos, 2048, SectorStatus::NonTrimmed)?;
|
||||||
|
}
|
||||||
|
WorkItem::SkipFill { pos, len } | WorkItem::GapFill { pos, len } => {
|
||||||
|
self.file.seek(SeekFrom::Start(pos))?;
|
||||||
|
// Subsequent writes are sequential; `WritebackFile`'s
|
||||||
|
// seek-elision keeps them on the writeback pipeline path.
|
||||||
|
let mut filled = 0u64;
|
||||||
|
while filled < len {
|
||||||
|
let chunk = (len - filled).min(self.zero.len() as u64) as usize;
|
||||||
|
self.file.write_all(&self.zero[..chunk])?;
|
||||||
|
filled += chunk as u64;
|
||||||
|
}
|
||||||
|
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
|
||||||
|
}
|
||||||
|
WorkItem::MarkBad { pos, len } => {
|
||||||
|
// Verify downgrade: the ISO bytes are already written by the
|
||||||
|
// preceding Good; only the mapfile status changes so patch
|
||||||
|
// re-reads this range. No file write.
|
||||||
|
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
|
||||||
|
}
|
||||||
|
WorkItem::StatsRequest => {
|
||||||
|
let stats = self.map.stats();
|
||||||
|
// DAMAGE only — NOT NonTried. NonTried is the unread remainder
|
||||||
|
// ahead of the sweep head, not damage; including it made the live
|
||||||
|
// located drilldown (at-risk movie time + range count) treat the
|
||||||
|
// whole unread disc as confirmed damage, so at sweep start it
|
||||||
|
// showed ~full-movie at-risk and melted to 0 as the sweep
|
||||||
|
// progressed. Matches the one-shot progress path, which already
|
||||||
|
// excludes NonTried.
|
||||||
|
let bad_ranges = self.map.ranges_with(&[
|
||||||
|
SectorStatus::NonTrimmed,
|
||||||
|
SectorStatus::Unreadable,
|
||||||
|
SectorStatus::NonScraped,
|
||||||
|
]);
|
||||||
|
// Best-effort: drop on backpressure; producer's cache
|
||||||
|
// stays current enough.
|
||||||
|
let _ = self
|
||||||
|
.prog_tx
|
||||||
|
.try_send(ProgressSnapshot { stats, bad_ranges });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Flow::Continue)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(mut self) -> Result<Self::Output, Error> {
|
||||||
|
// Drain the writeback pipeline + fsync the ISO, then persist
|
||||||
|
// any pending mapfile state. Same finalisation order as the
|
||||||
|
// pre-Pipeline consumer loop.
|
||||||
|
if let Err(e) = self.file.sync_all() {
|
||||||
|
if self.is_regular {
|
||||||
|
return Err(Error::IoError { source: e });
|
||||||
|
}
|
||||||
|
// Non-regular outputs (/dev/null, pipes) always fail
|
||||||
|
// sync_all; that's not a real error.
|
||||||
|
}
|
||||||
|
self.map.flush()?;
|
||||||
|
|
||||||
|
Ok(ConsumerSummary {
|
||||||
|
stats: self.map.stats(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+1021
File diff suppressed because it is too large
Load Diff
-169
@@ -1,169 +0,0 @@
|
|||||||
//! Drive session — open, identify, unlock, and read from optical drives.
|
|
||||||
//!
|
|
||||||
//! `DriveSession` is the entry point for all drive interaction. It handles
|
|
||||||
//! device identification, profile matching, platform-specific unlock, and
|
|
||||||
//! provides both raw sector reads and standard SCSI command execution.
|
|
||||||
//!
|
|
||||||
//! Two open modes:
|
|
||||||
//! - `open()` — identify + unlock. Ready for reading immediately.
|
|
||||||
//! - `open_no_unlock()` — identify only. Used for AACS authentication
|
|
||||||
//! which must happen before the drive enters raw mode.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
use crate::error::{Error, Result};
|
|
||||||
use crate::scsi::ScsiTransport;
|
|
||||||
use crate::identity::DriveId;
|
|
||||||
use crate::profile::{self, DriveProfile, Chipset};
|
|
||||||
use crate::platform::{Platform, DriveStatus};
|
|
||||||
use crate::platform::mt1959::Mt1959;
|
|
||||||
|
|
||||||
/// A drive session with identification, platform, and SCSI transport.
|
|
||||||
///
|
|
||||||
/// Created via `DriveSession::open()` or `DriveSession::open_no_unlock()`.
|
|
||||||
/// All disc reading goes through this struct.
|
|
||||||
pub struct DriveSession {
|
|
||||||
scsi: Box<dyn ScsiTransport>,
|
|
||||||
platform: Box<dyn Platform>,
|
|
||||||
pub profile: DriveProfile,
|
|
||||||
pub drive_id: DriveId,
|
|
||||||
device_path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DriveSession {
|
|
||||||
/// Open a drive, identify it, match a profile, and unlock for raw reads.
|
|
||||||
///
|
|
||||||
/// This is the standard entry point. After `open()`, the drive is ready
|
|
||||||
/// for sector reads, disc scanning, and content extraction.
|
|
||||||
pub fn open(device: &Path) -> Result<Self> {
|
|
||||||
let mut session = Self::open_no_unlock(device)?;
|
|
||||||
let _ = session.unlock(); // silently ignore — unencrypted discs don't need it
|
|
||||||
Ok(session)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open a drive WITHOUT unlocking.
|
|
||||||
///
|
|
||||||
/// Used when AACS authentication must happen before raw mode.
|
|
||||||
/// The AACS SCSI handshake requires the drive's standard firmware
|
|
||||||
/// state — unlocking puts the drive in vendor-specific raw mode
|
|
||||||
/// which disables the AACS layer.
|
|
||||||
pub fn open_no_unlock(device: &Path) -> Result<Self> {
|
|
||||||
let mut transport = crate::scsi::open(device)?;
|
|
||||||
let profiles = profile::load_bundled()?;
|
|
||||||
let drive_id = DriveId::from_drive(transport.as_mut())?;
|
|
||||||
|
|
||||||
let profile = profile::find_by_drive_id(&profiles, &drive_id)
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| Error::UnsupportedDrive {
|
|
||||||
vendor_id: drive_id.vendor_id.trim().to_string(),
|
|
||||||
product_id: drive_id.product_id.trim().to_string(),
|
|
||||||
product_revision: drive_id.product_revision.trim().to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let platform = create_platform(&profile, &drive_id)?;
|
|
||||||
|
|
||||||
Ok(DriveSession {
|
|
||||||
scsi: transport,
|
|
||||||
platform,
|
|
||||||
profile,
|
|
||||||
drive_id,
|
|
||||||
device_path: device.to_string_lossy().to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open with an explicit profile, skipping auto-detection.
|
|
||||||
pub fn open_with_profile(device: &Path, profile: DriveProfile) -> Result<Self> {
|
|
||||||
let mut transport = crate::scsi::open(device)?;
|
|
||||||
let drive_id = DriveId::from_drive(transport.as_mut())?;
|
|
||||||
let platform = create_platform(&profile, &drive_id)?;
|
|
||||||
|
|
||||||
Ok(DriveSession {
|
|
||||||
scsi: transport,
|
|
||||||
platform,
|
|
||||||
profile,
|
|
||||||
drive_id,
|
|
||||||
device_path: device.to_string_lossy().to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Device path this session was opened on.
|
|
||||||
pub fn device_path(&self) -> &str {
|
|
||||||
&self.device_path
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Activate raw disc access mode (vendor-specific unlock).
|
|
||||||
pub fn unlock(&mut self) -> Result<()> {
|
|
||||||
self.platform.unlock(self.scsi.as_mut())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if raw disc access mode is active.
|
|
||||||
pub fn is_unlocked(&self) -> bool {
|
|
||||||
self.platform.is_unlocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read drive status and feature flags.
|
|
||||||
pub fn status(&mut self) -> Result<DriveStatus> {
|
|
||||||
self.platform.status(self.scsi.as_mut())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read drive configuration block.
|
|
||||||
pub fn read_config(&mut self) -> Result<Vec<u8>> {
|
|
||||||
self.platform.read_config(self.scsi.as_mut())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read hardware register.
|
|
||||||
pub fn read_register(&mut self, index: u8) -> Result<[u8; 16]> {
|
|
||||||
self.platform.read_register(self.scsi.as_mut(), index)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calibrate read speed for the current disc.
|
|
||||||
pub fn calibrate(&mut self) -> Result<()> {
|
|
||||||
self.platform.calibrate(self.scsi.as_mut())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read raw disc sectors via platform-specific command.
|
|
||||||
pub fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
|
||||||
self.platform.read_sectors(self.scsi.as_mut(), lba, count, buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Platform-specific probe command.
|
|
||||||
pub fn probe(&mut self, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>> {
|
|
||||||
self.platform.probe(self.scsi.as_mut(), sub_cmd, address, length)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Standard SCSI READ(10) for disc filesystem data (UDF, MPLS, CLPI).
|
|
||||||
pub fn read_disc(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
|
||||||
let cdb = [
|
|
||||||
crate::scsi::SCSI_READ_10, 0x00,
|
|
||||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
|
||||||
0x00,
|
|
||||||
(count >> 8) as u8, count as u8,
|
|
||||||
0x00,
|
|
||||||
];
|
|
||||||
let result = self.scsi.as_mut().execute(
|
|
||||||
&cdb, crate::scsi::DataDirection::FromDevice, buf, 5_000)?;
|
|
||||||
Ok(result.bytes_transferred)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute a raw SCSI CDB. Used by parsers and AACS handshake.
|
|
||||||
pub fn scsi_execute(
|
|
||||||
&mut self,
|
|
||||||
cdb: &[u8],
|
|
||||||
direction: crate::scsi::DataDirection,
|
|
||||||
buf: &mut [u8],
|
|
||||||
timeout_ms: u32,
|
|
||||||
) -> Result<crate::scsi::ScsiResult> {
|
|
||||||
self.scsi.as_mut().execute(cdb, direction, buf, timeout_ms)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create the platform-specific driver for a given chipset.
|
|
||||||
fn create_platform(profile: &DriveProfile, drive_id: &DriveId) -> Result<Box<dyn Platform>> {
|
|
||||||
match profile.chipset {
|
|
||||||
Chipset::MediaTek => Ok(Box::new(Mt1959::new(profile.clone()))),
|
|
||||||
Chipset::Renesas => Err(Error::UnsupportedDrive {
|
|
||||||
vendor_id: drive_id.vendor_id.trim().to_string(),
|
|
||||||
product_id: drive_id.product_id.trim().to_string(),
|
|
||||||
product_revision: "Renesas not yet implemented".to_string(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
//! Drive data capture — read hardware information via SCSI.
|
||||||
|
|
||||||
|
use crate::drive::Drive;
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// Raw data captured from a drive's SCSI responses.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DriveCapture {
|
||||||
|
/// Raw INQUIRY response (96 bytes)
|
||||||
|
pub inquiry: Vec<u8>,
|
||||||
|
/// Raw GET_CONFIG 010C response
|
||||||
|
pub gc_010c: Vec<u8>,
|
||||||
|
/// GET_CONFIG feature responses: (feature_code, feature_name, data)
|
||||||
|
pub features: Vec<CapturedFeature>,
|
||||||
|
/// REPORT_KEY RPC state
|
||||||
|
pub rpc_state: Option<Vec<u8>>,
|
||||||
|
/// MODE SENSE page 2A (capabilities)
|
||||||
|
pub mode_2a: Option<Vec<u8>>,
|
||||||
|
/// READ_BUFFER 0xF1 (Pioneer vendor data)
|
||||||
|
pub rb_f1: Option<Vec<u8>>,
|
||||||
|
/// READ_BUFFER mode 6 (MTK vendor data)
|
||||||
|
pub rb_mode6: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feature codes to capture.
|
||||||
|
const FEATURES: &[(u16, &str)] = &[
|
||||||
|
(0x0000, "Profile List"),
|
||||||
|
(0x0001, "Core"),
|
||||||
|
(0x0003, "Removable Medium"),
|
||||||
|
(0x0010, "Random Readable"),
|
||||||
|
(0x001D, "Multi-Read"),
|
||||||
|
(0x001E, "CD Read"),
|
||||||
|
(0x001F, "DVD Read"),
|
||||||
|
(0x0040, "BD Read"),
|
||||||
|
(0x0041, "BD Write"),
|
||||||
|
(0x0100, "Power Management"),
|
||||||
|
(0x0102, "Embedded Changer"),
|
||||||
|
(0x0107, "Real Time Streaming"),
|
||||||
|
(0x0108, "Serial Number"),
|
||||||
|
(0x010C, "Firmware Information"),
|
||||||
|
(0x010D, "AACS"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Capture all available drive data via SCSI commands.
|
||||||
|
/// Returns raw responses — no formatting, no zipping, no presentation.
|
||||||
|
pub fn capture_drive_data(session: &mut Drive) -> Result<DriveCapture> {
|
||||||
|
let id = &session.drive_id;
|
||||||
|
|
||||||
|
// Already have INQUIRY from drive open
|
||||||
|
let inquiry = id.raw_inquiry.clone();
|
||||||
|
let gc_010c = id.raw_gc_010c.clone();
|
||||||
|
|
||||||
|
// Capture GET_CONFIG features using Drive's query methods
|
||||||
|
let mut features = Vec::new();
|
||||||
|
for &(code, name) in FEATURES {
|
||||||
|
if let Some(data) = session.get_config_feature(code) {
|
||||||
|
features.push(CapturedFeature { code, name, data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vendor-specific READ_BUFFER queries
|
||||||
|
let rb_f1 = session.read_buffer(0x02, 0xF1, 48); // Pioneer
|
||||||
|
let rb_mode6 = session.read_buffer(0x06, 0x00, 32); // MTK
|
||||||
|
|
||||||
|
// Standard queries
|
||||||
|
let rpc_state = session.report_key_rpc_state();
|
||||||
|
let mode_2a = session.mode_sense_page(0x2A);
|
||||||
|
|
||||||
|
Ok(DriveCapture {
|
||||||
|
inquiry,
|
||||||
|
gc_010c,
|
||||||
|
features,
|
||||||
|
rpc_state,
|
||||||
|
mode_2a,
|
||||||
|
rb_f1,
|
||||||
|
rb_mode6,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mask a string for privacy (letters->A, digits->0).
|
||||||
|
pub fn mask_string(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphabetic() {
|
||||||
|
'A'
|
||||||
|
} else if c.is_ascii_digit() {
|
||||||
|
'0'
|
||||||
|
} else {
|
||||||
|
c
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mask bytes for privacy.
|
||||||
|
pub fn mask_bytes(data: &[u8]) -> Vec<u8> {
|
||||||
|
data.iter()
|
||||||
|
.map(|&b| {
|
||||||
|
if b.is_ascii_alphabetic() {
|
||||||
|
b'A'
|
||||||
|
} else if b.is_ascii_digit() {
|
||||||
|
b'0'
|
||||||
|
} else {
|
||||||
|
b
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
//! 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 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) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||||
|
{
|
||||||
|
drives.push((path, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, 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(), 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() {
|
||||||
|
// 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
|
||||||
|
{
|
||||||
|
return Ok((sg_path, DeviceResolution::SrToSg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(), DeviceResolution::Direct))
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//! macOS drive discovery and device resolution.
|
||||||
|
//!
|
||||||
|
//! `find_drives` uses IOKit registry enumeration (via `scsi::list_drives`)
|
||||||
|
//! 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();
|
||||||
|
for info in discovered {
|
||||||
|
let path = std::path::Path::new(&info.path);
|
||||||
|
match crate::scsi::open(path) {
|
||||||
|
Ok(mut transport) => {
|
||||||
|
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||||
|
if !id.raw_inquiry.is_empty()
|
||||||
|
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||||
|
{
|
||||||
|
drives.push((info.path.clone(), id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drives
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok((path.to_string(), DeviceResolution::Direct))
|
||||||
|
}
|
||||||
+2009
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
|||||||
|
//! 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();
|
||||||
|
|
||||||
|
// Try CdRom0..CdRom15
|
||||||
|
for i in 0..16 {
|
||||||
|
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) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||||
|
{
|
||||||
|
drives.push((path, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try drive letters if CdRom didn't find anything
|
||||||
|
if drives.is_empty() {
|
||||||
|
for letter in b'D'..=b'Z' {
|
||||||
|
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) == SCSI_PERIPHERAL_TYPE_OPTICAL
|
||||||
|
{
|
||||||
|
// Normalize so returned paths are consistently in
|
||||||
|
// \\.\ form regardless of which loop matched.
|
||||||
|
drives.push((normalize_path(&path), id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drives
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// Accepts: "D:", "D:\\", "\\.\D:", "\\.\CdRom0"
|
||||||
|
///
|
||||||
|
/// NOTE: A near-identical `normalize_device_path` exists in `scsi::windows`.
|
||||||
|
/// Both are kept because they live in separate `cfg(windows)` modules that
|
||||||
|
/// cannot easily share a helper without introducing cross-module coupling.
|
||||||
|
fn normalize_path(path: &str) -> String {
|
||||||
|
if path.starts_with("\\\\.\\") {
|
||||||
|
return path.to_string();
|
||||||
|
}
|
||||||
|
let trimmed = path.trim_end_matches('\\');
|
||||||
|
if trimmed.len() == 2 && trimmed.as_bytes()[1] == b':' {
|
||||||
|
return format!("\\\\.\\{}", trimmed);
|
||||||
|
}
|
||||||
|
format!("\\\\.\\{}", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_drive_letter() {
|
||||||
|
assert_eq!(normalize_path("D:"), "\\\\.\\D:");
|
||||||
|
assert_eq!(normalize_path("E:\\"), "\\\\.\\E:");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_already_prefixed() {
|
||||||
|
assert_eq!(normalize_path("\\\\.\\D:"), "\\\\.\\D:");
|
||||||
|
assert_eq!(normalize_path("\\\\.\\CdRom0"), "\\\\.\\CdRom0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_cdrom() {
|
||||||
|
assert_eq!(normalize_path("CdRom0"), "\\\\.\\CdRom0");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
//! DVD-Video navigation — read-only resolver for the **main-feature start
|
||||||
|
//! point** (issue #40). Mirrors what a DVD player's nav VM resolves: First-Play
|
||||||
|
//! → menu "Play" → title dispatch → the first cell of the feature, so the rip
|
||||||
|
//! starts at the movie rather than at raw cell 0 (e.g. skipping a leading
|
||||||
|
//! logo/warning segment when the disc's own navigation does).
|
||||||
|
//!
|
||||||
|
//! Byte layout follows the DVD-Video specification (VMGI/VTSI headers,
|
||||||
|
//! PGC/cell tables, PCI/HLI button packets); the VM command decoder is
|
||||||
|
//! verified against libdvdnav's decoder.
|
||||||
|
//!
|
||||||
|
//! Current contents: [`vmcmd`] — the VM command decoder (proven against the
|
||||||
|
//! SOTL/Greenland test discs). The IFO/PCI parsing and the navigation executor
|
||||||
|
//! that resolves the start cell build on top of this.
|
||||||
|
|
||||||
|
pub mod vmcmd;
|
||||||
|
|
||||||
|
use crate::sector::SectorSource;
|
||||||
|
|
||||||
|
/// Resolve the feature title's **true start cell** (0-based index into the
|
||||||
|
/// title PGC's cell list) by following the disc's own navigation — First-Play →
|
||||||
|
/// menu "Play" → title dispatch — the way a player reaches the movie. This is
|
||||||
|
/// what lets the rip begin at the feature instead of at raw cell 0 when the
|
||||||
|
/// disc's nav enters the title past a leading logo/warning segment (e.g. a
|
||||||
|
/// disc whose "Play" resolves to a later cell than cell 0).
|
||||||
|
///
|
||||||
|
/// Returns `None` when navigation cannot be resolved, so the caller falls back
|
||||||
|
/// to the structural leading-cell filter (today's behaviour, ≈ cell 0 / 0:00).
|
||||||
|
///
|
||||||
|
/// TODO(#40): the IFO/PCI parsing + nav executor (built on [`vmcmd`]) land
|
||||||
|
/// incrementally. Until the executor is complete this returns `None`, so wiring
|
||||||
|
/// it in is behaviour-neutral; improvements to the resolver take effect here
|
||||||
|
/// without touching the call site.
|
||||||
|
pub fn resolve_feature_start(
|
||||||
|
reader: &mut dyn SectorSource,
|
||||||
|
udf: &crate::udf::UdfFs,
|
||||||
|
vtsn: u16,
|
||||||
|
vts_ttn: u16,
|
||||||
|
) -> Option<usize> {
|
||||||
|
// `reader`/`udf` are the seam inputs the nav executor will consume to read
|
||||||
|
// VIDEO_TS.IFO + the VTS IFOs/menu VOBs. Reserved until that lands.
|
||||||
|
let _ = (reader, udf);
|
||||||
|
tracing::trace!(
|
||||||
|
target: "freemkv::dvdnav",
|
||||||
|
vtsn,
|
||||||
|
vts_ttn,
|
||||||
|
"nav start-cell resolver: unresolved — caller falls back to leading-cell filter"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
//! DVD-Video VM command decoder.
|
||||||
|
//!
|
||||||
|
//! An 8-byte navigation command as found in PGC command tables (pre/post/cell)
|
||||||
|
//! and PCI button info. Decoded per the DVD-Video VM instruction set and
|
||||||
|
//! verified against libdvdnav's command decoder.
|
||||||
|
//!
|
||||||
|
//! Bit model: the 8 bytes are a big-endian 64-bit word. `byte0` bits 7-5 are the
|
||||||
|
//! command **type**; for type 1, `byte0` bit 4 selects Link (0) vs Jump (1), and
|
||||||
|
//! `byte1` bits 3-0 are the sub-command. Compare predicates live in `byte1`
|
||||||
|
//! bits 6-4 with the operands in bytes 2-5.
|
||||||
|
//!
|
||||||
|
//! This module is pure decode + a register model — no I/O, no English (numeric
|
||||||
|
//! semantics only), matching libfreemkv conventions. The navigation *executor*
|
||||||
|
//! and IFO/PCI parsing build on top of this.
|
||||||
|
|
||||||
|
/// A decoded navigation instruction. Only the variants freemkv's start-point
|
||||||
|
/// resolver needs are modelled explicitly; everything else is [`Instr::Other`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Instr {
|
||||||
|
Nop,
|
||||||
|
/// Stop executing the current command list (resume cell playback).
|
||||||
|
Break,
|
||||||
|
/// Goto command line within the same list (1-based).
|
||||||
|
Goto {
|
||||||
|
line: u8,
|
||||||
|
},
|
||||||
|
/// Leave the current domain.
|
||||||
|
Exit,
|
||||||
|
/// Jump to a VMG title (1-based TT_SRPT index).
|
||||||
|
JumpTt {
|
||||||
|
ttn: u8,
|
||||||
|
},
|
||||||
|
/// Jump to a title within the current VTS (1-based VTS title index).
|
||||||
|
JumpVtsTt {
|
||||||
|
ttn: u8,
|
||||||
|
},
|
||||||
|
/// Jump to a part-of-title (chapter) within a VTS title.
|
||||||
|
JumpVtsPtt {
|
||||||
|
ttn: u8,
|
||||||
|
pttn: u16,
|
||||||
|
},
|
||||||
|
/// Jump to the First-Play PGC.
|
||||||
|
JumpSsFp,
|
||||||
|
/// Jump to a Video-Manager menu (`menu` = menu id).
|
||||||
|
JumpSsVmgm {
|
||||||
|
menu: u8,
|
||||||
|
},
|
||||||
|
/// Jump to a Video-Title-Set menu.
|
||||||
|
JumpSsVtsm {
|
||||||
|
vts: u8,
|
||||||
|
ttn: u8,
|
||||||
|
menu: u8,
|
||||||
|
},
|
||||||
|
/// Jump to a specific VMGM menu PGC.
|
||||||
|
JumpSsVmgmPgc {
|
||||||
|
pgcn: u16,
|
||||||
|
},
|
||||||
|
/// Call a sub-domain (raw retained; resume handled by the executor).
|
||||||
|
CallSs {
|
||||||
|
sub: u8,
|
||||||
|
},
|
||||||
|
/// Link to a PGC number within the current domain.
|
||||||
|
LinkPgcn {
|
||||||
|
pgcn: u16,
|
||||||
|
},
|
||||||
|
/// Link to a part-of-title within the current PGC's title.
|
||||||
|
LinkPttn {
|
||||||
|
pttn: u16,
|
||||||
|
},
|
||||||
|
/// Link to a program number within the current PGC (1-based).
|
||||||
|
LinkPgn {
|
||||||
|
pgn: u8,
|
||||||
|
},
|
||||||
|
/// Link to a cell number within the current PGC (1-based).
|
||||||
|
LinkCn {
|
||||||
|
cn: u8,
|
||||||
|
},
|
||||||
|
/// A link "subset" op (LinkTopCell/NextPG/RSM/…); `sub` is the raw code.
|
||||||
|
LinkSub {
|
||||||
|
sub: u8,
|
||||||
|
},
|
||||||
|
/// Set a GPRM. `op` is the set-op code (1=mov, 3=add, …); value is immediate
|
||||||
|
/// (`imm`) when `immediate`, else the contents of register `src`.
|
||||||
|
SetGprm {
|
||||||
|
reg: u8,
|
||||||
|
op: u8,
|
||||||
|
immediate: bool,
|
||||||
|
imm: u16,
|
||||||
|
src: u8,
|
||||||
|
},
|
||||||
|
/// Set a system parameter / unmodelled set — executor may ignore.
|
||||||
|
SetSystem,
|
||||||
|
/// Anything not individually modelled (kept as raw bytes).
|
||||||
|
Other([u8; 8]),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compare predicate carried by a command (`byte1` bits 6-4). `None` = always.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Compare {
|
||||||
|
/// Compare op: 1=&,2===,3=!=,4=>=,5=>,6=<=,7=<.
|
||||||
|
pub op: u8,
|
||||||
|
/// Left register index (GPRM 0-15, SPRM 128+).
|
||||||
|
pub lhs_reg: u8,
|
||||||
|
/// Right side: immediate when `immediate`, else register `rhs_reg`.
|
||||||
|
pub immediate: bool,
|
||||||
|
pub imm: u16,
|
||||||
|
pub rhs_reg: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fully decoded command: its predicate (if any) and the instruction.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Command {
|
||||||
|
pub compare: Option<Compare>,
|
||||||
|
pub instr: Instr,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command types — `byte0` bits 7-5.
|
||||||
|
const TYPE_SPECIAL: u8 = 0;
|
||||||
|
const TYPE_LINK_JUMP: u8 = 1;
|
||||||
|
const TYPE_SET_SYSTEM: u8 = 2;
|
||||||
|
const TYPE_SET_GPRM: u8 = 3;
|
||||||
|
|
||||||
|
// Special (type 0) sub-commands — `byte1` bits 3-0.
|
||||||
|
const SP_GOTO: u8 = 1;
|
||||||
|
const SP_BREAK: u8 = 2;
|
||||||
|
|
||||||
|
// Jump/Call (type 1, direct=1) sub-commands.
|
||||||
|
const JP_EXIT: u8 = 1;
|
||||||
|
const JP_JUMP_TT: u8 = 2;
|
||||||
|
const JP_JUMP_VTS_TT: u8 = 3;
|
||||||
|
const JP_JUMP_VTS_PTT: u8 = 5;
|
||||||
|
const JP_JUMP_SS: u8 = 6;
|
||||||
|
const JP_CALL_SS: u8 = 8;
|
||||||
|
|
||||||
|
// Link (type 1, direct=0) sub-commands. NOTE: sub-op 0 is NOP/no-link and 1 is
|
||||||
|
// the LinkSub form (libdvdnav `decoder.c` `eval_link_instruction`).
|
||||||
|
const LK_SUB: u8 = 1;
|
||||||
|
const LK_PGCN: u8 = 4;
|
||||||
|
const LK_PTTN: u8 = 5;
|
||||||
|
const LK_PGN: u8 = 6;
|
||||||
|
const LK_CN: u8 = 7;
|
||||||
|
|
||||||
|
// JumpSS sub-domain selector — `byte5` bits 7-6.
|
||||||
|
const SS_FP: u8 = 0;
|
||||||
|
const SS_VMGM_MENU: u8 = 1;
|
||||||
|
const SS_VTSM: u8 = 2;
|
||||||
|
|
||||||
|
// Operand field widths (spec-defined bit counts).
|
||||||
|
const MASK_TTN: u8 = 0x7F; // 7-bit title number
|
||||||
|
const MASK_PGN: u8 = 0x7F; // 7-bit program number
|
||||||
|
const MASK_LINKOP: u8 = 0x1F; // 5-bit link sub-op
|
||||||
|
const MASK_REG: u8 = 0x0F; // 4-bit GPRM index
|
||||||
|
const MASK_MENU: u8 = 0x0F; // 4-bit menu id
|
||||||
|
const MASK_PTTN: u16 = 0x03FF; // 10-bit part-of-title
|
||||||
|
const MASK_PGCN: u16 = 0x7FFF; // 15-bit PGC number
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn be16(b: &[u8; 8], o: usize) -> u16 {
|
||||||
|
((b[o] as u16) << 8) | b[o + 1] as u16
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare-operand layouts ("if_version"s) per libdvdnav `decoder.c`. The op
|
||||||
|
// nibble is always `byte1` bits 6-4; the immediate flag is `byte1` bit 7. The
|
||||||
|
// operand *offsets* differ by command family.
|
||||||
|
//
|
||||||
|
// v1 (special + link): lhs reg = b[3]; rhs imm = bytes4-5 / rhs reg = b[4].
|
||||||
|
// v2 (jump + system-set): lhs reg = b[6]; rhs reg = b[7] (registers only).
|
||||||
|
// v3 (set-GPRM): lhs reg = b[2]; rhs imm = bytes6-7 / rhs reg = b[6].
|
||||||
|
fn if_v1(b: &[u8; 8]) -> Option<Compare> {
|
||||||
|
let op = (b[1] >> 4) & 7;
|
||||||
|
(op != 0).then(|| Compare {
|
||||||
|
op,
|
||||||
|
lhs_reg: b[3],
|
||||||
|
immediate: b[1] >> 7 != 0,
|
||||||
|
imm: be16(b, 4),
|
||||||
|
rhs_reg: b[4],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn if_v2(b: &[u8; 8]) -> Option<Compare> {
|
||||||
|
let op = (b[1] >> 4) & 7;
|
||||||
|
(op != 0).then(|| Compare {
|
||||||
|
op,
|
||||||
|
lhs_reg: b[6],
|
||||||
|
immediate: false,
|
||||||
|
imm: 0,
|
||||||
|
rhs_reg: b[7],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn if_v3(b: &[u8; 8]) -> Option<Compare> {
|
||||||
|
let op = (b[1] >> 4) & 7;
|
||||||
|
(op != 0).then(|| Compare {
|
||||||
|
op,
|
||||||
|
lhs_reg: b[2],
|
||||||
|
immediate: b[1] >> 7 != 0,
|
||||||
|
imm: be16(b, 6),
|
||||||
|
rhs_reg: b[6],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode an 8-byte VM command.
|
||||||
|
pub fn decode(b: &[u8; 8]) -> Command {
|
||||||
|
let typ = b[0] >> 5;
|
||||||
|
let direct = (b[0] >> 4) & 1;
|
||||||
|
let setop = b[0] & 0x0F;
|
||||||
|
let cmd = b[1] & 0x0F;
|
||||||
|
|
||||||
|
// Compare predicate, with the operand layout for this command family
|
||||||
|
// (libdvdnav `decoder.c` `vm_eval_command` type dispatch).
|
||||||
|
let compare = match (typ, direct) {
|
||||||
|
(TYPE_SPECIAL, _) => if_v1(b),
|
||||||
|
(TYPE_LINK_JUMP, 1) => if_v2(b), // jump
|
||||||
|
(TYPE_LINK_JUMP, 0) => if_v1(b), // link
|
||||||
|
(TYPE_SET_SYSTEM, _) => if_v2(b),
|
||||||
|
(TYPE_SET_GPRM, _) => if_v3(b),
|
||||||
|
_ => None, // 4/5/6 compound — not needed by the resolver
|
||||||
|
};
|
||||||
|
|
||||||
|
// JumpSS sub-domain selector lives in byte5 bits 7-6.
|
||||||
|
let ss_sel = b[5] >> 6;
|
||||||
|
|
||||||
|
let instr = match typ {
|
||||||
|
TYPE_LINK_JUMP if direct == 1 => match cmd {
|
||||||
|
JP_EXIT => Instr::Exit,
|
||||||
|
JP_JUMP_TT => Instr::JumpTt {
|
||||||
|
ttn: b[5] & MASK_TTN,
|
||||||
|
},
|
||||||
|
JP_JUMP_VTS_TT => Instr::JumpVtsTt {
|
||||||
|
ttn: b[5] & MASK_TTN,
|
||||||
|
},
|
||||||
|
JP_JUMP_VTS_PTT => Instr::JumpVtsPtt {
|
||||||
|
ttn: b[5] & MASK_TTN,
|
||||||
|
pttn: be16(b, 2) & MASK_PTTN,
|
||||||
|
},
|
||||||
|
JP_JUMP_SS => match ss_sel {
|
||||||
|
SS_FP => Instr::JumpSsFp,
|
||||||
|
SS_VMGM_MENU => Instr::JumpSsVmgm {
|
||||||
|
menu: b[5] & MASK_MENU,
|
||||||
|
},
|
||||||
|
SS_VTSM => Instr::JumpSsVtsm {
|
||||||
|
vts: b[4],
|
||||||
|
ttn: b[3],
|
||||||
|
menu: b[5] & MASK_MENU,
|
||||||
|
},
|
||||||
|
_ => Instr::JumpSsVmgmPgc {
|
||||||
|
pgcn: be16(b, 2) & MASK_PGCN,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
JP_CALL_SS => Instr::CallSs { sub: ss_sel },
|
||||||
|
_ => Instr::Nop,
|
||||||
|
},
|
||||||
|
TYPE_LINK_JUMP => match cmd {
|
||||||
|
// direct == 0 (link). sub-op 0 = NOP/no-link.
|
||||||
|
LK_SUB => Instr::LinkSub {
|
||||||
|
sub: b[7] & MASK_LINKOP,
|
||||||
|
},
|
||||||
|
LK_PGCN => Instr::LinkPgcn {
|
||||||
|
pgcn: be16(b, 6) & MASK_PGCN,
|
||||||
|
},
|
||||||
|
LK_PTTN => Instr::LinkPttn {
|
||||||
|
pttn: be16(b, 6) & MASK_PTTN,
|
||||||
|
},
|
||||||
|
LK_PGN => Instr::LinkPgn {
|
||||||
|
pgn: b[7] & MASK_PGN,
|
||||||
|
},
|
||||||
|
LK_CN => Instr::LinkCn { cn: b[7] },
|
||||||
|
_ => Instr::Nop,
|
||||||
|
},
|
||||||
|
TYPE_SPECIAL => match cmd {
|
||||||
|
SP_GOTO => Instr::Goto { line: b[7] },
|
||||||
|
SP_BREAK => Instr::Break,
|
||||||
|
_ => Instr::Nop,
|
||||||
|
},
|
||||||
|
TYPE_SET_GPRM => Instr::SetGprm {
|
||||||
|
reg: b[3] & MASK_REG,
|
||||||
|
op: setop,
|
||||||
|
immediate: direct != 0,
|
||||||
|
imm: be16(b, 4),
|
||||||
|
src: b[5],
|
||||||
|
},
|
||||||
|
TYPE_SET_SYSTEM => Instr::SetSystem,
|
||||||
|
_ => Instr::Other(*b),
|
||||||
|
};
|
||||||
|
|
||||||
|
Command { compare, instr }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn h(s: &str) -> [u8; 8] {
|
||||||
|
let v: Vec<u8> = (0..8)
|
||||||
|
.map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap())
|
||||||
|
.collect();
|
||||||
|
v.try_into().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// KATs taken from the real SOTL / Greenland discs (decoded in the PoC).
|
||||||
|
#[test]
|
||||||
|
fn greenland_first_play_is_jumptt_1() {
|
||||||
|
let c = decode(&h("3002000000010000"));
|
||||||
|
assert_eq!(c.instr, Instr::JumpTt { ttn: 1 });
|
||||||
|
assert!(c.compare.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sotl_first_play_is_jumpss_vtsm_root() {
|
||||||
|
// 30 06 ... byte5=0x83 -> sub 2 (VTSM), vts=byte4=1, menu=byte5&0xF=3 (root)
|
||||||
|
let c = decode(&h("3006000101830000"));
|
||||||
|
assert_eq!(
|
||||||
|
c.instr,
|
||||||
|
Instr::JumpSsVtsm {
|
||||||
|
vts: 1,
|
||||||
|
ttn: 1,
|
||||||
|
menu: 3
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sotl_title_dispatch_is_conditional_linkpgn_2() {
|
||||||
|
// 20 a6 ... CmpLink: if GPRM0 == 2 -> LinkPGN 2 (cell 2 = the 5:02 start)
|
||||||
|
let c = decode(&h("20a6000000020002"));
|
||||||
|
assert_eq!(c.instr, Instr::LinkPgn { pgn: 2 });
|
||||||
|
let cmp = c.compare.expect("conditional");
|
||||||
|
assert_eq!(cmp.op, 2); // ==
|
||||||
|
assert_eq!(cmp.lhs_reg, 0); // GPRM0
|
||||||
|
assert!(cmp.immediate);
|
||||||
|
assert_eq!(cmp.imm, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sotl_root_button_is_linkpgcn_37() {
|
||||||
|
assert_eq!(
|
||||||
|
decode(&h("2004000000000025")).instr,
|
||||||
|
Instr::LinkPgcn { pgcn: 37 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn greenland_scene_button_is_linkpgn() {
|
||||||
|
assert_eq!(
|
||||||
|
decode(&h("2006000000001401")).instr,
|
||||||
|
Instr::LinkPgn { pgn: 1 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jumpvts_ptt_decodes_ttn_and_pttn() {
|
||||||
|
// synthetic: 30 05 | ptt(bytes2-3)=0x0002 | ttn(byte5)=1
|
||||||
|
let c = decode(&h("3005000200010000"));
|
||||||
|
assert_eq!(c.instr, Instr::JumpVtsPtt { ttn: 1, pttn: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setgprm_immediate_mov() {
|
||||||
|
// SOTL First-Play pre[0]: 71 00 | reg=byte3=6 | imm(bytes4-5)=0x03e8 -> g6 = 1000
|
||||||
|
match decode(&h("7100000603e80000")).instr {
|
||||||
|
Instr::SetGprm {
|
||||||
|
reg,
|
||||||
|
op,
|
||||||
|
immediate,
|
||||||
|
imm,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(reg, 6);
|
||||||
|
assert_eq!(op, 1); // mov
|
||||||
|
assert!(immediate);
|
||||||
|
assert_eq!(imm, 1000);
|
||||||
|
}
|
||||||
|
other => panic!("expected SetGprm, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression for the libdvdnav cross-check: link sub-op 0 = NOP, 1 = LinkSub.
|
||||||
|
#[test]
|
||||||
|
fn link_subop_zero_is_nop_one_is_linksub() {
|
||||||
|
assert_eq!(decode(&h("2000000000000000")).instr, Instr::Nop);
|
||||||
|
assert_eq!(
|
||||||
|
decode(&h("2001000000000010")).instr,
|
||||||
|
Instr::LinkSub { sub: 0x10 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if_version_1 register compare: rhs register is byte4 (not byte5).
|
||||||
|
#[test]
|
||||||
|
fn link_register_compare_rhs_is_byte4() {
|
||||||
|
// 20 26: link, cmp=EQ(2), dircmp=0(register) ; cmd=6 LinkPGN
|
||||||
|
let c = decode(&h("2026000304000002"));
|
||||||
|
assert_eq!(c.instr, Instr::LinkPgn { pgn: 2 });
|
||||||
|
let cmp = c.compare.expect("conditional");
|
||||||
|
assert!(!cmp.immediate);
|
||||||
|
assert_eq!(cmp.lhs_reg, 3);
|
||||||
|
assert_eq!(cmp.rhs_reg, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if_version_2 jump compare: both operands are registers in byte6 / byte7.
|
||||||
|
#[test]
|
||||||
|
fn jump_compare_uses_bytes6_and_7() {
|
||||||
|
// 30 22: jump, cmp=EQ(2) ; cmd=2 JumpTT ttn=byte5=5
|
||||||
|
let c = decode(&h("3022000000050607"));
|
||||||
|
assert_eq!(c.instr, Instr::JumpTt { ttn: 5 });
|
||||||
|
let cmp = c.compare.expect("conditional");
|
||||||
|
assert!(!cmp.immediate);
|
||||||
|
assert_eq!(cmp.lhs_reg, 6);
|
||||||
|
assert_eq!(cmp.rhs_reg, 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1592
-70
File diff suppressed because it is too large
Load Diff
+150
@@ -0,0 +1,150 @@
|
|||||||
|
//! Event system for progress and status reporting.
|
||||||
|
//!
|
||||||
|
//! The lib fires events during operations like rip().
|
||||||
|
//! The app registers a callback to receive them.
|
||||||
|
//! No display logic, no English text — just data.
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! disc.rip(&mut session, 0, output, |event| {
|
||||||
|
//! match event.kind {
|
||||||
|
//! EventKind::BytesRead { bytes, total } => update_progress(bytes, total),
|
||||||
|
//! 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;
|
||||||
|
|
||||||
|
/// An event fired by the lib during operations.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Event {
|
||||||
|
pub kind: EventKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Types of events the lib can fire.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum EventKind {
|
||||||
|
// ── Init sequence events ────────────────────────────────────────
|
||||||
|
/// Drive opened successfully.
|
||||||
|
DriveOpened { device: String },
|
||||||
|
|
||||||
|
/// Drive is ready (disc spun up).
|
||||||
|
DriveReady,
|
||||||
|
|
||||||
|
/// Firmware init completed.
|
||||||
|
InitComplete { success: bool },
|
||||||
|
|
||||||
|
/// Disc probe completed.
|
||||||
|
ProbeComplete { success: bool },
|
||||||
|
|
||||||
|
/// Disc scan completed.
|
||||||
|
ScanComplete { titles: usize },
|
||||||
|
|
||||||
|
// ── Read events ─────────────────────────────────────────────────
|
||||||
|
/// Bytes successfully read and written to output.
|
||||||
|
BytesRead {
|
||||||
|
/// Bytes written so far.
|
||||||
|
bytes: u64,
|
||||||
|
/// Total bytes expected (0 if unknown).
|
||||||
|
total: u64,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A read error occurred. The lib will retry automatically.
|
||||||
|
ReadError {
|
||||||
|
/// Sector that failed.
|
||||||
|
sector: u64,
|
||||||
|
/// Error code.
|
||||||
|
error: Error,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Retrying a failed read.
|
||||||
|
Retry {
|
||||||
|
/// Current attempt number (1-based).
|
||||||
|
attempt: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Drive speed changed (error recovery or restoration).
|
||||||
|
SpeedChange {
|
||||||
|
/// New speed in KB/s (0xFFFF = max).
|
||||||
|
speed_kbs: u16,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Starting a new disc extent.
|
||||||
|
ExtentStart {
|
||||||
|
/// Extent index (0-based).
|
||||||
|
index: usize,
|
||||||
|
/// First sector of extent.
|
||||||
|
start_sector: u64,
|
||||||
|
/// Number of sectors in extent.
|
||||||
|
sector_count: u64,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Sector recovered after a retry (Drive::read multi-phase recovery).
|
||||||
|
SectorRecovered { sector: u64 },
|
||||||
|
|
||||||
|
/// Sector unreadable, zero-filled (skip mode).
|
||||||
|
SectorSkipped { sector: u64 },
|
||||||
|
|
||||||
|
/// Adaptive batch sizer changed the read size.
|
||||||
|
///
|
||||||
|
/// Fires on shrink (read failed at larger size) and on probe-up
|
||||||
|
/// (enough clean reads to try larger again). Consumers use this to
|
||||||
|
/// display a "recovering" state distinct from "ripping normally".
|
||||||
|
BatchSizeChanged {
|
||||||
|
new_size: u16,
|
||||||
|
reason: BatchSizeReason,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Operation complete.
|
||||||
|
Complete {
|
||||||
|
/// Total bytes written.
|
||||||
|
bytes: u64,
|
||||||
|
/// Total read errors encountered.
|
||||||
|
errors: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why the adaptive batch sizer changed size.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BatchSizeReason {
|
||||||
|
/// Read failed; sizer halved the batch.
|
||||||
|
Shrunk,
|
||||||
|
/// Clean-read streak threshold hit; sizer doubled toward preferred.
|
||||||
|
Probed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
//! One-bit cooperative cancellation flag.
|
||||||
|
//!
|
||||||
|
//! `Halt` is a clonable token wrapping `Arc<AtomicBool>`. Pass clones into
|
||||||
|
//! every long-running loop; the loop polls `is_cancelled()` and bails out
|
||||||
|
//! cleanly. Calling `cancel()` from any clone flips the shared flag, and
|
||||||
|
//! every other clone observes it on its next poll.
|
||||||
|
//!
|
||||||
|
//! Why: `Ordering::Relaxed` is sufficient on both load and store because
|
||||||
|
//! this flag is purely advisory — no other memory operations piggyback on
|
||||||
|
//! it for happens-before ordering. Callers that need to publish data
|
||||||
|
//! across threads do so via channels or other synchronization, not via
|
||||||
|
//! this bit.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
/// Clonable, infallible cooperative-cancellation token.
|
||||||
|
///
|
||||||
|
/// Clones share the same underlying flag. `cancel()` is one-way; there is
|
||||||
|
/// no `reset()` by design — construct a fresh `Halt` for a fresh
|
||||||
|
/// operation.
|
||||||
|
///
|
||||||
|
/// Construct with [`Halt::new`] (or [`Halt::default`]). The `Default`
|
||||||
|
/// impl forwards to `new()` — both produce a fresh, uncancelled token.
|
||||||
|
/// The pair exists because clippy's `new_without_default` lint requires
|
||||||
|
/// `Default` whenever a public `new()` is present, even when the two
|
||||||
|
/// would do exactly the same thing.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Halt(Arc<AtomicBool>);
|
||||||
|
|
||||||
|
impl Halt {
|
||||||
|
/// Construct a fresh, uncancelled token.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(Arc::new(AtomicBool::new(false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub fn from_arc(flag: Arc<AtomicBool>) -> Self {
|
||||||
|
Self(flag)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flip the shared flag to cancelled. Idempotent.
|
||||||
|
pub fn cancel(&self) {
|
||||||
|
self.0.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the shared flag.
|
||||||
|
pub fn is_cancelled(&self) -> bool {
|
||||||
|
self.0.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Halt {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared poll interval for halt-aware loops.
|
||||||
|
///
|
||||||
|
/// `bounded_syscall` checks the cancellation flag and the deadline
|
||||||
|
/// every [`POLL_INTERVAL`] while blocked on a worker; the same
|
||||||
|
/// cadence governs `Pipeline::send_with_halt`'s `try_send` retry.
|
||||||
|
/// 250 ms is the sweet spot between responsiveness (operator presses
|
||||||
|
/// Stop, sees it take effect within ~quarter-second) and waste
|
||||||
|
/// (atomic load + clock read is cheap but not free at thousands of
|
||||||
|
/// hertz).
|
||||||
|
///
|
||||||
|
/// Centralised here so the half-dozen halt-polling loops across `io`
|
||||||
|
/// can't drift apart silently.
|
||||||
|
pub const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fresh_is_not_cancelled() {
|
||||||
|
let h = Halt::new();
|
||||||
|
assert!(!h.is_cancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancel_flips_state() {
|
||||||
|
let h = Halt::new();
|
||||||
|
assert!(!h.is_cancelled());
|
||||||
|
h.cancel();
|
||||||
|
assert!(h.is_cancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancel_is_idempotent() {
|
||||||
|
let h = Halt::new();
|
||||||
|
h.cancel();
|
||||||
|
h.cancel();
|
||||||
|
assert!(h.is_cancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clone_shares_state() {
|
||||||
|
let original = Halt::new();
|
||||||
|
let cloned = original.clone();
|
||||||
|
assert!(!original.is_cancelled());
|
||||||
|
assert!(!cloned.is_cancelled());
|
||||||
|
|
||||||
|
// Cancel via the clone; the original observes it.
|
||||||
|
cloned.cancel();
|
||||||
|
assert!(original.is_cancelled());
|
||||||
|
assert!(cloned.is_cancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clone_shares_state_reverse_direction() {
|
||||||
|
let original = Halt::new();
|
||||||
|
let cloned = original.clone();
|
||||||
|
|
||||||
|
// Cancel via the original; the clone observes it.
|
||||||
|
original.cancel();
|
||||||
|
assert!(cloned.is_cancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clone_shares_state_across_threads() {
|
||||||
|
let h = Halt::new();
|
||||||
|
let h2 = h.clone();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
h2.cancel();
|
||||||
|
});
|
||||||
|
handle.join().unwrap();
|
||||||
|
assert!(h.is_cancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_arc_shares_state() {
|
||||||
|
// The 0.18 deprecation-window bridge: a Halt built from an
|
||||||
|
// existing Arc<AtomicBool> must be a *view* over the same bit,
|
||||||
|
// not a fresh copy. Cancelling either side flips both.
|
||||||
|
let arc = Arc::new(AtomicBool::new(false));
|
||||||
|
let halt = Halt::from_arc(arc.clone());
|
||||||
|
assert!(!halt.is_cancelled());
|
||||||
|
assert!(!arc.load(Ordering::Relaxed));
|
||||||
|
|
||||||
|
// Cancel via the wrapping Halt; the original Arc observes it.
|
||||||
|
halt.cancel();
|
||||||
|
assert!(arc.load(Ordering::Relaxed));
|
||||||
|
|
||||||
|
// Conversely: flip the Arc directly; the Halt view observes it.
|
||||||
|
let arc2 = Arc::new(AtomicBool::new(false));
|
||||||
|
let halt2 = Halt::from_arc(arc2.clone());
|
||||||
|
arc2.store(true, Ordering::Relaxed);
|
||||||
|
assert!(halt2.is_cancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn as_arc_returns_backing_flag() {
|
||||||
|
// `as_arc()` must hand back the *same* Arc, not a clone of a
|
||||||
|
// different bit. Verified by writing through the borrowed Arc
|
||||||
|
// and observing through the Halt.
|
||||||
|
let halt = Halt::new();
|
||||||
|
let arc = halt.as_arc().clone();
|
||||||
|
assert!(!halt.is_cancelled());
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
//! The single hex → bytes parser for the whole workspace.
|
||||||
|
//!
|
||||||
|
//! Key material arrives as hex from three third-party sources — the keydb, an
|
||||||
|
//! online key service, and the mapfile's `# freemkv-vid:` comment — and each
|
||||||
|
//! used to parse it slightly differently (one stripped `0x`/`0X`, one stripped
|
||||||
|
//! nothing, one stripped `0x` only). A key written with a prefix one parser
|
||||||
|
//! didn't expect was silently dropped → "can't decrypt" with no error. This is
|
||||||
|
//! the one parser they all call, so the prefix/case/validation rules live in
|
||||||
|
//! exactly one place.
|
||||||
|
//!
|
||||||
|
//! Operates on BYTES, not `&str` char indices: the inputs are untrusted, so a
|
||||||
|
//! multi-byte UTF-8 scalar must reject as malformed, never panic on a
|
||||||
|
//! mid-codepoint slice.
|
||||||
|
|
||||||
|
/// Parse a hex string into bytes. Accepts an optional `0x`/`0X` prefix
|
||||||
|
/// (case-insensitive), then requires an even run of ASCII hex digits. Any
|
||||||
|
/// non-hex byte, or an odd length, yields `None`.
|
||||||
|
pub fn parse_hex_bytes(s: &str) -> Option<Vec<u8>> {
|
||||||
|
let body = strip_prefix(s.trim());
|
||||||
|
let bytes = body.as_bytes();
|
||||||
|
// Empty → empty Vec (a legitimately-empty variable-length field); odd length
|
||||||
|
// is malformed. (`parse_hex_fixed` enforces a concrete length separately.)
|
||||||
|
if bytes.len() % 2 != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(bytes.len() / 2);
|
||||||
|
for pair in bytes.chunks_exact(2) {
|
||||||
|
out.push(byte(pair[0], pair[1])?);
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a hex string into a fixed `[u8; N]`. Accepts an optional `0x`/`0X`
|
||||||
|
/// prefix; requires EXACTLY `2*N` ASCII hex digits after it. `None` on any
|
||||||
|
/// non-hex byte or a length mismatch.
|
||||||
|
pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
|
||||||
|
let body = strip_prefix(s.trim());
|
||||||
|
let bytes = body.as_bytes();
|
||||||
|
if bytes.len() != 2 * N {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = [0u8; N];
|
||||||
|
for (i, slot) in out.iter_mut().enumerate() {
|
||||||
|
*slot = byte(bytes[2 * i], bytes[2 * i + 1])?;
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip a single leading `0x` / `0X` if present (case-insensitive).
|
||||||
|
fn strip_prefix(s: &str) -> &str {
|
||||||
|
s.strip_prefix("0x")
|
||||||
|
.or_else(|| s.strip_prefix("0X"))
|
||||||
|
.unwrap_or(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combine two ASCII hex-digit bytes into one byte. `as char` is intentional:
|
||||||
|
/// for a non-ASCII byte it produces a Latin-1 scalar that `to_digit(16)` then
|
||||||
|
/// rejects — so non-hex (incl. `+`/`-` sign chars) and multi-byte input fail
|
||||||
|
/// cleanly rather than slipping through `from_str_radix`'s sign handling.
|
||||||
|
fn byte(hi: u8, lo: u8) -> Option<u8> {
|
||||||
|
let hi = (hi as char).to_digit(16)?;
|
||||||
|
let lo = (lo as char).to_digit(16)?;
|
||||||
|
Some((hi * 16 + lo) as u8)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_accepts_0x_0x_and_bare_same_result() {
|
||||||
|
let want = [0x00, 0x11, 0xab, 0xCD, 0xef, 0x42, 0x99, 0x00];
|
||||||
|
let bare = "0011abcdef429900";
|
||||||
|
assert_eq!(parse_hex_fixed::<8>(bare), Some(want));
|
||||||
|
assert_eq!(parse_hex_fixed::<8>(&format!("0x{bare}")), Some(want));
|
||||||
|
// The case that used to be dropped by one parser but not another.
|
||||||
|
assert_eq!(parse_hex_fixed::<8>(&format!("0X{bare}")), Some(want));
|
||||||
|
assert_eq!(parse_hex_fixed::<8>(&format!(" 0X{bare} ")), Some(want));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_rejects_wrong_length_and_non_hex_and_signs() {
|
||||||
|
assert_eq!(parse_hex_fixed::<16>("00"), None); // too short
|
||||||
|
assert_eq!(parse_hex_fixed::<2>("00112233"), None); // too long
|
||||||
|
assert_eq!(parse_hex_fixed::<2>("zz11"), None); // non-hex
|
||||||
|
assert_eq!(parse_hex_fixed::<2>("+5-A"), None); // sign chars
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_panic_on_multibyte_of_exact_byte_length() {
|
||||||
|
// "中" is 3 bytes; + 29 'a' = 32 bytes → would mis-slice a &str-indexed
|
||||||
|
// parser. Must reject, not panic.
|
||||||
|
let s = "中".to_string() + &"a".repeat(29);
|
||||||
|
assert_eq!(s.len(), 32);
|
||||||
|
assert_eq!(parse_hex_fixed::<16>(&s), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bytes_variable_length_and_odd_rejected() {
|
||||||
|
assert_eq!(parse_hex_bytes("0xAABBCC"), Some(vec![0xAA, 0xBB, 0xCC]));
|
||||||
|
assert_eq!(parse_hex_bytes("AABBC"), None); // odd
|
||||||
|
// Empty (or prefix-only) → empty Vec: a legitimately-empty field.
|
||||||
|
assert_eq!(parse_hex_bytes(""), Some(vec![]));
|
||||||
|
assert_eq!(parse_hex_bytes("0x"), Some(vec![]));
|
||||||
|
}
|
||||||
|
}
|
||||||
+247
-11
@@ -8,7 +8,7 @@
|
|||||||
//! MMC-6 §5.3.10 — Feature 010Ch (Firmware Information)
|
//! MMC-6 §5.3.10 — Feature 010Ch (Firmware Information)
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::scsi::{ScsiTransport, DataDirection};
|
use crate::scsi::{DataDirection, ScsiTransport};
|
||||||
|
|
||||||
/// Drive identity from standard SCSI commands.
|
/// Drive identity from standard SCSI commands.
|
||||||
///
|
///
|
||||||
@@ -39,8 +39,14 @@ pub struct DriveId {
|
|||||||
/// Format: CCYYMMDDHHMI (12 ASCII characters)
|
/// Format: CCYYMMDDHHMI (12 ASCII characters)
|
||||||
pub firmware_date: String,
|
pub firmware_date: String,
|
||||||
|
|
||||||
|
/// Drive serial number — GET CONFIGURATION Feature 0108h
|
||||||
|
pub serial_number: String,
|
||||||
|
|
||||||
/// Raw 96-byte INQUIRY response for additional parsing if needed.
|
/// Raw 96-byte INQUIRY response for additional parsing if needed.
|
||||||
pub raw_inquiry: Vec<u8>,
|
pub raw_inquiry: Vec<u8>,
|
||||||
|
|
||||||
|
/// Raw GET CONFIGURATION Feature 010Ch response bytes.
|
||||||
|
pub raw_gc_010c: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DriveId {
|
impl DriveId {
|
||||||
@@ -51,22 +57,71 @@ impl DriveId {
|
|||||||
let cdb_inq = [0x12, 0x00, 0x00, 0x00, 0x60, 0x00];
|
let cdb_inq = [0x12, 0x00, 0x00, 0x00, 0x60, 0x00];
|
||||||
transport.execute(&cdb_inq, DataDirection::FromDevice, &mut inquiry, 5000)?;
|
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 mut gc = vec![0u8; 256];
|
||||||
let cdb_gc = [0x46, 0x02, 0x01, 0x0C, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00];
|
let cdb_gc = [0x46, 0x02, 0x01, 0x0C, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00];
|
||||||
let result = transport.execute(&cdb_gc, DataDirection::FromDevice, &mut gc, 5000)?;
|
// `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()),
|
||||||
|
};
|
||||||
|
|
||||||
let firmware_date = if result.bytes_transferred > 12 {
|
// GET CONFIGURATION Feature 0108h — Serial Number.
|
||||||
String::from_utf8_lossy(&gc[12..24.min(result.bytes_transferred)])
|
// Best-effort, like 010Ch above: the serial-number feature is
|
||||||
.trim().to_string()
|
// 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 {
|
||||||
|
// `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 {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self::from_inquiry(&inquiry, &firmware_date))
|
Ok(DriveId {
|
||||||
|
vendor_id: ascii_field(&inquiry, 8, 16),
|
||||||
|
product_id: ascii_field(&inquiry, 16, 32),
|
||||||
|
product_revision: ascii_field(&inquiry, 32, 36),
|
||||||
|
vendor_specific: ascii_field(&inquiry, 36, 43),
|
||||||
|
firmware_date,
|
||||||
|
serial_number,
|
||||||
|
raw_inquiry: inquiry,
|
||||||
|
raw_gc_010c,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build identity from raw INQUIRY bytes and firmware date string.
|
/// Build identity from raw INQUIRY bytes and firmware date string.
|
||||||
|
/// Used by tests and when serial isn't available.
|
||||||
pub fn from_inquiry(inquiry: &[u8], firmware_date: &str) -> Self {
|
pub fn from_inquiry(inquiry: &[u8], firmware_date: &str) -> Self {
|
||||||
DriveId {
|
DriveId {
|
||||||
vendor_id: ascii_field(inquiry, 8, 16),
|
vendor_id: ascii_field(inquiry, 8, 16),
|
||||||
@@ -74,7 +129,9 @@ impl DriveId {
|
|||||||
product_revision: ascii_field(inquiry, 32, 36),
|
product_revision: ascii_field(inquiry, 32, 36),
|
||||||
vendor_specific: ascii_field(inquiry, 36, 43),
|
vendor_specific: ascii_field(inquiry, 36, 43),
|
||||||
firmware_date: firmware_date.to_string(),
|
firmware_date: firmware_date.to_string(),
|
||||||
|
serial_number: String::new(),
|
||||||
raw_inquiry: inquiry.to_vec(),
|
raw_inquiry: inquiry.to_vec(),
|
||||||
|
raw_gc_010c: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,21 +140,26 @@ impl DriveId {
|
|||||||
/// Used to look up this drive in the profile database.
|
/// Used to look up this drive in the profile database.
|
||||||
/// All fields trimmed for consistent matching.
|
/// All fields trimmed for consistent matching.
|
||||||
pub fn match_key(&self) -> String {
|
pub fn match_key(&self) -> String {
|
||||||
format!("{}|{}|{}|{}",
|
format!(
|
||||||
|
"{}|{}|{}|{}",
|
||||||
self.vendor_id.trim(),
|
self.vendor_id.trim(),
|
||||||
self.product_id.trim(),
|
self.product_id.trim(),
|
||||||
self.product_revision.trim(),
|
self.product_revision.trim(),
|
||||||
self.vendor_specific.trim())
|
self.vendor_specific.trim()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for DriveId {
|
impl std::fmt::Display for DriveId {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
write!(f, "{} {} {} {}",
|
write!(
|
||||||
|
f,
|
||||||
|
"{} {} {} {}",
|
||||||
self.vendor_id.trim(),
|
self.vendor_id.trim(),
|
||||||
self.product_id.trim(),
|
self.product_id.trim(),
|
||||||
self.product_revision.trim(),
|
self.product_revision.trim(),
|
||||||
self.vendor_specific.trim())
|
self.vendor_specific.trim()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +176,49 @@ fn ascii_field(data: &[u8], start: usize, end: usize) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn test_bu40n_identity() {
|
fn test_bu40n_identity() {
|
||||||
@@ -149,4 +254,135 @@ mod tests {
|
|||||||
assert_eq!(id.vendor_specific.trim(), "16/04/");
|
assert_eq!(id.vendor_specific.trim(), "16/04/");
|
||||||
assert_eq!(id.firmware_date, "201604250000");
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1782
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
|||||||
|
//! Bounded-syscall primitive: run a (potentially-blocking) operation
|
||||||
|
//! on a worker thread, with a hard wall-clock deadline and an optional
|
||||||
|
//! cooperative [`Halt`] poll. The calling thread is never trapped
|
||||||
|
//! inside a kernel call.
|
||||||
|
//!
|
||||||
|
//! ## Why this exists
|
||||||
|
//!
|
||||||
|
//! [`crate::halt::Halt`] is cooperative: callers poll
|
||||||
|
//! `is_cancelled()`. It cannot reach inside a syscall the kernel
|
||||||
|
//! currently owns the thread for — `libc::sync_file_range`,
|
||||||
|
//! `libc::fsync`, `File::write` on NFS, and so on. `/api/stop` from
|
||||||
|
//! autorip therefore can't unstick a thread sitting in such a syscall.
|
||||||
|
//!
|
||||||
|
//! [`bounded_syscall`] is the escape hatch: it runs `op` on a fresh
|
||||||
|
//! worker thread, then `recv_timeout`s on a rendezvous channel for the
|
||||||
|
//! result. The wait is broken into ~250 ms slices so the calling
|
||||||
|
//! thread can poll the supplied [`Halt`] in between. If the deadline
|
||||||
|
//! elapses or the halt fires, the worker is intentionally leaked — the
|
||||||
|
//! syscall will unwind whenever the kernel decides, or at process
|
||||||
|
//! exit, but the caller is free to fall back to a degraded code path
|
||||||
|
//! (skip the sync, log loudly, etc.).
|
||||||
|
//!
|
||||||
|
//! ## Trade-offs
|
||||||
|
//!
|
||||||
|
//! - **Thread per call.** Cheap (`std::thread::spawn` is < 100 µs on
|
||||||
|
//! Linux/macOS), but not free. Use on coarse-grained finalisation
|
||||||
|
//! syscalls (`sync_all`, `sync_file_range(WAIT_AFTER)`), not on hot
|
||||||
|
//! inner-loop writes.
|
||||||
|
//! - **Leak on timeout.** A wedged syscall keeps a kernel slot and a
|
||||||
|
//! user-space thread around for the rest of the process's life.
|
||||||
|
//! Bounded by the number of independent rip/mux sessions, which is
|
||||||
|
//! one per disc. The alternative — trapping the caller forever —
|
||||||
|
//! defeats the entire purpose of `/api/stop`.
|
||||||
|
//! - **Halt granularity ~250 ms.** Halt observation is not instant;
|
||||||
|
//! it's the worst-case latency of the `recv_timeout` slice. Good
|
||||||
|
//! enough for human-driven stop requests; not suitable for hard
|
||||||
|
//! real-time deadlines.
|
||||||
|
//!
|
||||||
|
//! ## Single source of truth
|
||||||
|
//!
|
||||||
|
//! Do NOT inline this pattern. Every blocking-syscall wrapper in the
|
||||||
|
//! rip + mux pipeline calls this helper, so changes (e.g. swapping the
|
||||||
|
//! channel impl, adjusting the poll slice, adding metrics) land in one
|
||||||
|
//! place.
|
||||||
|
//!
|
||||||
|
//! ## Platform
|
||||||
|
//!
|
||||||
|
//! Pure `std::thread` + `std::sync::mpsc`. No `cfg(target_os)` needed
|
||||||
|
//! here — the helper itself is platform-agnostic. Callers that wrap
|
||||||
|
//! Linux-only syscalls (`sync_file_range`) still need their own
|
||||||
|
//! `#[cfg(target_os = "linux")]` gates; this helper does not.
|
||||||
|
|
||||||
|
use std::sync::mpsc::{RecvTimeoutError, sync_channel};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::halt::{Halt, POLL_INTERVAL};
|
||||||
|
|
||||||
|
/// Failure outcome from a bounded syscall wrapper.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) enum BoundedError {
|
||||||
|
/// The user-visible halt token fired during the wait. The worker
|
||||||
|
/// thread is intentionally leaked — the caller should fall back to
|
||||||
|
/// a degraded code path rather than waiting on the syscall to
|
||||||
|
/// return.
|
||||||
|
Halted,
|
||||||
|
/// The deadline elapsed before the syscall returned. Same leak
|
||||||
|
/// semantics as `Halted`.
|
||||||
|
Timeout,
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a (potentially-blocking) operation on a worker thread with a
|
||||||
|
/// deadline and an optional cooperative halt-token poll. Returns the
|
||||||
|
/// operation's result if it completes within `timeout`; otherwise one
|
||||||
|
/// of [`BoundedError::Halted`] / [`BoundedError::Timeout`] /
|
||||||
|
/// [`BoundedError::WorkerLost`].
|
||||||
|
///
|
||||||
|
/// On `Halted` / `Timeout` the worker thread is intentionally leaked:
|
||||||
|
/// the syscall will unwind whenever the kernel decides, or when the
|
||||||
|
/// process exits. The calling thread is never trapped inside a kernel
|
||||||
|
/// call.
|
||||||
|
///
|
||||||
|
/// `halt` is polled at [`POLL_INTERVAL`] granularity. Pass `None` for
|
||||||
|
/// callers that don't (yet) have a halt token plumbed through —
|
||||||
|
/// behaviour degrades to deadline-only, matching the 0.20.5
|
||||||
|
/// `wait_after_with_timeout` shape this helper generalises.
|
||||||
|
///
|
||||||
|
/// `op` returns `R: Send + 'static`. The closure must own everything
|
||||||
|
/// it touches because it may outlive this call (timeout / halt cases).
|
||||||
|
pub(crate) fn bounded_syscall<F, R>(
|
||||||
|
halt: Option<&Halt>,
|
||||||
|
timeout: Duration,
|
||||||
|
op: F,
|
||||||
|
) -> Result<R, BoundedError>
|
||||||
|
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 /
|
||||||
|
// halt path the receiver is dropped and the worker's send
|
||||||
|
// returns Err, which the worker ignores.
|
||||||
|
let (tx, rx) = sync_channel::<R>(0);
|
||||||
|
let _ = thread::Builder::new()
|
||||||
|
.name("freemkv-bounded-syscall".into())
|
||||||
|
.spawn(move || {
|
||||||
|
// Ignore the send error: if we time out (or get halted)
|
||||||
|
// before the worker finishes, the receiver is dropped
|
||||||
|
// and `tx.send` returns Err. Either way, the worker has
|
||||||
|
// nothing more to do.
|
||||||
|
let _ = tx.send(op());
|
||||||
|
});
|
||||||
|
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
let now = Instant::now();
|
||||||
|
let remaining = deadline.saturating_duration_since(now);
|
||||||
|
let slice = remaining.min(POLL_INTERVAL);
|
||||||
|
match rx.recv_timeout(slice) {
|
||||||
|
Ok(v) => return Ok(v),
|
||||||
|
Err(RecvTimeoutError::Timeout) => {
|
||||||
|
if let Some(h) = halt {
|
||||||
|
if h.is_cancelled() {
|
||||||
|
return Err(BoundedError::Halted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Err(BoundedError::Timeout);
|
||||||
|
}
|
||||||
|
// Otherwise: another slice.
|
||||||
|
}
|
||||||
|
Err(RecvTimeoutError::Disconnected) => {
|
||||||
|
// Worker thread spawn failed, or it panicked before
|
||||||
|
// sending. Caller treats this as "no syscall ran" —
|
||||||
|
// typically a no-op + log.
|
||||||
|
return Err(BoundedError::WorkerLost);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn op_completes_quickly() {
|
||||||
|
let r = bounded_syscall(None, Duration::from_secs(2), || 42u32);
|
||||||
|
assert!(matches!(r, Ok(42)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn op_exceeds_timeout() {
|
||||||
|
// Op sleeps longer than the deadline → Timeout.
|
||||||
|
let r = bounded_syscall(None, Duration::from_millis(300), || {
|
||||||
|
thread::sleep(Duration::from_secs(2));
|
||||||
|
0u32
|
||||||
|
});
|
||||||
|
assert!(matches!(r, Err(BoundedError::Timeout)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn halt_fires_during_wait() {
|
||||||
|
let halt = Halt::new();
|
||||||
|
let halt2 = halt.clone();
|
||||||
|
// Flip the halt from a side thread after ~300 ms — long
|
||||||
|
// enough that the receive loop has rolled at least one
|
||||||
|
// 250 ms slice and is sitting in `recv_timeout` again when
|
||||||
|
// the bit flips.
|
||||||
|
thread::spawn(move || {
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
halt2.cancel();
|
||||||
|
});
|
||||||
|
let r = bounded_syscall(Some(&halt), Duration::from_secs(5), || {
|
||||||
|
thread::sleep(Duration::from_secs(5));
|
||||||
|
0u32
|
||||||
|
});
|
||||||
|
assert!(matches!(r, Err(BoundedError::Halted)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_panics() {
|
||||||
|
// Worker panics → sender drops without sending → recv sees
|
||||||
|
// Disconnected → WorkerLost. We use an explicit panic in the
|
||||||
|
// op closure rather than `panic!()` from inside the channel
|
||||||
|
// machinery; the spawned thread's panic is contained (no
|
||||||
|
// process abort) because we don't `.join()` it.
|
||||||
|
let r = bounded_syscall(None, Duration::from_secs(2), || -> u32 {
|
||||||
|
panic!("intentional test panic");
|
||||||
|
});
|
||||||
|
assert!(matches!(r, Err(BoundedError::WorkerLost)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn halt_already_set_before_call_still_returns_halted() {
|
||||||
|
// Halt observed on the very first poll slice. The op blocks
|
||||||
|
// forever; we must not wait the full timeout to notice the
|
||||||
|
// halt is already set.
|
||||||
|
let halt = Halt::new();
|
||||||
|
halt.cancel();
|
||||||
|
let started = Instant::now();
|
||||||
|
let r = bounded_syscall(Some(&halt), Duration::from_secs(10), || {
|
||||||
|
thread::sleep(Duration::from_secs(10));
|
||||||
|
0u32
|
||||||
|
});
|
||||||
|
assert!(matches!(r, Err(BoundedError::Halted)));
|
||||||
|
// Should bail out within ~1 s; allow 2 s of slack for slow
|
||||||
|
// CI hosts.
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(2),
|
||||||
|
"halt-already-set took {:?}",
|
||||||
|
started.elapsed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ok_path_takes_no_halt_token() {
|
||||||
|
// Sanity: the `None` halt path is the documented zero-config
|
||||||
|
// form (matches the 0.20.5 `wait_after_with_timeout`
|
||||||
|
// behaviour). Op returns immediately; we must observe Ok.
|
||||||
|
let flag = Arc::new(AtomicBool::new(false));
|
||||||
|
let f2 = flag.clone();
|
||||||
|
let r = bounded_syscall(None, Duration::from_secs(2), move || {
|
||||||
|
f2.store(true, Ordering::Relaxed);
|
||||||
|
"ok"
|
||||||
|
});
|
||||||
|
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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
//! `BytePrefetcher` — `std::io::Read` analogue of
|
||||||
|
//! [`crate::sector::PrefetchedSectorSource`].
|
||||||
|
//!
|
||||||
|
//! Spawns a producer thread that fills a bounded pool of `Vec<u8>`
|
||||||
|
//! chunks from the underlying reader and ships them through a
|
||||||
|
//! channel; the consumer pulls filled chunks, uses them, and sends
|
||||||
|
//! the empty `Vec<u8>` back through a recycle channel so the
|
||||||
|
//! producer can re-fill in place. Result: zero allocations and zero
|
||||||
|
//! cross-thread frees in the steady-state hot loop.
|
||||||
|
//!
|
||||||
|
//! This is the byte-stream half of the freemkv mux highway —
|
||||||
|
//! `BytePrefetcher` feeds [`crate::mux::demux_thread::DemuxThread`]
|
||||||
|
//! 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, POLL_INTERVAL};
|
||||||
|
use crossbeam_channel::{Receiver, RecvTimeoutError, SendTimeoutError, Sender, bounded};
|
||||||
|
use std::io::Read;
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
|
/// Items flowing through the forward channel.
|
||||||
|
pub type Batch = std::io::Result<Vec<u8>>;
|
||||||
|
|
||||||
|
/// Forward channel depth — how many filled buffers the producer can
|
||||||
|
/// stay ahead by. Two is enough to absorb a moderate consumer stall
|
||||||
|
/// without piling up bytes.
|
||||||
|
const FORWARD_DEPTH: usize = 2;
|
||||||
|
|
||||||
|
/// Recycle channel depth = forward + 1 so the producer always has at
|
||||||
|
/// least one buffer to fill while the consumer holds one.
|
||||||
|
const RECYCLE_DEPTH: usize = FORWARD_DEPTH + 1;
|
||||||
|
|
||||||
|
/// Default chunk size — 16 MiB matches the ISO-mux sector batch and
|
||||||
|
/// is large enough that per-chunk overhead is amortised; small
|
||||||
|
/// enough that the in-flight memory footprint stays bounded.
|
||||||
|
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<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PrefetchShell {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(h) = self.producer.take() {
|
||||||
|
let _ = h.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawned byte prefetcher. Drop joins the producer thread.
|
||||||
|
pub struct BytePrefetcher {
|
||||||
|
rx: Option<Receiver<Batch>>,
|
||||||
|
recycle_tx: Option<Sender<Vec<u8>>>,
|
||||||
|
producer: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BytePrefetcher {
|
||||||
|
/// Spawn the producer thread. `reader` must be `Send` because it
|
||||||
|
/// moves into the thread. `chunk_bytes` is the size of each
|
||||||
|
/// recycled buffer; pick the natural batch size of the
|
||||||
|
/// downstream demuxer (16 MiB for the BD-TS mux pipeline).
|
||||||
|
pub fn new<R: Read + Send + 'static>(
|
||||||
|
mut reader: R,
|
||||||
|
chunk_bytes: usize,
|
||||||
|
halt: Option<Halt>,
|
||||||
|
) -> 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);
|
||||||
|
|
||||||
|
// Seed the recycle pool. Without these the first
|
||||||
|
// `recycle_rx.recv()` would block forever (no consumer has
|
||||||
|
// returned a buffer yet).
|
||||||
|
for _ in 0..RECYCLE_DEPTH {
|
||||||
|
let _ = recycle_tx.send(vec![0u8; chunk_bytes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
hb.tick(produced_bytes, 0);
|
||||||
|
if cancelled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 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. 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 {
|
||||||
|
// SAFETY: capacity is at least chunk_bytes
|
||||||
|
// after construction.
|
||||||
|
unsafe { buf.set_len(chunk_bytes) };
|
||||||
|
}
|
||||||
|
// Read up to one full chunk. Short reads are
|
||||||
|
// valid and common — pipe `truncate` so the
|
||||||
|
// consumer sees only the bytes that arrived.
|
||||||
|
let n = match reader.read(&mut buf[..]) {
|
||||||
|
Ok(0) => return, // EOF — drop tx, consumer sees RecvError
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(Err(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
produced_bytes += n as u64;
|
||||||
|
buf.truncate(n);
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
rx: Some(rx),
|
||||||
|
recycle_tx: Some(recycle_tx),
|
||||||
|
producer: Some(producer),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peel off the channels for zero-copy pipeline consumption. The
|
||||||
|
/// caller (typically [`crate::mux::demux_thread::DemuxThread`])
|
||||||
|
/// 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) {
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! Linux read-side platform hooks: sequential-access hint at open +
|
||||||
|
//! periodic page-cache eviction during streaming reads.
|
||||||
|
//!
|
||||||
|
//! ## Why both
|
||||||
|
//!
|
||||||
|
//! `POSIX_FADV_SEQUENTIAL` at open widens the kernel's readahead window
|
||||||
|
//! so each pread aggregates into fewer NFS round-trips. `DONTNEED` on
|
||||||
|
//! the consumed window (called periodically by the caller) drops the
|
||||||
|
//! already-read pages from the page cache so an 85 GB streaming ISO
|
||||||
|
//! read doesn't fill memory and starve concurrent writes (the MKV
|
||||||
|
//! output during mux). Together they mirror the write-side
|
||||||
|
//! WritebackPipeline's policy.
|
||||||
|
//!
|
||||||
|
//! ## History
|
||||||
|
//!
|
||||||
|
//! Pre-Phase-1 (0.20.7 baseline) had both. Phase 1's introduction of
|
||||||
|
//! `FileSectorSource` silently dropped the read-side DONTNEED, and
|
||||||
|
//! 0.21.2's revert of `SEQUENTIAL` (mistakenly attributing a regression
|
||||||
|
//! to it) removed the hint. Net effect: 85 GB of ISO reads pinned in
|
||||||
|
//! the page cache + no readahead widening → mux throughput collapse
|
||||||
|
//! from 18 MB/s historical to 2.7-8 MB/s on 0.21.x. Restored in 0.21.6.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
|
||||||
|
pub(super) fn hint_sequential(file: &File, _len_bytes: u64) {
|
||||||
|
// Best-effort: return value ignored. A fadvise failure has no
|
||||||
|
// user-observable consequence.
|
||||||
|
unsafe {
|
||||||
|
libc::posix_fadvise(file.as_raw_fd(), 0, 0, libc::POSIX_FADV_SEQUENTIAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop pages in the half-open byte range `[start, start+len)` from
|
||||||
|
/// the page cache. Called periodically by `read_sectors` to bound the
|
||||||
|
/// read-side page cache pressure.
|
||||||
|
pub(super) fn drop_window(file: &File, start: u64, len: u64) {
|
||||||
|
unsafe {
|
||||||
|
libc::posix_fadvise(
|
||||||
|
file.as_raw_fd(),
|
||||||
|
start as i64,
|
||||||
|
len as i64,
|
||||||
|
libc::POSIX_FADV_DONTNEED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Async-prefetch `len` bytes at `offset` into the page cache. The
|
||||||
|
/// kernel `readahead(2)` syscall queues the I/O and returns
|
||||||
|
/// immediately — it does NOT wait for completion. Called right after
|
||||||
|
/// each consumed read so the next batch's I/O overlaps with the
|
||||||
|
/// caller's processing of the current batch (decrypt + demux + mux).
|
||||||
|
///
|
||||||
|
/// Without this hint, with a synchronous demux consumer running at
|
||||||
|
/// ~50 MB/s and a single-spindle disk capable of ~150 MB/s, the disk
|
||||||
|
/// sits idle ~70% of each iteration because kernel readahead alone
|
||||||
|
/// (capped at `/sys/block/<dev>/queue/read_ahead_kb`, default 128 KB)
|
||||||
|
/// can only pre-stage a tiny slice of the next batch. An explicit
|
||||||
|
/// `readahead()` of the same size as the current batch tells the
|
||||||
|
/// kernel to queue the full next-batch read now.
|
||||||
|
pub(super) fn prefetch(file: &File, offset: u64, len: u64) {
|
||||||
|
unsafe {
|
||||||
|
libc::readahead(file.as_raw_fd(), offset as i64, len as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
//! macOS: hint the kernel to prefetch a generous chunk. macOS has no
|
||||||
|
//! direct `POSIX_FADV_SEQUENTIAL` equivalent; the idiomatic hint is
|
||||||
|
//! `fcntl(F_RDADVISE, &radvisory)` describing the byte range you
|
||||||
|
//! intend to read soon. We point it at the whole file (clamped to a
|
||||||
|
//! ceiling so a multi-TB ISO doesn't ask the kernel to prefetch
|
||||||
|
//! everything at once).
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
|
||||||
|
/// 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) so the kernel's prefetch ≥ our app-level
|
||||||
|
/// pipeline depth.
|
||||||
|
const RDADVISE_MAX_BYTES: i64 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
pub(super) fn hint_sequential(file: &File, len_bytes: u64) {
|
||||||
|
let bytes = (len_bytes as i64).min(RDADVISE_MAX_BYTES);
|
||||||
|
let mut ra = libc::radvisory {
|
||||||
|
ra_offset: 0,
|
||||||
|
ra_count: bytes as libc::c_int,
|
||||||
|
};
|
||||||
|
// Best-effort.
|
||||||
|
unsafe {
|
||||||
|
libc::fcntl(file.as_raw_fd(), libc::F_RDADVISE, &mut ra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// macOS has no direct `POSIX_FADV_DONTNEED` equivalent for a byte
|
||||||
|
/// range. `fcntl(F_NOCACHE)` would disable caching globally on the fd
|
||||||
|
/// (too coarse — we want the unread region to still benefit). Best
|
||||||
|
/// approximation: no-op. macOS's unified buffer cache is generally
|
||||||
|
/// less prone to the pin-everything pathology that triggers the
|
||||||
|
/// regression on Linux NFS clients.
|
||||||
|
pub(super) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||||
|
|
||||||
|
/// Async-prefetch the byte range `[offset, offset+len)`. macOS uses
|
||||||
|
/// the same `fcntl(F_RDADVISE, &radvisory)` primitive as the open-
|
||||||
|
/// time sequential hint, just targeted at a moving window instead of
|
||||||
|
/// the whole file. The kernel queues I/O for the requested range and
|
||||||
|
/// returns immediately.
|
||||||
|
pub(super) fn prefetch(file: &File, offset: u64, len: u64) {
|
||||||
|
let bytes = (len as i64).min(RDADVISE_MAX_BYTES);
|
||||||
|
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(), libc::F_RDADVISE, &mut ra);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
//! [`FileSectorSource`] — read 2048-byte sectors from an ISO file on
|
||||||
|
//! disk via direct `seek + read_exact` (`pread`-equivalent) calls,
|
||||||
|
//! letting the kernel's own readahead policy manage prefetch.
|
||||||
|
//!
|
||||||
|
//! ## Why no app-level buffer
|
||||||
|
//!
|
||||||
|
//! Pre-0.21.3 this source held a 32 MiB (later 4 MiB) read-ahead
|
||||||
|
//! buffer to amortise per-sector NFS round-trips. Empirically that
|
||||||
|
//! buffer hurt: 32 MiB refills bursted the NFS TCP connection hard
|
||||||
|
//! enough to starve the concurrent writer, and even a 4 MiB window
|
||||||
|
//! gave the kernel less freedom to pipeline reads with writes. Direct
|
||||||
|
//! pread per call lets Linux's readahead widen as it detects the
|
||||||
|
//! sequential pattern, and naturally interleaves with writeback.
|
||||||
|
//!
|
||||||
|
//! ## DONTNEED on the consumed window
|
||||||
|
//!
|
||||||
|
//! 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_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
|
||||||
|
//!
|
||||||
|
//! On `open()` each platform issues its "sequential access expected"
|
||||||
|
//! hint so OS-level readahead widens. The hint and the DONTNEED call
|
||||||
|
//! live in per-OS sibling modules ([`linux::hint_sequential`] et al.)
|
||||||
|
//! — no inline `#[cfg]` in this file.
|
||||||
|
//!
|
||||||
|
//! ## Read-ahead prefetch
|
||||||
|
//!
|
||||||
|
//! After every consumed read we issue an OS-level prefetch hint for
|
||||||
|
//! the next equivalent-sized window (`platform::prefetch`). The
|
||||||
|
//! kernel queues that I/O asynchronously and returns immediately, so
|
||||||
|
//! the next batch's read overlaps with the caller's processing of
|
||||||
|
//! the current batch (decrypt + demux + mux). Without this the disk
|
||||||
|
//! sits idle ~70% of each iteration because kernel SEQUENTIAL
|
||||||
|
//! readahead alone (capped at `read_ahead_kb`, default 128 KB) is
|
||||||
|
//! far smaller than our 16 MiB app-level batch.
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod macos;
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||||
|
mod other;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod windows;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use linux as platform;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use macos as platform;
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||||
|
use other as platform;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use windows as platform;
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::sector::SectorSource;
|
||||||
|
|
||||||
|
use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
|
||||||
|
|
||||||
|
/// Bytes-read threshold per `posix_fadvise(DONTNEED)` drop on the
|
||||||
|
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
|
||||||
|
/// cache stays bounded the same way the write side does.
|
||||||
|
///
|
||||||
|
/// 32 MiB is the empirically tuned value on the rip1 test bed (single
|
||||||
|
/// 7200rpm HDD via SATA): smaller windows (8 / 16 MiB) shorten the
|
||||||
|
/// kernel-readahead overlap and slow the producer; larger windows
|
||||||
|
/// (64 / 128 MiB) let the page cache pin enough of the ISO to
|
||||||
|
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
|
||||||
|
const READ_DROP_CHUNK_BYTES_DEFAULT: u64 = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
fn read_drop_chunk_bytes() -> u64 {
|
||||||
|
std::env::var("FREEMKV_READ_DROP_CHUNK_MIB")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse::<u64>().ok())
|
||||||
|
.filter(|&n| n > 0)
|
||||||
|
.map(|n| n * 1024 * 1024)
|
||||||
|
.unwrap_or(READ_DROP_CHUNK_BYTES_DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SectorSource backed by a file (ISO image). Every `read_sectors`
|
||||||
|
/// call is a direct `seek + read_exact` against the underlying file
|
||||||
|
/// — kernel readahead handles prefetch, and every
|
||||||
|
/// [`READ_DROP_CHUNK_BYTES_DEFAULT`] bytes of consumed data the
|
||||||
|
/// platform's `DONTNEED` hook drops the consumed window from the
|
||||||
|
/// page cache to bound memory pressure.
|
||||||
|
pub struct FileSectorSource {
|
||||||
|
file: File,
|
||||||
|
/// Total file size in sectors. Constant after construction;
|
||||||
|
/// surfaced via [`SectorSource::capacity_sectors`].
|
||||||
|
capacity: u32,
|
||||||
|
/// Bytes read since the last DONTNEED drop. Drives the per-
|
||||||
|
/// [`read_drop_chunk_bytes`] page-cache eviction in read_sectors.
|
||||||
|
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. 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSectorSource {
|
||||||
|
/// Open an existing ISO file for reading. Capacity is derived
|
||||||
|
/// from `metadata().len() / 2048`. Returns
|
||||||
|
/// [`Error::IsoTooLarge`] if the file would exceed the 32-bit
|
||||||
|
/// LBA address space (~8 TB).
|
||||||
|
///
|
||||||
|
/// Issues the platform's "sequential access expected" hint on the
|
||||||
|
/// fd (Linux `posix_fadvise(SEQUENTIAL)`, macOS `fcntl(F_RDADVISE)`,
|
||||||
|
/// 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_U64;
|
||||||
|
if sectors > u32::MAX as u64 {
|
||||||
|
return Err(Error::IsoTooLarge {
|
||||||
|
path: path.to_string_lossy().into_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let capacity = sectors as u32;
|
||||||
|
|
||||||
|
// Best-effort sequential hint. Ignored on platforms without
|
||||||
|
// an equivalent primitive (or where the API exists but the
|
||||||
|
// FS doesn't honour it).
|
||||||
|
platform::hint_sequential(&file, len);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
file,
|
||||||
|
capacity,
|
||||||
|
bytes_read_since_drop: 0,
|
||||||
|
drop_window_start: 0,
|
||||||
|
drop_chunk_bytes: read_drop_chunk_bytes(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SectorSource for FileSectorSource {
|
||||||
|
fn capacity_sectors(&self) -> u32 {
|
||||||
|
self.capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_sectors(
|
||||||
|
&mut self,
|
||||||
|
lba: u32,
|
||||||
|
count: u16,
|
||||||
|
out: &mut [u8],
|
||||||
|
_recovery: bool,
|
||||||
|
) -> Result<usize> {
|
||||||
|
let count = count as u32;
|
||||||
|
let bytes = count as usize * SECTOR_BYTES;
|
||||||
|
debug_assert!(
|
||||||
|
out.len() >= bytes,
|
||||||
|
"FileSectorSource::read_sectors: out len {} < requested {}",
|
||||||
|
out.len(),
|
||||||
|
bytes
|
||||||
|
);
|
||||||
|
if count == 0 {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let offset = lba as u64 * SECTOR_BYTES_U64;
|
||||||
|
self.file
|
||||||
|
.seek(SeekFrom::Start(offset))
|
||||||
|
.map_err(|e| Error::IoError { source: e })?;
|
||||||
|
self.file
|
||||||
|
.read_exact(&mut out[..bytes])
|
||||||
|
.map_err(|e| Error::IoError { source: e })?;
|
||||||
|
|
||||||
|
// Queue the next batch's read with the kernel before the
|
||||||
|
// caller starts processing what we just returned. readahead()
|
||||||
|
// is non-blocking — it queues I/O and returns, so the kernel
|
||||||
|
// pulls those pages into cache while the consumer (decrypt +
|
||||||
|
// demux + mux) runs. Next read_sectors call hits a warm cache.
|
||||||
|
platform::prefetch(&self.file, offset + bytes as u64, bytes as u64);
|
||||||
|
|
||||||
|
// Periodic page-cache eviction on the read side. Without
|
||||||
|
// this, an 85 GB streaming ISO read pins the entire file in
|
||||||
|
// the kernel page cache, which starves concurrent writes and
|
||||||
|
// collapses mux throughput. Mirrors the write-side
|
||||||
|
// WritebackPipeline's DONTNEED policy.
|
||||||
|
self.bytes_read_since_drop += bytes as u64;
|
||||||
|
if self.bytes_read_since_drop >= self.drop_chunk_bytes {
|
||||||
|
let drop_start = self.drop_window_start;
|
||||||
|
let drop_len = self.bytes_read_since_drop;
|
||||||
|
platform::drop_window(&self.file, drop_start, drop_len);
|
||||||
|
self.drop_window_start = drop_start + drop_len;
|
||||||
|
self.bytes_read_since_drop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
/// Build a deterministic ISO of `sectors` sectors where sector `n`
|
||||||
|
/// is filled with the byte pattern `((n & 0xff) as u8)`. Lets us
|
||||||
|
/// verify any sector by content alone.
|
||||||
|
fn make_iso(path: &std::path::Path, sectors: u32) {
|
||||||
|
let mut f = std::fs::File::create(path).unwrap();
|
||||||
|
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);
|
||||||
|
f.write_all(&chunk).unwrap();
|
||||||
|
}
|
||||||
|
f.flush().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sectors used by spanning-boundary tests. Pick something that
|
||||||
|
/// exercises multi-megabyte reads without making test ISOs huge.
|
||||||
|
/// 8192 sectors = 16 MiB — large enough to cross any readahead
|
||||||
|
/// chunk size we set the kernel hint to.
|
||||||
|
const TEST_SPAN_SECTORS: u32 = 8192;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sequential_reads_match_file() {
|
||||||
|
let total = TEST_SPAN_SECTORS * 2 + 17;
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("seq.iso");
|
||||||
|
make_iso(&path, total);
|
||||||
|
|
||||||
|
let mut src = FileSectorSource::open(&path).unwrap();
|
||||||
|
assert_eq!(src.capacity_sectors(), total);
|
||||||
|
|
||||||
|
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;
|
||||||
|
assert!(
|
||||||
|
got.iter().all(|b| *b == expected),
|
||||||
|
"sector {lba} content mismatch: expected 0x{expected:02x}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_sector_read_across_chunk_boundary() {
|
||||||
|
let total = TEST_SPAN_SECTORS * 2;
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("span.iso");
|
||||||
|
make_iso(&path, total);
|
||||||
|
|
||||||
|
let mut src = FileSectorSource::open(&path).unwrap();
|
||||||
|
|
||||||
|
let span_lba = TEST_SPAN_SECTORS - 2;
|
||||||
|
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_BYTES..(i + 1) * SECTOR_BYTES] {
|
||||||
|
assert_eq!(*b, expected, "byte mismatch at sub-sector {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backward_seek_reads_correct_bytes() {
|
||||||
|
// Read forward then jump back: the SectorSource contract is
|
||||||
|
// byte-correctness regardless of access pattern.
|
||||||
|
let total = TEST_SPAN_SECTORS * 2 + 5;
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("back.iso");
|
||||||
|
make_iso(&path, total);
|
||||||
|
|
||||||
|
let mut src = FileSectorSource::open(&path).unwrap();
|
||||||
|
let mut got = vec![0u8; SECTOR_BYTES];
|
||||||
|
|
||||||
|
src.read_sectors(TEST_SPAN_SECTORS + 1, 1, &mut got, false)
|
||||||
|
.unwrap();
|
||||||
|
src.read_sectors(0, 1, &mut got, false).unwrap();
|
||||||
|
assert!(got.iter().all(|b| *b == 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_at_eof_returns_correct_bytes() {
|
||||||
|
// File smaller than the readahead chunk — reads near EOF must
|
||||||
|
// still return correct bytes.
|
||||||
|
let total: u32 = 100;
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("small.iso");
|
||||||
|
make_iso(&path, total);
|
||||||
|
|
||||||
|
let mut src = FileSectorSource::open(&path).unwrap();
|
||||||
|
assert_eq!(src.capacity_sectors(), total);
|
||||||
|
|
||||||
|
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;
|
||||||
|
assert!(got.iter().all(|b| *b == expected));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn large_single_read() {
|
||||||
|
// A multi-MB single read must work — the implementation has
|
||||||
|
// no app-level chunking, so this just exercises the direct
|
||||||
|
// pread path on a larger request.
|
||||||
|
let total = TEST_SPAN_SECTORS + 100;
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("big.iso");
|
||||||
|
make_iso(&path, total);
|
||||||
|
|
||||||
|
let mut src = FileSectorSource::open(&path).unwrap();
|
||||||
|
let req = (TEST_SPAN_SECTORS + 1) as u16;
|
||||||
|
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_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_BYTES;
|
||||||
|
assert!(
|
||||||
|
big[last_off..last_off + SECTOR_BYTES]
|
||||||
|
.iter()
|
||||||
|
.all(|b| *b == exp)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_chunk_size_env_override() {
|
||||||
|
// Explicit 8 MiB via env var.
|
||||||
|
// SAFETY: tests in this crate are single-threaded per the
|
||||||
|
// default cargo test harness, but std::env::set_var is
|
||||||
|
// declared `unsafe` since Rust 2024 (it can race with other
|
||||||
|
// threads / TLS). For a test that runs in-process before any
|
||||||
|
// FileSectorSource construction this is safe in practice.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("FREEMKV_READ_DROP_CHUNK_MIB", "8");
|
||||||
|
}
|
||||||
|
assert_eq!(read_drop_chunk_bytes(), 8 * 1024 * 1024);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("FREEMKV_READ_DROP_CHUNK_MIB");
|
||||||
|
}
|
||||||
|
assert_eq!(read_drop_chunk_bytes(), READ_DROP_CHUNK_BYTES_DEFAULT);
|
||||||
|
|
||||||
|
// Garbage env value falls back to default.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("FREEMKV_READ_DROP_CHUNK_MIB", "not-a-number");
|
||||||
|
}
|
||||||
|
assert_eq!(read_drop_chunk_bytes(), READ_DROP_CHUNK_BYTES_DEFAULT);
|
||||||
|
unsafe {
|
||||||
|
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_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//! Fallback for targets without a known sequential-readahead hint
|
||||||
|
//! (BSDs, illumos, etc.). No-op — reads still work, they just don't
|
||||||
|
//! get the OS-level prefetch widening.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
|
||||||
|
pub(super) fn hint_sequential(_file: &File, _len_bytes: u64) {}
|
||||||
|
|
||||||
|
pub(super) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||||
|
|
||||||
|
pub(super) fn prefetch(_file: &File, _offset: u64, _len: u64) {}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//! Windows: the canonical sequential-access hint is
|
||||||
|
//! `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 no-op stub"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows page-cache eviction is not exposed via a posix_fadvise
|
||||||
|
/// equivalent. The kernel does its own working-set management. No-op
|
||||||
|
/// for now.
|
||||||
|
pub(super) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||||
|
|
||||||
|
/// Windows async-prefetch hint. With FILE_FLAG_SEQUENTIAL_SCAN at
|
||||||
|
/// open the kernel already prefetches aggressively, so there's no
|
||||||
|
/// per-range hint we'd add on top. No-op stub for parity with the
|
||||||
|
/// posix platforms.
|
||||||
|
pub(super) fn prefetch(_file: &File, _offset: u64, _len: u64) {}
|
||||||
@@ -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) {}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
//! File I/O helpers that bound kernel cache pressure on big writes.
|
||||||
|
//!
|
||||||
|
//! `WritebackFile` is a drop-in wrapper around `std::fs::File` for any
|
||||||
|
//! call site that performs large sequential writes (sweep, patch, mux,
|
||||||
|
//! etc.). It implements `Write` and `Seek` so existing code paths can
|
||||||
|
//! swap `File` for `WritebackFile` with no body changes. Internally it
|
||||||
|
//! drives a `WritebackPipeline` that, on Linux, drains dirty pages
|
||||||
|
//! continuously at 32 MB granularity to avoid the kernel's
|
||||||
|
//! accumulate-then-burst flush behaviour. macOS and Windows use a
|
||||||
|
//! no-op pipeline — their default cache policies have not been shown
|
||||||
|
//! to exhibit the same pathology for this access pattern.
|
||||||
|
//!
|
||||||
|
//! `FileSectorSource` is the read-side dual — it implements
|
||||||
|
//! [`crate::sector::SectorSource`] for an ISO file using direct
|
||||||
|
//! `pread`-equivalent calls so the kernel's own readahead policy runs
|
||||||
|
//! (which interleaves naturally with the concurrent writeback). It
|
||||||
|
//! pairs that with periodic `posix_fadvise(DONTNEED)` drops on the
|
||||||
|
//! consumed window so an 85 GB streaming ISO read doesn't fill the
|
||||||
|
//! page cache and starve the concurrent MKV write.
|
||||||
|
//!
|
||||||
|
//! `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_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_prefetcher;
|
||||||
|
pub mod file_sector_source;
|
||||||
|
pub mod fsync;
|
||||||
|
pub mod sink;
|
||||||
|
mod writeback;
|
||||||
|
mod writeback_file;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub(crate) mod platform_macos;
|
||||||
|
|
||||||
|
pub mod pipeline;
|
||||||
|
|
||||||
|
pub(crate) use writeback_file::WritebackFile;
|
||||||
|
|
||||||
|
pub use pipeline::{
|
||||||
|
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
|
||||||
|
WRITE_THROUGH_DEPTH,
|
||||||
|
};
|
||||||
+1595
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
//! Shared macOS `fcntl(F_PREALLOCATE)` definitions.
|
||||||
|
//!
|
||||||
|
//! The `libc` crate doesn't expose these symbols across all macOS SDK
|
||||||
|
//! versions, so we define them locally with values from
|
||||||
|
//! `/usr/include/sys/fcntl.h`. Two call sites (
|
||||||
|
//! [`crate::io::writeback_file`] and [`crate::io::sink::preallocate`])
|
||||||
|
//! need the same constants and `fstore_t` layout — keeping a single
|
||||||
|
//! source of truth here prevents the two copies from drifting.
|
||||||
|
//!
|
||||||
|
//! Module-level cfg gate lives in the parent (`io/mod.rs`); this file
|
||||||
|
//! is only compiled on macOS, so no inner `#![cfg]` is needed.
|
||||||
|
|
||||||
|
/// `fcntl(F_PREALLOCATE)` command number from `sys/fcntl.h`.
|
||||||
|
pub(crate) const F_PREALLOCATE: libc::c_int = 42;
|
||||||
|
|
||||||
|
/// Anchor preallocation at the current physical EOF.
|
||||||
|
pub(crate) const F_PEOFPOSMODE: libc::c_int = 3;
|
||||||
|
|
||||||
|
/// Prefer a contiguous allocation. Try this first; on `EINVAL` (no
|
||||||
|
/// contiguous run of that size), fall back to `F_ALLOCATEALL`.
|
||||||
|
pub(crate) const F_ALLOCATECONTIG: libc::c_uint = 0x0000_0002;
|
||||||
|
|
||||||
|
/// Allow non-contiguous allocation. Stronger guarantee than just
|
||||||
|
/// asking for `F_ALLOCATECONTIG` because the kernel will piece
|
||||||
|
/// together fragments rather than failing.
|
||||||
|
pub(crate) const F_ALLOCATEALL: libc::c_uint = 0x0000_0004;
|
||||||
|
|
||||||
|
/// `fstore_t` from `sys/fcntl.h`. `repr(C)` because we hand it to
|
||||||
|
/// `fcntl(F_PREALLOCATE)` which writes through the pointer.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) struct Fstore {
|
||||||
|
pub fst_flags: libc::c_uint,
|
||||||
|
pub fst_posmode: libc::c_int,
|
||||||
|
pub fst_offset: libc::off_t,
|
||||||
|
pub fst_length: libc::off_t,
|
||||||
|
pub fst_bytesalloc: libc::off_t,
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
//! `LocalFileSink` — `BufWriter<File>` for the common local-disk case.
|
||||||
|
//!
|
||||||
|
//! Buffering: 4 MiB internal `BufWriter`. Sized to coalesce the small
|
||||||
|
//! per-PES writes that come out of the muxer into kernel-page-aligned
|
||||||
|
//! flushes without making the buffer big enough to matter for memory
|
||||||
|
//! pressure on a single concurrent rip.
|
||||||
|
//!
|
||||||
|
//! `Seek` flushes the underlying `BufWriter` first; otherwise a seek
|
||||||
|
//! could leapfrog buffered data and silently corrupt the file. This is
|
||||||
|
//! the same shape `BufWriter` itself uses when it impls `Seek` in
|
||||||
|
//! stdlib, and is necessary for MKV's seek-back operations (cluster
|
||||||
|
//! size patch, Cues index, segment header backpatch) to land on the
|
||||||
|
//! right offset.
|
||||||
|
//!
|
||||||
|
//! [`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;
|
||||||
|
|
||||||
|
/// Random-access write sink for local disks.
|
||||||
|
///
|
||||||
|
/// Wraps a `BufWriter<File>` with a 4 MiB internal buffer and forwards
|
||||||
|
/// `Write`/`Seek` so any call site that previously held a `File` or
|
||||||
|
/// `WritebackFile` can drop this in. `finish()` flushes the buffer and
|
||||||
|
/// runs `sync_all` on the underlying file so the caller can drop it
|
||||||
|
/// without losing data.
|
||||||
|
///
|
||||||
|
/// Construction always opens the file `create + truncate + read +
|
||||||
|
/// write`. `read` is enabled so the same handle can be reused for a
|
||||||
|
/// verification re-read after the mux (the existing
|
||||||
|
/// `FileSectorSink::create` pattern). On Linux, [`with_size_hint`]
|
||||||
|
/// additionally calls `fallocate(FALLOC_FL_KEEP_SIZE)` to pre-reserve
|
||||||
|
/// extents.
|
||||||
|
///
|
||||||
|
/// [`with_size_hint`]: Self::with_size_hint
|
||||||
|
pub struct LocalFileSink {
|
||||||
|
inner: BufWriter<File>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalFileSink {
|
||||||
|
/// Open `path` for writing, truncating any existing contents.
|
||||||
|
pub fn create(path: &Path) -> io::Result<Self> {
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(path)?;
|
||||||
|
Ok(Self {
|
||||||
|
inner: BufWriter::with_capacity(BUFFER_BYTES, file),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`Self::create`] but additionally calls the per-OS
|
||||||
|
/// preallocate path with `size_bytes`. On Linux this is
|
||||||
|
/// `fallocate(FALLOC_FL_KEEP_SIZE)` so the on-disk extents are
|
||||||
|
/// reserved up front (reducing fragmentation for big sequential
|
||||||
|
/// muxer output); on other OSes it is a no-op today. Failures
|
||||||
|
/// from the preallocate call are non-fatal — the file is still
|
||||||
|
/// returned, just without the size reservation.
|
||||||
|
pub fn with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(path)?;
|
||||||
|
preallocate::preallocate(&file, size_bytes);
|
||||||
|
Ok(Self {
|
||||||
|
inner: BufWriter::with_capacity(BUFFER_BYTES, file),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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).
|
||||||
|
/// [`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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
|
||||||
|
self.inner.write_all(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
self.inner.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Seek for LocalFileSink {
|
||||||
|
fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
|
||||||
|
// Flush before seeking so buffered bytes land at the offset
|
||||||
|
// they were written for, not the new one.
|
||||||
|
self.inner.flush()?;
|
||||||
|
self.inner.get_mut().seek(from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_seek_roundtrip() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let p = dir.path().join("rt.bin");
|
||||||
|
let mut s = LocalFileSink::create(&p).unwrap();
|
||||||
|
s.write_all(b"AAAA").unwrap();
|
||||||
|
s.write_all(b"BBBB").unwrap();
|
||||||
|
// Seek back over the second word and overwrite.
|
||||||
|
s.seek(SeekFrom::Start(4)).unwrap();
|
||||||
|
s.write_all(b"CCCC").unwrap();
|
||||||
|
s.sync_all().unwrap();
|
||||||
|
drop(s);
|
||||||
|
|
||||||
|
let mut f = File::open(&p).unwrap();
|
||||||
|
let mut got = Vec::new();
|
||||||
|
f.read_to_end(&mut got).unwrap();
|
||||||
|
assert_eq!(&got[..], b"AAAACCCC");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_flushes() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let p = dir.path().join("drop.bin");
|
||||||
|
{
|
||||||
|
let mut s = LocalFileSink::create(&p).unwrap();
|
||||||
|
s.write_all(b"buffered").unwrap();
|
||||||
|
// No explicit flush / sync_all — BufWriter drop runs the
|
||||||
|
// flush and the file should land on disk.
|
||||||
|
}
|
||||||
|
let bytes = std::fs::read(&p).unwrap();
|
||||||
|
assert_eq!(&bytes[..], b"buffered");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_size_hint_creates_writable_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let p = dir.path().join("sz.bin");
|
||||||
|
let mut s = LocalFileSink::with_size_hint(&p, 64 * 1024).unwrap();
|
||||||
|
s.write_all(b"hint-ok").unwrap();
|
||||||
|
s.sync_all().unwrap();
|
||||||
|
drop(s);
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
//! Output-sink trait split for the buffering architecture.
|
||||||
|
//!
|
||||||
|
//! Two traits, one for each capability axis of an output destination:
|
||||||
|
//!
|
||||||
|
//! - [`SequentialSink`] — anything you can `Write` to in order. Sockets,
|
||||||
|
//! pipes, append-only stores, plain files. Containers that don't need
|
||||||
|
//! seek (M2TS, fMP4, HEVC elementary) target this.
|
||||||
|
//! - [`RandomAccessSink`] — everything `SequentialSink` plus a working
|
||||||
|
//! `Seek`. Local files, NFS files, anything with random-write
|
||||||
|
//! semantics. Containers that need backpatch (MKV cluster sizes, Cues
|
||||||
|
//! index, MP4 moov-at-end) target this.
|
||||||
|
//!
|
||||||
|
//! `RandomAccessSink: SequentialSink` — every random-access sink is
|
||||||
|
//! also a valid sequential sink. The muxer is generic over which it
|
||||||
|
//! requires (`MkvMux<S: RandomAccessSink>`, `M2tsMux<S: SequentialSink>`)
|
||||||
|
//! so an attempt to mux MKV to a network socket is a compile error.
|
||||||
|
//!
|
||||||
|
//! Buffering policy belongs to the concrete sink, not to a wrapper at
|
||||||
|
//! the call site. `LocalFileSink` wraps a `BufWriter<File>` with a
|
||||||
|
//! 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.
|
||||||
|
|
||||||
|
use std::io::{Seek, Write};
|
||||||
|
|
||||||
|
mod local_file;
|
||||||
|
mod preallocate;
|
||||||
|
mod socket;
|
||||||
|
|
||||||
|
pub use local_file::LocalFileSink;
|
||||||
|
pub use socket::{SocketSink, UdpSocketSink};
|
||||||
|
|
||||||
|
/// Sequential-only write destination. Sockets, pipes, append-only
|
||||||
|
/// stores. No seek. Implementations own their write buffering — the
|
||||||
|
/// 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 + 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<()> {
|
||||||
|
self.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Random-access write destination. Local files, NFS files, anything
|
||||||
|
/// with a working `Seek`. Inherits the `SequentialSink` contract — a
|
||||||
|
/// random-access sink is always usable as a sequential sink.
|
||||||
|
pub trait RandomAccessSink: SequentialSink + Seek {}
|
||||||
|
|
||||||
|
/// Pick the right `RandomAccessSink` impl for `dest` based on its
|
||||||
|
/// filesystem type.
|
||||||
|
///
|
||||||
|
/// - Linux + NFS path → `WritebackFile` with its adaptive-chunk
|
||||||
|
/// sync_file_range machinery and (when supported) `fallocate` size
|
||||||
|
/// hint.
|
||||||
|
/// - everything else → [`LocalFileSink`] over `BufWriter<File>`. On
|
||||||
|
/// non-Linux there is no `WritebackFile` machinery to opt into, and
|
||||||
|
/// on local Linux the kernel's default writeback policy is already
|
||||||
|
/// fine.
|
||||||
|
///
|
||||||
|
/// `size_hint`, when present, is forwarded to the per-OS preallocate
|
||||||
|
/// path (`fallocate(KEEP_SIZE)` on Linux, `F_PREALLOCATE` on macOS when
|
||||||
|
/// implemented, no-op elsewhere).
|
||||||
|
///
|
||||||
|
/// Returns a boxed trait object so the call site (mux construction)
|
||||||
|
/// stays agnostic of which concrete sink got picked.
|
||||||
|
// 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(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)?,
|
||||||
|
None => crate::io::WritebackFile::create(dest)?,
|
||||||
|
};
|
||||||
|
return Ok(Box::new(wf));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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 _ = crate::platform::fs_type::detect;
|
||||||
|
|
||||||
|
let sink = match size_hint {
|
||||||
|
Some(n) => LocalFileSink::with_size_hint(dest, n)?,
|
||||||
|
None => LocalFileSink::create(dest)?,
|
||||||
|
};
|
||||||
|
Ok(Box::new(sink))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// 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 concrete_sinks_satisfy_traits() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
// `LocalFileSink` is a random-access (and thus sequential) sink.
|
||||||
|
let mut s = LocalFileSink::create(&dir.path().join("b.bin")).unwrap();
|
||||||
|
_assert_is_sequential(&mut s);
|
||||||
|
_assert_is_random_access(&mut s);
|
||||||
|
|
||||||
|
// `WritebackFile` ditto, via its explicit per-type impls.
|
||||||
|
let mut wf = crate::io::WritebackFile::create(&dir.path().join("c.bin")).unwrap();
|
||||||
|
_assert_is_sequential(&mut wf);
|
||||||
|
_assert_is_random_access(&mut wf);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_for_mkv_returns_a_random_access_sink() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let p = dir.path().join("c.bin");
|
||||||
|
let mut sink = open_for_mkv(&p, Some(64 * 1024)).unwrap();
|
||||||
|
use std::io::{Seek, SeekFrom, Write};
|
||||||
|
sink.write_all(b"hello").unwrap();
|
||||||
|
sink.seek(SeekFrom::Start(0)).unwrap();
|
||||||
|
sink.finish().unwrap();
|
||||||
|
drop(sink);
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//! Linux `fallocate(FALLOC_FL_KEEP_SIZE)` preallocation.
|
||||||
|
//!
|
||||||
|
//! `KEEP_SIZE` reserves extents without changing the apparent file
|
||||||
|
//! length, which matches the muxer's expectation that writes still grow
|
||||||
|
//! the file naturally.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
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, len) };
|
||||||
|
tracing::debug!(
|
||||||
|
target: "mux",
|
||||||
|
"LocalFileSink fallocate size_hint={size_bytes} rc={rc} ok={}",
|
||||||
|
rc == 0
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//! macOS `F_PREALLOCATE` extent reservation.
|
||||||
|
//!
|
||||||
|
//! `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;
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
|
||||||
|
use crate::io::platform_macos::{
|
||||||
|
F_ALLOCATEALL, F_ALLOCATECONTIG, F_PEOFPOSMODE, F_PREALLOCATE, Fstore,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// 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: 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 only.
|
||||||
|
store.fst_flags = F_ALLOCATEALL;
|
||||||
|
rc = unsafe { libc::fcntl(fd, F_PREALLOCATE, &mut store as *mut Fstore) };
|
||||||
|
}
|
||||||
|
tracing::debug!(
|
||||||
|
target: "mux",
|
||||||
|
"LocalFileSink F_PREALLOCATE size_hint={size_bytes} rc={rc} bytesalloc={}",
|
||||||
|
store.fst_bytesalloc
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//! Per-OS extent preallocation. Best-effort; failures are logged at
|
||||||
|
//! debug and otherwise swallowed because the file is still usable
|
||||||
|
//! without the size reservation — only large-file fragmentation gets
|
||||||
|
//! marginally worse.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod macos;
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
|
mod other;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use linux::preallocate_impl;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use macos::preallocate_impl;
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
|
use other::preallocate_impl;
|
||||||
|
|
||||||
|
/// Reserve `size_bytes` of disk space for `file`'s on-disk extents.
|
||||||
|
/// Reported file size is unchanged — writes still grow the file
|
||||||
|
/// naturally; only the allocator's extent map is primed.
|
||||||
|
pub(super) fn preallocate(file: &File, size_bytes: u64) {
|
||||||
|
preallocate_impl(file, size_bytes);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//! Fallback preallocate impl. No-op.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
|
||||||
|
pub(super) fn preallocate_impl(_file: &File, size_bytes: u64) {
|
||||||
|
tracing::debug!(
|
||||||
|
target: "mux",
|
||||||
|
"LocalFileSink preallocate size_hint={size_bytes} skipped (no platform impl)"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
//! TCP / UDP socket sinks (sequential-only).
|
||||||
|
//!
|
||||||
|
//! [`SocketSink`] wraps a `TcpStream` in a 1 MiB `BufWriter`. Constructor
|
||||||
|
//! tunes `SO_SNDBUF` to a caller hint when provided. `finish()` flushes
|
||||||
|
//! the buffer then `shutdown(Write)`s the socket so the peer sees clean
|
||||||
|
//! end-of-stream.
|
||||||
|
//!
|
||||||
|
//! [`UdpSocketSink`] wraps a connected `UdpSocket`. Each `write` call
|
||||||
|
//! emits exactly one datagram — the caller is responsible for packetizing
|
||||||
|
//! to a reasonable MTU (188 × 7 = 1316 bytes for MPEG-TS-over-UDP is the
|
||||||
|
//! conventional choice). `finish()` is a no-op; UDP has no end-of-stream
|
||||||
|
//! marker.
|
||||||
|
//!
|
||||||
|
//! 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, 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
|
||||||
|
/// containers (TS = 188-byte packets, fMP4 fragment headers = ~100 bytes)
|
||||||
|
/// from translating into syscall storms.
|
||||||
|
const TCP_BUF_CAPACITY: usize = 1024 * 1024;
|
||||||
|
|
||||||
|
/// Sequential-only sink over a TCP connection.
|
||||||
|
///
|
||||||
|
/// Wraps a `BufWriter<TcpStream>`; the inner `TcpStream` is kept as a
|
||||||
|
/// clone so [`finish`](Self::finish) can call `shutdown(Write)` after
|
||||||
|
/// flushing the buffer (the buffered writer doesn't expose the socket
|
||||||
|
/// directly).
|
||||||
|
pub struct SocketSink {
|
||||||
|
/// Buffered write half. All payload bytes go through this.
|
||||||
|
buf: BufWriter<TcpStream>,
|
||||||
|
/// Shutdown handle — clone of the socket inside `buf`. Used only by
|
||||||
|
/// `finish()` for `shutdown(Write)`; never read or written through.
|
||||||
|
shutdown_handle: TcpStream,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SocketSink {
|
||||||
|
/// Open a TCP connection to `addr` and wrap it for sequential
|
||||||
|
/// 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: `"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. 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)?;
|
||||||
|
}
|
||||||
|
let shutdown_handle = stream.try_clone()?;
|
||||||
|
Ok(Self {
|
||||||
|
buf: BufWriter::with_capacity(TCP_BUF_CAPACITY, stream),
|
||||||
|
shutdown_handle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Write for SocketSink {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||||
|
self.buf.write(buf)
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
self.buf.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SequentialSink for SocketSink {
|
||||||
|
/// Drain the BufWriter and `shutdown(Write)` the underlying socket
|
||||||
|
/// 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
|
||||||
|
// by the peer — but we surface them so callers can log.
|
||||||
|
self.shutdown_handle.shutdown(Shutdown::Write)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sequential-only sink over a connected UDP socket.
|
||||||
|
///
|
||||||
|
/// Each [`write`](Write::write) call sends exactly one datagram. The
|
||||||
|
/// caller is responsible for splitting payload at packet boundaries —
|
||||||
|
/// for MPEG-TS this means 7 × 188 = 1316 bytes per datagram, the
|
||||||
|
/// industry standard for MPEG-TS-over-UDP. No buffering happens here;
|
||||||
|
/// adding it would silently merge datagrams.
|
||||||
|
///
|
||||||
|
/// `finish()` is a no-op: UDP has no end-of-stream marker. Closing the
|
||||||
|
/// socket happens on drop.
|
||||||
|
pub struct UdpSocketSink {
|
||||||
|
socket: UdpSocket,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UdpSocketSink {
|
||||||
|
/// Bind a local UDP socket to an ephemeral port and `connect` it to
|
||||||
|
/// `peer`. `connect` doesn't open a connection — it just fixes the
|
||||||
|
/// peer address so subsequent `send` calls don't need to repeat it,
|
||||||
|
/// and so receive-side filtering rejects packets from other sources.
|
||||||
|
///
|
||||||
|
/// `sndbuf_bytes`, when present, is a hint to `SO_SNDBUF`.
|
||||||
|
pub fn connect<A: ToSocketAddrs>(peer: A, sndbuf_bytes: Option<usize>) -> io::Result<Self> {
|
||||||
|
// 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)?;
|
||||||
|
}
|
||||||
|
Ok(Self { socket })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Write for UdpSocketSink {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||||
|
// `send` writes the entire datagram or fails — no partial sends
|
||||||
|
// for UDP. Match `Write::write`'s contract by reporting bytes
|
||||||
|
// accepted.
|
||||||
|
self.socket.send(buf)
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Platform `SO_SNDBUF` tuning ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// std's `TcpStream` / `UdpSocket` don't expose `SO_SNDBUF`. We drop to
|
||||||
|
// libc on Linux + macOS (the libc-dep targets in Cargo.toml). On other
|
||||||
|
// targets the hint is silently ignored — the socket still works, the
|
||||||
|
// kernel just picks its own send-buffer size.
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
fn set_send_buffer(stream: &TcpStream, bytes: usize) -> io::Result<()> {
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
setsockopt_sndbuf(stream.as_raw_fd(), bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
fn set_udp_send_buffer(socket: &UdpSocket, bytes: usize) -> io::Result<()> {
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
setsockopt_sndbuf(socket.as_raw_fd(), bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
|
fn set_send_buffer(_stream: &TcpStream, _bytes: usize) -> io::Result<()> {
|
||||||
|
// Non-Linux-non-macOS targets aren't in Cargo.toml's libc dep list;
|
||||||
|
// silently ignore the hint rather than failing the connect. Callers
|
||||||
|
// can detect via the lack of an explicit "sndbuf applied" signal
|
||||||
|
// (not provided, intentionally — this is a hint, not a guarantee).
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
|
fn set_udp_send_buffer(_socket: &UdpSocket, _bytes: usize) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
fn setsockopt_sndbuf(fd: std::os::unix::io::RawFd, bytes: usize) -> io::Result<()> {
|
||||||
|
// Clamp into c_int range; SO_SNDBUF takes an `int` argument.
|
||||||
|
let want: libc::c_int = bytes.try_into().unwrap_or(libc::c_int::MAX);
|
||||||
|
let ret = unsafe {
|
||||||
|
libc::setsockopt(
|
||||||
|
fd,
|
||||||
|
libc::SOL_SOCKET,
|
||||||
|
libc::SO_SNDBUF,
|
||||||
|
&want as *const _ as *const libc::c_void,
|
||||||
|
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ret != 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::net::{TcpListener, UdpSocket};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
/// Bind a listener, accept on a thread, return (listener_addr,
|
||||||
|
/// accepted-bytes future via JoinHandle).
|
||||||
|
#[test]
|
||||||
|
fn socket_sink_round_trips_bytes() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let accept = thread::spawn(move || {
|
||||||
|
let (mut sock, _) = listener.accept().unwrap();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
sock.read_to_end(&mut buf).unwrap();
|
||||||
|
buf
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut sink = SocketSink::connect(addr, Some(256 * 1024)).unwrap();
|
||||||
|
// Write enough to overflow the BufWriter at least once, then a
|
||||||
|
// tail that lives in the buffer until `finish` flushes.
|
||||||
|
let big: Vec<u8> = (0..(2 * TCP_BUF_CAPACITY))
|
||||||
|
.map(|i| (i & 0xff) as u8)
|
||||||
|
.collect();
|
||||||
|
sink.write_all(&big).unwrap();
|
||||||
|
sink.write_all(b"tail\n").unwrap();
|
||||||
|
sink.finish().unwrap();
|
||||||
|
drop(sink);
|
||||||
|
|
||||||
|
let received = accept.join().unwrap();
|
||||||
|
assert_eq!(received.len(), big.len() + 5);
|
||||||
|
assert_eq!(&received[..big.len()], &big[..]);
|
||||||
|
assert_eq!(&received[big.len()..], b"tail\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn socket_sink_is_sequential_only() {
|
||||||
|
// Compile-time assertion via dyn — if this ever started
|
||||||
|
// satisfying `RandomAccessSink`, the trait split would be broken.
|
||||||
|
fn _assert_seq(_: &mut dyn super::super::SequentialSink) {}
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let _accept = thread::spawn(move || {
|
||||||
|
let _ = listener.accept();
|
||||||
|
});
|
||||||
|
let mut sink = SocketSink::connect(addr, None).unwrap();
|
||||||
|
_assert_seq(&mut sink);
|
||||||
|
// The negative is harder to assert directly (no `is_not<T>`),
|
||||||
|
// but `SocketSink` does not impl `Seek`, so it can't unify with
|
||||||
|
// `RandomAccessSink`'s super-bound. The Phase 2 blanket impl
|
||||||
|
// `impl<T: SequentialSink + Seek> RandomAccessSink for T {}` thus
|
||||||
|
// excludes it by construction.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn udp_socket_sink_delivers_datagrams() {
|
||||||
|
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, Some(128 * 1024)).unwrap();
|
||||||
|
|
||||||
|
sink.write_all(&[1, 2, 3, 4, 5]).unwrap();
|
||||||
|
sink.write_all(&[9, 9, 9]).unwrap();
|
||||||
|
sink.finish().unwrap();
|
||||||
|
|
||||||
|
let mut buf = [0u8; 64];
|
||||||
|
let n1 = receiver.recv(&mut buf).unwrap();
|
||||||
|
assert_eq!(&buf[..n1], &[1, 2, 3, 4, 5]);
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//! Per-platform writeback pipeline. On Linux, drains dirty pages
|
||||||
|
//! continuously at chunk granularity to keep the kernel's writeback
|
||||||
|
//! queue bounded. On macOS and Windows, a no-op stub.
|
||||||
|
//!
|
||||||
|
//! The platform decision lives entirely in this file (the cfg-gated
|
||||||
|
//! `pub use` below). Callers — and `DiskWriter` itself — are
|
||||||
|
//! platform-independent.
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux;
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
mod noop;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(super) use linux::WritebackPipeline;
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub(super) use noop::WritebackPipeline;
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
//! Linux writeback pipeline using `sync_file_range` + `posix_fadvise`.
|
||||||
|
//!
|
||||||
|
//! Pathology this fixes: the kernel's default `vm.dirty_ratio` (~20 %
|
||||||
|
//! of RAM) lets dirty pages accumulate to hundreds of MB during a
|
||||||
|
//! big sequential write, then bursts a flush at 99 % disk utilisation.
|
||||||
|
//! While the burst runs, app writes block on the writeback queue —
|
||||||
|
//! observed empirically as instantaneous speed dropping from ~15 MB/s
|
||||||
|
//! to ~1 MB/s every ~30 s during a Pass 1 sweep.
|
||||||
|
//!
|
||||||
|
//! Strategy: every `chunk_bytes` of new sequential output, kick async
|
||||||
|
//! writeback (`SYNC_FILE_RANGE_WRITE`) on the just-completed chunk and
|
||||||
|
//! finalise the *previous* chunk via `WAIT_AFTER` + `posix_fadvise
|
||||||
|
//! (DONTNEED)`. By the time we finalise, that previous chunk has had
|
||||||
|
//! a full chunk's worth of work to flush — the wait is near-instant.
|
||||||
|
//! Dirty cache stays bounded at ~2 × `chunk_bytes` and writes drain
|
||||||
|
//! continuously instead of in bursts.
|
||||||
|
//!
|
||||||
|
//! The chunk size is adaptive: we measure the elapsed time of the
|
||||||
|
//! `WAIT_AFTER` call over a rolling window of the last 16 chunks and
|
||||||
|
//! resize the chunk based on the p95. Slow storage (NFS, network
|
||||||
|
//! shares, HDD) sees larger chunks to amortise per-chunk overhead;
|
||||||
|
//! fast storage (NVMe) sees smaller chunks to keep cache pressure
|
||||||
|
//! tight. Bounds: [4 MiB, 256 MiB].
|
||||||
|
//!
|
||||||
|
//! ## NFS escape hatch
|
||||||
|
//!
|
||||||
|
//! `sync_file_range(WAIT_AFTER)` on an NFS-mounted file can block
|
||||||
|
//! indefinitely waiting for the server's commit ack. If the server
|
||||||
|
//! never acks (network partition, server-side hang, slow commit), the
|
||||||
|
//! syscall never returns and the consumer thread is stuck inside the
|
||||||
|
//! kernel — `/api/stop` can't reach it because halt is cooperative.
|
||||||
|
//!
|
||||||
|
//! When `fstatfs` reports the file lives on an NFS mount
|
||||||
|
//! (`f_type == NFS_SUPER_MAGIC`), the pipeline skips the WAIT_AFTER +
|
||||||
|
//! `posix_fadvise(DONTNEED)` dance entirely. NFS clients have their
|
||||||
|
//! own buffering and commit semantics that handle dirty-page bounds
|
||||||
|
//! without us forcing the issue. The async `SYNC_FILE_RANGE_WRITE`
|
||||||
|
//! kickoff still runs (non-blocking by spec) so writeback still gets
|
||||||
|
//! a nudge.
|
||||||
|
//!
|
||||||
|
//! ## Defence in depth: WAIT_AFTER timeout
|
||||||
|
//!
|
||||||
|
//! Even on local storage, a degraded disk or odd filesystem driver
|
||||||
|
//! could in principle wedge inside WAIT_AFTER. Each WAIT_AFTER call
|
||||||
|
//! runs on a worker thread with a 30s recv_timeout on its result
|
||||||
|
//! channel. On timeout we log a loud error, set a `degraded` flag,
|
||||||
|
//! and from then on skip WAIT_AFTER + DONTNEED for the rest of the
|
||||||
|
//! pipeline's life (same shape as the NFS path). The worker thread
|
||||||
|
//! is intentionally leaked — it unwinds whenever the syscall
|
||||||
|
//! eventually returns or the process exits. The mux continues; the
|
||||||
|
//! original dirty-burst pathology re-emerges but the rip can still
|
||||||
|
//! finish instead of freezing.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::os::unix::io::{AsRawFd, RawFd};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const ADAPTIVE_WINDOW: usize = 16;
|
||||||
|
const CHUNK_BYTES_MIN: u64 = 4 * 1024 * 1024;
|
||||||
|
const CHUNK_BYTES_MAX: u64 = 256 * 1024 * 1024;
|
||||||
|
const ADAPTIVE_GROW_MS: u64 = 200;
|
||||||
|
const ADAPTIVE_SHRINK_MS: u64 = 20;
|
||||||
|
/// Every N chunks, emit a `debug!` snapshot of the current chunk
|
||||||
|
/// size so operators tailing the log can see where the autoscaler
|
||||||
|
/// settled.
|
||||||
|
const SIZE_LOG_INTERVAL: u64 = 32;
|
||||||
|
/// Hard upper bound on a single `sync_file_range(WAIT_AFTER)` call.
|
||||||
|
/// Beyond this we declare the pipeline degraded and stop calling
|
||||||
|
/// WAIT_AFTER for the rest of its life.
|
||||||
|
const WAIT_AFTER_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
pub(crate) struct WritebackPipeline {
|
||||||
|
/// Aliases the wrapping `WritebackFile::file`. Only valid for the
|
||||||
|
/// lifetime of that struct — moving the `File` independently
|
||||||
|
/// would silently UAF this fd. The pipeline is a private field of
|
||||||
|
/// `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)>,
|
||||||
|
/// Rolling window of recent `WAIT_AFTER` elapsed_ms measurements.
|
||||||
|
wait_after_window: VecDeque<u64>,
|
||||||
|
/// Count of chunks emitted (used to space out periodic
|
||||||
|
/// `debug!` size snapshots).
|
||||||
|
chunk_count: u64,
|
||||||
|
/// True when the underlying file is on an NFS mount. NFS makes
|
||||||
|
/// WAIT_AFTER unsafe (can block forever on missing server ack), so
|
||||||
|
/// we skip it entirely and let the NFS client handle commit on
|
||||||
|
/// close.
|
||||||
|
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. 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 {
|
||||||
|
/// Construct a pipeline aliasing `file`'s file descriptor. The
|
||||||
|
/// returned `WritebackPipeline` MUST be dropped before `file`
|
||||||
|
/// itself, or kept inside the same struct that owns `file` — the
|
||||||
|
/// alias is unchecked.
|
||||||
|
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={}",
|
||||||
|
if is_nfs { "nfs-skip-wait" } else { "wait+dontneed" }
|
||||||
|
);
|
||||||
|
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: AtomicBool::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if we should bypass the WAIT_AFTER + DONTNEED finalisation
|
||||||
|
/// step. NFS always bypasses; local storage bypasses once the
|
||||||
|
/// pipeline has flipped to degraded after a WAIT_AFTER timeout.
|
||||||
|
#[inline]
|
||||||
|
fn skip_wait(&self) -> bool {
|
||||||
|
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.
|
||||||
|
pub(crate) fn note_progress(&mut self, pos: u64) {
|
||||||
|
if pos < self.last_flush_pos.saturating_add(self.chunk_bytes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 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.
|
||||||
|
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() {
|
||||||
|
// NFS branch (or degraded fallback after a prior
|
||||||
|
// timeout): the WAIT_AFTER + DONTNEED dance is what
|
||||||
|
// hangs on NFS — skip it. We still advance `pending`
|
||||||
|
// so the next call has a stable cycle.
|
||||||
|
} else {
|
||||||
|
// Normal local-storage branch with belt-and-braces
|
||||||
|
// timeout. If WAIT_AFTER hangs > WAIT_AFTER_TIMEOUT
|
||||||
|
// 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.clone_for_worker(), self.fd, prev_off, prev_len)
|
||||||
|
{
|
||||||
|
Some(ms) => {
|
||||||
|
wait_ms = ms;
|
||||||
|
let t_fadv = Instant::now();
|
||||||
|
unsafe {
|
||||||
|
libc::posix_fadvise(
|
||||||
|
self.fd,
|
||||||
|
prev_off as i64,
|
||||||
|
prev_len as i64,
|
||||||
|
libc::POSIX_FADV_DONTNEED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fadvise_ms = t_fadv.elapsed().as_millis() as u64;
|
||||||
|
self.record_wait(wait_ms);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Timeout branch: switch to NFS-style skip
|
||||||
|
// for the rest of the pipeline's life. Do
|
||||||
|
// 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, chunk_bytes lowered to floor)",
|
||||||
|
WAIT_AFTER_TIMEOUT.as_secs(),
|
||||||
|
prev_off,
|
||||||
|
prev_len
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.pending = Some((chunk_off, chunk_len));
|
||||||
|
self.last_flush_pos = pos;
|
||||||
|
self.chunk_count += 1;
|
||||||
|
tracing::trace!(
|
||||||
|
target: "mux",
|
||||||
|
"WritebackPipeline chunk off={} len={} wait_after_ms={wait_ms} fadvise_ms={fadvise_ms} chunk_bytes={} skip_wait={}",
|
||||||
|
chunk_off,
|
||||||
|
chunk_len,
|
||||||
|
self.chunk_bytes,
|
||||||
|
self.skip_wait(),
|
||||||
|
);
|
||||||
|
if self.chunk_count % SIZE_LOG_INTERVAL == 0 {
|
||||||
|
tracing::debug!(
|
||||||
|
target: "mux",
|
||||||
|
"WritebackPipeline chunk_bytes={} after {} chunks is_nfs={} degraded={}",
|
||||||
|
self.chunk_bytes,
|
||||||
|
self.chunk_count,
|
||||||
|
self.is_nfs,
|
||||||
|
self.degraded.load(Ordering::Relaxed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a new `WAIT_AFTER` measurement into the rolling window
|
||||||
|
/// and, if the window is full, adapt `chunk_bytes` based on p95.
|
||||||
|
fn record_wait(&mut self, wait_ms: u64) {
|
||||||
|
if self.wait_after_window.len() == ADAPTIVE_WINDOW {
|
||||||
|
self.wait_after_window.pop_front();
|
||||||
|
}
|
||||||
|
self.wait_after_window.push_back(wait_ms);
|
||||||
|
if self.wait_after_window.len() < ADAPTIVE_WINDOW {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 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_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)
|
||||||
|
} else if p95 < ADAPTIVE_SHRINK_MS && self.chunk_bytes > CHUNK_BYTES_MIN {
|
||||||
|
(self.chunk_bytes / 2).max(CHUNK_BYTES_MIN)
|
||||||
|
} else {
|
||||||
|
self.chunk_bytes
|
||||||
|
};
|
||||||
|
if new != old {
|
||||||
|
self.chunk_bytes = new;
|
||||||
|
tracing::info!(
|
||||||
|
target: "mux",
|
||||||
|
"WritebackPipeline adaptive chunk_bytes {} -> {} p95_ms={p95}",
|
||||||
|
old,
|
||||||
|
new
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caller is about to seek away from the current write region.
|
||||||
|
/// Drain any in-flight chunk and reset tracking.
|
||||||
|
pub(crate) fn handle_seek(&mut self, new_pos: u64) {
|
||||||
|
self.finalize();
|
||||||
|
self.last_flush_pos = new_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain any in-flight chunk. Idempotent. Call before `sync_all()`
|
||||||
|
/// or when discarding the pipeline.
|
||||||
|
pub(crate) fn finalize(&mut self) {
|
||||||
|
if let Some((prev_off, prev_len)) = self.pending.take() {
|
||||||
|
tracing::debug!(
|
||||||
|
target: "mux",
|
||||||
|
"WritebackPipeline finalize chunk off={prev_off} len={prev_len} skip_wait={} is_nfs={} degraded={}",
|
||||||
|
self.skip_wait(),
|
||||||
|
self.is_nfs,
|
||||||
|
self.degraded.load(Ordering::Relaxed),
|
||||||
|
);
|
||||||
|
if self.skip_wait() {
|
||||||
|
// NFS / degraded: skip WAIT_AFTER + DONTNEED. close()
|
||||||
|
// / sync_all() handle commit through their normal
|
||||||
|
// paths.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match wait_after_with_timeout(self.clone_for_worker(), self.fd, prev_off, prev_len) {
|
||||||
|
Some(_ms) => unsafe {
|
||||||
|
libc::posix_fadvise(
|
||||||
|
self.fd,
|
||||||
|
prev_off as i64,
|
||||||
|
prev_len as i64,
|
||||||
|
libc::POSIX_FADV_DONTNEED,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
self.degraded.store(true, Ordering::Relaxed);
|
||||||
|
tracing::error!(
|
||||||
|
target: "mux",
|
||||||
|
"WritebackPipeline finalize WAIT_AFTER timed out after {}s on chunk off={prev_off} len={prev_len}, marking writeback degraded",
|
||||||
|
WAIT_AFTER_TIMEOUT.as_secs(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe whether `fd` lives on an NFS mount. Thin wrapper around
|
||||||
|
/// [`crate::platform::fs_type::detect_fd`] so writeback policy and
|
||||||
|
/// general-purpose fs-type classification stay in sync (same magic
|
||||||
|
/// numbers, same musl-vs-glibc cast handling).
|
||||||
|
///
|
||||||
|
/// Fails open: any classification other than NFS counts as "not NFS"
|
||||||
|
/// (including `Unknown` on `fstatfs` error) — better to run the
|
||||||
|
/// normal local-storage path on a misdetected NFS mount and surface
|
||||||
|
/// the freeze loudly via [`WAIT_AFTER_TIMEOUT`] than to needlessly
|
||||||
|
/// disable writeback bounding on every local file because of a
|
||||||
|
/// transient stat error.
|
||||||
|
fn detect_nfs(fd: RawFd) -> bool {
|
||||||
|
matches!(
|
||||||
|
crate::platform::fs_type::detect_fd(fd),
|
||||||
|
crate::platform::fs_type::FsType::Nfs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// 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();
|
||||||
|
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,
|
||||||
|
Err(crate::io::bounded::BoundedError::WorkerLost) => {
|
||||||
|
// Worker thread spawn failed or panicked before sending.
|
||||||
|
// Treat as a benign success (no syscall ran) rather than
|
||||||
|
// a degrade trigger — falling through with elapsed_ms=0
|
||||||
|
// matches the no-op behaviour.
|
||||||
|
Some(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
/// Helper: build a `WritebackPipeline` over a local tempfile. On
|
||||||
|
/// every test rig (linux dev box, CI) the tempfile lives on a
|
||||||
|
/// local FS, so `is_nfs=false` and `skip_wait` returns false until
|
||||||
|
/// we explicitly mark the pipeline degraded.
|
||||||
|
fn local_pipeline(chunk_bytes: u64) -> (NamedTempFile, WritebackPipeline) {
|
||||||
|
let f = NamedTempFile::new().expect("tempfile create");
|
||||||
|
let pipeline = WritebackPipeline::new(f.as_file(), 0, chunk_bytes);
|
||||||
|
(f, pipeline)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_pipeline_starts_active() {
|
||||||
|
let (_f, p) = local_pipeline(32 * 1024 * 1024);
|
||||||
|
assert!(!p.is_nfs, "local tempfile must not classify as NFS");
|
||||||
|
assert!(!p.degraded.load(Ordering::Relaxed));
|
||||||
|
assert!(!p.skip_wait(), "fresh local pipeline must not skip wait");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn degraded_flag_short_circuits_wait() {
|
||||||
|
let (_f, p) = local_pipeline(32 * 1024 * 1024);
|
||||||
|
assert!(!p.skip_wait());
|
||||||
|
p.degraded.store(true, Ordering::Relaxed);
|
||||||
|
assert!(
|
||||||
|
p.skip_wait(),
|
||||||
|
"degraded flag must force the wait+dontneed bypass"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_wait_grows_chunk_on_high_p95() {
|
||||||
|
let (_f, mut p) = local_pipeline(16 * 1024 * 1024);
|
||||||
|
// Fill the window with samples above the grow threshold.
|
||||||
|
for _ in 0..ADAPTIVE_WINDOW {
|
||||||
|
p.record_wait(ADAPTIVE_GROW_MS + 50);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
p.chunk_bytes > 16 * 1024 * 1024,
|
||||||
|
"chunk should have grown; got {}",
|
||||||
|
p.chunk_bytes
|
||||||
|
);
|
||||||
|
assert!(p.chunk_bytes <= CHUNK_BYTES_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_wait_shrinks_chunk_on_low_p95() {
|
||||||
|
let (_f, mut p) = local_pipeline(64 * 1024 * 1024);
|
||||||
|
for _ in 0..ADAPTIVE_WINDOW {
|
||||||
|
p.record_wait(1); // well under ADAPTIVE_SHRINK_MS
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
p.chunk_bytes < 64 * 1024 * 1024,
|
||||||
|
"chunk should have shrunk; got {}",
|
||||||
|
p.chunk_bytes
|
||||||
|
);
|
||||||
|
assert!(p.chunk_bytes >= CHUNK_BYTES_MIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_wait_no_op_below_window_fill() {
|
||||||
|
let (_f, mut p) = local_pipeline(16 * 1024 * 1024);
|
||||||
|
let initial = p.chunk_bytes;
|
||||||
|
// Only push a few samples; window not full → no adaptation.
|
||||||
|
for _ in 0..(ADAPTIVE_WINDOW - 1) {
|
||||||
|
p.record_wait(ADAPTIVE_GROW_MS + 100);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
p.chunk_bytes, initial,
|
||||||
|
"chunk must not change before window is full"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_wait_clamps_to_chunk_bounds() {
|
||||||
|
// Grow past the max.
|
||||||
|
let (_f, mut p) = local_pipeline(CHUNK_BYTES_MAX);
|
||||||
|
for _ in 0..ADAPTIVE_WINDOW {
|
||||||
|
p.record_wait(ADAPTIVE_GROW_MS + 1000);
|
||||||
|
}
|
||||||
|
assert_eq!(p.chunk_bytes, CHUNK_BYTES_MAX, "must clamp to MAX");
|
||||||
|
|
||||||
|
// Shrink past the min.
|
||||||
|
let (_f, mut p) = local_pipeline(CHUNK_BYTES_MIN);
|
||||||
|
for _ in 0..ADAPTIVE_WINDOW {
|
||||||
|
p.record_wait(0);
|
||||||
|
}
|
||||||
|
assert_eq!(p.chunk_bytes, CHUNK_BYTES_MIN, "must clamp to MIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_nfs_local_file_is_false() {
|
||||||
|
// Local tempfile must not classify as NFS. This locks in the
|
||||||
|
// consolidation through `crate::platform::fs_type::detect_fd`.
|
||||||
|
let f = NamedTempFile::new().expect("tempfile create");
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
assert!(!detect_nfs(f.as_file().as_raw_fd()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn note_progress_below_chunk_is_noop() {
|
||||||
|
let (_f, mut p) = local_pipeline(32 * 1024 * 1024);
|
||||||
|
// No-op return before crossing the first chunk boundary.
|
||||||
|
let before = p.chunk_count;
|
||||||
|
p.note_progress(1024); // < 32 MiB
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//! No-op writeback pipeline for non-Linux targets. macOS and Windows
|
||||||
|
//! page cache policies have not been shown to exhibit the Linux
|
||||||
|
//! accumulate-then-burst flush pathology for our access pattern.
|
||||||
|
//! If that changes, replace this stub with a real implementation
|
||||||
|
//! (e.g. `F_NOCACHE` on macOS, `FILE_FLAG_WRITE_THROUGH` on Windows).
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
|
||||||
|
pub(crate) struct WritebackPipeline;
|
||||||
|
|
||||||
|
impl WritebackPipeline {
|
||||||
|
pub(crate) fn new(_file: &File, _start_pos: u64, _chunk_bytes: u64) -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
pub(crate) fn note_progress(&mut self, _pos: u64) {}
|
||||||
|
pub(crate) fn handle_seek(&mut self, _new_pos: u64) {}
|
||||||
|
pub(crate) fn finalize(&mut self) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
//! Linux platform impl for [`super::WritebackFile`].
|
||||||
|
//!
|
||||||
|
//! - `preallocate`: `fallocate(FALLOC_FL_KEEP_SIZE)` — reserve extents
|
||||||
|
//! without growing the reported file size. Reduces extent
|
||||||
|
//! fragmentation on large sequential writes (mux output on NFS in
|
||||||
|
//! particular).
|
||||||
|
//! - `durable_sync`: `fsync` wrapped in
|
||||||
|
//! [`crate::io::bounded::bounded_syscall`] with a 60 s deadline so a
|
||||||
|
//! wedged NFS server can't trap the calling thread indefinitely.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io;
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Pre-reserve extents for `size_bytes` of upcoming sequential writes.
|
||||||
|
/// Best-effort: a non-zero rc is logged but not propagated, since the
|
||||||
|
/// caller would just continue with the unreserved file anyway.
|
||||||
|
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.
|
||||||
|
// 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={}",
|
||||||
|
rc == 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
Err(io::Error::last_os_error())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Ok(inner) => inner,
|
||||||
|
Err(crate::io::bounded::BoundedError::Timeout) => {
|
||||||
|
tracing::error!(
|
||||||
|
target: "mux",
|
||||||
|
"WritebackFile::sync_all fsync timed out after 60s; kernel will flush on close (best-effort)"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(crate::io::bounded::BoundedError::Halted) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "mux",
|
||||||
|
"WritebackFile::sync_all fsync skipped (halt requested); data not durably flushed, kernel will flush on close"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(crate::io::bounded::BoundedError::WorkerLost) => {
|
||||||
|
tracing::error!(
|
||||||
|
target: "mux",
|
||||||
|
"WritebackFile::sync_all fsync worker lost before completion; data not durably flushed, kernel will flush on close"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
//! macOS platform impl for [`super::WritebackFile`].
|
||||||
|
//!
|
||||||
|
//! - `preallocate`: `fcntl(F_PREALLOCATE)` — macOS's fallocate-equiv.
|
||||||
|
//! 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
|
||||||
|
//! write cache) — what `fsync` should have been on macOS. Falls back
|
||||||
|
//! to plain `fsync` if F_FULLFSYNC returns ENOTSUP.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io;
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::io::platform_macos::{
|
||||||
|
F_ALLOCATEALL, F_ALLOCATECONTIG, F_PEOFPOSMODE, F_PREALLOCATE, Fstore,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `fcntl(F_FULLFSYNC)` opcode. Documented in `man 2 fcntl` on macOS;
|
||||||
|
/// not in the `libc` crate as a named constant.
|
||||||
|
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: len,
|
||||||
|
fst_bytesalloc: 0,
|
||||||
|
};
|
||||||
|
// First attempt: contiguous.
|
||||||
|
let mut rc = unsafe { libc::fcntl(file.as_raw_fd(), F_PREALLOCATE, &mut fst) };
|
||||||
|
if rc == -1 {
|
||||||
|
// Fall back: drop the contiguous hint, allow scattered extents.
|
||||||
|
fst.fst_flags = F_ALLOCATEALL;
|
||||||
|
rc = unsafe { libc::fcntl(file.as_raw_fd(), F_PREALLOCATE, &mut fst) };
|
||||||
|
}
|
||||||
|
tracing::debug!(
|
||||||
|
target: "mux",
|
||||||
|
"WritebackFile F_PREALLOCATE size_hint={size_bytes} rc={rc} bytes_allocated={} ok={}",
|
||||||
|
fst.fst_bytesalloc,
|
||||||
|
rc != -1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ## 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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Ok(inner) => inner,
|
||||||
|
Err(crate::io::bounded::BoundedError::Timeout) => {
|
||||||
|
tracing::error!(
|
||||||
|
target: "mux",
|
||||||
|
"WritebackFile::sync_all F_FULLFSYNC timed out after 60s; kernel will flush on close (best-effort)"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(crate::io::bounded::BoundedError::Halted) => Ok(()),
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user