Skip to main content

aube_store/
git.rs

1use crate::{
2    CappedReader, Error, MAX_TARBALL_DECOMPRESSED_BYTES, MAX_TARBALL_ENTRIES,
3    MAX_TARBALL_ENTRY_BYTES,
4};
5use aube_util::url::redact_url;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9/// Construct a Git subprocess that disables Git's own terminal credential
10/// prompts. Credential helpers and configured tokens still work. SSH
11/// transports may still prompt independently through the user's SSH client.
12pub(crate) fn git_command() -> Command {
13    let mut command = Command::new("git");
14    command.env("GIT_TERMINAL_PROMPT", "0");
15    command
16}
17
18/// Render a git argv tail for error messages with any embedded
19/// userinfo stripped. A raw `{args:?}` would otherwise dump the
20/// full `git+https://<token>@host/repo.git` URL right back into
21/// the error string that ships to CI logs.
22fn redact_args(args: &[&str]) -> String {
23    let mut s = String::from("[");
24    for (i, a) in args.iter().enumerate() {
25        if i > 0 {
26            s.push_str(", ");
27        }
28        s.push('"');
29        s.push_str(&redact_url(a));
30        s.push('"');
31    }
32    s.push(']');
33    s
34}
35
36/// Reject values that would be interpreted by git as an option when
37/// handed to a subcommand as a positional argument. Defense against
38/// the CVE-2017-1000117 class of argv injection.
39///
40/// Modern git releases refuse dash-prefixed URLs at the CLI layer,
41/// but this check still matters:
42///
43/// - self-hosted runners still ship older git binaries,
44/// - the same helper is reused for committish values fed to
45///   `git checkout`, where a `--` terminator can't be used because it
46///   would turn the committish into a pathspec.
47///
48/// A NUL byte is also rejected. It never appears in a legitimate url,
49/// ref, or commit, and is a recurring split point for tool pipelines
50/// downstream.
51pub(crate) fn validate_git_positional(value: &str, kind: &str) -> Result<(), Error> {
52    if value.starts_with('-') {
53        return Err(Error::Git(format!(
54            "refusing to pass {kind} starting with `-` to git: {value:?}"
55        )));
56    }
57    if value.contains('\0') {
58        return Err(Error::Git(format!(
59            "refusing to pass {kind} containing NUL byte to git"
60        )));
61    }
62    Ok(())
63}
64
65/// Resolve a git ref (branch name, tag, or partial commit) to a full
66/// 40-char commit SHA by shelling out to `git ls-remote`. `committish`
67/// of `None` means resolve `HEAD`. An input that already looks like a
68/// full 40-char hex SHA is returned as-is without touching the network.
69///
70/// Matches the pnpm flow: try exact ref, then `refs/tags/<ref>`,
71/// `refs/heads/<ref>`, falling back to the HEAD of the repo when the
72/// caller passes `None`.
73pub fn git_resolve_ref(url: &str, committish: Option<&str>) -> Result<String, Error> {
74    validate_git_positional(url, "git url")?;
75    // Already a full commit SHA? No network round-trip needed.
76    if let Some(c) = committish
77        && c.len() == 40
78        && c.chars().all(|ch| ch.is_ascii_hexdigit())
79    {
80        return Ok(c.to_ascii_lowercase());
81    }
82    // Always list all refs in one shot — filtering server-side with
83    // `git ls-remote <url> HEAD` only works when the remote's HEAD
84    // symbolic ref resolves, and some hosts (and our bare-repo test
85    // fixtures) leave HEAD dangling. Listing everything also lets us
86    // fall back to `main` / `master` without a second network call.
87    //
88    // `--` terminates git's own option parsing so an attacker-supplied
89    // url that slips a leading `-` past `validate_git_positional` (we
90    // don't expect this, but defense in depth) can't land as an option.
91    let out = git_command()
92        .args(["ls-remote", "--", url])
93        .output()
94        .map_err(|e| Error::Git(format!("spawn git ls-remote {}: {e}", redact_url(url))))?;
95    if !out.status.success() {
96        let stderr = String::from_utf8_lossy(&out.stderr);
97        return Err(Error::Git(format!(
98            "git ls-remote {} failed: {}",
99            redact_url(url),
100            redact_url(stderr.trim())
101        )));
102    }
103    let stdout = String::from_utf8_lossy(&out.stdout);
104    let mut head: Option<String> = None;
105    let mut main_branch: Option<String> = None;
106    let mut master_branch: Option<String> = None;
107    let mut tag_match: Option<String> = None;
108    let mut head_match: Option<String> = None;
109    let mut first: Option<String> = None;
110    for line in stdout.lines() {
111        let mut parts = line.split('\t');
112        let sha = parts.next().unwrap_or("").trim();
113        let name = parts.next().unwrap_or("").trim();
114        if sha.is_empty() || name.is_empty() {
115            continue;
116        }
117        if first.is_none() {
118            first = Some(sha.to_string());
119        }
120        match name {
121            "HEAD" => head = Some(sha.to_string()),
122            "refs/heads/main" => main_branch = Some(sha.to_string()),
123            "refs/heads/master" => master_branch = Some(sha.to_string()),
124            _ => {}
125        }
126        if let Some(want) = committish {
127            if name == format!("refs/tags/{want}") || name == format!("refs/tags/{want}^{{}}") {
128                tag_match = Some(sha.to_string());
129            } else if name == format!("refs/heads/{want}") {
130                head_match = Some(sha.to_string());
131            }
132        }
133    }
134    if let Some(want) = committish {
135        if let Some(sha) = tag_match.or(head_match) {
136            return Ok(sha);
137        }
138        // ls-remote only advertises branches and tags, so an
139        // abbreviated commit SHA never matches a ref name. Pass it
140        // through unchanged — `git_shallow_clone` resolves the prefix
141        // by fetching and running `git checkout`, and the resolver
142        // promotes the rev-parsed full SHA back into `GitSource`
143        // before writing the lockfile (see `resolve_git_source`).
144        //
145        // Lower bound is 7 to stay in lockstep with `git_commit_matches`:
146        // a shorter prefix would clear this gate but then trip the
147        // post-checkout verification with a confusing mismatch error.
148        // 7 is also git's own default `core.abbrev`, so anything users
149        // copy out of a git UI lands at or above the cutoff.
150        let looks_hex =
151            want.len() >= 7 && want.len() < 40 && want.chars().all(|c| c.is_ascii_hexdigit());
152        if looks_hex {
153            return Ok(want.to_ascii_lowercase());
154        }
155        Err(Error::Git(format!(
156            "git ls-remote {}: no ref matched {want}",
157            redact_url(url)
158        )))
159    } else {
160        head.or(main_branch)
161            .or(master_branch)
162            .or(first)
163            .ok_or_else(|| {
164                Error::Git(format!(
165                    "git ls-remote {}: no refs advertised",
166                    redact_url(url)
167                ))
168            })
169    }
170}
171
172/// Shallow-clone `url` at `commit` into a fresh temp directory and
173/// return the temp path. The caller is responsible for removing the
174/// returned directory once it's imported into the store.
175///
176/// Uses the `git init` / `git fetch --depth 1` / `git checkout` dance
177/// rather than `git clone --depth 1 --branch` so we can fetch a raw
178/// commit hash that isn't advertised as a branch tip — pnpm does the
179/// same for exactly this reason.
180/// Return true if `url`'s hostname matches any entry in `hosts`
181/// using the same exact-match semantics pnpm uses for
182/// `git-shallow-hosts`. No wildcards, no subdomain folding —
183/// `github.com` does *not* match `api.github.com`.
184///
185/// Handles the three URL shapes aube actually hands to git:
186///   - `https://host/path`, `git://host/path`, `git+https://host/path`
187///   - `git+ssh://git@host/path`
188///   - `ssh://git@host/path`
189///
190/// Anything we can't parse (malformed, bare paths) returns `false`,
191/// which means "not in the shallow list" — a full clone is the safe
192/// default for weird inputs.
193pub fn git_host_in_list(url: &str, hosts: &[String]) -> bool {
194    let Some(host) = git_url_host(url) else {
195        return false;
196    };
197    hosts.iter().any(|h| h == host)
198}
199
200/// Extract the hostname from a git remote URL string. Public for
201/// testability; not expected to be useful to external callers.
202pub fn git_url_host(url: &str) -> Option<&str> {
203    // Strip the scheme if present. `git+` prefixes (`git+https://`,
204    // `git+ssh://`) wrap a regular URL — drop them before parsing.
205    let rest = url.strip_prefix("git+").unwrap_or(url);
206    let after_scheme = match rest.split_once("://") {
207        Some((_, r)) => r,
208        // No scheme: could be scp-style `git@host:owner/repo.git`,
209        // which has no `://`. Handle that below. Anything else (a
210        // bare path, a malformed string) has no host.
211        None => {
212            // scp-style: `user@host:path`
213            let (userhost, _) = rest.split_once(':')?;
214            let host = userhost
215                .rsplit_once('@')
216                .map(|(_, h)| h)
217                .unwrap_or(userhost);
218            if host.is_empty() || host.contains('/') {
219                return None;
220            }
221            return Some(host);
222        }
223    };
224    // Drop optional `user@` prefix.
225    let authority = after_scheme
226        .split_once('/')
227        .map(|(a, _)| a)
228        .unwrap_or(after_scheme);
229    let host_with_port = authority
230        .rsplit_once('@')
231        .map(|(_, h)| h)
232        .unwrap_or(authority);
233    // Drop optional `:port`. IPv6 literals are wrapped in brackets
234    // (`[::1]` / `[::1]:22`) and their address itself contains `:`s,
235    // so blindly splitting on the last `:` would slice off part of
236    // the address. Detect the bracket form first and pull out what's
237    // between `[` and `]`; only plain hostname:port strings fall
238    // through to the generic split.
239    let host = if let Some(inner) = host_with_port.strip_prefix('[') {
240        inner.split_once(']').map(|(h, _)| h).unwrap_or(inner)
241    } else {
242        host_with_port
243            .rsplit_once(':')
244            .map(|(h, _)| h)
245            .unwrap_or(host_with_port)
246    };
247    if host.is_empty() { None } else { Some(host) }
248}
249
250/// Clone a git repo into a deterministic per-(url, commit) cache dir
251/// and check out `commit`. When `shallow` is true, aube uses
252/// `fetch --depth 1 origin <sha>` and falls back to a full fetch if
253/// the server rejects by-SHA shallow fetches; when false, aube skips
254/// straight to the full-fetch path. Callers decide shallow vs. full
255/// by consulting the `gitShallowHosts` setting via
256/// [`git_host_in_list`].
257///
258/// Returns `(clone_dir, head_sha)` where `head_sha` is the 40-char
259/// `git rev-parse HEAD` of the checked-out tree. Callers can pass
260/// `commit` as either a full SHA or an abbreviated hex prefix; the
261/// returned SHA is always the canonical full-length form so the
262/// resolver can pin the lockfile to it.
263pub fn git_shallow_clone(
264    url: &str,
265    commit: &str,
266    shallow: bool,
267) -> Result<(PathBuf, String), Error> {
268    validate_git_positional(url, "git url")?;
269    validate_git_positional(commit, "git commit")?;
270    // Deterministic path keyed by url+commit so two callers in the
271    // same process (resolver → installer) reuse the same checkout
272    // instead of re-cloning. Two different repos that happen to
273    // share a commit hash can't collide because the url is in the
274    // hash. PID is intentionally NOT in the path — that's what made
275    // the old version leak a fresh dir on every call.
276    //
277    // `shallow` is deliberately *not* part of the cache key: the
278    // checkout a full clone leaves behind is a strict superset of
279    // the one a shallow clone leaves behind (both have the requested
280    // commit at HEAD; only the `.git/shallow` marker and object
281    // count differ). Two installs that hit the same (url, commit)
282    // under different shallow settings can reuse each other's work,
283    // and `import_directory` ignores `.git/` so the store sees
284    // identical output either way.
285    // Keep git scratch out of world-writable /tmp. Predictable names
286    // under $TMPDIR are the classic symlink pre-plant vector. Attacker
287    // creates /tmp/aube-git-<k>-<c> as a symlink into $HOME/.ssh, then
288    // the remove_dir_all below walks right through it and nukes the
289    // victim's keys. 0700 on the cache root blocks the same race on a
290    // shared user dir.
291    let git_root = crate::dirs::cache_dir()
292        .map(|d| d.join("git"))
293        .unwrap_or_else(std::env::temp_dir);
294    std::fs::create_dir_all(&git_root).map_err(|e| Error::Io(git_root.clone(), e))?;
295    #[cfg(unix)]
296    {
297        use std::os::unix::fs::PermissionsExt;
298        if let Err(e) = std::fs::set_permissions(&git_root, std::fs::Permissions::from_mode(0o700))
299        {
300            warn!(
301                "failed to chmod 0700 {}: {e}. Git scratch dir may be world-accessible, check filesystem permissions",
302                git_root.display()
303            );
304        }
305    }
306    // Cache key derives from `(url, commit_input)`. When the caller
307    // passes an abbreviated SHA, the initial target lands under that
308    // key; after the clone, we re-key to the canonical full SHA so
309    // a follow-up call (typically the installer reading the
310    // lockfile-pinned full SHA) hits the same checkout instead of
311    // re-cloning.
312    let cache_key = |key_input: &str| -> (String, String) {
313        let mut hasher = blake3::Hasher::new();
314        hasher.update(url.as_bytes());
315        hasher.update(b"\0");
316        hasher.update(key_input.as_bytes());
317        let digest = hasher.finalize();
318        let key: String = digest
319            .as_bytes()
320            .iter()
321            .take(8)
322            .map(|b| format!("{b:02x}"))
323            .collect();
324        let short = key_input
325            .get(..key_input.len().min(12))
326            .unwrap_or(key_input)
327            .to_string();
328        (key, short)
329    };
330    let (key, commit_short) = cache_key(commit);
331    let target = git_root.join(format!("aube-git-{key}-{commit_short}"));
332
333    // Fast path: a previous call already finished this (url, commit)
334    // pair and left a complete checkout at `target`. Verify cheaply
335    // with `git rev-parse HEAD`; if it matches, reuse. A mismatch
336    // means we're looking at an abandoned partial-failure stub from
337    // an older aube version — it'll get replaced by the atomic
338    // rename below.
339    if target.join(".git").is_dir()
340        && let Ok(out) = git_command()
341            .args(["rev-parse", "HEAD"])
342            .current_dir(&target)
343            .output()
344        && out.status.success()
345    {
346        let head = String::from_utf8_lossy(&out.stdout).trim().to_string();
347        if git_commit_matches(&head, commit) {
348            return Ok((target, head));
349        }
350    }
351
352    // Clone into a scratch dir first and atomically rename into
353    // place. This solves two problems simultaneously:
354    //   1. Partial-failure cleanup — if any git command fails, we
355    //      drop the scratch dir and `target` is untouched, so a
356    //      retry starts from a clean slate.
357    //   2. Concurrent `aube install` races — two processes won't
358    //      collide on `target` because each clones into its own
359    //      PID-scoped scratch, and only one `rename` wins. The
360    //      loser discovers `target` already has the right HEAD
361    //      and reuses it.
362    // Random suffix from tempfile::Builder. The old <pid> suffix was
363    // guessable, so a local attacker could pre-plant a symlink at the
364    // exact scratch path before git init ever ran. CSPRNG bytes make
365    // that race unwinnable.
366    let scratch = tempfile::Builder::new()
367        .prefix(&format!("aube-git-{key}-{commit_short}."))
368        .tempdir_in(&git_root)
369        .map_err(|e| Error::Io(git_root.clone(), e))?
370        .keep();
371
372    let run_in = |dir: &Path, args: &[&str]| -> Result<(), Error> {
373        let out = git_command()
374            .args(args)
375            .current_dir(dir)
376            .output()
377            .map_err(|e| Error::Git(format!("spawn git {}: {e}", redact_args(args))))?;
378        if !out.status.success() {
379            let stderr = String::from_utf8_lossy(&out.stderr);
380            return Err(Error::Git(format!(
381                "git {} failed: {}",
382                redact_args(args),
383                redact_url(stderr.trim())
384            )));
385        }
386        Ok(())
387    };
388
389    let do_clone = || -> Result<String, Error> {
390        run_in(&scratch, &["init", "-q"])?;
391        run_in(&scratch, &["remote", "add", "--", "origin", url])?;
392        // Shallow fetch by raw SHA only works when the remote allows
393        // uploads of any reachable object (GitHub/GitLab/Bitbucket
394        // do; many self-hosted servers don't). Fall back to a full
395        // fetch on any failure. When `shallow` is false — caller
396        // said the host isn't on the shallow list — skip the depth=1
397        // attempt entirely to avoid a guaranteed-wasted round trip.
398        let shallow_ok = shallow
399            && run_in(
400                &scratch,
401                &["fetch", "--depth", "1", "-q", "--", "origin", commit],
402            )
403            .is_ok();
404        if !shallow_ok {
405            run_in(&scratch, &["fetch", "-q", "--", "origin"])?;
406        }
407        // `git checkout -- <commit>` treats <commit> as a pathspec, so
408        // we cannot use the argv separator here. `validate_git_positional`
409        // at function entry already rejected a leading `-` on `commit`.
410        run_in(&scratch, &["checkout", "-q", commit])?;
411        // Confirm the checkout landed exactly on the expected commit
412        // before the scratch clone is renamed into place. Git's own
413        // SHA-1 object addressing protects against a server returning
414        // a different blob for a given SHA, but a local git
415        // misconfiguration (default branch mismatch, rewritten ref,
416        // stale reflog) could still leave HEAD on something else —
417        // mirrors the defensive check the reuse path at line 1260
418        // already performs.
419        let out = git_command()
420            .args(["rev-parse", "HEAD"])
421            .current_dir(&scratch)
422            .output()
423            .map_err(|e| Error::Git(format!("spawn git rev-parse: {e}")))?;
424        if !out.status.success() {
425            return Err(Error::Git(format!(
426                "git rev-parse HEAD failed: {}",
427                redact_url(String::from_utf8_lossy(&out.stderr).trim())
428            )));
429        }
430        let actual = String::from_utf8_lossy(&out.stdout).trim().to_string();
431        if !git_commit_matches(&actual, commit) {
432            return Err(Error::Git(format!(
433                "git clone HEAD {actual} does not match requested commit {commit}"
434            )));
435        }
436        Ok(actual)
437    };
438    let head_sha = match do_clone() {
439        Ok(sha) => sha,
440        Err(e) => {
441            let _ = std::fs::remove_dir_all(&scratch);
442            return Err(e);
443        }
444    };
445
446    // `rename` is atomic on the same filesystem. Two outcomes:
447    //  - Target doesn't exist → we win and it's ours.
448    //  - Target already exists (another process raced us, or there
449    //    was a stale partial-failure stub above) → rename fails
450    //    with ENOTEMPTY/EEXIST. Verify the existing target has our
451    //    commit and reuse it; otherwise remove it and retry once.
452    match aube_util::fs_atomic::rename_with_retry(&scratch, &target) {
453        Ok(()) => Ok((
454            canonicalize_clone_dir(&target, commit, &head_sha, &cache_key),
455            head_sha,
456        )),
457        Err(_) => {
458            if target.join(".git").is_dir()
459                && let Ok(out) = git_command()
460                    .args(["rev-parse", "HEAD"])
461                    .current_dir(&target)
462                    .output()
463                && out.status.success()
464            {
465                let head = String::from_utf8_lossy(&out.stdout).trim().to_string();
466                if git_commit_matches(&head, commit) {
467                    let _ = std::fs::remove_dir_all(&scratch);
468                    return Ok((
469                        canonicalize_clone_dir(&target, commit, &head, &cache_key),
470                        head,
471                    ));
472                }
473            }
474            // Stale target — clear and retry the rename. Any
475            // remaining race here would be between two installs
476            // both trying to replace a stale target, which is still
477            // safe because each scratch is PID-scoped.
478            let _ = std::fs::remove_dir_all(&target);
479            aube_util::fs_atomic::rename_with_retry(&scratch, &target).map_err(|e| {
480                let _ = std::fs::remove_dir_all(&scratch);
481                Error::Git(format!("rename clone into place: {e}"))
482            })?;
483            Ok((
484                canonicalize_clone_dir(&target, commit, &head_sha, &cache_key),
485                head_sha,
486            ))
487        }
488    }
489}
490
491/// Re-key an abbreviated-SHA cache directory to its canonical
492/// full-SHA path so a follow-up `git_shallow_clone` call (e.g. the
493/// installer reading the lockfile-pinned full SHA) reuses the
494/// existing checkout instead of cloning again. No-op when `commit`
495/// already matches `head_sha`. Best-effort: if the rename fails
496/// (cross-FS, race, perms), leaves the original path intact and
497/// the caller pays one extra clone next time.
498fn canonicalize_clone_dir(
499    target: &Path,
500    commit: &str,
501    head_sha: &str,
502    cache_key: &dyn Fn(&str) -> (String, String),
503) -> PathBuf {
504    if commit.eq_ignore_ascii_case(head_sha) {
505        return target.to_path_buf();
506    }
507    let parent = match target.parent() {
508        Some(p) => p,
509        None => return target.to_path_buf(),
510    };
511    let (key, short) = cache_key(head_sha);
512    let canonical = parent.join(format!("aube-git-{key}-{short}"));
513    if canonical.join(".git").is_dir() {
514        // Race: another caller already wrote the canonical entry.
515        // Drop our duplicate so disk doesn't bloat with two copies.
516        let _ = std::fs::remove_dir_all(target);
517        return canonical;
518    }
519    match aube_util::fs_atomic::rename_with_retry(target, &canonical) {
520        Ok(()) => canonical,
521        Err(_) => target.to_path_buf(),
522    }
523}
524
525/// Extract a codeload-style HTTPS tarball (e.g. the bytes of a GET to
526/// `https://codeload.github.com/<owner>/<repo>/tar.gz/<sha>`) into a
527/// deterministic per-(url, commit) cache directory and return a path
528/// shaped like `git_shallow_clone`'s output: the extracted tree at
529/// the top level, with the `<owner>-<repo>-<sha>/` wrapper component
530/// codeload adds stripped off so callers can join `subpath` and read
531/// `package.json` exactly the same way they do for a clone.
532///
533/// `commit` must be a 40-char SHA — codeload tarballs do not embed
534/// `.git/`, so there is no post-extraction `rev-parse HEAD` to verify
535/// the extracted tree is the requested commit. The lockfile resolver
536/// (or an upstream `git ls-remote`) is responsible for pinning a SHA
537/// before this is called. The returned `head_sha` is `commit`
538/// lowercased.
539///
540/// Cache layout uses a separate `aube-codeload-` prefix from the
541/// `aube-git-` prefix `git_shallow_clone` writes, so a per-dep
542/// fallback from one path to the other doesn't trip on the other
543/// caller's marker files.
544pub fn extract_codeload_tarball(
545    bytes: &[u8],
546    url: &str,
547    commit: &str,
548    integrity: Option<&str>,
549) -> Result<(PathBuf, String), Error> {
550    let git_root = crate::dirs::cache_dir()
551        .map(|d| d.join("git"))
552        .unwrap_or_else(std::env::temp_dir);
553    extract_codeload_tarball_at(&git_root, bytes, url, commit, integrity)
554}
555
556/// Return the cached codeload extract for `(url, commit)` without
557/// touching the network. Callers should consult this *before*
558/// downloading a codeload tarball — once the resolver has populated
559/// the cache during BFS, the install-time materialization should
560/// reuse it instead of paying a second HTTPS round-trip only to have
561/// `extract_codeload_tarball` short-circuit and discard the bytes.
562/// Mirrors `git_shallow_clone`'s top-of-function fast path.
563///
564/// Returns `None` for any input that couldn't possibly correspond to
565/// a cached entry — invalid URL/commit shapes, abbreviated SHAs, no
566/// resolvable cache root — so callers can chain straight into the
567/// fetch path on `None` without untangling an `Err`.
568pub fn codeload_cache_lookup(
569    url: &str,
570    commit: &str,
571    integrity: Option<&str>,
572) -> Option<(PathBuf, String)> {
573    let git_root = crate::dirs::cache_dir()
574        .map(|d| d.join("git"))
575        .unwrap_or_else(std::env::temp_dir);
576    let (target, head_sha) = codeload_cache_paths(&git_root, url, commit, integrity)?;
577    target.is_dir().then_some((target, head_sha))
578}
579
580/// Return the SRI integrity sidecar for a cached codeload extract.
581///
582/// Fresh extracts write this next to the cache directory so resolver
583/// re-runs from a warm cache keep emitting the same lockfile integrity.
584pub fn codeload_cache_integrity(
585    url: &str,
586    commit: &str,
587    integrity: Option<&str>,
588) -> Option<String> {
589    let git_root = crate::dirs::cache_dir()
590        .map(|d| d.join("git"))
591        .unwrap_or_else(std::env::temp_dir);
592    let (target, _) = codeload_cache_paths(&git_root, url, commit, integrity)?;
593    target
594        .is_dir()
595        .then(|| read_codeload_integrity(&target))
596        .flatten()
597}
598
599/// Compute the deterministic `(target, head_sha)` pair for a
600/// `(url, commit)` cache lookup, without touching the FS. Returns
601/// `None` for any input shape that `extract_codeload_tarball` would
602/// reject with `Err`, so the lookup and write paths agree on which
603/// inputs even *can* have a cache entry.
604pub(crate) fn codeload_integrity_path(target: &Path) -> PathBuf {
605    let file_name = target
606        .file_name()
607        .and_then(|s| s.to_str())
608        .map(|s| format!("{s}.integrity"))
609        .unwrap_or_else(|| "aube-codeload.integrity".to_string());
610    target.with_file_name(file_name)
611}
612
613fn codeload_integrity(bytes: &[u8]) -> String {
614    crate::sha512_integrity(bytes)
615}
616
617pub(crate) fn read_codeload_integrity(target: &Path) -> Option<String> {
618    std::fs::read_to_string(codeload_integrity_path(target))
619        .ok()
620        .map(|s| s.trim().to_string())
621        .filter(|s| !s.is_empty())
622}
623
624pub(crate) fn codeload_cache_paths(
625    cache_root: &Path,
626    url: &str,
627    commit: &str,
628    integrity: Option<&str>,
629) -> Option<(PathBuf, String)> {
630    if validate_git_positional(url, "git url").is_err()
631        || validate_git_positional(commit, "git commit").is_err()
632    {
633        return None;
634    }
635    if commit.len() != 40 || !commit.chars().all(|c| c.is_ascii_hexdigit()) {
636        return None;
637    }
638    let head_sha = commit.to_ascii_lowercase();
639    let mut hasher = blake3::Hasher::new();
640    hasher.update(url.as_bytes());
641    hasher.update(b"\0");
642    hasher.update(head_sha.as_bytes());
643    if let Some(integrity) = integrity {
644        hasher.update(b"\0");
645        hasher.update(integrity.as_bytes());
646    }
647    let digest = hasher.finalize();
648    let key: String = digest
649        .as_bytes()
650        .iter()
651        .take(8)
652        .map(|b| format!("{b:02x}"))
653        .collect();
654    let short = head_sha[..12].to_string();
655    Some((
656        cache_root.join(format!("aube-codeload-{key}-{short}")),
657        head_sha,
658    ))
659}
660
661/// Inner form of [`extract_codeload_tarball`] that takes the cache
662/// root explicitly. Public callers go through the wrapper above so
663/// the cache root resolution is uniform; tests pass an in-test
664/// `tempfile::tempdir()` directly to avoid mutating `XDG_CACHE_HOME`,
665/// which `cargo test`'s default parallel scheduling would race
666/// across multiple tests in the same binary.
667pub(crate) fn extract_codeload_tarball_at(
668    git_root: &Path,
669    bytes: &[u8],
670    url: &str,
671    commit: &str,
672    integrity: Option<&str>,
673) -> Result<(PathBuf, String), Error> {
674    use std::io::Read;
675    let (target, head_sha) =
676        codeload_cache_paths(git_root, url, commit, integrity).ok_or_else(|| {
677        Error::Git(format!(
678            "extract_codeload_tarball: invalid (url, commit) — commit must be a full 40-char SHA, got {commit}"
679        ))
680    })?;
681    let key_short = target
682        .file_name()
683        .and_then(|s| s.to_str())
684        .and_then(|s| s.strip_prefix("aube-codeload-"))
685        .unwrap_or("");
686
687    std::fs::create_dir_all(git_root).map_err(|e| Error::Io(git_root.to_path_buf(), e))?;
688    #[cfg(unix)]
689    {
690        use std::os::unix::fs::PermissionsExt;
691        if let Err(e) = std::fs::set_permissions(git_root, std::fs::Permissions::from_mode(0o700)) {
692            warn!(
693                "failed to chmod 0700 {}: {e}. Git scratch dir may be world-accessible, check filesystem permissions",
694                git_root.display()
695            );
696        }
697    }
698
699    // Reuse a prior successful extraction for this exact (url, commit).
700    // The atomic-rename pattern below makes a populated `target` always
701    // a complete tree — no half-extracted state to worry about.
702    if target.is_dir() {
703        if read_codeload_integrity(&target).is_none() {
704            let integrity = codeload_integrity(bytes);
705            let _ = std::fs::write(codeload_integrity_path(&target), &integrity);
706        }
707        return Ok((target, head_sha));
708    }
709
710    let integrity = codeload_integrity(bytes);
711
712    // Extract into a scratch dir and atomic-rename into place. Same
713    // failure-recovery and concurrent-install reasoning as
714    // `git_shallow_clone`'s scratch dance.
715    let scratch = tempfile::Builder::new()
716        .prefix(&format!("aube-codeload-{key_short}."))
717        .tempdir_in(git_root)
718        .map_err(|e| Error::Io(git_root.to_path_buf(), e))?
719        .keep();
720
721    let extract_into = |target: &Path| -> Result<(), Error> {
722        let gz = flate2::read::GzDecoder::new(bytes);
723        let capped = CappedReader::new(gz, MAX_TARBALL_DECOMPRESSED_BYTES);
724        let buffered = std::io::BufReader::with_capacity(256 * 1024, capped);
725        let mut archive = tar::Archive::new(buffered);
726        let mut entries_seen: usize = 0;
727        for entry in archive.entries().map_err(|e| Error::Tar(e.to_string()))? {
728            entries_seen += 1;
729            if entries_seen > MAX_TARBALL_ENTRIES {
730                return Err(Error::Tar(format!(
731                    "tarball exceeds entry cap of {MAX_TARBALL_ENTRIES}"
732                )));
733            }
734            let mut entry = entry.map_err(|e| Error::Tar(e.to_string()))?;
735            let entry_type = entry.header().entry_type();
736            // Codeload archives carry directories, regular files, and
737            // (rarely) symlinks. Reject everything else for the same
738            // reason `import_tarball` does — the linker imports this
739            // tree into the store and we don't want the same node-tar
740            // CVE class biting us through the git path.
741            if matches!(
742                entry_type,
743                tar::EntryType::XGlobalHeader | tar::EntryType::XHeader
744            ) {
745                continue;
746            }
747            let raw_path = entry
748                .path()
749                .map_err(|e| Error::Tar(e.to_string()))?
750                .to_path_buf();
751            // Strip the leading `<owner>-<repo>-<sha>/` wrapper
752            // codeload prepends. If an entry is at depth 0 (the
753            // wrapper directory itself) just create the target dir;
754            // if at depth >= 1 lop off the first component.
755            let mut comps = raw_path.components();
756            let _wrapper = comps.next();
757            let rel: PathBuf = comps.collect();
758            if rel.as_os_str().is_empty() {
759                continue;
760            }
761            // Reject any path that would escape the target (`..`,
762            // absolute) — `tar::Entry::unpack` does this internally
763            // but we're materializing manually so it's our job.
764            for c in rel.components() {
765                use std::path::Component;
766                if !matches!(c, Component::Normal(_)) {
767                    return Err(Error::Tar(format!(
768                        "tarball entry has unsafe path component: {}",
769                        raw_path.display()
770                    )));
771                }
772            }
773            let dest = target.join(&rel);
774            if entry_type.is_dir() {
775                std::fs::create_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;
776                continue;
777            }
778            if let Some(parent) = dest.parent() {
779                std::fs::create_dir_all(parent).map_err(|e| Error::Io(parent.to_path_buf(), e))?;
780            }
781            match entry_type {
782                tar::EntryType::Regular | tar::EntryType::Continuous => {
783                    let declared = entry
784                        .header()
785                        .size()
786                        .map_err(|e| Error::Tar(e.to_string()))?;
787                    if declared > MAX_TARBALL_ENTRY_BYTES {
788                        return Err(Error::Tar(format!(
789                            "tarball entry exceeds per-entry cap: {declared} bytes > {MAX_TARBALL_ENTRY_BYTES}"
790                        )));
791                    }
792                    let mut out =
793                        std::fs::File::create(&dest).map_err(|e| Error::Io(dest.clone(), e))?;
794                    let mut limited = entry.by_ref().take(MAX_TARBALL_ENTRY_BYTES);
795                    std::io::copy(&mut limited, &mut out)
796                        .map_err(|e| Error::Io(dest.clone(), e))?;
797                    #[cfg(unix)]
798                    {
799                        use std::os::unix::fs::PermissionsExt;
800                        if let Ok(mode) = entry.header().mode() {
801                            // Mask to 0o755 / 0o644 — codeload archives
802                            // sometimes carry executable bits; preserve
803                            // them so build scripts work, but never
804                            // honor setuid/setgid/sticky.
805                            let safe = if mode & 0o111 != 0 { 0o755 } else { 0o644 };
806                            let _ = std::fs::set_permissions(
807                                &dest,
808                                std::fs::Permissions::from_mode(safe),
809                            );
810                        }
811                    }
812                }
813                tar::EntryType::Symlink => {
814                    let link_target = entry
815                        .link_name()
816                        .map_err(|e| Error::Tar(e.to_string()))?
817                        .ok_or_else(|| Error::Tar("symlink without target".into()))?
818                        .into_owned();
819                    // Reject absolute or `..`-laden symlink targets so
820                    // a hostile archive can't plant a link out of the
821                    // extraction tree. The store-import pass would
822                    // then resolve the link inside the prepared dir
823                    // and read whatever the attacker pointed at.
824                    if link_target.is_absolute()
825                        || link_target.components().any(|c| {
826                            matches!(
827                                c,
828                                std::path::Component::ParentDir | std::path::Component::RootDir
829                            )
830                        })
831                    {
832                        return Err(Error::Tar(format!(
833                            "tarball symlink {} -> {} escapes target",
834                            raw_path.display(),
835                            link_target.display()
836                        )));
837                    }
838                    #[cfg(unix)]
839                    std::os::unix::fs::symlink(&link_target, &dest)
840                        .map_err(|e| Error::Io(dest.clone(), e))?;
841                    #[cfg(windows)]
842                    {
843                        // Windows symlink creation requires SeCreateSymbolicLink
844                        // (Developer Mode or admin), which most install hosts
845                        // lack. Silently dropping the entry would leave a
846                        // half-extracted tree that the linker would walk
847                        // straight into a "missing file" error several
848                        // layers down with no breadcrumbs back to the git
849                        // dep that's actually broken. Surface it now —
850                        // packages that genuinely need symlinks can fall
851                        // through to the `git clone` path on the next
852                        // install attempt by removing the cached extract,
853                        // since `git clone` materializes symlinks via
854                        // git's own (admin-aware) write path.
855                        return Err(Error::Tar(format!(
856                            "tarball symlink {} -> {} not supported on Windows; \
857                             remove the codeload cache entry and retry to fall back to `git clone`",
858                            raw_path.display(),
859                            link_target.display()
860                        )));
861                    }
862                }
863                _ => {
864                    return Err(Error::Tar(format!(
865                        "tarball entry type {entry_type:?} is not allowed"
866                    )));
867                }
868            }
869        }
870        Ok(())
871    };
872
873    if let Err(e) = extract_into(&scratch) {
874        let _ = std::fs::remove_dir_all(&scratch);
875        return Err(e);
876    }
877
878    match aube_util::fs_atomic::rename_with_retry(&scratch, &target) {
879        Ok(()) => {
880            let _ = std::fs::write(codeload_integrity_path(&target), &integrity);
881            Ok((target, head_sha))
882        }
883        Err(_) => {
884            // Two concurrent extracts of the same (url, commit) — the
885            // loser sees `target` already populated. Drop the loser's
886            // scratch and reuse the winner's directory.
887            if target.is_dir() {
888                let _ = std::fs::remove_dir_all(&scratch);
889                if read_codeload_integrity(&target).is_none() {
890                    let _ = std::fs::write(codeload_integrity_path(&target), &integrity);
891                }
892                return Ok((target, head_sha));
893            }
894            let _ = std::fs::remove_dir_all(&target);
895            aube_util::fs_atomic::rename_with_retry(&scratch, &target).map_err(|e| {
896                let _ = std::fs::remove_dir_all(&scratch);
897                Error::Git(format!("rename codeload extract into place: {e}"))
898            })?;
899            let _ = std::fs::write(codeload_integrity_path(&target), &integrity);
900            Ok((target, head_sha))
901        }
902    }
903}
904
905pub(crate) fn git_commit_matches(actual: &str, requested: &str) -> bool {
906    actual == requested
907        || (requested.len() >= 7
908            && requested.len() < 40
909            && requested.chars().all(|c| c.is_ascii_hexdigit())
910            && actual.starts_with(requested))
911}