Skip to main content

scrollcase_consumer/contract/
links.rs

1//! Mirror of the rule deciding which symbolic links a box payload may carry.
2//!
3//! A conda prefix is dense with links: the shared-library soname convention alone stores every large
4//! library two or three times, and `bin` carries interpreter aliases. Preserving them costs nothing
5//! to store and everything to get wrong, because a link is the classic way an archive writes outside
6//! the directory it was extracted into.
7//!
8//! So the rule is deliberately narrow and purely lexical, which is what makes it provable:
9//!
10//! 1. a target is relative — never absolute, never a drive letter, never a backslash;
11//! 2. resolved against the link's own directory it stays inside the payload, so `..` is allowed
12//!    exactly as far as it cannot escape;
13//! 3. a link resolves to a *regular file*, never to a directory;
14//! 4. no entry may have a link as a path prefix, so nothing is ever written *through* a link;
15//! 5. chains terminate, within a small bound, without a cycle.
16//!
17//! Nothing here consults the filesystem, which is what lets the builder and every consumer apply one
18//! rule rather than three approximations of it. A consumer applies it to the archive **as received**:
19//! a box assembled by hand gets no benefit of the doubt.
20
21use std::collections::{HashMap, HashSet};
22
23/// How many links a single resolution may traverse before it is treated as hostile.
24///
25/// Real prefixes use one or two hops; a longer chain has no legitimate source and is the cheap way
26/// to make resolution expensive.
27pub const MAX_PAYLOAD_LINK_DEPTH: usize = 8;
28
29/// What an entry in a payload or archive is.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum EntryKind {
32    /// A regular file.
33    File,
34    /// A symbolic link, whose content is its target string.
35    Link,
36    /// A directory.
37    Directory,
38}
39
40/// One entry as the link rules see it.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct PayloadEntry {
43    /// Payload-relative path, forward slashes.
44    pub path: String,
45    /// What the entry is.
46    pub kind: EntryKind,
47    /// The raw link body, present only on a link.
48    pub link_target: Option<String>,
49}
50
51impl PayloadEntry {
52    /// A regular file entry.
53    #[must_use]
54    pub fn file(path: impl Into<String>) -> Self {
55        Self {
56            path: path.into(),
57            kind: EntryKind::File,
58            link_target: None,
59        }
60    }
61
62    /// A link entry carrying its raw target.
63    #[must_use]
64    pub fn link(path: impl Into<String>, target: impl Into<String>) -> Self {
65        Self {
66            path: path.into(),
67            kind: EntryKind::Link,
68            link_target: Some(target.into()),
69        }
70    }
71
72    /// A directory entry.
73    #[must_use]
74    pub fn directory(path: impl Into<String>) -> Self {
75        Self {
76            path: path.into(),
77            kind: EntryKind::Directory,
78            link_target: None,
79        }
80    }
81}
82
83/// Whether a raw link target is shaped like one a payload may carry, before resolving it.
84#[must_use]
85pub fn is_relative_link_target(target: &str) -> bool {
86    if target.is_empty() || target.contains('\0') || target.contains('\\') {
87        return false;
88    }
89    if target.starts_with('/') {
90        return false;
91    }
92    let mut characters = target.chars();
93    !matches!(
94        (characters.next(), characters.next()),
95        (Some(letter), Some(':')) if letter.is_ascii_alphabetic()
96    )
97}
98
99/// Resolves a link target against the link's own location, staying inside the payload.
100///
101/// Returns the resolved payload-relative path, or `None` when the link may not be carried — an
102/// absolute target, an escape through `..`, or a link onto itself.
103#[must_use]
104pub fn resolve_payload_link_target(link_path: &str, target: &str) -> Option<String> {
105    if !is_relative_link_target(target) {
106        return None;
107    }
108    let segments: Vec<&str> = link_path.split('/').collect();
109    // The link's own name is not part of the directory its target resolves against.
110    let mut stack: Vec<&str> = segments[..segments.len() - 1].to_vec();
111    if segments.last().is_none_or(|last| last.is_empty()) {
112        return None;
113    }
114    for part in target.split('/') {
115        if part.is_empty() || part == "." {
116            continue;
117        }
118        if part == ".." {
119            // Underflow means the target climbed past the payload root: exactly the escape being
120            // guarded against, and the reason this is checked per segment rather than on the result.
121            if stack.is_empty() {
122                return None;
123            }
124            stack.pop();
125            continue;
126        }
127        stack.push(part);
128    }
129    if stack.is_empty() {
130        return None;
131    }
132    let resolved = stack.join("/");
133    if resolved == link_path {
134        return None;
135    }
136    Some(resolved)
137}
138
139/// Rejects an entry set in which anything could be written through a link.
140///
141/// Returns the offending entry path, or `None` when the set is safe.
142#[must_use]
143pub fn find_entry_through_link(entries: &[PayloadEntry]) -> Option<&str> {
144    let links: HashSet<&str> = entries
145        .iter()
146        .filter(|entry| entry.kind == EntryKind::Link)
147        .map(|entry| entry.path.as_str())
148        .collect();
149    if links.is_empty() {
150        return None;
151    }
152    for entry in entries {
153        for (index, _) in entry.path.match_indices('/') {
154            if links.contains(&entry.path[..index]) {
155                return Some(&entry.path);
156            }
157        }
158    }
159    None
160}
161
162/// Follows every link in an entry set until it reaches a regular file.
163///
164/// A chain that ends anywhere else is refused: at a directory (rule 3), at nothing at all, at
165/// itself, or at more hops than a real prefix ever needs. Returns the offending link path, or `None`
166/// when every chain ends at a file.
167#[must_use]
168pub fn find_unresolvable_link(entries: &[PayloadEntry]) -> Option<&str> {
169    let by_path: HashMap<&str, &PayloadEntry> = entries
170        .iter()
171        .map(|entry| (entry.path.as_str(), entry))
172        .collect();
173    let mut directories: HashSet<&str> = HashSet::new();
174    for entry in entries {
175        if entry.kind == EntryKind::Directory {
176            directories.insert(&entry.path);
177        }
178        for (index, _) in entry.path.match_indices('/') {
179            directories.insert(&entry.path[..index]);
180        }
181    }
182
183    for entry in entries {
184        if entry.kind != EntryKind::Link {
185            continue;
186        }
187        let mut seen: HashSet<&str> = HashSet::from([entry.path.as_str()]);
188        let mut current = entry;
189        let mut depth = 0usize;
190        loop {
191            if depth >= MAX_PAYLOAD_LINK_DEPTH {
192                return Some(&entry.path);
193            }
194            let target = current.link_target.as_deref().unwrap_or("");
195            let Some(resolved) = resolve_payload_link_target(&current.path, target) else {
196                return Some(&entry.path);
197            };
198            // A directory may exist implicitly, through its children, without an entry of its own —
199            // so this has to be asked before looking the path up as an entry.
200            if directories.contains(resolved.as_str()) {
201                return Some(&entry.path);
202            }
203            let Some(next) = by_path.get(resolved.as_str()) else {
204                return Some(&entry.path);
205            };
206            match next.kind {
207                EntryKind::File => break,
208                EntryKind::Link => {
209                    if !seen.insert(next.path.as_str()) {
210                        return Some(&entry.path);
211                    }
212                    current = next;
213                }
214                EntryKind::Directory => return Some(&entry.path),
215            }
216            depth += 1;
217        }
218    }
219    None
220}
221
222/// Whether a target platform can extract a payload containing links.
223///
224/// Creating a symbolic link on Windows needs Developer Mode or elevation, so a Windows box keeps
225/// materialising every link rather than producing an archive that fails to extract on an ordinary
226/// machine.
227#[must_use]
228pub fn target_carries_links(platform: &str) -> bool {
229    platform != "windows"
230}
231
232#[cfg(test)]
233mod tests {
234    use super::{
235        find_entry_through_link, find_unresolvable_link, is_relative_link_target,
236        resolve_payload_link_target, target_carries_links, PayloadEntry, MAX_PAYLOAD_LINK_DEPTH,
237    };
238
239    #[test]
240    fn only_relative_targets_are_shaped_like_a_payload_link() {
241        assert!(is_relative_link_target("python3.11"));
242        assert!(is_relative_link_target("../lib/libfoo.so.1"));
243        for invalid in ["", "/usr/bin/python", "C:/windows/system32", "a\\b", "a\0b"] {
244            assert!(!is_relative_link_target(invalid), "{invalid} was accepted");
245        }
246    }
247
248    #[test]
249    fn a_target_resolves_against_the_links_own_directory() {
250        assert_eq!(
251            resolve_payload_link_target("venv/bin/python", "python3.11").as_deref(),
252            Some("venv/bin/python3.11")
253        );
254        assert_eq!(
255            resolve_payload_link_target("venv/lib/libfoo.so", "../lib64/libfoo.so.1").as_deref(),
256            Some("venv/lib64/libfoo.so.1")
257        );
258    }
259
260    #[test]
261    fn a_target_may_never_climb_past_the_payload_root() {
262        assert_eq!(resolve_payload_link_target("venv/bin/python", "../../../etc/passwd"), None);
263        assert_eq!(resolve_payload_link_target("python", "../escape"), None);
264        // A link onto itself resolves nowhere.
265        assert_eq!(resolve_payload_link_target("venv/bin/python", "python"), None);
266    }
267
268    #[test]
269    fn a_chain_that_ends_at_a_file_is_carryable() {
270        let entries = vec![
271            PayloadEntry::link("venv/bin/python", "python3"),
272            PayloadEntry::link("venv/bin/python3", "python3.11"),
273            PayloadEntry::file("venv/bin/python3.11"),
274        ];
275        assert_eq!(find_unresolvable_link(&entries), None);
276        assert_eq!(find_entry_through_link(&entries), None);
277    }
278
279    #[test]
280    fn a_chain_that_ends_anywhere_else_is_refused() {
281        // At nothing.
282        let dangling = vec![PayloadEntry::link("venv/bin/python", "python3.11")];
283        assert_eq!(find_unresolvable_link(&dangling), Some("venv/bin/python"));
284
285        // At a directory — rule 3, the one that keeps the rest small. A directory reaches this
286        // function in two shapes, and both are refused: named by an entry of its own, and existing
287        // only implicitly through its children. The two are asserted separately because the checks
288        // that catch them are different lines, and a single case would leave one of them unproven.
289        let explicit_directory = vec![
290            PayloadEntry::link("venv/lib/python3.1", "python3.11"),
291            PayloadEntry::directory("venv/lib/python3.11"),
292            PayloadEntry::file("venv/lib/python3.11/os.py"),
293        ];
294        assert_eq!(
295            find_unresolvable_link(&explicit_directory),
296            Some("venv/lib/python3.1")
297        );
298
299        let implicit_directory = vec![
300            PayloadEntry::link("venv/lib/python3.1", "python3.11"),
301            PayloadEntry::file("venv/lib/python3.11/os.py"),
302        ];
303        assert_eq!(
304            find_unresolvable_link(&implicit_directory),
305            Some("venv/lib/python3.1")
306        );
307
308        // At itself, through a cycle.
309        let cycle = vec![
310            PayloadEntry::link("a", "b"),
311            PayloadEntry::link("b", "a"),
312        ];
313        assert!(find_unresolvable_link(&cycle).is_some());
314
315        // At more hops than a real prefix ever needs.
316        let mut long: Vec<PayloadEntry> = (0..=MAX_PAYLOAD_LINK_DEPTH)
317            .map(|index| PayloadEntry::link(format!("l{index}"), format!("l{}", index + 1)))
318            .collect();
319        long.push(PayloadEntry::file(format!("l{}", MAX_PAYLOAD_LINK_DEPTH + 1)));
320        assert_eq!(find_unresolvable_link(&long), Some("l0"));
321    }
322
323    #[test]
324    fn nothing_may_be_written_through_a_link() {
325        let entries = vec![
326            PayloadEntry::link("venv/lib/python3.1", "python3.11"),
327            PayloadEntry::file("venv/lib/python3.11/os.py"),
328            PayloadEntry::file("venv/lib/python3.1/evil.py"),
329        ];
330        assert_eq!(
331            find_entry_through_link(&entries),
332            Some("venv/lib/python3.1/evil.py")
333        );
334    }
335
336    #[test]
337    fn windows_boxes_carry_no_links() {
338        assert!(target_carries_links("macos"));
339        assert!(target_carries_links("linux"));
340        assert!(!target_carries_links("windows"));
341    }
342}