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