omy-core 0.0.1

omy 加密文件格式的核心实现:格式读写、密钥体系、分块 AEAD。零 FFmpeg 依赖。
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
//! 树形模式:逐文件加密并保持目录结构(文档 05 §3,决策 D-05)。
//!
//! # 与容器模式的关系
//!
//! 这是文档 05 §1 里两种文件夹模式中的**模式 B**。容器模式
//! ([`crate::pack`])把整棵树塞进一个 `.omy`,树形模式在磁盘上保留结构:
//!
//! ```text
//! 明文               容器模式              树形模式
//! 工作资料/     →   a4332ff0.omy    →    MFZWIZLTOQ.omy/
//! ├── 文档/          (单个文件)          ├── NBSWY3DPFQ.omy/
//! │   └── 报告.docx                       │   └── ONXW2ZLUNBSGK.omy
//! └── 照片/                               └── PB2GK3TUN5XA.omy/
//! ```
//!
//! 取舍不是「哪个更好」,两个方向都有人需要(所以 D-05 决定都做):
//!
//! | | 容器 | 树形 |
//! |---|---|---|
//! | 目录结构 | 完全隐藏 | **泄露文件数、大小、树深度** |
//! | 改一个文件 | 重写整包 | 只重写那一个 |
//! | 云盘同步 | 每次全量 | 增量 |
//! | 单独分享一个文件 | 不行 | 可以 |
//! | 损坏影响面 | 头部坏了全丢 | 只影响那一个 |
//!
//! # 泄露的元数据必须告知用户(N6)
//!
//! 3.2 GB、1247 个文件的目录用树形模式加密后,旁观者能数出文件个数、看出
//! 每个文件的大致尺寸、看出目录有几层。看不到名字和内容,但**看得到形状**。
//! 「照片/2023/私人/」下躺着 400 个 2–5 MB 的文件,即使名字全是乱码也已经
//! 说明了不少事。
//!
//! 这属于威胁模型 N6 已声明的非目标。UI 必须直说,不能让用户以为选了加密
//! 就什么都藏住了——[`TreeReport::leak_notice`] 提供统一的告知文案。
//!
//! # 为什么遍历逻辑抽出来共用
//!
//! 打包时的几个决策——按名字稳定排序、用 `symlink_metadata` 而不是
//! `metadata`、跳过符号链接与非普通文件、深度上限——两种模式必须完全一致。
//! 各写一份的话,哪天给容器模式修了「跳过 socket 文件」,树形模式还在按
//! 老样子处理,同一个目录用两种模式加密会得到不同的内容集合。
//!
//! 所以 [`walk_dir`] 由本模块与 [`crate::pack`] 共用,判定规则只有一处。

use crate::crypto::{CipherId, Kek, NONCE_LEN};
use crate::dirname::{DIRNAME_SIDECAR, EncryptedDirname, looks_encrypted};
use crate::error::{Error, Result};
use crate::file::{EncryptOptions, RandomMaterial};
use crate::pack::{ProgressFn, SkipReason, SkippedEntry, walk_dir};
use std::path::{Path, PathBuf};

/// 树形模式加密的结果。
#[derive(Debug, Default)]
pub struct TreeReport {
    /// 加密后的根目录(磁盘路径,名字已加密)。
    pub root: PathBuf,
    /// 成功加密的文件数。
    pub files: usize,
    /// 创建的目录数。
    pub dirs: usize,
    /// 需要边车文件才能还原的超长目录名数量。
    ///
    /// 单独报出来是因为它有运维含义:这些目录一旦丢了 `.omy-name`
    /// 就再也认不出原名,用户可能想缩短目录层级重来一次。
    pub long_names: usize,
    /// 被跳过的条目,需如实展示。
    pub skipped: Vec<SkippedEntry>,
}

impl TreeReport {
    /// 树形模式泄露元数据的统一告知文案(N6)。
    ///
    /// 放在 core 而不是各前端:CLI 与 GUI 说法不一致会让人以为是两种不同的
    /// 行为。措辞刻意具体——「泄露元数据」这种说法用户无法据此判断风险,
    /// 「别人能数出你有多少文件」才能。
    #[must_use]
    pub const fn leak_notice() -> &'static str {
        "逐个加密模式下,别人能看到你有多少文件、每个文件多大、目录有几层,但看不到文件名和内容。"
    }
}

/// 逐个文件的媒体附加信息回调。
///
/// 入参是该文件的**明文字节**与文件名,返回 `(缩略图, 媒体元信息, moov 副本)`,
/// 不需要就返回三个 `None`。
///
/// # 为什么是回调而不是 core 自己做
///
/// 生成缩略图要靠 `omy-media`,而它是 **LGPL**,core 是主许可——
/// core 依赖它会把许可传染到整个下游。回调把这件事留给调用方:
/// CLI 和 GUI 本来就已经链接了 omy-media。
///
/// 回调在加密循环里**同步调用**,每个文件一次。实现里会起 ffprobe 子进程,
/// 因此大目录会明显变慢;调用方若不需要就传 `None`,那样一个子进程都不会起。
pub type MediaExtrasFn<'a> =
    &'a mut dyn FnMut(&[u8], &str) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>);

/// 加密一棵目录树。
///
/// `out_parent` 是加密后根目录的**父目录**;根目录名本身会被加密。
///
/// # 内存
///
/// 与容器模式不同,这里**逐个文件**读取和加密,内存占用是单个最大文件的
/// 大小,而不是整棵树。这是树形模式的一个实际优势:容器模式打包 50 GB
/// 目录需要 50 GB 内存,树形模式不需要。
///
/// # Errors
///
/// - 根路径不是目录
/// - 目录名加密失败
/// - 写盘失败(磁盘满、权限不足)
///
/// 单个文件读不出来**不算错误**,记进 `skipped` 继续——与容器模式一致。
pub fn encrypt_tree(
    root: &Path,
    out_parent: &Path,
    keks: &[Kek],
    vault_salt: &[u8; 16],
    opts: &EncryptOptions,
    progress: Option<ProgressFn<'_>>,
) -> Result<TreeReport> {
    encrypt_tree_with_media(root, out_parent, keks, vault_salt, opts, progress, None)
}

/// 与 [`encrypt_tree`] 相同,但为每个文件生成媒体附加信息。
///
/// 不带 `media` 的版本产出的树**没有缩略图**:列表页里这样的相册每一项都是
/// 通用图标。回调语义见 [`MediaExtrasFn`]。
///
/// # Errors
///
/// 与 [`encrypt_tree`] 一致。
pub fn encrypt_tree_with_media(
    root: &Path,
    out_parent: &Path,
    keks: &[Kek],
    vault_salt: &[u8; 16],
    opts: &EncryptOptions,
    mut progress: Option<ProgressFn<'_>>,
    mut media: Option<MediaExtrasFn<'_>>,
) -> Result<TreeReport> {
    if !root.is_dir() {
        return Err(Error::Io(std::io::Error::new(
            std::io::ErrorKind::NotADirectory,
            "encrypt_tree expects a directory",
        )));
    }
    if keks.is_empty() {
        return Err(Error::TooManySlots { got: 0, max: 8 });
    }

    // 整棵树共用一个随机目录密钥,它被每把 KEK 各包一份写进边车。
    //
    // 不再用 DirnameKey::derive(keks[0]):那样目录名只有第一把钥匙能解开,
    // 后面的密码能打开每个文件却看不见目录结构,decrypt 报的是
    // 「content hash mismatch」——用户看到的是「文件损坏」。多密码与恢复码
    // 在树上用不了,根因都在这里
    let dk = crate::dirsidecar::DirKey::generate();

    let root_name = root
        .file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_else(|| String::from("folder"));

    let mut rep = TreeReport::default();

    // 根目录名也要加密,否则最外层直接暴露「工作资料」
    let enc_root =
        crate::dirname::encrypt_dirname_with(&root_name, &dk, &random_nonce(), opts.cipher)?;
    let out_root = out_parent.join(&enc_root.disk_name);
    create_encrypted_dir(&out_root, &enc_root, &mut rep)?;
    // 槽位目录只在加密时确定一次,整棵树共用。每个密文目录各写一份,
    // 与 DK 包裹同理——任一子目录被单独拷走仍要能自洽
    let tree_slot_dir = match &opts.slot_directory {
        Some(raw) => Some(crate::slotdir::SlotDirectory::decode(raw)?),
        None => None,
    };
    write_keys_sidecar(&out_root, keks, &dk, vault_salt, opts.cipher, tree_slot_dir.as_ref())?;
    rep.root = out_root.clone();

    // 明文相对路径 → 密文磁盘路径。目录必须先建好,子项才知道该往哪写;
    // 而 walk_dir 保证父目录先于子项被访问,所以查表时一定已经有值
    let mut mapping: std::collections::HashMap<Vec<String>, PathBuf> =
        std::collections::HashMap::new();
    mapping.insert(Vec::new(), out_root.clone());

    let mut walk_skipped = Vec::new();
    walk_dir(root, &mut walk_skipped, &mut |item| {
        let Some(parent_rel) = item.comps.split_last().map(|(_, p)| p.to_vec()) else {
            return Ok(());
        };
        let Some(parent_out) = mapping.get(&parent_rel).cloned() else {
            // 父目录不在表里说明它被跳过了(读不出来 / 太深),
            // 子项自然也进不去。如实记录而不是静默丢弃
            rep.skipped.push(SkippedEntry {
                path: item.comps.join("/"),
                reason: SkipReason::Unreadable,
            });
            return Ok(());
        };
        let Some(name) = item.comps.last() else {
            return Ok(());
        };

        if item.link_target.is_some() {
            // 树形模式存不了链接:每个条目落成独立 `.omy` 文件,没有容器
            // 索引那样的地方记录链接目标。容器模式能存(见 `pack_folder`)。
            //
            // 必须显式跳过,不能落到下面的分支去:那里会 `std::fs::read`,
            // 而它跟随链接,于是**目标的内容**会被加密成一个普通文件,还原
            // 出来内容看着对、类型却错了——静默的错误结果。
            rep.skipped.push(SkippedEntry {
                path: item.comps.join("/"),
                reason: SkipReason::Symlink,
            });
            return Ok(());
        }

        if item.is_dir {
            // 子目录共用同一个 DK,但**每个目录都要写一份边车**。
            //
            // 只在根上放一份会更省,但那样任何一个子目录被单独拷出来就
            // 再也解不开名字了——而「把某个子目录单独发给别人」是完全
            // 正常的用法。边车与目录同进退,是它放在目录内部的全部意义
            let enc =
                crate::dirname::encrypt_dirname_with(name, &dk, &random_nonce(), opts.cipher)?;
            let dir_out = parent_out.join(&enc.disk_name);
            create_encrypted_dir(&dir_out, &enc, &mut rep)?;
            write_keys_sidecar(
                &dir_out,
                keks,
                &dk,
                vault_salt,
                opts.cipher,
                tree_slot_dir.as_ref(),
            )?;
            mapping.insert(item.comps.clone(), dir_out);
        } else {
            let Ok(data) = std::fs::read(&item.path) else {
                rep.skipped.push(SkippedEntry {
                    path: item.comps.join("/"),
                    reason: SkipReason::Unreadable,
                });
                return Ok(());
            };
            let size = data.len() as u64;

            // 每个文件独立加密:独立 FEK、独立 file_uuid。
            // 文件名写进它自己的 TLV,磁盘名只保留密文哈希——
            // 文件名不需要 base32 那套(有 TLV 可用),用哈希更短
            let mut fopts = opts.clone();
            fopts.filename = Some(name.clone());
            // 媒体附加信息逐文件生成。
            //
            // 必须在这里覆盖,不能只依赖 `opts`:调用方传进来的那份是整棵树
            // 共用的,里面的 thumbnail 之类要么为空,要么是上一个文件的——
            // 后者会把 A 的缩略图写进 B。
            if let Some(cb) = media.as_deref_mut() {
                let (thumb, meta, moov) = cb(&data, name);
                fopts.thumbnail = thumb;
                fopts.media_meta = meta;
                fopts.moov_cache = moov;
            }
            let rnd = RandomMaterial::generate();
            let enc = crate::file::encrypt(&data, keks, vault_salt, &fopts, &rnd)?;

            // 磁盘名取 file_uuid 的十六进制:唯一、定长、不泄露原名长度。
            // 不用文件名的哈希:同名文件会得到同名密文,泄露「这两个文件同名」
            let disk_name = format!("{}.omy", hex16(&rnd.file_uuid));
            crate::fsatomic::write_atomic(&parent_out.join(&disk_name), &enc.bytes)?;

            rep.files = rep.files.saturating_add(1);
            if let Some(cb) = progress.as_deref_mut() {
                cb(&item.comps.join("/"), size);
            }
        }
        Ok(())
    })?;

    rep.skipped.extend(walk_skipped);
    Ok(rep)
}

/// 往一个密文目录里写钥匙包裹边车。
///
/// 每个密文目录都有一份,内容相同(同一棵树共用一个 DK)。冗余是刻意的,
/// 换来的是「任何一个子目录被单独拷走仍能解开名字」。
fn write_keys_sidecar(
    dir: &Path,
    keks: &[Kek],
    dk: &crate::dirsidecar::DirKey,
    vault_salt: &[u8; 16],
    cipher: CipherId,
    slot_dir: Option<&crate::slotdir::SlotDirectory>,
) -> Result<()> {
    // 可管理模式:边车里多带一份加密的槽位目录。与单文件同一个判据——
    // 「用了哪种模式」是产品事实,写在明文 flags 里;目录内容是用户
    // 秘密,用 DK 加密
    let blob = match slot_dir {
        Some(sd) => crate::dirsidecar::build_sidecar_managed(
            keks,
            sd,
            dk,
            vault_salt,
            &random_nonce(),
            cipher,
        )?,
        None => crate::dirsidecar::build_sidecar(keks, dk, vault_salt, &random_nonce(), cipher)?,
    };
    crate::fsatomic::write_atomic(&dir.join(crate::dirsidecar::KEYS_SIDECAR), &blob)?;
    Ok(())
}

