Skip to main content

zlayer_toolchain/
lib.rs

1//! `zlayer-toolchain` — reusable runtime toolchain provisioning.
2//!
3//! This is a **leaf** crate: it depends only on other leaf crates
4//! (`zlayer-paths`, `zlayer-registry`, `zlayer-types`) and external crates. It
5//! depends on neither `zlayer-agent` nor `zlayer-builder`. It exists to break
6//! the `zlayer-builder` -> `zlayer-agent` build cycle: the macOS sandboxes
7//! (Seatbelt / HCS) have no package manager, so this crate is "our apt-get" —
8//! it provisions a named tool into a self-contained, absolute-prefix **keg** and
9//! returns a [`ToolchainHandle`] describing how to run it.
10//!
11//! # Provisioning strategy (macOS)
12//!
13//! A keg is produced one of two ways, both relocation-free (no `@@HOMEBREW@@`):
14//! - **Source build** ([`source_build`]): fetch the Homebrew formula's
15//!   `urls.stable.url` source tarball and build it at an absolute keg prefix
16//!   with the host Command Line Tools (the homebrew-core C-tool population:
17//!   git, jq, cmake, ...).
18//! - **Prebuilt fetch** ([`prebuilt`]): land a self-contained vendor archive
19//!   for the language toolchains (go/node/rust/...).
20//!
21//! Every keg carries a [`manifest::KegManifest`] (`toolchain.json`) describing
22//! its `path_dirs` + `env`, so the resolver is generic — no tool is special-
23//! cased on the handle path.
24//!
25//! # Surface
26//!
27//! - [`ensure_toolchain`] — provision a named tool and return a
28//!   [`ToolchainHandle`].
29//! - [`probe_ready_toolchain`] — non-blocking, filesystem-only `.ready` probe
30//!   that reconstructs a handle from an already-provisioned keg.
31//!
32//! The old Homebrew **bottle** resolver/installer (download a prebuilt bottle
33//! and rewrite its `@@HOMEBREW@@` install-name placeholders) has been removed
34//! entirely — see the module docs on [`source_build`] for why that path was a
35//! dead end under Seatbelt.
36
37use std::collections::HashMap;
38use std::path::{Path, PathBuf};
39
40pub mod brew_emulate;
41pub mod error;
42pub mod formula;
43pub mod lockfile;
44pub mod manifest;
45pub mod package_index;
46pub mod prebuilt;
47pub mod source_build;
48pub mod windows;
49
50pub use error::{Result, ToolchainError};
51pub use lockfile::{LockedTool, ToolchainLockfile, ToolchainLockfileExt, LOCKFILE_NAME};
52
53/// Target platform for a toolchain provisioning request.
54///
55/// Both platforms provision into the same keg format. macOS goes through
56/// source-build / prebuilt-fetch / brew-emulate ([`ensure_macos_keg`]); Windows
57/// goes through MinGit / portable-artifact extraction ([`windows`]). A Windows
58/// formula with no relocatable portable artifact returns
59/// [`ToolchainError::NotImplemented`] so the caller routes it to the HCS
60/// choco-capture path in the runtime layer.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ToolPlatform {
63    /// macOS host — provision via source build / prebuilt fetch into a keg.
64    MacOS,
65    /// Windows host — provision via MinGit / portable artifact into a keg.
66    Windows,
67}
68
69/// A provisioned toolchain: where it lives and how to run it.
70///
71/// `path_dirs` are absolute directories to prepend to `PATH`; `env` are extra
72/// environment variables the caller should set so the tool resolves its own
73/// libraries / exec helpers out of the keg instead of the host. Both are a
74/// direct projection of the keg's [`manifest::KegManifest`].
75#[derive(Debug, Clone)]
76pub struct ToolchainHandle {
77    /// Root directory of the provisioned keg (the cache key dir).
78    pub install_dir: PathBuf,
79    /// Absolute directories to prepend to `PATH`.
80    pub path_dirs: Vec<String>,
81    /// Extra environment variables for running the tool.
82    pub env: HashMap<String, String>,
83}
84
85/// Host architecture token used in cache keys (`arm64` / `x86_64`).
86fn arch_token() -> &'static str {
87    match std::env::consts::ARCH {
88        "aarch64" => "arm64",
89        other => other,
90    }
91}
92
93/// Split a package request into `(formula, version_token)`.
94///
95/// `git` -> `("git", "latest")`; `openssl@3` -> `("openssl@3", "3")`. The full
96/// `pkg` is always the brew formula name (brew versioned formulae keep their
97/// `@major` suffix); the version token only feeds the cache key.
98fn split_pkg(pkg: &str) -> (&str, &str) {
99    match pkg.split_once('@') {
100        Some((_, ver)) if !ver.is_empty() => (pkg, ver),
101        _ => (pkg, "latest"),
102    }
103}
104
105/// Ensure a runtime tool is provisioned and return a handle describing how to
106/// run it.
107///
108/// The install is idempotent: the keg lives at
109/// `{cache_dir}/{formula}-{version}-{arch}` and is guarded by a `.ready` marker
110/// (written after the [`manifest::KegManifest`]), so a populated cache
111/// short-circuits without any network or build work.
112///
113/// # Errors
114///
115/// Returns [`ToolchainError::NotImplemented`] for a Windows formula with no
116/// portable artifact (the caller routes those to the HCS choco-capture path).
117/// Propagates formula-resolution, download, dependency and build errors.
118/// When `lockfile` is `Some`, a lock hit for `(pkg, platform, arch)` pins the
119/// exact version + URL and the download is verified against the pinned sha256
120/// ("consume-only": this crate never *writes* the lock — the CLI does). A lock
121/// miss live-resolves and records the resolved digest in the keg manifest.
122pub async fn ensure_toolchain(
123    pkg: &str,
124    platform: ToolPlatform,
125    cache_dir: &Path,
126    lockfile: Option<&ToolchainLockfile>,
127) -> Result<ToolchainHandle> {
128    match platform {
129        ToolPlatform::Windows => {
130            let keg = windows::ensure_windows_keg(pkg, cache_dir, lockfile).await?;
131            build_handle_from_keg(keg).await
132        }
133        ToolPlatform::MacOS => {
134            let keg = ensure_macos_keg(pkg, cache_dir, lockfile).await?;
135            build_handle_from_keg(keg).await
136        }
137    }
138}
139
140/// Provision (or reuse) a macOS keg for `pkg`, returning the keg directory.
141///
142/// Dispatches generically: language toolchains land via the relocation-free
143/// prebuilt fetcher; everything else is built from source. Both write a
144/// [`manifest::KegManifest`] and converge on the same cache + keg layout. This
145/// is the crate-internal entry point that [`source_build`] also re-enters to
146/// resolve build dependencies recursively.
147pub(crate) async fn ensure_macos_keg(
148    pkg: &str,
149    cache_dir: &Path,
150    lockfile: Option<&ToolchainLockfile>,
151) -> Result<PathBuf> {
152    let (formula, _version) = split_pkg(pkg);
153    if prebuilt::is_prebuilt_formula(formula) {
154        // Language toolchains (go/node/rust/...) land as relocation-free
155        // self-contained vendor archives.
156        prebuilt::ensure_prebuilt(formula, cache_dir, lockfile).await
157    } else {
158        // The homebrew-core C-tool population (git/jq/cmake/...) is built from
159        // source at an absolute keg prefix.
160        source_build::ensure_from_source(formula, cache_dir, lockfile).await
161    }
162}
163
164/// Map a keg arch token (`arm64` / `x86_64`) to the vendor-download arch token
165/// (`arm64` / `amd64`) the prebuilt resolvers expect.
166fn vendor_arch(arch: &str) -> &str {
167    if arch == "x86_64" {
168        "amd64"
169    } else {
170        arch
171    }
172}
173
174/// The lowercase platform token stored in a manifest / lock entry.
175fn platform_token(platform: ToolPlatform) -> &'static str {
176    match platform {
177        ToolPlatform::MacOS => "macos",
178        ToolPlatform::Windows => "windows",
179    }
180}
181
182/// Sanitize a tool token for use inside a temp-file name.
183fn sanitize(tool: &str) -> String {
184    tool.chars()
185        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
186        .collect()
187}
188
189/// Resolve a tool to a fully-pinned [`LockedTool`] by live-resolving its exact
190/// version + download URL, streaming the artifact to a temp file, and hashing
191/// the bytes (verifying against the upstream-published digest when one exists).
192///
193/// This is the resolution path the CLI's `zlayer toolchains lock` reuses, so the
194/// lock writer never duplicates resolver logic. `arch` is the keg arch token
195/// (`arm64` / `x86_64`); the vendor-arch mapping is internal.
196///
197/// # Errors
198///
199/// Propagates resolution / download / digest-verification failures.
200pub async fn resolve_locked_tool(
201    tool: &str,
202    platform: ToolPlatform,
203    arch: &str,
204) -> Result<LockedTool> {
205    let (formula, _version) = split_pkg(tool);
206    let (version, url, expected): (String, String, Option<String>) = match platform {
207        ToolPlatform::MacOS => {
208            if prebuilt::is_prebuilt_formula(formula) {
209                let r = prebuilt::resolve_prebuilt(formula, vendor_arch(arch)).await?;
210                (r.version, r.url, r.sha256)
211            } else {
212                let spec = source_build::resolve_source_spec(formula).await?;
213                let sha = (!spec.sha256.is_empty()).then_some(spec.sha256);
214                (spec.version, spec.tarball_url, sha)
215            }
216        }
217        ToolPlatform::Windows => windows::resolve_locked_windows(formula).await?,
218    };
219
220    // Stream to a temp file to compute (and, when published, verify) the digest.
221    let tmp = std::env::temp_dir().join(format!(
222        "zlayer-lock-{}-{arch}-{}",
223        sanitize(tool),
224        std::process::id()
225    ));
226    let sha256 = package_index::download_verified(&url, &tmp, expected.as_deref()).await?;
227    let _ = tokio::fs::remove_file(&tmp).await;
228
229    Ok(LockedTool {
230        tool: tool.to_string(),
231        platform: platform_token(platform).to_string(),
232        arch: arch.to_string(),
233        version,
234        url,
235        sha256,
236        resolved_at: chrono::Utc::now().to_rfc3339(),
237    })
238}
239
240/// Reconstruct a [`ToolchainHandle`] from a provisioned keg by reading (or, for
241/// a pre-manifest keg, synthesizing) its [`manifest::KegManifest`].
242async fn build_handle_from_keg(keg: PathBuf) -> Result<ToolchainHandle> {
243    let manifest = manifest::KegManifest::load_or_synthesize(&keg).await?;
244    Ok(ToolchainHandle {
245        install_dir: keg,
246        path_dirs: manifest.path_dirs,
247        env: manifest.env,
248    })
249}
250
251/// Non-blocking, filesystem-only probe for an already-provisioned keg.
252///
253/// Returns `Some(handle)` iff a `.ready`-stamped keg for `(pkg, platform,
254/// cache_dir)` exists on disk; otherwise `None`. Does **no** network I/O, no
255/// build, and no mutation — it reconstructs the [`ToolchainHandle`] from the
256/// keg's manifest exactly as the fast path of [`ensure_toolchain`] does.
257///
258/// It exists so a caller that provisions in the background (and therefore can't
259/// `await` the cold install on its hot path) can still inject a keg already on
260/// disk — e.g. from a previous daemon process. Returns `None` for any non-macOS
261/// platform or when no ready keg is present.
262pub async fn probe_ready_toolchain(
263    pkg: &str,
264    _platform: ToolPlatform,
265    cache_dir: &Path,
266) -> Option<ToolchainHandle> {
267    // The keg layout + cache key are platform-agnostic, so the probe is too: it
268    // just looks for a `.ready`-stamped `<formula>-<ver>-<arch>` keg on disk.
269    let (formula, _version) = split_pkg(pkg);
270    let keg = newest_ready_keg(formula, cache_dir).await?;
271    build_handle_from_keg(keg).await.ok()
272}
273
274/// Find the newest `{formula}-<ver>-<arch>` keg under `cache_dir` that is
275/// stamped `.ready` (mtime-ordered). Returns `None` for a cold/partial cache.
276async fn newest_ready_keg(formula: &str, cache_dir: &Path) -> Option<PathBuf> {
277    let prefix = format!("{formula}-");
278    let arch_suffix = format!("-{}", arch_token());
279    let mut entries = tokio::fs::read_dir(cache_dir).await.ok()?;
280    let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
281    while let Ok(Some(entry)) = entries.next_entry().await {
282        let name = entry.file_name();
283        let Some(name) = name.to_str() else { continue };
284        if !name.starts_with(&prefix) || !name.ends_with(&arch_suffix) {
285            continue;
286        }
287        let keg = entry.path();
288        if !tokio::fs::try_exists(keg.join(".ready"))
289            .await
290            .unwrap_or(false)
291        {
292            continue;
293        }
294        let mtime = entry
295            .metadata()
296            .await
297            .ok()
298            .and_then(|m| m.modified().ok())
299            .unwrap_or(std::time::UNIX_EPOCH);
300        if best.as_ref().is_none_or(|(t, _)| mtime >= *t) {
301            best = Some((mtime, keg));
302        }
303    }
304    best.map(|(_, keg)| keg)
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::manifest::{KegManifest, KegSource};
311
312    #[test]
313    fn split_pkg_plain_defaults_to_latest() {
314        assert_eq!(split_pkg("git"), ("git", "latest"));
315    }
316
317    #[test]
318    fn split_pkg_versioned_keeps_full_formula() {
319        assert_eq!(split_pkg("openssl@3"), ("openssl@3", "3"));
320    }
321
322    #[test]
323    fn split_pkg_trailing_at_is_latest() {
324        assert_eq!(split_pkg("weird@"), ("weird@", "latest"));
325    }
326
327    /// A Windows formula with no portable artifact returns `NotImplemented`
328    /// (routed to the HCS choco-capture path). `git` IS implemented on Windows
329    /// (MinGit) so it is NOT used here — that path is network-bound and lives in
330    /// the `windows` module's own tests.
331    #[tokio::test]
332    async fn windows_non_portable_formula_is_not_implemented() {
333        let tmp = tempfile::tempdir().unwrap();
334        let err = ensure_toolchain("cowsay", ToolPlatform::Windows, tmp.path(), None)
335            .await
336            .unwrap_err();
337        assert!(matches!(err, ToolchainError::NotImplemented(_)));
338    }
339
340    /// Lay down a source-built `git` keg WITHOUT a manifest (the pre-manifest
341    /// layout) so the offline tests exercise the backward-compatible synthesis
342    /// path of [`build_handle_from_keg`].
343    async fn seed_legacy_git_keg(cache_dir: &Path, version: &str) -> PathBuf {
344        let keg = cache_dir.join(format!("git-{version}-{}", arch_token()));
345        tokio::fs::create_dir_all(keg.join("bin")).await.unwrap();
346        tokio::fs::create_dir_all(keg.join("libexec/git-core"))
347            .await
348            .unwrap();
349        tokio::fs::create_dir_all(keg.join("etc")).await.unwrap();
350        tokio::fs::write(keg.join("etc/gitconfig"), b"")
351            .await
352            .unwrap();
353        tokio::fs::write(keg.join(".ready"), b"").await.unwrap();
354        keg
355    }
356
357    /// Lay down a keg WITH a `toolchain.json` manifest so the resolver's generic
358    /// (non-synthesized) path is covered.
359    async fn seed_keg_with_manifest(cache_dir: &Path, tool: &str, version: &str) -> PathBuf {
360        let keg = cache_dir.join(format!("{tool}-{version}-{}", arch_token()));
361        let bin = keg.join("bin");
362        tokio::fs::create_dir_all(&bin).await.unwrap();
363        let mut env = HashMap::new();
364        env.insert("FOO".to_string(), "bar".to_string());
365        let manifest = KegManifest {
366            tool: tool.to_string(),
367            version: version.to_string(),
368            arch: arch_token().to_string(),
369            platform: "macos".to_string(),
370            path_dirs: vec![bin.display().to_string()],
371            env,
372            source: KegSource::SourceBuild {
373                url: String::new(),
374                sha256: String::new(),
375            },
376            build_deps: vec![],
377            provisioned_at: "2026-06-30T00:00:00Z".to_string(),
378        };
379        manifest.write_to_keg(&keg).await.unwrap();
380        tokio::fs::write(keg.join(".ready"), b"").await.unwrap();
381        keg
382    }
383
384    /// A pre-manifest `git` keg synthesizes a handle whose env is ONLY
385    /// `GIT_EXEC_PATH` (→ `<keg>/libexec/git-core`), with NO
386    /// `DYLD_FALLBACK_LIBRARY_PATH` and NO `GIT_CONFIG_SYSTEM`.
387    #[tokio::test]
388    async fn handle_synthesized_for_legacy_git_keg_drops_dyld() {
389        let tmp = tempfile::tempdir().unwrap();
390        let keg = seed_legacy_git_keg(tmp.path(), "2.55.0").await;
391
392        let handle = build_handle_from_keg(keg.clone()).await.unwrap();
393
394        assert_eq!(handle.install_dir, keg);
395        assert_eq!(
396            handle.path_dirs,
397            vec![keg.join("bin").display().to_string()]
398        );
399        assert_eq!(
400            handle.env.get("GIT_EXEC_PATH"),
401            Some(&keg.join("libexec/git-core").display().to_string())
402        );
403        assert!(!handle.env.contains_key("DYLD_FALLBACK_LIBRARY_PATH"));
404        assert!(!handle.env.contains_key("GIT_CONFIG_SYSTEM"));
405    }
406
407    /// A keg WITH a manifest projects the manifest's `path_dirs` + `env`
408    /// verbatim onto the handle.
409    #[tokio::test]
410    async fn handle_reads_manifest_when_present() {
411        let tmp = tempfile::tempdir().unwrap();
412        let keg = seed_keg_with_manifest(tmp.path(), "jq", "1.8.2").await;
413
414        let handle = build_handle_from_keg(keg.clone()).await.unwrap();
415        assert_eq!(handle.install_dir, keg);
416        assert_eq!(
417            handle.path_dirs,
418            vec![keg.join("bin").display().to_string()]
419        );
420        assert_eq!(handle.env.get("FOO"), Some(&"bar".to_string()));
421    }
422
423    /// The on-disk fallback the runtime relies on: a `.ready` keg is probed
424    /// (no install) and reconstructs the SAME handle as the fast path.
425    #[tokio::test]
426    async fn probe_ready_returns_handle_for_ready_keg() {
427        let tmp = tempfile::tempdir().unwrap();
428        let keg = seed_legacy_git_keg(tmp.path(), "2.55.0").await;
429
430        let handle = probe_ready_toolchain("git", ToolPlatform::MacOS, tmp.path())
431            .await
432            .expect("ready keg should be probed without install");
433
434        assert_eq!(handle.install_dir, keg);
435        assert_eq!(
436            handle.env.get("GIT_EXEC_PATH"),
437            Some(&keg.join("libexec/git-core").display().to_string())
438        );
439    }
440
441    /// Probing a different tool's keg works generically (not git-special).
442    #[tokio::test]
443    async fn probe_ready_is_generic_across_tools() {
444        let tmp = tempfile::tempdir().unwrap();
445        let keg = seed_keg_with_manifest(tmp.path(), "jq", "1.8.2").await;
446
447        let handle = probe_ready_toolchain("jq", ToolPlatform::MacOS, tmp.path())
448            .await
449            .expect("ready jq keg should be probed");
450        assert_eq!(handle.install_dir, keg);
451        assert_eq!(handle.env.get("FOO"), Some(&"bar".to_string()));
452    }
453
454    /// A keg dir that exists but is NOT stamped `.ready` (mid-build) must probe
455    /// to `None` — never inject a partial keg.
456    #[tokio::test]
457    async fn probe_ready_returns_none_without_ready_marker() {
458        let tmp = tempfile::tempdir().unwrap();
459        let keg = tmp.path().join(format!("git-2.55.0-{}", arch_token()));
460        tokio::fs::create_dir_all(keg.join("bin")).await.unwrap();
461
462        assert!(
463            probe_ready_toolchain("git", ToolPlatform::MacOS, tmp.path())
464                .await
465                .is_none(),
466            "an unstamped keg must not be injected"
467        );
468    }
469
470    /// Cold cache, an absent tool, and non-macOS requests all probe to `None`.
471    #[tokio::test]
472    async fn probe_ready_returns_none_for_cold_or_unsupported() {
473        let tmp = tempfile::tempdir().unwrap();
474        assert!(
475            probe_ready_toolchain("git", ToolPlatform::MacOS, tmp.path())
476                .await
477                .is_none(),
478            "cold cache should probe None"
479        );
480        assert!(
481            probe_ready_toolchain("jq", ToolPlatform::MacOS, tmp.path())
482                .await
483                .is_none(),
484            "absent tool should probe None"
485        );
486        assert!(
487            probe_ready_toolchain("git", ToolPlatform::Windows, tmp.path())
488                .await
489                .is_none(),
490            "cold cache probes None on every platform"
491        );
492    }
493
494    /// Full end-to-end provision of BOTH verification targets: build `git` and
495    /// `jq` FROM SOURCE and run them. Asserts each built binary carries NO
496    /// `@@HOMEBREW@@` placeholder (the bottle-relocation failure mode) and that
497    /// a manifest was written. `#[ignore]` because it hits the network + the
498    /// compiler and only works on macOS with CLT; run with:
499    ///   `cargo test -p zlayer-toolchain -- --ignored`.
500    #[tokio::test]
501    #[ignore = "live build test; fetches + compiles git and jq from source (macOS + CLT only)"]
502    async fn ensure_git_and_jq_build_from_source_and_run() {
503        let tmp = tempfile::tempdir().unwrap();
504
505        for (tool, version_needle) in [("git", "git version"), ("jq", "jq-")] {
506            let handle = ensure_toolchain(tool, ToolPlatform::MacOS, tmp.path(), None)
507                .await
508                .unwrap_or_else(|e| panic!("{tool} toolchain should build from source: {e}"));
509
510            let bin = handle.install_dir.join("bin").join(tool);
511            assert!(
512                bin.exists(),
513                "{tool} binary should exist at <keg>/bin/{tool}"
514            );
515
516            // The keg must carry a manifest.
517            let manifest = KegManifest::read_from_keg(&handle.install_dir)
518                .await
519                .unwrap()
520                .unwrap_or_else(|| panic!("{tool} keg must have a manifest"));
521            assert_eq!(manifest.tool, tool);
522
523            // No @@HOMEBREW@@ anywhere in the built binary.
524            let bytes = tokio::fs::read(&bin).await.expect("read binary");
525            assert!(
526                !contains_subslice_bytes(&bytes, b"@@HOMEBREW"),
527                "source-built {tool} must contain NO @@HOMEBREW@@ references"
528            );
529
530            let mut cmd = tokio::process::Command::new(&bin);
531            for (k, v) in &handle.env {
532                cmd.env(k, v);
533            }
534            let out = cmd.arg("--version").output().await.expect("run --version");
535            assert!(out.status.success(), "{tool} --version should succeed");
536            assert!(
537                String::from_utf8_lossy(&out.stdout).contains(version_needle)
538                    || String::from_utf8_lossy(&out.stderr).contains(version_needle),
539                "{tool} --version output should contain '{version_needle}'"
540            );
541        }
542    }
543
544    /// Tiny byte-substring helper for the binary placeholder scan above.
545    fn contains_subslice_bytes(haystack: &[u8], needle: &[u8]) -> bool {
546        if needle.is_empty() || haystack.len() < needle.len() {
547            return false;
548        }
549        haystack
550            .windows(needle.len())
551            .any(|window| window == needle)
552    }
553}