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 **toolchain** and
9//! returns a [`ToolchainHandle`] describing how to run it.
10//!
11//! # Provisioning strategy (macOS)
12//!
13//! A toolchain 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 toolchain 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 toolchain carries a [`manifest::ToolchainManifest`] (`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 toolchain.
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 coverage;
42pub mod error;
43pub mod executor;
44pub mod formula;
45pub mod lockfile;
46pub mod manifest;
47pub mod package_index;
48pub mod prebuilt;
49pub mod recipe;
50pub mod recipe_choco;
51pub mod registry;
52pub mod relocate;
53pub mod source_build;
54pub mod windows;
55
56pub use error::{Result, ToolchainError};
57pub use lockfile::{LockedTool, ToolchainLockfile, ToolchainLockfileExt, LOCKFILE_NAME};
58
59use crate::coverage::{CoverageRecord, CoverageStatus};
60use crate::registry::{PublishedToolchain, PulledToolchain, ToolchainArtifactId};
61use crate::relocate::RelocationReport;
62
63/// Target platform for a toolchain provisioning request.
64///
65/// Both platforms provision into the same toolchain format. macOS goes through
66/// source-build / prebuilt-fetch / brew-emulate ([`ensure_macos_toolchain`]); Windows
67/// goes through MinGit for `git` and the Chocolatey chain for everything else
68/// ([`windows`]): portable zip/web-file packages are fetched + extracted
69/// leaf-side, while exe/msi installer packages run `choco install` inside a
70/// Windows container via the registered [`executor::ContainerBuildExecutor`]
71/// (failing with [`ToolchainError::ExecutorUnavailable`] when none is
72/// registered — never a host subprocess).
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum ToolPlatform {
75    /// macOS host — provision via source build / prebuilt fetch into a toolchain.
76    MacOS,
77    /// Windows host — provision via MinGit / portable artifact into a toolchain.
78    Windows,
79}
80
81/// A provisioned toolchain: where it lives and how to run it.
82///
83/// `path_dirs` are absolute directories to prepend to `PATH`; `env` are extra
84/// environment variables the caller should set so the tool resolves its own
85/// libraries / exec helpers out of the toolchain instead of the host. Both are a
86/// direct projection of the toolchain's [`manifest::ToolchainManifest`].
87#[derive(Debug, Clone)]
88pub struct ToolchainHandle {
89    /// Root directory of the provisioned toolchain (the cache key dir).
90    pub install_dir: PathBuf,
91    /// Absolute directories to prepend to `PATH`.
92    pub path_dirs: Vec<String>,
93    /// Extra environment variables for running the tool.
94    pub env: HashMap<String, String>,
95}
96
97/// Host architecture token used in cache keys (`arm64` / `x86_64`).
98fn arch_token() -> &'static str {
99    match std::env::consts::ARCH {
100        "aarch64" => "arm64",
101        other => other,
102    }
103}
104
105/// Split a package request into `(formula, version_token)`.
106///
107/// `git` -> `("git", "latest")`; `openssl@3` -> `("openssl@3", "3")`. The full
108/// `pkg` is always the brew formula name (brew versioned formulae keep their
109/// `@major` suffix); the version token only feeds the cache key.
110fn split_pkg(pkg: &str) -> (&str, &str) {
111    match pkg.split_once('@') {
112        Some((_, ver)) if !ver.is_empty() => (pkg, ver),
113        _ => (pkg, "latest"),
114    }
115}
116
117/// Ensure a runtime tool is provisioned and return a handle describing how to
118/// run it.
119///
120/// The install is idempotent: the toolchain lives at
121/// `{cache_dir}/{formula}-{version}-{arch}` and is guarded by a `.ready` marker
122/// (written after the [`manifest::ToolchainManifest`]), so a populated cache
123/// short-circuits without any network or build work.
124///
125/// # Errors
126///
127/// Returns [`ToolchainError::ExecutorUnavailable`] for a Windows formula whose
128/// Chocolatey install plan needs the containerized `choco` path (exe/msi
129/// installers) when no runtime container executor is registered.
130/// Propagates formula-resolution, download, dependency and build errors.
131/// When `lockfile` is `Some`, a lock hit for `(pkg, platform, arch)` pins the
132/// exact version + URL and the download is verified against the pinned sha256
133/// ("consume-only": this crate never *writes* the lock — the CLI does). A lock
134/// miss live-resolves and records the resolved digest in the toolchain manifest.
135pub async fn ensure_toolchain(
136    pkg: &str,
137    platform: ToolPlatform,
138    cache_dir: &Path,
139    lockfile: Option<&ToolchainLockfile>,
140) -> Result<ToolchainHandle> {
141    match platform {
142        ToolPlatform::Windows => {
143            let toolchain = windows::ensure_windows_toolchain(pkg, cache_dir, lockfile).await?;
144            build_handle_from_toolchain(toolchain).await
145        }
146        ToolPlatform::MacOS => {
147            let toolchain = ensure_macos_toolchain(pkg, cache_dir, lockfile).await?;
148            build_handle_from_toolchain(toolchain).await
149        }
150    }
151}
152
153/// Provision (or reuse) a macOS toolchain for `pkg`, returning the toolchain directory.
154///
155/// Dispatches generically: language toolchains land via the relocation-free
156/// prebuilt fetcher; everything else is built from source. Both write a
157/// [`manifest::ToolchainManifest`] and converge on the same cache + toolchain layout. This
158/// is the crate-internal entry point that [`source_build`] also re-enters to
159/// resolve build dependencies recursively.
160pub(crate) async fn ensure_macos_toolchain(
161    pkg: &str,
162    cache_dir: &Path,
163    lockfile: Option<&ToolchainLockfile>,
164) -> Result<PathBuf> {
165    let (formula, _version) = split_pkg(pkg);
166    if prebuilt::is_prebuilt_formula(formula) {
167        // Language toolchains (go/node/rust/...) land as relocation-free
168        // self-contained vendor archives.
169        prebuilt::ensure_prebuilt(formula, cache_dir, lockfile).await
170    } else {
171        // The homebrew-core C-tool population (git/jq/cmake/...) is built from
172        // source at an absolute toolchain prefix.
173        source_build::ensure_from_source(formula, cache_dir, lockfile).await
174    }
175}
176
177/// Map a toolchain arch token (`arm64` / `x86_64`) to the vendor-download arch token
178/// (`arm64` / `amd64`) the prebuilt resolvers expect.
179fn vendor_arch(arch: &str) -> &str {
180    if arch == "x86_64" {
181        "amd64"
182    } else {
183        arch
184    }
185}
186
187/// The lowercase platform token stored in a manifest / lock entry.
188fn platform_token(platform: ToolPlatform) -> &'static str {
189    match platform {
190        ToolPlatform::MacOS => "macos",
191        ToolPlatform::Windows => "windows",
192    }
193}
194
195/// Sanitize a tool token for use inside a temp-file name.
196fn sanitize(tool: &str) -> String {
197    tool.chars()
198        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
199        .collect()
200}
201
202/// Resolve a tool to a fully-pinned [`LockedTool`] by live-resolving its exact
203/// version + download URL, streaming the artifact to a temp file, and hashing
204/// the bytes (verifying against the upstream-published digest when one exists).
205///
206/// This is the resolution path the CLI's `zlayer toolchains lock` reuses, so the
207/// lock writer never duplicates resolver logic. `arch` is the toolchain arch token
208/// (`arm64` / `x86_64`); the vendor-arch mapping is internal.
209///
210/// # Errors
211///
212/// Propagates resolution / download / digest-verification failures.
213pub async fn resolve_locked_tool(
214    tool: &str,
215    platform: ToolPlatform,
216    arch: &str,
217) -> Result<LockedTool> {
218    let (formula, _version) = split_pkg(tool);
219    let (version, url, expected): (String, String, Option<String>) = match platform {
220        ToolPlatform::MacOS => {
221            if prebuilt::is_prebuilt_formula(formula) {
222                let r = prebuilt::resolve_prebuilt(formula, vendor_arch(arch)).await?;
223                (r.version, r.url, r.sha256)
224            } else {
225                let spec = source_build::resolve_source_spec(formula).await?;
226                let sha = (!spec.sha256.is_empty()).then_some(spec.sha256);
227                (spec.version, spec.tarball_url, sha)
228            }
229        }
230        ToolPlatform::Windows => windows::resolve_locked_windows(formula).await?,
231    };
232
233    // Stream to a temp file to compute (and, when published, verify) the digest.
234    let tmp = std::env::temp_dir().join(format!(
235        "zlayer-lock-{}-{arch}-{}",
236        sanitize(tool),
237        std::process::id()
238    ));
239    let sha256 = package_index::download_verified(&url, &tmp, expected.as_deref()).await?;
240    let _ = tokio::fs::remove_file(&tmp).await;
241
242    Ok(LockedTool {
243        tool: tool.to_string(),
244        platform: platform_token(platform).to_string(),
245        arch: arch.to_string(),
246        version,
247        url,
248        sha256,
249        resolved_at: chrono::Utc::now().to_rfc3339(),
250    })
251}
252
253/// Reconstruct a [`ToolchainHandle`] from a provisioned toolchain by reading (or, for
254/// a pre-manifest toolchain, synthesizing) its [`manifest::ToolchainManifest`].
255async fn build_handle_from_toolchain(toolchain: PathBuf) -> Result<ToolchainHandle> {
256    let manifest = manifest::ToolchainManifest::load_or_synthesize(&toolchain).await?;
257    Ok(ToolchainHandle {
258        install_dir: toolchain,
259        path_dirs: manifest.path_dirs,
260        env: manifest.env,
261    })
262}
263
264/// Non-blocking, filesystem-only probe for an already-provisioned toolchain.
265///
266/// Returns `Some(handle)` iff a `.ready`-stamped toolchain for `(pkg, platform,
267/// cache_dir)` exists on disk; otherwise `None`. Does **no** network I/O, no
268/// build, and no mutation — it reconstructs the [`ToolchainHandle`] from the
269/// toolchain's manifest exactly as the fast path of [`ensure_toolchain`] does.
270///
271/// It exists so a caller that provisions in the background (and therefore can't
272/// `await` the cold install on its hot path) can still inject a toolchain already on
273/// disk — e.g. from a previous daemon process. Returns `None` for any non-macOS
274/// platform or when no ready toolchain is present.
275pub async fn probe_ready_toolchain(
276    pkg: &str,
277    _platform: ToolPlatform,
278    cache_dir: &Path,
279) -> Option<ToolchainHandle> {
280    // The toolchain layout + cache key are platform-agnostic, so the probe is too: it
281    // just looks for a `.ready`-stamped `<formula>-<ver>-<arch>` toolchain on disk.
282    let (formula, _version) = split_pkg(pkg);
283    let toolchain = newest_ready_toolchain(formula, cache_dir).await?;
284    build_handle_from_toolchain(toolchain).await.ok()
285}
286
287/// Find the newest `{formula}-<ver>-<arch>` toolchain under `cache_dir` that is
288/// stamped `.ready` (mtime-ordered). Returns `None` for a cold/partial cache.
289async fn newest_ready_toolchain(formula: &str, cache_dir: &Path) -> Option<PathBuf> {
290    let prefix = format!("{formula}-");
291    let arch_suffix = format!("-{}", arch_token());
292    let mut entries = tokio::fs::read_dir(cache_dir).await.ok()?;
293    let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
294    while let Ok(Some(entry)) = entries.next_entry().await {
295        let name = entry.file_name();
296        let Some(name) = name.to_str() else { continue };
297        if !name.starts_with(&prefix) || !name.ends_with(&arch_suffix) {
298            continue;
299        }
300        let toolchain = entry.path();
301        if !tokio::fs::try_exists(toolchain.join(".ready"))
302            .await
303            .unwrap_or(false)
304        {
305            continue;
306        }
307        let mtime = entry
308            .metadata()
309            .await
310            .ok()
311            .and_then(|m| m.modified().ok())
312            .unwrap_or(std::time::UNIX_EPOCH);
313        if best.as_ref().is_none_or(|(t, _)| mtime >= *t) {
314            best = Some((mtime, toolchain));
315        }
316    }
317    best.map(|(_, toolchain)| toolchain)
318}
319
320// ---------------------------------------------------------------------------
321// Toolchain-registry pull-first + build-coverage wiring (shared by the three
322// resolve entry points: macOS source-build, Windows, and prebuilt).
323//
324// Every resolve is: local `.ready` wins (zero network) → else pull-first from
325// the registry → else build → (non-prebuilt) relocate + publish → record
326// coverage exactly once. Registry pulls/publishes AND coverage writes are all
327// best-effort: a registry error never fails a resolve, and `coverage::record`
328// returns `()`. A cold offline machine resolves exactly as before, plus two
329// quiet, failed best-effort network attempts (a pull, then a publish).
330// ---------------------------------------------------------------------------
331
332/// Max bytes of an error `Display` kept in a coverage `error_tail` (~2 KiB) —
333/// enough to triage a build failure without bloating the coverage row.
334const ERROR_TAIL_MAX: usize = 2048;
335
336/// Try the toolchain registry before building `id` into `toolchain`.
337///
338/// - `Ok(Some(()))` — a published artifact was pulled: [`registry::try_pull_toolchain`]
339///   already unpacked + relocated it and wrote its manifest (`source = Registry`);
340///   this then stamps `.ready` (WITHOUT rewriting that manifest — see
341///   [`stamp_ready`]) and records coverage `Built`. The caller returns the
342///   toolchain with NO build.
343/// - `Ok(None)` — a miss (nothing published, or the registry unreachable-as-
344///   not-found): the caller builds.
345///
346/// A registry transport error is swallowed to `Ok(None)` (logged `warn`): the
347/// registry must NEVER block a resolve. The only propagated error is a local
348/// `.ready` write failure after a successful pull (a genuine local-FS fault).
349pub(crate) async fn pull_first(id: &ToolchainArtifactId, toolchain: &Path) -> Result<Option<()>> {
350    match registry::try_pull_toolchain(id, toolchain).await {
351        Ok(Some(pulled)) => {
352            stamp_ready(toolchain).await?;
353            record_pull_built(id, &pulled).await;
354            Ok(Some(()))
355        }
356        Ok(None) => Ok(None),
357        Err(e) => {
358            tracing::warn!(
359                tool = %id.tool,
360                error = %e,
361                "toolchain registry pull failed (non-fatal); building instead"
362            );
363            Ok(None)
364        }
365    }
366}
367
368/// Stamp the `.ready` marker on an already-materialized toolchain dir WITHOUT
369/// touching `toolchain.json`.
370///
371/// Used after a registry pull, which already wrote the manifest with
372/// `source = Registry` and deliberately left `.ready` to the caller. The
373/// source-build / Windows finalize path would REWRITE the manifest (clobbering
374/// the Registry provenance with `SourceBuild`/`Prebuilt`), so it must not be
375/// reused here — a bare marker write is exactly what the pull contract expects.
376pub(crate) async fn stamp_ready(toolchain: &Path) -> Result<()> {
377    tokio::fs::write(toolchain.join(".ready"), b"").await?;
378    Ok(())
379}
380
381/// Finish a successful LOCAL build: (non-prebuilt) best-effort publish, then
382/// record coverage `Built`.
383///
384/// `report` is the [`RelocationReport`] the build's finalize produced (present
385/// for the relocatable source-build / Windows paths, `None` for prebuilt).
386/// Publish runs only when [`should_publish`] — language prebuilts are already
387/// relocation-free vendor archives (and large: the OCI client buffers blobs in
388/// memory), so they are pull-first + coverage-only, never relocated or
389/// published. A publish failure (the common anonymous / no-token case) is a
390/// `warn`, and coverage is still recorded `Built`, just without a registry ref.
391pub(crate) async fn finish_built(
392    id: &ToolchainArtifactId,
393    toolchain: &Path,
394    report: Option<&RelocationReport>,
395    is_prebuilt: bool,
396) {
397    let published = if should_publish(is_prebuilt) {
398        match report {
399            Some(report) => try_publish(id, toolchain, report).await,
400            None => None,
401        }
402    } else {
403        None
404    };
405    coverage::record(&build_coverage(
406        id,
407        CoverageStatus::Built,
408        published.as_ref(),
409        None,
410    ))
411    .await;
412}
413
414/// Record a net-fallback resolve (the loud full-network brew/choco route won):
415/// coverage `NetFallback`, no registry ref (net-fallback artifacts are not
416/// hermetic, so they are not published from here).
417pub(crate) async fn record_net_fallback(id: &ToolchainArtifactId) {
418    coverage::record(&build_coverage(id, CoverageStatus::NetFallback, None, None)).await;
419}
420
421/// Record a failed resolve: coverage `Failed` with the error's `Display` as the
422/// (truncated) `error_tail`. Best-effort — the caller still propagates `err`.
423pub(crate) async fn record_failed(id: &ToolchainArtifactId, err: &ToolchainError) {
424    let tail = err.to_string();
425    coverage::record(&build_coverage(
426        id,
427        CoverageStatus::Failed,
428        None,
429        Some(&tail),
430    ))
431    .await;
432}
433
434/// Record a registry pull-hit as coverage `Built`, carrying the pulled
435/// artifact's reference + digest (a pull is, for coverage, the same
436/// `(registry_ref, digest)` coordinate a publish would record).
437async fn record_pull_built(id: &ToolchainArtifactId, pulled: &PulledToolchain) {
438    let published = PublishedToolchain {
439        reference: pulled.reference.clone(),
440        digest: pulled.digest.clone(),
441    };
442    coverage::record(&build_coverage(
443        id,
444        CoverageStatus::Built,
445        Some(&published),
446        None,
447    ))
448    .await;
449}
450
451/// Best-effort publish of a freshly-built toolchain: read its just-written
452/// manifest, then [`registry::publish_toolchain`] it (built prefix = the
453/// toolchain dir itself). `None` on any failure (missing manifest, or a push
454/// denied for anonymous / no-token — the common case), which is logged `warn`
455/// and leaves the toolchain built-locally-only.
456async fn try_publish(
457    id: &ToolchainArtifactId,
458    toolchain: &Path,
459    report: &RelocationReport,
460) -> Option<PublishedToolchain> {
461    let manifest = match manifest::ToolchainManifest::read_from_toolchain(toolchain).await {
462        Ok(Some(manifest)) => manifest,
463        Ok(None) => {
464            tracing::warn!(tool = %id.tool, "no manifest present to publish; skipping publish");
465            return None;
466        }
467        Err(e) => {
468            tracing::warn!(tool = %id.tool, error = %e, "reading manifest for publish failed; skipping publish");
469            return None;
470        }
471    };
472    match registry::publish_toolchain(toolchain, &manifest, report, toolchain, id).await {
473        Ok(published) => Some(published),
474        Err(e) => {
475            tracing::warn!(
476                tool = %id.tool,
477                error = %e,
478                "toolchain publish failed (non-fatal; an anonymous / no-token push is expected)"
479            );
480            None
481        }
482    }
483}
484
485/// Whether a freshly-built toolchain of this kind is relocated + published.
486///
487/// Language prebuilts are excluded: they are already relocation-free vendor
488/// archives and large enough that publishing (the OCI client buffers blobs in
489/// memory) is not worth it. Everything else (source builds, Windows toolchains)
490/// is published so the next machine skips the build.
491fn should_publish(is_prebuilt: bool) -> bool {
492    !is_prebuilt
493}
494
495/// Assemble a [`CoverageRecord`] from a resolve outcome. Pure (no I/O): the
496/// status / registry-ref / error-tail wiring is unit-testable without a network
497/// or a real build. `platform` / `arch` come straight off the artifact id (the
498/// crate's `"macos"` / `"windows"` + `"arm64"` / `"x86_64"` tokens); a present
499/// `published` fills `registry_ref` / `registry_digest`; a present `err` fills a
500/// truncated `error_tail`.
501fn build_coverage(
502    id: &ToolchainArtifactId,
503    status: CoverageStatus,
504    published: Option<&PublishedToolchain>,
505    err: Option<&str>,
506) -> CoverageRecord {
507    CoverageRecord {
508        tool: id.tool.clone(),
509        platform: id.os.clone(),
510        arch: id.arch.clone(),
511        status,
512        version: id.version.clone(),
513        registry_ref: published.map(|p| p.reference.clone()).unwrap_or_default(),
514        registry_digest: published.map(|p| p.digest.clone()).unwrap_or_default(),
515        error_tail: err.map(truncate_error_tail).unwrap_or_default(),
516        recorded_at: coverage::now_rfc3339(),
517    }
518}
519
520/// Truncate an error `Display` to [`ERROR_TAIL_MAX`] bytes for a coverage
521/// `error_tail`, on a char boundary so the result stays valid UTF-8.
522fn truncate_error_tail(err: &str) -> String {
523    if err.len() <= ERROR_TAIL_MAX {
524        return err.to_string();
525    }
526    let mut end = ERROR_TAIL_MAX;
527    while end > 0 && !err.is_char_boundary(end) {
528        end -= 1;
529    }
530    err[..end].to_string()
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use crate::manifest::{ToolchainManifest, ToolchainSource};
537
538    #[test]
539    fn split_pkg_plain_defaults_to_latest() {
540        assert_eq!(split_pkg("git"), ("git", "latest"));
541    }
542
543    #[test]
544    fn split_pkg_versioned_keeps_full_formula() {
545        assert_eq!(split_pkg("openssl@3"), ("openssl@3", "3"));
546    }
547
548    #[test]
549    fn split_pkg_trailing_at_is_latest() {
550        assert_eq!(split_pkg("weird@"), ("weird@", "latest"));
551    }
552
553    // -- E2: coverage-record assembly + pull-first / publish gating ------------
554
555    fn sample_id() -> ToolchainArtifactId {
556        ToolchainArtifactId {
557            tool: "jq".to_string(),
558            version: "1.8.1".to_string(),
559            os: "macos".to_string(),
560            arch: "arm64".to_string(),
561        }
562    }
563
564    /// A normal build records `Built` carrying the published ref + digest.
565    #[test]
566    fn build_coverage_built_carries_registry_ref_and_digest() {
567        let id = sample_id();
568        let published = PublishedToolchain {
569            reference: "ghcr.io/blackleafdigital/zlayer/toolchains:jq-1.8.1-macos-arm64"
570                .to_string(),
571            digest: "sha256:abc".to_string(),
572        };
573        let rec = build_coverage(&id, CoverageStatus::Built, Some(&published), None);
574        assert_eq!(rec.tool, "jq");
575        assert_eq!(rec.platform, "macos");
576        assert_eq!(rec.arch, "arm64");
577        assert_eq!(rec.version, "1.8.1");
578        assert_eq!(rec.status, CoverageStatus::Built);
579        assert_eq!(rec.registry_ref, published.reference);
580        assert_eq!(rec.registry_digest, "sha256:abc");
581        assert!(rec.error_tail.is_empty());
582        assert!(!rec.recorded_at.is_empty(), "recorded_at is stamped");
583    }
584
585    /// A prebuilt / publish-skipped `Built` still has status `Built` but no
586    /// registry ref (published is `None`).
587    #[test]
588    fn build_coverage_built_without_publish_has_no_ref() {
589        let rec = build_coverage(&sample_id(), CoverageStatus::Built, None, None);
590        assert_eq!(rec.status, CoverageStatus::Built);
591        assert!(rec.registry_ref.is_empty());
592        assert!(rec.registry_digest.is_empty());
593        assert!(rec.error_tail.is_empty());
594    }
595
596    /// A build-fail path records `Failed` with the error's Display as `error_tail`.
597    #[test]
598    fn build_coverage_failed_carries_error_tail() {
599        let err = "Registry error: generic source build blew up".to_string();
600        let rec = build_coverage(&sample_id(), CoverageStatus::Failed, None, Some(&err));
601        assert_eq!(rec.status, CoverageStatus::Failed);
602        assert_eq!(rec.error_tail, err);
603        assert!(rec.registry_ref.is_empty());
604        assert!(rec.registry_digest.is_empty());
605    }
606
607    /// A net-fallback path records `NetFallback` (no ref, no error tail).
608    #[test]
609    fn build_coverage_net_fallback_status() {
610        let rec = build_coverage(&sample_id(), CoverageStatus::NetFallback, None, None);
611        assert_eq!(rec.status, CoverageStatus::NetFallback);
612        assert!(rec.registry_ref.is_empty());
613        assert!(rec.error_tail.is_empty());
614    }
615
616    /// `error_tail` is capped at ~2 KiB on a char boundary (valid UTF-8).
617    #[test]
618    fn error_tail_truncates_to_cap_on_char_boundary() {
619        let short = "small error";
620        assert_eq!(truncate_error_tail(short), short);
621
622        // 4000 × 2-byte chars = 8000 bytes → truncated to <= ERROR_TAIL_MAX.
623        let long = "é".repeat(4000);
624        let cut = truncate_error_tail(&long);
625        assert!(cut.len() <= ERROR_TAIL_MAX);
626        assert!(cut.len() > ERROR_TAIL_MAX - 4, "cut near the cap, not tiny");
627        assert!(
628            long.starts_with(&cut),
629            "a valid-UTF-8 prefix of the original"
630        );
631    }
632
633    /// The publish gate: source-build / Windows toolchains publish; language
634    /// prebuilts never do (excluded from relocate + publish).
635    #[test]
636    fn should_publish_excludes_prebuilt() {
637        assert!(
638            should_publish(false),
639            "source-build / Windows toolchains publish"
640        );
641        assert!(
642            !should_publish(true),
643            "language prebuilts are never published"
644        );
645    }
646
647    /// The pull-hit short-circuit's stamp step: `stamp_ready` adds `.ready` to a
648    /// materialized dir WITHOUT clobbering the pull's `source = Registry`
649    /// manifest (the finalize path would rewrite it). Together with registry.rs's
650    /// `pull_round_trip_from_local_registry_rehomes_and_stamps_source` (which
651    /// proves the pull unpacks + relocates + writes the Registry manifest and
652    /// leaves `.ready` to the caller) this covers the pull-hit branch end-to-end
653    /// without a live registry — `try_pull_toolchain` cannot be injected without
654    /// touching registry.rs, so the branch is split at this stamp seam.
655    #[tokio::test]
656    async fn stamp_ready_writes_marker_without_clobbering_registry_manifest() {
657        let tmp = tempfile::tempdir().unwrap();
658        let dir = tmp.path();
659        let mut env = HashMap::new();
660        env.insert("K".to_string(), format!("{}/lib", dir.display()));
661        let manifest = ToolchainManifest {
662            tool: "jq".to_string(),
663            version: "1.8.1".to_string(),
664            arch: "arm64".to_string(),
665            platform: "macos".to_string(),
666            path_dirs: vec![format!("{}/bin", dir.display())],
667            env,
668            source: ToolchainSource::Registry {
669                reference: "ghcr.io/x/toolchains:jq-1.8.1-macos-arm64".to_string(),
670                digest: "sha256:deadbeef".to_string(),
671            },
672            build_deps: vec![],
673            provisioned_at: "2026-07-06T00:00:00Z".to_string(),
674        };
675        manifest.write_to_toolchain(dir).await.unwrap();
676        assert!(
677            !dir.join(".ready").exists(),
678            "pull leaves .ready to the caller"
679        );
680
681        stamp_ready(dir).await.unwrap();
682
683        assert!(dir.join(".ready").is_file(), ".ready stamped");
684        let reread = ToolchainManifest::read_from_toolchain(dir)
685            .await
686            .unwrap()
687            .unwrap();
688        match reread.source {
689            ToolchainSource::Registry { reference, digest } => {
690                assert_eq!(reference, "ghcr.io/x/toolchains:jq-1.8.1-macos-arm64");
691                assert_eq!(digest, "sha256:deadbeef");
692            }
693            other => panic!("stamp_ready must NOT rewrite the Registry manifest; got {other:?}"),
694        }
695    }
696
697    /// A Windows formula whose choco plan is an exe installer (7zip's
698    /// `chocolateyInstall.ps1` is `Install-ChocolateyPackage`) requires the
699    /// runtime container executor; with none registered the chain fails with
700    /// `ExecutorUnavailable`. Live because the chain resolves + downloads the
701    /// `.nupkg` through the package index BEFORE the executor decision — the
702    /// offline decision-fn coverage lives in the `windows` module's own tests.
703    #[tokio::test]
704    #[ignore = "live: resolves + downloads a Chocolatey package through the package index"]
705    async fn windows_exe_installer_without_executor_is_executor_unavailable() {
706        let tmp = tempfile::tempdir().unwrap();
707        let err = ensure_toolchain("7zip", ToolPlatform::Windows, tmp.path(), None)
708            .await
709            .unwrap_err();
710        assert!(
711            matches!(err, ToolchainError::ExecutorUnavailable { .. }),
712            "{err}"
713        );
714    }
715
716    /// Lay down a source-built `git` toolchain WITHOUT a manifest (the pre-manifest
717    /// layout) so the offline tests exercise the backward-compatible synthesis
718    /// path of [`build_handle_from_toolchain`].
719    async fn seed_legacy_git_toolchain(cache_dir: &Path, version: &str) -> PathBuf {
720        let toolchain = cache_dir.join(format!("git-{version}-{}", arch_token()));
721        tokio::fs::create_dir_all(toolchain.join("bin"))
722            .await
723            .unwrap();
724        tokio::fs::create_dir_all(toolchain.join("libexec/git-core"))
725            .await
726            .unwrap();
727        tokio::fs::create_dir_all(toolchain.join("etc"))
728            .await
729            .unwrap();
730        tokio::fs::write(toolchain.join("etc/gitconfig"), b"")
731            .await
732            .unwrap();
733        tokio::fs::write(toolchain.join(".ready"), b"")
734            .await
735            .unwrap();
736        toolchain
737    }
738
739    /// Lay down a toolchain WITH a `toolchain.json` manifest so the resolver's generic
740    /// (non-synthesized) path is covered.
741    async fn seed_toolchain_with_manifest(cache_dir: &Path, tool: &str, version: &str) -> PathBuf {
742        let toolchain = cache_dir.join(format!("{tool}-{version}-{}", arch_token()));
743        let bin = toolchain.join("bin");
744        tokio::fs::create_dir_all(&bin).await.unwrap();
745        let mut env = HashMap::new();
746        env.insert("FOO".to_string(), "bar".to_string());
747        let manifest = ToolchainManifest {
748            tool: tool.to_string(),
749            version: version.to_string(),
750            arch: arch_token().to_string(),
751            platform: "macos".to_string(),
752            path_dirs: vec![bin.display().to_string()],
753            env,
754            source: ToolchainSource::SourceBuild {
755                url: String::new(),
756                sha256: String::new(),
757            },
758            build_deps: vec![],
759            provisioned_at: "2026-06-30T00:00:00Z".to_string(),
760        };
761        manifest.write_to_toolchain(&toolchain).await.unwrap();
762        tokio::fs::write(toolchain.join(".ready"), b"")
763            .await
764            .unwrap();
765        toolchain
766    }
767
768    /// A pre-manifest `git` toolchain synthesizes a handle whose env is ONLY
769    /// `GIT_EXEC_PATH` (→ `<toolchain>/libexec/git-core`), with NO
770    /// `DYLD_FALLBACK_LIBRARY_PATH` and NO `GIT_CONFIG_SYSTEM`.
771    #[tokio::test]
772    async fn handle_synthesized_for_legacy_git_toolchain_drops_dyld() {
773        let tmp = tempfile::tempdir().unwrap();
774        let toolchain = seed_legacy_git_toolchain(tmp.path(), "2.55.0").await;
775
776        let handle = build_handle_from_toolchain(toolchain.clone())
777            .await
778            .unwrap();
779
780        assert_eq!(handle.install_dir, toolchain);
781        assert_eq!(
782            handle.path_dirs,
783            vec![toolchain.join("bin").display().to_string()]
784        );
785        assert_eq!(
786            handle.env.get("GIT_EXEC_PATH"),
787            Some(&toolchain.join("libexec/git-core").display().to_string())
788        );
789        assert!(!handle.env.contains_key("DYLD_FALLBACK_LIBRARY_PATH"));
790        assert!(!handle.env.contains_key("GIT_CONFIG_SYSTEM"));
791    }
792
793    /// A toolchain WITH a manifest projects the manifest's `path_dirs` + `env`
794    /// verbatim onto the handle.
795    #[tokio::test]
796    async fn handle_reads_manifest_when_present() {
797        let tmp = tempfile::tempdir().unwrap();
798        let toolchain = seed_toolchain_with_manifest(tmp.path(), "jq", "1.8.2").await;
799
800        let handle = build_handle_from_toolchain(toolchain.clone())
801            .await
802            .unwrap();
803        assert_eq!(handle.install_dir, toolchain);
804        assert_eq!(
805            handle.path_dirs,
806            vec![toolchain.join("bin").display().to_string()]
807        );
808        assert_eq!(handle.env.get("FOO"), Some(&"bar".to_string()));
809    }
810
811    /// The on-disk fallback the runtime relies on: a `.ready` toolchain is probed
812    /// (no install) and reconstructs the SAME handle as the fast path.
813    #[tokio::test]
814    async fn probe_ready_returns_handle_for_ready_toolchain() {
815        let tmp = tempfile::tempdir().unwrap();
816        let toolchain = seed_legacy_git_toolchain(tmp.path(), "2.55.0").await;
817
818        let handle = probe_ready_toolchain("git", ToolPlatform::MacOS, tmp.path())
819            .await
820            .expect("ready toolchain should be probed without install");
821
822        assert_eq!(handle.install_dir, toolchain);
823        assert_eq!(
824            handle.env.get("GIT_EXEC_PATH"),
825            Some(&toolchain.join("libexec/git-core").display().to_string())
826        );
827    }
828
829    /// Probing a different tool's toolchain works generically (not git-special).
830    #[tokio::test]
831    async fn probe_ready_is_generic_across_tools() {
832        let tmp = tempfile::tempdir().unwrap();
833        let toolchain = seed_toolchain_with_manifest(tmp.path(), "jq", "1.8.2").await;
834
835        let handle = probe_ready_toolchain("jq", ToolPlatform::MacOS, tmp.path())
836            .await
837            .expect("ready jq toolchain should be probed");
838        assert_eq!(handle.install_dir, toolchain);
839        assert_eq!(handle.env.get("FOO"), Some(&"bar".to_string()));
840    }
841
842    /// A toolchain dir that exists but is NOT stamped `.ready` (mid-build) must probe
843    /// to `None` — never inject a partial toolchain.
844    #[tokio::test]
845    async fn probe_ready_returns_none_without_ready_marker() {
846        let tmp = tempfile::tempdir().unwrap();
847        let toolchain = tmp.path().join(format!("git-2.55.0-{}", arch_token()));
848        tokio::fs::create_dir_all(toolchain.join("bin"))
849            .await
850            .unwrap();
851
852        assert!(
853            probe_ready_toolchain("git", ToolPlatform::MacOS, tmp.path())
854                .await
855                .is_none(),
856            "an unstamped toolchain must not be injected"
857        );
858    }
859
860    /// Cold cache, an absent tool, and non-macOS requests all probe to `None`.
861    #[tokio::test]
862    async fn probe_ready_returns_none_for_cold_or_unsupported() {
863        let tmp = tempfile::tempdir().unwrap();
864        assert!(
865            probe_ready_toolchain("git", ToolPlatform::MacOS, tmp.path())
866                .await
867                .is_none(),
868            "cold cache should probe None"
869        );
870        assert!(
871            probe_ready_toolchain("jq", ToolPlatform::MacOS, tmp.path())
872                .await
873                .is_none(),
874            "absent tool should probe None"
875        );
876        assert!(
877            probe_ready_toolchain("git", ToolPlatform::Windows, tmp.path())
878                .await
879                .is_none(),
880            "cold cache probes None on every platform"
881        );
882    }
883
884    /// Full end-to-end provision of BOTH verification targets: build `git` and
885    /// `jq` FROM SOURCE and run them. Asserts each built binary carries NO
886    /// `@@HOMEBREW@@` placeholder (the bottle-relocation failure mode) and that
887    /// a manifest was written. `#[ignore]` because it hits the network + the
888    /// compiler and only works on macOS with CLT; run with:
889    ///   `cargo test -p zlayer-toolchain -- --ignored`.
890    #[tokio::test]
891    #[ignore = "live build test; fetches + compiles git and jq from source (macOS + CLT only)"]
892    async fn ensure_git_and_jq_build_from_source_and_run() {
893        let tmp = tempfile::tempdir().unwrap();
894
895        for (tool, version_needle) in [("git", "git version"), ("jq", "jq-")] {
896            let handle = ensure_toolchain(tool, ToolPlatform::MacOS, tmp.path(), None)
897                .await
898                .unwrap_or_else(|e| panic!("{tool} toolchain should build from source: {e}"));
899
900            let bin = handle.install_dir.join("bin").join(tool);
901            assert!(
902                bin.exists(),
903                "{tool} binary should exist at <toolchain>/bin/{tool}"
904            );
905
906            // The toolchain must carry a manifest.
907            let manifest = ToolchainManifest::read_from_toolchain(&handle.install_dir)
908                .await
909                .unwrap()
910                .unwrap_or_else(|| panic!("{tool} toolchain must have a manifest"));
911            assert_eq!(manifest.tool, tool);
912
913            // No @@HOMEBREW@@ anywhere in the built binary.
914            let bytes = tokio::fs::read(&bin).await.expect("read binary");
915            assert!(
916                !contains_subslice_bytes(&bytes, b"@@HOMEBREW"),
917                "source-built {tool} must contain NO @@HOMEBREW@@ references"
918            );
919
920            let mut cmd = tokio::process::Command::new(&bin);
921            for (k, v) in &handle.env {
922                cmd.env(k, v);
923            }
924            let out = cmd.arg("--version").output().await.expect("run --version");
925            assert!(out.status.success(), "{tool} --version should succeed");
926            assert!(
927                String::from_utf8_lossy(&out.stdout).contains(version_needle)
928                    || String::from_utf8_lossy(&out.stderr).contains(version_needle),
929                "{tool} --version output should contain '{version_needle}'"
930            );
931        }
932    }
933
934    /// Tiny byte-substring helper for the binary placeholder scan above.
935    fn contains_subslice_bytes(haystack: &[u8], needle: &[u8]) -> bool {
936        if needle.is_empty() || haystack.len() < needle.len() {
937            return false;
938        }
939        haystack
940            .windows(needle.len())
941            .any(|window| window == needle)
942    }
943}