coordinode-lsm-tree 5.8.1

Embedded LSM-tree storage engine in pure Rust, no C/C++ dependency. MVCC snapshots, BuRR filters, zstd dictionary compression, columnar PAX blocks, AES-256-GCM at rest, self-healing per-block ECC, compaction on a near-full disk, no_std support.
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
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026-present, Structured World Foundation

//! Block-granular SST salvage: recover the readable blocks of an SST whose
//! whole-file verification fails, dropping the corrupted ones.
//!
//! Where [`crate::repair`] rebuilds the manifest *around* unreadable SSTs and
//! [`crate::verify`] reports per-block health read-only, salvage walks an SST
//! block-by-block, re-emits every data block that passes its checksum (and ECC
//! recovery where present) into a fresh, fully-valid SST, and reports the key
//! ranges it had to drop. A single corrupted block then costs only its own key
//! range instead of the whole file.
//!
//! The salvaged SST is written through the normal [`crate::table`] writer, so
//! it carries fresh per-block checksums, a fresh index, and a fresh filter:
//! the corruption is not propagated into the recovered copy.

use crate::UserKey;
use crate::encryption::EncryptionProvider;
use alloc::string::String;
use alloc::sync::Arc;
use alloc::vec::Vec;
use std::path::PathBuf;

/// The recovery + write context salvage needs to recover an SST that is
/// encrypted and/or zstd-dictionary compressed.
///
/// Block salvage opens the source and rewrites the recovered copy through the
/// normal table path, so both ends need the same crypto / dictionary context as
/// the live tree: without the [`EncryptionProvider`] an encrypted source cannot
/// be decrypted to read its blocks (and the rewritten copy would be plaintext,
/// inconsistent with an encrypted reopen); without the dictionary a
/// dictionary-compressed source cannot be decompressed (and the copy could not
/// be re-compressed to match). [`crate::repair`] fills this from the tree's
/// `Config`; [`salvage_sst`] defaults it to empty (a plain, unencrypted source).
#[derive(Clone, Default)]
pub struct SalvageOptions {
    /// Encryption provider matching the source's at-rest encryption, or `None`
    /// for an unencrypted source.
    pub encryption: Option<Arc<dyn EncryptionProvider>>,
    /// zstd dictionary matching the source's dictionary compression, or `None`
    /// when the source uses no dictionary.
    #[cfg(zstd_any)]
    pub zstd_dictionary: Option<Arc<crate::compression::ZstdDictionary>>,
    /// The open / decrypt context id for an ENCRYPTED source: block AAD binds
    /// the table identity, so an encrypted source sealed under a non-zero id
    /// only decrypts when the same id is supplied here.
    /// [`crate::repair`] passes the table's real id; the standalone default of
    /// `0` matches an unencrypted or id-`0` encrypted source. An UNENCRYPTED
    /// source needs no id at all — the salvage-mode open reads the stored one
    /// from the metadata — and the recovered copy is always stamped with the
    /// SOURCE's stored id (its identity), never with this field.
    pub table_id: crate::TableId,
    /// The durable table id the caller knows OUT-OF-BAND (the SST file name /
    /// manifest entry), or `None` (the standalone default) when the source's
    /// stored id is the identity. When `Some`, the salvage open cross-checks
    /// the meta payload against it — with fallback to the mirrored MID meta
    /// copy — so a checksum-clean tail meta whose stored id was forged cannot
    /// poison the recovered copy's identity. [`crate::repair`] passes the id
    /// derived from the file name.
    pub expected_stored_id: Option<crate::TableId>,
    /// Stamp the recovered copy with THIS identity instead of the source's, for
    /// a caller that publishes the copy BESIDE its source under a fresh id
    /// rather than in its place. `None` (the default) keeps the source's
    /// identity, which is what an in-place replacement needs. The destination
    /// file name must match: an SST whose stamped id disagrees with its name is
    /// rejected on open.
    pub output_id: Option<crate::TableId>,
    /// Opt-in to salvaging a delete-bearing columnar SST whose positional
    /// delete bitmap cannot be applied (the bitmap section is unreadable, or a
    /// readable bitmap's positioning zone map is unreadable). The degraded
    /// recovery emits EVERY row live — positionally-deleted rows are
    /// resurrected in the salvaged copy. `false` (the default) fails such a
    /// salvage closed instead, preserving delete semantics at the cost of
    /// recovering nothing from that SST.
    pub allow_delete_resurrection: bool,
    /// Durability mode for the recovered copy's final sync (file + parent
    /// directory). [`crate::repair`] passes the tree's `Config::sync_mode`,
    /// so a Full-durability repair persists the salvaged SST as strongly as
    /// the manifest it rebuilds around it; the standalone default is
    /// [`crate::fs::SyncMode::Normal`].
    pub sync_mode: crate::fs::SyncMode,
    /// Prefix extractor matching the tree's
    /// [`Config::prefix_extractor`](crate::config::Config::prefix_extractor),
    /// or `None` when the tree indexes no prefixes. The extractor is not
    /// persisted in the SST (it is configuration), so the rebuilt filter can
    /// only carry the source's prefix hashes when the caller supplies it —
    /// without it, prefix scans see the salvaged copy as a false negative
    /// and its matching rows vanish from every prefix read.
    /// [`crate::repair`] passes the tree's configured extractor.
    pub prefix_extractor: Option<Arc<dyn crate::prefix::PrefixExtractor>>,
    /// Per-blob-file handle rewrite applied to every re-emitted indirection
    /// entry, or `None` (the default) to re-emit handles untouched.
    /// [`crate::repair`] fills it after reshaping blob files (a blob salvage
    /// compacted a file, or a punched file's frontier was recovered), so the
    /// rewritten SST's handles land on live records; an entry whose record no
    /// longer exists is DROPPED and counted in
    /// [`SalvageReport::entries_dropped_by_rewrite`]. A set rewrite disables
    /// verbatim block copy-through (raw block bytes would carry stale handles).
    pub blob_rewrite: Option<Arc<crate::HashMap<crate::vlog::BlobFileId, BlobFileRewrite>>>,
    /// Shared live-progress counters the block walk ticks per inspected /
    /// re-emitted / dropped block and per recovered row, or `None` (the
    /// default) to skip publishing. [`crate::repair`] forwards the handle set
    /// via [`Config::with_recovery_progress`](crate::Config::with_recovery_progress);
    /// a standalone [`salvage_sst_with_options`] caller sets it directly and
    /// polls [`RecoveryProgress::snapshot`](crate::RecoveryProgress::snapshot)
    /// from another thread.
    pub progress: Option<Arc<crate::RecoveryProgress>>,
}

/// How a referencing SST's blob handles into ONE blob file must be rewritten
/// when repair reshaped that file. Keyed per blob-file id in
/// [`SalvageOptions::blob_rewrite`].
#[derive(Debug, Clone)]
pub enum BlobFileRewrite {
    /// The blob file was salvaged into a COMPACTED copy under a FRESH blob
    /// file id: every surviving record moved (and, when re-compressed, may
    /// have changed on-disk size), per [`BlobSalvageReport::offset_remap`].
    /// A rewritten handle is retargeted at `new_id` as well as at the new
    /// offset, because the replacement is a new file — the damaged original
    /// keeps its own id and stays untouched until the manifest commit makes
    /// the replacement live. A handle whose source offset is absent points at
    /// a record the salvage dropped — its entry is removed from the rewritten
    /// SST (its key then reads as absent instead of erroring on damaged
    /// bytes).
    Remap {
        /// Id of the salvaged replacement the rewritten handles must target.
        new_id: crate::vlog::BlobFileId,
        /// Source offset -> where that record landed in the replacement.
        offsets: crate::HashMap<u64, BlobRecordRelocation>,
    },
    /// The blob file is intact but its consumed prefix was punched at this
    /// live-data frontier: a handle below it (a pre-relocation SST left behind
    /// by a crash) points into zeroed bytes — its entry is removed; handles at
    /// or above the frontier are kept untouched.
    DropBelow(u64),
}

/// Why a block could not be salvaged and had to be dropped.
#[derive(Debug, Clone)]
pub enum DropReason {
    /// The block header failed to decode: corrupt magic, an invalid length, or
    /// a mismatch on the header's own checksum.
    HeaderCorrupted(String),
    /// The data segment did not match the XXH3 checksum stored in its header and
    /// error-correcting codes (when present) could not recover it.
    ChecksumMismatch,
    /// The block could not be read from disk: an I/O error or a truncated tail.
    ReadError(String),
    /// The block verified intact but its entries could not be decoded (an
    /// unexpected format / version inside an otherwise checksum-clean block).
    DecodeError(String),
}

/// A block the salvage walk could not recover, with the key range it covered
/// (when the index can still resolve it) so an operator knows exactly what data
/// the salvaged copy is missing.
#[derive(Debug, Clone)]
pub struct DroppedBlock {
    /// Byte offset of the block within the source SST.
    pub offset: u64,
    /// The SFA section the block belonged to (e.g. `b"data"`).
    pub section: Vec<u8>,
    /// Why the block was dropped.
    pub reason: DropReason,
    /// The block's `[first, last]` user-key range, if the index could still
    /// resolve it; `None` when the index entry for the block is itself lost.
    pub key_range: Option<(UserKey, UserKey)>,
}

/// The outcome of salvaging a single SST.
///
/// Produced by the salvage walk over one source file. Inspect [`is_complete`]
/// to tell a clean recovery (every block re-emitted) from a lossy one (some key
/// ranges dropped); [`dropped`] lists exactly what was lost.
///
/// [`is_complete`]: SalvageReport::is_complete
/// [`dropped`]: SalvageReport::dropped
#[derive(Debug)]
pub struct SalvageReport {
    /// Path of the freshly written salvaged SST, or `None` when no block was
    /// recoverable and nothing was written.
    pub salvaged_path: Option<PathBuf>,
    /// Total data blocks the walk INSPECTED. This is not partitioned by
    /// `blocks_salvaged + dropped.len()`: a columnar block whose rows were all
    /// positionally deleted is inspected and counted here, yet re-emits nothing
    /// (not in `blocks_salvaged`) and loses nothing (not in `dropped`). Derive
    /// completeness from [`is_complete`](Self::is_complete) (`dropped.is_empty()`),
    /// never from `blocks_salvaged == blocks_total`.
    pub blocks_total: usize,
    /// Data blocks successfully re-emitted into the salvaged SST.
    pub blocks_salvaged: usize,
    /// Of [`blocks_salvaged`](Self::blocks_salvaged), how many read back cleanly
    /// (checksum passed without ECC recovery) and were copied through **verbatim**
    /// — their raw on-disk bytes byte-copied, skipping the decode + re-encode +
    /// recompression the rest pay. The remainder
    /// (`blocks_salvaged - blocks_copied_verbatim`) were re-emitted rather than
    /// byte-copied: ECC-recovered blocks (re-encoded from their healed payload)
    /// and, for a columnar SST that carries deletes, its clean blocks too
    /// (re-emitted with the delete mask applied so deleted rows are not
    /// resurrected). A high ratio means a mostly-healthy, delete-free SST was
    /// recovered cheaply.
    pub blocks_copied_verbatim: usize,
    /// Entries recovered into the salvaged SST.
    pub entries_salvaged: u64,
    /// Entries REMOVED by the [`SalvageOptions::blob_rewrite`] handle rewrite
    /// (their blob record was dropped by a blob salvage, or lies below a
    /// punched frontier). Always zero without a configured rewrite. These are
    /// accounted separately from [`dropped`](Self::dropped): the block itself
    /// was healthy, only individual value records no longer exist.
    pub entries_dropped_by_rewrite: u64,
    /// Blocks the walk had to drop, with their key ranges where known.
    pub dropped: Vec<DroppedBlock>,
    /// `true` when the source carried positional deletes that could NOT be
    /// applied faithfully and [`SalvageOptions::allow_delete_resurrection`] let
    /// the salvage proceed anyway: the recovered copy re-emits those rows LIVE,
    /// so positionally-deleted rows came back. Always `false` for a normal
    /// salvage (and when the opt-in is set but no unappliable delete mask was
    /// encountered), so a caller can warn ONLY when resurrection actually
    /// happened.
    pub delete_rows_resurrected: bool,
}

impl SalvageReport {
    /// Returns `true` when no block had to be dropped: every block the walk
    /// inspected was either recovered or carried no live rows, so no key range
    /// was lost.
    ///
    /// This is orthogonal to whether a file was written: a source whose every
    /// block is wholly deleted drops nothing yet recovers nothing, so
    /// `is_complete()` is `true` while [`salvaged_path`](Self::salvaged_path) is
    /// `None`. Always check `salvaged_path` before using the recovered copy.
    ///
    /// # Examples
    ///
    /// ```
    /// use lsm_tree::salvage::SalvageReport;
    ///
    /// let clean = SalvageReport {
    ///     salvaged_path: None,
    ///     blocks_total: 4,
    ///     blocks_salvaged: 4,
    ///     blocks_copied_verbatim: 4,
    ///     entries_salvaged: 100,
    ///     entries_dropped_by_rewrite: 0,
    ///     dropped: Vec::new(),
    ///     delete_rows_resurrected: false,
    /// };
    /// assert!(clean.is_complete());
    /// ```
    #[must_use]
    pub fn is_complete(&self) -> bool {
        self.dropped.is_empty()
    }
}

/// Salvages the readable blocks of the SST at `source` into a fresh SST at
/// `dest`.
///
/// Opens `source` (its metadata, index, and SFA trailer must be intact), walks
/// every data block in key order, re-emits the entries of each block that loads
/// cleanly into a brand-new SST at `dest`, and records the key range of every
/// block it had to drop. The salvaged SST is written through the normal table
/// writer, so it carries fresh per-block checksums, a fresh index, and a fresh
/// filter: a single corrupt source block costs only its own key range, not the
/// whole file.
///
/// The salvaged copy mirrors the source's persisted layout (data + index
/// compression, ECC, restart interval, columnar layout with a regenerated zone
/// map, per-KV checksum footers). A columnar source is recovered as columnar:
/// the recovered rows are transposed back into PAX blocks, so the copy keeps the
/// columnar layout and its zone map (a readable delete-bitmap is applied on
/// read, so the surviving rows are already post-delete and the copy needs no
/// delete-bitmap). Per-field value sub-columns collapse to a single value
/// column in this row round-trip; preserving them verbatim is a separate step.
/// When the delete bitmap CANNOT be applied (an unreadable bitmap section, or a
/// readable bitmap whose positioning zone map is unreadable), the salvage fails
/// closed by default — recovering "all rows live" would resurrect
/// positionally-deleted rows — unless the caller opts in via
/// [`SalvageOptions::allow_delete_resurrection`].
///
/// The positional walk re-emits only point entries, so an SST that carries
/// range tombstones cannot be salvaged without dropping them (which would let
/// lower-level keys they cover reappear after repair). Such a source fails
/// closed rather than salvaging into a copy with broken merge semantics.
///
/// The walk is positional (block-index order): iteration is not
/// comparator-driven, so the recovered entries keep their on-disk order. This
/// entry point opens and rewrites under the default lexicographic comparator;
/// [`crate::repair`] recovers under the tree's configured comparator so a
/// custom-comparator table is rebuilt and reopened consistently.
///
/// # Errors
///
/// Returns an error when `source` cannot be opened at all (its metadata, index,
/// or SFA trailer is unreadable), when it carries range tombstones (salvage
/// fails closed rather than dropping them), when its positional delete bitmap
/// cannot be applied (fails closed rather than resurrecting deleted rows; see
/// [`SalvageOptions::allow_delete_resurrection`]), or when writing `dest`
/// fails. Per-block corruption is not an error: such blocks are dropped and
/// listed in the returned [`SalvageReport`].
///
/// # Examples
///
/// ```no_run
/// use lsm_tree::fs::{Fs, StdFs};
/// use lsm_tree::salvage::salvage_sst;
/// use std::sync::Arc;
///
/// let fs: Arc<dyn Fs> = Arc::new(StdFs);
/// let report = salvage_sst("tables/5".as_ref(), "tables/5.salvaged".into(), &fs)?;
/// if report.is_complete() {
///     println!("fully recovered {} block(s)", report.blocks_salvaged);
/// } else {
///     println!(
///         "recovered {} block(s), dropped {}",
///         report.blocks_salvaged,
///         report.dropped.len(),
///     );
/// }
/// # Ok::<(), lsm_tree::Error>(())
/// ```
pub fn salvage_sst(
    source: &std::path::Path,
    dest: std::path::PathBuf,
    fs: &alloc::sync::Arc<dyn crate::fs::Fs>,
) -> crate::Result<SalvageReport> {
    salvage_sst_with_options(source, dest, fs, &SalvageOptions::default())
}

/// Salvages `source` into `dest` with an explicit recovery + write context.
///
/// Use this over [`salvage_sst`] to salvage an SST that is encrypted and/or
/// zstd-dictionary compressed: supply the matching [`EncryptionProvider`] and
/// dictionary in `options` so the source can be decrypted / decompressed to read
/// its blocks and the recovered copy is written under the same context. Opens and
/// rewrites under the default lexicographic comparator; [`crate::repair`] uses the
/// tree's configured comparator instead via the crate-internal path.
///
/// # Errors
///
/// As [`salvage_sst`]; additionally fails to open the source when `options` does
/// not carry the encryption / dictionary context the source was written with.
pub fn salvage_sst_with_options(
    source: &std::path::Path,
    dest: std::path::PathBuf,
    fs: &alloc::sync::Arc<dyn crate::fs::Fs>,
    options: &SalvageOptions,
) -> crate::Result<SalvageReport> {
    salvage_with_context(
        source,
        dest,
        fs,
        &crate::comparator::default_comparator(),
        options,
    )
}

