llm_coding_tools_core/path/
allowed.rs1use super::PathResolver;
4use crate::error::{ToolError, ToolResult};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8#[derive(Debug, Clone)]
34pub struct AllowedPathResolver {
35 allowed_paths: Arc<[PathBuf]>,
37}
38
39impl AllowedPathResolver {
40 pub fn new(allowed_paths: impl IntoIterator<Item = impl AsRef<Path>>) -> ToolResult<Self> {
46 let canonicalized: Result<Arc<[PathBuf]>, _> = allowed_paths
47 .into_iter()
48 .map(|p| {
49 let path = p.as_ref();
50 path.canonicalize().map_err(|e| {
51 ToolError::InvalidPath(format!(
52 "failed to canonicalize allowed path '{}': {}",
53 path.display(),
54 e
55 ))
56 })
57 })
58 .collect();
59
60 Ok(Self {
61 allowed_paths: canonicalized?,
62 })
63 }
64
65 pub fn from_canonical(allowed_paths: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
75 Self {
76 allowed_paths: allowed_paths
77 .into_iter()
78 .map(|p| p.as_ref().to_path_buf())
79 .collect(),
80 }
81 }
82
83 pub fn allowed_paths(&self) -> &[PathBuf] {
85 &self.allowed_paths
86 }
87}
88
89impl PathResolver for AllowedPathResolver {
90 fn resolve(&self, path: &str) -> ToolResult<PathBuf> {
91 let input_path = PathBuf::from(path);
92
93 for base in self.allowed_paths.iter() {
95 let candidate = base.join(&input_path);
96
97 if let Ok(canonical) = candidate.canonicalize() {
99 if canonical.starts_with(base) {
101 return Ok(canonical);
102 }
103 continue;
105 }
106
107 if let Some(parent) = candidate.parent() {
109 if let Ok(canonical_parent) = parent.canonicalize() {
110 if canonical_parent.starts_with(base) {
111 let file_name = candidate.file_name().ok_or_else(|| {
113 ToolError::InvalidPath("path has no file name".into())
114 })?;
115 return Ok(canonical_parent.join(file_name));
116 }
117 }
118 }
119 }
120
121 Err(ToolError::InvalidPath(format!(
122 "path '{}' is not within allowed directories",
123 path
124 )))
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use std::fs;
132 use tempfile::TempDir;
133
134 fn setup_test_dir() -> TempDir {
135 let dir = TempDir::new().unwrap();
136 fs::create_dir_all(dir.path().join("subdir")).unwrap();
137 fs::write(dir.path().join("file.txt"), "content").unwrap();
138 fs::write(dir.path().join("subdir/nested.txt"), "nested").unwrap();
139 dir
140 }
141
142 #[test]
143 fn resolves_relative_path_in_allowed_dir() {
144 let dir = setup_test_dir();
145 let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap();
146
147 let result = resolver.resolve("file.txt");
148 assert!(result.is_ok());
149 assert!(result.unwrap().ends_with("file.txt"));
150 }
151
152 #[test]
153 fn resolves_nested_path() {
154 let dir = setup_test_dir();
155 let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap();
156
157 let result = resolver.resolve("subdir/nested.txt");
158 assert!(result.is_ok());
159 }
160
161 #[test]
162 fn rejects_path_traversal() {
163 let dir = setup_test_dir();
164 let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap();
165
166 let result = resolver.resolve("../../../etc/passwd");
167 assert!(result.is_err());
168 assert!(result
169 .unwrap_err()
170 .to_string()
171 .contains("not within allowed"));
172 }
173
174 #[test]
175 fn allows_non_existent_path_for_write() {
176 let dir = setup_test_dir();
177 let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap();
178
179 let result = resolver.resolve("new_file.txt");
180 assert!(result.is_ok());
181 }
182
183 #[test]
184 fn allows_nested_non_existent_path() {
185 let dir = setup_test_dir();
186 let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap();
187
188 let result = resolver.resolve("subdir/new_file.txt");
189 assert!(result.is_ok());
190 }
191
192 #[test]
193 fn rejects_non_existent_path_outside_allowed() {
194 let dir = setup_test_dir();
195 let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap();
196
197 let result = resolver.resolve("subdir/../../../new_file.txt");
199 assert!(result.is_err());
200 }
201
202 #[test]
203 fn tries_multiple_allowed_paths() {
204 let dir1 = setup_test_dir();
205 let dir2 = setup_test_dir();
206 fs::write(dir2.path().join("only_in_dir2.txt"), "content").unwrap();
207
208 let resolver =
209 AllowedPathResolver::new(vec![dir1.path().to_path_buf(), dir2.path().to_path_buf()])
210 .unwrap();
211
212 let result = resolver.resolve("only_in_dir2.txt");
214 assert!(result.is_ok());
215 }
216
217 #[test]
218 fn returns_canonical_path() {
219 let dir = setup_test_dir();
220 let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap();
221
222 let result = resolver.resolve("subdir/../file.txt");
223 assert!(result.is_ok());
224 let resolved = result.unwrap();
226 assert!(!resolved.to_string_lossy().contains(".."));
227 }
228}