zlayer-toolchain 0.14.1

Runtime toolchain provisioning (macOS Homebrew bottle resolver/installer) for ZLayer
Documentation
//! Brew-emulate fallback: build a homebrew-core formula with **real Homebrew
//! installed at the keg prefix**, for the macOS long tail the generic
//! [`crate::source_build`] recipe runner cannot reproduce.
//!
//! # When this runs
//!
//! [`crate::source_build::ensure_from_source`] handles the homebrew-core C-tool
//! population with a generic autotools/CMake/Makefile runner. Some formulae have
//! custom `.rb` `install do` logic (compile a single file by hand, run a
//! bespoke `install.sh`, `cargo install` / `go build`, apply `patches`, …) that
//! a generic build-system detector cannot reproduce — for those the generic
//! runner either fails detection (no `configure`/`CMakeLists.txt`/`Makefile`) or
//! errors mid-build. This module is the fallback: it runs the formula's *actual*
//! Homebrew install recipe, so whatever custom logic the formula carries is
//! executed faithfully.
//!
//! # Why install Homebrew AT the keg prefix
//!
//! The keg must be **self-contained and relocation-free** — no `@@HOMEBREW@@`
//! placeholders in any binary's load commands (those abort under a darwin
//! Seatbelt container, see [`crate::source_build`] module docs). Two properties
//! get us there:
//!
//! 1. **A non-default prefix forces build-from-source.** We additionally pass
//!    `--build-from-source`, so Homebrew compiles the named formula instead of
//!    pouring a relocatable bottle (a poured bottle is exactly where the
//!    `@@HOMEBREW@@` install-name placeholders come from). A compiled binary
//!    bakes the *absolute* prefix path into its `LC_LOAD_DYLIB` / rpath load
//!    commands.
//! 2. **The prefix lives inside the keg, permanently.** We clone Homebrew into
//!    `<keg>/brew` and point `HOMEBREW_PREFIX`/`HOMEBREW_CELLAR`/
//!    `HOMEBREW_REPOSITORY` there, so those baked-in absolute paths
//!    (`<keg>/brew/opt/<dep>/lib/...`) remain valid for the life of the keg —
//!    no post-install relocation, ever.
//!
//! The resulting keg has the standard layout the resolver expects (a `bin` of
//! the installed formula's executables, reached via the Homebrew prefix's
//! `opt/<formula>/bin` + `bin` symlink dirs) plus a [`KegManifest`].

use std::path::{Path, PathBuf};

use tracing::{info, warn};

use crate::error::{Result, ToolchainError};
use crate::manifest::{KegManifest, KegSource};
use crate::source_build::SourceSpec;

/// Upstream Homebrew git repository (cloned shallow into the keg prefix).
const HOMEBREW_REPO_URL: &str = "https://github.com/Homebrew/brew";

/// Host architecture token used in cache keys (`arm64` / `x86_64`).
fn arch_token() -> &'static str {
    match std::env::consts::ARCH {
        "aarch64" => "arm64",
        other => other,
    }
}