/// 重写边车,保留认不出来的旧包裹。
///
/// 与文件槽位的 `OtherSlots::Carry` 对齐:改密码不该让挂在这棵树上的
/// 恢复码失效。两处语义不一致的话,会出现「恢复码开得了每个文件、
/// 却打不开目录名」这种半残状态。
fn rewrite_keys_sidecar(
    dir: &Path,
    keep: &[Kek],
    dk: &crate::dirsidecar::DirKey,
    vault_salt: &[u8; 16],
    cipher: CipherId,
    others: crate::keyslot::OtherSlots,
) -> Result<()> {
    let path = dir.join(crate::dirsidecar::KEYS_SIDECAR);
    // 无论 Carry 还是 Discard 都要读一次:**判断模式需要它**。
    // 只在 Carry 时读的话,remove 会让整棵树静默退回可否认模式——
    // 用户只是想踢掉一个人,却连带丢了「能看清有几把钥匙」这个能力
    let raw = std::fs::read(&path).ok();
    let was_managed = raw.as_deref().is_some_and(crate::dirsidecar::is_managed);
    // Discard 时不搬旧包裹:remove 的语义就是不要旧的那些
    let old = match others {
        crate::keyslot::OtherSlots::Carry => raw,
        crate::keyslot::OtherSlots::Discard => None,
    };
    // 原边车是可管理模式的话,新的也必须是——否则换一次密码整棵树就
    // 静默退回可否认模式,用户收不到任何提示,只会在下次想精确删某个
    // 协作者时发现能力没了。单文件的 reencrypt 犯过同样的错
    let blob = if was_managed {
        // 目录按 keep 重建,不能搬旧的:rekey 之后能解开边车的只有 keep
        // 里那些,搬旧目录会让它谎称「恢复码还在」而其实已经废了
        let mut sd = crate::slotdir::SlotDirectory::new();
        for i in 0..keep.len().min(crate::header::SLOT_COUNT) {
            sd.set(i, crate::slotdir::SlotEntry::of(crate::slotdir::SlotKind::Vault))?;
        }
        crate::dirsidecar::build_sidecar_managed(
            keep,
            &sd,
            dk,
            vault_salt,
            &random_nonce(),
            cipher,
        )?
    } else {
        crate::dirsidecar::rebuild_sidecar_carrying(
            old.as_deref(),
            keep,
            dk,
            vault_salt,
            &random_nonce(),
            cipher,
        )?
    };
    crate::fsatomic::write_atomic(&path, &blob)?;
    Ok(())
}

/// 从一个密文目录读出 DK。
///
/// # Errors
///
/// - 边车文件不存在或读不出来
/// - [`Error::NoMatchingSlot`]:手头的钥匙都打不开它
fn read_keys_sidecar(
    dir: &Path,
    keks: &[Kek],
    vault_salt: &[u8; 16],
    cipher: CipherId,
) -> Result<crate::dirsidecar::DirKey> {
    let blob = std::fs::read(dir.join(crate::dirsidecar::KEYS_SIDECAR))?;
    let (dk, _nonce) = crate::dirsidecar::open_sidecar(&blob, keks, vault_salt, cipher)?;
    Ok(dk)
}

/// 一棵树的槽位清单。
#[derive(Debug)]
pub struct TreeSlots {
    /// 是不是可管理模式。false 时 `directory` 为 `None`。
    pub managed: bool,
    /// 槽位目录。可否认模式下为 `None`——那不是「读取失败」,是设计如此。
    pub directory: Option<crate::slotdir::SlotDirectory>,
    /// 手头这把钥匙占的槽位下标。
    pub current: usize,
}

/// 读取一棵树的槽位清单。
///
/// 与单文件的 `OpenedFile::slot_directory` 对应。需要能解开边车的钥匙:
/// 槽位目录是用 DK 加密的,而 DK 本身要靠钥匙才能解出来。
///
/// # Errors
///
/// 边车不存在、读不了、或所有钥匙都解不开时返回错误。
pub fn tree_slots(
    root: &Path,
    keks: &[Kek],
    vault_salt: &[u8; 16],
    cipher: CipherId,
) -> Result<TreeSlots> {
    let blob = std::fs::read(root.join(crate::dirsidecar::KEYS_SIDECAR))?;
    let (dk, _nonce, at) =
        crate::dirsidecar::open_sidecar_at(&blob, keks, vault_salt, cipher)?;
    if !crate::dirsidecar::is_managed(&blob) {
        return Ok(TreeSlots { managed: false, directory: None, current: at });
    }
    let dir = crate::dirsidecar::read_slot_directory(&blob, &dk, vault_salt, cipher)?;
    Ok(TreeSlots { managed: true, directory: Some(dir), current: at })
}

/// 在可管理模式下精确清掉某一个槽位。
///
/// 整棵树的每个密文目录都有一份边车,所以要逐个改写——漏掉一个,那个
/// 子目录被单独拷走时被删的钥匙仍然能解开它的名字。
///
/// # Errors
///
/// - 不是可管理模式、或手头钥匙解不开边车
/// - `slot` 正是当前这把钥匙所在的槽(删掉自己之后就没法继续操作了)
/// - 目标槽本来就是空的
pub fn tree_remove_slot(
    root: &Path,
    keks: &[Kek],
    slot: usize,
    vault_salt: &[u8; 16],
    cipher: CipherId,
) -> Result<usize> {
    let info = tree_slots(root, keks, vault_salt, cipher)?;
    let Some(dir) = info.directory else {
        return Err(Error::MalformedTlv {
            tlv_type: 0,
            reason: "this tree is in deniable mode; slots cannot be removed individually",
        });
    };
    if slot == info.current {
        return Err(Error::MalformedTlv {
            tlv_type: 0,
            reason: "refusing to remove the key currently in use",
        });
    }
    if !dir.get(slot).is_some_and(|e| e.kind.is_occupied()) {
        return Err(Error::MalformedTlv {
            tlv_type: 0,
            reason: "that slot is already empty",
        });
    }

    let mut after = dir;
    after.set(slot, crate::slotdir::SlotEntry::empty())?;

    // 每个密文目录各改一份。逐个而不是只改根:任一子目录被单独拷走时
    // 也要拒绝那把被删的钥匙,否则「删掉了」只在根目录成立
    let mut changed = 0usize;
    let mut stack = vec![root.to_path_buf()];
    while let Some(cur) = stack.pop() {
        let path = cur.join(crate::dirsidecar::KEYS_SIDECAR);
        if let Ok(blob) = std::fs::read(&path)
            && crate::dirsidecar::is_managed(&blob)
            && let Ok((dk, nonce, _)) =
                crate::dirsidecar::open_sidecar_at(&blob, keks, vault_salt, cipher)
        {
            let mut plans: Vec<crate::dirsidecar::SidecarPlan> =
                (0..crate::dirsidecar::SIDECAR_SLOTS)
                    .map(|_| crate::dirsidecar::SidecarPlan::Keep)
                    .collect();
            if let Some(p) = plans.get_mut(slot) {
                *p = crate::dirsidecar::SidecarPlan::Clear;
            }
            let out = crate::dirsidecar::rewrite_sidecar_managed(
                &blob, &plans, &after, &dk, vault_salt, &nonce, cipher,
            )?;
            crate::fsatomic::write_atomic(&path, &out)?;
            changed = changed.saturating_add(1);
        }
        let Ok(rd) = std::fs::read_dir(&cur) else { continue };
        for ent in rd.flatten() {
            let sub = ent.path();
            if sub.is_dir() {
                stack.push(sub);
            }
        }
    }
    Ok(changed)
}

/// 建目录并在需要时写入边车文件。
fn create_encrypted_dir(path: &Path, enc: &EncryptedDirname, rep: &mut TreeReport) -> Result<()> {
    std::fs::create_dir_all(path)?;
    rep.dirs = rep.dirs.saturating_add(1);
    // 超长名的完整密文必须落盘,否则该目录名永远无法还原。
    // 这一步失败要报错而不是忽略——忽略的话用户拿到一个解不开名字的目录
    if let Some(sc) = &enc.sidecar {
        crate::fsatomic::write_atomic(&path.join(DIRNAME_SIDECAR), sc)?;
        rep.long_names = rep.long_names.saturating_add(1);
    }
    Ok(())
}

/// 还原一棵加密目录树。
///
/// `enc_root` 是密文根目录,`out_parent` 是还原目标的父目录。
///
/// # Errors
///
/// - `enc_root` 不是目录,或名字不像密文目录名
/// - 目录名解密失败(密码不对)
/// - 写盘失败
pub fn decrypt_tree(
    enc_root: &Path,
    out_parent: &Path,
    keks: &[Kek],
    vault_salt: &[u8; 16],
    cipher: CipherId,
    mut progress: Option<ProgressFn<'_>>,
) -> Result<TreeReport> {
    if !enc_root.is_dir() {
        return Err(Error::Io(std::io::Error::new(
            std::io::ErrorKind::NotADirectory,
            "decrypt_tree expects a directory",
        )));
    }
    // DK 从边车里取。任一把钥匙都能解开它——这正是改造的目的:
    // 不再只认 keks[0]
    let dk = read_keys_sidecar(enc_root, keks, vault_salt, cipher)?;

    let enc_name = enc_root
        .file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_default();
    let plain_root = decrypt_dir_name_at(enc_root, &enc_name, &dk, cipher)?;

    let out_root = out_parent.join(&plain_root);
    std::fs::create_dir_all(&out_root)?;

    let mut rep = TreeReport { root: out_root.clone(), dirs: 1, ..TreeReport::default() };
    decrypt_into(enc_root, &out_root, keks, &dk, cipher, &mut rep, &mut progress)?;
    Ok(rep)
}

/// 递归还原一层。
///
/// 用递归而不是显式栈:还原时的深度受**密文目录树**限制,而那棵树是我们
/// 自己按 `MAX_DEPTH` 建出来的,不会超。打包侧面对的是用户给的任意目录,
/// 才需要显式栈防爆栈。
fn decrypt_into(
    enc_dir: &Path,
    out_dir: &Path,
    keks: &[Kek],
    dk: &crate::dirsidecar::DirKey,
    cipher: CipherId,
    rep: &mut TreeReport,
    progress: &mut Option<ProgressFn<'_>>,
) -> Result<()> {
    let rd = std::fs::read_dir(enc_dir)?;
    let mut items: Vec<_> = rd.flatten().collect();
    items.sort_by_key(std::fs::DirEntry::file_name);

    for item in items {
        let name = item.file_name().to_string_lossy().into_owned();
        // 两个边车都是我们自己的记账,不属于用户数据。
        // 漏掉任何一个,还原出来的目录里会多出一个乱码文件
        if name == DIRNAME_SIDECAR || name == crate::dirsidecar::KEYS_SIDECAR {
            continue;
        }
        let path = item.path();
        let Ok(md) = path.symlink_metadata() else {
            rep.skipped.push(SkippedEntry {
                path: name,
                reason: SkipReason::Unreadable,
            });
            continue;
        };

        if md.is_dir() {
            let plain = decrypt_dir_name_at(&path, &name, dk, cipher)?;
            let sub_out = out_dir.join(&plain);
            std::fs::create_dir_all(&sub_out)?;
            rep.dirs = rep.dirs.saturating_add(1);
            decrypt_into(&path, &sub_out, keks, dk, cipher, rep, progress)?;
        } else if md.is_file() {
            let Ok(bytes) = std::fs::read(&path) else {
                rep.skipped.push(SkippedEntry {
                    path: name,
                    reason: SkipReason::Unreadable,
                });
                continue;
            };
            // 不是 omy 文件就跳过:用户可能自己往密文目录里放了东西,
            // 拿它去解密只会得到一堆认证失败的噪音
            if !crate::file::is_omy_file(&bytes) {
                rep.skipped.push(SkippedEntry {
                    path: name,
                    reason: SkipReason::NotRegular,
                });
                continue;
            }
            let opened = crate::file::open(&bytes, keks)?;
            let plain_name = opened.filename()?;
            let data = opened.decrypt_all(&bytes)?;
            let size = data.len() as u64;

            // 文件名来自密文内部,可能含路径分隔符或 `..`——那是攻击手法。
            // 必须过 sanitize,与容器模式共用同一套规则
            let safe = crate::unpack::sanitize_filename(&plain_name);
            crate::fsatomic::write_atomic(&out_dir.join(&safe), &data)?;
            rep.files = rep.files.saturating_add(1);
            if let Some(cb) = progress.as_deref_mut() {
                cb(&safe, size);
            }
        }
    }
    Ok(())
}

/// 解密一个目录的名字,必要时读它内部的长名边车文件。
fn decrypt_dir_name_at(
    dir: &Path,
    disk_name: &str,
    dk: &crate::dirsidecar::DirKey,
    cipher: CipherId,
) -> Result<String> {
    if !looks_encrypted(disk_name) {
        return Err(Error::MalformedTlv {
            tlv_type: 0,
            reason: "directory name is not an encrypted name",
        });
    }
    // 只有截断过的名字才需要长名边车。无条件去读会让每个目录多一次
    // 失败的 open 系统调用,大树上是可观的开销。
    //
    // 注意这个 .omy-name 与钥匙边车 .omy-keys 是两回事:前者存目录名密文,
    // 后者存打开目录名的钥匙
    let long_name = if disk_name.contains('~') {
        Some(std::fs::read(dir.join(DIRNAME_SIDECAR))?)
    } else {
        None
    };
    crate::dirname::decrypt_dirname_with(disk_name, long_name.as_deref(), dk, cipher)
}

