1use std::path::PathBuf;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum DigestSource {
10 Local(PathBuf),
13 Remote {
15 canonical: String,
18 gh_slug: Option<(String, String)>,
22 },
23}
24
25pub fn parse_source(raw: &str) -> Result<DigestSource, String> {
34 let trimmed = raw.trim();
35 if trimmed.is_empty() {
36 return Err("source must not be empty".to_string());
37 }
38
39 if let Some(rest) = trimmed.strip_prefix("https://") {
40 if rest.is_empty() {
41 return Err(format!("source {trimmed:?} is not a valid https:// URL"));
42 }
43 let canonical = canonicalize_https_url(trimmed);
44 let gh_slug = github_slug(&canonical);
45 return Ok(DigestSource::Remote { canonical, gh_slug });
46 }
47
48 for (scheme, hint) in [
49 (
50 "ssh://",
51 "SSH URLs are rejected in v1 (no interactive auth in the daemon)",
52 ),
53 (
54 "git://",
55 "the git:// protocol is rejected in v1 -- use an https:// URL",
56 ),
57 ("http://", "plain http:// URLs are rejected -- use https://"),
58 ] {
59 if trimmed.starts_with(scheme) {
60 return Err(format!("source {trimmed:?}: {hint}"));
61 }
62 }
63 if is_scp_shorthand(trimmed) {
64 return Err(format!(
65 "source {trimmed:?}: SSH shorthand URLs are rejected in v1 (no interactive auth in the daemon)"
66 ));
67 }
68
69 if !trimmed.starts_with('/') {
70 return Err(format!(
71 "source {trimmed:?} is neither an https:// URL nor an absolute local path (relative paths are rejected)"
72 ));
73 }
74 let path = PathBuf::from(trimmed);
75 if !path.join(".git").exists() {
76 return Err(format!(
77 "local path {trimmed:?} does not contain a .git entry"
78 ));
79 }
80 Ok(DigestSource::Local(path))
81}
82
83fn is_scp_shorthand(s: &str) -> bool {
87 if s.starts_with('/') {
88 return false;
89 }
90 let Some(at) = s.find('@') else {
91 return false;
92 };
93 let Some(colon) = s.find(':') else {
94 return false;
95 };
96 if colon <= at {
97 return false;
98 }
99 let user = &s[..at];
100 !user.is_empty()
101 && user
102 .chars()
103 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
104}
105
106fn canonicalize_https_url(url: &str) -> String {
107 let mut s = url.trim_end_matches('/').to_string();
108 if let Some(stripped) = s.strip_suffix(".git") {
109 s = stripped.to_string();
110 }
111 s
112}
113
114fn github_slug(canonical: &str) -> Option<(String, String)> {
118 let rest = canonical.strip_prefix("https://")?;
119 let mut parts = rest.splitn(2, '/');
120 let host = parts.next()?;
121 if !matches!(host, "github.com" | "www.github.com") {
122 return None;
123 }
124 let path = parts.next()?;
125 let mut segs = path.split('/').filter(|s| !s.is_empty());
126 let owner = segs.next()?;
127 let repo = segs.next()?;
128 Some((owner.to_string(), repo.to_string()))
129}
130
131pub fn repo_basename(source: &DigestSource) -> String {
134 match source {
135 DigestSource::Local(p) => p
136 .file_name()
137 .and_then(|f| f.to_str())
138 .unwrap_or("repo")
139 .to_string(),
140 DigestSource::Remote { canonical, .. } => canonical
141 .rsplit('/')
142 .next()
143 .filter(|s| !s.is_empty())
144 .unwrap_or("repo")
145 .to_string(),
146 }
147}
148
149pub fn cache_key(canonical_url: &str) -> String {
154 blake3::hash(canonical_url.as_bytes()).to_hex()[..16].to_string()
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn https_github_url_derives_slug_and_canonical_form() {
163 let src = parse_source("https://github.com/ohdearquant/khive.git").unwrap();
164 match src {
165 DigestSource::Remote { canonical, gh_slug } => {
166 assert_eq!(canonical, "https://github.com/ohdearquant/khive");
167 assert_eq!(
168 gh_slug,
169 Some(("ohdearquant".to_string(), "khive".to_string()))
170 );
171 }
172 other => panic!("expected Remote, got {other:?}"),
173 }
174 }
175
176 #[test]
177 fn https_github_url_trailing_slash_canonicalizes_same_as_no_slash() {
178 let a = parse_source("https://github.com/org/repo/").unwrap();
179 let b = parse_source("https://github.com/org/repo").unwrap();
180 assert_eq!(a, b);
181 }
182
183 #[test]
184 fn https_non_github_host_has_no_gh_slug() {
185 let src = parse_source("https://gitlab.com/org/repo").unwrap();
186 match src {
187 DigestSource::Remote { gh_slug, .. } => assert_eq!(gh_slug, None),
188 other => panic!("expected Remote, got {other:?}"),
189 }
190 }
191
192 #[test]
193 fn ssh_url_rejected() {
194 let err = parse_source("ssh://git@github.com/org/repo.git").unwrap_err();
195 assert!(err.contains("SSH"), "{err}");
196 }
197
198 #[test]
199 fn scp_shorthand_rejected() {
200 let err = parse_source("git@github.com:org/repo.git").unwrap_err();
201 assert!(err.contains("SSH"), "{err}");
202 }
203
204 #[test]
205 fn git_protocol_rejected() {
206 let err = parse_source("git://github.com/org/repo.git").unwrap_err();
207 assert!(err.contains("git://"), "{err}");
208 }
209
210 #[test]
211 fn plain_http_rejected() {
212 let err = parse_source("http://github.com/org/repo").unwrap_err();
213 assert!(err.contains("http://"), "{err}");
214 }
215
216 #[test]
217 fn relative_local_path_rejected() {
218 let err = parse_source("relative/path/repo").unwrap_err();
219 assert!(err.contains("absolute"), "{err}");
220 }
221
222 #[test]
223 fn absolute_local_path_without_git_dir_rejected() {
224 let dir = tempfile::tempdir().expect("tempdir");
225 let err = parse_source(dir.path().to_str().unwrap()).unwrap_err();
226 assert!(err.contains(".git"), "{err}");
227 }
228
229 #[test]
230 fn absolute_local_path_with_git_dir_accepted() {
231 let dir = tempfile::tempdir().expect("tempdir");
232 std::fs::create_dir_all(dir.path().join(".git")).unwrap();
233 let src = parse_source(dir.path().to_str().unwrap()).unwrap();
234 assert_eq!(src, DigestSource::Local(dir.path().to_path_buf()));
235 }
236
237 #[test]
238 fn repo_basename_local_uses_dir_name() {
239 let src = DigestSource::Local(PathBuf::from("/home/x/my-repo"));
240 assert_eq!(repo_basename(&src), "my-repo");
241 }
242
243 #[test]
244 fn repo_basename_remote_uses_last_path_segment() {
245 let src = DigestSource::Remote {
246 canonical: "https://github.com/org/my-repo".to_string(),
247 gh_slug: Some(("org".to_string(), "my-repo".to_string())),
248 };
249 assert_eq!(repo_basename(&src), "my-repo");
250 }
251
252 #[test]
253 fn cache_key_is_deterministic_and_16_hex_chars() {
254 let a = cache_key("https://github.com/org/repo");
255 let b = cache_key("https://github.com/org/repo");
256 let c = cache_key("https://github.com/org/other");
257 assert_eq!(a, b);
258 assert_ne!(a, c);
259 assert_eq!(a.len(), 16);
260 assert!(a.chars().all(|ch| ch.is_ascii_hexdigit()));
261 }
262}