use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
use crate::error::{Result, ToolchainError};
use crate::executor::{ContainerBuildExecutor, ContainerBuildRequest, NetPolicy};
use crate::manifest::{ToolchainManifest, ToolchainSource};
use crate::recipe::{InstallPlan, InstallStep};
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 toolchain = cache_dir.join(format!("{formula}-{}-{}", spec.version, arch_token()));
let ready_marker = toolchain.join(".ready");
if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
return Ok(toolchain);
}
let _ = tokio::fs::remove_dir_all(&toolchain).await;
tokio::fs::create_dir_all(&toolchain).await?;
let brew_prefix = toolchain.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?;
let executor = crate::executor::container_executor();
brew_install_in_container(formula, &toolchain, &brew_cache, executor.as_deref()).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 = ToolchainManifest {
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: ToolchainSource::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_toolchain(&toolchain).await?;
tokio::fs::write(&ready_marker, b"").await?;
info!(formula, toolchain = %toolchain.display(), "brew-emulate fallback produced a self-contained toolchain");
Ok(toolchain)
}
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 brew_install_in_container(
formula: &str,
toolchain: &Path,
brew_cache: &Path,
executor: Option<&dyn ContainerBuildExecutor>,
) -> Result<()> {
let Some(executor) = executor else {
return Err(ToolchainError::ExecutorUnavailable {
tool: formula.to_string(),
});
};
let scratch = toolchain.join(".build");
tokio::fs::create_dir_all(scratch.join("home")).await?;
let req = assemble_brew_container_request(formula, toolchain, brew_cache);
warn!(
target: "net_fallback",
tool = %formula,
"NET-FALLBACK: building via brew inside a throwaway container (recipe not natively executable)"
);
let report = executor.execute(&req).await?;
debug!(tool = %formula, log_tail = %report.log_tail, "brew-in-container install succeeded");
warn!(
target: "net_fallback",
tool = %formula,
"NET-FALLBACK: brew-in-container build completed"
);
Ok(())
}
fn assemble_brew_container_request(
formula: &str,
toolchain: &Path,
brew_cache: &Path,
) -> ContainerBuildRequest {
let brew_prefix = toolchain.join("brew");
let scratch = toolchain.join(".build");
let env = brew_env(&brew_prefix, brew_cache, &scratch);
ContainerBuildRequest {
tool: formula.to_string(),
platform: crate::ToolPlatform::MacOS,
plan: InstallPlan {
steps: vec![InstallStep::System {
argv: vec![
brew_prefix.join("bin/brew").display().to_string(),
"install".to_string(),
"--build-from-source".to_string(),
formula.to_string(),
],
}],
resources: vec![],
patches: vec![],
env: env.clone(),
deparallelize: false,
},
src_dir: scratch.clone(),
prefix: toolchain.to_path_buf(),
scratch_dir: scratch,
dep_toolchains: vec![],
resources_dir: None,
env,
path_prefix: vec![],
net: NetPolicy::AllowLoud,
}
}
fn brew_env(brew_prefix: &Path, brew_cache: &Path, scratch: &Path) -> HashMap<String, String> {
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());
let mut env = HashMap::new();
env.insert("PATH".to_string(), path_parts.join(":"));
env.insert("HOMEBREW_PREFIX".to_string(), prefix_str.clone());
env.insert("HOMEBREW_REPOSITORY".to_string(), prefix_str);
env.insert(
"HOMEBREW_CELLAR".to_string(),
brew_prefix.join("Cellar").display().to_string(),
);
env.insert(
"HOMEBREW_CACHE".to_string(),
brew_cache.display().to_string(),
);
env.insert("HOMEBREW_NO_AUTO_UPDATE".to_string(), "1".to_string());
env.insert("HOMEBREW_NO_ANALYTICS".to_string(), "1".to_string());
env.insert("HOMEBREW_NO_ENV_HINTS".to_string(), "1".to_string());
env.insert("HOMEBREW_NO_INSTALL_CLEANUP".to_string(), "1".to_string());
env.insert(
"HOME".to_string(),
scratch.join("home").display().to_string(),
);
env
}
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_toolchain() {
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 toolchain = tmp.path().join(format!("demo-1.2.3-{}", arch_token()));
tokio::fs::create_dir_all(&toolchain).await.unwrap();
tokio::fs::write(toolchain.join(".ready"), b"")
.await
.unwrap();
let got = ensure_via_brew("demo", &spec, tmp.path()).await.unwrap();
assert_eq!(
got, toolchain,
"a ready toolchain short-circuits without invoking brew"
);
}
#[tokio::test]
async fn brew_install_without_executor_is_executor_unavailable() {
let tmp = tempfile::tempdir().unwrap();
let err =
brew_install_in_container("demo", tmp.path(), &tmp.path().join(".brew-cache"), None)
.await
.expect_err("executor-less brew install must be a hard error");
assert!(
matches!(err, ToolchainError::ExecutorUnavailable { ref tool } if tool == "demo"),
"expected ExecutorUnavailable for 'demo', got: {err}"
);
}
#[test]
fn brew_container_request_carries_brew_argv_env_and_allowloud() {
let toolchain = Path::new("/tc/demo-1.2.3-arm64");
let brew_cache = Path::new("/tc/.brew-cache");
let req = assemble_brew_container_request("demo", toolchain, brew_cache);
assert_eq!(req.tool, "demo");
assert_eq!(req.platform, crate::ToolPlatform::MacOS);
assert_eq!(
req.net,
NetPolicy::AllowLoud,
"network is inherent to the brew path and must be loud"
);
assert_eq!(req.prefix, toolchain);
assert_eq!(req.scratch_dir, toolchain.join(".build"));
assert_eq!(
req.src_dir,
toolchain.join(".build"),
"brew needs no source tree; scratch doubles as src_dir"
);
assert!(req.dep_toolchains.is_empty());
assert!(req.resources_dir.is_none());
assert!(
req.path_prefix.is_empty(),
"PATH travels in env, not path_prefix"
);
assert_eq!(
req.plan.steps,
vec![InstallStep::System {
argv: vec![
"/tc/demo-1.2.3-arm64/brew/bin/brew".to_string(),
"install".to_string(),
"--build-from-source".to_string(),
"demo".to_string(),
],
}]
);
assert!(req.plan.resources.is_empty());
assert!(req.plan.patches.is_empty());
assert!(!req.plan.deparallelize);
assert_eq!(
req.plan.env, req.env,
"steps run with the same env the request carries"
);
let env = &req.env;
assert_eq!(
env.get("HOMEBREW_PREFIX").map(String::as_str),
Some("/tc/demo-1.2.3-arm64/brew")
);
assert_eq!(
env.get("HOMEBREW_REPOSITORY").map(String::as_str),
Some("/tc/demo-1.2.3-arm64/brew")
);
assert_eq!(
env.get("HOMEBREW_CELLAR").map(String::as_str),
Some("/tc/demo-1.2.3-arm64/brew/Cellar")
);
assert_eq!(
env.get("HOMEBREW_CACHE").map(String::as_str),
Some("/tc/.brew-cache")
);
for quiet in [
"HOMEBREW_NO_AUTO_UPDATE",
"HOMEBREW_NO_ANALYTICS",
"HOMEBREW_NO_ENV_HINTS",
"HOMEBREW_NO_INSTALL_CLEANUP",
] {
assert_eq!(
env.get(quiet).map(String::as_str),
Some("1"),
"{quiet} must be set to 1"
);
}
assert_eq!(
env.get("HOME").map(String::as_str),
Some("/tc/demo-1.2.3-arm64/.build/home"),
"container HOME lives under scratch"
);
let path = env.get("PATH").expect("PATH must be set");
assert!(
path.starts_with("/tc/demo-1.2.3-arm64/brew/bin:"),
"prefix bin must lead PATH, got: {path}"
);
assert!(
path.ends_with("/usr/bin:/bin:/usr/sbin:/sbin"),
"system fallback must end PATH, got: {path}"
);
}
struct CapturingExecutor {
seen: std::sync::Mutex<Option<ContainerBuildRequest>>,
}
impl ContainerBuildExecutor for CapturingExecutor {
fn execute<'a>(
&'a self,
req: &'a ContainerBuildRequest,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = Result<crate::executor::ContainerBuildReport>>
+ Send
+ 'a,
>,
> {
Box::pin(async move {
*self.seen.lock().unwrap() = Some(req.clone());
Ok(crate::executor::ContainerBuildReport {
log_tail: String::new(),
})
})
}
}
#[tokio::test]
async fn brew_install_routes_request_through_injected_executor() {
let tmp = tempfile::tempdir().unwrap();
let toolchain = tmp.path().join(format!("demo-1.2.3-{}", arch_token()));
tokio::fs::create_dir_all(&toolchain).await.unwrap();
let brew_cache = tmp.path().join(".brew-cache");
let exec = CapturingExecutor {
seen: std::sync::Mutex::new(None),
};
brew_install_in_container("demo", &toolchain, &brew_cache, Some(&exec))
.await
.expect("mock container build should succeed");
let seen = exec
.seen
.lock()
.unwrap()
.take()
.expect("executor must have been invoked with the assembled request");
assert_eq!(seen.tool, "demo");
assert_eq!(seen.net, NetPolicy::AllowLoud);
assert_eq!(seen.prefix, toolchain);
assert!(
toolchain.join(".build").join("home").is_dir(),
"scratch HOME dir created before the container runs"
);
}
}