anvilsign in

collin/anvil

1use std::path::{Component, Path, PathBuf};
2
3use crate::error::{Error, Result};
4
5/// Normalize a path by resolving `.` and `..` components lexically,
6/// without touching the filesystem.
7fn normalize(path: &Path) -> PathBuf {
8 let mut out = PathBuf::new();
9 for component in path.components() {
10 match component {
11 Component::ParentDir => {
12 out.pop();
13 }
14 Component::CurDir => {}
15 c => out.push(c),
16 }
17 }
18 out
19}
20
21/// Resolve a relative repo path against a root directory.
22/// Returns the canonical absolute path if it is within root.
23pub fn resolve_repo_path(root: &Path, relative: &str) -> Result<PathBuf> {
24 let candidate = root.join(relative);
25
26 let canonical_root = root
27 .canonicalize()
28 .map_err(|_| Error::RepoNotFound(relative.to_string()))?;
29
30 // Lexically normalize the candidate to detect traversal before hitting the filesystem.
31 let normalized = normalize(&canonical_root.join(relative));
32 if !normalized.starts_with(&canonical_root) {
33 return Err(Error::PathTraversal(candidate));
34 }
35
36 let canonical = candidate
37 .canonicalize()
38 .map_err(|_| Error::RepoNotFound(relative.to_string()))?;
39
40 if !canonical.starts_with(&canonical_root) {
41 return Err(Error::PathTraversal(candidate));
42 }
43
44 Ok(canonical)
45}
46
47#[cfg(test)]
48mod tests {
49 use tempfile::TempDir;
50
51 use super::*;
52
53 #[test]
54 fn resolve_simple_path() {
55 let root = TempDir::new().unwrap();
56 let repo_dir = root.path().join("myrepo.git");
57 std::fs::create_dir(&repo_dir).unwrap();
58
59 let resolved = resolve_repo_path(root.path(), "myrepo.git").unwrap();
60 assert_eq!(resolved, repo_dir.canonicalize().unwrap());
61 }
62
63 #[test]
64 fn resolve_nested_path() {
65 let root = TempDir::new().unwrap();
66 let repo_dir = root.path().join("org/project.git");
67 std::fs::create_dir_all(&repo_dir).unwrap();
68
69 let resolved = resolve_repo_path(root.path(), "org/project.git").unwrap();
70 assert_eq!(resolved, repo_dir.canonicalize().unwrap());
71 }
72
73 #[test]
74 fn reject_traversal() {
75 let root = TempDir::new().unwrap();
76 let err = resolve_repo_path(root.path(), "../etc/passwd").unwrap_err();
77 assert!(matches!(err, Error::PathTraversal(_)));
78 }
79
80 #[test]
81 fn reject_traversal_in_middle() {
82 let root = TempDir::new().unwrap();
83 let repo_dir = root.path().join("legit");
84 std::fs::create_dir(&repo_dir).unwrap();
85
86 let err = resolve_repo_path(root.path(), "legit/../../etc/passwd").unwrap_err();
87 assert!(matches!(err, Error::PathTraversal(_)));
88 }
89
90 #[test]
91 fn reject_nonexistent_path() {
92 let root = TempDir::new().unwrap();
93 let err = resolve_repo_path(root.path(), "nonexistent.git").unwrap_err();
94 assert!(matches!(err, Error::RepoNotFound(_)));
95 }
96}