/// Salvages `source` into `dest` under a caller-supplied `comparator` and
/// recovery context.
///
/// [`crate::repair`] calls this with the tree's configured comparator and the
/// `Config`'s encryption provider + zstd dictionary, so the rewritten SST opens,
/// orders, and decrypts / decompresses consistently with the rest of the tree;
/// the public entry points wrap it with the default lexicographic comparator.
pub(crate) fn salvage_with_context(
    source: &std::path::Path,
    dest: std::path::PathBuf,
    fs: &alloc::sync::Arc<dyn crate::fs::Fs>,
    comparator: &crate::comparator::SharedComparator,
    options: &SalvageOptions,
) -> crate::Result<SalvageReport> {
    // Arbitrate DIVERGENT meta mirrors. When both copies decode under the
    // expected id but disagree in ANY field, neither is provably genuine: an
    // internally-consistent forged tail (a changed compression tag, a changed
    // columnar descriptor) would make the tail-first open mis-decode every
    // healthy data block and drop it — repair would then discard a table
    // whose intact MID mirror recovers everything. Since no copy can be
    // proven authoritative, run the walk under BOTH mirror orders and keep
    // the attempt that recovers more.
    let diverged = meta_mirrors_diverge(source, fs, options)?;
    // Divergent mirrors disable the verbatim copy-through: neither copy is
    // provably genuine, and a divergence confined to a DECODE-TRANSPARENT
    // layout field (a re-stamped restart interval — full block decoding is
    // trailer-driven) would byte-copy blocks whose encoding disagrees with
    // the chosen meta, silently truncating the partial-decode read path's
    // synthesized blocks. Re-encoding under the chosen meta keeps the copy
    // self-consistent whichever mirror wins.
    if diverged == MirrorDivergence::Agree {
        // No arbitration: write directly to `dest`. There is no second attempt,
        // so `dest` is never a live intermediate another process could replace.
        return salvage_attempt(source, dest, fs, comparator, options, false, true);
    }
    if diverged == MirrorDivergence::NonDerivable {
        // The mirrors disagree in a field the walk cannot re-derive and nothing
        // authenticates — `bulk_ingested`, `recency`, or the compaction
        // lineage. The writer copies whichever mirror is selected, so a
        // "complete" attempt proves nothing about them: a forged tail clearing
        // `bulk_ingested` would republish an ingested SST at global seqno 0,
        // visible to snapshots that never saw it. Refuse rather than pick.
        log::error!(
            "{}: metadata mirrors disagree in fields no entry derives \
             (bulk-ingest provenance, L0 recency, or compaction lineage); \
             neither copy can be authenticated, so salvage will not choose",
            source.display(),
        );
        return Err(crate::Error::Unrecoverable);
    }
    // Divergent mirrors: run BOTH attempts to PRIVATE temp paths and publish
    // only the selected winner, so `dest` is never a live intermediate a foreign
    // process could atomically replace and this path then delete. Verbatim
    // copy-through stays disabled — neither mirror is provably genuine.
    let tail_dest = next_arb_temp(fs, &dest)?;
    let tail = salvage_attempt(
        source,
        tail_dest.clone(),
        fs,
        comparator,
        options,
        false,
        false,
    );
    // A tail attempt that saw blocks and dropped nothing cannot be improved on:
    // the tie-break prefers the tail (authoritative-by-convention) copy.
    if let Ok(r) = &tail
        && r.blocks_total > 0
        && r.dropped.is_empty()
    {
        return publish_from_temp(fs, tail, &tail_dest, &dest, options);
    }
    let mid_dest = next_arb_temp(fs, &dest)?;
    let mid = salvage_attempt(
        source,
        mid_dest.clone(),
        fs,
        comparator,
        options,
        true,
        false,
    );

    // Publish the winner from its temp; discard the loser's temp first so no
    // artifact lingers outside the `.healtmp-` sweep namespace, but ONLY when
    // this invocation actually created that temp. An attempt whose `create_new`
    // lost a race to a concurrent salvage returns `AlreadyExists` WITHOUT
    // creating the file, so discarding its path would delete the file the race
    // winner owns (see [`attempt_owns_temp`]).
    match arbitrate_mirrors(&tail, &mid) {
        MirrorArbitration::Propagate => {
            // A transient failure lost only to an INCOMPLETE success; a retry
            // could recover the dropped blocks. Clean up any temp this
            // invocation created, then propagate the transient error so the
            // caller retries the whole salvage.
            if attempt_owns_temp(&tail) {
                discard_partial(fs, &tail_dest);
            }
            if attempt_owns_temp(&mid) {
                discard_partial(fs, &mid_dest);
            }
            // Return whichever attempt raised the retryable error — the same
            // environmental class the arbitration used to decide to propagate.
            if matches!(&tail, Err(e) if e.is_environmental()) {
                tail
            } else {
                mid
            }
        }
        MirrorArbitration::PublishMid => {
            if attempt_owns_temp(&tail) {
                discard_partial(fs, &tail_dest);
            }
            publish_from_temp(fs, mid, &mid_dest, &dest, options)
        }
        MirrorArbitration::PublishTail => {
            if attempt_owns_temp(&mid) {
                discard_partial(fs, &mid_dest);
            }
            publish_from_temp(fs, tail, &tail_dest, &dest, options)
        }
    }
}

/// Whether a salvage attempt left a destination temp for the caller to clean up.
///
/// Ownership is proven by the actual OUTCOME — a present `salvaged_path` — not
/// inferred from the final error kind. An attempt leaves a temp to discard ONLY
/// when it succeeded AND actually wrote one (`Ok` with `salvaged_path == Some`).
/// Every other outcome leaves nothing for the caller to remove:
/// - an error BEFORE `Writer::new` (a checksum / recover / range-tombstone /
///   deletion-guard failure) never created the temp — inferring ownership from
///   the non-`AlreadyExists` error kind would delete a concurrent creator's file
///   on shared storage;
/// - an error AFTER `Writer::new` (a walk / finish failure) already discarded its
///   partial output internally;
/// - an `AlreadyExists` from `create_new` lost the race and created nothing;
/// - an `Ok` attempt that recovered nothing discarded its empty temp too
///   (`salvaged_path == None`).
fn attempt_owns_temp(result: &crate::Result<SalvageReport>) -> bool {
    matches!(result, Ok(r) if r.salvaged_path.is_some())
}

/// Which divergent-mirror attempt to publish, or whether to propagate a
/// transient failure.
#[derive(Debug, PartialEq, Eq)]
enum MirrorArbitration {
    /// Publish the tail (authoritative-by-convention) attempt.
    PublishTail,
    /// Publish the mid attempt.
    PublishMid,
    /// Propagate the transient failure: a retry could recover more than the
    /// incomplete success it would otherwise lose to.
    Propagate,
}

/// Chooses which divergent-mirror attempt to publish, or whether to propagate a
/// retryable failure so the caller retries.
///
/// An ENVIRONMENTAL failure on one attempt must NOT lose to an INCOMPLETE
/// success on the other: a retry of that mirror — after the ACL, the quota or
/// the memory pressure is fixed — could recover the blocks the incomplete
/// winner dropped, so propagate it. A COMPLETE success still wins (a retry
/// cannot improve on it), and a failure that implicates the BYTES always loses
/// to any success (a retry cannot help there). Between two successes the strictly more
/// complete recovery wins; an exact tie keeps the tail copy (authoritative by
/// convention — the recovered copy re-derives every authoritative field from the
/// re-emitted entries, so the losing mirror's non-derivable metadata never
/// reaches it and the tie-break only chooses which layout drives the re-encode).
fn arbitrate_mirrors(
    tail: &crate::Result<SalvageReport>,
    mid: &crate::Result<SalvageReport>,
) -> MirrorArbitration {
    // The ENVIRONMENTAL class, the same one the divergence probe and every
    // repair gate use: an ACL mistake or host pressure on ONE attempt says
    // nothing about that mirror's bytes, so publishing the other attempt's
    // INCOMPLETE result would commit a loss a fixed environment would not have
    // taken. Only a failure that implicates the bytes loses to a partial
    // success (a retry cannot improve on it).
    // The SAME class the repair gates use, straight off `Error` — including
    // the mis-supplied recovery context (a wrong key, a wrong or missing
    // dictionary), which is not an `Io` error at all: matching only `Io` here
    // published the other attempt's INCOMPLETE result and let repair replace
    // and remove an intact source over a fixable configuration mistake.
    let is_retryable =
        |r: &crate::Result<SalvageReport>| matches!(r, Err(e) if e.is_environmental());
    let complete = |r: &crate::Result<SalvageReport>| matches!(r, Ok(rep) if rep.is_complete());
    if (is_retryable(tail) && !complete(mid)) || (is_retryable(mid) && !complete(tail)) {
        return MirrorArbitration::Propagate;
    }
    // Completeness ranking: more recovered blocks first, then more recovered
    // entries (equal block counts can hold different key counts — block sizes
    // vary), then fewer dropped blocks.
    let completeness = |rep: &SalvageReport| {
        (
            rep.blocks_salvaged,
            rep.entries_salvaged,
            core::cmp::Reverse(rep.dropped.len()),
        )
    };
    match (tail, mid) {
        (Err(_), Ok(_)) => MirrorArbitration::PublishMid,
        (_, Err(_)) => MirrorArbitration::PublishTail,
        (Ok(t), Ok(m)) => {
            if completeness(m) > completeness(t) {
                MirrorArbitration::PublishMid
            } else {
                MirrorArbitration::PublishTail
            }
        }
    }
}

/// Probes forward from a process-local counter to a free `.healtmp-{n}` sibling
/// of `dest` for an arbitration attempt's PRIVATE output. The `.healtmp-`
/// namespace is the recovery-owned temp space a hard crash leaves for the next
/// open to sweep. A foreign artifact is never reclaimed (it may belong to a
/// concurrent salvage); termination holds because every probe advances the
/// counter and the on-disk artifacts are finite.
fn next_arb_temp(
    fs: &alloc::sync::Arc<dyn crate::fs::Fs>,
    dest: &std::path::Path,
) -> crate::Result<std::path::PathBuf> {
    static ARB_TMP_SEQ: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
    // Fold the process id into the HIGH bits so two concurrent standalone salvage
    // processes never select the SAME temp name: without this both process-local
    // counters start at 0, and a loser reading a shared name could `discard_partial`
    // the winner's in-progress output. The result stays a single u64, so recovery
    // still parses and sweeps `{id}.healtmp-{numeric}` on a crash. Pid reuse after
    // exit is handled by the exists-probe / AlreadyExists retry below.
    let pid_hi = u64::from(std::process::id()) << 32;
    loop {
        let counter = ARB_TMP_SEQ.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
        let seq = pid_hi | (counter & 0xFFFF_FFFF);
        let candidate = dest.with_extension(alloc::format!("healtmp-{seq}"));
        match fs.exists(&candidate) {
            Ok(false) => return Ok(candidate),
            Ok(true) => {}
            Err(e) => return Err(e.into()),
        }
    }
}

/// Publishes the winning arbitration attempt (`result`, written to `temp`) to
/// `dest` with NO-REPLACE semantics, so a foreign file already at `dest` is
/// never destroyed — the whole reason both attempts write to private temps.
/// `hard_link` claims `dest` atomically (it fails `AlreadyExists` if anything is
/// there); a backend without `hard_link` claims it with `create_new` instead —
/// equally atomic, so no fallback ever replaces a concurrently published file.
/// On any failure the temp is discarded and the error propagates, so a retry and
/// the repair caller both see `dest` as it was (free, or still owned by whoever
/// holds it). A successful publish is made durable before the temp name drops.
fn publish_from_temp(
    fs: &alloc::sync::Arc<dyn crate::fs::Fs>,
    result: crate::Result<SalvageReport>,
    temp: &std::path::Path,
    dest: &std::path::Path,
    options: &SalvageOptions,
) -> crate::Result<SalvageReport> {
    let already_exists = || {
        crate::Error::Io(crate::io::Error::new(
            crate::io::ErrorKind::AlreadyExists,
            "salvage destination already exists",
        ))
    };
    let mut rep = match result {
        Ok(rep) => rep,
        Err(e) => {
            // An erroring attempt leaves nothing for the caller to remove, so
            // NEVER discard `temp` here (ownership is proven by the OUTCOME, not
            // inferred from the error kind — see [`attempt_owns_temp`]): a
            // failure BEFORE `Writer::new` never created `temp` (deleting it
            // would remove a concurrent creator's file on shared storage, e.g.
            // across PID namespaces with the same numeric id), a failure AFTER
            // `Writer::new` already discarded its own partial internally, and an
            // `AlreadyExists` race loser created nothing.
            return Err(e);
        }
    };
    if rep.salvaged_path.is_none() {
        // The attempt wrote nothing (an empty source, or a failure that left no
        // file): there is nothing to publish.
        return Ok(rep);
    }
    match fs.hard_link(temp, dest) {
        Ok(()) => {}
        Err(e) if e.kind() == crate::io::ErrorKind::AlreadyExists => {
            discard_partial(fs, temp);
            return Err(already_exists());
        }
        Err(e) if e.kind() == crate::io::ErrorKind::Unsupported => {
            // A backend without `hard_link` can still claim the destination
            // ATOMICALLY: `create_new` fails `AlreadyExists` when anything sits
            // there, and the rename below then replaces only OUR OWN empty
            // claim — never a concurrently published file (`rename` replaces
            // an existing destination by contract, so a probe-then-rename
            // fallback would silently overwrite a racing creator's file). A
            // crash between the claim and the rename leaves an empty `dest`
            // that recovery reports unreadable, while the temp copy
            // still holds the content for a re-derive.
            match fs.open(
                dest,
                &crate::fs::FsOpenOptions::new().write(true).create_new(true),
            ) {
                Ok(claim) => {
                    drop(claim);
                    if let Err(e) = fs.rename(temp, dest) {
                        // Release the claim: an empty file left under the
                        // canonical name would make every retry fail
                        // `AlreadyExists` against our own leftover.
                        discard_partial(fs, dest);
                        discard_partial(fs, temp);
                        return Err(e.into());
                    }
                }
                Err(e) if e.kind() == crate::io::ErrorKind::AlreadyExists => {
                    discard_partial(fs, temp);
                    return Err(already_exists());
                }
                Err(e) => {
                    discard_partial(fs, temp);
                    return Err(e.into());
                }
            }
        }
        Err(e) => {
            discard_partial(fs, temp);
            return Err(e.into());
        }
    }
    // Drop the temp name BEFORE the durability sync, so one sync below covers
    // both directory mutations (the new `dest` entry and the removed temp). A
    // LIVE removal failure must not be shrugged off — the repair caller
    // commits a manifest that never references the temp, and a stuck
    // `.healtmp-` name makes the next open's artifact sweep hit the same
    // removal error — but it must also not strand a DURABLE `dest` behind an
    // error (a later retry would then bounce off `AlreadyExists` forever
    // despite this call reporting failure). Nothing is synced yet, so
    // unwinding the fresh `dest` entry returns the pair to its pre-publish
    // state; if even the unwind fails, the retry's directory scan resolves
    // the leftovers once the filesystem is fixed. A missing temp is fine (a
    // concurrent sweep won).
    match fs.remove_file(temp) {
        Ok(()) => {}
        Err(e) if e.kind() == crate::io::ErrorKind::NotFound => {}
        Err(e) => {
            discard_partial(fs, dest);
            return Err(e.into());
        }
    }
    // `dest` now links the copy and the temp name is gone, but both directory
    // mutations are fresh: without a sync a power loss can leave the manifest
    // referencing a `dest` that survives only under the temp name. A sync
    // failure removes the just-published copy and propagates, so a retry and
    // the repair caller both see the destination free.
    if let Err(e) = fs.sync_directory_with(entry_directory(dest), options.sync_mode) {
        discard_partial(fs, dest);
        return Err(e.into());
    }
    rep.salvaged_path = Some(dest.to_path_buf());
    Ok(rep)
}

/// How two decoded meta mirrors relate, from [`meta_mirrors_diverge`].
#[derive(Debug, PartialEq, Eq)]
enum MirrorDivergence {
    /// Identical, or only one copy is readable: the ordinary
    /// tail-with-MID-fallback machinery covers it.
    Agree,
    /// They disagree only in fields the salvage walk RE-DERIVES from the
    /// entries it re-emits, so recovering more blocks settles it: run both
    /// attempts and publish the more complete one.
    Derivable,
    /// They disagree in a field NOTHING derives or authenticates —
    /// `bulk_ingested`, `recency`, or the compaction lineage. The writer
    /// copies these from whichever mirror is selected, so choosing is
    /// choosing which forgery to believe.
    NonDerivable,
}

