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}