Skip to main content

exarch_core/error/
zip_inconsistency.rs

1//! Typed findings for ZIP archives whose structure is read differently by
2//! different ZIP readers.
3
4use crate::closed_enum::closed_enum;
5
6macro_rules! index_newtype {
7    ($(#[$meta:meta])* $name:ident) => {
8        $(#[$meta])*
9        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
10        pub struct $name(usize);
11
12        impl $name {
13            /// Wraps a raw index.
14            #[must_use]
15            pub const fn new(index: usize) -> Self {
16                Self(index)
17            }
18
19            /// Returns the raw index.
20            #[must_use]
21            pub const fn get(self) -> usize {
22                self.0
23            }
24        }
25
26        impl ::std::fmt::Display for $name {
27            fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
28                ::std::fmt::Display::fmt(&self.0, f)
29            }
30        }
31    };
32}
33
34index_newtype! {
35    /// Index of an entry in the `zip` crate's entry map, which equals its
36    /// position in the manifest listing.
37    ///
38    /// # Examples
39    ///
40    /// ```
41    /// use exarch_core::ZipEntryIndex;
42    ///
43    /// assert_eq!(ZipEntryIndex::new(3).get(), 3);
44    /// ```
45    ZipEntryIndex
46}
47
48index_newtype! {
49    /// Position of a record in the raw central directory, counting records
50    /// that the `zip` crate collapsed because they share a name.
51    ///
52    /// # Examples
53    ///
54    /// ```
55    /// use exarch_core::CentralRecordOrdinal;
56    ///
57    /// assert_eq!(CentralRecordOrdinal::new(1).to_string(), "1");
58    /// ```
59    CentralRecordOrdinal
60}
61
62closed_enum! {
63    /// Header field in which a ZIP local header disagrees with its central
64    /// directory record.
65    ///
66    /// # Examples
67    ///
68    /// ```
69    /// use exarch_core::ZipHeaderField;
70    ///
71    /// assert_eq!(ZipHeaderField::FileName.to_string(), "file name");
72    /// ```
73    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
74    #[non_exhaustive]
75    pub enum ZipHeaderField {
76        /// File name
77        FileName => "file name",
78
79        /// Unicode Path extra field
80        UnicodePath => "unicode path",
81
82        /// Compression method
83        CompressionMethod => "compression method",
84
85        /// Encryption flag
86        EncryptionFlag => "encryption flag",
87
88        /// Data descriptor flag
89        DataDescriptorFlag => "data descriptor flag",
90
91        /// Modification time
92        ModifiedTime => "modification time",
93
94        /// CRC-32
95        Crc32 => "crc32",
96
97        /// Compressed size
98        CompressedSize => "compressed size",
99
100        /// Uncompressed size
101        UncompressedSize => "uncompressed size",
102    }
103}
104
105closed_enum! {
106    /// Why a ZIP local header could not be compared with its central
107    /// directory record.
108    ///
109    /// # Examples
110    ///
111    /// ```
112    /// use exarch_core::ZipUncomparable;
113    ///
114    /// assert_eq!(ZipUncomparable::Unparseable.to_string(), "unparseable local header");
115    /// ```
116    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
117    #[non_exhaustive]
118    pub enum ZipUncomparable {
119        /// The `zip` crate reported a failed check this version does not know
120        Unrecognized => "unrecognized field",
121
122        /// The local header could not be parsed
123        Unparseable => "unparseable local header",
124    }
125}
126
127closed_enum! {
128    /// Part of a ZIP file in which an unmapped local header was found.
129    ///
130    /// # Examples
131    ///
132    /// ```
133    /// use exarch_core::HiddenRegion;
134    ///
135    /// assert_eq!(HiddenRegion::Prefix.to_string(), "before the archive start");
136    /// ```
137    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
138    #[non_exhaustive]
139    pub enum HiddenRegion {
140        /// Bytes before the archive start, such as a self-extractor stub
141        Prefix => "before the archive start",
142
143        /// Bytes between entries or before the central directory
144        Gap => "between entries",
145
146        /// Bytes behind the end of the compressed stream of a data-descriptor
147        /// entry, inside the range its central directory record declares
148        DescriptorTail => "inside the range of a data-descriptor entry",
149    }
150}
151
152closed_enum! {
153    /// Kind of end record found before an entry.
154    ///
155    /// # Examples
156    ///
157    /// ```
158    /// use exarch_core::EndRecordKind;
159    ///
160    /// assert_eq!(
161    ///     EndRecordKind::Zip64.to_string(),
162    ///     "ZIP64 end of central directory record"
163    /// );
164    /// ```
165    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
166    #[non_exhaustive]
167    pub enum EndRecordKind {
168        /// End of central directory record
169        Classic => "end of central directory record",
170
171        /// ZIP64 end of central directory record
172        Zip64 => "ZIP64 end of central directory record",
173    }
174}
175
176/// A structural finding that makes a ZIP archive ambiguous between readers.
177///
178/// Names are the raw central directory bytes decoded lossily; the `zip`
179/// crate's own key can differ after Unicode Path substitution.
180///
181/// # Examples
182///
183/// ```
184/// use exarch_core::CentralRecordOrdinal;
185/// use exarch_core::ZipInconsistency;
186///
187/// let finding = ZipInconsistency::DuplicateName {
188///     record: CentralRecordOrdinal::new(1),
189///     name: "a.txt".to_string(),
190/// };
191/// assert_eq!(
192///     finding.to_string(),
193///     "duplicate entry name \"a.txt\" (record 1)"
194/// );
195/// ```
196#[derive(Debug, Clone, PartialEq, Eq)]
197#[non_exhaustive]
198pub enum ZipInconsistency {
199    /// A local header disagrees with its central directory record.
200    HeaderMismatch {
201        /// Entry in the crate's entry map.
202        entry: ZipEntryIndex,
203        /// Field that differs.
204        field: ZipHeaderField,
205        /// Central directory name of the entry.
206        name: String,
207    },
208
209    /// Two or more central directory records share a name.
210    DuplicateName {
211        /// Position in the raw central directory of a record that the `zip`
212        /// crate collapsed into a later record with the same name.
213        record: CentralRecordOrdinal,
214        /// Shared name.
215        name: String,
216    },
217
218    /// Entry data ranges intersect.
219    OverlappingEntries,
220
221    /// A local header exists that no central directory record maps.
222    HiddenLocalHeader {
223        /// Absolute offset of the unmapped header.
224        offset: u64,
225        /// Part of the file the header was found in.
226        region: HiddenRegion,
227    },
228
229    /// An end of central directory record other than the one the reader used
230    /// follows it, so a reader that picks another candidate sees another
231    /// directory.
232    IgnoredEndRecord {
233        /// Absolute offset of the ignored record.
234        offset: u64,
235    },
236
237    /// A central directory record sits outside the central directory, in the
238    /// bytes before the first entry or between entries, so a streaming reader
239    /// that stops listing at the first central record never sees the entries
240    /// after it.
241    StrayCentralRecord {
242        /// Absolute offset of the stray record.
243        offset: u64,
244    },
245
246    /// An end record signature sits in the bytes before the first entry or
247    /// between entries, so a streaming reader that stops listing at it never
248    /// sees the entries after it.
249    StrayEndRecord {
250        /// Absolute offset of the stray record.
251        offset: u64,
252        /// Kind of end record the signature starts.
253        kind: EndRecordKind,
254    },
255
256    /// A data descriptor makes a streaming reader resume past the next entry
257    /// header, so it skips or misreads that entry.
258    DescriptorOverrun {
259        /// Entry whose descriptor is misread.
260        entry: ZipEntryIndex,
261        /// Absolute offset where the entry's data ends and the descriptor
262        /// starts.
263        offset: u64,
264    },
265
266    /// An `xl` extra field, in the local header or the central record, gives
267    /// an entry a file type that differs from the one its central attributes
268    /// give, so a reader that honours the field extracts another kind of entry
269    /// than one that follows the attributes.
270    XlTypeDivergence {
271        /// Entry whose types differ.
272        entry: ZipEntryIndex,
273        /// Absolute offset of the entry's local header.
274        offset: u64,
275    },
276
277    /// The central directory holds more records than its end record counts.
278    UncountedCentralRecords {
279        /// Count claimed by the end of central directory record.
280        counted: usize,
281        /// Records actually present.
282        found: usize,
283    },
284
285    /// A local header could not be compared with its central directory
286    /// record, so a disagreement between them cannot be ruled out.
287    HeaderUncomparable {
288        /// Entry in the crate's entry map.
289        entry: ZipEntryIndex,
290        /// Central directory name of the entry.
291        name: String,
292        /// Why the comparison was not possible.
293        reason: ZipUncomparable,
294    },
295}
296
297impl std::fmt::Display for ZipInconsistency {
298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299        match self {
300            Self::HeaderMismatch { entry, field, name } => write!(
301                f,
302                "local header disagrees with central directory on {field} for entry {entry} \
303                 ({name:?})"
304            ),
305            Self::HeaderUncomparable {
306                entry,
307                name,
308                reason,
309            } => match reason {
310                ZipUncomparable::Unparseable => write!(
311                    f,
312                    "local header of entry {entry} ({name:?}) cannot be parsed, so it cannot be \
313                     compared with the central directory"
314                ),
315                ZipUncomparable::Unrecognized => write!(
316                    f,
317                    "local header of entry {entry} ({name:?}) fails the header consistency check \
318                     in a way this version does not recognize"
319                ),
320            },
321            Self::DuplicateName { record, name } => {
322                write!(f, "duplicate entry name {name:?} (record {record})")
323            }
324            Self::OverlappingEntries => f.write_str("entry data ranges overlap"),
325            Self::HiddenLocalHeader { offset, region } => write!(
326                f,
327                "local header at offset {offset} ({region}) is not in the central directory"
328            ),
329            Self::IgnoredEndRecord { offset } => write!(
330                f,
331                "end of central directory record at offset {offset} is ignored by the reader"
332            ),
333            Self::StrayCentralRecord { offset } => write!(
334                f,
335                "central directory record outside the central directory at offset {offset}"
336            ),
337            Self::StrayEndRecord { offset, kind } => {
338                write!(f, "{kind} before an entry, at offset {offset}")
339            }
340            Self::DescriptorOverrun { entry, offset } => write!(
341                f,
342                "data descriptor of entry {entry} at offset {offset} makes a streaming reader \
343                 resume past the next entry"
344            ),
345            Self::XlTypeDivergence { entry, offset } => write!(
346                f,
347                "an xl extra field of entry {entry} (local header at offset {offset}) gives it a \
348                 file type that the central directory attributes do not"
349            ),
350            Self::UncountedCentralRecords { counted, found } => write!(
351                f,
352                "central directory holds {found} records but the end record counts {counted}"
353            ),
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    fn mismatch(field: ZipHeaderField) -> String {
363        ZipInconsistency::HeaderMismatch {
364            entry: ZipEntryIndex::new(2),
365            field,
366            name: "a.txt".into(),
367        }
368        .to_string()
369    }
370
371    #[test]
372    fn mismatch_message_names_the_field() {
373        assert_eq!(
374            mismatch(ZipHeaderField::Crc32),
375            "local header disagrees with central directory on crc32 for entry 2 (\"a.txt\")"
376        );
377    }
378
379    #[test]
380    fn unparseable_and_unrecognized_get_their_own_wording() {
381        let uncomparable = |reason| {
382            ZipInconsistency::HeaderUncomparable {
383                entry: ZipEntryIndex::new(2),
384                name: "a.txt".into(),
385                reason,
386            }
387            .to_string()
388        };
389        assert_eq!(
390            uncomparable(ZipUncomparable::Unparseable),
391            "local header of entry 2 (\"a.txt\") cannot be parsed, so it cannot be compared \
392             with the central directory"
393        );
394        assert_eq!(
395            uncomparable(ZipUncomparable::Unrecognized),
396            "local header of entry 2 (\"a.txt\") fails the header consistency check in a way \
397             this version does not recognize"
398        );
399    }
400
401    #[test]
402    fn findings_render_their_positions() {
403        assert_eq!(
404            ZipInconsistency::HiddenLocalHeader {
405                offset: 45,
406                region: HiddenRegion::Gap
407            }
408            .to_string(),
409            "local header at offset 45 (between entries) is not in the central directory"
410        );
411        assert_eq!(
412            ZipInconsistency::IgnoredEndRecord { offset: 7 }.to_string(),
413            "end of central directory record at offset 7 is ignored by the reader"
414        );
415        assert_eq!(
416            ZipInconsistency::StrayCentralRecord { offset: 9 }.to_string(),
417            "central directory record outside the central directory at offset 9"
418        );
419        assert_eq!(
420            ZipInconsistency::StrayEndRecord {
421                offset: 11,
422                kind: EndRecordKind::Zip64
423            }
424            .to_string(),
425            "ZIP64 end of central directory record before an entry, at offset 11"
426        );
427        assert_eq!(
428            ZipInconsistency::DescriptorOverrun {
429                entry: ZipEntryIndex::new(2),
430                offset: 70
431            }
432            .to_string(),
433            "data descriptor of entry 2 at offset 70 makes a streaming reader resume past the \
434             next entry"
435        );
436        assert_eq!(
437            ZipInconsistency::XlTypeDivergence {
438                entry: ZipEntryIndex::new(2),
439                offset: 70
440            }
441            .to_string(),
442            "an xl extra field of entry 2 (local header at offset 70) gives it a file type \
443             that the central directory attributes do not"
444        );
445        assert_eq!(
446            ZipInconsistency::UncountedCentralRecords {
447                counted: 1,
448                found: 2
449            }
450            .to_string(),
451            "central directory holds 2 records but the end record counts 1"
452        );
453    }
454}