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 const EVICTING_SUFFIX: &str = ".__evicting__";
41
42pub const LFS_OBJECTS_DIR: &str = ".__lfs__";
46
47const LFS_MARKER: &str = "/info/lfs/objects/";
49
50pub fn lfs_batch_repo(path: &str) -> Option<String> {
53 repo_name_from_path(path, &format!("{LFS_MARKER}batch"))
54}
55
56pub 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
70pub 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
79pub fn lfs_object_key(oid: &str) -> String {
84 format!("{LFS_OBJECTS_DIR}/{}/{oid}", &oid[..2])
85}
86
87pub fn lfs_object_path(cache_root: &Path, oid: &str) -> PathBuf {
89 cache_root.join(lfs_object_key(oid))
90}
91
92pub 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 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 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 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 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 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))); assert!(!valid_lfs_oid(&"a".repeat(63)));
198 assert!(!valid_lfs_oid(&"g".repeat(64))); 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}