stuffr-core 0.10.1

Core traits, stream ladder and resource governor for the stuffr toolkit
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
//! The salvage engine: format-agnostic scanning, bounding and collection.
//!
//! Salvage recovers entries from a damaged archive by scanning for records
//! directly, rather than trusting a single index (a central directory, an
//! EOCD) that may be truncated, zeroed, or — the motivating case — may
//! shadow real records behind a repeated name. The per-format part of that
//! (recognising a local header, computing a checksum) is a
//! [`SalvageScan`] implementation; this module is everything that does not
//! need to know which format it is scanning.
//!
//! **This module implements no per-format scanning at all.** It is proven
//! entirely against mocks — [`SalvageScan`] is the seam Task 2 onward builds
//! real scanners against (starting with zip).
//!
//! ## Verification and shadowing (Task 4)
//!
//! Two things this module now does, both format-agnostic:
//!
//! - **Deciding [`SalvageStatus`]** is delegated to [`SalvageScan::verify`],
//!   called once per candidate in [`annotate_candidates`]. The default
//!   answers [`SalvageStatus::Unattested`] — "nothing attests that this
//!   candidate is an entry at all" — which is the honest reading of a method
//!   that reads nothing, and the right answer for a mock with no
//!   [`Candidate::verifier`] and for a real format with neither a checksum
//!   nor a self-verifying header (cpio, ar). A format with a self-verifying
//!   header and no content checksum (tar) overrides it to answer
//!   [`SalvageStatus::Complete`]. A format that carries a content checksum
//!   (zip's CRC-32) overrides `verify` to
//!   decode the payload and compare it, answering `Intact` on agreement and
//!   `Partial` otherwise — never `Complete`, which is reserved for "no way
//!   to prove", not "tried and it disagreed". Earlier revisions of this
//!   module recorded every candidate as `Complete` unconditionally; that was
//!   a placeholder, not a claim about zip, and it was wrong for every zip
//!   entry the moment a real scanner carried a CRC-32.
//! - **Detecting a shadowed record** (setting [`SalvagedEntry::shadows`]) is
//!   generic too: [`annotate_candidates`] remembers the first candidate to
//!   report each distinct `(name, declared_len, verifier)` triple and marks
//!   any LATER candidate reporting the identical triple as shadowing it.
//!   This is a MEASUREMENT, never an inference from [`EntryMeta::name`]
//!   alone repeating: two different files that happen to share a name must
//!   not be linked this way, only two records whose original writer
//!   computed the same checksum, over the same declared length, under the
//!   same name. The checksum alone is not enough either — fixed after this
//!   module's own fix-round review measured it against real archives: every
//!   empty file and every directory entry in a zip has `Crc32(0)` — a
//!   structural certainty for a zero-byte payload, not a collision — so a
//!   checksum-only match reported an ordinary Python package's dozen
//!   `__init__.py` files, and its directories, as duplicates of one another.
//!   The degenerate case is refused outright rather than papered over by the
//!   wider conjunction: a candidate with `declared_len == Some(0)` never
//!   shadows and is never shadowed, because a checksum over zero bytes is
//!   the same value for every empty entry that ever existed and proves
//!   nothing about any of them being copies of each other.
//! - **Detecting a repeated NAME** ([`SalvagedEntry::collides_with`]) is a
//!   second, deliberately separate annotation, added after the final
//!   whole-branch review found the word "shadow" doing two jobs. The two
//!   facts are different and both worth printing: a shadow is a record
//!   MEASURED to be a copy of an earlier one, a collision is a record whose
//!   name an earlier one already used and whose content this scan could not
//!   prove identical. `stuffr list`'s own `Fidelity::EntryCountMismatch`
//!   warning is about the second, so on an archive whose duplicates differ,
//!   `list` reported two records shadowed while `salvage --list` marked
//!   none — one tool telling a user two contradictory things about the same
//!   eight records. None of the guards on shadow detection above apply to a
//!   collision, and applying them would be wrong: they exist because
//!   claiming two records are COPIES needs proof, and a collision claims
//!   nothing about content at all. The two are mutually exclusive — a
//!   proven copy is reported as a shadow and nothing else, since that is
//!   the strictly more informative claim.
//!
//! [`collect_candidates`] (the resync loop: find a candidate, advance) and
//! [`annotate_candidates`] (bound + verify + shadow, above) are both
//! exposed separately from [`salvage_all`] — which is just the two of them
//! run in sequence — so a format that reconciles more than one candidate
//! source (zip's central-directory fallback, `zip_salvage.rs`) can collect
//! from each source, merge, and annotate the merged list exactly once
//! rather than annotating twice and reconciling two already-decided
//! outcomes. The run's ceiling on a single entry is applied once, in
//! `annotate_candidates`, to whatever the merge produced — see
//! [`UnverifiedCause::OverEntryCeiling`] for why it is a status there
//! rather than a [`crate::Error`] anywhere.

use std::io::{Read, Write};
use std::path::Path;

use crate::archive::EntryMeta;
use crate::error::{Error, Result};
use crate::format::FormatId;
use crate::source::SeekRead;

/// 4 GiB. A scanned length is untrustworthy TWICE over — attacker-controlled
/// AND possibly corrupt — so this is a structural ceiling, not a budget.
///
/// Applied per ENTRY, never to the run: an entry over it is reported
/// [`UnverifiedCause::OverEntryCeiling`] and skipped, and the rest of the
/// archive is recovered as usual. It used to abort the run outright, which
/// meant one absurd declaration cost every entry around it.
pub const MAX_SALVAGE_ENTRY: u64 = 4 * 1024 * 1024 * 1024;

/// What a format can prove about a candidate it found.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verifier {
    /// CRC-32, as zip, arj and gzip compute it.
    Crc32(u32),
    /// CRC-16/ARC, as lha, arc and zoo compute it.
    Crc16(u16),
}

/// A record a scanner believes it found, before anything is decoded.
///
/// # Why this is `#[non_exhaustive]` AND has a constructor
///
/// Ruling S-I, deferred from Salvage Stage 2 Task 1 to Task 9 on purpose:
/// this struct is the one shape [`SalvageScan::next_candidate`] *forces* an
/// external implementor to build, so every field added to it used to break
/// every out-of-crate scanner at once — and at `0.5.0` all four crates are
/// published, so "out-of-crate" is not hypothetical. The attribute alone
/// would have been the wrong fix (`CONTRIBUTING.md`'s `#[non_exhaustive]`
/// section says why: it forbids `..` construction from another crate, which
/// is exactly what an implementor must do), which is why it waited for
/// [`Candidate::new`] — and for all five in-tree scanners to exist, so the
/// constructor's shape could be read off real callers rather than guessed.
///
/// The three arguments [`Candidate::new`] takes are the three facts every
/// scanner knows the instant it recognises a record. The four `with_*`
/// setters cover the rest, each taking the field's own type unchanged so
/// nothing is hidden behind a conversion.
#[derive(Debug)]
#[non_exhaustive]
pub struct Candidate {
    /// Byte offset of the record's header in the archive.
    pub offset: u64,
    /// Byte offset where this record's PAYLOAD begins — past whatever
    /// fixed and variable-length header fields this format's record
    /// carries (a zip local header's name/extra fields, ARC's fixed
    /// 28-byte record, and so on).
    ///
    /// Task 3c added this field after `entries.rs`'s write path hardcoded
    /// a ZIP local header's own 30-byte-plus-name-plus-extra layout as
    /// the way to locate every format's payload — which crashed
    /// (`Error::Io`, exit 1, the wildcard this project treats as a
    /// defect) the moment a non-zip scanner (ARC) reported a candidate
    /// near the end of the file, because reading 30 bytes from ITS offset
    /// ran past EOF. Each scanner already computes this value once, at
    /// discovery, to know where ITS OWN payload begins — this field
    /// carries that computation forward instead of a consumer re-deriving
    /// (and mis-deriving) it later. A future scanner (zoo, lha, arj) fills
    /// this the same way; nothing outside the scanner that discovered a
    /// candidate needs to know its record's own layout at all.
    pub payload_start: u64,
    pub meta: EntryMeta,
    /// Declared payload length. `None` when the header does not carry one.
    pub declared_len: Option<u64>,
    /// The checksum the ORIGINAL writer computed, when the format has one.
    /// `None` is what produces the intact/complete split honestly, rather
    /// than by a per-format convention someone has to remember.
    pub verifier: Option<Verifier>,
    /// How many of `declared_len`'s bytes the SOURCE actually holds, when
    /// it holds fewer — the truncated-tail case. `None` means the declared
    /// payload is entirely present (the ordinary case) or nothing was
    /// declared at all; `Some(n)` always means `n < declared_len`.
    ///
    /// # Why a scanner reports this instead of dropping the candidate
    ///
    /// A truncated archive — an interrupted download — is the single most
    /// common damaged zip there is, and it was the one shape salvage said
    /// nothing at all about. `zip_salvage.rs`'s validation gate refused any
    /// candidate whose declared payload ran past the end of the file, which
    /// is a correct refusal to INVENT the missing bytes (`zip -FF`
    /// fabricates them and reports success) implemented as SILENCE: no row,
    /// no note, no non-zero exit. Swept across six truncation points inside
    /// a three-header archive's last entry, every one reported "2 scanned"
    /// at exit 0 — the third header was not even counted.
    ///
    /// Reporting the candidate with this field set is what turns the
    /// refusal into a statement. Nothing is invented: the declared length
    /// stays exactly what the header said, so a reader can see both figures,
    /// and the bytes that ARE present are still a genuine prefix. What a
    /// scanner must NOT do is fold the shortfall into `declared_len` — that
    /// would make a truncated entry indistinguishable from a whole one of
    /// the smaller size, which is the same mistake in a different place.
    pub available_len: Option<u64>,
    /// The archive's own record marks this entry DELETED — the format's
    /// owner removed it and the writer left the record, and its payload,
    /// in place.
    ///
    /// `false` for every format that has no such flag (zip, tar, ar, cpio,
    /// arc), which is why it is a plain `bool` rather than an `Option`:
    /// "this format cannot express deletion" and "this record is not
    /// deleted" lead to the identical, unannotated row, and a third state
    /// would be a distinction no consumer could act on.
    ///
    /// # Why a scanner reports one rather than dropping it (Ruling S-R)
    ///
    /// Salvage Stage 2 Task 4, for ZOO — the first format here with the
    /// flag. `zoo d` marks an entry `deleted = 1` and leaves it whole in the
    /// file; `zoolist.c` neither lists nor extracts it, and `legacy::zoo`'s
    /// reader follows that exactly. The flag is ONE BYTE, so in a damaged
    /// archive a bit flip turns a live entry into one no ordinary verb will
    /// ever hand back — which is precisely the archive this verb exists for.
    /// So the scanner reports it.
    ///
    /// **And the report carries a marker, which is the part that took a
    /// review to get right.** Everywhere else salvage accepts less than an
    /// ordinary entry it says so on the artifact or in the row: a `Partial`
    /// lands as `NAME.partial` and never under its real name, a second
    /// record under a taken name lands as `NAME.salvaged-N`, a proven copy
    /// prints `[shadowed]`. A deleted record reported `Intact` under its
    /// real name at exit 0 was the one place that leniency was invisible —
    /// four verbs call the archive empty and the fifth writes the file, with
    /// nothing telling a user which it was.
    ///
    /// **The exit code deliberately does NOT move.** Recovering a deleted
    /// record is this verb working as designed, not degraded fidelity — see
    /// `stuffr::entries`'s own exit-code aggregation, which this field is
    /// invisible to.
    pub marked_deleted: bool,
}

