Skip to main content

camel_processor/
archive_splitter.rs

1//! Shared archive-entry validation and policy helpers for archive splitters.
2//!
3//! The ZIP and TAR stream splitters share entry-path validation, the default
4//! path-length cap, and the duplicate-name policy so security behavior cannot
5//! drift between the two formats. Error text stays format-specific: callers
6//! pass their format name (`kind`) and it is used verbatim in messages.
7
8use std::path::Path;
9
10use camel_api::CamelError;
11
12use serde::Deserialize;
13
14/// Shared default cap on validated entry path length.
15pub(crate) const DEFAULT_MAX_PATH_LENGTH: usize = 4096;
16
17/// Policy applied when an archive contains duplicate entry names.
18#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum DuplicatePolicy {
21    /// Emit every duplicate; later entries get a deterministic index suffix.
22    #[default]
23    AllowWithIndex,
24    /// Fail the split on the first duplicate name.
25    Reject,
26}
27
28/// Validate an archive entry path before any use.
29///
30/// `kind` is the archive format name used verbatim in error messages (for
31/// example `"ZIP"` or `"TAR"`), so each splitter keeps its own error text
32/// while sharing the validation logic. The validator enforces, in order:
33/// maximum length, NUL bytes, absolute paths, `..` traversal components,
34/// backslashes, and Windows drive prefixes.
35pub(crate) fn validate_entry_path(
36    path: &str,
37    max_length: usize,
38    kind: &str,
39) -> Result<String, CamelError> {
40    if path.len() > max_length {
41        return Err(CamelError::TypeConversionFailed(format!(
42            "{kind} entry path exceeds max length: {} > {}",
43            path.len(),
44            max_length
45        )));
46    }
47
48    if path.contains('\0') {
49        return Err(CamelError::TypeConversionFailed(format!(
50            "{kind} entry path contains NUL byte"
51        )));
52    }
53
54    if Path::new(path).is_absolute() {
55        return Err(CamelError::TypeConversionFailed(format!(
56            "{kind} entry path is absolute: {path}"
57        )));
58    }
59
60    for component in Path::new(path).components() {
61        if let std::path::Component::ParentDir = component {
62            return Err(CamelError::TypeConversionFailed(format!(
63                "{kind} entry path contains '..' traversal: {path}"
64            )));
65        }
66    }
67
68    if path.contains('\\') {
69        return Err(CamelError::TypeConversionFailed(format!(
70            "{kind} entry path contains backslash: {path}"
71        )));
72    }
73
74    if let Some(c) = path.chars().next()
75        && c.is_ascii_alphabetic()
76        && path.chars().nth(1) == Some(':')
77    {
78        return Err(CamelError::TypeConversionFailed(format!(
79            "{kind} entry path contains Windows drive prefix: {path}"
80        )));
81    }
82
83    Ok(path.to_string())
84}
85
86/// Deterministic [`DuplicatePolicy::AllowWithIndex`] name for the k-th
87/// (k >= 1) occurrence of a duplicate entry name: the occurrence index is
88/// inserted before the last extension (`a.tar` -> `a.1.tar`); names without a
89/// stem or extension get a plain numeric suffix (`README` -> `README.1`).
90///
91/// Shared by the ZIP and TAR splitters so the index-mangling semantics cannot
92/// drift between the formats. Callers must re-run [`validate_entry_path`] on
93/// the result: the suffix grows the name, so the path-length cap stays
94/// authoritative for indexed names too.
95pub(crate) fn indexed_duplicate_name(name: &str, occurrence: usize) -> String {
96    match name.rsplit_once('.') {
97        Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() => {
98            format!("{stem}.{occurrence}.{ext}")
99        }
100        _ => format!("{name}.{occurrence}"),
101    }
102}
103
104/// First collision-free indexed name for `base`, starting at `start` and
105/// bumping the index until the candidate is absent from `emitted`.
106///
107/// A literal archive entry can occupy an indexed name first (`a.txt`,
108/// `a.1.txt`, `a.txt` would otherwise derive `a.1.txt` twice), and two
109/// different base names can converge on the same indexed name, so the
110/// occurrence counter alone cannot guarantee uniqueness — only the set of
111/// names already emitted can. Returns the chosen name and the occurrence
112/// index it consumed, so callers can advance their per-base counter past
113/// any skipped indexes.
114///
115/// Shared by the ZIP and TAR splitters so the collision semantics cannot
116/// drift between the formats. Callers must re-run [`validate_entry_path`]
117/// on the result: the suffix grows the name, so the path-length cap stays
118/// authoritative for indexed names too.
119pub(crate) fn next_free_indexed_name(
120    base: &str,
121    start: usize,
122    emitted: &std::collections::HashSet<String>,
123) -> (String, usize) {
124    let mut occurrence = start.max(1);
125    loop {
126        let candidate = indexed_duplicate_name(base, occurrence);
127        if !emitted.contains(&candidate) {
128            return (candidate, occurrence);
129        }
130        occurrence += 1;
131    }
132}
133
134#[cfg(test)]
135pub(crate) mod test_util {
136    //! Test-only archive builders shared by the ZIP and TAR splitter tests.
137
138    /// CRC-32 (IEEE) checksum, bit-wise so no lookup table is needed.
139    pub(crate) fn crc32(data: &[u8]) -> u32 {
140        let mut crc: u32 = 0xFFFF_FFFF;
141        for &byte in data {
142            crc ^= u32::from(byte);
143            for _ in 0..8 {
144                let mask = (crc & 1).wrapping_neg();
145                crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
146            }
147        }
148        !crc
149    }
150
151    /// Build a minimal stored-method (uncompressed) ZIP with arbitrary entry
152    /// names, including duplicates the `zip` writer refuses to produce.
153    pub(crate) fn make_zip_raw(entries: &[(&str, &[u8])]) -> Vec<u8> {
154        struct Central {
155            name: String,
156            crc: u32,
157            size: u32,
158            offset: u32,
159        }
160
161        let mut out = Vec::new();
162        let mut centrals = Vec::with_capacity(entries.len());
163        for (name, data) in entries {
164            let offset = out.len() as u32;
165            let crc = crc32(data);
166            out.extend_from_slice(&0x0403_4b50_u32.to_le_bytes()); // local header
167            out.extend_from_slice(&20u16.to_le_bytes()); // version needed
168            out.extend_from_slice(&0u16.to_le_bytes()); // flags
169            out.extend_from_slice(&0u16.to_le_bytes()); // method: stored
170            out.extend_from_slice(&0u16.to_le_bytes()); // mod time (1980-01-01)
171            out.extend_from_slice(&0x21u16.to_le_bytes()); // mod date
172            out.extend_from_slice(&crc.to_le_bytes());
173            let size = data.len() as u32;
174            out.extend_from_slice(&size.to_le_bytes()); // compressed
175            out.extend_from_slice(&size.to_le_bytes()); // uncompressed
176            out.extend_from_slice(&(name.len() as u16).to_le_bytes());
177            out.extend_from_slice(&0u16.to_le_bytes()); // extra len
178            out.extend_from_slice(name.as_bytes());
179            out.extend_from_slice(data);
180            centrals.push(Central {
181                name: (*name).to_string(),
182                crc,
183                size,
184                offset,
185            });
186        }
187
188        let cd_start = out.len() as u32;
189        for central in &centrals {
190            out.extend_from_slice(&0x0201_4b50_u32.to_le_bytes()); // central dir
191            out.extend_from_slice(&20u16.to_le_bytes()); // version made by
192            out.extend_from_slice(&20u16.to_le_bytes()); // version needed
193            out.extend_from_slice(&0u16.to_le_bytes()); // flags
194            out.extend_from_slice(&0u16.to_le_bytes()); // method
195            out.extend_from_slice(&0u16.to_le_bytes()); // mod time
196            out.extend_from_slice(&0x21u16.to_le_bytes()); // mod date
197            out.extend_from_slice(&central.crc.to_le_bytes());
198            out.extend_from_slice(&central.size.to_le_bytes());
199            out.extend_from_slice(&central.size.to_le_bytes());
200            out.extend_from_slice(&(central.name.len() as u16).to_le_bytes());
201            out.extend_from_slice(&0u16.to_le_bytes()); // extra len
202            out.extend_from_slice(&0u16.to_le_bytes()); // comment len
203            out.extend_from_slice(&0u16.to_le_bytes()); // disk start
204            out.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
205            out.extend_from_slice(&0u32.to_le_bytes()); // external attrs
206            out.extend_from_slice(&central.offset.to_le_bytes());
207            out.extend_from_slice(central.name.as_bytes());
208        }
209        let cd_size = out.len() as u32 - cd_start;
210
211        out.extend_from_slice(&0x0605_4b50_u32.to_le_bytes()); // EOCD
212        out.extend_from_slice(&0u16.to_le_bytes()); // disk number
213        out.extend_from_slice(&0u16.to_le_bytes()); // central-dir disk
214        out.extend_from_slice(&(centrals.len() as u16).to_le_bytes());
215        out.extend_from_slice(&(centrals.len() as u16).to_le_bytes());
216        out.extend_from_slice(&cd_size.to_le_bytes());
217        out.extend_from_slice(&cd_start.to_le_bytes());
218        out.extend_from_slice(&0u16.to_le_bytes()); // comment len
219        out
220    }
221
222    /// The index-mangling contract shared verbatim by the ZIP and TAR
223    /// splitters: the occurrence index is inserted before the last
224    /// extension, and names without a stem or extension get a plain numeric
225    /// suffix. TAR exercises this end-to-end; ZIP shares the helper so the
226    /// semantics cannot drift.
227    #[test]
228    fn indexed_duplicate_name_is_deterministic_across_name_shapes() {
229        assert_eq!(super::indexed_duplicate_name("a.tar", 1), "a.1.tar");
230        assert_eq!(super::indexed_duplicate_name("a.tar", 3), "a.3.tar");
231        assert_eq!(super::indexed_duplicate_name("README", 1), "README.1");
232        assert_eq!(
233            super::indexed_duplicate_name("dir/file.bin", 2),
234            "dir/file.2.bin"
235        );
236        // Dotfiles have an empty stem: plain numeric suffix, no reorder.
237        assert_eq!(super::indexed_duplicate_name(".hidden", 1), ".hidden.1");
238        // Trailing-dot names have an empty extension: plain numeric suffix.
239        assert_eq!(super::indexed_duplicate_name("name.", 1), "name..1");
240    }
241}