zlayer-toolchain 0.14.3

Runtime toolchain provisioning (macOS Homebrew bottle resolver/installer) for ZLayer
Documentation
//! The leaf ↔ runtime container-build boundary.
//!
//! `zlayer-toolchain` is a **leaf** crate (it may not depend on
//! `zlayer-agent` or `zlayer-builder`), yet toolchain source builds must run
//! inside an isolated container provided by the runtime layer (Seatbelt on
//! macOS, HCS on Windows). This module is the seam between the two:
//!
//! - **The leaf defines** the executor contract: [`ContainerBuildExecutor`],
//!   its input ([`ContainerBuildRequest`]) and output
//!   ([`ContainerBuildReport`]), plus a process-global registration slot.
//! - **The runtime registers** its implementation once at startup via
//!   [`set_container_executor`] (idempotent — a later call replaces the
//!   current executor, which is how tests swap in mocks).
//! - **Build paths in this crate resolve** the executor with
//!   [`container_executor`]. When none is registered they must fail with
//!   [`crate::error::ToolchainError::ExecutorUnavailable`] — a containerized
//!   build step NEVER falls back to a host subprocess.
//!
//! The trait's `execute` method is a manually desugared async fn
//! (`Pin<Box<dyn Future ...>>`): the workspace does not use `async_trait`
//! and this crate does not add it.

use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::{Arc, OnceLock, PoisonError, RwLock};

// NOTE: no serde derives on `NetPolicy` / `ContainerBuildRequest`. The
// `crate::ToolPlatform` field does not derive Serialize/Deserialize (and this
// leaf module may not change it), and the request crosses no wire today — the
// runtime receives it in-process through the trait object. Add serde
// end-to-end if the request ever needs to serialize.

/// Network policy for a containerized toolchain build.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetPolicy {
    /// No network. All inputs are pre-fetched leaf-side before execution.
    Deny,
    /// Full outbound network, loudly logged (last-resort brew/choco nets only).
    AllowLoud,
}

/// Everything the runtime needs to execute an
/// [`InstallPlan`](crate::recipe::InstallPlan) in an isolated container.
#[derive(Debug, Clone)]
pub struct ContainerBuildRequest {
    /// The tool being built (formula name; feeds error surfacing + logs).
    pub tool: String,
    /// Target platform of the build (selects Seatbelt vs HCS runtime-side).
    pub platform: crate::ToolPlatform,
    /// The parsed install plan whose steps the container executes.
    pub plan: crate::recipe::InstallPlan,
    /// Extracted source tree the steps run in.
    pub src_dir: PathBuf,
    /// The toolchain install prefix — the ONLY tree the container may populate.
    pub prefix: PathBuf,
    /// Scratch dir (rw), conventionally `<prefix>/.build`.
    pub scratch_dir: PathBuf,
    /// Dependency toolchain prefixes (read + exec grants; bins prepended to
    /// `PATH`).
    pub dep_toolchains: Vec<PathBuf>,
    /// Pre-fetched resource/patch staging dir (`<scratch>/resources`), when any.
    pub resources_dir: Option<PathBuf>,
    /// Environment variables for every step (the plan's accumulated `ENV`).
    pub env: HashMap<String, String>,
    /// Extra `PATH` entries, highest priority first.
    pub path_prefix: Vec<String>,
    /// Network policy enforced on the container.
    pub net: NetPolicy,
}

/// What the runtime hands back after executing a [`ContainerBuildRequest`].
#[derive(Debug, Clone)]
pub struct ContainerBuildReport {
    /// Tail of the combined build log (for error surfacing + coverage records).
    pub log_tail: String,
}

