Skip to main content

scrollcase_consumer/
filesystem.rs

1//! Reading a payload tree, and refusing one that is not a payload tree.
2//!
3//! Everything here walks with `symlink_metadata`, never `metadata`. Following a link while deciding
4//! what an entry *is* would let a link to a directory be walked into as a directory, which is the
5//! exact confusion the link rules exist to remove.
6
7use std::collections::BTreeSet;
8use std::path::{Path, PathBuf};
9
10use sha2::{Digest, Sha256};
11
12use crate::contract::links::{find_entry_through_link, find_unresolvable_link, EntryKind, PayloadEntry};
13use crate::error::{fail, Error, Result};
14
15/// Names a build never carries into a payload, and that an installed tree grows on its own.
16const IGNORED_NAMES: &[&str] = &["__pycache__", ".DS_Store"];
17
18/// Lowercase hex SHA-256 of a file's bytes, streamed rather than buffered.
19///
20/// # Errors
21///
22/// When the file cannot be read.
23pub fn sha256_file(path: &Path) -> Result<String> {
24    use std::io::Read as _;
25    let mut file = std::fs::File::open(path)
26        .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
27    let mut hasher = Sha256::new();
28    let mut buffer = vec![0u8; 128 * 1024];
29    loop {
30        let read = file
31            .read(&mut buffer)
32            .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
33        if read == 0 {
34            break;
35        }
36        hasher.update(&buffer[..read]);
37    }
38    let digest = hasher.finalize();
39    let mut hex = String::with_capacity(digest.len() * 2);
40    for byte in digest {
41        use std::fmt::Write as _;
42        let _ = write!(hex, "{byte:02x}");
43    }
44    Ok(hex)
45}
46
47/// Lists payload entries in the stable order hashing and archive creation use.
48///
49/// A payload may hold regular files and the narrow class of links the contract permits; anything
50/// else — a socket, a device, a fifo — is refused, because nothing that is not one of those two
51/// things can be archived, hashed or relocated meaningfully.
52///
53/// # Errors
54///
55/// When the tree cannot be read, or holds an entry that is neither a file nor a link.
56pub fn collect_entries(root: &Path) -> Result<Vec<PayloadEntry>> {
57    let mut entries = Vec::new();
58    collect_into(root, root, &mut entries)?;
59    entries.sort_by(|left, right| left.path.cmp(&right.path));
60    Ok(entries)
61}
62
63fn collect_into(root: &Path, current: &Path, entries: &mut Vec<PayloadEntry>) -> Result<()> {
64    let mut names: Vec<PathBuf> = std::fs::read_dir(current)
65        .map_err(|error| Error::new(format!("cannot read {}: {error}", current.display())))?
66        .filter_map(std::result::Result::ok)
67        .map(|entry| entry.path())
68        .collect();
69    names.sort();
70
71    for path in names {
72        let name = path
73            .file_name()
74            .and_then(std::ffi::OsStr::to_str)
75            .unwrap_or_default()
76            .to_string();
77        // Case-sensitive on purpose: CPython writes `.pyc` and nothing else, and the Node and
78        // Python collectors compare the same way. A case-insensitive match here would skip a payload
79        // file the other implementations carry.
80        #[allow(clippy::case_sensitive_file_extension_comparisons)]
81        if IGNORED_NAMES.contains(&name.as_str()) || name.ends_with(".pyc") {
82            continue;
83        }
84        let relative = relative_forward_slash(root, &path)?;
85        let metadata = std::fs::symlink_metadata(&path)
86            .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
87        // Order matters: a link to a directory reports `is_dir()` false under symlink_metadata, but
88        // classifying by directory first would still walk into one on a following stat.
89        if metadata.is_symlink() {
90            let target = std::fs::read_link(&path)
91                .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
92            entries.push(PayloadEntry::link(
93                relative,
94                target.to_string_lossy().replace('\\', "/"),
95            ));
96        } else if metadata.is_dir() {
97            collect_into(root, &path, entries)?;
98        } else if metadata.is_file() {
99            entries.push(PayloadEntry::file(relative));
100        } else {
101            fail!("box special entries are not allowed: {relative}");
102        }
103    }
104    Ok(())
105}
106
107fn relative_forward_slash(root: &Path, path: &Path) -> Result<String> {
108    let Ok(relative) = path.strip_prefix(root) else {
109        fail!("Unsafe relative path: {}", path.display());
110    };
111    Ok(relative
112        .components()
113        .map(|component| component.as_os_str().to_string_lossy())
114        .collect::<Vec<_>>()
115        .join("/"))
116}
117
118/// Every path in a payload that resolves to content: regular files and the links to them.
119///
120/// A link is included deliberately. A real box reaches its interpreter through exactly that shape —
121/// `venv/bin/python` links to the versioned binary beside it — so a check that accepted only regular
122/// files here would reject every box the builder produces on macOS and Linux. The archive side asks
123/// the same question of the same set; only reading `box.json` needs an entry with its own bytes.
124///
125/// # Errors
126///
127/// See [`collect_entries`].
128pub fn collect_files(root: &Path) -> Result<BTreeSet<String>> {
129    Ok(collect_entries(root)?
130        .into_iter()
131        .filter(|entry| entry.kind != EntryKind::Directory)
132        .map(|entry| entry.path)
133        .collect())
134}
135
136/// Logical size of a payload.
137///
138/// A link contributes the length of its target string, which is what a POSIX filesystem reports as
139/// its size and what the Node and Python consumers therefore measure. Counting it as zero would make
140/// an honest box with a linked interpreter fail its own signed `installedSizeBytes`.
141///
142/// # Errors
143///
144/// See [`collect_entries`].
145pub fn payload_size(root: &Path) -> Result<u64> {
146    let mut total = 0u64;
147    for entry in collect_entries(root)? {
148        if entry.kind == EntryKind::Directory {
149            continue;
150        }
151        let path = crate::path::join_relative(root, &entry.path);
152        let metadata = std::fs::symlink_metadata(&path)
153            .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
154        total = total.saturating_add(metadata.len());
155    }
156    Ok(total)
157}
158
159/// Re-applies the payload rules to a tree that has just been written.
160///
161/// Validating the archive before extraction says what *should* have been written; this says what is
162/// actually on disk. They are different questions, and only the second one accounts for a filesystem
163/// that resolved two entry names to one path.
164///
165/// # Errors
166///
167/// When the tree holds a special entry, or a link the contract does not permit.
168pub fn validate_extracted_tree(root: &Path, allow_links: bool) -> Result<()> {
169    let entries = collect_entries(root)?;
170    if !allow_links {
171        if let Some(link) = entries.iter().find(|entry| entry.kind == EntryKind::Link) {
172            fail!("Archive links and special entries are not allowed: {}", link.path);
173        }
174        return Ok(());
175    }
176    if let Some(path) = find_unresolvable_link(&entries) {
177        fail!("Extracted link does not resolve to a file inside the payload: {path}");
178    }
179    if let Some(path) = find_entry_through_link(&entries) {
180        fail!("Extracted entry would be written through a link: {path}");
181    }
182    Ok(())
183}