Skip to main content

emperor_folder/
path.rs

1//! Root-relative path handling.
2//!
3//! Every path a tool receives is checked here before it reaches the filesystem: absolute paths
4//! and any `..` component are refused with a clear message. This is the friendly first line;
5//! the real confinement is [`cap_std::fs::Dir`], which refuses anything (including symlinks)
6//! that resolves outside the root even if this check were bypassed.
7
8use std::path::{Component, Path, PathBuf};
9
10use crate::error::FolderError;
11
12/// Turn a tool-supplied path into a clean path relative to the root folder.
13///
14/// `""` and `"."` mean the root itself and return an empty path. Leading `./` and repeated
15/// separators are dropped. Absolute paths (`/etc`, `C:\x`, `\\server\share`) and any `..`
16/// component are refused, even when the result would stay inside the root.
17pub fn relative(input: &str) -> Result<PathBuf, FolderError> {
18    let trimmed = input.trim();
19    if trimmed.starts_with('/') || trimmed.starts_with('\\') {
20        return Err(FolderError::Absolute(input.to_string()));
21    }
22    let mut out = PathBuf::new();
23    for component in Path::new(trimmed).components() {
24        match component {
25            Component::Normal(part) => out.push(part),
26            Component::CurDir => {}
27            Component::ParentDir => return Err(FolderError::Escape(input.to_string())),
28            Component::RootDir | Component::Prefix(_) => {
29                return Err(FolderError::Absolute(input.to_string()));
30            }
31        }
32    }
33    Ok(out)
34}
35
36/// Like [`relative`], but the root itself is refused: for tools that act on one entry
37/// (`read`, `write`, `move`).
38pub fn relative_entry(input: &str) -> Result<PathBuf, FolderError> {
39    let rel = relative(input)?;
40    if rel.as_os_str().is_empty() {
41        return Err(FolderError::Invalid(
42            "a file path is required; the root folder itself is not a file".into(),
43        ));
44    }
45    Ok(rel)
46}
47
48/// The path to hand to `cap_std::fs::Dir`: `.` for the root, the relative path otherwise.
49pub fn for_dir(rel: &Path) -> &Path {
50    if rel.as_os_str().is_empty() {
51        Path::new(".")
52    } else {
53        rel
54    }
55}
56
57/// Root-relative path as shown to the model: `/` separators on every platform.
58pub fn display(rel: &Path) -> String {
59    let parts: Vec<String> = rel
60        .components()
61        .filter_map(|c| match c {
62            Component::Normal(p) => Some(p.to_string_lossy().into_owned()),
63            _ => None,
64        })
65        .collect();
66    if parts.is_empty() {
67        ".".to_string()
68    } else {
69        parts.join("/")
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn plain_paths_pass_and_are_normalised() {
79        assert_eq!(relative("a/b.txt").unwrap(), PathBuf::from("a/b.txt"));
80        assert_eq!(relative("./a//b.txt").unwrap(), PathBuf::from("a/b.txt"));
81        assert_eq!(relative("").unwrap(), PathBuf::new());
82        assert_eq!(relative(".").unwrap(), PathBuf::new());
83        assert_eq!(relative(" notes.md ").unwrap(), PathBuf::from("notes.md"));
84    }
85
86    #[test]
87    fn absolute_paths_are_refused() {
88        for p in ["/etc/passwd", "/", "\\windows", "\\\\server\\share"] {
89            assert!(
90                matches!(relative(p), Err(FolderError::Absolute(_))),
91                "{p} must be refused"
92            );
93        }
94    }
95
96    #[test]
97    fn parent_components_are_refused_anywhere() {
98        for p in ["..", "../x", "a/../../x", "a/../b", "a/b/.."] {
99            assert!(
100                matches!(relative(p), Err(FolderError::Escape(_))),
101                "{p} must be refused"
102            );
103        }
104    }
105
106    #[test]
107    fn entry_paths_must_name_something() {
108        assert!(relative_entry(".").is_err());
109        assert!(relative_entry("").is_err());
110        assert!(relative_entry("a.txt").is_ok());
111    }
112
113    #[test]
114    fn display_uses_forward_slashes() {
115        assert_eq!(display(Path::new("a/b/c.rs")), "a/b/c.rs");
116        assert_eq!(display(Path::new("")), ".");
117        assert_eq!(for_dir(Path::new("")), Path::new("."));
118    }
119}