Skip to main content

khive_pack_git/
source.rs

1//! `git.digest` source resolution (ADR-088 Amendment 1): local paths vs.
2//! `https://` remote URLs, canonicalization, and `github.com` owner/repo
3//! slug derivation for `gh`-based issue/PR ingestion.
4
5use std::path::PathBuf;
6
7/// A digest source, resolved from the `git.digest` verb's `source` argument.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum DigestSource {
10    /// An absolute local path known to contain a `.git` entry (directory or,
11    /// for worktrees, a `gitdir:` pointer file).
12    Local(PathBuf),
13    /// A remote `https://` URL to clone/fetch into the scratch cache.
14    Remote {
15        /// Canonical form used as the cache key (trailing `/` and `.git`
16        /// suffix stripped) — same URL always maps to the same cache slot.
17        canonical: String,
18        /// `Some((owner, repo))` when the host is `github.com` — the only
19        /// host `gh` can serve issues/PRs for. `None` for any other https
20        /// host: the amendment's commits-only degradation applies.
21        gh_slug: Option<(String, String)>,
22    },
23}
24
25/// Parse and validate the `source` argument.
26///
27/// - `https://` URLs are accepted for any host.
28/// - `ssh://`, `git://`, `http://`, and scp-like `user@host:path` shorthand
29///   are rejected outright — no interactive auth in the daemon (ADR-088
30///   Amendment 1, security posture).
31/// - Anything else is treated as a local path: it must be absolute and must
32///   contain a `.git` entry; arbitrary directory walking is not performed.
33pub 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
83/// `true` for scp-like SSH shorthand (`user@host:path`, e.g.
84/// `git@github.com:org/repo.git`) — recognized by an `@` before the first
85/// `:` and no leading `/`.
86fn 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
114/// Derive `(owner, repo)` from a canonicalized `https://github.com/...` URL.
115/// Returns `None` for any other host, or a github.com URL with fewer than
116/// two path segments.
117fn 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
131/// Basename used as the default `project` entity name and as a fallback
132/// scratch-directory label.
133pub 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
149/// Deterministic cache-key for the scratch clone directory: the first 16 hex
150/// characters of `blake3(canonical_url)` -- short enough for a filesystem
151/// path component, long enough that collisions are not a practical concern
152/// for a handful of cached repos.
153pub 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}