impl Candidate {
    /// A candidate at `offset`, whose payload begins at `payload_start`,
    /// carrying `meta`.
    ///
    /// Nothing else is claimed. The four fields this does not take default
    /// to the answers that assert the least:
    ///
    /// * `declared_len: None` — the header carries no length, so nothing
    ///   bounds the payload and [`annotate_candidates`] reports
    ///   [`UnverifiedCause::NoDeclaredLength`] rather than reading it.
    /// * `verifier: None` — the format offers nothing to prove the content
    ///   with, so no shadow is ever claimed for this record (see
    ///   [`SalvagedEntry::shadows`], which needs a verifier to compare).
    /// * `available_len: None` — every declared byte is present, which is
    ///   the ordinary case; a scanner sets this only when it has MEASURED a
    ///   shortfall.
    /// * `marked_deleted: false` — the format has no deleted flag, or this
    ///   record is not marked. Four of the five in-tree scanners leave it.
    ///
    /// One default is worth naming because it decides a STATUS, not just a
    /// field: with no `verifier`, [`SalvageScan::verify`]'s own default
    /// answers [`SalvageStatus::Unattested`] — the tier that asserts the
    /// least, matching the discipline of the four above. It answered
    /// [`SalvageStatus::Complete`] until Salvage Stage 3, which was the
    /// lenient direction: `Complete` claims a header self-check the default
    /// never performs, and routes to exit 0. A scanner carrying a checksum
    /// has to write its own `verify` regardless (the default checks
    /// nothing), and that `verify` reads this very field, so the omission
    /// fails in the scanner's own first test rather than silently.
    pub fn new(offset: u64, payload_start: u64, meta: EntryMeta) -> Self {
        Self {
            offset,
            payload_start,
            meta,
            declared_len: None,
            verifier: None,
            available_len: None,
            marked_deleted: false,
        }
    }

    /// Sets [`Self::declared_len`].
    #[must_use]
    pub fn with_declared_len(mut self, declared_len: Option<u64>) -> Self {
        self.declared_len = declared_len;
        self
    }

    /// Sets [`Self::verifier`].
    #[must_use]
    pub fn with_verifier(mut self, verifier: Option<Verifier>) -> Self {
        self.verifier = verifier;
        self
    }

    /// Sets [`Self::available_len`] — the truncated-tail case, and only
    /// when the shortfall was measured. See that field's doc for why a
    /// scanner must never fold it into [`Self::declared_len`] instead.
    #[must_use]
    pub fn with_available_len(mut self, available_len: Option<u64>) -> Self {
        self.available_len = available_len;
        self
    }

    /// Sets [`Self::marked_deleted`] — Ruling S-R. See that field's doc.
    #[must_use]
    pub fn with_marked_deleted(mut self, marked_deleted: bool) -> Self {
        self.marked_deleted = marked_deleted;
        self
    }
}

/// Why an entry is [`SalvageStatus::Unverified`].
///
/// Three causes, and the STATUS does not distinguish them because the
/// decision they lead to is identical (listed, not written, exit 3) — the
/// same split [`crate::salvage`]'s consumers apply to
/// [`SalvageStatus::Partial`] via their own cause type (`entries.rs`'s
/// `PartialCause`, one crate up): **a tier carries a decision, a message
/// carries a cause.**
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnverifiedCause {
    /// This build recognises the entry's compression method but cannot
    /// decode it (zip method 99 is AES, 14 is LZMA — recognised, not
    /// decoded).
    UndecodableMethod,
    /// No length was available to bound the payload, so it was never read —
    /// a data-descriptor entry the raw scan found with no central-directory
    /// record to reconcile against. A CD-reconciled data-descriptor entry
    /// gets a real declared length and CRC from the central directory and
    /// does NOT reach this cause; see `zip_salvage.rs`'s
    /// `candidate_from_cd_record`.
    NoDeclaredLength,
    /// The bytes this entry would have required are past the ceiling this
    /// run will read for ONE entry — `policy.max_entry` narrowed by the
    /// scanner's own [`SalvageScan::max_whole_entry`] — so nothing was read
    /// and nothing was decoded. See [`annotate_candidates`], which is the
    /// one place that decides this, for the whole rule.
    ///
    /// # Why this is a cause and not an `Err`
    ///
    /// Salvage Stage 2 Task 3c's fix round 3. This refusal used to be an
    /// [`crate::Error::ResourceLimit`] raised from two different places — the
    /// engine's own [`collect_candidates`], and (for ARC) the scanner's own
    /// `verify` — and an `Err` at either site **aborts the whole run**:
    /// measured at the CLI on a two-entry archive whose second entry was
    /// over the ceiling, `stuffr salvage --list` printed no rows at all and
    /// exited 6, and `salvage -C out` left the destination empty, losing a
    /// first entry that decodes perfectly. That is the one thing this verb
    /// exists not to do. Three fix rounds chased the shape from site to
    /// site; what actually closes it is that an over-ceiling entry has a
    /// STATUS, so there is no `Err` left to propagate.
    ///
    /// Distinct from [`SalvageStatus::Partial`], which is what this used to
    /// collapse into once the `Err` was folded away at the write layer: a
    /// truncated entry's genuine prefix IS recovered (as `NAME.partial`),
    /// while an over-ceiling entry has real bytes nobody read. Reporting it
    /// as `Partial (truncated)` stated two false things — the entry is not
    /// truncated, and the empty `NAME.partial` it produced said nothing
    /// survived.
    ///
    /// # Why it carries both figures (fix round 4, NEW-F)
    ///
    /// The `Err` this replaced carried a sentence naming the declared size
    /// and the ceiling; the first version of this variant was a unit, so a
    /// user meeting a container's own fixed ceiling was told neither number
    /// and could not tell whether `--max-entry` would help. The cause is
    /// the only place that knows both, so it carries both and the CLI row
    /// prints them — the project's own "a message carries a cause", with
    /// the numbers that make the message actionable.
    OverEntryCeiling {
        /// Bytes reading this entry would have required: the BOUNDED
        /// figure the comparison actually used — `available_len` when the
        /// payload is truncated, the header's `declared_len` otherwise —
        /// never the raw declaration when the file holds less than it.
        needed: u64,
        /// The ceiling in force for this run: [`SalvagePolicy::max_entry`]
        /// narrowed by [`SalvageScan::max_whole_entry`]. Equal to
        /// `max_entry` means `--max-entry` can raise it; below it means a
        /// whole-decoding container's own fixed ceiling decided, and no
        /// flag moves that.
        ceiling: u64,
    },
}

/// What was proven about an entry AFTER decoding it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SalvageStatus {
    /// A checksum the original writer computed agrees.
    Intact,
    /// Every declared byte was present and the header self-verified, but the
    /// format offers NO CHECKSUM AT ALL to prove the content — `tar`, in
    /// this project's own formats. Never the right answer for a format that
    /// DOES carry one; see [`SalvageStatus::Unverified`] for that case,
    /// which this status is not permitted to stand in for.
    ///
    /// # `cpio` and `ar` are NOT this tier, and were named here until Stage 3
    ///
    /// This doc said "tar, cpio and ar" from Stage 1 until Salvage Stage 3,
    /// which is where the sentence's own *"and the header self-verified"*
    /// clause was measured against the three formats it named. `tar` has a
    /// header checksum and really does self-verify. **`cpio` and `ar` have
    /// neither a content checksum nor a self-verifying header**, so for
    /// those two the clause is not merely unproven — there is nothing in the
    /// format it could ever be true of, and a scanner answering `Complete`
    /// for one would be asserting a header self-check that does not exist.
    /// They are [`SalvageStatus::Unattested`].
    ///
    /// `stuffr_core::testing::check_salvage_claim` is what holds the line: this
    /// tier requires a format whose only checksum covers the HEADER
    /// (`Attestation::HeaderChecksumOnly`) AND that checksum to have been
    /// checked — so the checksumless pair cannot reach it, and neither can a
    /// format with a content checksum, whose honest tiers are `Intact`,
    /// `Partial` and `Unverified`.
    Complete,
    /// Nothing attests that this candidate is an entry AT ALL: the format
    /// carries **no checksum and no self-verifying header**, so a
    /// coincidental header match and a real entry are indistinguishable in
    /// the bytes — `cpio` and `ar`, in this project's own formats.
    ///
    /// The entry is still WRITTEN, under its real name: `salvage` is the
    /// recovery-biased verb, and a tier that refused to write would make
    /// these two formats unsalvageable rather than honestly salvaged. What
    /// the tier buys is that the run says so — `salvage_exit_code` answers
    /// **4** for it (degraded fidelity, never 3: nothing was refused and no
    /// capability was missing), and the `--list` row carries the reason
    /// beside the entry. There is no flag to opt into or out of this; the
    /// formats' own shape decides it.
    ///
    /// # Not [`Self::Unverified`] — and the near-miss name is the hazard
    ///
    /// The variant deliberately is NOT called `Unverifiable`: that is two
    /// letters from the [`Self::Unverified`] this enum already has, in an
    /// enum whose variants select exit codes, and a reviewer skimming match
    /// arms would not reliably see the difference. The distinction, stated
    /// wherever both appear:
    ///
    /// * [`Self::Unverified`] — the format *has* a checksum; this build
    ///   could not use it (an undecodable method), or the header declared
    ///   nothing to check.
    /// * `Unattested` — the format has **no checksum and no self-verifying
    ///   header**, so nothing attests that this candidate is an entry at
    ///   all.
    ///
    /// The consequences differ as much as the claims do: `Unverified` is
    /// NOT written and exits 3 (this build could not do it — a rebuild may
    /// fix it), `Unattested` IS written and exits 4 (nothing can fix it;
    /// the format carries no evidence to find).
    Unattested,
    /// Nothing about this entry's content was verified.
    ///
    /// Two causes ([`UnverifiedCause`]), and the tier does not distinguish
    /// them because the decision is identical — the entry is listed, not
    /// written, and the run exits 3:
    ///   * this build could not decode the method (zip 99 is AES, 14 is
    ///     LZMA);
    ///   * no length was available to bound the payload, so it was never
    ///     read (a data-descriptor entry the raw scan found with no
    ///     central-directory record to reconcile against).
    ///
    /// Distinct from [`Self::Complete`], which asserts every DECLARED byte
    /// was present — a claim that requires something to have been declared
    /// in the first place. Reporting `Complete` for either cause above was
    /// this enum's third instance of "nothing to disprove" being read as
    /// "proven" (after the Task 1 hardcoded placeholder and the Task 4
    /// undecodable-method case this variant was originally added for) — the
    /// exit-code table this status exists to keep honest reserves exit 3 for
    /// both causes, never exit 0, which is what `Complete` would have routed
    /// either one to.
    Unverified(UnverifiedCause),
    /// The payload ran out, the decoder failed mid-stream, or the content
    /// decoded whole and disagreed with the checksum the original writer
    /// computed. All three are the same fact from a caller's perspective —
    /// this build could not confirm the content is what was written — and
    /// none of them is `Complete` (something WAS checked) or `Intact` (it
    /// did not agree, or never finished).
    Partial,
}

