Skip to main content

sapphire_framework_sync/
paths.rs

1//! Workspace-relative paths.
2
3use std::path::{Component, Path, PathBuf};
4
5/// Whether the local filesystem is assumed case-insensitive.
6pub const CASE_INSENSITIVE_FS: bool = cfg!(any(windows, target_os = "macos"));
7
8/// Join a POSIX workspace-relative path onto `root`.
9pub fn to_native(root: &Path, rel: &str) -> PathBuf {
10    let mut out = root.to_path_buf();
11    for segment in rel.split('/') {
12        out.push(segment);
13    }
14    out
15}
16
17/// The POSIX workspace-relative form of `abs`.
18pub fn rel_from_native(root: &Path, abs: &Path) -> Option<String> {
19    let rel = abs.strip_prefix(root).ok()?;
20    let mut parts = Vec::new();
21    for component in rel.components() {
22        match component {
23            Component::Normal(s) => parts.push(s.to_str()?.to_owned()),
24            _ => return None,
25        }
26    }
27    if parts.is_empty() {
28        None
29    } else {
30        Some(parts.join("/"))
31    }
32}
33
34/// A well-formed workspace-relative POSIX path that cannot escape the root.
35/// Rejects paths with drive-letter prefixes in any segment (e.g., `C:`, `x:`) to prevent
36/// escaping on Windows, where `PathBuf::push` replaces the whole path if given a drive prefix.
37pub fn is_valid_rel(rel: &str) -> bool {
38    if rel.is_empty() || rel.starts_with('/') || rel.contains('\\') {
39        return false;
40    }
41    rel.split('/').all(|seg| {
42        if seg.is_empty() || seg == "." || seg == ".." {
43            return false;
44        }
45        let bytes = seg.as_bytes();
46        if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
47            return false;
48        }
49        true
50    })
51}
52
53/// Whether this OS can hold a file at `rel`.
54pub fn representable(rel: &str) -> bool {
55    representable_on(rel, cfg!(windows))
56}
57
58/// Whether an OS (Windows when `windows`, otherwise POSIX) can hold a file at `rel`.
59pub fn representable_on(rel: &str, windows: bool) -> bool {
60    if rel.contains('\0') {
61        return false;
62    }
63    if !windows {
64        return true;
65    }
66    rel.split('/').all(|seg| {
67        !seg.chars()
68            .any(|c| matches!(c, '<' | '>' | ':' | '"' | '|' | '?' | '*') || (c as u32) < 32)
69            && !seg.ends_with('.')
70            && !seg.ends_with(' ')
71            && !is_reserved_windows_name(seg)
72    })
73}
74
75fn is_reserved_windows_name(segment: &str) -> bool {
76    let stem = segment
77        .split('.')
78        .next()
79        .unwrap_or(segment)
80        .to_ascii_uppercase();
81    match stem.as_str() {
82        "CON" | "PRN" | "AUX" | "NUL" => true,
83        s if s.len() == 4 && (s.starts_with("COM") || s.starts_with("LPT")) => {
84            matches!(s.as_bytes()[3], b'1'..=b'9')
85        }
86        _ => false,
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn native_round_trip() {
96        let root = Path::new("root");
97        let abs = to_native(root, "a/b/c.txt");
98        assert_eq!(abs, Path::new("root").join("a").join("b").join("c.txt"));
99        assert_eq!(rel_from_native(root, &abs).as_deref(), Some("a/b/c.txt"));
100        assert_eq!(rel_from_native(root, root), None);
101        assert_eq!(rel_from_native(root, Path::new("elsewhere/x")), None);
102    }
103
104    #[test]
105    fn validity() {
106        for ok in ["a", "a/b.md", "2026-08-25T10:00.md", ".app/x"] {
107            assert!(is_valid_rel(ok), "{ok}");
108        }
109        for bad in [
110            "", "/a", "a\\b", "C:/x", "c:x", "a//b", "./a", "a/../b", "a/.", "a/C:/b", "a/c:x",
111            "dir/Z:",
112        ] {
113            assert!(!is_valid_rel(bad), "{bad}");
114        }
115    }
116
117    #[test]
118    fn windows_representability() {
119        assert!(representable_on("a/b.txt", true));
120        for bad in [
121            "a:b.txt",
122            "x/what?.md",
123            "trailing.",
124            "space ",
125            "CON",
126            "con.txt",
127            "dir/LPT1.log",
128            "a\u{1}b",
129        ] {
130            assert!(!representable_on(bad, true), "{bad}");
131            assert!(
132                representable_on(bad, false) || bad.contains('\u{0}'),
133                "{bad} is fine elsewhere"
134            );
135        }
136        assert!(representable_on("COM0", true), "COM0 is not reserved");
137        assert!(representable_on("CONSOLE.txt", true));
138        assert!(!representable_on("nul\u{0}", false));
139    }
140}