Skip to main content

stuffr_core/
fidelity.rs

1use std::fmt;
2
3use crate::display::fmt_name;
4use crate::format::FormatId;
5
6/// Which rung of the adaptive stream ladder a read landed on.
7#[derive(Clone, Copy, PartialEq, Eq, Debug)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize))]
9#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
10pub enum Rung {
11    /// Input was seekable. Authoritative index, full metadata.
12    Exact,
13    /// Not seekable, parsed forward. Real data, approximate metadata.
14    ForwardOnly,
15    /// Spooled to memory/temp then read exactly. Costs disk, not accuracy.
16    Spilled,
17    /// Best-effort salvage. Partial results by construction.
18    Degraded,
19}
20
21impl Rung {
22    /// Whether the read used the format's authoritative structures.
23    ///
24    /// Note `Spilled` is authoritative: spilling trades disk for accuracy, and
25    /// treating it as lossy would make `--strict-fidelity` reject exact reads.
26    pub fn is_authoritative(&self) -> bool {
27        matches!(self, Rung::Exact | Rung::Spilled)
28    }
29
30    /// Ordering by *quality loss*, worst = highest. Used by `merge`.
31    fn severity(&self) -> u8 {
32        match self {
33            Rung::Exact => 0,
34            Rung::Spilled => 1,
35            Rung::ForwardOnly => 2,
36            Rung::Degraded => 3,
37        }
38    }
39}
40
41impl fmt::Display for Rung {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        let s = match self {
44            Rung::Exact => "exact",
45            Rung::ForwardOnly => "forward-only",
46            Rung::Spilled => "spilled",
47            Rung::Degraded => "degraded",
48        };
49        f.write_str(s)
50    }
51}
52
53/// Which metadata fields are **missing**. `true` means absent, not present.
54#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize))]
56pub struct MetaFields {
57    pub mtime: bool,
58    pub mode: bool,
59    pub uid_gid: bool,
60    pub comment: bool,
61    pub extra: bool,
62    pub crc: bool,
63}
64
65impl MetaFields {
66    pub fn missing(&self) -> Vec<&'static str> {
67        let mut v = Vec::new();
68        if self.mtime {
69            v.push("mtime")
70        }
71        if self.mode {
72            v.push("mode")
73        }
74        if self.uid_gid {
75            v.push("uid_gid")
76        }
77        if self.comment {
78            v.push("comment")
79        }
80        if self.extra {
81            v.push("extra")
82        }
83        if self.crc {
84            v.push("crc")
85        }
86        v
87    }
88}
89
90/// One specific, structured thing that was lost or approximated.
91#[derive(Clone, PartialEq, Eq, Debug, thiserror::Error)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize))]
93#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "kebab-case"))]
94#[non_exhaustive]
95pub enum Fidelity {
96    #[error(
97        "`{format}` central/trailing index was never read; entry metadata came from inline headers"
98    )]
99    TrailingIndexUnread { format: FormatId },
100
101    #[error("size of entry `{}` was only known after its data (data descriptor)", fmt_name(.entry))]
102    SizeFromDataDescriptor { entry: String },
103
104    #[error("total entry count is unknown without the trailing index")]
105    EntryCountUnknown,
106
107    /// The archive's own index declares a different number of records than
108    /// the reader enumerated. Usually FEWER were enumerated, so entries exist
109    /// in the file that no caller can reach; zip can also enumerate MORE, when
110    /// its end-of-central-directory record's two counts contradict each other
111    /// (the v0.7.0 deep-fuzz finding), so the message names both figures
112    /// without saying which way round they are.
113    ///
114    /// Distinct from [`Self::EntryCountUnknown`], which is the forward reader
115    /// admitting it never saw a count at all. Here the count WAS read, from
116    /// the format's authoritative structure, and it disagrees with what came
117    /// back — the strongest evidence available that a read is incomplete, and
118    /// the one a seekable `Rung::Exact` read would otherwise have no way to
119    /// express. Without it a partial enumeration is indistinguishable from a
120    /// complete one, which is the failure this variant exists to prevent:
121    /// listing six of eight records and calling it exact fidelity.
122    ///
123    /// Raised, not recovered. Reaching the shadowed records would mean
124    /// re-parsing the index by hand; naming the shortfall is what the caller
125    /// needs in order to know not to trust the result, and
126    /// `--strict-fidelity` turns it into exit 4.
127    ///
128    /// `reason` is an owned `String` for the same reason
129    /// [`Self::EntrySkipped`]'s is: the cause is specific to the container
130    /// and the reader, and a shortfall that cannot say WHY is one the user
131    /// cannot act on.
132    #[error(
133        "`{format}` index declares {declared} entries but {enumerated} were \
134         enumerated: {}",
135        fmt_name(.reason)
136    )]
137    EntryCountMismatch {
138        format: FormatId,
139        /// What the archive's own index says it holds.
140        declared: u64,
141        /// What the reader was actually able to enumerate.
142        enumerated: u64,
143        /// Why the two differ, in terms the caller can act on.
144        reason: String,
145    },
146
147    #[error("entry `{}` is missing metadata: {}", fmt_name(.entry), fields.missing().join(", "))]
148    MetadataIncomplete { entry: String, fields: MetaFields },
149
150    #[error("decoded a solid block, wasting {wasted_bytes} bytes, to reach the requested entry")]
151    SolidBlockFullyDecoded { wasted_bytes: u64 },
152
153    #[error("skipped encrypted entry `{}`", fmt_name(.entry))]
154    EncryptedEntrySkipped { entry: String },
155
156    /// An entry this build cannot materialise, named along with why.
157    ///
158    /// Distinct from [`Self::MetadataIncomplete`], which is about an entry
159    /// that WAS written and lost some of its metadata: here the entry itself
160    /// is absent from the result. Extraction raises it for a device node,
161    /// fifo or socket — [`crate::EntryKind::Other`], the honest answer a
162    /// container gives for an entry shape `EntryKind` has no variant for yet
163    /// — and for a [`crate::EntryKind::Hardlink`] whose target it did not
164    /// write, or whose target is a directory. Writing one out as a regular
165    /// file carrying its "contents" would be a silent lie about what the
166    /// archive held. The write side
167    /// raises it too, for something met on disk that no container here can
168    /// store — a socket, an undecodable name, a directory being packed into
169    /// `ar`.
170    ///
171    /// `reason` is an owned `String` rather than a `&'static str` because the
172    /// write side's reasons carry runtime detail — the failing errno, the
173    /// container's own name, the undecodable bytes — and a reason that cannot
174    /// name the particular thing that went wrong is a reason the user cannot
175    /// act on.
176    #[error("skipped entry `{}`: {}", fmt_name(.entry), fmt_name(.reason))]
177    EntrySkipped { entry: String, reason: String },
178
179    /// A file that changed length between the moment its size was recorded
180    /// and the moment its bytes were read, stored at the length its header
181    /// already declares.
182    ///
183    /// Distinct from [`Self::EntrySkipped`]: the entry IS in the archive, and
184    /// the archive is structurally valid. What was lost is the tail of a file
185    /// that grew, or the tail of a file that shrank — replaced by zeros,
186    /// because the entry header was written before the payload was read and a
187    /// container cannot un-write it. Refusing instead would throw away the
188    /// whole archive over one file that a live system happened to touch,
189    /// which is what `pack ~ -o backup.tar` over a running home directory does
190    /// routinely; GNU tar prints `file changed as we read it` and continues,
191    /// and this is the same bargain, with the loss named rather than printed
192    /// and forgotten.
193    ///
194    /// `--strict-fidelity` still refuses on it, which is the half of the
195    /// bargain that keeps it honest.
196    #[error(
197        "entry `{}` changed size while it was being read: its header declares \
198         {declared} bytes, {}",
199        fmt_name(.entry),
200        fmt_name(.fixup)
201    )]
202    EntrySizeChanged {
203        entry: String,
204        /// The length written into the entry header, from the walk's `stat`.
205        declared: u64,
206        /// What the file actually supplied and what was done about it.
207        ///
208        /// Prose rather than a second `u64` because the two directions know
209        /// different things: a file that shrank supplies an exact count, while
210        /// a file that grew is only known to have had *more* — measuring how
211        /// much more would mean reading a tail that is deliberately not being
212        /// stored. A field that could name the figure in one case and not the
213        /// other would have to lie in one of them.
214        fixup: String,
215    },
216
217    #[error("stream truncated at offset {at}")]
218    TruncatedStream { at: u64 },
219
220    /// A directory record carries a checksum over ITSELF, and it does not
221    /// match the bytes the record is made of.
222    ///
223    /// Distinct from every other integrity signal in this enum: those are
224    /// about an entry's CONTENT, and a content checksum that fails is
225    /// [`crate::Error::Corrupt`], not a warning, because there is nothing
226    /// honest to hand back. This is about the METADATA record, and the
227    /// verdict is deliberately softer for one measured reason — the format
228    /// that carries such a field may itself treat it as advisory. ZOO is the
229    /// first: `zoo` 2.10's own lister prints a `*` beside an entry whose
230    /// `dir_crc` fails and carries on listing it, so refusing would be
231    /// stricter than the reference implementation, which is how a guard comes
232    /// to fire on input a real tool accepts.
233    ///
234    /// What it must NOT do is stay silent. A record whose own checksum fails
235    /// has been altered since it was written, and every field a caller is
236    /// about to trust — the entry's name, size, offset, and the link to the
237    /// next record — comes out of it. `--strict-fidelity` turns this into
238    /// exit 4, which is the whole point: a verb that says "exact fidelity"
239    /// over an altered record is making a claim the archive itself disputes.
240    ///
241    /// `offset` is the record's own position in the file, because a record
242    /// that fails its checksum may have no usable name to identify it by —
243    /// the reproducer that motivated this variant is a record whose name
244    /// field is intact and whose link field is not.
245    #[error(
246        "`{format}` directory record at offset {offset} fails its own checksum \
247         ({computed:#06x} computed against {recorded:#06x} recorded); the record has been \
248         altered since it was written"
249    )]
250    DirectoryRecordChecksum {
251        format: FormatId,
252        offset: u64,
253        computed: u16,
254        recorded: u16,
255    },
256
257    /// A zip entry's local file header and its central-directory record
258    /// disagree on a field both carry — the "cloaking" shape (GHSA-m665-v7pm-
259    /// 7grj), where one archive shows different names or contents depending
260    /// on which of its two descriptions a reader trusts.
261    ///
262    /// A warning, not [`crate::Error::Corrupt`]: the bytes handed back are
263    /// still a coherent reading (the central directory's on a seekable
264    /// source, the local headers' on a pipe), and the archive is not
265    /// self-contradictory within either description. What cannot be claimed
266    /// is that the reading is the only one, so `--strict-fidelity` turns this
267    /// into exit 4.
268    ///
269    /// `fields` lists, in a fixed order and never empty, every field the
270    /// two records disagree on for this one entry — one warning per entry,
271    /// not per field (it was per field up to 0.22.0, as `field`). Each is
272    /// one of `"order"`, `"name"`, `"compression method"`, `"CRC-32"`,
273    /// `"compressed size"`, `"uncompressed size"`, `"encryption flag"`,
274    /// `"data-descriptor flag"`, `"UTF-8 flag"` or `"entry count"`. `entry`
275    /// is the name as the authoritative description (the central directory,
276    /// when it was read) spells it, decoded as the reader decodes names.
277    ///
278    /// An archive lists at most 64 entries this way; the rest are counted
279    /// in one [`Fidelity::HeadersDisagreeUnlisted`].
280    #[error(
281        "`{format}` local header and central directory disagree on {} for entry `{}`",
282        .fields.join(", "),
283        fmt_name(.entry)
284    )]
285    HeadersDisagree {
286        format: FormatId,
287        entry: String,
288        fields: Vec<&'static str>,
289    },
290
291    /// More entries whose local header and central record disagree, past
292    /// the 64 an archive lists as [`Fidelity::HeadersDisagree`]: `entries`
293    /// counts them. Each listed warning copies its entry's name, so an
294    /// archive of a hundred thousand cloaked records would otherwise make a
295    /// report as large as its index; the count keeps the verdict
296    /// (`--strict-fidelity` exits 4 on either) without the copies.
297    #[error(
298        "`{format}` local header and central directory disagree for {entries} more \
299         entr(y/ies), not listed"
300    )]
301    HeadersDisagreeUnlisted { format: FormatId, entries: u64 },
302
303    /// A codec stream whose last complete, verified member (or stream) is
304    /// followed by bytes that are neither another member nor padding, and
305    /// were ignored.
306    ///
307    /// gzip and bzip2 files are legitimately concatenations, and the tools
308    /// that write them sometimes pad the end with NUL or `'X'` bytes, which
309    /// stay silent. Anything else after a verified member is ambiguous: it
310    /// may be harmless junk, or it may be the next member with its first
311    /// magic byte flipped — one byte that drops every later member while the
312    /// members before it still verify. The data handed back is the members
313    /// that did verify, so this is a warning rather than
314    /// [`crate::Error::Corrupt`] (the reference tools likewise say "trailing
315    /// garbage ignored" and recover the data), and `--strict-fidelity` turns
316    /// it into exit 4. A tail that starts like a damaged member header — the
317    /// magic intact and a field behind it impossible — is `Corrupt` instead,
318    /// and never reaches this variant.
319    ///
320    /// `bytes` is how many bytes were ignored, counted while the tail was
321    /// read through (never buffered).
322    #[error(
323        "`{format}` stream is followed by {bytes} byte(s) that are neither another member \
324         nor padding; they were ignored"
325    )]
326    TrailingDataIgnored { format: FormatId, bytes: u64 },
327
328    /// The local headers of `entries` entries were not compared with their
329    /// central-directory records ([`Fidelity::HeadersDisagree`] could not be
330    /// ruled out for them), because the comparison ran out of its work
331    /// budget.
332    ///
333    /// Each comparison reads a local header — up to 128 KiB of name and extra
334    /// field — and an index whose records keep pointing at the same few fat
335    /// headers (or at headers overlapping one another) makes every record pay
336    /// that again. The reads are bounded by a budget proportional to the
337    /// archive's own length, generous enough that a well-formed archive,
338    /// which reads each local header once, never reaches it. Past it, the
339    /// remaining entries are delivered as the central directory describes
340    /// them, unchecked, and this says so; `--strict-fidelity` turns it into
341    /// exit 4.
342    #[error(
343        "`{format}` local headers of {entries} entr(y/ies) were not compared with the central \
344         directory: the archive's records point at the same header bytes again and again, \
345         and the comparison's work budget ran out"
346    )]
347    LocalHeadersUnchecked { format: FormatId, entries: u64 },
348}
349
350/// Where a codec's decoder records what it approximated.
351///
352/// A codec layer hands back only a byte stream, and what it notices — trailing
353/// data after its last member, so far — is noticed at the END of that stream,
354/// long after [`crate::Codec::decoder`] returned. So the driver hands the
355/// decoder this shared, cloneable list through [`crate::DecodeOpts::fidelity`]
356/// and folds it into its own [`FidelityReport`] once the stream has been read
357/// ([`Self::drain_into`]). Cloning shares the list: every layer of a chain
358/// built from one `DecodeOpts` records into the same one.
359///
360/// A driver that never drains it loses the warnings, the same as a caller
361/// that never looks at a report; every driver that reports fidelity drains it.
362#[derive(Clone, Default)]
363pub struct FidelitySink(std::sync::Arc<std::sync::Mutex<Vec<Fidelity>>>);
364
365impl FidelitySink {
366    pub fn new() -> Self {
367        Self::default()
368    }
369
370    /// Records one warning.
371    pub fn warn(&self, w: Fidelity) {
372        self.lock().push(w);
373    }
374
375    /// Moves every recorded warning into `report` (de-duplicated as
376    /// [`FidelityReport::merge`] does), leaving the sink empty.
377    pub fn drain_into(&self, report: &mut FidelityReport) {
378        for w in std::mem::take(&mut *self.lock()) {
379            if !report.warnings.contains(&w) {
380                report.warn(w);
381            }
382        }
383    }
384
385    /// A copy of what has been recorded so far.
386    pub fn warnings(&self) -> Vec<Fidelity> {
387        self.lock().clone()
388    }
389
390    fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Fidelity>> {
391        // A panic while holding this lock leaves a Vec that is still whole;
392        // the warnings in it are as true as before.
393        self.0
394            .lock()
395            .unwrap_or_else(std::sync::PoisonError::into_inner)
396    }
397}
398
399impl fmt::Debug for FidelitySink {
400    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
401        f.debug_tuple("FidelitySink").field(&*self.lock()).finish()
402    }
403}
404
405/// What a read cost in fidelity. Returned alongside data, never logged and
406/// discarded — the caller must be able to discover what was approximated.
407#[derive(Clone, PartialEq, Eq, Debug)]
408#[cfg_attr(feature = "serde", derive(serde::Serialize))]
409pub struct FidelityReport {
410    pub rung: Rung,
411    pub warnings: Vec<Fidelity>,
412}
413
414impl FidelityReport {
415    pub fn exact() -> Self {
416        Self::new(Rung::Exact)
417    }
418
419    pub fn new(rung: Rung) -> Self {
420        Self {
421            rung,
422            warnings: Vec::new(),
423        }
424    }
425
426    pub fn warn(&mut self, w: Fidelity) {
427        self.warnings.push(w);
428    }
429
430    /// Whether anything was approximated. **This is the `--strict-fidelity`
431    /// gate** (exit code 4).
432    ///
433    /// Gating on warnings rather than on the rung is deliberate: a tar read
434    /// from a pipe lands on `ForwardOnly`, but tar has no trailing index, so
435    /// nothing was lost. Failing it would hand a script an error it cannot act
436    /// on. The rung stays diagnostic — reported by `stuffr info`.
437    pub fn has_warnings(&self) -> bool {
438        !self.warnings.is_empty()
439    }
440
441    /// True only if the rung was authoritative *and* nothing was approximated.
442    ///
443    /// Deliberately **not** the `--strict-fidelity` gate — see
444    /// [`Self::has_warnings`]. This is the stronger "the format's own index was
445    /// read and nothing was lost" claim, useful for archival verification.
446    pub fn is_lossless(&self) -> bool {
447        self.rung.is_authoritative() && self.warnings.is_empty()
448    }
449
450    /// Fold another report in: worst rung wins, warnings are unioned.
451    ///
452    /// For combining reports from independently-resolved layers — e.g. a
453    /// codec's own report merged with the inner container's, once a chain has
454    /// more than one — rather than for a single container's own findings,
455    /// which it now records by calling [`Self::warn`] directly on the report
456    /// it inherited from the ladder (see `crate::ladder::seed_report`). A
457    /// warning already present is not repeated, so folding the same loss in
458    /// from two sources still tells the caller about it once.
459    pub fn merge(&mut self, other: &FidelityReport) {
460        if other.rung.severity() > self.rung.severity() {
461            self.rung = other.rung;
462        }
463        for w in &other.warnings {
464            if !self.warnings.contains(w) {
465                self.warnings.push(w.clone());
466            }
467        }
468    }
469}
470
471impl Default for FidelityReport {
472    fn default() -> Self {
473        Self::exact()
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::format::FormatId;
481
482    #[test]
483    fn free_text_in_fidelity_messages_is_escaped() {
484        let r = || String::from("a\x1bb");
485        let all = [
486            Fidelity::EntrySkipped {
487                entry: "e".into(),
488                reason: r(),
489            },
490            Fidelity::EntryCountMismatch {
491                format: FormatId::new("tar"),
492                declared: 1,
493                enumerated: 0,
494                reason: r(),
495            },
496            Fidelity::EntrySizeChanged {
497                entry: "e".into(),
498                declared: 1,
499                fixup: r(),
500            },
501        ];
502        for f in all {
503            let m = f.to_string();
504            assert!(!m.contains('\x1b'), "raw ESC in {m:?}");
505            assert!(m.contains("a\\x1bb"), "{m:?}");
506        }
507        let plain = "a `b` \"c\" naïve 日本";
508        let m = Fidelity::EntrySkipped {
509            entry: "e".into(),
510            reason: plain.into(),
511        }
512        .to_string();
513        assert_eq!(m, format!("skipped entry `e`: {plain}"));
514    }
515
516    #[test]
517    fn entry_names_in_fidelity_messages_are_escaped() {
518        let n = || String::from("a\x1bb");
519        let all = [
520            Fidelity::SizeFromDataDescriptor { entry: n() },
521            Fidelity::MetadataIncomplete {
522                entry: n(),
523                fields: MetaFields::default(),
524            },
525            Fidelity::EncryptedEntrySkipped { entry: n() },
526            Fidelity::EntrySkipped {
527                entry: n(),
528                reason: "r".into(),
529            },
530            Fidelity::EntrySizeChanged {
531                entry: n(),
532                declared: 1,
533                fixup: "f".into(),
534            },
535            Fidelity::HeadersDisagree {
536                format: FormatId::new("zip"),
537                entry: n(),
538                fields: vec!["name", "CRC-32"],
539            },
540        ];
541        for f in all {
542            let m = f.to_string();
543            assert!(m.contains("a\\x1bb"), "{m}");
544            assert!(!m.contains('\x1b'), "{m:?}");
545        }
546    }
547
548    #[test]
549    fn headers_disagree_names_every_field_on_one_line() {
550        let w = Fidelity::HeadersDisagree {
551            format: FormatId::new("zip"),
552            entry: "a.txt".into(),
553            fields: vec!["name", "CRC-32"],
554        };
555        assert_eq!(
556            w.to_string(),
557            "`zip` local header and central directory disagree on name, CRC-32 for entry `a.txt`"
558        );
559        let more = Fidelity::HeadersDisagreeUnlisted {
560            format: FormatId::new("zip"),
561            entries: 3,
562        };
563        assert_eq!(
564            more.to_string(),
565            "`zip` local header and central directory disagree for 3 more entr(y/ies), not listed"
566        );
567    }
568
569    #[test]
570    fn spilled_is_authoritative_even_though_it_is_a_lower_rung() {
571        // Spilling costs disk, not accuracy. Treating it as lossy would make
572        // `--strict-fidelity` reject perfectly exact reads.
573        assert!(Rung::Exact.is_authoritative());
574        assert!(Rung::Spilled.is_authoritative());
575        assert!(!Rung::ForwardOnly.is_authoritative());
576        assert!(!Rung::Degraded.is_authoritative());
577    }
578
579    #[test]
580    fn exact_report_is_lossless() {
581        let r = FidelityReport::exact();
582        assert_eq!(r.rung, Rung::Exact);
583        assert!(r.is_lossless());
584        assert!(r.warnings.is_empty());
585    }
586
587    #[test]
588    fn a_warning_makes_an_otherwise_exact_report_lossy() {
589        let mut r = FidelityReport::exact();
590        r.warn(Fidelity::EntryCountUnknown);
591        assert!(!r.is_lossless());
592        assert_eq!(r.warnings.len(), 1);
593    }
594
595    #[test]
596    fn forward_only_is_lossy_even_with_no_warnings() {
597        assert!(!FidelityReport::new(Rung::ForwardOnly).is_lossless());
598    }
599
600    #[test]
601    fn merge_keeps_the_worst_rung_and_concatenates_warnings() {
602        let mut a = FidelityReport::exact();
603        a.warn(Fidelity::EntryCountUnknown);
604
605        let mut b = FidelityReport::new(Rung::Degraded);
606        b.warn(Fidelity::TruncatedStream { at: 99 });
607
608        a.merge(&b);
609        assert_eq!(a.rung, Rung::Degraded);
610        assert_eq!(a.warnings.len(), 2);
611    }
612
613    #[test]
614    fn merge_does_not_upgrade_a_worse_rung() {
615        let mut a = FidelityReport::new(Rung::Degraded);
616        a.merge(&FidelityReport::exact());
617        assert_eq!(a.rung, Rung::Degraded);
618    }
619
620    #[test]
621    fn merge_does_not_repeat_a_warning_already_present() {
622        // The ladder and the container can each independently observe the same
623        // loss; the caller should be told once.
624        let mut a = FidelityReport::exact();
625        a.warn(Fidelity::EntryCountUnknown);
626
627        let mut b = FidelityReport::new(Rung::ForwardOnly);
628        b.warn(Fidelity::EntryCountUnknown);
629
630        a.merge(&b);
631        assert_eq!(a.warnings.len(), 1);
632        assert_eq!(a.rung, Rung::ForwardOnly);
633    }
634
635    #[test]
636    fn warnings_carry_actionable_detail_not_just_a_kind() {
637        // The whole premise is that the caller can find out WHAT was lost.
638        let f = Fidelity::SizeFromDataDescriptor {
639            entry: "logs/a.txt".into(),
640        };
641        assert!(format!("{f}").contains("logs/a.txt"));
642
643        let f = Fidelity::TrailingIndexUnread {
644            format: FormatId::new("zip"),
645        };
646        assert!(format!("{f}").contains("zip"));
647
648        let f = Fidelity::SolidBlockFullyDecoded {
649            wasted_bytes: 41_943_040,
650        };
651        assert!(format!("{f}").contains("41943040"));
652
653        let f = Fidelity::EntrySkipped {
654            entry: "dev/null".into(),
655            reason: "device nodes, fifos and sockets are not created".into(),
656        };
657        let s = format!("{f}");
658        assert!(
659            s.contains("dev/null") && s.contains("sockets"),
660            "a skip must say both which entry and why: {s}"
661        );
662
663        let f = Fidelity::EntryCountMismatch {
664            format: FormatId::new("zip"),
665            declared: 8,
666            enumerated: 6,
667            reason: "records sharing a name collapse into one".into(),
668        };
669        let s = format!("{f}");
670        assert!(
671            s.contains('8') && s.contains('6') && s.contains("sharing a name"),
672            "a shortfall must name BOTH counts and the cause, or the caller \
673             cannot tell how much is missing or why: {s}"
674        );
675
676        let f = Fidelity::MetadataIncomplete {
677            entry: "b.bin".into(),
678            fields: MetaFields {
679                mode: true,
680                ..Default::default()
681            },
682        };
683        let s = format!("{f}");
684        assert!(s.contains("b.bin") && s.contains("mode"));
685    }
686
687    #[test]
688    fn meta_fields_lists_only_the_missing_ones() {
689        let m = MetaFields {
690            mtime: true,
691            crc: true,
692            ..Default::default()
693        };
694        assert_eq!(m.missing(), vec!["mtime", "crc"]);
695        assert!(MetaFields::default().missing().is_empty());
696    }
697
698    #[test]
699    fn a_forward_only_read_that_lost_nothing_passes_strict_mode() {
700        // tar on a pipe: rung is ForwardOnly, but tar has no trailing index, so
701        // nothing was approximated. Strict mode must not fail this.
702        let r = FidelityReport::new(Rung::ForwardOnly);
703        assert!(!r.has_warnings(), "nothing was approximated");
704        assert!(!r.is_lossless(), "but the rung was not authoritative");
705    }
706
707    #[test]
708    fn strict_mode_fires_on_any_warning_regardless_of_rung() {
709        let mut r = FidelityReport::exact();
710        r.warn(Fidelity::EntryCountUnknown);
711        assert!(r.has_warnings());
712    }
713
714    #[test]
715    fn a_clean_exact_read_passes_both_predicates() {
716        let r = FidelityReport::exact();
717        assert!(!r.has_warnings());
718        assert!(r.is_lossless());
719    }
720
721    #[test]
722    fn trailing_data_names_the_format_and_the_byte_count() {
723        let s = Fidelity::TrailingDataIgnored {
724            format: FormatId::new("gzip"),
725            bytes: 1_401_287,
726        }
727        .to_string();
728        assert!(s.contains("gzip") && s.contains("1401287"), "{s}");
729    }
730
731    #[test]
732    fn a_sink_shared_by_clones_drains_once_into_the_report() {
733        let sink = FidelitySink::new();
734        let layer = sink.clone();
735        let w = Fidelity::TrailingDataIgnored {
736            format: FormatId::new("bzip2"),
737            bytes: 3,
738        };
739        layer.warn(w.clone());
740        layer.warn(w.clone());
741        assert_eq!(
742            sink.warnings().len(),
743            2,
744            "a clone records into the same list"
745        );
746
747        let mut report = FidelityReport::exact();
748        sink.drain_into(&mut report);
749        assert_eq!(report.warnings, vec![w], "de-duplicated as merge does");
750        assert!(sink.warnings().is_empty(), "drained");
751        sink.drain_into(&mut report);
752        assert_eq!(report.warnings.len(), 1);
753    }
754}