Skip to main content

zlayer_toolchain/
source_build.rs

1//! Build runtime toolchains **from source** into a self-contained, absolute
2//! cache toolchain — "our apt-get" for the macOS sandbox, which has no package
3//! manager.
4//!
5//! # Why source-build instead of a Homebrew bottle
6//!
7//! Relocating a Homebrew *bottle* (rewriting its baked `@@HOMEBREW_PREFIX@@`
8//! install-name placeholders) is a dead end: the rewrite is length-preserving
9//! and silently skips placeholders shorter than the cache prefix, so the toolchain's
10//! binary keeps `@@HOMEBREW_PREFIX@@/...` `LC_LOAD_DYLIB` paths. Under a darwin
11//! Seatbelt container those paths don't exist and macOS strips `DYLD_*` from
12//! the signed binary, so dyld aborts (`Symbol not found … Abort trap: 6`).
13//!
14//! Building from source at an absolute toolchain prefix sidesteps both failure modes:
15//! every `LC_LOAD_DYLIB` is an absolute path to a macOS system library
16//! (`/usr/lib/...`) or an absolute sibling-toolchain path — never `@@HOMEBREW@@` — and
17//! the compiled `sysconfdir`/`prefix` live inside the toolchain, so the tool reads its
18//! own config instead of `/etc/*` (which EPERMs under the deny-default profile).
19//!
20//! # A fully generic, data-driven build
21//!
22//! There is **no per-formula recipe table**. Everything the build needs is
23//! derived from the Homebrew formula JSON we already parse plus the extracted
24//! source tree:
25//!
26//! - **Dependencies come from the formula's data.** Anything in
27//!   `uses_from_macos` is provided by macOS itself (the Seatbelt profile already
28//!   grants `/usr/lib` + `/usr/include` via the Command Line Tools) so it is
29//!   skipped. Every *other* `dependency` / `build_dependency` is resolved
30//!   **recursively** as a sibling toolchain via [`crate::ensure_macos_toolchain`] and wired
31//!   onto the build with *absolute* toolchain paths — build tools land on `PATH`,
32//!   libraries contribute `-I<toolchain>/include` / `-L<toolchain>/lib` /
33//!   `-Wl,-rpath,<toolchain>/lib` / `PKG_CONFIG_PATH=<toolchain>/lib/pkgconfig`. So git's
34//!   `gettext`/`pcre2` and jq's `oniguruma` become resolved toolchains automatically —
35//!   no `NO_GETTEXT`, no `--with-oniguruma=builtin`, no hardcoded skip lists.
36//! - **The build system is autodetected** from the extracted tree (a generated
37//!   `configure`, a `CMake` self-host `bootstrap`, a `CMakeLists.txt`, a bare
38//!   `Makefile`, or an autotools project shipped as `configure.ac`).
39//! - **Irreducible env is derived from the install layout, not the name.** A toolchain
40//!   that installed `<toolchain>/libexec/git-core` gets `GIT_EXEC_PATH` pointed there —
41//!   true for any git-exec-helper tool, asserted by layout, never by `== "git"`.
42//!
43//! # Strategy order
44//!
45//! [`ensure_from_source`] routes each formula through up to three strategies:
46//!
47//! 1. **Parsed-recipe container build** — when the runtime has registered a
48//!    [`crate::executor::ContainerBuildExecutor`], the formula's real
49//!    `def install` is fetched and parsed into a typed
50//!    [`crate::recipe::InstallPlan`]; a fully-supported plan executes inside an
51//!    isolated container with the network **denied** (source tarball, resources
52//!    and patches are pre-fetched and sha-verified leaf-side). With no executor
53//!    registered this strategy — including the recipe fetch itself — is skipped
54//!    entirely, so executor-less processes behave exactly as before.
55//! 2. **The generic autodetected build** described above.
56//! 3. **Brew-emulate**: if the generic build fails (a custom `install do` /
57//!    patches the generic build can't reproduce), [`crate::brew_emulate`] runs
58//!    the formula's *real* Homebrew recipe at the toolchain prefix — so
59//!    genuinely-custom formulae still work with **zero** hardcoding.
60
61use 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
74/// The macOS platform token stored in an artifact id / coverage record.
75const MACOS_OS_TOKEN: &str = "macos";
76
77/// Which build strategy produced a toolchain, for coverage + publish routing.
78///
79/// The choice IS the net-fallback signal: `NetFallback` is only reachable via
80/// the `brew_emulate` branch, which runs (with the network allowed loudly) only
81/// after both the container-recipe and generic strategies have failed.
82enum BuildKind {
83    /// A hermetic build (container-recipe or generic) won: the toolchain was
84    /// relocated (report attached) and is publishable → coverage `Built`.
85    Hermetic(RelocationReport),
86    /// The `brew_emulate` loud full-network fallback won → coverage `NetFallback`
87    /// (not hermetic, not published).
88    NetFallback,
89}
90
91/// Resolved source-build coordinates for a tool.
92#[derive(Debug, Clone)]
93pub struct SourceSpec {
94    /// Stable version string (e.g. `2.55.0`).
95    pub version: String,
96    /// URL of the source tarball.
97    pub tarball_url: String,
98    /// The source tarball's sha256 (bare hex, `sha256:` prefix stripped). Empty
99    /// when the formula publishes no `urls.stable.checksum`.
100    pub sha256: String,
101    /// Runtime dependency formula names.
102    pub dependencies: Vec<String>,
103    /// Build-only dependency formula names.
104    pub build_dependencies: Vec<String>,
105    /// Dependency names macOS itself provides (need no toolchain).
106    pub macos_provided: Vec<String>,
107}
108
109/// Which build system a recipe drives, autodetected from the extracted tree.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111enum BuildSystem {
112    /// `./configure --prefix=<toolchain> && make && make install` (a generated
113    /// `configure`, or `configure.ac`/`autogen.sh` regenerated first).
114    Autotools,
115    /// `cmake -DCMAKE_INSTALL_PREFIX=<toolchain> … && cmake --build && cmake --install`
116    /// using a `cmake` resolved as a build-dependency toolchain.
117    CMake,
118    /// A self-hosting `CMake` (`bootstrap` + `CMakeLists.txt`, i.e. cmake itself):
119    /// `./bootstrap --prefix=<toolchain> --parallel=N && make && make install`.
120    CMakeBootstrap,
121    /// A bare hand-written `Makefile`: `make prefix=<toolchain> … install`.
122    MakePrefix,
123}
124
125/// Host architecture token used in cache keys (`arm64` / `x86_64`).
126fn arch_token() -> &'static str {
127    match std::env::consts::ARCH {
128        "aarch64" => "arm64",
129        other => other,
130    }
131}
132
133/// Fetch the formula and extract its source-build coordinates.
134///
135/// # Errors
136///
137/// Returns [`ToolchainError::RegistryError`] if the formula cannot be fetched
138/// or carries no stable version / source URL.
139pub 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
166/// Build `formula` from source into a self-contained toolchain under `cache_dir`,
167/// writing a [`ToolchainManifest`] and returning the toolchain path.
168///
169/// Idempotent: a `<toolchain>/.ready` marker (written LAST, after the manifest)
170/// short-circuits a populated toolchain. A cold build removes any partial toolchain, builds
171/// into a scratch dir, installs into the toolchain prefix, writes the manifest, then
172/// stamps `.ready`.
173///
174/// Strategy order: parsed-recipe container build (only when a runtime
175/// [`crate::executor::ContainerBuildExecutor`] is registered) → generic
176/// autodetected build → brew-emulate. See the module docs.
177///
178/// # Errors
179///
180/// Propagates formula-resolution, download, extraction, dependency, and build
181/// failures.
182pub 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    // A lock hit pins the exact version + source URL + digest (consume-only).
190    // The dependency graph still comes from the resolved formula.
191    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    // Local `.ready` wins FIRST: a locally-present toolchain short-circuits with
204    // zero network (no pull, no coverage).
205    if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
206        return Ok(toolchain);
207    }
208
209    // Identity is known now (version resolved above). Try the registry before
210    // building — best-effort; a miss or transport error just falls through.
211    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    // Miss → build, then record coverage + (for a hermetic build) publish.
222    // Coverage is recorded EXACTLY ONCE on every terminal outcome.
223    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
239/// Run the source-build strategy chain: parsed-recipe container build (only when
240/// a runtime executor is registered) → generic autodetected build →
241/// `brew_emulate`. Returns which strategy won ([`BuildKind`]) so the caller can
242/// route coverage + publish; propagates a terminal build error.
243async 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    // Strategy 1: the formula's REAL recipe, parsed into a typed plan and
251    // executed inside an isolated container (network denied, all inputs
252    // pre-fetched leaf-side). Gated on a registered runtime executor — an
253    // executor-less process skips this entirely (no recipe fetch, no extra
254    // network) and behaves exactly as it did before this route existed.
255    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                    // Expected until the runtime registers an executor;
270                    // not a coverage gap worth a warning.
271                    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                // Drop any partial container-route state so the generic build
284                // (and, after it, brew-emulate) starts from a clean prefix.
285                let _ = tokio::fs::remove_dir_all(toolchain).await;
286            }
287        }
288    }
289
290    // Try the generic build next. If it can't build this formula — no build
291    // system detected (a custom `install do` with no
292    // `configure`/`CMakeLists.txt`/`Makefile`, e.g. a `cargo install` / `go
293    // build` formula), a required dependency toolchain won't build, or the build
294    // errors in a way the generic flags don't cover — fall back to running the
295    // formula's REAL Homebrew recipe at the toolchain prefix (`brew_emulate`).
296    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            // Drop the partial generic toolchain so the brew-emulate install starts
305            // from a clean prefix (it reuses the same `<toolchain>` cache key).
306            let _ = tokio::fs::remove_dir_all(toolchain).await;
307            // The loud full-network fallback: `brew_emulate` (AllowLoud) → NetFallback.
308            crate::brew_emulate::ensure_via_brew(formula, spec, cache_dir).await?;
309            Ok(BuildKind::NetFallback)
310        }
311    }
312}
313
314/// Attempt the recipe-plan container route: resolve dependencies, fetch and
315/// parse the formula's real `def install`, and — when the plan is fully
316/// supported — execute it in the runtime container `executor`.
317///
318/// Any error (dependency resolution, recipe fetch/parse, unsupported
319/// constructs, resource pre-fetch, executor failure) makes the caller fall
320/// through to the generic-build → brew-emulate chain unchanged.
321async 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    // Fresh attempt: a crashed prior attempt must not leave a half-populated
330    // prefix that confuses the container install.
331    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    // 1. Dependencies FIRST: the plan's `Formula["x"].opt_*` references resolve
337    //    through each dep's toolchain prefix.
338    let deps = resolve_dependencies(formula, spec, cache_dir, lockfile).await?;
339
340    // 2. Fetch + parse the recipe. `SourceSpec` is a distilled projection that
341    //    does not retain the `FormulaData` (`ruby_source_path` in particular),
342    //    so re-fetch it through the package index (cheap, cached server-side).
343    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    // 3. Route only fully-supported plans to the container; surface every
356    //    unsupported construct individually so coverage gaps are visible.
357    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
379/// Execute a fully-supported [`InstallPlan`] in the runtime container:
380/// download + extract the source, pre-fetch every resource/patch
381/// (sha-verified — the container runs with the network DENIED), run the plan,
382/// then finish the toolchain exactly like the generic build does (manifest,
383/// scratch cleanup, `.ready` stamped LAST).
384async 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
407/// Run an assembled [`ContainerBuildRequest`] through `executor` and, on
408/// success, finish the toolchain (manifest + `.ready`) exactly the way
409/// [`try_generic_source_build`] finishes — via the shared
410/// [`finalize_toolchain`].
411async 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
430/// Assemble the [`ContainerBuildRequest`] for a fully-supported plan: network
431/// denied (all inputs pre-fetched), dependency toolchain bins on `PATH`
432/// (mirroring the generic build's [`run_cmd`] PATH assembly, where dep
433/// toolchain bins come first), and the plan's accumulated env merged
434/// (+ `MAKEFLAGS=-j1` when the recipe deparallelizes).
435fn 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
460/// The container environment for a plan: the plan's accumulated `ENV` sets,
461/// plus `MAKEFLAGS=-j1` when the recipe called `ENV.deparallelize` (overriding
462/// any recipe-set `MAKEFLAGS` — deparallelize is an explicit "this build
463/// races under parallel make").
464fn 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
472/// Pre-fetch staging destination for a named recipe resource:
473/// `<resources_dir>/<name>/<url-basename>` (the same basename derivation
474/// [`download_and_extract`] uses for the source tarball).
475fn 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
485/// Pre-fetch staging destination for `plan.patches[index]`:
486/// `<resources_dir>/patches/<index>`.
487fn patch_dest(resources_dir: &Path, index: usize) -> PathBuf {
488    resources_dir.join("patches").join(index.to_string())
489}
490
491/// Pre-fetch every resource + patch of `plan` into `<scratch>/resources`
492/// (sha256-verified — the container runs with the network DENIED, so every
493/// input must land here first). Returns `None` when the plan declares neither.
494///
495/// # Errors
496///
497/// A resource/patch with an empty sha256 is an error on this path: Homebrew
498/// formulae always publish digests (the weak-checksum case is Chocolatey-only
499/// and never reaches this macOS route), so an empty digest means a parse gap —
500/// refuse the unverified download rather than staging it silently.
501async 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
539/// Attempt the generic (autotools/CMake/Makefile) source build into `toolchain`.
540///
541/// On success the toolchain is fully populated: manifest written and `.ready` stamped.
542/// On any failure (detection or build) the caller falls back to
543/// [`crate::brew_emulate`]. Kept as a separate fallible step so the fallback
544/// decision is a single `match` at the call site.
545async 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    // Fresh build. Remove any partial toolchain so a crashed prior attempt can't leave
553    // a half-installed prefix that confuses the installer.
554    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    // 1. Resolve dependencies as sibling toolchains and assemble the build env. macOS-
561    //    provided deps (`uses_from_macos`) are skipped; everything else is built
562    //    recursively as a toolchain with absolute paths.
563    let deps = resolve_dependencies(formula, spec, cache_dir, lockfile).await?;
564
565    // 2. Download + extract the source tarball into `<scratch>/src`.
566    let src_dir = download_and_extract(formula, spec, &scratch).await?;
567
568    // 3. Autodetect the build system and run it into the toolchain prefix.
569    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    // 4. Relocate + write the manifest + stamp `.ready`, returning the report.
574    finalize_toolchain(
575        formula,
576        spec,
577        toolchain,
578        &scratch,
579        deps.build_dep_names,
580        &deps.prefixes,
581    )
582    .await
583}
584
585/// Finish a successfully-populated toolchain the single canonical way (shared
586/// by the container-recipe and generic build paths):
587///
588/// 1. clean the scratch dir FIRST (so relocation walks only the installed tree,
589///    never the build's intermediate objects),
590/// 2. [`make_relocatable`](crate::relocate::make_relocatable) the tree in place
591///    (built prefix = the toolchain dir; `dep_dirs` = the resolved dependency
592///    toolchains) — rewriting Mach-O load commands + text placeholders and
593///    returning the [`RelocationReport`] the publish step consumes,
594/// 3. write the [`ToolchainManifest`] AFTER relocation (so its absolute
595///    `path_dirs` are not themselves placeholdered), then stamp `.ready` LAST.
596async 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    // Clean scratch before relocating so the relocation pass sees only the
605    // installed toolchain tree. A NotFound (already clean) is fine; only a real
606    // failure warns.
607    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
622/// Build the [`ToolchainManifest`] for a freshly-installed source toolchain, deriving any
623/// irreducible runtime env from the **installed layout** (never the formula
624/// name): a toolchain that produced `<toolchain>/libexec/git-core` gets `GIT_EXEC_PATH`
625/// pointed there, so git discovers its exec-helpers out of the toolchain.
626async 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    // Layout-derived, not name-derived: any tool that installs git's exec-helper
640    // dir needs `GIT_EXEC_PATH` to find them out of the toolchain rather than the host.
641    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/// Environment accumulated for a source build: `PATH` plus linker/include/
663/// pkg-config flags pointing at resolved dependency toolchains.
664#[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/// Dependency material resolved for a source build: the accumulated
673/// [`BuildEnv`], the build-dep names recorded in the manifest, and each
674/// resolved dependency's toolchain prefix (feeding
675/// [`RecipeCtx::dep_prefixes`] and the container's read/exec grants).
676#[derive(Debug, Default)]
677struct ResolvedDeps {
678    /// `PATH` / `CPPFLAGS` / `LDFLAGS` / `PKG_CONFIG_PATH` from dep toolchains.
679    env: BuildEnv,
680    /// Build-dependency formula names (recorded in the toolchain manifest).
681    build_dep_names: Vec<String>,
682    /// Formula name → resolved toolchain prefix (build + runtime deps).
683    prefixes_by_name: HashMap<String, PathBuf>,
684    /// Unique resolved toolchain prefixes, in resolution order.
685    prefixes: Vec<PathBuf>,
686}
687
688impl ResolvedDeps {
689    /// Record a resolved dependency toolchain under its formula name,
690    /// deduplicating the prefix list (a dep can be both a build and a runtime
691    /// dependency).
692    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
701/// Resolve a formula's build + runtime dependencies into sibling toolchains and build
702/// the [`ResolvedDeps`] (its [`BuildEnv`] plus each dep's toolchain prefix),
703/// **purely from the formula's data**:
704///
705/// - Deps macOS itself provides (`uses_from_macos`) are skipped — the Command
706///   Line Tools already expose them under `/usr/lib` + `/usr/include`, which the
707///   Seatbelt profile grants.
708/// - Every other build dependency is **required** (its absence fails the build,
709///   routing the formula to brew-emulate) and its `bin` dirs go on `PATH`.
710/// - Every other runtime dependency is best-effort (a missing optional library
711///   should not abort) and is wired via `-I`/`-L`/`-rpath`/`PKG_CONFIG_PATH`.
712///
713/// Resolution is **recursive**: [`crate::ensure_macos_toolchain`] re-enters the whole
714/// provisioning pipeline for each dep (prebuilt-lang fetch or another source
715/// build), so an arbitrarily deep dependency graph is materialized as toolchains.
716async 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    // A dependency provided by macOS itself needs no toolchain.
725    let is_macos_provided = |dep: &str| spec.macos_provided.iter().any(|d| d == dep);
726
727    // Build dependencies — required; their bin dirs go on PATH.
728    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    // Runtime dependencies — best effort; wire link/include/pkg-config flags so
742    // the binary's load commands reference the absolute dependency-toolchain path.
743    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
766/// Add `-I`/`-L`/`-rpath`/pkg-config entries for a dependency toolchain so the build
767/// links against its absolute path (never `@@HOMEBREW@@`).
768fn 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
784/// Download the source tarball and extract it (stripping the top-level dir) into
785/// `<scratch>/src`. macOS `tar` (libarchive) auto-detects the compression, so a
786/// single `tar xf` handles `.tar.xz` / `.tar.gz` / `.tgz` / `.tar.bz2`.
787async 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    // Stream + verify against the formula's `urls.stable.checksum` when present;
797    // otherwise the digest is computed (trust-on-first-download) and recorded in
798    // the manifest. A mismatch deletes the partial and aborts the build.
799    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
823/// Detect the build system of an extracted source tree, preferring the path that
824/// needs the least extra tooling and is canonical for the project shape:
825///
826/// 1. `bootstrap` (+ `CMakeLists.txt`) → a self-hosting `CMake` (cmake itself).
827/// 2. a generated `configure` → autotools (ready to run).
828/// 3. a hand-written top-level `Makefile`/`GNUmakefile` → make-prefix (git).
829/// 4. `CMakeLists.txt` → `CMake` (built with a `cmake` toolchain).
830/// 5. `configure.ac`/`configure.in`/`autogen.sh`/`bootstrap.sh` → autotools
831///    (regenerate `configure` first).
832///
833/// # Errors
834///
835/// Returns [`ToolchainError::RegistryError`] if no recognised build system is
836/// present.
837async 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        // Self-hosting CMake (e.g. cmake's own source): its `bootstrap` builds a
848        // minimal cmake then generates the real build — can't use a cmake toolchain to
849        // build cmake. The bootstrap wrapper bakes `--prefix` into the build.
850        Ok(BuildSystem::CMakeBootstrap)
851    } else if exists("configure").await {
852        Ok(BuildSystem::Autotools)
853    } else if exists("Makefile").await || exists("GNUmakefile").await {
854        // A ready top-level Makefile (not Makefile.in) is a hand-written build
855        // (git, simple C tools): `make prefix=<toolchain> install`.
856        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        // Autotools project shipped without a generated `configure`. Generate it
865        // (needs autoconf/automake/libtool on PATH from build-dep toolchains).
866        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/// Run the detected build system into the toolchain prefix using the host CLT plus the
879/// resolved dependency toolchains (no per-formula flags — every quirk is data-driven).
880#[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            // Hand-written Makefile: one `make -jN prefix=<toolchain> install`. The
896            // Makefile bakes its own install/sysconfdir from `prefix=`.
897            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            // Self-hosting CMake: bootstrap (bakes the install prefix), then make.
906            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            // Generate `configure` if the tarball shipped only configure.ac.
937            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
1023/// Apply the [`BuildEnv`] to a command and run it, returning a build error with
1024/// the tail of stderr on failure. The host environment is inherited; `PATH`,
1025/// `CPPFLAGS`, `LDFLAGS` and `PKG_CONFIG_PATH` are prepended/augmented.
1026async fn run_cmd(
1027    formula: &str,
1028    step: &str,
1029    cmd: &mut tokio::process::Command,
1030    env: &BuildEnv,
1031) -> Result<()> {
1032    // PATH: dependency toolchain bins first, then the host CLT/system paths.
1033    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    /// A self-hosting `CMake` (cmake itself ships `bootstrap` + `CMakeLists.txt`)
1112    /// must bootstrap, NOT try to build with a cmake we don't yet have.
1113    #[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    /// A generated `configure` wins over a sibling `Makefile.in`; and a tree with
1129    /// only `configure.ac` is still autotools (regenerated first).
1130    #[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    /// A hand-written `Makefile` that also ships `configure.ac` (git's shape:
1143    /// no generated `configure`) takes the ready Makefile path, not the
1144    /// regenerate-with-autotools path.
1145    #[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    /// `resolve_dependencies` skips `uses_from_macos` deps without touching the
1187    /// network: a spec whose every dep is macOS-provided resolves to an empty
1188    /// build env and no resolved build deps.
1189    #[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    /// The manifest's env is derived from the installed LAYOUT, not the formula
1216    /// name: a toolchain with `libexec/git-core` gets `GIT_EXEC_PATH`; one without
1217    /// gets none — for the same formula name either way.
1218    #[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        // Toolchain WITH the git-core exec dir → GIT_EXEC_PATH present.
1230        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        // Same formula NAME, no git-core dir → no GIT_EXEC_PATH (layout-driven).
1247        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    // -----------------------------------------------------------------------
1253    // Container-recipe route (C3). These tests exercise the pure/observable
1254    // pieces (env assembly, pre-fetch layout, request assembly, finalize) and
1255    // pass a LOCAL mock executor directly — they never touch the process-global
1256    // executor slot, which belongs exclusively to `executor.rs`'s single
1257    // sequential test. The end-to-end route (real recipe fetch + container
1258    // run) is deferred to D5's live `#[ignore]` tier.
1259    // -----------------------------------------------------------------------
1260
1261    /// A minimal plan with the given env pairs and deparallelize flag.
1262    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        // The plan's accumulated ENV is carried through verbatim; no
1278        // deparallelize → no MAKEFLAGS injected.
1279        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        // ENV.deparallelize → MAKEFLAGS=-j1, overriding a recipe-set value.
1285        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    /// Pre-fetch destinations: `<resources>/<name>/<url-basename>` for
1291    /// resources (with a stable fallback name for basename-less URLs) and
1292    /// `<resources>/patches/<index>` for patches — and the layout is writable
1293    /// exactly as assembled (no network involved).
1294    #[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        // A URL with an empty basename falls back to a stable name.
1312        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        // Fake write proves the assembled layout is directly materializable
1331        // (download_verified creates parents the same way).
1332        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    /// `prefetch_plan_inputs` needs no network for the decisions under test:
1341    /// an input-less plan short-circuits to `None`, and an empty/blank sha256
1342    /// is rejected BEFORE any download is attempted (the choco weak-checksum
1343    /// case never reaches this macOS path).
1344    #[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    /// The assembled request denies the network, targets macOS, carries the
1380    /// deduped dep prefixes + their bin dirs as PATH prefix (mirroring
1381    /// `run_cmd`), and merges the plan env (+ MAKEFLAGS on deparallelize).
1382    #[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); // build+runtime double-record dedupes
1395
1396        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    /// Local mock executor passed DIRECTLY as `&dyn` (never through the
1431    /// process-global slot — that slot belongs to `executor.rs`'s single
1432    /// sequential test). Captures the request it served; optionally fails.
1433    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    /// A successful container run finishes the toolchain EXACTLY like the
1465    /// generic build: manifest written (same fields, `SourceBuild` variant),
1466    /// scratch removed, `.ready` stamped LAST. A failed run propagates the
1467    /// error and leaves NO `.ready`, so the caller's fall-through to the
1468    /// generic build starts clean.
1469    #[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        // Success path.
1482        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        // Failure path: error propagates, NO .ready stamped.
1532        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}