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: an `ar` symbol table whose header the reader cannot
819 /// parse; GNU `ar`'s Mach-O `__.SYMDEF` with a blank mode was the first,
820 /// and is read since 0.8.1). Not evidence of damage; what follows is
821 /// unreachable to this scanner.
822 UnsupportedShape,
823}
824
825impl WalkStop {
826 pub fn new(
827 format: FormatId,
828 offset: u64,
829 remaining: u64,
830 kind: WalkStopKind,
831 cause: impl Into<String>,
832 ) -> Self {
833 Self {
834 format,
835 offset,
836 remaining,
837 kind,
838 cause: cause.into(),
839 }
840 }
841}
842
843/// The one sentence a caller prints for a [`WalkStop`] — written here, once,
844/// so every sequential-only scanner gets the same report without a line of
845/// its own, and so its kinds can never be described two ways each.
846///
847/// An [`WalkStopKind::Unreadable`] stop says the entries after it are
848/// *unreachable by construction, not absent*, with the offset and the byte
849/// count; a [`WalkStopKind::CutShort`] one says that nothing after it is
850/// missing from the report, which is exactly as true — the file ended inside
851/// that record; an [`WalkStopKind::UnsupportedShape`] one names the shape and
852/// says it is not evidence of damage.
853///
854/// Two of the sentences say the walk goes "as `stuffr list` does". That is a
855/// contract on every scanner that reports a [`WalkStop`]: it walks with its
856/// format's ORDINARY reader, over the file's own bytes, repairing none of
857/// them. `ar`'s walk served its global header as `!<arch>\n` until Stage 3
858/// Task 4's fix round 1, which made the phrase false (a bad magic `list`
859/// refuses was walked past) — a scanner that needs a repair like that must
860/// get its own sentence here rather than borrow this one.
861pub fn describe_walk_stop(stop: &WalkStop) -> String {
862 let WalkStop {
863 format,
864 offset,
865 remaining,
866 kind,
867 cause,
868 } = stop;
869 let bytes = if *remaining == 1 { "byte" } else { "bytes" };
870 match kind {
871 WalkStopKind::Unreadable => format!(
872 "this build's {format} salvage scanner walks the archive in order from its start, \
873 as `stuffr list` does, and stopped at offset {offset} with {remaining} {bytes} still \
874 to go: {cause}. The {format} format marks its records with nothing strong enough \
875 to search for, so the scanner cannot find its way past a record it cannot read; \
876 any entries in those {remaining} {bytes} are unreachable by construction — not \
877 absent, and not counted in this report"
878 ),
879 WalkStopKind::UnsupportedShape => format!(
880 "this build's {format} salvage scanner walks the archive in order from its start, \
881 as `stuffr list` does, and stopped at offset {offset} with {remaining} {bytes} still \
882 to go: {cause}. That is a record shape this build does not read, not evidence of \
883 damage; any entries in those {remaining} {bytes} are unreachable to this scanner \
884 and not counted in this report"
885 ),
886 WalkStopKind::CutShort => format!(
887 "this build's {format} salvage scanner walks the archive in order from its start, \
888 and the file ends {remaining} {bytes} into the record at offset {offset} ({cause}): \
889 too few for any further entry, so the archive is cut short there and nothing \
890 after that point is missing from this report"
891 ),
892 }
893}
894
895#[derive(Debug, Clone, Copy, PartialEq, Eq)]
896pub enum PartialPolicy {
897 Keep,
898 Skip,
899 Ask,
900}
901
902pub struct SalvagePolicy {
903 /// Recovery-biased by default: salvage is the one permissive verb, and
904 /// the strict path is the entire rest of the tool.
905 pub partial: PartialPolicy,
906 /// Structural ceiling on any one entry's length, refused BEFORE
907 /// allocation — and refused as a per-entry
908 /// [`UnverifiedCause::OverEntryCeiling`], never as an error that ends
909 /// the run. [`SalvageScan::max_whole_entry`] narrows it further for a
910 /// format that decodes an entry whole; the two compose as a minimum and
911 /// are applied together, once, in [`annotate_candidates`].
912 pub max_entry: u64,
913 /// Demand proof: every [`SalvageStatus::Partial`] entry is skipped,
914 /// whatever [`Self::partial`] says, and so is every
915 /// [`SalvageStatus::Unattested`] one (Ruling 3-H, Salvage Stage 3).
916 ///
917 /// The second clause is this flag's doing, unlike the two refused
918 /// below: an `Unattested` entry is WRITTEN by default, under its real
919 /// name, and only `strict` declines it — the format carries nothing
920 /// that could prove it, which is exactly what "demand proof" asks for.
921 ///
922 /// **Those two are the whole of it, and this doc claimed three things
923 /// until the final whole-branch review's F5.** It read "partial skipped,
924 /// ceiling fixed, nothing unverifiable", and only the first clause was
925 /// this flag's doing. The ceiling is NOT fixed under `strict`:
926 /// [`Self::max_entry`] applies identically with and without it, and
927 /// `--max-entry` is honoured either way. And an
928 /// [`SalvageStatus::Unverified`] entry is never written under any
929 /// policy at all — that is the ops layer's unconditional rule, not
930 /// something `strict` causes — so crediting it here made a flag look
931 /// load-bearing for a guarantee that holds without it.
932 ///
933 /// The true clauses are enough to justify the flag: `salvage` is this
934 /// tool's single recovery-biased verb, so `strict` is how a caller asks
935 /// for the discipline every other verb applies by default.
936 pub strict: bool,
937}
938
939impl Default for SalvagePolicy {
940 fn default() -> Self {
941 Self {
942 partial: PartialPolicy::Keep,
943 max_entry: MAX_SALVAGE_ENTRY,
944 strict: false,
945 }
946 }
947}
948
949/// The per-format seam. A scanner recognises its own format's record shape
950/// (a local header's magic, a member's checksum field) and reports what it
951/// finds as a stream of [`Candidate`]s; [`salvage_all`] supplies everything
952/// that does not depend on which format that is.
953pub trait SalvageScan {
954 /// Find the next candidate at or after `from`. `Ok(None)` ends the scan.
955 fn next_candidate(&mut self, src: &mut dyn SeekRead, from: u64) -> Result<Option<Candidate>>;
956
957 /// The largest single entry this scanner is willing to have read for it
958 /// — the ceiling [`annotate_candidates`] applies, narrowed further by
959 /// `policy.max_entry`, before it calls [`Self::verify`] at all.
960 ///
961 /// `u64::MAX` (the default) means "this scanner imposes none of its
962 /// own": zip streams every payload it verifies through a `take`, so the
963 /// only figure that bounds it is the policy's. A format that **decodes
964 /// an entry whole** — ARC, and the legacy containers still to come —
965 /// overrides this with its own container-side ceiling
966 /// (`arc.rs`'s `MAX_ARC_ENTRY_LEN`, 256 MiB), because for those the
967 /// figure really does become a single allocation.
968 ///
969 /// **Declaring a ceiling here is the ONLY way a scanner refuses an
970 /// entry for its size**, and that is the point: the refusal is then a
971 /// [`SalvageStatus`] the engine assigns
972 /// ([`UnverifiedCause::OverEntryCeiling`]), not a [`crate::Error`] the
973 /// scanner raises, so it can never abort a run over one entry. A
974 /// scanner that instead checks a size inside [`Self::verify`] and
975 /// `?`-propagates the failure reintroduces the exact defect three fix
976 /// rounds of Task 3c chased.
977 ///
978 /// **That is documented, not enforced, and saying so is more useful
979 /// than claiming otherwise.** An earlier wording here called the defect
980 /// "unrepresentable"; it is not. [`Self::verify`] still returns
981 /// `Result` — it must, so a scanner CAN report a genuine run-level
982 /// fault — so the wrong thing is still expressible. What changed is
983 /// that the right thing now exists, costs one method, and is written
984 /// into [`Self::verify`]'s contract below.
985 fn max_whole_entry(&self) -> u64 {
986 u64::MAX
987 }
988
989 /// Decide what was actually proven about a candidate's payload, after
990 /// discovery — called once per candidate, in file order, by
991 /// [`annotate_candidates`].
992 ///
993 /// # The contract on `Err`
994 ///
995 /// **`Err` here aborts the WHOLE run**, discarding every entry already
996 /// recovered, so it is reserved for a fault that really is about the
997 /// run and not about this one entry (the destination is gone, the
998 /// source handle itself failed). Everything a single candidate can be
999 /// wrong about — a short read, a bad checksum, a method this build
1000 /// cannot decode, a device error partway through one payload — is a
1001 /// [`SalvageStatus`], never an `Err`. `zip_salvage.rs`'s own
1002 /// `verify_candidate` has stated this in prose since Stage 1 and folds
1003 /// every one of those; ARC's did not, for one case (its size ceiling),
1004 /// and that single `?` cost three fix rounds. The size ceiling now
1005 /// belongs to [`Self::max_whole_entry`], which the engine applies, so
1006 /// there is nothing left for an implementation to `?` on.
1007 ///
1008 /// # What an implementation may assume
1009 ///
1010 /// The candidate's bounded length (`available_len` if the payload was
1011 /// truncated, else `declared_len`) is already **at or below**
1012 /// [`Self::max_whole_entry`] — [`annotate_candidates`] answers
1013 /// [`UnverifiedCause::OverEntryCeiling`] itself, without calling this,
1014 /// for anything above it. So an implementation that decodes whole may
1015 /// size a buffer from that length without re-checking it.
1016 ///
1017 /// The default answers [`SalvageStatus::Unattested`] unconditionally,
1018 /// which is the least this method can claim: it reads nothing, decodes
1019 /// nothing and compares nothing, so "nothing attests that this candidate
1020 /// is an entry at all" is exactly what happened. A format that carries a
1021 /// checksum overrides this to decode the payload — reusing its own
1022 /// existing codec machinery, never inventing a new one here — and
1023 /// compare it against [`Candidate::verifier`]; a format that carries a
1024 /// self-verifying HEADER and no content checksum (tar) overrides it to
1025 /// check that header and answer [`SalvageStatus::Complete`].
1026 ///
1027 /// **It answered `Complete` until Salvage Stage 3**, and the change is
1028 /// not cosmetic. `Complete` asserts that every declared byte was present
1029 /// *and the header self-verified* — two claims this default has never
1030 /// been in a position to make — and it routes to **exit 0**, so a
1031 /// checksumless scanner that simply forgot to override was reporting a
1032 /// clean recovery over candidates nothing had examined. `Unattested`
1033 /// routes to exit 4 and says why on the row. The same argument
1034 /// [`Candidate::new`] makes for its own four defaults: the default is
1035 /// the answer that asserts the LEAST, so forgetting to override is
1036 /// visible rather than flattering.
1037 fn verify(&self, _src: &mut dyn SeekRead, _candidate: &Candidate) -> Result<SalvageStatus> {
1038 Ok(SalvageStatus::Unattested)
1039 }
1040
1041 /// Decode one salvaged entry's payload out of the archive at
1042 /// `archive_path` and into `out`, answering whether the entry's whole
1043 /// declared length was produced.
1044 ///
1045 /// # Why this is on the trait at all (final whole-branch review, F2)
1046 ///
1047 /// `0.6.0` spends a breaking release making this trait implementable
1048 /// from outside. It was implementable and not USABLE: the ops layer's
1049 /// payload dispatch was a private `match` on a [`crate::FormatId`]
1050 /// string, so a scanner written outside this workspace got
1051 /// [`salvage_all`] and none of the behaviour that makes `salvage` what
1052 /// it is. This method is where a scanner's write half now DECLARES
1053 /// itself, beside the scan half, in one place a reader can see both.
1054 ///
1055 /// **It does not by itself make an out-of-tree scanner reachable from
1056 /// `stuffr salvage`, and pretending otherwise would be the same
1057 /// over-claim F2 named.** The ops layer still resolves a format to a
1058 /// scanner by name, with no registration point for a third party to add
1059 /// one; closing that needs a salvage registry and is Stage 3's. What
1060 /// changed is that the seam is now declared rather than scattered — the
1061 /// five in-tree scanners implement this method and the ops layer calls
1062 /// it, so the next one has a signature to fit rather than a convention
1063 /// to guess.
1064 ///
1065 /// # The contract
1066 ///
1067 /// `Ok(true)` means every declared byte was produced; `Ok(false)` means
1068 /// the payload ran short or the decoder stopped early, which the caller
1069 /// renders as a `.partial` write. **`Err` is reserved for the same
1070 /// narrow set [`Self::verify`] reserves it for** — with one addition
1071 /// the ops layer relies on: [`crate::Error::Io`] here is treated as a
1072 /// fact about THIS entry (the archive could not be re-opened or read
1073 /// for it, the destination refused the bytes) and folded into that
1074 /// entry's own outcome, never the run's. A size refusal is never an
1075 /// `Err`: it belongs to [`Self::max_whole_entry`], which the engine
1076 /// applies before `verify` is ever called.
1077 ///
1078 /// The default refuses. A scanner that only ever reports
1079 /// [`SalvageStatus::Unverified`] has nothing to write, and inheriting a
1080 /// refusal is the honest answer for it; the alternative — a required
1081 /// method — would be a breaking change to every implementation for no
1082 /// gain to one that cannot write anything anyway.
1083 fn write_payload(
1084 &self,
1085 _archive_path: &Path,
1086 _entry: &SalvagedEntry,
1087 _compressed_len: u64,
1088 _out: &mut dyn Write,
1089 ) -> Result<bool> {
1090 Err(Error::Unsupported(
1091 "this salvage scanner has no payload writer".to_string(),
1092 ))
1093 }
1094}
1095
1096/// Walk `scan` over `src`, collecting every candidate it reports into a
1097/// [`SalvageOutcome`].
1098///
1099/// **Requires a seekable source.** Scanning searches back and forth over the
1100/// archive rather than reading it once in order, so this takes
1101/// `&mut dyn SeekRead` rather than the ladder's `Box<dyn Source>` (whose
1102/// `as_seek()` may be `None` for a pipe). A caller sitting on a
1103/// non-seekable source must spool it first — exactly as the `arj` and `zoo`
1104/// containers already do, reporting `Rung::Spilled` — which is a later
1105/// task's ops-layer concern, not this function's.
1106///
1107/// Every candidate's length is bounded against the run's entry ceiling
1108/// (`policy.max_entry`, narrowed by [`SalvageScan::max_whole_entry`])
1109/// **before** anything is sized from it — in [`annotate_candidates`], one
1110/// step before the only place that could size anything, and as a per-entry
1111/// [`UnverifiedCause::OverEntryCeiling`] rather than a [`crate::Error`] that ends
1112/// the run.
1113pub fn salvage_all(
1114 scan: &mut dyn SalvageScan,
1115 src: &mut dyn SeekRead,
1116 policy: &SalvagePolicy,
1117) -> Result<SalvageOutcome> {
1118 let candidates = collect_candidates(scan, src)?;
1119 annotate_candidates(&*scan, src, candidates, policy)
1120}
1121
1122/// The resync loop alone: find a candidate, advance strictly past it,
1123/// repeat. Never reads a payload, never sizes anything from a declared
1124/// length, and never calls [`SalvageScan::verify`] — see
1125/// [`annotate_candidates`] for both the entry ceiling and the verification.
1126///
1127/// Exposed separately from [`salvage_all`] so a format that reconciles more
1128/// than one candidate source (zip's central-directory fallback) can collect
1129/// from each source before merging, and annotate the merged list once.
1130///
1131/// # The entry ceiling used to be enforced here, and was an `Err`
1132///
1133/// Salvage Stage 2 Task 3c's fix round 3 moved it to
1134/// [`annotate_candidates`] and turned it into a status. Two things were
1135/// wrong with it here, and only the second is obvious:
1136///
1137/// 1. **It refused a whole archive over one entry.** Measured at the CLI on
1138/// a 78-byte two-entry ARC, `stuffr salvage --list --max-entry 4`
1139/// printed no rows and exited 6 — the healthy first entry, which the
1140/// same binary recovers byte-for-byte at the default ceiling, simply
1141/// lost. That is the same defect the ARC scanner's own ceiling had, in
1142/// the engine rather than in a format.
1143/// 2. **It was one of two ceilings, checked in two places**, so which
1144/// disposition an over-ceiling entry got depended on which check saw it
1145/// first. There is now one rule, applied once, to both figures.
1146///
1147/// Nothing is weakened by the move: this loop sizes nothing from a declared
1148/// length (it only advances `from` by it), so "bounded before anything is
1149/// sized from it" holds exactly as before — the bound simply sits one
1150/// function later, still strictly before the only allocation there is.
1151pub fn collect_candidates(
1152 scan: &mut dyn SalvageScan,
1153 src: &mut dyn SeekRead,
1154) -> Result<Vec<Candidate>> {
1155 let mut candidates = Vec::new();
1156 let mut from = 0u64;
1157
1158 while let Some(candidate) = scan.next_candidate(src, from)? {
1159 // Advance strictly past this candidate so the scan always makes
1160 // progress — but NEVER by a length this candidate merely CLAIMED.
1161 // `available_len.is_some()` means the header's own declared length
1162 // disagreed with the file (there were fewer bytes left than it
1163 // said), and a header that lied about its size once cannot be
1164 // trusted to say how much of the source its "payload" consumed
1165 // either. This used to advance by `available_len` — the bytes
1166 // actually present, not the declared figure — on the reasoning that
1167 // a truncated entry has nothing worth finding after it anyway. That
1168 // is true for a genuinely truncated REAL last entry, but the
1169 // engine cannot tell that case apart from a coincidental
1170 // false-positive header sitting on top of noise with a garbage
1171 // multi-hundred-megabyte declared length: `available_len` is the
1172 // same "everything left in the file" figure either way. Salvage
1173 // Stage 2 Task 3's fix round 1 found exactly this — one
1174 // coincidental marker+name match in an ARC noise corpus, with a
1175 // declared length far past the file, jumped the scan straight to
1176 // EOF in one step and silently dropped every real entry that
1177 // happened to sit between it and the end of the file: no error, no
1178 // `Partial`, no note, in the one verb whose entire job is not
1179 // losing entries silently. So a truncated candidate now advances
1180 // minimally — past its own marker byte only — so the very next
1181 // byte onward is examined for another header. That costs a few
1182 // bytes of re-scanning after a genuinely truncated real entry
1183 // (which has nothing left to find anyway, so the outcome is
1184 // unchanged); it is the entire fix for a phantom, whose lie no
1185 // longer costs the rest of the archive. An UNTRUNCATED candidate's
1186 // declared length was just checked against the file two lines
1187 // above — it fits, so it is not a lie, and advancing by it is
1188 // unchanged.
1189 let advance = if candidate.available_len.is_some() {
1190 1
1191 } else {
1192 candidate.declared_len.unwrap_or(1).max(1)
1193 };
1194 from = candidate.offset.saturating_add(advance);
1195
1196 candidates.push(candidate);
1197 }
1198
1199 Ok(candidates)
1200}
1201
1202/// Turns already-discovered candidates — in file order — into the bounded,
1203/// verified, shadow-annotated entries a caller receives. See this module's
1204/// doc comment for what the passes (bound, verify, shadow) mean and why
1205/// they live here rather than per-format.
1206///
1207/// # The entry ceiling, in one place (fix round 3, Task 3c)
1208///
1209/// `policy.max_entry` narrowed by [`SalvageScan::max_whole_entry`] is the
1210/// run's ceiling on ONE entry, and this is the only place it is applied. A
1211/// candidate whose bounded length (`available_len` if the payload is
1212/// truncated, else `declared_len`) is over it is reported
1213/// [`SalvageStatus::Unverified`]`(`[`UnverifiedCause::OverEntryCeiling`]`)`
1214/// and [`SalvageScan::verify`] is **not called for it at all** — so nothing
1215/// is read and nothing is allocated, which was the whole purpose of the two
1216/// `Err`-raising checks this replaced, without their cost: neither the
1217/// engine's own nor a scanner's could refuse one entry without ending the
1218/// run and discarding every entry already recovered.
1219///
1220/// The bounded length, never the declared one, is what is compared — the
1221/// rule [`Candidate::available_len`]'s own doc argues for and the reason a
1222/// truncated tail's garbage declaration cannot decide anything: a candidate
1223/// whose payload ran out can never cost more than the bytes that exist.
1224pub fn annotate_candidates(
1225 scan: &dyn SalvageScan,
1226 src: &mut dyn SeekRead,
1227 candidates: Vec<Candidate>,
1228 policy: &SalvagePolicy,
1229) -> Result<SalvageOutcome> {
1230 let ceiling = policy.max_entry.min(scan.max_whole_entry());
1231 let mut entries = Vec::with_capacity(candidates.len());
1232 // Earliest scan_position to report each distinct (name, declared_len,
1233 // verifier) triple seen so far — a candidate reporting one already in
1234 // here is shadowing that position. A degenerate zero-length checksum
1235 // (every empty file, every directory entry) is never pushed here and
1236 // never looked up here — see `is_degenerate` below.
1237 let mut seen: Vec<(String, Option<u64>, Verifier, usize)> = Vec::new();
1238 // Earliest scan_position to report each distinct NAME, regardless of
1239 // anything else about the record — the weaker, directly-observed fact
1240 // `SalvagedEntry::collides_with` reports, and the one `stuffr list`'s
1241 // own fidelity warning is about. Kept separate from `seen` above rather
1242 // than folded into it: that list is deliberately full of guards against
1243 // over-claiming a COPY (a verifier must exist, the declared length must
1244 // agree, a zero-length checksum never counts), and every one of them
1245 // would be wrong here, where nothing about content is being claimed.
1246 let mut names_seen: Vec<(String, usize)> = Vec::new();
1247
1248 for (scan_position, candidate) in candidates.into_iter().enumerate() {
1249 // Bounded length, then the ceiling, then — only if it fits —
1250 // verification. `verify` is what reads and (for a whole-decoding
1251 // format) allocates, so refusing above it is refusing before the
1252 // allocation, which is the property the two `Err`s this replaced
1253 // existed for.
1254 let bounded_len = candidate.available_len.or(candidate.declared_len);
1255 let status = match bounded_len {
1256 Some(needed) if needed > ceiling => {
1257 SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling { needed, ceiling })
1258 }
1259 _ => scan.verify(src, &candidate)?,
1260 };
1261
1262 let declared_len = candidate.declared_len;
1263 let verifier = candidate.verifier;
1264 // A checksum over zero declared bytes is the same value for every
1265 // empty entry that ever existed (CRC-32 and CRC-16/ARC of an empty
1266 // input are both fixed constants) and proves nothing about any two
1267 // of them being copies of each other — measured against a real
1268 // archive during this task's fix round: every `__init__.py` and
1269 // every directory entry collapsed onto one shadow chain under a
1270 // checksum-only match.
1271 let is_degenerate = declared_len == Some(0);
1272
1273 let shadows = if is_degenerate {
1274 None
1275 } else {
1276 verifier.and_then(|v| {
1277 seen.iter()
1278 .find(|&&(ref seen_name, seen_len, seen_verifier, _)| {
1279 seen_verifier == v
1280 && seen_len == declared_len
1281 && *seen_name == candidate.meta.name
1282 })
1283 .map(|&(_, _, _, earlier_position)| earlier_position)
1284 })
1285 };
1286
1287 if !is_degenerate
1288 && shadows.is_none()
1289 && let Some(v) = verifier
1290 {
1291 seen.push((candidate.meta.name.clone(), declared_len, v, scan_position));
1292 }
1293
1294 // The two annotations are mutually exclusive: a record proven to be
1295 // a copy is reported as a shadow and nothing else, because "this is
1296 // a duplicate of #2" is strictly more informative than "something
1297 // earlier used this name". Everything else that repeats a name is a
1298 // collision.
1299 let earliest_with_name = names_seen
1300 .iter()
1301 .find(|(name, _)| *name == candidate.meta.name)
1302 .map(|&(_, position)| position);
1303 let collides_with = if shadows.is_some() {
1304 None
1305 } else {
1306 earliest_with_name
1307 };
1308 if earliest_with_name.is_none() {
1309 names_seen.push((candidate.meta.name.clone(), scan_position));
1310 }
1311
1312 let marked_deleted = candidate.marked_deleted;
1313 entries.push(
1314 SalvagedEntry::new(
1315 scan_position,
1316 candidate.offset,
1317 candidate.payload_start,
1318 candidate.meta,
1319 status,
1320 )
1321 .with_shadows(shadows)
1322 .with_collides_with(collides_with)
1323 .with_marked_deleted(marked_deleted),
1324 );
1325 }
1326
1327 Ok(SalvageOutcome {
1328 entries,
1329 sightings: Vec::new(),
1330 walk_stop: None,
1331 })
1332}
1333
1334/// Streams `reader` into `out`, bounded to `expected_len` bytes — the
1335/// entry's own declared uncompressed size — so a small compressed input
1336/// cannot expand arbitrarily far past what its own header claims. Never
1337/// buffers a growable copy of the payload, only a fixed 64 KiB window, and
1338/// never trusts the decoder to stop on its own.
1339///
1340/// Returns `true` when exactly `expected_len` bytes were produced, `false`
1341/// when `reader` ran out or errored first — [`crate`]'s own convention
1342/// throughout this module: "did not fully recover" is a fact a caller
1343/// reports (as [`SalvageStatus::Partial`]'s own two causes already are),
1344/// never a [`crate::Error`] that aborts the whole run over one entry.
1345///
1346/// Lives here, rather than duplicated once per format module, because it
1347/// has no format-specific knowledge at all — only `Read`/`Write` and a byte
1348/// count. Task 3c moved it up from `entries.rs`, where it was zip-shaped
1349/// plumbing bolted onto a zip-only write path; every [`SalvageScan`]
1350/// implementation's own `write_payload` (zip's, ARC's, and any later
1351/// format's) calls this same function once it has a decoded reader in
1352/// hand, so a truncated or overrunning decode is reported identically
1353/// regardless of which format produced it.
1354pub fn stream_bounded_copy(
1355 mut reader: impl Read,
1356 expected_len: u64,
1357 out: &mut dyn Write,
1358) -> Result<bool> {
1359 let mut buf = [0u8; 64 * 1024];
1360 let mut produced = 0u64;
1361 loop {
1362 if produced >= expected_len {
1363 return Ok(true);
1364 }
1365 let want = ((expected_len - produced) as usize).min(buf.len());
1366 let n = match reader.read(&mut buf[..want]) {
1367 Ok(0) => return Ok(false),
1368 Ok(n) => n,
1369 Err(_) => return Ok(false),
1370 };
1371 out.write_all(&buf[..n])?;
1372 produced += n as u64;
1373 }
1374}
1375
1376#[cfg(test)]
1377mod tests {
1378 use std::io::{Cursor, Read, Seek, SeekFrom};
1379
1380 use super::*;
1381
1382 fn bytes(data: &[u8]) -> Cursor<Vec<u8>> {
1383 Cursor::new(data.to_vec())
1384 }
1385
1386 /// A reader that panics if asked to read more bytes in one call than its
1387 /// backing buffer holds. `salvage_all`'s own loop never reads a
1388 /// candidate's payload at all (see the module doc), so a correct
1389 /// implementation never trips this — it exists to catch a regression
1390 /// that reads before bounding, the exact shape this project has shipped
1391 /// six times across `cpio`, `ar` and `zip`.
1392 struct PanickingReader(Cursor<Vec<u8>>);
1393
1394 impl Read for PanickingReader {
1395 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1396 let backing_len = self.0.get_ref().len();
1397 assert!(
1398 buf.len() <= backing_len,
1399 "oversized read requested: {} bytes against a {backing_len}-byte source \
1400 — an allocation or read was sized from a declared length before it was bounded",
1401 buf.len()
1402 );
1403 self.0.read(buf)
1404 }
1405 }
1406
1407 impl Seek for PanickingReader {
1408 fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
1409 self.0.seek(pos)
1410 }
1411 }
1412
1413 fn panicking_reader(len: usize) -> PanickingReader {
1414 PanickingReader(Cursor::new(vec![0u8; len]))
1415 }
1416
1417 /// A scanner that never finds anything, however many times it is asked.
1418 struct NeverFinds;
1419
1420 impl SalvageScan for NeverFinds {
1421 fn next_candidate(
1422 &mut self,
1423 _src: &mut dyn SeekRead,
1424 _from: u64,
1425 ) -> Result<Option<Candidate>> {
1426 Ok(None)
1427 }
1428 }
1429
1430 /// A scanner that reports exactly one candidate, declaring the given
1431 /// length, then ends the scan. It never touches `src` itself — it
1432 /// exists purely to hand `salvage_all` a declared length to bound.
1433 struct DeclaresLength(u64);
1434
1435 impl SalvageScan for DeclaresLength {
1436 fn next_candidate(
1437 &mut self,
1438 _src: &mut dyn SeekRead,
1439 from: u64,
1440 ) -> Result<Option<Candidate>> {
1441 if from > 0 {
1442 return Ok(None);
1443 }
1444 Ok(Some(Candidate {
1445 offset: 0,
1446 payload_start: 0,
1447 meta: EntryMeta::file("absurd"),
1448 declared_len: Some(self.0),
1449 verifier: None,
1450 available_len: None,
1451 marked_deleted: false,
1452 }))
1453 }
1454 }
1455
1456 /// A scanner reporting one candidate whose header DECLARES `declared`
1457 /// bytes while the source holds only `available` of them — the
1458 /// truncated-tail shape, the one a real scanner reports by setting
1459 /// [`Candidate::available_len`].
1460 struct DeclaresMoreThanIsThere {
1461 declared: u64,
1462 available: u64,
1463 }
1464
1465 impl SalvageScan for DeclaresMoreThanIsThere {
1466 fn next_candidate(
1467 &mut self,
1468 _src: &mut dyn SeekRead,
1469 from: u64,
1470 ) -> Result<Option<Candidate>> {
1471 if from > 0 {
1472 return Ok(None);
1473 }
1474 Ok(Some(Candidate {
1475 offset: 0,
1476 payload_start: 0,
1477 meta: EntryMeta::file("cut-short"),
1478 declared_len: Some(self.declared),
1479 verifier: Some(Verifier::Crc32(1)),
1480 available_len: Some(self.available),
1481 marked_deleted: false,
1482 }))
1483 }
1484 }
1485
1486 #[test]
1487 fn a_scanner_that_finds_nothing_yields_no_entries() {
1488 let out = salvage_all(
1489 &mut NeverFinds,
1490 &mut bytes(&[0u8; 4096]),
1491 &SalvagePolicy::default(),
1492 );
1493 assert!(
1494 out.unwrap().entries.is_empty(),
1495 "a scanner finding nothing must yield nothing"
1496 );
1497 }
1498
1499 /// The ceiling test that matters most: a reader that PANICS on an
1500 /// oversized read, so this test fails if the read happens at all — not
1501 /// merely if the wrong status comes back. See this module's
1502 /// falsification note in the task report for what happens when the
1503 /// bound is moved below a read.
1504 ///
1505 /// Fix round 3 (Task 3c): the refusal is a per-entry STATUS now, not an
1506 /// `Err` — so this asserts both halves, that nothing was read AND that
1507 /// the run completed with the entry reported. The old assertion
1508 /// (`unwrap_err()`, exit 6) proved only the first and was satisfied by
1509 /// the behaviour that lost every other entry in the archive.
1510 #[test]
1511 fn an_absurd_declared_length_is_refused_before_the_allocation_it_would_size() {
1512 let mut scan = DeclaresLength(u64::MAX);
1513 let out = salvage_all(
1514 &mut scan,
1515 &mut panicking_reader(4096),
1516 &SalvagePolicy::default(),
1517 )
1518 .expect("an entry over the ceiling must not abort the run");
1519 assert_eq!(out.entries.len(), 1, "the entry is still reported");
1520 assert_eq!(
1521 out.entries[0].status,
1522 SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1523 needed: u64::MAX,
1524 ceiling: MAX_SALVAGE_ENTRY,
1525 })
1526 );
1527 }
1528
1529 /// The other half of the same rule, and the one the CLI reaches:
1530 /// everything AROUND an over-ceiling entry is still recovered. Measured
1531 /// before this fix as `stuffr salvage --list --max-entry 4` over a
1532 /// 78-byte ARC — no rows at all, exit 6.
1533 #[test]
1534 fn an_entry_over_the_ceiling_does_not_cost_the_entries_around_it() {
1535 struct TwoCandidates;
1536 impl SalvageScan for TwoCandidates {
1537 fn next_candidate(
1538 &mut self,
1539 _src: &mut dyn SeekRead,
1540 from: u64,
1541 ) -> Result<Option<Candidate>> {
1542 let (offset, len, name) = match from {
1543 0 => (0u64, 16u64, "small-first"),
1544 1..=16 => (17u64, 4096u64, "huge-second"),
1545 _ => return Ok(None),
1546 };
1547 Ok(Some(Candidate {
1548 offset,
1549 payload_start: offset,
1550 meta: EntryMeta::file(name),
1551 declared_len: Some(len),
1552 verifier: Some(Verifier::Crc32(len as u32)),
1553 available_len: None,
1554 marked_deleted: false,
1555 }))
1556 }
1557 }
1558
1559 let out = salvage_all(
1560 &mut TwoCandidates,
1561 &mut bytes(&[0u8; 8192]),
1562 &SalvagePolicy {
1563 max_entry: 64,
1564 ..SalvagePolicy::default()
1565 },
1566 )
1567 .expect("one over-ceiling entry must never abort the run");
1568 assert_eq!(out.entries.len(), 2);
1569 // `TwoCandidates` does not override `verify`, so the first entry
1570 // takes the trait's default — `Unattested` since Salvage Stage 3,
1571 // where it was `Complete`. Neither figure is what this test is
1572 // about: the point is that the SECOND entry's ceiling refusal did
1573 // not cost the first entry its row.
1574 assert_eq!(out.entries[0].status, SalvageStatus::Unattested);
1575 assert_eq!(
1576 out.entries[1].status,
1577 SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1578 needed: 4096,
1579 ceiling: 64,
1580 })
1581 );
1582 }
1583
1584 /// A scanner's own [`SalvageScan::max_whole_entry`] narrows the policy's
1585 /// ceiling — the composition ARC relies on, proven against a mock so it
1586 /// does not depend on ARC's own 256 MiB constant. The candidate here is
1587 /// well under `policy.max_entry` and over the scanner's own figure, so
1588 /// only the `min` can refuse it; the panicking reader proves it is
1589 /// refused without being read.
1590 #[test]
1591 fn a_scanners_own_whole_entry_ceiling_narrows_the_policys() {
1592 struct DecodesWhole;
1593 impl SalvageScan for DecodesWhole {
1594 fn next_candidate(
1595 &mut self,
1596 _src: &mut dyn SeekRead,
1597 from: u64,
1598 ) -> Result<Option<Candidate>> {
1599 if from > 0 {
1600 return Ok(None);
1601 }
1602 Ok(Some(Candidate {
1603 offset: 0,
1604 payload_start: 0,
1605 meta: EntryMeta::file("whole"),
1606 declared_len: Some(2048),
1607 verifier: Some(Verifier::Crc32(7)),
1608 available_len: None,
1609 marked_deleted: false,
1610 }))
1611 }
1612 fn max_whole_entry(&self) -> u64 {
1613 1024
1614 }
1615 fn verify(&self, _src: &mut dyn SeekRead, _c: &Candidate) -> Result<SalvageStatus> {
1616 panic!("verify must not run for a candidate over the ceiling");
1617 }
1618 }
1619
1620 let out = salvage_all(
1621 &mut DecodesWhole,
1622 &mut panicking_reader(4096),
1623 &SalvagePolicy::default(),
1624 )
1625 .expect("a scanner ceiling is a per-entry refusal, not a run failure");
1626 assert_eq!(
1627 out.entries[0].status,
1628 SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1629 needed: 2048,
1630 // The SCANNER's figure, not the policy's 4 GiB — the `min`
1631 // is what this test is really pinning.
1632 ceiling: 1024,
1633 })
1634 );
1635 }
1636
1637 #[test]
1638 fn a_declared_length_within_the_ceiling_is_collected_rather_than_refused() {
1639 let mut scan = DeclaresLength(4096);
1640 let out = salvage_all(
1641 &mut scan,
1642 &mut panicking_reader(4096),
1643 &SalvagePolicy::default(),
1644 )
1645 .unwrap();
1646 assert_eq!(out.entries.len(), 1);
1647 let entry = &out.entries[0];
1648 assert_eq!(entry.scan_position, 0);
1649 assert_eq!(entry.offset, 0);
1650 // `DeclaresLength` does not override `verify`, so this is the
1651 // trait's default: `Unattested` since Salvage Stage 3, where it was
1652 // `Complete`. The test is about the entry being COLLECTED — its
1653 // declared length sits under the ceiling — not about which tier a
1654 // mock that checks nothing lands in.
1655 assert_eq!(entry.status, SalvageStatus::Unattested);
1656 assert_eq!(entry.shadows, None);
1657 assert_eq!(entry.collides_with, None);
1658 }
1659
1660 /// A truncated candidate is COLLECTED, not dropped — the whole point of
1661 /// [`Candidate::available_len`]. Before it existed, `zip_salvage.rs`
1662 /// refused such a candidate outright and a truncated archive's last
1663 /// entry vanished with no row, no note and exit 0.
1664 #[test]
1665 fn a_candidate_whose_payload_ran_out_is_still_collected() {
1666 let mut scan = DeclaresMoreThanIsThere {
1667 declared: 4000,
1668 available: 1950,
1669 };
1670 let out = salvage_all(
1671 &mut scan,
1672 &mut panicking_reader(4096),
1673 &SalvagePolicy::default(),
1674 )
1675 .unwrap();
1676 assert_eq!(
1677 out.entries.len(),
1678 1,
1679 "a header found but not completable is a fact worth reporting"
1680 );
1681 }
1682
1683 /// The ceiling is checked against what could be READ, not against what
1684 /// a truncated header CLAIMED. Otherwise a garbage length in a tail the
1685 /// scan has already established is not there would abort the entire run
1686 /// at exit 6 — losing every earlier entry over an absent one, in the
1687 /// verb that exists to rescue exactly that archive.
1688 #[test]
1689 fn a_truncated_candidate_is_bounded_by_what_is_there_not_by_what_it_claimed() {
1690 let mut scan = DeclaresMoreThanIsThere {
1691 declared: u64::MAX,
1692 available: 2048,
1693 };
1694 let out = salvage_all(
1695 &mut scan,
1696 &mut panicking_reader(4096),
1697 &SalvagePolicy::default(),
1698 )
1699 .expect("an absent tail must not abort a run that recovered real entries");
1700 assert_eq!(out.entries.len(), 1);
1701 }
1702
1703 /// The complement, so the rule above cannot be read as "truncated
1704 /// candidates are never bounded": what IS there is still bounded, and a
1705 /// truncated candidate holding more available bytes than the ceiling
1706 /// allows is refused exactly as an untruncated one would be — reported
1707 /// [`UnverifiedCause::OverEntryCeiling`], never read (the reader
1708 /// panics if it is), and never allowed to end the run.
1709 #[test]
1710 fn a_truncated_candidate_whose_available_bytes_exceed_the_ceiling_is_refused() {
1711 let mut scan = DeclaresMoreThanIsThere {
1712 declared: u64::MAX,
1713 available: 4096,
1714 };
1715 let out = salvage_all(
1716 &mut scan,
1717 &mut panicking_reader(4096),
1718 &SalvagePolicy {
1719 max_entry: 1024,
1720 ..SalvagePolicy::default()
1721 },
1722 )
1723 .expect("a bounded refusal is per-entry, never a run failure");
1724 assert_eq!(
1725 out.entries[0].status,
1726 // `needed` is 4096 — the bytes that ARE there — never the
1727 // `u64::MAX` this candidate's header declared. Fix round 4
1728 // (NEW-F): the figures a user is shown are the ones the
1729 // comparison used.
1730 SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling {
1731 needed: 4096,
1732 ceiling: 1024,
1733 }),
1734 "an over-ceiling entry is unverified, not truncated: nobody read it"
1735 );
1736 }
1737
1738 #[test]
1739 fn the_default_policy_is_recovery_biased() {
1740 let policy = SalvagePolicy::default();
1741 assert_eq!(policy.partial, PartialPolicy::Keep);
1742 assert_eq!(policy.max_entry, MAX_SALVAGE_ENTRY);
1743 assert!(!policy.strict);
1744 }
1745
1746 // -----------------------------------------------------------------
1747 // Task 4: `verify` dispatch and shadow detection, proven against a
1748 // mock — same discipline as the rest of this module, since neither
1749 // mechanism needs to know which format it is annotating.
1750 // -----------------------------------------------------------------
1751
1752 /// Reports a fixed, caller-scripted sequence of candidates and answers
1753 /// `verify` from the SAME script rather than decoding anything — this
1754 /// module's engine is proven against mocks, and verification/shadow
1755 /// detection are no exception. Each entry names its own `name` and
1756 /// `declared_len` (rather than a fixed, offset-derived name) so a test
1757 /// can freely vary any one of the three fields shadow detection now
1758 /// requires to agree.
1759 struct ScriptedCandidates {
1760 plan: Vec<(&'static str, Option<u64>, Option<Verifier>, SalvageStatus)>,
1761 next: usize,
1762 }
1763
1764 impl SalvageScan for ScriptedCandidates {
1765 fn next_candidate(
1766 &mut self,
1767 _src: &mut dyn SeekRead,
1768 _from: u64,
1769 ) -> Result<Option<Candidate>> {
1770 if self.next >= self.plan.len() {
1771 return Ok(None);
1772 }
1773 let offset = self.next as u64;
1774 let (name, declared_len, verifier, _) = self.plan[self.next];
1775 self.next += 1;
1776 Ok(Some(Candidate {
1777 offset,
1778 payload_start: offset,
1779 meta: EntryMeta::file(name),
1780 declared_len,
1781 verifier,
1782 available_len: None,
1783 marked_deleted: false,
1784 }))
1785 }
1786
1787 fn verify(&self, _src: &mut dyn SeekRead, candidate: &Candidate) -> Result<SalvageStatus> {
1788 Ok(self.plan[candidate.offset as usize].3)
1789 }
1790 }
1791
1792 /// `verify` decides each entry's status — the whole point of Task 4:
1793 /// this used to be a hardcoded `Complete` for every candidate, which was
1794 /// wrong for any format carrying a real checksum.
1795 #[test]
1796 fn verify_decides_each_entrys_status_not_a_hardcoded_default() {
1797 let mut scan = ScriptedCandidates {
1798 plan: vec![
1799 (
1800 "a",
1801 Some(1),
1802 Some(Verifier::Crc32(1)),
1803 SalvageStatus::Intact,
1804 ),
1805 ("b", Some(1), None, SalvageStatus::Complete),
1806 (
1807 "c",
1808 Some(1),
1809 Some(Verifier::Crc32(2)),
1810 SalvageStatus::Partial,
1811 ),
1812 ],
1813 next: 0,
1814 };
1815 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1816 .expect("scripted candidates must annotate cleanly");
1817 assert_eq!(out.entries[0].status, SalvageStatus::Intact);
1818 assert_eq!(out.entries[1].status, SalvageStatus::Complete);
1819 assert_eq!(out.entries[2].status, SalvageStatus::Partial);
1820 }
1821
1822 /// A later candidate reporting the SAME name, declared_len AND verifier
1823 /// as an earlier one is its shadow — pointing at the earliest position,
1824 /// not merely "some" earlier one.
1825 #[test]
1826 fn a_later_candidate_sharing_an_earlier_verifier_is_marked_as_its_shadow() {
1827 let mut scan = ScriptedCandidates {
1828 plan: vec![
1829 (
1830 "dup",
1831 Some(1),
1832 Some(Verifier::Crc32(7)),
1833 SalvageStatus::Intact,
1834 ),
1835 (
1836 "other",
1837 Some(1),
1838 Some(Verifier::Crc32(9)),
1839 SalvageStatus::Intact,
1840 ),
1841 (
1842 "dup",
1843 Some(1),
1844 Some(Verifier::Crc32(7)),
1845 SalvageStatus::Intact,
1846 ),
1847 ],
1848 next: 0,
1849 };
1850 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1851 .expect("scripted candidates must annotate cleanly");
1852 assert_eq!(out.entries[0].shadows, None);
1853 assert_eq!(out.entries[1].shadows, None);
1854 assert_eq!(
1855 out.entries[2].shadows,
1856 Some(0),
1857 "must point at the earliest matching position"
1858 );
1859 }
1860
1861 /// A candidate with no verifier at all (a format with no checksum, or a
1862 /// zip data-descriptor entry) must never be reported as shadowing or
1863 /// shadowed — there is nothing to measure, so nothing is claimed.
1864 #[test]
1865 fn candidates_with_no_verifier_never_shadow_each_other() {
1866 let mut scan = ScriptedCandidates {
1867 plan: vec![
1868 ("a", Some(1), None, SalvageStatus::Complete),
1869 ("a", Some(1), None, SalvageStatus::Complete),
1870 ],
1871 next: 0,
1872 };
1873 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1874 .expect("scripted candidates must annotate cleanly");
1875 assert_eq!(out.entries[0].shadows, None);
1876 assert_eq!(out.entries[1].shadows, None);
1877 }
1878
1879 /// Two candidates whose checksum AND declared_len agree but whose NAME
1880 /// differs must never be linked — the fix-round requirement that a
1881 /// checksum match alone is not enough, only the full
1882 /// `(name, declared_len, verifier)` triple.
1883 #[test]
1884 fn a_matching_verifier_under_a_different_name_is_never_marked_as_a_shadow() {
1885 let mut scan = ScriptedCandidates {
1886 plan: vec![
1887 (
1888 "one.txt",
1889 Some(4),
1890 Some(Verifier::Crc32(42)),
1891 SalvageStatus::Intact,
1892 ),
1893 (
1894 "two.txt",
1895 Some(4),
1896 Some(Verifier::Crc32(42)),
1897 SalvageStatus::Intact,
1898 ),
1899 ],
1900 next: 0,
1901 };
1902 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1903 .expect("scripted candidates must annotate cleanly");
1904 assert_eq!(
1905 out.entries[1].shadows, None,
1906 "same checksum, different name — not a shadow"
1907 );
1908 }
1909
1910 /// The degenerate case: every empty entry (and, in zip, every directory
1911 /// entry) checksums to the same fixed constant over zero declared
1912 /// bytes. Even under the SAME name, a zero-length checksum must never
1913 /// mark a shadow — the value proves nothing, unlike a real collision.
1914 #[test]
1915 fn a_zero_length_declared_verifier_never_shadows_even_under_a_matching_name() {
1916 let mut scan = ScriptedCandidates {
1917 plan: vec![
1918 (
1919 "empty.txt",
1920 Some(0),
1921 Some(Verifier::Crc32(0)),
1922 SalvageStatus::Complete,
1923 ),
1924 (
1925 "empty.txt",
1926 Some(0),
1927 Some(Verifier::Crc32(0)),
1928 SalvageStatus::Complete,
1929 ),
1930 ],
1931 next: 0,
1932 };
1933 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1934 .expect("scripted candidates must annotate cleanly");
1935 assert_eq!(out.entries[0].shadows, None);
1936 assert_eq!(
1937 out.entries[1].shadows, None,
1938 "a zero-byte checksum must never mark a shadow, even under an identical name"
1939 );
1940 assert_eq!(
1941 out.entries[1].collides_with,
1942 Some(0),
1943 "the NAME still repeats, and that is a directly observed fact no checksum \
1944 guard applies to"
1945 );
1946 }
1947
1948 // -----------------------------------------------------------------
1949 // Name collisions: the second annotation, separate from shadowing.
1950 // -----------------------------------------------------------------
1951
1952 /// The finding this annotation closes: two records under one name whose
1953 /// content does NOT agree are what `stuffr list` calls shadowed, and
1954 /// what salvage marked in no way at all — because its own `shadows`
1955 /// means something stricter.
1956 #[test]
1957 fn a_repeated_name_with_different_content_is_reported_as_a_collision() {
1958 let mut scan = ScriptedCandidates {
1959 plan: vec![
1960 (
1961 "dup.txt",
1962 Some(21),
1963 Some(Verifier::Crc32(7)),
1964 SalvageStatus::Intact,
1965 ),
1966 (
1967 "other.txt",
1968 Some(21),
1969 Some(Verifier::Crc32(9)),
1970 SalvageStatus::Intact,
1971 ),
1972 (
1973 "dup.txt",
1974 Some(21),
1975 Some(Verifier::Crc32(11)),
1976 SalvageStatus::Intact,
1977 ),
1978 ],
1979 next: 0,
1980 };
1981 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
1982 .expect("scripted candidates must annotate cleanly");
1983 assert_eq!(out.entries[2].shadows, None, "the content does not agree");
1984 assert_eq!(
1985 out.entries[2].collides_with,
1986 Some(0),
1987 "but the name does, and that is the fact `list` warns about"
1988 );
1989 assert_eq!(out.entries[0].collides_with, None);
1990 assert_eq!(out.entries[1].collides_with, None);
1991 }
1992
1993 /// The two annotations are mutually exclusive: a record PROVEN to be a
1994 /// copy says so, and does not also report the weaker fact. Reporting
1995 /// both would make every shadow print two markers for one relationship.
1996 #[test]
1997 fn a_proven_shadow_is_not_also_reported_as_a_name_collision() {
1998 let mut scan = ScriptedCandidates {
1999 plan: vec![
2000 (
2001 "dup.txt",
2002 Some(21),
2003 Some(Verifier::Crc32(7)),
2004 SalvageStatus::Intact,
2005 ),
2006 (
2007 "dup.txt",
2008 Some(21),
2009 Some(Verifier::Crc32(7)),
2010 SalvageStatus::Intact,
2011 ),
2012 ],
2013 next: 0,
2014 };
2015 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2016 .expect("scripted candidates must annotate cleanly");
2017 assert_eq!(out.entries[1].shadows, Some(0));
2018 assert_eq!(out.entries[1].collides_with, None);
2019 }
2020
2021 /// A third record under a name two earlier ones already used points at
2022 /// the EARLIEST of them, the same rule `shadows` follows — so a chain of
2023 /// collisions names one origin rather than each pointing at its
2024 /// predecessor.
2025 #[test]
2026 fn a_third_record_under_one_name_names_the_earliest_position() {
2027 let mut scan = ScriptedCandidates {
2028 plan: vec![
2029 (
2030 "dup",
2031 Some(4),
2032 Some(Verifier::Crc32(1)),
2033 SalvageStatus::Intact,
2034 ),
2035 (
2036 "dup",
2037 Some(4),
2038 Some(Verifier::Crc32(2)),
2039 SalvageStatus::Intact,
2040 ),
2041 (
2042 "dup",
2043 Some(4),
2044 Some(Verifier::Crc32(3)),
2045 SalvageStatus::Intact,
2046 ),
2047 ],
2048 next: 0,
2049 };
2050 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2051 .expect("scripted candidates must annotate cleanly");
2052 assert_eq!(out.entries[1].collides_with, Some(0));
2053 assert_eq!(out.entries[2].collides_with, Some(0));
2054 }
2055
2056 /// The negative double: distinct names must never be marked, however
2057 /// much else they share. Without this, an annotation that fired on
2058 /// everything would pass every test above.
2059 #[test]
2060 fn distinct_names_are_never_reported_as_colliding() {
2061 let mut scan = ScriptedCandidates {
2062 plan: vec![
2063 (
2064 "one.txt",
2065 Some(4),
2066 Some(Verifier::Crc32(42)),
2067 SalvageStatus::Intact,
2068 ),
2069 (
2070 "two.txt",
2071 Some(4),
2072 Some(Verifier::Crc32(42)),
2073 SalvageStatus::Intact,
2074 ),
2075 ],
2076 next: 0,
2077 };
2078 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2079 .expect("scripted candidates must annotate cleanly");
2080 assert_eq!(out.entries[0].collides_with, None);
2081 assert_eq!(out.entries[1].collides_with, None);
2082 }
2083
2084 /// A collision needs no verifier at all, unlike a shadow. A format with
2085 /// no checksum (tar, cpio, ar — and a zip data-descriptor entry) can
2086 /// still repeat a name, and that repetition is exactly as real there.
2087 #[test]
2088 fn a_repeated_name_collides_even_with_no_verifier_to_compare() {
2089 let mut scan = ScriptedCandidates {
2090 plan: vec![
2091 ("same", Some(4), None, SalvageStatus::Complete),
2092 ("same", Some(4), None, SalvageStatus::Complete),
2093 ],
2094 next: 0,
2095 };
2096 let out = salvage_all(&mut scan, &mut bytes(&[0u8; 16]), &SalvagePolicy::default())
2097 .expect("scripted candidates must annotate cleanly");
2098 assert_eq!(
2099 out.entries[1].shadows, None,
2100 "no verifier, so nothing proves they are copies"
2101 );
2102 assert_eq!(
2103 out.entries[1].collides_with,
2104 Some(0),
2105 "but the name repeats, which needs no verifier to observe"
2106 );
2107 }
2108
2109 /// Ruling S-I. `Candidate` is `#[non_exhaustive]`, so an out-of-crate
2110 /// scanner reaches it only through `new` plus the `with_*` setters —
2111 /// which means the DEFAULTS are now public API, not an implementation
2112 /// detail of six struct literals. Each of the four must assert the
2113 /// least: nothing declared, nothing to verify with, nothing missing,
2114 /// nothing deleted.
2115 #[test]
2116 fn a_new_candidate_claims_nothing_it_was_not_told() {
2117 let c = Candidate::new(7, 42, EntryMeta::file("probe"));
2118 assert_eq!(c.offset, 7);
2119 assert_eq!(c.payload_start, 42);
2120 assert_eq!(c.declared_len, None, "no length was declared");
2121 assert_eq!(c.verifier, None, "nothing can prove the content");
2122 assert_eq!(
2123 c.available_len, None,
2124 "a shortfall is MEASURED, never assumed"
2125 );
2126 assert!(!c.marked_deleted, "no record is deleted until one says so");
2127 }
2128
2129 /// The four setters are the only route to those fields from another
2130 /// crate, so each one is pinned rather than trusted to the compiler.
2131 #[test]
2132 fn each_candidate_setter_reaches_its_own_field() {
2133 let c = Candidate::new(0, 0, EntryMeta::file("probe"))
2134 .with_declared_len(Some(9))
2135 .with_verifier(Some(Verifier::Crc16(0xbeef)))
2136 .with_available_len(Some(4))
2137 .with_marked_deleted(true);
2138 assert_eq!(c.declared_len, Some(9));
2139 assert_eq!(c.verifier, Some(Verifier::Crc16(0xbeef)));
2140 assert_eq!(c.available_len, Some(4));
2141 assert!(c.marked_deleted);
2142 }
2143
2144 /// Same ruling, same reason, for the type every per-format
2145 /// `write_payload` test builds directly. Its three defaults are the
2146 /// annotations `annotate_candidates` adds, so an entry built by hand
2147 /// must carry none of them.
2148 #[test]
2149 fn a_new_salvaged_entry_carries_no_annotations() {
2150 let e = SalvagedEntry::new(3, 7, 42, EntryMeta::file("probe"), SalvageStatus::Complete);
2151 assert_eq!(e.scan_position, 3);
2152 assert_eq!(e.offset, 7);
2153 assert_eq!(e.payload_start, 42);
2154 assert_eq!(e.status, SalvageStatus::Complete);
2155 assert_eq!(e.shadows, None);
2156 assert_eq!(e.collides_with, None);
2157 assert!(!e.marked_deleted);
2158
2159 let annotated = SalvagedEntry::new(0, 0, 0, EntryMeta::file("p"), SalvageStatus::Intact)
2160 .with_shadows(Some(1))
2161 .with_collides_with(Some(2))
2162 .with_marked_deleted(true);
2163 assert_eq!(annotated.shadows, Some(1));
2164 assert_eq!(annotated.collides_with, Some(2));
2165 assert!(annotated.marked_deleted);
2166 }
2167
2168 /// Task 2-N: the note a caller prints for its sightings. None for none —
2169 /// a run with nothing refused must print nothing — and otherwise one
2170 /// group per (format, shape), each with its complete count and at most
2171 /// [`SIGHTING_OFFSETS_SHOWN`] offsets, in the order first seen.
2172 #[test]
2173 fn describe_sightings_groups_by_shape_and_bounds_what_it_lists() {
2174 assert_eq!(describe_sightings(&[]), None);
2175
2176 let f = FormatId::new("fmt");
2177 let mut seen: Vec<Sighting> = (0..10u64)
2178 .map(|i| Sighting::new(f, i * 512, "a(s)"))
2179 .collect();
2180 seen.insert(1, Sighting::new(f, 7, "b(s)"));
2181 let note = describe_sightings(&seen).unwrap();
2182 assert!(
2183 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"),
2184 "{note}"
2185 );
2186 assert!(note.contains("`stuffr list`"), "{note}");
2187 }
2188
2189 /// Task 4: the walk-stop note. A hole says what the brief requires —
2190 /// the entries after it are unreachable by construction, NOT absent,
2191 /// with the offset and the byte count — and a cut-short tail says the
2192 /// opposite, because nothing can be hiding in too few bytes for a record.
2193 #[test]
2194 fn describe_walk_stop_says_unreachable_for_a_hole_and_complete_for_a_cut() {
2195 let f = FormatId::new("fmt");
2196 let hole = describe_walk_stop(&WalkStop::new(
2197 f,
2198 1234,
2199 5000,
2200 WalkStopKind::Unreadable,
2201 "bad field",
2202 ));
2203 for needle in [
2204 "offset 1234",
2205 "5000 bytes",
2206 "bad field",
2207 "unreachable by construction",
2208 "not absent",
2209 "fmt",
2210 ] {
2211 assert!(hole.contains(needle), "missing {needle:?}: {hole}");
2212 }
2213 let cut = describe_walk_stop(&WalkStop::new(f, 90, 1, WalkStopKind::CutShort, "eof"));
2214 assert!(cut.contains("1 byte into the record at offset 90"), "{cut}");
2215 assert!(cut.contains("cut short"), "{cut}");
2216 assert!(!cut.contains("unreachable"), "{cut}");
2217 let shape = describe_walk_stop(&WalkStop::new(
2218 f,
2219 8,
2220 600,
2221 WalkStopKind::UnsupportedShape,
2222 "the symbol table",
2223 ));
2224 assert!(shape.contains("the symbol table"), "{shape}");
2225 assert!(shape.contains("not evidence of damage"), "{shape}");
2226 assert!(!shape.contains("unreachable by construction"), "{shape}");
2227 }
2228}