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(
39 &self,
40 cwd: &Path,
41 candidate: &Path,
42 ) -> Result<PathBuf, PathError> {
43 let absolute = if candidate.is_absolute() {
44 candidate.to_path_buf()
45 } else {
46 cwd.join(candidate)
47 };
48 let normalized = normalize_lexical(&absolute);
49
50 if self.path_policy == PathPolicy::Unrestricted {
51 return Ok(normalized);
52 }
53
54 if !normalized.starts_with(&self.workspace_root) {
56 return Err(PathError::Escape(normalized.display().to_string()));
57 }
58 let canonical_ancestor =
62 canonicalize_existing_ancestor(&normalized)
63 .await
64 .map_err(|source| PathError::Io {
65 path: normalized.display().to_string(),
66 source,
67 })?;
68 if !canonical_ancestor.starts_with(&self.workspace_root) {
69 return Err(PathError::Escape(normalized.display().to_string()));
70 }
71 Ok(normalized)
72 }
73}
74
75fn normalize_lexical(path: &Path) -> PathBuf {
77 let mut out = PathBuf::new();
78 for component in path.components() {
79 match component {
80 Component::ParentDir => {
81 out.pop();
82 }
83 Component::CurDir => {}
84 other => out.push(other.as_os_str()),
85 }
86 }
87 out
88}
89
90async fn canonicalize_existing_ancestor(path: &Path) -> std::io::Result<PathBuf> {
92 let mut current = path;
93 loop {
94 match tokio::fs::canonicalize(current).await {
95 Ok(canonical) => return Ok(canonical),
96 Err(e) if e.kind() == ErrorKind::NotFound => match current.parent() {
97 Some(parent) => current = parent,
98 None => return Err(e),
99 },
100 Err(e) => return Err(e),
101 }
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use crate::{PathPolicy, test_host};
109 use tempfile::tempdir;
110
111 #[tokio::test]
112 async fn rejects_parent_and_absolute_escapes() {
113 let dir = tempdir().unwrap();
114 let host = test_host(dir.path(), PathPolicy::Jailed, false);
115 let root = host.workspace_root().to_path_buf();
116
117 for bad in ["../etc/passwd", "/etc/passwd", "a/../../b"] {
118 let err = host
119 .resolve_in_jail(&root, Path::new(bad))
120 .await
121 .expect_err(bad);
122 assert!(matches!(err, PathError::Escape(_)), "{bad} should escape");
123 }
124 }
125
126 #[tokio::test]
127 async fn allows_paths_in_jail_including_nonexistent_leaf() {
128 let dir = tempdir().unwrap();
129 let host = test_host(dir.path(), PathPolicy::Jailed, false);
130 let root = host.workspace_root().to_path_buf();
131
132 let resolved = host
133 .resolve_in_jail(&root, Path::new("newdir/new.txt"))
134 .await
135 .expect("nonexistent leaf under root is allowed");
136 assert!(resolved.starts_with(&root));
137
138 let sub = root.join("sub");
140 let resolved = host
141 .resolve_in_jail(&sub, Path::new("f.txt"))
142 .await
143 .expect("relative under cwd");
144 assert_eq!(resolved, sub.join("f.txt"));
145 }
146
147 #[cfg(unix)]
148 #[tokio::test]
149 async fn rejects_symlink_escape() {
150 let dir = tempdir().unwrap();
151 let outside = tempdir().unwrap();
152 let host = test_host(dir.path(), PathPolicy::Jailed, false);
153 let root = host.workspace_root().to_path_buf();
154
155 std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap();
157 let err = host
158 .resolve_in_jail(&root, Path::new("link/secret.txt"))
159 .await
160 .expect_err("symlink escape");
161 assert!(matches!(err, PathError::Escape(_)));
162 }
163
164 #[tokio::test]
165 async fn unrestricted_allows_escapes() {
166 let dir = tempdir().unwrap();
167 let host = test_host(dir.path(), PathPolicy::Unrestricted, false);
168 let root = host.workspace_root().to_path_buf();
169
170 let resolved = host
171 .resolve_in_jail(&root, Path::new("/etc/hostname"))
172 .await
173 .expect("unrestricted allows absolute out-of-root");
174 assert_eq!(resolved, Path::new("/etc/hostname"));
175 }
176}