/// Decodes BOTH meta mirrors under the caller's id/encryption context and
/// reports how they relate. Any unreadable copy is [`MirrorDivergence::Agree`]
/// — the ordinary tail-with-MID-fallback machinery already covers broken
/// mirrors; arbitration is only for two VALID copies that disagree.
fn meta_mirrors_diverge(
    source: &std::path::Path,
    fs: &alloc::sync::Arc<dyn crate::fs::Fs>,
    options: &SalvageOptions,
) -> crate::Result<MirrorDivergence> {
    // A TRANSIENT open failure must not return Ok(false) (which would skip the
    // dual-attempt arbitration and salvage from the tail view alone): the source
    // is being salvaged, so it EXISTS — a failure to open it is a retryable I/O
    // fault, not a structurally-absent mirror. `Fs::open` only ever fails with an
    // `io::Error`, so propagate it; the caller (repair) then retries instead of
    // publishing a tail-preferred recovery that omits healthy blocks a divergent
    // MID mirror would have kept.
    let mut file = match fs.open(source, &crate::fs::FsOpenOptions::new().read(true)) {
        Ok(f) => f,
        Err(e) => return Err(crate::Error::Io(e)),
    };
    // A TRANSIENT trailer read must not return Ok(false) (which would skip the
    // dual-attempt arbitration and salvage directly from the tail view): a source
    // with divergent mirrors could then be re-encoded under a forged layout.
    // Propagate sfa::Error::Io; reserve Ok(false) for structural unreadability.
    let trailer = match crate::sfa::Reader::from_reader(&mut file) {
        Ok(t) => t,
        Err(crate::sfa::Error::Io(e)) => return Err(crate::Error::Io(e)),
        Err(_) => return Ok(MirrorDivergence::Agree),
    };
    let Ok(regions) = crate::table::regions::ParsedRegions::parse_from_toc(trailer.toc()) else {
        return Ok(MirrorDivergence::Agree);
    };
    let Some(mid_handle) = regions.metadata_mid else {
        return Ok(MirrorDivergence::Agree);
    };
    // Mirror recover_inner's id policy: an encrypted open always binds the
    // caller's AAD id; an unencrypted one uses the out-of-band durable id
    // when the caller knows it (repair), else no cross-check.
    let expected_id = if options.encryption.is_some() {
        Some(options.table_id)
    } else {
        options.expected_stored_id
    };
    let tail = crate::table::meta::ParsedMeta::load_with_handle(
        &*file,
        &regions.metadata,
        expected_id,
        options.encryption.as_deref(),
    );
    let mid = crate::table::meta::ParsedMeta::load_with_handle(
        &*file,
        &mid_handle,
        expected_id,
        options.encryption.as_deref(),
    );
    match (tail, mid) {
        (Ok(t), Ok(m)) => {
            if t == m {
                return Ok(MirrorDivergence::Agree);
            }
            // Which fields disagree decides whether an arbitration can settle
            // it at all. The salvage walk re-derives the ENTRY-backed fields
            // from the records it re-emits, so a disagreement there is
            // resolvable by recovering more blocks. These fields are NOT
            // derived from any entry — the writer copies them from whichever
            // mirror was selected — so a forged tail simply becomes the truth
            // (clearing `bulk_ingested` republishes an ingested SST at global
            // seqno 0; rewriting `recency` or the lineage re-orders L0 or
            // resurrects a compaction's inputs). Nothing in the file
            // authenticates them against each other, so the salvage refuses
            // to choose.
            let non_derivable_disagree = t.bulk_ingested != m.bulk_ingested
                || t.recency != m.recency
                || t.lineage != m.lineage
                || t.lineage_prev != m.lineage_prev
                || t.lineage_transformed != m.lineage_transformed
                || t.lineage_last != m.lineage_last;
            Ok(if non_derivable_disagree {
                MirrorDivergence::NonDerivable
            } else {
                MirrorDivergence::Derivable
            })
        }
        // An ENVIRONMENTAL read on either mirror must not masquerade as "mirrors
        // agree" (which would skip the MID recovery attempt and salvage under a
        // possibly forged tail): propagate it so the caller retries. The class
        // covers the transient kinds AND the access failures that do not
        // implicate the bytes — an ACL mistake or host pressure on ONE mirror
        // otherwise falls back to the other, and if that one only supports a
        // partial salvage the repair publishes the lossy result even though a
        // fixed environment would have recovered more from the first.
        (Err(crate::Error::Io(io)), _) | (_, Err(crate::Error::Io(io)))
            if io.kind().is_environmental() =>
        {
            Err(crate::Error::Io(io))
        }
        // A structurally unreadable mirror OR an I/O failure that DOES implicate
        // its bytes is the ordinary tail-with-MID-fallback case (`recover_inner`
        // recovers from the readable copy): arbitration is only for two valid
        // copies that disagree, and a retry cannot make a rotted mirror decode.
        _ => Ok(MirrorDivergence::Agree),
    }
}

/// One salvage walk of `source` into `dest` under a fixed meta-mirror order
/// (`prefer_mid_meta`; see [`salvage_with_context`] for the arbitration).
fn salvage_attempt(
    source: &std::path::Path,
    dest: std::path::PathBuf,
    fs: &alloc::sync::Arc<dyn crate::fs::Fs>,
    comparator: &crate::comparator::SharedComparator,
    options: &SalvageOptions,
    prefer_mid_meta: bool,
    allow_verbatim: bool,
) -> crate::Result<SalvageReport> {
    // Digest the source through the injected `Fs`, not `std::fs`: salvage runs
    // over MemFs / fault-injected / routed backends (repair passes its own `fs`),
    // where a direct `std::fs` read would miss the file or hash the wrong bytes.
    // A persistent bad sector fails the whole-file digest, but salvage does not
    // need it: the block walk classifies unreadable blocks itself, and the
    // recovered copy is written under its own fresh digest. Fall back to a
    // placeholder so a bad-sector source still reaches block-level recovery
    // (mirrors the blob salvage path, which also opens with `from_raw(0)`); a
    // healthy source keeps its real digest.
    let checksum = match crate::repair::compute_table_checksum(&**fs, source) {
        Ok(c) => crate::Checksum::from_raw(c),
        Err(_) => crate::Checksum::from_raw(0),
    };
    let table = {
        let mut params = crate::table::RecoverParams::new(
            source.to_path_buf(),
            checksum,
            // The source's table id: encrypted block AAD binds it, so an
            // encrypted source only decrypts when opened under the same id
            // (`0` for the legacy standalone / unencrypted path).
            options.table_id,
            Arc::clone(fs),
            comparator.clone(),
            Arc::new(crate::cache::Cache::with_capacity_bytes(8 * 1024 * 1024)),
        );
        params.descriptor_table = Some(Arc::new(crate::descriptor_table::DescriptorTable::new(64)));
        // Decrypt / decompress the source with the caller's context: without it
        // an encrypted or dictionary-compressed source cannot be read at all.
        params.encryption.clone_from(&options.encryption);
        #[cfg(zstd_any)]
        {
            params.zstd_dictionary.clone_from(&options.zstd_dictionary);
        }
        crate::table::Table::recover_inner(
            params,
            // Salvage mode: a corrupt delete-bitmap / missing zone map degrades
            // to "all rows live" instead of failing, so a damaged sidecar still
            // opens. A caller-known durable id (repair) keeps the meta id
            // cross-check live, so a forged tail id falls back to the MID
            // mirror.
            crate::table::RecoveryMode::Salvage {
                expected_id: options.expected_stored_id,
                prefer_mid_meta,
            },
        )?
    };

    // Fail closed on range tombstones, present OR hidden: the positional walk
    // re-emits only point entries, so salvaging an SST that carries range
    // tombstones would drop them and let lower-level keys they cover reappear
    // after repair (a merge-semantics violation). A re-stamped TOC can also
    // RENAME the range_tombstones section to a recognized name whose block
    // decodes cleanly (an empty `filter`), hiding it from `range_tombstones()`
    // without tripping the degradation flag — but the persisted
    // `range_tombstone_count` still records it, so cross-check the count too.
    // Reject either way until the writer path can re-emit range tombstones.
    if !table.range_tombstones().is_empty() || table.metadata.range_tombstone_count > 0 {
        return Err(crate::Error::FeatureUnsupported(
            "salvage of an SST with range tombstones",
        ));
    }

    // Fail closed when the salvage open DEGRADED a rebuildable side section
    // (filter / filter_tli, seqno bounds, zone map, locator) AND the table
    // exposes NO deletion metadata. A re-stamped TOC can rename a
    // `range_tombstones` / `delete_bitmap` section to one of those names and
    // re-role its block: it passes the byte-level walk AND the tiling check
    // (the catalogue stays uniquely named), and the parsed table reports no
    // deletion, but its CONTENT is not what the name claims, so the open
    // degrades it. Salvage re-derives every such section from the recovered
    // entries, so it would DISCARD the relabeled deletion and re-emit the
    // suppressed rows as live. A genuinely rotted section is indistinguishable
    // from the relabel, so both fail closed; the rows come back from a replica,
    // a checkpoint plus journal replay, or a backup. The signal is purely
    // STRUCTURAL (each
    // section decodes its own bytes, independent of the data blocks), so a
    // corrupt DATA block still salvages. A table that DOES carry a visible
    // deletion (a delete bitmap; range tombstones were rejected above) is
    // exempt: its deletions are accounted for and applied.
    #[cfg(feature = "columnar")]
    let has_visible_deletion = table.has_delete_bitmap_section();
    #[cfg(not(feature = "columnar"))]
    let has_visible_deletion = false;
    if !has_visible_deletion && table.salvage_degraded_a_rebuildable_section() {
        return Err(crate::Error::FeatureUnsupported(
            "salvage of an SST with a degraded rebuildable section that may hide \
             a relabeled deletion",
        ));
    }

    // Fail closed when the on-disk TOC catalogue could CONCEAL a deletion section
    // without degrading any block: an OMITTED, RENAMED, SHADOWED, or gap-leaving
    // `delete_bitmap` / `range_tombstones` entry (behind a re-stamped TOC
    // checksum) leaves the parsed table reporting no deletion while every
    // remaining block still passes its byte-level checks: the relabel guard
    // above only catches a re-roled block whose catalogue stays perfectly tiled.
    // Repair drops such a table via this same check, but the
    // standalone `salvage_sst` / CLI path never runs the repair verifier, so a
    // positional walk here would re-emit the suppressed rows as live. A read
    // structural ambiguity grades closed, a transient read PROPAGATES (see
    // [`crate::repair::toc_may_hide_deletions`]) so a flaky probe aborts salvage
    // for a retry rather than refusing a recoverable table. A table with a VISIBLE
    // deletion is exempt: its deletions are applied.
    if !has_visible_deletion && crate::repair::toc_may_hide_deletions(fs, source)? {
        return Err(crate::Error::FeatureUnsupported(
            "salvage of an SST whose TOC may hide a deletion section \
             (an omitted, renamed, or shadowed entry)",
        ));
    }

    // Fail closed when the delete mask cannot be applied FAITHFULLY: the
    // salvage-mode open degraded it (an unreadable bitmap, or a readable
    // bitmap whose zone map was unreadable), or the zone map decodes but its
    // claimed positions do not match the actual per-block row counts (a
    // checksum-repatched tamper that would silently mask the WRONG rows).
    // Emitting "all rows live" instead resurrects positionally-deleted rows,
    // which the caller must explicitly opt into via
    // `allow_delete_resurrection`; under that opt-in the walk re-emits
    // UNMASKED — it never masks against unverified positions.
    #[cfg(feature = "columnar")]
    let delete_mask_unpositionable = table.delete_bitmap_degraded
        // A PRESENT delete_bitmap section that decodes to an EMPTY bitmap is a
        // forge: the writer only emits the section when it holds positions, so a
        // checksum-consistent corruption to empty would otherwise pass as
        // positionable and let the masked path re-emit every deleted row live.
        || (table.has_delete_bitmap_section() && table.delete_bitmap().is_empty())
        // A transient I/O fault while cross-checking the delete positions aborts
        // salvage (propagated so repair retries) rather than being read as a
        // persistent unpositionable mask — which under the default
        // `allow_delete_resurrection == false` would drop the table from the
        // rebuilt manifest a retry could have recovered.
        || (!table.delete_bitmap().is_empty() && !table.delete_positions_verified()?)
        // Authenticate the bitmap CONTENTS, not just its positions: an
        // equal-cardinality substitution (a different, checksum-valid bitmap) is
        // structurally positionable yet masks the WRONG rows. With no original
        // whole-file digest here, the meta-recorded content hash is the only way to
        // catch it; a present section without a matching hash cannot be
        // authenticated, so mask it as unpositionable (fail closed).
        || !table.delete_bitmap_authenticated();
    #[cfg(not(feature = "columnar"))]
    let delete_mask_unpositionable = table.delete_bitmap_degraded;
    if delete_mask_unpositionable && !options.allow_delete_resurrection {
        return Err(crate::Error::InvalidHeader(
            "salvage: the delete bitmap cannot be applied; recovering would resurrect deleted \
             rows (opt in with allow_delete_resurrection)",
        ));
    }

    // The recovered copy is written under the SAME layout as the source —
    // compression, ECC, restart interval, columnar (+ zone map), per-KV
    // checksums (`mirror_from`) — plus the caller's encryption provider and zstd
    // dictionary, so a columnar / encrypted / dictionary source salvages into a
    // faithful copy that reopens under the live tree's `Config` instead of a
    // degraded row-major / plaintext mismatch.
    // The recovered copy is stamped with the SOURCE's stored table id (its
    // identity), not the caller's open/AAD context id: an unencrypted
    // salvage-mode open accepts any stored id (`options.table_id` stays the
    // default 0), and the copy must keep the source's identity so it reopens
    // consistently when swapped in for the original. For an encrypted source
    // the two are necessarily equal (the open's AAD binds the caller's id).
    // `output_id` overrides that when the copy is published UNDER A NEW
    // IDENTITY beside its source instead of replacing it.
    // A KV-separated source's entries hold ValueHandles into blob files, and
    // blob GC / relocation consults the table's linked_blob_files section to
    // decide whether a blob is still referenced. The SOURCE's list is IGNORED
    // entirely — it is not authoritative in either direction: a forged count
    // word can under-report (hiding a blob GC would then delete) and a forged
    // record can OVER-report an id that exists nowhere (a corrupt reference
    // downstream consumers must never see). The walk derives the copy's links
    // exactly from the indirections of its recovered rows: a dropped block's
    // indirections do not exist in the copy, so no source-only id can ever be
    // needed by it.
    let writer = crate::table::Writer::new(
        dest.clone(),
        options.output_id.unwrap_or(table.metadata.id),
        0,
        Arc::clone(fs),
    )?
    .mirror_from(
        &table.metadata,
        table.has_zone_map(),
        table.has_seqno_bounds(),
    )
    // The copy carries the SOURCE's content, so it sorts at the source's L0
    // position during manifest repair. Pinned explicitly (not just mirrored):
    // a copy published under a FRESH id would otherwise fall back to that new
    // id and claim a recency its content does not have.
    .use_recency(Some(table.l0_recency()))
    // The source's compaction lineage travels with the copy for the same
    // reason: the content is the same derived output.
    .use_lineage(table.metadata.lineage.clone())
    .use_lineage_prev(table.metadata.lineage_prev)
    .use_lineage_transformed(table.metadata.lineage_transformed)
    .use_lineage_last(table.metadata.lineage_last)
    .use_sync_mode(options.sync_mode)
    // The extractor is configuration (never persisted in the SST), so
    // the rebuilt filter only carries the source's prefix hashes when
    // the caller supplies it.
    .use_prefix_extractor(options.prefix_extractor.clone())
    .use_encryption(options.encryption.clone());
    // Without an extractor, DISABLE the filter entirely rather than rebuild one
    // from complete-key hashes: the source's prefix-indexing intent is
    // unknowable (the extractor is not persisted and cannot be inferred), and a
    // complete-key-only filter answers `maybe_contains_prefix` DEFINITELY-ABSENT
    // for a source that came from a prefix-indexed tree, silently dropping every
    // recovered row from prefix scans. No filter answers "maybe present" (a full
    // block read), which is always correct; the point-lookup speedup is
    // sacrificed for correctness.
    let writer = if options.prefix_extractor.is_none() {
        writer.use_bloom_policy(crate::config::BloomConstructionPolicy::BitsPerKey(0.0))
    } else {
        writer
    };
    #[cfg(zstd_any)]
    let writer = writer.use_zstd_dictionary(options.zstd_dictionary.clone());

    let walk = match salvage_blocks(
        &table,
        writer,
        comparator,
        !delete_mask_unpositionable,
        allow_verbatim,
        options.blob_rewrite.as_deref(),
        options.progress.as_deref(),
    ) {
        Ok(walk) => walk,
        Err(e) => {
            // A `write` / `finish` failure after `Writer::new` created `dest`
            // leaves a partial SST there. Remove it before propagating: a
            // leftover fragment is a file every later run would re-open and
            // re-reject.
            discard_partial(fs, &dest);
            return Err(e);
        }
    };

    let salvaged_path = if walk.wrote {
        Some(dest)
    } else {
        // Nothing recoverable. `Writer::new` already created `dest` and the walk
        // dropped the writer, so remove the empty file: a repair caller would
        // otherwise see a stray broken table file in its place.
        discard_partial(fs, &dest);
        None
    };

    Ok(SalvageReport {
        salvaged_path,
        blocks_total: walk.blocks_total,
        blocks_salvaged: walk.blocks_salvaged,
        blocks_copied_verbatim: walk.blocks_copied_verbatim,
        entries_salvaged: walk.entries_salvaged,
        entries_dropped_by_rewrite: walk.entries_dropped_by_rewrite,
        dropped: walk.dropped,
        // Resurrection happened exactly when the delete mask was unappliable and
        // the opt-in let the walk re-emit unmasked (the fail-closed branch above
        // already returned otherwise).
        delete_rows_resurrected: delete_mask_unpositionable && options.allow_delete_resurrection,
    })
}

