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}