/// Build `formula` into a self-contained keg by running its *real* Homebrew
/// install recipe at a keg-rooted prefix, returning the keg path.
///
/// The keg lives at `<cache_dir>/<formula>-<version>-<arch>/` (identical to the
/// source-build layout, so the cache key, `.ready` marker, and
/// [`crate::probe_ready_toolchain`] all behave the same). The Homebrew checkout
/// lands at `<keg>/brew` and is reused as the prefix; the formula is installed
/// with `--build-from-source` so no `@@HOMEBREW@@` placeholder ever reaches a
/// binary's load commands.
///
/// # Errors
///
/// Returns [`ToolchainError::RegistryError`] if Homebrew cannot be provisioned
/// at the prefix or the `brew install` fails, or an I/O error on a filesystem
/// failure.
pub async fn ensure_via_brew(
    formula: &str,
    spec: &SourceSpec,
    cache_dir: &Path,
) -> Result<PathBuf> {
    let keg = cache_dir.join(format!("{formula}-{}-{}", spec.version, arch_token()));
    let ready_marker = keg.join(".ready");
    if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
        return Ok(keg);
    }

    // Fresh build — clear any partial keg from a crashed prior attempt.
    let _ = tokio::fs::remove_dir_all(&keg).await;
    tokio::fs::create_dir_all(&keg).await?;

    let brew_prefix = keg.join("brew");
    provision_brew_at_prefix(&brew_prefix).await?;

    // Shared download cache so a repeated fallback reuses fetched bottles/source.
    let brew_cache = cache_dir.join(".brew-cache");
    tokio::fs::create_dir_all(&brew_cache).await?;

    run_brew_install(formula, &brew_prefix, &brew_cache).await?;

    // The formula's executables are reached through the prefix's `opt/<formula>`
    // symlink (canonical). Prefer ONLY that per-formula bin: the shared prefix
    // `bin` aggregates every dependency's shims PLUS a `brew` wrapper, and since
    // the slim step below deletes `Library/` (Homebrew's Ruby) that `brew` shim
    // is DEAD -- yet prepending prefix `bin` to PATH would shadow the host's real
    // `/opt/homebrew/bin/brew`, so a job's `brew install <x>` hits the dead shim
    // and dies `brew.sh: No such file or directory` (exit 127). Expose only
    // `opt/<formula>/bin`; fall back to the prefix `bin` ONLY when the formula has
    // no opt bin (rare), so no keg is ever left with an empty PATH.
    let mut path_dirs = Vec::new();
    let opt_bin = brew_prefix.join("opt").join(formula).join("bin");
    if tokio::fs::try_exists(&opt_bin).await.unwrap_or(false) {
        path_dirs.push(opt_bin.display().to_string());
    } else {
        let prefix_bin = brew_prefix.join("bin");
        if tokio::fs::try_exists(&prefix_bin).await.unwrap_or(false) {
            path_dirs.push(prefix_bin.display().to_string());
        }
    }
    if path_dirs.is_empty() {
        return Err(ToolchainError::RegistryError {
            message: format!(
                "brew-emulate install of {formula} produced no bin dir under {}",
                brew_prefix.display()
            ),
        });
    }

    // Defensive guard: the whole point is no `@@HOMEBREW@@` placeholder survives.
    // `--build-from-source` guarantees this for the named formula; assert it so a
    // regression (e.g. brew silently pouring a bottle) is caught at provision
    // time rather than as an Abort trap inside the sandbox.
    if let Some(offending) = scan_for_homebrew_placeholder(&opt_bin).await {
        return Err(ToolchainError::RegistryError {
            message: format!(
                "brew-emulate {formula}: binary {} still carries an @@HOMEBREW@@ load \
                 command (bottle poured instead of built from source?)",
                offending.display()
            ),
        });
    }

    // Slim the keg: the compiled binaries never reference Homebrew's own Ruby
    // code or git history at runtime, so drop them. The Cellar + opt + bin +
    // lib trees (which the load commands DO reference) are left intact.
    for slim in [".git", "Library", "docs", "completions", "manpages"] {
        let _ = tokio::fs::remove_dir_all(brew_prefix.join(slim)).await;
    }
    // The `brew` CLI shim in the prefix `bin` is dead once `Library/` (its Ruby)
    // is gone; drop it so it can never shadow the host `brew` if the prefix `bin`
    // is ever surfaced.
    let _ = tokio::fs::remove_file(brew_prefix.join("bin").join("brew")).await;

    let manifest = KegManifest {
        tool: formula.to_string(),
        version: spec.version.clone(),
        arch: arch_token().to_string(),
        platform: "macos".to_string(),
        path_dirs,
        env: std::collections::HashMap::new(),
        source: KegSource::SourceBuild {
            url: spec.tarball_url.clone(),
            sha256: spec.sha256.clone(),
        },
        build_deps: spec.build_dependencies.clone(),
        provisioned_at: chrono::Utc::now().to_rfc3339(),
    };
    manifest.write_to_keg(&keg).await?;
    tokio::fs::write(&ready_marker, b"").await?;

    info!(formula, keg = %keg.display(), "brew-emulate fallback produced a self-contained keg");
    Ok(keg)
}

