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 _, Seek as _, SeekFrom};
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/// The directory location and record count committed by the ZIP footer.
126struct CentralDirectory {
127    offset: u64,
128    size: u64,
129    records: u64,
130}
131
132fn archive_read_error(path: &Path, error: impl std::fmt::Display) -> Error {
133    Error::new(format!("cannot read archive {}: {error}", path.display()))
134}
135
136fn u16_at(bytes: &[u8], offset: usize) -> u16 {
137    u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
138}
139
140fn u32_at(bytes: &[u8], offset: usize) -> u32 {
141    u32::from_le_bytes([
142        bytes[offset],
143        bytes[offset + 1],
144        bytes[offset + 2],
145        bytes[offset + 3],
146    ])
147}
148
149fn u64_at(bytes: &[u8], offset: usize) -> u64 {
150    u64::from_le_bytes([
151        bytes[offset],
152        bytes[offset + 1],
153        bytes[offset + 2],
154        bytes[offset + 3],
155        bytes[offset + 4],
156        bytes[offset + 5],
157        bytes[offset + 6],
158        bytes[offset + 7],
159    ])
160}
161
162fn read_exact_at(
163    file: &mut std::fs::File,
164    path: &Path,
165    offset: u64,
166    bytes: &mut [u8],
167) -> Result<()> {
168    file.seek(SeekFrom::Start(offset))
169        .and_then(|_| file.read_exact(bytes))
170        .map_err(|error| archive_read_error(path, error))
171}
172
173/// Reads a ZIP64 EOCD whose locator sits immediately before the ordinary EOCD.
174fn zip64_central_directory(
175    file: &mut std::fs::File,
176    path: &Path,
177    eocd_offset: u64,
178) -> Result<Option<CentralDirectory>> {
179    const LOCATOR_SIGNATURE: [u8; 4] = [b'P', b'K', 6, 7];
180    const EOCD_SIGNATURE: [u8; 4] = [b'P', b'K', 6, 6];
181    const LOCATOR_LENGTH: u64 = 20;
182    const EOCD_MINIMUM_LENGTH: u64 = 56;
183    const SEARCH_CHUNK: u64 = 64 * 1024;
184
185    let Some(locator_offset) = eocd_offset.checked_sub(LOCATOR_LENGTH) else {
186        return Ok(None);
187    };
188    let mut locator = [0u8; 20];
189    read_exact_at(file, path, locator_offset, &mut locator)?;
190    if locator[..4] != LOCATOR_SIGNATURE
191        || u32_at(&locator, 4) != 0
192        || u32_at(&locator, 16) != 1
193    {
194        return Ok(None);
195    }
196    let relative_eocd_offset = u64_at(&locator, 8);
197    if relative_eocd_offset >= locator_offset {
198        return Ok(None);
199    }
200
201    // The locator's offset is relative to the start of the ZIP data. A self-extracting prefix can
202    // therefore move the real EOCD64 forward. Search backwards in small windows, validating the
203    // record's own length and all three directory coordinates before accepting a signature.
204    let mut search_end = locator_offset;
205    while search_end > relative_eocd_offset {
206        let search_start = relative_eocd_offset.max(search_end.saturating_sub(SEARCH_CHUNK));
207        let read_end = locator_offset.min(search_end.saturating_add(3));
208        let length = usize::try_from(read_end - search_start)
209            .map_err(|error| archive_read_error(path, error))?;
210        let mut window = vec![0u8; length];
211        read_exact_at(file, path, search_start, &mut window)?;
212
213        let owned_starts = usize::try_from(search_end - search_start)
214            .map_err(|error| archive_read_error(path, error))?;
215        for index in (0..owned_starts).rev() {
216            if window.get(index..index + 4) != Some(EOCD_SIGNATURE.as_slice()) {
217                continue;
218            }
219            let candidate = search_start + index as u64;
220            if candidate + EOCD_MINIMUM_LENGTH > locator_offset {
221                continue;
222            }
223            let mut header = [0u8; 56];
224            read_exact_at(file, path, candidate, &mut header)?;
225            let Some(record_end) = candidate
226                .checked_add(12)
227                .and_then(|start| start.checked_add(u64_at(&header, 4)))
228            else {
229                continue;
230            };
231            if record_end != locator_offset
232                || u64_at(&header, 4) < 44
233                || u32_at(&header, 16) != 0
234                || u32_at(&header, 20) != 0
235                || u64_at(&header, 24) != u64_at(&header, 32)
236            {
237                continue;
238            }
239
240            let Some(archive_offset) = candidate.checked_sub(relative_eocd_offset) else {
241                continue;
242            };
243            let directory_size = u64_at(&header, 40);
244            let Some(directory_offset) = archive_offset.checked_add(u64_at(&header, 48)) else {
245                continue;
246            };
247            if directory_offset.checked_add(directory_size) != Some(candidate) {
248                continue;
249            }
250            return Ok(Some(CentralDirectory {
251                offset: directory_offset,
252                size: directory_size,
253                records: u64_at(&header, 32),
254            }));
255        }
256        search_end = search_start;
257    }
258    Ok(None)
259}
260
261/// Locates the central directory without reading payload bytes.
262fn central_directory(file: &mut std::fs::File, path: &Path) -> Result<CentralDirectory> {
263    const EOCD_SIGNATURE: [u8; 4] = [b'P', b'K', 5, 6];
264    const EOCD_LENGTH: usize = 22;
265    const MAX_COMMENT_LENGTH: u64 = u16::MAX as u64;
266
267    let file_length = file
268        .metadata()
269        .map_err(|error| archive_read_error(path, error))?
270        .len();
271    let tail_length = file_length.min(EOCD_LENGTH as u64 + MAX_COMMENT_LENGTH);
272    let tail_offset = file_length - tail_length;
273    let tail_capacity =
274        usize::try_from(tail_length).map_err(|error| archive_read_error(path, error))?;
275    let mut tail = vec![0u8; tail_capacity];
276    read_exact_at(file, path, tail_offset, &mut tail)?;
277
278    if tail.len() >= EOCD_LENGTH {
279        for index in (0..=tail.len() - EOCD_LENGTH).rev() {
280            if tail[index..index + 4] != EOCD_SIGNATURE {
281                continue;
282            }
283            let comment_length = usize::from(u16_at(&tail, index + 20));
284            if index + EOCD_LENGTH + comment_length != tail.len() {
285                continue;
286            }
287            let eocd_offset = tail_offset + index as u64;
288            let may_be_zip64 = u16_at(&tail, index + 8) == u16::MAX
289                || u16_at(&tail, index + 10) == u16::MAX
290                || u32_at(&tail, index + 12) == u32::MAX
291                || u32_at(&tail, index + 16) == u32::MAX;
292            if may_be_zip64 {
293                if let Some(directory) = zip64_central_directory(file, path, eocd_offset)? {
294                    return Ok(directory);
295                }
296            }
297
298            let records_on_disk = u16_at(&tail, index + 8);
299            let records = u16_at(&tail, index + 10);
300            if u16_at(&tail, index + 4) != 0
301                || u16_at(&tail, index + 6) != 0
302                || records_on_disk != records
303            {
304                continue;
305            }
306            let directory_size = u64::from(u32_at(&tail, index + 12));
307            let relative_offset = u64::from(u32_at(&tail, index + 16));
308            let Some(relative_end) = relative_offset.checked_add(directory_size) else {
309                continue;
310            };
311            let Some(archive_offset) = eocd_offset.checked_sub(relative_end) else {
312                continue;
313            };
314            return Ok(CentralDirectory {
315                offset: archive_offset + relative_offset,
316                size: directory_size,
317                records: u64::from(records),
318            });
319        }
320    }
321    Err(archive_read_error(path, "invalid ZIP central directory"))
322}
323
324/// Refuses an archive whose central directory names one path twice.
325///
326/// This is read from the raw bytes rather than from the ZIP backend, and it has to be: the backend
327/// indexes entries by name, so a duplicate is collapsed before any reader can see it — the last
328/// record silently wins. That is precisely the ambiguity the collision rule exists to remove, so the
329/// question is asked of the archive as received.
330///
331/// A central-directory record is `PK\x01\x02` followed by 42 bytes of header, then its variable
332/// name, extra field and comment. Walking exactly the EOCD or EOCD64 region and declared record count
333/// is important: the same signature inside a stored nested archive is payload data, not an index.
334fn assert_no_duplicate_names(path: &Path) -> Result<()> {
335    const SIGNATURE: [u8; 4] = [b'P', b'K', 1, 2];
336    const HEADER_LENGTH: usize = 46;
337
338    let mut file = std::fs::File::open(path)
339        .map_err(|error| archive_read_error(path, error))?;
340    let central = central_directory(&mut file, path)?;
341    if central.records > central.size / HEADER_LENGTH as u64 {
342        return Err(archive_read_error(path, "invalid ZIP central directory"));
343    }
344    file.seek(SeekFrom::Start(central.offset))
345        .map_err(|error| archive_read_error(path, error))?;
346    let mut directory = (&mut file).take(central.size);
347    let mut seen: HashSet<Vec<u8>> = HashSet::new();
348    for _ in 0..central.records {
349        let mut header = [0u8; HEADER_LENGTH];
350        directory
351            .read_exact(&mut header)
352            .map_err(|error| archive_read_error(path, error))?;
353        if header[..4] != SIGNATURE {
354            return Err(archive_read_error(path, "invalid ZIP central directory"));
355        }
356        let name_length = usize::from(u16_at(&header, 28));
357        let extra_length = u64::from(u16_at(&header, 30));
358        let comment_length = u64::from(u16_at(&header, 32));
359        let variable_length = name_length as u64 + extra_length + comment_length;
360        if variable_length > directory.limit() {
361            return Err(archive_read_error(path, "invalid ZIP central directory"));
362        }
363
364        let mut name = vec![0u8; name_length];
365        directory
366            .read_exact(&mut name)
367            .map_err(|error| archive_read_error(path, error))?;
368        if seen.contains(&name) {
369            let name = String::from_utf8_lossy(&name);
370            fail!("Archive entry collides with another entry: {name}");
371        }
372        seen.insert(name);
373
374        let skipped = std::io::copy(
375            &mut directory.by_ref().take(extra_length + comment_length),
376            &mut std::io::sink(),
377        )
378        .map_err(|error| archive_read_error(path, error))?;
379        if skipped != extra_length + comment_length {
380            return Err(archive_read_error(path, "invalid ZIP central directory"));
381        }
382    }
383    Ok(())
384}
385
386fn open(path: &Path) -> Result<zip::ZipArchive<std::fs::File>> {
387    let file = std::fs::File::open(path)
388        .map_err(|error| Error::new(format!("cannot read archive {}: {error}", path.display())))?;
389    zip::ZipArchive::new(file)
390        .map_err(|error| Error::new(format!("cannot read archive {}: {error}", path.display())))
391}
392
393/// Lists and validates every entry before any archive data is trusted or extracted.
394///
395/// # Errors
396///
397/// When the archive cannot be read, or holds an unsafe name, an encrypted or special entry, a
398/// colliding name, or a link the contract does not permit.
399pub fn list_zip_entries(path: &Path) -> Result<Vec<ArchiveEntry>> {
400    assert_no_duplicate_names(path)?;
401    let mut archive = open(path)?;
402    let mut entries: Vec<ArchiveEntry> = Vec::with_capacity(archive.len());
403    for index in 0..archive.len() {
404        // `by_index_raw` so an encrypted entry is refused by name here, rather than surfacing as the
405        // zip backend's own "password required" further down.
406        let (name, encrypted, mode, size) = {
407            let entry = archive
408                .by_index_raw(index)
409                .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?;
410            (
411                entry.name().to_string(),
412                entry.encrypted(),
413                entry.unix_mode(),
414                entry.size(),
415            )
416        };
417        let mut classified = classify(&name, encrypted, mode, size)?;
418        if classified.kind == EntryKind::Link {
419            let mut target = String::new();
420            archive
421                .by_index(index)
422                .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?
423                .take(MAX_LINK_TARGET_BYTES + 1)
424                .read_to_string(&mut target)
425                .map_err(|error| {
426                    Error::new(format!("cannot read archive link {}: {error}", classified.path))
427                })?;
428            if target.len() as u64 > MAX_LINK_TARGET_BYTES {
429                fail!("Archive link target is too long: {}", classified.path);
430            }
431            classified.link_target = Some(target);
432        }
433        entries.push(classified);
434    }
435
436    assert_no_collisions(&entries)?;
437    let payload: Vec<PayloadEntry> = entries.iter().map(ArchiveEntry::as_payload_entry).collect();
438    if let Some(path) = find_unresolvable_link(&payload) {
439        fail!("Archive link does not resolve to a file inside the payload: {path}");
440    }
441    if let Some(path) = find_entry_through_link(&payload) {
442        fail!("Archive entry would be written through a link: {path}");
443    }
444    Ok(entries)
445}
446
447/// Reads one small metadata entry without extracting the surrounding archive.
448///
449/// # Errors
450///
451/// When the entry is missing, is not a regular file, or is larger than a metadata entry may be.
452pub fn read_zip_entry(path: &Path, wanted: &str, maximum_bytes: u64) -> Result<Vec<u8>> {
453    let safe = safe_relative_path(wanted)?;
454    let mut archive = open(path)?;
455    for index in 0..archive.len() {
456        let (name, encrypted, mode, size) = {
457            let entry = archive
458                .by_index_raw(index)
459                .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?;
460            (
461                entry.name().to_string(),
462                entry.encrypted(),
463                entry.unix_mode(),
464                entry.size(),
465            )
466        };
467        let classified = classify(&name, encrypted, mode, size)?;
468        if classified.path != safe || classified.kind != EntryKind::File {
469            continue;
470        }
471        if classified.size > maximum_bytes {
472            fail!("ZIP entry is too large to read as metadata: {safe}");
473        }
474        let mut bytes = Vec::new();
475        archive
476            .by_index(index)
477            .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?
478            .take(maximum_bytes + 1)
479            .read_to_end(&mut bytes)
480            .map_err(|error| Error::new(format!("cannot read {safe}: {error}")))?;
481        if bytes.len() as u64 > maximum_bytes {
482            fail!("ZIP entry is too large to read as metadata: {safe}");
483        }
484        return Ok(bytes);
485    }
486    fail!("ZIP archive does not contain {safe}")
487}
488
489/// Reads one small metadata entry as UTF-8 text.
490///
491/// # Errors
492///
493/// See [`read_zip_entry`]; additionally when the bytes are not valid UTF-8.
494pub fn read_zip_entry_text(path: &Path, wanted: &str) -> Result<String> {
495    let bytes = read_zip_entry(path, wanted, MAX_METADATA_BYTES)?;
496    String::from_utf8(bytes).map_err(|_| Error::new(format!("Invalid UTF-8 in {wanted}.")))
497}
498
499/// Extracts a prevalidated archive.
500///
501/// # Errors
502///
503/// When validation fails, or when the destination cannot be written.
504pub fn extract_zip_archive(archive_path: &Path, destination: &Path) -> Result<()> {
505    // Validated in full first, and the targets returned here are the only ones written below:
506    // reading a link target twice would let a concurrently rewritten archive pass the check with one
507    // value and extract with another.
508    let validated = list_zip_entries(archive_path)?;
509    let link_targets: HashMap<&str, &str> = validated
510        .iter()
511        .filter(|entry| entry.kind == EntryKind::Link)
512        .filter_map(|entry| Some((entry.path.as_str(), entry.link_target.as_deref()?)))
513        .collect();
514
515    std::fs::create_dir_all(destination)?;
516    let mut archive = open(archive_path)?;
517    for (index, entry) in validated.iter().enumerate() {
518        let output = join_relative(destination, &entry.path);
519        match entry.kind {
520            EntryKind::Directory => {
521                std::fs::create_dir_all(&output)?;
522                continue;
523            }
524            EntryKind::Link => {
525                if let Some(parent) = output.parent() {
526                    std::fs::create_dir_all(parent)?;
527                }
528                // Written as the relative string it was validated as, never as a resolved absolute
529                // path: the link must mean the same thing wherever the box is extracted.
530                let target = link_targets.get(entry.path.as_str()).copied().unwrap_or("");
531                create_symlink(target, &output)?;
532                continue;
533            }
534            EntryKind::File => {}
535        }
536        if let Some(parent) = output.parent() {
537            std::fs::create_dir_all(parent)?;
538        }
539        // `create_new` rather than `create`: an entry must never land on a path another entry
540        // already produced, and the filesystem is the last place that can still say so.
541        let mut file = new_file(&output, entry.mode)?;
542        let mut source = archive
543            .by_index(index)
544            .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?;
545        std::io::copy(&mut source, &mut file)
546            .map_err(|error| Error::new(format!("cannot write {}: {error}", output.display())))?;
547    }
548
549    // The archive said what should be written; this asks what actually is.
550    validate_extracted_tree(destination, true)
551}
552
553#[cfg(unix)]
554fn new_file(path: &Path, mode: u32) -> Result<std::fs::File> {
555    use std::os::unix::fs::OpenOptionsExt as _;
556    std::fs::OpenOptions::new()
557        .write(true)
558        .create_new(true)
559        .mode(if mode == 0 { 0o644 } else { mode })
560        .open(path)
561        .map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))
562}
563
564#[cfg(not(unix))]
565fn new_file(path: &Path, _mode: u32) -> Result<std::fs::File> {
566    // Windows extraction restores no mode, which is also why the payload digest does not record one.
567    std::fs::OpenOptions::new()
568        .write(true)
569        .create_new(true)
570        .open(path)
571        .map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))
572}
573
574#[cfg(unix)]
575fn create_symlink(target: &str, path: &Path) -> Result<()> {
576    std::os::unix::fs::symlink(target, path)
577        .map_err(|error| Error::new(format!("cannot write link {}: {error}", path.display())))
578}
579
580#[cfg(not(unix))]
581fn create_symlink(target: &str, path: &Path) -> Result<()> {
582    // A Windows box carries no links at all, so reaching this means an archive built for another
583    // target. Creating one needs Developer Mode or elevation, and failing here is the honest answer.
584    std::os::windows::fs::symlink_file(target, path)
585        .map_err(|error| Error::new(format!("cannot write link {}: {error}", path.display())))
586}
587
588#[cfg(test)]
589mod tests {
590    use super::{assert_no_collisions, classify, ArchiveEntry};
591    use crate::contract::links::EntryKind;
592
593    fn entry(path: &str, kind: EntryKind) -> ArchiveEntry {
594        ArchiveEntry {
595            path: path.to_string(),
596            kind,
597            size: 0,
598            mode: 0o644,
599            link_target: None,
600        }
601    }
602
603    // The classifier is exercised directly rather than through crafted archives, because the ZIP
604    // writer cannot emit the two cases that matter most: it refuses to encrypt without the AES
605    // feature, and `unix_permissions` masks off exactly the type bits that make an entry special.
606    // Testing through a writer that cannot express the hostile input would prove nothing.
607
608    #[test]
609    fn an_encrypted_entry_is_refused_before_anything_else() {
610        let error = classify("box.json", true, Some(0o100_644), 10).unwrap_err();
611        assert!(error.message().contains("Encrypted ZIP entries"), "{error}");
612    }
613
614    #[test]
615    fn special_entries_are_refused_by_their_type_bits() {
616        for (name, mode) in [
617            ("fifo", 0o010_000),
618            ("device", 0o020_000),
619            ("block", 0o060_000),
620            ("socket", 0o140_000),
621        ] {
622            let error = classify(name, false, Some(mode | 0o644), 0).unwrap_err();
623            assert!(
624                error.message().contains("special entries"),
625                "{name} was accepted: {error}"
626            );
627        }
628    }
629
630    #[test]
631    fn regular_files_and_directories_are_classified_as_the_format_expects() {
632        let file = classify("box.json", false, Some(0o100_644), 12).unwrap();
633        assert_eq!(file.kind, EntryKind::File);
634        assert_eq!(file.mode, 0o644);
635
636        // A directory is named either by its trailing slash or by its type bits.
637        assert_eq!(
638            classify("venv/", false, None, 0).unwrap().kind,
639            EntryKind::Directory
640        );
641        assert_eq!(
642            classify("venv", false, Some(0o040_755), 0).unwrap().kind,
643            EntryKind::Directory
644        );
645
646        // An archive with no mode information at all still yields a usable regular file.
647        assert_eq!(
648            classify("plain.txt", false, None, 3).unwrap().kind,
649            EntryKind::File
650        );
651    }
652
653    #[test]
654    fn a_link_is_classified_but_its_target_is_not_yet_known() {
655        let link = classify("venv/bin/python", false, Some(0o120_777), 9).unwrap();
656        assert_eq!(link.kind, EntryKind::Link);
657        assert!(link.link_target.is_none());
658    }
659
660    #[test]
661    fn an_oversized_link_target_is_refused_before_it_is_read() {
662        let error = classify("venv/bin/python", false, Some(0o120_777), 4096).unwrap_err();
663        assert!(error.message().contains("link target is too long"), "{error}");
664    }
665
666    #[test]
667    fn an_entry_name_that_escapes_the_root_is_refused() {
668        for name in ["../escape", "/etc/passwd", "C:/windows", "venv/../../out"] {
669            let error = classify(name, false, Some(0o100_644), 1).unwrap_err();
670            assert!(
671                error.message().contains("Unsafe relative path"),
672                "{name} was accepted: {error}"
673            );
674        }
675    }
676
677    #[test]
678    fn colliding_entries_are_refused_in_every_shape() {
679        // The same name twice.
680        let duplicate = vec![entry("a.txt", EntryKind::File), entry("a.txt", EntryKind::File)];
681        assert!(assert_no_collisions(&duplicate).is_err());
682
683        // A file, then something written underneath it as if it were a directory.
684        let through_file = vec![entry("a", EntryKind::File), entry("a/b", EntryKind::File)];
685        assert!(assert_no_collisions(&through_file).is_err());
686
687        // The same, in the order that makes the parent appear second.
688        let after_children = vec![entry("a/b", EntryKind::File), entry("a", EntryKind::File)];
689        assert!(assert_no_collisions(&after_children).is_err());
690
691        // A legitimate tree collides with nothing.
692        let fine = vec![
693            entry("box.json", EntryKind::File),
694            entry("venv", EntryKind::Directory),
695            entry("venv/bin", EntryKind::Directory),
696            entry("venv/bin/python", EntryKind::File),
697        ];
698        assert!(assert_no_collisions(&fine).is_ok());
699    }
700}