/// Which class of evidence a FORMAT offers for one of its entries — a
/// per-format constant, and the third argument to the honesty oracle
/// `stuffr_core::testing::check_salvage_claim`.
///
/// **Always compiled, unlike the oracle**, since Salvage Stage 3 Task 5:
/// `stuffr formats`' SALVAGE column renders it (`yes` only for
/// [`Attestation::ContentChecksum`]), so it cannot live behind the
/// `testing` feature with the oracle that consumes it. Which format has
/// which class is stated once, beside the scanner dispatch
/// (`stuffr::entries::salvage_attestation`); the fuzz target and the corpus
/// generator read it from there rather than keeping a table of their own.
///
/// **Three classes, which is why this is an enum and not a `bool`.** Salvage
/// Stage 3 Task 1 first shipped the argument as `format_offers_verifier:
/// bool`, and its fix round's review showed with a truth table that no
/// reading of a two-valued flag can hold the boundary: read as "a content
/// checksum", it let `cpio` claim [`SalvageStatus::Complete`]; read as "a
/// content checksum OR a self-verifying header" (the shipped reading), it
/// let `tar` claim [`SalvageStatus::Intact`] and — live against the five
/// scanners that shipped before this stage — let `zip`, `arc`, `zoo`, `lha`
/// or `arj` answer `Complete` for an entry whose CRC was compared and
/// DISAGREED, turning an exit-4 `Partial` into an exit-0 clean recovery.
/// Both false claims sat in the one cell (`checked`, `offers`) that tar's
/// honest `Complete` also occupies, so no function of those inputs could
/// refuse them and permit it. Ruling 3-G.
///
/// Two bools (`offers_content_checksum`, `header_self_verifies`) were
/// considered and refused: four states for three classes, one of them
/// meaningless, and the split pushed back onto every caller. Here each
/// scanner NAMES its class.
///
/// **Deliberately NOT `#[non_exhaustive]`**, for the reason Ruling 3-D kept
/// it off [`SalvageStatus`]: a fourth class of evidence must fail to compile
/// at every `match`, `check_salvage_claim`'s own included, rather than be
/// absorbed by a wildcard arm that decides what it permits by accident.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Attestation {
    /// A checksum over the entry's CONTENT, computed by the original writer —
    /// `zip`, `arc`, `zoo`, `lha`, `arj`. The only class that can earn
    /// [`SalvageStatus::Intact`].
    ContentChecksum,
    /// A checksum over the HEADER only; the content has none — `tar`. The
    /// only class that can earn [`SalvageStatus::Complete`]: "every declared
    /// byte was present and the header self-verified" is exactly what this
    /// class can prove and all it can prove.
    HeaderChecksumOnly,
    /// Neither — `cpio`, `ar`. A coincidental header match and a real entry
    /// are indistinguishable in the bytes, so the only honest tier this
    /// class can reach on a whole entry is [`SalvageStatus::Unattested`].
    Nothing,
}

/// One entry the scan recovered.
///
/// `#[non_exhaustive]` plus [`SalvagedEntry::new`], for the same reason
/// [`Candidate`] carries both — see that type's own doc. This one is built
/// by [`annotate_candidates`] in ordinary use, but every per-format scanner
/// crate builds one directly to drive its `write_payload` seam, so the
/// literal it used to need was an out-of-crate literal all the same.
#[derive(Debug)]
#[non_exhaustive]
pub struct SalvagedEntry {
    /// This entry's position in SCAN order — not an index any container's
    /// own format assigns, and never renumbered by what `list` would show.
    /// A later task's CLI surfaces this distinction explicitly (`--index`
    /// on `salvage` names a scan position, not a list index).
    pub scan_position: usize,
    pub offset: u64,
    /// Carried forward from [`Candidate::payload_start`] — see that
    /// field's doc for why a consumer must use this rather than
    /// re-deriving a payload's location from `offset` itself.
    pub payload_start: u64,
    pub meta: EntryMeta,
    pub status: SalvageStatus,
    /// Set when this record's `(name, declared_len, verifier)` all agree
    /// with an EARLIER record's — a measurement, never inferred from a
    /// repeated name alone, and never set for a degenerate zero-length
    /// checksum (every empty file and every directory entry shares one).
    /// See [`annotate_candidates`] for where this is computed.
    ///
    /// **This is the byte-identical duplicate, and ONLY that.** The other,
    /// weaker fact — an earlier record under the same name whose content
    /// this scan could not prove identical — is [`Self::collides_with`], and
    /// the two are mutually exclusive by construction. They were one word
    /// ("shadow") until the final whole-branch review measured what that
    /// cost: `stuffr list`'s own fidelity warning uses "shadowed" for a
    /// repeated NAME, so on an archive whose duplicates differ, `list` said
    /// two records were shadowed and `salvage --list` marked none.
    pub shadows: Option<usize>,
    /// Set when an EARLIER record carries the same `meta.name` and this one
    /// is not that record's [`Self::shadows`] — i.e. the name repeats but
    /// the content could not be proven identical.
    ///
    /// This is the fact `stuffr list`'s `Fidelity::EntryCountMismatch`
    /// warning is about: a later record repeating a name is what makes an
    /// earlier one unreachable through a central directory's name index,
    /// whatever the bytes under it. It is also the fact that decides
    /// anything on the WRITE side, because two records under one name and
    /// two different payloads cannot both land on one path.
    ///
    /// Deliberately NOT gated on a verifier, a declared length or the
    /// degenerate zero-length case the way [`Self::shadows`] is: those
    /// guards exist because claiming two records are COPIES of each other
    /// needs proof, and this annotation claims no such thing. A repeated
    /// name is directly observed, and `None` here means only that no
    /// earlier record used this name — never that one did and this could
    /// not tell.
    pub collides_with: Option<usize>,
    /// Carried forward unchanged from [`Candidate::marked_deleted`] — see
    /// that field for the whole ruling. Unlike [`Self::shadows`] and
    /// [`Self::collides_with`], this is not something this module MEASURES:
    /// it is a fact the archive's own record states, which only the scanner
    /// that read that record can know.
    pub marked_deleted: bool,
}

impl SalvagedEntry {
    /// An entry at scan position `scan_position`, found at `offset`, whose
    /// payload begins at `payload_start`, carrying `meta` and proven to
    /// `status`.
    ///
    /// The three fields this does not take default to "nothing was
    /// observed": no shadow, no name collision, not marked deleted. All
    /// three are annotations [`annotate_candidates`] adds (or, for
    /// `marked_deleted`, carries forward from [`Candidate`]), so a caller
    /// building one directly — every per-format `write_payload` test does —
    /// wants exactly those defaults.
    pub fn new(
        scan_position: usize,
        offset: u64,
        payload_start: u64,
        meta: EntryMeta,
        status: SalvageStatus,
    ) -> Self {
        Self {
            scan_position,
            offset,
            payload_start,
            meta,
            status,
            shadows: None,
            collides_with: None,
            marked_deleted: false,
        }
    }

    /// Sets [`Self::shadows`] — the byte-identical duplicate, and only
    /// that. See the field's own doc for what separates it from
    /// [`Self::collides_with`].
    #[must_use]
    pub fn with_shadows(mut self, shadows: Option<usize>) -> Self {
        self.shadows = shadows;
        self
    }

    /// Sets [`Self::collides_with`] — the weaker, name-only annotation.
    #[must_use]
    pub fn with_collides_with(mut self, collides_with: Option<usize>) -> Self {
        self.collides_with = collides_with;
        self
    }

    /// Sets [`Self::marked_deleted`], carried forward from
    /// [`Candidate::marked_deleted`] rather than measured here.
    #[must_use]
    pub fn with_marked_deleted(mut self, marked_deleted: bool) -> Self {
        self.marked_deleted = marked_deleted;
        self
    }
}

/// The result of a scan: every entry the scanner recovered, in scan order.
///
/// Later tasks may extend this struct; the `entries` field stays public,
/// and `#[non_exhaustive]` is what makes "extend" honest. No constructor is
/// paired with it, unlike [`Candidate`] and [`SalvagedEntry`]: this struct
/// is built by [`salvage_all`] alone, in this crate, and no scanner — in
/// this workspace or outside it — has any reason to build one.
#[derive(Debug)]
#[non_exhaustive]
pub struct SalvageOutcome {
    pub entries: Vec<SalvagedEntry>,
    /// Header shapes the scanner recognised and could not gate, in scan
    /// order — see [`Sighting`]. Always empty from [`salvage_all`]: the
    /// engine cannot know what its scanner refused, so a format's own
    /// `salvage_*` entry point fills it in after the scan. A sighting is
    /// never an entry; the facade's `salvage_exit_code` counts a
    /// non-selective run that has one as degraded (exit 4) since the
    /// Salvage Stage 3 final review's I-1, which reversed Ruling S-X there.
    pub sightings: Vec<Sighting>,
    /// Where a SEQUENTIAL-ONLY scanner's walk stopped short of the end of
    /// the source, if it did — see [`WalkStop`]. Always `None` from
    /// [`salvage_all`], for the same reason [`Self::sightings`] is always
    /// empty there: only the scanner knows its walk ended early, so its own
    /// `salvage_*` entry point fills this in. Like a sighting it is never an
    /// entry and never touches the run's exit code.
    pub walk_stop: Option<WalkStop>,
}

/// A header a scanner SAW and has no gate for (Rulings S-V and 3-J): its
/// structure is a header's, and something about it — a field this build
/// cannot check, a shape a shifted copy also takes — leaves the scan unable
/// to tell it from a phantom. So it is counted here and never becomes a
/// [`Candidate`], because the engine advances past a candidate by its
/// declared length and a phantom's length is how a scan jumps a real header.
///
/// **Why it has to be reported at all.** A run that recovers nothing while
/// seeing one refuses with [`Error::Unsupported`] (exit 3) — the scanner's own
/// job, since only it can name the shape. A run that recovered OTHER entries
/// reports them at its ordinary exit code (Ruling S-X), and until Salvage
/// Stage 3's Task 2-N it said nothing else: an entry `stuffr list` shows was
/// simply absent from `salvage --list`, at exit 0, with an empty stderr. The
/// sightings recorded here are what the caller prints instead
/// ([`describe_sightings`]), in a mixed run as in any other — and since the
/// stage's final review (I-1, reversing S-X for this case) a non-selective
/// mixed run also exits 4, not 0: a `salvage -C out && rm` script must not
/// delete an archive holding an entry `stuffr list` reads.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Sighting {
    /// The scanner's format, for the sentence [`describe_sightings`] writes.
    pub format: FormatId,
    /// Where the header was seen.
    pub offset: u64,
    /// What was seen, as a plural noun phrase that follows a count —
    /// `"header(s) whose magic field … is neither \`ustar\` nor blank"`. One
    /// phrase per shape, shared with the scanner's own exit-3 refusal, so
    /// the two can never describe one shape two ways.
    pub shape: &'static str,
}

impl Sighting {
    pub fn new(format: FormatId, offset: u64, shape: &'static str) -> Self {
        Self {
            format,
            offset,
            shape,
        }
    }
}

/// The most offsets [`describe_sightings`] lists per shape before it says
/// how many more there were. The count itself is always the complete figure.
const SIGHTING_OFFSETS_SHOWN: usize = 8;