/// 生成随机 nonce。
///
/// 走 [`crate::util::fill_random`] 而不是自己碰 `rand_core`:熵源不可用时
/// 的处置策略(快速失败而非降级)只该有一处决定。
fn random_nonce() -> [u8; NONCE_LEN] {
    let mut n = [0u8; NONCE_LEN];
    crate::util::fill_random(&mut n);
    n
}

/// 在密文树里找任意一个加密文件,用来取 vault 参数。
///
/// # 为什么需要它
///
/// 单个 `.omy` 文件的头部自带 `vault_salt` 与 KDF 参数,而**目录没有头部**。
/// 可是解目录名又必须先有 KEK,KEK 又要靠 salt 和参数才能派生——鸡生蛋。
/// 解法是从树里任取一个文件读它的头部:同一个 vault 内这些参数本就一致,
/// 取哪个都一样。
///
/// 三个入口都要用(CLI 解目录、GUI 加密后自动解锁、GUI 浏览解目录名),
/// 所以放在 core 里只留一份。新增判定(比如将来要跳过别的边车文件)
/// 只需改这里。
///
/// 深度优先向下找:根目录下可能只有子目录而没有直接的文件。
/// # 优先选头部能解析的
///
/// 树里可能混进损坏的、或压根不是本格式的 `.omy`。拿它当样本会让整个操作
/// 报「文件已损坏」——而那棵树其余部分完好,本该点名那一个文件、照改剩下的。
/// 更糟的是这个结果**随目录枚举顺序变化**:同一棵树、同一条命令,两次运行
/// 可能给出完全不同的错误。实测遇到过,是最难查的一类不确定性。
///
/// 全都解析不过时退回第一个 `.omy`,让调用方如实报「已损坏」;压根没有
/// `.omy` 才返回 `None`,报「不是密文树」。这三种处境对用户是三件不同的
/// 事——「坏了」和「不是」指向完全不同的排查方向,不该压成同一个结果。
#[must_use]
pub fn find_any_file(root: &Path) -> Option<PathBuf> {
    let mut damaged = None;
    find_sample(root, &mut damaged).or(damaged)
}

/// 深度优先找一个头部能解析的密文文件,顺带记下第一个解析不过的。
fn find_sample(dir: &Path, damaged: &mut Option<PathBuf>) -> Option<PathBuf> {
    // 读不了这一层不代表整棵树没救:继续往别的分支找
    let Ok(rd) = std::fs::read_dir(dir) else {
        return None;
    };
    let mut dirs = Vec::new();
    for e in rd.flatten() {
        let p = e.path();
        if p.is_dir() {
            dirs.push(p);
            continue;
        }
        // 边车文件叫 `.omy-name`,扩展名不是 `omy`,所以不会被误取。
        // 但它确实以 `.omy` 开头,靠「名字含 .omy」判断就会中招
        if !p.extension().is_some_and(|x| x == "omy") {
            continue;
        }
        if header_parses(&p) {
            return Some(p);
        }
        if damaged.is_none() {
            *damaged = Some(p);
        }
    }
    dirs.into_iter().find_map(|d| find_sample(&d, damaged))
}

/// 这个文件的头部能不能解析。
///
/// 只读前面一小段:判断是不是本格式不需要整份文件,而样本文件可能有几个 GB。
fn header_parses(path: &Path) -> bool {
    use std::io::Read as _;
    let Ok(mut f) = std::fs::File::open(path) else {
        return false;
    };
    let mut buf = vec![0u8; crate::scan::MIN_PROBE_SIZE];
    let mut got = 0usize;
    while got < buf.len() {
        let Some(dst) = buf.get_mut(got..) else { break };
        match f.read(dst) {
            Ok(0) => break,
            Ok(n) => got = got.saturating_add(n),
            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
            Err(_) => return false,
        }
    }
    buf.truncate(got);
    crate::file::peek_header(&buf).is_ok()
}

/// 批量改密码的结果。
#[derive(Debug, Default)]
pub struct RekeyReport {
    /// 成功改写的文件数。
    pub changed: usize,
    /// 重新加密了名字的目录数(含根目录)。
    pub dirs_renamed: usize,
    /// 改密码后这棵树的根路径。
    ///
    /// **根目录名会变**:它由 KEK 派生的密钥加密,换密码就得换名字。调用方
    /// 必须用这个值,原来手里的路径已经失效——不返回的话,GUI 刷新列表时
    /// 会指向一个不存在的目录,表现为「改完密码文件夹不见了」。
    pub root: PathBuf,
    /// 失败的:`(路径, 原因)`。
    ///
    /// 逐个记而不是只留一个总的错误码:一棵树里可能只有个别文件出问题
    /// (被别的程序占用、权限不对、单个文件损坏),用户需要知道**是哪些**
    /// 才能去处理,而其余文件已经改好了。
    pub failed: Vec<(PathBuf, String)>,
    /// 是否换掉了文件密钥(轮换)。
    pub payload_rewritten: bool,
    /// 轮换时重写的明文总字节数。非轮换恒为 0。
    pub bytes_rewritten: u64,
}

impl RekeyReport {
    /// 是否整棵树都改成功了。
    #[must_use]
    pub const fn is_complete(&self) -> bool {
        self.failed.is_empty()
    }
}

/// 给整棵密文树里的每个文件改密码(增删改),载荷一字节不动。
///
/// `unlock` 用于解开现有文件,`keep` 是改写后应当能打开这些文件的**全部**
/// KEK。语义与 [`crate::keyslot::rewrite_slots`] 完全一致,只是作用于整棵树。
///
/// 目录名也会一并用新密钥重新加密——只改文件会留下一个两个密码都用不了的
/// 状态(新密码开得了文件但解不开目录名,旧密码反之)。所以**根目录名会变**,
/// 新路径在 [`RekeyReport::root`] 里返回。
///
/// # 为什么可以只派生一次 KEK
///
/// 同一棵树里所有文件共用一个 `vault_salt` 与一组 KDF 参数(`encrypt_tree`
/// 把同一个 salt 传给每个文件),所以 Argon2 只需跑一次。这很关键:Argon2
/// 每次几百毫秒,若每个文件都要派生,1000 个文件就是好几分钟。
///
/// 而改写本身只动 384 字节 slot 区 + 32 字节 MAC,所以整棵树的成本约等于
/// 「一次 Argon2 + N 次小文件读写」。
///
/// # 部分失败是**如实报告**,不回滚
///
/// 这是这个操作最需要想清楚的地方。改到第 50 个文件时断电或出错,前 49 个
/// 已经是新密码、后面还是旧密码,于是两个密码各能开一半。
///
/// 三种可能的处理方式,选第三种:
///
/// 1. **回滚**——要先把 N 个文件的原始字节全留一份,一棵大树可能是几十 GB;
///    而且回滚本身也会中途失败,那时状态更难描述。
/// 2. **假装成功**——最糟。用户以为密码已经全换了,实际一半没换;等他哪天
///    用新密码打不开另一半,早已不知道该用哪个旧密码。
/// 3. **如实报告**(本实现)——返回改了哪些、哪些没改。**旧密码不会失效**,
///    所以用户手上一定有一个能打开剩余文件的密码,重跑一次即可补齐。
///
/// 第三种之所以安全,前提是 `keep` 里通常仍包含用户当前用的密码,或者用户
/// 知道旧密码。调用方在文案上必须讲清「哪些文件还是旧密码」,否则用户不知道
/// 该重跑。
///
/// 幂等性:对已经改好的文件重跑会失败(`unlock` 里的旧密码打不开它了),
/// 所以补跑时应当把新旧密码都放进 `unlock`。
///
/// # 为什么不先全部改好再统一落盘
///
/// 那需要把 N 个文件的新字节全部驻留内存。改写只动头部,但 `rewrite_slots`
/// 返回的是**完整文件字节**——一棵 50 GB 的树就是 50 GB 内存。
///
/// # Errors
///
/// - [`Error::MalformedHeader`]:`keep` 为空
/// - [`Error::MalformedHeader`]:`root` 下找不到任何加密文件
///
/// 单个文件的失败不会中断遍历,记进 `failed`。
pub fn rekey_tree(
    root: &Path,
    unlock: &[Kek],
    keep: &[Kek],
    vault_salt: &[u8; 16],
    cipher: CipherId,
    others: crate::keyslot::OtherSlots,
) -> Result<RekeyReport> {
    let params = RekeyParams { vault_salt, cipher, rotate: false, others };
    rekey_tree_with_progress(root, unlock, keep, params, None)
}

/// 进度回调:`(当前文件名, 第几个, 共几个, 该文件已处理字节, 该文件总字节)`。
///
/// 带上文件内的字节进度而不只是「第 i / n 个」:轮换一个 4 GB 的视频要几十
/// 秒,只报文件序号的话进度条会长时间停在同一格,用户无从判断是在跑还是
/// 卡死了。改密码时字节进度恒为 (0, 0),调用方据此只显示文件序号即可。
pub type RekeyProgressFn<'a> = &'a mut dyn FnMut(&str, usize, usize, u64, u64);

/// 改写整棵树的钥匙时要一起传的那几项。
///
/// 打成结构体而不是继续加形参:到第 8 个参数时,调用点已经是一串
/// &salt, cipher, false, Carry, None —— 读的人根本分不出谁是谁,
/// 而把 rotate 和 others 的位置写反不会编译报错,只会静默改变语义。
#[derive(Debug, Clone, Copy)]
pub struct RekeyParams<'a> {
    /// vault 级 salt。
    pub vault_salt: &'a [u8; 16],
    /// AEAD 算法。
    pub cipher: CipherId,
    /// 是否连载荷一起重写(轮换文件密钥)。
    pub rotate: bool,
    /// 认不出来的槽位怎么处理:只有 remove 该用 Discard。
    pub others: crate::keyslot::OtherSlots,
}

/// 同 [`rekey_tree`],另外支持轮换文件密钥与进度上报。
///
/// # `rotate` 的代价
///
/// `false` 时只重建每个文件 384 字节的 slot 区,一棵几万文件的树也是秒级。
/// `true` 时要把每个文件的载荷完整读一遍、用新 FEK 写一遍,耗时与总数据量
/// 成正比——几十 GB 的树要跑很久。
///
/// # 为什么轮换值得做
///
/// 改密码只换外层包裹,FEK 没变:**攻击者手里若有旧文件副本,仍能用旧密码
/// 打开那个副本**。只有换掉 FEK 并重写载荷,才能让旧密码与这份数据彻底
/// 无关(已经流出去的副本仍是独立密文,本操作对它无能为力——它永远可以
/// 被旧密码打开,这一点必须让用户知道)。
///
/// # 中途失败不会毁数据
///
/// 每个文件各自 `write_atomic`,所以任一时刻每个文件要么是完整的旧密文、
/// 要么是完整的新密文,不存在写坏一半的文件。若同时换了密码,部分失败会
/// 留下「一半新密码、一半旧密码」——报告里如实列出是哪些,两个密码都提供
/// 就能重跑补齐。
pub fn rekey_tree_with_progress(
    root: &Path,
    unlock: &[Kek],
    keep: &[Kek],
    params: RekeyParams<'_>,
    mut progress: Option<RekeyProgressFn<'_>>,
) -> Result<RekeyReport> {
    let RekeyParams { vault_salt, cipher, rotate, others } = params;
    if keep.is_empty() {
        return Err(Error::MalformedHeader {
            reason: "refusing to leave files with zero key slots; they could never be opened again",
        });
    }
    // 先确认这确实是一棵密文树。不查的话,对一个普通目录执行会得到
    // 「0 个文件已改写」——看起来像成功,其实什么都没发生
    if find_any_file(root).is_none() {
        return Err(Error::MalformedHeader {
            reason: "no encrypted file found under the given directory",
        });
    }

    // DK 从边车里取。它是随机的、与密码无关——这带来一个很大的简化:
    // **换密码不再需要给目录改名**,只要用新的钥匙集合重写边车即可。
    //
    // 改造之前目录名密钥由 keks[0] 派生,换密码就得把整棵树的目录名全部
    // 重新加密、逐个改名;增量备份要重传全部目录项,用户回到文件管理器
    // 还会发现文件夹「不见了」。现在这些都不再发生
    let dk = read_keys_sidecar(root, unlock, vault_salt, cipher)?;

    let mut rep = RekeyReport { payload_rewritten: rotate, ..RekeyReport::default() };
    // 只有轮换才值得先数一遍:改密码是秒级的,为了显示「第 i / n 个」
    // 多遍历一次目录反而占了操作本身可观的比例
    let total = if rotate { count_files(root) } else { 0 };
    let mut ctx = RekeyCtx {
        unlock,
        keep,
        dk: &dk,
        vault_salt,
        cipher,
        others,
        rotate,
        total,
        done: 0,
    };
    rekey_into(root, &mut ctx, &mut progress, &mut rep);

    // 根目录的边车也要重写——rekey_into 只处理子目录
    if let Err(e) = rewrite_keys_sidecar(root, keep, &dk, vault_salt, cipher, others) {
        rep.failed.push((root.to_path_buf(), e.to_string()));
    }

    // 目录名不变,所以 root 原样返回。调用方手里的路径依然有效
    rep.root = root.to_path_buf();
    Ok(rep)
}

