1use std::io::ErrorKind;
4use std::path::{Component, Path, PathBuf};
5
6use crate::{Host, PathPolicy};
7
8#[derive(Debug, thiserror::Error)]
10pub enum PathError {
11 #[error("path escapes the workspace root: {0}")]
13 Escape(String),
14 #[error("workspace root is invalid: {0}")]
16 InvalidRoot(String),
17 #[error("io error resolving {path}: {source}")]
19 Io {
20 path: String,
22 source: std::io::Error,
24 },
25}
26
27impl Host {
28 pub async fn resolve_in_jail(
48 &self,
49 cwd: &Path,
50 candidate: &Path,
51 ) -> Result<PathBuf, PathError> {
52 let candidate = expand_home_prefix(candidate, home_dir().as_deref());
53 let absolute = if candidate.is_absolute() {
54 candidate.to_path_buf()
55 } else {
56 cwd.join(&*candidate)
57 };
58 let normalized = normalize_lexical(&absolute);
59
60 if self.path_policy == PathPolicy::Unrestricted {
61 return Ok(normalized);
62 }
63
64 if !normalized.starts_with(&self.workspace_root) {
66 return Err(PathError::Escape(normalized.display().to_string()));
67 }
68 let canonical_ancestor =
72 canonicalize_existing_ancestor(&normalized)
73 .await
74 .map_err(|source| PathError::Io {
75 path: normalized.display().to_string(),
76 source,
77 })?;
78 if !canonical_ancestor.starts_with(&self.workspace_root) {
79 return Err(PathError::Escape(normalized.display().to_string()));
80 }
81 Ok(normalized)
82 }
83}
84
85fn home_dir() -> Option<PathBuf> {
92 std::env::var_os("HOME")
93 .filter(|h| !h.is_empty())
94 .map(PathBuf::from)
95}
96
97fn expand_home_prefix<'a>(path: &'a Path, home: Option<&Path>) -> std::borrow::Cow<'a, Path> {
105 use std::borrow::Cow;
106
107 let Some(raw) = path.to_str() else {
110 return Cow::Borrowed(path);
111 };
112 let Some(home) = home else {
113 return Cow::Borrowed(path);
114 };
115 if raw == "~" {
116 return Cow::Owned(home.to_path_buf());
117 }
118 match raw.strip_prefix("~/") {
119 Some(rest) => Cow::Owned(home.join(rest.trim_start_matches('/'))),
122 None => Cow::Borrowed(path),
123 }
124}
125
126fn normalize_lexical(path: &Path) -> PathBuf {
128 let mut out = PathBuf::new();
129 for component in path.components() {
130 match component {
131 Component::ParentDir => {
132 out.pop();
133 }
134 Component::CurDir => {}
135 other => out.push(other.as_os_str()),
136 }
137 }
138 out
139}
140
141async fn canonicalize_existing_ancestor(path: &Path) -> std::io::Result<PathBuf> {
143 let mut current = path;
144 loop {
145 match tokio::fs::canonicalize(current).await {
146 Ok(canonical) => return Ok(canonical),
147 Err(e) if e.kind() == ErrorKind::NotFound => match current.parent() {
148 Some(parent) => current = parent,
149 None => return Err(e),
150 },
151 Err(e) => return Err(e),
152 }
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::{PathPolicy, test_host};
160 use tempfile::tempdir;
161
162 #[test]
163 fn expands_bare_tilde_and_tilde_slash() {
164 let home = Path::new("/home/u");
165 let cases = [
166 ("~", "/home/u"),
167 ("~/", "/home/u"),
168 ("~/.locode/settings.json", "/home/u/.locode/settings.json"),
169 ("~//nested", "/home/u/nested"),
170 ];
171 for (raw, want) in cases {
172 let got = expand_home_prefix(Path::new(raw), Some(home));
173 assert_eq!(normalize_lexical(&got), Path::new(want), "{raw}");
174 }
175 }
176
177 #[test]
178 fn leaves_non_home_prefixes_alone() {
179 let home = Path::new("/home/u");
180 for raw in [
183 "~root/x",
184 "~x",
185 "a/~/b",
186 "./~",
187 "relative/path",
188 "/abs/path",
189 ] {
190 let got = expand_home_prefix(Path::new(raw), Some(home));
191 assert_eq!(got.as_ref(), Path::new(raw), "{raw} must be untouched");
192 }
193 }
194
195 #[test]
196 fn without_home_the_path_is_unchanged() {
197 let got = expand_home_prefix(Path::new("~/x"), None);
198 assert_eq!(got.as_ref(), Path::new("~/x"));
199 }
200
201 #[tokio::test]
202 async fn tilde_resolves_to_the_real_home_not_a_cwd_subdir() {
203 let Some(home) = home_dir() else {
207 return; };
209 let dir = tempdir().unwrap();
210 let host = test_host(dir.path(), PathPolicy::Unrestricted, false);
211 let cwd = host.workspace_root().to_path_buf();
212
213 let resolved = host
214 .resolve_in_jail(&cwd, Path::new("~/.locode/settings.json"))
215 .await
216 .expect("unrestricted resolve");
217 assert_eq!(resolved, home.join(".locode/settings.json"));
218 assert!(!resolved.starts_with(&cwd), "must not land under cwd");
219 }
220
221 #[tokio::test]
222 async fn jailed_tilde_escape_names_the_expanded_path() {
223 let Some(home) = home_dir() else {
226 return;
227 };
228 let dir = tempdir().unwrap();
229 let host = test_host(dir.path(), PathPolicy::Jailed, false);
230 let cwd = host.workspace_root().to_path_buf();
231
232 let err = host
233 .resolve_in_jail(&cwd, Path::new("~/.locode/settings.json"))
234 .await
235 .expect_err("home is outside the workspace root");
236 match err {
237 PathError::Escape(shown) => {
238 assert!(shown.starts_with(&home.display().to_string()), "{shown}");
239 assert!(
240 !shown.contains('~'),
241 "the literal tilde must be gone: {shown}"
242 );
243 }
244 other => panic!("expected Escape, got {other:?}"),
245 }
246 }
247
248 #[tokio::test]
249 async fn rejects_parent_and_absolute_escapes() {
250 let dir = tempdir().unwrap();
251 let host = test_host(dir.path(), PathPolicy::Jailed, false);
252 let root = host.workspace_root().to_path_buf();
253
254 for bad in ["../etc/passwd", "/etc/passwd", "a/../../b"] {
255 let err = host
256 .resolve_in_jail(&root, Path::new(bad))
257 .await
258 .expect_err(bad);
259 assert!(matches!(err, PathError::Escape(_)), "{bad} should escape");
260 }
261 }
262
263 #[tokio::test]
264 async fn allows_paths_in_jail_including_nonexistent_leaf() {
265 let dir = tempdir().unwrap();
266 let host = test_host(dir.path(), PathPolicy::Jailed, false);
267 let root = host.workspace_root().to_path_buf();
268
269 let resolved = host
270 .resolve_in_jail(&root, Path::new("newdir/new.txt"))
271 .await
272 .expect("nonexistent leaf under root is allowed");
273 assert!(resolved.starts_with(&root));
274
275 let sub = root.join("sub");
277 let resolved = host
278 .resolve_in_jail(&sub, Path::new("f.txt"))
279 .await
280 .expect("relative under cwd");
281 assert_eq!(resolved, sub.join("f.txt"));
282 }
283
284 #[cfg(unix)]
285 #[cfg(unix)]
287 #[tokio::test]
288 async fn rejects_symlink_escape() {
289 let dir = tempdir().unwrap();
290 let outside = tempdir().unwrap();
291 let host = test_host(dir.path(), PathPolicy::Jailed, false);
292 let root = host.workspace_root().to_path_buf();
293
294 std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap();
296 let err = host
297 .resolve_in_jail(&root, Path::new("link/secret.txt"))
298 .await
299 .expect_err("symlink escape");
300 assert!(matches!(err, PathError::Escape(_)));
301 }
302
303 #[cfg(unix)]
305 #[tokio::test]
306 async fn unrestricted_allows_escapes() {
307 let dir = tempdir().unwrap();
308 let host = test_host(dir.path(), PathPolicy::Unrestricted, false);
309 let root = host.workspace_root().to_path_buf();
310
311 let resolved = host
312 .resolve_in_jail(&root, Path::new("/etc/hostname"))
313 .await
314 .expect("unrestricted allows absolute out-of-root");
315 assert_eq!(resolved, Path::new("/etc/hostname"));
316 }
317}