Skip to main content

zeroclaw_config/
paths.rs

1//! Shared path helpers used by both schema-tier validation and the
2//! scoped file browser. Single source of truth for "lexically normalize a
3//! path" and "resolve a relative input under a fixed root with no escape".
4
5use std::path::{Component, Path, PathBuf};
6
7/// Resolve `.` and `..` components lexically — never touches the
8/// filesystem. Sufficient for "stays inside `<root>`" reasoning where the
9/// path may not yet exist.
10#[must_use]
11pub fn normalize_lexical(path: &Path) -> PathBuf {
12    let mut out = PathBuf::new();
13    for component in path.components() {
14        match component {
15            Component::ParentDir => {
16                out.pop();
17            }
18            Component::CurDir => {}
19            other => out.push(other.as_os_str()),
20        }
21    }
22    out
23}
24
25/// Resolve `raw` (interpreted as relative-to-root unless absolute) and
26/// assert the result stays under `root` after lexical normalization.
27/// Returns the normalized absolute path on success.
28pub fn resolve_under(root: &Path, raw: &str) -> Result<PathBuf, RootEscapeError> {
29    let trimmed = raw.trim_matches('/');
30    let candidate = if trimmed.is_empty() {
31        root.to_path_buf()
32    } else {
33        root.join(trimmed)
34    };
35    let normalized = normalize_lexical(&candidate);
36    let root_normalized = normalize_lexical(root);
37    if !normalized.starts_with(&root_normalized) {
38        return Err(RootEscapeError {
39            input: raw.to_string(),
40            root: root_normalized.display().to_string(),
41        });
42    }
43    Ok(normalized)
44}
45
46#[derive(Debug, thiserror::Error)]
47#[error("path '{input}' escapes root '{root}'")]
48pub struct RootEscapeError {
49    pub input: String,
50    pub root: String,
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    #[test]
58    fn empty_input_resolves_to_root() {
59        let root = Path::new("/tmp/install/shared");
60        assert_eq!(resolve_under(root, "").unwrap(), root);
61        assert_eq!(resolve_under(root, "/").unwrap(), root);
62    }
63
64    #[test]
65    fn relative_input_joins_under_root() {
66        let root = Path::new("/tmp/install/shared");
67        assert_eq!(
68            resolve_under(root, "skills/coding").unwrap(),
69            root.join("skills/coding"),
70        );
71    }
72
73    #[test]
74    fn dotdot_escape_is_rejected() {
75        let root = Path::new("/tmp/install/shared");
76        assert!(resolve_under(root, "../etc").is_err());
77        assert!(resolve_under(root, "skills/../../etc").is_err());
78    }
79
80    #[test]
81    fn double_slash_normalized_away() {
82        let root = Path::new("/tmp/install/shared");
83        assert_eq!(
84            resolve_under(root, "skills//coding/").unwrap(),
85            root.join("skills/coding"),
86        );
87    }
88}