Skip to main content

scrollcase_consumer/
archive.rs

1//! Defensive archive reading.
2//!
3//! Nothing inside an archive is trusted before it is validated. Entry names are checked against path
4//! traversal, encrypted and special entries are refused outright, colliding names are refused, and
5//! every link is judged by the same rule the builder applied — against the archive **as received**
6//! rather than as intended. A box assembled by hand gets no benefit of the doubt here.
7//!
8//! The whole archive is validated before a single byte is written. That ordering is the point: a
9//! reader that validated entry by entry while extracting would already have written the files
10//! preceding the one that turned out to be hostile.
11
12use std::collections::{HashMap, HashSet};
13use std::io::Read as _;
14use std::path::Path;
15
16use crate::contract::links::{find_entry_through_link, find_unresolvable_link, EntryKind, PayloadEntry};
17use crate::error::{fail, Error, Result};
18use crate::filesystem::validate_extracted_tree;
19use crate::path::{join_relative, safe_relative_path};
20
21const ZIP_FILE_TYPE_MASK: u32 = 0o170_000;
22const ZIP_REGULAR_FILE: u32 = 0o100_000;
23const ZIP_DIRECTORY: u32 = 0o040_000;
24const ZIP_SYMBOLIC_LINK: u32 = 0o120_000;
25
26/// The longest link target a payload may carry.
27///
28/// A real one is a file name; anything approaching a path limit is either corrupt or an attempt to
29/// make reading the archive expensive.
30const MAX_LINK_TARGET_BYTES: u64 = 1024;
31
32/// The largest metadata entry this crate will read into memory.
33const MAX_METADATA_BYTES: u64 = 1024 * 1024;
34
35/// One validated archive entry.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ArchiveEntry {
38    /// Payload-relative path, forward slashes.
39    pub path: String,
40    /// What the entry is.
41    pub kind: EntryKind,
42    /// Uncompressed size the archive declares.
43    pub size: u64,
44    /// Permission bits, without the type bits.
45    pub mode: u32,
46    /// The link body, present only on a link and only after the entry list has been read.
47    pub link_target: Option<String>,
48}
49
50impl ArchiveEntry {
51    fn as_payload_entry(&self) -> PayloadEntry {
52        PayloadEntry {
53            path: self.path.clone(),
54            kind: self.kind,
55            link_target: self.link_target.clone(),
56        }
57    }
58}
59
60/// Classifies one entry and refuses encrypted and special entries.
61fn classify(name: &str, encrypted: bool, unix_mode: Option<u32>, size: u64) -> Result<ArchiveEntry> {
62    if encrypted {
63        fail!("Encrypted ZIP entries are not allowed: {name}");
64    }
65    let trimmed = name.strip_suffix('/').unwrap_or(name);
66    let path = safe_relative_path(trimmed)?;
67    let mode = unix_mode.unwrap_or(0);
68    let unix_type = mode & ZIP_FILE_TYPE_MASK;
69
70    if unix_type == ZIP_SYMBOLIC_LINK {
71        if size > MAX_LINK_TARGET_BYTES {
72            fail!("Archive link target is too long: {path}");
73        }
74        // The target is the entry's own content, so it is not known yet. Nothing may be extracted
75        // until the entry list has read it and the link rules have passed.
76        return Ok(ArchiveEntry {
77            path,
78            kind: EntryKind::Link,
79            size,
80            mode: 0o777,
81            link_target: None,
82        });
83    }
84
85    let is_directory = name.ends_with('/') || unix_type == ZIP_DIRECTORY;
86    if !is_directory && unix_type != 0 && unix_type != ZIP_REGULAR_FILE {
87        fail!("Archive special entries are not allowed: {path}");
88    }
89    Ok(ArchiveEntry {
90        path,
91        kind: if is_directory {
92            EntryKind::Directory
93        } else {
94            EntryKind::File
95        },
96        size,
97        mode: mode & 0o777,
98        link_target: None,
99    })
100}
101
102/// Refuses duplicate paths and file/directory collisions before extraction begins.
103fn assert_no_collisions(entries: &[ArchiveEntry]) -> Result<()> {
104    let mut seen: HashMap<&str, EntryKind> = HashMap::new();
105    let mut parents_with_children: HashSet<&str> = HashSet::new();
106    for entry in entries {
107        if seen.contains_key(entry.path.as_str()) {
108            fail!("Archive entry collides with another entry: {}", entry.path);
109        }
110        for (index, _) in entry.path.match_indices('/') {
111            let parent = &entry.path[..index];
112            if seen.get(parent) == Some(&EntryKind::File) {
113                fail!("Archive entry collides with another entry: {}", entry.path);
114            }
115            parents_with_children.insert(parent);
116        }
117        if entry.kind == EntryKind::File && parents_with_children.contains(entry.path.as_str()) {
118            fail!("Archive entry collides with another entry: {}", entry.path);
119        }
120        seen.insert(entry.path.as_str(), entry.kind);
121    }
122    Ok(())
123}
124
125/// Refuses an archive whose central directory names one path twice.
126///
127/// This is read from the raw bytes rather than from the ZIP backend, and it has to be: the backend
128/// indexes entries by name, so a duplicate is collapsed before any reader can see it — the last
129/// record silently wins. That is precisely the ambiguity the collision rule exists to remove, so the
130/// question is asked of the archive as received.
131///
132/// A central-directory record is `PK\x01\x02` followed by 42 bytes of header, then the name.
133fn assert_no_duplicate_names(path: &Path) -> Result<()> {
134    const SIGNATURE: [u8; 4] = [b'P', b'K', 1, 2];
135    const HEADER_LENGTH: usize = 46;
136    let bytes = std::fs::read(path)
137        .map_err(|error| Error::new(format!("cannot read archive {}: {error}", path.display())))?;
138    let mut seen: HashSet<&[u8]> = HashSet::new();
139    let mut cursor = 0usize;
140    while cursor + HEADER_LENGTH <= bytes.len() {
141        if bytes[cursor..cursor + 4] != SIGNATURE {
142            cursor += 1;
143            continue;
144        }
145        let name_length = u16::from_le_bytes([bytes[cursor + 28], bytes[cursor + 29]]) as usize;
146        let start = cursor + HEADER_LENGTH;
147        let Some(name) = bytes.get(start..start + name_length) else {
148            break;
149        };
150        if !seen.insert(name) {
151            let name = String::from_utf8_lossy(name);
152            fail!("Archive entry collides with another entry: {name}");
153        }
154        cursor = start + name_length;
155    }
156    Ok(())
157}
158
159fn open(path: &Path) -> Result<zip::ZipArchive<std::fs::File>> {
160    let file = std::fs::File::open(path)
161        .map_err(|error| Error::new(format!("cannot read archive {}: {error}", path.display())))?;
162    zip::ZipArchive::new(file)
163        .map_err(|error| Error::new(format!("cannot read archive {}: {error}", path.display())))
164}
165
166/// Lists and validates every entry before any archive data is trusted or extracted.
167///
168/// # Errors
169///
170/// When the archive cannot be read, or holds an unsafe name, an encrypted or special entry, a
171/// colliding name, or a link the contract does not permit.
172pub fn list_zip_entries(path: &Path) -> Result<Vec<ArchiveEntry>> {
173    assert_no_duplicate_names(path)?;
174    let mut archive = open(path)?;
175    let mut entries: Vec<ArchiveEntry> = Vec::with_capacity(archive.len());
176    for index in 0..archive.len() {
177        // `by_index_raw` so an encrypted entry is refused by name here, rather than surfacing as the
178        // zip backend's own "password required" further down.
179        let (name, encrypted, mode, size) = {
180            let entry = archive
181                .by_index_raw(index)
182                .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?;
183            (
184                entry.name().to_string(),
185                entry.encrypted(),
186                entry.unix_mode(),
187                entry.size(),
188            )
189        };
190        let mut classified = classify(&name, encrypted, mode, size)?;
191        if classified.kind == EntryKind::Link {
192            let mut target = String::new();
193            archive
194                .by_index(index)
195                .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?
196                .take(MAX_LINK_TARGET_BYTES + 1)
197                .read_to_string(&mut target)
198                .map_err(|error| {
199                    Error::new(format!("cannot read archive link {}: {error}", classified.path))
200                })?;
201            if target.len() as u64 > MAX_LINK_TARGET_BYTES {
202                fail!("Archive link target is too long: {}", classified.path);
203            }
204            classified.link_target = Some(target);
205        }
206        entries.push(classified);
207    }
208
209    assert_no_collisions(&entries)?;
210    let payload: Vec<PayloadEntry> = entries.iter().map(ArchiveEntry::as_payload_entry).collect();
211    if let Some(path) = find_unresolvable_link(&payload) {
212        fail!("Archive link does not resolve to a file inside the payload: {path}");
213    }
214    if let Some(path) = find_entry_through_link(&payload) {
215        fail!("Archive entry would be written through a link: {path}");
216    }
217    Ok(entries)
218}
219
220/// Reads one small metadata entry without extracting the surrounding archive.
221///
222/// # Errors
223///
224/// When the entry is missing, is not a regular file, or is larger than a metadata entry may be.
225pub fn read_zip_entry(path: &Path, wanted: &str, maximum_bytes: u64) -> Result<Vec<u8>> {
226    let safe = safe_relative_path(wanted)?;
227    let mut archive = open(path)?;
228    for index in 0..archive.len() {
229        let (name, encrypted, mode, size) = {
230            let entry = archive
231                .by_index_raw(index)
232                .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?;
233            (
234                entry.name().to_string(),
235                entry.encrypted(),
236                entry.unix_mode(),
237                entry.size(),
238            )
239        };
240        let classified = classify(&name, encrypted, mode, size)?;
241        if classified.path != safe || classified.kind != EntryKind::File {
242            continue;
243        }
244        if classified.size > maximum_bytes {
245            fail!("ZIP entry is too large to read as metadata: {safe}");
246        }
247        let mut bytes = Vec::new();
248        archive
249            .by_index(index)
250            .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?
251            .take(maximum_bytes + 1)
252            .read_to_end(&mut bytes)
253            .map_err(|error| Error::new(format!("cannot read {safe}: {error}")))?;
254        if bytes.len() as u64 > maximum_bytes {
255            fail!("ZIP entry is too large to read as metadata: {safe}");
256        }
257        return Ok(bytes);
258    }
259    fail!("ZIP archive does not contain {safe}")
260}
261
262/// Reads one small metadata entry as UTF-8 text.
263///
264/// # Errors
265///
266/// See [`read_zip_entry`]; additionally when the bytes are not valid UTF-8.
267pub fn read_zip_entry_text(path: &Path, wanted: &str) -> Result<String> {
268    let bytes = read_zip_entry(path, wanted, MAX_METADATA_BYTES)?;
269    String::from_utf8(bytes).map_err(|_| Error::new(format!("Invalid UTF-8 in {wanted}.")))
270}
271
272/// Extracts a prevalidated archive.
273///
274/// # Errors
275///
276/// When validation fails, or when the destination cannot be written.
277pub fn extract_zip_archive(archive_path: &Path, destination: &Path) -> Result<()> {
278    // Validated in full first, and the targets returned here are the only ones written below:
279    // reading a link target twice would let a concurrently rewritten archive pass the check with one
280    // value and extract with another.
281    let validated = list_zip_entries(archive_path)?;
282    let link_targets: HashMap<&str, &str> = validated
283        .iter()
284        .filter(|entry| entry.kind == EntryKind::Link)
285        .filter_map(|entry| Some((entry.path.as_str(), entry.link_target.as_deref()?)))
286        .collect();
287
288    std::fs::create_dir_all(destination)?;
289    let mut archive = open(archive_path)?;
290    for (index, entry) in validated.iter().enumerate() {
291        let output = join_relative(destination, &entry.path);
292        match entry.kind {
293            EntryKind::Directory => {
294                std::fs::create_dir_all(&output)?;
295                continue;
296            }
297            EntryKind::Link => {
298                if let Some(parent) = output.parent() {
299                    std::fs::create_dir_all(parent)?;
300                }
301                // Written as the relative string it was validated as, never as a resolved absolute
302                // path: the link must mean the same thing wherever the box is extracted.
303                let target = link_targets.get(entry.path.as_str()).copied().unwrap_or("");
304                create_symlink(target, &output)?;
305                continue;
306            }
307            EntryKind::File => {}
308        }
309        if let Some(parent) = output.parent() {
310            std::fs::create_dir_all(parent)?;
311        }
312        // `create_new` rather than `create`: an entry must never land on a path another entry
313        // already produced, and the filesystem is the last place that can still say so.
314        let mut file = new_file(&output, entry.mode)?;
315        let mut source = archive
316            .by_index(index)
317            .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?;
318        std::io::copy(&mut source, &mut file)
319            .map_err(|error| Error::new(format!("cannot write {}: {error}", output.display())))?;
320    }
321
322    // The archive said what should be written; this asks what actually is.
323    validate_extracted_tree(destination, true)
324}
325
326#[cfg(unix)]
327fn new_file(path: &Path, mode: u32) -> Result<std::fs::File> {
328    use std::os::unix::fs::OpenOptionsExt as _;
329    std::fs::OpenOptions::new()
330        .write(true)
331        .create_new(true)
332        .mode(if mode == 0 { 0o644 } else { mode })
333        .open(path)
334        .map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))
335}
336
337#[cfg(not(unix))]
338fn new_file(path: &Path, _mode: u32) -> Result<std::fs::File> {
339    // Windows extraction restores no mode, which is also why the payload digest does not record one.
340    std::fs::OpenOptions::new()
341        .write(true)
342        .create_new(true)
343        .open(path)
344        .map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))
345}
346
347#[cfg(unix)]
348fn create_symlink(target: &str, path: &Path) -> Result<()> {
349    std::os::unix::fs::symlink(target, path)
350        .map_err(|error| Error::new(format!("cannot write link {}: {error}", path.display())))
351}
352
353#[cfg(not(unix))]
354fn create_symlink(target: &str, path: &Path) -> Result<()> {
355    // A Windows box carries no links at all, so reaching this means an archive built for another
356    // target. Creating one needs Developer Mode or elevation, and failing here is the honest answer.
357    std::os::windows::fs::symlink_file(target, path)
358        .map_err(|error| Error::new(format!("cannot write link {}: {error}", path.display())))
359}
360
361#[cfg(test)]
362mod tests {
363    use super::{assert_no_collisions, classify, ArchiveEntry};
364    use crate::contract::links::EntryKind;
365
366    fn entry(path: &str, kind: EntryKind) -> ArchiveEntry {
367        ArchiveEntry {
368            path: path.to_string(),
369            kind,
370            size: 0,
371            mode: 0o644,
372            link_target: None,
373        }
374    }
375
376    // The classifier is exercised directly rather than through crafted archives, because the ZIP
377    // writer cannot emit the two cases that matter most: it refuses to encrypt without the AES
378    // feature, and `unix_permissions` masks off exactly the type bits that make an entry special.
379    // Testing through a writer that cannot express the hostile input would prove nothing.
380
381    #[test]
382    fn an_encrypted_entry_is_refused_before_anything_else() {
383        let error = classify("box.json", true, Some(0o100_644), 10).unwrap_err();
384        assert!(error.message().contains("Encrypted ZIP entries"), "{error}");
385    }
386
387    #[test]
388    fn special_entries_are_refused_by_their_type_bits() {
389        for (name, mode) in [
390            ("fifo", 0o010_000),
391            ("device", 0o020_000),
392            ("block", 0o060_000),
393            ("socket", 0o140_000),
394        ] {
395            let error = classify(name, false, Some(mode | 0o644), 0).unwrap_err();
396            assert!(
397                error.message().contains("special entries"),
398                "{name} was accepted: {error}"
399            );
400        }
401    }
402
403    #[test]
404    fn regular_files_and_directories_are_classified_as_the_format_expects() {
405        let file = classify("box.json", false, Some(0o100_644), 12).unwrap();
406        assert_eq!(file.kind, EntryKind::File);
407        assert_eq!(file.mode, 0o644);
408
409        // A directory is named either by its trailing slash or by its type bits.
410        assert_eq!(
411            classify("venv/", false, None, 0).unwrap().kind,
412            EntryKind::Directory
413        );
414        assert_eq!(
415            classify("venv", false, Some(0o040_755), 0).unwrap().kind,
416            EntryKind::Directory
417        );
418
419        // An archive with no mode information at all still yields a usable regular file.
420        assert_eq!(
421            classify("plain.txt", false, None, 3).unwrap().kind,
422            EntryKind::File
423        );
424    }
425
426    #[test]
427    fn a_link_is_classified_but_its_target_is_not_yet_known() {
428        let link = classify("venv/bin/python", false, Some(0o120_777), 9).unwrap();
429        assert_eq!(link.kind, EntryKind::Link);
430        assert!(link.link_target.is_none());
431    }
432
433    #[test]
434    fn an_oversized_link_target_is_refused_before_it_is_read() {
435        let error = classify("venv/bin/python", false, Some(0o120_777), 4096).unwrap_err();
436        assert!(error.message().contains("link target is too long"), "{error}");
437    }
438
439    #[test]
440    fn an_entry_name_that_escapes_the_root_is_refused() {
441        for name in ["../escape", "/etc/passwd", "C:/windows", "venv/../../out"] {
442            let error = classify(name, false, Some(0o100_644), 1).unwrap_err();
443            assert!(
444                error.message().contains("Unsafe relative path"),
445                "{name} was accepted: {error}"
446            );
447        }
448    }
449
450    #[test]
451    fn colliding_entries_are_refused_in_every_shape() {
452        // The same name twice.
453        let duplicate = vec![entry("a.txt", EntryKind::File), entry("a.txt", EntryKind::File)];
454        assert!(assert_no_collisions(&duplicate).is_err());
455
456        // A file, then something written underneath it as if it were a directory.
457        let through_file = vec![entry("a", EntryKind::File), entry("a/b", EntryKind::File)];
458        assert!(assert_no_collisions(&through_file).is_err());
459
460        // The same, in the order that makes the parent appear second.
461        let after_children = vec![entry("a/b", EntryKind::File), entry("a", EntryKind::File)];
462        assert!(assert_no_collisions(&after_children).is_err());
463
464        // A legitimate tree collides with nothing.
465        let fine = vec![
466            entry("box.json", EntryKind::File),
467            entry("venv", EntryKind::Directory),
468            entry("venv/bin", EntryKind::Directory),
469            entry("venv/bin/python", EntryKind::File),
470        ];
471        assert!(assert_no_collisions(&fine).is_ok());
472    }
473}