/// The tally a [`salvage_blocks`] walk returns: the report counters plus whether
/// a destination file was actually finished (`wrote`), which the caller uses to
/// decide between keeping `dest` and removing the empty placeholder.
struct SalvageWalk {
    blocks_total: usize,
    blocks_salvaged: usize,
    blocks_copied_verbatim: usize,
    entries_salvaged: u64,
    entries_dropped_by_rewrite: u64,
    dropped: Vec<DroppedBlock>,
    wrote: bool,
}

/// Best-effort removal of a destination salvage could not complete (an empty or
/// partially-written SST). A leftover fragment is a file the next run would
/// re-open and re-reject; failure is logged, not propagated, so the original
/// error stays the one the caller sees.
fn discard_partial(fs: &alloc::sync::Arc<dyn crate::fs::Fs>, dest: &std::path::Path) {
    if let Err(e) = fs.remove_file(dest) {
        log::warn!(
            "salvage: could not remove the incomplete destination {}: {e}",
            dest.display(),
        );
    }
}

/// The directory to fsync so `path`'s new directory entry is durable.
///
/// [`std::path::Path::parent`] yields an EMPTY path for a bare relative
/// destination (`Path::new("blob").parent() == Some("")`), which is not a
/// syncable directory: fsyncing it fails and the caller would discard the
/// recovered file it had just written. Map the empty (and absent) parent to the
/// current directory so a bare relative destination still gets its entry synced.
/// Every [`Fs`](crate::fs::Fs) backend accepts `.` in `sync_directory` — the
/// in-memory backend recognizes it as its implicit root — so this spelling is
/// safe regardless of which backend performs the salvage.
fn entry_directory(path: &std::path::Path) -> &std::path::Path {
    match path.parent() {
        Some(parent) if !parent.as_os_str().is_empty() => parent,
        _ => std::path::Path::new("."),
    }
}

/// Classifies a block load / read failure into a [`DroppedBlock`], distinguishing
/// a bit-rot checksum mismatch from a structural decode error from a raw
/// read / decompress failure, and attaching the block's `(prev_end, end_key]`
/// range as the lower/upper bound of the lost keys.
/// Removes the entries a LOST neighbouring block may have shadowed.
///
/// `boundary` is the lost block's last key when its range was known; the only
/// key it can have held newer versions of is that one, so only its entries go.
/// When the range is unknown (`None`), the same is true of THIS block's first
/// key — nothing else could have sat between them — so that key's entries go
/// instead.
/// Returns the retained entries and, when the block held NOTHING but the
/// shadowed key, that key again: a block made up entirely of it does not prove
/// the version run has ended, and the chain can continue into the next block,
/// which would then publish an even older version. Suppression therefore holds
/// until a surviving entry with a different key ends the run.
/// Which entries are the shadowed key's is the engine's one identity relation
/// (see [`crate::comparator::same_user_key`]) — the same question the read path
/// answers when it groups a key's versions, asked the same way.
fn suppress_shadowed_boundary(
    entries: Vec<crate::InternalValue>,
    boundary: Option<&UserKey>,
) -> (Vec<crate::InternalValue>, Option<UserKey>) {
    let Some(shadowed) = boundary
        .cloned()
        .or_else(|| entries.first().map(|e| e.key.user_key.clone()))
    else {
        return (entries, None);
    };
    let kept: Vec<_> = entries
        .into_iter()
        .filter(|e| !crate::comparator::same_user_key(&e.key.user_key, &shadowed))
        .collect();
    let carry = kept.is_empty().then_some(shadowed);
    (kept, carry)
}

/// Outcome of suppressing a boundary key inside a decoded columnar batch.
#[cfg(feature = "columnar")]
enum BoundarySuppression {
    /// The key was not in this batch: the version run ended before it.
    Unchanged,
    /// The batch was NOTHING but the shadowed key; the run may continue, so the
    /// key comes back to keep suppressing into the next block.
    Emptied(UserKey),
    /// The surviving rows, re-encoded, with the row view the caller emits from.
    Rebuilt(
        crate::table::columnar::ColumnBatch,
        Vec<crate::InternalValue>,
    ),
}

/// [`suppress_shadowed_boundary`] for a columnar block: the batch has to be
/// rebuilt from its surviving rows, which value SUB-COLUMNS cannot round-trip —
/// that exotic combination fails closed rather than publishing a resurrected
/// version.
#[cfg(feature = "columnar")]
fn suppress_columnar_boundary(
    batch: &crate::table::columnar::ColumnBatch,
    boundary: Option<&UserKey>,
) -> crate::Result<BoundarySuppression> {
    let entries = crate::table::columnar::column_batch_to_entries(batch)?;
    let before = entries.len();
    let (kept, carry) = suppress_shadowed_boundary(entries, boundary);
    if kept.len() == before {
        return Ok(BoundarySuppression::Unchanged);
    }
    if let Some(key) = carry {
        return Ok(BoundarySuppression::Emptied(key));
    }
    if batch.columns.len() > 4 {
        return Err(crate::Error::FeatureUnsupported(
            "boundary-key suppression in a columnar block with value sub-columns",
        ));
    }
    let rebuilt = crate::table::columnar::entries_to_column_batch(&kept)?;
    Ok(BoundarySuppression::Rebuilt(rebuilt, kept))
}

fn classify_drop(
    e: &crate::Error,
    offset: u64,
    prev_end: Option<&UserKey>,
    end_key: Option<&UserKey>,
) -> DroppedBlock {
    use alloc::format;
    let reason = match e {
        crate::Error::ChecksumMismatch { .. } => DropReason::ChecksumMismatch,
        crate::Error::InvalidHeader(_) | crate::Error::InvalidTag(_) => {
            DropReason::DecodeError(format!("{e:?}"))
        }
        _ => DropReason::ReadError(format!("{e:?}")),
    };
    DroppedBlock {
        offset,
        section: b"data".to_vec(),
        reason,
        // A gap-probed block (omitted by a forged index) has no separator,
        // so its key range is unknown until decode — `None`, like a block
        // whose index entry is lost.
        key_range: end_key.map(|ek| (prev_end.cloned().unwrap_or_else(UserKey::empty), ek.clone())),
    }
}

/// Applies a [`BlobFileRewrite`] set to a block's decoded entries: handles
/// into a remapped blob are re-encoded at their salvaged offset, and entries
/// whose record no longer exists (absent from the remap, or below a punched
/// frontier) are removed and counted. An indirection that fails to decode is
/// corrupt content — the caller drops the whole block, as everywhere else.
/// Runs BEFORE blob-link derivation so the rewritten SST's
/// `linked_blob_files` reflects the NEW handles.
///
/// Losing a record removes the HEAD of a key's version chain, so every older
/// version of that key goes with it — otherwise the rewritten SST exposes an
/// overwritten value, or undoes a newer one by exposing an older tombstone.
/// Versions of a key are contiguous and ordered newest-first, so that is every
/// following entry of the same key. Returns the surviving entries and, when the
/// block ENDS inside such a suppressed run, the key to keep suppressing: the
/// chain can continue into the next block.
///
/// "The same key" is the engine's one identity relation (see
/// [`crate::comparator::same_user_key`]), the same question the read path
/// answers when it groups a key's versions.
fn rewrite_block_indirections(
    entries: Vec<crate::InternalValue>,
    rewrite: &crate::HashMap<crate::vlog::BlobFileId, BlobFileRewrite>,
    dropped_entries: &mut u64,
) -> crate::Result<(Vec<crate::InternalValue>, Option<UserKey>)> {
    use crate::coding::{Decode, Encode};

    let mut out = Vec::with_capacity(entries.len());
    let mut headless: Option<UserKey> = None;
    for mut entry in entries {
        if headless
            .as_ref()
            .is_some_and(|h| crate::comparator::same_user_key(&entry.key.user_key, h))
        {
            // An older version of a key whose newer one just went missing.
            *dropped_entries += 1;
            continue;
        }
        // A different key: the previous key's chain has ended.
        headless = None;
        if entry.key.value_type != crate::ValueType::Indirection {
            out.push(entry);
            continue;
        }
        let mut cursor = &entry.value[..];
        let mut ind = crate::blob_tree::handle::BlobIndirection::decode_from(&mut cursor)?;
        match rewrite.get(&ind.vhandle.blob_file_id) {
            None => out.push(entry),
            Some(BlobFileRewrite::Remap { new_id, offsets }) => {
                if let Some(&relocation) = offsets.get(&ind.vhandle.offset) {
                    // ALL THREE fields: the replacement is a NEW file, so the
                    // handle must name it; and a live read cross-checks the
                    // handle's size against the frame header, so a stale size
                    // from the source file would reject the salvaged record.
                    ind.vhandle.blob_file_id = *new_id;
                    ind.vhandle.offset = relocation.offset;
                    ind.vhandle.on_disk_size = relocation.on_disk_size;
                    let mut buf = Vec::new();
                    ind.encode_into(&mut buf)?;
                    entry.value = buf.into();
                    out.push(entry);
                } else {
                    *dropped_entries += 1;
                    headless = Some(entry.key.user_key);
                }
            }
            Some(BlobFileRewrite::DropBelow(frontier)) => {
                if ind.vhandle.offset < *frontier {
                    *dropped_entries += 1;
                    headless = Some(entry.key.user_key);
                } else {
                    out.push(entry);
                }
            }
        }
    }
    Ok((out, headless))
}

/// Decodes the [`crate::blob_tree::handle::BlobIndirection`] of every
/// indirection entry in `entries`. An entry TAGGED as an indirection whose
/// value fails to decode is corrupt content the live read path could not
/// follow either — the caller drops the block rather than laundering it into
/// the recovered copy.
fn collect_indirections(
    entries: &[crate::InternalValue],
) -> crate::Result<Vec<crate::blob_tree::handle::BlobIndirection>> {
    use crate::coding::Decode;

    let mut out = Vec::new();
    for entry in entries {
        if entry.key.value_type == crate::ValueType::Indirection {
            let mut cursor = &entry.value[..];
            out.push(crate::blob_tree::handle::BlobIndirection::decode_from(
                &mut cursor,
            )?);
        }
    }
    Ok(out)
}

/// [`collect_indirections`] for a columnar batch: a cheap value-type-column
/// scan first, so the per-row materialization is only paid when the batch
/// actually holds indirections (KV-separated columnar sources are rare).
#[cfg(feature = "columnar")]
fn collect_columnar_indirections(
    batch: &crate::table::columnar::ColumnBatch,
) -> crate::Result<Vec<crate::blob_tree::handle::BlobIndirection>> {
    let tag = u8::from(crate::ValueType::Indirection);
    // Columns are key / seqno / value-type / values...; the value-type column
    // holds one tag byte per row.
    let has_indirections = batch.columns.get(2).is_some_and(|c| c.data.contains(&tag));
    if !has_indirections {
        return Ok(Vec::new());
    }
    let entries = crate::table::columnar::column_batch_to_entries(batch)?;
    collect_indirections(&entries)
}

/// Folds one block's recovered indirections into the walk's derived blob-link
/// map, mirroring the accumulation the live write path does per entry.
fn fold_blob_links(
    derived: &mut crate::HashMap<crate::vlog::BlobFileId, crate::table::writer::LinkedFile>,
    indirections: &[crate::blob_tree::handle::BlobIndirection],
) {
    for ind in indirections {
        derived
            .entry(ind.vhandle.blob_file_id)
            .and_modify(|link| {
                link.bytes += u64::from(ind.size);
                link.on_disk_bytes += u64::from(ind.vhandle.on_disk_size);
                link.len += 1;
            })
            .or_insert_with(|| crate::table::writer::LinkedFile {
                blob_file_id: ind.vhandle.blob_file_id,
                bytes: u64::from(ind.size),
                on_disk_bytes: u64::from(ind.vhandle.on_disk_size),
                len: 1,
            });
    }
}

/// The walk totals already published to a [`crate::RecoveryProgress`] handle,
/// so each [`publish_progress`] call sends only the delta (the shared counters
/// are cumulative across every table of one repair).
#[derive(Default)]
struct PublishedProgress {
    blocks_scanned: usize,
    blocks_recovered: usize,
    blocks_dropped: usize,
    blocks_healed: u64,
    kvs: u64,
    columns: u64,
}

/// Publishes the walk's running totals as deltas against `published`, then
/// records them as published. A `None` handle is a no-op.
#[expect(
    clippy::too_many_arguments,
    reason = "a plain projection of the walk's running totals; bundling them into a struct would only rename the call sites"
)]
fn publish_progress(
    progress: Option<&crate::RecoveryProgress>,
    published: &mut PublishedProgress,
    blocks_scanned: usize,
    blocks_recovered: usize,
    blocks_dropped: usize,
    blocks_healed: u64,
    kvs: u64,
    columns: u64,
) {
    let Some(p) = progress else { return };
    p.add_blocks(
        (blocks_scanned - published.blocks_scanned) as u64,
        (blocks_recovered - published.blocks_recovered) as u64,
        (blocks_dropped - published.blocks_dropped) as u64,
        blocks_healed - published.blocks_healed,
    );
    p.add_rows(kvs - published.kvs, columns - published.columns);
    *published = PublishedProgress {
        blocks_scanned,
        blocks_recovered,
        blocks_dropped,
        blocks_healed,
        kvs,
        columns,
    };
}

