Skip to main content

stuffr_core/
fidelity.rs

1use std::fmt;
2
3use crate::format::FormatId;
4
5/// Which rung of the adaptive stream ladder a read landed on.
6#[derive(Clone, Copy, PartialEq, Eq, Debug)]
7#[cfg_attr(feature = "serde", derive(serde::Serialize))]
8#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
9pub enum Rung {
10    /// Input was seekable. Authoritative index, full metadata.
11    Exact,
12    /// Not seekable, parsed forward. Real data, approximate metadata.
13    ForwardOnly,
14    /// Spooled to memory/temp then read exactly. Costs disk, not accuracy.
15    Spilled,
16    /// Best-effort salvage. Partial results by construction.
17    Degraded,
18}
19
20impl Rung {
21    /// Whether the read used the format's authoritative structures.
22    ///
23    /// Note `Spilled` is authoritative: spilling trades disk for accuracy, and
24    /// treating it as lossy would make `--strict-fidelity` reject exact reads.
25    pub fn is_authoritative(&self) -> bool {
26        matches!(self, Rung::Exact | Rung::Spilled)
27    }
28
29    /// Ordering by *quality loss*, worst = highest. Used by `merge`.
30    fn severity(&self) -> u8 {
31        match self {
32            Rung::Exact => 0,
33            Rung::Spilled => 1,
34            Rung::ForwardOnly => 2,
35            Rung::Degraded => 3,
36        }
37    }
38}
39
40impl fmt::Display for Rung {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        let s = match self {
43            Rung::Exact => "exact",
44            Rung::ForwardOnly => "forward-only",
45            Rung::Spilled => "spilled",
46            Rung::Degraded => "degraded",
47        };
48        f.write_str(s)
49    }
50}
51
52/// Which metadata fields are **missing**. `true` means absent, not present.
53#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
54#[cfg_attr(feature = "serde", derive(serde::Serialize))]
55pub struct MetaFields {
56    pub mtime: bool,
57    pub mode: bool,
58    pub uid_gid: bool,
59    pub comment: bool,
60    pub extra: bool,
61    pub crc: bool,
62}
63
64impl MetaFields {
65    pub fn missing(&self) -> Vec<&'static str> {
66        let mut v = Vec::new();
67        if self.mtime {
68            v.push("mtime")
69        }
70        if self.mode {
71            v.push("mode")
72        }
73        if self.uid_gid {
74            v.push("uid_gid")
75        }
76        if self.comment {
77            v.push("comment")
78        }
79        if self.extra {
80            v.push("extra")
81        }
82        if self.crc {
83            v.push("crc")
84        }
85        v
86    }
87}
88
89/// One specific, structured thing that was lost or approximated.
90#[derive(Clone, PartialEq, Eq, Debug, thiserror::Error)]
91#[cfg_attr(feature = "serde", derive(serde::Serialize))]
92#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "kebab-case"))]
93#[non_exhaustive]
94pub enum Fidelity {
95    #[error(
96        "`{format}` central/trailing index was never read; entry metadata came from inline headers"
97    )]
98    TrailingIndexUnread { format: FormatId },
99
100    #[error("size of entry `{entry}` was only known after its data (data descriptor)")]
101    SizeFromDataDescriptor { entry: String },
102
103    #[error("total entry count is unknown without the trailing index")]
104    EntryCountUnknown,
105
106    /// The archive's own index declares a different number of records than
107    /// the reader enumerated. Usually FEWER were enumerated, so entries exist
108    /// in the file that no caller can reach; zip can also enumerate MORE, when
109    /// its end-of-central-directory record's two counts contradict each other
110    /// (the v0.7.0 deep-fuzz finding), so the message names both figures
111    /// without saying which way round they are.
112    ///
113    /// Distinct from [`Self::EntryCountUnknown`], which is the forward reader
114    /// admitting it never saw a count at all. Here the count WAS read, from
115    /// the format's authoritative structure, and it disagrees with what came
116    /// back — the strongest evidence available that a read is incomplete, and
117    /// the one a seekable `Rung::Exact` read would otherwise have no way to
118    /// express. Without it a partial enumeration is indistinguishable from a
119    /// complete one, which is the failure this variant exists to prevent:
120    /// listing six of eight records and calling it exact fidelity.
121    ///
122    /// Raised, not recovered. Reaching the shadowed records would mean
123    /// re-parsing the index by hand; naming the shortfall is what the caller
124    /// needs in order to know not to trust the result, and
125    /// `--strict-fidelity` turns it into exit 4.
126    ///
127    /// `reason` is an owned `String` for the same reason
128    /// [`Self::EntrySkipped`]'s is: the cause is specific to the container
129    /// and the reader, and a shortfall that cannot say WHY is one the user
130    /// cannot act on.
131    #[error(
132        "`{format}` index declares {declared} entries but {enumerated} were \
133         enumerated: {reason}"
134    )]
135    EntryCountMismatch {
136        format: FormatId,
137        /// What the archive's own index says it holds.
138        declared: u64,
139        /// What the reader was actually able to enumerate.
140        enumerated: u64,
141        /// Why the two differ, in terms the caller can act on.
142        reason: String,
143    },
144
145    #[error("entry `{entry}` is missing metadata: {}", fields.missing().join(", "))]
146    MetadataIncomplete { entry: String, fields: MetaFields },
147
148    #[error("decoded a solid block, wasting {wasted_bytes} bytes, to reach the requested entry")]
149    SolidBlockFullyDecoded { wasted_bytes: u64 },
150
151    #[error("skipped encrypted entry `{entry}`")]
152    EncryptedEntrySkipped { entry: String },
153
154    /// An entry this build cannot materialise, named along with why.
155    ///
156    /// Distinct from [`Self::MetadataIncomplete`], which is about an entry
157    /// that WAS written and lost some of its metadata: here the entry itself
158    /// is absent from the result. Extraction raises it for a device node,
159    /// fifo, socket or hardlink — [`crate::EntryKind::Other`], the honest
160    /// answer a container gives for an entry shape `EntryKind` has no variant
161    /// for yet. Writing one out as a regular file carrying its "contents"
162    /// would be a silent lie about what the archive held. The write side
163    /// raises it too, for something met on disk that no container here can
164    /// store — a socket, an undecodable name, a directory being packed into
165    /// `ar`.
166    ///
167    /// `reason` is an owned `String` rather than a `&'static str` because the
168    /// write side's reasons carry runtime detail — the failing errno, the
169    /// container's own name, the undecodable bytes — and a reason that cannot
170    /// name the particular thing that went wrong is a reason the user cannot
171    /// act on.
172    #[error("skipped entry `{entry}`: {reason}")]
173    EntrySkipped { entry: String, reason: String },
174
175    /// A file that changed length between the moment its size was recorded
176    /// and the moment its bytes were read, stored at the length its header
177    /// already declares.
178    ///
179    /// Distinct from [`Self::EntrySkipped`]: the entry IS in the archive, and
180    /// the archive is structurally valid. What was lost is the tail of a file
181    /// that grew, or the tail of a file that shrank — replaced by zeros,
182    /// because the entry header was written before the payload was read and a
183    /// container cannot un-write it. Refusing instead would throw away the
184    /// whole archive over one file that a live system happened to touch,
185    /// which is what `pack ~ -o backup.tar` over a running home directory does
186    /// routinely; GNU tar prints `file changed as we read it` and continues,
187    /// and this is the same bargain, with the loss named rather than printed
188    /// and forgotten.
189    ///
190    /// `--strict-fidelity` still refuses on it, which is the half of the
191    /// bargain that keeps it honest.
192    #[error(
193        "entry `{entry}` changed size while it was being read: its header declares \
194         {declared} bytes, {fixup}"
195    )]
196    EntrySizeChanged {
197        entry: String,
198        /// The length written into the entry header, from the walk's `stat`.
199        declared: u64,
200        /// What the file actually supplied and what was done about it.
201        ///
202        /// Prose rather than a second `u64` because the two directions know
203        /// different things: a file that shrank supplies an exact count, while
204        /// a file that grew is only known to have had *more* — measuring how
205        /// much more would mean reading a tail that is deliberately not being
206        /// stored. A field that could name the figure in one case and not the
207        /// other would have to lie in one of them.
208        fixup: String,
209    },
210
211    #[error("stream truncated at offset {at}")]
212    TruncatedStream { at: u64 },
213
214    /// A directory record carries a checksum over ITSELF, and it does not
215    /// match the bytes the record is made of.
216    ///
217    /// Distinct from every other integrity signal in this enum: those are
218    /// about an entry's CONTENT, and a content checksum that fails is
219    /// [`crate::Error::Corrupt`], not a warning, because there is nothing
220    /// honest to hand back. This is about the METADATA record, and the
221    /// verdict is deliberately softer for one measured reason — the format
222    /// that carries such a field may itself treat it as advisory. ZOO is the
223    /// first: `zoo` 2.10's own lister prints a `*` beside an entry whose
224    /// `dir_crc` fails and carries on listing it, so refusing would be
225    /// stricter than the reference implementation, which is how a guard comes
226    /// to fire on input a real tool accepts.
227    ///
228    /// What it must NOT do is stay silent. A record whose own checksum fails
229    /// has been altered since it was written, and every field a caller is
230    /// about to trust — the entry's name, size, offset, and the link to the
231    /// next record — comes out of it. `--strict-fidelity` turns this into
232    /// exit 4, which is the whole point: a verb that says "exact fidelity"
233    /// over an altered record is making a claim the archive itself disputes.
234    ///
235    /// `offset` is the record's own position in the file, because a record
236    /// that fails its checksum may have no usable name to identify it by —
237    /// the reproducer that motivated this variant is a record whose name
238    /// field is intact and whose link field is not.
239    #[error(
240        "`{format}` directory record at offset {offset} fails its own checksum \
241         ({computed:#06x} computed against {recorded:#06x} recorded); the record has been \
242         altered since it was written"
243    )]
244    DirectoryRecordChecksum {
245        format: FormatId,
246        offset: u64,
247        computed: u16,
248        recorded: u16,
249    },
250}
251
252/// What a read cost in fidelity. Returned alongside data, never logged and
253/// discarded — the caller must be able to discover what was approximated.
254#[derive(Clone, PartialEq, Eq, Debug)]
255#[cfg_attr(feature = "serde", derive(serde::Serialize))]
256pub struct FidelityReport {
257    pub rung: Rung,
258    pub warnings: Vec<Fidelity>,
259}
260
261impl FidelityReport {
262    pub fn exact() -> Self {
263        Self::new(Rung::Exact)
264    }
265
266    pub fn new(rung: Rung) -> Self {
267        Self {
268            rung,
269            warnings: Vec::new(),
270        }
271    }
272
273    pub fn warn(&mut self, w: Fidelity) {
274        self.warnings.push(w);
275    }
276
277    /// Whether anything was approximated. **This is the `--strict-fidelity`
278    /// gate** (exit code 4).
279    ///
280    /// Gating on warnings rather than on the rung is deliberate: a tar read
281    /// from a pipe lands on `ForwardOnly`, but tar has no trailing index, so
282    /// nothing was lost. Failing it would hand a script an error it cannot act
283    /// on. The rung stays diagnostic — reported by `stuffr info`.
284    pub fn has_warnings(&self) -> bool {
285        !self.warnings.is_empty()
286    }
287
288    /// True only if the rung was authoritative *and* nothing was approximated.
289    ///
290    /// Deliberately **not** the `--strict-fidelity` gate — see
291    /// [`Self::has_warnings`]. This is the stronger "the format's own index was
292    /// read and nothing was lost" claim, useful for archival verification.
293    pub fn is_lossless(&self) -> bool {
294        self.rung.is_authoritative() && self.warnings.is_empty()
295    }
296
297    /// Fold another report in: worst rung wins, warnings are unioned.
298    ///
299    /// For combining reports from independently-resolved layers — e.g. a
300    /// codec's own report merged with the inner container's, once a chain has
301    /// more than one — rather than for a single container's own findings,
302    /// which it now records by calling [`Self::warn`] directly on the report
303    /// it inherited from the ladder (see [`crate::ladder::seed_report`]). A
304    /// warning already present is not repeated, so folding the same loss in
305    /// from two sources still tells the caller about it once.
306    pub fn merge(&mut self, other: &FidelityReport) {
307        if other.rung.severity() > self.rung.severity() {
308            self.rung = other.rung;
309        }
310        for w in &other.warnings {
311            if !self.warnings.contains(w) {
312                self.warnings.push(w.clone());
313            }
314        }
315    }
316}
317
318impl Default for FidelityReport {
319    fn default() -> Self {
320        Self::exact()
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::format::FormatId;
328
329    #[test]
330    fn spilled_is_authoritative_even_though_it_is_a_lower_rung() {
331        // Spilling costs disk, not accuracy. Treating it as lossy would make
332        // `--strict-fidelity` reject perfectly exact reads.
333        assert!(Rung::Exact.is_authoritative());
334        assert!(Rung::Spilled.is_authoritative());
335        assert!(!Rung::ForwardOnly.is_authoritative());
336        assert!(!Rung::Degraded.is_authoritative());
337    }
338
339    #[test]
340    fn exact_report_is_lossless() {
341        let r = FidelityReport::exact();
342        assert_eq!(r.rung, Rung::Exact);
343        assert!(r.is_lossless());
344        assert!(r.warnings.is_empty());
345    }
346
347    #[test]
348    fn a_warning_makes_an_otherwise_exact_report_lossy() {
349        let mut r = FidelityReport::exact();
350        r.warn(Fidelity::EntryCountUnknown);
351        assert!(!r.is_lossless());
352        assert_eq!(r.warnings.len(), 1);
353    }
354
355    #[test]
356    fn forward_only_is_lossy_even_with_no_warnings() {
357        assert!(!FidelityReport::new(Rung::ForwardOnly).is_lossless());
358    }
359
360    #[test]
361    fn merge_keeps_the_worst_rung_and_concatenates_warnings() {
362        let mut a = FidelityReport::exact();
363        a.warn(Fidelity::EntryCountUnknown);
364
365        let mut b = FidelityReport::new(Rung::Degraded);
366        b.warn(Fidelity::TruncatedStream { at: 99 });
367
368        a.merge(&b);
369        assert_eq!(a.rung, Rung::Degraded);
370        assert_eq!(a.warnings.len(), 2);
371    }
372
373    #[test]
374    fn merge_does_not_upgrade_a_worse_rung() {
375        let mut a = FidelityReport::new(Rung::Degraded);
376        a.merge(&FidelityReport::exact());
377        assert_eq!(a.rung, Rung::Degraded);
378    }
379
380    #[test]
381    fn merge_does_not_repeat_a_warning_already_present() {
382        // The ladder and the container can each independently observe the same
383        // loss; the caller should be told once.
384        let mut a = FidelityReport::exact();
385        a.warn(Fidelity::EntryCountUnknown);
386
387        let mut b = FidelityReport::new(Rung::ForwardOnly);
388        b.warn(Fidelity::EntryCountUnknown);
389
390        a.merge(&b);
391        assert_eq!(a.warnings.len(), 1);
392        assert_eq!(a.rung, Rung::ForwardOnly);
393    }
394
395    #[test]
396    fn warnings_carry_actionable_detail_not_just_a_kind() {
397        // The whole premise is that the caller can find out WHAT was lost.
398        let f = Fidelity::SizeFromDataDescriptor {
399            entry: "logs/a.txt".into(),
400        };
401        assert!(format!("{f}").contains("logs/a.txt"));
402
403        let f = Fidelity::TrailingIndexUnread {
404            format: FormatId::new("zip"),
405        };
406        assert!(format!("{f}").contains("zip"));
407
408        let f = Fidelity::SolidBlockFullyDecoded {
409            wasted_bytes: 41_943_040,
410        };
411        assert!(format!("{f}").contains("41943040"));
412
413        let f = Fidelity::EntrySkipped {
414            entry: "dev/null".into(),
415            reason: "device nodes, fifos and hardlinks are not created".into(),
416        };
417        let s = format!("{f}");
418        assert!(
419            s.contains("dev/null") && s.contains("hardlinks"),
420            "a skip must say both which entry and why: {s}"
421        );
422
423        let f = Fidelity::EntryCountMismatch {
424            format: FormatId::new("zip"),
425            declared: 8,
426            enumerated: 6,
427            reason: "records sharing a name collapse into one".into(),
428        };
429        let s = format!("{f}");
430        assert!(
431            s.contains('8') && s.contains('6') && s.contains("sharing a name"),
432            "a shortfall must name BOTH counts and the cause, or the caller \
433             cannot tell how much is missing or why: {s}"
434        );
435
436        let f = Fidelity::MetadataIncomplete {
437            entry: "b.bin".into(),
438            fields: MetaFields {
439                mode: true,
440                ..Default::default()
441            },
442        };
443        let s = format!("{f}");
444        assert!(s.contains("b.bin") && s.contains("mode"));
445    }
446
447    #[test]
448    fn meta_fields_lists_only_the_missing_ones() {
449        let m = MetaFields {
450            mtime: true,
451            crc: true,
452            ..Default::default()
453        };
454        assert_eq!(m.missing(), vec!["mtime", "crc"]);
455        assert!(MetaFields::default().missing().is_empty());
456    }
457
458    #[test]
459    fn a_forward_only_read_that_lost_nothing_passes_strict_mode() {
460        // tar on a pipe: rung is ForwardOnly, but tar has no trailing index, so
461        // nothing was approximated. Strict mode must not fail this.
462        let r = FidelityReport::new(Rung::ForwardOnly);
463        assert!(!r.has_warnings(), "nothing was approximated");
464        assert!(!r.is_lossless(), "but the rung was not authoritative");
465    }
466
467    #[test]
468    fn strict_mode_fires_on_any_warning_regardless_of_rung() {
469        let mut r = FidelityReport::exact();
470        r.warn(Fidelity::EntryCountUnknown);
471        assert!(r.has_warnings());
472    }
473
474    #[test]
475    fn a_clean_exact_read_passes_both_predicates() {
476        let r = FidelityReport::exact();
477        assert!(!r.has_warnings());
478        assert!(r.is_lossless());
479    }
480}