/// Provision a throwaway Homebrew checkout at `brew_prefix`.
///
/// Prefers a shallow `git clone` (the host CLT git, unsandboxed — this is
/// provision time, not the sandboxed runtime); falls back to the GitHub source
/// tarball when git is unavailable. A real git checkout is preferred because
/// `brew` is happier inside a git repository.
async fn provision_brew_at_prefix(brew_prefix: &Path) -> Result<()> {
    if tokio::fs::try_exists(brew_prefix.join("bin/brew"))
        .await
        .unwrap_or(false)
    {
        return Ok(());
    }
    if let Some(parent) = brew_prefix.parent() {
        tokio::fs::create_dir_all(parent).await?;
    }

    // Try a shallow clone first.
    let clone = tokio::process::Command::new("git")
        .arg("clone")
        .arg("--depth=1")
        .arg(HOMEBREW_REPO_URL)
        .arg(brew_prefix)
        .output()
        .await;
    if let Ok(out) = clone {
        if out.status.success()
            && tokio::fs::try_exists(brew_prefix.join("bin/brew"))
                .await
                .unwrap_or(false)
        {
            return Ok(());
        }
        warn!(
            "git clone of Homebrew failed ({}); falling back to source tarball",
            String::from_utf8_lossy(&out.stderr).trim()
        );
    }

    // Fallback: download + extract the master tarball.
    let tarball = "https://github.com/Homebrew/brew/archive/refs/heads/master.tar.gz";
    let bytes = reqwest::get(tarball)
        .await
        .map_err(|e| ToolchainError::RegistryError {
            message: format!("failed to download Homebrew tarball: {e}"),
        })?
        .bytes()
        .await
        .map_err(|e| ToolchainError::RegistryError {
            message: format!("failed to read Homebrew tarball bytes: {e}"),
        })?;
    let tmp = brew_prefix.with_extension("tar.gz");
    tokio::fs::write(&tmp, &bytes).await?;
    tokio::fs::create_dir_all(brew_prefix).await?;
    let untar = tokio::process::Command::new("tar")
        .arg("xf")
        .arg(&tmp)
        .args(["--strip-components", "1", "-C"])
        .arg(brew_prefix)
        .output()
        .await?;
    let _ = tokio::fs::remove_file(&tmp).await;
    if !untar.status.success() {
        return Err(ToolchainError::RegistryError {
            message: format!(
                "failed to extract Homebrew tarball: {}",
                String::from_utf8_lossy(&untar.stderr)
            ),
        });
    }
    if !tokio::fs::try_exists(brew_prefix.join("bin/brew"))
        .await
        .unwrap_or(false)
    {
        return Err(ToolchainError::RegistryError {
            message: format!(
                "Homebrew checkout at {} has no bin/brew",
                brew_prefix.display()
            ),
        });
    }
    Ok(())
}

/// Run `brew install --build-from-source <formula>` with the prefix env pointed
/// at the keg-rooted Homebrew. Inherits the host environment (so the host CLT
/// `clang`/`git`/`curl` resolve) and overrides the Homebrew prefix variables.
async fn run_brew_install(formula: &str, brew_prefix: &Path, brew_cache: &Path) -> Result<()> {
    let brew_bin = brew_prefix.join("bin/brew");
    let prefix_str = brew_prefix.display().to_string();

    // PATH: prefix bin first, then host PATH, then the system fallback.
    let host_path = std::env::var("PATH").unwrap_or_default();
    let mut path_parts = vec![brew_prefix.join("bin").display().to_string()];
    if !host_path.is_empty() {
        path_parts.push(host_path);
    }
    path_parts.push("/usr/bin:/bin:/usr/sbin:/sbin".to_string());

    info!(formula, prefix = %prefix_str, "running brew install --build-from-source");
    let out = tokio::process::Command::new(&brew_bin)
        .arg("install")
        .arg("--build-from-source")
        .arg(formula)
        .env("PATH", path_parts.join(":"))
        .env("HOMEBREW_PREFIX", &prefix_str)
        .env("HOMEBREW_REPOSITORY", &prefix_str)
        .env("HOMEBREW_CELLAR", brew_prefix.join("Cellar"))
        .env("HOMEBREW_CACHE", brew_cache)
        .env("HOMEBREW_NO_AUTO_UPDATE", "1")
        .env("HOMEBREW_NO_ANALYTICS", "1")
        .env("HOMEBREW_NO_ENV_HINTS", "1")
        .env("HOMEBREW_NO_INSTALL_CLEANUP", "1")
        .output()
        .await?;

    if !out.status.success() {
        let tail = String::from_utf8_lossy(&out.stderr)
            .lines()
            .rev()
            .take(30)
            .collect::<Vec<_>>()
            .into_iter()
            .rev()
            .collect::<Vec<_>>()
            .join("\n");
        return Err(ToolchainError::RegistryError {
            message: format!("brew install --build-from-source {formula} failed:\n{tail}"),
        });
    }
    Ok(())
}