/// Walks `table`'s data blocks in index order, re-emitting every block that
/// loads and decodes cleanly into `writer` and recording the rest.
///
/// `apply_delete_mask` gates the delete-masked re-emit of a delete-bearing
/// columnar source: `false` means the mask is unpositionable (degraded bitmap
/// or unverified zone-map positions) and the caller explicitly opted into
/// resurrection — the walk then re-emits every row LIVE rather than masking
/// against unverified positions. Ignored for sources without a delete-bitmap
/// section.
///
/// Consumes `writer`: on success it is finished (when at least one block was
/// emitted) or dropped (when none were). On a `write` / `finish` error the
/// writer is dropped as the error unwinds, so the caller must remove the partial
/// destination it left behind.
#[cfg_attr(
    not(feature = "columnar"),
    expect(
        unused_variables,
        reason = "the delete mask exists only for columnar sources; without the feature the flag has no consumer"
    )
)]
fn salvage_blocks(
    table: &crate::table::Table,
    mut writer: crate::table::Writer,
    comparator: &crate::comparator::SharedComparator,
    apply_delete_mask: bool,
    allow_verbatim: bool,
    blob_rewrite: Option<&crate::HashMap<crate::vlog::BlobFileId, BlobFileRewrite>>,
    progress: Option<&crate::RecoveryProgress>,
) -> crate::Result<SalvageWalk> {
    use crate::table::block::ParsedItem;
    use alloc::format;

    // A handle rewrite invalidates every raw block byte (it carries the old
    // handles), so the verbatim copy-through is disabled for the whole walk.
    let allow_verbatim = allow_verbatim && blob_rewrite.is_none();
    let mut blocks_total = 0usize;
    let mut blocks_salvaged = 0usize;
    let mut blocks_copied_verbatim = 0usize;
    let mut entries_salvaged = 0u64;
    let mut blocks_healed = 0u64;
    // Only the columnar copy-through / re-emit arms count columns.
    #[cfg_attr(
        not(feature = "columnar"),
        expect(unused_mut, reason = "only the columnar walk arms count columns")
    )]
    let mut columns_salvaged = 0u64;
    let mut entries_dropped_by_rewrite = 0u64;
    let mut published = PublishedProgress::default();
    let mut dropped: Vec<DroppedBlock> = Vec::new();
    // Blob links DERIVED from the recovered entries' indirections, keyed by
    // blob file id — exact for the recovered copy (only emitted rows count).
    // The source's own linked_blob_files section is deliberately not
    // consulted: it is not authoritative in either direction (see the caller).
    let mut derived_blob_links: crate::HashMap<
        crate::vlog::BlobFileId,
        crate::table::writer::LinkedFile,
    > = crate::HashMap::default();
    // Lower bound for a dropped block's range: the previous block's last key,
    // since the index stores each block's last key (so block N covers
    // `(end_key[N-1], end_key[N]]`).
    let mut prev_end: Option<UserKey> = None;

    // Set when a block is DROPPED, cleared by the next block that emits. A
    // key's versions are contiguous, so they can straddle a block boundary with
    // the newest at the end of one block and an older one at the start of the
    // next — and emitting that older version after losing the newer republishes
    // it as current (a deletion resurrects, an overwritten value returns).
    // `Some(end_key)` names the lost block's last key, so only that key is
    // suppressed; `None` means the lost block's range is unknown (no index
    // separator), and then the next block's FIRST key is suppressed, since it is
    // the only one whose newer versions could have been in there.
    let mut lost_boundary: Option<Option<UserKey>> = None;
    // The previous block's OWN separator (not the running `prev_end` bound):
    // `None` when the walk framed that block without one, which is exactly the
    // unknown-range case the boundary above encodes.
    let mut prev_block_end: Option<UserKey> = None;
    let mut dropped_seen;

    // Enumerate the index handles first. A corrupt index entry stops the
    // collection after reporting it: once the index stream desyncs, later
    // entries are unknowable. This is NOT the end of recovery — the physical
    // data section is still writer-ordered and self-framing, so the tiling
    // walk below recovers every block the broken enumeration could not reach
    // (a mid-partition rot must not cost the failed and later partitions).
    // Capture (do NOT yet record) a broken enumeration: whether it is data loss
    // depends on the fallback below. When the physical data section is readable,
    // the tiling walk recovers every block INDEPENDENTLY of the index, so a
    // corrupt index partition costs nothing and must not be reported as a
    // dropped block; only when there is no physical fallback (unreadable TOC) is
    // the un-enumerated remainder truly lost.
    let mut indexed: Vec<crate::table::KeyedBlockHandle> = Vec::new();
    let mut index_enum_error: Option<String> = None;
    for handle in table.data_block_handles() {
        match handle {
            Ok(k) => indexed.push(k),
            Err(e) => {
                index_enum_error = Some(format!("{e:?}"));
                break;
            }
        }
    }

    // Walk the PHYSICAL data section regardless of how the index enumeration
    // went. Two failure modes both need it:
    // - A CLEANLY enumerated index can still OMIT a handle (both TLI mirrors
    //   forged to the same truncated list pass every byte-level check and the
    //   mirror comparison), invisible to the open.
    // - A BROKEN index (a rotted leaf partition) yields only a prefix, leaving
    //   the failed and later partitions' blocks unreferenced.
    // The writer emits blocks back-to-back, so the section tiling is the only
    // ground truth: frame each uncovered byte range from its block headers and
    // salvage those blocks too (their end key is unknown until decode); an
    // unframeable gap is reported dropped, never silently skipped. The index
    // handles that DID enumerate still contribute their end keys.
    let mut items: Vec<(crate::table::BlockHandle, Option<UserKey>)> = Vec::new();
    let data_section = {
        let mut file = table
            .fs
            .open(&table.path, &crate::fs::FsOpenOptions::new().read(true))?;
        match crate::sfa::Reader::from_reader(&mut file) {
            Ok(t) => {
                let toc_pos = t.toc_pos();
                t.toc().section(b"data").and_then(|s| {
                    // checked, not saturating: a re-stamped `data` length that
                    // overflows `pos + len` must NOT saturate to a `u64::MAX`
                    // section end — the byte-at-a-time resync loop below would then
                    // probe every nonexistent offset up to it, hanging salvage. A
                    // section that ends past where the TOC begins is equally corrupt
                    // (it would overlap the index / meta / TOC), so require the end
                    // to land at or before `toc_pos`.
                    let end = s.pos().checked_add(s.len())?;
                    (end <= toc_pos).then_some((s.pos(), end))
                })
            }
            // Only a TRANSIENT I/O error re-reading the trailer would silently
            // disable the physical walk that recovers index-omitted blocks: if the
            // index is checksum-consistent but omits a block, salvage would then
            // publish the indexed subset and report a COMPLETE recovery while
            // losing the omitted keys. Abort so the caller retries. A PERSISTENT
            // I/O failure that implicates the BYTES (a bad sector / truncated
            // trailer) or a STRUCTURAL trailer error is the genuine "no physical
            // fallback" case (an unreadable TOC): fall back to the index
            // enumeration alone, so the already-open table's indexed blocks stay
            // recoverable. The ENVIRONMENTAL class propagates instead — an ACL
            // mistake or host pressure says nothing about the trailer.
            Err(crate::sfa::Error::Io(e)) if e.kind().is_environmental() => {
                return Err(crate::Error::Io(e));
            }
            Err(_) => None,
        }
    };
    if let Some((section_pos, section_end)) = data_section {
        // One open handle for the WHOLE physical walk: the resync scan steps one
        // byte at a time (block starts are not aligned), so opening the file per
        // probe would make salvage O(section_len) opens instead of O(blocks).
        let probe_file = table
            .fs
            .open(&table.path, &crate::fs::FsOpenOptions::new().read(true))?;
        // The gap walk must accept a candidate only after its PAYLOAD loads, not
        // just its header frame: a header-checksum-valid but FAKE header inside
        // corrupt bytes can declare a forged size that spans real blocks after
        // it. Advancing by that unvalidated size would skip the intact blocks
        // (the later load pass drops only the fake candidate, losing the rest).
        // So fully load each candidate here; the block type matches the SST.
        let probe_block_type = {
            #[cfg(feature = "columnar")]
            {
                if table.metadata.columnar {
                    crate::table::block::BlockType::Columnar
                } else {
                    crate::table::block::BlockType::Data
                }
            }
            #[cfg(not(feature = "columnar"))]
            {
                crate::table::block::BlockType::Data
            }
        };
        // A candidate is REAL only if its header frames AND its payload loads.
        // `Ok(Some)` = a framed, loaded block; `Ok(None)` = structurally not a
        // block, or one whose bytes are themselves unreadable (a header that did
        // not frame, a payload that did not decode, a bad-sector read a retry
        // can't fix) — the gap walk treats it as a chain break and drops it.
        // `Err(Io)` is reserved for an ENVIRONMENTAL read failure: the same
        // class every other gate uses, because a break here drops the candidate
        // (or the whole unanchored tail) as permanently lost and lets repair
        // publish the partial replacement over a source whose bytes were never
        // proven corrupt.
        let frames_and_loads =
            |at: u64, to: u64| -> crate::Result<Option<crate::table::BlockHandle>> {
                match table.probe_block_handle_in(&*probe_file, at, to) {
                    Ok(h) => match table.salvage_load_block(&h, probe_block_type) {
                        Ok(_) => Ok(Some(h)),
                        Err(crate::Error::Io(io)) if io.kind().is_environmental() => {
                            Err(crate::Error::Io(io))
                        }
                        Err(_) => Ok(None),
                    },
                    Err(crate::Error::Io(io)) if io.kind().is_environmental() => {
                        Err(crate::Error::Io(io))
                    }
                    Err(_) => Ok(None),
                }
            };
        // Returns `true` when the gap `[from, to)` tiled CONTIGUOUSLY to `to`
        // (so `to` is a proven boundary), `false` when the chain broke before
        // reaching it.
        let probe_gap = |from: u64,
                         to: u64,
                         items: &mut Vec<(crate::table::BlockHandle, Option<UserKey>)>,
                         dropped: &mut Vec<DroppedBlock>|
         -> crate::Result<bool> {
            // `from` is a TRUSTED boundary: the data section start, or the end
            // of a block whose frame this walk already validated. Blocks that
            // tile CONTIGUOUSLY from it inherit that provenance — each
            // framed-and-loaded block's validated size anchors the next offset.
            // The moment an offset does NOT frame a loadable block, the chain
            // breaks: every later offset is reachable only by byte scanning,
            // which cannot prove a boundary. An uncompressed block can carry a
            // complete checksum-valid SST block inside a user value, so a scan
            // that resynced past a broken header could frame that NESTED forge
            // and re-emit its interior entries as genuine data. Fail closed
            // exactly as the blob resync path does: drop the whole remaining gap
            // rather than emit unanchored candidates. A contiguous intact
            // section still recovers every block; only bytes after a broken
            // boundary are surrendered.
            let mut at = from;
            while at < to {
                if let Some(h) = frames_and_loads(at, to)? {
                    let next = at + u64::from(h.size());
                    // A zero-size frame would not advance `at` (an infinite loop)
                    // and cannot anchor the next offset. Treat it as a broken
                    // boundary: fall through to the drop-and-stop below rather
                    // than spin or emit an unanchored candidate.
                    if next > at {
                        items.push((h, None));
                        at = next;
                        continue;
                    }
                }
                // The contiguous chain broke here (a header that did not frame,
                // or one that framed with a checksum-valid but FAKE size whose
                // payload does not load). Report the unanchored remainder as one
                // dropped region and stop — no resync-and-emit, because a byte
                // scan past this point cannot distinguish an original block
                // start from a nested frame inside a corrupt block's user bytes.
                dropped.push(DroppedBlock {
                    offset: at,
                    section: b"data".to_vec(),
                    reason: DropReason::HeaderCorrupted(
                        "unanchored bytes after a broken block boundary; the tail \
                         cannot be proven to be original block starts"
                            .to_owned(),
                    ),
                    key_range: None,
                });
                return Ok(false);
            }
            Ok(true)
        };
        // Records an indexed handle the walk surrenders because the physical
        // chain broke below its offset: with no proven boundary, a forged index
        // could point it at a checksum-valid frame nested in a corrupt block's
        // value bytes, so its own claimed key range is untrusted too (`None`).
        let drop_unanchored_handle = |off: u64, dropped: &mut Vec<DroppedBlock>| {
            dropped.push(DroppedBlock {
                offset: off,
                section: b"data".to_vec(),
                reason: DropReason::HeaderCorrupted(
                    "indexed block after a broken physical chain has no \
                         authenticated boundary"
                        .to_owned(),
                ),
                key_range: None,
            });
        };
        let mut cursor = section_pos;
        // The tiling below is offset-driven, but the index yields KEY order,
        // which a forged index can decouple from the physical order: an
        // out-of-place handle would be covered twice (once by the gap probe,
        // once by itself), and the duplicate emit would be rejected as an
        // ordering violation — misreporting an intact block as dropped.
        // Offset order equals the writer's key order for the blocks
        // themselves, so re-sorting also keeps the emit order valid.
        indexed.sort_unstable_by_key(|k| *k.as_ref().offset());
        // Whether the index (TLI + its mirror) is TRUSTWORTHY: the mirrors agree,
        // the binary-index pointers authenticate, and the decoded handles TILE
        // their section. A checksum-restamped index that points a handle at a
        // frame nested inside another block's value bytes fails the tiling check
        // (the nested offset overlaps its host block), so a passing verification
        // proves every indexed offset is an ORIGINAL block boundary. With that
        // proof, each indexed offset is its own provenance: a block whose header
        // is corrupt costs only its own keys and the walk recovers the rest by
        // their trusted offsets (block-granular salvage). WITHOUT it, an indexed
        // offset is no more trustworthy than a byte-scanned one — a forged index
        // could point it at a nested frame — so the physical chain is the only
        // provenance and the walk fails closed past the first break.
        // A transient I/O fault while authenticating the index STRUCTURE aborts
        // the walk (propagated up so repair retries) instead of degrading to
        // physical-chain-only provenance: reading `false` here would surrender
        // every indexed block past the first header break, dropping healthy keys
        // the intact TLI could anchor on retry.
        let tli_trusted = table.tli_structure_authenticated()?;
        // Tracks the contiguous physical chain from `section_pos`, consulted only
        // when the index is NOT trusted. Once a boundary breaks there, every
        // later offset is reachable only past unprovable bytes, so it is
        // surrendered rather than trusted.
        let mut chain_anchored = true;
        for keyed in indexed {
            let off = *keyed.as_ref().offset();
            // A handle whose offset is at or beyond the section end points
            // outside the data region (a checksum-repatched / forged index).
            // Probing the gap up to it would scan past the section, potentially
            // to an attacker-controlled u64 (an unbounded hang, and later SST
            // sections framed as data). Skip it; the final gap probe still
            // covers the rest of the section from the cursor.
            if off >= section_end {
                continue;
            }
            // A handle starting inside already-covered bytes (a duplicate or
            // overlapping forge) is skipped: its span was walked physically as
            // part of the anchored prefix.
            if off < cursor {
                continue;
            }
            // Untrusted index only: the chain already broke below this offset,
            // so it has no proven boundary — surrender it (and every one after).
            if !chain_anchored {
                drop_unanchored_handle(off, &mut dropped);
                continue;
            }
            if off > cursor {
                let reached = probe_gap(cursor, off, &mut items, &mut dropped)?;
                if !reached && !tli_trusted {
                    // Untrusted index: the gap up to this handle broke, so `off`
                    // sits past the last proven boundary and is unanchored too.
                    // Surrender it and arm the flag for the rest of the walk.
                    chain_anchored = false;
                    drop_unanchored_handle(off, &mut dropped);
                    continue;
                }
                // The gap tiled cleanly, OR the trusted index proves `off` is an
                // original boundary regardless of the intervening (now dropped)
                // index-omitted bytes. Either way `off` is a boundary.
                cursor = off;
            }
            // Trust the indexed SPAN only after the block's own header
            // confirms it: an oversized forged handle would otherwise
            // advance the cursor past back-to-back blocks the gap walk
            // should discover (the oversized non-ECC frame still decodes
            // its first payload, so nothing later would flag the loss).
            let (handle, end_key) =
                match table.probe_block_handle_in(&*probe_file, off, section_end) {
                    Ok(probed) if probed.size() == keyed.as_ref().size() => {
                        (*keyed.as_ref(), Some(keyed.end_key().clone()))
                    }
                    // The physical frame disagrees: walk the physically framed
                    // block instead (the lying handle's separator is just as
                    // untrusted as its span).
                    Ok(probed) => (probed, None),
                    // An ENVIRONMENTAL read failure is retryable: propagate it
                    // rather than surrender the block (and, when untrusted, the
                    // whole tail) to a permanent drop over a fault that says
                    // nothing about the bytes. A read failure that DOES implicate
                    // them is not fixed by a retry, so it falls through to the
                    // unframeable-header arm below and drops just this block
                    // instead of aborting the whole salvage.
                    Err(crate::Error::Io(io)) if io.kind().is_environmental() => {
                        return Err(crate::Error::Io(io));
                    }
                    // The indexed block's header does not frame, so its size is
                    // UNVERIFIED. With a TRUSTED index the block's offset is still
                    // an original boundary, so leave the cursor here and let the
                    // next handle's gap probe frame from it — it records the
                    // corrupt block as a drop and recovers the intact blocks after
                    // it by their trusted offsets (block-granular). With an
                    // UNTRUSTED index the byte range past this unframeable header
                    // cannot be proven to be original block starts (a nested forge
                    // would frame just as well), so surrender this block and every
                    // offset after it.
                    Err(_) => {
                        if !tli_trusted {
                            chain_anchored = false;
                            drop_unanchored_handle(off, &mut dropped);
                        }
                        continue;
                    }
                };
            // Both surviving arms probed the frame within `section_end`, so the
            // block ends there by construction: `off + size <= section_end`,
            // which cannot overflow a `u64` bounded by the validated section.
            let next = (off + u64::from(handle.size())).min(section_end);
            items.push((handle, end_key));
            cursor = cursor.max(next);
        }
        // The trailing gap is probed only while the chain is still anchored:
        // once it broke, the remaining bytes were already surrendered above.
        if chain_anchored && cursor < section_end {
            probe_gap(cursor, section_end, &mut items, &mut dropped)?;
        }
    } else {
        // Unreadable TOC (no physical data section to tile against): walk
        // exactly what the index enumeration gave. Here a broken enumeration IS
        // data loss: with no physical fallback, the un-enumerated handles are
        // unrecoverable, so record the structural index error as a drop.
        if let Some(reason) = index_enum_error {
            dropped.push(DroppedBlock {
                offset: 0,
                section: b"index".to_vec(),
                reason: DropReason::HeaderCorrupted(reason),
                key_range: None,
            });
        }
        for keyed in indexed {
            let handle = *keyed.as_ref();
            items.push((handle, Some(keyed.end_key().clone())));
        }
    }

    // Drops recorded BEFORE the emit loop (a corrupt index entry and every
    // `probe_gap` region: a header-corrupt block the tiling walk resynced past)
    // never enter `items`, so the per-item increment below would miss them.
    // They were still INSPECTED and lost, so count them in the total: without
    // this a header-corrupt block yields `blocks_total == blocks_salvaged`
    // while a block was dropped, reporting full coverage despite the loss.
    blocks_total += dropped.len();

    // Those same pre-loop drops are the ONLY ones the per-iteration delta below
    // cannot attribute to a position, so they are handled by offset instead:
    // each lost DATA region shadows whichever item follows it in the walk, not
    // the first one. Counting them as a delta would arm the suppression before
    // the table's very first block and drop a key nothing had shadowed. An
    // `index`-section drop names no data region — the handles it lost sit past
    // the last enumerated one, so no item follows them to suppress.
    let mut lost_regions: Vec<u64> = dropped
        .iter()
        .filter(|d| d.section == b"data")
        .map(|d| d.offset)
        .collect();
    lost_regions.sort_unstable();
    let mut lost_regions = lost_regions.into_iter().peekable();
    dropped_seen = dropped.len();

    for (block_handle, end_key) in items {
        // Publish the previous iteration's outcome (top-of-loop so every
        // `continue` path is covered; the totals-vs-published delta form makes
        // the call idempotent per state).
        publish_progress(
            progress,
            &mut published,
            blocks_total,
            blocks_salvaged,
            dropped.len(),
            blocks_healed,
            entries_salvaged,
            columns_salvaged,
        );
        blocks_total += 1;
        // Did the PREVIOUS iteration lose its block? Its OWN end key names the
        // only key this block may hold shadowed entries for. `prev_end` is the
        // running lower bound, which for a lost block whose range was unknown
        // still holds an EARLIER block's key — an unrelated one — so the last
        // block's own separator is what arms this, and its absence arms the
        // unknown boundary that suppresses this block's first key instead.
        if dropped.len() > dropped_seen {
            lost_boundary = Some(prev_block_end.clone());
        }
        dropped_seen = dropped.len();
        let offset = *block_handle.offset();
        // A resynced-past region lying before this block shadows it just as a
        // lost block would, and its own key range is unknown by construction:
        // the gap probe never framed it, so nothing named its keys.
        let mut passed_lost_region = false;
        while lost_regions.next_if(|start| *start < offset).is_some() {
            passed_lost_region = true;
        }
        if passed_lost_region {
            lost_boundary = Some(None);
        }
        // For the NEXT iteration: this block's own separator, or `None` when the
        // walk framed it without one.
        prev_block_end.clone_from(&end_key);

        // Columnar source: a clean block is byte-copied verbatim — preserving its
        // PAX value sub-columns, zone map, and per-row seqnos without the transpose
        // + recompression a re-encode pays — and an ECC-recovered block is
        // re-emitted from its healed `ColumnBatch`. When the SST carries
        // materialized positional deletes, a verbatim copy would resurrect deleted
        // rows (the bitmap is not carried into the recovered SST), so every block
        // is instead re-emitted as a delete-masked batch. Per-block corruption is
        // isolated either way.
        #[cfg(feature = "columnar")]
        if table.metadata.columnar {
            // A delete-bearing SST (it carries a delete-bitmap section) always
            // takes the re-emit path: byte-copying its blocks verbatim would
            // resurrect positionally-deleted rows (the recovered copy carries no
            // bitmap), and a salvage-mode open degrades a corrupt bitmap to empty,
            // so `delete_bitmap().is_empty()` cannot tell "no deletes" from "deletes
            // whose bitmap was lost". A degraded bitmap still recovers all rows
            // live (the documented salvage degradation) — but never via a verbatim
            // copy. Only a genuinely delete-free SST is eligible for copy-through.
            // The MASKED re-emit additionally requires verified positions
            // (`apply_delete_mask`); an unpositionable mask under the explicit
            // resurrection opt-in re-emits every row live via the unmasked arm.
            if table.has_delete_bitmap_section() && apply_delete_mask {
                // An INDEX-OMITTED block (recovered by the physical gap walk,
                // `end_key` unknown) has no verified delete-start position:
                // `delete_positions_verified` walked only the indexed blocks,
                // and the masked load would treat an unmapped batch as all
                // rows live — permanently resurrecting the rows the bitmap
                // marked there, without the resurrection opt-in. Fail closed
                // per block: drop it and report the loss.
                if end_key.is_none() {
                    dropped.push(classify_drop(
                        &crate::Error::InvalidHeader(
                            "delete positions unverifiable for an index-omitted block",
                        ),
                        offset,
                        prev_end.as_ref(),
                        None,
                    ));
                    continue;
                }
                // Re-emit each block as a delete-masked batch so the recovered copy
                // keeps any (readable) deletes applied.
                match table.load_columnar_block_masked(&block_handle) {
                    Ok(Some(batch)) => {
                        // Handle rewrite on the delete-masked path: same
                        // entry round-trip as the unmasked columnar arm (the
                        // mask is already applied, so only surviving rows are
                        // transformed), with the same sub-column fail-closed.
                        let mut rewrite_carry: Option<UserKey> = None;
                        let batch = match blob_rewrite {
                            Some(rw) => {
                                if batch.columns.len() > 4 {
                                    return Err(crate::Error::FeatureUnsupported(
                                        "blob-handle rewrite of a columnar block \
                                         with value sub-columns",
                                    ));
                                }
                                let step = crate::table::columnar::column_batch_to_entries(&batch)
                                    .and_then(|entries| {
                                        rewrite_block_indirections(
                                            entries,
                                            rw,
                                            &mut entries_dropped_by_rewrite,
                                        )
                                    });
                                match step {
                                    Ok((entries, carry)) if entries.is_empty() => {
                                        // Every surviving record removed by the
                                        // rewrite: nothing to emit, nothing lost.
                                        if let Some(key) = carry {
                                            lost_boundary = Some(Some(key));
                                        }
                                        prev_end = end_key.or(prev_end);
                                        continue;
                                    }
                                    Ok((entries, carry)) => {
                                        rewrite_carry = carry;
                                        match crate::table::columnar::entries_to_column_batch(
                                            &entries,
                                        ) {
                                            Ok(batch) => batch,
                                            Err(e) => {
                                                dropped.push(classify_drop(
                                                    &e,
                                                    offset,
                                                    prev_end.as_ref(),
                                                    end_key.as_ref(),
                                                ));
                                                prev_end = end_key.or(prev_end);
                                                continue;
                                            }
                                        }
                                    }
                                    Err(e) => {
                                        dropped.push(classify_drop(
                                            &e,
                                            offset,
                                            prev_end.as_ref(),
                                            end_key.as_ref(),
                                        ));
                                        prev_end = end_key.or(prev_end);
                                        continue;
                                    }
                                }
                            }
                            None => batch,
                        };
                        // Boundary suppression on the delete-masked path too: a
                        // lost block can have held the newest version of the key
                        // this batch opens with, and the mask does not change that.
                        let batch = match lost_boundary.take() {
                            Some(boundary) => {
                                match suppress_columnar_boundary(&batch, boundary.as_ref()) {
                                    Ok(BoundarySuppression::Unchanged) => batch,
                                    Ok(BoundarySuppression::Emptied(key)) => {
                                        lost_boundary = Some(Some(key));
                                        prev_end = end_key.or(prev_end);
                                        continue;
                                    }
                                    Ok(BoundarySuppression::Rebuilt(batch, _)) => batch,
                                    Err(e @ crate::Error::FeatureUnsupported(_)) => {
                                        return Err(e);
                                    }
                                    Err(e) => {
                                        dropped.push(classify_drop(
                                            &e,
                                            offset,
                                            prev_end.as_ref(),
                                            end_key.as_ref(),
                                        ));
                                        prev_end = end_key.or(prev_end);
                                        continue;
                                    }
                                }
                            }
                            None => batch,
                        };
                        if let Some(key) = rewrite_carry {
                            lost_boundary = Some(Some(key));
                        }
                        let rows = u64::from(batch.row_count);
                        // Indirections of the SURVIVING (unmasked) rows,
                        // BEFORE emit: an undecodable indirection is corrupt
                        // content — drop the block, don't launder it.
                        let block_links = match collect_columnar_indirections(&batch) {
                            Ok(links) => links,
                            Err(e) => {
                                dropped.push(classify_drop(
                                    &e,
                                    offset,
                                    prev_end.as_ref(),
                                    end_key.as_ref(),
                                ));
                                prev_end = end_key.or(prev_end);
                                continue;
                            }
                        };
                        // A writer REJECTION (ordering / framing validation,
                        // `InvalidHeader` / `InvalidTag`) is block-local
                        // malformed content — drop the block and keep walking;
                        // destination I/O errors stay hard.
                        match writer.write_columnar_block_verbatim(&batch, comparator) {
                            Ok(_) => {
                                entries_salvaged += rows;
                                blocks_salvaged += 1;
                                columns_salvaged += batch.columns.len() as u64;
                                fold_blob_links(&mut derived_blob_links, &block_links);
                            }
                            Err(
                                e @ (crate::Error::InvalidHeader(_) | crate::Error::InvalidTag(_)),
                            ) => {
                                dropped.push(classify_drop(
                                    &e,
                                    offset,
                                    prev_end.as_ref(),
                                    end_key.as_ref(),
                                ));
                            }
                            Err(e) => return Err(e),
                        }
                    }
                    // Wholly-deleted block: nothing to recover, nothing lost.
                    Ok(None) => {}
                    // An ENVIRONMENTAL read (a masked block re-read after cache
                    // eviction, or an access failure that does not implicate the
                    // bytes) propagates so repair retries; a failure that DOES
                    // implicate them, or a structural one, drops just this block,
                    // mirroring the unmasked salvage_load_block arm — a truncated
                    // or unreadable block must not sink every intact sibling.
                    Err(crate::Error::Io(io)) if io.kind().is_environmental() => {
                        return Err(crate::Error::Io(io));
                    }
                    Err(e) => dropped.push(classify_drop(
                        &e,
                        offset,
                        prev_end.as_ref(),
                        end_key.as_ref(),
                    )),
                }
            } else {
                match table
                    .salvage_load_block(&block_handle, crate::table::block::BlockType::Columnar)
                {
                    // Row materialization validates the batch content (framing,
                    // value-type tags, key invariants) beyond the outer frame
                    // decode. A checksum-consistent block that fails EITHER step
                    // is malformed content — drop this one block and keep
                    // walking, exactly like a row-major block whose entries fail
                    // to decode. Only writer errors (I/O to the destination)
                    // stay hard errors.
                    Ok(mut sb) => {
                        let ecc_healed = sb.ecc_recovered;
                        if !allow_verbatim {
                            sb.verbatim = None;
                        }
                        match crate::table::columnar::ColumnBatch::decode(&sb.block.data).and_then(
                            |batch| {
                                crate::table::columnar::column_batch_to_entries(&batch)
                                    .map(|entries| (batch, entries))
                            },
                        ) {
                            // A real writer never emits an empty data block, so a
                            // checksum-clean ZERO-ROW batch is malformed input:
                            // the writer primitives below would emit NOTHING for
                            // it, and counting it as salvaged would misreport an
                            // unrecovered block (an SST whose only block is empty
                            // would even report a salvaged path that the
                            // empty-table finish just removed).
                            Ok((batch, _)) if batch.row_count == 0 => {
                                dropped.push(classify_drop(
                                    &crate::Error::InvalidHeader("columnar: zero-row data block"),
                                    offset,
                                    prev_end.as_ref(),
                                    end_key.as_ref(),
                                ));
                            }
                            Ok((batch, entries)) => {
                                // Apply the blob-handle rewrite by round-tripping
                                // through the row entries, then rebuilding the
                                // batch, so the emitted block carries the NEW
                                // handles. A batch with value SUB-COLUMNS cannot
                                // round-trip (they are not reconstructible from
                                // entries) — a KV-separated columnar flush never
                                // writes them (indirections live in the opaque
                                // value column), so fail closed on the exotic
                                // combination instead of silently dropping data.
                                // A chain the rewrite beheads at this block's tail
                                // continues into the next block, so its key is
                                // handed on once this block's own suppression has
                                // been applied.
                                let mut rewrite_carry: Option<UserKey> = None;
                                let (batch, entries) = match blob_rewrite {
                                    Some(rw) => {
                                        if batch.columns.len() > 4 {
                                            return Err(crate::Error::FeatureUnsupported(
                                                "blob-handle rewrite of a columnar block \
                                                 with value sub-columns",
                                            ));
                                        }
                                        let entries = match rewrite_block_indirections(
                                            entries,
                                            rw,
                                            &mut entries_dropped_by_rewrite,
                                        ) {
                                            Ok((entries, carry)) => {
                                                rewrite_carry = carry;
                                                entries
                                            }
                                            Err(e) => {
                                                dropped.push(classify_drop(
                                                    &e,
                                                    offset,
                                                    prev_end.as_ref(),
                                                    end_key.as_ref(),
                                                ));
                                                prev_end = end_key.or(prev_end);
                                                continue;
                                            }
                                        };
                                        if entries.is_empty() {
                                            // Every record removed by the rewrite:
                                            // nothing to emit, nothing lost.
                                            if let Some(key) = rewrite_carry {
                                                lost_boundary = Some(Some(key));
                                            }
                                            prev_end = end_key.or(prev_end);
                                            continue;
                                        }
                                        match crate::table::columnar::entries_to_column_batch(
                                            &entries,
                                        ) {
                                            Ok(batch) => (batch, entries),
                                            Err(e) => {
                                                dropped.push(classify_drop(
                                                    &e,
                                                    offset,
                                                    prev_end.as_ref(),
                                                    end_key.as_ref(),
                                                ));
                                                prev_end = end_key.or(prev_end);
                                                continue;
                                            }
                                        }
                                    }
                                    None => (batch, entries),
                                };
                                // Drop the entries a LOST block may have shadowed,
                                // exactly as the row branch does below: versions of
                                // one key are contiguous, so the key at this
                                // boundary can have had newer versions inside the
                                // block that went missing.
                                let mut rebuilt_by_suppression = false;
                                let (batch, entries) = match lost_boundary.take() {
                                    Some(boundary) => {
                                        match suppress_columnar_boundary(&batch, boundary.as_ref())
                                        {
                                            Ok(BoundarySuppression::Unchanged) => (batch, entries),
                                            Ok(BoundarySuppression::Emptied(key)) => {
                                                lost_boundary = Some(Some(key));
                                                prev_end = end_key.or(prev_end);
                                                continue;
                                            }
                                            Ok(BoundarySuppression::Rebuilt(batch, kept)) => {
                                                rebuilt_by_suppression = true;
                                                (batch, kept)
                                            }
                                            Err(e @ crate::Error::FeatureUnsupported(_)) => {
                                                return Err(e);
                                            }
                                            Err(e) => {
                                                dropped.push(classify_drop(
                                                    &e,
                                                    offset,
                                                    prev_end.as_ref(),
                                                    end_key.as_ref(),
                                                ));
                                                prev_end = end_key.or(prev_end);
                                                continue;
                                            }
                                        }
                                    }
                                    None => (batch, entries),
                                };
                                if let Some(key) = rewrite_carry {
                                    lost_boundary = Some(Some(key));
                                }
                                let rows = u64::from(batch.row_count);
                                // Indirections BEFORE emit: an entry tagged as an
                                // indirection whose value fails to decode is
                                // corrupt content — drop the block rather than
                                // laundering it into the copy.
                                let block_links = match collect_indirections(&entries) {
                                    Ok(links) => links,
                                    Err(e) => {
                                        dropped.push(classify_drop(
                                            &e,
                                            offset,
                                            prev_end.as_ref(),
                                            end_key.as_ref(),
                                        ));
                                        prev_end = end_key.or(prev_end);
                                        continue;
                                    }
                                };
                                // A delete-bearing SST is never byte-copied, even
                                // on this unmasked (resurrection opt-in) arm: the
                                // re-encode keeps the recovered copy's layout
                                // consistent with the degraded-bitmap path.
                                // A suppressed boundary key rebuilt the batch, so the
                                // block's raw bytes no longer describe it — re-encode.
                                let verbatim_source = if table.has_delete_bitmap_section()
                                    || rebuilt_by_suppression
                                {
                                    None
                                } else {
                                    sb.verbatim
                                };
                                // A writer REJECTION (ordering / framing validation)
                                // is block-local malformed content — drop the block
                                // and keep walking; destination I/O errors stay hard.
                                let emitted = match verbatim_source {
                                    // Clean: copy the block's raw bytes as-is,
                                    // carrying the block's per-column zone-map
                                    // stats (this is the columnar path, so the
                                    // synthetic row-block stat would be rejected
                                    // by the copy's own `verify_zone_map`).
                                    Some((raw, header, layout)) => writer
                                        .append_verbatim_data_block(
                                            &raw,
                                            header,
                                            layout,
                                            &entries,
                                            Some(batch.zone_stats()),
                                            comparator,
                                        )
                                        .map(|_| true),
                                    // ECC-recovered (or delete-bearing): re-encode the
                                    // batch so the recovered copy carries clean bytes.
                                    None => writer
                                        .write_columnar_block_verbatim(&batch, comparator)
                                        .map(|_| false),
                                };
                                match emitted {
                                    Ok(verbatim) => {
                                        if verbatim {
                                            blocks_copied_verbatim += 1;
                                        }
                                        if ecc_healed {
                                            blocks_healed += 1;
                                        }
                                        entries_salvaged += rows;
                                        blocks_salvaged += 1;
                                        columns_salvaged += batch.columns.len() as u64;
                                        fold_blob_links(&mut derived_blob_links, &block_links);
                                    }
                                    Err(
                                        e @ (crate::Error::InvalidHeader(_)
                                        | crate::Error::InvalidTag(_)),
                                    ) => {
                                        dropped.push(classify_drop(
                                            &e,
                                            offset,
                                            prev_end.as_ref(),
                                            end_key.as_ref(),
                                        ));
                                    }
                                    Err(e) => return Err(e),
                                }
                            }
                            // An ENVIRONMENTAL I/O read is retryable and propagates,
                            // so a partial columnar table is not published with
                            // healthy rows permanently lost. A failure that
                            // implicates the bytes, or a structural one, drops just
                            // this block (truncation / an unreadable block must not
                            // sink its intact siblings).
                            Err(crate::Error::Io(io)) if io.kind().is_environmental() => {
                                return Err(crate::Error::Io(io));
                            }
                            Err(e) => {
                                dropped.push(classify_drop(
                                    &e,
                                    offset,
                                    prev_end.as_ref(),
                                    end_key.as_ref(),
                                ));
                            }
                        }
                    }
                    // Same environmental-only I/O propagation for the
                    // delete-masked arm.
                    Err(crate::Error::Io(io)) if io.kind().is_environmental() => {
                        return Err(crate::Error::Io(io));
                    }
                    Err(e) => dropped.push(classify_drop(
                        &e,
                        offset,
                        prev_end.as_ref(),
                        end_key.as_ref(),
                    )),
                }
            }
            prev_end = end_key.or(prev_end);
            continue;
        }

        // Row source: a clean block is byte-copied verbatim; an ECC-recovered block
        // is re-emitted entry by entry from its healed payload.
        match table.salvage_load_block(&block_handle, crate::table::block::BlockType::Data) {
            Ok(mut sb) => {
                let ecc_healed = sb.ecc_recovered;
                if !allow_verbatim {
                    sb.verbatim = None;
                }
                // Footer presence is a per-SST property (`kv_checksum_algo`), not a
                // per-block header flag, so the descriptor supplies it here.
                let has_kv_footer = table.metadata.kv_checksum_algo.is_some();
                // Verify the per-KV digests BEFORE stripping the footer: a
                // block-checksum-re-stamped entry whose stored digest no
                // longer matches its bytes would otherwise be recovered (even
                // byte-copied verbatim) into a copy the live per-KV scrub
                // rejects — laundering the corruption. A mismatch is
                // block-local malformed content: drop the block, keep walking.
                if has_kv_footer
                    && let Err(e) = crate::table::DataBlock::verify_kv_checked(
                        &sb.block.data,
                        sb.block.header,
                        comparator.clone(),
                        table.metadata.kv_checksum_algo,
                    )
                {
                    dropped.push(classify_drop(
                        &e,
                        offset,
                        prev_end.as_ref(),
                        end_key.as_ref(),
                    ));
                    prev_end = end_key.or(prev_end);
                    continue;
                }
                match crate::table::DataBlock::from_loaded(sb.block, has_kv_footer) {
                    // `try_iter`, not `iter`: a checksum-clean but structurally
                    // malformed block (e.g. an invalid trailer) must be reported as
                    // a dropped `DecodeError`, never panic the salvage walk.
                    Ok(data_block) => match data_block.try_iter(comparator.clone()) {
                        Ok(iter) => {
                            let entries: Vec<crate::InternalValue> =
                                iter.map(|p| p.materialize(data_block.as_slice())).collect();
                            // A real writer never emits an empty data block, so
                            // checksum-clean ZERO entries are malformed input:
                            // the emit below would write nothing, and counting
                            // the block as salvaged would misreport it (see the
                            // columnar zero-row arm above).
                            if entries.is_empty() {
                                dropped.push(classify_drop(
                                    &crate::Error::InvalidHeader(
                                        "row block decodes to zero entries",
                                    ),
                                    offset,
                                    prev_end.as_ref(),
                                    end_key.as_ref(),
                                ));
                                prev_end = end_key.or(prev_end);
                                continue;
                            }
                            // The entry decoder turns a mid-stream parse
                            // failure into an ordinary end of iteration, so a
                            // checksum-clean block with a valid prefix and a
                            // malformed tail yields FEWER entries than its
                            // trailer declares. Accepting the prefix would
                            // silently lose the remaining keys (or byte-copy
                            // the still-malformed block verbatim) — drop the
                            // block instead.
                            if entries.len() != data_block.len() {
                                dropped.push(classify_drop(
                                    &crate::Error::InvalidHeader(
                                        "row block iterates to fewer entries than its \
                                         trailer declares",
                                    ),
                                    offset,
                                    prev_end.as_ref(),
                                    end_key.as_ref(),
                                ));
                                prev_end = end_key.or(prev_end);
                                continue;
                            }
                            // Apply the blob-handle rewrite BEFORE deriving
                            // links or emitting: removed entries must not
                            // count as salvaged, and the derived links must
                            // reflect the NEW handles. An undecodable
                            // indirection is corrupt content — drop the block,
                            // like the link derivation below.
                            let (entries, rewrite_carry) = match blob_rewrite {
                                Some(rw) => match rewrite_block_indirections(
                                    entries,
                                    rw,
                                    &mut entries_dropped_by_rewrite,
                                ) {
                                    Ok(pair) => pair,
                                    Err(e) => {
                                        dropped.push(classify_drop(
                                            &e,
                                            offset,
                                            prev_end.as_ref(),
                                            end_key.as_ref(),
                                        ));
                                        prev_end = end_key.or(prev_end);
                                        continue;
                                    }
                                },
                                None => (entries, None),
                            };
                            // Drop the entries a LOST block may have shadowed:
                            // versions of one key are contiguous, so the key at
                            // this boundary can have had newer versions inside
                            // the block that went missing, and emitting the
                            // older ones would republish them as current.
                            let entries = match lost_boundary.take() {
                                Some(boundary) => {
                                    let before = entries.len();
                                    let (kept, carry) =
                                        suppress_shadowed_boundary(entries, boundary.as_ref());
                                    // The run has not ended yet: keep suppressing
                                    // into the next block.
                                    if let Some(key) = carry {
                                        lost_boundary = Some(Some(key));
                                    }
                                    if kept.len() != before {
                                        // The block no longer contains what its
                                        // bytes say: a verbatim copy would carry
                                        // the suppressed key straight through,
                                        // so re-emit the survivors row by row.
                                        sb.verbatim = None;
                                    }
                                    kept
                                }
                                None => entries,
                            };
                            // A chain the REWRITE beheaded at this block's tail
                            // continues into the next one, so hand it the key.
                            if let Some(key) = rewrite_carry {
                                lost_boundary = Some(Some(key));
                            }
                            if entries.is_empty() {
                                // Every entry's record was removed by the
                                // rewrite, or every one was shadowed by a lost
                                // block: the block legitimately contributes
                                // nothing (accounted per entry, not a loss).
                                prev_end = end_key.or(prev_end);
                                continue;
                            }
                            let count = entries.len() as u64;
                            // Indirections BEFORE emit: an entry tagged as an
                            // indirection whose value fails to decode is
                            // corrupt content — drop the block rather than
                            // laundering it into the copy.
                            let block_links = match collect_indirections(&entries) {
                                Ok(links) => links,
                                Err(e) => {
                                    dropped.push(classify_drop(
                                        &e,
                                        offset,
                                        prev_end.as_ref(),
                                        end_key.as_ref(),
                                    ));
                                    prev_end = end_key.or(prev_end);
                                    continue;
                                }
                            };
                            // Ordering guard for BOTH emit paths: the verbatim
                            // append validates internally, but the row-by-row
                            // re-emit (`writer.write`) trusts its input, so a
                            // tampered checksum-repatched block must be caught
                            // here. A validation rejection is block-local
                            // malformed content — drop the block and keep
                            // walking; destination I/O errors stay hard.
                            let emitted = writer
                                .validate_direct_block_order(&entries, comparator)
                                .and_then(|()| {
                                    if let Some((raw, header, layout)) = sb.verbatim {
                                        writer
                                            .append_verbatim_data_block(
                                                &raw, header, layout, &entries, None, comparator,
                                            )
                                            .map(|_| true)
                                    } else {
                                        for e in entries {
                                            writer.write(e)?;
                                        }
                                        Ok(false)
                                    }
                                });
                            match emitted {
                                Ok(verbatim) => {
                                    if verbatim {
                                        blocks_copied_verbatim += 1;
                                    }
                                    if ecc_healed {
                                        blocks_healed += 1;
                                    }
                                    entries_salvaged += count;
                                    blocks_salvaged += 1;
                                    fold_blob_links(&mut derived_blob_links, &block_links);
                                }
                                Err(
                                    e @ (crate::Error::InvalidHeader(_)
                                    | crate::Error::InvalidTag(_)),
                                ) => {
                                    dropped.push(classify_drop(
                                        &e,
                                        offset,
                                        prev_end.as_ref(),
                                        end_key.as_ref(),
                                    ));
                                }
                                Err(e) => return Err(e),
                            }
                        }
                        Err(e) => dropped.push(DroppedBlock {
                            offset,
                            section: b"data".to_vec(),
                            reason: DropReason::DecodeError(format!("{e:?}")),
                            key_range: end_key.as_ref().map(|ek| {
                                (prev_end.clone().unwrap_or_else(UserKey::empty), ek.clone())
                            }),
                        }),
                    },
                    Err(e) => dropped.push(DroppedBlock {
                        offset,
                        section: b"data".to_vec(),
                        reason: DropReason::DecodeError(format!("{e:?}")),
                        key_range: end_key.as_ref().map(|ek| {
                            (prev_end.clone().unwrap_or_else(UserKey::empty), ek.clone())
                        }),
                    }),
                }
            }
            // An ENVIRONMENTAL I/O error on the block read aborts: dropping the
            // block and finishing dest would let repair install a partial
            // replacement missing keys the fixed environment still holds, and
            // then remove the source — permanent loss from an ACL mistake or
            // host pressure. That covers the transient kinds AND the access
            // failures that do not implicate the bytes (`PermissionDenied`,
            // `OutOfMemory`, …), the same class the blob walk and every repair
            // gate use. A failure that DOES implicate the bytes (a bad-sector
            // `Other` / EIO) or a truncated final block (`UnexpectedEof`) is not
            // fixed by a retry, so it drops just this block — a
            // persistently-unreadable tail block must not prevent every intact
            // earlier block from being recovered.
            Err(crate::Error::Io(io)) if io.kind().is_environmental() => {
                return Err(crate::Error::Io(io));
            }
            Err(e) => dropped.push(classify_drop(
                &e,
                offset,
                prev_end.as_ref(),
                end_key.as_ref(),
            )),
        }
        prev_end = end_key.or(prev_end);
    }
    // Final publish: the last iteration's outcome (the loop publishes at the
    // TOP, so the tail is otherwise unaccounted).
    publish_progress(
        progress,
        &mut published,
        blocks_total,
        blocks_salvaged,
        dropped.len(),
        blocks_healed,
        entries_salvaged,
        columns_salvaged,
    );

    let wrote = blocks_salvaged > 0;
    if wrote {
        // Blob links: EXACTLY the derived map. A dropped block's indirections
        // do not exist in the copy, so no id beyond the recovered rows can be
        // needed — and copying a source-only id would let a forged record
        // plant a reference to a blob that exists nowhere.
        let mut links: Vec<crate::table::writer::LinkedFile> =
            derived_blob_links.into_values().collect();
        // Deterministic section order regardless of hash-map iteration.
        links.sort_unstable_by_key(|l| l.blob_file_id);
        for link in links {
            writer.link_blob_file(link.blob_file_id, link.len, link.bytes, link.on_disk_bytes);
        }
        writer.finish()?;
    } else {
        drop(writer);
    }

    Ok(SalvageWalk {
        blocks_total,
        blocks_salvaged,
        blocks_copied_verbatim,
        entries_salvaged,
        entries_dropped_by_rewrite,
        dropped,
        wrote,
    })
}

