Skip to main content

sloc_git/
ops.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4use std::io::Read as _;
5use std::path::{Path, PathBuf};
6use std::process::Stdio;
7use std::sync::OnceLock;
8use std::time::{Duration, Instant};
9
10use anyhow::{Context, Result, bail};
11
12use crate::{GitCommit, GitRef, GitRefKind, RepoRefs};
13
14/// Optional positive host allowlist for clone targets, parsed once from
15/// `SLOC_GIT_HOST_ALLOWLIST` (comma-separated, lowercased hostnames). When empty,
16/// `validate_clone_url` runs in denylist mode (metadata/loopback blocking only).
17fn git_host_allowlist() -> &'static [String] {
18    static ALLOW: OnceLock<Vec<String>> = OnceLock::new();
19    ALLOW.get_or_init(|| {
20        std::env::var("SLOC_GIT_HOST_ALLOWLIST")
21            .unwrap_or_default()
22            .split(',')
23            .map(|s| s.trim().to_lowercase())
24            .filter(|s| !s.is_empty())
25            .collect()
26    })
27}
28
29/// True when `SLOC_GIT_HOST_ALLOWLIST` names at least one host. Callers use this to decide
30/// whether network operations that fetch attacker-influenced URLs (e.g. submodule population
31/// on a network-facing server) may proceed under the positive allowlist.
32#[must_use]
33pub fn host_allowlist_configured() -> bool {
34    !git_host_allowlist().is_empty()
35}
36
37/// When `SLOC_GIT_REQUIRE_ALLOWLIST` is truthy, clones are refused unless
38/// `SLOC_GIT_HOST_ALLOWLIST` names the target host. This lets internet-facing or
39/// multi-tenant deployments run allowlist-only (fail closed): only explicitly listed
40/// hostnames are clonable, so a hostname that resolves to an internal address only at
41/// clone time cannot slip through the validate-time resolution check. Unset by default,
42/// so denylist-mode deployments are unaffected.
43fn require_host_allowlist() -> bool {
44    static REQ: OnceLock<bool> = OnceLock::new();
45    *REQ.get_or_init(|| {
46        std::env::var("SLOC_GIT_REQUIRE_ALLOWLIST")
47            .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
48    })
49}
50
51/// When `SLOC_GIT_SSL_NO_VERIFY` is set (any value), TLS certificate verification is
52/// disabled for git network operations via `-c http.sslVerify=false`. This is the escape
53/// hatch for corporate networks whose VPN/proxy performs TLS inspection with a self-signed
54/// CA that is not in the machine's trust store — the common reason a Bitbucket/GitHub fetch
55/// fails on an internal network. Off by default; a startup warning is printed when set.
56fn ssl_no_verify() -> bool {
57    static NO_VERIFY: OnceLock<bool> = OnceLock::new();
58    *NO_VERIFY.get_or_init(|| std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some())
59}
60
61/// Wall-clock ceiling for a single git subprocess, from `SLOC_GIT_TIMEOUT` (seconds).
62/// Defaults to 300s. Guarantees a stalled clone/fetch (dead VPN, black-holed proxy) fails
63/// with a clear error instead of hanging the web request forever.
64fn git_timeout() -> Duration {
65    static TIMEOUT: OnceLock<Duration> = OnceLock::new();
66    *TIMEOUT.get_or_init(|| {
67        let secs = std::env::var("SLOC_GIT_TIMEOUT")
68            .ok()
69            .and_then(|v| v.parse::<u64>().ok())
70            .filter(|&s| s > 0)
71            .unwrap_or(300);
72        Duration::from_secs(secs)
73    })
74}
75
76// ── per-host credential registry ───────────────────────────────────────────────
77
78/// A credential resolved for a specific git host from the in-app registry.
79///
80/// The secret (`token` / the key file's contents) is NEVER placed in a git argv or
81/// written to disk. It travels only in the child process environment (`GIT_U`/`GIT_P`
82/// for HTTPS, `GIT_SSH_COMMAND` for SSH); see [`cred_injection`].
83enum GitCredential {
84    /// HTTPS personal-access-token auth (`username` + `token`).
85    Https { user: String, token: String },
86    /// SSH key auth (path to a private key file).
87    Ssh { key_path: String },
88}
89
90/// Map a hostname to the env-var suffix used by the credential registry:
91/// uppercase, with every non-alphanumeric byte replaced by `_`
92/// (`bitbucket.instance2.com` → `BITBUCKET_INSTANCE2_COM`, `host:7990` → `HOST_7990`).
93///
94/// Note: this is intentionally lossy — `a.b.com`, `a-b.com`, and `a_b.com` all map to
95/// `A_B_COM`. That collision is documented; keep instance hostnames distinct beyond
96/// punctuation, or use `SLOC_GIT_CRED_FILE` (which keys on the exact hostname).
97fn hostkey(host: &str) -> String {
98    host.chars()
99        .map(|c| {
100            if c.is_ascii_alphanumeric() {
101                c.to_ascii_uppercase()
102            } else {
103                '_'
104            }
105        })
106        .collect()
107}
108
109/// Resolve a per-host credential from the in-app registry, or `None` to fall through
110/// to git's own credential resolution (OS credential helper, `~/.netrc`, ssh-agent).
111///
112/// Resolution order for host `H` (first match wins):
113/// 1. `SLOC_GIT_CRED_<HOSTKEY>` = `username:token` — HTTPS PAT.
114/// 2. `SLOC_GIT_SSHKEY_<HOSTKEY>` = path to an SSH private key.
115/// 3. `SLOC_GIT_CRED_FILE` — a bulk `host = "user:token"` map (see [`cred_from_file`]).
116///
117/// Not cached: env is read live so the value is correct under runtime changes and the
118/// unit tests can mutate it. Cheap relative to a network clone.
119fn resolve_credential(host: &str, port: Option<u16>) -> Option<GitCredential> {
120    // Try the port-qualified key first (`SLOC_GIT_CRED_HOST_7990`) so two instances
121    // on the same host but different ports can carry distinct credentials, then the
122    // bare-host key (`SLOC_GIT_CRED_HOST`). The port form matches the documented
123    // `git.corp:7990 → GIT_CORP_7990` convention; without it the port suffix was dead.
124    let mut keys: Vec<String> = Vec::with_capacity(2);
125    if let Some(pt) = port {
126        keys.push(hostkey(&format!("{host}:{pt}")));
127    }
128    keys.push(hostkey(host));
129    for key in &keys {
130        if let Ok(v) = std::env::var(format!("SLOC_GIT_CRED_{key}"))
131            && let Some((user, token)) = v.split_once(':')
132            && !token.is_empty()
133        {
134            return Some(GitCredential::Https {
135                user: user.to_owned(),
136                token: token.to_owned(),
137            });
138        }
139        if let Ok(p) = std::env::var(format!("SLOC_GIT_SSHKEY_{key}"))
140            && !p.trim().is_empty()
141        {
142            return Some(GitCredential::Ssh { key_path: p });
143        }
144    }
145    cred_from_file(host)
146}
147
148/// Look `host` up in the optional bulk credentials file named by `SLOC_GIT_CRED_FILE`.
149///
150/// Format: one `host = "user:token"` entry per line (`#` comments and blank lines are
151/// ignored); quotes optional; host match is case-insensitive on the exact hostname.
152/// The file is treated as a secret — its contents are never logged. On Unix a warning is
153/// emitted if it is group/world-readable.
154fn cred_from_file(host: &str) -> Option<GitCredential> {
155    let path = std::env::var("SLOC_GIT_CRED_FILE").ok()?;
156    let path = path.trim();
157    if path.is_empty() {
158        return None;
159    }
160    warn_if_world_readable(path);
161    let content = std::fs::read_to_string(path).ok()?;
162    let host_lower = host.to_lowercase();
163    for line in content.lines() {
164        let line = line.trim();
165        if line.is_empty() || line.starts_with('#') {
166            continue;
167        }
168        let Some((k, v)) = line.split_once('=') else {
169            continue;
170        };
171        if k.trim().trim_matches('"').to_lowercase() != host_lower {
172            continue;
173        }
174        let v = v.trim().trim_matches('"');
175        if let Some((user, token)) = v.split_once(':')
176            && !token.is_empty()
177        {
178            return Some(GitCredential::Https {
179                user: user.to_owned(),
180                token: token.to_owned(),
181            });
182        }
183    }
184    None
185}
186
187/// Warn (once per call, best-effort) if a secrets file is readable beyond its owner.
188#[cfg(unix)]
189fn warn_if_world_readable(path: &str) {
190    use std::os::unix::fs::PermissionsExt as _;
191    if let Ok(meta) = std::fs::metadata(path)
192        && meta.permissions().mode() & 0o077 != 0
193    {
194        eprintln!(
195            "warning: SLOC_GIT_CRED_FILE {path:?} is group/world-readable; \
196             restrict it with chmod 600"
197        );
198    }
199}
200
201#[cfg(not(unix))]
202fn warn_if_world_readable(_path: &str) {}
203
204/// Extra git config (`-c` pairs) and child-process env for a resolved credential.
205///
206/// - HTTPS: an empty `credential.helper=` first (resets any inherited system/GCM helper
207///   so it can't win or pop a GUI), then a shell-snippet helper that echoes the credential
208///   read from `$GIT_U`/`$GIT_P`. The helper text contains only the variable *names* — the
209///   secret is supplied via `env` and never appears in argv or on disk.
210/// - SSH: `GIT_SSH_COMMAND` pinning the key with `IdentitiesOnly=yes` (so an ssh-agent key
211///   can't shadow it) and `BatchMode=yes` (fail fast, matching the non-interactive model).
212#[derive(Default)]
213struct CredInjection {
214    config: Vec<String>,
215    env: Vec<(String, String)>,
216}
217
218fn cred_injection(host: &str, port: Option<u16>) -> CredInjection {
219    match resolve_credential(host, port) {
220        Some(GitCredential::Https { user, token }) => CredInjection {
221            config: vec![
222                "credential.helper=".to_owned(),
223                "credential.helper=!f() { test \"$1\" = get && echo \"username=$GIT_U\" && \
224                 echo \"password=$GIT_P\"; }; f"
225                    .to_owned(),
226            ],
227            env: vec![("GIT_U".to_owned(), user), ("GIT_P".to_owned(), token)],
228        },
229        Some(GitCredential::Ssh { key_path }) => {
230            let mut ssh = format!("ssh -i \"{key_path}\" -o IdentitiesOnly=yes -o BatchMode=yes");
231            // Default: strict host-key checking (first contact with an unseeded
232            // known_hosts fails, matching the non-interactive model). Opt in to
233            // trust-on-first-use with SLOC_GIT_SSH_ACCEPT_NEW=1.
234            if ssh_accept_new() {
235                ssh.push_str(" -o StrictHostKeyChecking=accept-new");
236            }
237            CredInjection {
238                config: Vec::new(),
239                env: vec![("GIT_SSH_COMMAND".to_owned(), ssh)],
240            }
241        }
242        None => CredInjection::default(),
243    }
244}
245
246/// Opt-in trust-on-first-use for SSH clones: when set, `StrictHostKeyChecking=accept-new`
247/// is added to the injected SSH command so a first contact with a host absent from
248/// `known_hosts` records its key instead of failing. Default OFF keeps strict checking —
249/// pre-seed `known_hosts` (e.g. via `ssh-keyscan`) on a fresh agent otherwise.
250fn ssh_accept_new() -> bool {
251    std::env::var("SLOC_GIT_SSH_ACCEPT_NEW")
252        .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
253}
254
255// ── offline / local-source import gate ───────────────────────────────────────────
256
257/// Whether local/offline git sources (bundle, `file://`, local path) are permitted.
258/// `SLOC_GIT_ALLOW_LOCAL` truthy. Default OFF preserves the SSRF posture for
259/// internet-facing servers (a bare `file:///etc/...` clone is an LFI otherwise).
260fn allow_local() -> bool {
261    std::env::var("SLOC_GIT_ALLOW_LOCAL").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
262}
263
264/// The directory local sources must resolve under, from `SLOC_GIT_LOCAL_ROOT`.
265/// Required whenever `allow_local()` is on (fail closed) — the filesystem analog of
266/// `SLOC_GIT_HOST_ALLOWLIST`.
267fn local_root() -> Option<PathBuf> {
268    std::env::var("SLOC_GIT_LOCAL_ROOT")
269        .ok()
270        .map(PathBuf::from)
271        .filter(|p| !p.as_os_str().is_empty())
272}
273
274/// `-c key=value` config flags applied to every network-touching git invocation
275/// (clone/fetch). Makes internal/corporate repos work with zero configuration:
276/// - `http.sslBackend=schannel` (Windows only) — validate TLS against the Windows system
277///   certificate store instead of Git for Windows' own bundled CA file. The system store
278///   already holds the enterprise/proxy root CAs that IT deploys, so a TLS-inspecting
279///   corporate proxy or VPN is trusted automatically — the same reason the repo opens fine
280///   in a browser. This is why a fetch that used to need `SLOC_GIT_SSL_NO_VERIFY` now just
281///   works, and it keeps certificate verification ON (no security downgrade). On Linux/macOS
282///   git already uses the system trust store, so nothing extra is needed there.
283/// - `http.followRedirects=false` — never follow an HTTP redirect into an SSRF target.
284/// - `http.lowSpeedLimit`/`http.lowSpeedTime` — abort a transfer that drops below ~1 KB/s
285///   for 30s, so a flaky VPN/proxy fails fast rather than hanging.
286/// - `http.sslVerify=false` — last-resort override, only when `SLOC_GIT_SSL_NO_VERIFY` is set
287///   (a self-signed cert that isn't in any trust store). Rarely needed now.
288fn network_git_config() -> Vec<String> {
289    let mut cfg = vec![
290        "http.followRedirects=false".to_owned(),
291        "http.lowSpeedLimit=1000".to_owned(),
292        "http.lowSpeedTime=30".to_owned(),
293    ];
294    if cfg!(windows) {
295        cfg.push("http.sslBackend=schannel".to_owned());
296    }
297    if ssl_no_verify() {
298        cfg.push("http.sslVerify=false".to_owned());
299    }
300    cfg
301}
302
303/// Prepend `-c <cfg>` pairs to a git argument list, borrowing from `cfg`.
304fn with_config<'a>(cfg: &'a [String], tail: &[&'a str]) -> Vec<&'a str> {
305    let mut v = Vec::with_capacity(cfg.len() * 2 + tail.len());
306    for c in cfg {
307        v.push("-c");
308        v.push(c.as_str());
309    }
310    v.extend_from_slice(tail);
311    v
312}
313
314/// Persist the network config into the freshly-cloned repo's local git config.
315/// Blobless clones fetch file contents lazily (the promisor kicks in when a ref is checked
316/// out into a worktree), and that implicit fetch reads the repo config — not our per-command
317/// `-c` flags. Writing them here makes the SSL bypass and low-speed abort apply to those
318/// lazy fetches too, so scanning a ref works on the same corporate network the clone did.
319/// Best-effort: a failure here doesn't invalidate an otherwise-successful clone.
320fn persist_repo_config(dest: &Path, cfg: &[String]) {
321    let mut helper_reset = false;
322    for kv in cfg {
323        if let Some((key, value)) = kv.split_once('=') {
324            if key == "credential.helper" {
325                // `credential.helper` is a multi-valued key: we persist BOTH the empty
326                // reset (drops inherited system/GCM helpers so the promisor fetch can't
327                // fall back to a GUI prompt and hang) AND the env-reading helper. A plain
328                // `git config` would overwrite, clobbering the reset — so clear once, then
329                // `--add` each value in order. No secret is written: the helper text holds
330                // only `$GIT_U`/`$GIT_P` variable names.
331                if !helper_reset {
332                    let _ = run_git(dest, &["config", "--unset-all", "credential.helper"]);
333                    helper_reset = true;
334                }
335                let _ = run_git(dest, &["config", "--add", "credential.helper", value]);
336            } else {
337                let _ = run_git(dest, &["config", key, value]);
338            }
339        }
340    }
341}
342
343// ── low-level git runner ───────────────────────────────────────────────────────
344
345fn run_git(repo: &Path, args: &[&str]) -> Result<String> {
346    run_git_env(repo, args, &[])
347}
348
349/// Like [`run_git`], but sets additional child-process environment variables (e.g. the
350/// per-host credential secret `GIT_U`/`GIT_P`, or `GIT_SSH_COMMAND`). The secret lives
351/// only in the child env — never in argv, never on disk. All the spawn/drain/timeout
352/// logic is shared with `run_git` (which calls this with an empty `extra_env`).
353fn run_git_env(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> Result<String> {
354    let mut cmd = std::process::Command::new("git");
355    // Force non-interactive operation. Without this, a `clone`/`fetch` that hits an
356    // authentication challenge (e.g. a rate-limited anonymous clone returning 401, or a
357    // private repo) blocks indefinitely waiting for input that never arrives — git asks on
358    // the terminal and Git Credential Manager pops a GUI dialog, neither of which a
359    // background server subprocess can answer. The request then hangs forever and the web
360    // UI spins on "Fetching repository…". These variables make git fail fast with an error
361    // instead. They suppress only *interactive* prompts; already-stored credentials (SSH
362    // agent, cached HTTPS tokens) are still used, so configured private repos keep working.
363    cmd.env("GIT_TERMINAL_PROMPT", "0")
364        .env("GCM_INTERACTIVE", "never")
365        .env("GIT_ASKPASS", "")
366        .env("SSH_ASKPASS", "");
367    // Per-host credential secrets injected by the caller (clone/fetch/worktree). Set after
368    // the interactive-suppression vars so a resolved credential's helper can answer git.
369    for (k, v) in extra_env {
370        cmd.env(k, v);
371    }
372    cmd.args(args)
373        .current_dir(repo)
374        .stdin(Stdio::null())
375        .stdout(Stdio::piped())
376        .stderr(Stdio::piped());
377    let mut child = cmd.spawn().context("failed to spawn git process")?;
378
379    // Drain stdout/stderr on dedicated threads: a chatty git process (clone progress,
380    // large logs) can otherwise fill a fixed-size OS pipe buffer and block on write while
381    // we poll for the timeout below — a deadlock that would look exactly like a hang.
382    let mut out_pipe = child.stdout.take();
383    let mut err_pipe = child.stderr.take();
384    let out_handle = std::thread::spawn(move || {
385        let mut buf = Vec::new();
386        if let Some(p) = out_pipe.as_mut() {
387            let _ = p.read_to_end(&mut buf);
388        }
389        buf
390    });
391    let err_handle = std::thread::spawn(move || {
392        let mut buf = Vec::new();
393        if let Some(p) = err_pipe.as_mut() {
394            let _ = p.read_to_end(&mut buf);
395        }
396        buf
397    });
398
399    // Poll for completion, killing the process if it exceeds the wall-clock ceiling.
400    let timeout = git_timeout();
401    let start = Instant::now();
402    let status = loop {
403        if let Some(status) = child.try_wait().context("failed to poll git process")? {
404            break status;
405        }
406        if start.elapsed() >= timeout {
407            let _ = child.kill();
408            let _ = child.wait();
409            bail!(
410                "git {} timed out after {}s — the remote did not respond in time. \
411                 On a corporate network this usually means a proxy or VPN is slow or \
412                 blocking the connection. Raise the ceiling with SLOC_GIT_TIMEOUT=<seconds>, \
413                 or check your proxy/VPN configuration.",
414                args.first().copied().unwrap_or(""),
415                timeout.as_secs()
416            );
417        }
418        std::thread::sleep(Duration::from_millis(100));
419    };
420
421    let stdout = out_handle.join().unwrap_or_default();
422    let stderr = err_handle.join().unwrap_or_default();
423    if !status.success() {
424        let stderr = String::from_utf8_lossy(&stderr);
425        bail!(
426            "git {}: {}",
427            args.first().copied().unwrap_or(""),
428            stderr.trim()
429        );
430    }
431    Ok(String::from_utf8_lossy(&stdout).trim().to_owned())
432}
433
434// ── URL normalization ─────────────────────────────────────────────────────────
435
436/// Convert a repository browse URL into a clonable git URL.
437///
438/// Handles Bitbucket Server/Data Center (`/projects/{PROJ}/repos/{REPO}/...`),
439/// GitLab (`/path/repo/-/tree/...`), GitHub (`github.com/{owner}/{repo}/tree/...`),
440/// and Bitbucket Cloud (`bitbucket.org/{ws}/{repo}/src/...`). SSH URLs and URLs
441/// that already look like clone targets are returned unchanged.
442#[must_use]
443pub fn normalize_git_url(raw: &str) -> String {
444    let url = raw.trim();
445    if url.starts_with("git@") || url.starts_with("ssh://") {
446        return url.to_owned();
447    }
448    let scheme = if url.starts_with("https://") {
449        "https"
450    } else if url.starts_with("http://") {
451        "http"
452    } else {
453        return url.to_owned();
454    };
455    let authority_and_path = &url[scheme.len() + 3..];
456    let (host, path) = authority_and_path
457        .find('/')
458        .map_or((authority_and_path, "/"), |i| {
459            (&authority_and_path[..i], &authority_and_path[i..])
460        });
461    let path = path.trim_end_matches('/');
462
463    try_normalize_bitbucket_server(scheme, host, path)
464        .or_else(|| try_normalize_gitlab(scheme, host, path))
465        .or_else(|| try_normalize_github(scheme, host, path))
466        .or_else(|| try_normalize_bitbucket_cloud(scheme, host, path))
467        .unwrap_or_else(|| url.to_owned())
468}
469
470// ── Bitbucket Server / Data Center ────────────────────────────────────────────
471// Browse URL: /{context}/projects/{PROJECT}/repos/{REPO}[/...]
472// Clone URL:  /{context}/scm/{project_lower}/{repo}.git
473fn try_normalize_bitbucket_server(scheme: &str, host: &str, path: &str) -> Option<String> {
474    let path_lower = path.to_lowercase();
475    let proj_pos = path_lower.find("/projects/")?;
476    let after = &path[proj_pos + "/projects/".len()..];
477    let parts: Vec<&str> = after.splitn(4, '/').collect();
478    if parts.len() < 3 || !parts[1].eq_ignore_ascii_case("repos") {
479        return None;
480    }
481    let context = &path[..proj_pos];
482    let project = parts[0].to_lowercase();
483    let repo = parts[2].trim_end_matches(".git");
484    Some(format!(
485        "{scheme}://{host}{context}/scm/{project}/{repo}.git"
486    ))
487}
488
489// ── GitLab (any host) ─────────────────────────────────────────────────────────
490// Browse URL: /path/to/repo/-/tree/branch  →  Clone URL: /path/to/repo.git
491fn try_normalize_gitlab(scheme: &str, host: &str, path: &str) -> Option<String> {
492    let idx = path.find("/-/")?;
493    let repo_path = path[..idx].trim_end_matches(".git");
494    Some(format!("{scheme}://{host}{repo_path}.git"))
495}
496
497// ── GitHub ────────────────────────────────────────────────────────────────────
498// Browse URL: github.com/{owner}/{repo}/{tree|blob|...}/...
499fn try_normalize_github(scheme: &str, host: &str, path: &str) -> Option<String> {
500    if host != "github.com" && !host.ends_with(".github.com") {
501        return None;
502    }
503    let p = path.trim_start_matches('/');
504    let parts: Vec<&str> = p.splitn(4, '/').collect();
505    if parts.len() < 3
506        || !matches!(
507            parts[2],
508            "tree" | "blob" | "commits" | "commit" | "releases" | "tags" | "branches"
509        )
510    {
511        return None;
512    }
513    let owner = parts[0];
514    let repo = parts[1].trim_end_matches(".git");
515    Some(format!("{scheme}://{host}/{owner}/{repo}.git"))
516}
517
518// ── Bitbucket Cloud ───────────────────────────────────────────────────────────
519// Browse URL: bitbucket.org/{workspace}/{repo}/src/...
520fn try_normalize_bitbucket_cloud(scheme: &str, host: &str, path: &str) -> Option<String> {
521    if host != "bitbucket.org" {
522        return None;
523    }
524    let p = path.trim_start_matches('/');
525    let parts: Vec<&str> = p.splitn(4, '/').collect();
526    if parts.len() < 3 || parts[2] != "src" {
527        return None;
528    }
529    let ws = parts[0];
530    let repo = parts[1].trim_end_matches(".git");
531    Some(format!("{scheme}://{host}/{ws}/{repo}.git"))
532}
533
534// ── clone / fetch ─────────────────────────────────────────────────────────────
535
536fn validate_clone_url(url: &str) -> Result<()> {
537    let lower = url.to_lowercase();
538    // http:// excluded: prevents SSRF against plaintext internal HTTP services.
539    // file:// excluded: prevents local filesystem access.
540    let allowed = ["https://", "git://", "ssh://", "git@"];
541    if !allowed.iter().any(|p| lower.starts_with(p)) {
542        bail!(
543            "git URL rejected: only https://, git://, ssh://, and git@ URLs are \
544             permitted (got {url:?})"
545        );
546    }
547    // SSRF protection: block loopback, link-local, and cloud-metadata hosts.
548    // RFC 1918 private ranges are intentionally ALLOWED so the tool can scan
549    // internal/corporate git servers (10.x, 192.168.x, 172.16-31.x); the real
550    // threat is cloud-metadata and loopback, not "any private IP".
551    // The check is host-scoped (not a whole-URL substring match) so legitimate
552    // paths/tags such as "release-v10.2" are never mistaken for an IP.
553    let Some(host) = host_of_git_url(url) else {
554        return Ok(());
555    };
556    check_host_allowed(&host)?;
557    check_resolved_ips(&host, url)?;
558    Ok(())
559}
560
561/// Host-level SSRF gate: positive allowlist (when configured) plus the
562/// loopback/link-local/cloud-metadata denylist. Split out of `validate_clone_url`
563/// to keep that function's cognitive complexity low.
564fn check_host_allowed(host: &str) -> Result<()> {
565    // Positive allowlist (durable SSRF control): when SLOC_GIT_HOST_ALLOWLIST is
566    // configured, only those hosts may be cloned. This closes the validate-vs-clone
567    // DNS TOCTOU — an attacker cannot point an *allowed name* at an internal IP and
568    // have it accepted unless the name itself is allowlisted. Empty = denylist mode
569    // (loopback/link-local/metadata blocking only), preserving prior behaviour.
570    let allow = git_host_allowlist();
571    if allow.is_empty() {
572        if require_host_allowlist() {
573            bail!(
574                "git URL rejected: SLOC_GIT_REQUIRE_ALLOWLIST is set but \
575                 SLOC_GIT_HOST_ALLOWLIST is empty (no hosts are permitted)"
576            );
577        }
578    } else if !allow.iter().any(|h| h == host) {
579        bail!("git URL rejected: host {host:?} is not in SLOC_GIT_HOST_ALLOWLIST");
580    }
581    if is_ssrf_blocked_host(host) {
582        bail!(
583            "git URL rejected: loopback, link-local, and cloud-metadata \
584             addresses are not permitted (host {host:?})"
585        );
586    }
587    Ok(())
588}
589
590/// Defence against DNS-rebinding: a hostname that is not itself an IP literal can
591/// still resolve to an SSRF-sensitive address. Resolve it now and reject if *any*
592/// resolved IP is blocked. A resolution failure is not fatal (the host may only be
593/// resolvable by git's own resolver in some air-gapped setups) — git will then fail
594/// or succeed on its own; the residual is the documented validate-vs-clone TOCTOU.
595fn check_resolved_ips(host: &str, url: &str) -> Result<()> {
596    let Some(port) = port_of_git_url(url) else {
597        return Ok(());
598    };
599    let Ok(addrs) = resolve_host_port(host, port) else {
600        return Ok(());
601    };
602    for addr in addrs {
603        if is_ssrf_blocked_ip(addr.ip()) {
604            bail!(
605                "git URL rejected: host {host:?} resolves to a blocked \
606                 address {} (loopback/link-local/cloud-metadata)",
607                addr.ip()
608            );
609        }
610    }
611    Ok(())
612}
613
614/// Live DNS resolution seam for `check_resolved_ips`. Production performs a real
615/// `getaddrinfo`; the `cfg(test)` build resolves purely in-process so the unit
616/// suite is network-hermetic (no `github.com` A/AAAA lookups on every `cargo test`,
617/// which trip DNS alarms on monitored air-gapped sites). The DNS-rebinding path it
618/// guards needs a real hostile record and is exercised by integration tests, not
619/// these offline units.
620#[cfg(not(test))]
621fn resolve_host_port(
622    host: &str,
623    port: u16,
624) -> std::io::Result<std::vec::IntoIter<std::net::SocketAddr>> {
625    use std::net::ToSocketAddrs as _;
626    (host, port).to_socket_addrs()
627}
628
629#[cfg(test)]
630fn resolve_host_port(
631    host: &str,
632    port: u16,
633) -> std::io::Result<std::vec::IntoIter<std::net::SocketAddr>> {
634    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
635    // IP-literal hosts resolve to themselves, so the SSRF-blocked-IP assertions still
636    // hold without touching the network; any real hostname resolves to a fixed public
637    // address so the block-loop is still exercised but no DNS query is emitted.
638    let ip = host
639        .parse::<IpAddr>()
640        .unwrap_or(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)));
641    Ok(vec![SocketAddr::new(ip, port)].into_iter())
642}
643
644/// Extract the host (lowercased, brackets stripped) from a git clone URL.
645/// Handles `git@host:path`, `scheme://[user@]host[:port]/path`, and IPv6 literals.
646fn host_of_git_url(url: &str) -> Option<String> {
647    let u = url.trim();
648    // scp-like syntax: git@host:path (no scheme)
649    if let Some(rest) = u.strip_prefix("git@") {
650        let host = rest.split(':').next().unwrap_or(rest);
651        return Some(host.to_lowercase());
652    }
653    // scheme://[user@]host[:port]/path
654    let after_scheme = u.split("://").nth(1)?;
655    let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
656    // Strip any userinfo (user[:pass]@).
657    let authority = authority.rsplit('@').next().unwrap_or(authority);
658    // IPv6 literal: [::1]:port → ::1
659    let host = authority.strip_prefix('[').map_or_else(
660        || authority.split(':').next().unwrap_or(authority).to_string(),
661        |stripped| stripped.split(']').next().unwrap_or(stripped).to_string(),
662    );
663    Some(host.to_lowercase())
664}
665
666/// Best-effort port extraction for DNS-rebinding resolution. Returns the explicit
667/// port if present, otherwise the scheme default (https 443, git 9418, ssh 22).
668/// `None` only when no host/scheme can be determined.
669fn port_of_git_url(url: &str) -> Option<u16> {
670    let u = url.trim();
671    // scp-like git@host:path — git over ssh, port 22 (path after ':' is not a port).
672    if u.starts_with("git@") {
673        return Some(22);
674    }
675    let (scheme, after_scheme) = u.split_once("://")?;
676    let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
677    let authority = authority.rsplit('@').next().unwrap_or(authority);
678    // Explicit port: take the segment after the last ':' that is not inside [..].
679    let explicit = authority.strip_prefix('[').map_or_else(
680        // No '[' prefix: take the segment after the last ':'.
681        || {
682            authority
683                .rsplit_once(':')
684                .and_then(|(_, p)| p.parse::<u16>().ok())
685        },
686        // IPv6 literal: [host]:port
687        |stripped| {
688            stripped
689                .split_once("]:")
690                .and_then(|(_, p)| p.parse::<u16>().ok())
691        },
692    );
693    explicit.or_else(|| match scheme.to_lowercase().as_str() {
694        "https" => Some(443),
695        "git" => Some(9418),
696        "ssh" => Some(22),
697        _ => None,
698    })
699}
700
701/// The EXPLICIT `:port` from a git URL authority, or `None` when the URL carries no
702/// port. Unlike [`port_of_git_url`], no scheme default is substituted, and scp-like
703/// `git@host:path` is treated as port-less (the part after `:` is a path). Used only
704/// to build the optional port-qualified credential key so two instances on the same
705/// host but different ports resolve distinct credentials.
706fn explicit_port_of_git_url(url: &str) -> Option<u16> {
707    let u = url.trim();
708    if u.starts_with("git@") {
709        return None;
710    }
711    let (_scheme, after_scheme) = u.split_once("://")?;
712    let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
713    let authority = authority.rsplit('@').next().unwrap_or(authority);
714    authority.strip_prefix('[').map_or_else(
715        || {
716            authority
717                .rsplit_once(':')
718                .and_then(|(_, p)| p.parse::<u16>().ok())
719        },
720        |stripped| {
721            stripped
722                .split_once("]:")
723                .and_then(|(_, p)| p.parse::<u16>().ok())
724        },
725    )
726}
727
728/// Known cloud-metadata / instance-data hostnames that must never be reachable.
729const BLOCKED_METADATA_HOSTNAMES: &[&str] = &[
730    "metadata.google.internal",
731    "metadata.internal",
732    "instance-data",
733];
734
735/// Returns true when `host` (a hostname or IP literal) is an SSRF-sensitive
736/// loopback, link-local, unspecified, multicast, or cloud-metadata target.
737/// RFC 1918 / IPv6 unique-local private ranges are NOT blocked.
738fn is_ssrf_blocked_host(host: &str) -> bool {
739    let h = host
740        .trim()
741        .trim_start_matches('[')
742        .trim_end_matches(']')
743        .to_lowercase();
744    if h == "localhost" || BLOCKED_METADATA_HOSTNAMES.contains(&h.as_str()) {
745        return true;
746    }
747    h.parse::<std::net::IpAddr>().is_ok_and(is_ssrf_blocked_ip)
748}
749
750/// IP-level SSRF classification. Blocks loopback, link-local, unspecified,
751/// broadcast, multicast, and the Alibaba metadata IP. Allows RFC 1918 / ULA.
752fn is_ssrf_blocked_ip(ip: std::net::IpAddr) -> bool {
753    match ip {
754        std::net::IpAddr::V4(v4) => {
755            v4.is_loopback()
756                || v4.is_link_local()
757                || v4.is_unspecified()
758                || v4.is_broadcast()
759                || v4.is_multicast()
760                || v4.octets() == [100, 100, 100, 200] // Alibaba Cloud metadata
761        }
762        std::net::IpAddr::V6(v6) => {
763            v6.is_loopback()
764                || v6.is_unspecified()
765                || v6.is_multicast()
766                || (v6.segments()[0] & 0xffc0) == 0xfe80 // link-local fe80::/10
767        }
768    }
769}
770
771/// How a clone source string should be treated. The four forms are NOT interchangeable
772/// under the blobless partial-clone flags, so each gets its own clone-arg construction.
773enum GitSource {
774    /// `https://`, `http://`, `git://`, `ssh://`, `git@host:` — network transport, SSRF-gated.
775    Remote,
776    /// `file:///local/path` — regular git transport, so `--filter` / promisor work.
777    FileUrl,
778    /// A bare local filesystem path (`/path`, `C:\path`). git ignores `--filter` and
779    /// hardlinks objects for these; we force `--no-local` for safety + partial-clone parity.
780    LocalPath,
781    /// A `*.bundle` file (a static packfile) — verify then clone; `--filter` is meaningless.
782    Bundle,
783}
784
785/// Classify a (already-normalized) clone source string.
786fn classify_source(url: &str) -> GitSource {
787    let u = url.trim();
788    let lower = u.to_lowercase();
789    if lower.starts_with("https://")
790        || lower.starts_with("http://")
791        || lower.starts_with("git://")
792        || lower.starts_with("ssh://")
793        || u.starts_with("git@")
794    {
795        GitSource::Remote
796    } else if lower.starts_with("file://") {
797        GitSource::FileUrl
798    } else if lower.ends_with(".bundle") {
799        GitSource::Bundle
800    } else {
801        GitSource::LocalPath
802    }
803}
804
805/// Clone `url` into `dest`, or fetch all refs if the repo already exists.
806///
807/// Browse URLs (GitHub, GitLab, Bitbucket web pages) are automatically converted to their
808/// corresponding git clone URLs first. Remote sources are SSRF-gated and get per-host
809/// credentials from the in-app registry (see [`cred_injection`]). Local/offline sources
810/// (a git bundle, a `file://` mirror, or a local path) are only permitted when
811/// `SLOC_GIT_ALLOW_LOCAL` is set and resolve under `SLOC_GIT_LOCAL_ROOT`.
812///
813/// # Errors
814/// Returns an error if the source is rejected, the clone directory cannot be created,
815/// or the underlying `git clone` / `git fetch` command fails.
816pub fn clone_or_fetch(url: &str, dest: &Path) -> Result<()> {
817    let normalized = normalize_git_url(url);
818    let url = normalized.as_str();
819    match classify_source(url) {
820        GitSource::Remote => clone_or_fetch_remote(url, dest),
821        source => clone_or_fetch_local(url, dest, &source),
822    }
823}
824
825/// Remote clone/fetch: SSRF validation, network hardening config, per-host credential
826/// injection, blobless fast path with a full-clone fallback for servers that reject filters.
827fn clone_or_fetch_remote(url: &str, dest: &Path) -> Result<()> {
828    validate_clone_url(url)?;
829    // `network_git_config()` supplies `http.followRedirects=false` (SSRF hardening — a
830    // redirect can't escape the validated host), the low-speed abort (a stalled VPN/proxy
831    // fails fast), and optional `http.sslVerify=false` for TLS-inspecting corporate proxies.
832    let mut cfg = network_git_config();
833    // Per-host credential from the registry (HTTPS helper config and/or the secret env).
834    let inj = host_of_git_url(url)
835        .map(|h| cred_injection(&h, explicit_port_of_git_url(url)))
836        .unwrap_or_default();
837    cfg.extend(inj.config.iter().cloned());
838    let env: Vec<(&str, &str)> = inj
839        .env
840        .iter()
841        .map(|(k, v)| (k.as_str(), v.as_str()))
842        .collect();
843
844    if dest.join(".git").exists() {
845        let args = with_config(&cfg, &["fetch", "--all", "--tags", "--prune"]);
846        run_git_env(dest, &args, &env)?;
847        return Ok(());
848    }
849
850    std::fs::create_dir_all(dest).context("failed to create clone directory")?;
851    let dest_str = dest.to_str().unwrap_or(".");
852    let parent = dest.parent().unwrap_or(dest);
853
854    // Fast path: a blobless (`--filter=blob:none`), no-checkout clone. Only commit and tree
855    // metadata is downloaded — no file blobs, no working tree — which is all that ref
856    // listing needs, and is dramatically faster than a full clone on large repos and slow
857    // corporate links. File contents are fetched lazily by the promisor when a ref is later
858    // scanned into a worktree. `--no-tags` is NOT passed: the Tags tab needs them.
859    let fast = with_config(
860        &cfg,
861        &[
862            "clone",
863            "--filter=blob:none",
864            "--no-checkout",
865            "--no-single-branch",
866            url,
867            dest_str,
868        ],
869    );
870    if let Err(e) = run_git_env(parent, &fast, &env) {
871        // A handful of older self-hosted servers (e.g. legacy Bitbucket Server) reject
872        // object filtering outright instead of degrading to a full clone. Only in that
873        // specific case do we clean up the partial directory and retry without the filter —
874        // a genuine network/auth failure is surfaced directly rather than paying a second
875        // timeout.
876        let msg = e.to_string().to_lowercase();
877        if !(msg.contains("filter") || msg.contains("partial")) {
878            return Err(e);
879        }
880        let _ = std::fs::remove_dir_all(dest);
881        std::fs::create_dir_all(dest).context("failed to re-create clone directory")?;
882        let full = with_config(
883            &cfg,
884            &[
885                "clone",
886                "--no-checkout",
887                "--no-single-branch",
888                url,
889                dest_str,
890            ],
891        );
892        run_git_env(parent, &full, &env)?;
893    }
894    persist_repo_config(dest, &cfg);
895    Ok(())
896}
897
898/// Local/offline clone from a git bundle, a `file://` mirror, or a local path. Gated by
899/// `SLOC_GIT_ALLOW_LOCAL` + `SLOC_GIT_LOCAL_ROOT`; no network, no credentials, no SSRF risk
900/// once the source is confirmed to resolve under the configured root.
901fn clone_or_fetch_local(url: &str, dest: &Path, source: &GitSource) -> Result<()> {
902    let src = validate_local_source(url, source)?;
903    let cfg = network_git_config();
904    if dest.join(".git").exists() {
905        let args = with_config(&cfg, &["fetch", "--all", "--tags", "--prune"]);
906        run_git(dest, &args)?;
907        return Ok(());
908    }
909    std::fs::create_dir_all(dest).context("failed to create clone directory")?;
910    let dest_str = dest.to_str().unwrap_or(".");
911    let parent = dest.parent().unwrap_or(dest);
912
913    let tail: Vec<&str> = match source {
914        // A bundle is a self-contained packfile — a plain no-checkout clone. `git clone`
915        // validates the bundle itself (a corrupt/incomplete bundle fails the clone), and
916        // `git bundle verify` can't run here (it needs an existing repository). `--filter`
917        // has no promisor remote to defer to, so it is intentionally omitted.
918        GitSource::Bundle => vec!["clone", "--no-checkout", &src, dest_str],
919        // `file://` uses the regular git transport, so partial clone + promisor work as remote.
920        GitSource::FileUrl => vec![
921            "clone",
922            "--filter=blob:none",
923            "--no-checkout",
924            "--no-single-branch",
925            &src,
926            dest_str,
927        ],
928        // A bare local path defaults to `--local` (hardlink/copy, ignores `--filter`, and
929        // historically dereferences symlinks in objects/). Force `--no-local` for the safe
930        // copy transport, which also re-enables `--filter`.
931        GitSource::LocalPath => vec![
932            "clone",
933            "--no-local",
934            "--filter=blob:none",
935            "--no-checkout",
936            "--no-single-branch",
937            &src,
938            dest_str,
939        ],
940        GitSource::Remote => unreachable!("remote sources are handled by clone_or_fetch_remote"),
941    };
942    let args = with_config(&cfg, &tail);
943    run_git(parent, &args)?;
944    persist_repo_config(dest, &cfg);
945    Ok(())
946}
947
948/// Publish the contents of `src_dir` into `repo_url` on `branch`, under `subdir`, as a single
949/// commit, then push. `work_dir` is a caller-owned empty scratch directory used as the clone
950/// working tree.
951///
952/// Reuses the exact same SSRF validation, host-allowlist, per-host credential injection, and
953/// network-hardening (`http.followRedirects=false`, low-speed abort) as [`clone_or_fetch`], so
954/// it is safe to point at a corporate host over a VPN or at an air-gapped local mirror (the
955/// latter behind `SLOC_GIT_ALLOW_LOCAL` / `SLOC_GIT_LOCAL_ROOT`). Appends to the branch's
956/// existing history when it exists, creates it (even from an empty repo's unborn HEAD)
957/// otherwise. Returns `Ok(())` without pushing when nothing changed since the last publish.
958///
959/// # Errors
960/// Propagates clone/commit/push failures and URL-validation / local-gate rejections.
961pub fn publish_dir(
962    repo_url: &str,
963    branch: &str,
964    subdir: &str,
965    src_dir: &Path,
966    message: &str,
967    work_dir: &Path,
968) -> Result<()> {
969    let normalized = normalize_git_url(repo_url);
970    let url = normalized.as_str();
971
972    // Resolve git config + credential env exactly as a clone would, honoring the source gates.
973    let (cfg, env_owned): (Vec<String>, Vec<(String, String)>) = match classify_source(url) {
974        GitSource::Remote => {
975            validate_clone_url(url)?;
976            let mut cfg = network_git_config();
977            let inj = host_of_git_url(url)
978                .map(|h| cred_injection(&h, explicit_port_of_git_url(url)))
979                .unwrap_or_default();
980            cfg.extend(inj.config.iter().cloned());
981            (cfg, inj.env)
982        }
983        source => {
984            validate_local_source(url, &source)?;
985            (network_git_config(), Vec::new())
986        }
987    };
988    let env: Vec<(&str, &str)> = env_owned
989        .iter()
990        .map(|(k, v)| (k.as_str(), v.as_str()))
991        .collect();
992
993    std::fs::create_dir_all(work_dir).context("failed to create publish work dir")?;
994    let dest_str = work_dir
995        .to_str()
996        .context("publish work dir path is not valid UTF-8")?;
997    let parent = work_dir.parent().unwrap_or(work_dir);
998
999    // Full checkout clone: we need a working tree to add files onto the existing history.
1000    let clone_args = with_config(&cfg, &["clone", url, dest_str]);
1001    run_git_env(parent, &clone_args, &env)?;
1002    persist_repo_config(work_dir, &cfg);
1003
1004    // Land on the target branch: base on its remote head when present (append), else create
1005    // it fresh — including from an empty repo's unborn HEAD.
1006    let origin_ref = format!("origin/{branch}");
1007    if run_git(work_dir, &["rev-parse", "--verify", "--quiet", &origin_ref]).is_ok() {
1008        run_git(work_dir, &["checkout", "-B", branch, &origin_ref])?;
1009    } else {
1010        run_git(work_dir, &["checkout", "-B", branch])?;
1011    }
1012
1013    // Replace the destination subdir with the fresh artifacts.
1014    let target = if subdir.is_empty() {
1015        work_dir.to_path_buf()
1016    } else {
1017        work_dir.join(subdir)
1018    };
1019    if target != work_dir && target.exists() {
1020        std::fs::remove_dir_all(&target).ok();
1021    }
1022    copy_dir_all(src_dir, &target)?;
1023
1024    run_git(work_dir, &["add", "-A"])?;
1025    if run_git(work_dir, &["status", "--porcelain"])?
1026        .trim()
1027        .is_empty()
1028    {
1029        return Ok(()); // nothing changed since the last publish — skip the empty commit/push
1030    }
1031    run_git(
1032        work_dir,
1033        &[
1034            "-c",
1035            "user.email=oxide-sloc@localhost",
1036            "-c",
1037            "user.name=oxide-sloc",
1038            "commit",
1039            "-m",
1040            message,
1041        ],
1042    )?;
1043    let refspec = format!("HEAD:refs/heads/{branch}");
1044    let push_args = with_config(&cfg, &["push", "origin", &refspec]);
1045    run_git_env(work_dir, &push_args, &env)?;
1046    Ok(())
1047}
1048
1049/// Recursively copy a directory tree (files + subdirs), skipping symlinks so a link inside a
1050/// scanned/uploaded artifact tree cannot redirect the copy or loop. Local to `sloc-git` so
1051/// [`publish_dir`] needs no dependency on `sloc-core`.
1052fn copy_dir_all(src: &Path, dest: &Path) -> Result<()> {
1053    std::fs::create_dir_all(dest)?;
1054    for entry in std::fs::read_dir(src)?.flatten() {
1055        let ft = entry.file_type()?;
1056        if ft.is_symlink() {
1057            continue;
1058        }
1059        let to = dest.join(entry.file_name());
1060        if ft.is_dir() {
1061            copy_dir_all(&entry.path(), &to)?;
1062        } else if ft.is_file() {
1063            std::fs::copy(entry.path(), &to)?;
1064        }
1065    }
1066    Ok(())
1067}
1068
1069/// Validate a local/offline source against the fail-closed gate and return the filesystem
1070/// path git should clone from. Enforces: `SLOC_GIT_ALLOW_LOCAL` on; `SLOC_GIT_LOCAL_ROOT`
1071/// set; `file://` has no host authority; no UNC (SMB is a network fetch, not local); and the
1072/// canonicalized source resolves under the configured root (defeats `..`/symlink traversal).
1073fn validate_local_source(url: &str, source: &GitSource) -> Result<String> {
1074    if !allow_local() {
1075        bail!(
1076            "local/offline git source rejected: set SLOC_GIT_ALLOW_LOCAL=1 to enable bundle / \
1077             file:// / local-path imports (got {url:?})"
1078        );
1079    }
1080    let Some(root) = local_root() else {
1081        bail!(
1082            "SLOC_GIT_ALLOW_LOCAL is set but SLOC_GIT_LOCAL_ROOT is not — refusing local import \
1083             (fail-closed). Point SLOC_GIT_LOCAL_ROOT at the directory holding your bundles/mirrors."
1084        );
1085    };
1086
1087    let raw = url.trim();
1088    let path = match source {
1089        GitSource::FileUrl => file_url_to_path(raw)?,
1090        _ => raw.to_owned(),
1091    };
1092    // UNC (`\\server\share` or `//server/share`) is an SMB fetch to an attacker-chosen host —
1093    // that is remote (SSRF + credential-leak), not local. Never accept it under the local gate.
1094    if path.starts_with("\\\\") || path.starts_with("//") {
1095        bail!("UNC path rejected: SMB shares are network sources, not local ({url:?})");
1096    }
1097
1098    let canon = std::fs::canonicalize(&path)
1099        .with_context(|| format!("local git source not found or unreadable: {path:?}"))?;
1100    let root_canon = std::fs::canonicalize(&root)
1101        .with_context(|| format!("SLOC_GIT_LOCAL_ROOT not found: {}", root.display()))?;
1102    if !canon.starts_with(&root_canon) {
1103        bail!(
1104            "local git source {} is outside SLOC_GIT_LOCAL_ROOT {}",
1105            canon.display(),
1106            root_canon.display()
1107        );
1108    }
1109    // Strip any Windows verbatim (`\\?\`) prefix so git accepts the path.
1110    Ok(deverbatim(&canon))
1111}
1112
1113/// Convert a `file://` URL to a local filesystem path, rejecting a non-empty host authority
1114/// (`file://host/path` is an SMB/UNC fetch on Windows — treated as remote and refused).
1115/// Handles `file:///home/x` → `/home/x` and `file:///C:/x` → `C:/x`.
1116fn file_url_to_path(url: &str) -> Result<String> {
1117    let rest = &url.trim()[7..]; // strip "file://"
1118    if !rest.starts_with('/') {
1119        bail!(
1120            "file:// URL with a host authority is not permitted (use file:///local/path): {url:?}"
1121        );
1122    }
1123    // Drop exactly one leading slash for a Windows drive path (`/C:/x` → `C:/x`); keep the
1124    // rooted slash for a POSIX path (`/home/x`).
1125    let after = &rest[1..];
1126    if after.len() >= 2 && after.as_bytes()[1] == b':' {
1127        Ok(after.to_owned())
1128    } else {
1129        Ok(rest.to_owned())
1130    }
1131}
1132
1133/// Strip a Windows verbatim path prefix (`\\?\`) that `std::fs::canonicalize` adds, since
1134/// git does not accept it. No-op on other platforms / non-verbatim paths.
1135fn deverbatim(p: &Path) -> String {
1136    let s = p.to_string_lossy();
1137    s.strip_prefix(r"\\?\").unwrap_or(&s).to_owned()
1138}
1139
1140/// Resolve `ref_name` to its full SHA in `repo`.
1141///
1142/// # Errors
1143/// Returns an error if `git rev-parse` fails (e.g. the ref does not exist).
1144pub fn get_sha(repo: &Path, ref_name: &str) -> Result<String> {
1145    run_git(repo, &["rev-parse", ref_name])
1146}
1147
1148// ── local repository detection ────────────────────────────────────────────────
1149
1150/// True when `s` points at an existing local git repository — a directory containing a
1151/// `.git` entry (working tree or worktree/submodule pointer) or a bare repository dir.
1152///
1153/// This is a cheap filesystem check (no subprocess, no network) used to branch the Git
1154/// Browser between the local flow (operate on the repo in place) and the remote flow (clone
1155/// the URL). Anything that looks like a remote URL scheme is deliberately rejected so a
1156/// `file://`, `https://`, or `git@` string is never mistaken for a local path.
1157#[must_use]
1158pub fn is_local_repo_path(s: &str) -> bool {
1159    let t = s.trim();
1160    if t.is_empty() {
1161        return false;
1162    }
1163    let lower = t.to_lowercase();
1164    if lower.starts_with("https://")
1165        || lower.starts_with("http://")
1166        || lower.starts_with("git://")
1167        || lower.starts_with("ssh://")
1168        || lower.starts_with("file://")
1169        || t.starts_with("git@")
1170    {
1171        return false;
1172    }
1173    let p = Path::new(t);
1174    if !p.is_dir() {
1175        return false;
1176    }
1177    // Non-bare repo (or a linked worktree / submodule): a `.git` dir or `.git` file.
1178    // Bare repo: has `HEAD` plus an `objects/` directory at the top level.
1179    p.join(".git").exists() || (p.join("HEAD").is_file() && p.join("objects").is_dir())
1180}
1181
1182/// Verify that `path` is inside a git repository and return the canonical repository root
1183/// (the working-tree top level, or the given path for a bare repo). Runs no network I/O.
1184///
1185/// # Errors
1186/// Returns an error if `path` is not a git repository.
1187pub fn open_local_repo(path: &Path) -> Result<PathBuf> {
1188    // `--show-toplevel` yields the working-tree root for a normal repo; it fails for a bare
1189    // repo, in which case the path itself is the repository.
1190    if let Ok(top) = run_git(path, &["rev-parse", "--show-toplevel"])
1191        && !top.trim().is_empty()
1192    {
1193        return Ok(PathBuf::from(top.trim()));
1194    }
1195    // Bare repo (or worktree without a toplevel): confirm it really is a git dir.
1196    run_git(path, &["rev-parse", "--git-dir"]).context("not a git repository")?;
1197    Ok(std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()))
1198}
1199
1200// ── worktree helpers ──────────────────────────────────────────────────────────
1201
1202/// Resolve a user-facing ref name to a concrete commit SHA the worktree/scan commands accept.
1203///
1204/// A clone only materialises a *local* branch for the repository's default branch;
1205/// every other branch exists solely as a remote-tracking ref (`refs/remotes/origin/<name>`).
1206/// Ref listing strips the `origin/` prefix for display, so a bare branch name like "test"
1207/// won't resolve directly — we fall back to the remote-tracking form. Tags and raw SHAs
1208/// resolve on the first candidate. Peeling with `^{commit}` also dereferences annotated tags.
1209///
1210/// # Errors
1211/// Returns an error if none of the candidate spellings resolve to a commit.
1212pub fn resolve_committish(repo: &Path, ref_name: &str) -> Result<String> {
1213    let candidates = [
1214        ref_name.to_owned(),
1215        format!("origin/{ref_name}"),
1216        format!("refs/remotes/origin/{ref_name}"),
1217    ];
1218    for cand in &candidates {
1219        let spec = format!("{cand}^{{commit}}");
1220        if let Ok(sha) = run_git(repo, &["rev-parse", "--verify", "-q", &spec])
1221            && !sha.is_empty()
1222        {
1223            return Ok(sha);
1224        }
1225    }
1226    bail!(
1227        "ref {ref_name:?} not found in repository (tried it directly, as origin/{ref_name}, \
1228         and as refs/remotes/origin/{ref_name})"
1229    );
1230}
1231
1232/// Create a detached worktree at `worktree_path` pointing at `ref_name`.
1233///
1234/// `ref_name` is resolved via [`resolve_committish`] first, so a bare branch name that
1235/// only exists as a remote-tracking ref (every branch except the default one, in a fresh
1236/// clone) still checks out correctly instead of failing with "invalid reference".
1237///
1238/// # Errors
1239/// Returns an error if `ref_name` cannot be resolved or `git worktree add` fails.
1240pub fn create_worktree(repo: &Path, ref_name: &str, worktree_path: &Path) -> Result<()> {
1241    let wt = worktree_path.to_str().unwrap_or(".");
1242    let committish = resolve_committish(repo, ref_name)?;
1243    // A blobless clone fetches file contents lazily: `worktree add` triggers a promisor
1244    // fetch against origin. That fetch needs the same per-host credential the clone used, so
1245    // re-resolve it from the repo's origin URL and pass the secret env through (the helper
1246    // config itself is already persisted in the repo config by `persist_repo_config`).
1247    let env = cred_env_for_repo(repo);
1248    let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
1249    run_git_env(
1250        repo,
1251        &["worktree", "add", "--detach", wt, &committish],
1252        &env_refs,
1253    )?;
1254    Ok(())
1255}
1256
1257/// Resolve the per-host credential *env* (secret) for an already-cloned repo by reading its
1258/// `remote.origin.url`. Returns empty when there is no origin, no host, or no registry match
1259/// (local/offline clones, or hosts that use git's own credential resolution).
1260fn cred_env_for_repo(repo: &Path) -> Vec<(String, String)> {
1261    let Ok(url) = run_git(repo, &["config", "--get", "remote.origin.url"]) else {
1262        return Vec::new();
1263    };
1264    let Some(host) = host_of_git_url(&url) else {
1265        return Vec::new();
1266    };
1267    cred_injection(&host, explicit_port_of_git_url(&url)).env
1268}
1269
1270/// Remove a worktree previously created with [`create_worktree`].
1271///
1272/// # Errors
1273/// This function always succeeds; the underlying git command failure is intentionally ignored.
1274pub fn destroy_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
1275    let wt = worktree_path.to_str().unwrap_or(".");
1276    let _ = run_git(repo, &["worktree", "remove", "--force", wt]);
1277    Ok(())
1278}
1279
1280// ── submodule population ───────────────────────────────────────────────────────
1281
1282/// Recursively check out a super-repo's submodules inside `worktree` so their files are
1283/// present for analysis. Best-effort: a repo with no `.gitmodules` is a no-op, and a
1284/// submodule whose fetch fails simply stays empty (its per-submodule breakdown is then blank)
1285/// rather than failing the whole scan.
1286///
1287/// Security: `git submodule update` would clone whatever URLs `.gitmodules` records, which
1288/// bypasses the SSRF gate that guards the top-level clone. Every recorded submodule URL is
1289/// therefore validated with the same [`validate_clone_url`] check first; a submodule whose URL
1290/// is rejected is skipped (and its name returned) instead of being fetched. Relative submodule
1291/// URLs resolve against the superproject origin and cannot target a new host, so they are
1292/// allowed; absolute local/`file://` submodule references are refused (a legitimate local
1293/// super-repo references its submodules by relative path).
1294///
1295/// Returns the names of submodules that were skipped as unsafe.
1296///
1297/// # Errors
1298/// Returns an error only if the working tree cannot be inspected; a failed submodule fetch is
1299/// swallowed (best-effort).
1300pub fn populate_submodules(worktree: &Path) -> Result<Vec<String>> {
1301    if !worktree.join(".gitmodules").is_file() {
1302        return Ok(Vec::new());
1303    }
1304    let mut safe_paths: Vec<String> = Vec::new();
1305    let mut skipped: Vec<String> = Vec::new();
1306    for name in submodule_names(worktree) {
1307        let url = gitmodules_value(worktree, &name, "url");
1308        let path = gitmodules_value(worktree, &name, "path");
1309        if url.trim().is_empty() || path.trim().is_empty() {
1310            continue;
1311        }
1312        if submodule_url_is_safe(url.trim()) {
1313            safe_paths.push(path.trim().to_owned());
1314        } else {
1315            skipped.push(name);
1316        }
1317    }
1318    if safe_paths.is_empty() {
1319        return Ok(skipped);
1320    }
1321    // Update only the vetted submodule paths, recursively. `network_git_config()` carries the
1322    // same redirect/low-speed/TLS hardening the clone used. Nested submodules are a documented
1323    // residual: only the top-level URLs are validated here.
1324    let cfg = network_git_config();
1325    let mut tail: Vec<&str> = vec!["submodule", "update", "--init", "--recursive", "--"];
1326    tail.extend(safe_paths.iter().map(String::as_str));
1327    let args = with_config(&cfg, &tail);
1328    let _ = run_git(worktree, &args); // best-effort: leave any failing submodule empty
1329    Ok(skipped)
1330}
1331
1332/// Names of the submodules declared in `<worktree>/.gitmodules`.
1333fn submodule_names(worktree: &Path) -> Vec<String> {
1334    let out = run_git(
1335        worktree,
1336        &[
1337            "config",
1338            "-f",
1339            ".gitmodules",
1340            "--name-only",
1341            "--get-regexp",
1342            r"^submodule\..*\.path$",
1343        ],
1344    )
1345    .unwrap_or_default();
1346    out.lines()
1347        .filter_map(|l| {
1348            l.trim()
1349                .strip_prefix("submodule.")
1350                .and_then(|s| s.strip_suffix(".path"))
1351                .map(str::to_owned)
1352        })
1353        .collect()
1354}
1355
1356/// Read `submodule.<name>.<key>` from the worktree's `.gitmodules`.
1357fn gitmodules_value(worktree: &Path, name: &str, key: &str) -> String {
1358    run_git(
1359        worktree,
1360        &[
1361            "config",
1362            "-f",
1363            ".gitmodules",
1364            "--get",
1365            &format!("submodule.{name}.{key}"),
1366        ],
1367    )
1368    .unwrap_or_default()
1369}
1370
1371/// Whether a `.gitmodules` submodule URL is safe to fetch. Relative URLs (same origin host)
1372/// are allowed; remote URLs go through the SSRF gate; absolute local/`file://`/bundle URLs are
1373/// refused.
1374fn submodule_url_is_safe(url: &str) -> bool {
1375    let u = url.trim();
1376    if u.is_empty() {
1377        return false;
1378    }
1379    if u.starts_with('.') {
1380        return true;
1381    }
1382    let norm = normalize_git_url(u);
1383    match classify_source(&norm) {
1384        GitSource::Remote => validate_clone_url(&norm).is_ok(),
1385        _ => false,
1386    }
1387}
1388
1389// ── ref listing ───────────────────────────────────────────────────────────────
1390
1391/// Return all branches, tags, and recent commits for `repo`.
1392///
1393/// # Errors
1394/// Returns an error if any underlying git command fails.
1395pub fn list_refs(repo: &Path) -> Result<RepoRefs> {
1396    Ok(RepoRefs {
1397        branches: list_branches(repo)?,
1398        tags: list_tags(repo)?,
1399        recent_commits: list_commits(repo, "HEAD", 40)?,
1400    })
1401}
1402
1403/// Like [`list_refs`], but for a repository operated on **in place** (a local checkout the
1404/// user pointed us at). It lists the repo's *local* branch heads (`git branch`) rather than
1405/// remote-tracking refs (`git branch -r`), because a working repo's branches of interest are
1406/// its local heads. Tags and recent commits are listed identically to [`list_refs`].
1407///
1408/// # Errors
1409/// Returns an error if any underlying git command fails.
1410pub fn list_refs_local(repo: &Path) -> Result<RepoRefs> {
1411    Ok(RepoRefs {
1412        branches: list_branches_local(repo)?,
1413        tags: list_tags(repo)?,
1414        recent_commits: list_commits(repo, "HEAD", 40)?,
1415    })
1416}
1417
1418/// List the local branch heads of `repo` (no remote-tracking refs, no `origin/` prefix).
1419fn list_branches_local(repo: &Path) -> Result<Vec<GitRef>> {
1420    let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
1421    let out = run_git(repo, &["branch", &format!("--format={fmt}")])?;
1422    Ok(out
1423        .lines()
1424        .filter(|l| !l.trim().is_empty())
1425        .map(|l| parse_ref_line(l, GitRefKind::Branch))
1426        .collect())
1427}
1428
1429fn list_branches(repo: &Path) -> Result<Vec<GitRef>> {
1430    // `%(symref)` is the leading column and is non-empty only for symbolic refs such as the
1431    // remote's default-branch pointer `origin/HEAD`. We must filter on it rather than on the
1432    // ref name: `%(refname:short)` collapses `refs/remotes/origin/HEAD` down to bare `origin`,
1433    // which is neither "HEAD" nor "*/HEAD", so a name-based filter lets it through and renders
1434    // a phantom duplicate of the default branch (same SHA, displayed as "origin").
1435    let fmt = "%(symref)|%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
1436    // Use -r (remote-tracking only) to avoid local/remote duplicates.
1437    // Strip the leading remote name (e.g. "origin/") from each ref so the
1438    // displayed name matches what the upstream repository calls the branch.
1439    let out = run_git(repo, &["branch", "-r", &format!("--format={fmt}")])?;
1440    let refs = out
1441        .lines()
1442        .filter(|l| !l.trim().is_empty())
1443        // Split off the symref column; skip the line entirely when it is a symbolic ref.
1444        .filter_map(|l| {
1445            let (symref, rest) = l.split_once('|')?;
1446            if symref.trim().is_empty() {
1447                Some(rest)
1448            } else {
1449                None
1450            }
1451        })
1452        .map(|l| parse_ref_line(l, GitRefKind::Branch))
1453        .map(|mut r| {
1454            // Strip the remote prefix ("origin/", "upstream/", etc.).
1455            if let Some(slash) = r.name.find('/') {
1456                r.name = r.name[slash + 1..].to_owned();
1457            }
1458            r
1459        })
1460        .collect::<Vec<_>>();
1461    Ok(refs)
1462}
1463
1464fn list_tags(repo: &Path) -> Result<Vec<GitRef>> {
1465    let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
1466    let out = run_git(
1467        repo,
1468        &["tag", "--sort=-creatordate", &format!("--format={fmt}")],
1469    )?;
1470    Ok(out
1471        .lines()
1472        .filter(|l| !l.trim().is_empty())
1473        .map(|l| parse_ref_line(l, GitRefKind::Tag))
1474        .collect())
1475}
1476
1477fn parse_ref_line(line: &str, kind: GitRefKind) -> GitRef {
1478    let parts: Vec<&str> = line.splitn(4, '|').collect();
1479    let name = parts.first().copied().unwrap_or("").to_owned();
1480    let sha = parts.get(1).copied().unwrap_or("").to_owned();
1481    let date = parts.get(2).copied().and_then(parse_git_date);
1482    let message = parts.get(3).map(|s| (*s).to_owned());
1483    GitRef {
1484        kind,
1485        name,
1486        sha,
1487        date,
1488        message,
1489    }
1490}
1491
1492// ── commit listing ────────────────────────────────────────────────────────────
1493
1494/// Return up to `limit` commits reachable from `ref_name`.
1495///
1496/// # Errors
1497/// Returns an error if `git log` fails.
1498pub fn list_commits(repo: &Path, ref_name: &str, limit: usize) -> Result<Vec<GitCommit>> {
1499    let fmt = "%H|%h|%an|%aI|%s";
1500    let n = format!("-{limit}");
1501    let out = run_git(repo, &["log", ref_name, &format!("--format={fmt}"), &n])?;
1502    Ok(out
1503        .lines()
1504        .filter(|l| !l.trim().is_empty())
1505        .map(parse_commit_line)
1506        .collect())
1507}
1508
1509fn parse_commit_line(line: &str) -> GitCommit {
1510    let p: Vec<&str> = line.splitn(5, '|').collect();
1511    let sha = p.first().copied().unwrap_or("").to_owned();
1512    let short_sha = p.get(1).copied().unwrap_or("").to_owned();
1513    let author = p.get(2).copied().unwrap_or("").to_owned();
1514    let date = p
1515        .get(3)
1516        .copied()
1517        .and_then(parse_git_date)
1518        .unwrap_or_default();
1519    let subject = p.get(4).copied().unwrap_or("").to_owned();
1520    GitCommit {
1521        sha,
1522        short_sha,
1523        author,
1524        date,
1525        subject,
1526    }
1527}
1528
1529fn parse_git_date(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
1530    chrono::DateTime::parse_from_rfc3339(s)
1531        .ok()
1532        .map(|d| d.with_timezone(&chrono::Utc))
1533}
1534
1535/// Serializes tests that mutate process-global env vars (credential registry / local-import
1536/// gate settings), which would otherwise race under the parallel test runner. Shared by both
1537/// the `tests` and `git_integration` modules.
1538#[cfg(test)]
1539static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1540
1541/// Acquire [`ENV_LOCK`], tolerating a prior panic (poisoning) so one failing env-mutating
1542/// test doesn't cascade into spurious `PoisonError` failures that hide the real cause.
1543#[cfg(test)]
1544fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1545    ENV_LOCK
1546        .lock()
1547        .unwrap_or_else(std::sync::PoisonError::into_inner)
1548}
1549
1550#[cfg(test)]
1551mod tests {
1552    use super::*;
1553    use crate::GitRefKind;
1554    use chrono::Timelike as _;
1555
1556    // ── SSRF host classification ───────────────────────────────────────────────
1557
1558    #[test]
1559    fn is_ssrf_blocked_host_blocks_localhost_and_metadata() {
1560        assert!(is_ssrf_blocked_host("localhost"));
1561        assert!(is_ssrf_blocked_host("metadata.google.internal"));
1562        assert!(is_ssrf_blocked_host("metadata.internal"));
1563        assert!(is_ssrf_blocked_host("instance-data"));
1564        // Case/whitespace/bracket normalisation.
1565        assert!(is_ssrf_blocked_host("  LOCALHOST  "));
1566        // IP literals: loopback and link-local blocked.
1567        assert!(is_ssrf_blocked_host("127.0.0.1"));
1568        assert!(is_ssrf_blocked_host("[::1]"));
1569        assert!(is_ssrf_blocked_host("169.254.169.254"));
1570    }
1571
1572    #[test]
1573    fn require_host_allowlist_defaults_false() {
1574        // With SLOC_GIT_REQUIRE_ALLOWLIST unset, allowlist enforcement is off.
1575        assert!(!require_host_allowlist());
1576    }
1577
1578    #[test]
1579    fn check_host_allowed_denylist_mode_permits_public_blocks_sensitive() {
1580        // Empty allowlist + enforcement off: public hosts pass, SSRF-sensitive hosts fail.
1581        assert!(check_host_allowed("github.com").is_ok());
1582        assert!(check_host_allowed("localhost").is_err());
1583    }
1584
1585    #[test]
1586    fn is_ssrf_blocked_host_allows_public_hosts() {
1587        assert!(!is_ssrf_blocked_host("github.com"));
1588        assert!(!is_ssrf_blocked_host("example.com"));
1589        // RFC 1918 private ranges are intentionally NOT blocked.
1590        assert!(!is_ssrf_blocked_host("192.168.1.10"));
1591        assert!(!is_ssrf_blocked_host("10.0.0.1"));
1592    }
1593
1594    // ── network config helpers ────────────────────────────────────────────────
1595
1596    #[test]
1597    fn network_git_config_always_hardens_redirects_and_lowspeed() {
1598        let cfg = network_git_config();
1599        assert!(cfg.iter().any(|c| c == "http.followRedirects=false"));
1600        assert!(cfg.iter().any(|c| c == "http.lowSpeedLimit=1000"));
1601        assert!(cfg.iter().any(|c| c == "http.lowSpeedTime=30"));
1602    }
1603
1604    #[cfg(windows)]
1605    #[test]
1606    fn network_git_config_uses_schannel_on_windows() {
1607        // On Windows we validate against the system certificate store so corporate
1608        // root CAs are trusted automatically — no SLOC_GIT_SSL_NO_VERIFY required.
1609        let cfg = network_git_config();
1610        assert!(cfg.iter().any(|c| c == "http.sslBackend=schannel"));
1611    }
1612
1613    #[test]
1614    fn with_config_interleaves_dash_c_pairs_before_tail() {
1615        let cfg = vec!["a=1".to_owned(), "b=2".to_owned()];
1616        let args = with_config(&cfg, &["clone", "url", "dest"]);
1617        assert_eq!(args, vec!["-c", "a=1", "-c", "b=2", "clone", "url", "dest"]);
1618    }
1619
1620    #[test]
1621    fn with_config_empty_cfg_is_just_the_tail() {
1622        let cfg: Vec<String> = Vec::new();
1623        assert_eq!(with_config(&cfg, &["fetch"]), vec!["fetch"]);
1624    }
1625
1626    #[test]
1627    fn git_timeout_is_positive() {
1628        // Default (or env-provided) timeout is always a positive duration.
1629        assert!(git_timeout().as_secs() > 0);
1630    }
1631
1632    // ── normalize_git_url ─────────────────────────────────────────────────────
1633
1634    #[test]
1635    fn normalize_github_tree_url() {
1636        assert_eq!(
1637            normalize_git_url("https://github.com/owner/repo/tree/main"),
1638            "https://github.com/owner/repo.git"
1639        );
1640    }
1641
1642    #[test]
1643    fn normalize_github_blob_url() {
1644        assert_eq!(
1645            normalize_git_url("https://github.com/owner/repo/blob/main/README.md"),
1646            "https://github.com/owner/repo.git"
1647        );
1648    }
1649
1650    #[test]
1651    fn normalize_github_commits_url() {
1652        assert_eq!(
1653            normalize_git_url("https://github.com/owner/repo/commits/main"),
1654            "https://github.com/owner/repo.git"
1655        );
1656    }
1657
1658    #[test]
1659    fn normalize_github_releases_url() {
1660        assert_eq!(
1661            normalize_git_url("https://github.com/owner/repo/releases"),
1662            "https://github.com/owner/repo.git"
1663        );
1664    }
1665
1666    #[test]
1667    fn normalize_github_tags_url() {
1668        assert_eq!(
1669            normalize_git_url("https://github.com/owner/repo/tags"),
1670            "https://github.com/owner/repo.git"
1671        );
1672    }
1673
1674    #[test]
1675    fn normalize_github_branches_url() {
1676        assert_eq!(
1677            normalize_git_url("https://github.com/owner/repo/branches"),
1678            "https://github.com/owner/repo.git"
1679        );
1680    }
1681
1682    #[test]
1683    fn normalize_github_plain_clone_url_unchanged() {
1684        let url = "https://github.com/owner/repo.git";
1685        assert_eq!(normalize_git_url(url), url);
1686    }
1687
1688    #[test]
1689    fn normalize_gitlab_tree_url() {
1690        assert_eq!(
1691            normalize_git_url("https://gitlab.com/group/subgroup/repo/-/tree/main"),
1692            "https://gitlab.com/group/subgroup/repo.git"
1693        );
1694    }
1695
1696    #[test]
1697    fn normalize_gitlab_blob_url() {
1698        assert_eq!(
1699            normalize_git_url("https://gitlab.com/org/repo/-/blob/main/src/lib.rs"),
1700            "https://gitlab.com/org/repo.git"
1701        );
1702    }
1703
1704    #[test]
1705    fn normalize_gitlab_self_hosted() {
1706        assert_eq!(
1707            normalize_git_url("https://gitlab.corp.com/team/project/-/tree/develop"),
1708            "https://gitlab.corp.com/team/project.git"
1709        );
1710    }
1711
1712    #[test]
1713    fn normalize_bitbucket_server_browse_url() {
1714        assert_eq!(
1715            normalize_git_url("https://bitbucket.corp.com/projects/MYPROJ/repos/myrepo/browse"),
1716            "https://bitbucket.corp.com/scm/myproj/myrepo.git"
1717        );
1718    }
1719
1720    #[test]
1721    fn normalize_bitbucket_server_with_context() {
1722        assert_eq!(
1723            normalize_git_url("https://host.com/ctx/projects/PROJ/repos/repo/browse"),
1724            "https://host.com/ctx/scm/proj/repo.git"
1725        );
1726    }
1727
1728    #[test]
1729    fn normalize_bitbucket_cloud_src_url() {
1730        assert_eq!(
1731            normalize_git_url("https://bitbucket.org/workspace/repo/src/main/README.md"),
1732            "https://bitbucket.org/workspace/repo.git"
1733        );
1734    }
1735
1736    #[test]
1737    fn normalize_ssh_url_unchanged() {
1738        let url = "git@github.com:owner/repo.git";
1739        assert_eq!(normalize_git_url(url), url);
1740    }
1741
1742    #[test]
1743    fn normalize_ssh_protocol_url_unchanged() {
1744        let url = "ssh://git@github.com/owner/repo.git";
1745        assert_eq!(normalize_git_url(url), url);
1746    }
1747
1748    #[test]
1749    fn normalize_trims_leading_trailing_whitespace() {
1750        assert_eq!(
1751            normalize_git_url("  https://github.com/owner/repo/tree/main  "),
1752            "https://github.com/owner/repo.git"
1753        );
1754    }
1755
1756    #[test]
1757    fn normalize_http_url_without_match_returned_unchanged() {
1758        let url = "http://internal.corp.com/repo.git";
1759        assert_eq!(normalize_git_url(url), url);
1760    }
1761
1762    // ── validate_clone_url ────────────────────────────────────────────────────
1763
1764    #[test]
1765    fn validate_https_url_ok() {
1766        assert!(validate_clone_url("https://github.com/owner/repo.git").is_ok());
1767    }
1768
1769    #[test]
1770    fn validate_git_protocol_url_ok() {
1771        assert!(validate_clone_url("git://github.com/owner/repo.git").is_ok());
1772    }
1773
1774    #[test]
1775    fn validate_ssh_protocol_url_ok() {
1776        assert!(validate_clone_url("ssh://git@github.com/owner/repo.git").is_ok());
1777    }
1778
1779    #[test]
1780    fn validate_git_at_url_ok() {
1781        assert!(validate_clone_url("git@github.com:owner/repo.git").is_ok());
1782    }
1783
1784    #[test]
1785    fn validate_http_plain_rejected() {
1786        assert!(
1787            validate_clone_url("http://github.com/owner/repo.git").is_err(),
1788            "plain http:// must be rejected"
1789        );
1790    }
1791
1792    #[test]
1793    fn validate_link_local_169_254_rejected() {
1794        assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
1795    }
1796
1797    #[test]
1798    fn validate_google_metadata_endpoint_rejected() {
1799        assert!(
1800            validate_clone_url("https://metadata.google.internal/computeMetadata/v1/").is_err()
1801        );
1802    }
1803
1804    #[test]
1805    fn validate_alibaba_metadata_rejected() {
1806        assert!(validate_clone_url("https://100.100.100.200/latest/meta-data/").is_err());
1807    }
1808
1809    #[test]
1810    fn validate_ipv6_fe80_link_local_rejected() {
1811        assert!(validate_clone_url("https://[fe80::1]/repo").is_err());
1812    }
1813
1814    #[test]
1815    fn validate_file_protocol_rejected() {
1816        assert!(validate_clone_url("file:///etc/passwd").is_err());
1817    }
1818
1819    #[test]
1820    fn validate_empty_string_rejected() {
1821        assert!(validate_clone_url("").is_err());
1822    }
1823
1824    #[test]
1825    fn validate_rfc1918_10_allowed() {
1826        // RFC 1918 private ranges are allowed (internal corporate git servers).
1827        assert!(validate_clone_url("https://10.0.0.1/repo.git").is_ok());
1828    }
1829
1830    #[test]
1831    fn validate_rfc1918_192_168_allowed() {
1832        assert!(validate_clone_url("https://192.168.1.1/repo.git").is_ok());
1833    }
1834
1835    #[test]
1836    fn validate_rfc1918_172_16_allowed() {
1837        assert!(validate_clone_url("https://172.16.0.1/repo.git").is_ok());
1838    }
1839
1840    #[test]
1841    fn validate_rfc1918_172_31_allowed() {
1842        assert!(validate_clone_url("https://172.31.255.255/repo.git").is_ok());
1843    }
1844
1845    #[test]
1846    fn validate_ipv6_ula_fd_allowed() {
1847        // IPv6 unique-local (fc00::/7) is the private-range equivalent — allowed.
1848        assert!(validate_clone_url("https://[fd12:3456:789a::1]/repo").is_ok());
1849    }
1850
1851    // ── port_of_git_url (DNS-rebind resolution helper) ────────────────────────
1852    #[test]
1853    fn port_https_default() {
1854        assert_eq!(port_of_git_url("https://github.com/o/r.git"), Some(443));
1855    }
1856
1857    #[test]
1858    fn port_explicit_overrides_default() {
1859        assert_eq!(
1860            port_of_git_url("https://gitlab.corp:8443/o/r.git"),
1861            Some(8443)
1862        );
1863    }
1864
1865    #[test]
1866    fn port_git_scheme_default() {
1867        assert_eq!(port_of_git_url("git://example.com/r.git"), Some(9418));
1868    }
1869
1870    #[test]
1871    fn port_scp_like_is_ssh() {
1872        assert_eq!(port_of_git_url("git@github.com:owner/repo.git"), Some(22));
1873    }
1874
1875    #[test]
1876    fn port_ipv6_with_explicit_port() {
1877        assert_eq!(port_of_git_url("https://[fd00::1]:7000/r"), Some(7000));
1878    }
1879
1880    #[test]
1881    fn port_ipv6_default() {
1882        assert_eq!(port_of_git_url("https://[fd00::1]/r"), Some(443));
1883    }
1884
1885    #[test]
1886    fn validate_metadata_ip_literal_still_rejected() {
1887        // IP-literal path remains blocked regardless of the new DNS resolution step.
1888        assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
1889    }
1890
1891    #[test]
1892    fn validate_loopback_127_rejected() {
1893        assert!(validate_clone_url("https://127.0.0.1/repo.git").is_err());
1894    }
1895
1896    #[test]
1897    fn validate_localhost_rejected() {
1898        assert!(validate_clone_url("https://localhost/repo.git").is_err());
1899    }
1900
1901    #[test]
1902    fn validate_unspecified_0_0_0_0_rejected() {
1903        assert!(validate_clone_url("https://0.0.0.0/repo.git").is_err());
1904    }
1905
1906    // ── host_of_git_url ───────────────────────────────────────────────────────
1907
1908    // The URL embeds userinfo purely to prove the parser drops it and returns
1909    // only the host — no real secret, this is a parsing fixture.
1910    #[test]
1911    fn host_of_git_url_https_with_port_and_creds() {
1912        assert_eq!(
1913            host_of_git_url("https://user:pw@gitlab.corp.com:8443/team/repo.git").as_deref(),
1914            Some("gitlab.corp.com")
1915        );
1916    }
1917
1918    #[test]
1919    fn host_of_git_url_scp_syntax() {
1920        assert_eq!(
1921            host_of_git_url("git@github.com:owner/repo.git").as_deref(),
1922            Some("github.com")
1923        );
1924    }
1925
1926    #[test]
1927    fn host_of_git_url_ipv6_literal() {
1928        assert_eq!(
1929            host_of_git_url("https://[fe80::1]:443/repo").as_deref(),
1930            Some("fe80::1")
1931        );
1932    }
1933
1934    #[test]
1935    fn validate_clone_url_path_with_version_number_not_blocked() {
1936        // Regression: a path/tag containing "10." must not be mistaken for an IP.
1937        assert!(validate_clone_url("https://github.com/acme/release-v10.2.git").is_ok());
1938        assert!(validate_clone_url("https://github.com/foo/bar-127-baz.git").is_ok());
1939    }
1940
1941    // ── try_normalize_bitbucket_server ────────────────────────────────────────
1942
1943    #[test]
1944    fn bitbucket_server_uppercase_project_lowercased() {
1945        let r = try_normalize_bitbucket_server(
1946            "https",
1947            "bb.corp.com",
1948            "/projects/PROJ/repos/myrepo/browse",
1949        );
1950        assert_eq!(
1951            r,
1952            Some("https://bb.corp.com/scm/proj/myrepo.git".to_owned())
1953        );
1954    }
1955
1956    #[test]
1957    fn bitbucket_server_without_projects_returns_none() {
1958        assert!(
1959            try_normalize_bitbucket_server("https", "bb.corp.com", "/scm/proj/repo.git").is_none()
1960        );
1961    }
1962
1963    #[test]
1964    fn bitbucket_server_missing_repos_segment_returns_none() {
1965        assert!(
1966            try_normalize_bitbucket_server("https", "bb.corp.com", "/projects/PROJ/browse")
1967                .is_none()
1968        );
1969    }
1970
1971    // ── try_normalize_gitlab ──────────────────────────────────────────────────
1972
1973    #[test]
1974    fn gitlab_dash_tree_normalized() {
1975        let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo/-/tree/main");
1976        assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
1977    }
1978
1979    #[test]
1980    fn gitlab_no_dash_returns_none() {
1981        assert!(try_normalize_gitlab("https", "gitlab.com", "/group/repo").is_none());
1982    }
1983
1984    #[test]
1985    fn gitlab_strips_existing_dot_git_before_readding() {
1986        let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo.git/-/tree/main");
1987        assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
1988    }
1989
1990    // ── try_normalize_github ──────────────────────────────────────────────────
1991
1992    #[test]
1993    fn github_tree_normalized() {
1994        let r = try_normalize_github("https", "github.com", "/owner/repo/tree/main");
1995        assert_eq!(r, Some("https://github.com/owner/repo.git".to_owned()));
1996    }
1997
1998    #[test]
1999    fn github_non_github_host_returns_none() {
2000        assert!(try_normalize_github("https", "gitlab.com", "/owner/repo/tree/main").is_none());
2001    }
2002
2003    #[test]
2004    fn github_plain_two_segment_path_returns_none() {
2005        assert!(try_normalize_github("https", "github.com", "/owner/repo").is_none());
2006    }
2007
2008    #[test]
2009    fn github_unknown_third_segment_returns_none() {
2010        assert!(try_normalize_github("https", "github.com", "/owner/repo/wiki").is_none());
2011    }
2012
2013    // ── try_normalize_bitbucket_cloud ─────────────────────────────────────────
2014
2015    #[test]
2016    fn bitbucket_cloud_src_normalized() {
2017        let r = try_normalize_bitbucket_cloud(
2018            "https",
2019            "bitbucket.org",
2020            "/workspace/repo/src/main/README.md",
2021        );
2022        assert_eq!(
2023            r,
2024            Some("https://bitbucket.org/workspace/repo.git".to_owned())
2025        );
2026    }
2027
2028    #[test]
2029    fn bitbucket_cloud_non_bitbucket_host_returns_none() {
2030        assert!(
2031            try_normalize_bitbucket_cloud("https", "github.com", "/ws/repo/src/main").is_none()
2032        );
2033    }
2034
2035    #[test]
2036    fn bitbucket_cloud_without_src_segment_returns_none() {
2037        assert!(try_normalize_bitbucket_cloud("https", "bitbucket.org", "/ws/repo").is_none());
2038    }
2039
2040    // ── parse_ref_line ────────────────────────────────────────────────────────
2041
2042    #[test]
2043    fn parse_ref_line_all_fields() {
2044        let line = "main|abc1234|2024-01-15T10:00:00+00:00|Initial commit";
2045        let r = parse_ref_line(line, GitRefKind::Branch);
2046        assert_eq!(r.name, "main");
2047        assert_eq!(r.sha, "abc1234");
2048        assert!(r.date.is_some());
2049        assert_eq!(r.message.as_deref(), Some("Initial commit"));
2050        assert!(matches!(r.kind, GitRefKind::Branch));
2051    }
2052
2053    #[test]
2054    fn parse_ref_line_tag_kind() {
2055        let line = "v1.0.0|deadbeef|2024-01-01T00:00:00+00:00|Release v1.0.0";
2056        let r = parse_ref_line(line, GitRefKind::Tag);
2057        assert_eq!(r.name, "v1.0.0");
2058        assert!(matches!(r.kind, GitRefKind::Tag));
2059    }
2060
2061    #[test]
2062    fn parse_ref_line_name_only() {
2063        let r = parse_ref_line("main", GitRefKind::Branch);
2064        assert_eq!(r.name, "main");
2065        assert_eq!(r.sha, "");
2066        assert!(r.date.is_none());
2067        assert!(r.message.is_none());
2068    }
2069
2070    #[test]
2071    fn parse_ref_line_invalid_date_gives_none() {
2072        let r = parse_ref_line("main|abc|not-a-date|msg", GitRefKind::Branch);
2073        assert!(r.date.is_none());
2074        assert_eq!(r.message.as_deref(), Some("msg"));
2075    }
2076
2077    #[test]
2078    fn parse_ref_line_empty_string() {
2079        let r = parse_ref_line("", GitRefKind::Branch);
2080        assert_eq!(r.name, "");
2081    }
2082
2083    // ── parse_commit_line ─────────────────────────────────────────────────────
2084
2085    #[test]
2086    fn parse_commit_line_all_fields() {
2087        let line =
2088            "abc1234567890abcdef|abc1234|Alice Smith|2024-01-15T10:00:00+00:00|Fix critical bug";
2089        let c = parse_commit_line(line);
2090        assert_eq!(c.sha, "abc1234567890abcdef");
2091        assert_eq!(c.short_sha, "abc1234");
2092        assert_eq!(c.author, "Alice Smith");
2093        assert_eq!(c.subject, "Fix critical bug");
2094    }
2095
2096    #[test]
2097    fn parse_commit_line_empty() {
2098        let c = parse_commit_line("");
2099        assert_eq!(c.sha, "");
2100        assert_eq!(c.short_sha, "");
2101        assert_eq!(c.author, "");
2102        assert_eq!(c.subject, "");
2103    }
2104
2105    #[test]
2106    fn parse_commit_line_partial_fields() {
2107        let c = parse_commit_line("sha1|sha_short");
2108        assert_eq!(c.sha, "sha1");
2109        assert_eq!(c.short_sha, "sha_short");
2110        assert_eq!(c.author, "");
2111    }
2112
2113    #[test]
2114    fn parse_commit_line_subject_with_pipe() {
2115        // splitn(5, '|') keeps everything in the 5th slot
2116        let line = "sha|short|author|2024-01-01T00:00:00+00:00|subject with | pipe inside";
2117        let c = parse_commit_line(line);
2118        assert_eq!(c.subject, "subject with | pipe inside");
2119    }
2120
2121    // ── parse_git_date ────────────────────────────────────────────────────────
2122
2123    #[test]
2124    fn parse_git_date_valid_rfc3339() {
2125        let dt = parse_git_date("2024-01-15T10:30:00+00:00");
2126        assert!(dt.is_some());
2127    }
2128
2129    #[test]
2130    fn parse_git_date_invalid_returns_none() {
2131        assert!(parse_git_date("not-a-date").is_none());
2132        assert!(parse_git_date("").is_none());
2133    }
2134
2135    #[test]
2136    fn parse_git_date_with_offset_converts_to_utc() {
2137        let dt = parse_git_date("2024-06-01T12:00:00+05:00").unwrap();
2138        // +05:00 offset means UTC is 12:00 - 5:00 = 07:00
2139        assert_eq!(dt.time().hour(), 7);
2140    }
2141
2142    #[test]
2143    fn port_of_git_url_unknown_scheme_returns_none() {
2144        // A recognised scheme with no explicit port falls back to its default…
2145        assert_eq!(port_of_git_url("https://host/repo"), Some(443));
2146        assert_eq!(port_of_git_url("ssh://host/repo"), Some(22));
2147        assert_eq!(port_of_git_url("git://host/repo"), Some(9418));
2148        // …but an unknown scheme with no explicit port yields None.
2149        assert_eq!(port_of_git_url("file://host/repo"), None);
2150        assert_eq!(port_of_git_url("ftp://host/repo"), None);
2151    }
2152
2153    // ── per-host credential registry ──────────────────────────────────────────
2154
2155    #[test]
2156    fn hostkey_normalizes_punctuation_and_case() {
2157        assert_eq!(
2158            hostkey("bitbucket.instance2.com"),
2159            "BITBUCKET_INSTANCE2_COM"
2160        );
2161        assert_eq!(hostkey("git-host.corp"), "GIT_HOST_CORP");
2162        assert_eq!(hostkey("host:7990"), "HOST_7990");
2163    }
2164
2165    #[test]
2166    fn resolve_credential_https_env_wins() {
2167        let _g = env_lock();
2168        let host = "cred-https-test.example";
2169        let key = format!("SLOC_GIT_CRED_{}", hostkey(host));
2170        // SAFETY: single-threaded under ENV_LOCK; removed before the guard drops.
2171        unsafe { std::env::set_var(&key, "alice:secrettoken") };
2172        let cred = resolve_credential(host, None);
2173        // SAFETY: see above.
2174        unsafe { std::env::remove_var(&key) };
2175        match cred {
2176            Some(GitCredential::Https { user, token }) => {
2177                assert_eq!(user, "alice");
2178                assert_eq!(token, "secrettoken");
2179            }
2180            _ => panic!("expected an HTTPS credential from the env registry"),
2181        }
2182    }
2183
2184    #[test]
2185    fn resolve_credential_ssh_key_env() {
2186        let _g = env_lock();
2187        let host = "cred-ssh-test.example";
2188        let key = format!("SLOC_GIT_SSHKEY_{}", hostkey(host));
2189        // SAFETY: single-threaded under ENV_LOCK; removed before the guard drops.
2190        unsafe { std::env::set_var(&key, "/home/u/.ssh/id_ed25519") };
2191        let cred = resolve_credential(host, None);
2192        // SAFETY: see above.
2193        unsafe { std::env::remove_var(&key) };
2194        match cred {
2195            Some(GitCredential::Ssh { key_path }) => {
2196                assert_eq!(key_path, "/home/u/.ssh/id_ed25519");
2197            }
2198            _ => panic!("expected an SSH credential from the env registry"),
2199        }
2200    }
2201
2202    #[test]
2203    fn resolve_credential_none_falls_through() {
2204        // No registry entry → None, so git falls back to its own credential resolution.
2205        assert!(resolve_credential("no-such-cred-host.invalid", None).is_none());
2206    }
2207
2208    #[test]
2209    fn cred_injection_https_keeps_secret_out_of_config() {
2210        let _g = env_lock();
2211        let host = "inj-test.example";
2212        let key = format!("SLOC_GIT_CRED_{}", hostkey(host));
2213        // SAFETY: single-threaded under ENV_LOCK; removed before the guard drops.
2214        unsafe { std::env::set_var(&key, "bob:tok123") };
2215        let inj = cred_injection(host, None);
2216        // SAFETY: see above.
2217        unsafe { std::env::remove_var(&key) };
2218
2219        // First config entry resets inherited helpers; the helper reads the secret from env.
2220        assert_eq!(
2221            inj.config.first().map(String::as_str),
2222            Some("credential.helper=")
2223        );
2224        assert!(
2225            inj.config
2226                .iter()
2227                .any(|c| c.contains("$GIT_U") && c.contains("$GIT_P"))
2228        );
2229        assert!(
2230            !inj.config.iter().any(|c| c.contains("tok123")),
2231            "the token must NEVER appear in git config / argv"
2232        );
2233        assert!(inj.env.iter().any(|(k, v)| k == "GIT_U" && v == "bob"));
2234        assert!(inj.env.iter().any(|(k, v)| k == "GIT_P" && v == "tok123"));
2235    }
2236
2237    #[test]
2238    fn resolve_credential_port_qualified_key_wins() {
2239        let _g = env_lock();
2240        let host = "cred-port-test.example";
2241        let port_key = format!("SLOC_GIT_CRED_{}", hostkey(&format!("{host}:7990")));
2242        let bare_key = format!("SLOC_GIT_CRED_{}", hostkey(host));
2243        // SAFETY: single-threaded under ENV_LOCK; removed before the guard drops.
2244        unsafe {
2245            std::env::set_var(&port_key, "svc-port:porttoken");
2246            std::env::set_var(&bare_key, "svc-bare:baretoken");
2247        }
2248        let with_port = resolve_credential(host, Some(7990));
2249        let without_port = resolve_credential(host, None);
2250        // SAFETY: see above.
2251        unsafe {
2252            std::env::remove_var(&port_key);
2253            std::env::remove_var(&bare_key);
2254        }
2255        match with_port {
2256            Some(GitCredential::Https { user, .. }) => assert_eq!(user, "svc-port"),
2257            _ => panic!("port-qualified key should win when a port is present"),
2258        }
2259        match without_port {
2260            Some(GitCredential::Https { user, .. }) => assert_eq!(user, "svc-bare"),
2261            _ => panic!("bare-host key should resolve when no port is given"),
2262        }
2263    }
2264
2265    #[test]
2266    fn resolve_credential_falls_back_to_bare_when_only_bare_key_set() {
2267        let _g = env_lock();
2268        let host = "cred-fallback-test.example";
2269        let bare_key = format!("SLOC_GIT_CRED_{}", hostkey(host));
2270        // SAFETY: single-threaded under ENV_LOCK; removed before the guard drops.
2271        unsafe { std::env::set_var(&bare_key, "svc:tok") };
2272        // A port is supplied but only the bare-host key exists → it is still used.
2273        let cred = resolve_credential(host, Some(7990));
2274        // SAFETY: see above.
2275        unsafe { std::env::remove_var(&bare_key) };
2276        assert!(matches!(cred, Some(GitCredential::Https { .. })));
2277    }
2278
2279    #[test]
2280    fn explicit_port_of_git_url_extracts_or_none() {
2281        assert_eq!(
2282            explicit_port_of_git_url("https://git.corp:7990/team/repo.git"),
2283            Some(7990)
2284        );
2285        assert_eq!(
2286            explicit_port_of_git_url("https://git.corp/team/repo.git"),
2287            None
2288        );
2289        assert_eq!(
2290            explicit_port_of_git_url("ssh://git@host:2222/repo.git"),
2291            Some(2222)
2292        );
2293        // scp-like: the segment after ':' is a path, not a port.
2294        assert_eq!(
2295            explicit_port_of_git_url("git@github.com:owner/repo.git"),
2296            None
2297        );
2298        assert_eq!(
2299            explicit_port_of_git_url("https://[fe80::1]:443/repo"),
2300            Some(443)
2301        );
2302        assert_eq!(explicit_port_of_git_url("https://[fe80::1]/repo"), None);
2303    }
2304
2305    // ── source classification / normalize passthrough ─────────────────────────
2306
2307    #[test]
2308    fn classify_source_recognizes_each_form() {
2309        assert!(matches!(
2310            classify_source("https://github.com/o/r.git"),
2311            GitSource::Remote
2312        ));
2313        assert!(matches!(
2314            classify_source("git@github.com:o/r.git"),
2315            GitSource::Remote
2316        ));
2317        assert!(matches!(
2318            classify_source("ssh://git@h/o/r.git"),
2319            GitSource::Remote
2320        ));
2321        assert!(matches!(
2322            classify_source("file:///srv/mirror/r"),
2323            GitSource::FileUrl
2324        ));
2325        assert!(matches!(
2326            classify_source("/srv/mirror/r.bundle"),
2327            GitSource::Bundle
2328        ));
2329        assert!(matches!(
2330            classify_source(r"C:\mirror\r"),
2331            GitSource::LocalPath
2332        ));
2333    }
2334
2335    #[test]
2336    fn normalize_git_url_passes_local_sources_through() {
2337        for u in [
2338            "file:///srv/mirror/r",
2339            r"C:\mirror\repo",
2340            r"\\srv\share\repo",
2341            "/srv/x.bundle",
2342        ] {
2343            assert_eq!(
2344                normalize_git_url(u),
2345                u,
2346                "local source must pass through unchanged: {u}"
2347            );
2348        }
2349    }
2350
2351    #[test]
2352    fn file_url_to_path_posix_windows_and_rejects_host() {
2353        assert_eq!(
2354            file_url_to_path("file:///home/u/repo").unwrap(),
2355            "/home/u/repo"
2356        );
2357        assert_eq!(
2358            file_url_to_path("file:///C:/mirror/repo").unwrap(),
2359            "C:/mirror/repo"
2360        );
2361        assert!(
2362            file_url_to_path("file://server/share/repo").is_err(),
2363            "a file:// URL with a host authority must be rejected"
2364        );
2365    }
2366
2367    // ── local-import gate (validate_local_source) ─────────────────────────────
2368
2369    #[test]
2370    fn validate_local_source_rejected_when_disabled() {
2371        let _g = env_lock();
2372        // SAFETY: single-threaded under ENV_LOCK.
2373        unsafe {
2374            std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2375            std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2376        }
2377        assert!(validate_local_source("/srv/x.bundle", &GitSource::Bundle).is_err());
2378    }
2379
2380    #[test]
2381    fn validate_local_source_requires_root_when_enabled() {
2382        let _g = env_lock();
2383        // SAFETY: single-threaded under ENV_LOCK.
2384        unsafe {
2385            std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2386            std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2387        }
2388        let err = validate_local_source("/srv/x.bundle", &GitSource::Bundle)
2389            .unwrap_err()
2390            .to_string();
2391        // SAFETY: see above.
2392        unsafe { std::env::remove_var("SLOC_GIT_ALLOW_LOCAL") };
2393        assert!(
2394            err.contains("SLOC_GIT_LOCAL_ROOT"),
2395            "must fail closed without a configured root: {err}"
2396        );
2397    }
2398
2399    #[test]
2400    fn validate_local_source_rejects_outside_root() {
2401        let _g = env_lock();
2402        let root = tempfile::tempdir().unwrap();
2403        let outside = tempfile::tempdir().unwrap();
2404        // SAFETY: single-threaded under ENV_LOCK.
2405        unsafe {
2406            std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2407            std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2408        }
2409        let outside_path = outside.path().to_string_lossy().into_owned();
2410        let res = validate_local_source(&outside_path, &GitSource::LocalPath);
2411        // SAFETY: see above.
2412        unsafe {
2413            std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2414            std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2415        }
2416        assert!(
2417            res.is_err(),
2418            "a source outside SLOC_GIT_LOCAL_ROOT must be rejected"
2419        );
2420    }
2421
2422    #[test]
2423    fn validate_local_source_accepts_inside_root() {
2424        let _g = env_lock();
2425        let root = tempfile::tempdir().unwrap();
2426        let inside = root.path().join("sub");
2427        std::fs::create_dir_all(&inside).unwrap();
2428        // SAFETY: single-threaded under ENV_LOCK.
2429        unsafe {
2430            std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2431            std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2432        }
2433        let inside_path = inside.to_string_lossy().into_owned();
2434        let res = validate_local_source(&inside_path, &GitSource::LocalPath);
2435        // SAFETY: see above.
2436        unsafe {
2437            std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2438            std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2439        }
2440        assert!(
2441            res.is_ok(),
2442            "a source under the root must be accepted: {res:?}"
2443        );
2444    }
2445
2446    #[test]
2447    fn validate_local_source_rejects_unc_even_when_enabled() {
2448        let _g = env_lock();
2449        let root = tempfile::tempdir().unwrap();
2450        // SAFETY: single-threaded under ENV_LOCK.
2451        unsafe {
2452            std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2453            std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2454        }
2455        let res = validate_local_source(r"\\attacker\share\repo", &GitSource::LocalPath);
2456        // SAFETY: see above.
2457        unsafe {
2458            std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2459            std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2460        }
2461        assert!(
2462            res.is_err(),
2463            "UNC/SMB paths must be treated as remote and rejected"
2464        );
2465    }
2466
2467    #[test]
2468    fn clone_or_fetch_file_url_rejected_without_gate() {
2469        // SSRF→LFI stays blocked: file:// is refused unless the local gate is explicitly on.
2470        let _g = env_lock();
2471        // SAFETY: single-threaded under ENV_LOCK.
2472        unsafe { std::env::remove_var("SLOC_GIT_ALLOW_LOCAL") };
2473        let dest = tempfile::tempdir().unwrap();
2474        assert!(clone_or_fetch("file:///etc/passwd", dest.path()).is_err());
2475    }
2476}
2477
2478// ── git subprocess integration tests ─────────────────────────────────────────
2479//
2480// These tests exercise run_git, clone_or_fetch, get_sha, list_refs,
2481// list_commits, create_worktree, and destroy_worktree against a real git
2482// repository created in a temp directory.  They require git to be on PATH
2483// (always true in this project's development and CI environments).
2484#[cfg(test)]
2485mod git_integration {
2486    use super::*;
2487    use std::path::Path;
2488    use tempfile::tempdir;
2489
2490    // ── helpers ───────────────────────────────────────────────────────────────
2491
2492    fn git(dir: &Path, args: &[&str]) {
2493        let status = std::process::Command::new("git")
2494            .args(args)
2495            .current_dir(dir)
2496            .env("GIT_AUTHOR_NAME", "Test")
2497            .env("GIT_AUTHOR_EMAIL", "test@example.com")
2498            .env("GIT_COMMITTER_NAME", "Test")
2499            .env("GIT_COMMITTER_EMAIL", "test@example.com")
2500            .status()
2501            .expect("git must be on PATH");
2502        assert!(status.success(), "git {args:?} failed");
2503    }
2504
2505    /// Initialise a bare-minimum git repo with a single commit on branch `main`.
2506    fn make_repo(dir: &Path) {
2507        git(dir, &["init", "-b", "main"]);
2508        std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();
2509        git(dir, &["add", "hello.txt"]);
2510        git(dir, &["commit", "--no-gpg-sign", "-m", "initial"]);
2511    }
2512
2513    // ── publish_dir (git-push export) ─────────────────────────────────────────
2514
2515    #[test]
2516    fn publish_dir_pushes_snapshot_to_local_bare_repo() {
2517        let _g = env_lock();
2518        let root = tempdir().unwrap();
2519        // A bare target repo under the allowed local root plays the role of the remote.
2520        let bare = root.path().join("target.git");
2521        git(root.path(), &["init", "--bare", bare.to_str().unwrap()]);
2522
2523        // Artifacts to publish (nested, to exercise the recursive copy).
2524        let src = tempdir().unwrap();
2525        std::fs::write(src.path().join("result.json"), b"{\"ok\":true}").unwrap();
2526        std::fs::create_dir_all(src.path().join("html")).unwrap();
2527        std::fs::write(src.path().join("html").join("r.html"), b"<html></html>").unwrap();
2528
2529        let work = root.path().join("work");
2530        // SAFETY: single-threaded under ENV_LOCK; the local-source gate is required so
2531        // publish_dir accepts a filesystem path as the target.
2532        unsafe {
2533            std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2534            std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2535        }
2536        let res = publish_dir(
2537            bare.to_str().unwrap(),
2538            "reports",
2539            "run-1",
2540            src.path(),
2541            "publish run-1",
2542            &work,
2543        );
2544        // SAFETY: see above.
2545        unsafe {
2546            std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2547            std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2548        }
2549        assert!(res.is_ok(), "publish_dir failed: {res:?}");
2550
2551        // The bare repo now carries the branch with the artifacts under the subdir.
2552        let out = std::process::Command::new("git")
2553            .args([
2554                &format!("--git-dir={}", bare.to_str().unwrap()),
2555                "ls-tree",
2556                "-r",
2557                "--name-only",
2558                "reports",
2559            ])
2560            .output()
2561            .expect("git must be on PATH");
2562        let listing = String::from_utf8_lossy(&out.stdout);
2563        assert!(
2564            listing.contains("run-1/result.json"),
2565            "expected run-1/result.json in: {listing}"
2566        );
2567        assert!(
2568            listing.contains("run-1/html/r.html"),
2569            "expected run-1/html/r.html in: {listing}"
2570        );
2571    }
2572
2573    // ── run_git ───────────────────────────────────────────────────────────────
2574
2575    #[test]
2576    fn run_git_success_returns_stdout() {
2577        let dir = tempdir().unwrap();
2578        make_repo(dir.path());
2579        // `git rev-parse HEAD` is the simplest command that produces output
2580        let sha = run_git(dir.path(), &["rev-parse", "HEAD"]).unwrap();
2581        assert_eq!(sha.len(), 40, "full SHA must be 40 hex chars: {sha}");
2582    }
2583
2584    #[test]
2585    fn run_git_failure_returns_error() {
2586        let dir = tempdir().unwrap();
2587        make_repo(dir.path());
2588        let result = run_git(dir.path(), &["rev-parse", "nonexistent-ref-xyz"]);
2589        assert!(result.is_err(), "nonexistent ref must return an error");
2590    }
2591
2592    // ── clone_or_fetch ────────────────────────────────────────────────────────
2593
2594    #[test]
2595    fn clone_or_fetch_clones_local_repo() {
2596        let src = tempdir().unwrap();
2597        make_repo(src.path());
2598
2599        let dest_root = tempdir().unwrap();
2600        let dest = dest_root.path().join("clone");
2601
2602        // Use the file:// URL so validate_clone_url accepts it ... but wait,
2603        // file:// is NOT in the allowlist.  Use https:// scheme bypass: pass the
2604        // raw path directly and let normalize_git_url pass it through unchanged,
2605        // then test validate_clone_url separately.
2606        // Instead: bypass validate_clone_url by calling run_git directly for the
2607        // clone, then test clone_or_fetch on a subsequent fetch.
2608
2609        // Set up the clone manually so we can test the fetch branch.
2610        std::fs::create_dir_all(&dest).unwrap();
2611        let src_str = src.path().to_str().unwrap();
2612        let dest_str = dest.to_str().unwrap();
2613        run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
2614        assert!(dest.join(".git").exists(), "clone must create .git dir");
2615
2616        // Now the dest exists; add a second commit to src and fetch.
2617        std::fs::write(src.path().join("second.txt"), "v2\n").unwrap();
2618        git(src.path(), &["add", "second.txt"]);
2619        git(src.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
2620
2621        // clone_or_fetch on existing dest → runs git fetch
2622        // We bypass URL validation by calling the underlying path directly
2623        // (validate_clone_url would reject local paths; test the fetch branch
2624        // via run_git directly since it's already covered by run_git tests above)
2625        run_git(&dest, &["fetch", "--all", "--tags", "--prune"]).unwrap();
2626    }
2627
2628    #[test]
2629    fn list_branches_excludes_origin_head_symref() {
2630        // A fresh clone carries `origin/HEAD -> origin/main`. `%(refname:short)` shortens that
2631        // symref to bare `origin`, which a name-based filter misses — it would surface as a
2632        // phantom branch duplicating the default branch. Verify it is dropped.
2633        let src = tempdir().unwrap();
2634        let inner = src.path().join("inner");
2635        std::fs::create_dir_all(&inner).unwrap();
2636        make_repo(&inner);
2637        git(&inner, &["branch", "feature-x"]);
2638
2639        let dest_root = tempdir().unwrap();
2640        let dest = dest_root.path().join("clone");
2641        let src_str = inner.to_str().unwrap();
2642        let dest_str = dest.to_str().unwrap();
2643        run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
2644        // Ensure the remote HEAD symref exists (some git versions set it on clone already).
2645        let _ = run_git(&dest, &["remote", "set-head", "origin", "--auto"]);
2646
2647        let branches = list_branches(&dest).unwrap();
2648        let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect();
2649        assert!(
2650            !names.contains(&"origin"),
2651            "origin/HEAD symref must not appear as a branch: {names:?}"
2652        );
2653        assert!(
2654            names.contains(&"main"),
2655            "main branch must be listed: {names:?}"
2656        );
2657        assert!(
2658            names.contains(&"feature-x"),
2659            "real branches must still be listed: {names:?}"
2660        );
2661    }
2662
2663    #[test]
2664    fn clone_or_fetch_rejects_http_plain_url() {
2665        let dest = tempdir().unwrap();
2666        let result = clone_or_fetch("http://example.com/repo.git", dest.path());
2667        assert!(
2668            result.is_err(),
2669            "http:// must be rejected by validate_clone_url"
2670        );
2671    }
2672
2673    #[test]
2674    fn clone_or_fetch_rejects_link_local_url() {
2675        let dest = tempdir().unwrap();
2676        let result = clone_or_fetch("https://169.254.169.254/repo", dest.path());
2677        assert!(result.is_err());
2678    }
2679
2680    // ── offline import: git bundle / local path (SLOC_GIT_ALLOW_LOCAL gate) ────
2681
2682    #[test]
2683    fn clone_or_fetch_imports_git_bundle_under_local_root() {
2684        let _g = env_lock();
2685        // Build a source repo and bundle it inside the allowed root (the air-gap import file).
2686        let root = tempdir().unwrap();
2687        let src = root.path().join("src");
2688        std::fs::create_dir_all(&src).unwrap();
2689        make_repo(&src);
2690        let bundle = root.path().join("repo.bundle");
2691        run_git(
2692            &src,
2693            &["bundle", "create", bundle.to_str().unwrap(), "--all"],
2694        )
2695        .unwrap();
2696
2697        // SAFETY: single-threaded under ENV_LOCK; cleaned up before the guard drops.
2698        unsafe {
2699            std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2700            std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2701        }
2702        let dest_root = tempdir().unwrap();
2703        let dest = dest_root.path().join("clone");
2704        let res = clone_or_fetch(bundle.to_str().unwrap(), &dest);
2705        let refs = res.as_ref().ok().and(list_refs(&dest).ok());
2706        // SAFETY: see above.
2707        unsafe {
2708            std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2709            std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2710        }
2711
2712        res.unwrap();
2713        assert!(
2714            dest.join(".git").exists(),
2715            "bundle import must produce a clone"
2716        );
2717        let names: Vec<String> = refs
2718            .expect("refs must be listable from the imported clone")
2719            .branches
2720            .into_iter()
2721            .map(|b| b.name)
2722            .collect();
2723        assert!(
2724            names.iter().any(|n| n == "main"),
2725            "bundle clone must expose the main branch: {names:?}"
2726        );
2727    }
2728
2729    #[test]
2730    fn clone_or_fetch_imports_local_path_under_root() {
2731        let _g = env_lock();
2732        let root = tempdir().unwrap();
2733        let src = root.path().join("mirror");
2734        std::fs::create_dir_all(&src).unwrap();
2735        make_repo(&src);
2736
2737        // SAFETY: single-threaded under ENV_LOCK; cleaned up before the guard drops.
2738        unsafe {
2739            std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2740            std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2741        }
2742        let dest_root = tempdir().unwrap();
2743        let dest = dest_root.path().join("clone");
2744        let res = clone_or_fetch(src.to_str().unwrap(), &dest);
2745        // SAFETY: see above.
2746        unsafe {
2747            std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2748            std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2749        }
2750
2751        res.unwrap();
2752        assert!(
2753            dest.join(".git").exists(),
2754            "local-path import must produce a clone"
2755        );
2756    }
2757
2758    // ── get_sha ───────────────────────────────────────────────────────────────
2759
2760    #[test]
2761    fn get_sha_returns_full_commit_hash() {
2762        let dir = tempdir().unwrap();
2763        make_repo(dir.path());
2764        let sha = get_sha(dir.path(), "HEAD").unwrap();
2765        assert_eq!(sha.len(), 40);
2766        assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
2767    }
2768
2769    #[test]
2770    fn get_sha_nonexistent_ref_errors() {
2771        let dir = tempdir().unwrap();
2772        make_repo(dir.path());
2773        assert!(get_sha(dir.path(), "refs/heads/nonexistent").is_err());
2774    }
2775
2776    // ── list_commits ──────────────────────────────────────────────────────────
2777
2778    #[test]
2779    fn list_commits_returns_at_least_one_commit() {
2780        let dir = tempdir().unwrap();
2781        make_repo(dir.path());
2782        let commits = list_commits(dir.path(), "HEAD", 10).unwrap();
2783        assert!(
2784            !commits.is_empty(),
2785            "must return at least the initial commit"
2786        );
2787        let c = &commits[0];
2788        assert_eq!(c.sha.len(), 40);
2789        assert!(!c.short_sha.is_empty());
2790        assert_eq!(c.author, "Test");
2791        assert_eq!(c.subject, "initial");
2792    }
2793
2794    #[test]
2795    fn list_commits_respects_limit() {
2796        let dir = tempdir().unwrap();
2797        make_repo(dir.path());
2798        // Add a second commit
2799        std::fs::write(dir.path().join("b.txt"), "b\n").unwrap();
2800        git(dir.path(), &["add", "b.txt"]);
2801        git(dir.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
2802
2803        let one = list_commits(dir.path(), "HEAD", 1).unwrap();
2804        assert_eq!(one.len(), 1, "limit=1 must return exactly 1 commit");
2805
2806        let two = list_commits(dir.path(), "HEAD", 10).unwrap();
2807        assert_eq!(two.len(), 2, "limit=10 must return both commits");
2808    }
2809
2810    // ── list_refs (branches + tags) ───────────────────────────────────────────
2811
2812    #[test]
2813    fn list_refs_returns_main_branch() {
2814        let src = tempdir().unwrap();
2815        make_repo(src.path());
2816
2817        // Clone so we have remote-tracking refs (list_branches uses -r)
2818        let dest_root = tempdir().unwrap();
2819        let dest = dest_root.path().join("clone");
2820        let src_str = src.path().to_str().unwrap();
2821        let dest_str = dest.to_str().unwrap();
2822        run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
2823
2824        let refs = list_refs(&dest).unwrap();
2825        let branch_names: Vec<&str> = refs.branches.iter().map(|b| b.name.as_str()).collect();
2826        assert!(
2827            branch_names.contains(&"main"),
2828            "branches must include 'main', got: {branch_names:?}"
2829        );
2830    }
2831
2832    #[test]
2833    fn list_refs_returns_tag() {
2834        let src = tempdir().unwrap();
2835        make_repo(src.path());
2836        git(src.path(), &["tag", "v1.0.0"]);
2837
2838        let dest_root = tempdir().unwrap();
2839        let dest = dest_root.path().join("clone");
2840        let src_str = src.path().to_str().unwrap();
2841        run_git(src.path(), &["clone", src_str, dest.to_str().unwrap()]).unwrap();
2842        // Fetch tags explicitly
2843        run_git(&dest, &["fetch", "--tags"]).unwrap();
2844
2845        let refs = list_refs(&dest).unwrap();
2846        let tag_names: Vec<&str> = refs.tags.iter().map(|t| t.name.as_str()).collect();
2847        assert!(
2848            tag_names.contains(&"v1.0.0"),
2849            "tags must include 'v1.0.0', got: {tag_names:?}"
2850        );
2851    }
2852
2853    // ── create_worktree / destroy_worktree ────────────────────────────────────
2854
2855    #[test]
2856    fn create_and_destroy_worktree() {
2857        let repo = tempdir().unwrap();
2858        make_repo(repo.path());
2859
2860        let sha = get_sha(repo.path(), "HEAD").unwrap();
2861
2862        let wt_root = tempdir().unwrap();
2863        let wt_path = wt_root.path().join("worktree");
2864
2865        create_worktree(repo.path(), &sha, &wt_path).unwrap();
2866        assert!(
2867            wt_path.exists(),
2868            "worktree directory must exist after creation"
2869        );
2870        assert!(
2871            wt_path.join("hello.txt").exists(),
2872            "worktree must contain committed files"
2873        );
2874
2875        destroy_worktree(repo.path(), &wt_path).unwrap();
2876        assert!(
2877            !wt_path.exists(),
2878            "worktree directory must be removed after destroy"
2879        );
2880    }
2881
2882    #[test]
2883    fn destroy_worktree_on_nonexistent_path_succeeds() {
2884        // destroy_worktree intentionally ignores errors
2885        let repo = tempdir().unwrap();
2886        make_repo(repo.path());
2887        let nonexistent = repo.path().join("does_not_exist");
2888        assert!(destroy_worktree(repo.path(), &nonexistent).is_ok());
2889    }
2890
2891    #[test]
2892    fn create_worktree_resolves_non_default_remote_branch() {
2893        // A fresh clone only materialises a local branch for the default branch; every other
2894        // branch exists solely as origin/<name>. Ref listing shows the bare name, so scanning
2895        // a non-default branch must still resolve — the regression the infra test caught.
2896        let src = tempdir().unwrap();
2897        let inner = src.path().join("inner");
2898        std::fs::create_dir_all(&inner).unwrap();
2899        make_repo(&inner);
2900        git(&inner, &["checkout", "-b", "feature-x"]);
2901        std::fs::write(inner.join("feat.txt"), "feature\n").unwrap();
2902        git(&inner, &["add", "feat.txt"]);
2903        git(&inner, &["commit", "--no-gpg-sign", "-m", "feature commit"]);
2904        git(&inner, &["checkout", "main"]);
2905
2906        let dest_root = tempdir().unwrap();
2907        let dest = dest_root.path().join("clone");
2908        run_git(
2909            src.path(),
2910            &["clone", inner.to_str().unwrap(), dest.to_str().unwrap()],
2911        )
2912        .unwrap();
2913
2914        // Bare "feature-x" is only a remote-tracking ref in the clone; must still check out.
2915        let wt_root = tempdir().unwrap();
2916        let wt = wt_root.path().join("wt");
2917        create_worktree(&dest, "feature-x", &wt).unwrap();
2918        assert!(
2919            wt.join("feat.txt").exists(),
2920            "worktree must contain the feature branch's file"
2921        );
2922        destroy_worktree(&dest, &wt).unwrap();
2923    }
2924
2925    #[test]
2926    fn resolve_committish_falls_back_to_origin_and_rejects_unknown() {
2927        let src = tempdir().unwrap();
2928        let inner = src.path().join("inner");
2929        std::fs::create_dir_all(&inner).unwrap();
2930        make_repo(&inner);
2931        git(&inner, &["branch", "release-1"]);
2932
2933        let dest_root = tempdir().unwrap();
2934        let dest = dest_root.path().join("clone");
2935        run_git(
2936            src.path(),
2937            &["clone", inner.to_str().unwrap(), dest.to_str().unwrap()],
2938        )
2939        .unwrap();
2940
2941        // Non-default branch resolves via the origin/ fallback to a 40-char SHA.
2942        let sha = resolve_committish(&dest, "release-1").unwrap();
2943        assert_eq!(sha.len(), 40, "must resolve to a full SHA: {sha}");
2944        // A genuinely absent ref is an error, not a silent empty string.
2945        assert!(resolve_committish(&dest, "no-such-branch").is_err());
2946    }
2947
2948    // ── local-repo detection ──────────────────────────────────────────────────
2949
2950    #[test]
2951    fn is_local_repo_path_true_for_repo_dir() {
2952        let dir = tempdir().unwrap();
2953        make_repo(dir.path());
2954        assert!(is_local_repo_path(dir.path().to_str().unwrap()));
2955    }
2956
2957    #[test]
2958    fn is_local_repo_path_false_for_plain_dir() {
2959        let dir = tempdir().unwrap();
2960        assert!(!is_local_repo_path(dir.path().to_str().unwrap()));
2961    }
2962
2963    #[test]
2964    fn is_local_repo_path_false_for_remote_or_url_forms() {
2965        assert!(!is_local_repo_path("https://github.com/owner/repo.git"));
2966        assert!(!is_local_repo_path("git@github.com:owner/repo.git"));
2967        assert!(!is_local_repo_path("ssh://git@host/repo.git"));
2968        assert!(!is_local_repo_path("file:///tmp/repo"));
2969        assert!(!is_local_repo_path(""));
2970    }
2971
2972    #[test]
2973    fn open_local_repo_returns_toplevel() {
2974        let dir = tempdir().unwrap();
2975        make_repo(dir.path());
2976        let root = open_local_repo(dir.path()).unwrap();
2977        // `--show-toplevel` and the temp dir may differ only by symlink normalisation
2978        // (e.g. /var → /private/var on macOS); compare by a marker file both must contain.
2979        assert!(
2980            root.join("hello.txt").exists(),
2981            "toplevel must be the repo root: {root:?}"
2982        );
2983    }
2984
2985    #[test]
2986    fn open_local_repo_errs_on_non_repo() {
2987        let dir = tempdir().unwrap();
2988        assert!(open_local_repo(dir.path()).is_err());
2989    }
2990
2991    // ── list_refs_local (local heads, not remote-tracking) ────────────────────
2992
2993    #[test]
2994    fn list_refs_local_lists_local_branches_and_tags() {
2995        let dir = tempdir().unwrap();
2996        make_repo(dir.path());
2997        git(dir.path(), &["branch", "feature-x"]);
2998        git(dir.path(), &["tag", "v1.0.0"]);
2999
3000        let refs = list_refs_local(dir.path()).unwrap();
3001        let branches: Vec<&str> = refs.branches.iter().map(|b| b.name.as_str()).collect();
3002        assert!(
3003            branches.contains(&"main"),
3004            "local heads must include main: {branches:?}"
3005        );
3006        assert!(
3007            branches.contains(&"feature-x"),
3008            "local heads must include feature-x (no clone/origin needed): {branches:?}"
3009        );
3010        let tags: Vec<&str> = refs.tags.iter().map(|t| t.name.as_str()).collect();
3011        assert!(
3012            tags.contains(&"v1.0.0"),
3013            "tags must include v1.0.0: {tags:?}"
3014        );
3015        assert!(
3016            !refs.recent_commits.is_empty(),
3017            "recent commits must be listed"
3018        );
3019    }
3020
3021    // ── submodule population + SSRF gate ──────────────────────────────────────
3022
3023    #[test]
3024    fn submodule_url_is_safe_classification() {
3025        // Relative URLs resolve against the superproject origin — always allowed.
3026        assert!(submodule_url_is_safe("../sibling.git"));
3027        assert!(submodule_url_is_safe("./nested/mod.git"));
3028        // Absolute local / file references are refused (a local super-repo uses relative URLs).
3029        assert!(!submodule_url_is_safe("/etc/passwd"));
3030        assert!(!submodule_url_is_safe("file:///etc/shadow"));
3031        // SSRF-sensitive remotes are refused by the shared clone gate.
3032        assert!(!submodule_url_is_safe("https://169.254.169.254/x.git"));
3033        assert!(!submodule_url_is_safe(
3034            "http://metadata.google.internal/x.git"
3035        ));
3036        // A normal public remote passes.
3037        assert!(submodule_url_is_safe("https://github.com/owner/repo.git"));
3038    }
3039
3040    #[test]
3041    fn populate_submodules_noop_without_gitmodules() {
3042        let dir = tempdir().unwrap();
3043        make_repo(dir.path());
3044        assert!(populate_submodules(dir.path()).unwrap().is_empty());
3045    }
3046
3047    #[test]
3048    fn populate_submodules_skips_ssrf_submodule() {
3049        // A .gitmodules that points a submodule at a metadata/loopback host must be skipped, not
3050        // fetched — the whole point of validating submodule URLs against the SSRF gate.
3051        let dir = tempdir().unwrap();
3052        make_repo(dir.path());
3053        let gitmodules =
3054            "[submodule \"evil\"]\n\tpath = evil\n\turl = https://169.254.169.254/x.git\n";
3055        std::fs::write(dir.path().join(".gitmodules"), gitmodules).unwrap();
3056        let skipped = populate_submodules(dir.path()).unwrap();
3057        assert_eq!(
3058            skipped,
3059            vec!["evil".to_string()],
3060            "the unsafe submodule must be reported skipped"
3061        );
3062    }
3063}