Skip to main content

perspt_core/
path.rs

1//! Canonical path resolution for artifact paths.
2//!
3//! Provides a single normalization function that all path consumers share:
4//! bundle validation, ownership manifest lookups, sandbox copy, policy checks,
5//! and commit reconciliation.  This ensures that `src/main.rs`, `./src/main.rs`,
6//! `src/../src/main.rs`, and `src/./main.rs` all resolve to the same identity.
7//!
8//! Paths are always workspace-relative.  Absolute paths and traversals that
9//! escape the workspace root are rejected.
10
11use std::path::{Component, PathBuf};
12
13/// Errors returned by path normalization.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum PathError {
16    /// The path is empty after normalization.
17    Empty,
18    /// The path is absolute (starts with `/` or a drive letter).
19    Absolute(String),
20    /// The path escapes the workspace root via `..` traversal.
21    Escapes(String),
22    /// The path contains a null byte or other invalid component.
23    Invalid(String),
24}
25
26impl std::fmt::Display for PathError {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            PathError::Empty => write!(f, "path is empty"),
30            PathError::Absolute(p) => write!(f, "path is absolute: '{}'", p),
31            PathError::Escapes(p) => write!(f, "path escapes workspace root: '{}'", p),
32            PathError::Invalid(p) => write!(f, "path contains invalid components: '{}'", p),
33        }
34    }
35}
36
37impl std::error::Error for PathError {}
38
39/// Normalize a workspace-relative artifact path to its canonical form.
40///
41/// Resolves `.` and `..` components, strips redundant separators, and
42/// converts backslashes to forward slashes.  The result is a clean
43/// relative path suitable for use as a map key and file identity.
44///
45/// # Errors
46///
47/// Returns `PathError` if the path is empty, absolute, or escapes the
48/// workspace root (net `..` depth goes below zero).
49///
50/// # Examples
51///
52/// ```
53/// use perspt_core::path::normalize_artifact_path;
54///
55/// assert_eq!(normalize_artifact_path("src/main.rs").unwrap(), "src/main.rs");
56/// assert_eq!(normalize_artifact_path("./src/main.rs").unwrap(), "src/main.rs");
57/// assert_eq!(normalize_artifact_path("src/../src/main.rs").unwrap(), "src/main.rs");
58/// assert_eq!(normalize_artifact_path("src/./main.rs").unwrap(), "src/main.rs");
59/// assert!(normalize_artifact_path("../escape.rs").is_err());
60/// assert!(normalize_artifact_path("/absolute/path").is_err());
61/// ```
62pub fn normalize_artifact_path(raw: &str) -> Result<String, PathError> {
63    if raw.is_empty() {
64        return Err(PathError::Empty);
65    }
66
67    // Null bytes are never valid in paths
68    if raw.contains('\0') {
69        return Err(PathError::Invalid(raw.to_string()));
70    }
71
72    // Normalize backslashes before parsing
73    let normalized = raw.replace('\\', "/");
74    let p = std::path::Path::new(&normalized);
75
76    // Reject absolute paths early
77    if p.is_absolute() || normalized.starts_with('/') {
78        return Err(PathError::Absolute(raw.to_string()));
79    }
80
81    // Windows drive prefix check
82    let bytes = normalized.as_bytes();
83    if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
84        return Err(PathError::Absolute(raw.to_string()));
85    }
86
87    // Resolve components, tracking depth to detect escapes
88    let mut components: Vec<String> = Vec::new();
89    let mut depth: i32 = 0;
90
91    for component in p.components() {
92        match component {
93            Component::Normal(s) => {
94                let s = s.to_string_lossy().to_string();
95                components.push(s);
96                depth += 1;
97            }
98            Component::ParentDir => {
99                if depth <= 0 {
100                    return Err(PathError::Escapes(raw.to_string()));
101                }
102                components.pop();
103                depth -= 1;
104            }
105            Component::CurDir => {
106                // Skip `.` components
107            }
108            Component::RootDir | Component::Prefix(_) => {
109                return Err(PathError::Absolute(raw.to_string()));
110            }
111        }
112    }
113
114    let result: PathBuf = components.iter().collect();
115    let result_str = result.to_string_lossy().to_string();
116
117    // Normalize to forward slashes in the output
118    let result_str = result_str.replace('\\', "/");
119
120    if result_str.is_empty() {
121        return Err(PathError::Empty);
122    }
123
124    Ok(result_str)
125}
126
127/// Normalize a path for use as a map key (ownership manifest, bundle dedup).
128///
129/// Thin wrapper around `normalize_artifact_path` that returns `None` on
130/// error instead of `Err`.  Callers that want diagnostics should use
131/// `normalize_artifact_path` directly.
132pub fn normalize_path_key(raw: &str) -> Option<String> {
133    normalize_artifact_path(raw).ok()
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_simple_relative_path() {
142        assert_eq!(
143            normalize_artifact_path("src/main.rs").unwrap(),
144            "src/main.rs"
145        );
146    }
147
148    #[test]
149    fn test_dot_prefix_stripped() {
150        assert_eq!(
151            normalize_artifact_path("./src/main.rs").unwrap(),
152            "src/main.rs"
153        );
154    }
155
156    #[test]
157    fn test_redundant_parent_resolved() {
158        assert_eq!(
159            normalize_artifact_path("src/../src/main.rs").unwrap(),
160            "src/main.rs"
161        );
162    }
163
164    #[test]
165    fn test_dot_in_middle_stripped() {
166        assert_eq!(
167            normalize_artifact_path("src/./main.rs").unwrap(),
168            "src/main.rs"
169        );
170    }
171
172    #[test]
173    fn test_multiple_slashes_normalized() {
174        assert_eq!(
175            normalize_artifact_path("src///main.rs").unwrap(),
176            "src/main.rs"
177        );
178    }
179
180    #[test]
181    fn test_backslash_normalized() {
182        assert_eq!(
183            normalize_artifact_path("src\\lib\\mod.rs").unwrap(),
184            "src/lib/mod.rs"
185        );
186    }
187
188    #[test]
189    fn test_trailing_slash_preserved_as_dir() {
190        // A trailing slash results in the directory name
191        let r = normalize_artifact_path("src/lib/").unwrap();
192        assert_eq!(r, "src/lib");
193    }
194
195    #[test]
196    fn test_empty_path_rejected() {
197        assert_eq!(normalize_artifact_path(""), Err(PathError::Empty));
198    }
199
200    #[test]
201    fn test_absolute_unix_rejected() {
202        assert!(matches!(
203            normalize_artifact_path("/etc/passwd"),
204            Err(PathError::Absolute(_))
205        ));
206    }
207
208    #[test]
209    fn test_absolute_windows_rejected() {
210        assert!(matches!(
211            normalize_artifact_path("C:\\Windows\\file.txt"),
212            Err(PathError::Absolute(_))
213        ));
214    }
215
216    #[test]
217    fn test_escape_via_dotdot_rejected() {
218        assert!(matches!(
219            normalize_artifact_path("../escape.rs"),
220            Err(PathError::Escapes(_))
221        ));
222    }
223
224    #[test]
225    fn test_deep_escape_rejected() {
226        assert!(matches!(
227            normalize_artifact_path("a/b/../../../../escape"),
228            Err(PathError::Escapes(_))
229        ));
230    }
231
232    #[test]
233    fn test_dotdot_that_stays_inside() {
234        assert_eq!(
235            normalize_artifact_path("a/b/../c/file.rs").unwrap(),
236            "a/c/file.rs"
237        );
238    }
239
240    #[test]
241    fn test_null_byte_rejected() {
242        assert!(matches!(
243            normalize_artifact_path("src/\0bad.rs"),
244            Err(PathError::Invalid(_))
245        ));
246    }
247
248    #[test]
249    fn test_just_dot_is_empty() {
250        assert_eq!(normalize_artifact_path("."), Err(PathError::Empty));
251    }
252
253    #[test]
254    fn test_normalize_path_key_returns_none_on_error() {
255        assert!(normalize_path_key("").is_none());
256        assert!(normalize_path_key("/abs").is_none());
257        assert!(normalize_path_key("../escape").is_none());
258    }
259
260    #[test]
261    fn test_normalize_path_key_returns_some_on_success() {
262        assert_eq!(
263            normalize_path_key("./src/main.rs"),
264            Some("src/main.rs".into())
265        );
266    }
267}