Skip to main content

zlayer_toolchain/
executor.rs

1//! The leaf ↔ runtime container-build boundary.
2//!
3//! `zlayer-toolchain` is a **leaf** crate (it may not depend on
4//! `zlayer-agent` or `zlayer-builder`), yet toolchain source builds must run
5//! inside an isolated container provided by the runtime layer (Seatbelt on
6//! macOS, HCS on Windows). This module is the seam between the two:
7//!
8//! - **The leaf defines** the executor contract: [`ContainerBuildExecutor`],
9//!   its input ([`ContainerBuildRequest`]) and output
10//!   ([`ContainerBuildReport`]), plus a process-global registration slot.
11//! - **The runtime registers** its implementation once at startup via
12//!   [`set_container_executor`] (idempotent — a later call replaces the
13//!   current executor, which is how tests swap in mocks).
14//! - **Build paths in this crate resolve** the executor with
15//!   [`container_executor`]. When none is registered they must fail with
16//!   [`crate::error::ToolchainError::ExecutorUnavailable`] — a containerized
17//!   build step NEVER falls back to a host subprocess.
18//!
19//! The trait's `execute` method is a manually desugared async fn
20//! (`Pin<Box<dyn Future ...>>`): the workspace does not use `async_trait`
21//! and this crate does not add it.
22
23use std::collections::HashMap;
24use std::future::Future;
25use std::path::PathBuf;
26use std::pin::Pin;
27use std::sync::{Arc, OnceLock, PoisonError, RwLock};
28
29// NOTE: no serde derives on `NetPolicy` / `ContainerBuildRequest`. The
30// `crate::ToolPlatform` field does not derive Serialize/Deserialize (and this
31// leaf module may not change it), and the request crosses no wire today — the
32// runtime receives it in-process through the trait object. Add serde
33// end-to-end if the request ever needs to serialize.
34
35/// Network policy for a containerized toolchain build.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum NetPolicy {
38    /// No network. All inputs are pre-fetched leaf-side before execution.
39    Deny,
40    /// Full outbound network, loudly logged (last-resort brew/choco nets only).
41    AllowLoud,
42}
43
44/// Everything the runtime needs to execute an
45/// [`InstallPlan`](crate::recipe::InstallPlan) in an isolated container.
46#[derive(Debug, Clone)]
47pub struct ContainerBuildRequest {
48    /// The tool being built (formula name; feeds error surfacing + logs).
49    pub tool: String,
50    /// Target platform of the build (selects Seatbelt vs HCS runtime-side).
51    pub platform: crate::ToolPlatform,
52    /// The parsed install plan whose steps the container executes.
53    pub plan: crate::recipe::InstallPlan,
54    /// Extracted source tree the steps run in.
55    pub src_dir: PathBuf,
56    /// The toolchain install prefix — the ONLY tree the container may populate.
57    pub prefix: PathBuf,
58    /// Scratch dir (rw), conventionally `<prefix>/.build`.
59    pub scratch_dir: PathBuf,
60    /// Dependency toolchain prefixes (read + exec grants; bins prepended to
61    /// `PATH`).
62    pub dep_toolchains: Vec<PathBuf>,
63    /// Pre-fetched resource/patch staging dir (`<scratch>/resources`), when any.
64    pub resources_dir: Option<PathBuf>,
65    /// Environment variables for every step (the plan's accumulated `ENV`).
66    pub env: HashMap<String, String>,
67    /// Extra `PATH` entries, highest priority first.
68    pub path_prefix: Vec<String>,
69    /// Network policy enforced on the container.
70    pub net: NetPolicy,
71}
72
73/// What the runtime hands back after executing a [`ContainerBuildRequest`].
74#[derive(Debug, Clone)]
75pub struct ContainerBuildReport {
76    /// Tail of the combined build log (for error surfacing + coverage records).
77    pub log_tail: String,
78}
79
80/// Implemented by the runtime layer (Seatbelt on macOS, HCS on Windows).
81///
82/// The method is a manually desugared async fn — implementors typically wrap
83/// an async block in [`Box::pin`]. The future borrows both the executor and
84/// the request for `'a`, so no cloning is forced on the implementor.
85pub trait ContainerBuildExecutor: Send + Sync + 'static {
86    /// Execute the request's install plan inside an isolated container,
87    /// populating `req.prefix` and nothing else.
88    fn execute<'a>(
89        &'a self,
90        req: &'a ContainerBuildRequest,
91    ) -> Pin<Box<dyn Future<Output = crate::Result<ContainerBuildReport>> + Send + 'a>>;
92}
93
94/// Process-global executor slot. `OnceLock` lazily creates the `RwLock`;
95/// the `RwLock` makes registration replaceable (tests swap in mocks).
96static EXECUTOR: OnceLock<RwLock<Option<Arc<dyn ContainerBuildExecutor>>>> = OnceLock::new();
97
98/// The lazily-initialized global slot.
99fn executor_slot() -> &'static RwLock<Option<Arc<dyn ContainerBuildExecutor>>> {
100    EXECUTOR.get_or_init(|| RwLock::new(None))
101}
102
103/// Register the process-global container build executor.
104///
105/// Idempotent: calling again replaces the current executor (tests swap in
106/// mocks this way). The runtime layer calls this once at startup; until it
107/// does, [`container_executor`] returns `None` and containerized build paths
108/// fail with [`crate::error::ToolchainError::ExecutorUnavailable`].
109///
110/// Poison-tolerant: the guarded value is a plain pointer swap with no
111/// intermediate states, so a panic in another thread holding the lock cannot
112/// leave it torn — this recovers the inner value instead of propagating the
113/// poison.
114pub fn set_container_executor(exec: Arc<dyn ContainerBuildExecutor>) {
115    let mut slot = executor_slot()
116        .write()
117        .unwrap_or_else(PoisonError::into_inner);
118    *slot = Some(exec);
119}
120
121/// The currently registered container build executor, if any.
122///
123/// `None` means the runtime layer has not registered one — callers needing a
124/// containerized build must then return
125/// [`crate::error::ToolchainError::ExecutorUnavailable`], never fall back to
126/// a host subprocess. Poison-tolerant for the same reason as
127/// [`set_container_executor`].
128#[must_use]
129pub fn container_executor() -> Option<Arc<dyn ContainerBuildExecutor>> {
130    executor_slot()
131        .read()
132        .unwrap_or_else(PoisonError::into_inner)
133        .clone()
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::recipe::InstallPlan;
140    use crate::ToolPlatform;
141
142    /// Mock executor returning a canned report tagged with its identity and
143    /// the request's tool, so assertions prove WHICH executor served WHICH
144    /// request through the trait object.
145    struct MockExecutor {
146        tag: &'static str,
147    }
148
149    impl ContainerBuildExecutor for MockExecutor {
150        fn execute<'a>(
151            &'a self,
152            req: &'a ContainerBuildRequest,
153        ) -> Pin<Box<dyn Future<Output = crate::Result<ContainerBuildReport>> + Send + 'a>>
154        {
155            Box::pin(async move {
156                Ok(ContainerBuildReport {
157                    log_tail: format!("{}:{}", self.tag, req.tool),
158                })
159            })
160        }
161    }
162
163    /// A minimal well-formed request for `tool`.
164    fn request(tool: &str) -> ContainerBuildRequest {
165        ContainerBuildRequest {
166            tool: tool.to_string(),
167            platform: ToolPlatform::MacOS,
168            plan: InstallPlan {
169                steps: vec![],
170                resources: vec![],
171                patches: vec![],
172                env: HashMap::new(),
173                deparallelize: false,
174            },
175            src_dir: PathBuf::from("/tmp/src"),
176            prefix: PathBuf::from("/tmp/prefix"),
177            scratch_dir: PathBuf::from("/tmp/prefix/.build"),
178            dep_toolchains: vec![],
179            resources_dir: None,
180            env: HashMap::new(),
181            path_prefix: vec![],
182            net: NetPolicy::Deny,
183        }
184    }
185
186    /// D5 STATIC GUARD: no code path in this crate may spawn a host `brew`
187    /// or `choco` subprocess. Containerized package-manager builds go through
188    /// the registered [`ContainerBuildExecutor`], and its absence must
189    /// surface as `ExecutorUnavailable` — never a host package-manager
190    /// fallback (the module-level contract above). This test reads every
191    /// `.rs` file under this crate's `src/` at test time and rejects the
192    /// spawn patterns outright.
193    ///
194    /// No allow-list: as of writing the crate has ZERO textual hits, even in
195    /// comments (`brew_emulate.rs` spawns only `git`/`tar` at provision time,
196    /// and its brew argv travels inside a `ContainerBuildRequest` plan as
197    /// data, not through `Command::new`). If a future doc comment ever needs
198    /// to mention the pattern, write it in a form that breaks the contiguous
199    /// byte sequence. The needles here are assembled from pieces at runtime
200    /// for exactly that reason — this test's own source can never match
201    /// itself.
202    #[test]
203    fn no_host_brew_or_choco_subprocess_in_crate_sources() {
204        let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
205        // `Command::new(` + the offending opener, assembled so the pattern
206        // never appears contiguously in THIS file's source text.
207        let spawn = "Command::new(";
208        let q = '"';
209        let needles = [
210            // Literal spawns: `..new("brew")` / `..new("choco")` + exe forms.
211            format!("{spawn}{q}brew{q}"),
212            format!("{spawn}{q}brew.sh{q}"),
213            format!("{spawn}{q}choco{q}"),
214            format!("{spawn}{q}choco.exe{q}"),
215            // Variable-holding spawns: `..new(brew_bin)` / `..new(&brew_bin)`.
216            format!("{spawn}brew_bin"),
217            format!("{spawn}&brew_bin"),
218        ];
219
220        let mut stack = vec![src_root.clone()];
221        let mut scanned = 0usize;
222        let mut offenders: Vec<String> = Vec::new();
223        while let Some(dir) = stack.pop() {
224            let entries = std::fs::read_dir(&dir)
225                .unwrap_or_else(|e| panic!("read_dir({}) failed: {e}", dir.display()));
226            for entry in entries {
227                let path = entry.expect("directory entry").path();
228                if path.is_dir() {
229                    stack.push(path);
230                } else if path.extension().is_some_and(|ext| ext == "rs") {
231                    let text = std::fs::read_to_string(&path)
232                        .unwrap_or_else(|e| panic!("read {} failed: {e}", path.display()));
233                    scanned += 1;
234                    for needle in &needles {
235                        if text.contains(needle.as_str()) {
236                            offenders.push(format!("{}: contains `{needle}`", path.display()));
237                        }
238                    }
239                }
240            }
241        }
242
243        assert!(
244            scanned >= 10,
245            "expected to scan this crate's sources but found only {scanned} .rs files under {}",
246            src_root.display()
247        );
248        assert!(
249            offenders.is_empty(),
250            "host brew/choco subprocess pattern(s) found — containerized builds must go \
251             through the ContainerBuildExecutor, never a host package manager:\n{}",
252            offenders.join("\n")
253        );
254    }
255
256    /// The executor slot is PROCESS-GLOBAL and libtest runs tests in
257    /// parallel, so the "None before registration", "registration
258    /// round-trip", and "replacement wins" assertions all live in this ONE
259    /// sequential test fn — splitting them into separate `#[test]`s would
260    /// race. No other test in this crate may touch the global slot.
261    #[tokio::test]
262    async fn global_slot_none_then_registered_then_replaced() {
263        // (1) None before any registration.
264        assert!(
265            container_executor().is_none(),
266            "no executor must be registered before set_container_executor"
267        );
268
269        // (2) Registration round-trip: register a mock, resolve it through
270        // the global, and call through the trait object.
271        set_container_executor(Arc::new(MockExecutor { tag: "first" }));
272        let exec = container_executor().expect("executor should be registered");
273        let report = exec.execute(&request("git")).await.unwrap();
274        assert_eq!(report.log_tail, "first:git");
275
276        // (3) Replacement: registering a second mock wins.
277        set_container_executor(Arc::new(MockExecutor { tag: "second" }));
278        let exec = container_executor().expect("executor should still be registered");
279        let report = exec.execute(&request("jq")).await.unwrap();
280        assert_eq!(
281            report.log_tail, "second:jq",
282            "the most recently registered executor must serve requests"
283        );
284    }
285}