/// Why a blob (vlog) record could not be salvaged.
#[derive(Debug, Clone)]
pub enum BlobDropReason {
    /// The record's stored checksum did not match its key + value bytes
    /// (bit-rot). The walk re-syncs at the next record, so only this record is
    /// lost.
    ChecksumMismatch,
    /// A structural failure (bad frame magic, header CRC, or a frame that runs
    /// past the data section) that desynchronizes the record stream: the walk
    /// cannot locate later records and stops at this point.
    Corrupt(String),
}

/// A blob record the salvage walk could not recover.
#[derive(Debug, Clone)]
pub struct DroppedBlob {
    /// Why the record was dropped.
    pub reason: BlobDropReason,
}

/// The outcome of salvaging a single blob (vlog) file.
///
/// Inspect [`is_complete`](BlobSalvageReport::is_complete) to tell a clean
/// recovery (every record re-emitted) from a lossy one; [`dropped`] lists what
/// was lost. Always check [`salvaged_path`] before using the recovered copy.
///
/// [`dropped`]: BlobSalvageReport::dropped
/// [`salvaged_path`]: BlobSalvageReport::salvaged_path
#[derive(Debug)]
pub struct BlobSalvageReport {
    /// Path of the freshly written salvaged blob file, or `None` when no record
    /// was recoverable and nothing was written.
    pub salvaged_path: Option<PathBuf>,
    /// Total records the walk inspected (recovered plus dropped).
    pub records_total: usize,
    /// Records successfully re-emitted into the salvaged blob file.
    pub records_salvaged: usize,
    /// `(source_offset, relocation)` for every re-emitted record, in walk
    /// order. The salvaged file is written COMPACTED — after the first dropped
    /// record every later record lands at a NEW offset, and a compressed
    /// record is re-compressed, so its on-disk size may change too — so
    /// existing SST entries whose `ValueHandle`
    /// points into the source file must be rewritten through this table
    /// (BOTH fields; a live read cross-checks the handle against the frame
    /// header) before the salvaged file can replace the original under the
    /// same id. A source offset absent from this map (and implied by
    /// [`Self::dropped`]) is lost: its handle has no target.
    pub offset_remap: Vec<(u64, BlobRecordRelocation)>,
    /// Records the walk had to drop.
    pub dropped: Vec<DroppedBlob>,
}

