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/// Reserved top-level directory under the cache root holding cached git-LFS objects
43/// (content-addressed by oid, shared across repos - see `lfs_object_path`). Reserved
44/// like the suffixes above so a client repo path can never resolve into the LFS store.
45pub const LFS_OBJECTS_DIR: &str = ".__lfs__";
46
47/// The path marker that identifies a git-LFS endpoint, `<repo>/info/lfs/objects/...`.
48const LFS_MARKER: &str = "/info/lfs/objects/";
49
50/// Repo name for the LFS batch endpoint (`<repo>/info/lfs/objects/batch`), or `None`
51/// if the path is not a batch request.
52pub fn lfs_batch_repo(path: &str) -> Option<String> {
53    repo_name_from_path(path, &format!("{LFS_MARKER}batch"))
54}
55
56/// Split an LFS object path (`<repo>/info/lfs/objects/<oid>`) into `(repo, oid)`.
57/// Any query string must be stripped by the caller. `None` if the path is not an
58/// object request or the oid is malformed.
59pub fn lfs_object_from_path(path: &str) -> Option<(String, String)> {
60    let p = path.trim_start_matches('/');
61    let idx = p.find(LFS_MARKER)?;
62    let repo = &p[..idx];
63    let oid = &p[idx + LFS_MARKER.len()..];
64    if repo.is_empty() || !valid_lfs_oid(oid) {
65        return None;
66    }
67    Some((repo.to_string(), oid.to_string()))
68}
69
70/// An LFS oid is the lowercase-hex sha256 of the object's content: 64 hex digits.
71/// Validated before use as both a filesystem path component and the cache key.
72pub fn valid_lfs_oid(oid: &str) -> bool {
73    oid.len() == 64
74        && oid
75            .bytes()
76            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
77}
78
79/// Cache key of an LFS object: its path relative to the cache root, `/`-joined so it
80/// matches the keys the eviction index uses for mirrors. Content-addressed and shared
81/// across all repos (the oid is the content hash), sharded by the first two hex chars
82/// so no single directory holds every object. Caller must pass a `valid_lfs_oid`.
83pub fn lfs_object_key(oid: &str) -> String {
84    format!("{LFS_OBJECTS_DIR}/{}/{oid}", &oid[..2])
85}
86
87/// On-disk path of a cached LFS object (`cache_root` joined with `lfs_object_key`).
88pub fn lfs_object_path(cache_root: &Path, oid: &str) -> PathBuf {
89    cache_root.join(lfs_object_key(oid))
90}
91
92/// Validate a repo path (no traversal / absolute / NUL) and resolve it against
93/// the upstream base and cache root.
94pub fn resolve(name: &str, upstream_base: &str, cache_root: &Path) -> Result<RepoRef> {
95    if name.is_empty() || name.contains('\0') || name.starts_with('/') {
96        bail!("invalid repo path: {name:?}");
97    }
98    for comp in name.split('/') {
99        if comp.is_empty() || comp == "." || comp == ".." {
100            bail!("invalid repo path component in {name:?}");
101        }
102    }
103    // Reserved: the clone staging and eviction trash dirs are siblings of the
104    // cache dir carrying these suffixes, and the LFS store is a reserved top-level
105    // dir - so a client path containing any of these could alias an in-flight clone,
106    // a mirror mid-eviction, or the LFS object store.
107    if name.contains(INCOMING_SUFFIX)
108        || name.contains(EVICTING_SUFFIX)
109        || name.contains(LFS_OBJECTS_DIR)
110    {
111        bail!("invalid repo path (reserved suffix) in {name:?}");
112    }
113    let cache_dir = cache_root.join(name);
114    // Defence in depth against traversal that slipped past the component checks.
115    if !cache_dir.starts_with(cache_root) {
116        bail!("repo path escapes cache root: {name:?}");
117    }
118    let upstream_url = format!("{}/{}", upstream_base.trim_end_matches('/'), name);
119    Ok(RepoRef {
120        name: name.to_string(),
121        upstream_url,
122        cache_dir,
123    })
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn strips_suffixes() {
132        assert_eq!(
133            repo_name_from_path("/group/team/foo.git/info/refs", "/info/refs").as_deref(),
134            Some("group/team/foo.git")
135        );
136        assert_eq!(
137            repo_name_from_path("/a/b.git/git-upload-pack", "/git-upload-pack").as_deref(),
138            Some("a/b.git")
139        );
140        assert_eq!(repo_name_from_path("/nope", "/info/refs"), None);
141    }
142
143    #[test]
144    fn rejects_traversal() {
145        let root = Path::new("/cache");
146        assert!(resolve("../etc/passwd", "https://up", root).is_err());
147        assert!(resolve("a/../../b", "https://up", root).is_err());
148        assert!(resolve("/abs", "https://up", root).is_err());
149        let ok = resolve("g/r.git", "https://up/", root).unwrap();
150        assert_eq!(ok.upstream_url, "https://up/g/r.git");
151        assert_eq!(ok.cache_dir, Path::new("/cache/g/r.git"));
152    }
153
154    #[test]
155    fn rejects_reserved_suffixes() {
156        let root = Path::new("/cache");
157        // A client must not be able to name a repo that maps onto the staging dir
158        // (`<cache_dir>.__incoming__`) or eviction trash (`<cache_dir>.__evicting__`)
159        // of another, or the LFS object store (`.__lfs__`).
160        assert!(resolve(&format!("foo{INCOMING_SUFFIX}"), "https://up", root).is_err());
161        assert!(resolve(&format!("a/b{INCOMING_SUFFIX}"), "https://up", root).is_err());
162        assert!(resolve(&format!("foo{EVICTING_SUFFIX}"), "https://up", root).is_err());
163        assert!(resolve(&format!("a/b{EVICTING_SUFFIX}"), "https://up", root).is_err());
164        assert!(resolve(LFS_OBJECTS_DIR, "https://up", root).is_err());
165        assert!(resolve(&format!("{LFS_OBJECTS_DIR}/ab/cd"), "https://up", root).is_err());
166    }
167
168    #[test]
169    fn parses_lfs_batch_and_object_paths() {
170        assert_eq!(
171            lfs_batch_repo("/group/foo.git/info/lfs/objects/batch").as_deref(),
172            Some("group/foo.git")
173        );
174        assert_eq!(lfs_batch_repo("/group/foo.git/info/refs"), None);
175
176        let oid = "a".repeat(64);
177        let (repo, got) =
178            lfs_object_from_path(&format!("/g/r.git/info/lfs/objects/{oid}")).unwrap();
179        assert_eq!(repo, "g/r.git");
180        assert_eq!(got, oid);
181        // A non-hex or wrong-length oid is not an object path.
182        assert!(lfs_object_from_path("/g/r.git/info/lfs/objects/NOTHEX").is_none());
183        assert!(lfs_object_from_path("/g/r.git/info/lfs/objects/abc").is_none());
184        // The batch endpoint is not an object (batch is not a valid oid).
185        assert!(lfs_object_from_path("/g/r.git/info/lfs/objects/batch").is_none());
186    }
187
188    #[test]
189    fn validates_oids_and_shards_the_object_path() {
190        assert!(valid_lfs_oid(&"0".repeat(64)));
191        assert!(valid_lfs_oid(&format!(
192            "{}{}",
193            "a".repeat(32),
194            "f".repeat(32)
195        )));
196        assert!(!valid_lfs_oid(&"A".repeat(64))); // uppercase is not git-lfs's form
197        assert!(!valid_lfs_oid(&"a".repeat(63)));
198        assert!(!valid_lfs_oid(&"g".repeat(64))); // not hex
199
200        let oid = format!("ab{}", "c".repeat(62));
201        assert_eq!(
202            lfs_object_path(Path::new("/cache"), &oid),
203            Path::new("/cache")
204                .join(LFS_OBJECTS_DIR)
205                .join("ab")
206                .join(&oid)
207        );
208    }
209}