agpm_cli/utils/
path_validation.rs1use anyhow::{Context, Result, anyhow};
7use std::path::{Component, Path, PathBuf};
8
9pub fn validate_project_path(path: &Path, project_dir: &Path) -> Result<PathBuf> {
24 let canonical = safe_canonicalize(path)?;
25 let project_canonical = safe_canonicalize(project_dir)?;
26
27 if !canonical.starts_with(&project_canonical) {
28 return Err(anyhow!("Path '{}' escapes project directory", path.display()));
29 }
30
31 Ok(canonical)
32}
33
34pub fn safe_canonicalize(path: &Path) -> Result<PathBuf> {
45 if !path.exists() {
47 if let Some(parent) = path.parent()
49 && parent.exists()
50 {
51 let canonical_parent = parent.canonicalize().with_context(|| {
52 format!("Failed to canonicalize parent of '{}'", path.display())
53 })?;
54
55 if let Some(file_name) = path.file_name() {
56 return Ok(canonical_parent.join(file_name));
57 }
58 }
59 return Err(anyhow!("Path does not exist: {}", path.display()));
60 }
61
62 path.canonicalize().with_context(|| format!("Failed to canonicalize path: {}", path.display()))
63}
64
65pub fn ensure_within_directory(path: &Path, boundary: &Path) -> Result<bool> {
74 let canonical_path = safe_canonicalize(path)?;
75 let canonical_boundary = safe_canonicalize(boundary)?;
76
77 Ok(canonical_path.starts_with(&canonical_boundary))
78}
79
80pub fn validate_no_traversal(path: &Path) -> Result<()> {
92 for component in path.components() {
93 match component {
94 Component::ParentDir => {
95 return Err(anyhow!(
96 "Path contains parent directory reference (..): {}",
97 path.display()
98 ));
99 }
100 Component::RootDir => {
103 }
106 _ => {}
107 }
108 }
109 Ok(())
110}
111
112pub fn safe_relative_path(base: &Path, target: &Path) -> Result<PathBuf> {
121 let base_canonical = safe_canonicalize(base)?;
122 let target_canonical = safe_canonicalize(target)?;
123
124 target_canonical.strip_prefix(&base_canonical).map(std::path::Path::to_path_buf).map_err(|_| {
125 anyhow!("Cannot create relative path from {} to {}", base.display(), target.display())
126 })
127}
128
129pub fn ensure_directory_exists(dir: &Path) -> Result<PathBuf> {
140 if !dir.exists() {
141 std::fs::create_dir_all(dir)
142 .with_context(|| format!("Failed to create directory: {}", dir.display()))?;
143 }
144
145 safe_canonicalize(dir)
146}
147
148pub fn validate_resource_path(
158 path: &Path,
159 resource_type: &str,
160 project_dir: &Path,
161) -> Result<PathBuf> {
162 validate_no_traversal(path)?;
164
165 let full_path = if path.is_absolute() {
167 path.to_path_buf()
168 } else {
169 project_dir.join(path)
170 };
171
172 let canonical_project = safe_canonicalize(project_dir)?;
174
175 if full_path.exists() {
176 validate_project_path(&full_path, project_dir)?;
178 } else {
179 if let Some(parent) = full_path.parent()
181 && parent.exists()
182 {
183 let canonical_parent = safe_canonicalize(parent)?;
184 if !canonical_parent.starts_with(&canonical_project) {
185 return Err(anyhow!("Path '{}' escapes project directory", full_path.display()));
186 }
187 }
188 }
189
190 if resource_type != "directory" && full_path.extension().is_none_or(|ext| ext != "md") {
192 return Err(anyhow!(
193 "Invalid {} file: expected .md extension, got {}",
194 resource_type,
195 full_path.display()
196 ));
197 }
198
199 Ok(full_path)
200}
201
202pub fn sanitize_file_name(name: &str) -> String {
210 name.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.').collect()
211}
212
213pub fn find_project_root(start_path: &Path) -> Result<PathBuf> {
223 let mut current = if start_path.is_file() {
224 start_path.parent().ok_or_else(|| anyhow!("Invalid start path"))?
225 } else {
226 start_path
227 };
228
229 loop {
230 if current.join("agpm.toml").exists() {
231 return safe_canonicalize(current);
232 }
233
234 match current.parent() {
235 Some(parent) => current = parent,
236 None => {
237 return Err(anyhow!(
238 "No agpm.toml found in any parent directory of {}",
239 start_path.display()
240 ));
241 }
242 }
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use std::fs;
250 use tempfile::tempdir;
251
252 #[test]
253 fn test_validate_no_traversal() {
254 assert!(validate_no_traversal(Path::new("foo/bar")).is_ok());
256 assert!(validate_no_traversal(Path::new("/absolute/path")).is_ok());
257 assert!(validate_no_traversal(Path::new("./relative")).is_ok());
258
259 assert!(validate_no_traversal(Path::new("../parent")).is_err());
261 assert!(validate_no_traversal(Path::new("foo/../bar")).is_err());
262 assert!(validate_no_traversal(Path::new("../../escape")).is_err());
263 }
264
265 #[test]
266 fn test_sanitize_file_name() {
267 assert_eq!(sanitize_file_name("valid-name_123.md"), "valid-name_123.md");
268 assert_eq!(sanitize_file_name("bad/\\name<>:|?*"), "badname");
269 assert_eq!(sanitize_file_name("spaces are removed"), "spacesareremoved");
270 }
271
272 #[test]
273 fn test_validate_project_path() -> Result<()> {
274 let temp_dir = tempdir()?;
275 let project_dir = temp_dir.path();
276
277 let test_file = project_dir.join("test.md");
279 fs::write(&test_file, "test")?;
280
281 let result = validate_project_path(&test_file, project_dir)?;
283 let canonical_project = project_dir.canonicalize()?;
284 assert!(result.starts_with(&canonical_project));
285
286 let outside_path = temp_dir.path().parent().unwrap().join("outside.md");
288 assert!(validate_project_path(&outside_path, project_dir).is_err());
289
290 Ok(())
291 }
292
293 #[test]
294 fn test_ensure_directory_exists() -> Result<()> {
295 let temp_dir = tempdir()?;
296 let new_dir = temp_dir.path().join("new").join("nested").join("dir");
297
298 assert!(!new_dir.exists());
299
300 let result = ensure_directory_exists(&new_dir)?;
301 assert!(result.exists());
302 assert!(result.is_dir());
303
304 Ok(())
305 }
306
307 #[test]
308 fn test_find_project_root() -> Result<()> {
309 let temp_dir = tempdir()?;
310 let project_dir = temp_dir.path();
311
312 fs::write(project_dir.join("agpm.toml"), "[project]")?;
314
315 let nested = project_dir.join("src").join("nested");
317 fs::create_dir_all(&nested)?;
318
319 let found = find_project_root(&nested)?;
321 assert_eq!(found, project_dir.canonicalize()?);
322
323 let file_path = nested.join("file.rs");
325 fs::write(&file_path, "// test")?;
326 let found = find_project_root(&file_path)?;
327 assert_eq!(found, project_dir.canonicalize()?);
328
329 Ok(())
330 }
331
332 #[test]
333 fn test_safe_relative_path() -> Result<()> {
334 let temp_dir = tempdir()?;
335 let base = temp_dir.path();
336 let target = base.join("subdir").join("file.md");
337
338 fs::create_dir_all(target.parent().unwrap())?;
340 fs::write(&target, "test")?;
341
342 let relative = safe_relative_path(base, &target)?;
343 assert_eq!(relative, Path::new("subdir").join("file.md"));
344
345 Ok(())
346 }
347
348 #[test]
349 fn test_validate_resource_path() -> Result<()> {
350 let temp_dir = tempdir()?;
351 let project_dir = temp_dir.path();
352
353 let agent_path = Path::new("agents/my-agent.md");
355 validate_resource_path(agent_path, "agent", project_dir)?;
356
357 let wrong_ext = Path::new("agents/my-agent.txt");
359 let result = validate_resource_path(wrong_ext, "agent", project_dir);
360 assert!(result.is_err());
361
362 let traversal = Path::new("../outside/agent.md");
364 let result = validate_resource_path(traversal, "agent", project_dir);
365 assert!(result.is_err());
366
367 Ok(())
368 }
369}