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}