/// Implemented by the runtime layer (Seatbelt on macOS, HCS on Windows).
///
/// The method is a manually desugared async fn — implementors typically wrap
/// an async block in [`Box::pin`]. The future borrows both the executor and
/// the request for `'a`, so no cloning is forced on the implementor.
pub trait ContainerBuildExecutor: Send + Sync + 'static {
    /// Execute the request's install plan inside an isolated container,
    /// populating `req.prefix` and nothing else.
    fn execute<'a>(
        &'a self,
        req: &'a ContainerBuildRequest,
    ) -> Pin<Box<dyn Future<Output = crate::Result<ContainerBuildReport>> + Send + 'a>>;
}

/// Process-global executor slot. `OnceLock` lazily creates the `RwLock`;
/// the `RwLock` makes registration replaceable (tests swap in mocks).
static EXECUTOR: OnceLock<RwLock<Option<Arc<dyn ContainerBuildExecutor>>>> = OnceLock::new();

/// The lazily-initialized global slot.
fn executor_slot() -> &'static RwLock<Option<Arc<dyn ContainerBuildExecutor>>> {
    EXECUTOR.get_or_init(|| RwLock::new(None))
}

/// Register the process-global container build executor.
///
/// Idempotent: calling again replaces the current executor (tests swap in
/// mocks this way). The runtime layer calls this once at startup; until it
/// does, [`container_executor`] returns `None` and containerized build paths
/// fail with [`crate::error::ToolchainError::ExecutorUnavailable`].
///
/// Poison-tolerant: the guarded value is a plain pointer swap with no
/// intermediate states, so a panic in another thread holding the lock cannot
/// leave it torn — this recovers the inner value instead of propagating the
/// poison.
pub fn set_container_executor(exec: Arc<dyn ContainerBuildExecutor>) {
    let mut slot = executor_slot()
        .write()
        .unwrap_or_else(PoisonError::into_inner);
    *slot = Some(exec);
}

