Skip to main content

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}