1use std::path::{Component, Path, PathBuf};
6
7#[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
25pub 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}