use std::path::{Path, PathBuf};
use tracing::{info, warn};
use crate::error::{Result, ToolchainError};
use crate::manifest::{KegManifest, KegSource};
use crate::source_build::SourceSpec;
const HOMEBREW_REPO_URL: &str = "https://github.com/Homebrew/brew";
fn arch_token() -> &'static str {
match std::env::consts::ARCH {
"aarch64" => "arm64",
other => other,
}
}
pub async fn ensure_via_brew(
formula: &str,
spec: &SourceSpec,
cache_dir: &Path,
) -> Result<PathBuf> {
let keg = cache_dir.join(format!("{formula}-{}-{}", spec.version, arch_token()));
let ready_marker = keg.join(".ready");
if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
return Ok(keg);
}
let _ = tokio::fs::remove_dir_all(&keg).await;
tokio::fs::create_dir_all(&keg).await?;
let brew_prefix = keg.join("brew");
provision_brew_at_prefix(&brew_prefix).await?;
let brew_cache = cache_dir.join(".brew-cache");
tokio::fs::create_dir_all(&brew_cache).await?;
run_brew_install(formula, &brew_prefix, &brew_cache).await?;
let mut path_dirs = Vec::new();
let opt_bin = brew_prefix.join("opt").join(formula).join("bin");
if tokio::fs::try_exists(&opt_bin).await.unwrap_or(false) {
path_dirs.push(opt_bin.display().to_string());
} else {
let prefix_bin = brew_prefix.join("bin");
if tokio::fs::try_exists(&prefix_bin).await.unwrap_or(false) {
path_dirs.push(prefix_bin.display().to_string());
}
}
if path_dirs.is_empty() {
return Err(ToolchainError::RegistryError {
message: format!(
"brew-emulate install of {formula} produced no bin dir under {}",
brew_prefix.display()
),
});
}
if let Some(offending) = scan_for_homebrew_placeholder(&opt_bin).await {
return Err(ToolchainError::RegistryError {
message: format!(
"brew-emulate {formula}: binary {} still carries an @@HOMEBREW@@ load \
command (bottle poured instead of built from source?)",
offending.display()
),
});
}
for slim in [".git", "Library", "docs", "completions", "manpages"] {
let _ = tokio::fs::remove_dir_all(brew_prefix.join(slim)).await;
}
let _ = tokio::fs::remove_file(brew_prefix.join("bin").join("brew")).await;
let manifest = KegManifest {
tool: formula.to_string(),
version: spec.version.clone(),
arch: arch_token().to_string(),
platform: "macos".to_string(),
path_dirs,
env: std::collections::HashMap::new(),
source: KegSource::SourceBuild {
url: spec.tarball_url.clone(),
sha256: spec.sha256.clone(),
},
build_deps: spec.build_dependencies.clone(),
provisioned_at: chrono::Utc::now().to_rfc3339(),
};
manifest.write_to_keg(&keg).await?;
tokio::fs::write(&ready_marker, b"").await?;
info!(formula, keg = %keg.display(), "brew-emulate fallback produced a self-contained keg");
Ok(keg)
}
async fn provision_brew_at_prefix(brew_prefix: &Path) -> Result<()> {
if tokio::fs::try_exists(brew_prefix.join("bin/brew"))
.await
.unwrap_or(false)
{
return Ok(());
}
if let Some(parent) = brew_prefix.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let clone = tokio::process::Command::new("git")
.arg("clone")
.arg("--depth=1")
.arg(HOMEBREW_REPO_URL)
.arg(brew_prefix)
.output()
.await;
if let Ok(out) = clone {
if out.status.success()
&& tokio::fs::try_exists(brew_prefix.join("bin/brew"))
.await
.unwrap_or(false)
{
return Ok(());
}
warn!(
"git clone of Homebrew failed ({}); falling back to source tarball",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let tarball = "https://github.com/Homebrew/brew/archive/refs/heads/master.tar.gz";
let bytes = reqwest::get(tarball)
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("failed to download Homebrew tarball: {e}"),
})?
.bytes()
.await
.map_err(|e| ToolchainError::RegistryError {
message: format!("failed to read Homebrew tarball bytes: {e}"),
})?;
let tmp = brew_prefix.with_extension("tar.gz");
tokio::fs::write(&tmp, &bytes).await?;
tokio::fs::create_dir_all(brew_prefix).await?;
let untar = tokio::process::Command::new("tar")
.arg("xf")
.arg(&tmp)
.args(["--strip-components", "1", "-C"])
.arg(brew_prefix)
.output()
.await?;
let _ = tokio::fs::remove_file(&tmp).await;
if !untar.status.success() {
return Err(ToolchainError::RegistryError {
message: format!(
"failed to extract Homebrew tarball: {}",
String::from_utf8_lossy(&untar.stderr)
),
});
}
if !tokio::fs::try_exists(brew_prefix.join("bin/brew"))
.await
.unwrap_or(false)
{
return Err(ToolchainError::RegistryError {
message: format!(
"Homebrew checkout at {} has no bin/brew",
brew_prefix.display()
),
});
}
Ok(())
}
async fn run_brew_install(formula: &str, brew_prefix: &Path, brew_cache: &Path) -> Result<()> {
let brew_bin = brew_prefix.join("bin/brew");
let prefix_str = brew_prefix.display().to_string();
let host_path = std::env::var("PATH").unwrap_or_default();
let mut path_parts = vec![brew_prefix.join("bin").display().to_string()];
if !host_path.is_empty() {
path_parts.push(host_path);
}
path_parts.push("/usr/bin:/bin:/usr/sbin:/sbin".to_string());
info!(formula, prefix = %prefix_str, "running brew install --build-from-source");
let out = tokio::process::Command::new(&brew_bin)
.arg("install")
.arg("--build-from-source")
.arg(formula)
.env("PATH", path_parts.join(":"))
.env("HOMEBREW_PREFIX", &prefix_str)
.env("HOMEBREW_REPOSITORY", &prefix_str)
.env("HOMEBREW_CELLAR", brew_prefix.join("Cellar"))
.env("HOMEBREW_CACHE", brew_cache)
.env("HOMEBREW_NO_AUTO_UPDATE", "1")
.env("HOMEBREW_NO_ANALYTICS", "1")
.env("HOMEBREW_NO_ENV_HINTS", "1")
.env("HOMEBREW_NO_INSTALL_CLEANUP", "1")
.output()
.await?;
if !out.status.success() {
let tail = String::from_utf8_lossy(&out.stderr)
.lines()
.rev()
.take(30)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n");
return Err(ToolchainError::RegistryError {
message: format!("brew install --build-from-source {formula} failed:\n{tail}"),
});
}
Ok(())
}
async fn scan_for_homebrew_placeholder(dir: &Path) -> Option<PathBuf> {
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let mut entries = tokio::fs::read_dir(&d).await.ok()?;
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let ft = entry.file_type().await.ok()?;
if ft.is_dir() {
stack.push(path);
} else if ft.is_file() {
if let Ok(bytes) = tokio::fs::read(&path).await {
if contains_subslice(&bytes, b"@@HOMEBREW") {
return Some(path);
}
}
}
}
}
None
}
fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || haystack.len() < needle.len() {
return false;
}
haystack.windows(needle.len()).any(|w| w == needle)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn placeholder_scan_helper_matches() {
assert!(contains_subslice(b"abc@@HOMEBREW@@/lib", b"@@HOMEBREW"));
assert!(!contains_subslice(
b"/usr/lib/libSystem.dylib",
b"@@HOMEBREW"
));
assert!(!contains_subslice(b"", b"@@HOMEBREW"));
}
#[tokio::test]
async fn placeholder_scan_finds_offender_and_clean_is_none() {
let tmp = tempfile::tempdir().unwrap();
let sub = tmp.path().join("bin");
tokio::fs::create_dir_all(&sub).await.unwrap();
tokio::fs::write(sub.join("clean"), b"/usr/lib/libSystem.B.dylib")
.await
.unwrap();
assert!(scan_for_homebrew_placeholder(tmp.path()).await.is_none());
tokio::fs::write(
sub.join("dirty"),
b"@@HOMEBREW_PREFIX@@/opt/x/lib/libx.dylib",
)
.await
.unwrap();
let hit = scan_for_homebrew_placeholder(tmp.path()).await;
assert!(hit.is_some());
assert!(hit.unwrap().ends_with("dirty"));
}
#[tokio::test]
async fn ensure_via_brew_short_circuits_on_ready_keg() {
let tmp = tempfile::tempdir().unwrap();
let spec = SourceSpec {
version: "1.2.3".to_string(),
tarball_url: "https://example/x.tar.gz".to_string(),
sha256: String::new(),
dependencies: vec![],
build_dependencies: vec![],
macos_provided: vec![],
};
let keg = tmp.path().join(format!("demo-1.2.3-{}", arch_token()));
tokio::fs::create_dir_all(&keg).await.unwrap();
tokio::fs::write(keg.join(".ready"), b"").await.unwrap();
let got = ensure_via_brew("demo", &spec, tmp.path()).await.unwrap();
assert_eq!(got, keg, "a ready keg short-circuits without invoking brew");
}
}