/// 只对给定的这些文件改密码,不碰目录名,也不遍历整棵树。
///
/// 用于重试上一次 [`rekey_tree_with_progress`] 报告里失败的那些文件。
///
/// # 为什么不重走整棵树
///
/// 上一次已经改好的文件现在用**新**密码,重走时拿旧密码去开必然失败,于是
/// 报告里会多出一堆假失败——用户分不清哪些是真问题。而且目录名在第一次
/// 就已经改完了,再改一次等于把整棵树的名字又换一遍。
///
/// # 参数
///
/// `files` 是绝对路径(报告里返回的就是绝对路径)。不接受相对路径:
/// 相对于谁很容易搞错,而调用方手里本来就有绝对路径。
///
/// # Errors
///
/// `keep` 为空时拒绝:那会留下永远打不开的文件。
pub fn retry_files(
    files: &[PathBuf],
    unlock: &[Kek],
    keep: &[Kek],
    rotate: bool,
    others: crate::keyslot::OtherSlots,
    mut progress: Option<RekeyProgressFn<'_>>,
) -> Result<RekeyReport> {
    if keep.is_empty() {
        return Err(Error::MalformedHeader {
            reason: "refusing to leave files with zero key slots; they could never be opened again",
        });
    }
    let mut rep = RekeyReport { payload_rewritten: rotate, ..RekeyReport::default() };
    let total = files.len();
    for (i, path) in files.iter().enumerate() {
        let idx = i.saturating_add(1);
        let shown =
            path.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned());
        let mut on_bytes = |done: u64, bytes: u64| {
            if let Some(cb) = progress.as_deref_mut() {
                cb(&shown, idx, total, done, bytes);
            }
        };
        match rekey_one(path, unlock, keep, rotate, others, &mut on_bytes) {
            Ok(written) => {
                rep.changed = rep.changed.saturating_add(1);
                rep.bytes_rewritten = rep.bytes_rewritten.saturating_add(written);
            }
            Err(err) => rep.failed.push((path.clone(), err)),
        }
    }
    // 没改目录名,所以根路径无从得知也无需变化。留空表示「路径没变」,
    // 调用方不该拿它去刷新界面
    Ok(rep)
}

/// 递归改写一层:先文件,再子目录名。
///
/// # 目录名必须一起改
///
/// 目录名由 `DirnameKey::derive(keep[0], vault_salt)` 加密,KEK 一换密钥就
/// 变了。只改文件的话会留下一个**两个密码都用不了**的状态:新密码开得了
/// 文件却解不开目录名(进不去),旧密码解得开目录名却开不了文件。这比
/// 「不能改密码」糟得多,所以两者必须在同一个操作里完成。
///
/// # 为什么自底向上
///
/// 先递归进子目录处理完,回来再重命名它。反过来做的话,重命名之后手里的
/// 路径就失效了,后续遍历会找不到文件。
/// 遍历时共享的那一组参数。
///
/// 打成一个结构体而不是继续加形参:递归函数已经有 8 个参数了,再加轮换
/// 开关、文件总数和进度回调会到 11 个,调用点根本读不出谁是谁。
struct RekeyCtx<'a> {
    unlock: &'a [Kek],
    keep: &'a [Kek],
    /// 整棵树共用的目录密钥。改写钥匙集合时它保持不变——
    /// 这正是「换密码不用改目录名」的原因
    dk: &'a crate::dirsidecar::DirKey,
    vault_salt: &'a [u8; 16],
    cipher: CipherId,
    /// 认不出来的槽位怎么处理。
    ///
    /// 文件槽位与边车必须跟随**同一个**策略。写死 Carry 的后果实测过:
    /// remove 承诺作废其它密码,实际第二个密码照样能解开整棵树——
    /// 用户以为撤销了访问权,实际没有。
    others: crate::keyslot::OtherSlots,
    rotate: bool,
    /// 待处理的密文文件总数,用于「第 i / n 个」。
    total: usize,
    /// 已处理到第几个。
    done: usize,
}

fn rekey_into(
    dir: &Path,
    ctx: &mut RekeyCtx<'_>,
    progress: &mut Option<RekeyProgressFn<'_>>,
    rep: &mut RekeyReport,
) {
    let Ok(rd) = std::fs::read_dir(dir) else {
        rep.failed.push((dir.to_path_buf(), String::from("read_dir failed")));
        return;
    };
    let mut subdirs = Vec::new();
    for e in rd.flatten() {
        let p = e.path();
        if p.is_dir() {
            subdirs.push(p);
            continue;
        }
        // 只处理真正的密文文件。边车 `.omy-name` 的扩展名不是 `omy`,
        // 与 find_any_file 用同一条判据
        if !p.extension().is_some_and(|x| x == "omy") {
            continue;
        }
        ctx.done = ctx.done.saturating_add(1);
        let idx = ctx.done;
        let total = ctx.total;
        // 磁盘名而不是解出来的原名:这一步还没解密,为了显示一个名字去解
        // 整个文件头没有道理,何况用户在列表里看到的本来也是这个
        let shown = p.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned());
        let mut on_bytes = |done: u64, bytes_total: u64| {
            if let Some(cb) = progress.as_deref_mut() {
                cb(&shown, idx, total, done, bytes_total);
            }
        };
        match rekey_one(&p, ctx.unlock, ctx.keep, ctx.rotate, ctx.others, &mut on_bytes) {
            Ok(written) => {
                rep.changed = rep.changed.saturating_add(1);
                rep.bytes_rewritten = rep.bytes_rewritten.saturating_add(written);
            }
            Err(err) => rep.failed.push((p, err)),
        }
    }
    // 自底向上:先把子目录内部处理完,再重写它自己的边车。
    //
    // 不再需要改名——DK 不变,目录名密文照旧有效。只有「谁能解开 DK」
    // 这件事变了,而那全部写在边车里
    for sub in subdirs {
        rekey_into(&sub, ctx, progress, rep);
        // 不是我们加密的目录就跳过:没有边车可写,也不该凭空造一个
        if !crate::dirname::looks_encrypted(
            &sub.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned()),
        ) {
            continue;
        }
        if let Err(e) = rewrite_keys_sidecar(&sub, ctx.keep, ctx.dk, ctx.vault_salt, ctx.cipher, ctx.others) {
            rep.failed.push((sub, e.to_string()));
        }
    }
}

/// 数一棵树里有多少个密文文件。
///
/// 轮换前先数一遍才能显示「第 i / n 个」。多走一次目录遍历的代价远小于
/// 轮换本身(那要把每个文件的载荷读写一遍),而没有总数的进度条只能转圈,
/// 用户无从判断还要等多久。
fn count_files(dir: &Path) -> usize {
    let Ok(rd) = std::fs::read_dir(dir) else {
        return 0;
    };
    let mut n: usize = 0;
    for e in rd.flatten() {
        let p = e.path();
        if p.is_dir() {
            n = n.saturating_add(count_files(&p));
        } else if p.extension().is_some_and(|x| x == "omy") {
            n = n.saturating_add(1);
        }
    }
    n
}

/// 改写单个文件。错误转成字符串,好让调用方逐个展示。
fn rekey_one(
    path: &Path,
    unlock: &[Kek],
    keep: &[Kek],
    rotate: bool,
    others: crate::keyslot::OtherSlots,
    on_bytes: &mut dyn FnMut(u64, u64),
) -> core::result::Result<u64, String> {
    let data = std::fs::read(path).map_err(|e| format!("read: {e}"))?;

    // 两条路径只差这一步:改密码只重建 384 字节的 slot 区,轮换要把载荷
    // 读一遍再写一遍。前后的检查(自证可打开、原子写回)完全共用——
    // 分成两个函数的话,将来加一项检查就必然漏掉一边
    let (bytes, written) = if rotate {
        // 读与写各占一半:把两个阶段拼成一条 0→100,用户看到的是一条
        // 匀速推进的进度,而不是「跑到 100% 又归零重来一遍」
        //
        // 走 RefCell 是因为 rotate_fek_with_progress 要两个独立的
        // `&mut dyn FnMut`,而它们都得往同一个回调里写——借用检查器不接受
        // 两个闭包同时可变借用。运行期两者其实不重叠(解密跑完才轮到加密)
        let sink = core::cell::RefCell::new(on_bytes);
        let mut dec = |done: u64, total: u64| {
            if let Ok(mut f) = sink.try_borrow_mut() {
                f(done, total.saturating_mul(2));
            }
        };
        let mut enc = |done: u64, total: u64| {
            if let Ok(mut f) = sink.try_borrow_mut() {
                f(total.saturating_add(done), total.saturating_mul(2));
            }
        };
        let out = crate::reencrypt::rotate_fek_with_progress(
            &data,
            unlock,
            keep,
            &crate::file::RandomMaterial::generate(),
            Some(&mut dec),
            Some(&mut enc),
        )
        .map_err(|e| e.to_string())?;
        (out.bytes, out.plaintext_size)
    } else {
        // 策略由调用方决定:add / change 用 Carry 保住认不出来的槽位
        // (典型是恢复码),只有 remove 用 Discard 清场。
        //
        // 「树只能有一个密码」的旧约束已经不存在了——目录名改用两层结构
        // 之后,任一把钥匙都能解开它。这段注释以前写的是那个旧前提,
        // 那是用 key add 或恢复码流程加上去的,与「树只能有一个浏览密码」
        // 并不矛盾。填随机会把它们静默抹掉
        let out = crate::keyslot::rewrite_slots(&data, unlock, keep, others)
        .map_err(|e| e.to_string())?;
        (out.bytes, 0)
    };

    // 写回前自证每个保留密码都能打开新字节。逐个验而不是只验第一个:
    // add 最容易犯的错是新密码能开、原密码被挤掉,只验一个正好漏掉。
    //
    // 顺序不能反——先覆盖再发现打不开,用户就同时失去了文件和访问权
    for k in keep {
        crate::file::open(&bytes, &[k.duplicate()])
            .map_err(|_| String::from("rewrite verify failed"))?;
    }

    crate::fsatomic::write_atomic(path, &bytes).map_err(|e| format!("write: {e}"))?;
    Ok(written)
}

/// 16 字节转小写十六进制。
fn hex16(b: &[u8; 16]) -> String {
    let mut s = String::with_capacity(32);
    for byte in b {
        // 手写而不用 format! 循环:这在每个文件上都要跑一次
        const HEX: &[u8; 16] = b"0123456789abcdef";
        let hi = usize::from(byte >> 4);
        let lo = usize::from(byte & 0x0F);
        s.push(char::from(*HEX.get(hi).unwrap_or(&b'0')));
        s.push(char::from(*HEX.get(lo).unwrap_or(&b'0')));
    }
    s
}

#[cfg(test)]
#[expect(clippy::unwrap_used, clippy::indexing_slicing, reason = "测试里用断言即可")]
mod tests {
    use super::*;
    use crate::crypto::Argon2Params;
    use crate::header::FixedHeader;

    /// 造一棵小树并加密,返回 (工作目录, 密文根, salt, cipher)。
    ///
    /// cipher 一并返回而不是让调用方硬写常量:写错的话目录名会用错算法
    /// 解密、报 content hash mismatch,看起来像「加密坏了」这种严重缺陷。
    fn tree_fixture(tag: &str) -> (PathBuf, PathBuf, [u8; 16], CipherId) {
        let root = std::env::temp_dir().join(format!("omy-rekey-{tag}"));
        let _ = std::fs::remove_dir_all(&root);
        let src = root.join("src");
        std::fs::create_dir_all(src.join("sub")).unwrap();
        std::fs::write(src.join("a.txt"), b"AAA").unwrap();
        std::fs::write(src.join("sub/b.txt"), b"BBB").unwrap();

        let salt = [7u8; 16];
        let kek = Kek::from_password(b"old", &salt, Argon2Params::TEST_WEAK).unwrap();
        let out = root.join("enc");
        std::fs::create_dir_all(&out).unwrap();
        let opts = crate::file::EncryptOptions::default();
        let rep = encrypt_tree(&src, &out, &[kek], &salt, &opts, None).unwrap();
        (root, rep.root, salt, opts.cipher)
    }

    fn kek_of(pw: &[u8], salt: &[u8; 16]) -> Kek {
        Kek::from_password(pw, salt, Argon2Params::TEST_WEAK).unwrap()
    }

