use std::path::{Path, PathBuf};
use anyhow::{Result, bail};
#[derive(Debug, Clone)]
pub struct RepoRef {
pub name: String,
pub upstream_url: String,
pub cache_dir: PathBuf,
}
pub fn repo_name_from_path(path: &str, suffix: &str) -> Option<String> {
let repo = path
.trim_start_matches('/')
.strip_suffix(suffix.trim_start_matches('/'))?;
Some(repo.trim_end_matches('/').to_string())
}
pub const INCOMING_SUFFIX: &str = ".__incoming__";
pub const EVICTING_SUFFIX: &str = ".__evicting__";
pub const LFS_OBJECTS_DIR: &str = ".__lfs__";
const LFS_MARKER: &str = "/info/lfs/objects/";
pub fn lfs_batch_repo(path: &str) -> Option<String> {
repo_name_from_path(path, &format!("{LFS_MARKER}batch"))
}
pub fn lfs_object_from_path(path: &str) -> Option<(String, String)> {
let p = path.trim_start_matches('/');
let idx = p.find(LFS_MARKER)?;
let repo = &p[..idx];
let oid = &p[idx + LFS_MARKER.len()..];
if repo.is_empty() || !valid_lfs_oid(oid) {
return None;
}
Some((repo.to_string(), oid.to_string()))
}
pub fn valid_lfs_oid(oid: &str) -> bool {
oid.len() == 64
&& oid
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
pub fn lfs_object_key(oid: &str) -> String {
format!("{LFS_OBJECTS_DIR}/{}/{oid}", &oid[..2])
}
pub fn lfs_object_path(cache_root: &Path, oid: &str) -> PathBuf {
cache_root.join(lfs_object_key(oid))
}
pub fn resolve(name: &str, upstream_base: &str, cache_root: &Path) -> Result<RepoRef> {
if name.is_empty() || name.contains('\0') || name.starts_with('/') {
bail!("invalid repo path: {name:?}");
}
for comp in name.split('/') {
if comp.is_empty() || comp == "." || comp == ".." {
bail!("invalid repo path component in {name:?}");
}
}
if name.contains(INCOMING_SUFFIX)
|| name.contains(EVICTING_SUFFIX)
|| name.contains(LFS_OBJECTS_DIR)
{
bail!("invalid repo path (reserved suffix) in {name:?}");
}
let cache_dir = cache_root.join(name);
if !cache_dir.starts_with(cache_root) {
bail!("repo path escapes cache root: {name:?}");
}
let upstream_url = format!("{}/{}", upstream_base.trim_end_matches('/'), name);
Ok(RepoRef {
name: name.to_string(),
upstream_url,
cache_dir,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_suffixes() {
assert_eq!(
repo_name_from_path("/group/team/foo.git/info/refs", "/info/refs").as_deref(),
Some("group/team/foo.git")
);
assert_eq!(
repo_name_from_path("/a/b.git/git-upload-pack", "/git-upload-pack").as_deref(),
Some("a/b.git")
);
assert_eq!(repo_name_from_path("/nope", "/info/refs"), None);
}
#[test]
fn rejects_traversal() {
let root = Path::new("/cache");
assert!(resolve("../etc/passwd", "https://up", root).is_err());
assert!(resolve("a/../../b", "https://up", root).is_err());
assert!(resolve("/abs", "https://up", root).is_err());
let ok = resolve("g/r.git", "https://up/", root).unwrap();
assert_eq!(ok.upstream_url, "https://up/g/r.git");
assert_eq!(ok.cache_dir, Path::new("/cache/g/r.git"));
}
#[test]
fn rejects_reserved_suffixes() {
let root = Path::new("/cache");
assert!(resolve(&format!("foo{INCOMING_SUFFIX}"), "https://up", root).is_err());
assert!(resolve(&format!("a/b{INCOMING_SUFFIX}"), "https://up", root).is_err());
assert!(resolve(&format!("foo{EVICTING_SUFFIX}"), "https://up", root).is_err());
assert!(resolve(&format!("a/b{EVICTING_SUFFIX}"), "https://up", root).is_err());
assert!(resolve(LFS_OBJECTS_DIR, "https://up", root).is_err());
assert!(resolve(&format!("{LFS_OBJECTS_DIR}/ab/cd"), "https://up", root).is_err());
}
#[test]
fn parses_lfs_batch_and_object_paths() {
assert_eq!(
lfs_batch_repo("/group/foo.git/info/lfs/objects/batch").as_deref(),
Some("group/foo.git")
);
assert_eq!(lfs_batch_repo("/group/foo.git/info/refs"), None);
let oid = "a".repeat(64);
let (repo, got) =
lfs_object_from_path(&format!("/g/r.git/info/lfs/objects/{oid}")).unwrap();
assert_eq!(repo, "g/r.git");
assert_eq!(got, oid);
assert!(lfs_object_from_path("/g/r.git/info/lfs/objects/NOTHEX").is_none());
assert!(lfs_object_from_path("/g/r.git/info/lfs/objects/abc").is_none());
assert!(lfs_object_from_path("/g/r.git/info/lfs/objects/batch").is_none());
}
#[test]
fn validates_oids_and_shards_the_object_path() {
assert!(valid_lfs_oid(&"0".repeat(64)));
assert!(valid_lfs_oid(&format!(
"{}{}",
"a".repeat(32),
"f".repeat(32)
)));
assert!(!valid_lfs_oid(&"A".repeat(64))); assert!(!valid_lfs_oid(&"a".repeat(63)));
assert!(!valid_lfs_oid(&"g".repeat(64)));
let oid = format!("ab{}", "c".repeat(62));
assert_eq!(
lfs_object_path(Path::new("/cache"), &oid),
Path::new("/cache")
.join(LFS_OBJECTS_DIR)
.join("ab")
.join(&oid)
);
}
}