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}