/// Scan every regular file under `dir` for the `@@HOMEBREW@@` byte sequence in a
/// binary's load commands. Returns the first offending path, or `None` when the
/// tree is clean. Best-effort and shallow-recursive; a missing dir is clean.
async fn scan_for_homebrew_placeholder(dir: &Path) -> Option<PathBuf> {
    let mut stack = vec![dir.to_path_buf()];
    while let Some(d) = stack.pop() {
        let mut entries = tokio::fs::read_dir(&d).await.ok()?;
        while let Ok(Some(entry)) = entries.next_entry().await {
            let path = entry.path();
            let ft = entry.file_type().await.ok()?;
            if ft.is_dir() {
                stack.push(path);
            } else if ft.is_file() {
                if let Ok(bytes) = tokio::fs::read(&path).await {
                    if contains_subslice(&bytes, b"@@HOMEBREW") {
                        return Some(path);
                    }
                }
            }
        }
    }
    None
}

/// Tiny byte-substring helper for the placeholder scan.
fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
    if needle.is_empty() || haystack.len() < needle.len() {
        return false;
    }
    haystack.windows(needle.len()).any(|w| w == needle)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn placeholder_scan_helper_matches() {
        assert!(contains_subslice(b"abc@@HOMEBREW@@/lib", b"@@HOMEBREW"));
        assert!(!contains_subslice(
            b"/usr/lib/libSystem.dylib",
            b"@@HOMEBREW"
        ));
        assert!(!contains_subslice(b"", b"@@HOMEBREW"));
    }

    #[tokio::test]
    async fn placeholder_scan_finds_offender_and_clean_is_none() {
        let tmp = tempfile::tempdir().unwrap();
        let sub = tmp.path().join("bin");
        tokio::fs::create_dir_all(&sub).await.unwrap();
        tokio::fs::write(sub.join("clean"), b"/usr/lib/libSystem.B.dylib")
            .await
            .unwrap();
        assert!(scan_for_homebrew_placeholder(tmp.path()).await.is_none());

        tokio::fs::write(
            sub.join("dirty"),
            b"@@HOMEBREW_PREFIX@@/opt/x/lib/libx.dylib",
        )
        .await
        .unwrap();
        let hit = scan_for_homebrew_placeholder(tmp.path()).await;
        assert!(hit.is_some());
        assert!(hit.unwrap().ends_with("dirty"));
    }

    #[tokio::test]
    async fn ensure_via_brew_short_circuits_on_ready_keg() {
        let tmp = tempfile::tempdir().unwrap();
        let spec = SourceSpec {
            version: "1.2.3".to_string(),
            tarball_url: "https://example/x.tar.gz".to_string(),
            sha256: String::new(),
            dependencies: vec![],
            build_dependencies: vec![],
            macos_provided: vec![],
        };
        let keg = tmp.path().join(format!("demo-1.2.3-{}", arch_token()));
        tokio::fs::create_dir_all(&keg).await.unwrap();
        tokio::fs::write(keg.join(".ready"), b"").await.unwrap();

        let got = ensure_via_brew("demo", &spec, tmp.path()).await.unwrap();
        assert_eq!(got, keg, "a ready keg short-circuits without invoking brew");
    }
}