Skip to main content

monty_types/
virtual_path.rs

1//! Lexical POSIX normalization shared by the interpreter and filesystem hosts.
2
3use std::borrow::Cow;
4
5/// Returns an absolute POSIX path with `.` removed and `..` resolved, stopping at `/`.
6/// Relative inputs are rooted at `/`; this never reads the host cwd or follows symlinks.
7/// Already normalized paths are borrowed. Validate NUL bytes and length limits first:
8/// normalization can remove invalid components and does not provide filesystem confinement.
9#[must_use]
10pub fn normalize_virtual_path(path: &str) -> Cow<'_, str> {
11    if is_normalized(path) {
12        Cow::Borrowed(path)
13    } else {
14        let mut out = String::with_capacity(path.len());
15        for segment in path.split('/') {
16            match segment {
17                "" | "." => {}
18                ".." => out.truncate(out.rfind('/').unwrap_or(0)),
19                segment => {
20                    out.push('/');
21                    out.push_str(segment);
22                }
23            }
24        }
25        if out.is_empty() {
26            out.push('/');
27        }
28        Cow::Owned(out)
29    }
30}
31
32/// Checks a host-supplied working directory: absolute, POSIX, no NUL bytes.
33///
34/// Trailing slashes are dropped so `os.getcwd()` never reports `/data/`; the
35/// root itself stays `/`. `.` and `..` are left for the interpreter, which
36/// normalizes the directory when it adopts it. The error is the message of
37/// the `ValueError` hosts raise for it.
38///
39/// ```
40/// use monty_types::validate_cwd;
41///
42/// assert_eq!(validate_cwd("/data/").unwrap(), "/data");
43/// assert_eq!(validate_cwd("data").unwrap_err(), "cwd must be an absolute POSIX path: \"data\"");
44/// ```
45pub fn validate_cwd(cwd: &str) -> Result<String, String> {
46    if cwd.contains('\0') {
47        Err(format!("cwd must not contain NUL bytes: {cwd:?}"))
48    } else if !cwd.starts_with('/') {
49        Err(format!("cwd must be an absolute POSIX path: {cwd:?}"))
50    } else {
51        let trimmed = cwd.trim_end_matches('/');
52        Ok(if trimmed.is_empty() {
53            "/".to_owned()
54        } else {
55            trimmed.to_owned()
56        })
57    }
58}
59
60/// Recognizes paths that can be returned without allocating or rewriting them.
61fn is_normalized(path: &str) -> bool {
62    path == "/"
63        || (path.starts_with('/')
64            && !path.ends_with('/')
65            && path
66                .split('/')
67                .skip(1)
68                .all(|segment| !matches!(segment, "" | "." | "..")))
69}