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}