stuffr-core 0.5.0

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
//! 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::Complete`] — "every declared byte fit inside
//!   the source and the header self-verified, but nothing checked its
//!   content" — which is honest for a mock with no [`Candidate::verifier`]
//!   to check, and for a real format with no checksum at all (tar, cpio,
//!   ar). A format that DOES carry one (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, bound its
//! declared length, advance) and [`annotate_candidates`] (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 under the identical bound, merge, and
//! annotate the merged list exactly once, rather than annotating twice and
//! reconciling two already-decided outcomes.

use crate::archive::EntryMeta;
use crate::error::{Error, Result};
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.
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.
#[derive(Debug)]
pub struct Candidate {
    /// Byte offset of the record's header in the archive.
    pub offset: 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>,
}

/// Why an entry is [`SalvageStatus::Unverified`].
///
/// Two 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,
}

/// 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, cpio and
    /// ar, 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.
    Complete,
    /// 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,
}

/// One entry the scan recovered.
#[derive(Debug)]
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,
    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>,
}

/// The result of a scan: every entry the scanner recovered, in scan order.
///
/// Later tasks may extend this struct; the `entries` field stays public.
#[derive(Debug)]
pub struct SalvageOutcome {
    pub entries: Vec<SalvagedEntry>,
}

#[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 declared length, refused BEFORE allocation.
    pub max_entry: u64,
    /// Demand proof: partial skipped, ceiling fixed, nothing unverifiable.
    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>>;

    /// Decide what was actually proven about a candidate's payload, after
    /// discovery — called once per candidate, in file order, by
    /// [`annotate_candidates`].
    ///
    /// The default answers [`SalvageStatus::Complete`] unconditionally: a
    /// scanner with no real checksum (or, in this module's own tests, a mock
    /// that never populates [`Candidate::verifier`]) has nothing to check,
    /// and `Complete` is the honest claim for that case. A format that DOES
    /// carry 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`].
    fn verify(&self, _src: &mut dyn SeekRead, _candidate: &Candidate) -> Result<SalvageStatus> {
        Ok(SalvageStatus::Complete)
    }
}

/// 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 `declared_len` is bounded against `policy.max_entry`
/// **before** anything is sized from it: an oversized declaration is refused
/// with [`Error::ResourceLimit`] the moment it is seen, never after an
/// allocation or a read has already acted on it.
pub fn salvage_all(
    scan: &mut dyn SalvageScan,
    src: &mut dyn SeekRead,
    policy: &SalvagePolicy,
) -> Result<SalvageOutcome> {
    let candidates = collect_candidates(scan, src, policy)?;
    annotate_candidates(&*scan, src, candidates)
}

/// The resync loop alone: find a candidate, bound its declared length
/// against `policy.max_entry` **before** anything is sized from it, advance
/// strictly past it, repeat. Never reads a payload and never calls
/// [`SalvageScan::verify`] — see [`annotate_candidates`] for that.
///
/// 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 under the identical bound before merging.
pub fn collect_candidates(
    scan: &mut dyn SalvageScan,
    src: &mut dyn SeekRead,
    policy: &SalvagePolicy,
) -> Result<Vec<Candidate>> {
    let mut candidates = Vec::new();
    let mut from = 0u64;

    while let Some(candidate) = scan.next_candidate(src, from)? {
        // The ceiling is checked against what could actually be READ, not
        // against what the header DECLARED, and the two differ only for a
        // truncated candidate ([`Candidate::available_len`]). That is not a
        // loosening: this ceiling exists to refuse before anything is sized
        // from an attacker-controlled figure, and for a truncated candidate
        // nothing can ever be sized past the bytes that exist — the source
        // ends first. Checking the declared figure instead would abort the
        // WHOLE run (exit 6) over a garbage length in a tail the scan has
        // already established is not there, which is exactly the archive a
        // caller reached for salvage to rescue.
        let bounded_len = candidate.available_len.or(candidate.declared_len);
        if let Some(len) = bounded_len
            && len > policy.max_entry
        {
            return Err(Error::ResourceLimit(format!(
                "declared entry length {len} bytes exceeds the {}-byte salvage ceiling \
                 (see MAX_SALVAGE_ENTRY)",
                policy.max_entry
            )));
        }

        // Advance strictly past this candidate so the scan always makes
        // progress, regardless of what the scanner reported `from` as or
        // whether it declared a length at all. A truncated candidate
        // advances past the bytes that EXIST, not past the ones it claimed:
        // advancing by the declared figure would skip the rest of the file
        // (there is none) either way, but the available one keeps the loop's
        // arithmetic honest about where it actually is.
        let advance = bounded_len.unwrap_or(1).max(1);
        from = candidate.offset.saturating_add(advance);

        candidates.push(candidate);
    }

    Ok(candidates)
}

/// Turns already-discovered, already-bounded candidates — in file order —
/// into the verified, shadow-annotated entries a caller receives. See this
/// module's doc comment for what the two passes (verify, shadow) mean and
/// why they live here rather than per-format.
pub fn annotate_candidates(
    scan: &dyn SalvageScan,
    src: &mut dyn SeekRead,
    candidates: Vec<Candidate>,
) -> Result<SalvageOutcome> {
    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() {
        let status = 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));
        }

        entries.push(SalvagedEntry {
            scan_position,
            offset: candidate.offset,
            meta: candidate.meta,
            status,
            shadows,
            collides_with,
        });
    }

    Ok(SalvageOutcome { entries })
}

#[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,
                meta: EntryMeta::file("absurd"),
                declared_len: Some(self.0),
                verifier: None,
                available_len: None,
            }))
        }
    }

    /// 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,
                meta: EntryMeta::file("cut-short"),
                declared_len: Some(self.declared),
                verifier: Some(Verifier::Crc32(1)),
                available_len: Some(self.available),
            }))
        }
    }

    #[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 allocation happens at all
    /// — not merely if the wrong error code comes back. See this module's
    /// falsification note in the task report for what happens when the
    /// bound is moved below a read.
    #[test]
    fn an_absurd_declared_length_is_refused_before_the_allocation_it_would_size() {
        let mut scan = DeclaresLength(u64::MAX);
        let err = salvage_all(
            &mut scan,
            &mut panicking_reader(4096),
            &SalvagePolicy::default(),
        )
        .unwrap_err();
        assert_eq!(err.exit_code(), 6);
    }

    #[test]
    fn a_declared_length_within_the_ceiling_is_collected_as_complete() {
        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);
        assert_eq!(entry.status, SalvageStatus::Complete);
        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.
    #[test]
    fn a_truncated_candidate_whose_available_bytes_exceed_the_ceiling_is_refused() {
        let mut scan = DeclaresMoreThanIsThere {
            declared: u64::MAX,
            available: 4096,
        };
        let err = salvage_all(
            &mut scan,
            &mut panicking_reader(4096),
            &SalvagePolicy {
                max_entry: 1024,
                ..SalvagePolicy::default()
            },
        )
        .unwrap_err();
        assert_eq!(err.exit_code(), 6);
    }

    #[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,
                meta: EntryMeta::file(name),
                declared_len,
                verifier,
                available_len: None,
            }))
        }

        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"
        );
    }
}