impl BlobSalvageReport {
    /// Returns `true` when no record had to be dropped.
    #[must_use]
    pub fn is_complete(&self) -> bool {
        self.dropped.is_empty()
    }
}

/// Where one blob record landed in the salvaged replacement file.
///
/// Carried per source offset in [`BlobSalvageReport::offset_remap`]. Both
/// fields must be installed into a referencing SST's rewritten
/// `ValueHandle`: the compacted rewrite moves the
/// record, and the decompress + re-compress re-emit may change its on-disk
/// size (compressor output is not stable across versions or implementations),
/// while a live read cross-checks the handle's size against the frame header
/// and rejects a mismatch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BlobRecordRelocation {
    /// Offset of the re-emitted record in the salvaged file.
    pub offset: u64,
    /// On-disk size of the re-emitted (possibly re-compressed) value.
    pub on_disk_size: u32,
}

/// Decompresses one blob record's on-disk bytes, mirroring the live reader's
/// per-compression dispatch: the frame checksum covered the ON-DISK bytes, so
/// a clean-checksum record must additionally prove its content round-trips —
/// the salvage re-emit and repair's frame validation both gate on this.
/// `None` verifies the length equality the uncompressed format guarantees.
/// Dictionary compression decodes only when the caller supplies the matching
/// dictionary (repair has the tree's config); the standalone salvage entry
/// passes none and rejects dictionary sources up front.
// The lifetime is only elidable when the dictionary parameter is compiled
// out: with it there are two input references and elision cannot tie the
// output to the right one, so the name has to stay.
#[cfg_attr(
    not(zstd_any),
    expect(
        clippy::elidable_lifetime_names,
        reason = "named to stay valid in the zstd build, where a second reference parameter exists"
    )
)]
pub(crate) fn decompress_blob_value<'a>(
    compression: crate::CompressionType,
    on_disk: &'a [u8],
    real_len: usize,
    #[cfg(zstd_any)] zstd_dictionary: Option<&crate::compression::ZstdDictionary>,
) -> crate::Result<alloc::borrow::Cow<'a, [u8]>> {
    match compression {
        crate::CompressionType::None => {
            if on_disk.len() != real_len {
                return Err(crate::Error::InvalidHeader("Blob"));
            }
            Ok(alloc::borrow::Cow::Borrowed(on_disk))
        }
        #[cfg(feature = "lz4")]
        crate::CompressionType::Lz4 => {
            let mut buf = alloc::vec![0u8; real_len];
            let written = lz4_flex::block::decompress_into(on_disk, &mut buf)
                .map_err(|_| crate::Error::Decompress(compression))?;
            if written != real_len {
                return Err(crate::Error::Decompress(compression));
            }
            Ok(alloc::borrow::Cow::Owned(buf))
        }
        #[cfg(zstd_any)]
        crate::CompressionType::Zstd(_) => {
            use crate::compression::CompressionProvider as _;
            let decompressed = crate::compression::ZstdBackend::decompress(on_disk, real_len)
                .map_err(|_| crate::Error::Decompress(compression))?;
            if decompressed.len() != real_len {
                return Err(crate::Error::Decompress(compression));
            }
            Ok(alloc::borrow::Cow::Owned(decompressed))
        }
        #[cfg(zstd_any)]
        crate::CompressionType::ZstdDict { dict_id, .. } => {
            use crate::compression::CompressionProvider as _;
            let Some(dict) = zstd_dictionary else {
                return Err(crate::Error::FeatureUnsupported(
                    "salvage of a dictionary-compressed blob file",
                ));
            };
            if dict.id() != dict_id {
                return Err(crate::Error::ZstdDictMismatch {
                    expected: dict_id,
                    got: Some(dict.id()),
                });
            }
            let decompressed =
                crate::compression::ZstdBackend::decompress_with_dict(on_disk, dict, real_len)
                    .map_err(|_| crate::Error::Decompress(compression))?;
            if decompressed.len() != real_len {
                return Err(crate::Error::Decompress(compression));
            }
            Ok(alloc::borrow::Cow::Owned(decompressed))
        }
    }
}

/// Whether `entry`'s internal key sorts BEFORE `prev` under the blob-file order
/// (ascending user key, ties broken by DESCENDING seqno, newest first). A `None`
/// `prev` (the first accepted record) never regresses. The user-key ordering uses
/// the tree's `comparator`, not raw bytes: a KV-separated tree may be configured
/// with a reverse or otherwise non-lexicographic comparator, and a valid blob
/// file from such a tree must not be judged as regressing.
pub(crate) fn blob_key_regresses(
    comparator: &crate::comparator::SharedComparator,
    prev: Option<&(crate::UserKey, crate::SeqNo)>,
    entry: &crate::vlog::blob_file::scanner::ScanEntry,
) -> bool {
    let Some((prev_key, prev_seqno)) = prev else {
        return false;
    };
    match comparator.compare(entry.key.as_ref(), prev_key.as_ref()) {
        core::cmp::Ordering::Less => true,
        // Same user key: newest-first means the higher seqno comes first, so a
        // seqno ABOVE the previous one at an equal key is out of order.
        core::cmp::Ordering::Equal => entry.seqno > *prev_seqno,
        core::cmp::Ordering::Greater => false,
    }
}

