use crate::{
CappedReader, Error, MAX_TARBALL_DECOMPRESSED_BYTES, MAX_TARBALL_ENTRIES,
MAX_TARBALL_ENTRY_BYTES,
};
use aube_util::url::redact_url;
use std::path::{Path, PathBuf};
use std::process::Command;
pub(crate) fn git_command() -> Command {
let mut command = Command::new("git");
command.env("GIT_TERMINAL_PROMPT", "0");
command
}
fn redact_args(args: &[&str]) -> String {
let mut s = String::from("[");
for (i, a) in args.iter().enumerate() {
if i > 0 {
s.push_str(", ");
}
s.push('"');
s.push_str(&redact_url(a));
s.push('"');
}
s.push(']');
s
}
pub(crate) fn validate_git_positional(value: &str, kind: &str) -> Result<(), Error> {
if value.starts_with('-') {
return Err(Error::Git(format!(
"refusing to pass {kind} starting with `-` to git: {value:?}"
)));
}
if value.contains('\0') {
return Err(Error::Git(format!(
"refusing to pass {kind} containing NUL byte to git"
)));
}
Ok(())
}
pub fn git_resolve_ref(url: &str, committish: Option<&str>) -> Result<String, Error> {
validate_git_positional(url, "git url")?;
if let Some(c) = committish
&& c.len() == 40
&& c.chars().all(|ch| ch.is_ascii_hexdigit())
{
return Ok(c.to_ascii_lowercase());
}
let out = git_command()
.args(["ls-remote", "--", url])
.output()
.map_err(|e| Error::Git(format!("spawn git ls-remote {}: {e}", redact_url(url))))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(Error::Git(format!(
"git ls-remote {} failed: {}",
redact_url(url),
redact_url(stderr.trim())
)));
}
let stdout = String::from_utf8_lossy(&out.stdout);
let mut head: Option<String> = None;
let mut main_branch: Option<String> = None;
let mut master_branch: Option<String> = None;
let mut tag_match: Option<String> = None;
let mut head_match: Option<String> = None;
let mut first: Option<String> = None;
for line in stdout.lines() {
let mut parts = line.split('\t');
let sha = parts.next().unwrap_or("").trim();
let name = parts.next().unwrap_or("").trim();
if sha.is_empty() || name.is_empty() {
continue;
}
if first.is_none() {
first = Some(sha.to_string());
}
match name {
"HEAD" => head = Some(sha.to_string()),
"refs/heads/main" => main_branch = Some(sha.to_string()),
"refs/heads/master" => master_branch = Some(sha.to_string()),
_ => {}
}
if let Some(want) = committish {
if name == format!("refs/tags/{want}") || name == format!("refs/tags/{want}^{{}}") {
tag_match = Some(sha.to_string());
} else if name == format!("refs/heads/{want}") {
head_match = Some(sha.to_string());
}
}
}
if let Some(want) = committish {
if let Some(sha) = tag_match.or(head_match) {
return Ok(sha);
}
let looks_hex =
want.len() >= 7 && want.len() < 40 && want.chars().all(|c| c.is_ascii_hexdigit());
if looks_hex {
return Ok(want.to_ascii_lowercase());
}
Err(Error::Git(format!(
"git ls-remote {}: no ref matched {want}",
redact_url(url)
)))
} else {
head.or(main_branch)
.or(master_branch)
.or(first)
.ok_or_else(|| {
Error::Git(format!(
"git ls-remote {}: no refs advertised",
redact_url(url)
))
})
}
}
pub fn git_host_in_list(url: &str, hosts: &[String]) -> bool {
let Some(host) = git_url_host(url) else {
return false;
};
hosts.iter().any(|h| h == host)
}
pub fn git_url_host(url: &str) -> Option<&str> {
let rest = url.strip_prefix("git+").unwrap_or(url);
let after_scheme = match rest.split_once("://") {
Some((_, r)) => r,
None => {
let (userhost, _) = rest.split_once(':')?;
let host = userhost
.rsplit_once('@')
.map(|(_, h)| h)
.unwrap_or(userhost);
if host.is_empty() || host.contains('/') {
return None;
}
return Some(host);
}
};
let authority = after_scheme
.split_once('/')
.map(|(a, _)| a)
.unwrap_or(after_scheme);
let host_with_port = authority
.rsplit_once('@')
.map(|(_, h)| h)
.unwrap_or(authority);
let host = if let Some(inner) = host_with_port.strip_prefix('[') {
inner.split_once(']').map(|(h, _)| h).unwrap_or(inner)
} else {
host_with_port
.rsplit_once(':')
.map(|(h, _)| h)
.unwrap_or(host_with_port)
};
if host.is_empty() { None } else { Some(host) }
}
pub fn git_shallow_clone(
url: &str,
commit: &str,
shallow: bool,
) -> Result<(PathBuf, String), Error> {
validate_git_positional(url, "git url")?;
validate_git_positional(commit, "git commit")?;
let git_root = crate::dirs::cache_dir()
.map(|d| d.join("git"))
.unwrap_or_else(std::env::temp_dir);
std::fs::create_dir_all(&git_root).map_err(|e| Error::Io(git_root.clone(), e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::set_permissions(&git_root, std::fs::Permissions::from_mode(0o700))
{
warn!(
"failed to chmod 0700 {}: {e}. Git scratch dir may be world-accessible, check filesystem permissions",
git_root.display()
);
}
}
let cache_key = |key_input: &str| -> (String, String) {
let mut hasher = blake3::Hasher::new();
hasher.update(url.as_bytes());
hasher.update(b"\0");
hasher.update(key_input.as_bytes());
let digest = hasher.finalize();
let key: String = digest
.as_bytes()
.iter()
.take(8)
.map(|b| format!("{b:02x}"))
.collect();
let short = key_input
.get(..key_input.len().min(12))
.unwrap_or(key_input)
.to_string();
(key, short)
};
let (key, commit_short) = cache_key(commit);
let target = git_root.join(format!("aube-git-{key}-{commit_short}"));
if target.join(".git").is_dir()
&& let Ok(out) = git_command()
.args(["rev-parse", "HEAD"])
.current_dir(&target)
.output()
&& out.status.success()
{
let head = String::from_utf8_lossy(&out.stdout).trim().to_string();
if git_commit_matches(&head, commit) {
return Ok((target, head));
}
}
let scratch = tempfile::Builder::new()
.prefix(&format!("aube-git-{key}-{commit_short}."))
.tempdir_in(&git_root)
.map_err(|e| Error::Io(git_root.clone(), e))?
.keep();
let run_in = |dir: &Path, args: &[&str]| -> Result<(), Error> {
let out = git_command()
.args(args)
.current_dir(dir)
.output()
.map_err(|e| Error::Git(format!("spawn git {}: {e}", redact_args(args))))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(Error::Git(format!(
"git {} failed: {}",
redact_args(args),
redact_url(stderr.trim())
)));
}
Ok(())
};
let do_clone = || -> Result<String, Error> {
run_in(&scratch, &["init", "-q"])?;
run_in(&scratch, &["remote", "add", "--", "origin", url])?;
let shallow_ok = shallow
&& run_in(
&scratch,
&["fetch", "--depth", "1", "-q", "--", "origin", commit],
)
.is_ok();
if !shallow_ok {
run_in(&scratch, &["fetch", "-q", "--", "origin"])?;
}
run_in(&scratch, &["checkout", "-q", commit])?;
let out = git_command()
.args(["rev-parse", "HEAD"])
.current_dir(&scratch)
.output()
.map_err(|e| Error::Git(format!("spawn git rev-parse: {e}")))?;
if !out.status.success() {
return Err(Error::Git(format!(
"git rev-parse HEAD failed: {}",
redact_url(String::from_utf8_lossy(&out.stderr).trim())
)));
}
let actual = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !git_commit_matches(&actual, commit) {
return Err(Error::Git(format!(
"git clone HEAD {actual} does not match requested commit {commit}"
)));
}
Ok(actual)
};
let head_sha = match do_clone() {
Ok(sha) => sha,
Err(e) => {
let _ = std::fs::remove_dir_all(&scratch);
return Err(e);
}
};
match aube_util::fs_atomic::rename_with_retry(&scratch, &target) {
Ok(()) => Ok((
canonicalize_clone_dir(&target, commit, &head_sha, &cache_key),
head_sha,
)),
Err(_) => {
if target.join(".git").is_dir()
&& let Ok(out) = git_command()
.args(["rev-parse", "HEAD"])
.current_dir(&target)
.output()
&& out.status.success()
{
let head = String::from_utf8_lossy(&out.stdout).trim().to_string();
if git_commit_matches(&head, commit) {
let _ = std::fs::remove_dir_all(&scratch);
return Ok((
canonicalize_clone_dir(&target, commit, &head, &cache_key),
head,
));
}
}
let _ = std::fs::remove_dir_all(&target);
aube_util::fs_atomic::rename_with_retry(&scratch, &target).map_err(|e| {
let _ = std::fs::remove_dir_all(&scratch);
Error::Git(format!("rename clone into place: {e}"))
})?;
Ok((
canonicalize_clone_dir(&target, commit, &head_sha, &cache_key),
head_sha,
))
}
}
}
fn canonicalize_clone_dir(
target: &Path,
commit: &str,
head_sha: &str,
cache_key: &dyn Fn(&str) -> (String, String),
) -> PathBuf {
if commit.eq_ignore_ascii_case(head_sha) {
return target.to_path_buf();
}
let parent = match target.parent() {
Some(p) => p,
None => return target.to_path_buf(),
};
let (key, short) = cache_key(head_sha);
let canonical = parent.join(format!("aube-git-{key}-{short}"));
if canonical.join(".git").is_dir() {
let _ = std::fs::remove_dir_all(target);
return canonical;
}
match aube_util::fs_atomic::rename_with_retry(target, &canonical) {
Ok(()) => canonical,
Err(_) => target.to_path_buf(),
}
}
pub fn extract_codeload_tarball(
bytes: &[u8],
url: &str,
commit: &str,
integrity: Option<&str>,
) -> Result<(PathBuf, String), Error> {
let git_root = crate::dirs::cache_dir()
.map(|d| d.join("git"))
.unwrap_or_else(std::env::temp_dir);
extract_codeload_tarball_at(&git_root, bytes, url, commit, integrity)
}
pub fn codeload_cache_lookup(
url: &str,
commit: &str,
integrity: Option<&str>,
) -> Option<(PathBuf, String)> {
let git_root = crate::dirs::cache_dir()
.map(|d| d.join("git"))
.unwrap_or_else(std::env::temp_dir);
let (target, head_sha) = codeload_cache_paths(&git_root, url, commit, integrity)?;
target.is_dir().then_some((target, head_sha))
}
pub fn codeload_cache_integrity(
url: &str,
commit: &str,
integrity: Option<&str>,
) -> Option<String> {
let git_root = crate::dirs::cache_dir()
.map(|d| d.join("git"))
.unwrap_or_else(std::env::temp_dir);
let (target, _) = codeload_cache_paths(&git_root, url, commit, integrity)?;
target
.is_dir()
.then(|| read_codeload_integrity(&target))
.flatten()
}
pub(crate) fn codeload_integrity_path(target: &Path) -> PathBuf {
let file_name = target
.file_name()
.and_then(|s| s.to_str())
.map(|s| format!("{s}.integrity"))
.unwrap_or_else(|| "aube-codeload.integrity".to_string());
target.with_file_name(file_name)
}
fn codeload_integrity(bytes: &[u8]) -> String {
crate::sha512_integrity(bytes)
}
pub(crate) fn read_codeload_integrity(target: &Path) -> Option<String> {
std::fs::read_to_string(codeload_integrity_path(target))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub(crate) fn codeload_cache_paths(
cache_root: &Path,
url: &str,
commit: &str,
integrity: Option<&str>,
) -> Option<(PathBuf, String)> {
if validate_git_positional(url, "git url").is_err()
|| validate_git_positional(commit, "git commit").is_err()
{
return None;
}
if commit.len() != 40 || !commit.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let head_sha = commit.to_ascii_lowercase();
let mut hasher = blake3::Hasher::new();
hasher.update(url.as_bytes());
hasher.update(b"\0");
hasher.update(head_sha.as_bytes());
if let Some(integrity) = integrity {
hasher.update(b"\0");
hasher.update(integrity.as_bytes());
}
let digest = hasher.finalize();
let key: String = digest
.as_bytes()
.iter()
.take(8)
.map(|b| format!("{b:02x}"))
.collect();
let short = head_sha[..12].to_string();
Some((
cache_root.join(format!("aube-codeload-{key}-{short}")),
head_sha,
))
}
pub(crate) fn extract_codeload_tarball_at(
git_root: &Path,
bytes: &[u8],
url: &str,
commit: &str,
integrity: Option<&str>,
) -> Result<(PathBuf, String), Error> {
use std::io::Read;
let (target, head_sha) =
codeload_cache_paths(git_root, url, commit, integrity).ok_or_else(|| {
Error::Git(format!(
"extract_codeload_tarball: invalid (url, commit) — commit must be a full 40-char SHA, got {commit}"
))
})?;
let key_short = target
.file_name()
.and_then(|s| s.to_str())
.and_then(|s| s.strip_prefix("aube-codeload-"))
.unwrap_or("");
std::fs::create_dir_all(git_root).map_err(|e| Error::Io(git_root.to_path_buf(), e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::set_permissions(git_root, std::fs::Permissions::from_mode(0o700)) {
warn!(
"failed to chmod 0700 {}: {e}. Git scratch dir may be world-accessible, check filesystem permissions",
git_root.display()
);
}
}
if target.is_dir() {
if read_codeload_integrity(&target).is_none() {
let integrity = codeload_integrity(bytes);
let _ = std::fs::write(codeload_integrity_path(&target), &integrity);
}
return Ok((target, head_sha));
}
let integrity = codeload_integrity(bytes);
let scratch = tempfile::Builder::new()
.prefix(&format!("aube-codeload-{key_short}."))
.tempdir_in(git_root)
.map_err(|e| Error::Io(git_root.to_path_buf(), e))?
.keep();
let extract_into = |target: &Path| -> Result<(), Error> {
let gz = flate2::read::GzDecoder::new(bytes);
let capped = CappedReader::new(gz, MAX_TARBALL_DECOMPRESSED_BYTES);
let buffered = std::io::BufReader::with_capacity(256 * 1024, capped);
let mut archive = tar::Archive::new(buffered);
let mut entries_seen: usize = 0;
for entry in archive.entries().map_err(|e| Error::Tar(e.to_string()))? {
entries_seen += 1;
if entries_seen > MAX_TARBALL_ENTRIES {
return Err(Error::Tar(format!(
"tarball exceeds entry cap of {MAX_TARBALL_ENTRIES}"
)));
}
let mut entry = entry.map_err(|e| Error::Tar(e.to_string()))?;
let entry_type = entry.header().entry_type();
if matches!(
entry_type,
tar::EntryType::XGlobalHeader | tar::EntryType::XHeader
) {
continue;
}
let raw_path = entry
.path()
.map_err(|e| Error::Tar(e.to_string()))?
.to_path_buf();
let mut comps = raw_path.components();
let _wrapper = comps.next();
let rel: PathBuf = comps.collect();
if rel.as_os_str().is_empty() {
continue;
}
for c in rel.components() {
use std::path::Component;
if !matches!(c, Component::Normal(_)) {
return Err(Error::Tar(format!(
"tarball entry has unsafe path component: {}",
raw_path.display()
)));
}
}
let dest = target.join(&rel);
if entry_type.is_dir() {
std::fs::create_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;
continue;
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::Io(parent.to_path_buf(), e))?;
}
match entry_type {
tar::EntryType::Regular | tar::EntryType::Continuous => {
let declared = entry
.header()
.size()
.map_err(|e| Error::Tar(e.to_string()))?;
if declared > MAX_TARBALL_ENTRY_BYTES {
return Err(Error::Tar(format!(
"tarball entry exceeds per-entry cap: {declared} bytes > {MAX_TARBALL_ENTRY_BYTES}"
)));
}
let mut out =
std::fs::File::create(&dest).map_err(|e| Error::Io(dest.clone(), e))?;
let mut limited = entry.by_ref().take(MAX_TARBALL_ENTRY_BYTES);
std::io::copy(&mut limited, &mut out)
.map_err(|e| Error::Io(dest.clone(), e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(mode) = entry.header().mode() {
let safe = if mode & 0o111 != 0 { 0o755 } else { 0o644 };
let _ = std::fs::set_permissions(
&dest,
std::fs::Permissions::from_mode(safe),
);
}
}
}
tar::EntryType::Symlink => {
let link_target = entry
.link_name()
.map_err(|e| Error::Tar(e.to_string()))?
.ok_or_else(|| Error::Tar("symlink without target".into()))?
.into_owned();
if link_target.is_absolute()
|| link_target.components().any(|c| {
matches!(
c,
std::path::Component::ParentDir | std::path::Component::RootDir
)
})
{
return Err(Error::Tar(format!(
"tarball symlink {} -> {} escapes target",
raw_path.display(),
link_target.display()
)));
}
#[cfg(unix)]
std::os::unix::fs::symlink(&link_target, &dest)
.map_err(|e| Error::Io(dest.clone(), e))?;
#[cfg(windows)]
{
return Err(Error::Tar(format!(
"tarball symlink {} -> {} not supported on Windows; \
remove the codeload cache entry and retry to fall back to `git clone`",
raw_path.display(),
link_target.display()
)));
}
}
_ => {
return Err(Error::Tar(format!(
"tarball entry type {entry_type:?} is not allowed"
)));
}
}
}
Ok(())
};
if let Err(e) = extract_into(&scratch) {
let _ = std::fs::remove_dir_all(&scratch);
return Err(e);
}
match aube_util::fs_atomic::rename_with_retry(&scratch, &target) {
Ok(()) => {
let _ = std::fs::write(codeload_integrity_path(&target), &integrity);
Ok((target, head_sha))
}
Err(_) => {
if target.is_dir() {
let _ = std::fs::remove_dir_all(&scratch);
if read_codeload_integrity(&target).is_none() {
let _ = std::fs::write(codeload_integrity_path(&target), &integrity);
}
return Ok((target, head_sha));
}
let _ = std::fs::remove_dir_all(&target);
aube_util::fs_atomic::rename_with_retry(&scratch, &target).map_err(|e| {
let _ = std::fs::remove_dir_all(&scratch);
Error::Git(format!("rename codeload extract into place: {e}"))
})?;
let _ = std::fs::write(codeload_integrity_path(&target), &integrity);
Ok((target, head_sha))
}
}
}
pub(crate) fn git_commit_matches(actual: &str, requested: &str) -> bool {
actual == requested
|| (requested.len() >= 7
&& requested.len() < 40
&& requested.chars().all(|c| c.is_ascii_hexdigit())
&& actual.starts_with(requested))
}