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::formula::{self, Formula};
use crate::manifest::{ToolchainManifest, ToolchainSource};
use crate::recipe::{InstallPlan, RecipeCtx, ResourceSpec};
use crate::registry::ToolchainArtifactId;
use crate::relocate::RelocationReport;
const MACOS_OS_TOKEN: &str = "macos";
enum BuildKind {
Hermetic(RelocationReport),
NetFallback,
}
#[derive(Debug, Clone)]
pub struct SourceSpec {
pub version: String,
pub tarball_url: String,
pub sha256: String,
pub dependencies: Vec<String>,
pub build_dependencies: Vec<String>,
pub macos_provided: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BuildSystem {
Autotools,
CMake,
CMakeBootstrap,
MakePrefix,
}
fn arch_token() -> &'static str {
match std::env::consts::ARCH {
"aarch64" => "arm64",
other => other,
}
}
pub async fn resolve_source_spec(formula: &str) -> Result<SourceSpec> {
let info: Formula = formula::fetch_formula(formula).await?;
let version = info
.stable_version()
.ok_or_else(|| ToolchainError::RegistryError {
message: format!("formula {formula} has no stable version"),
})?
.to_string();
let tarball_url = info
.stable_url()
.ok_or_else(|| ToolchainError::RegistryError {
message: format!("formula {formula} has no stable source URL"),
})?
.to_string();
Ok(SourceSpec {
version,
tarball_url,
sha256: info.stable_checksum().unwrap_or_default(),
dependencies: info.dependencies.clone(),
build_dependencies: info.build_dependencies.clone(),
macos_provided: info.macos_provided(),
})
}
pub async fn ensure_from_source(
formula: &str,
cache_dir: &Path,
lockfile: Option<&crate::ToolchainLockfile>,
) -> Result<PathBuf> {
let mut spec = resolve_source_spec(formula).await?;
if let Some(locked) = lockfile.and_then(|l| {
use crate::ToolchainLockfileExt;
l.lookup(formula, "macos", arch_token())
}) {
spec.version = locked.version.clone();
spec.tarball_url = locked.url.clone();
spec.sha256 = locked.sha256.clone();
}
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 id = ToolchainArtifactId {
tool: formula.to_string(),
version: spec.version.clone(),
os: MACOS_OS_TOKEN.to_string(),
arch: arch_token().to_string(),
};
if crate::pull_first(&id, &toolchain).await?.is_some() {
return Ok(toolchain);
}
match run_build_chain(formula, &spec, &toolchain, cache_dir, lockfile).await {
Ok(BuildKind::Hermetic(report)) => {
crate::finish_built(&id, &toolchain, Some(&report), false).await;
Ok(toolchain)
}
Ok(BuildKind::NetFallback) => {
crate::record_net_fallback(&id).await;
Ok(toolchain)
}
Err(e) => {
crate::record_failed(&id, &e).await;
Err(e)
}
}
}
async fn run_build_chain(
formula: &str,
spec: &SourceSpec,
toolchain: &Path,
cache_dir: &Path,
lockfile: Option<&crate::ToolchainLockfile>,
) -> Result<BuildKind> {
if let Some(executor) = crate::executor::container_executor() {
match try_recipe_container_build(
formula,
spec,
toolchain,
cache_dir,
lockfile,
executor.as_ref(),
)
.await
{
Ok(report) => return Ok(BuildKind::Hermetic(report)),
Err(e) => {
if matches!(e, ToolchainError::ExecutorUnavailable { .. }) {
debug!(
formula,
error = %e,
"container executor unavailable; falling through to the generic source build"
);
} else {
warn!(
formula,
error = %e,
"container recipe build failed; falling through to the generic source build"
);
}
let _ = tokio::fs::remove_dir_all(toolchain).await;
}
}
}
match try_generic_source_build(formula, spec, toolchain, cache_dir, lockfile).await {
Ok(report) => Ok(BuildKind::Hermetic(report)),
Err(e) => {
warn!(
formula,
error = %e,
"generic source build failed; falling back to brew-emulate at the toolchain prefix"
);
let _ = tokio::fs::remove_dir_all(toolchain).await;
crate::brew_emulate::ensure_via_brew(formula, spec, cache_dir).await?;
Ok(BuildKind::NetFallback)
}
}
}
async fn try_recipe_container_build(
formula: &str,
spec: &SourceSpec,
toolchain: &Path,
cache_dir: &Path,
lockfile: Option<&crate::ToolchainLockfile>,
executor: &dyn ContainerBuildExecutor,
) -> Result<RelocationReport> {
let _ = tokio::fs::remove_dir_all(toolchain).await;
tokio::fs::create_dir_all(toolchain).await?;
let scratch = toolchain.join(".build");
tokio::fs::create_dir_all(&scratch).await?;
let deps = resolve_dependencies(formula, spec, cache_dir, lockfile).await?;
let info: Formula = formula::fetch_formula(formula).await?;
let rb = crate::recipe::fetch_recipe_cached(&info, &scratch.join("recipe.rb")).await?;
let ctx = RecipeCtx {
tool: formula.to_string(),
version: spec.version.clone(),
prefix: toolchain.to_path_buf(),
dep_prefixes: deps.prefixes_by_name.clone(),
target_macos: true,
target_arm: arch_token() == "arm64",
};
let plan = crate::recipe::parse_install_plan(&rb, &ctx)?;
if !plan.is_fully_supported() {
let constructs: Vec<String> = plan
.unsupported_constructs()
.into_iter()
.map(str::to_string)
.collect();
for construct in &constructs {
warn!(
formula,
construct = %construct,
"recipe construct unsupported by the native interpreter"
);
}
return Err(ToolchainError::RecipeUnsupported {
tool: formula.to_string(),
constructs,
});
}
execute_plan_in_container(formula, spec, toolchain, plan, &deps, executor).await
}
async fn execute_plan_in_container(
formula: &str,
spec: &SourceSpec,
toolchain: &Path,
plan: InstallPlan,
deps: &ResolvedDeps,
executor: &dyn ContainerBuildExecutor,
) -> Result<RelocationReport> {
let scratch = toolchain.join(".build");
let src_dir = download_and_extract(formula, spec, &scratch).await?;
let resources_dir = prefetch_plan_inputs(formula, &plan, &scratch).await?;
let req = assemble_container_request(
formula,
toolchain,
&scratch,
plan,
deps,
src_dir,
resources_dir,
);
run_request_and_finalize(spec, deps.build_dep_names.clone(), req, executor).await
}
async fn run_request_and_finalize(
spec: &SourceSpec,
build_dep_names: Vec<String>,
req: ContainerBuildRequest,
executor: &dyn ContainerBuildExecutor,
) -> Result<RelocationReport> {
let report = executor.execute(&req).await?;
debug!(tool = %req.tool, log_tail = %report.log_tail, "container recipe build succeeded");
finalize_toolchain(
&req.tool,
spec,
&req.prefix,
&req.scratch_dir,
build_dep_names,
&req.dep_toolchains,
)
.await
}
fn assemble_container_request(
formula: &str,
toolchain: &Path,
scratch: &Path,
plan: InstallPlan,
deps: &ResolvedDeps,
src_dir: PathBuf,
resources_dir: Option<PathBuf>,
) -> ContainerBuildRequest {
let env = plan_env(&plan);
ContainerBuildRequest {
tool: formula.to_string(),
platform: crate::ToolPlatform::MacOS,
plan,
src_dir,
prefix: toolchain.to_path_buf(),
scratch_dir: scratch.to_path_buf(),
dep_toolchains: deps.prefixes.clone(),
resources_dir,
env,
path_prefix: deps.env.path_prefix.clone(),
net: NetPolicy::Deny,
}
}
fn plan_env(plan: &InstallPlan) -> HashMap<String, String> {
let mut env = plan.env.clone();
if plan.deparallelize {
env.insert("MAKEFLAGS".to_string(), "-j1".to_string());
}
env
}
fn resource_dest(resources_dir: &Path, res: &ResourceSpec) -> PathBuf {
let file = res
.url
.rsplit('/')
.next()
.filter(|s| !s.is_empty())
.unwrap_or("resource.tar");
resources_dir.join(&res.name).join(file)
}
fn patch_dest(resources_dir: &Path, index: usize) -> PathBuf {
resources_dir.join("patches").join(index.to_string())
}
async fn prefetch_plan_inputs(
formula: &str,
plan: &InstallPlan,
scratch: &Path,
) -> Result<Option<PathBuf>> {
if plan.resources.is_empty() && plan.patches.is_empty() {
return Ok(None);
}
let resources_dir = scratch.join("resources");
for res in &plan.resources {
if res.sha256.trim().is_empty() {
return Err(ToolchainError::RegistryError {
message: format!(
"resource '{}' of {formula} declares no sha256; refusing an unverified pre-fetch",
res.name
),
});
}
let dest = resource_dest(&resources_dir, res);
crate::package_index::download_verified(&res.url, &dest, Some(&res.sha256)).await?;
}
for (index, patch) in plan.patches.iter().enumerate() {
if patch.sha256.trim().is_empty() {
return Err(ToolchainError::RegistryError {
message: format!(
"patch #{index} of {formula} declares no sha256; refusing an unverified pre-fetch"
),
});
}
let dest = patch_dest(&resources_dir, index);
crate::package_index::download_verified(&patch.url, &dest, Some(&patch.sha256)).await?;
}
Ok(Some(resources_dir))
}
async fn try_generic_source_build(
formula: &str,
spec: &SourceSpec,
toolchain: &Path,
cache_dir: &Path,
lockfile: Option<&crate::ToolchainLockfile>,
) -> Result<RelocationReport> {
let _ = tokio::fs::remove_dir_all(toolchain).await;
tokio::fs::create_dir_all(toolchain).await?;
let scratch = toolchain.join(".build");
tokio::fs::create_dir_all(&scratch).await?;
let deps = resolve_dependencies(formula, spec, cache_dir, lockfile).await?;
let src_dir = download_and_extract(formula, spec, &scratch).await?;
let system = detect_build_system(&src_dir).await?;
info!(formula, ?system, "detected build system");
run_build(formula, &src_dir, toolchain, system, &deps.env).await?;
finalize_toolchain(
formula,
spec,
toolchain,
&scratch,
deps.build_dep_names,
&deps.prefixes,
)
.await
}
async fn finalize_toolchain(
formula: &str,
spec: &SourceSpec,
toolchain: &Path,
scratch: &Path,
build_deps: Vec<String>,
dep_dirs: &[PathBuf],
) -> Result<RelocationReport> {
match tokio::fs::remove_dir_all(scratch).await {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => warn!(error = %e, "failed to clean source scratch dir (non-fatal)"),
}
let report = crate::relocate::make_relocatable(toolchain, toolchain, dep_dirs).await?;
let manifest = build_manifest(formula, spec, toolchain, build_deps).await;
manifest.write_to_toolchain(toolchain).await?;
tokio::fs::write(toolchain.join(".ready"), b"").await?;
Ok(report)
}
async fn build_manifest(
formula: &str,
spec: &SourceSpec,
toolchain: &Path,
build_deps: Vec<String>,
) -> ToolchainManifest {
let mut path_dirs = Vec::new();
let bin = toolchain.join("bin");
if tokio::fs::try_exists(&bin).await.unwrap_or(false) {
path_dirs.push(bin.display().to_string());
}
let mut env: HashMap<String, String> = HashMap::new();
let git_exec = toolchain.join("libexec/git-core");
if tokio::fs::try_exists(&git_exec).await.unwrap_or(false) {
env.insert("GIT_EXEC_PATH".to_string(), git_exec.display().to_string());
}
ToolchainManifest {
tool: formula.to_string(),
version: spec.version.clone(),
arch: arch_token().to_string(),
platform: "macos".to_string(),
path_dirs,
env,
source: ToolchainSource::SourceBuild {
url: spec.tarball_url.clone(),
sha256: spec.sha256.clone(),
},
build_deps,
provisioned_at: chrono::Utc::now().to_rfc3339(),
}
}
#[derive(Debug, Default, Clone)]
struct BuildEnv {
path_prefix: Vec<String>,
cppflags: Vec<String>,
ldflags: Vec<String>,
pkg_config_path: Vec<String>,
}
#[derive(Debug, Default)]
struct ResolvedDeps {
env: BuildEnv,
build_dep_names: Vec<String>,
prefixes_by_name: HashMap<String, PathBuf>,
prefixes: Vec<PathBuf>,
}
impl ResolvedDeps {
fn record(&mut self, dep: &str, toolchain: &Path) {
self.prefixes_by_name
.insert(dep.to_string(), toolchain.to_path_buf());
if !self.prefixes.iter().any(|p| p == toolchain) {
self.prefixes.push(toolchain.to_path_buf());
}
}
}
async fn resolve_dependencies(
formula: &str,
spec: &SourceSpec,
cache_dir: &Path,
lockfile: Option<&crate::ToolchainLockfile>,
) -> Result<ResolvedDeps> {
let mut deps = ResolvedDeps::default();
let is_macos_provided = |dep: &str| spec.macos_provided.iter().any(|d| d == dep);
for dep in &spec.build_dependencies {
if is_macos_provided(dep) {
continue;
}
let toolchain = Box::pin(crate::ensure_macos_toolchain(dep, cache_dir, lockfile)).await?;
let manifest = ToolchainManifest::load_or_synthesize(&toolchain).await?;
deps.env.path_prefix.extend(manifest.path_dirs.clone());
add_dep_link_flags(&mut deps.env, &toolchain);
deps.record(dep, &toolchain);
deps.build_dep_names.push(dep.clone());
info!(formula, dep, toolchain = %toolchain.display(), "resolved build dependency toolchain");
}
for dep in &spec.dependencies {
if is_macos_provided(dep) {
continue;
}
match Box::pin(crate::ensure_macos_toolchain(dep, cache_dir, lockfile)).await {
Ok(toolchain) => {
if let Ok(manifest) = ToolchainManifest::load_or_synthesize(&toolchain).await {
deps.env.path_prefix.extend(manifest.path_dirs.clone());
}
add_dep_link_flags(&mut deps.env, &toolchain);
deps.record(dep, &toolchain);
info!(formula, dep, toolchain = %toolchain.display(), "resolved runtime dependency toolchain");
}
Err(e) => warn!(
formula, dep, error = %e,
"runtime dependency toolchain unavailable; continuing without it"
),
}
}
Ok(deps)
}
fn add_dep_link_flags(env: &mut BuildEnv, toolchain: &Path) {
let include = toolchain.join("include");
let lib = toolchain.join("lib");
if include.is_dir() {
env.cppflags.push(format!("-I{}", include.display()));
}
if lib.is_dir() {
env.ldflags.push(format!("-L{}", lib.display()));
env.ldflags.push(format!("-Wl,-rpath,{}", lib.display()));
let pc = lib.join("pkgconfig");
if pc.is_dir() {
env.pkg_config_path.push(pc.display().to_string());
}
}
}
async fn download_and_extract(formula: &str, spec: &SourceSpec, scratch: &Path) -> Result<PathBuf> {
let tar_name = spec
.tarball_url
.rsplit('/')
.next()
.filter(|s| !s.is_empty())
.unwrap_or("source.tar");
let tar_path = scratch.join(tar_name);
info!(url = %spec.tarball_url, "downloading {formula} source tarball");
let expected = (!spec.sha256.is_empty()).then_some(spec.sha256.as_str());
crate::package_index::download_verified(&spec.tarball_url, &tar_path, expected).await?;
let src_dir = scratch.join("src");
let _ = tokio::fs::remove_dir_all(&src_dir).await;
tokio::fs::create_dir_all(&src_dir).await?;
let untar = tokio::process::Command::new("tar")
.arg("xf")
.arg(&tar_path)
.args(["--strip-components", "1", "-C"])
.arg(&src_dir)
.output()
.await?;
if !untar.status.success() {
return Err(ToolchainError::RegistryError {
message: format!(
"failed to extract {formula} source: {}",
String::from_utf8_lossy(&untar.stderr)
),
});
}
Ok(src_dir)
}
async fn detect_build_system(src_dir: &Path) -> Result<BuildSystem> {
let exists = |rel: &str| {
let p = src_dir.join(rel);
async move { tokio::fs::try_exists(&p).await.unwrap_or(false) }
};
let has_bootstrap = exists("bootstrap").await || exists("bootstrap.sh").await;
let has_cmakelists = exists("CMakeLists.txt").await;
if has_bootstrap && has_cmakelists {
Ok(BuildSystem::CMakeBootstrap)
} else if exists("configure").await {
Ok(BuildSystem::Autotools)
} else if exists("Makefile").await || exists("GNUmakefile").await {
Ok(BuildSystem::MakePrefix)
} else if has_cmakelists {
Ok(BuildSystem::CMake)
} else if exists("configure.ac").await
|| exists("configure.in").await
|| exists("autogen.sh").await
|| has_bootstrap
{
Ok(BuildSystem::Autotools)
} else {
Err(ToolchainError::RegistryError {
message: format!(
"could not detect a build system \
(configure/CMakeLists.txt/Makefile/bootstrap) in {}",
src_dir.display()
),
})
}
}
#[allow(clippy::too_many_lines)]
async fn run_build(
formula: &str,
src_dir: &Path,
toolchain: &Path,
system: BuildSystem,
build_env: &BuildEnv,
) -> Result<()> {
let jobs = std::thread::available_parallelism()
.map_or(4, std::num::NonZero::get)
.to_string();
let toolchain_str = toolchain.display().to_string();
match system {
BuildSystem::MakePrefix => {
let mut cmd = tokio::process::Command::new("make");
cmd.current_dir(src_dir)
.arg(format!("-j{jobs}"))
.arg(format!("prefix={toolchain_str}"))
.arg("install");
run_cmd(formula, "make install", &mut cmd, build_env).await?;
}
BuildSystem::CMakeBootstrap => {
run_cmd(
formula,
"bootstrap",
tokio::process::Command::new("./bootstrap")
.current_dir(src_dir)
.arg(format!("--prefix={toolchain_str}"))
.arg(format!("--parallel={jobs}")),
build_env,
)
.await?;
run_cmd(
formula,
"make",
tokio::process::Command::new("make")
.current_dir(src_dir)
.arg(format!("-j{jobs}")),
build_env,
)
.await?;
run_cmd(
formula,
"make install",
tokio::process::Command::new("make")
.current_dir(src_dir)
.arg("install"),
build_env,
)
.await?;
}
BuildSystem::Autotools => {
if !src_dir.join("configure").is_file() {
let autogen = src_dir.join("autogen.sh");
if autogen.is_file() {
run_cmd(
formula,
"autogen.sh",
tokio::process::Command::new("sh")
.current_dir(src_dir)
.arg("autogen.sh"),
build_env,
)
.await?;
} else {
run_cmd(
formula,
"autoreconf",
tokio::process::Command::new("autoreconf")
.current_dir(src_dir)
.arg("-fi"),
build_env,
)
.await?;
}
}
let mut configure = tokio::process::Command::new("./configure");
configure
.current_dir(src_dir)
.arg(format!("--prefix={toolchain_str}"));
run_cmd(formula, "configure", &mut configure, build_env).await?;
let mut make = tokio::process::Command::new("make");
make.current_dir(src_dir).arg(format!("-j{jobs}"));
run_cmd(formula, "make", &mut make, build_env).await?;
run_cmd(
formula,
"make install",
tokio::process::Command::new("make")
.current_dir(src_dir)
.arg("install"),
build_env,
)
.await?;
}
BuildSystem::CMake => {
let build_dir = src_dir.join("_zl_build");
let mut configure = tokio::process::Command::new("cmake");
configure
.current_dir(src_dir)
.arg("-S")
.arg(".")
.arg("-B")
.arg(&build_dir)
.arg(format!("-DCMAKE_INSTALL_PREFIX={toolchain_str}"))
.arg("-DCMAKE_BUILD_TYPE=Release");
run_cmd(formula, "cmake configure", &mut configure, build_env).await?;
run_cmd(
formula,
"cmake build",
tokio::process::Command::new("cmake")
.current_dir(src_dir)
.arg("--build")
.arg(&build_dir)
.arg("-j")
.arg(&jobs),
build_env,
)
.await?;
run_cmd(
formula,
"cmake install",
tokio::process::Command::new("cmake")
.current_dir(src_dir)
.arg("--install")
.arg(&build_dir),
build_env,
)
.await?;
}
}
Ok(())
}
async fn run_cmd(
formula: &str,
step: &str,
cmd: &mut tokio::process::Command,
env: &BuildEnv,
) -> Result<()> {
let host_path = std::env::var("PATH").unwrap_or_default();
let system_path = "/usr/bin:/bin:/usr/sbin:/sbin";
let mut path_parts: Vec<String> = env.path_prefix.clone();
if !host_path.is_empty() {
path_parts.push(host_path);
}
path_parts.push(system_path.to_string());
cmd.env("PATH", path_parts.join(":"));
if !env.cppflags.is_empty() {
cmd.env("CPPFLAGS", env.cppflags.join(" "));
}
if !env.ldflags.is_empty() {
cmd.env("LDFLAGS", env.ldflags.join(" "));
}
if !env.pkg_config_path.is_empty() {
cmd.env("PKG_CONFIG_PATH", env.pkg_config_path.join(":"));
}
info!(formula, step, "running source build step");
let out = cmd.output().await?;
if !out.status.success() {
let tail = String::from_utf8_lossy(&out.stderr)
.lines()
.rev()
.take(25)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n");
return Err(ToolchainError::RegistryError {
message: format!("{formula} `{step}` failed:\n{tail}"),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn detect_autotools_from_configure() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("configure"), b"#!/bin/sh\n")
.await
.unwrap();
assert_eq!(
detect_build_system(tmp.path()).await.unwrap(),
BuildSystem::Autotools
);
}
#[tokio::test]
async fn detect_cmake_from_cmakelists() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("CMakeLists.txt"), b"project(x)\n")
.await
.unwrap();
assert_eq!(
detect_build_system(tmp.path()).await.unwrap(),
BuildSystem::CMake
);
}
#[tokio::test]
async fn detect_make_from_bare_makefile() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("Makefile"), b"all:\n\ttrue\n")
.await
.unwrap();
assert_eq!(
detect_build_system(tmp.path()).await.unwrap(),
BuildSystem::MakePrefix
);
}
#[tokio::test]
async fn detect_cmake_bootstrap_for_self_host() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("bootstrap"), b"#!/bin/sh\n")
.await
.unwrap();
tokio::fs::write(tmp.path().join("CMakeLists.txt"), b"project(cmake)\n")
.await
.unwrap();
assert_eq!(
detect_build_system(tmp.path()).await.unwrap(),
BuildSystem::CMakeBootstrap
);
}
#[tokio::test]
async fn detect_autotools_from_configure_ac_only() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("configure.ac"), b"AC_INIT([x],[1])\n")
.await
.unwrap();
assert_eq!(
detect_build_system(tmp.path()).await.unwrap(),
BuildSystem::Autotools
);
}
#[tokio::test]
async fn detect_prefers_ready_makefile_over_configure_ac() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("Makefile"), b"all:\n\ttrue\n")
.await
.unwrap();
tokio::fs::write(tmp.path().join("configure.ac"), b"AC_INIT([git],[1])\n")
.await
.unwrap();
assert_eq!(
detect_build_system(tmp.path()).await.unwrap(),
BuildSystem::MakePrefix
);
}
#[tokio::test]
async fn detect_fails_on_unknown_tree() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("README"), b"hi\n")
.await
.unwrap();
assert!(detect_build_system(tmp.path()).await.is_err());
}
#[test]
fn dep_link_flags_use_absolute_toolchain_paths() {
let tmp = tempfile::tempdir().unwrap();
let toolchain = tmp.path();
std::fs::create_dir_all(toolchain.join("include")).unwrap();
std::fs::create_dir_all(toolchain.join("lib/pkgconfig")).unwrap();
let mut env = BuildEnv::default();
add_dep_link_flags(&mut env, toolchain);
assert!(env.cppflags.iter().any(|f| f.contains("/include")));
assert!(env.ldflags.iter().any(|f| f.starts_with("-L")));
assert!(env
.ldflags
.iter()
.any(|f| f.contains("-Wl,-rpath,") && !f.contains("@@HOMEBREW")));
assert!(env.pkg_config_path.iter().any(|p| p.contains("pkgconfig")));
}
#[tokio::test]
async fn macos_provided_deps_are_skipped_offline() {
let tmp = tempfile::tempdir().unwrap();
let spec = SourceSpec {
version: "1.0".to_string(),
tarball_url: "https://example/x.tar.gz".to_string(),
sha256: String::new(),
dependencies: vec!["curl".to_string(), "zlib".to_string()],
build_dependencies: vec!["expat".to_string()],
macos_provided: vec!["curl".to_string(), "zlib".to_string(), "expat".to_string()],
};
let deps = resolve_dependencies("demo", &spec, tmp.path(), None)
.await
.expect("all-macos-provided deps resolve offline");
assert!(
deps.build_dep_names.is_empty(),
"no toolchain deps to resolve"
);
assert!(deps.prefixes_by_name.is_empty());
assert!(deps.prefixes.is_empty());
assert!(deps.env.path_prefix.is_empty());
assert!(deps.env.cppflags.is_empty());
assert!(deps.env.ldflags.is_empty());
assert!(deps.env.pkg_config_path.is_empty());
}
#[tokio::test]
async fn manifest_env_is_layout_derived_not_name_derived() {
let spec = SourceSpec {
version: "2.55.0".to_string(),
tarball_url: "https://example/git.tar.xz".to_string(),
sha256: String::new(),
dependencies: vec![],
build_dependencies: vec![],
macos_provided: vec![],
};
let with_dir = tempfile::tempdir().unwrap();
tokio::fs::create_dir_all(with_dir.path().join("libexec/git-core"))
.await
.unwrap();
let m = build_manifest("git", &spec, with_dir.path(), vec![]).await;
assert_eq!(
m.env.get("GIT_EXEC_PATH"),
Some(
&with_dir
.path()
.join("libexec/git-core")
.display()
.to_string()
)
);
let without_dir = tempfile::tempdir().unwrap();
let m2 = build_manifest("git", &spec, without_dir.path(), vec![]).await;
assert!(!m2.env.contains_key("GIT_EXEC_PATH"));
}
fn plan_with(env: &[(&str, &str)], deparallelize: bool) -> InstallPlan {
InstallPlan {
steps: vec![],
resources: vec![],
patches: vec![],
env: env
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
deparallelize,
}
}
#[test]
fn plan_env_merges_env_and_deparallelize_forces_serial_make() {
let env = plan_env(&plan_with(&[("CFLAGS", "-O2"), ("LANG", "C")], false));
assert_eq!(env.get("CFLAGS").map(String::as_str), Some("-O2"));
assert_eq!(env.get("LANG").map(String::as_str), Some("C"));
assert!(!env.contains_key("MAKEFLAGS"));
let env = plan_env(&plan_with(&[("MAKEFLAGS", "-j8"), ("CFLAGS", "-O2")], true));
assert_eq!(env.get("MAKEFLAGS").map(String::as_str), Some("-j1"));
assert_eq!(env.get("CFLAGS").map(String::as_str), Some("-O2"));
}
#[test]
fn prefetch_destination_layout_is_per_resource_and_patch_index() {
let tmp = tempfile::tempdir().unwrap();
let resources_dir = tmp.path().join("resources");
let res = ResourceSpec {
name: "certifi".to_string(),
url: "https://files.example/certifi-2026.1.tar.gz".to_string(),
sha256: "ab".repeat(32),
stage_to: None,
};
let dest = resource_dest(&resources_dir, &res);
assert_eq!(
dest,
resources_dir.join("certifi").join("certifi-2026.1.tar.gz")
);
let odd = ResourceSpec {
name: "odd".to_string(),
url: "https://files.example/".to_string(),
sha256: "cd".repeat(32),
stage_to: None,
};
assert_eq!(
resource_dest(&resources_dir, &odd),
resources_dir.join("odd").join("resource.tar")
);
let patch0 = patch_dest(&resources_dir, 0);
assert_eq!(patch0, resources_dir.join("patches").join("0"));
assert_eq!(
patch_dest(&resources_dir, 7),
resources_dir.join("patches").join("7")
);
std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
std::fs::write(&dest, b"fake resource bytes").unwrap();
std::fs::create_dir_all(patch0.parent().unwrap()).unwrap();
std::fs::write(&patch0, b"--- a\n+++ b\n").unwrap();
assert!(dest.is_file());
assert!(patch0.is_file());
}
#[tokio::test]
async fn prefetch_short_circuits_empty_plan_and_rejects_missing_sha_offline() {
let tmp = tempfile::tempdir().unwrap();
let none = prefetch_plan_inputs("demo", &plan_with(&[], false), tmp.path())
.await
.expect("no resources/patches → nothing to stage");
assert!(none.is_none());
let mut plan = plan_with(&[], false);
plan.resources.push(ResourceSpec {
name: "noverify".to_string(),
url: "https://files.example/x.tar.gz".to_string(),
sha256: String::new(),
stage_to: None,
});
let err = prefetch_plan_inputs("demo", &plan, tmp.path())
.await
.expect_err("an empty resource sha256 must be an error on the macOS path");
assert!(err.to_string().contains("noverify"));
let mut plan = plan_with(&[], false);
plan.patches.push(crate::recipe::PatchSpec {
url: "https://files.example/fix.patch".to_string(),
sha256: " ".to_string(),
strip: 1,
});
assert!(
prefetch_plan_inputs("demo", &plan, tmp.path())
.await
.is_err(),
"a blank patch sha256 must be an error too"
);
}
#[test]
fn container_request_denies_network_and_wires_deps_and_env() {
let tmp = tempfile::tempdir().unwrap();
let toolchain = tmp.path().join("jq-1.8.1-arm64");
let scratch = toolchain.join(".build");
let dep_prefix = tmp.path().join("oniguruma-6.9.10-arm64");
let mut deps = ResolvedDeps::default();
deps.env
.path_prefix
.push(dep_prefix.join("bin").display().to_string());
deps.record("oniguruma", &dep_prefix);
deps.record("oniguruma", &dep_prefix);
let req = assemble_container_request(
"jq",
&toolchain,
&scratch,
plan_with(&[("CFLAGS", "-O2")], true),
&deps,
scratch.join("src"),
Some(scratch.join("resources")),
);
assert_eq!(req.tool, "jq");
assert_eq!(req.platform, crate::ToolPlatform::MacOS);
assert_eq!(
req.net,
NetPolicy::Deny,
"container recipe builds must run with the network denied"
);
assert_eq!(req.prefix, toolchain);
assert_eq!(req.scratch_dir, scratch);
assert_eq!(req.src_dir, scratch.join("src"));
assert_eq!(req.resources_dir, Some(scratch.join("resources")));
assert_eq!(
req.dep_toolchains,
vec![dep_prefix.clone()],
"dep prefixes deduped, in resolution order"
);
assert_eq!(
req.path_prefix,
vec![dep_prefix.join("bin").display().to_string()]
);
assert_eq!(req.env.get("CFLAGS").map(String::as_str), Some("-O2"));
assert_eq!(req.env.get("MAKEFLAGS").map(String::as_str), Some("-j1"));
}
struct CapturingExecutor {
seen: std::sync::Mutex<Option<ContainerBuildRequest>>,
fail: bool,
}
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());
if self.fail {
Err(ToolchainError::RegistryError {
message: "mock container build failed".to_string(),
})
} else {
Ok(crate::executor::ContainerBuildReport {
log_tail: String::new(),
})
}
})
}
}
#[tokio::test]
async fn container_run_finalizes_like_generic_build_and_failure_leaves_no_ready() {
let tmp = tempfile::tempdir().unwrap();
let spec = SourceSpec {
version: "1.8.1".to_string(),
tarball_url: "https://example/jq-1.8.1.tar.gz".to_string(),
sha256: "cafe".to_string(),
dependencies: vec![],
build_dependencies: vec![],
macos_provided: vec![],
};
let toolchain = tmp.path().join("jq-1.8.1-arm64");
let scratch = toolchain.join(".build");
tokio::fs::create_dir_all(scratch.join("src"))
.await
.unwrap();
let req = assemble_container_request(
"jq",
&toolchain,
&scratch,
plan_with(&[], false),
&ResolvedDeps::default(),
scratch.join("src"),
None,
);
let exec = CapturingExecutor {
seen: std::sync::Mutex::new(None),
fail: false,
};
run_request_and_finalize(&spec, vec!["pkgconf".to_string()], req, &exec)
.await
.expect("mock container build should finalize the toolchain");
let seen = exec
.seen
.lock()
.unwrap()
.take()
.expect("executor must have been invoked with the assembled request");
assert_eq!(seen.tool, "jq");
assert_eq!(seen.net, NetPolicy::Deny);
let manifest = ToolchainManifest::read_from_toolchain(&toolchain)
.await
.unwrap()
.expect("manifest written before .ready");
assert_eq!(manifest.tool, "jq");
assert_eq!(manifest.version, "1.8.1");
assert_eq!(manifest.platform, "macos");
assert_eq!(manifest.build_deps, vec!["pkgconf"]);
assert_eq!(
manifest.source,
ToolchainSource::SourceBuild {
url: spec.tarball_url.clone(),
sha256: spec.sha256.clone(),
}
);
assert!(toolchain.join(".ready").is_file(), ".ready stamped last");
assert!(!scratch.exists(), "scratch cleaned like the generic build");
let toolchain2 = tmp.path().join("jq-9.9.9-arm64");
let scratch2 = toolchain2.join(".build");
tokio::fs::create_dir_all(&scratch2).await.unwrap();
let req2 = assemble_container_request(
"jq",
&toolchain2,
&scratch2,
plan_with(&[], false),
&ResolvedDeps::default(),
scratch2.join("src"),
None,
);
let failing = CapturingExecutor {
seen: std::sync::Mutex::new(None),
fail: true,
};
let err = run_request_and_finalize(&spec, vec![], req2, &failing)
.await
.expect_err("executor failure must propagate");
assert!(err.to_string().contains("mock container build failed"));
assert!(
!toolchain2.join(".ready").exists(),
"no .ready on failure — the fall-through must not see a ready toolchain"
);
}
}