Skip to main content

git_cache_proxy/
repo.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Request-path -> (upstream URL, on-disk cache dir) resolution, hardened
3//! against path traversal.
4
5use std::path::{Path, PathBuf};
6
7use anyhow::{Result, bail};
8
9/// A resolved repository: where to fetch it from and where it is cached.
10#[derive(Debug, Clone)]
11pub struct RepoRef {
12    /// Normalised repo path, e.g. `group/team/foo.git`. Used as the cache key
13    /// and as the sub-path under the cache root.
14    pub name: String,
15    /// Full upstream clone URL.
16    pub upstream_url: String,
17    /// On-disk bare mirror directory.
18    pub cache_dir: PathBuf,
19}
20
21/// Strip a smart-HTTP endpoint suffix (`/info/refs`, `/git-upload-pack`) from a
22/// request path and return the repo name. `None` if the suffix isn't present.
23pub fn repo_name_from_path(path: &str, suffix: &str) -> Option<String> {
24    let repo = path
25        .trim_start_matches('/')
26        .strip_suffix(suffix.trim_start_matches('/'))?;
27    Some(repo.trim_end_matches('/').to_string())
28}
29
30/// Reserved suffix for the staging directory a mirror is cloned into before its
31/// atomic rename into place (see `git::clone_mirror`). `resolve` rejects any
32/// client path containing it, so a request can never resolve onto another repo's
33/// in-flight clone directory.
34pub const INCOMING_SUFFIX: &str = ".__incoming__";
35
36/// Reserved suffix for the trash directory a mirror is renamed to during eviction
37/// before its (possibly slow) removal (see `git::GitCache::evict`). Reserved for
38/// the same reason as `INCOMING_SUFFIX`: a client path must never alias a mirror
39/// mid-eviction.
40pub const EVICTING_SUFFIX: &str = ".__evicting__";
41
42/// Validate a repo path (no traversal / absolute / NUL) and resolve it against
43/// the upstream base and cache root.
44pub fn resolve(name: &str, upstream_base: &str, cache_root: &Path) -> Result<RepoRef> {
45    if name.is_empty() || name.contains('\0') || name.starts_with('/') {
46        bail!("invalid repo path: {name:?}");
47    }
48    for comp in name.split('/') {
49        if comp.is_empty() || comp == "." || comp == ".." {
50            bail!("invalid repo path component in {name:?}");
51        }
52    }
53    // Reserved: the clone staging and eviction trash dirs are siblings of the
54    // cache dir carrying these suffixes, so a client path containing one could
55    // alias an in-flight clone or a mirror mid-eviction.
56    if name.contains(INCOMING_SUFFIX) || name.contains(EVICTING_SUFFIX) {
57        bail!("invalid repo path (reserved suffix) in {name:?}");
58    }
59    let cache_dir = cache_root.join(name);
60    // Defence in depth against traversal that slipped past the component checks.
61    if !cache_dir.starts_with(cache_root) {
62        bail!("repo path escapes cache root: {name:?}");
63    }
64    let upstream_url = format!("{}/{}", upstream_base.trim_end_matches('/'), name);
65    Ok(RepoRef {
66        name: name.to_string(),
67        upstream_url,
68        cache_dir,
69    })
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    #[test]
77    fn strips_suffixes() {
78        assert_eq!(
79            repo_name_from_path("/group/team/foo.git/info/refs", "/info/refs").as_deref(),
80            Some("group/team/foo.git")
81        );
82        assert_eq!(
83            repo_name_from_path("/a/b.git/git-upload-pack", "/git-upload-pack").as_deref(),
84            Some("a/b.git")
85        );
86        assert_eq!(repo_name_from_path("/nope", "/info/refs"), None);
87    }
88
89    #[test]
90    fn rejects_traversal() {
91        let root = Path::new("/cache");
92        assert!(resolve("../etc/passwd", "https://up", root).is_err());
93        assert!(resolve("a/../../b", "https://up", root).is_err());
94        assert!(resolve("/abs", "https://up", root).is_err());
95        let ok = resolve("g/r.git", "https://up/", root).unwrap();
96        assert_eq!(ok.upstream_url, "https://up/g/r.git");
97        assert_eq!(ok.cache_dir, Path::new("/cache/g/r.git"));
98    }
99
100    #[test]
101    fn rejects_reserved_suffixes() {
102        let root = Path::new("/cache");
103        // A client must not be able to name a repo that maps onto the staging dir
104        // (`<cache_dir>.__incoming__`) or eviction trash (`<cache_dir>.__evicting__`)
105        // of another.
106        assert!(resolve(&format!("foo{INCOMING_SUFFIX}"), "https://up", root).is_err());
107        assert!(resolve(&format!("a/b{INCOMING_SUFFIX}"), "https://up", root).is_err());
108        assert!(resolve(&format!("foo{EVICTING_SUFFIX}"), "https://up", root).is_err());
109        assert!(resolve(&format!("a/b{EVICTING_SUFFIX}"), "https://up", root).is_err());
110    }
111}