/// The currently registered container build executor, if any.
///
/// `None` means the runtime layer has not registered one — callers needing a
/// containerized build must then return
/// [`crate::error::ToolchainError::ExecutorUnavailable`], never fall back to
/// a host subprocess. Poison-tolerant for the same reason as
/// [`set_container_executor`].
#[must_use]
pub fn container_executor() -> Option<Arc<dyn ContainerBuildExecutor>> {
    executor_slot()
        .read()
        .unwrap_or_else(PoisonError::into_inner)
        .clone()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::recipe::InstallPlan;
    use crate::ToolPlatform;

    /// Mock executor returning a canned report tagged with its identity and
    /// the request's tool, so assertions prove WHICH executor served WHICH
    /// request through the trait object.
    struct MockExecutor {
        tag: &'static str,
    }

    impl ContainerBuildExecutor for MockExecutor {
        fn execute<'a>(
            &'a self,
            req: &'a ContainerBuildRequest,
        ) -> Pin<Box<dyn Future<Output = crate::Result<ContainerBuildReport>> + Send + 'a>>
        {
            Box::pin(async move {
                Ok(ContainerBuildReport {
                    log_tail: format!("{}:{}", self.tag, req.tool),
                })
            })
        }
    }

    /// A minimal well-formed request for `tool`.
    fn request(tool: &str) -> ContainerBuildRequest {
        ContainerBuildRequest {
            tool: tool.to_string(),
            platform: ToolPlatform::MacOS,
            plan: InstallPlan {
                steps: vec![],
                resources: vec![],
                patches: vec![],
                env: HashMap::new(),
                deparallelize: false,
            },
            src_dir: PathBuf::from("/tmp/src"),
            prefix: PathBuf::from("/tmp/prefix"),
            scratch_dir: PathBuf::from("/tmp/prefix/.build"),
            dep_toolchains: vec![],
            resources_dir: None,
            env: HashMap::new(),
            path_prefix: vec![],
            net: NetPolicy::Deny,
        }
    }

    /// D5 STATIC GUARD: no code path in this crate may spawn a host `brew`
    /// or `choco` subprocess. Containerized package-manager builds go through
    /// the registered [`ContainerBuildExecutor`], and its absence must
    /// surface as `ExecutorUnavailable` — never a host package-manager
    /// fallback (the module-level contract above). This test reads every
    /// `.rs` file under this crate's `src/` at test time and rejects the
    /// spawn patterns outright.
    ///
    /// No allow-list: as of writing the crate has ZERO textual hits, even in
    /// comments (`brew_emulate.rs` spawns only `git`/`tar` at provision time,
    /// and its brew argv travels inside a `ContainerBuildRequest` plan as
    /// data, not through `Command::new`). If a future doc comment ever needs
    /// to mention the pattern, write it in a form that breaks the contiguous
    /// byte sequence. The needles here are assembled from pieces at runtime
    /// for exactly that reason — this test's own source can never match
    /// itself.
    #[test]
    fn no_host_brew_or_choco_subprocess_in_crate_sources() {
        let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
        // `Command::new(` + the offending opener, assembled so the pattern
        // never appears contiguously in THIS file's source text.
        let spawn = "Command::new(";
        let q = '"';
        let needles = [
            // Literal spawns: `..new("brew")` / `..new("choco")` + exe forms.
            format!("{spawn}{q}brew{q}"),
            format!("{spawn}{q}brew.sh{q}"),
            format!("{spawn}{q}choco{q}"),
            format!("{spawn}{q}choco.exe{q}"),
            // Variable-holding spawns: `..new(brew_bin)` / `..new(&brew_bin)`.
            format!("{spawn}brew_bin"),
            format!("{spawn}&brew_bin"),
        ];

        let mut stack = vec![src_root.clone()];
        let mut scanned = 0usize;
        let mut offenders: Vec<String> = Vec::new();
        while let Some(dir) = stack.pop() {
            let entries = std::fs::read_dir(&dir)
                .unwrap_or_else(|e| panic!("read_dir({}) failed: {e}", dir.display()));
            for entry in entries {
                let path = entry.expect("directory entry").path();
                if path.is_dir() {
                    stack.push(path);
                } else if path.extension().is_some_and(|ext| ext == "rs") {
                    let text = std::fs::read_to_string(&path)
                        .unwrap_or_else(|e| panic!("read {} failed: {e}", path.display()));
                    scanned += 1;
                    for needle in &needles {
                        if text.contains(needle.as_str()) {
                            offenders.push(format!("{}: contains `{needle}`", path.display()));
                        }
                    }
                }
            }
        }

        assert!(
            scanned >= 10,
            "expected to scan this crate's sources but found only {scanned} .rs files under {}",
            src_root.display()
        );
        assert!(
            offenders.is_empty(),
            "host brew/choco subprocess pattern(s) found — containerized builds must go \
             through the ContainerBuildExecutor, never a host package manager:\n{}",
            offenders.join("\n")
        );
    }

    /// The executor slot is PROCESS-GLOBAL and libtest runs tests in
    /// parallel, so the "None before registration", "registration
    /// round-trip", and "replacement wins" assertions all live in this ONE
    /// sequential test fn — splitting them into separate `#[test]`s would
    /// race. No other test in this crate may touch the global slot.
    #[tokio::test]
    async fn global_slot_none_then_registered_then_replaced() {
        // (1) None before any registration.
        assert!(
            container_executor().is_none(),
            "no executor must be registered before set_container_executor"
        );

        // (2) Registration round-trip: register a mock, resolve it through
        // the global, and call through the trait object.
        set_container_executor(Arc::new(MockExecutor { tag: "first" }));
        let exec = container_executor().expect("executor should be registered");
        let report = exec.execute(&request("git")).await.unwrap();
        assert_eq!(report.log_tail, "first:git");

        // (3) Replacement: registering a second mock wins.
        set_container_executor(Arc::new(MockExecutor { tag: "second" }));
        let exec = container_executor().expect("executor should still be registered");
        let report = exec.execute(&request("jq")).await.unwrap();
        assert_eq!(
            report.log_tail, "second:jq",
            "the most recently registered executor must serve requests"
        );
    }
}