    #[test]
    fn media_extras_are_written_per_file_not_shared() {
        // 回调返回的缩略图必须写进**对应那个文件**。
        //
        // 不这样会怎样:早先树形模式只设了 filename,其余字段沿用调用方
        // 传进来的那份 opts —— 那是整棵树共用的一份,于是要么全为空
        // (谁都没有缩略图),要么写进了上一个文件的缩略图(A 的图出现在
        // B 上)。后者尤其难查:界面能显示图,只是显示错了。
        let root = std::env::temp_dir().join("omy-media-cb");
        let _ = std::fs::remove_dir_all(&root);
        let src = root.join("src");
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(src.join("one.bin"), b"1111").unwrap();
        std::fs::write(src.join("two.bin"), b"2222").unwrap();

        let salt = [9u8; 16];
        let kek = kek_of(b"pw", &salt);
        let out = root.join("enc");
        std::fs::create_dir_all(&out).unwrap();
        let opts = crate::file::EncryptOptions::default();

        // 每个文件给一份**内容不同**的假缩略图,内容由文件名决定。
        // 若实现把它们串了,下面按名字取回时就会对不上。
        let mut seen = Vec::new();
        let mut cb = |_data: &[u8], name: &str| {
            seen.push(name.to_owned());
            let thumb = format!("thumb-of-{name}").into_bytes();
            let meta = format!("{{\"name\":\"{name}\"}}").into_bytes();
            (Some(thumb), Some(meta), None)
        };
        let rep = encrypt_tree_with_media(
            &src,
            &out,
            &[kek.duplicate()],
            &salt,
            &opts,
            None,
            Some(&mut cb),
        )
        .unwrap();
        assert_eq!(rep.files, 2);
        seen.sort();
        assert_eq!(seen, vec!["one.bin", "two.bin"], "回调应当每个文件各调一次");

        // 逐个解开,核对缩略图与文件名是否配套
        let mut checked = 0;
        for p in all_files(&rep.root) {
            let data = std::fs::read(&p).unwrap();
            let opened = crate::file::open(&data, &[kek.duplicate()]).unwrap();
            let name = opened.filename().unwrap();
            let thumb = opened.thumbnail().unwrap();
            assert_eq!(
                thumb,
                format!("thumb-of-{name}").into_bytes(),
                "{name} 拿到的是别人的缩略图"
            );
            let meta = opened.media_meta().unwrap();
            assert_eq!(meta, format!("{{\"name\":\"{name}\"}}").into_bytes());
            checked += 1;
        }
        assert_eq!(checked, 2);
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn encrypt_tree_without_media_writes_no_thumbnail() {
        // 不传回调时不该凭空造出缩略图。这条守住「回调是唯一来源」,
        // 顺带保证不传回调的调用方(老代码)行为不变。
        let root = std::env::temp_dir().join("omy-media-none");
        let _ = std::fs::remove_dir_all(&root);
        let src = root.join("src");
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(src.join("a.bin"), b"AAAA").unwrap();

        let salt = [11u8; 16];
        let kek = kek_of(b"pw", &salt);
        let out = root.join("enc");
        std::fs::create_dir_all(&out).unwrap();
        let opts = crate::file::EncryptOptions::default();
        let rep = encrypt_tree(&src, &out, &[kek.duplicate()], &salt, &opts, None).unwrap();

        for p in all_files(&rep.root) {
            let data = std::fs::read(&p).unwrap();
            let opened = crate::file::open(&data, &[kek.duplicate()]).unwrap();
            assert!(!opened.has_thumbnail(), "没传回调却有缩略图");
        }
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn rekey_tree_changes_every_file_and_old_password_stops_working() {
        let (root, enc_root, salt, cipher) = tree_fixture("change");
        let old = kek_of(b"old", &salt);
        let new = kek_of(b"new", &salt);

        let rep =
            rekey_tree(&enc_root, &[old.duplicate()], &[new.duplicate()], &salt, cipher, crate::keyslot::OtherSlots::Carry).unwrap();
        assert_eq!(rep.changed, 2, "两个文件都该改到;漏掉深层文件是最可能的缺陷");
        assert!(rep.is_complete(), "失败项: {:?}", rep.failed);
        // 目录名现在由随机 DK 加密,与密码无关——换密码不再改名。
        // 这比旧行为好:用户回到文件管理器不会发现文件夹「不见了」,
        // 增量备份也不用重传全部目录项
        assert_eq!(rep.root, enc_root, "换密码不该改目录名");
        assert!(rep.root.is_dir(), "根路径应当仍然有效");
        let enc_root = rep.root.clone();

        // 正面:新密码能打开每一个文件
        let mut checked = 0;
        for f in all_files(&enc_root) {
            let data = std::fs::read(&f).unwrap();
            let opened = crate::file::open(&data, &[new.duplicate()]).unwrap();
            // 取出原文件名:能解出来就说明 FEK 是对的(文件名用 FEK 加密)
            assert!(opened.filename().unwrap().ends_with(".txt"));
            checked += 1;
            // 反证:旧密码必须打不开了,否则「改密码」根本没生效
            assert!(
                crate::file::open(&data, &[old.duplicate()]).is_err(),
                "旧密码仍能打开 {f:?}——改密码没生效,而用户以为已经换了"
            );
        }
        assert_eq!(checked, 2);
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn rekey_tree_keeps_payload_byte_identical() {
        // 改密码只该动 384 字节 slot 区 + 32 字节 MAC。若实现退化成
        // 「解密后重新加密」,载荷和 file_uuid 都会变——对增量备份来说
        // 整棵树都要重传,而且大文件会慢几个数量级
        let (root, enc_root, salt, cipher) = tree_fixture("payload");
        // 按**磁盘文件名**索引而不是完整路径:目录名会随改密码变化,
        // 完整路径在 rekey 之后就失效了。文件名是随机 uuid,不会变
        let before: std::collections::BTreeMap<String, Vec<u8>> = all_files(&enc_root)
            .into_iter()
            .map(|p| {
                let d = std::fs::read(&p).unwrap();
                (p.file_name().unwrap().to_string_lossy().into_owned(), d)
            })
            .collect();

        let old = kek_of(b"old", &salt);
        let new = kek_of(b"new", &salt);
        let rep = rekey_tree(&enc_root, &[old], &[new], &salt, cipher, crate::keyslot::OtherSlots::Carry).unwrap();

        for p in all_files(&rep.root) {
            let name = p.file_name().unwrap().to_string_lossy().into_owned();
            let old_bytes = before.get(&name).expect("改密码后出现了新文件名——uuid 不该变");
            let new_bytes = std::fs::read(&p).unwrap();
            assert_eq!(old_bytes.len(), new_bytes.len(), "长度变了:{p:?}");
            let old_bytes = old_bytes.clone();
            // 头部固定区 + slot 区 + MAC 之后的部分必须逐字节相同。
            // 用 header_len 定位载荷起点,不靠硬编码偏移
            let h = FixedHeader::parse(&new_bytes).unwrap();
            let start = h.header_len as usize;
            assert_eq!(
                &old_bytes[start..],
                &new_bytes[start..],
                "载荷被改写了:{p:?}——说明退化成了解密后重新加密"
            );
            // file_uuid 也必须不变
            let h0 = FixedHeader::parse(&old_bytes).unwrap();
            assert_eq!(h0.file_uuid, h.file_uuid, "file_uuid 变了:{p:?}");
        }
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn rekey_tree_refuses_empty_keep() {
        // keep 为空会留下永远打不开的文件,等同于销毁数据
        let (root, enc_root, salt, cipher) = tree_fixture("empty");
        let old = kek_of(b"old", &salt);
        assert!(rekey_tree(&enc_root, &[old], &[], &salt, cipher, crate::keyslot::OtherSlots::Carry).is_err());
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn rekey_tree_rejects_a_plain_directory() {
        // 对普通目录执行必须报错,不能返回「0 个已改写」——那看着像成功,
        // 而用户其实选错了目录
        let root = std::env::temp_dir().join("omy-rekey-plain");
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(root.join("x.txt"), b"not encrypted").unwrap();
        let salt = [7u8; 16];
        let k = kek_of(b"pw", &salt);
        assert!(
            rekey_tree(&root, &[k.duplicate()], &[k], &salt, CipherId::ChaCha20Poly1305, crate::keyslot::OtherSlots::Carry)
                .is_err(),
            "对普通目录应当报错,而不是静默返回 0 个已改写"
        );
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn rekey_tree_rejects_a_wrong_password_without_touching_anything() {
        // 密码不对时整体拒绝,且磁盘一字节不动。
        //
        // 行为在改用两层目录名结构后变了:以前是逐个文件试、报 N 条
        // 失败;现在开头读边车就失败,直接返回 Err。后者更好——用户
        // 拿到的是一句明确的「打不开这棵树」,而不是一串让他分不清
        // 「密码不对」还是「文件坏了」的逐文件失败。
        //
        // 不变的是那条核心断言:磁盘上一字节都不能动。写坏了才报错的
        // 实现会让用户既没改成密码、又丢了文件
        let (root, enc_root, salt, cipher) = tree_fixture("wrongpw");
        let before: Vec<Vec<u8>> =
            all_files(&enc_root).iter().map(|p| std::fs::read(p).unwrap()).collect();

        let bad = kek_of(b"bad", &salt);
        let new = kek_of(b"new", &salt);
        let r = rekey_tree(&enc_root, &[bad], &[new], &salt, cipher, crate::keyslot::OtherSlots::Carry);
        assert!(r.is_err(), "密码不对必须整体拒绝,而不是部分成功");

        let after: Vec<Vec<u8>> =
            all_files(&enc_root).iter().map(|p| std::fs::read(p).unwrap()).collect();
        assert_eq!(before, after, "密码不对却动了文件");
        assert!(enc_root.is_dir(), "密码不对却动了根目录");
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn tree_remove_really_voids_the_other_passwords() {
        // remove 承诺「只保留当前密码」。这条守着它真的兑现了。
        //
        // 不这样会怎样:实测过一个版本,rekey_tree 内部对文件槽位和
        // 边车都写死 Carry,于是第二个密码照样能解开整棵树——用户以为
        // 撤销了某人的访问权,实际没有。这是安全事故级的缺陷,而当时
        // 没有任何测试守着它,是靠临时探针偶然发现的。
        let (root, enc_root, salt, cipher) = tree_fixture("rmvoid");
        let keep_me = kek_of(b"old", &salt);
        let to_void = kek_of(b"second", &salt);

        // 先把第二个密码加上,并自证它真的能用——否则下面的断言
        // 可能只是因为它从来没生效过
        let added = rekey_tree(
            &enc_root,
            &[keep_me.duplicate()],
            &[keep_me.duplicate(), to_void.duplicate()],
            &salt,
            cipher,
            crate::keyslot::OtherSlots::Carry,
        )
        .unwrap();
        assert!(added.is_complete(), "{:?}", added.failed);
        let probe = root.join("probe-before");
        std::fs::create_dir_all(&probe).unwrap();
        assert!(
            decrypt_tree(&added.root, &probe, &[to_void.duplicate()], &salt, cipher, None)
                .is_ok(),
            "第二个密码一开始就该能解开整棵树,否则这条测试证明不了什么"
        );

        // remove:只保留第一个密码
        let removed = rekey_tree(
            &added.root,
            &[keep_me.duplicate()],
            &[keep_me.duplicate()],
            &salt,
            cipher,
            crate::keyslot::OtherSlots::Discard,
        )
        .unwrap();
        assert!(removed.is_complete(), "{:?}", removed.failed);

        // 保留的那个还能用
        let after_keep = root.join("after-keep");
        std::fs::create_dir_all(&after_keep).unwrap();
        assert!(
            decrypt_tree(&removed.root, &after_keep, &[keep_me], &salt, cipher, None).is_ok(),
            "remove 把该保留的密码也弄丢了"
        );

        // 被移除的那个必须两层都失效:文件打不开,整棵树也解不开。
        // 只查其中一层是不够的——缺陷正是「文件层清了、目录名层没清」
        // 这种半残状态
        for f in all_files(&removed.root) {
            let data = std::fs::read(&f).unwrap();
            assert!(
                crate::file::open(&data, &[to_void.duplicate()]).is_err(),
                "被移除的密码仍能打开文件 {f:?}"
            );
        }
        let after_void = root.join("after-void");
        std::fs::create_dir_all(&after_void).unwrap();
        assert!(
            decrypt_tree(&removed.root, &after_void, &[to_void], &salt, cipher, None).is_err(),
            "被移除的密码仍能解开整棵树——用户以为撤销了访问权,实际没有"
        );

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn rekey_tree_can_add_a_second_password_keeping_the_first() {
        // add 的语义:两个密码都能开。最容易犯的错是新密码把原密码挤掉
        let (root, enc_root, salt, cipher) = tree_fixture("add");
        let old = kek_of(b"old", &salt);
        let extra = kek_of(b"extra", &salt);
        let rep = rekey_tree(
            &enc_root,
            &[old.duplicate()],
            &[old.duplicate(), extra.duplicate()],
            &salt,
            cipher,
            crate::keyslot::OtherSlots::Carry,
        )
        .unwrap();
        assert!(rep.is_complete(), "{:?}", rep.failed);
        // add 保留了原密码作为第一个 KEK,所以目录名密钥不变、根路径不变。
        // 这是 add 与 change 的一个可观察差别
        assert_eq!(rep.root, enc_root, "根目录名任何时候都不该变");

        for f in all_files(&enc_root) {
            let data = std::fs::read(&f).unwrap();
            assert!(crate::file::open(&data, &[old.duplicate()]).is_ok(), "原密码被挤掉了");
            assert!(crate::file::open(&data, &[extra.duplicate()]).is_ok(), "新密码不生效");
        }
        let _ = std::fs::remove_dir_all(&root);
    }

    /// 造一棵树并让其中一个文件写入失败,返回 (root, 报告, 两个 KEK, cipher)。
    ///
    /// 用「设为只读」制造失败而不是塞垃圾文件:只读文件 open 成功、写回失败,
    /// 正是真实场景(被别的程序占用、权限不对)走的那条路径。塞垃圾文件走的
    /// 是 open 失败,测不到「改到一半」的状态。
    ///
    /// 只在 Windows 上编译:POSIX 的写入权限看的是**目录**,只读文件照样能被
    /// rename 覆盖,所以这个手法在那里造不出失败。与其写一段在 Unix 上静默
    /// 失效的测试,不如明确限定平台
    #[cfg(windows)]
    fn partial_failure_fixture(tag: &str) -> (PathBuf, RekeyReport, Kek, Kek, CipherId) {
        let (root, enc_root, salt, cipher) = tree_fixture(tag);
        let old = kek_of(b"old", &salt);
        let new = kek_of(b"new", &salt);

        let victim = all_files(&enc_root).into_iter().next().expect("树里应当有文件");
        let mut perm = std::fs::metadata(&victim).unwrap().permissions();
        perm.set_readonly(true);
        std::fs::set_permissions(&victim, perm).unwrap();

        let rep =
            rekey_tree(&enc_root, &[old.duplicate()], &[new.duplicate()], &salt, cipher, crate::keyslot::OtherSlots::Carry).unwrap();

        // 解除只读,让调用方可以接着测重试。
        // clippy 提醒 set_readonly(false) 在 Unix 上等于全局可写——这里整个
        // fixture 已经限定 windows,不存在那个问题
        for (p, _) in &rep.failed {
            if let Ok(md) = std::fs::metadata(p) {
                #[expect(
                    clippy::permissions_set_readonly_false,
                    reason = "仅 Windows 编译,不存在 Unix 上全局可写的问题"
                )]
                {
                    let mut perm = md.permissions();
                    perm.set_readonly(false);
                    let _ = std::fs::set_permissions(p, perm);
                }
            }
        }
        (root, rep, old, new, cipher)
    }

    #[test]
    #[cfg(windows)]
    fn partial_failure_reports_paths_that_still_exist() {
        // 修复前这里每一条 exists() 都是 false:失败是在改名之前记下的,
        // 之后父目录被逐层改名,路径全部指向不存在的位置。
        //
        // 不这样会怎样:CLI 把一串不存在的路径打印给用户,GUI 也没法按它
        // 重试——「重试」拿到的每个目标都是「文件不存在」
        let (root, rep, _old, _new, _cipher) = partial_failure_fixture("partpath");

        assert!(!rep.is_complete(), "只读文件应当导致部分失败");
        assert_eq!(rep.failed.len(), 1, "只有一个文件被设为只读:{:?}", rep.failed);
        // 两层结构下不再改名,所以失败路径天然不会失效。
        // 这条断言保留是为了记录:路径有效性现在是结构保证的,
        // 不再依赖「改完名记得回填路径」那段容易漏的逻辑
        assert_eq!(rep.dirs_renamed, 0, "不该再有改名发生");

        for (p, _) in &rep.failed {
            assert!(p.exists(), "报告里的失败路径不存在:{p:?}");
            // 而且必须落在改名后的新树里,不能是旧前缀
            assert!(
                p.starts_with(&rep.root),
                "失败路径不在报告返回的新根目录下:{p:?} 不以 {:?} 开头",
                rep.root
            );
        }
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    #[cfg(windows)]
    fn retry_files_finishes_the_job_without_touching_the_rest() {
        // 重试的完整闭环:只处理清单里的文件,处理完整棵树能用新密码解开。
        let (root, rep, old, new, cipher) = partial_failure_fixture("retryok");
        let salt = [7u8; 16];
        let before = rep.changed;
        let list: Vec<PathBuf> = rep.failed.iter().map(|(p, _)| p.clone()).collect();

        // 目录名不该再变一次:改名在第一次就完成了
        let names_before: Vec<String> = walk_dirs(&rep.root);

        let r2 = retry_files(&list, &[old.duplicate()], &[new.duplicate()], false, crate::keyslot::OtherSlots::Carry, None).unwrap();
        assert!(r2.is_complete(), "重试应当成功:{:?}", r2.failed);
        assert_eq!(r2.changed, list.len(), "改写数应当等于清单长度");

        assert_eq!(walk_dirs(&rep.root), names_before, "重试不该改动目录名");
        assert!(rep.root.exists(), "重试不该动根目录");

        // 最终判据:整棵树用新密码完整解开,内容一字不差
        let out = root.join("dec");
        std::fs::create_dir_all(&out).unwrap();
        let d = decrypt_tree(&rep.root, &out, &[new], &salt, cipher, None).unwrap();
        assert_eq!(
            d.files,
            before.saturating_add(r2.changed),
            "解出的文件数应当等于第一次改好的 + 重试补上的"
        );
        assert_eq!(std::fs::read(d.root.join("a.txt")).unwrap(), b"AAA");
        assert_eq!(std::fs::read(d.root.join("sub/b.txt")).unwrap(), b"BBB");
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    #[cfg(windows)]
    fn retry_files_only_touches_the_listed_files() {
        // 反证:清单外的文件一个字节都不能动。
        //
        // 不测的话,一个「收到清单却仍然遍历整棵树」的实现照样能让上一条
        // 通过(它最终也能全部改好),但那会拿旧密码去开已经改成新密码的
        // 文件,产生一堆假失败,用户分不清哪些是真问题
        let (root, rep, old, new, cipher) = partial_failure_fixture("retryscope");
        let list: Vec<PathBuf> = rep.failed.iter().map(|(p, _)| p.clone()).collect();

        // 记下清单外每个文件的字节
        let others: Vec<(PathBuf, Vec<u8>)> = all_files(&rep.root)
            .into_iter()
            .filter(|p| !list.contains(p))
            .map(|p| {
                let b = std::fs::read(&p).unwrap();
                (p, b)
            })
            .collect();
        assert!(!others.is_empty(), "应当有清单外的文件");

        let r2 = retry_files(&list, &[old], &[new], false, crate::keyslot::OtherSlots::Carry, None).unwrap();
        assert!(r2.is_complete(), "{:?}", r2.failed);
        assert_eq!(r2.changed, list.len(), "改写数不该超过清单长度");

        for (p, was) in &others {
            assert_eq!(&std::fs::read(p).unwrap(), was, "动了清单外的文件:{p:?}");
        }
        let _ = std::fs::remove_dir_all(&root);
        let _ = cipher;
    }

    #[test]
    fn retry_files_refuses_to_leave_a_file_unopenable() {
        // keep 为空会写出永远打不开的文件。空清单则应当直接成功(无事可做),
        // 不要为此报错——调用方可能在循环里无条件调用
        let empty: Vec<PathBuf> = Vec::new();
        let salt = [7u8; 16];
        let k = kek_of(b"x", &salt);
        assert!(retry_files(&empty, &[k.duplicate()], &[], false, crate::keyslot::OtherSlots::Carry, None).is_err());
        let rep = retry_files(&empty, &[k.duplicate()], &[k], false, crate::keyslot::OtherSlots::Carry, None).unwrap();
        assert_eq!(rep.changed, 0);
        assert!(rep.is_complete());
    }

    /// 列出一棵树里所有子目录的磁盘名(排序后),用于断言「没改名」。
    #[cfg(windows)]
    fn walk_dirs(root: &Path) -> Vec<String> {
        let mut out = Vec::new();
        fn rec(d: &Path, out: &mut Vec<String>) {
            let Ok(rd) = std::fs::read_dir(d) else { return };
            for e in rd.flatten() {
                let p = e.path();
                if p.is_dir() {
                    out.push(p.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default());
                    rec(&p, out);
                }
            }
        }
        rec(root, &mut out);
        out.sort();
        out
    }

    #[test]
    fn find_any_file_skips_files_whose_header_does_not_parse() {
        // 树里混进一个坏 .omy 时,样本必须绕过它。
        //
        // 不这样会怎样:拿坏文件当样本 → peek_header 失败 → 整个操作报
        // 「文件已损坏」,而那棵树其余部分完好。更糟的是结果**随目录枚举
        // 顺序变化**:同一棵树同一条命令,两次运行给出不同的错误。实测
        // 遇到过,是最难查的一类不确定性
        let (root, enc_root, _salt, _cipher) = tree_fixture("skipbad");

        // 名字排在真密文之前,尽量让它被先枚举到
        let junk = enc_root.join("0000-broken.omy");
        std::fs::write(&junk, b"not an omy file at all").unwrap();

        let found = find_any_file(&enc_root).expect("应当仍能找到样本文件");
        assert_ne!(found, junk, "选中了头部解析不过的文件");
        // 自证选到的那个真的能解析,而不是碰巧躲开了坏文件
        let data = std::fs::read(&found).unwrap();
        assert!(
            crate::file::peek_header(&data).is_ok(),
            "选中的样本头部也解析不过:{found:?}"
        );

        // 全是坏文件时要退回那个坏文件,好让调用方报「已损坏」。
        // 返回 None 会被报成「这不是密文树」——可它确实是,只是文件坏了,
        // 把「坏了」说成「不是」会让用户往错的方向排查
        let only_bad = root.join("onlybad");
        std::fs::create_dir_all(&only_bad).unwrap();
        let bad_one = only_bad.join("x.omy");
        std::fs::write(&bad_one, b"garbage").unwrap();
        assert_eq!(find_any_file(&only_bad).as_deref(), Some(bad_one.as_path()));

        // 压根没有 .omy 才是 None:这时才该报「不是密文树」
        let empty = root.join("empty");
        std::fs::create_dir_all(&empty).unwrap();
        std::fs::write(empty.join("readme.txt"), b"hi").unwrap();
        assert!(find_any_file(&empty).is_none(), "没有密文文件时应当返回 None");

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn rekey_tree_rotate_replaces_the_ciphertext_unlike_a_plain_rekey() {
        // 轮换与改密码的**唯一**可观察差别就是这个:载荷密文变没变。
        // 少了这条,一个「悄悄忽略 rotate 参数」的实现能通过其余所有断言
        let (root, enc_root, salt, cipher) = tree_fixture("rot");
        let old = kek_of(b"old", &salt);
        let new = kek_of(b"new", &salt);

        let before: std::collections::BTreeMap<String, Vec<u8>> = all_files(&enc_root)
            .into_iter()
            .map(|p| {
                let d = std::fs::read(&p).unwrap();
                (p.file_name().unwrap().to_string_lossy().into_owned(), d)
            })
            .collect();

        let rep = rekey_tree_with_progress(
            &enc_root,
            &[old.duplicate()],
            &[new.duplicate()],
            RekeyParams { vault_salt: &salt, cipher, rotate: true, others: crate::keyslot::OtherSlots::Carry },
            None,
        )
        .unwrap();
        assert!(rep.is_complete(), "{:?}", rep.failed);
        assert!(rep.payload_rewritten, "报告里要标明换了文件密钥");
        assert!(rep.bytes_rewritten > 0, "重写的明文字节数不该是 0");

        // 轮换会换掉 file_uuid,所以磁盘名(uuid)也会变——按名字配不上,
        // 正好说明确实换了。逐字节比对全体内容:只要有一个文件的字节
        // 原封不动,就是没真的轮换
        let after: Vec<Vec<u8>> =
            all_files(&rep.root).iter().map(|p| std::fs::read(p).unwrap()).collect();
        assert_eq!(after.len(), before.len(), "文件数不该变");
        for bytes in &after {
            assert!(
                !before.values().any(|b| b == bytes),
                "有文件的密文一字节没变——rotate 没生效"
            );
        }

        // 换完还得能用:整棵树用新密码解得开,内容一致
        let out = root.join("dec");
        std::fs::create_dir_all(&out).unwrap();
        let d = decrypt_tree(&rep.root, &out, &[new], &salt, cipher, None).unwrap();
        assert_eq!(d.files, 2);
        assert_eq!(std::fs::read(d.root.join("a.txt")).unwrap(), b"AAA");
        assert_eq!(std::fs::read(d.root.join("sub/b.txt")).unwrap(), b"BBB");
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn rekey_tree_without_rotate_keeps_the_ciphertext() {
        // 反证。上一条若因为「所有路径都重写密文」而通过,这条会失败——
        // 改密码必须只动 slot 区,载荷一字节不改(否则增量备份要重传整份)
        //
        // 单独造 fixture 而不用 tree_fixture:那个写的是 3 字节明文,载荷
        // 密文才 23 字节,比对尾部时很容易倒着切进头部区——而头部本来就
        // 该变(slot 区重建了),于是断言必然失败,看着像产品缺陷
        const N: usize = 2000;
        let root = std::env::temp_dir().join("omy-rekey-norot");
        let _ = std::fs::remove_dir_all(&root);
        let src = root.join("src");
        std::fs::create_dir_all(&src).unwrap();
        let body: Vec<u8> = (0..N).map(|i| (i % 251) as u8).collect();
        std::fs::write(src.join("big.bin"), &body).unwrap();
        let salt = [7u8; 16];
        let out = root.join("enc");
        std::fs::create_dir_all(&out).unwrap();
        let opts = crate::file::EncryptOptions::default();
        let cipher = opts.cipher;
        let old = kek_of(b"old", &salt);
        let new = kek_of(b"new", &salt);
        let enc_root =
            encrypt_tree(&src, &out, &[old.duplicate()], &salt, &opts, None).unwrap().root;

        let before: std::collections::BTreeMap<String, Vec<u8>> = all_files(&enc_root)
            .into_iter()
            .map(|p| {
                let d = std::fs::read(&p).unwrap();
                (p.file_name().unwrap().to_string_lossy().into_owned(), d)
            })
            .collect();
        assert_eq!(before.len(), 1);

        let rep =
            rekey_tree_with_progress(&enc_root, &[old], &[new], RekeyParams { vault_salt: &salt, cipher, rotate: false, others: crate::keyslot::OtherSlots::Carry }, None)
                .unwrap();
        assert!(!rep.payload_rewritten);
        assert_eq!(rep.bytes_rewritten, 0, "没轮换就不该报重写字节数");

        for p in all_files(&rep.root) {
            let name = p.file_name().unwrap().to_string_lossy().into_owned();
            // 磁盘名就是 file_uuid:改密码不换它,所以按名字一定配得上。
            // 配不上说明 file_uuid 变了,那正是我们要防的
            let old_bytes = before.get(&name).expect("改密码不该换掉磁盘名(file_uuid)");
            let new_bytes = std::fs::read(&p).unwrap();
            assert_eq!(old_bytes.len(), new_bytes.len(), "长度变了:{p:?}");
            // 载荷在文件末尾,长度是明文 + AEAD tag。往回取这么多一定落在
            // 载荷内,不会切进会变的头部区
            let tail = old_bytes.len().saturating_sub(N);
            assert_eq!(&old_bytes[tail..], &new_bytes[tail..], "载荷变了:{p:?}");
        }
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn rekey_tree_rotate_reports_progress_for_every_file() {
        // 进度回调是 GUI 进度条的唯一数据来源。不测的话,一个「压根不调
        // 回调」的实现完全无声——界面上表现为进度条一直停在 0
        let (root, enc_root, salt, cipher) = tree_fixture("prog");
        let old = kek_of(b"old", &salt);

        let mut seen: Vec<(String, usize, usize)> = Vec::new();
        let mut max_ratio = 0.0f64;
        let mut cb = |name: &str, idx: usize, total: usize, done: u64, bytes: u64| {
            let key = (name.to_owned(), idx, total);
            if !seen.contains(&key) {
                seen.push(key);
            }
            if bytes > 0 {
                #[expect(clippy::cast_precision_loss, reason = "测试里比个比例够用")]
                let r = done as f64 / bytes as f64;
                if r > max_ratio {
                    max_ratio = r;
                }
            }
        };
        let rep = rekey_tree_with_progress(
            &enc_root,
            &[old.duplicate()],
            &[old],
            RekeyParams { vault_salt: &salt, cipher, rotate: true, others: crate::keyslot::OtherSlots::Carry },
            Some(&mut cb),
        )
        .unwrap();
        assert!(rep.is_complete(), "{:?}", rep.failed);

        assert_eq!(seen.len(), 2, "两个文件都该汇报进度,实际 {seen:?}");
        for (_, idx, total) in &seen {
            assert_eq!(*total, 2, "总数应当是文件数");
            assert!(*idx >= 1 && *idx <= 2, "序号越界: {idx}");
        }
        // 序号必须各不相同:都报 1 的话进度条永远停在第一个
        assert_ne!(seen[0].1, seen[1].1, "两个文件报了同一个序号");
        // 进度要真的推进到接近完成,而不是只发一次 0
        assert!(max_ratio > 0.5, "进度最高只到 {max_ratio},没有推进到末尾");
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn rekey_tree_rotate_can_keep_the_same_password() {
        // 「密码不变、只让旧副本作废」是轮换的正当用法。要求必须换密码
        // 会让这个需求无法表达
        let (root, enc_root, salt, cipher) = tree_fixture("samepw");
        let old = kek_of(b"old", &salt);
        let before: Vec<Vec<u8>> =
            all_files(&enc_root).iter().map(|p| std::fs::read(p).unwrap()).collect();

        let rep = rekey_tree_with_progress(
            &enc_root,
            &[old.duplicate()],
            &[old.duplicate()],
            RekeyParams { vault_salt: &salt, cipher, rotate: true, others: crate::keyslot::OtherSlots::Carry },
            None,
        )
        .unwrap();
        assert!(rep.is_complete(), "{:?}", rep.failed);
        // 密码没变 → 目录名密钥没变 → 根路径不该变
        assert_eq!(rep.root, enc_root, "目录名与密码无关,任何时候都不该变");

        // 但密文必须变了:这正是「让旧副本作废」的实质
        let after: Vec<Vec<u8>> =
            all_files(&enc_root).iter().map(|p| std::fs::read(p).unwrap()).collect();
        for b in &after {
            assert!(!before.contains(b), "密文没变,旧副本仍然等价");
        }
        // 原密码还能用
        let out = root.join("dec");
        std::fs::create_dir_all(&out).unwrap();
        let d = decrypt_tree(&enc_root, &out, &[old], &salt, cipher, None).unwrap();
        assert_eq!(d.files, 2);
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn rekey_tree_leaves_directory_names_openable() {
        // 目录名由 keep 的**第一个** KEK 派生。如果改密码后第一个 KEK 变了,
        // 目录名就解不开、整个分支进不去。这条守的是「换密码后树还能浏览」
        let (root, enc_root, salt, cipher) = tree_fixture("dirname");
        let old = kek_of(b"old", &salt);
        let new = kek_of(b"new", &salt);
        let rk =
            rekey_tree(&enc_root, &[old.duplicate()], &[new.duplicate()], &salt, cipher, crate::keyslot::OtherSlots::Carry).unwrap();
        assert!(rk.is_complete(), "{:?}", rk.failed);
        // 不再改名,所以 dirs_renamed 恒为 0。真正要守的是下面那条:
        // 换完密码整棵树仍能用新密码完整解开
        assert_eq!(rk.dirs_renamed, 0, "两层结构下换密码不该改名");
        assert_eq!(rk.root, enc_root, "根路径不该变");

        // 用新密码解整棵树。这一条是整个改动的核心:只改文件不改目录名的话,
        // 新密码开得了文件却解不开目录名,旧密码反之——两个密码都用不了
        let out = root.join("dec");
        std::fs::create_dir_all(&out).unwrap();
        let rep = decrypt_tree(&rk.root, &out, &[new], &salt, cipher, None).unwrap();
        assert_eq!(rep.files, 2, "换密码后整棵树应当仍能解开,失败: {:?}", rep.skipped);
        assert!(rep.root.join("sub/b.txt").is_file(), "子目录结构没还原对");

        // 反证:旧密码不该还能解开这棵树
        let out2 = root.join("dec-old");
        std::fs::create_dir_all(&out2).unwrap();
        assert!(
            decrypt_tree(&rk.root, &out2, &[old], &salt, cipher, None).is_err(),
            "旧密码仍能解开整棵树——改密码没生效"
        );
        let _ = std::fs::remove_dir_all(&root);
    }

    /// 收集树里所有密文文件,按路径排序好让比较稳定。
    fn all_files(root: &Path) -> Vec<PathBuf> {
        let mut out = Vec::new();
        walk(root, &mut out);
        out.sort();
        out
    }

    fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
        let Ok(rd) = std::fs::read_dir(dir) else { return };
        for e in rd.flatten() {
            let p = e.path();
            if p.is_dir() {
                walk(&p, out);
            } else if p.extension().is_some_and(|x| x == "omy") {
                out.push(p);
            }
        }
    }

    fn kek() -> Kek {
        Kek::from_password(b"pw", &[3u8; 16], Argon2Params::TEST_WEAK).unwrap()
    }

    /// 造一棵测试树。
    fn make_tree(tag: &str) -> PathBuf {
        let root = std::env::temp_dir().join(format!("omy-tree-{tag}"));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("文档/草稿")).unwrap();
        std::fs::create_dir_all(root.join("empty")).unwrap();
        std::fs::write(root.join("a.txt"), b"content of a").unwrap();
        std::fs::write(root.join("文档/报告.docx"), b"report body").unwrap();
        std::fs::write(root.join("文档/草稿/draft.md"), b"# draft").unwrap();
        root
    }

    fn opts() -> EncryptOptions {
        EncryptOptions { argon2: Argon2Params::TEST_WEAK, ..EncryptOptions::default() }
    }

    /// 造一个「够长到触发截断 + 边车、又能真正创建出来」的目录名。
    ///
    /// 两个约束是相反方向的,必须同时满足:
    ///
    /// - **下限**:明文超过 128 字节,密文 base32 编码后才会超过 255,
    ///   走截断 + 边车那条路。低于它测的就不是这条路径了。
    /// - **上限**:Linux 单个路径组件上限是 255 **字节**,超了
    ///   `create_dir_all` 直接报 `File name too long`(ENAMETOOLONG)。
    ///
    /// 这里刻意不写 `"某某".repeat(45)` 这种字面量:Windows 按 UTF-16
    /// 计数,同样的字面量在本机建得出来、在 Linux 上建不出来,于是
    /// **本地全绿而 CI 挂**,报错还只是一句 os error 36,看不出是名字太长。
    /// 按字节算长度就不会有这种平台差异。
    fn long_dir_name(unit: &str) -> String {
        let unit_len = unit.len();
        assert!(unit_len > 0, "重复单元不能为空");
        // 取 200 字节上下:稳稳越过 128 的下限,又离 255 有余量
        let times = 200_usize.div_euclid(unit_len).max(1);
        let name = unit.repeat(times);
        assert!(
            name.len() > 128,
            "目录名 {} 字节,不足以触发截断+边车",
            name.len()
        );
        assert!(
            name.len() <= 255,
            "目录名 {} 字节,超过 Linux 单组件上限,测试自身会建不出目录",
            name.len()
        );
        name
    }

    #[cfg(unix)]
    #[test]
    fn tree_mode_reports_symlinks_as_skipped_and_does_not_follow() {
        // 树形模式存不下链接(每个条目是独立 .omy 文件,没有容器索引那样的
        // 地方记目标),所以必须如实报成跳过。
        //
        // 更重要的是不能跟随:`std::fs::read` 会读到**目标的内容**,于是
        // 链接被加密成一个普通文件。解出来内容看着完全正常,只是类型从
        // 链接变成了文件——不比对类型根本发现不了。
        let root = std::env::temp_dir().join("omy-tree-symlink-src");
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(root.join("real.txt"), b"real body").unwrap();
        std::os::unix::fs::symlink("real.txt", root.join("link.txt")).unwrap();

        let out = std::env::temp_dir().join("omy-tree-symlink-out");
        let _ = std::fs::remove_dir_all(&out);
        std::fs::create_dir_all(&out).unwrap();

        let ks = [kek()];
        let rep = encrypt_tree(&root, &out, &ks, &[3u8; 16], &opts(), None).unwrap();

        assert_eq!(rep.files, 1, "只有 real.txt 该被加密;跟随链接会变成 2");
        assert!(
            rep.skipped.iter().any(|s| s.reason == SkipReason::Symlink),
            "链接必须如实报成跳过,实际 {:?}",
            rep.skipped
        );

        let _ = std::fs::remove_dir_all(&root);
        let _ = std::fs::remove_dir_all(&out);
    }

    #[test]
    fn find_any_file_skips_the_name_sidecar() {
        // 边车文件叫 `.omy-name`,它以 `.omy` 开头但不是加密文件。
        // 把它当成样本去读头部只会失败,而失败的表现是「解不开目录名」,
        // 会被误认为密码不对。
        //
        // 这条同时守「只有子目录时要能往下找」:根目录下没有直接的文件。
        let root = std::env::temp_dir().join("omy-find-any-sidecar");
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("sub")).unwrap();
        // 边车放在**上层**,真文件放在下层:如果实现按广度优先且不排除
        // 边车,就会先撞上边车
        std::fs::write(root.join(crate::dirname::DIRNAME_SIDECAR), b"not a file").unwrap();
        std::fs::write(root.join("sub/aabb.omy"), b"pretend header").unwrap();

        let got = find_any_file(&root).expect("应当找到子目录里的 .omy");
        assert_eq!(
            got.file_name().and_then(|s| s.to_str()),
            Some("aabb.omy"),
            "拿到的必须是真加密文件而不是边车,实际 {got:?}"
        );

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn find_any_file_returns_none_for_tree_without_files() {
        // 只有目录没有文件时必须返回 None 而不是随便给一个路径。
        // 给错的话调用方会拿它去读头部,得到一个含糊的 IO 错误
        let root = std::env::temp_dir().join("omy-find-any-empty");
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("a/b/c")).unwrap();
        assert!(find_any_file(&root).is_none(), "空树应当返回 None");
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn roundtrips_whole_tree() {
        let src = make_tree("round");
        let enc_parent = std::env::temp_dir().join("omy-tree-round-enc");
        let dec_parent = std::env::temp_dir().join("omy-tree-round-dec");
        let _ = std::fs::remove_dir_all(&enc_parent);
        let _ = std::fs::remove_dir_all(&dec_parent);
        std::fs::create_dir_all(&enc_parent).unwrap();
        std::fs::create_dir_all(&dec_parent).unwrap();

        let ks = [kek()];
        let rep = encrypt_tree(&src, &enc_parent, &ks, &[3u8; 16], &opts(), None).unwrap();
        assert_eq!(rep.files, 3, "三个文件都要加密");
        assert_eq!(rep.dirs, 4, "根 + 文档 + 草稿 + empty");

        let out = decrypt_tree(
            &rep.root,
            &dec_parent,
            &ks,
            &[3u8; 16],
            CipherId::ChaCha20Poly1305,
            None,
        )
        .unwrap();

        // 逐个核对内容,而不只看文件数——数量对但内容串位是最难查的形态
        assert_eq!(
            std::fs::read(out.root.join("a.txt")).unwrap(),
            b"content of a"
        );
        assert_eq!(
            std::fs::read(out.root.join("文档/报告.docx")).unwrap(),
            b"report body"
        );
        assert_eq!(
            std::fs::read(out.root.join("文档/草稿/draft.md")).unwrap(),
            b"# draft"
        );
        assert!(out.root.join("empty").is_dir(), "空目录也要还原");
        assert_eq!(
            out.root.file_name().unwrap().to_string_lossy(),
            "omy-tree-round",
            "根目录名要还原成原名"
        );

        for p in [&src, &enc_parent, &dec_parent] {
            let _ = std::fs::remove_dir_all(p);
        }
    }

    #[test]
    fn no_plaintext_name_appears_on_disk() {
        // 这是树形模式的核心承诺:结构可见但名字不可见。
        // 遍历密文树的所有路径,确认没有任何明文名残留
        let src = make_tree("names");
        let enc_parent = std::env::temp_dir().join("omy-tree-names-enc");
        let _ = std::fs::remove_dir_all(&enc_parent);
        std::fs::create_dir_all(&enc_parent).unwrap();

        let ks = [kek()];
        let rep = encrypt_tree(&src, &enc_parent, &ks, &[3u8; 16], &opts(), None).unwrap();

        let mut all = Vec::new();
        collect_paths(&rep.root, &mut all);
        let joined = all.join("|");
        for leaked in ["文档", "草稿", "报告", "draft", "a.txt", "empty"] {
            assert!(
                !joined.contains(leaked),
                "明文名 {leaked} 出现在密文树的路径里:{joined}"
            );
        }

        for p in [&src, &enc_parent] {
            let _ = std::fs::remove_dir_all(p);
        }
    }

    #[test]
    fn structure_is_preserved_and_therefore_leaked() {
        // 反证:树形模式**故意**保留结构。这条断言存在的意义是让
        // 「泄露了什么」变成可验证的事实,而不是文档里一句提醒——
        // 哪天有人以为这个模式也藏结构,这条会告诉他不是
        let src = make_tree("leak");
        let enc_parent = std::env::temp_dir().join("omy-tree-leak-enc");
        let _ = std::fs::remove_dir_all(&enc_parent);
        std::fs::create_dir_all(&enc_parent).unwrap();

        let ks = [kek()];
        let rep = encrypt_tree(&src, &enc_parent, &ks, &[3u8; 16], &opts(), None).unwrap();

        // 不解密也能数出:3 个文件、4 层结构
        let mut files = 0;
        let mut dirs = 0;
        count(&rep.root, &mut files, &mut dirs);
        assert_eq!(files, 3, "文件数对外可见——这正是 N6 声明的泄露");
        assert_eq!(dirs, 4, "目录数同样可见");

        // 改用两层目录名结构后,每个密文目录都恒定带一份 .omy-keys。
        // 这条把新增的泄露面钉成事实:**边车个数等于目录个数**。
        // 边车长度固定 408 字节,所以不泄露钥匙数量;但它确实又多了
        // 一个「这是 omy 加密目录」的指纹。
        //
        // 反过来这条也守着「每个目录都必须有边车」:少一个,那个目录
        // 被单独拷走之后名字就永远解不开了
        let mut sidecars = 0;
        count_sidecars(&rep.root, &mut sidecars);
        assert_eq!(sidecars, dirs, "每个密文目录都该有一份边车");

        // 文案必须具体到「能数出多少文件」,而不是抽象的「泄露元数据」
        let notice = TreeReport::leak_notice();
        assert!(notice.contains("多少文件"), "告知文案要说清泄露什么");
        assert!(notice.contains("看不到文件名"), "也要说清藏住了什么");

        for p in [&src, &enc_parent] {
            let _ = std::fs::remove_dir_all(p);
        }
    }

    #[test]
    fn wrong_password_cannot_decrypt_tree() {
        let src = make_tree("wrongpw");
        let enc_parent = std::env::temp_dir().join("omy-tree-wrongpw-enc");
        let dec_parent = std::env::temp_dir().join("omy-tree-wrongpw-dec");
        for p in [&enc_parent, &dec_parent] {
            let _ = std::fs::remove_dir_all(p);
            std::fs::create_dir_all(p).unwrap();
        }

        let rep =
            encrypt_tree(&src, &enc_parent, &[kek()], &[3u8; 16], &opts(), None).unwrap();

        let bad = Kek::from_password(b"wrong", &[3u8; 16], Argon2Params::TEST_WEAK).unwrap();
        assert!(
            decrypt_tree(
                &rep.root,
                &dec_parent,
                &[bad],
                &[3u8; 16],
                CipherId::ChaCha20Poly1305,
                None
            )
            .is_err(),
            "错密码必须解不开"
        );

        for p in [&src, &enc_parent, &dec_parent] {
            let _ = std::fs::remove_dir_all(p);
        }
    }

    #[test]
    fn same_content_files_get_different_disk_names() {
        // 磁盘名取 file_uuid 而不是名字/内容的哈希。若取后者,
        // 两个同名或同内容的文件会得到相同磁盘名——既撞名,
        // 又泄露「这两个文件一样」
        let root = std::env::temp_dir().join("omy-tree-dup");
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("d1")).unwrap();
        std::fs::create_dir_all(root.join("d2")).unwrap();
        std::fs::write(root.join("d1/same.txt"), b"identical").unwrap();
        std::fs::write(root.join("d2/same.txt"), b"identical").unwrap();

        let enc_parent = std::env::temp_dir().join("omy-tree-dup-enc");
        let _ = std::fs::remove_dir_all(&enc_parent);
        std::fs::create_dir_all(&enc_parent).unwrap();

        let rep =
            encrypt_tree(&root, &enc_parent, &[kek()], &[3u8; 16], &opts(), None).unwrap();
        let mut names = Vec::new();
        collect_file_names(&rep.root, &mut names);
        assert_eq!(names.len(), 2);
        assert_ne!(names[0], names[1], "同名同内容的文件不该得到相同磁盘名");

        for p in [&root, &enc_parent] {
            let _ = std::fs::remove_dir_all(p);
        }
    }

    #[test]
    fn long_directory_name_survives_roundtrip() {
        // 超长目录名走截断 + 边车文件那条路。这条端到端确认边车真的
        // 被写进去且被读出来——单测里 encrypt_dirname 返回 sidecar
        // 不代表调用方写了它
        let root = std::env::temp_dir().join("omy-tree-long");
        let _ = std::fs::remove_dir_all(&root);
        // 长度要同时满足两个约束,见 `long_dir_name` 的说明
        let long = long_dir_name("很长的目录名");
        std::fs::create_dir_all(root.join(&long)).unwrap();
        std::fs::write(root.join(&long).join("x.txt"), b"deep").unwrap();

        let enc_parent = std::env::temp_dir().join("omy-tree-long-enc");
        let dec_parent = std::env::temp_dir().join("omy-tree-long-dec");
        for p in [&enc_parent, &dec_parent] {
            let _ = std::fs::remove_dir_all(p);
            std::fs::create_dir_all(p).unwrap();
        }

        let rep =
            encrypt_tree(&root, &enc_parent, &[kek()], &[3u8; 16], &opts(), None).unwrap();
        assert!(rep.long_names >= 1, "该报告有超长目录名");

        let out = decrypt_tree(
            &rep.root,
            &dec_parent,
            &[kek()],
            &[3u8; 16],
            CipherId::ChaCha20Poly1305,
            None,
        )
        .unwrap();
        assert_eq!(
            std::fs::read(out.root.join(&long).join("x.txt")).unwrap(),
            b"deep",
            "超长目录名要能完整还原"
        );

        for p in [&root, &enc_parent, &dec_parent] {
            let _ = std::fs::remove_dir_all(p);
        }
    }

    #[test]
    fn sidecar_file_is_not_restored_as_user_data() {
        // 边车文件是我们自己的记账。还原时把它当用户文件写出来,
        // 用户会在自己的目录里看到一个莫名的 .omy-name
        let root = std::env::temp_dir().join("omy-tree-sc");
        let _ = std::fs::remove_dir_all(&root);
        let long = long_dir_name("长目录名");
        std::fs::create_dir_all(root.join(&long)).unwrap();
        std::fs::write(root.join(&long).join("f.txt"), b"x").unwrap();

        let enc_parent = std::env::temp_dir().join("omy-tree-sc-enc");
        let dec_parent = std::env::temp_dir().join("omy-tree-sc-dec");
        for p in [&enc_parent, &dec_parent] {
            let _ = std::fs::remove_dir_all(p);
            std::fs::create_dir_all(p).unwrap();
        }

        let rep =
            encrypt_tree(&root, &enc_parent, &[kek()], &[3u8; 16], &opts(), None).unwrap();
        // 前提校验:这条测试的意义在于「边车文件存在时不被当用户数据还原」,
        // 所以必须先确认真的产生了边车。否则名字一旦不够长就不走截断那条路,
        // 测试会在什么都没覆盖的情况下照样变绿——变异测试实测到过这个假绿。
        assert!(
            rep.long_names >= 1,
            "该用例需要触发截断+边车,实际 long_names={}",
            rep.long_names
        );

        let out = decrypt_tree(
            &rep.root,
            &dec_parent,
            &[kek()],
            &[3u8; 16],
            CipherId::ChaCha20Poly1305,
            None,
        )
        .unwrap();

        let mut all = Vec::new();
        collect_paths(&out.root, &mut all);
        assert!(
            !all.iter().any(|p| p.contains(DIRNAME_SIDECAR)),
            "还原结果里不该出现边车文件:{all:?}"
        );
        // 上面那条断言不够:把 `name == DIRNAME_SIDECAR` 的跳过删掉后,
        // 边车文件会被 is_omy_file 拦下、不会写出去,所以那条依然通过。
        // 但它会以 NotRegular 出现在 skipped 里——用户看到一个莫名的
        // 「跳过了 .omy-name」,会以为自己的数据丢了东西。
        // 干净的树还原后 skipped 必须是空的
        assert!(
            out.skipped.is_empty(),
            "干净的树还原后不该有跳过项,实际 {:?}",
            out.skipped
        );

        for p in [&root, &enc_parent, &dec_parent] {
            let _ = std::fs::remove_dir_all(p);
        }
    }

    #[test]
    fn malicious_filename_in_ciphertext_cannot_escape() {
        // 文件名来自**密文内部**,是攻击者可控的:拿到密钥的人(或另一个
        // 实现)可以造一个 TLV_FILENAME 写着 `../../evil.txt` 的文件。
        // 还原时若不过 sanitize,写盘位置就跑到目标目录之外去了。
        //
        // 变异测试抓出来的缺口:原有的 roundtrip 测试用的都是正常文件名,
        // 把 sanitize 那行删掉照样全绿。这条断言必须用真正带 `../` 的名字。
        //
        // 逃逸落点必须留在自己的沙盒里。第一版把 dec_parent 直接放在
        // 系统临时目录下,于是变异跑的时候真的在 %TEMP% 根上写出了
        // `escaped.txt`——它留在那儿,导致**还原源码后这条测试依然失败**,
        // 看起来像产品有问题。所以套一层 sandbox/a/b:`../..` 最远只能
        // 逃到 sandbox 内,随 remove_dir_all 一起清掉。
        let sandbox = std::env::temp_dir().join("omy-tree-evil-sandbox");
        let _ = std::fs::remove_dir_all(&sandbox);
        let enc_parent = sandbox.join("enc");
        let dec_parent = sandbox.join("a/b");
        for p in [&enc_parent, &dec_parent] {
            std::fs::create_dir_all(p).unwrap();
        }

        // 先正常加密一棵树,拿到密文根目录
        let src = make_tree("evil");
        let ks = [kek()];
        let rep = encrypt_tree(&src, &enc_parent, &ks, &[3u8; 16], &opts(), None).unwrap();

        // 手工造一个文件名带路径逃逸的加密文件,塞进密文目录
        let mut evil = opts();
        evil.filename = Some(String::from("../../escaped.txt"));
        let rnd = RandomMaterial::generate();
        let ef = crate::file::encrypt(b"pwned", &ks, &[3u8; 16], &evil, &rnd).unwrap();
        std::fs::write(rep.root.join("deadbeef.omy"), &ef.bytes).unwrap();

        let out = decrypt_tree(
            &rep.root,
            &dec_parent,
            &ks,
            &[3u8; 16],
            CipherId::ChaCha20Poly1305,
            None,
        )
        .unwrap();

        // 还原根目录之外的每一层都不能出现那个文件
        for up in [sandbox.join("a"), sandbox.clone(), dec_parent.clone()] {
            let leaked = up.join("escaped.txt");
            assert!(!leaked.exists(), "文件逃出了还原根目录:{}", leaked.display());
        }
        // 内容应当落在还原根目录内,名字里的分隔符被替换掉
        let mut names = Vec::new();
        collect_file_names(&out.root, &mut names);
        assert!(
            names.iter().any(|n| n.contains("escaped.txt") && !n.contains('/')),
            "逃逸名应被清洗后留在根目录内,实际 {names:?}"
        );

        let _ = std::fs::remove_dir_all(&src);
        let _ = std::fs::remove_dir_all(&sandbox);
    }

    #[test]
    fn rejects_non_directory() {
        let f = std::env::temp_dir().join("omy-tree-not-dir.txt");
        std::fs::write(&f, b"x").unwrap();
        assert!(
            encrypt_tree(&f, &std::env::temp_dir(), &[kek()], &[3u8; 16], &opts(), None).is_err(),
            "对文件调用必须报错"
        );
        let _ = std::fs::remove_file(&f);
    }

    #[test]
    fn progress_reports_every_file() {
        let src = make_tree("prog");
        let enc_parent = std::env::temp_dir().join("omy-tree-prog-enc");
        let _ = std::fs::remove_dir_all(&enc_parent);
        std::fs::create_dir_all(&enc_parent).unwrap();

        let mut seen = Vec::new();
        let mut cb = |n: &str, s: u64| seen.push((n.to_owned(), s));
        let rep =
            encrypt_tree(&src, &enc_parent, &[kek()], &[3u8; 16], &opts(), Some(&mut cb)).unwrap();
        assert_eq!(seen.len(), rep.files, "每个文件都要回调一次");

        for p in [&src, &enc_parent] {
            let _ = std::fs::remove_dir_all(p);
        }
    }

    // ---- 测试辅助 ----

    fn collect_paths(dir: &Path, out: &mut Vec<String>) {
        let Ok(rd) = std::fs::read_dir(dir) else { return };
        for e in rd.flatten() {
            out.push(e.file_name().to_string_lossy().into_owned());
            if e.path().is_dir() {
                collect_paths(&e.path(), out);
            }
        }
    }

    fn collect_file_names(dir: &Path, out: &mut Vec<String>) {
        let Ok(rd) = std::fs::read_dir(dir) else { return };
        for e in rd.flatten() {
            if e.path().is_dir() {
                collect_file_names(&e.path(), out);
                continue;
            }
            // 边车不是用户文件。算进来会让「两个同内容文件磁盘名不同」
            // 这类断言拿到一堆同名的 .omy-keys,看着像磁盘名撞了
            let name = e.file_name().to_string_lossy().into_owned();
            if name == crate::dirsidecar::KEYS_SIDECAR || name == DIRNAME_SIDECAR {
                continue;
            }
            out.push(name);
        }
    }

    /// 数用户文件与目录。
    ///
    /// 边车不计入:它是我们自己的记账,不是用户数据。把它算进来的话
    /// 「外界能数出几个文件」会虚增一倍——那不是真实的泄露面,
    /// 会让 N6 那条断言失去意义。
    fn count(dir: &Path, files: &mut usize, dirs: &mut usize) {
        *dirs = dirs.saturating_add(1);
        let Ok(rd) = std::fs::read_dir(dir) else { return };
        for e in rd.flatten() {
            if e.path().is_dir() {
                count(&e.path(), files, dirs);
                continue;
            }
            let name = e.file_name().to_string_lossy().into_owned();
            if name == crate::dirsidecar::KEYS_SIDECAR || name == DIRNAME_SIDECAR {
                continue;
            }
            *files = files.saturating_add(1);
        }
    }

    /// 数边车个数,用于断言「每个密文目录都有一份」。
    fn count_sidecars(dir: &Path, n: &mut usize) {
        let Ok(rd) = std::fs::read_dir(dir) else { return };
        for e in rd.flatten() {
            if e.path().is_dir() {
                count_sidecars(&e.path(), n);
            } else if e.file_name().to_string_lossy() == crate::dirsidecar::KEYS_SIDECAR {
                *n = n.saturating_add(1);
            }
        }
    }
}