/// The one sentence a caller prints for `sightings`, or `None` when there
/// are none — how many headers of each shape were seen, where, and that the
/// ordinary verbs may read them or name the variant they are in. It is the
/// same wording family as each scanner's exit-3 refusal (its shape phrases
/// are the scanner's own), and it is written here, once, so every scanner
/// that records a [`Sighting`] gets the same report without a line of its
/// own.
///
/// **"Or name the variant" is cpio's clause (Salvage Stage 3 Task 3).** The
/// tail said only "may read these headers normally", which is true of a tar
/// header the scan cannot gate and false of a cpio `odc` or `newc-crc`
/// header: `stuffr list` refuses both variants at exit 3, naming them. A note
/// telling a user to expect `list` to read them would be the false "list
/// reads it" claim Task 2-N's fix round removed from tar.
pub fn describe_sightings(sightings: &[Sighting]) -> Option<String> {
    if sightings.is_empty() {
        return None;
    }
    // Grouped by (format, shape) in first-seen order: a handful of shapes
    // at most, so a linear search beats a map and keeps the order stable.
    let mut groups: Vec<((FormatId, &'static str), Vec<u64>)> = Vec::new();
    for s in sightings {
        let key = (s.format, s.shape);
        match groups.iter_mut().find(|(k, _)| *k == key) {
            Some((_, offsets)) => offsets.push(s.offset),
            None => groups.push((key, vec![s.offset])),
        }
    }
    let parts: Vec<String> = groups
        .iter()
        .map(|((format, shape), offsets)| {
            let mut shown: Vec<String> = offsets
                .iter()
                .take(SIGHTING_OFFSETS_SHOWN)
                .map(u64::to_string)
                .collect();
            if offsets.len() > SIGHTING_OFFSETS_SHOWN {
                shown.push(format!(
                    "and {} more",
                    offsets.len() - SIGHTING_OFFSETS_SHOWN
                ));
            }
            format!(
                "{} {format} {shape} at offset(s) {}",
                offsets.len(),
                shown.join(", ")
            )
        })
        .collect();
    Some(format!(
        "this build's salvage scanner saw {} — each with the structure of a header, but in a \
         shape it has no gate for, so none is listed or recovered; `stuffr list` and \
         `stuffr unpack` may read these headers normally, or name the variant they are in, \
         and it is the SCAN that stops there",
        parts.join("; and ")
    ))
}

/// Where a scanner that can ONLY walk its format in order — record after
/// record from the start, the way the ordinary reader does — had to stop
/// before the end of the source (Salvage Stage 3 Task 4).
///
/// # Why a walk stop is not a [`Sighting`]
///
/// A sighting is a header the scan SAW, whose structure is a header's and
/// which it cannot gate. A walk stop is the opposite: a place where the
/// walk found nothing it could read as a record at all, and — because the
/// format carries nothing strong enough to search for (an `ar` member's
/// only marker is two bytes) — cannot look any further. Folding it into
/// [`Sighting`] would make "saw a real header" and "could see nothing" one
/// channel, and [`describe_sightings`]'s own sentence ("`stuffr list` may
/// read these headers normally") would then be false about it.
///
/// # What it must say, and why it has to be said at all
///
/// The entries a sequential walk cannot reach are **unreachable by
/// construction, not absent**: a run that recovers three entries from a
/// ten-entry archive with a hole after the third reports three rows, and
/// nothing in those rows says seven more may sit behind the hole. So the
/// caller prints [`describe_walk_stop`] of this on every run that has one,
/// and the offset it names is where a user with a hex editor would look.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct WalkStop {
    /// The scanner's format, for the sentence [`describe_walk_stop`] writes.
    pub format: FormatId,
    /// Where the record the walk could not read begins — the first byte
    /// after the last record it did read.
    pub offset: u64,
    /// Bytes from [`Self::offset`] to the end of the source.
    pub remaining: u64,
    /// Which of the two things this stop is — see [`WalkStopKind`].
    pub kind: WalkStopKind,
    /// The ordinary reader's own words for why that record would not read,
    /// so the note and `stuffr list`'s refusal of the same bytes agree.
    pub cause: String,
}

/// The three stops a sequential walk can make, each told apart by what the
/// scanner measured rather than guessed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WalkStopKind {
    /// The file ends inside the record at the stop point — the reader ran
    /// out of bytes mid-record, or fewer bytes remain than the format's
    /// smallest record header. Everything from the stop point on belongs to
    /// that incomplete record, so no entry can be hiding after it — the
    /// common damage, and the one a sequential walk handles fully.
    CutShort,
    /// Enough bytes remain to hold further records, and the walk could not
    /// read the one at the stop point. Whatever those bytes hold is
    /// unreachable by construction.
    Unreadable,
    /// The record at the stop point is one the scanner RECOGNISES — its
    /// [`WalkStop::cause`] names it — in a shape this build cannot read (Task
    /// 4 fix round 1: an `ar` symbol table whose header the reader cannot
    /// parse; GNU `ar`'s Mach-O `__.SYMDEF` with a blank mode was the first,
    /// and is read since 0.8.1). Not evidence of damage; what follows is
    /// unreachable to this scanner.
    UnsupportedShape,
}

impl WalkStop {
    pub fn new(
        format: FormatId,
        offset: u64,
        remaining: u64,
        kind: WalkStopKind,
        cause: impl Into<String>,
    ) -> Self {
        Self {
            format,
            offset,
            remaining,
            kind,
            cause: cause.into(),
        }
    }
}

