Skip to main content

use_archive_manifest/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! Normalized archive manifest primitives for `RustUse`.
5
6use std::collections::BTreeSet;
7
8use use_archive_entry::{ArchiveEntry, ArchiveEntryKind};
9use use_archive_format::ArchiveEncoding;
10use use_archive_path::is_safe_relative_archive_path;
11
12/// A normalized listing of archive entries.
13#[derive(Clone, Debug, Default, Eq, PartialEq)]
14pub struct ArchiveManifest {
15    /// Archive and compression format metadata.
16    pub format: ArchiveEncoding,
17    /// Normalized archive entry listing.
18    pub entries: Vec<ArchiveEntry>,
19}
20
21impl ArchiveManifest {
22    /// Creates an empty manifest for an archive encoding.
23    #[must_use]
24    pub const fn new(format: ArchiveEncoding) -> Self {
25        Self {
26            format,
27            entries: Vec::new(),
28        }
29    }
30
31    /// Adds an entry listing to the manifest.
32    #[must_use]
33    pub fn with_entries(mut self, entries: Vec<ArchiveEntry>) -> Self {
34        self.entries = entries;
35        self
36    }
37
38    /// Appends one entry to the manifest.
39    pub fn push_entry(&mut self, entry: ArchiveEntry) {
40        self.entries.push(entry);
41    }
42
43    /// Returns the archive encoding metadata.
44    #[must_use]
45    pub const fn format(&self) -> ArchiveEncoding {
46        self.format
47    }
48
49    /// Returns the manifest entries.
50    #[must_use]
51    pub fn entries(&self) -> &[ArchiveEntry] {
52        &self.entries
53    }
54
55    /// Returns the number of entries.
56    #[must_use]
57    pub fn entry_count(&self) -> usize {
58        self.entries.len()
59    }
60
61    /// Returns the sum of all known entry sizes.
62    #[must_use]
63    pub fn total_size(&self) -> u64 {
64        self.entries.iter().filter_map(ArchiveEntry::size).sum()
65    }
66
67    /// Returns the number of entries with known sizes.
68    #[must_use]
69    pub fn known_size_count(&self) -> usize {
70        self.entries
71            .iter()
72            .filter(|entry| entry.size().is_some())
73            .count()
74    }
75
76    /// Returns the number of regular file entries.
77    #[must_use]
78    pub fn file_count(&self) -> usize {
79        self.count_kind(ArchiveEntryKind::File)
80    }
81
82    /// Returns the number of directory entries.
83    #[must_use]
84    pub fn directory_count(&self) -> usize {
85        self.count_kind(ArchiveEntryKind::Directory)
86    }
87
88    /// Returns the number of symbolic link entries.
89    #[must_use]
90    pub fn symlink_count(&self) -> usize {
91        self.count_kind(ArchiveEntryKind::Symlink)
92    }
93
94    /// Returns whether any entry path is unsafe for relative extraction.
95    #[must_use]
96    pub fn has_unsafe_paths(&self) -> bool {
97        self.entries
98            .iter()
99            .any(|entry| !is_safe_relative_archive_path(entry.path()))
100    }
101
102    /// Returns entries whose paths are unsafe for relative extraction.
103    #[must_use]
104    pub fn unsafe_paths(&self) -> Vec<&ArchiveEntry> {
105        self.entries
106            .iter()
107            .filter(|entry| !is_safe_relative_archive_path(entry.path()))
108            .collect()
109    }
110
111    /// Returns sorted, deduplicated lowercase entry extensions.
112    #[must_use]
113    pub fn extensions(&self) -> Vec<String> {
114        let extensions = self
115            .entries
116            .iter()
117            .filter_map(|entry| entry_extension(entry.path()))
118            .collect::<BTreeSet<_>>();
119
120        extensions.into_iter().collect()
121    }
122
123    fn count_kind(&self, kind: ArchiveEntryKind) -> usize {
124        self.entries
125            .iter()
126            .filter(|entry| entry.kind() == kind)
127            .count()
128    }
129}
130
131fn entry_extension(path: &str) -> Option<String> {
132    let leaf = path.rsplit('/').next().unwrap_or(path);
133    let (stem, extension) = leaf.rsplit_once('.')?;
134
135    if stem.is_empty() || extension.is_empty() {
136        None
137    } else {
138        Some(extension.to_ascii_lowercase())
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::ArchiveManifest;
145    use use_archive_entry::{ArchiveEntry, ArchiveEntryKind};
146    use use_archive_format::{ArchiveEncoding, ArchiveFormat, CompressionFormat};
147
148    #[test]
149    fn summarizes_entry_counts_and_sizes() {
150        let manifest = ArchiveManifest::new(ArchiveEncoding::new(
151            ArchiveFormat::Tar,
152            CompressionFormat::Zstd,
153        ))
154        .with_entries(vec![
155            ArchiveEntry::new("docs/readme.md", ArchiveEntryKind::File).with_size(128),
156            ArchiveEntry::new("docs", ArchiveEntryKind::Directory),
157            ArchiveEntry::new("bin/tool", ArchiveEntryKind::File).with_size(256),
158        ]);
159
160        assert_eq!(manifest.entry_count(), 3);
161        assert_eq!(manifest.file_count(), 2);
162        assert_eq!(manifest.directory_count(), 1);
163        assert_eq!(manifest.total_size(), 384);
164        assert_eq!(manifest.known_size_count(), 2);
165    }
166
167    #[test]
168    fn detects_unsafe_paths_and_extensions() {
169        let manifest = ArchiveManifest::new(ArchiveEncoding::from_extension("release.zip"))
170            .with_entries(vec![
171                ArchiveEntry::file("docs/readme.MD"),
172                ArchiveEntry::file("../secrets.env"),
173            ]);
174
175        assert!(manifest.has_unsafe_paths());
176        assert_eq!(manifest.unsafe_paths().len(), 1);
177        assert_eq!(
178            manifest.extensions(),
179            vec!["env".to_owned(), "md".to_owned()]
180        );
181    }
182}