1use std::path::{Path, PathBuf};
6
7use anyhow::{Result, bail};
8
9#[derive(Debug, Clone)]
11pub struct RepoRef {
12 pub name: String,
15 pub upstream_url: String,
17 pub cache_dir: PathBuf,
19}
20
21pub 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
30pub const INCOMING_SUFFIX: &str = ".__incoming__";
35
36pub fn resolve(name: &str, upstream_base: &str, cache_root: &Path) -> Result<RepoRef> {
39 if name.is_empty() || name.contains('\0') || name.starts_with('/') {
40 bail!("invalid repo path: {name:?}");
41 }
42 for comp in name.split('/') {
43 if comp.is_empty() || comp == "." || comp == ".." {
44 bail!("invalid repo path component in {name:?}");
45 }
46 }
47 if name.contains(INCOMING_SUFFIX) {
50 bail!("invalid repo path (reserved suffix) in {name:?}");
51 }
52 let cache_dir = cache_root.join(name);
53 if !cache_dir.starts_with(cache_root) {
55 bail!("repo path escapes cache root: {name:?}");
56 }
57 let upstream_url = format!("{}/{}", upstream_base.trim_end_matches('/'), name);
58 Ok(RepoRef {
59 name: name.to_string(),
60 upstream_url,
61 cache_dir,
62 })
63}
64
65#[cfg(test)]
66mod tests {
67 use super::*;
68
69 #[test]
70 fn strips_suffixes() {
71 assert_eq!(
72 repo_name_from_path("/group/team/foo.git/info/refs", "/info/refs").as_deref(),
73 Some("group/team/foo.git")
74 );
75 assert_eq!(
76 repo_name_from_path("/a/b.git/git-upload-pack", "/git-upload-pack").as_deref(),
77 Some("a/b.git")
78 );
79 assert_eq!(repo_name_from_path("/nope", "/info/refs"), None);
80 }
81
82 #[test]
83 fn rejects_traversal() {
84 let root = Path::new("/cache");
85 assert!(resolve("../etc/passwd", "https://up", root).is_err());
86 assert!(resolve("a/../../b", "https://up", root).is_err());
87 assert!(resolve("/abs", "https://up", root).is_err());
88 let ok = resolve("g/r.git", "https://up/", root).unwrap();
89 assert_eq!(ok.upstream_url, "https://up/g/r.git");
90 assert_eq!(ok.cache_dir, Path::new("/cache/g/r.git"));
91 }
92
93 #[test]
94 fn rejects_reserved_incoming_suffix() {
95 let root = Path::new("/cache");
96 assert!(resolve(&format!("foo{INCOMING_SUFFIX}"), "https://up", root).is_err());
99 assert!(resolve(&format!("a/b{INCOMING_SUFFIX}"), "https://up", root).is_err());
100 }
101}