1use std::path::{Component, Path, PathBuf};
6
7pub fn resolve_under_root(root: &Path, path: &Path) -> Result<PathBuf, String> {
12 let root = absolute_root(root)?;
13 let joined = if path.is_absolute() {
14 path.to_path_buf()
15 } else {
16 root.join(path)
17 };
18
19 let normalized = normalize_lexically(&joined);
20 let resolved = resolve_existing_prefix(&normalized)?;
21
22 if !resolved.starts_with(&root) {
23 return Err(format!("Path escapes source root: {}", path.display()));
24 }
25
26 Ok(resolved)
27}
28
29fn absolute_root(root: &Path) -> Result<PathBuf, String> {
30 if let Ok(canon) = root.canonicalize() {
31 return Ok(canon);
32 }
33
34 let abs = if root.is_absolute() {
35 normalize_lexically(root)
36 } else {
37 let cwd = std::env::current_dir().map_err(|e| format!("Invalid source root: {e}"))?;
38 normalize_lexically(&cwd.join(root))
39 };
40 Ok(abs)
41}
42
43fn normalize_lexically(path: &Path) -> PathBuf {
44 let mut out = PathBuf::new();
45 for component in path.components() {
46 match component {
47 Component::Prefix(prefix) => out.push(prefix.as_os_str()),
48 Component::RootDir => out.push(component),
49 Component::CurDir => {}
50 Component::ParentDir => {
51 out.pop();
52 }
53 Component::Normal(part) => out.push(part),
54 }
55 }
56 out
57}
58
59fn resolve_existing_prefix(path: &Path) -> Result<PathBuf, String> {
61 let mut current = path.to_path_buf();
62 let mut missing = Vec::new();
63
64 while !current.as_os_str().is_empty() && !current.exists() {
65 match current.file_name() {
66 Some(name) => {
67 missing.push(name.to_os_string());
68 match current.parent() {
69 Some(parent) if parent != current => current = parent.to_path_buf(),
70 _ => break,
71 }
72 }
73 None => break,
74 }
75 }
76
77 let mut resolved = if current.exists() {
78 current
79 .canonicalize()
80 .map_err(|e| format!("Failed to resolve path: {e}"))?
81 } else {
82 current
83 };
84
85 for part in missing.into_iter().rev() {
86 resolved.push(part);
87 }
88 Ok(resolved)
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use std::fs;
95 use tempfile::TempDir;
96
97 #[test]
98 fn relative_path_stays_inside_root() {
99 let root = TempDir::new().unwrap();
100 let resolved = resolve_under_root(root.path(), Path::new("a/b.txt")).unwrap();
101 assert_eq!(
102 resolved,
103 root.path().canonicalize().unwrap().join("a/b.txt")
104 );
105 }
106
107 #[test]
108 fn parent_dir_inside_root_is_allowed() {
109 let root = TempDir::new().unwrap();
110 fs::create_dir(root.path().join("sub")).unwrap();
111 let resolved = resolve_under_root(root.path(), Path::new("sub/../kept.txt")).unwrap();
112 assert_eq!(
113 resolved,
114 root.path().canonicalize().unwrap().join("kept.txt")
115 );
116 }
117
118 #[test]
119 fn parent_dir_escape_is_rejected() {
120 let root = TempDir::new().unwrap();
121 let err = resolve_under_root(root.path(), Path::new("../escape.txt")).unwrap_err();
122 assert!(err.contains("escapes"), "{err}");
123 }
124
125 #[test]
126 fn nested_parent_escape_is_rejected() {
127 let root = TempDir::new().unwrap();
128 let err = resolve_under_root(root.path(), Path::new("sub/../../escape.txt")).unwrap_err();
129 assert!(err.contains("escapes"), "{err}");
130 }
131
132 #[test]
133 fn absolute_path_inside_root_is_allowed() {
134 let root = TempDir::new().unwrap();
135 let inside = root.path().join("inside.txt");
136 let resolved = resolve_under_root(root.path(), &inside).unwrap();
137 assert_eq!(
138 resolved,
139 root.path().canonicalize().unwrap().join("inside.txt")
140 );
141 }
142
143 #[test]
144 fn absolute_path_outside_root_is_rejected() {
145 let root = TempDir::new().unwrap();
146 let outside = TempDir::new().unwrap();
147 let err = resolve_under_root(root.path(), &outside.path().join("x.txt")).unwrap_err();
148 assert!(err.contains("escapes"), "{err}");
149 }
150
151 #[test]
152 #[cfg(unix)]
153 fn symlink_escape_is_rejected() {
154 let root = TempDir::new().unwrap();
155 let outside = TempDir::new().unwrap();
156 std::os::unix::fs::symlink(outside.path(), root.path().join("out")).unwrap();
157 let err = resolve_under_root(root.path(), Path::new("out/secret.txt")).unwrap_err();
158 assert!(err.contains("escapes"), "{err}");
159 }
160}