Skip to main content

scrollcase_consumer/
path.rs

1//! The path rule every payload-supplied name is screened by.
2//!
3//! Manifest and archive paths are joined onto a caller's directory, so a name that escapes it writes
4//! wherever it likes. The check is purely lexical and consults no filesystem, which is what lets the
5//! builder and every consumer apply one rule rather than three approximations: a backslash is folded
6//! to a separator first so a Windows-shaped name cannot smuggle a segment past the segment checks,
7//! and then nothing absolute, nothing with a drive letter, no `..`, no empty segment and no NUL
8//! survives.
9
10use crate::error::{fail, Result};
11
12/// Normalises a payload-relative path and refuses anything that could escape its root.
13///
14/// Returns the forward-slash form used everywhere in the format.
15///
16/// # Errors
17///
18/// When the value is empty, absolute, drive-qualified, contains a NUL, or holds a `..` or empty
19/// segment.
20pub fn safe_relative_path(value: &str) -> Result<String> {
21    let normalized = value.replace('\\', "/");
22    if normalized.is_empty() || normalized.starts_with('/') || normalized.contains('\0') {
23        fail!("Unsafe relative path: {value}");
24    }
25    if has_drive_prefix(&normalized) {
26        fail!("Unsafe relative path: {value}");
27    }
28    if normalized
29        .split('/')
30        .any(|segment| segment == ".." || segment.is_empty())
31    {
32        fail!("Unsafe relative path: {value}");
33    }
34    Ok(normalized)
35}
36
37/// Whether a normalised path starts with a `C:/`-style drive qualifier.
38fn has_drive_prefix(value: &str) -> bool {
39    let mut characters = value.chars();
40    matches!(
41        (characters.next(), characters.next(), characters.next()),
42        (Some(letter), Some(':'), Some('/')) if letter.is_ascii_alphabetic()
43    )
44}
45
46/// Joins a validated payload-relative path onto a root using the host separator.
47#[must_use]
48pub fn join_relative(root: &std::path::Path, relative: &str) -> std::path::PathBuf {
49    let mut path = root.to_path_buf();
50    for segment in relative.split('/') {
51        path.push(segment);
52    }
53    path
54}
55
56#[cfg(test)]
57mod tests {
58    use super::safe_relative_path;
59
60    #[test]
61    fn accepts_ordinary_payload_paths() {
62        assert_eq!(safe_relative_path("box.json").unwrap(), "box.json");
63        assert_eq!(
64            safe_relative_path("venv/bin/python").unwrap(),
65            "venv/bin/python"
66        );
67        // A backslash is a separator, not a name character: the segments behind it are still checked.
68        assert_eq!(
69            safe_relative_path("venv\\python.exe").unwrap(),
70            "venv/python.exe"
71        );
72    }
73
74    #[test]
75    fn refuses_every_way_out_of_the_root() {
76        for value in [
77            "",
78            "/etc/passwd",
79            "C:/windows",
80            "..",
81            "../escape",
82            "venv/../../escape",
83            "venv\\..\\escape",
84            "venv//python",
85            "venv/\0/python",
86        ] {
87            let error = safe_relative_path(value).unwrap_err();
88            assert!(
89                error.message().contains("Unsafe relative path"),
90                "{value} was accepted or misreported: {error}"
91            );
92        }
93    }
94}