1use std::collections::HashMap;
62use std::path::{Path, PathBuf};
63
64use tracing::{debug, info, warn};
65
66use crate::error::{Result, ToolchainError};
67use crate::executor::{ContainerBuildExecutor, ContainerBuildRequest, NetPolicy};
68use crate::formula::{self, Formula};
69use crate::manifest::{ToolchainManifest, ToolchainSource};
70use crate::recipe::{InstallPlan, RecipeCtx, ResourceSpec};
71use crate::registry::ToolchainArtifactId;
72use crate::relocate::RelocationReport;
73
74const MACOS_OS_TOKEN: &str = "macos";
76
77enum BuildKind {
83 Hermetic(RelocationReport),
86 NetFallback,
89}
90
91#[derive(Debug, Clone)]
93pub struct SourceSpec {
94 pub version: String,
96 pub tarball_url: String,
98 pub sha256: String,
101 pub dependencies: Vec<String>,
103 pub build_dependencies: Vec<String>,
105 pub macos_provided: Vec<String>,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111enum BuildSystem {
112 Autotools,
115 CMake,
118 CMakeBootstrap,
121 MakePrefix,
123}
124
125fn arch_token() -> &'static str {
127 match std::env::consts::ARCH {
128 "aarch64" => "arm64",
129 other => other,
130 }
131}
132
133pub async fn resolve_source_spec(formula: &str) -> Result<SourceSpec> {
140 let info: Formula = formula::fetch_formula(formula).await?;
141
142 let version = info
143 .stable_version()
144 .ok_or_else(|| ToolchainError::RegistryError {
145 message: format!("formula {formula} has no stable version"),
146 })?
147 .to_string();
148
149 let tarball_url = info
150 .stable_url()
151 .ok_or_else(|| ToolchainError::RegistryError {
152 message: format!("formula {formula} has no stable source URL"),
153 })?
154 .to_string();
155
156 Ok(SourceSpec {
157 version,
158 tarball_url,
159 sha256: info.stable_checksum().unwrap_or_default(),
160 dependencies: info.dependencies.clone(),
161 build_dependencies: info.build_dependencies.clone(),
162 macos_provided: info.macos_provided(),
163 })
164}
165
166pub async fn ensure_from_source(
183 formula: &str,
184 cache_dir: &Path,
185 lockfile: Option<&crate::ToolchainLockfile>,
186) -> Result<PathBuf> {
187 let mut spec = resolve_source_spec(formula).await?;
188
189 if let Some(locked) = lockfile.and_then(|l| {
192 use crate::ToolchainLockfileExt;
193 l.lookup(formula, "macos", arch_token())
194 }) {
195 spec.version = locked.version.clone();
196 spec.tarball_url = locked.url.clone();
197 spec.sha256 = locked.sha256.clone();
198 }
199
200 let toolchain = cache_dir.join(format!("{formula}-{}-{}", spec.version, arch_token()));
201 let ready_marker = toolchain.join(".ready");
202
203 if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
206 return Ok(toolchain);
207 }
208
209 let id = ToolchainArtifactId {
212 tool: formula.to_string(),
213 version: spec.version.clone(),
214 os: MACOS_OS_TOKEN.to_string(),
215 arch: arch_token().to_string(),
216 };
217 if crate::pull_first(&id, &toolchain).await?.is_some() {
218 return Ok(toolchain);
219 }
220
221 match run_build_chain(formula, &spec, &toolchain, cache_dir, lockfile).await {
224 Ok(BuildKind::Hermetic(report)) => {
225 crate::finish_built(&id, &toolchain, Some(&report), false).await;
226 Ok(toolchain)
227 }
228 Ok(BuildKind::NetFallback) => {
229 crate::record_net_fallback(&id).await;
230 Ok(toolchain)
231 }
232 Err(e) => {
233 crate::record_failed(&id, &e).await;
234 Err(e)
235 }
236 }
237}
238
239async fn run_build_chain(
244 formula: &str,
245 spec: &SourceSpec,
246 toolchain: &Path,
247 cache_dir: &Path,
248 lockfile: Option<&crate::ToolchainLockfile>,
249) -> Result<BuildKind> {
250 if let Some(executor) = crate::executor::container_executor() {
256 match try_recipe_container_build(
257 formula,
258 spec,
259 toolchain,
260 cache_dir,
261 lockfile,
262 executor.as_ref(),
263 )
264 .await
265 {
266 Ok(report) => return Ok(BuildKind::Hermetic(report)),
267 Err(e) => {
268 if matches!(e, ToolchainError::ExecutorUnavailable { .. }) {
269 debug!(
272 formula,
273 error = %e,
274 "container executor unavailable; falling through to the generic source build"
275 );
276 } else {
277 warn!(
278 formula,
279 error = %e,
280 "container recipe build failed; falling through to the generic source build"
281 );
282 }
283 let _ = tokio::fs::remove_dir_all(toolchain).await;
286 }
287 }
288 }
289
290 match try_generic_source_build(formula, spec, toolchain, cache_dir, lockfile).await {
297 Ok(report) => Ok(BuildKind::Hermetic(report)),
298 Err(e) => {
299 warn!(
300 formula,
301 error = %e,
302 "generic source build failed; falling back to brew-emulate at the toolchain prefix"
303 );
304 let _ = tokio::fs::remove_dir_all(toolchain).await;
307 crate::brew_emulate::ensure_via_brew(formula, spec, cache_dir).await?;
309 Ok(BuildKind::NetFallback)
310 }
311 }
312}
313
314async fn try_recipe_container_build(
322 formula: &str,
323 spec: &SourceSpec,
324 toolchain: &Path,
325 cache_dir: &Path,
326 lockfile: Option<&crate::ToolchainLockfile>,
327 executor: &dyn ContainerBuildExecutor,
328) -> Result<RelocationReport> {
329 let _ = tokio::fs::remove_dir_all(toolchain).await;
332 tokio::fs::create_dir_all(toolchain).await?;
333 let scratch = toolchain.join(".build");
334 tokio::fs::create_dir_all(&scratch).await?;
335
336 let deps = resolve_dependencies(formula, spec, cache_dir, lockfile).await?;
339
340 let info: Formula = formula::fetch_formula(formula).await?;
344 let rb = crate::recipe::fetch_recipe_cached(&info, &scratch.join("recipe.rb")).await?;
345 let ctx = RecipeCtx {
346 tool: formula.to_string(),
347 version: spec.version.clone(),
348 prefix: toolchain.to_path_buf(),
349 dep_prefixes: deps.prefixes_by_name.clone(),
350 target_macos: true,
351 target_arm: arch_token() == "arm64",
352 };
353 let plan = crate::recipe::parse_install_plan(&rb, &ctx)?;
354
355 if !plan.is_fully_supported() {
358 let constructs: Vec<String> = plan
359 .unsupported_constructs()
360 .into_iter()
361 .map(str::to_string)
362 .collect();
363 for construct in &constructs {
364 warn!(
365 formula,
366 construct = %construct,
367 "recipe construct unsupported by the native interpreter"
368 );
369 }
370 return Err(ToolchainError::RecipeUnsupported {
371 tool: formula.to_string(),
372 constructs,
373 });
374 }
375
376 execute_plan_in_container(formula, spec, toolchain, plan, &deps, executor).await
377}
378
379async fn execute_plan_in_container(
385 formula: &str,
386 spec: &SourceSpec,
387 toolchain: &Path,
388 plan: InstallPlan,
389 deps: &ResolvedDeps,
390 executor: &dyn ContainerBuildExecutor,
391) -> Result<RelocationReport> {
392 let scratch = toolchain.join(".build");
393 let src_dir = download_and_extract(formula, spec, &scratch).await?;
394 let resources_dir = prefetch_plan_inputs(formula, &plan, &scratch).await?;
395 let req = assemble_container_request(
396 formula,
397 toolchain,
398 &scratch,
399 plan,
400 deps,
401 src_dir,
402 resources_dir,
403 );
404 run_request_and_finalize(spec, deps.build_dep_names.clone(), req, executor).await
405}
406
407async fn run_request_and_finalize(
412 spec: &SourceSpec,
413 build_dep_names: Vec<String>,
414 req: ContainerBuildRequest,
415 executor: &dyn ContainerBuildExecutor,
416) -> Result<RelocationReport> {
417 let report = executor.execute(&req).await?;
418 debug!(tool = %req.tool, log_tail = %report.log_tail, "container recipe build succeeded");
419 finalize_toolchain(
420 &req.tool,
421 spec,
422 &req.prefix,
423 &req.scratch_dir,
424 build_dep_names,
425 &req.dep_toolchains,
426 )
427 .await
428}
429
430fn assemble_container_request(
436 formula: &str,
437 toolchain: &Path,
438 scratch: &Path,
439 plan: InstallPlan,
440 deps: &ResolvedDeps,
441 src_dir: PathBuf,
442 resources_dir: Option<PathBuf>,
443) -> ContainerBuildRequest {
444 let env = plan_env(&plan);
445 ContainerBuildRequest {
446 tool: formula.to_string(),
447 platform: crate::ToolPlatform::MacOS,
448 plan,
449 src_dir,
450 prefix: toolchain.to_path_buf(),
451 scratch_dir: scratch.to_path_buf(),
452 dep_toolchains: deps.prefixes.clone(),
453 resources_dir,
454 env,
455 path_prefix: deps.env.path_prefix.clone(),
456 net: NetPolicy::Deny,
457 }
458}
459
460fn plan_env(plan: &InstallPlan) -> HashMap<String, String> {
465 let mut env = plan.env.clone();
466 if plan.deparallelize {
467 env.insert("MAKEFLAGS".to_string(), "-j1".to_string());
468 }
469 env
470}
471
472fn resource_dest(resources_dir: &Path, res: &ResourceSpec) -> PathBuf {
476 let file = res
477 .url
478 .rsplit('/')
479 .next()
480 .filter(|s| !s.is_empty())
481 .unwrap_or("resource.tar");
482 resources_dir.join(&res.name).join(file)
483}
484
485fn patch_dest(resources_dir: &Path, index: usize) -> PathBuf {
488 resources_dir.join("patches").join(index.to_string())
489}
490
491async fn prefetch_plan_inputs(
502 formula: &str,
503 plan: &InstallPlan,
504 scratch: &Path,
505) -> Result<Option<PathBuf>> {
506 if plan.resources.is_empty() && plan.patches.is_empty() {
507 return Ok(None);
508 }
509 let resources_dir = scratch.join("resources");
510
511 for res in &plan.resources {
512 if res.sha256.trim().is_empty() {
513 return Err(ToolchainError::RegistryError {
514 message: format!(
515 "resource '{}' of {formula} declares no sha256; refusing an unverified pre-fetch",
516 res.name
517 ),
518 });
519 }
520 let dest = resource_dest(&resources_dir, res);
521 crate::package_index::download_verified(&res.url, &dest, Some(&res.sha256)).await?;
522 }
523
524 for (index, patch) in plan.patches.iter().enumerate() {
525 if patch.sha256.trim().is_empty() {
526 return Err(ToolchainError::RegistryError {
527 message: format!(
528 "patch #{index} of {formula} declares no sha256; refusing an unverified pre-fetch"
529 ),
530 });
531 }
532 let dest = patch_dest(&resources_dir, index);
533 crate::package_index::download_verified(&patch.url, &dest, Some(&patch.sha256)).await?;
534 }
535
536 Ok(Some(resources_dir))
537}
538
539async fn try_generic_source_build(
546 formula: &str,
547 spec: &SourceSpec,
548 toolchain: &Path,
549 cache_dir: &Path,
550 lockfile: Option<&crate::ToolchainLockfile>,
551) -> Result<RelocationReport> {
552 let _ = tokio::fs::remove_dir_all(toolchain).await;
555 tokio::fs::create_dir_all(toolchain).await?;
556
557 let scratch = toolchain.join(".build");
558 tokio::fs::create_dir_all(&scratch).await?;
559
560 let deps = resolve_dependencies(formula, spec, cache_dir, lockfile).await?;
564
565 let src_dir = download_and_extract(formula, spec, &scratch).await?;
567
568 let system = detect_build_system(&src_dir).await?;
570 info!(formula, ?system, "detected build system");
571 run_build(formula, &src_dir, toolchain, system, &deps.env).await?;
572
573 finalize_toolchain(
575 formula,
576 spec,
577 toolchain,
578 &scratch,
579 deps.build_dep_names,
580 &deps.prefixes,
581 )
582 .await
583}
584
585async fn finalize_toolchain(
597 formula: &str,
598 spec: &SourceSpec,
599 toolchain: &Path,
600 scratch: &Path,
601 build_deps: Vec<String>,
602 dep_dirs: &[PathBuf],
603) -> Result<RelocationReport> {
604 match tokio::fs::remove_dir_all(scratch).await {
608 Ok(()) => {}
609 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
610 Err(e) => warn!(error = %e, "failed to clean source scratch dir (non-fatal)"),
611 }
612
613 let report = crate::relocate::make_relocatable(toolchain, toolchain, dep_dirs).await?;
614
615 let manifest = build_manifest(formula, spec, toolchain, build_deps).await;
616 manifest.write_to_toolchain(toolchain).await?;
617 tokio::fs::write(toolchain.join(".ready"), b"").await?;
618
619 Ok(report)
620}
621
622async fn build_manifest(
627 formula: &str,
628 spec: &SourceSpec,
629 toolchain: &Path,
630 build_deps: Vec<String>,
631) -> ToolchainManifest {
632 let mut path_dirs = Vec::new();
633 let bin = toolchain.join("bin");
634 if tokio::fs::try_exists(&bin).await.unwrap_or(false) {
635 path_dirs.push(bin.display().to_string());
636 }
637
638 let mut env: HashMap<String, String> = HashMap::new();
639 let git_exec = toolchain.join("libexec/git-core");
642 if tokio::fs::try_exists(&git_exec).await.unwrap_or(false) {
643 env.insert("GIT_EXEC_PATH".to_string(), git_exec.display().to_string());
644 }
645
646 ToolchainManifest {
647 tool: formula.to_string(),
648 version: spec.version.clone(),
649 arch: arch_token().to_string(),
650 platform: "macos".to_string(),
651 path_dirs,
652 env,
653 source: ToolchainSource::SourceBuild {
654 url: spec.tarball_url.clone(),
655 sha256: spec.sha256.clone(),
656 },
657 build_deps,
658 provisioned_at: chrono::Utc::now().to_rfc3339(),
659 }
660}
661
662#[derive(Debug, Default, Clone)]
665struct BuildEnv {
666 path_prefix: Vec<String>,
667 cppflags: Vec<String>,
668 ldflags: Vec<String>,
669 pkg_config_path: Vec<String>,
670}
671
672#[derive(Debug, Default)]
677struct ResolvedDeps {
678 env: BuildEnv,
680 build_dep_names: Vec<String>,
682 prefixes_by_name: HashMap<String, PathBuf>,
684 prefixes: Vec<PathBuf>,
686}
687
688impl ResolvedDeps {
689 fn record(&mut self, dep: &str, toolchain: &Path) {
693 self.prefixes_by_name
694 .insert(dep.to_string(), toolchain.to_path_buf());
695 if !self.prefixes.iter().any(|p| p == toolchain) {
696 self.prefixes.push(toolchain.to_path_buf());
697 }
698 }
699}
700
701async fn resolve_dependencies(
717 formula: &str,
718 spec: &SourceSpec,
719 cache_dir: &Path,
720 lockfile: Option<&crate::ToolchainLockfile>,
721) -> Result<ResolvedDeps> {
722 let mut deps = ResolvedDeps::default();
723
724 let is_macos_provided = |dep: &str| spec.macos_provided.iter().any(|d| d == dep);
726
727 for dep in &spec.build_dependencies {
729 if is_macos_provided(dep) {
730 continue;
731 }
732 let toolchain = Box::pin(crate::ensure_macos_toolchain(dep, cache_dir, lockfile)).await?;
733 let manifest = ToolchainManifest::load_or_synthesize(&toolchain).await?;
734 deps.env.path_prefix.extend(manifest.path_dirs.clone());
735 add_dep_link_flags(&mut deps.env, &toolchain);
736 deps.record(dep, &toolchain);
737 deps.build_dep_names.push(dep.clone());
738 info!(formula, dep, toolchain = %toolchain.display(), "resolved build dependency toolchain");
739 }
740
741 for dep in &spec.dependencies {
744 if is_macos_provided(dep) {
745 continue;
746 }
747 match Box::pin(crate::ensure_macos_toolchain(dep, cache_dir, lockfile)).await {
748 Ok(toolchain) => {
749 if let Ok(manifest) = ToolchainManifest::load_or_synthesize(&toolchain).await {
750 deps.env.path_prefix.extend(manifest.path_dirs.clone());
751 }
752 add_dep_link_flags(&mut deps.env, &toolchain);
753 deps.record(dep, &toolchain);
754 info!(formula, dep, toolchain = %toolchain.display(), "resolved runtime dependency toolchain");
755 }
756 Err(e) => warn!(
757 formula, dep, error = %e,
758 "runtime dependency toolchain unavailable; continuing without it"
759 ),
760 }
761 }
762
763 Ok(deps)
764}
765
766fn add_dep_link_flags(env: &mut BuildEnv, toolchain: &Path) {
769 let include = toolchain.join("include");
770 let lib = toolchain.join("lib");
771 if include.is_dir() {
772 env.cppflags.push(format!("-I{}", include.display()));
773 }
774 if lib.is_dir() {
775 env.ldflags.push(format!("-L{}", lib.display()));
776 env.ldflags.push(format!("-Wl,-rpath,{}", lib.display()));
777 let pc = lib.join("pkgconfig");
778 if pc.is_dir() {
779 env.pkg_config_path.push(pc.display().to_string());
780 }
781 }
782}
783
784async fn download_and_extract(formula: &str, spec: &SourceSpec, scratch: &Path) -> Result<PathBuf> {
788 let tar_name = spec
789 .tarball_url
790 .rsplit('/')
791 .next()
792 .filter(|s| !s.is_empty())
793 .unwrap_or("source.tar");
794 let tar_path = scratch.join(tar_name);
795 info!(url = %spec.tarball_url, "downloading {formula} source tarball");
796 let expected = (!spec.sha256.is_empty()).then_some(spec.sha256.as_str());
800 crate::package_index::download_verified(&spec.tarball_url, &tar_path, expected).await?;
801
802 let src_dir = scratch.join("src");
803 let _ = tokio::fs::remove_dir_all(&src_dir).await;
804 tokio::fs::create_dir_all(&src_dir).await?;
805 let untar = tokio::process::Command::new("tar")
806 .arg("xf")
807 .arg(&tar_path)
808 .args(["--strip-components", "1", "-C"])
809 .arg(&src_dir)
810 .output()
811 .await?;
812 if !untar.status.success() {
813 return Err(ToolchainError::RegistryError {
814 message: format!(
815 "failed to extract {formula} source: {}",
816 String::from_utf8_lossy(&untar.stderr)
817 ),
818 });
819 }
820 Ok(src_dir)
821}
822
823async fn detect_build_system(src_dir: &Path) -> Result<BuildSystem> {
838 let exists = |rel: &str| {
839 let p = src_dir.join(rel);
840 async move { tokio::fs::try_exists(&p).await.unwrap_or(false) }
841 };
842
843 let has_bootstrap = exists("bootstrap").await || exists("bootstrap.sh").await;
844 let has_cmakelists = exists("CMakeLists.txt").await;
845
846 if has_bootstrap && has_cmakelists {
847 Ok(BuildSystem::CMakeBootstrap)
851 } else if exists("configure").await {
852 Ok(BuildSystem::Autotools)
853 } else if exists("Makefile").await || exists("GNUmakefile").await {
854 Ok(BuildSystem::MakePrefix)
857 } else if has_cmakelists {
858 Ok(BuildSystem::CMake)
859 } else if exists("configure.ac").await
860 || exists("configure.in").await
861 || exists("autogen.sh").await
862 || has_bootstrap
863 {
864 Ok(BuildSystem::Autotools)
867 } else {
868 Err(ToolchainError::RegistryError {
869 message: format!(
870 "could not detect a build system \
871 (configure/CMakeLists.txt/Makefile/bootstrap) in {}",
872 src_dir.display()
873 ),
874 })
875 }
876}
877
878#[allow(clippy::too_many_lines)]
881async fn run_build(
882 formula: &str,
883 src_dir: &Path,
884 toolchain: &Path,
885 system: BuildSystem,
886 build_env: &BuildEnv,
887) -> Result<()> {
888 let jobs = std::thread::available_parallelism()
889 .map_or(4, std::num::NonZero::get)
890 .to_string();
891 let toolchain_str = toolchain.display().to_string();
892
893 match system {
894 BuildSystem::MakePrefix => {
895 let mut cmd = tokio::process::Command::new("make");
898 cmd.current_dir(src_dir)
899 .arg(format!("-j{jobs}"))
900 .arg(format!("prefix={toolchain_str}"))
901 .arg("install");
902 run_cmd(formula, "make install", &mut cmd, build_env).await?;
903 }
904 BuildSystem::CMakeBootstrap => {
905 run_cmd(
907 formula,
908 "bootstrap",
909 tokio::process::Command::new("./bootstrap")
910 .current_dir(src_dir)
911 .arg(format!("--prefix={toolchain_str}"))
912 .arg(format!("--parallel={jobs}")),
913 build_env,
914 )
915 .await?;
916 run_cmd(
917 formula,
918 "make",
919 tokio::process::Command::new("make")
920 .current_dir(src_dir)
921 .arg(format!("-j{jobs}")),
922 build_env,
923 )
924 .await?;
925 run_cmd(
926 formula,
927 "make install",
928 tokio::process::Command::new("make")
929 .current_dir(src_dir)
930 .arg("install"),
931 build_env,
932 )
933 .await?;
934 }
935 BuildSystem::Autotools => {
936 if !src_dir.join("configure").is_file() {
938 let autogen = src_dir.join("autogen.sh");
939 if autogen.is_file() {
940 run_cmd(
941 formula,
942 "autogen.sh",
943 tokio::process::Command::new("sh")
944 .current_dir(src_dir)
945 .arg("autogen.sh"),
946 build_env,
947 )
948 .await?;
949 } else {
950 run_cmd(
951 formula,
952 "autoreconf",
953 tokio::process::Command::new("autoreconf")
954 .current_dir(src_dir)
955 .arg("-fi"),
956 build_env,
957 )
958 .await?;
959 }
960 }
961
962 let mut configure = tokio::process::Command::new("./configure");
963 configure
964 .current_dir(src_dir)
965 .arg(format!("--prefix={toolchain_str}"));
966 run_cmd(formula, "configure", &mut configure, build_env).await?;
967
968 let mut make = tokio::process::Command::new("make");
969 make.current_dir(src_dir).arg(format!("-j{jobs}"));
970 run_cmd(formula, "make", &mut make, build_env).await?;
971
972 run_cmd(
973 formula,
974 "make install",
975 tokio::process::Command::new("make")
976 .current_dir(src_dir)
977 .arg("install"),
978 build_env,
979 )
980 .await?;
981 }
982 BuildSystem::CMake => {
983 let build_dir = src_dir.join("_zl_build");
984 let mut configure = tokio::process::Command::new("cmake");
985 configure
986 .current_dir(src_dir)
987 .arg("-S")
988 .arg(".")
989 .arg("-B")
990 .arg(&build_dir)
991 .arg(format!("-DCMAKE_INSTALL_PREFIX={toolchain_str}"))
992 .arg("-DCMAKE_BUILD_TYPE=Release");
993 run_cmd(formula, "cmake configure", &mut configure, build_env).await?;
994
995 run_cmd(
996 formula,
997 "cmake build",
998 tokio::process::Command::new("cmake")
999 .current_dir(src_dir)
1000 .arg("--build")
1001 .arg(&build_dir)
1002 .arg("-j")
1003 .arg(&jobs),
1004 build_env,
1005 )
1006 .await?;
1007
1008 run_cmd(
1009 formula,
1010 "cmake install",
1011 tokio::process::Command::new("cmake")
1012 .current_dir(src_dir)
1013 .arg("--install")
1014 .arg(&build_dir),
1015 build_env,
1016 )
1017 .await?;
1018 }
1019 }
1020 Ok(())
1021}
1022
1023async fn run_cmd(
1027 formula: &str,
1028 step: &str,
1029 cmd: &mut tokio::process::Command,
1030 env: &BuildEnv,
1031) -> Result<()> {
1032 let host_path = std::env::var("PATH").unwrap_or_default();
1034 let system_path = "/usr/bin:/bin:/usr/sbin:/sbin";
1035 let mut path_parts: Vec<String> = env.path_prefix.clone();
1036 if !host_path.is_empty() {
1037 path_parts.push(host_path);
1038 }
1039 path_parts.push(system_path.to_string());
1040 cmd.env("PATH", path_parts.join(":"));
1041
1042 if !env.cppflags.is_empty() {
1043 cmd.env("CPPFLAGS", env.cppflags.join(" "));
1044 }
1045 if !env.ldflags.is_empty() {
1046 cmd.env("LDFLAGS", env.ldflags.join(" "));
1047 }
1048 if !env.pkg_config_path.is_empty() {
1049 cmd.env("PKG_CONFIG_PATH", env.pkg_config_path.join(":"));
1050 }
1051
1052 info!(formula, step, "running source build step");
1053 let out = cmd.output().await?;
1054 if !out.status.success() {
1055 let tail = String::from_utf8_lossy(&out.stderr)
1056 .lines()
1057 .rev()
1058 .take(25)
1059 .collect::<Vec<_>>()
1060 .into_iter()
1061 .rev()
1062 .collect::<Vec<_>>()
1063 .join("\n");
1064 return Err(ToolchainError::RegistryError {
1065 message: format!("{formula} `{step}` failed:\n{tail}"),
1066 });
1067 }
1068 Ok(())
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073 use super::*;
1074
1075 #[tokio::test]
1076 async fn detect_autotools_from_configure() {
1077 let tmp = tempfile::tempdir().unwrap();
1078 tokio::fs::write(tmp.path().join("configure"), b"#!/bin/sh\n")
1079 .await
1080 .unwrap();
1081 assert_eq!(
1082 detect_build_system(tmp.path()).await.unwrap(),
1083 BuildSystem::Autotools
1084 );
1085 }
1086
1087 #[tokio::test]
1088 async fn detect_cmake_from_cmakelists() {
1089 let tmp = tempfile::tempdir().unwrap();
1090 tokio::fs::write(tmp.path().join("CMakeLists.txt"), b"project(x)\n")
1091 .await
1092 .unwrap();
1093 assert_eq!(
1094 detect_build_system(tmp.path()).await.unwrap(),
1095 BuildSystem::CMake
1096 );
1097 }
1098
1099 #[tokio::test]
1100 async fn detect_make_from_bare_makefile() {
1101 let tmp = tempfile::tempdir().unwrap();
1102 tokio::fs::write(tmp.path().join("Makefile"), b"all:\n\ttrue\n")
1103 .await
1104 .unwrap();
1105 assert_eq!(
1106 detect_build_system(tmp.path()).await.unwrap(),
1107 BuildSystem::MakePrefix
1108 );
1109 }
1110
1111 #[tokio::test]
1114 async fn detect_cmake_bootstrap_for_self_host() {
1115 let tmp = tempfile::tempdir().unwrap();
1116 tokio::fs::write(tmp.path().join("bootstrap"), b"#!/bin/sh\n")
1117 .await
1118 .unwrap();
1119 tokio::fs::write(tmp.path().join("CMakeLists.txt"), b"project(cmake)\n")
1120 .await
1121 .unwrap();
1122 assert_eq!(
1123 detect_build_system(tmp.path()).await.unwrap(),
1124 BuildSystem::CMakeBootstrap
1125 );
1126 }
1127
1128 #[tokio::test]
1131 async fn detect_autotools_from_configure_ac_only() {
1132 let tmp = tempfile::tempdir().unwrap();
1133 tokio::fs::write(tmp.path().join("configure.ac"), b"AC_INIT([x],[1])\n")
1134 .await
1135 .unwrap();
1136 assert_eq!(
1137 detect_build_system(tmp.path()).await.unwrap(),
1138 BuildSystem::Autotools
1139 );
1140 }
1141
1142 #[tokio::test]
1146 async fn detect_prefers_ready_makefile_over_configure_ac() {
1147 let tmp = tempfile::tempdir().unwrap();
1148 tokio::fs::write(tmp.path().join("Makefile"), b"all:\n\ttrue\n")
1149 .await
1150 .unwrap();
1151 tokio::fs::write(tmp.path().join("configure.ac"), b"AC_INIT([git],[1])\n")
1152 .await
1153 .unwrap();
1154 assert_eq!(
1155 detect_build_system(tmp.path()).await.unwrap(),
1156 BuildSystem::MakePrefix
1157 );
1158 }
1159
1160 #[tokio::test]
1161 async fn detect_fails_on_unknown_tree() {
1162 let tmp = tempfile::tempdir().unwrap();
1163 tokio::fs::write(tmp.path().join("README"), b"hi\n")
1164 .await
1165 .unwrap();
1166 assert!(detect_build_system(tmp.path()).await.is_err());
1167 }
1168
1169 #[test]
1170 fn dep_link_flags_use_absolute_toolchain_paths() {
1171 let tmp = tempfile::tempdir().unwrap();
1172 let toolchain = tmp.path();
1173 std::fs::create_dir_all(toolchain.join("include")).unwrap();
1174 std::fs::create_dir_all(toolchain.join("lib/pkgconfig")).unwrap();
1175 let mut env = BuildEnv::default();
1176 add_dep_link_flags(&mut env, toolchain);
1177 assert!(env.cppflags.iter().any(|f| f.contains("/include")));
1178 assert!(env.ldflags.iter().any(|f| f.starts_with("-L")));
1179 assert!(env
1180 .ldflags
1181 .iter()
1182 .any(|f| f.contains("-Wl,-rpath,") && !f.contains("@@HOMEBREW")));
1183 assert!(env.pkg_config_path.iter().any(|p| p.contains("pkgconfig")));
1184 }
1185
1186 #[tokio::test]
1190 async fn macos_provided_deps_are_skipped_offline() {
1191 let tmp = tempfile::tempdir().unwrap();
1192 let spec = SourceSpec {
1193 version: "1.0".to_string(),
1194 tarball_url: "https://example/x.tar.gz".to_string(),
1195 sha256: String::new(),
1196 dependencies: vec!["curl".to_string(), "zlib".to_string()],
1197 build_dependencies: vec!["expat".to_string()],
1198 macos_provided: vec!["curl".to_string(), "zlib".to_string(), "expat".to_string()],
1199 };
1200 let deps = resolve_dependencies("demo", &spec, tmp.path(), None)
1201 .await
1202 .expect("all-macos-provided deps resolve offline");
1203 assert!(
1204 deps.build_dep_names.is_empty(),
1205 "no toolchain deps to resolve"
1206 );
1207 assert!(deps.prefixes_by_name.is_empty());
1208 assert!(deps.prefixes.is_empty());
1209 assert!(deps.env.path_prefix.is_empty());
1210 assert!(deps.env.cppflags.is_empty());
1211 assert!(deps.env.ldflags.is_empty());
1212 assert!(deps.env.pkg_config_path.is_empty());
1213 }
1214
1215 #[tokio::test]
1219 async fn manifest_env_is_layout_derived_not_name_derived() {
1220 let spec = SourceSpec {
1221 version: "2.55.0".to_string(),
1222 tarball_url: "https://example/git.tar.xz".to_string(),
1223 sha256: String::new(),
1224 dependencies: vec![],
1225 build_dependencies: vec![],
1226 macos_provided: vec![],
1227 };
1228
1229 let with_dir = tempfile::tempdir().unwrap();
1231 tokio::fs::create_dir_all(with_dir.path().join("libexec/git-core"))
1232 .await
1233 .unwrap();
1234 let m = build_manifest("git", &spec, with_dir.path(), vec![]).await;
1235 assert_eq!(
1236 m.env.get("GIT_EXEC_PATH"),
1237 Some(
1238 &with_dir
1239 .path()
1240 .join("libexec/git-core")
1241 .display()
1242 .to_string()
1243 )
1244 );
1245
1246 let without_dir = tempfile::tempdir().unwrap();
1248 let m2 = build_manifest("git", &spec, without_dir.path(), vec![]).await;
1249 assert!(!m2.env.contains_key("GIT_EXEC_PATH"));
1250 }
1251
1252 fn plan_with(env: &[(&str, &str)], deparallelize: bool) -> InstallPlan {
1263 InstallPlan {
1264 steps: vec![],
1265 resources: vec![],
1266 patches: vec![],
1267 env: env
1268 .iter()
1269 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1270 .collect(),
1271 deparallelize,
1272 }
1273 }
1274
1275 #[test]
1276 fn plan_env_merges_env_and_deparallelize_forces_serial_make() {
1277 let env = plan_env(&plan_with(&[("CFLAGS", "-O2"), ("LANG", "C")], false));
1280 assert_eq!(env.get("CFLAGS").map(String::as_str), Some("-O2"));
1281 assert_eq!(env.get("LANG").map(String::as_str), Some("C"));
1282 assert!(!env.contains_key("MAKEFLAGS"));
1283
1284 let env = plan_env(&plan_with(&[("MAKEFLAGS", "-j8"), ("CFLAGS", "-O2")], true));
1286 assert_eq!(env.get("MAKEFLAGS").map(String::as_str), Some("-j1"));
1287 assert_eq!(env.get("CFLAGS").map(String::as_str), Some("-O2"));
1288 }
1289
1290 #[test]
1295 fn prefetch_destination_layout_is_per_resource_and_patch_index() {
1296 let tmp = tempfile::tempdir().unwrap();
1297 let resources_dir = tmp.path().join("resources");
1298
1299 let res = ResourceSpec {
1300 name: "certifi".to_string(),
1301 url: "https://files.example/certifi-2026.1.tar.gz".to_string(),
1302 sha256: "ab".repeat(32),
1303 stage_to: None,
1304 };
1305 let dest = resource_dest(&resources_dir, &res);
1306 assert_eq!(
1307 dest,
1308 resources_dir.join("certifi").join("certifi-2026.1.tar.gz")
1309 );
1310
1311 let odd = ResourceSpec {
1313 name: "odd".to_string(),
1314 url: "https://files.example/".to_string(),
1315 sha256: "cd".repeat(32),
1316 stage_to: None,
1317 };
1318 assert_eq!(
1319 resource_dest(&resources_dir, &odd),
1320 resources_dir.join("odd").join("resource.tar")
1321 );
1322
1323 let patch0 = patch_dest(&resources_dir, 0);
1324 assert_eq!(patch0, resources_dir.join("patches").join("0"));
1325 assert_eq!(
1326 patch_dest(&resources_dir, 7),
1327 resources_dir.join("patches").join("7")
1328 );
1329
1330 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1333 std::fs::write(&dest, b"fake resource bytes").unwrap();
1334 std::fs::create_dir_all(patch0.parent().unwrap()).unwrap();
1335 std::fs::write(&patch0, b"--- a\n+++ b\n").unwrap();
1336 assert!(dest.is_file());
1337 assert!(patch0.is_file());
1338 }
1339
1340 #[tokio::test]
1345 async fn prefetch_short_circuits_empty_plan_and_rejects_missing_sha_offline() {
1346 let tmp = tempfile::tempdir().unwrap();
1347
1348 let none = prefetch_plan_inputs("demo", &plan_with(&[], false), tmp.path())
1349 .await
1350 .expect("no resources/patches → nothing to stage");
1351 assert!(none.is_none());
1352
1353 let mut plan = plan_with(&[], false);
1354 plan.resources.push(ResourceSpec {
1355 name: "noverify".to_string(),
1356 url: "https://files.example/x.tar.gz".to_string(),
1357 sha256: String::new(),
1358 stage_to: None,
1359 });
1360 let err = prefetch_plan_inputs("demo", &plan, tmp.path())
1361 .await
1362 .expect_err("an empty resource sha256 must be an error on the macOS path");
1363 assert!(err.to_string().contains("noverify"));
1364
1365 let mut plan = plan_with(&[], false);
1366 plan.patches.push(crate::recipe::PatchSpec {
1367 url: "https://files.example/fix.patch".to_string(),
1368 sha256: " ".to_string(),
1369 strip: 1,
1370 });
1371 assert!(
1372 prefetch_plan_inputs("demo", &plan, tmp.path())
1373 .await
1374 .is_err(),
1375 "a blank patch sha256 must be an error too"
1376 );
1377 }
1378
1379 #[test]
1383 fn container_request_denies_network_and_wires_deps_and_env() {
1384 let tmp = tempfile::tempdir().unwrap();
1385 let toolchain = tmp.path().join("jq-1.8.1-arm64");
1386 let scratch = toolchain.join(".build");
1387 let dep_prefix = tmp.path().join("oniguruma-6.9.10-arm64");
1388
1389 let mut deps = ResolvedDeps::default();
1390 deps.env
1391 .path_prefix
1392 .push(dep_prefix.join("bin").display().to_string());
1393 deps.record("oniguruma", &dep_prefix);
1394 deps.record("oniguruma", &dep_prefix); let req = assemble_container_request(
1397 "jq",
1398 &toolchain,
1399 &scratch,
1400 plan_with(&[("CFLAGS", "-O2")], true),
1401 &deps,
1402 scratch.join("src"),
1403 Some(scratch.join("resources")),
1404 );
1405
1406 assert_eq!(req.tool, "jq");
1407 assert_eq!(req.platform, crate::ToolPlatform::MacOS);
1408 assert_eq!(
1409 req.net,
1410 NetPolicy::Deny,
1411 "container recipe builds must run with the network denied"
1412 );
1413 assert_eq!(req.prefix, toolchain);
1414 assert_eq!(req.scratch_dir, scratch);
1415 assert_eq!(req.src_dir, scratch.join("src"));
1416 assert_eq!(req.resources_dir, Some(scratch.join("resources")));
1417 assert_eq!(
1418 req.dep_toolchains,
1419 vec![dep_prefix.clone()],
1420 "dep prefixes deduped, in resolution order"
1421 );
1422 assert_eq!(
1423 req.path_prefix,
1424 vec![dep_prefix.join("bin").display().to_string()]
1425 );
1426 assert_eq!(req.env.get("CFLAGS").map(String::as_str), Some("-O2"));
1427 assert_eq!(req.env.get("MAKEFLAGS").map(String::as_str), Some("-j1"));
1428 }
1429
1430 struct CapturingExecutor {
1434 seen: std::sync::Mutex<Option<ContainerBuildRequest>>,
1435 fail: bool,
1436 }
1437
1438 impl ContainerBuildExecutor for CapturingExecutor {
1439 fn execute<'a>(
1440 &'a self,
1441 req: &'a ContainerBuildRequest,
1442 ) -> std::pin::Pin<
1443 Box<
1444 dyn std::future::Future<Output = Result<crate::executor::ContainerBuildReport>>
1445 + Send
1446 + 'a,
1447 >,
1448 > {
1449 Box::pin(async move {
1450 *self.seen.lock().unwrap() = Some(req.clone());
1451 if self.fail {
1452 Err(ToolchainError::RegistryError {
1453 message: "mock container build failed".to_string(),
1454 })
1455 } else {
1456 Ok(crate::executor::ContainerBuildReport {
1457 log_tail: String::new(),
1458 })
1459 }
1460 })
1461 }
1462 }
1463
1464 #[tokio::test]
1470 async fn container_run_finalizes_like_generic_build_and_failure_leaves_no_ready() {
1471 let tmp = tempfile::tempdir().unwrap();
1472 let spec = SourceSpec {
1473 version: "1.8.1".to_string(),
1474 tarball_url: "https://example/jq-1.8.1.tar.gz".to_string(),
1475 sha256: "cafe".to_string(),
1476 dependencies: vec![],
1477 build_dependencies: vec![],
1478 macos_provided: vec![],
1479 };
1480
1481 let toolchain = tmp.path().join("jq-1.8.1-arm64");
1483 let scratch = toolchain.join(".build");
1484 tokio::fs::create_dir_all(scratch.join("src"))
1485 .await
1486 .unwrap();
1487 let req = assemble_container_request(
1488 "jq",
1489 &toolchain,
1490 &scratch,
1491 plan_with(&[], false),
1492 &ResolvedDeps::default(),
1493 scratch.join("src"),
1494 None,
1495 );
1496 let exec = CapturingExecutor {
1497 seen: std::sync::Mutex::new(None),
1498 fail: false,
1499 };
1500 run_request_and_finalize(&spec, vec!["pkgconf".to_string()], req, &exec)
1501 .await
1502 .expect("mock container build should finalize the toolchain");
1503
1504 let seen = exec
1505 .seen
1506 .lock()
1507 .unwrap()
1508 .take()
1509 .expect("executor must have been invoked with the assembled request");
1510 assert_eq!(seen.tool, "jq");
1511 assert_eq!(seen.net, NetPolicy::Deny);
1512
1513 let manifest = ToolchainManifest::read_from_toolchain(&toolchain)
1514 .await
1515 .unwrap()
1516 .expect("manifest written before .ready");
1517 assert_eq!(manifest.tool, "jq");
1518 assert_eq!(manifest.version, "1.8.1");
1519 assert_eq!(manifest.platform, "macos");
1520 assert_eq!(manifest.build_deps, vec!["pkgconf"]);
1521 assert_eq!(
1522 manifest.source,
1523 ToolchainSource::SourceBuild {
1524 url: spec.tarball_url.clone(),
1525 sha256: spec.sha256.clone(),
1526 }
1527 );
1528 assert!(toolchain.join(".ready").is_file(), ".ready stamped last");
1529 assert!(!scratch.exists(), "scratch cleaned like the generic build");
1530
1531 let toolchain2 = tmp.path().join("jq-9.9.9-arm64");
1533 let scratch2 = toolchain2.join(".build");
1534 tokio::fs::create_dir_all(&scratch2).await.unwrap();
1535 let req2 = assemble_container_request(
1536 "jq",
1537 &toolchain2,
1538 &scratch2,
1539 plan_with(&[], false),
1540 &ResolvedDeps::default(),
1541 scratch2.join("src"),
1542 None,
1543 );
1544 let failing = CapturingExecutor {
1545 seen: std::sync::Mutex::new(None),
1546 fail: true,
1547 };
1548 let err = run_request_and_finalize(&spec, vec![], req2, &failing)
1549 .await
1550 .expect_err("executor failure must propagate");
1551 assert!(err.to_string().contains("mock container build failed"));
1552 assert!(
1553 !toolchain2.join(".ready").exists(),
1554 "no .ready on failure — the fall-through must not see a ready toolchain"
1555 );
1556 }
1557}