/// Salvages the readable records of the blob (vlog) file at `source` into a fresh
/// blob file at `dest`.
///
/// Where [`crate::repair`] rebuilds the blob-file *manifest* around whole files,
/// this walks one blob file record by record and re-emits every record whose
/// checksum verifies, recording the rest. Corruption that leaves the frame
/// header intact (a checksum mismatch, or a header-CRC / structural break) makes
/// the record stream re-synchronize at the next frame magic. That magic (and
/// every frame chained after it) has an UNPROVEN boundary (it may be nested in
/// the damaged frame's user bytes), so the walk drops the ENTIRE tail past the
/// first resync (fail closed): the conservative loss is more than one record
/// (as much as everything after the first damage), because a fabricated chain of
/// checksum-valid frames is byte-for-byte indistinguishable from genuine ones
/// and re-emitting it would forge records. Only the records BEFORE the first
/// resync are re-emitted; a genuine truncation (a frame running past the data
/// section) still terminates the walk cleanly.
///
/// `blob_file_id` is the source's id (its file name), recorded in the recovered
/// file's metadata. The recovered file PRESERVES the source's value-compression
/// descriptor: each surviving value is decompressed (validating the payload)
/// and re-emitted through the writer under the same codec, so LZ4 and Zstd
/// sources salvage in full. A dictionary-compressed source additionally needs
/// its dictionary via `zstd_dictionary` — without it the values cannot be
/// decoded, and the salvage is rejected with [`Error::FeatureUnsupported`]
/// rather than guessing (the repair caller passes the tree's configured
/// dictionary automatically).
///
/// The salvaged file is written COMPACTED: after the first dropped record,
/// every later record lands at a new offset, so it is **not a drop-in
/// replacement** for the source while SST entries still hold
/// `ValueHandle::offset` values into it. Re-target those handles through
/// [`BlobSalvageReport::offset_remap`] first; a source offset absent from the
/// map is a lost record.
///
/// `live_data_start` is the source's tight-space live-data frontier (`0` for a
/// whole, unreclaimed file): the walk starts there, so a punched prefix — which
/// reads as zeros and would otherwise rot the first header, arm the sticky
/// resync taint, and surrender the entire LIVE suffix — is never inspected.
/// Records below the frontier are dead by definition (their relocated copies
/// live elsewhere), so skipping them loses nothing.
///
/// [`Error::FeatureUnsupported`]: crate::Error::FeatureUnsupported
///
/// # Errors
///
/// Returns an error when `source` cannot be opened at all (its metadata / SFA
/// trailer is unreadable), when it is dictionary-compressed and no matching
/// dictionary was supplied, or when writing `dest` fails. Per-record
/// corruption is not an error: such records are dropped and listed in the
/// returned [`BlobSalvageReport`].
pub fn salvage_blob_file(
    source: &std::path::Path,
    dest: std::path::PathBuf,
    fs: &alloc::sync::Arc<dyn crate::fs::Fs>,
    blob_file_id: crate::vlog::BlobFileId,
    comparator: &crate::comparator::SharedComparator,
    live_data_start: u64,
    #[cfg(zstd_any)] zstd_dictionary: Option<&alloc::sync::Arc<crate::compression::ZstdDictionary>>,
) -> crate::Result<BlobSalvageReport> {
    use crate::vlog::blob_file::{scanner::Scanner, writer::Writer as BlobWriter};
    use alloc::format;

    // Read the source's metadata (only the TOC + meta section, NOT the data
    // section, so a data-corrupt file still opens) for its compression type:
    // the scanner yields on-disk bytes, so a compressed record is DECOMPRESSED
    // here (proving it round-trips) and re-emitted through a writer stamped
    // with the same compression — never copied through verbatim under a
    // mismatched descriptor. A DICTIONARY-compressed source decodes only when
    // the caller supplies the matching dictionary (manifest repair passes the
    // tree's configured one); without it this entry fails closed, the same
    // way SST salvage fails closed on range tombstones.
    //
    // A placeholder checksum is passed on purpose: `recover_blob_file` only STORES
    // it (it never verifies the source against it), and only `compression()` is
    // read from the handle. Computing the real whole-file digest here would stream
    // every byte — including a persistently unreadable later frame or truncated
    // tail sector — and abort before the scanner and writer are even created,
    // making the valid-prefix recovery below unreachable. The salvaged dest gets
    // its own digest on finish.
    let source_handle =
        crate::vlog::recover_blob_file(source, blob_file_id, crate::Checksum::from_raw(0), 0, fs)?;
    let compression = source_handle.compression();
    #[cfg(zstd_any)]
    if let crate::CompressionType::ZstdDict { dict_id, .. } = compression {
        let Some(dict) = zstd_dictionary else {
            // The SAME error a wrong dictionary raises, with `got: None`: both
            // are the caller supplying the wrong recovery context, and both
            // must reach the operator unchanged instead of grading the intact
            // file as damaged.
            return Err(crate::Error::ZstdDictMismatch {
                expected: dict_id,
                got: None,
            });
        };
        // Validate the supplied dictionary against the persisted descriptor
        // BEFORE the record walk: a mismatched dictionary fails every frame's
        // decompress, and the walk's catch-all would record each as a Corrupt
        // drop — a "successful" salvage of zero records that discards a fully
        // intact file. Fail closed up front instead.
        if dict.id() != dict_id {
            return Err(crate::Error::ZstdDictMismatch {
                expected: dict_id,
                got: Some(dict.id()),
            });
        }
    }

    let scanner = if live_data_start > 0 {
        Scanner::resume(source, &**fs, blob_file_id, live_data_start)?
    } else {
        Scanner::new(source, &**fs, blob_file_id)?
    };
    // Destination ownership is decided by the writer's `create_new` open, and
    // the CONSTRUCTOR owns cleanup of any partial file it created: on a
    // constructor error this call created nothing (or the constructor already
    // removed it), so no caller-side cleanup — an existence pre-check here
    // would race a concurrent creator (TOCTOU) and delete a file this salvage
    // does not own. Later `write` / `finish` failures still clean up below:
    // by then `create_new` has proven `dest` is ours.
    // Blob salvage is a rare recovery operation, so sync at the strongest
    // durability: the writer fsyncs the file's bytes and the parent directory is
    // synced below, so the recovered file survives a power loss the moment the
    // report claims success.
    let sync_mode = crate::fs::SyncMode::Full;
    let mut writer = BlobWriter::new(&dest, blob_file_id, 0, &**fs)?
        .use_sync_mode(sync_mode)
        .use_compression(compression);
    #[cfg(zstd_any)]
    {
        // The re-emit re-compresses under the source's descriptor; a
        // dictionary descriptor needs the dictionary on the write side too.
        writer = writer.use_zstd_dictionary(zstd_dictionary.cloned());
    }

    let mut records_total = 0usize;
    let mut records_salvaged = 0usize;
    let mut offset_remap: Vec<(u64, BlobRecordRelocation)> = Vec::new();
    let mut dropped: Vec<DroppedBlob> = Vec::new();
    // The internal key of the last record accepted for re-emit, to enforce the
    // `BlobWriter` sorted-input contract on the salvaged file (see the accept
    // arm). Blob files order by user key ascending, ties broken by DESCENDING
    // seqno (newest first).
    let mut prev_written: Option<(crate::UserKey, crate::SeqNo)> = None;
    // Emit every recoverable record. A `write` failure here (not a per-record
    // checksum/corruption drop, which the match arms absorb) is a hard error: it
    // leaves a partial `dest`, removed on the error path below the same way the
    // SST salvage path removes its partial output.
    let walk = (|| -> crate::Result<()> {
        for item in scanner {
            records_total += 1;
            match item {
                // A frame the scanner reached at or after a byte-wise RESYNC has
                // an UNPROVEN boundary: the resync magic may be an original frame
                // boundary OR a checksum-valid `BLO4` frame nested inside the
                // damaged frame's user-controlled value bytes, and every frame
                // CHAINED past it inherits that unanchored start; the two are
                // byte-for-byte indistinguishable. The taint is sticky, so this
                // arm drops the ENTIRE tail after the first resync: re-emitting
                // any of it would FABRICATE records (and bogus `offset_remap`
                // entries pointing inside the damaged region), which is worse
                // than losing the tail (fail closed on unprovable provenance).
                Ok(entry) if entry.resynced => {
                    // The taint is STICKY: every frame from here to EOF inherits the
                    // same unprovable boundary, so the whole tail is surrendered.
                    // Record the loss ONCE and STOP the walk. Continuing would
                    // re-drop each tainted frame (wasted work plus an allocation per
                    // record) and, worse, keep reading the already-surrendered tail,
                    // where a TRANSIENT read fault would reach the transient-error
                    // arm and abort the whole salvage, discarding the valid prefix
                    // output that needed none of those bytes.
                    let _ = entry;
                    dropped.push(DroppedBlob {
                        reason: BlobDropReason::Corrupt(
                            "tail surrendered at the first resync: every frame past a \
                             damaged frame has an unprovable boundary, dropped as one"
                                .to_string(),
                        ),
                    });
                    break;
                }
                // A frame whose CRCs are internally consistent but whose
                // key_len is ZERO is malformed input (the writer's ingest
                // never emits an empty key and asserts against one): route it
                // through the corrupt-record path — the scanner is already
                // positioned past the frame, so the walk continues.
                Ok(entry) if entry.key.is_empty() => {
                    dropped.push(DroppedBlob {
                        reason: BlobDropReason::Corrupt("frame carries an empty key".to_string()),
                    });
                }
                // A frame whose declared `real_val_len` disagrees with the
                // bytes actually stored (for an UNCOMPRESSED source the two
                // must be equal; a compressed record proves its length through
                // the decompression below instead) is rejected by the live
                // blob reader — re-emitting it would restamp a consistent
                // length and launder a record live reads treat as corrupt.
                // Drop it; the scanner is already past the frame, so the walk
                // continues.
                Ok(entry)
                    if compression == crate::CompressionType::None
                        && entry.uncompressed_len as usize != entry.value.len() =>
                {
                    dropped.push(DroppedBlob {
                        reason: BlobDropReason::Corrupt(
                            "frame's declared value length disagrees with its stored bytes"
                                .to_string(),
                        ),
                    });
                }
                // A frame whose internal key regresses below the last accepted
                // record: `BlobWriter` requires records in key order (ascending
                // user key, ties by descending seqno), and re-emitting an
                // out-of-order frame would corrupt the salvaged file's key range
                // and break the later merge scanner's per-reader sorted-input
                // assumption (mismatched blob relocation, or a panic in the
                // relocating compaction). The frame's own checksum is intact, so
                // this is not payload rot; drop it and keep the sorted prefix.
                Ok(entry) if blob_key_regresses(comparator, prev_written.as_ref(), &entry) => {
                    dropped.push(DroppedBlob {
                        reason: BlobDropReason::Corrupt(
                            "frame's internal key regresses below the previous salvaged \
                             record; re-emitting it would violate the blob writer's \
                             sorted-input contract"
                                .to_string(),
                        ),
                    });
                }
                Ok(entry) => {
                    // A compressed record's frame checksum covered its ON-DISK
                    // bytes; prove the content itself round-trips by
                    // decompressing here (the re-emit below re-compresses it
                    // under the same descriptor). A record that fails to
                    // decompress despite a clean checksum is structural
                    // corruption — drop it and keep walking.
                    let value = match decompress_blob_value(
                        compression,
                        &entry.value,
                        entry.uncompressed_len as usize,
                        #[cfg(zstd_any)]
                        zstd_dictionary.map(alloc::sync::Arc::as_ref),
                    ) {
                        Ok(value) => value,
                        // The caller brought the wrong dictionary: the frame is
                        // intact and every later one fails identically, so
                        // grading them corrupt would "salvage" the whole file
                        // into nothing. The id check above catches this before
                        // the walk; this arm keeps the classification honest on
                        // any path that reaches here anyway.
                        #[cfg(zstd_any)]
                        Err(e @ crate::Error::ZstdDictMismatch { .. }) => return Err(e),
                        Err(e) => {
                            dropped.push(DroppedBlob {
                                reason: BlobDropReason::Corrupt(format!(
                                    "frame's value does not decompress: {e:?}"
                                )),
                            });
                            continue;
                        }
                    };
                    // Record the frame relocation BEFORE the write advances the
                    // writer: existing SST ValueHandles point at SOURCE frame
                    // offsets, and the compacted rewrite shifts every record
                    // after the first drop, so the caller needs this map to
                    // re-target handles before the salvaged file can replace
                    // the original.
                    let salvaged_offset = writer.offset();
                    // `write` re-compresses under the salvaged file's own
                    // descriptor and returns the resulting ON-DISK value size —
                    // recorded per record because compressor output is not
                    // stable across versions, so the source handle's size
                    // cannot be assumed to survive the round-trip.
                    let on_disk_size = writer.write(&entry.key, entry.seqno, &value)?;
                    offset_remap.push((
                        entry.offset,
                        BlobRecordRelocation {
                            offset: salvaged_offset,
                            on_disk_size,
                        },
                    ));
                    prev_written = Some((entry.key.clone(), entry.seqno));
                    records_salvaged += 1;
                }
                // Payload rot: the checksum failed, so the scanner RESYNCS at the
                // next magic and arms the sticky taint. This record drops here;
                // every frame after it comes back through the `resynced` arm
                // above and drops too (the tail's provenance is now unprovable).
                Err(crate::Error::ChecksumMismatch { .. }) => dropped.push(DroppedBlob {
                    reason: BlobDropReason::ChecksumMismatch,
                }),
                // Header rot (rotted magic or a length field caught by the
                // header CRC): the scanner has RESYNCHRONIZED at the next
                // frame magic (arming the sticky taint, so the tail after it
                // drops) or TERMINATED, when the CRC-vouched frame end overruns
                // the data section (real truncation). Either way the walk is safe.
                Err(
                    e @ (crate::Error::HeaderCrcMismatch { .. } | crate::Error::InvalidHeader(_)),
                ) => {
                    dropped.push(DroppedBlob {
                        reason: BlobDropReason::Corrupt(format!("{e:?}")),
                    });
                }
                // An ENVIRONMENTAL I/O error — transient (a retry clears it) or
                // one that does not implicate the source bytes at all
                // (`PermissionDenied` is an ACL mistake, `OutOfMemory` is host
                // pressure; the same classification the repair gates use) — may
                // strike AFTER at least one record was emitted, so recording it
                // as corruption and finishing `dest` would publish a lossy
                // report whose offset map silently omits the healthy UNREAD
                // tail. Propagate it so the caller retries once the environment
                // is fixed and the partial dest is discarded (below) rather
                // than accepting avoidable data loss.
                Err(crate::Error::Io(io)) if io.kind().is_environmental() => {
                    return Err(crate::Error::Io(io));
                }
                // Any other error — a PERSISTENT I/O failure (a bad-sector `Other`
                // or a truncated tail `UnexpectedEof`, neither fixed by a retry)
                // or a decode fault the scanner does not re-sync from: an error
                // that leaves the read position before `data_end` without
                // terminating would make the iterator keep yielding it. Record
                // the corrupt tail as a drop and stop the walk, keeping the
                // valid prefix salvageable — this is the last record it can
                // inspect.
                Err(e) => {
                    dropped.push(DroppedBlob {
                        reason: BlobDropReason::Corrupt(format!("{e:?}")),
                    });
                    break;
                }
            }
        }
        Ok(())
    })();

    let salvaged_path = match walk {
        // A write failed mid-walk: drop the writer and remove the partial dest
        // before propagating, so a retry / repair caller never sees a half-written
        // blob file.
        Err(e) => {
            drop(writer);
            discard_partial(fs, &dest);
            return Err(e);
        }
        Ok(()) if records_salvaged > 0 => {
            // A `finish` failure likewise leaves a partial dest — remove it before
            // propagating.
            if let Err(e) = writer.finish() {
                discard_partial(fs, &dest);
                return Err(e);
            }
            // The writer synced the file's bytes; sync the parent directory too
            // so the new directory entry is durable before the report claims
            // success (without it a power loss can discard the entry). A bare
            // relative `dest` has an EMPTY parent, so resolve it to the current
            // directory first — otherwise the sync fails and this discards the
            // recovered file. A sync failure removes the file and propagates, so
            // a caller never sees a salvaged_path whose entry is not durable.
            if let Err(e) = fs.sync_directory_with(entry_directory(&dest), sync_mode) {
                discard_partial(fs, &dest);
                return Err(e.into());
            }
            Some(dest)
        }
        // Nothing recoverable: `BlobWriter::new` created `dest`, so remove the
        // empty placeholder a repair caller would otherwise re-reject.
        Ok(()) => {
            drop(writer);
            discard_partial(fs, &dest);
            None
        }
    };

    Ok(BlobSalvageReport {
        salvaged_path,
        records_total,
        records_salvaged,
        offset_remap,
        dropped,
    })
}

#[cfg(test)]
mod tests;