Skip to main content

exarch_core/inspection/
manifest.rs

1//! Archive manifest types for listing.
2
3use std::path::Path;
4use std::path::PathBuf;
5use std::time::SystemTime;
6
7use crate::ArchiveError;
8use crate::Result;
9use crate::ZipEntryIndex;
10use crate::closed_enum::closed_enum;
11use crate::formats::detect::ArchiveType;
12use crate::security::fs_view::LinkKind;
13use crate::security::fs_view::check_link_target_length;
14
15/// Complete manifest of archive contents.
16///
17/// Generated by `list_archive()`, contains metadata about all entries
18/// without extracting them to disk. Fields are private so that the entry
19/// count and total size always agree with the stored entries.
20///
21/// # Examples
22///
23/// ```no_run
24/// use exarch_core::SecurityConfig;
25/// use exarch_core::list_archive;
26///
27/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
28/// let config = SecurityConfig::default();
29/// let manifest = list_archive("archive.tar.gz", &config)?;
30///
31/// for entry in manifest.entries() {
32///     println!("{}: {} bytes", entry.path.display(), entry.size);
33/// }
34/// # Ok(())
35/// # }
36/// ```
37#[derive(Debug, Clone)]
38pub struct ArchiveManifest {
39    entries: Vec<ArchiveEntry>,
40    oversized: Vec<OversizedEntry>,
41    total_size: u64,
42    format: ArchiveType,
43}
44
45/// The position of an entry in an [`ArchiveManifest`], only obtainable from
46/// the manifest that holds it.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub(crate) struct EntryPosition(usize);
49
50/// An entry whose path exceeds the host's name limits.
51///
52/// Only the position and the path length are kept, so an archive of many
53/// entries with huge names cannot inflate the manifest. The entry is not in
54/// [`ArchiveManifest::entries`].
55///
56/// # Examples
57///
58/// ```
59/// use exarch_core::OversizedEntry;
60///
61/// let entry = OversizedEntry::new(3, 70_000);
62/// assert_eq!(entry.index, 3);
63/// assert_eq!(entry.bytes, 70_000);
64/// ```
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[non_exhaustive]
67pub struct OversizedEntry {
68    /// Zero-based position among the listed entries, kept and dropped
69    pub index: usize,
70
71    /// Length of the stored path in bytes
72    pub bytes: usize,
73}
74
75impl OversizedEntry {
76    /// Creates a record of the entry at `index` with a path of `bytes` bytes.
77    #[must_use]
78    pub const fn new(index: usize, bytes: usize) -> Self {
79        Self { index, bytes }
80    }
81}
82
83impl ArchiveManifest {
84    /// Creates a new empty manifest.
85    #[must_use]
86    pub fn new(format: ArchiveType) -> Self {
87        Self {
88            entries: Vec::new(),
89            oversized: Vec::new(),
90            total_size: 0,
91            format,
92        }
93    }
94
95    /// Records an entry dropped for its over-long path; its position counts
96    /// the kept and the dropped entries listed before it.
97    pub(crate) fn add_oversized(&mut self, bytes: usize) {
98        let index = self.entries.len() + self.oversized.len();
99        self.oversized.push(OversizedEntry::new(index, bytes));
100    }
101
102    /// Returns the entries dropped because their path exceeds the host's
103    /// limits; only the verification pre-flight produces any.
104    #[must_use]
105    pub fn oversized_entries(&self) -> &[OversizedEntry] {
106        &self.oversized
107    }
108
109    /// Returns the position in [`entries`](Self::entries) of the ZIP entry
110    /// with the given index, or `None` when that entry was dropped for its
111    /// over-long path.
112    ///
113    /// ZIP entries are listed in index order, and the dropped ones record
114    /// their listing position, so the kept entry's position is its index less
115    /// the dropped entries before it.
116    pub(crate) fn position_of_zip_entry(&self, index: ZipEntryIndex) -> Option<usize> {
117        let index = index.get();
118        let dropped_before = self.oversized.partition_point(|o| o.index < index);
119        if self
120            .oversized
121            .get(dropped_before)
122            .is_some_and(|o| o.index == index)
123        {
124            return None;
125        }
126        let position = index - dropped_before;
127        (position < self.entries.len()).then_some(position)
128    }
129
130    /// Adds an entry to the manifest.
131    ///
132    /// # Errors
133    ///
134    /// Returns [`ArchiveError::InvalidArchive`] if the total uncompressed size
135    /// would overflow `u64`.
136    ///
137    /// # Examples
138    ///
139    /// ```
140    /// use std::path::PathBuf;
141    ///
142    /// use exarch_core::ArchiveEntry;
143    /// use exarch_core::ArchiveManifest;
144    /// use exarch_core::ManifestEntryKind;
145    /// use exarch_core::formats::detect::ArchiveType;
146    ///
147    /// let mut manifest = ArchiveManifest::new(ArchiveType::Tar);
148    /// let entry = ArchiveEntry::new(PathBuf::from("a.txt"), ManifestEntryKind::File, 10);
149    /// manifest.add_entry(entry)?;
150    /// assert_eq!(manifest.total_entries(), 1);
151    /// assert_eq!(manifest.total_size(), 10);
152    /// # Ok::<(), exarch_core::ArchiveError>(())
153    /// ```
154    pub fn add_entry(&mut self, entry: ArchiveEntry) -> Result<()> {
155        self.total_size = self.total_size.checked_add(entry.size).ok_or_else(|| {
156            ArchiveError::InvalidArchive("archive total size overflows u64".to_string())
157        })?;
158        self.entries.push(entry);
159        Ok(())
160    }
161
162    /// The position the next added entry takes.
163    pub(crate) fn next_position(&self) -> EntryPosition {
164        EntryPosition(self.entries.len())
165    }
166
167    /// Sets the target of the symlink entry at `position`, which listing reads
168    /// after the entry was added. Any other entry is left alone.
169    pub(crate) fn set_link_target(&mut self, position: EntryPosition, target: LinkTarget) {
170        if let Some(ArchiveEntry {
171            kind: ManifestEntryKind::Symlink { target: slot },
172            ..
173        }) = self.entries.get_mut(position.0)
174        {
175            *slot = target;
176        }
177    }
178
179    /// Returns all entries in the archive (files, dirs, symlinks, hardlinks).
180    #[must_use]
181    pub fn entries(&self) -> &[ArchiveEntry] {
182        &self.entries
183    }
184
185    /// Consumes the manifest and returns its entries.
186    #[must_use]
187    pub fn into_entries(self) -> Vec<ArchiveEntry> {
188        self.entries
189    }
190
191    /// Returns the total number of entries.
192    #[must_use]
193    pub fn total_entries(&self) -> usize {
194        self.entries.len()
195    }
196
197    /// Returns the total uncompressed size in bytes.
198    #[must_use]
199    pub const fn total_size(&self) -> u64 {
200        self.total_size
201    }
202
203    /// Returns the archive format.
204    #[must_use]
205    pub const fn format(&self) -> ArchiveType {
206        self.format
207    }
208}
209
210/// Single entry in archive manifest.
211///
212/// Contains metadata about a file, directory, symlink, or hardlink
213/// without extracting it to disk. Construct with [`ArchiveEntry::new`] and
214/// assign the optional fields.
215#[derive(Debug, Clone)]
216#[non_exhaustive]
217pub struct ArchiveEntry {
218    /// Entry path (relative, as stored in archive)
219    pub path: PathBuf,
220
221    /// Entry kind; link kinds carry their target
222    pub kind: ManifestEntryKind,
223
224    /// Uncompressed size in bytes (0 for directories)
225    pub size: u64,
226
227    /// How this entry's data is compressed, as far as the format exposes it
228    pub compression: EntryCompression,
229
230    /// File permissions (Unix mode)
231    pub mode: Option<u32>,
232
233    /// Modification time
234    pub modified: Option<SystemTime>,
235}
236
237impl ArchiveEntry {
238    /// Creates an entry with all optional metadata unset.
239    #[must_use]
240    pub const fn new(path: PathBuf, kind: ManifestEntryKind, size: u64) -> Self {
241        Self {
242            path,
243            kind,
244            size,
245            compression: EntryCompression::Unknown,
246            mode: None,
247            modified: None,
248        }
249    }
250
251    /// Returns the compressed size in bytes when the format reports one.
252    ///
253    /// For the first file of a solid block this is the size of the whole
254    /// block; other solid members report `None`.
255    #[must_use]
256    pub const fn compressed_size(&self) -> Option<u64> {
257        self.compression.compressed_size()
258    }
259
260    /// Returns the compression ratio of an entry that owns its compressed
261    /// data, or `None` when none can be attributed to it (TAR, solid members,
262    /// empty entries).
263    #[must_use]
264    pub fn compression_ratio(&self) -> Option<f64> {
265        self.compression
266            .entry_compressed_size()
267            .filter(|&c| c > 0)
268            .map(|c| self.size as f64 / c as f64)
269    }
270}
271
272/// Compressed and uncompressed totals of one solid block.
273///
274/// # Examples
275///
276/// ```
277/// use exarch_core::SolidBlock;
278///
279/// let block = SolidBlock::new(100, 13_800);
280/// assert_eq!(block.compressed, 100);
281/// assert_eq!(block.uncompressed, 13_800);
282/// ```
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284#[non_exhaustive]
285pub struct SolidBlock {
286    /// Sum of the block's pack stream sizes in bytes
287    pub compressed: u64,
288
289    /// Sum of the uncompressed sizes of every file in the block
290    pub uncompressed: u64,
291}
292
293impl SolidBlock {
294    /// Creates a block from its compressed and uncompressed totals.
295    #[must_use]
296    pub const fn new(compressed: u64, uncompressed: u64) -> Self {
297        Self {
298            compressed,
299            uncompressed,
300        }
301    }
302}
303
304/// How an archive entry's data is compressed.
305///
306/// Solid 7z members share one compressed stream, so only the block as a whole
307/// has a meaningful ratio; encoding that here keeps a per-file ratio from
308/// being computed for them.
309///
310/// # Examples
311///
312/// ```
313/// use exarch_core::EntryCompression;
314///
315/// assert_eq!(EntryCompression::Entry(40).compressed_size(), Some(40));
316/// assert_eq!(EntryCompression::Unknown.compressed_size(), None);
317/// ```
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319#[non_exhaustive]
320pub enum EntryCompression {
321    /// The format does not expose a compressed size (TAR) or the entry has no
322    /// data stream (empty 7z files)
323    Unknown,
324
325    /// The entry owns this many compressed bytes (ZIP, 7z single-file block)
326    Entry(u64),
327
328    /// First file of a solid block; carries the totals of the whole block
329    SolidBlockStart(SolidBlock),
330
331    /// Later file of a solid block; its data is accounted to the block start
332    SolidBlockMember,
333}
334
335impl EntryCompression {
336    /// Compressed size to report for the entry: the entry's own size, or the
337    /// whole block's size for a solid block start.
338    #[must_use]
339    pub const fn compressed_size(&self) -> Option<u64> {
340        match self {
341            Self::Entry(compressed) => Some(*compressed),
342            Self::SolidBlockStart(block) => Some(block.compressed),
343            Self::Unknown | Self::SolidBlockMember => None,
344        }
345    }
346
347    /// Compressed size owned by the entry alone, for per-file ratio checks.
348    #[must_use]
349    pub const fn entry_compressed_size(&self) -> Option<u64> {
350        match self {
351            Self::Entry(compressed) => Some(*compressed),
352            Self::Unknown | Self::SolidBlockStart(_) | Self::SolidBlockMember => None,
353        }
354    }
355}
356
357/// Link target of a manifest entry, as far as the listing could keep it.
358///
359/// An over-long target is not retained: only its byte length is kept, so an
360/// archive of many links with huge targets cannot inflate the manifest.
361///
362/// # Examples
363///
364/// ```
365/// use std::path::Path;
366///
367/// use exarch_core::LinkKind;
368/// use exarch_core::LinkTarget;
369///
370/// assert_eq!(
371///     LinkTarget::Recorded("a.txt".into()).path(),
372///     Some(Path::new("a.txt"))
373/// );
374/// assert_eq!(LinkTarget::Oversized { bytes: 9000 }.path(), None);
375/// // 40000 exceeds the path limit of every supported host
376/// assert_eq!(
377///     LinkTarget::classify("x".repeat(40_000).into(), LinkKind::Symlink),
378///     LinkTarget::Oversized { bytes: 40_000 }
379/// );
380/// assert_eq!(LinkTarget::Missing.path(), None);
381/// assert_eq!(LinkTarget::Unread.path(), None);
382/// assert_eq!(LinkTarget::Unreadable.path(), None);
383/// ```
384#[derive(Debug, Clone, PartialEq, Eq)]
385#[non_exhaustive]
386pub enum LinkTarget {
387    /// Target as stored in the archive, within the host's name limits
388    Recorded(PathBuf),
389
390    /// The archive stores no target
391    Missing,
392
393    /// The archive stores a target that was not read: a 7z target kept in
394    /// compressed entry data, left alone by a listing that skips stored
395    /// targets (the default), by a verification without an integrity check, or
396    /// by an integrity pass whose budget or admission limits did not reach it
397    Unread,
398
399    /// The archive stores a target that could not be read: a 7z reparse point
400    /// (in every listing), or a 7z target whose read was attempted and failed
401    /// because its block is refused for decoding or its data is corrupt
402    Unreadable,
403
404    /// The target exceeds the host's name limits; only its length is kept
405    Oversized {
406        /// Length of the stored target in bytes
407        bytes: usize,
408    },
409}
410
411impl LinkTarget {
412    /// Keeps `path` when it fits the host's limits for `kind`, otherwise
413    /// drops it and keeps only its length.
414    ///
415    /// # Examples
416    ///
417    /// ```
418    /// use std::assert_matches;
419    ///
420    /// use exarch_core::LinkKind;
421    /// use exarch_core::LinkTarget;
422    ///
423    /// let long_component: std::path::PathBuf = "x".repeat(300).into();
424    /// assert_matches!(
425    ///     LinkTarget::classify(long_component.clone(), LinkKind::Symlink),
426    ///     LinkTarget::Recorded(_)
427    /// );
428    /// assert_eq!(
429    ///     LinkTarget::classify(long_component, LinkKind::Hardlink),
430    ///     LinkTarget::Oversized { bytes: 300 }
431    /// );
432    /// ```
433    #[must_use]
434    pub fn classify(path: PathBuf, kind: LinkKind) -> Self {
435        if check_link_target_length(&path, kind).is_ok() {
436            Self::Recorded(path)
437        } else {
438            Self::Oversized {
439                bytes: path.as_os_str().len(),
440            }
441        }
442    }
443
444    /// Returns the recorded target, or `None` when it is missing or oversized.
445    #[must_use]
446    pub fn path(&self) -> Option<&Path> {
447        match self {
448            Self::Recorded(path) => Some(path),
449            Self::Missing | Self::Unread | Self::Unreadable | Self::Oversized { .. } => None,
450        }
451    }
452}
453
454/// Kind of a manifest entry; link kinds carry their target.
455///
456/// The target lives in the variant, so a regular file cannot carry one.
457/// [`LinkTarget::Missing`] and [`LinkTarget::Oversized`] occur only in entries
458/// produced by the pre-flight listing that
459/// [`verify_archive`](crate::verify_archive) uses, where such a target must
460/// become a report issue instead of a listing error. 7z link targets are
461/// [`LinkTarget::Unread`] by default, in verification without an integrity
462/// check, and for links an integrity pass did not reach (budget or admission
463/// limits). [`LinkTarget::Unreadable`] marks a 7z reparse point in every
464/// listing, and a target whose read was attempted and failed.
465///
466/// # Examples
467///
468/// ```
469/// use std::path::Path;
470///
471/// use exarch_core::LinkTarget;
472/// use exarch_core::ManifestEntryKind;
473/// use exarch_core::ManifestEntryType;
474///
475/// let kind = ManifestEntryKind::Symlink {
476///     target: LinkTarget::Recorded("a.txt".into()),
477/// };
478/// assert_eq!(kind.entry_type(), ManifestEntryType::Symlink);
479/// assert_eq!(kind.link_target(), Some(Path::new("a.txt")));
480/// assert_eq!(ManifestEntryKind::File.link_target(), None);
481/// ```
482#[derive(Debug, Clone, PartialEq, Eq)]
483#[non_exhaustive]
484pub enum ManifestEntryKind {
485    /// Regular file
486    File,
487
488    /// Directory
489    Directory,
490
491    /// Symbolic link
492    Symlink {
493        /// Link target as stored in the archive
494        target: LinkTarget,
495    },
496
497    /// Hard link
498    Hardlink {
499        /// Link target as stored in the archive
500        target: LinkTarget,
501    },
502}
503
504impl ManifestEntryKind {
505    /// Returns the wire tag of this kind.
506    #[must_use]
507    pub const fn entry_type(&self) -> ManifestEntryType {
508        match self {
509            Self::File => ManifestEntryType::File,
510            Self::Directory => ManifestEntryType::Directory,
511            Self::Symlink { .. } => ManifestEntryType::Symlink,
512            Self::Hardlink { .. } => ManifestEntryType::Hardlink,
513        }
514    }
515
516    /// Returns `true` for a symlink or a hardlink.
517    #[must_use]
518    pub const fn is_link(&self) -> bool {
519        matches!(self, Self::Symlink { .. } | Self::Hardlink { .. })
520    }
521
522    /// Returns the target of a symlink, if one was recorded.
523    #[must_use]
524    pub fn symlink_target(&self) -> Option<&Path> {
525        match self {
526            Self::Symlink { target } => target.path(),
527            Self::File | Self::Directory | Self::Hardlink { .. } => None,
528        }
529    }
530
531    /// Returns the target of a hardlink, if one was recorded.
532    #[must_use]
533    pub fn hardlink_target(&self) -> Option<&Path> {
534        match self {
535            Self::Hardlink { target } => target.path(),
536            Self::File | Self::Directory | Self::Symlink { .. } => None,
537        }
538    }
539
540    /// Returns the link target of a symlink or hardlink, if one was recorded.
541    #[must_use]
542    pub fn link_target(&self) -> Option<&Path> {
543        self.symlink_target().or_else(|| self.hardlink_target())
544    }
545}
546
547closed_enum! {
548    /// Entry type in manifest (does NOT require validation).
549    ///
550    /// Unlike `EntryType`, this enum is used for read-only listing
551    /// and does not enforce security constraints.
552    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
553    pub enum ManifestEntryType {
554        /// Regular file
555        File => "File",
556
557        /// Directory
558        Directory => "Directory",
559
560        /// Symbolic link
561        Symlink => "Symlink",
562
563        /// Hard link
564        Hardlink => "Hardlink",
565    }
566}
567
568#[cfg(test)]
569#[allow(clippy::unwrap_used)]
570mod tests {
571    use std::assert_matches;
572
573    use super::*;
574
575    #[test]
576    fn test_manifest_new() {
577        let manifest = ArchiveManifest::new(ArchiveType::TarGz);
578        assert_eq!(manifest.total_entries(), 0);
579        assert_eq!(manifest.total_size(), 0);
580        assert!(manifest.entries().is_empty());
581        assert_eq!(manifest.format(), ArchiveType::TarGz);
582    }
583
584    #[test]
585    fn test_manifest_add_entry() {
586        let mut manifest = ArchiveManifest::new(ArchiveType::TarGz);
587
588        let entry = ArchiveEntry {
589            path: PathBuf::from("file.txt"),
590            kind: ManifestEntryKind::File,
591            size: 1024,
592            compression: EntryCompression::Entry(512),
593            mode: Some(0o644),
594            modified: None,
595        };
596
597        manifest.add_entry(entry).unwrap();
598
599        assert_eq!(manifest.total_entries(), 1);
600        assert_eq!(manifest.total_size(), 1024);
601        assert_eq!(manifest.entries().len(), 1);
602    }
603
604    #[test]
605    fn test_manifest_add_entry_size_overflow() {
606        let mut manifest = ArchiveManifest::new(ArchiveType::Tar);
607        let big = ArchiveEntry::new(PathBuf::from("a"), ManifestEntryKind::File, u64::MAX);
608        manifest.add_entry(big).unwrap();
609
610        let more = ArchiveEntry::new(PathBuf::from("b"), ManifestEntryKind::File, 1);
611        assert_matches!(
612            manifest.add_entry(more),
613            Err(ArchiveError::InvalidArchive(_))
614        );
615        assert_eq!(manifest.total_entries(), 1);
616        assert_eq!(manifest.total_size(), u64::MAX);
617    }
618
619    #[test]
620    fn test_compression_ratio() {
621        let entry = ArchiveEntry {
622            path: PathBuf::from("file.txt"),
623            kind: ManifestEntryKind::File,
624            size: 1000,
625            compression: EntryCompression::Entry(100),
626            mode: None,
627            modified: None,
628        };
629
630        assert_eq!(entry.compression_ratio(), Some(10.0));
631    }
632
633    #[test]
634    fn test_compression_ratio_no_compressed() {
635        let entry = ArchiveEntry {
636            path: PathBuf::from("file.txt"),
637            kind: ManifestEntryKind::File,
638            size: 1000,
639            compression: EntryCompression::Unknown,
640            mode: None,
641            modified: None,
642        };
643
644        assert_eq!(entry.compression_ratio(), None);
645    }
646
647    #[test]
648    fn test_solid_entries_have_no_per_file_ratio() {
649        let mut entry = ArchiveEntry::new(PathBuf::from("a"), ManifestEntryKind::File, 1000);
650        entry.compression = EntryCompression::SolidBlockStart(SolidBlock::new(10, 2000));
651        assert_eq!(entry.compression_ratio(), None);
652        assert_eq!(entry.compressed_size(), Some(10));
653
654        entry.compression = EntryCompression::SolidBlockMember;
655        assert_eq!(entry.compression_ratio(), None);
656        assert_eq!(entry.compressed_size(), None);
657    }
658
659    #[test]
660    fn test_manifest_entry_type_display() {
661        assert_eq!(ManifestEntryType::File.to_string(), "File");
662        assert_eq!(ManifestEntryType::Directory.to_string(), "Directory");
663        assert_eq!(ManifestEntryType::Symlink.to_string(), "Symlink");
664        assert_eq!(ManifestEntryType::Hardlink.to_string(), "Hardlink");
665    }
666
667    #[test]
668    fn test_position_of_zip_entry_skips_dropped_entries() {
669        let mut manifest = ArchiveManifest::new(ArchiveType::Zip);
670        manifest.add_oversized(300);
671        for name in ["a", "b"] {
672            let entry = ArchiveEntry::new(PathBuf::from(name), ManifestEntryKind::File, 0);
673            manifest.add_entry(entry).unwrap();
674        }
675        manifest.add_oversized(300);
676
677        let position = |index| manifest.position_of_zip_entry(ZipEntryIndex::new(index));
678        assert_eq!(position(0), None);
679        assert_eq!(position(1), Some(0));
680        assert_eq!(position(2), Some(1));
681        assert_eq!(position(3), None);
682        assert_eq!(position(9), None);
683    }
684}