Skip to main content

gitserver_core/
path.rs

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 super::*;
50    use tempfile::TempDir;
51
52    #[test]
53    fn resolve_simple_path() {
54        let root = TempDir::new().unwrap();
55        let repo_dir = root.path().join("myrepo.git");
56        std::fs::create_dir(&repo_dir).unwrap();
57
58        let resolved = resolve_repo_path(root.path(), "myrepo.git").unwrap();
59        assert_eq!(resolved, repo_dir.canonicalize().unwrap());
60    }
61
62    #[test]
63    fn resolve_nested_path() {
64        let root = TempDir::new().unwrap();
65        let repo_dir = root.path().join("org/project.git");
66        std::fs::create_dir_all(&repo_dir).unwrap();
67
68        let resolved = resolve_repo_path(root.path(), "org/project.git").unwrap();
69        assert_eq!(resolved, repo_dir.canonicalize().unwrap());
70    }
71
72    #[test]
73    fn reject_traversal() {
74        let root = TempDir::new().unwrap();
75        let err = resolve_repo_path(root.path(), "../etc/passwd").unwrap_err();
76        assert!(matches!(err, Error::PathTraversal(_)));
77    }
78
79    #[test]
80    fn reject_traversal_in_middle() {
81        let root = TempDir::new().unwrap();
82        let repo_dir = root.path().join("legit");
83        std::fs::create_dir(&repo_dir).unwrap();
84
85        let err = resolve_repo_path(root.path(), "legit/../../etc/passwd").unwrap_err();
86        assert!(matches!(err, Error::PathTraversal(_)));
87    }
88
89    #[test]
90    fn reject_nonexistent_path() {
91        let root = TempDir::new().unwrap();
92        let err = resolve_repo_path(root.path(), "nonexistent.git").unwrap_err();
93        assert!(matches!(err, Error::RepoNotFound(_)));
94    }
95}