zlayer_builder/backend/mod.rs
1//! Build backend abstraction.
2//!
3//! Provides a unified [`BuildBackend`] trait that decouples the build orchestration
4//! logic from the underlying container tooling. Platform-specific implementations
5//! are selected at runtime via [`detect_backend`].
6//!
7//! # Backends
8//!
9//! - [`BuildahBackend`] — wraps the `buildah` CLI (Linux + macOS with buildah installed).
10//! - `SandboxBackend` (macOS-only) — uses the Seatbelt sandbox when buildah is unavailable.
11//! - `HcsBackend` (Windows-only, see [`hcs`]) — native Windows builder via HCS;
12//! wraps `zlayer_agent::windows::{scratch, layer}` to produce OCI images
13//! without Docker Desktop.
14//!
15//! # Target OS routing
16//!
17//! The [`ImageOs`] enum selects which *image* OS we are building for (Linux or
18//! Windows). [`detect_backend`] branches on both the host OS and the target OS:
19//! Windows images can only be built on a Windows host (via the HCS-backed
20//! backend that landed in Phase L-4), while Linux images on Windows hosts
21//! currently require a Linux peer (a WSL2-buildah route is a Phase L
22//! follow-up).
23
24mod buildah;
25pub mod buildah_sidecar;
26#[cfg(target_os = "windows")]
27pub mod hcs;
28pub mod progress;
29#[cfg(target_os = "macos")]
30pub(crate) mod sandbox;
31#[cfg(target_os = "windows")]
32pub mod wsl2;
33
34pub use buildah::BuildahBackend;
35pub use buildah_sidecar::BuildahSidecarBackend;
36#[cfg(target_os = "windows")]
37pub use hcs::HcsBackend;
38#[cfg(target_os = "macos")]
39pub use sandbox::SandboxBackend;
40#[cfg(target_os = "windows")]
41pub use wsl2::Wsl2BuildBackend;
42
43use std::path::Path;
44use std::sync::Arc;
45
46use crate::builder::{BuildOptions, BuiltImage, RegistryAuth};
47use crate::dockerfile::Dockerfile;
48use crate::error::{BuildError, Result};
49use crate::tui::BuildEvent;
50
51/// Operating system of the image being built.
52///
53/// This is distinct from the host OS — a Linux host can only build Linux
54/// images, a Windows host is required to build Windows images (via the
55/// HCS-backed backend landing in Phase L-4), and a macOS host is required to
56/// build Darwin images (via the Seatbelt [`SandboxBackend`]).
57///
58/// Serializes to lowercase (`"linux"` / `"windows"` / `"darwin"`) for
59/// YAML/JSON configs.
60#[derive(
61 Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize,
62)]
63#[serde(rename_all = "lowercase")]
64pub enum ImageOs {
65 #[default]
66 Linux,
67 Windows,
68 /// macOS (Darwin) image — built natively on a macOS host via the Seatbelt
69 /// [`SandboxBackend`], which stamps OCI `os: darwin`.
70 Darwin,
71}
72
73/// Classify image OS from the magic bytes of an entrypoint binary.
74/// Mach-O (cf fa ed fe / fe ed fa cf 64-bit either-endian) and fat (ca fe ba be) -> Darwin;
75/// ELF (7f 45 4c 46) -> Linux; PE (4d 5a / "MZ") -> Windows. Best-effort; None on unknown.
76/// NOTE: ca fe ba be is also Java class-file magic — acceptable since this is only consulted
77/// when no explicit OS is declared and the input is an entrypoint binary, not a .class.
78pub(crate) fn image_os_from_magic(bytes: &[u8]) -> Option<ImageOs> {
79 match bytes {
80 [0xcf, 0xfa, 0xed, 0xfe, ..]
81 | [0xfe, 0xed, 0xfa, 0xcf, ..]
82 | [0xca, 0xfe, 0xba, 0xbe, ..] => Some(ImageOs::Darwin),
83 [0x7f, 0x45, 0x4c, 0x46, ..] => Some(ImageOs::Linux),
84 [0x4d, 0x5a, ..] => Some(ImageOs::Windows),
85 _ => None,
86 }
87}
88
89/// Error returned when parsing an unknown [`ImageOs`] string.
90#[derive(thiserror::Error, Debug)]
91#[error("unknown OS: {0} (expected linux, windows, or darwin)")]
92pub struct ImageOsParseError(pub String);
93
94impl std::str::FromStr for ImageOs {
95 type Err = ImageOsParseError;
96
97 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
98 // Accept bare OS names ("linux", "Windows") AND platform-style strings
99 // ("linux/amd64", "windows/arm64", "darwin/arm64"). Split on '/' first
100 // and match the OS component case-insensitively.
101 let os_part = s.split('/').next().unwrap_or("").trim();
102 match os_part.to_ascii_lowercase().as_str() {
103 "linux" => Ok(ImageOs::Linux),
104 "windows" => Ok(ImageOs::Windows),
105 "darwin" | "macos" => Ok(ImageOs::Darwin),
106 _ => Err(ImageOsParseError(s.to_string())),
107 }
108 }
109}
110
111/// A pluggable build backend.
112///
113/// Implementations handle the low-level mechanics of building, pushing, tagging,
114/// and managing manifest lists for container images.
115#[async_trait::async_trait]
116pub trait BuildBackend: Send + Sync {
117 /// Build a container image from a parsed Dockerfile.
118 ///
119 /// # Arguments
120 ///
121 /// * `context` — path to the build context directory
122 /// * `dockerfile` — parsed Dockerfile IR
123 /// * `options` — build configuration (tags, args, caching, etc.)
124 /// * `event_tx` — optional channel for streaming progress events to a TUI
125 async fn build_image(
126 &self,
127 context: &Path,
128 dockerfile: &Dockerfile,
129 options: &BuildOptions,
130 event_tx: Option<std::sync::mpsc::Sender<BuildEvent>>,
131 ) -> Result<BuiltImage>;
132
133 /// Push an image to a container registry.
134 async fn push_image(&self, tag: &str, auth: Option<&RegistryAuth>) -> Result<()>;
135
136 /// Tag an existing image with a new name.
137 async fn tag_image(&self, image: &str, new_tag: &str) -> Result<()>;
138
139 /// Create a new (empty) manifest list.
140 async fn manifest_create(&self, name: &str) -> Result<()>;
141
142 /// Add an image to an existing manifest list.
143 async fn manifest_add(&self, manifest: &str, image: &str) -> Result<()>;
144
145 /// Push a manifest list (and all referenced images) to a registry.
146 ///
147 /// `auth` carries optional registry credentials (mirrors [`Self::push_image`]);
148 /// when `Some`, they authenticate the manifest-list push.
149 async fn manifest_push(
150 &self,
151 name: &str,
152 destination: &str,
153 auth: Option<&RegistryAuth>,
154 ) -> Result<()>;
155
156 /// Returns `true` if the backend tooling is installed and functional.
157 async fn is_available(&self) -> bool;
158
159 /// Human-readable name for this backend (e.g. `"buildah"`, `"sandbox"`).
160 fn name(&self) -> &'static str;
161
162 /// Export the built image `tag` to an OCI **image-layout archive (tar)** at
163 /// `dest`, in the format [`zlayer_registry::import_image`] consumes
164 /// (`oci-layout` + `index.json` + `blobs/sha256/*`).
165 ///
166 /// Returns `true` if the backend performed the export natively; `false` if it
167 /// has no native export and the caller should fall back to its buildah-based
168 /// `buildah push … oci-archive:` path. This is backend-owned so a non-buildah
169 /// backend — the macOS Seatbelt [`SandboxBackend`] — can populate the local
170 /// registry with no `buildah` binary on the host. The default declines
171 /// (`Ok(false)`), preserving the buildah export for backends that wrap a real
172 /// buildah executor.
173 async fn export_oci_archive(&self, tag: &str, dest: &Path) -> Result<bool> {
174 let _ = (tag, dest);
175 Ok(false)
176 }
177}
178
179/// Auto-detect the best available build backend for the given target OS.
180///
181/// Thin wrapper around [`detect_backend_with_options`] that passes
182/// `options = None`. Use this from call sites that have no access to a
183/// [`BuildOptions`] (early-construction probes, pipeline scaffolding) and
184/// therefore can only consult `ZLAYER_BACKEND` + the auto-detect matrix.
185///
186/// Selection matrix (host × target), when no override is set:
187///
188/// | Host / Target | Linux image | Windows image | Darwin image |
189/// |---------------|--------------------------------------------------------|-------------------------------------------|-------------------------------------------|
190/// | Linux | buildah-sidecar if available, else buildah-cli | Err — requires Windows host | Err — requires macOS host |
191/// | macOS | VZ buildah sidecar / buildah-cli (no sandbox fallback) | Err — requires Windows host | Seatbelt sandbox backend |
192/// | Windows | Err — Linux peer required (WSL2 follow-up) | HCS-backed native Windows builder (L-4) | Err — requires macOS host |
193///
194/// # Errors
195///
196/// Returns an error if the host cannot build images for the requested
197/// `target_os`, or if the selected backend's tooling is missing.
198pub async fn detect_backend(target_os: ImageOs) -> Result<Arc<dyn BuildBackend>> {
199 detect_backend_with_options(target_os, None).await
200}
201
202/// Auto-detect the best available build backend, honoring an optional
203/// per-build override on [`BuildOptions::backend_override`].
204///
205/// Selection precedence:
206///
207/// 1. `BuildOptions::backend_override` (when `options` is `Some`).
208/// 2. `ZLAYER_BACKEND` environment variable.
209/// 3. The host × target auto-detect matrix documented on [`detect_backend`].
210///
211/// When the precedence falls on step 1 or 2 (an explicit operator choice),
212/// the requested backend is **constructed unconditionally** — we do not
213/// silently fall back to a different kind. If the requested backend is
214/// later found to be unavailable, the actual build call will surface the
215/// failure rather than letting auto-fallback mask the operator's intent.
216///
217/// # Errors
218///
219/// Returns [`BuildError::NotSupported`] when the requested (or auto-selected)
220/// backend kind cannot be paired with `target_os` on this host, and bubbles
221/// any constructor error from the chosen backend.
222pub async fn detect_backend_with_options(
223 target_os: ImageOs,
224 options: Option<&BuildOptions>,
225) -> Result<Arc<dyn BuildBackend>> {
226 use zlayer_types::builder::BuilderBackendKind;
227
228 // 1) Explicit override on BuildOptions.
229 if let Some(opts) = options {
230 if let Some(kind) = opts.backend_override {
231 return construct_backend(kind, target_os).await;
232 }
233 }
234
235 // 2) ZLAYER_BACKEND env var. Accepted values match
236 // `BuilderBackendKind::from_str` (buildah / buildah-cli /
237 // buildah-sidecar / sidecar / sandbox / hcs / etc.).
238 if let Ok(env_val) = std::env::var("ZLAYER_BACKEND") {
239 let parsed: BuilderBackendKind =
240 env_val
241 .parse()
242 .map_err(|e: String| BuildError::NotSupported {
243 operation: format!("ZLAYER_BACKEND={env_val}: {e}"),
244 })?;
245 return construct_backend(parsed, target_os).await;
246 }
247
248 // 3) Auto-detect per host × target.
249 #[cfg(target_os = "windows")]
250 {
251 match target_os {
252 ImageOs::Linux => {
253 let backend =
254 Wsl2BuildBackend::new(&zlayer_wsl::distro::configured_distro()).await?;
255 Ok(Arc::new(backend))
256 }
257 ImageOs::Windows => {
258 let backend = HcsBackend::new().await?;
259 Ok(Arc::new(backend))
260 }
261 ImageOs::Darwin => Err(BuildError::NotSupported {
262 operation: "building macOS images requires a macOS host".to_string(),
263 }),
264 }
265 }
266
267 #[cfg(target_os = "macos")]
268 {
269 match target_os {
270 ImageOs::Linux => {
271 // Preferred macOS path: route to a `zlayer-buildd` sidecar
272 // running in a VZ-Linux container when one has been wired up
273 // by the build front-end (env-configured remote addr). This
274 // is the only way to run real Linux Dockerfile `RUN` steps on
275 // a macOS host — native buildah can't, and the Seatbelt
276 // sandbox only covers a narrow subset.
277 if let Some(sidecar) = sidecar_from_env() {
278 if sidecar.is_available().await {
279 return Ok(Arc::new(sidecar));
280 }
281 tracing::warn!(
282 "ZLAYER_BUILDD_ADDR set but sidecar not reachable; \
283 falling back to buildah-cli / sandbox"
284 );
285 }
286 if let Ok(backend) = BuildahBackend::try_new().await {
287 Ok(Arc::new(backend))
288 } else {
289 // No sandbox fallback: the Seatbelt sandbox only produces
290 // macOS-native rootfs, so using it for a Linux target would
291 // mis-resolve the base image. Demand a real Linux builder.
292 Err(BuildError::NotSupported {
293 operation: "building a Linux image on macOS requires the VZ buildah \
294 sidecar (start `zlayer-buildd` / set ZLAYER_BUILDD_ADDR) or a \
295 native buildah; refusing to fall back to the Seatbelt sandbox"
296 .to_string(),
297 })
298 }
299 }
300 ImageOs::Windows => Err(BuildError::NotSupported {
301 operation: "building Windows images requires a Windows host — run this build \
302 on a Windows node of the ZLayer cluster"
303 .to_string(),
304 }),
305 ImageOs::Darwin => Ok(Arc::new(SandboxBackend::default())),
306 }
307 }
308
309 #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
310 {
311 match target_os {
312 ImageOs::Linux => {
313 // Prefer the sidecar backend (Stage 3 default) when it can
314 // actually spawn / dial `zlayer-buildd` and complete a TLS
315 // handshake; otherwise fall back to the CLI shellout. The
316 // sidecar probe is cheap when the binary is missing
317 // (filesystem misses + ZLAYER_BUILDD_BIN check), so this
318 // is fine to run on the hot path.
319 let sidecar = BuildahSidecarBackend::default();
320 if sidecar.is_available().await {
321 Ok(Arc::new(sidecar))
322 } else {
323 tracing::debug!(
324 "buildah-sidecar unavailable on Linux host, falling back to buildah-cli"
325 );
326 let cli = BuildahBackend::new().await?;
327 Ok(Arc::new(cli))
328 }
329 }
330 ImageOs::Windows => Err(BuildError::NotSupported {
331 operation: "building Windows images requires a Windows host — run this build \
332 on a Windows node of the ZLayer cluster"
333 .to_string(),
334 }),
335 ImageOs::Darwin => Err(BuildError::NotSupported {
336 operation: "building macOS images requires a macOS host".to_string(),
337 }),
338 }
339 }
340}
341
342/// Build a [`BuildahSidecarBackend`] from environment configuration, if the
343/// macOS build front-end has wired one up.
344///
345/// The front-end (`zlayer build` on macOS) starts a `zlayer-buildd` in a
346/// VZ-Linux container and exports the wiring through env vars:
347///
348/// - `ZLAYER_BUILDD_ADDR` — `host:port` to dial (required; presence is the
349/// on/off switch).
350/// - `ZLAYER_BUILDD_TLS_DIR` — mTLS material dir (optional; defaults to the
351/// per-user `${data}/buildd`).
352/// - `ZLAYER_BUILDD_CONTEXT_MOUNT` — `HOST_PREFIX:GUEST_PREFIX` so the
353/// backend rewrites context paths to what the in-guest buildah sees.
354///
355/// Returns `None` when `ZLAYER_BUILDD_ADDR` is unset (no managed sidecar).
356#[cfg(target_os = "macos")]
357fn sidecar_from_env() -> Option<BuildahSidecarBackend> {
358 use std::path::PathBuf;
359 use zlayer_types::builder::SidecarConfig;
360
361 let addr = std::env::var("ZLAYER_BUILDD_ADDR").ok()?;
362 let tls_dir = std::env::var("ZLAYER_BUILDD_TLS_DIR")
363 .ok()
364 .map(PathBuf::from);
365 let context_mount = std::env::var("ZLAYER_BUILDD_CONTEXT_MOUNT")
366 .ok()
367 .and_then(|s| {
368 s.split_once(':')
369 .map(|(h, g)| (PathBuf::from(h), PathBuf::from(g)))
370 });
371
372 Some(BuildahSidecarBackend::new(SidecarConfig {
373 addr: Some(addr),
374 tls_dir,
375 context_mount,
376 ..Default::default()
377 }))
378}
379
380/// Construct the backend implementation for the requested
381/// [`BuilderBackendKind`], validating that the kind is meaningful for the
382/// requested image OS.
383///
384/// Compatibility matrix:
385///
386/// - `BuildahCli` ↔ Linux target (Linux/macOS host only)
387/// - `BuildahSidecar` ↔ Linux target (Linux host only)
388/// - `Sandbox` ↔ Linux target (macOS host only — Seatbelt builder)
389/// - `Hcs` ↔ Windows target (Windows host only)
390///
391/// Anything outside that matrix is rejected with
392/// [`BuildError::NotSupported`] so the operator gets a clear error instead
393/// of a silent fallback.
394#[cfg_attr(windows, allow(clippy::needless_return))]
395#[allow(clippy::too_many_lines)]
396async fn construct_backend(
397 kind: zlayer_types::builder::BuilderBackendKind,
398 target_os: ImageOs,
399) -> Result<Arc<dyn BuildBackend>> {
400 use zlayer_types::builder::BuilderBackendKind;
401
402 match kind {
403 BuilderBackendKind::BuildahCli => {
404 if target_os != ImageOs::Linux {
405 return Err(BuildError::NotSupported {
406 operation: format!(
407 "buildah-cli backend can only build Linux images, requested target_os={target_os:?}"
408 ),
409 });
410 }
411 #[cfg(target_os = "windows")]
412 {
413 return Err(BuildError::NotSupported {
414 operation: "buildah-cli backend is not available on Windows hosts \
415 (requires a Linux peer)"
416 .to_string(),
417 });
418 }
419 #[cfg(not(target_os = "windows"))]
420 {
421 let backend = BuildahBackend::new().await?;
422 Ok(Arc::new(backend))
423 }
424 }
425 BuilderBackendKind::BuildahSidecar => {
426 if target_os != ImageOs::Linux {
427 return Err(BuildError::NotSupported {
428 operation: format!(
429 "buildah-sidecar backend can only build Linux images, requested target_os={target_os:?}"
430 ),
431 });
432 }
433 // Linux hosts spawn a local sidecar; macOS hosts dial a
434 // `zlayer-buildd` running in a VZ-Linux container (env-wired by
435 // the `zlayer build` front-end). Windows has no sidecar path.
436 #[cfg(target_os = "linux")]
437 {
438 Ok(Arc::new(BuildahSidecarBackend::default()))
439 }
440 #[cfg(target_os = "macos")]
441 {
442 // Honor the env-configured remote sidecar when present so an
443 // explicit `--backend buildah-sidecar` / `ZLAYER_BACKEND`
444 // dials the managed VZ buildd instead of trying to spawn one
445 // locally (which can't run on macOS).
446 Ok(Arc::new(sidecar_from_env().unwrap_or_default()))
447 }
448 #[cfg(all(not(target_os = "linux"), not(target_os = "macos")))]
449 {
450 Err(BuildError::NotSupported {
451 operation: "buildah-sidecar backend is not available on this host \
452 (zlayer-buildd runs on Linux or in a macOS VZ container)"
453 .to_string(),
454 })
455 }
456 }
457 BuilderBackendKind::Sandbox => {
458 if target_os != ImageOs::Darwin {
459 return Err(BuildError::NotSupported {
460 operation: format!(
461 "macOS sandbox backend can only build Darwin (macOS) images, requested target_os={target_os:?}"
462 ),
463 });
464 }
465 #[cfg(target_os = "macos")]
466 {
467 Ok(Arc::new(SandboxBackend::default()))
468 }
469 #[cfg(not(target_os = "macos"))]
470 {
471 Err(BuildError::NotSupported {
472 operation: "sandbox backend is only available on macOS hosts".to_string(),
473 })
474 }
475 }
476 BuilderBackendKind::Wsl2Buildah => {
477 if target_os != ImageOs::Linux {
478 return Err(BuildError::NotSupported {
479 operation: format!(
480 "WSL2 buildah backend can only build Linux images, requested target_os={target_os:?}"
481 ),
482 });
483 }
484 #[cfg(target_os = "windows")]
485 {
486 let backend =
487 Wsl2BuildBackend::new(&zlayer_wsl::distro::configured_distro()).await?;
488 Ok(Arc::new(backend))
489 }
490 #[cfg(not(target_os = "windows"))]
491 {
492 Err(BuildError::NotSupported {
493 operation: "WSL2 buildah backend is only available on Windows hosts"
494 .to_string(),
495 })
496 }
497 }
498 BuilderBackendKind::Hcs => {
499 if target_os != ImageOs::Windows {
500 return Err(BuildError::NotSupported {
501 operation: format!(
502 "HCS backend can only build Windows images, requested target_os={target_os:?}"
503 ),
504 });
505 }
506 #[cfg(target_os = "windows")]
507 {
508 let backend = HcsBackend::new().await?;
509 Ok(Arc::new(backend))
510 }
511 #[cfg(not(target_os = "windows"))]
512 {
513 Err(BuildError::NotSupported {
514 operation: "HCS backend is only available on Windows hosts".to_string(),
515 })
516 }
517 }
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use zlayer_types::builder::BuilderBackendKind;
525
526 #[test]
527 fn image_os_parses_simple_and_slash_form() {
528 assert_eq!("linux".parse::<ImageOs>().unwrap(), ImageOs::Linux);
529 assert_eq!("Linux".parse::<ImageOs>().unwrap(), ImageOs::Linux);
530 assert_eq!("windows".parse::<ImageOs>().unwrap(), ImageOs::Windows);
531 assert_eq!("linux/amd64".parse::<ImageOs>().unwrap(), ImageOs::Linux);
532 assert_eq!(
533 "windows/amd64".parse::<ImageOs>().unwrap(),
534 ImageOs::Windows
535 );
536 // Darwin is now a first-class variant; accept "darwin", the "macos"
537 // alias, mixed case, and the platform-style slash form.
538 assert_eq!("darwin".parse::<ImageOs>().unwrap(), ImageOs::Darwin);
539 assert_eq!("macos".parse::<ImageOs>().unwrap(), ImageOs::Darwin);
540 assert_eq!("Darwin".parse::<ImageOs>().unwrap(), ImageOs::Darwin);
541 assert_eq!("darwin/arm64".parse::<ImageOs>().unwrap(), ImageOs::Darwin);
542 assert!("plan9".parse::<ImageOs>().is_err());
543 }
544
545 #[test]
546 fn image_os_serde_round_trip() {
547 assert_eq!(
548 serde_json::to_string(&ImageOs::Darwin).unwrap(),
549 "\"darwin\""
550 );
551 assert_eq!(
552 serde_json::from_str::<ImageOs>("\"darwin\"").unwrap(),
553 ImageOs::Darwin
554 );
555 }
556
557 #[test]
558 fn image_os_from_magic_classifies_binaries() {
559 // Mach-O 64-bit little-endian.
560 assert_eq!(
561 image_os_from_magic(&[0xcf, 0xfa, 0xed, 0xfe, 0x07, 0x00]),
562 Some(ImageOs::Darwin)
563 );
564 // Mach-O 64-bit big-endian.
565 assert_eq!(
566 image_os_from_magic(&[0xfe, 0xed, 0xfa, 0xcf]),
567 Some(ImageOs::Darwin)
568 );
569 // Mach-O fat / universal binary.
570 assert_eq!(
571 image_os_from_magic(&[0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00, 0x00, 0x02]),
572 Some(ImageOs::Darwin)
573 );
574 // ELF.
575 assert_eq!(
576 image_os_from_magic(&[0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01]),
577 Some(ImageOs::Linux)
578 );
579 // PE / "MZ".
580 assert_eq!(
581 image_os_from_magic(&[0x4d, 0x5a, 0x90, 0x00]),
582 Some(ImageOs::Windows)
583 );
584 // Junk / unknown.
585 assert_eq!(image_os_from_magic(&[0x00, 0x01, 0x02, 0x03]), None);
586 assert_eq!(image_os_from_magic(&[]), None);
587 }
588
589 /// `construct_backend` must reject (kind × `target_os`) pairs that cannot
590 /// possibly succeed regardless of host. The full host-platform matrix is
591 /// covered in the cfg-gated branches inside `construct_backend`; this
592 /// test pins the cross-target rejections so a future refactor cannot
593 /// silently let `Hcs` accept a Linux target (or vice versa).
594 #[tokio::test]
595 async fn construct_backend_rejects_mismatched_target_os() {
596 // HCS is Windows-only.
597 fn assert_not_supported(result: Result<Arc<dyn BuildBackend>>, label: &str) {
598 match result {
599 Ok(_) => panic!("{label}: expected NotSupported, got Ok"),
600 Err(BuildError::NotSupported { .. }) => {}
601 Err(other) => panic!("{label}: expected NotSupported, got: {other:?}"),
602 }
603 }
604
605 // HCS is Windows-only.
606 assert_not_supported(
607 construct_backend(BuilderBackendKind::Hcs, ImageOs::Linux).await,
608 "HCS + Linux target",
609 );
610
611 // Buildah CLI is Linux-image only.
612 assert_not_supported(
613 construct_backend(BuilderBackendKind::BuildahCli, ImageOs::Windows).await,
614 "BuildahCli + Windows target",
615 );
616
617 // Buildah sidecar is Linux-image only.
618 assert_not_supported(
619 construct_backend(BuilderBackendKind::BuildahSidecar, ImageOs::Windows).await,
620 "BuildahSidecar + Windows target",
621 );
622
623 // Sandbox is Darwin-image only (and macOS-host only): both Windows and
624 // Linux targets are now rejected.
625 assert_not_supported(
626 construct_backend(BuilderBackendKind::Sandbox, ImageOs::Windows).await,
627 "Sandbox + Windows target",
628 );
629 assert_not_supported(
630 construct_backend(BuilderBackendKind::Sandbox, ImageOs::Linux).await,
631 "Sandbox + Linux target",
632 );
633
634 // The non-sandbox backends must all reject a Darwin target — only the
635 // Seatbelt sandbox can build macOS images.
636 assert_not_supported(
637 construct_backend(BuilderBackendKind::Hcs, ImageOs::Darwin).await,
638 "HCS + Darwin target",
639 );
640 assert_not_supported(
641 construct_backend(BuilderBackendKind::BuildahCli, ImageOs::Darwin).await,
642 "BuildahCli + Darwin target",
643 );
644 assert_not_supported(
645 construct_backend(BuilderBackendKind::BuildahSidecar, ImageOs::Darwin).await,
646 "BuildahSidecar + Darwin target",
647 );
648 }
649
650 /// On a macOS host, auto-detecting a backend for a Darwin target must yield
651 /// the Seatbelt sandbox backend (whose `name()` is `"sandbox"`).
652 #[cfg(target_os = "macos")]
653 #[tokio::test]
654 async fn detect_backend_darwin_target_uses_sandbox_on_macos() {
655 let backend = detect_backend(ImageOs::Darwin)
656 .await
657 .expect("Darwin target on macOS host should yield the sandbox backend");
658 assert_eq!(backend.name(), "sandbox");
659 }
660
661 /// Smoke test for the Linux × Linux precedence: with `zlayer-buildd`
662 /// missing from PATH, `detect_backend(ImageOs::Linux)` must fall back to
663 /// the CLI backend (`BuildahBackend`). Gated behind `#[ignore]` because
664 /// it mutates process-wide environment variables.
665 ///
666 /// Run manually with:
667 /// `cargo test -p zlayer-builder --lib backend::tests::detect_backend_falls_back_to_cli_when_sidecar_missing -- --ignored --nocapture`
668 #[cfg(target_os = "linux")]
669 #[tokio::test]
670 #[ignore = "mutates PATH / ZLAYER_BUILDD_BIN / ZLAYER_DATA_DIR; serialize manually"]
671 #[allow(unsafe_code, clippy::await_holding_lock)]
672 async fn detect_backend_falls_back_to_cli_when_sidecar_missing() {
673 let _g = crate::TEST_ENV_LOCK
674 .lock()
675 .unwrap_or_else(std::sync::PoisonError::into_inner);
676
677 let prev_path = std::env::var_os("PATH");
678 let prev_buildd = std::env::var_os("ZLAYER_BUILDD_BIN");
679 let prev_data = std::env::var_os("ZLAYER_DATA_DIR");
680 let prev_backend = std::env::var_os("ZLAYER_BACKEND");
681
682 let tmp = tempfile::tempdir().expect("tempdir");
683
684 // SAFETY: env mutation is serialized by `TEST_ENV_LOCK`.
685 unsafe {
686 std::env::remove_var("ZLAYER_BUILDD_BIN");
687 std::env::remove_var("ZLAYER_BACKEND");
688 std::env::set_var("PATH", tmp.path());
689 std::env::set_var("ZLAYER_DATA_DIR", tmp.path());
690 }
691
692 let result = detect_backend(ImageOs::Linux).await;
693
694 // SAFETY: env mutation is serialized by `TEST_ENV_LOCK`.
695 unsafe {
696 match prev_path {
697 Some(v) => std::env::set_var("PATH", v),
698 None => std::env::remove_var("PATH"),
699 }
700 match prev_buildd {
701 Some(v) => std::env::set_var("ZLAYER_BUILDD_BIN", v),
702 None => std::env::remove_var("ZLAYER_BUILDD_BIN"),
703 }
704 match prev_data {
705 Some(v) => std::env::set_var("ZLAYER_DATA_DIR", v),
706 None => std::env::remove_var("ZLAYER_DATA_DIR"),
707 }
708 match prev_backend {
709 Some(v) => std::env::set_var("ZLAYER_BACKEND", v),
710 None => std::env::remove_var("ZLAYER_BACKEND"),
711 }
712 }
713
714 // The CLI fallback requires `buildah` on PATH to construct; if the
715 // test box has no buildah at all, both backends are unavailable and
716 // we can only assert the type didn't pick the sidecar.
717 match result {
718 Ok(backend) => assert_eq!(
719 backend.name(),
720 "buildah",
721 "expected CLI fallback ('buildah'), got: {}",
722 backend.name(),
723 ),
724 Err(e) => {
725 // Acceptable: no buildah binary on this box either.
726 eprintln!("detect_backend returned err (no buildah on PATH?): {e}");
727 }
728 }
729 }
730}