stuffr_core/salvage.rs
1//! The salvage engine: format-agnostic scanning, bounding and collection.
2//!
3//! Salvage recovers entries from a damaged archive by scanning for records
4//! directly, rather than trusting a single index (a central directory, an
5//! EOCD) that may be truncated, zeroed, or — the motivating case — may
6//! shadow real records behind a repeated name. The per-format part of that
7//! (recognising a local header, computing a checksum) is a
8//! [`SalvageScan`] implementation; this module is everything that does not
9//! need to know which format it is scanning.
10//!
11//! **This module implements no per-format scanning at all.** It is proven
12//! entirely against mocks — [`SalvageScan`] is the seam Task 2 onward builds
13//! real scanners against (starting with zip).
14//!
15//! ## Verification and shadowing (Task 4)
16//!
17//! Two things this module now does, both format-agnostic:
18//!
19//! - **Deciding [`SalvageStatus`]** is delegated to [`SalvageScan::verify`],
20//! called once per candidate in [`annotate_candidates`]. The default
21//! answers [`SalvageStatus::Unattested`] — "nothing attests that this
22//! candidate is an entry at all" — which is the honest reading of a method
23//! that reads nothing, and the right answer for a mock with no
24//! [`Candidate::verifier`] and for a real format with neither a checksum
25//! nor a self-verifying header (cpio, ar). A format with a self-verifying
26//! header and no content checksum (tar) overrides it to answer
27//! [`SalvageStatus::Complete`]. A format that carries a content checksum
28//! (zip's CRC-32) overrides `verify` to
29//! decode the payload and compare it, answering `Intact` on agreement and
30//! `Partial` otherwise — never `Complete`, which is reserved for "no way
31//! to prove", not "tried and it disagreed". Earlier revisions of this
32//! module recorded every candidate as `Complete` unconditionally; that was
33//! a placeholder, not a claim about zip, and it was wrong for every zip
34//! entry the moment a real scanner carried a CRC-32.
35//! - **Detecting a shadowed record** (setting [`SalvagedEntry::shadows`]) is
36//! generic too: [`annotate_candidates`] remembers the first candidate to
37//! report each distinct `(name, declared_len, verifier)` triple and marks
38//! any LATER candidate reporting the identical triple as shadowing it.
39//! This is a MEASUREMENT, never an inference from [`EntryMeta::name`]
40//! alone repeating: two different files that happen to share a name must
41//! not be linked this way, only two records whose original writer
42//! computed the same checksum, over the same declared length, under the
43//! same name. The checksum alone is not enough either — fixed after this
44//! module's own fix-round review measured it against real archives: every
45//! empty file and every directory entry in a zip has `Crc32(0)` — a
46//! structural certainty for a zero-byte payload, not a collision — so a
47//! checksum-only match reported an ordinary Python package's dozen
48//! `__init__.py` files, and its directories, as duplicates of one another.
49//! The degenerate case is refused outright rather than papered over by the
50//! wider conjunction: a candidate with `declared_len == Some(0)` never
51//! shadows and is never shadowed, because a checksum over zero bytes is
52//! the same value for every empty entry that ever existed and proves
53//! nothing about any of them being copies of each other.
54//! - **Detecting a repeated NAME** ([`SalvagedEntry::collides_with`]) is a
55//! second, deliberately separate annotation, added after the final
56//! whole-branch review found the word "shadow" doing two jobs. The two
57//! facts are different and both worth printing: a shadow is a record
58//! MEASURED to be a copy of an earlier one, a collision is a record whose
59//! name an earlier one already used and whose content this scan could not
60//! prove identical. `stuffr list`'s own `Fidelity::EntryCountMismatch`
61//! warning is about the second, so on an archive whose duplicates differ,
62//! `list` reported two records shadowed while `salvage --list` marked
63//! none — one tool telling a user two contradictory things about the same
64//! eight records. None of the guards on shadow detection above apply to a
65//! collision, and applying them would be wrong: they exist because
66//! claiming two records are COPIES needs proof, and a collision claims
67//! nothing about content at all. The two are mutually exclusive — a
68//! proven copy is reported as a shadow and nothing else, since that is
69//! the strictly more informative claim.
70//!
71//! [`collect_candidates`] (the resync loop: find a candidate, advance) and
72//! [`annotate_candidates`] (bound + verify + shadow, above) are both
73//! exposed separately from [`salvage_all`] — which is just the two of them
74//! run in sequence — so a format that reconciles more than one candidate
75//! source (zip's central-directory fallback, `zip_salvage.rs`) can collect
76//! from each source, merge, and annotate the merged list exactly once
77//! rather than annotating twice and reconciling two already-decided
78//! outcomes. The run's ceiling on a single entry is applied once, in
79//! `annotate_candidates`, to whatever the merge produced — see
80//! [`UnverifiedCause::OverEntryCeiling`] for why it is a status there
81//! rather than a [`crate::Error`] anywhere.
82
83use std::io::{Read, Write};
84use std::path::Path;
85
86use crate::archive::EntryMeta;
87use crate::error::{Error, Result};
88use crate::format::FormatId;
89use crate::source::SeekRead;
90
91/// 4 GiB. A scanned length is untrustworthy TWICE over — attacker-controlled
92/// AND possibly corrupt — so this is a structural ceiling, not a budget.
93///
94/// Applied per ENTRY, never to the run: an entry over it is reported
95/// [`UnverifiedCause::OverEntryCeiling`] and skipped, and the rest of the
96/// archive is recovered as usual. It used to abort the run outright, which
97/// meant one absurd declaration cost every entry around it.
98pub const MAX_SALVAGE_ENTRY: u64 = 4 * 1024 * 1024 * 1024;
99
100/// What a format can prove about a candidate it found.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Verifier {
103 /// CRC-32, as zip, arj and gzip compute it.
104 Crc32(u32),
105 /// CRC-16/ARC, as lha, arc and zoo compute it.
106 Crc16(u16),
107}
108
109/// A record a scanner believes it found, before anything is decoded.
110///
111/// # Why this is `#[non_exhaustive]` AND has a constructor
112///
113/// Ruling S-I, deferred from Salvage Stage 2 Task 1 to Task 9 on purpose:
114/// this struct is the one shape [`SalvageScan::next_candidate`] *forces* an
115/// external implementor to build, so every field added to it used to break
116/// every out-of-crate scanner at once — and at `0.5.0` all four crates are
117/// published, so "out-of-crate" is not hypothetical. The attribute alone
118/// would have been the wrong fix (`CONTRIBUTING.md`'s `#[non_exhaustive]`
119/// section says why: it forbids `..` construction from another crate, which
120/// is exactly what an implementor must do), which is why it waited for
121/// [`Candidate::new`] — and for all five in-tree scanners to exist, so the
122/// constructor's shape could be read off real callers rather than guessed.
123///
124/// The three arguments [`Candidate::new`] takes are the three facts every
125/// scanner knows the instant it recognises a record. The four `with_*`
126/// setters cover the rest, each taking the field's own type unchanged so
127/// nothing is hidden behind a conversion.
128#[derive(Debug)]
129#[non_exhaustive]
130pub struct Candidate {
131 /// Byte offset of the record's header in the archive.
132 pub offset: u64,
133 /// Byte offset where this record's PAYLOAD begins — past whatever
134 /// fixed and variable-length header fields this format's record
135 /// carries (a zip local header's name/extra fields, ARC's fixed
136 /// 28-byte record, and so on).
137 ///
138 /// Task 3c added this field after `entries.rs`'s write path hardcoded
139 /// a ZIP local header's own 30-byte-plus-name-plus-extra layout as
140 /// the way to locate every format's payload — which crashed
141 /// (`Error::Io`, exit 1, the wildcard this project treats as a
142 /// defect) the moment a non-zip scanner (ARC) reported a candidate
143 /// near the end of the file, because reading 30 bytes from ITS offset
144 /// ran past EOF. Each scanner already computes this value once, at
145 /// discovery, to know where ITS OWN payload begins — this field
146 /// carries that computation forward instead of a consumer re-deriving
147 /// (and mis-deriving) it later. A future scanner (zoo, lha, arj) fills
148 /// this the same way; nothing outside the scanner that discovered a
149 /// candidate needs to know its record's own layout at all.
150 pub payload_start: u64,
151 pub meta: EntryMeta,
152 /// Declared payload length. `None` when the header does not carry one.
153 pub declared_len: Option<u64>,
154 /// The checksum the ORIGINAL writer computed, when the format has one.
155 /// `None` is what produces the intact/complete split honestly, rather
156 /// than by a per-format convention someone has to remember.
157 pub verifier: Option<Verifier>,
158 /// How many of `declared_len`'s bytes the SOURCE actually holds, when
159 /// it holds fewer — the truncated-tail case. `None` means the declared
160 /// payload is entirely present (the ordinary case) or nothing was
161 /// declared at all; `Some(n)` always means `n < declared_len`.
162 ///
163 /// # Why a scanner reports this instead of dropping the candidate
164 ///
165 /// A truncated archive — an interrupted download — is the single most
166 /// common damaged zip there is, and it was the one shape salvage said
167 /// nothing at all about. `zip_salvage.rs`'s validation gate refused any
168 /// candidate whose declared payload ran past the end of the file, which
169 /// is a correct refusal to INVENT the missing bytes (`zip -FF`
170 /// fabricates them and reports success) implemented as SILENCE: no row,
171 /// no note, no non-zero exit. Swept across six truncation points inside
172 /// a three-header archive's last entry, every one reported "2 scanned"
173 /// at exit 0 — the third header was not even counted.
174 ///
175 /// Reporting the candidate with this field set is what turns the
176 /// refusal into a statement. Nothing is invented: the declared length
177 /// stays exactly what the header said, so a reader can see both figures,
178 /// and the bytes that ARE present are still a genuine prefix. What a
179 /// scanner must NOT do is fold the shortfall into `declared_len` — that
180 /// would make a truncated entry indistinguishable from a whole one of
181 /// the smaller size, which is the same mistake in a different place.
182 pub available_len: Option<u64>,
183 /// The archive's own record marks this entry DELETED — the format's
184 /// owner removed it and the writer left the record, and its payload,
185 /// in place.
186 ///
187 /// `false` for every format that has no such flag (zip, tar, ar, cpio,
188 /// arc), which is why it is a plain `bool` rather than an `Option`:
189 /// "this format cannot express deletion" and "this record is not
190 /// deleted" lead to the identical, unannotated row, and a third state
191 /// would be a distinction no consumer could act on.
192 ///
193 /// # Why a scanner reports one rather than dropping it (Ruling S-R)
194 ///
195 /// Salvage Stage 2 Task 4, for ZOO — the first format here with the
196 /// flag. `zoo d` marks an entry `deleted = 1` and leaves it whole in the
197 /// file; `zoolist.c` neither lists nor extracts it, and `legacy::zoo`'s
198 /// reader follows that exactly. The flag is ONE BYTE, so in a damaged
199 /// archive a bit flip turns a live entry into one no ordinary verb will
200 /// ever hand back — which is precisely the archive this verb exists for.
201 /// So the scanner reports it.
202 ///
203 /// **And the report carries a marker, which is the part that took a
204 /// review to get right.** Everywhere else salvage accepts less than an
205 /// ordinary entry it says so on the artifact or in the row: a `Partial`
206 /// lands as `NAME.partial` and never under its real name, a second
207 /// record under a taken name lands as `NAME.salvaged-N`, a proven copy
208 /// prints `[shadowed]`. A deleted record reported `Intact` under its
209 /// real name at exit 0 was the one place that leniency was invisible —
210 /// four verbs call the archive empty and the fifth writes the file, with
211 /// nothing telling a user which it was.
212 ///
213 /// **The exit code deliberately does NOT move.** Recovering a deleted
214 /// record is this verb working as designed, not degraded fidelity — see
215 /// `stuffr::entries`'s own exit-code aggregation, which this field is
216 /// invisible to.
217 pub marked_deleted: bool,
218}
219
220impl Candidate {
221 /// A candidate at `offset`, whose payload begins at `payload_start`,
222 /// carrying `meta`.
223 ///
224 /// Nothing else is claimed. The four fields this does not take default
225 /// to the answers that assert the least:
226 ///
227 /// * `declared_len: None` — the header carries no length, so nothing
228 /// bounds the payload and [`annotate_candidates`] reports
229 /// [`UnverifiedCause::NoDeclaredLength`] rather than reading it.
230 /// * `verifier: None` — the format offers nothing to prove the content
231 /// with, so no shadow is ever claimed for this record (see
232 /// [`SalvagedEntry::shadows`], which needs a verifier to compare).
233 /// * `available_len: None` — every declared byte is present, which is
234 /// the ordinary case; a scanner sets this only when it has MEASURED a
235 /// shortfall.
236 /// * `marked_deleted: false` — the format has no deleted flag, or this
237 /// record is not marked. Four of the five in-tree scanners leave it.
238 ///
239 /// One default is worth naming because it decides a STATUS, not just a
240 /// field: with no `verifier`, [`SalvageScan::verify`]'s own default
241 /// answers [`SalvageStatus::Unattested`] — the tier that asserts the
242 /// least, matching the discipline of the four above. It answered
243 /// [`SalvageStatus::Complete`] until Salvage Stage 3, which was the
244 /// lenient direction: `Complete` claims a header self-check the default
245 /// never performs, and routes to exit 0. A scanner carrying a checksum
246 /// has to write its own `verify` regardless (the default checks
247 /// nothing), and that `verify` reads this very field, so the omission
248 /// fails in the scanner's own first test rather than silently.
249 pub fn new(offset: u64, payload_start: u64, meta: EntryMeta) -> Self {
250 Self {
251 offset,
252 payload_start,
253 meta,
254 declared_len: None,
255 verifier: None,
256 available_len: None,
257 marked_deleted: false,
258 }
259 }
260
261 /// Sets [`Self::declared_len`].
262 #[must_use]
263 pub fn with_declared_len(mut self, declared_len: Option<u64>) -> Self {
264 self.declared_len = declared_len;
265 self
266 }
267
268 /// Sets [`Self::verifier`].
269 #[must_use]
270 pub fn with_verifier(mut self, verifier: Option<Verifier>) -> Self {
271 self.verifier = verifier;
272 self
273 }
274
275 /// Sets [`Self::available_len`] — the truncated-tail case, and only
276 /// when the shortfall was measured. See that field's doc for why a
277 /// scanner must never fold it into [`Self::declared_len`] instead.
278 #[must_use]
279 pub fn with_available_len(mut self, available_len: Option<u64>) -> Self {
280 self.available_len = available_len;
281 self
282 }
283
284 /// Sets [`Self::marked_deleted`] — Ruling S-R. See that field's doc.
285 #[must_use]
286 pub fn with_marked_deleted(mut self, marked_deleted: bool) -> Self {
287 self.marked_deleted = marked_deleted;
288 self
289 }
290}
291
292/// Why an entry is [`SalvageStatus::Unverified`].
293///
294/// Three causes, and the STATUS does not distinguish them because the
295/// decision they lead to is identical (listed, not written, exit 3) — the
296/// same split [`crate::salvage`]'s consumers apply to
297/// [`SalvageStatus::Partial`] via their own cause type (`entries.rs`'s
298/// `PartialCause`, one crate up): **a tier carries a decision, a message
299/// carries a cause.**
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub enum UnverifiedCause {
302 /// This build recognises the entry's compression method but cannot
303 /// decode it (zip method 99 is AES, 14 is LZMA — recognised, not
304 /// decoded).
305 UndecodableMethod,
306 /// No length was available to bound the payload, so it was never read —
307 /// a data-descriptor entry the raw scan found with no central-directory
308 /// record to reconcile against. A CD-reconciled data-descriptor entry
309 /// gets a real declared length and CRC from the central directory and
310 /// does NOT reach this cause; see `zip_salvage.rs`'s
311 /// `candidate_from_cd_record`.
312 NoDeclaredLength,
313 /// The bytes this entry would have required are past the ceiling this
314 /// run will read for ONE entry — `policy.max_entry` narrowed by the
315 /// scanner's own [`SalvageScan::max_whole_entry`] — so nothing was read
316 /// and nothing was decoded. See [`annotate_candidates`], which is the
317 /// one place that decides this, for the whole rule.
318 ///
319 /// # Why this is a cause and not an `Err`
320 ///
321 /// Salvage Stage 2 Task 3c's fix round 3. This refusal used to be an
322 /// [`crate::Error::ResourceLimit`] raised from two different places — the
323 /// engine's own [`collect_candidates`], and (for ARC) the scanner's own
324 /// `verify` — and an `Err` at either site **aborts the whole run**:
325 /// measured at the CLI on a two-entry archive whose second entry was
326 /// over the ceiling, `stuffr salvage --list` printed no rows at all and
327 /// exited 6, and `salvage -C out` left the destination empty, losing a
328 /// first entry that decodes perfectly. That is the one thing this verb
329 /// exists not to do. Three fix rounds chased the shape from site to
330 /// site; what actually closes it is that an over-ceiling entry has a
331 /// STATUS, so there is no `Err` left to propagate.
332 ///
333 /// Distinct from [`SalvageStatus::Partial`], which is what this used to
334 /// collapse into once the `Err` was folded away at the write layer: a
335 /// truncated entry's genuine prefix IS recovered (as `NAME.partial`),
336 /// while an over-ceiling entry has real bytes nobody read. Reporting it
337 /// as `Partial (truncated)` stated two false things — the entry is not
338 /// truncated, and the empty `NAME.partial` it produced said nothing
339 /// survived.
340 ///
341 /// # Why it carries both figures (fix round 4, NEW-F)
342 ///
343 /// The `Err` this replaced carried a sentence naming the declared size
344 /// and the ceiling; the first version of this variant was a unit, so a
345 /// user meeting a container's own fixed ceiling was told neither number
346 /// and could not tell whether `--max-entry` would help. The cause is
347 /// the only place that knows both, so it carries both and the CLI row
348 /// prints them — the project's own "a message carries a cause", with
349 /// the numbers that make the message actionable.
350 OverEntryCeiling {
351 /// Bytes reading this entry would have required: the BOUNDED
352 /// figure the comparison actually used — `available_len` when the
353 /// payload is truncated, the header's `declared_len` otherwise —
354 /// never the raw declaration when the file holds less than it.
355 needed: u64,
356 /// The ceiling in force for this run: [`SalvagePolicy::max_entry`]
357 /// narrowed by [`SalvageScan::max_whole_entry`]. Equal to
358 /// `max_entry` means `--max-entry` can raise it; below it means a
359 /// whole-decoding container's own fixed ceiling decided, and no
360 /// flag moves that.
361 ceiling: u64,
362 },
363}
364
365/// What was proven about an entry AFTER decoding it.
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub enum SalvageStatus {
368 /// A checksum the original writer computed agrees.
369 Intact,
370 /// Every declared byte was present and the header self-verified, but the
371 /// format offers NO CHECKSUM AT ALL to prove the content — `tar`, in
372 /// this project's own formats. Never the right answer for a format that
373 /// DOES carry one; see [`SalvageStatus::Unverified`] for that case,
374 /// which this status is not permitted to stand in for.
375 ///
376 /// # `cpio` and `ar` are NOT this tier, and were named here until Stage 3
377 ///
378 /// This doc said "tar, cpio and ar" from Stage 1 until Salvage Stage 3,
379 /// which is where the sentence's own *"and the header self-verified"*
380 /// clause was measured against the three formats it named. `tar` has a
381 /// header checksum and really does self-verify. **`cpio` and `ar` have
382 /// neither a content checksum nor a self-verifying header**, so for
383 /// those two the clause is not merely unproven — there is nothing in the
384 /// format it could ever be true of, and a scanner answering `Complete`
385 /// for one would be asserting a header self-check that does not exist.
386 /// They are [`SalvageStatus::Unattested`].
387 ///
388 /// `stuffr_core::testing::check_salvage_claim` is what holds the line: this
389 /// tier requires a format whose only checksum covers the HEADER
390 /// (`Attestation::HeaderChecksumOnly`) AND that checksum to have been
391 /// checked — so the checksumless pair cannot reach it, and neither can a
392 /// format with a content checksum, whose honest tiers are `Intact`,
393 /// `Partial` and `Unverified`.
394 Complete,
395 /// Nothing attests that this candidate is an entry AT ALL: the format
396 /// carries **no checksum and no self-verifying header**, so a
397 /// coincidental header match and a real entry are indistinguishable in
398 /// the bytes — `cpio` and `ar`, in this project's own formats.
399 ///
400 /// The entry is still WRITTEN, under its real name: `salvage` is the
401 /// recovery-biased verb, and a tier that refused to write would make
402 /// these two formats unsalvageable rather than honestly salvaged. What
403 /// the tier buys is that the run says so — `salvage_exit_code` answers
404 /// **4** for it (degraded fidelity, never 3: nothing was refused and no
405 /// capability was missing), and the `--list` row carries the reason
406 /// beside the entry. There is no flag to opt into or out of this; the
407 /// formats' own shape decides it.
408 ///
409 /// # Not [`Self::Unverified`] — and the near-miss name is the hazard
410 ///
411 /// The variant deliberately is NOT called `Unverifiable`: that is two
412 /// letters from the [`Self::Unverified`] this enum already has, in an
413 /// enum whose variants select exit codes, and a reviewer skimming match
414 /// arms would not reliably see the difference. The distinction, stated
415 /// wherever both appear:
416 ///
417 /// * [`Self::Unverified`] — the format *has* a checksum; this build
418 /// could not use it (an undecodable method), or the header declared
419 /// nothing to check.
420 /// * `Unattested` — the format has **no checksum and no self-verifying
421 /// header**, so nothing attests that this candidate is an entry at
422 /// all.
423 ///
424 /// The consequences differ as much as the claims do: `Unverified` is
425 /// NOT written and exits 3 (this build could not do it — a rebuild may
426 /// fix it), `Unattested` IS written and exits 4 (nothing can fix it;
427 /// the format carries no evidence to find).
428 Unattested,
429 /// Nothing about this entry's content was verified.
430 ///
431 /// Two causes ([`UnverifiedCause`]), and the tier does not distinguish
432 /// them because the decision is identical — the entry is listed, not
433 /// written, and the run exits 3:
434 /// * this build could not decode the method (zip 99 is AES, 14 is
435 /// LZMA);
436 /// * no length was available to bound the payload, so it was never
437 /// read (a data-descriptor entry the raw scan found with no
438 /// central-directory record to reconcile against).
439 ///
440 /// Distinct from [`Self::Complete`], which asserts every DECLARED byte
441 /// was present — a claim that requires something to have been declared
442 /// in the first place. Reporting `Complete` for either cause above was
443 /// this enum's third instance of "nothing to disprove" being read as
444 /// "proven" (after the Task 1 hardcoded placeholder and the Task 4
445 /// undecodable-method case this variant was originally added for) — the
446 /// exit-code table this status exists to keep honest reserves exit 3 for
447 /// both causes, never exit 0, which is what `Complete` would have routed
448 /// either one to.
449 Unverified(UnverifiedCause),
450 /// The payload ran out, the decoder failed mid-stream, or the content
451 /// decoded whole and disagreed with the checksum the original writer
452 /// computed. All three are the same fact from a caller's perspective —
453 /// this build could not confirm the content is what was written — and
454 /// none of them is `Complete` (something WAS checked) or `Intact` (it
455 /// did not agree, or never finished).
456 Partial,
457}
458
459/// Which class of evidence a FORMAT offers for one of its entries — a
460/// per-format constant, and the third argument to the honesty oracle
461/// `stuffr_core::testing::check_salvage_claim`.
462///
463/// **Always compiled, unlike the oracle**, since Salvage Stage 3 Task 5:
464/// `stuffr formats`' SALVAGE column renders it (`yes` only for
465/// [`Attestation::ContentChecksum`]), so it cannot live behind the
466/// `testing` feature with the oracle that consumes it. Which format has
467/// which class is stated once, beside the scanner dispatch
468/// (`stuffr::entries::salvage_attestation`); the fuzz target and the corpus
469/// generator read it from there rather than keeping a table of their own.
470///
471/// **Three classes, which is why this is an enum and not a `bool`.** Salvage
472/// Stage 3 Task 1 first shipped the argument as `format_offers_verifier:
473/// bool`, and its fix round's review showed with a truth table that no
474/// reading of a two-valued flag can hold the boundary: read as "a content
475/// checksum", it let `cpio` claim [`SalvageStatus::Complete`]; read as "a
476/// content checksum OR a self-verifying header" (the shipped reading), it
477/// let `tar` claim [`SalvageStatus::Intact`] and — live against the five
478/// scanners that shipped before this stage — let `zip`, `arc`, `zoo`, `lha`
479/// or `arj` answer `Complete` for an entry whose CRC was compared and
480/// DISAGREED, turning an exit-4 `Partial` into an exit-0 clean recovery.
481/// Both false claims sat in the one cell (`checked`, `offers`) that tar's
482/// honest `Complete` also occupies, so no function of those inputs could
483/// refuse them and permit it. Ruling 3-G.
484///
485/// Two bools (`offers_content_checksum`, `header_self_verifies`) were
486/// considered and refused: four states for three classes, one of them
487/// meaningless, and the split pushed back onto every caller. Here each
488/// scanner NAMES its class.
489///
490/// **Deliberately NOT `#[non_exhaustive]`**, for the reason Ruling 3-D kept
491/// it off [`SalvageStatus`]: a fourth class of evidence must fail to compile
492/// at every `match`, `check_salvage_claim`'s own included, rather than be
493/// absorbed by a wildcard arm that decides what it permits by accident.
494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495pub enum Attestation {
496 /// A checksum over the entry's CONTENT, computed by the original writer —
497 /// `zip`, `arc`, `zoo`, `lha`, `arj`. The only class that can earn
498 /// [`SalvageStatus::Intact`].
499 ContentChecksum,
500 /// A checksum over the HEADER only; the content has none — `tar`. The
501 /// only class that can earn [`SalvageStatus::Complete`]: "every declared
502 /// byte was present and the header self-verified" is exactly what this
503 /// class can prove and all it can prove.
504 HeaderChecksumOnly,
505 /// Neither — `cpio`, `ar`. A coincidental header match and a real entry
506 /// are indistinguishable in the bytes, so the only honest tier this
507 /// class can reach on a whole entry is [`SalvageStatus::Unattested`].
508 Nothing,
509}
510
511/// One entry the scan recovered.
512///
513/// `#[non_exhaustive]` plus [`SalvagedEntry::new`], for the same reason
514/// [`Candidate`] carries both — see that type's own doc. This one is built
515/// by [`annotate_candidates`] in ordinary use, but every per-format scanner
516/// crate builds one directly to drive its `write_payload` seam, so the
517/// literal it used to need was an out-of-crate literal all the same.
518#[derive(Debug)]
519#[non_exhaustive]
520pub struct SalvagedEntry {
521 /// This entry's position in SCAN order — not an index any container's
522 /// own format assigns, and never renumbered by what `list` would show.
523 /// A later task's CLI surfaces this distinction explicitly (`--index`
524 /// on `salvage` names a scan position, not a list index).
525 pub scan_position: usize,
526 pub offset: u64,
527 /// Carried forward from [`Candidate::payload_start`] — see that
528 /// field's doc for why a consumer must use this rather than
529 /// re-deriving a payload's location from `offset` itself.
530 pub payload_start: u64,
531 pub meta: EntryMeta,
532 pub status: SalvageStatus,
533 /// Set when this record's `(name, declared_len, verifier)` all agree
534 /// with an EARLIER record's — a measurement, never inferred from a
535 /// repeated name alone, and never set for a degenerate zero-length
536 /// checksum (every empty file and every directory entry shares one).
537 /// See [`annotate_candidates`] for where this is computed.
538 ///
539 /// **This is the byte-identical duplicate, and ONLY that.** The other,
540 /// weaker fact — an earlier record under the same name whose content
541 /// this scan could not prove identical — is [`Self::collides_with`], and
542 /// the two are mutually exclusive by construction. They were one word
543 /// ("shadow") until the final whole-branch review measured what that
544 /// cost: `stuffr list`'s own fidelity warning uses "shadowed" for a
545 /// repeated NAME, so on an archive whose duplicates differ, `list` said
546 /// two records were shadowed and `salvage --list` marked none.
547 pub shadows: Option<usize>,
548 /// Set when an EARLIER record carries the same `meta.name` and this one
549 /// is not that record's [`Self::shadows`] — i.e. the name repeats but
550 /// the content could not be proven identical.
551 ///
552 /// This is the fact `stuffr list`'s `Fidelity::EntryCountMismatch`
553 /// warning is about: a later record repeating a name is what makes an
554 /// earlier one unreachable through a central directory's name index,
555 /// whatever the bytes under it. It is also the fact that decides
556 /// anything on the WRITE side, because two records under one name and
557 /// two different payloads cannot both land on one path.
558 ///
559 /// Deliberately NOT gated on a verifier, a declared length or the
560 /// degenerate zero-length case the way [`Self::shadows`] is: those
561 /// guards exist because claiming two records are COPIES of each other
562 /// needs proof, and this annotation claims no such thing. A repeated
563 /// name is directly observed, and `None` here means only that no
564 /// earlier record used this name — never that one did and this could
565 /// not tell.
566 pub collides_with: Option<usize>,
567 /// Carried forward unchanged from [`Candidate::marked_deleted`] — see
568 /// that field for the whole ruling. Unlike [`Self::shadows`] and
569 /// [`Self::collides_with`], this is not something this module MEASURES:
570 /// it is a fact the archive's own record states, which only the scanner
571 /// that read that record can know.
572 pub marked_deleted: bool,
573}
574
575impl SalvagedEntry {
576 /// An entry at scan position `scan_position`, found at `offset`, whose
577 /// payload begins at `payload_start`, carrying `meta` and proven to
578 /// `status`.
579 ///
580 /// The three fields this does not take default to "nothing was
581 /// observed": no shadow, no name collision, not marked deleted. All
582 /// three are annotations [`annotate_candidates`] adds (or, for
583 /// `marked_deleted`, carries forward from [`Candidate`]), so a caller
584 /// building one directly — every per-format `write_payload` test does —
585 /// wants exactly those defaults.
586 pub fn new(
587 scan_position: usize,
588 offset: u64,
589 payload_start: u64,
590 meta: EntryMeta,
591 status: SalvageStatus,
592 ) -> Self {
593 Self {
594 scan_position,
595 offset,
596 payload_start,
597 meta,
598 status,
599 shadows: None,
600 collides_with: None,
601 marked_deleted: false,
602 }
603 }
604
605 /// Sets [`Self::shadows`] — the byte-identical duplicate, and only
606 /// that. See the field's own doc for what separates it from
607 /// [`Self::collides_with`].
608 #[must_use]
609 pub fn with_shadows(mut self, shadows: Option<usize>) -> Self {
610 self.shadows = shadows;
611 self
612 }
613
614 /// Sets [`Self::collides_with`] — the weaker, name-only annotation.
615 #[must_use]
616 pub fn with_collides_with(mut self, collides_with: Option<usize>) -> Self {
617 self.collides_with = collides_with;
618 self
619 }
620
621 /// Sets [`Self::marked_deleted`], carried forward from
622 /// [`Candidate::marked_deleted`] rather than measured here.
623 #[must_use]
624 pub fn with_marked_deleted(mut self, marked_deleted: bool) -> Self {
625 self.marked_deleted = marked_deleted;
626 self
627 }
628}
629
630/// The result of a scan: every entry the scanner recovered, in scan order.
631///
632/// Later tasks may extend this struct; the `entries` field stays public,
633/// and `#[non_exhaustive]` is what makes "extend" honest. No constructor is
634/// paired with it, unlike [`Candidate`] and [`SalvagedEntry`]: this struct
635/// is built by [`salvage_all`] alone, in this crate, and no scanner — in
636/// this workspace or outside it — has any reason to build one.
637#[derive(Debug)]
638#[non_exhaustive]
639pub struct SalvageOutcome {
640 pub entries: Vec<SalvagedEntry>,
641 /// Header shapes the scanner recognised and could not gate, in scan
642 /// order — see [`Sighting`]. Always empty from [`salvage_all`]: the
643 /// engine cannot know what its scanner refused, so a format's own
644 /// `salvage_*` entry point fills it in after the scan. A sighting is
645 /// never an entry; the facade's `salvage_exit_code` counts a
646 /// non-selective run that has one as degraded (exit 4) since the
647 /// Salvage Stage 3 final review's I-1, which reversed Ruling S-X there.
648 pub sightings: Vec<Sighting>,
649 /// Where a SEQUENTIAL-ONLY scanner's walk stopped short of the end of
650 /// the source, if it did — see [`WalkStop`]. Always `None` from
651 /// [`salvage_all`], for the same reason [`Self::sightings`] is always
652 /// empty there: only the scanner knows its walk ended early, so its own
653 /// `salvage_*` entry point fills this in. Like a sighting it is never an
654 /// entry and never touches the run's exit code.
655 pub walk_stop: Option<WalkStop>,
656}
657
658/// A header a scanner SAW and has no gate for (Rulings S-V and 3-J): its
659/// structure is a header's, and something about it — a field this build
660/// cannot check, a shape a shifted copy also takes — leaves the scan unable
661/// to tell it from a phantom. So it is counted here and never becomes a
662/// [`Candidate`], because the engine advances past a candidate by its
663/// declared length and a phantom's length is how a scan jumps a real header.
664///
665/// **Why it has to be reported at all.** A run that recovers nothing while
666/// seeing one refuses with [`Error::Unsupported`] (exit 3) — the scanner's own
667/// job, since only it can name the shape. A run that recovered OTHER entries
668/// reports them at its ordinary exit code (Ruling S-X), and until Salvage
669/// Stage 3's Task 2-N it said nothing else: an entry `stuffr list` shows was
670/// simply absent from `salvage --list`, at exit 0, with an empty stderr. The
671/// sightings recorded here are what the caller prints instead
672/// ([`describe_sightings`]), in a mixed run as in any other — and since the
673/// stage's final review (I-1, reversing S-X for this case) a non-selective
674/// mixed run also exits 4, not 0: a `salvage -C out && rm` script must not
675/// delete an archive holding an entry `stuffr list` reads.
676#[derive(Debug, Clone, PartialEq, Eq)]
677#[non_exhaustive]
678pub struct Sighting {
679 /// The scanner's format, for the sentence [`describe_sightings`] writes.
680 pub format: FormatId,
681 /// Where the header was seen.
682 pub offset: u64,
683 /// What was seen, as a plural noun phrase that follows a count —
684 /// `"header(s) whose magic field … is neither \`ustar\` nor blank"`. One
685 /// phrase per shape, shared with the scanner's own exit-3 refusal, so
686 /// the two can never describe one shape two ways.
687 pub shape: &'static str,
688}
689
690impl Sighting {
691 pub fn new(format: FormatId, offset: u64, shape: &'static str) -> Self {
692 Self {
693 format,
694 offset,
695 shape,
696 }
697 }
698}
699
700/// The most offsets [`describe_sightings`] lists per shape before it says
701/// how many more there were. The count itself is always the complete figure.
702const SIGHTING_OFFSETS_SHOWN: usize = 8;
703
704/// The one sentence a caller prints for `sightings`, or `None` when there
705/// are none — how many headers of each shape were seen, where, and that the
706/// ordinary verbs may read them or name the variant they are in. It is the
707/// same wording family as each scanner's exit-3 refusal (its shape phrases
708/// are the scanner's own), and it is written here, once, so every scanner
709/// that records a [`Sighting`] gets the same report without a line of its
710/// own.
711///
712/// **"Or name the variant" is cpio's clause (Salvage Stage 3 Task 3).** The
713/// tail said only "may read these headers normally", which is true of a tar
714/// header the scan cannot gate and false of a cpio `odc` or `newc-crc`
715/// header: `stuffr list` refuses both variants at exit 3, naming them. A note
716/// telling a user to expect `list` to read them would be the false "list
717/// reads it" claim Task 2-N's fix round removed from tar.
718pub fn describe_sightings(sightings: &[Sighting]) -> Option<String> {
719 if sightings.is_empty() {
720 return None;
721 }
722 // Grouped by (format, shape) in first-seen order: a handful of shapes
723 // at most, so a linear search beats a map and keeps the order stable.
724 let mut groups: Vec<((FormatId, &'static str), Vec<u64>)> = Vec::new();
725 for s in sightings {
726 let key = (s.format, s.shape);
727 match groups.iter_mut().find(|(k, _)| *k == key) {
728 Some((_, offsets)) => offsets.push(s.offset),
729 None => groups.push((key, vec![s.offset])),
730 }
731 }
732 let parts: Vec<String> = groups
733 .iter()
734 .map(|((format, shape), offsets)| {
735 let mut shown: Vec<String> = offsets
736 .iter()
737 .take(SIGHTING_OFFSETS_SHOWN)
738 .map(u64::to_string)
739 .collect();
740 if offsets.len() > SIGHTING_OFFSETS_SHOWN {
741 shown.push(format!(
742 "and {} more",
743 offsets.len() - SIGHTING_OFFSETS_SHOWN
744 ));
745 }
746 format!(
747 "{} {format} {shape} at offset(s) {}",
748 offsets.len(),
749 shown.join(", ")
750 )
751 })
752 .collect();
753 Some(format!(
754 "this build's salvage scanner saw {} — each with the structure of a header, but in a \
755 shape it has no gate for, so none is listed or recovered; `stuffr list` and \
756 `stuffr unpack` may read these headers normally, or name the variant they are in, \
757 and it is the SCAN that stops there",
758 parts.join("; and ")
759 ))
760}
761
762/// Where a scanner that can ONLY walk its format in order — record after
763/// record from the start, the way the ordinary reader does — had to stop
764/// before the end of the source (Salvage Stage 3 Task 4).
765///
766/// # Why a walk stop is not a [`Sighting`]
767///
768/// A sighting is a header the scan SAW, whose structure is a header's and
769/// which it cannot gate. A walk stop is the opposite: a place where the
770/// walk found nothing it could read as a record at all, and — because the
771/// format carries nothing strong enough to search for (an `ar` member's
772/// only marker is two bytes) — cannot look any further. Folding it into
773/// [`Sighting`] would make "saw a real header" and "could see nothing" one
774/// channel, and [`describe_sightings`]'s own sentence ("`stuffr list` may
775/// read these headers normally") would then be false about it.
776///
777/// # What it must say, and why it has to be said at all
778///
779/// The entries a sequential walk cannot reach are **unreachable by
780/// construction, not absent**: a run that recovers three entries from a
781/// ten-entry archive with a hole after the third reports three rows, and
782/// nothing in those rows says seven more may sit behind the hole. So the
783/// caller prints [`describe_walk_stop`] of this on every run that has one,
784/// and the offset it names is where a user with a hex editor would look.
785#[derive(Debug, Clone, PartialEq, Eq)]
786#[non_exhaustive]
787pub struct WalkStop {
788 /// The scanner's format, for the sentence [`describe_walk_stop`] writes.
789 pub format: FormatId,
790 /// Where the record the walk could not read begins — the first byte
791 /// after the last record it did read.
792 pub offset: u64,
793 /// Bytes from [`Self::offset`] to the end of the source.
794 pub remaining: u64,
795 /// Which of the two things this stop is — see [`WalkStopKind`].
796 pub kind: WalkStopKind,
797 /// The ordinary reader's own words for why that record would not read,
798 /// so the note and `stuffr list`'s refusal of the same bytes agree.
799 pub cause: String,
800}
801
802/// The three stops a sequential walk can make, each told apart by what the
803/// scanner measured rather than guessed.
804#[derive(Debug, Clone, Copy, PartialEq, Eq)]
805pub enum WalkStopKind {
806 /// The file ends inside the record at the stop point — the reader ran
807 /// out of bytes mid-record, or fewer bytes remain than the format's
808 /// smallest record header. Everything from the stop point on belongs to
809 /// that incomplete record, so no entry can be hiding after it — the
810 /// common damage, and the one a sequential walk handles fully.
811 CutShort,
812 /// Enough bytes remain to hold further records, and the walk could not
813 /// read the one at the stop point. Whatever those bytes hold is
814 /// unreachable by construction.
815 Unreadable,
816 /// The record at the stop point is one the scanner RECOGNISES — its
817 /// [`WalkStop::cause`] names it — in a shape this build cannot read (Task
818 /// 4 fix round 1: GNU `ar`'s Mach-O `__.SYMDEF` with a blank mode). Not
819 /// evidence of damage; what follows is unreachable to this scanner.
820 UnsupportedShape,
821}
822
823impl WalkStop {
824 pub fn new(
825 format: FormatId,
826 offset: u64,
827 remaining: u64,
828 kind: WalkStopKind,
829 cause: impl Into<String>,
830 ) -> Self {
831 Self {
832 format,
833 offset,
834 remaining,
835 kind,
836 cause: cause.into(),
837 }
838 }
839}
840
841/// The one sentence a caller prints for a [`WalkStop`] — written here, once,
842/// so every sequential-only scanner gets the same report without a line of
843/// its own, and so its kinds can never be described two ways each.
844///
845/// An [`WalkStopKind::Unreadable`] stop says the entries after it are
846/// *unreachable by construction, not absent*, with the offset and the byte
847/// count; a [`WalkStopKind::CutShort`] one says that nothing after it is
848/// missing from the report, which is exactly as true — the file ended inside
849/// that record; an [`WalkStopKind::UnsupportedShape`] one names the shape and
850/// says it is not evidence of damage.
851///
852/// Two of the sentences say the walk goes "as `stuffr list` does". That is a
853/// contract on every scanner that reports a [`WalkStop`]: it walks with its
854/// format's ORDINARY reader, over the file's own bytes, repairing none of
855/// them. `ar`'s walk served its global header as `!<arch>\n` until Stage 3
856/// Task 4's fix round 1, which made the phrase false (a bad magic `list`
857/// refuses was walked past) — a scanner that needs a repair like that must
858/// get its own sentence here rather than borrow this one.
859pub fn describe_walk_stop(stop: &WalkStop) -> String {
860 let WalkStop {
861 format,
862 offset,
863 remaining,
864 kind,
865 cause,
866 } = stop;
867 let bytes = if *remaining == 1 { "byte" } else { "bytes" };
868 match kind {
869 WalkStopKind::Unreadable => format!(
870 "this build's {format} salvage scanner walks the archive in order from its start, \
871 as `stuffr list` does, and stopped at offset {offset} with {remaining} {bytes} still \
872 to go: {cause}. The {format} format marks its records with nothing strong enough \
873 to search for, so the scanner cannot find its way past a record it cannot read; \
874 any entries in those {remaining} {bytes} are unreachable by construction — not \
875 absent, and not counted in this report"
876 ),
877 WalkStopKind::UnsupportedShape => format!(
878 "this build's {format} salvage scanner walks the archive in order from its start, \
879 as `stuffr list` does, and stopped at offset {offset} with {remaining} {bytes} still \
880 to go: {cause}. That is a record shape this build does not read, not evidence of \
881 damage; any entries in those {remaining} {bytes} are unreachable to this scanner \
882 and not counted in this report"
883 ),
884 WalkStopKind::CutShort => format!(
885 "this build's {format} salvage scanner walks the archive in order from its start, \
886 and the file ends {remaining} {bytes} into the record at offset {offset} ({cause}): \
887 too few for any further entry, so the archive is cut short there and nothing \
888 after that point is missing from this report"
889 ),
890 }
891}
892
893#[derive(Debug, Clone, Copy, PartialEq, Eq)]
894pub enum PartialPolicy {
895 Keep,
896 Skip,
897 Ask,
898}
899
900pub struct SalvagePolicy {
901 /// Recovery-biased by default: salvage is the one permissive verb, and
902 /// the strict path is the entire rest of the tool.
903 pub partial: PartialPolicy,
904 /// Structural ceiling on any one entry's length, refused BEFORE
905 /// allocation — and refused as a per-entry
906 /// [`UnverifiedCause::OverEntryCeiling`], never as an error that ends
907 /// the run. [`SalvageScan::max_whole_entry`] narrows it further for a
908 /// format that decodes an entry whole; the two compose as a minimum and
909 /// are applied together, once, in [`annotate_candidates`].
910 pub max_entry: u64,
911 /// Demand proof: every [`SalvageStatus::Partial`] entry is skipped,
912 /// whatever [`Self::partial`] says, and so is every
913 /// [`SalvageStatus::Unattested`] one (Ruling 3-H, Salvage Stage 3).
914 ///
915 /// The second clause is this flag's doing, unlike the two refused
916 /// below: an `Unattested` entry is WRITTEN by default, under its real
917 /// name, and only `strict` declines it — the format carries nothing
918 /// that could prove it, which is exactly what "demand proof" asks for.
919 ///
920 /// **Those two are the whole of it, and this doc claimed three things
921 /// until the final whole-branch review's F5.** It read "partial skipped,
922 /// ceiling fixed, nothing unverifiable", and only the first clause was
923 /// this flag's doing. The ceiling is NOT fixed under `strict`:
924 /// [`Self::max_entry`] applies identically with and without it, and
925 /// `--max-entry` is honoured either way. And an
926 /// [`SalvageStatus::Unverified`] entry is never written under any
927 /// policy at all — that is the ops layer's unconditional rule, not
928 /// something `strict` causes — so crediting it here made a flag look
929 /// load-bearing for a guarantee that holds without it.
930 ///
931 /// The true clauses are enough to justify the flag: `salvage` is this
932 /// tool's single recovery-biased verb, so `strict` is how a caller asks
933 /// for the discipline every other verb applies by default.
934 pub strict: bool,
935}
936
937impl Default for SalvagePolicy {
938 fn default() -> Self {
939 Self {
940 partial: PartialPolicy::Keep,
941 max_entry: MAX_SALVAGE_ENTRY,
942 strict: false,
943 }
944 }
945}
946
947/// The per-format seam. A scanner recognises its own format's record shape
948/// (a local header's magic, a member's checksum field) and reports what it
949/// finds as a stream of [`Candidate`]s; [`salvage_all`] supplies everything
950/// that does not depend on which format that is.
951pub trait SalvageScan {
952 /// Find the next candidate at or after `from`. `Ok(None)` ends the scan.
953 fn next_candidate(&mut self, src: &mut dyn SeekRead, from: u64) -> Result<Option<Candidate>>;
954
955 /// The largest single entry this scanner is willing to have read for it
956 /// — the ceiling [`annotate_candidates`] applies, narrowed further by
957 /// `policy.max_entry`, before it calls [`Self::verify`] at all.
958 ///
959 /// `u64::MAX` (the default) means "this scanner imposes none of its
960 /// own": zip streams every payload it verifies through a `take`, so the
961 /// only figure that bounds it is the policy's. A format that **decodes
962 /// an entry whole** — ARC, and the legacy containers still to come —
963 /// overrides this with its own container-side ceiling
964 /// (`arc.rs`'s `MAX_ARC_ENTRY_LEN`, 256 MiB), because for those the
965 /// figure really does become a single allocation.
966 ///
967 /// **Declaring a ceiling here is the ONLY way a scanner refuses an
968 /// entry for its size**, and that is the point: the refusal is then a
969 /// [`SalvageStatus`] the engine assigns
970 /// ([`UnverifiedCause::OverEntryCeiling`]), not a [`crate::Error`] the
971 /// scanner raises, so it can never abort a run over one entry. A
972 /// scanner that instead checks a size inside [`Self::verify`] and
973 /// `?`-propagates the failure reintroduces the exact defect three fix
974 /// rounds of Task 3c chased.
975 ///
976 /// **That is documented, not enforced, and saying so is more useful
977 /// than claiming otherwise.** An earlier wording here called the defect
978 /// "unrepresentable"; it is not. [`Self::verify`] still returns
979 /// `Result` — it must, so a scanner CAN report a genuine run-level
980 /// fault — so the wrong thing is still expressible. What changed is
981 /// that the right thing now exists, costs one method, and is written
982 /// into [`Self::verify`]'s contract below.
983 fn max_whole_entry(&self) -> u64 {
984 u64::MAX
985 }
986
987 /// Decide what was actually proven about a candidate's payload, after
988 /// discovery — called once per candidate, in file order, by
989 /// [`annotate_candidates`].
990 ///
991 /// # The contract on `Err`
992 ///
993 /// **`Err` here aborts the WHOLE run**, discarding every entry already
994 /// recovered, so it is reserved for a fault that really is about the
995 /// run and not about this one entry (the destination is gone, the
996 /// source handle itself failed). Everything a single candidate can be
997 /// wrong about — a short read, a bad checksum, a method this build
998 /// cannot decode, a device error partway through one payload — is a
999 /// [`SalvageStatus`], never an `Err`. `zip_salvage.rs`'s own
1000 /// `verify_candidate` has stated this in prose since Stage 1 and folds
1001 /// every one of those; ARC's did not, for one case (its size ceiling),
1002 /// and that single `?` cost three fix rounds. The size ceiling now
1003 /// belongs to [`Self::max_whole_entry`], which the engine applies, so
1004 /// there is nothing left for an implementation to `?` on.
1005 ///
1006 /// # What an implementation may assume
1007 ///
1008 /// The candidate's bounded length (`available_len` if the payload was
1009 /// truncated, else `declared_len`) is already **at or below**
1010 /// [`Self::max_whole_entry`] — [`annotate_candidates`] answers
1011 /// [`UnverifiedCause::OverEntryCeiling`] itself, without calling this,
1012 /// for anything above it. So an implementation that decodes whole may
1013 /// size a buffer from that length without re-checking it.
1014 ///
1015 /// The default answers [`SalvageStatus::Unattested`] unconditionally,
1016 /// which is the least this method can claim: it reads nothing, decodes
1017 /// nothing and compares nothing, so "nothing attests that this candidate
1018 /// is an entry at all" is exactly what happened. A format that carries a
1019 /// checksum overrides this to decode the payload — reusing its own
1020 /// existing codec machinery, never inventing a new one here — and
1021 /// compare it against [`Candidate::verifier`]; a format that carries a
1022 /// self-verifying HEADER and no content checksum (tar) overrides it to
1023 /// check that header and answer [`SalvageStatus::Complete`].
1024 ///
1025 /// **It answered `Complete` until Salvage Stage 3**, and the change is
1026 /// not cosmetic. `Complete` asserts that every declared byte was present
1027 /// *and the header self-verified* — two claims this default has never
1028 /// been in a position to make — and it routes to **exit 0**, so a
1029 /// checksumless scanner that simply forgot to override was reporting a
1030 /// clean recovery over candidates nothing had examined. `Unattested`
1031 /// routes to exit 4 and says why on the row. The same argument
1032 /// [`Candidate::new`] makes for its own four defaults: the default is
1033 /// the answer that asserts the LEAST, so forgetting to override is
1034 /// visible rather than flattering.
1035 fn verify(&self, _src: &mut dyn SeekRead, _candidate: &Candidate) -> Result<SalvageStatus> {
1036 Ok(SalvageStatus::Unattested)
1037 }
1038
1039 /// Decode one salvaged entry's payload out of the archive at
1040 /// `archive_path` and into `out`, answering whether the entry's whole
1041 /// declared length was produced.
1042 ///
1043 /// # Why this is on the trait at all (final whole-branch review, F2)
1044 ///
1045 /// `0.6.0` spends a breaking release making this trait implementable
1046 /// from outside. It was implementable and not USABLE: the ops layer's
1047 /// payload dispatch was a private `match` on a [`crate::FormatId`]
1048 /// string, so a scanner written outside this workspace got
1049 /// [`salvage_all`] and none of the behaviour that makes `salvage` what
1050 /// it is. This method is where a scanner's write half now DECLARES
1051 /// itself, beside the scan half, in one place a reader can see both.
1052 ///
1053 /// **It does not by itself make an out-of-tree scanner reachable from
1054 /// `stuffr salvage`, and pretending otherwise would be the same
1055 /// over-claim F2 named.** The ops layer still resolves a format to a
1056 /// scanner by name, with no registration point for a third party to add
1057 /// one; closing that needs a salvage registry and is Stage 3's. What
1058 /// changed is that the seam is now declared rather than scattered — the
1059 /// five in-tree scanners implement this method and the ops layer calls
1060 /// it, so the next one has a signature to fit rather than a convention
1061 /// to guess.
1062 ///
1063 /// # The contract
1064 ///
1065 /// `Ok(true)` means every declared byte was produced; `Ok(false)` means
1066 /// the payload ran short or the decoder stopped early, which the caller
1067 /// renders as a `.partial` write. **`Err` is reserved for the same
1068 /// narrow set [`Self::verify`] reserves it for** — with one addition
1069 /// the ops layer relies on: [`crate::Error::Io`] here is treated as a
1070 /// fact about THIS entry (the archive could not be re-opened or read
1071 /// for it, the destination refused the bytes) and folded into that
1072 /// entry's own outcome, never the run's. A size refusal is never an
1073 /// `Err`: it belongs to [`Self::max_whole_entry`], which the engine
1074 /// applies before `verify` is ever called.
1075 ///
1076 /// The default refuses. A scanner that only ever reports
1077 /// [`SalvageStatus::Unverified`] has nothing to write, and inheriting a
1078 /// refusal is the honest answer for it; the alternative — a required
1079 /// method — would be a breaking change to every implementation for no
1080 /// gain to one that cannot write anything anyway.
1081 fn write_payload(
1082 &self,
1083 _archive_path: &Path,
1084 _entry: &SalvagedEntry,
1085 _compressed_len: u64,
1086 _out: &mut dyn Write,
1087 ) -> Result<bool> {
1088 Err(Error::Unsupported(
1089 "this salvage scanner has no payload writer".to_string(),
1090 ))
1091 }
1092}
1093
1094/// Walk `scan` over `src`, collecting every candidate it reports into a
1095/// [`SalvageOutcome`].
1096///
1097/// **Requires a seekable source.** Scanning searches back and forth over the
1098/// archive rather than reading it once in order, so this takes
1099/// `&mut dyn SeekRead` rather than the ladder's `Box<dyn Source>` (whose
1100/// `as_seek()` may be `None` for a pipe). A caller sitting on a
1101/// non-seekable source must spool it first — exactly as the `arj` and `zoo`
1102/// containers already do, reporting `Rung::Spilled` — which is a later
1103/// task's ops-layer concern, not this function's.
1104///
1105/// Every candidate's length is bounded against the run's entry ceiling
1106/// (`policy.max_entry`, narrowed by [`SalvageScan::max_whole_entry`])
1107/// **before** anything is sized from it — in [`annotate_candidates`], one
1108/// step before the only place that could size anything, and as a per-entry
1109/// [`UnverifiedCause::OverEntryCeiling`] rather than a [`crate::Error`] that ends
1110/// the run.
1111pub fn salvage_all(
1112 scan: &mut dyn SalvageScan,
1113 src: &mut dyn SeekRead,
1114 policy: &SalvagePolicy,
1115) -> Result<SalvageOutcome> {
1116 let candidates = collect_candidates(scan, src)?;
1117 annotate_candidates(&*scan, src, candidates, policy)
1118}
1119
1120/// The resync loop alone: find a candidate, advance strictly past it,
1121/// repeat. Never reads a payload, never sizes anything from a declared
1122/// length, and never calls [`SalvageScan::verify`] — see
1123/// [`annotate_candidates`] for both the entry ceiling and the verification.
1124///
1125/// Exposed separately from [`salvage_all`] so a format that reconciles more
1126/// than one candidate source (zip's central-directory fallback) can collect
1127/// from each source before merging, and annotate the merged list once.
1128///
1129/// # The entry ceiling used to be enforced here, and was an `Err`
1130///
1131/// Salvage Stage 2 Task 3c's fix round 3 moved it to
1132/// [`annotate_candidates`] and turned it into a status. Two things were
1133/// wrong with it here, and only the second is obvious:
1134///
1135/// 1. **It refused a whole archive over one entry.** Measured at the CLI on
1136/// a 78-byte two-entry ARC, `stuffr salvage --list --max-entry 4`
1137/// printed no rows and exited 6 — the healthy first entry, which the
1138/// same binary recovers byte-for-byte at the default ceiling, simply
1139/// lost. That is the same defect the ARC scanner's own ceiling had, in
1140/// the engine rather than in a format.
1141/// 2. **It was one of two ceilings, checked in two places**, so which
1142/// disposition an over-ceiling entry got depended on which check saw it
1143/// first. There is now one rule, applied once, to both figures.
1144///
1145/// Nothing is weakened by the move: this loop sizes nothing from a declared
1146/// length (it only advances `from` by it), so "bounded before anything is
1147/// sized from it" holds exactly as before — the bound simply sits one
1148/// function later, still strictly before the only allocation there is.
1149pub fn collect_candidates(
1150 scan: &mut dyn SalvageScan,
1151 src: &mut dyn SeekRead,
1152) -> Result<Vec<Candidate>> {
1153 let mut candidates = Vec::new();
1154 let mut from = 0u64;
1155
1156 while let Some(candidate) = scan.next_candidate(src, from)? {
1157 // Advance strictly past this candidate so the scan always makes
1158 // progress — but NEVER by a length this candidate merely CLAIMED.
1159 // `available_len.is_some()` means the header's own declared length
1160 // disagreed with the file (there were fewer bytes left than it
1161 // said), and a header that lied about its size once cannot be
1162 // trusted to say how much of the source its "payload" consumed
1163 // either. This used to advance by `available_len` — the bytes
1164 // actually present, not the declared figure — on the reasoning that
1165 // a truncated entry has nothing worth finding after it anyway. That
1166 // is true for a genuinely truncated REAL last entry, but the
1167 // engine cannot tell that case apart from a coincidental
1168 // false-positive header sitting on top of noise with a garbage
1169 // multi-hundred-megabyte declared length: `available_len` is the
1170 // same "everything left in the file" figure either way. Salvage
1171 // Stage 2 Task 3's fix round 1 found exactly this — one
1172 // coincidental marker+name match in an ARC noise corpus, with a
1173 // declared length far past the file, jumped the scan straight to
1174 // EOF in one step and silently dropped every real entry that
1175 // happened to sit between it and the end of the file: no error, no
1176 // `Partial`, no note, in the one verb whose entire job is not
1177 // losing entries silently. So a truncated candidate now advances
1178 // minimally — past its own marker byte only — so the very next
1179 // byte onward is examined for another header. That costs a few
1180 // bytes of re-scanning after a genuinely truncated real entry
1181 // (which has nothing left to find anyway, so the outcome is
1182 // unchanged); it is the entire fix for a phantom, whose lie no
1183 // longer costs the rest of the archive. An UNTRUNCATED candidate's
1184 // declared length was just checked against the file two lines
1185 // above — it fits, so it is not a lie, and advancing by it is
1186 // unchanged.
1187 let advance = if candidate.available_len.is_some() {
1188 1
1189 } else {
1190 candidate.declared_len.unwrap_or(1).max(1)
1191 };
1192 from = candidate.offset.saturating_add(advance);
1193
1194 candidates.push(candidate);
1195 }
1196
1197 Ok(candidates)
1198}
1199
1200/// Turns already-discovered candidates — in file order — into the bounded,
1201/// verified, shadow-annotated entries a caller receives. See this module's
1202/// doc comment for what the passes (bound, verify, shadow) mean and why
1203/// they live here rather than per-format.
1204///
1205/// # The entry ceiling, in one place (fix round 3, Task 3c)
1206///
1207/// `policy.max_entry` narrowed by [`SalvageScan::max_whole_entry`] is the
1208/// run's ceiling on ONE entry, and this is the only place it is applied. A
1209/// candidate whose bounded length (`available_len` if the payload is
1210/// truncated, else `declared_len`) is over it is reported
1211/// [`SalvageStatus::Unverified`]`(`[`UnverifiedCause::OverEntryCeiling`]`)`
1212/// and [`SalvageScan::verify`] is **not called for it at all** — so nothing
1213/// is read and nothing is allocated, which was the whole purpose of the two
1214/// `Err`-raising checks this replaced, without their cost: neither the
1215/// engine's own nor a scanner's could refuse one entry without ending the
1216/// run and discarding every entry already recovered.
1217///
1218/// The bounded length, never the declared one, is what is compared — the
1219/// rule [`Candidate::available_len`]'s own doc argues for and the reason a
1220/// truncated tail's garbage declaration cannot decide anything: a candidate
1221/// whose payload ran out can never cost more than the bytes that exist.
1222pub fn annotate_candidates(
1223 scan: &dyn SalvageScan,
1224 src: &mut dyn SeekRead,
1225 candidates: Vec<Candidate>,
1226 policy: &SalvagePolicy,
1227) -> Result<SalvageOutcome> {
1228 let ceiling = policy.max_entry.min(scan.max_whole_entry());
1229 let mut entries = Vec::with_capacity(candidates.len());
1230 // Earliest scan_position to report each distinct (name, declared_len,
1231 // verifier) triple seen so far — a candidate reporting one already in
1232 // here is shadowing that position. A degenerate zero-length checksum
1233 // (every empty file, every directory entry) is never pushed here and
1234 // never looked up here — see `is_degenerate` below.
1235 let mut seen: Vec<(String, Option<u64>, Verifier, usize)> = Vec::new();
1236 // Earliest scan_position to report each distinct NAME, regardless of
1237 // anything else about the record — the weaker, directly-observed fact
1238 // `SalvagedEntry::collides_with` reports, and the one `stuffr list`'s
1239 // own fidelity warning is about. Kept separate from `seen` above rather
1240 // than folded into it: that list is deliberately full of guards against
1241 // over-claiming a COPY (a verifier must exist, the declared length must
1242 // agree, a zero-length checksum never counts), and every one of them
1243 // would be wrong here, where nothing about content is being claimed.
1244 let mut names_seen: Vec<(String, usize)> = Vec::new();
1245
1246 for (scan_position, candidate) in candidates.into_iter().enumerate() {
1247 // Bounded length, then the ceiling, then — only if it fits —
1248 // verification. `verify` is what reads and (for a whole-decoding
1249 // format) allocates, so refusing above it is refusing before the
1250 // allocation, which is the property the two `Err`s this replaced
1251 // existed for.
1252 let bounded_len = candidate.available_len.or(candidate.declared_len);
1253 let status = match bounded_len {
1254 Some(needed) if needed > ceiling => {
1255 SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling { needed, ceiling })
1256 }
1257 _ => scan.verify(src, &candidate)?,
1258 };
1259
1260 let declared_len = candidate.declared_len;
1261 let verifier = candidate.verifier;
1262 // A checksum over zero declared bytes is the same value for every
1263 // empty entry that ever existed (CRC-32 and CRC-16/ARC of an empty
1264 // input are both fixed constants) and proves nothing about any two
1265 // of them being copies of each other — measured against a real
1266 // archive during this task's fix round: every `__init__.py` and
1267 // every directory entry collapsed onto one shadow chain under a
1268 // checksum-only match.
1269 let is_degenerate = declared_len == Some(0);
1270
1271 let shadows = if is_degenerate {
1272 None
1273 } else {
1274 verifier.and_then(|v| {
1275 seen.iter()
1276 .find(|&&(ref seen_name, seen_len, seen_verifier, _)| {
1277 seen_verifier == v
1278 && seen_len == declared_len
1279 && *seen_name == candidate.meta.name
1280 })
1281 .map(|&(_, _, _, earlier_position)| earlier_position)
1282 })
1283 };
1284
1285 if !is_degenerate
1286 && shadows.is_none()
1287 && let Some(v) = verifier
1288 {
1289 seen.push((candidate.meta.name.clone(), declared_len, v, scan_position));
1290 }
1291
1292 // The two annotations are mutually exclusive: a record proven to be
1293 // a copy is reported as a shadow and nothing else, because "this is
1294 // a duplicate of #2" is strictly more informative than "something
1295 // earlier used this name". Everything else that repeats a name is a
1296 // collision.
1297 let earliest_with_name = names_seen
1298 .iter()
1299 .find(|(name, _)| *name == candidate.meta.name)
1300 .map(|&(_, position)| position);
1301 let collides_with = if shadows.is_some() {
1302 None
1303 } else {
1304 earliest_with_name
1305 };
1306 if earliest_with_name.is_none() {
1307 names_seen.push((candidate.meta.name.clone(), scan_position));
1308 }
1309
1310 let marked_deleted = candidate.marked_deleted;
1311 entries.push(
1312 SalvagedEntry::new(
1313 scan_position,
1314 candidate.offset,
1315 candidate.payload_start,
1316 candidate.meta,
1317 status,
1318 )
1319 .with_shadows(shadows)
1320 .with_collides_with(collides_with)
1321 .with_marked_deleted(marked_deleted),
1322 );
1323 }
1324
1325 Ok(SalvageOutcome {
1326 entries,
1327 sightings: Vec::new(),
1328 walk_stop: None,
1329 })
1330}
1331
1332/// Streams `reader` into `out`, bounded to `expected_len` bytes — the
1333/// entry's own declared uncompressed size — so a small compressed input
1334/// cannot expand arbitrarily far past what its own header claims. Never
1335/// buffers a growable copy of the payload, only a fixed 64 KiB window, and
1336/// never trusts the decoder to stop on its own.
1337///
1338/// Returns `true` when exactly `expected_len` bytes were produced, `false`
1339/// when `reader` ran out or errored first — [`crate`]'s own convention
1340/// throughout this module: "did not fully recover" is a fact a caller
1341/// reports (as [`SalvageStatus::Partial`]'s own two causes already are),
1342/// never a [`crate::Error`] that aborts the whole run over one entry.
1343///
1344/// Lives here, rather than duplicated once per format module, because it
1345/// has no format-specific knowledge at all — only `Read`/`Write` and a byte
1346/// count. Task 3c moved it up from `entries.rs`, where it was zip-shaped
1347/// plumbing bolted onto a zip-only write path; every [`SalvageScan`]
1348/// implementation's own `write_payload` (zip's, ARC's, and any later
1349/// format's) calls this same function once it has a decoded reader in
1350/// hand, so a truncated or overrunning decode is reported identically
1351/// regardless of which format produced it.
1352pub fn stream_bounded_copy(
1353 mut reader: impl Read,
1354 expected_len: u64,
1355 out: &mut dyn Write,
1356) -> Result<bool> {
1357 let mut buf = [0u8; 64 * 1024];
1358 let mut produced = 0u64;
1359 loop {
1360 if produced >= expected_len {
1361 return Ok(true);
1362 }
1363 let want = ((expected_len - produced) as usize).min(buf.len());
1364 let n = match reader.read(&mut buf[..want]) {
1365 Ok(0) => return Ok(false),
1366 Ok(n) => n,
1367 Err(_) => return Ok(false),
1368 };
1369 out.write_all(&buf[..n])?;
1370 produced += n as u64;
1371 }
1372}
1373
1374#[cfg(test)]
1375mod tests {
1376 use std::io::{Cursor, Read, Seek, SeekFrom};
1377
1378 use super::*;
1379
1380 fn bytes(data: &[u8]) -> Cursor<Vec<u8>> {
1381 Cursor::new(data.to_vec())
1382 }
1383
1384 /// A reader that panics if asked to read more bytes in one call than its
1385 /// backing buffer holds. `salvage_all`'s own loop never reads a
1386 /// candidate's payload at all (see the module doc), so a correct
1387 /// implementation never trips this — it exists to catch a regression
1388 /// that reads before bounding, the exact shape this project has shipped
1389 /// six times across `cpio`, `ar` and `zip`.
1390 struct PanickingReader(Cursor<Vec<u8>>);
1391
1392 impl Read for PanickingReader {
1393 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1394 let backing_len = self.0.get_ref().len();
1395 assert!(
1396 buf.len() <= backing_len,
1397 "oversized read requested: {} bytes against a {backing_len}-byte source \
1398 — an allocation or read was sized from a declared length before it was bounded",
1399 buf.len()
1400 );
1401 self.0.read(buf)
1402 }
1403 }
1404
1405 impl Seek for PanickingReader {
1406 fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
1407 self.0.seek(pos)
1408 }
1409 }
1410
1411 fn panicking_reader(len: usize) -> PanickingReader {
1412 PanickingReader(Cursor::new(vec![0u8; len]))
1413 }
1414
1415 /// A scanner that never finds anything, however many times it is asked.
1416 struct NeverFinds;
1417
1418 impl SalvageScan for NeverFinds {
1419 fn next_candidate(
1420 &mut self,
1421 _src: &mut dyn SeekRead,
1422 _from: u64,
1423 ) -> Result<Option<Candidate>> {
1424 Ok(None)
1425 }
1426 }
1427
1428 /// A scanner that reports exactly one candidate, declaring the given
1429 /// length, then ends the scan. It never touches `src` itself — it
1430 /// exists purely to hand `salvage_all` a declared length to bound.
1431 struct DeclaresLength(u64);
1432
1433 impl SalvageScan for DeclaresLength {
1434 fn next_candidate(
1435 &mut self,
1436 _src: &mut dyn SeekRead,
1437 from: u64,
1438 ) -> Result<Option<Candidate>> {
1439 if from > 0 {
1440 return Ok(None);
1441 }
1442 Ok(Some(Candidate {
1443 offset: 0,
1444 payload_start: 0,
1445 meta: EntryMeta::file("absurd"),
1446 declared_len: Some(self.0),
1447 verifier: None,
1448 available_len: None,
1449 marked_deleted: false,
1450 }))
1451 }
1452 }
1453
1454 /// A scanner reporting one candidate whose header DECLARES `declared`
1455 /// bytes while the source holds only `available` of them — the
1456 /// truncated-tail shape, the one a real scanner reports by setting
1457 /// [`Candidate::available_len`].
1458 struct DeclaresMoreThanIsThere {
1459 declared: u64,
1460 available: u64,
1461 }
1462
1463 impl SalvageScan for DeclaresMoreThanIsThere {
1464 fn next_candidate(
1465 &mut self,
1466 _src: &mut dyn SeekRead,
1467 from: u64,
1468 ) -> Result<Option<Candidate>> {
1469 if from > 0 {
1470 return Ok(None);
1471 }
1472 Ok(Some(Candidate {
1473 offset: 0,
1474 payload_start: 0,
1475 meta: EntryMeta::file("cut-short"),
1476 declared_len: Some(self.declared),
1477 verifier: Some(Verifier::Crc32(1)),
1478 available_len: Some(self.available),
1479 marked_deleted: false,
1480 }))
1481 }
1482 }
1483
1484 #[test]
1485 fn a_scanner_that_finds_nothing_yields_no_entries() {
1486 let out = salvage_all(
1487 &mut NeverFinds,
1488 &mut bytes(&[0u8; 4096]),
1489 &SalvagePolicy::default(),
1490 );
1491 assert!(
1492 out.unwrap().entries.is_empty(),
1493 "a scanner finding nothing must yield nothing"
1494 );
1495 }
1496
1497 /// The ceiling test that matters most: a reader that PANICS on an
1498 /// oversized read, so this test fails if the read happens at all — not
1499 /// merely if the wrong status comes back. See this module's
1500 /// falsification note in the task report for what happens when the
1501 /// bound is moved below a read.
1502 ///
1503 /// Fix round 3 (Task 3c): the refusal is a per-entry STATUS now, not an
1504 /// `Err` — so this asserts both halves, that nothing was read AND that
1505 /// the run completed with the entry reported. The old assertion
1506 /// (`unwrap_err()`, exit 6) proved only the first and was satisfied by
1507 /// the behaviour that lost every other entry in the archive.
1508 #[test]
1509 fn an_absurd_declared_length_is_refused_before_the_allocation_it_would_size() {
1510 let mut scan = DeclaresLength(u64::MAX);
1511 let out = salvage_all(
1512 &mut scan,
1513 &mut panicking_reader(4096),
1514 &SalvagePolicy::default(),
1515 )
1516 .expect("an entry over the ceiling must not abort the run");
1517 assert_eq!(out.entries.len(), 1, "the entry is still reported");
1518 assert_eq!(
1519 out.entries[0].status,
1520 SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1521 needed: u64::MAX,
1522 ceiling: MAX_SALVAGE_ENTRY,
1523 })
1524 );
1525 }
1526
1527 /// The other half of the same rule, and the one the CLI reaches:
1528 /// everything AROUND an over-ceiling entry is still recovered. Measured
1529 /// before this fix as `stuffr salvage --list --max-entry 4` over a
1530 /// 78-byte ARC — no rows at all, exit 6.
1531 #[test]
1532 fn an_entry_over_the_ceiling_does_not_cost_the_entries_around_it() {
1533 struct TwoCandidates;
1534 impl SalvageScan for TwoCandidates {
1535 fn next_candidate(
1536 &mut self,
1537 _src: &mut dyn SeekRead,
1538 from: u64,
1539 ) -> Result<Option<Candidate>> {
1540 let (offset, len, name) = match from {
1541 0 => (0u64, 16u64, "small-first"),
1542 1..=16 => (17u64, 4096u64, "huge-second"),
1543 _ => return Ok(None),
1544 };
1545 Ok(Some(Candidate {
1546 offset,
1547 payload_start: offset,
1548 meta: EntryMeta::file(name),
1549 declared_len: Some(len),
1550 verifier: Some(Verifier::Crc32(len as u32)),
1551 available_len: None,
1552 marked_deleted: false,
1553 }))
1554 }
1555 }
1556
1557 let out = salvage_all(
1558 &mut TwoCandidates,
1559 &mut bytes(&[0u8; 8192]),
1560 &SalvagePolicy {
1561 max_entry: 64,
1562 ..SalvagePolicy::default()
1563 },
1564 )
1565 .expect("one over-ceiling entry must never abort the run");
1566 assert_eq!(out.entries.len(), 2);
1567 // `TwoCandidates` does not override `verify`, so the first entry
1568 // takes the trait's default — `Unattested` since Salvage Stage 3,
1569 // where it was `Complete`. Neither figure is what this test is
1570 // about: the point is that the SECOND entry's ceiling refusal did
1571 // not cost the first entry its row.
1572 assert_eq!(out.entries[0].status, SalvageStatus::Unattested);
1573 assert_eq!(
1574 out.entries[1].status,
1575 SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1576 needed: 4096,
1577 ceiling: 64,
1578 })
1579 );
1580 }
1581
1582 /// A scanner's own [`SalvageScan::max_whole_entry`] narrows the policy's
1583 /// ceiling — the composition ARC relies on, proven against a mock so it
1584 /// does not depend on ARC's own 256 MiB constant. The candidate here is
1585 /// well under `policy.max_entry` and over the scanner's own figure, so
1586 /// only the `min` can refuse it; the panicking reader proves it is
1587 /// refused without being read.
1588 #[test]
1589 fn a_scanners_own_whole_entry_ceiling_narrows_the_policys() {
1590 struct DecodesWhole;
1591 impl SalvageScan for DecodesWhole {
1592 fn next_candidate(
1593 &mut self,
1594 _src: &mut dyn SeekRead,
1595 from: u64,
1596 ) -> Result<Option<Candidate>> {
1597 if from > 0 {
1598 return Ok(None);
1599 }
1600 Ok(Some(Candidate {
1601 offset: 0,
1602 payload_start: 0,
1603 meta: EntryMeta::file("whole"),
1604 declared_len: Some(2048),
1605 verifier: Some(Verifier::Crc32(7)),
1606 available_len: None,
1607 marked_deleted: false,
1608 }))
1609 }
1610 fn max_whole_entry(&self) -> u64 {
1611 1024
1612 }
1613 fn verify(&self, _src: &mut dyn SeekRead, _c: &Candidate) -> Result<SalvageStatus> {
1614 panic!("verify must not run for a candidate over the ceiling");
1615 }
1616 }
1617
1618 let out = salvage_all(
1619 &mut DecodesWhole,
1620 &mut panicking_reader(4096),
1621 &SalvagePolicy::default(),
1622 )
1623 .expect("a scanner ceiling is a per-entry refusal, not a run failure");
1624 assert_eq!(
1625 out.entries[0].status,
1626 SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1627 needed: 2048,
1628 // The SCANNER's figure, not the policy's 4 GiB — the `min`
1629 // is what this test is really pinning.
1630 ceiling: 1024,
1631 })
1632 );
1633 }
1634
1635 #[test]
1636 fn a_declared_length_within_the_ceiling_is_collected_rather_than_refused() {
1637 let mut scan = DeclaresLength(4096);
1638 let out = salvage_all(
1639 &mut scan,
1640 &mut panicking_reader(4096),
1641 &SalvagePolicy::default(),
1642 )
1643 .unwrap();
1644 assert_eq!(out.entries.len(), 1);
1645 let entry = &out.entries[0];
1646 assert_eq!(entry.scan_position, 0);
1647 assert_eq!(entry.offset, 0);
1648 // `DeclaresLength` does not override `verify`, so this is the
1649 // trait's default: `Unattested` since Salvage Stage 3, where it was
1650 // `Complete`. The test is about the entry being COLLECTED — its
1651 // declared length sits under the ceiling — not about which tier a
1652 // mock that checks nothing lands in.
1653 assert_eq!(entry.status, SalvageStatus::Unattested);
1654 assert_eq!(entry.shadows, None);
1655 assert_eq!(entry.collides_with, None);
1656 }
1657
1658 /// A truncated candidate is COLLECTED, not dropped — the whole point of
1659 /// [`Candidate::available_len`]. Before it existed, `zip_salvage.rs`
1660 /// refused such a candidate outright and a truncated archive's last
1661 /// entry vanished with no row, no note and exit 0.
1662 #[test]
1663 fn a_candidate_whose_payload_ran_out_is_still_collected() {
1664 let mut scan = DeclaresMoreThanIsThere {
1665 declared: 4000,
1666 available: 1950,
1667 };
1668 let out = salvage_all(
1669 &mut scan,
1670 &mut panicking_reader(4096),
1671 &SalvagePolicy::default(),
1672 )
1673 .unwrap();
1674 assert_eq!(
1675 out.entries.len(),
1676 1,
1677 "a header found but not completable is a fact worth reporting"
1678 );
1679 }
1680
1681 /// The ceiling is checked against what could be READ, not against what
1682 /// a truncated header CLAIMED. Otherwise a garbage length in a tail the
1683 /// scan has already established is not there would abort the entire run
1684 /// at exit 6 — losing every earlier entry over an absent one, in the
1685 /// verb that exists to rescue exactly that archive.
1686 #[test]
1687 fn a_truncated_candidate_is_bounded_by_what_is_there_not_by_what_it_claimed() {
1688 let mut scan = DeclaresMoreThanIsThere {
1689 declared: u64::MAX,
1690 available: 2048,
1691 };
1692 let out = salvage_all(
1693 &mut scan,
1694 &mut panicking_reader(4096),
1695 &SalvagePolicy::default(),
1696 )
1697 .expect("an absent tail must not abort a run that recovered real entries");
1698 assert_eq!(out.entries.len(), 1);
1699 }
1700
1701 /// The complement, so the rule above cannot be read as "truncated
1702 /// candidates are never bounded": what IS there is still bounded, and a
1703 /// truncated candidate holding more available bytes than the ceiling
1704 /// allows is refused exactly as an untruncated one would be — reported
1705 /// [`UnverifiedCause::OverEntryCeiling`], never read (the reader
1706 /// panics if it is), and never allowed to end the run.
1707 #[test]
1708 fn a_truncated_candidate_whose_available_bytes_exceed_the_ceiling_is_refused() {
1709 let mut scan = DeclaresMoreThanIsThere {
1710 declared: u64::MAX,
1711 available: 4096,
1712 };
1713 let out = salvage_all(
1714 &mut scan,
1715 &mut panicking_reader(4096),
1716 &SalvagePolicy {
1717 max_entry: 1024,
1718 ..SalvagePolicy::default()
1719 },
1720 )
1721 .expect("a bounded refusal is per-entry, never a run failure");
1722 assert_eq!(
1723 out.entries[0].status,
1724 // `needed` is 4096 — the bytes that ARE there — never the
1725 // `u64::MAX` this candidate's header declared. Fix round 4
1726 // (NEW-F): the figures a user is shown are the ones the
1727 // comparison used.
1728 SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1729 needed: 4096,
1730 ceiling: 1024,
1731 }),
1732 "an over-ceiling entry is unverified, not truncated: nobody read it"
1733 );
1734 }
1735
1736 #[test]
1737 fn the_default_policy_is_recovery_biased() {
1738 let policy = SalvagePolicy::default();
1739 assert_eq!(policy.partial, PartialPolicy::Keep);
1740 assert_eq!(policy.max_entry, MAX_SALVAGE_ENTRY);
1741 assert!(!policy.strict);
1742 }
1743
1744 // -----------------------------------------------------------------
1745 // Task 4: `verify` dispatch and shadow detection, proven against a
1746 // mock — same discipline as the rest of this module, since neither
1747 // mechanism needs to know which format it is annotating.
1748 // -----------------------------------------------------------------
1749
1750 /// Reports a fixed, caller-scripted sequence of candidates and answers
1751 /// `verify` from the SAME script rather than decoding anything — this
1752 /// module's engine is proven against mocks, and verification/shadow
1753 /// detection are no exception. Each entry names its own `name` and
1754 /// `declared_len` (rather than a fixed, offset-derived name) so a test
1755 /// can freely vary any one of the three fields shadow detection now
1756 /// requires to agree.
1757 struct ScriptedCandidates {
1758 plan: Vec<(&'static str, Option<u64>, Option<Verifier>, SalvageStatus)>,
1759 next: usize,
1760 }
1761
1762 impl SalvageScan for ScriptedCandidates {
1763 fn next_candidate(
1764 &mut self,
1765 _src: &mut dyn SeekRead,
1766 _from: u64,
1767 ) -> Result<Option<Candidate>> {
1768 if self.next >= self.plan.len() {
1769 return Ok(None);
1770 }
1771 let offset = self.next as u64;
1772 let (name, declared_len, verifier, _) = self.plan[self.next];
1773 self.next += 1;
1774 Ok(Some(Candidate {
1775 offset,
1776 payload_start: offset,
1777 meta: EntryMeta::file(name),
1778 declared_len,
1779 verifier,
1780 available_len: None,
1781 marked_deleted: false,
1782 }))
1783 }
1784
1785 fn verify(&self, _src: &mut dyn SeekRead, candidate: &Candidate) -> Result<SalvageStatus> {
1786 Ok(self.plan[candidate.offset as usize].3)
1787 }
1788 }
1789
1790 /// `verify` decides each entry's status — the whole point of Task 4:
1791 /// this used to be a hardcoded `Complete` for every candidate, which was
1792 /// wrong for any format carrying a real checksum.
1793 #[test]
1794 fn verify_decides_each_entrys_status_not_a_hardcoded_default() {
1795 let mut scan = ScriptedCandidates {
1796 plan: vec![
1797 (
1798 "a",
1799 Some(1),
1800 Some(Verifier::Crc32(1)),
1801 SalvageStatus::Intact,
1802 ),
1803 ("b", Some(1), None, SalvageStatus::Complete),
1804 (
1805 "c",
1806 Some(1),
1807 Some(Verifier::Crc32(2)),
1808 SalvageStatus::Partial,
1809 ),
1810 ],
1811 next: 0,
1812 };
1813 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1814 .expect("scripted candidates must annotate cleanly");
1815 assert_eq!(out.entries[0].status, SalvageStatus::Intact);
1816 assert_eq!(out.entries[1].status, SalvageStatus::Complete);
1817 assert_eq!(out.entries[2].status, SalvageStatus::Partial);
1818 }
1819
1820 /// A later candidate reporting the SAME name, declared_len AND verifier
1821 /// as an earlier one is its shadow — pointing at the earliest position,
1822 /// not merely "some" earlier one.
1823 #[test]
1824 fn a_later_candidate_sharing_an_earlier_verifier_is_marked_as_its_shadow() {
1825 let mut scan = ScriptedCandidates {
1826 plan: vec![
1827 (
1828 "dup",
1829 Some(1),
1830 Some(Verifier::Crc32(7)),
1831 SalvageStatus::Intact,
1832 ),
1833 (
1834 "other",
1835 Some(1),
1836 Some(Verifier::Crc32(9)),
1837 SalvageStatus::Intact,
1838 ),
1839 (
1840 "dup",
1841 Some(1),
1842 Some(Verifier::Crc32(7)),
1843 SalvageStatus::Intact,
1844 ),
1845 ],
1846 next: 0,
1847 };
1848 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1849 .expect("scripted candidates must annotate cleanly");
1850 assert_eq!(out.entries[0].shadows, None);
1851 assert_eq!(out.entries[1].shadows, None);
1852 assert_eq!(
1853 out.entries[2].shadows,
1854 Some(0),
1855 "must point at the earliest matching position"
1856 );
1857 }
1858
1859 /// A candidate with no verifier at all (a format with no checksum, or a
1860 /// zip data-descriptor entry) must never be reported as shadowing or
1861 /// shadowed — there is nothing to measure, so nothing is claimed.
1862 #[test]
1863 fn candidates_with_no_verifier_never_shadow_each_other() {
1864 let mut scan = ScriptedCandidates {
1865 plan: vec![
1866 ("a", Some(1), None, SalvageStatus::Complete),
1867 ("a", Some(1), None, SalvageStatus::Complete),
1868 ],
1869 next: 0,
1870 };
1871 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1872 .expect("scripted candidates must annotate cleanly");
1873 assert_eq!(out.entries[0].shadows, None);
1874 assert_eq!(out.entries[1].shadows, None);
1875 }
1876
1877 /// Two candidates whose checksum AND declared_len agree but whose NAME
1878 /// differs must never be linked — the fix-round requirement that a
1879 /// checksum match alone is not enough, only the full
1880 /// `(name, declared_len, verifier)` triple.
1881 #[test]
1882 fn a_matching_verifier_under_a_different_name_is_never_marked_as_a_shadow() {
1883 let mut scan = ScriptedCandidates {
1884 plan: vec![
1885 (
1886 "one.txt",
1887 Some(4),
1888 Some(Verifier::Crc32(42)),
1889 SalvageStatus::Intact,
1890 ),
1891 (
1892 "two.txt",
1893 Some(4),
1894 Some(Verifier::Crc32(42)),
1895 SalvageStatus::Intact,
1896 ),
1897 ],
1898 next: 0,
1899 };
1900 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1901 .expect("scripted candidates must annotate cleanly");
1902 assert_eq!(
1903 out.entries[1].shadows, None,
1904 "same checksum, different name — not a shadow"
1905 );
1906 }
1907
1908 /// The degenerate case: every empty entry (and, in zip, every directory
1909 /// entry) checksums to the same fixed constant over zero declared
1910 /// bytes. Even under the SAME name, a zero-length checksum must never
1911 /// mark a shadow — the value proves nothing, unlike a real collision.
1912 #[test]
1913 fn a_zero_length_declared_verifier_never_shadows_even_under_a_matching_name() {
1914 let mut scan = ScriptedCandidates {
1915 plan: vec![
1916 (
1917 "empty.txt",
1918 Some(0),
1919 Some(Verifier::Crc32(0)),
1920 SalvageStatus::Complete,
1921 ),
1922 (
1923 "empty.txt",
1924 Some(0),
1925 Some(Verifier::Crc32(0)),
1926 SalvageStatus::Complete,
1927 ),
1928 ],
1929 next: 0,
1930 };
1931 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1932 .expect("scripted candidates must annotate cleanly");
1933 assert_eq!(out.entries[0].shadows, None);
1934 assert_eq!(
1935 out.entries[1].shadows, None,
1936 "a zero-byte checksum must never mark a shadow, even under an identical name"
1937 );
1938 assert_eq!(
1939 out.entries[1].collides_with,
1940 Some(0),
1941 "the NAME still repeats, and that is a directly observed fact no checksum \
1942 guard applies to"
1943 );
1944 }
1945
1946 // -----------------------------------------------------------------
1947 // Name collisions: the second annotation, separate from shadowing.
1948 // -----------------------------------------------------------------
1949
1950 /// The finding this annotation closes: two records under one name whose
1951 /// content does NOT agree are what `stuffr list` calls shadowed, and
1952 /// what salvage marked in no way at all — because its own `shadows`
1953 /// means something stricter.
1954 #[test]
1955 fn a_repeated_name_with_different_content_is_reported_as_a_collision() {
1956 let mut scan = ScriptedCandidates {
1957 plan: vec![
1958 (
1959 "dup.txt",
1960 Some(21),
1961 Some(Verifier::Crc32(7)),
1962 SalvageStatus::Intact,
1963 ),
1964 (
1965 "other.txt",
1966 Some(21),
1967 Some(Verifier::Crc32(9)),
1968 SalvageStatus::Intact,
1969 ),
1970 (
1971 "dup.txt",
1972 Some(21),
1973 Some(Verifier::Crc32(11)),
1974 SalvageStatus::Intact,
1975 ),
1976 ],
1977 next: 0,
1978 };
1979 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1980 .expect("scripted candidates must annotate cleanly");
1981 assert_eq!(out.entries[2].shadows, None, "the content does not agree");
1982 assert_eq!(
1983 out.entries[2].collides_with,
1984 Some(0),
1985 "but the name does, and that is the fact `list` warns about"
1986 );
1987 assert_eq!(out.entries[0].collides_with, None);
1988 assert_eq!(out.entries[1].collides_with, None);
1989 }
1990
1991 /// The two annotations are mutually exclusive: a record PROVEN to be a
1992 /// copy says so, and does not also report the weaker fact. Reporting
1993 /// both would make every shadow print two markers for one relationship.
1994 #[test]
1995 fn a_proven_shadow_is_not_also_reported_as_a_name_collision() {
1996 let mut scan = ScriptedCandidates {
1997 plan: vec![
1998 (
1999 "dup.txt",
2000 Some(21),
2001 Some(Verifier::Crc32(7)),
2002 SalvageStatus::Intact,
2003 ),
2004 (
2005 "dup.txt",
2006 Some(21),
2007 Some(Verifier::Crc32(7)),
2008 SalvageStatus::Intact,
2009 ),
2010 ],
2011 next: 0,
2012 };
2013 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2014 .expect("scripted candidates must annotate cleanly");
2015 assert_eq!(out.entries[1].shadows, Some(0));
2016 assert_eq!(out.entries[1].collides_with, None);
2017 }
2018
2019 /// A third record under a name two earlier ones already used points at
2020 /// the EARLIEST of them, the same rule `shadows` follows — so a chain of
2021 /// collisions names one origin rather than each pointing at its
2022 /// predecessor.
2023 #[test]
2024 fn a_third_record_under_one_name_names_the_earliest_position() {
2025 let mut scan = ScriptedCandidates {
2026 plan: vec![
2027 (
2028 "dup",
2029 Some(4),
2030 Some(Verifier::Crc32(1)),
2031 SalvageStatus::Intact,
2032 ),
2033 (
2034 "dup",
2035 Some(4),
2036 Some(Verifier::Crc32(2)),
2037 SalvageStatus::Intact,
2038 ),
2039 (
2040 "dup",
2041 Some(4),
2042 Some(Verifier::Crc32(3)),
2043 SalvageStatus::Intact,
2044 ),
2045 ],
2046 next: 0,
2047 };
2048 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2049 .expect("scripted candidates must annotate cleanly");
2050 assert_eq!(out.entries[1].collides_with, Some(0));
2051 assert_eq!(out.entries[2].collides_with, Some(0));
2052 }
2053
2054 /// The negative double: distinct names must never be marked, however
2055 /// much else they share. Without this, an annotation that fired on
2056 /// everything would pass every test above.
2057 #[test]
2058 fn distinct_names_are_never_reported_as_colliding() {
2059 let mut scan = ScriptedCandidates {
2060 plan: vec![
2061 (
2062 "one.txt",
2063 Some(4),
2064 Some(Verifier::Crc32(42)),
2065 SalvageStatus::Intact,
2066 ),
2067 (
2068 "two.txt",
2069 Some(4),
2070 Some(Verifier::Crc32(42)),
2071 SalvageStatus::Intact,
2072 ),
2073 ],
2074 next: 0,
2075 };
2076 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2077 .expect("scripted candidates must annotate cleanly");
2078 assert_eq!(out.entries[0].collides_with, None);
2079 assert_eq!(out.entries[1].collides_with, None);
2080 }
2081
2082 /// A collision needs no verifier at all, unlike a shadow. A format with
2083 /// no checksum (tar, cpio, ar — and a zip data-descriptor entry) can
2084 /// still repeat a name, and that repetition is exactly as real there.
2085 #[test]
2086 fn a_repeated_name_collides_even_with_no_verifier_to_compare() {
2087 let mut scan = ScriptedCandidates {
2088 plan: vec![
2089 ("same", Some(4), None, SalvageStatus::Complete),
2090 ("same", Some(4), None, SalvageStatus::Complete),
2091 ],
2092 next: 0,
2093 };
2094 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2095 .expect("scripted candidates must annotate cleanly");
2096 assert_eq!(
2097 out.entries[1].shadows, None,
2098 "no verifier, so nothing proves they are copies"
2099 );
2100 assert_eq!(
2101 out.entries[1].collides_with,
2102 Some(0),
2103 "but the name repeats, which needs no verifier to observe"
2104 );
2105 }
2106
2107 /// Ruling S-I. `Candidate` is `#[non_exhaustive]`, so an out-of-crate
2108 /// scanner reaches it only through `new` plus the `with_*` setters —
2109 /// which means the DEFAULTS are now public API, not an implementation
2110 /// detail of six struct literals. Each of the four must assert the
2111 /// least: nothing declared, nothing to verify with, nothing missing,
2112 /// nothing deleted.
2113 #[test]
2114 fn a_new_candidate_claims_nothing_it_was_not_told() {
2115 let c = Candidate::new(7, 42, EntryMeta::file("probe"));
2116 assert_eq!(c.offset, 7);
2117 assert_eq!(c.payload_start, 42);
2118 assert_eq!(c.declared_len, None, "no length was declared");
2119 assert_eq!(c.verifier, None, "nothing can prove the content");
2120 assert_eq!(
2121 c.available_len, None,
2122 "a shortfall is MEASURED, never assumed"
2123 );
2124 assert!(!c.marked_deleted, "no record is deleted until one says so");
2125 }
2126
2127 /// The four setters are the only route to those fields from another
2128 /// crate, so each one is pinned rather than trusted to the compiler.
2129 #[test]
2130 fn each_candidate_setter_reaches_its_own_field() {
2131 let c = Candidate::new(0, 0, EntryMeta::file("probe"))
2132 .with_declared_len(Some(9))
2133 .with_verifier(Some(Verifier::Crc16(0xbeef)))
2134 .with_available_len(Some(4))
2135 .with_marked_deleted(true);
2136 assert_eq!(c.declared_len, Some(9));
2137 assert_eq!(c.verifier, Some(Verifier::Crc16(0xbeef)));
2138 assert_eq!(c.available_len, Some(4));
2139 assert!(c.marked_deleted);
2140 }
2141
2142 /// Same ruling, same reason, for the type every per-format
2143 /// `write_payload` test builds directly. Its three defaults are the
2144 /// annotations `annotate_candidates` adds, so an entry built by hand
2145 /// must carry none of them.
2146 #[test]
2147 fn a_new_salvaged_entry_carries_no_annotations() {
2148 let e = SalvagedEntry::new(3, 7, 42, EntryMeta::file("probe"), SalvageStatus::Complete);
2149 assert_eq!(e.scan_position, 3);
2150 assert_eq!(e.offset, 7);
2151 assert_eq!(e.payload_start, 42);
2152 assert_eq!(e.status, SalvageStatus::Complete);
2153 assert_eq!(e.shadows, None);
2154 assert_eq!(e.collides_with, None);
2155 assert!(!e.marked_deleted);
2156
2157 let annotated = SalvagedEntry::new(0, 0, 0, EntryMeta::file("p"), SalvageStatus::Intact)
2158 .with_shadows(Some(1))
2159 .with_collides_with(Some(2))
2160 .with_marked_deleted(true);
2161 assert_eq!(annotated.shadows, Some(1));
2162 assert_eq!(annotated.collides_with, Some(2));
2163 assert!(annotated.marked_deleted);
2164 }
2165
2166 /// Task 2-N: the note a caller prints for its sightings. None for none —
2167 /// a run with nothing refused must print nothing — and otherwise one
2168 /// group per (format, shape), each with its complete count and at most
2169 /// [`SIGHTING_OFFSETS_SHOWN`] offsets, in the order first seen.
2170 #[test]
2171 fn describe_sightings_groups_by_shape_and_bounds_what_it_lists() {
2172 assert_eq!(describe_sightings(&[]), None);
2173
2174 let f = FormatId::new("fmt");
2175 let mut seen: Vec<Sighting> = (0..10u64)
2176 .map(|i| Sighting::new(f, i * 512, "a(s)"))
2177 .collect();
2178 seen.insert(1, Sighting::new(f, 7, "b(s)"));
2179 let note = describe_sightings(&seen).unwrap();
2180 assert!(
2181 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"),
2182 "{note}"
2183 );
2184 assert!(note.contains("`stuffr list`"), "{note}");
2185 }
2186
2187 /// Task 4: the walk-stop note. A hole says what the brief requires —
2188 /// the entries after it are unreachable by construction, NOT absent,
2189 /// with the offset and the byte count — and a cut-short tail says the
2190 /// opposite, because nothing can be hiding in too few bytes for a record.
2191 #[test]
2192 fn describe_walk_stop_says_unreachable_for_a_hole_and_complete_for_a_cut() {
2193 let f = FormatId::new("fmt");
2194 let hole = describe_walk_stop(&WalkStop::new(
2195 f,
2196 1234,
2197 5000,
2198 WalkStopKind::Unreadable,
2199 "bad field",
2200 ));
2201 for needle in [
2202 "offset 1234",
2203 "5000 bytes",
2204 "bad field",
2205 "unreachable by construction",
2206 "not absent",
2207 "fmt",
2208 ] {
2209 assert!(hole.contains(needle), "missing {needle:?}: {hole}");
2210 }
2211 let cut = describe_walk_stop(&WalkStop::new(f, 90, 1, WalkStopKind::CutShort, "eof"));
2212 assert!(cut.contains("1 byte into the record at offset 90"), "{cut}");
2213 assert!(cut.contains("cut short"), "{cut}");
2214 assert!(!cut.contains("unreachable"), "{cut}");
2215 let shape = describe_walk_stop(&WalkStop::new(
2216 f,
2217 8,
2218 600,
2219 WalkStopKind::UnsupportedShape,
2220 "the symbol table",
2221 ));
2222 assert!(shape.contains("the symbol table"), "{shape}");
2223 assert!(shape.contains("not evidence of damage"), "{shape}");
2224 assert!(!shape.contains("unreachable by construction"), "{shape}");
2225 }
2226}