Skip to main content

zlayer_toolchain/
brew_emulate.rs

1//! Brew-emulate fallback: build a homebrew-core formula with **real Homebrew
2//! installed at the keg prefix**, for the macOS long tail the generic
3//! [`crate::source_build`] recipe runner cannot reproduce.
4//!
5//! # When this runs
6//!
7//! [`crate::source_build::ensure_from_source`] handles the homebrew-core C-tool
8//! population with a generic autotools/CMake/Makefile runner. Some formulae have
9//! custom `.rb` `install do` logic (compile a single file by hand, run a
10//! bespoke `install.sh`, `cargo install` / `go build`, apply `patches`, …) that
11//! a generic build-system detector cannot reproduce — for those the generic
12//! runner either fails detection (no `configure`/`CMakeLists.txt`/`Makefile`) or
13//! errors mid-build. This module is the fallback: it runs the formula's *actual*
14//! Homebrew install recipe, so whatever custom logic the formula carries is
15//! executed faithfully.
16//!
17//! # Why install Homebrew AT the keg prefix
18//!
19//! The keg must be **self-contained and relocation-free** — no `@@HOMEBREW@@`
20//! placeholders in any binary's load commands (those abort under a darwin
21//! Seatbelt container, see [`crate::source_build`] module docs). Two properties
22//! get us there:
23//!
24//! 1. **A non-default prefix forces build-from-source.** We additionally pass
25//!    `--build-from-source`, so Homebrew compiles the named formula instead of
26//!    pouring a relocatable bottle (a poured bottle is exactly where the
27//!    `@@HOMEBREW@@` install-name placeholders come from). A compiled binary
28//!    bakes the *absolute* prefix path into its `LC_LOAD_DYLIB` / rpath load
29//!    commands.
30//! 2. **The prefix lives inside the keg, permanently.** We clone Homebrew into
31//!    `<keg>/brew` and point `HOMEBREW_PREFIX`/`HOMEBREW_CELLAR`/
32//!    `HOMEBREW_REPOSITORY` there, so those baked-in absolute paths
33//!    (`<keg>/brew/opt/<dep>/lib/...`) remain valid for the life of the keg —
34//!    no post-install relocation, ever.
35//!
36//! The resulting keg has the standard layout the resolver expects (a `bin` of
37//! the installed formula's executables, reached via the Homebrew prefix's
38//! `opt/<formula>/bin` + `bin` symlink dirs) plus a [`KegManifest`].
39
40use std::path::{Path, PathBuf};
41
42use tracing::{info, warn};
43
44use crate::error::{Result, ToolchainError};
45use crate::manifest::{KegManifest, KegSource};
46use crate::source_build::SourceSpec;
47
48/// Upstream Homebrew git repository (cloned shallow into the keg prefix).
49const HOMEBREW_REPO_URL: &str = "https://github.com/Homebrew/brew";
50
51/// Host architecture token used in cache keys (`arm64` / `x86_64`).
52fn arch_token() -> &'static str {
53    match std::env::consts::ARCH {
54        "aarch64" => "arm64",
55        other => other,
56    }
57}
58
59/// Build `formula` into a self-contained keg by running its *real* Homebrew
60/// install recipe at a keg-rooted prefix, returning the keg path.
61///
62/// The keg lives at `<cache_dir>/<formula>-<version>-<arch>/` (identical to the
63/// source-build layout, so the cache key, `.ready` marker, and
64/// [`crate::probe_ready_toolchain`] all behave the same). The Homebrew checkout
65/// lands at `<keg>/brew` and is reused as the prefix; the formula is installed
66/// with `--build-from-source` so no `@@HOMEBREW@@` placeholder ever reaches a
67/// binary's load commands.
68///
69/// # Errors
70///
71/// Returns [`ToolchainError::RegistryError`] if Homebrew cannot be provisioned
72/// at the prefix or the `brew install` fails, or an I/O error on a filesystem
73/// failure.
74pub async fn ensure_via_brew(
75    formula: &str,
76    spec: &SourceSpec,
77    cache_dir: &Path,
78) -> Result<PathBuf> {
79    let keg = cache_dir.join(format!("{formula}-{}-{}", spec.version, arch_token()));
80    let ready_marker = keg.join(".ready");
81    if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
82        return Ok(keg);
83    }
84
85    // Fresh build — clear any partial keg from a crashed prior attempt.
86    let _ = tokio::fs::remove_dir_all(&keg).await;
87    tokio::fs::create_dir_all(&keg).await?;
88
89    let brew_prefix = keg.join("brew");
90    provision_brew_at_prefix(&brew_prefix).await?;
91
92    // Shared download cache so a repeated fallback reuses fetched bottles/source.
93    let brew_cache = cache_dir.join(".brew-cache");
94    tokio::fs::create_dir_all(&brew_cache).await?;
95
96    run_brew_install(formula, &brew_prefix, &brew_cache).await?;
97
98    // The formula's executables are reached through the prefix's `opt/<formula>`
99    // symlink (canonical). Prefer ONLY that per-formula bin: the shared prefix
100    // `bin` aggregates every dependency's shims PLUS a `brew` wrapper, and since
101    // the slim step below deletes `Library/` (Homebrew's Ruby) that `brew` shim
102    // is DEAD -- yet prepending prefix `bin` to PATH would shadow the host's real
103    // `/opt/homebrew/bin/brew`, so a job's `brew install <x>` hits the dead shim
104    // and dies `brew.sh: No such file or directory` (exit 127). Expose only
105    // `opt/<formula>/bin`; fall back to the prefix `bin` ONLY when the formula has
106    // no opt bin (rare), so no keg is ever left with an empty PATH.
107    let mut path_dirs = Vec::new();
108    let opt_bin = brew_prefix.join("opt").join(formula).join("bin");
109    if tokio::fs::try_exists(&opt_bin).await.unwrap_or(false) {
110        path_dirs.push(opt_bin.display().to_string());
111    } else {
112        let prefix_bin = brew_prefix.join("bin");
113        if tokio::fs::try_exists(&prefix_bin).await.unwrap_or(false) {
114            path_dirs.push(prefix_bin.display().to_string());
115        }
116    }
117    if path_dirs.is_empty() {
118        return Err(ToolchainError::RegistryError {
119            message: format!(
120                "brew-emulate install of {formula} produced no bin dir under {}",
121                brew_prefix.display()
122            ),
123        });
124    }
125
126    // Defensive guard: the whole point is no `@@HOMEBREW@@` placeholder survives.
127    // `--build-from-source` guarantees this for the named formula; assert it so a
128    // regression (e.g. brew silently pouring a bottle) is caught at provision
129    // time rather than as an Abort trap inside the sandbox.
130    if let Some(offending) = scan_for_homebrew_placeholder(&opt_bin).await {
131        return Err(ToolchainError::RegistryError {
132            message: format!(
133                "brew-emulate {formula}: binary {} still carries an @@HOMEBREW@@ load \
134                 command (bottle poured instead of built from source?)",
135                offending.display()
136            ),
137        });
138    }
139
140    // Slim the keg: the compiled binaries never reference Homebrew's own Ruby
141    // code or git history at runtime, so drop them. The Cellar + opt + bin +
142    // lib trees (which the load commands DO reference) are left intact.
143    for slim in [".git", "Library", "docs", "completions", "manpages"] {
144        let _ = tokio::fs::remove_dir_all(brew_prefix.join(slim)).await;
145    }
146    // The `brew` CLI shim in the prefix `bin` is dead once `Library/` (its Ruby)
147    // is gone; drop it so it can never shadow the host `brew` if the prefix `bin`
148    // is ever surfaced.
149    let _ = tokio::fs::remove_file(brew_prefix.join("bin").join("brew")).await;
150
151    let manifest = KegManifest {
152        tool: formula.to_string(),
153        version: spec.version.clone(),
154        arch: arch_token().to_string(),
155        platform: "macos".to_string(),
156        path_dirs,
157        env: std::collections::HashMap::new(),
158        source: KegSource::SourceBuild {
159            url: spec.tarball_url.clone(),
160            sha256: spec.sha256.clone(),
161        },
162        build_deps: spec.build_dependencies.clone(),
163        provisioned_at: chrono::Utc::now().to_rfc3339(),
164    };
165    manifest.write_to_keg(&keg).await?;
166    tokio::fs::write(&ready_marker, b"").await?;
167
168    info!(formula, keg = %keg.display(), "brew-emulate fallback produced a self-contained keg");
169    Ok(keg)
170}
171
172/// Provision a throwaway Homebrew checkout at `brew_prefix`.
173///
174/// Prefers a shallow `git clone` (the host CLT git, unsandboxed — this is
175/// provision time, not the sandboxed runtime); falls back to the GitHub source
176/// tarball when git is unavailable. A real git checkout is preferred because
177/// `brew` is happier inside a git repository.
178async fn provision_brew_at_prefix(brew_prefix: &Path) -> Result<()> {
179    if tokio::fs::try_exists(brew_prefix.join("bin/brew"))
180        .await
181        .unwrap_or(false)
182    {
183        return Ok(());
184    }
185    if let Some(parent) = brew_prefix.parent() {
186        tokio::fs::create_dir_all(parent).await?;
187    }
188
189    // Try a shallow clone first.
190    let clone = tokio::process::Command::new("git")
191        .arg("clone")
192        .arg("--depth=1")
193        .arg(HOMEBREW_REPO_URL)
194        .arg(brew_prefix)
195        .output()
196        .await;
197    if let Ok(out) = clone {
198        if out.status.success()
199            && tokio::fs::try_exists(brew_prefix.join("bin/brew"))
200                .await
201                .unwrap_or(false)
202        {
203            return Ok(());
204        }
205        warn!(
206            "git clone of Homebrew failed ({}); falling back to source tarball",
207            String::from_utf8_lossy(&out.stderr).trim()
208        );
209    }
210
211    // Fallback: download + extract the master tarball.
212    let tarball = "https://github.com/Homebrew/brew/archive/refs/heads/master.tar.gz";
213    let bytes = reqwest::get(tarball)
214        .await
215        .map_err(|e| ToolchainError::RegistryError {
216            message: format!("failed to download Homebrew tarball: {e}"),
217        })?
218        .bytes()
219        .await
220        .map_err(|e| ToolchainError::RegistryError {
221            message: format!("failed to read Homebrew tarball bytes: {e}"),
222        })?;
223    let tmp = brew_prefix.with_extension("tar.gz");
224    tokio::fs::write(&tmp, &bytes).await?;
225    tokio::fs::create_dir_all(brew_prefix).await?;
226    let untar = tokio::process::Command::new("tar")
227        .arg("xf")
228        .arg(&tmp)
229        .args(["--strip-components", "1", "-C"])
230        .arg(brew_prefix)
231        .output()
232        .await?;
233    let _ = tokio::fs::remove_file(&tmp).await;
234    if !untar.status.success() {
235        return Err(ToolchainError::RegistryError {
236            message: format!(
237                "failed to extract Homebrew tarball: {}",
238                String::from_utf8_lossy(&untar.stderr)
239            ),
240        });
241    }
242    if !tokio::fs::try_exists(brew_prefix.join("bin/brew"))
243        .await
244        .unwrap_or(false)
245    {
246        return Err(ToolchainError::RegistryError {
247            message: format!(
248                "Homebrew checkout at {} has no bin/brew",
249                brew_prefix.display()
250            ),
251        });
252    }
253    Ok(())
254}
255
256/// Run `brew install --build-from-source <formula>` with the prefix env pointed
257/// at the keg-rooted Homebrew. Inherits the host environment (so the host CLT
258/// `clang`/`git`/`curl` resolve) and overrides the Homebrew prefix variables.
259async fn run_brew_install(formula: &str, brew_prefix: &Path, brew_cache: &Path) -> Result<()> {
260    let brew_bin = brew_prefix.join("bin/brew");
261    let prefix_str = brew_prefix.display().to_string();
262
263    // PATH: prefix bin first, then host PATH, then the system fallback.
264    let host_path = std::env::var("PATH").unwrap_or_default();
265    let mut path_parts = vec![brew_prefix.join("bin").display().to_string()];
266    if !host_path.is_empty() {
267        path_parts.push(host_path);
268    }
269    path_parts.push("/usr/bin:/bin:/usr/sbin:/sbin".to_string());
270
271    info!(formula, prefix = %prefix_str, "running brew install --build-from-source");
272    let out = tokio::process::Command::new(&brew_bin)
273        .arg("install")
274        .arg("--build-from-source")
275        .arg(formula)
276        .env("PATH", path_parts.join(":"))
277        .env("HOMEBREW_PREFIX", &prefix_str)
278        .env("HOMEBREW_REPOSITORY", &prefix_str)
279        .env("HOMEBREW_CELLAR", brew_prefix.join("Cellar"))
280        .env("HOMEBREW_CACHE", brew_cache)
281        .env("HOMEBREW_NO_AUTO_UPDATE", "1")
282        .env("HOMEBREW_NO_ANALYTICS", "1")
283        .env("HOMEBREW_NO_ENV_HINTS", "1")
284        .env("HOMEBREW_NO_INSTALL_CLEANUP", "1")
285        .output()
286        .await?;
287
288    if !out.status.success() {
289        let tail = String::from_utf8_lossy(&out.stderr)
290            .lines()
291            .rev()
292            .take(30)
293            .collect::<Vec<_>>()
294            .into_iter()
295            .rev()
296            .collect::<Vec<_>>()
297            .join("\n");
298        return Err(ToolchainError::RegistryError {
299            message: format!("brew install --build-from-source {formula} failed:\n{tail}"),
300        });
301    }
302    Ok(())
303}
304
305/// Scan every regular file under `dir` for the `@@HOMEBREW@@` byte sequence in a
306/// binary's load commands. Returns the first offending path, or `None` when the
307/// tree is clean. Best-effort and shallow-recursive; a missing dir is clean.
308async fn scan_for_homebrew_placeholder(dir: &Path) -> Option<PathBuf> {
309    let mut stack = vec![dir.to_path_buf()];
310    while let Some(d) = stack.pop() {
311        let mut entries = tokio::fs::read_dir(&d).await.ok()?;
312        while let Ok(Some(entry)) = entries.next_entry().await {
313            let path = entry.path();
314            let ft = entry.file_type().await.ok()?;
315            if ft.is_dir() {
316                stack.push(path);
317            } else if ft.is_file() {
318                if let Ok(bytes) = tokio::fs::read(&path).await {
319                    if contains_subslice(&bytes, b"@@HOMEBREW") {
320                        return Some(path);
321                    }
322                }
323            }
324        }
325    }
326    None
327}
328
329/// Tiny byte-substring helper for the placeholder scan.
330fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
331    if needle.is_empty() || haystack.len() < needle.len() {
332        return false;
333    }
334    haystack.windows(needle.len()).any(|w| w == needle)
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn placeholder_scan_helper_matches() {
343        assert!(contains_subslice(b"abc@@HOMEBREW@@/lib", b"@@HOMEBREW"));
344        assert!(!contains_subslice(
345            b"/usr/lib/libSystem.dylib",
346            b"@@HOMEBREW"
347        ));
348        assert!(!contains_subslice(b"", b"@@HOMEBREW"));
349    }
350
351    #[tokio::test]
352    async fn placeholder_scan_finds_offender_and_clean_is_none() {
353        let tmp = tempfile::tempdir().unwrap();
354        let sub = tmp.path().join("bin");
355        tokio::fs::create_dir_all(&sub).await.unwrap();
356        tokio::fs::write(sub.join("clean"), b"/usr/lib/libSystem.B.dylib")
357            .await
358            .unwrap();
359        assert!(scan_for_homebrew_placeholder(tmp.path()).await.is_none());
360
361        tokio::fs::write(
362            sub.join("dirty"),
363            b"@@HOMEBREW_PREFIX@@/opt/x/lib/libx.dylib",
364        )
365        .await
366        .unwrap();
367        let hit = scan_for_homebrew_placeholder(tmp.path()).await;
368        assert!(hit.is_some());
369        assert!(hit.unwrap().ends_with("dirty"));
370    }
371
372    #[tokio::test]
373    async fn ensure_via_brew_short_circuits_on_ready_keg() {
374        let tmp = tempfile::tempdir().unwrap();
375        let spec = SourceSpec {
376            version: "1.2.3".to_string(),
377            tarball_url: "https://example/x.tar.gz".to_string(),
378            sha256: String::new(),
379            dependencies: vec![],
380            build_dependencies: vec![],
381            macos_provided: vec![],
382        };
383        let keg = tmp.path().join(format!("demo-1.2.3-{}", arch_token()));
384        tokio::fs::create_dir_all(&keg).await.unwrap();
385        tokio::fs::write(keg.join(".ready"), b"").await.unwrap();
386
387        let got = ensure_via_brew("demo", &spec, tmp.path()).await.unwrap();
388        assert_eq!(got, keg, "a ready keg short-circuits without invoking brew");
389    }
390}