1use std::path::{Component, Path, PathBuf};
18
19#[derive(Debug, thiserror::Error)]
21pub enum PathError {
22 #[error("path traversal denied: {0}")]
24 Traversal(String),
25 #[error("absolute path not allowed: {0}")]
27 AbsolutePath(String),
28 #[error("path escapes base directory: {0}")]
30 EscapesBase(String),
31 #[error("io error: {0}")]
33 Io(#[from] std::io::Error),
34}
35
36pub fn safe_resolve(base: &Path, user_path: &Path) -> Result<PathBuf, PathError> {
49 for comp in user_path.components() {
51 match comp {
52 Component::ParentDir => {
53 return Err(PathError::Traversal(user_path.display().to_string()));
54 }
55 Component::RootDir | Component::Prefix(_) => {
56 return Err(PathError::AbsolutePath(user_path.display().to_string()));
57 }
58 _ => {}
59 }
60 }
61
62 let base_canon = base.canonicalize()?;
63
64 let mut resolved = base_canon.clone();
69 let mut tail_started = false;
70 for comp in user_path.components() {
71 match comp {
72 Component::Normal(seg) => {
73 if tail_started {
74 resolved.push(seg);
75 continue;
76 }
77 let candidate = resolved.join(seg);
78 match candidate.canonicalize() {
79 Ok(c) => {
80 resolved = c;
81 }
82 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
83 resolved.push(seg);
86 tail_started = true;
87 }
88 Err(e) => return Err(PathError::Io(e)),
89 }
90 }
91 Component::CurDir => {}
93 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
95 return Err(PathError::Traversal(user_path.display().to_string()));
96 }
97 }
98 }
99
100 if !resolved.starts_with(&base_canon) {
105 return Err(PathError::EscapesBase(resolved.display().to_string()));
106 }
107
108 Ok(resolved)
109}
110
111#[cfg(test)]
112#[allow(clippy::expect_used, clippy::unwrap_used)]
113mod tests {
114 use super::*;
115 use tempfile::TempDir;
116
117 #[test]
118 fn accepts_simple_relative_path() {
119 let base = TempDir::new().unwrap();
120 std::fs::write(base.path().join("a.txt"), "x").unwrap();
121 let resolved = safe_resolve(base.path(), Path::new("a.txt")).unwrap();
122 assert_eq!(resolved, base.path().canonicalize().unwrap().join("a.txt"));
123 }
124
125 #[test]
126 fn accepts_nested_relative_path() {
127 let base = TempDir::new().unwrap();
128 std::fs::create_dir_all(base.path().join("sub")).unwrap();
129 std::fs::write(base.path().join("sub/a.txt"), "x").unwrap();
130 let resolved = safe_resolve(base.path(), Path::new("sub/a.txt")).unwrap();
131 let expected = base
132 .path()
133 .canonicalize()
134 .unwrap()
135 .join("sub")
136 .join("a.txt");
137 assert_eq!(resolved, expected);
138 }
139
140 #[test]
141 fn accepts_nonexistent_leaf_under_base() {
142 let base = TempDir::new().unwrap();
143 let resolved = safe_resolve(base.path(), Path::new("new.txt")).unwrap();
144 assert_eq!(
145 resolved,
146 base.path().canonicalize().unwrap().join("new.txt")
147 );
148 }
149
150 #[test]
151 fn accepts_nonexistent_nested_leaf() {
152 let base = TempDir::new().unwrap();
153 let resolved = safe_resolve(base.path(), Path::new("a/b/c.txt")).unwrap();
154 let expected = base
155 .path()
156 .canonicalize()
157 .unwrap()
158 .join("a")
159 .join("b")
160 .join("c.txt");
161 assert_eq!(resolved, expected);
162 }
163
164 #[test]
165 fn accepts_curdir_components() {
166 let base = TempDir::new().unwrap();
167 let resolved = safe_resolve(base.path(), Path::new("./x.txt")).unwrap();
168 assert_eq!(resolved, base.path().canonicalize().unwrap().join("x.txt"));
169 }
170
171 #[test]
172 fn rejects_parent_dir_component() {
173 let base = TempDir::new().unwrap();
174 let err = safe_resolve(base.path(), Path::new("../escape.txt")).unwrap_err();
175 assert!(matches!(err, PathError::Traversal(_)));
176 }
177
178 #[test]
179 fn rejects_embedded_parent_dir() {
180 let base = TempDir::new().unwrap();
181 let err = safe_resolve(base.path(), Path::new("a/../../etc/passwd")).unwrap_err();
182 assert!(matches!(err, PathError::Traversal(_)));
183 }
184
185 #[test]
186 fn rejects_absolute_path() {
187 let base = TempDir::new().unwrap();
188 let err = safe_resolve(base.path(), Path::new("/etc/passwd")).unwrap_err();
189 assert!(matches!(err, PathError::AbsolutePath(_)));
190 }
191
192 #[test]
193 fn rejects_missing_base() {
194 let base = TempDir::new().unwrap();
195 let missing = base.path().join("does_not_exist");
196 let err = safe_resolve(&missing, Path::new("x")).unwrap_err();
197 assert!(matches!(err, PathError::Io(_)));
198 }
199
200 #[cfg(unix)]
201 #[test]
202 fn rejects_symlink_escaping_base() {
203 let outside = TempDir::new().unwrap();
204 std::fs::write(outside.path().join("secret"), "top-secret").unwrap();
205
206 let base = TempDir::new().unwrap();
207 std::os::unix::fs::symlink(outside.path(), base.path().join("link")).unwrap();
209
210 let err = safe_resolve(base.path(), Path::new("link/secret")).unwrap_err();
211 assert!(
212 matches!(err, PathError::EscapesBase(_)),
213 "expected EscapesBase, got {err:?}"
214 );
215 }
216
217 #[cfg(unix)]
218 #[test]
219 fn accepts_symlink_pointing_inside_base() {
220 let base = TempDir::new().unwrap();
221 std::fs::create_dir_all(base.path().join("real")).unwrap();
222 std::fs::write(base.path().join("real/data"), "ok").unwrap();
223 std::os::unix::fs::symlink(base.path().join("real"), base.path().join("alias")).unwrap();
224
225 let resolved = safe_resolve(base.path(), Path::new("alias/data")).unwrap();
226 assert!(resolved.starts_with(base.path().canonicalize().unwrap()));
228 }
229
230 #[cfg(unix)]
231 #[test]
232 fn rejects_symlink_parent_with_nonexistent_leaf() {
233 let outside = TempDir::new().unwrap();
236 let base = TempDir::new().unwrap();
237 std::os::unix::fs::symlink(outside.path(), base.path().join("link")).unwrap();
238
239 let err = safe_resolve(base.path(), Path::new("link/new.txt")).unwrap_err();
240 assert!(
241 matches!(err, PathError::EscapesBase(_)),
242 "expected EscapesBase, got {err:?}"
243 );
244 }
245
246 #[test]
247 fn empty_path_resolves_to_base() {
248 let base = TempDir::new().unwrap();
249 let resolved = safe_resolve(base.path(), Path::new("")).unwrap();
250 assert_eq!(resolved, base.path().canonicalize().unwrap());
251 }
252}