/// The one sentence a caller prints for a [`WalkStop`] — written here, once,
/// so every sequential-only scanner gets the same report without a line of
/// its own, and so its kinds can never be described two ways each.
///
/// An [`WalkStopKind::Unreadable`] stop says the entries after it are
/// *unreachable by construction, not absent*, with the offset and the byte
/// count; a [`WalkStopKind::CutShort`] one says that nothing after it is
/// missing from the report, which is exactly as true — the file ended inside
/// that record; an [`WalkStopKind::UnsupportedShape`] one names the shape and
/// says it is not evidence of damage.
///
/// Two of the sentences say the walk goes "as `stuffr list` does". That is a
/// contract on every scanner that reports a [`WalkStop`]: it walks with its
/// format's ORDINARY reader, over the file's own bytes, repairing none of
/// them. `ar`'s walk served its global header as `!<arch>\n` until Stage 3
/// Task 4's fix round 1, which made the phrase false (a bad magic `list`
/// refuses was walked past) — a scanner that needs a repair like that must
/// get its own sentence here rather than borrow this one.
pub fn describe_walk_stop(stop: &WalkStop) -> String {
    let WalkStop {
        format,
        offset,
        remaining,
        kind,
        cause,
    } = stop;
    let bytes = if *remaining == 1 { "byte" } else { "bytes" };
    match kind {
        WalkStopKind::Unreadable => format!(
            "this build's {format} salvage scanner walks the archive in order from its start, \
             as `stuffr list` does, and stopped at offset {offset} with {remaining} {bytes} still \
             to go: {cause}. The {format} format marks its records with nothing strong enough \
             to search for, so the scanner cannot find its way past a record it cannot read; \
             any entries in those {remaining} {bytes} are unreachable by construction — not \
             absent, and not counted in this report"
        ),
        WalkStopKind::UnsupportedShape => format!(
            "this build's {format} salvage scanner walks the archive in order from its start, \
             as `stuffr list` does, and stopped at offset {offset} with {remaining} {bytes} still \
             to go: {cause}. That is a record shape this build does not read, not evidence of \
             damage; any entries in those {remaining} {bytes} are unreachable to this scanner \
             and not counted in this report"
        ),
        WalkStopKind::CutShort => format!(
            "this build's {format} salvage scanner walks the archive in order from its start, \
             and the file ends {remaining} {bytes} into the record at offset {offset} ({cause}): \
             too few for any further entry, so the archive is cut short there and nothing \
             after that point is missing from this report"
        ),
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PartialPolicy {
    Keep,
    Skip,
    Ask,
}

pub struct SalvagePolicy {
    /// Recovery-biased by default: salvage is the one permissive verb, and
    /// the strict path is the entire rest of the tool.
    pub partial: PartialPolicy,
    /// Structural ceiling on any one entry's length, refused BEFORE
    /// allocation — and refused as a per-entry
    /// [`UnverifiedCause::OverEntryCeiling`], never as an error that ends
    /// the run. [`SalvageScan::max_whole_entry`] narrows it further for a
    /// format that decodes an entry whole; the two compose as a minimum and
    /// are applied together, once, in [`annotate_candidates`].
    pub max_entry: u64,
    /// Demand proof: every [`SalvageStatus::Partial`] entry is skipped,
    /// whatever [`Self::partial`] says, and so is every
    /// [`SalvageStatus::Unattested`] one (Ruling 3-H, Salvage Stage 3).
    ///
    /// The second clause is this flag's doing, unlike the two refused
    /// below: an `Unattested` entry is WRITTEN by default, under its real
    /// name, and only `strict` declines it — the format carries nothing
    /// that could prove it, which is exactly what "demand proof" asks for.
    ///
    /// **Those two are the whole of it, and this doc claimed three things
    /// until the final whole-branch review's F5.** It read "partial skipped,
    /// ceiling fixed, nothing unverifiable", and only the first clause was
    /// this flag's doing. The ceiling is NOT fixed under `strict`:
    /// [`Self::max_entry`] applies identically with and without it, and
    /// `--max-entry` is honoured either way. And an
    /// [`SalvageStatus::Unverified`] entry is never written under any
    /// policy at all — that is the ops layer's unconditional rule, not
    /// something `strict` causes — so crediting it here made a flag look
    /// load-bearing for a guarantee that holds without it.
    ///
    /// The true clauses are enough to justify the flag: `salvage` is this
    /// tool's single recovery-biased verb, so `strict` is how a caller asks
    /// for the discipline every other verb applies by default.
    pub strict: bool,
}

impl Default for SalvagePolicy {
    fn default() -> Self {
        Self {
            partial: PartialPolicy::Keep,
            max_entry: MAX_SALVAGE_ENTRY,
            strict: false,
        }
    }
}

/// The per-format seam. A scanner recognises its own format's record shape
/// (a local header's magic, a member's checksum field) and reports what it
/// finds as a stream of [`Candidate`]s; [`salvage_all`] supplies everything
/// that does not depend on which format that is.
pub trait SalvageScan {
    /// Find the next candidate at or after `from`. `Ok(None)` ends the scan.
    fn next_candidate(&mut self, src: &mut dyn SeekRead, from: u64) -> Result<Option<Candidate>>;

    /// The largest single entry this scanner is willing to have read for it
    /// — the ceiling [`annotate_candidates`] applies, narrowed further by
    /// `policy.max_entry`, before it calls [`Self::verify`] at all.
    ///
    /// `u64::MAX` (the default) means "this scanner imposes none of its
    /// own": zip streams every payload it verifies through a `take`, so the
    /// only figure that bounds it is the policy's. A format that **decodes
    /// an entry whole** — ARC, and the legacy containers still to come —
    /// overrides this with its own container-side ceiling
    /// (`arc.rs`'s `MAX_ARC_ENTRY_LEN`, 256 MiB), because for those the
    /// figure really does become a single allocation.
    ///
    /// **Declaring a ceiling here is the ONLY way a scanner refuses an
    /// entry for its size**, and that is the point: the refusal is then a
    /// [`SalvageStatus`] the engine assigns
    /// ([`UnverifiedCause::OverEntryCeiling`]), not a [`crate::Error`] the
    /// scanner raises, so it can never abort a run over one entry. A
    /// scanner that instead checks a size inside [`Self::verify`] and
    /// `?`-propagates the failure reintroduces the exact defect three fix
    /// rounds of Task 3c chased.
    ///
    /// **That is documented, not enforced, and saying so is more useful
    /// than claiming otherwise.** An earlier wording here called the defect
    /// "unrepresentable"; it is not. [`Self::verify`] still returns
    /// `Result` — it must, so a scanner CAN report a genuine run-level
    /// fault — so the wrong thing is still expressible. What changed is
    /// that the right thing now exists, costs one method, and is written
    /// into [`Self::verify`]'s contract below.
    fn max_whole_entry(&self) -> u64 {
        u64::MAX
    }

    /// Decide what was actually proven about a candidate's payload, after
    /// discovery — called once per candidate, in file order, by
    /// [`annotate_candidates`].
    ///
    /// # The contract on `Err`
    ///
    /// **`Err` here aborts the WHOLE run**, discarding every entry already
    /// recovered, so it is reserved for a fault that really is about the
    /// run and not about this one entry (the destination is gone, the
    /// source handle itself failed). Everything a single candidate can be
    /// wrong about — a short read, a bad checksum, a method this build
    /// cannot decode, a device error partway through one payload — is a
    /// [`SalvageStatus`], never an `Err`. `zip_salvage.rs`'s own
    /// `verify_candidate` has stated this in prose since Stage 1 and folds
    /// every one of those; ARC's did not, for one case (its size ceiling),
    /// and that single `?` cost three fix rounds. The size ceiling now
    /// belongs to [`Self::max_whole_entry`], which the engine applies, so
    /// there is nothing left for an implementation to `?` on.
    ///
    /// # What an implementation may assume
    ///
    /// The candidate's bounded length (`available_len` if the payload was
    /// truncated, else `declared_len`) is already **at or below**
    /// [`Self::max_whole_entry`] — [`annotate_candidates`] answers
    /// [`UnverifiedCause::OverEntryCeiling`] itself, without calling this,
    /// for anything above it. So an implementation that decodes whole may
    /// size a buffer from that length without re-checking it.
    ///
    /// The default answers [`SalvageStatus::Unattested`] unconditionally,
    /// which is the least this method can claim: it reads nothing, decodes
    /// nothing and compares nothing, so "nothing attests that this candidate
    /// is an entry at all" is exactly what happened. A format that carries a
    /// checksum overrides this to decode the payload — reusing its own
    /// existing codec machinery, never inventing a new one here — and
    /// compare it against [`Candidate::verifier`]; a format that carries a
    /// self-verifying HEADER and no content checksum (tar) overrides it to
    /// check that header and answer [`SalvageStatus::Complete`].
    ///
    /// **It answered `Complete` until Salvage Stage 3**, and the change is
    /// not cosmetic. `Complete` asserts that every declared byte was present
    /// *and the header self-verified* — two claims this default has never
    /// been in a position to make — and it routes to **exit 0**, so a
    /// checksumless scanner that simply forgot to override was reporting a
    /// clean recovery over candidates nothing had examined. `Unattested`
    /// routes to exit 4 and says why on the row. The same argument
    /// [`Candidate::new`] makes for its own four defaults: the default is
    /// the answer that asserts the LEAST, so forgetting to override is
    /// visible rather than flattering.
    fn verify(&self, _src: &mut dyn SeekRead, _candidate: &Candidate) -> Result<SalvageStatus> {
        Ok(SalvageStatus::Unattested)
    }

    /// Decode one salvaged entry's payload out of the archive at
    /// `archive_path` and into `out`, answering whether the entry's whole
    /// declared length was produced.
    ///
    /// # Why this is on the trait at all (final whole-branch review, F2)
    ///
    /// `0.6.0` spends a breaking release making this trait implementable
    /// from outside. It was implementable and not USABLE: the ops layer's
    /// payload dispatch was a private `match` on a [`crate::FormatId`]
    /// string, so a scanner written outside this workspace got
    /// [`salvage_all`] and none of the behaviour that makes `salvage` what
    /// it is. This method is where a scanner's write half now DECLARES
    /// itself, beside the scan half, in one place a reader can see both.
    ///
    /// **It does not by itself make an out-of-tree scanner reachable from
    /// `stuffr salvage`, and pretending otherwise would be the same
    /// over-claim F2 named.** The ops layer still resolves a format to a
    /// scanner by name, with no registration point for a third party to add
    /// one; closing that needs a salvage registry and is Stage 3's. What
    /// changed is that the seam is now declared rather than scattered — the
    /// five in-tree scanners implement this method and the ops layer calls
    /// it, so the next one has a signature to fit rather than a convention
    /// to guess.
    ///
    /// # The contract
    ///
    /// `Ok(true)` means every declared byte was produced; `Ok(false)` means
    /// the payload ran short or the decoder stopped early, which the caller
    /// renders as a `.partial` write. **`Err` is reserved for the same
    /// narrow set [`Self::verify`] reserves it for** — with one addition
    /// the ops layer relies on: [`crate::Error::Io`] here is treated as a
    /// fact about THIS entry (the archive could not be re-opened or read
    /// for it, the destination refused the bytes) and folded into that
    /// entry's own outcome, never the run's. A size refusal is never an
    /// `Err`: it belongs to [`Self::max_whole_entry`], which the engine
    /// applies before `verify` is ever called.
    ///
    /// The default refuses. A scanner that only ever reports
    /// [`SalvageStatus::Unverified`] has nothing to write, and inheriting a
    /// refusal is the honest answer for it; the alternative — a required
    /// method — would be a breaking change to every implementation for no
    /// gain to one that cannot write anything anyway.
    fn write_payload(
        &self,
        _archive_path: &Path,
        _entry: &SalvagedEntry,
        _compressed_len: u64,
        _out: &mut dyn Write,
    ) -> Result<bool> {
        Err(Error::Unsupported(
            "this salvage scanner has no payload writer".to_string(),
        ))
    }
}

/// Walk `scan` over `src`, collecting every candidate it reports into a
/// [`SalvageOutcome`].
///
/// **Requires a seekable source.** Scanning searches back and forth over the
/// archive rather than reading it once in order, so this takes
/// `&mut dyn SeekRead` rather than the ladder's `Box<dyn Source>` (whose
/// `as_seek()` may be `None` for a pipe). A caller sitting on a
/// non-seekable source must spool it first — exactly as the `arj` and `zoo`
/// containers already do, reporting `Rung::Spilled` — which is a later
/// task's ops-layer concern, not this function's.
///
/// Every candidate's length is bounded against the run's entry ceiling
/// (`policy.max_entry`, narrowed by [`SalvageScan::max_whole_entry`])
/// **before** anything is sized from it — in [`annotate_candidates`], one
/// step before the only place that could size anything, and as a per-entry
/// [`UnverifiedCause::OverEntryCeiling`] rather than a [`crate::Error`] that ends
/// the run.
pub fn salvage_all(
    scan: &mut dyn SalvageScan,
    src: &mut dyn SeekRead,
    policy: &SalvagePolicy,
) -> Result<SalvageOutcome> {
    let candidates = collect_candidates(scan, src)?;
    annotate_candidates(&*scan, src, candidates, policy)
}

/// The resync loop alone: find a candidate, advance strictly past it,
/// repeat. Never reads a payload, never sizes anything from a declared
/// length, and never calls [`SalvageScan::verify`] — see
/// [`annotate_candidates`] for both the entry ceiling and the verification.
///
/// Exposed separately from [`salvage_all`] so a format that reconciles more
/// than one candidate source (zip's central-directory fallback) can collect
/// from each source before merging, and annotate the merged list once.
///
/// # The entry ceiling used to be enforced here, and was an `Err`
///
/// Salvage Stage 2 Task 3c's fix round 3 moved it to
/// [`annotate_candidates`] and turned it into a status. Two things were
/// wrong with it here, and only the second is obvious:
///
/// 1. **It refused a whole archive over one entry.** Measured at the CLI on
///    a 78-byte two-entry ARC, `stuffr salvage --list --max-entry 4`
///    printed no rows and exited 6 — the healthy first entry, which the
///    same binary recovers byte-for-byte at the default ceiling, simply
///    lost. That is the same defect the ARC scanner's own ceiling had, in
///    the engine rather than in a format.
/// 2. **It was one of two ceilings, checked in two places**, so which
///    disposition an over-ceiling entry got depended on which check saw it
///    first. There is now one rule, applied once, to both figures.
///
/// Nothing is weakened by the move: this loop sizes nothing from a declared
/// length (it only advances `from` by it), so "bounded before anything is
/// sized from it" holds exactly as before — the bound simply sits one
/// function later, still strictly before the only allocation there is.
pub fn collect_candidates(
    scan: &mut dyn SalvageScan,
    src: &mut dyn SeekRead,
) -> Result<Vec<Candidate>> {
    let mut candidates = Vec::new();
    let mut from = 0u64;

    while let Some(candidate) = scan.next_candidate(src, from)? {
        // Advance strictly past this candidate so the scan always makes
        // progress — but NEVER by a length this candidate merely CLAIMED.
        // `available_len.is_some()` means the header's own declared length
        // disagreed with the file (there were fewer bytes left than it
        // said), and a header that lied about its size once cannot be
        // trusted to say how much of the source its "payload" consumed
        // either. This used to advance by `available_len` — the bytes
        // actually present, not the declared figure — on the reasoning that
        // a truncated entry has nothing worth finding after it anyway. That
        // is true for a genuinely truncated REAL last entry, but the
        // engine cannot tell that case apart from a coincidental
        // false-positive header sitting on top of noise with a garbage
        // multi-hundred-megabyte declared length: `available_len` is the
        // same "everything left in the file" figure either way. Salvage
        // Stage 2 Task 3's fix round 1 found exactly this — one
        // coincidental marker+name match in an ARC noise corpus, with a
        // declared length far past the file, jumped the scan straight to
        // EOF in one step and silently dropped every real entry that
        // happened to sit between it and the end of the file: no error, no
        // `Partial`, no note, in the one verb whose entire job is not
        // losing entries silently. So a truncated candidate now advances
        // minimally — past its own marker byte only — so the very next
        // byte onward is examined for another header. That costs a few
        // bytes of re-scanning after a genuinely truncated real entry
        // (which has nothing left to find anyway, so the outcome is
        // unchanged); it is the entire fix for a phantom, whose lie no
        // longer costs the rest of the archive. An UNTRUNCATED candidate's
        // declared length was just checked against the file two lines
        // above — it fits, so it is not a lie, and advancing by it is
        // unchanged.
        let advance = if candidate.available_len.is_some() {
            1
        } else {
            candidate.declared_len.unwrap_or(1).max(1)
        };
        from = candidate.offset.saturating_add(advance);

        candidates.push(candidate);
    }

    Ok(candidates)
}

/// Turns already-discovered candidates — in file order — into the bounded,
/// verified, shadow-annotated entries a caller receives. See this module's
/// doc comment for what the passes (bound, verify, shadow) mean and why
/// they live here rather than per-format.
///
/// # The entry ceiling, in one place (fix round 3, Task 3c)
///
/// `policy.max_entry` narrowed by [`SalvageScan::max_whole_entry`] is the
/// run's ceiling on ONE entry, and this is the only place it is applied. A
/// candidate whose bounded length (`available_len` if the payload is
/// truncated, else `declared_len`) is over it is reported
/// [`SalvageStatus::Unverified`]`(`[`UnverifiedCause::OverEntryCeiling`]`)`
/// and [`SalvageScan::verify`] is **not called for it at all** — so nothing
/// is read and nothing is allocated, which was the whole purpose of the two
/// `Err`-raising checks this replaced, without their cost: neither the
/// engine's own nor a scanner's could refuse one entry without ending the
/// run and discarding every entry already recovered.
///
/// The bounded length, never the declared one, is what is compared — the
/// rule [`Candidate::available_len`]'s own doc argues for and the reason a
/// truncated tail's garbage declaration cannot decide anything: a candidate
/// whose payload ran out can never cost more than the bytes that exist.
pub fn annotate_candidates(
    scan: &dyn SalvageScan,
    src: &mut dyn SeekRead,
    candidates: Vec<Candidate>,
    policy: &SalvagePolicy,
) -> Result<SalvageOutcome> {
    let ceiling = policy.max_entry.min(scan.max_whole_entry());
    let mut entries = Vec::with_capacity(candidates.len());
    // Earliest scan_position to report each distinct (name, declared_len,
    // verifier) triple seen so far — a candidate reporting one already in
    // here is shadowing that position. A degenerate zero-length checksum
    // (every empty file, every directory entry) is never pushed here and
    // never looked up here — see `is_degenerate` below.
    let mut seen: Vec<(String, Option<u64>, Verifier, usize)> = Vec::new();
    // Earliest scan_position to report each distinct NAME, regardless of
    // anything else about the record — the weaker, directly-observed fact
    // `SalvagedEntry::collides_with` reports, and the one `stuffr list`'s
    // own fidelity warning is about. Kept separate from `seen` above rather
    // than folded into it: that list is deliberately full of guards against
    // over-claiming a COPY (a verifier must exist, the declared length must
    // agree, a zero-length checksum never counts), and every one of them
    // would be wrong here, where nothing about content is being claimed.
    let mut names_seen: Vec<(String, usize)> = Vec::new();

    for (scan_position, candidate) in candidates.into_iter().enumerate() {
        // Bounded length, then the ceiling, then — only if it fits —
        // verification. `verify` is what reads and (for a whole-decoding
        // format) allocates, so refusing above it is refusing before the
        // allocation, which is the property the two `Err`s this replaced
        // existed for.
        let bounded_len = candidate.available_len.or(candidate.declared_len);
        let status = match bounded_len {
            Some(needed) if needed > ceiling => {
                SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling { needed, ceiling })
            }
            _ => scan.verify(src, &candidate)?,
        };

        let declared_len = candidate.declared_len;
        let verifier = candidate.verifier;
        // A checksum over zero declared bytes is the same value for every
        // empty entry that ever existed (CRC-32 and CRC-16/ARC of an empty
        // input are both fixed constants) and proves nothing about any two
        // of them being copies of each other — measured against a real
        // archive during this task's fix round: every `__init__.py` and
        // every directory entry collapsed onto one shadow chain under a
        // checksum-only match.
        let is_degenerate = declared_len == Some(0);

        let shadows = if is_degenerate {
            None
        } else {
            verifier.and_then(|v| {
                seen.iter()
                    .find(|&&(ref seen_name, seen_len, seen_verifier, _)| {
                        seen_verifier == v
                            && seen_len == declared_len
                            && *seen_name == candidate.meta.name
                    })
                    .map(|&(_, _, _, earlier_position)| earlier_position)
            })
        };

        if !is_degenerate
            && shadows.is_none()
            && let Some(v) = verifier
        {
            seen.push((candidate.meta.name.clone(), declared_len, v, scan_position));
        }

        // The two annotations are mutually exclusive: a record proven to be
        // a copy is reported as a shadow and nothing else, because "this is
        // a duplicate of #2" is strictly more informative than "something
        // earlier used this name". Everything else that repeats a name is a
        // collision.
        let earliest_with_name = names_seen
            .iter()
            .find(|(name, _)| *name == candidate.meta.name)
            .map(|&(_, position)| position);
        let collides_with = if shadows.is_some() {
            None
        } else {
            earliest_with_name
        };
        if earliest_with_name.is_none() {
            names_seen.push((candidate.meta.name.clone(), scan_position));
        }

        let marked_deleted = candidate.marked_deleted;
        entries.push(
            SalvagedEntry::new(
                scan_position,
                candidate.offset,
                candidate.payload_start,
                candidate.meta,
                status,
            )
            .with_shadows(shadows)
            .with_collides_with(collides_with)
            .with_marked_deleted(marked_deleted),
        );
    }

    Ok(SalvageOutcome {
        entries,
        sightings: Vec::new(),
        walk_stop: None,
    })
}

/// Streams `reader` into `out`, bounded to `expected_len` bytes — the
/// entry's own declared uncompressed size — so a small compressed input
/// cannot expand arbitrarily far past what its own header claims. Never
/// buffers a growable copy of the payload, only a fixed 64 KiB window, and
/// never trusts the decoder to stop on its own.
///
/// Returns `true` when exactly `expected_len` bytes were produced, `false`
/// when `reader` ran out or errored first — [`crate`]'s own convention
/// throughout this module: "did not fully recover" is a fact a caller
/// reports (as [`SalvageStatus::Partial`]'s own two causes already are),
/// never a [`crate::Error`] that aborts the whole run over one entry.
///
/// Lives here, rather than duplicated once per format module, because it
/// has no format-specific knowledge at all — only `Read`/`Write` and a byte
/// count. Task 3c moved it up from `entries.rs`, where it was zip-shaped
/// plumbing bolted onto a zip-only write path; every [`SalvageScan`]
/// implementation's own `write_payload` (zip's, ARC's, and any later
/// format's) calls this same function once it has a decoded reader in
/// hand, so a truncated or overrunning decode is reported identically
/// regardless of which format produced it.
pub fn stream_bounded_copy(
    mut reader: impl Read,
    expected_len: u64,
    out: &mut dyn Write,
) -> Result<bool> {
    let mut buf = [0u8; 64 * 1024];
    let mut produced = 0u64;
    loop {
        if produced >= expected_len {
            return Ok(true);
        }
        let want = ((expected_len - produced) as usize).min(buf.len());
        let n = match reader.read(&mut buf[..want]) {
            Ok(0) => return Ok(false),
            Ok(n) => n,
            Err(_) => return Ok(false),
        };
        out.write_all(&buf[..n])?;
        produced += n as u64;
    }
}

#[cfg(test)]
mod tests {
    use std::io::{Cursor, Read, Seek, SeekFrom};

    use super::*;

    fn bytes(data: &[u8]) -> Cursor<Vec<u8>> {
        Cursor::new(data.to_vec())
    }

    /// A reader that panics if asked to read more bytes in one call than its
    /// backing buffer holds. `salvage_all`'s own loop never reads a
    /// candidate's payload at all (see the module doc), so a correct
    /// implementation never trips this — it exists to catch a regression
    /// that reads before bounding, the exact shape this project has shipped
    /// six times across `cpio`, `ar` and `zip`.
    struct PanickingReader(Cursor<Vec<u8>>);

    impl Read for PanickingReader {
        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
            let backing_len = self.0.get_ref().len();
            assert!(
                buf.len() <= backing_len,
                "oversized read requested: {} bytes against a {backing_len}-byte source \
                 — an allocation or read was sized from a declared length before it was bounded",
                buf.len()
            );
            self.0.read(buf)
        }
    }

    impl Seek for PanickingReader {
        fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
            self.0.seek(pos)
        }
    }

    fn panicking_reader(len: usize) -> PanickingReader {
        PanickingReader(Cursor::new(vec![0u8; len]))
    }

    /// A scanner that never finds anything, however many times it is asked.
    struct NeverFinds;

    impl SalvageScan for NeverFinds {
        fn next_candidate(
            &mut self,
            _src: &mut dyn SeekRead,
            _from: u64,
        ) -> Result<Option<Candidate>> {
            Ok(None)
        }
    }

    /// A scanner that reports exactly one candidate, declaring the given
    /// length, then ends the scan. It never touches `src` itself — it
    /// exists purely to hand `salvage_all` a declared length to bound.
    struct DeclaresLength(u64);

    impl SalvageScan for DeclaresLength {
        fn next_candidate(
            &mut self,
            _src: &mut dyn SeekRead,
            from: u64,
        ) -> Result<Option<Candidate>> {
            if from > 0 {
                return Ok(None);
            }
            Ok(Some(Candidate {
                offset: 0,
                payload_start: 0,
                meta: EntryMeta::file("absurd"),
                declared_len: Some(self.0),
                verifier: None,
                available_len: None,
                marked_deleted: false,
            }))
        }
    }

    /// A scanner reporting one candidate whose header DECLARES `declared`
    /// bytes while the source holds only `available` of them — the
    /// truncated-tail shape, the one a real scanner reports by setting
    /// [`Candidate::available_len`].
    struct DeclaresMoreThanIsThere {
        declared: u64,
        available: u64,
    }

    impl SalvageScan for DeclaresMoreThanIsThere {
        fn next_candidate(
            &mut self,
            _src: &mut dyn SeekRead,
            from: u64,
        ) -> Result<Option<Candidate>> {
            if from > 0 {
                return Ok(None);
            }
            Ok(Some(Candidate {
                offset: 0,
                payload_start: 0,
                meta: EntryMeta::file("cut-short"),
                declared_len: Some(self.declared),
                verifier: Some(Verifier::Crc32(1)),
                available_len: Some(self.available),
                marked_deleted: false,
            }))
        }
    }

    #[test]
    fn a_scanner_that_finds_nothing_yields_no_entries() {
        let out = salvage_all(
            &mut NeverFinds,
            &mut bytes(&[0u8; 4096]),
            &SalvagePolicy::default(),
        );
        assert!(
            out.unwrap().entries.is_empty(),
            "a scanner finding nothing must yield nothing"
        );
    }

    /// The ceiling test that matters most: a reader that PANICS on an
    /// oversized read, so this test fails if the read happens at all — not
    /// merely if the wrong status comes back. See this module's
    /// falsification note in the task report for what happens when the
    /// bound is moved below a read.
    ///
    /// Fix round 3 (Task 3c): the refusal is a per-entry STATUS now, not an
    /// `Err` — so this asserts both halves, that nothing was read AND that
    /// the run completed with the entry reported. The old assertion
    /// (`unwrap_err()`, exit 6) proved only the first and was satisfied by
    /// the behaviour that lost every other entry in the archive.
    #[test]
    fn an_absurd_declared_length_is_refused_before_the_allocation_it_would_size() {
        let mut scan = DeclaresLength(u64::MAX);
        let out = salvage_all(
            &mut scan,
            &mut panicking_reader(4096),
            &SalvagePolicy::default(),
        )
        .expect("an entry over the ceiling must not abort the run");
        assert_eq!(out.entries.len(), 1, "the entry is still reported");
        assert_eq!(
            out.entries[0].status,
            SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
                needed: u64::MAX,
                ceiling: MAX_SALVAGE_ENTRY,
            })
        );
    }

    /// The other half of the same rule, and the one the CLI reaches:
    /// everything AROUND an over-ceiling entry is still recovered. Measured
    /// before this fix as `stuffr salvage --list --max-entry 4` over a
    /// 78-byte ARC — no rows at all, exit 6.
    #[test]
    fn an_entry_over_the_ceiling_does_not_cost_the_entries_around_it() {
        struct TwoCandidates;
        impl SalvageScan for TwoCandidates {
            fn next_candidate(
                &mut self,
                _src: &mut dyn SeekRead,
                from: u64,
            ) -> Result<Option<Candidate>> {
                let (offset, len, name) = match from {
                    0 => (0u64, 16u64, "small-first"),
                    1..=16 => (17u64, 4096u64, "huge-second"),
                    _ => return Ok(None),
                };
                Ok(Some(Candidate {
                    offset,
                    payload_start: offset,
                    meta: EntryMeta::file(name),
                    declared_len: Some(len),
                    verifier: Some(Verifier::Crc32(len as u32)),
                    available_len: None,
                    marked_deleted: false,
                }))
            }
        }

        let out = salvage_all(
            &mut TwoCandidates,
            &mut bytes(&[0u8; 8192]),
            &SalvagePolicy {
                max_entry: 64,
                ..SalvagePolicy::default()
            },
        )
        .expect("one over-ceiling entry must never abort the run");
        assert_eq!(out.entries.len(), 2);
        // `TwoCandidates` does not override `verify`, so the first entry
        // takes the trait's default — `Unattested` since Salvage Stage 3,
        // where it was `Complete`. Neither figure is what this test is
        // about: the point is that the SECOND entry's ceiling refusal did
        // not cost the first entry its row.
        assert_eq!(out.entries[0].status, SalvageStatus::Unattested);
        assert_eq!(
            out.entries[1].status,
            SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
                needed: 4096,
                ceiling: 64,
            })
        );
    }

    /// A scanner's own [`SalvageScan::max_whole_entry`] narrows the policy's
    /// ceiling — the composition ARC relies on, proven against a mock so it
    /// does not depend on ARC's own 256 MiB constant. The candidate here is
    /// well under `policy.max_entry` and over the scanner's own figure, so
    /// only the `min` can refuse it; the panicking reader proves it is
    /// refused without being read.
    #[test]
    fn a_scanners_own_whole_entry_ceiling_narrows_the_policys() {
        struct DecodesWhole;
        impl SalvageScan for DecodesWhole {
            fn next_candidate(
                &mut self,
                _src: &mut dyn SeekRead,
                from: u64,
            ) -> Result<Option<Candidate>> {
                if from > 0 {
                    return Ok(None);
                }
                Ok(Some(Candidate {
                    offset: 0,
                    payload_start: 0,
                    meta: EntryMeta::file("whole"),
                    declared_len: Some(2048),
                    verifier: Some(Verifier::Crc32(7)),
                    available_len: None,
                    marked_deleted: false,
                }))
            }
            fn max_whole_entry(&self) -> u64 {
                1024
            }
            fn verify(&self, _src: &mut dyn SeekRead, _c: &Candidate) -> Result<SalvageStatus> {
                panic!("verify must not run for a candidate over the ceiling");
            }
        }

        let out = salvage_all(
            &mut DecodesWhole,
            &mut panicking_reader(4096),
            &SalvagePolicy::default(),
        )
        .expect("a scanner ceiling is a per-entry refusal, not a run failure");
        assert_eq!(
            out.entries[0].status,
            SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
                needed: 2048,
                // The SCANNER's figure, not the policy's 4 GiB — the `min`
                // is what this test is really pinning.
                ceiling: 1024,
            })
        );
    }

    #[test]
    fn a_declared_length_within_the_ceiling_is_collected_rather_than_refused() {
        let mut scan = DeclaresLength(4096);
        let out = salvage_all(
            &mut scan,
            &mut panicking_reader(4096),
            &SalvagePolicy::default(),
        )
        .unwrap();
        assert_eq!(out.entries.len(), 1);
        let entry = &out.entries[0];
        assert_eq!(entry.scan_position, 0);
        assert_eq!(entry.offset, 0);
        // `DeclaresLength` does not override `verify`, so this is the
        // trait's default: `Unattested` since Salvage Stage 3, where it was
        // `Complete`. The test is about the entry being COLLECTED — its
        // declared length sits under the ceiling — not about which tier a
        // mock that checks nothing lands in.
        assert_eq!(entry.status, SalvageStatus::Unattested);
        assert_eq!(entry.shadows, None);
        assert_eq!(entry.collides_with, None);
    }

    /// A truncated candidate is COLLECTED, not dropped — the whole point of
    /// [`Candidate::available_len`]. Before it existed, `zip_salvage.rs`
    /// refused such a candidate outright and a truncated archive's last
    /// entry vanished with no row, no note and exit 0.
    #[test]
    fn a_candidate_whose_payload_ran_out_is_still_collected() {
        let mut scan = DeclaresMoreThanIsThere {
            declared: 4000,
            available: 1950,
        };
        let out = salvage_all(
            &mut scan,
            &mut panicking_reader(4096),
            &SalvagePolicy::default(),
        )
        .unwrap();
        assert_eq!(
            out.entries.len(),
            1,
            "a header found but not completable is a fact worth reporting"
        );
    }

    /// The ceiling is checked against what could be READ, not against what
    /// a truncated header CLAIMED. Otherwise a garbage length in a tail the
    /// scan has already established is not there would abort the entire run
    /// at exit 6 — losing every earlier entry over an absent one, in the
    /// verb that exists to rescue exactly that archive.
    #[test]
    fn a_truncated_candidate_is_bounded_by_what_is_there_not_by_what_it_claimed() {
        let mut scan = DeclaresMoreThanIsThere {
            declared: u64::MAX,
            available: 2048,
        };
        let out = salvage_all(
            &mut scan,
            &mut panicking_reader(4096),
            &SalvagePolicy::default(),
        )
        .expect("an absent tail must not abort a run that recovered real entries");
        assert_eq!(out.entries.len(), 1);
    }

    /// The complement, so the rule above cannot be read as "truncated
    /// candidates are never bounded": what IS there is still bounded, and a
    /// truncated candidate holding more available bytes than the ceiling
    /// allows is refused exactly as an untruncated one would be — reported
    /// [`UnverifiedCause::OverEntryCeiling`], never read (the reader
    /// panics if it is), and never allowed to end the run.
    #[test]
    fn a_truncated_candidate_whose_available_bytes_exceed_the_ceiling_is_refused() {
        let mut scan = DeclaresMoreThanIsThere {
            declared: u64::MAX,
            available: 4096,
        };
        let out = salvage_all(
            &mut scan,
            &mut panicking_reader(4096),
            &SalvagePolicy {
                max_entry: 1024,
                ..SalvagePolicy::default()
            },
        )
        .expect("a bounded refusal is per-entry, never a run failure");
        assert_eq!(
            out.entries[0].status,
            // `needed` is 4096 — the bytes that ARE there — never the
            // `u64::MAX` this candidate's header declared. Fix round 4
            // (NEW-F): the figures a user is shown are the ones the
            // comparison used.
            SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
                needed: 4096,
                ceiling: 1024,
            }),
            "an over-ceiling entry is unverified, not truncated: nobody read it"
        );
    }

    #[test]
    fn the_default_policy_is_recovery_biased() {
        let policy = SalvagePolicy::default();
        assert_eq!(policy.partial, PartialPolicy::Keep);
        assert_eq!(policy.max_entry, MAX_SALVAGE_ENTRY);
        assert!(!policy.strict);
    }

    // -----------------------------------------------------------------
    // Task 4: `verify` dispatch and shadow detection, proven against a
    // mock — same discipline as the rest of this module, since neither
    // mechanism needs to know which format it is annotating.
    // -----------------------------------------------------------------

    /// Reports a fixed, caller-scripted sequence of candidates and answers
    /// `verify` from the SAME script rather than decoding anything — this
    /// module's engine is proven against mocks, and verification/shadow
    /// detection are no exception. Each entry names its own `name` and
    /// `declared_len` (rather than a fixed, offset-derived name) so a test
    /// can freely vary any one of the three fields shadow detection now
    /// requires to agree.
    struct ScriptedCandidates {
        plan: Vec<(&'static str, Option<u64>, Option<Verifier>, SalvageStatus)>,
        next: usize,
    }

    impl SalvageScan for ScriptedCandidates {
        fn next_candidate(
            &mut self,
            _src: &mut dyn SeekRead,
            _from: u64,
        ) -> Result<Option<Candidate>> {
            if self.next >= self.plan.len() {
                return Ok(None);
            }
            let offset = self.next as u64;
            let (name, declared_len, verifier, _) = self.plan[self.next];
            self.next += 1;
            Ok(Some(Candidate {
                offset,
                payload_start: offset,
                meta: EntryMeta::file(name),
                declared_len,
                verifier,
                available_len: None,
                marked_deleted: false,
            }))
        }

        fn verify(&self, _src: &mut dyn SeekRead, candidate: &Candidate) -> Result<SalvageStatus> {
            Ok(self.plan[candidate.offset as usize].3)
        }
    }

    /// `verify` decides each entry's status — the whole point of Task 4:
    /// this used to be a hardcoded `Complete` for every candidate, which was
    /// wrong for any format carrying a real checksum.
    #[test]
    fn verify_decides_each_entrys_status_not_a_hardcoded_default() {
        let mut scan = ScriptedCandidates {
            plan: vec![
                (
                    "a",
                    Some(1),
                    Some(Verifier::Crc32(1)),
                    SalvageStatus::Intact,
                ),
                ("b", Some(1), None, SalvageStatus::Complete),
                (
                    "c",
                    Some(1),
                    Some(Verifier::Crc32(2)),
                    SalvageStatus::Partial,
                ),
            ],
            next: 0,
        };
        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
            .expect("scripted candidates must annotate cleanly");
        assert_eq!(out.entries[0].status, SalvageStatus::Intact);
        assert_eq!(out.entries[1].status, SalvageStatus::Complete);
        assert_eq!(out.entries[2].status, SalvageStatus::Partial);
    }

    /// A later candidate reporting the SAME name, declared_len AND verifier
    /// as an earlier one is its shadow — pointing at the earliest position,
    /// not merely "some" earlier one.
    #[test]
    fn a_later_candidate_sharing_an_earlier_verifier_is_marked_as_its_shadow() {
        let mut scan = ScriptedCandidates {
            plan: vec![
                (
                    "dup",
                    Some(1),
                    Some(Verifier::Crc32(7)),
                    SalvageStatus::Intact,
                ),
                (
                    "other",
                    Some(1),
                    Some(Verifier::Crc32(9)),
                    SalvageStatus::Intact,
                ),
                (
                    "dup",
                    Some(1),
                    Some(Verifier::Crc32(7)),
                    SalvageStatus::Intact,
                ),
            ],
            next: 0,
        };
        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
            .expect("scripted candidates must annotate cleanly");
        assert_eq!(out.entries[0].shadows, None);
        assert_eq!(out.entries[1].shadows, None);
        assert_eq!(
            out.entries[2].shadows,
            Some(0),
            "must point at the earliest matching position"
        );
    }

    /// A candidate with no verifier at all (a format with no checksum, or a
    /// zip data-descriptor entry) must never be reported as shadowing or
    /// shadowed — there is nothing to measure, so nothing is claimed.
    #[test]
    fn candidates_with_no_verifier_never_shadow_each_other() {
        let mut scan = ScriptedCandidates {
            plan: vec![
                ("a", Some(1), None, SalvageStatus::Complete),
                ("a", Some(1), None, SalvageStatus::Complete),
            ],
            next: 0,
        };
        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
            .expect("scripted candidates must annotate cleanly");
        assert_eq!(out.entries[0].shadows, None);
        assert_eq!(out.entries[1].shadows, None);
    }

    /// Two candidates whose checksum AND declared_len agree but whose NAME
    /// differs must never be linked — the fix-round requirement that a
    /// checksum match alone is not enough, only the full
    /// `(name, declared_len, verifier)` triple.
    #[test]
    fn a_matching_verifier_under_a_different_name_is_never_marked_as_a_shadow() {
        let mut scan = ScriptedCandidates {
            plan: vec![
                (
                    "one.txt",
                    Some(4),
                    Some(Verifier::Crc32(42)),
                    SalvageStatus::Intact,
                ),
                (
                    "two.txt",
                    Some(4),
                    Some(Verifier::Crc32(42)),
                    SalvageStatus::Intact,
                ),
            ],
            next: 0,
        };
        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
            .expect("scripted candidates must annotate cleanly");
        assert_eq!(
            out.entries[1].shadows, None,
            "same checksum, different name — not a shadow"
        );
    }

    /// The degenerate case: every empty entry (and, in zip, every directory
    /// entry) checksums to the same fixed constant over zero declared
    /// bytes. Even under the SAME name, a zero-length checksum must never
    /// mark a shadow — the value proves nothing, unlike a real collision.
    #[test]
    fn a_zero_length_declared_verifier_never_shadows_even_under_a_matching_name() {
        let mut scan = ScriptedCandidates {
            plan: vec![
                (
                    "empty.txt",
                    Some(0),
                    Some(Verifier::Crc32(0)),
                    SalvageStatus::Complete,
                ),
                (
                    "empty.txt",
                    Some(0),
                    Some(Verifier::Crc32(0)),
                    SalvageStatus::Complete,
                ),
            ],
            next: 0,
        };
        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
            .expect("scripted candidates must annotate cleanly");
        assert_eq!(out.entries[0].shadows, None);
        assert_eq!(
            out.entries[1].shadows, None,
            "a zero-byte checksum must never mark a shadow, even under an identical name"
        );
        assert_eq!(
            out.entries[1].collides_with,
            Some(0),
            "the NAME still repeats, and that is a directly observed fact no checksum \
             guard applies to"
        );
    }

    // -----------------------------------------------------------------
    // Name collisions: the second annotation, separate from shadowing.
    // -----------------------------------------------------------------

    /// The finding this annotation closes: two records under one name whose
    /// content does NOT agree are what `stuffr list` calls shadowed, and
    /// what salvage marked in no way at all — because its own `shadows`
    /// means something stricter.
    #[test]
    fn a_repeated_name_with_different_content_is_reported_as_a_collision() {
        let mut scan = ScriptedCandidates {
            plan: vec![
                (
                    "dup.txt",
                    Some(21),
                    Some(Verifier::Crc32(7)),
                    SalvageStatus::Intact,
                ),
                (
                    "other.txt",
                    Some(21),
                    Some(Verifier::Crc32(9)),
                    SalvageStatus::Intact,
                ),
                (
                    "dup.txt",
                    Some(21),
                    Some(Verifier::Crc32(11)),
                    SalvageStatus::Intact,
                ),
            ],
            next: 0,
        };
        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
            .expect("scripted candidates must annotate cleanly");
        assert_eq!(out.entries[2].shadows, None, "the content does not agree");
        assert_eq!(
            out.entries[2].collides_with,
            Some(0),
            "but the name does, and that is the fact `list` warns about"
        );
        assert_eq!(out.entries[0].collides_with, None);
        assert_eq!(out.entries[1].collides_with, None);
    }

    /// The two annotations are mutually exclusive: a record PROVEN to be a
    /// copy says so, and does not also report the weaker fact. Reporting
    /// both would make every shadow print two markers for one relationship.
    #[test]
    fn a_proven_shadow_is_not_also_reported_as_a_name_collision() {
        let mut scan = ScriptedCandidates {
            plan: vec![
                (
                    "dup.txt",
                    Some(21),
                    Some(Verifier::Crc32(7)),
                    SalvageStatus::Intact,
                ),
                (
                    "dup.txt",
                    Some(21),
                    Some(Verifier::Crc32(7)),
                    SalvageStatus::Intact,
                ),
            ],
            next: 0,
        };
        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
            .expect("scripted candidates must annotate cleanly");
        assert_eq!(out.entries[1].shadows, Some(0));
        assert_eq!(out.entries[1].collides_with, None);
    }

    /// A third record under a name two earlier ones already used points at
    /// the EARLIEST of them, the same rule `shadows` follows — so a chain of
    /// collisions names one origin rather than each pointing at its
    /// predecessor.
    #[test]
    fn a_third_record_under_one_name_names_the_earliest_position() {
        let mut scan = ScriptedCandidates {
            plan: vec![
                (
                    "dup",
                    Some(4),
                    Some(Verifier::Crc32(1)),
                    SalvageStatus::Intact,
                ),
                (
                    "dup",
                    Some(4),
                    Some(Verifier::Crc32(2)),
                    SalvageStatus::Intact,
                ),
                (
                    "dup",
                    Some(4),
                    Some(Verifier::Crc32(3)),
                    SalvageStatus::Intact,
                ),
            ],
            next: 0,
        };
        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
            .expect("scripted candidates must annotate cleanly");
        assert_eq!(out.entries[1].collides_with, Some(0));
        assert_eq!(out.entries[2].collides_with, Some(0));
    }

    /// The negative double: distinct names must never be marked, however
    /// much else they share. Without this, an annotation that fired on
    /// everything would pass every test above.
    #[test]
    fn distinct_names_are_never_reported_as_colliding() {
        let mut scan = ScriptedCandidates {
            plan: vec![
                (
                    "one.txt",
                    Some(4),
                    Some(Verifier::Crc32(42)),
                    SalvageStatus::Intact,
                ),
                (
                    "two.txt",
                    Some(4),
                    Some(Verifier::Crc32(42)),
                    SalvageStatus::Intact,
                ),
            ],
            next: 0,
        };
        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
            .expect("scripted candidates must annotate cleanly");
        assert_eq!(out.entries[0].collides_with, None);
        assert_eq!(out.entries[1].collides_with, None);
    }

    /// A collision needs no verifier at all, unlike a shadow. A format with
    /// no checksum (tar, cpio, ar — and a zip data-descriptor entry) can
    /// still repeat a name, and that repetition is exactly as real there.
    #[test]
    fn a_repeated_name_collides_even_with_no_verifier_to_compare() {
        let mut scan = ScriptedCandidates {
            plan: vec![
                ("same", Some(4), None, SalvageStatus::Complete),
                ("same", Some(4), None, SalvageStatus::Complete),
            ],
            next: 0,
        };
        let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
            .expect("scripted candidates must annotate cleanly");
        assert_eq!(
            out.entries[1].shadows, None,
            "no verifier, so nothing proves they are copies"
        );
        assert_eq!(
            out.entries[1].collides_with,
            Some(0),
            "but the name repeats, which needs no verifier to observe"
        );
    }

    /// Ruling S-I. `Candidate` is `#[non_exhaustive]`, so an out-of-crate
    /// scanner reaches it only through `new` plus the `with_*` setters —
    /// which means the DEFAULTS are now public API, not an implementation
    /// detail of six struct literals. Each of the four must assert the
    /// least: nothing declared, nothing to verify with, nothing missing,
    /// nothing deleted.
    #[test]
    fn a_new_candidate_claims_nothing_it_was_not_told() {
        let c = Candidate::new(7, 42, EntryMeta::file("probe"));
        assert_eq!(c.offset, 7);
        assert_eq!(c.payload_start, 42);
        assert_eq!(c.declared_len, None, "no length was declared");
        assert_eq!(c.verifier, None, "nothing can prove the content");
        assert_eq!(
            c.available_len, None,
            "a shortfall is MEASURED, never assumed"
        );
        assert!(!c.marked_deleted, "no record is deleted until one says so");
    }

    /// The four setters are the only route to those fields from another
    /// crate, so each one is pinned rather than trusted to the compiler.
    #[test]
    fn each_candidate_setter_reaches_its_own_field() {
        let c = Candidate::new(0, 0, EntryMeta::file("probe"))
            .with_declared_len(Some(9))
            .with_verifier(Some(Verifier::Crc16(0xbeef)))
            .with_available_len(Some(4))
            .with_marked_deleted(true);
        assert_eq!(c.declared_len, Some(9));
        assert_eq!(c.verifier, Some(Verifier::Crc16(0xbeef)));
        assert_eq!(c.available_len, Some(4));
        assert!(c.marked_deleted);
    }

    /// Same ruling, same reason, for the type every per-format
    /// `write_payload` test builds directly. Its three defaults are the
    /// annotations `annotate_candidates` adds, so an entry built by hand
    /// must carry none of them.
    #[test]
    fn a_new_salvaged_entry_carries_no_annotations() {
        let e = SalvagedEntry::new(3, 7, 42, EntryMeta::file("probe"), SalvageStatus::Complete);
        assert_eq!(e.scan_position, 3);
        assert_eq!(e.offset, 7);
        assert_eq!(e.payload_start, 42);
        assert_eq!(e.status, SalvageStatus::Complete);
        assert_eq!(e.shadows, None);
        assert_eq!(e.collides_with, None);
        assert!(!e.marked_deleted);

        let annotated = SalvagedEntry::new(0, 0, 0, EntryMeta::file("p"), SalvageStatus::Intact)
            .with_shadows(Some(1))
            .with_collides_with(Some(2))
            .with_marked_deleted(true);
        assert_eq!(annotated.shadows, Some(1));
        assert_eq!(annotated.collides_with, Some(2));
        assert!(annotated.marked_deleted);
    }

    /// Task 2-N: the note a caller prints for its sightings. None for none —
    /// a run with nothing refused must print nothing — and otherwise one
    /// group per (format, shape), each with its complete count and at most
    /// [`SIGHTING_OFFSETS_SHOWN`] offsets, in the order first seen.
    #[test]
    fn describe_sightings_groups_by_shape_and_bounds_what_it_lists() {
        assert_eq!(describe_sightings(&[]), None);

        let f = FormatId::new("fmt");
        let mut seen: Vec<Sighting> = (0..10u64)
            .map(|i| Sighting::new(f, i * 512, "a(s)"))
            .collect();
        seen.insert(1, Sighting::new(f, 7, "b(s)"));
        let note = describe_sightings(&seen).unwrap();
        assert!(
            note.contains("10 fmt a(s) at offset(s) 0, 512, 1024, 1536, 2048, 2560, 3072, 3584, and 2 more; and 1 fmt b(s) at offset(s) 7"),
            "{note}"
        );
        assert!(note.contains("`stuffr list`"), "{note}");
    }

    /// Task 4: the walk-stop note. A hole says what the brief requires —
    /// the entries after it are unreachable by construction, NOT absent,
    /// with the offset and the byte count — and a cut-short tail says the
    /// opposite, because nothing can be hiding in too few bytes for a record.
    #[test]
    fn describe_walk_stop_says_unreachable_for_a_hole_and_complete_for_a_cut() {
        let f = FormatId::new("fmt");
        let hole = describe_walk_stop(&WalkStop::new(
            f,
            1234,
            5000,
            WalkStopKind::Unreadable,
            "bad field",
        ));
        for needle in [
            "offset 1234",
            "5000 bytes",
            "bad field",
            "unreachable by construction",
            "not absent",
            "fmt",
        ] {
            assert!(hole.contains(needle), "missing {needle:?}: {hole}");
        }
        let cut = describe_walk_stop(&WalkStop::new(f, 90, 1, WalkStopKind::CutShort, "eof"));
        assert!(cut.contains("1 byte into the record at offset 90"), "{cut}");
        assert!(cut.contains("cut short"), "{cut}");
        assert!(!cut.contains("unreachable"), "{cut}");
        let shape = describe_walk_stop(&WalkStop::new(
            f,
            8,
            600,
            WalkStopKind::UnsupportedShape,
            "the symbol table",
        ));
        assert!(shape.contains("the symbol table"), "{shape}");
        assert!(shape.contains("not evidence of damage"), "{shape}");
        assert!(!shape.contains("unreachable by construction"), "{shape}");
    }
}