Skip to main content

zlayer_builder/zimage/
types.rs

1//! `ZImagefile` types - YAML-based image build format
2//!
3//! This module defines all serde-deserializable types for the `ZImagefile` format,
4//! an alternative to Dockerfiles using YAML syntax. The format supports four
5//! mutually exclusive build modes:
6//!
7//! 1. **Runtime template** - shorthand like `runtime: node22`
8//! 2. **Single-stage** - `base:` or `build:` + `steps:` at top level
9//! 3. **Multi-stage** - `stages:` map (`IndexMap` for insertion order, last = output)
10//! 4. **WASM** - `wasm:` configuration for WebAssembly builds
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use indexmap::IndexMap;
16use serde::{Deserialize, Serialize};
17
18use crate::backend::ImageOs;
19
20// ---------------------------------------------------------------------------
21// Build context (for `build:` directive)
22// ---------------------------------------------------------------------------
23
24/// Build context for building a base image from a local Dockerfile or `ZImagefile`.
25///
26/// Supports two forms:
27///
28/// ```yaml
29/// # Short form: just a path (defaults to auto-detecting build file)
30/// build: "."
31///
32/// # Long form: explicit configuration
33/// build:
34///   context: "./subdir"     # or use `workdir:` (context is an alias)
35///   file: "ZImagefile.prod" # specific build file
36///   args:
37///     RUST_VERSION: "1.90"
38/// ```
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(untagged)]
41pub enum ZBuildContext {
42    /// Short form: just a path to the build context directory.
43    Short(String),
44    /// Long form: explicit build configuration.
45    Full {
46        /// Build context directory. Defaults to the current working directory.
47        /// `context` is accepted as an alias for Docker Compose compatibility.
48        #[serde(alias = "context", default)]
49        workdir: Option<String>,
50        /// Path to the build file (Dockerfile or `ZImagefile`).
51        /// Auto-detected if omitted (prefers `ZImagefile` over Dockerfile).
52        #[serde(default, skip_serializing_if = "Option::is_none")]
53        file: Option<String>,
54        /// Build arguments passed to the nested build.
55        #[serde(default, skip_serializing_if = "HashMap::is_empty")]
56        args: HashMap<String, String>,
57    },
58}
59
60impl ZBuildContext {
61    /// Resolve the build context directory relative to a base path.
62    #[must_use]
63    pub fn context_dir(&self, base: &Path) -> PathBuf {
64        match self {
65            Self::Short(path) => base.join(path),
66            Self::Full { workdir, .. } => match workdir {
67                Some(dir) => base.join(dir),
68                None => base.to_path_buf(),
69            },
70        }
71    }
72
73    /// Get the explicit build file path, if specified.
74    #[must_use]
75    pub fn file(&self) -> Option<&str> {
76        match self {
77            Self::Short(_) => None,
78            Self::Full { file, .. } => file.as_deref(),
79        }
80    }
81
82    /// Get build arguments.
83    #[must_use]
84    pub fn args(&self) -> HashMap<String, String> {
85        match self {
86            Self::Short(_) => HashMap::new(),
87            Self::Full { args, .. } => args.clone(),
88        }
89    }
90}
91
92// ---------------------------------------------------------------------------
93// Isolation (friendly alias enum)
94// ---------------------------------------------------------------------------
95
96/// Friendly isolation alias accepted in a `ZImagefile` via the `isolation:` key.
97///
98/// These map to the canonical [`zlayer_types::spec::RuntimeIsolation`] (see
99/// [`ZImage::resolve_isolation`]). In addition to the canonical variants this
100/// enum accepts two convenience aliases: `native` (a synonym for `sandbox`) and
101/// `container` (resolved to `vz`/`vz-linux` based on the target OS).
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "kebab-case")]
104pub enum ZIsolation {
105    /// Let the runtime pick the isolation (no label stamped).
106    Auto,
107    /// Native OS sandbox (Seatbelt on macOS, etc.).
108    Sandbox,
109    /// Apple Virtualization.framework microVM.
110    Vz,
111    /// VZ-backed Linux microVM.
112    VzLinux,
113    /// Full hardware VM.
114    Vm,
115    /// Alias for [`ZIsolation::Sandbox`].
116    Native,
117    /// "Run a Linux container": resolves to `vz` on Darwin targets, else `vz-linux`.
118    Container,
119}
120
121// ---------------------------------------------------------------------------
122// Top-level ZImage
123// ---------------------------------------------------------------------------
124
125/// Top-level `ZImagefile` representation.
126///
127/// Exactly one of the four mode fields must be set:
128/// - `runtime` for runtime template shorthand
129/// - `base` + `steps` for single-stage builds
130/// - `stages` for multi-stage builds
131/// - `wasm` for WebAssembly component builds
132///
133/// Common image metadata fields (env, workdir, expose, cmd, etc.) apply to
134/// the final output image regardless of mode.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct ZImage {
138    /// `ZImagefile` format version (currently must be "1")
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub version: Option<String>,
141
142    // -- Mode 1: runtime template shorthand --
143    /// Runtime template name, e.g. "node22", "python313", "rust"
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub runtime: Option<String>,
146
147    // -- Mode 2: single-stage --
148    /// Base image for single-stage builds (e.g. "alpine:3.19").
149    /// Mutually exclusive with `build`.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub base: Option<String>,
152
153    /// Build a base image from a local Dockerfile/ZImagefile context.
154    /// Mutually exclusive with `base`.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub build: Option<ZBuildContext>,
157
158    /// Build steps for single-stage mode
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub steps: Vec<ZStep>,
161
162    /// Target platform for single-stage mode (e.g. "linux/amd64")
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub platform: Option<String>,
165
166    /// Target OS for this image/stage. Overrides any OS inferred from `platform`. Default: inferred or Linux.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub os: Option<ImageOs>,
169
170    /// Runtime isolation hint for the output image. Friendly aliases map to the
171    /// canonical [`zlayer_types::spec::RuntimeIsolation`] (see
172    /// [`ZImage::resolve_isolation`]); the resolved value is stamped into image
173    /// labels under `com.zlayer.isolation`.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub isolation: Option<ZIsolation>,
176
177    // -- Mode 3: multi-stage --
178    /// Named stages for multi-stage builds. Insertion order is preserved;
179    /// the last stage is the output image.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub stages: Option<IndexMap<String, ZStage>>,
182
183    // -- Mode 4: WASM --
184    /// WebAssembly build configuration
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub wasm: Option<ZWasmConfig>,
187
188    // -- Common image metadata --
189    /// Environment variables applied to the final image
190    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
191    pub env: HashMap<String, String>,
192
193    /// Working directory for the final image
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub workdir: Option<String>,
196
197    /// Ports to expose
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub expose: Option<ZExpose>,
200
201    /// Default command (CMD equivalent)
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub cmd: Option<ZCommand>,
204
205    /// Entrypoint command
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub entrypoint: Option<ZCommand>,
208
209    /// User to run as in the final image
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub user: Option<String>,
212
213    /// Image labels / metadata
214    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
215    pub labels: HashMap<String, String>,
216
217    /// Volume mount points
218    #[serde(default, skip_serializing_if = "Vec::is_empty")]
219    pub volumes: Vec<String>,
220
221    /// Healthcheck configuration
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub healthcheck: Option<ZHealthcheck>,
224
225    /// Signal to send when stopping the container
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub stopsignal: Option<String>,
228
229    /// Build arguments (name -> default value)
230    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
231    pub args: HashMap<String, String>,
232}
233
234impl ZImage {
235    /// Resolve the target OS for this image from the explicit `os:` field,
236    /// falling back to the OS portion of `platform:` if present, else `None`.
237    ///
238    /// Returns `None` when neither field gives a definitive answer — callers
239    /// should interpret that as "default to Linux" (i.e. the historical
240    /// behaviour for all existing `ZImagefiles`).
241    ///
242    /// A malformed `platform:` value (one that does not parse to a known
243    /// [`ImageOs`]) is ignored here on purpose: `platform:` has historically
244    /// been a free-form string and arch-only values like `"amd64"` must
245    /// continue to work. Callers wanting strict validation should use
246    /// [`ImageOs::from_str`] directly.
247    #[must_use]
248    pub fn resolve_target_os(&self) -> Option<ImageOs> {
249        if let Some(os) = self.os {
250            return Some(os);
251        }
252        if let Some(platform) = self.platform.as_deref() {
253            if let Ok(os) = platform.parse::<ImageOs>() {
254                return Some(os);
255            }
256        }
257        None
258    }
259
260    /// Resolve the friendly `isolation:` alias to the canonical
261    /// [`zlayer_types::spec::RuntimeIsolation`].
262    ///
263    /// Returns `None` when no `isolation:` was declared. `native` is a synonym
264    /// for `sandbox`; `container` resolves to `vz` on Darwin targets (so a Linux
265    /// container runs under a VZ Linux microVM on macOS) and `vz-linux`
266    /// otherwise.
267    #[must_use]
268    pub fn resolve_isolation(&self) -> Option<zlayer_types::spec::RuntimeIsolation> {
269        use crate::backend::ImageOs;
270        use zlayer_types::spec::RuntimeIsolation as RI;
271        self.isolation.map(|z| match z {
272            ZIsolation::Auto => RI::Auto,
273            ZIsolation::Sandbox | ZIsolation::Native => RI::Sandbox,
274            ZIsolation::Vz => RI::Vz,
275            ZIsolation::VzLinux => RI::VzLinux,
276            ZIsolation::Vm => RI::Vm,
277            ZIsolation::Container => match self.resolve_target_os() {
278                Some(ImageOs::Darwin) => RI::Vz,
279                _ => RI::VzLinux,
280            },
281        })
282    }
283}
284
285/// Best-effort detection of the image OS from the entrypoint binary's magic bytes,
286/// used only when the `ZImagefile` declares no explicit `os:`/`platform:`. Resolves the
287/// entrypoint (else cmd) first token to a destination path, finds the COPY step whose
288/// `to` lands that path, reads the source file under `context`, and classifies via
289/// [`crate::backend::image_os_from_magic`]. Returns `None` on any miss.
290///
291/// This is intentionally pure and defensive: a missing entrypoint, a destination that
292/// no COPY step produces, an unreadable/absent source file, or unrecognised magic bytes
293/// all yield `None` (callers then fall back to their default-Linux behaviour). It never
294/// panics.
295#[must_use]
296pub fn detect_image_os_from_binary(
297    zimage: &ZImage,
298    context: &std::path::Path,
299) -> Option<crate::backend::ImageOs> {
300    // 1. Resolve the entrypoint program path (prefer entrypoint, else cmd); first token.
301    let cmd = zimage.entrypoint.as_ref().or(zimage.cmd.as_ref())?;
302    let prog = command_first_token(cmd)?;
303    if prog.is_empty() {
304        return None;
305    }
306    let prog_path = Path::new(&prog);
307    let prog_base = prog_path.file_name()?;
308    let prog_parent = prog_path.parent();
309
310    // 2. Find a ZStep whose `to` equals (or is the parent dir of) that program path,
311    //    then try each of its COPY sources joined under `context`.
312    for step in &zimage.steps {
313        let (Some(to), Some(copy)) = (step.to.as_deref(), step.copy.as_ref()) else {
314            continue;
315        };
316        // Trim a trailing slash so a directory `to:` compares cleanly against a path.
317        let to_norm = Path::new(to.trim_end_matches('/'));
318        let exact = to_norm == prog_path;
319        let is_parent_dir = prog_parent.is_some_and(|p| !p.as_os_str().is_empty() && to_norm == p);
320        if !exact && !is_parent_dir {
321            continue;
322        }
323
324        for src in copy.to_vec() {
325            let base = context.join(&src);
326            // 3. Read magic from the first candidate that exists. When `to` is a
327            //    directory, the binary lives under it as `<source>/<basename>`; when
328            //    `to` names the file directly, the source itself is the binary.
329            for candidate in [base.clone(), base.join(prog_base)] {
330                if let Some(os) = read_magic_and_classify(&candidate) {
331                    return Some(os);
332                }
333            }
334        }
335    }
336
337    None
338}
339
340/// Extract the program name (first token) from a [`ZCommand`]: the first element of an
341/// exec-form array, or the first whitespace-delimited token of a shell-form string.
342fn command_first_token(cmd: &ZCommand) -> Option<String> {
343    match cmd {
344        ZCommand::Exec(v) => v.first().cloned(),
345        ZCommand::Shell(s) => s.split_whitespace().next().map(ToString::to_string),
346    }
347}
348
349/// Read up to the first 8 bytes of `path` (if it is a readable regular file) and classify
350/// the image OS from its magic bytes. Any I/O error, directory, or empty/unknown file
351/// yields `None`.
352fn read_magic_and_classify(path: &Path) -> Option<crate::backend::ImageOs> {
353    use std::io::Read;
354
355    if !path.is_file() {
356        return None;
357    }
358    let mut file = std::fs::File::open(path).ok()?;
359    let mut buf = [0u8; 8];
360    let n = file.read(&mut buf).ok()?;
361    if n == 0 {
362        return None;
363    }
364    crate::backend::image_os_from_magic(&buf[..n])
365}
366
367// ---------------------------------------------------------------------------
368// Stage
369// ---------------------------------------------------------------------------
370
371/// A single build stage in a multi-stage `ZImagefile`.
372#[derive(Debug, Clone, Serialize, Deserialize)]
373#[serde(deny_unknown_fields)]
374pub struct ZStage {
375    /// Base image for this stage (e.g. "node:22-alpine").
376    /// Mutually exclusive with `build`. One of `base` or `build` must be set.
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub base: Option<String>,
379
380    /// Build a base image from a local Dockerfile/ZImagefile context.
381    /// Mutually exclusive with `base`. One of `base` or `build` must be set.
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub build: Option<ZBuildContext>,
384
385    /// Target platform override (e.g. "linux/arm64")
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub platform: Option<String>,
388
389    /// Target OS for this image/stage. Overrides any OS inferred from `platform`. Default: inferred or Linux.
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub os: Option<ImageOs>,
392
393    /// Runtime isolation hint for this stage. Accepted for parity with the
394    /// top-level `isolation:`; only the top-level/output value is stamped into
395    /// image labels.
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub isolation: Option<ZIsolation>,
398
399    /// Build arguments scoped to this stage
400    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
401    pub args: HashMap<String, String>,
402
403    /// Environment variables for this stage
404    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
405    pub env: HashMap<String, String>,
406
407    /// Working directory for this stage
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub workdir: Option<String>,
410
411    /// Ordered build steps
412    #[serde(default, skip_serializing_if = "Vec::is_empty")]
413    pub steps: Vec<ZStep>,
414
415    /// Labels for this stage
416    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
417    pub labels: HashMap<String, String>,
418
419    /// Ports to expose
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub expose: Option<ZExpose>,
422
423    /// User to run as
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub user: Option<String>,
426
427    /// Entrypoint command
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub entrypoint: Option<ZCommand>,
430
431    /// Default command
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub cmd: Option<ZCommand>,
434
435    /// Volume mount points
436    #[serde(default, skip_serializing_if = "Vec::is_empty")]
437    pub volumes: Vec<String>,
438
439    /// Healthcheck configuration
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub healthcheck: Option<ZHealthcheck>,
442
443    /// Signal to send when stopping the container
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub stopsignal: Option<String>,
446}
447
448// ---------------------------------------------------------------------------
449// Step
450// ---------------------------------------------------------------------------
451
452/// A single build instruction within a stage.
453///
454/// Exactly one of the action fields (`run`, `copy`, `add`, `env`, `workdir`,
455/// `user`) should be set, with one exception for `env`:
456///
457/// - `env` alone sets persistent environment variables on the image (Dockerfile
458///   `ENV` semantics).
459/// - `env` alongside `run` is allowed and acts as a transient per-RUN modifier:
460///   the env vars are exported only for that single RUN command and are NOT
461///   baked into the resulting image. This is useful for build-time secrets or
462///   knobs (e.g. `CARGO_NET_OFFLINE=true`) that should not persist at runtime.
463///
464/// `env` combined with any other action field (`copy`, `add`, `workdir`, `user`)
465/// is rejected. The remaining fields (`to`, `from`, `owner`, `chmod`, `cache`)
466/// are modifiers that apply to the chosen action.
467#[derive(Debug, Clone, Serialize, Deserialize)]
468#[serde(deny_unknown_fields)]
469pub struct ZStep {
470    // -- Mutually exclusive action fields --
471    /// Shell command or exec-form command to run
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub run: Option<ZCommand>,
474
475    /// Source path(s) to copy into the image
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub copy: Option<ZCopySources>,
478
479    /// Source path(s) to add (supports URLs and auto-extraction)
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub add: Option<ZCopySources>,
482
483    /// Environment variables to set
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub env: Option<HashMap<String, String>>,
486
487    /// Change working directory
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub workdir: Option<String>,
490
491    /// Change user
492    #[serde(default, skip_serializing_if = "Option::is_none")]
493    pub user: Option<String>,
494
495    // -- Shared modifier fields --
496    /// Destination path (for copy/add actions)
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub to: Option<String>,
499
500    /// Source stage name for cross-stage copy (replaces `--from`)
501    #[serde(default, skip_serializing_if = "Option::is_none")]
502    pub from: Option<String>,
503
504    /// File ownership (replaces `--chown`)
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub owner: Option<String>,
507
508    /// File permissions (replaces `--chmod`)
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub chmod: Option<String>,
511
512    /// Cache mounts for RUN steps
513    #[serde(default, skip_serializing_if = "Vec::is_empty")]
514    pub cache: Vec<ZCacheMount>,
515}
516
517// ---------------------------------------------------------------------------
518// Cache mount
519// ---------------------------------------------------------------------------
520
521/// A cache mount specification for RUN steps.
522///
523/// Maps to `--mount=type=cache` in Dockerfile/buildah syntax.
524#[derive(Debug, Clone, Serialize, Deserialize)]
525#[serde(deny_unknown_fields)]
526pub struct ZCacheMount {
527    /// Target path inside the container where the cache is mounted
528    pub target: String,
529
530    /// Cache identifier (shared across builds with the same id)
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    pub id: Option<String>,
533
534    /// Sharing mode: "locked", "shared", or "private"
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub sharing: Option<String>,
537
538    /// Whether the mount is read-only
539    #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
540    pub readonly: bool,
541}
542
543// ---------------------------------------------------------------------------
544// Command (shell string or exec array)
545// ---------------------------------------------------------------------------
546
547/// A command that can be specified as either a shell string or an exec-form
548/// array of strings.
549///
550/// # YAML Examples
551///
552/// Shell form:
553/// ```yaml
554/// run: "apt-get update && apt-get install -y curl"
555/// ```
556///
557/// Exec form:
558/// ```yaml
559/// cmd: ["node", "server.js"]
560/// ```
561#[derive(Debug, Clone, Serialize, Deserialize)]
562#[serde(untagged)]
563pub enum ZCommand {
564    /// Shell form - passed to `/bin/sh -c`
565    Shell(String),
566    /// Exec form - executed directly
567    Exec(Vec<String>),
568}
569
570// ---------------------------------------------------------------------------
571// Copy sources
572// ---------------------------------------------------------------------------
573
574/// Source specification for copy/add steps. Can be a single path or multiple.
575///
576/// # YAML Examples
577///
578/// Single source:
579/// ```yaml
580/// copy: "package.json"
581/// ```
582///
583/// Multiple sources:
584/// ```yaml
585/// copy: ["package.json", "package-lock.json"]
586/// ```
587#[derive(Debug, Clone, Serialize, Deserialize)]
588#[serde(untagged)]
589pub enum ZCopySources {
590    /// A single source path
591    Single(String),
592    /// Multiple source paths
593    Multiple(Vec<String>),
594}
595
596impl ZCopySources {
597    /// Convert to a vector of source paths regardless of variant.
598    #[must_use]
599    pub fn to_vec(&self) -> Vec<String> {
600        match self {
601            Self::Single(s) => vec![s.clone()],
602            Self::Multiple(v) => v.clone(),
603        }
604    }
605}
606
607// ---------------------------------------------------------------------------
608// Expose
609// ---------------------------------------------------------------------------
610
611/// Port exposure specification. Can be a single port or multiple port specs.
612///
613/// # YAML Examples
614///
615/// Single port:
616/// ```yaml
617/// expose: 8080
618/// ```
619///
620/// Multiple ports with optional protocol:
621/// ```yaml
622/// expose:
623///   - 8080
624///   - "9090/udp"
625/// ```
626#[derive(Debug, Clone, Serialize, Deserialize)]
627#[serde(untagged)]
628pub enum ZExpose {
629    /// A single port number
630    Single(u16),
631    /// Multiple port specifications
632    Multiple(Vec<ZPortSpec>),
633}
634
635// ---------------------------------------------------------------------------
636// Port spec
637// ---------------------------------------------------------------------------
638
639/// A single port specification, either a bare port number or a port with
640/// protocol suffix.
641///
642/// # YAML Examples
643///
644/// ```yaml
645/// - 8080        # bare number, defaults to TCP
646/// - "8080/tcp"  # explicit TCP
647/// - "53/udp"    # explicit UDP
648/// ```
649#[derive(Debug, Clone, Serialize, Deserialize)]
650#[serde(untagged)]
651pub enum ZPortSpec {
652    /// Bare port number (defaults to TCP)
653    Number(u16),
654    /// Port with protocol, e.g. "8080/tcp" or "53/udp"
655    WithProtocol(String),
656}
657
658// ---------------------------------------------------------------------------
659// Healthcheck
660// ---------------------------------------------------------------------------
661
662/// Healthcheck configuration for the container.
663///
664/// # YAML Example
665///
666/// ```yaml
667/// healthcheck:
668///   cmd: "curl -f http://localhost:8080/health || exit 1"
669///   interval: "30s"
670///   timeout: "10s"
671///   start_period: "5s"
672///   retries: 3
673/// ```
674#[derive(Debug, Clone, Serialize, Deserialize)]
675#[serde(deny_unknown_fields)]
676pub struct ZHealthcheck {
677    /// Command to run for the health check
678    pub cmd: ZCommand,
679
680    /// Interval between health checks (e.g. "30s", "1m")
681    #[serde(default, skip_serializing_if = "Option::is_none")]
682    pub interval: Option<String>,
683
684    /// Timeout for each health check (e.g. "10s")
685    #[serde(default, skip_serializing_if = "Option::is_none")]
686    pub timeout: Option<String>,
687
688    /// Grace period before first check (e.g. "5s")
689    #[serde(default, skip_serializing_if = "Option::is_none")]
690    pub start_period: Option<String>,
691
692    /// Number of consecutive failures before unhealthy
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub retries: Option<u32>,
695}
696
697// ---------------------------------------------------------------------------
698// WASM config
699// ---------------------------------------------------------------------------
700
701/// WebAssembly build configuration for WASM mode.
702///
703/// # YAML Example
704///
705/// ```yaml
706/// wasm:
707///   target: "preview2"
708///   optimize: true
709///   opt_level: "Oz"
710///   language: "rust"
711///   world: "zlayer-http-handler"
712///   wit: "./wit"
713///   output: "./output.wasm"
714///   features: [json, metrics]
715///   build_args:
716///     CARGO_PROFILE_RELEASE_LTO: "true"
717///   pre_build:
718///     - "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
719///   post_build:
720///     - "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
721///   adapter: "./wasi_snapshot_preview1.reactor.wasm"
722/// ```
723#[derive(Debug, Clone, Serialize, Deserialize)]
724#[serde(deny_unknown_fields)]
725pub struct ZWasmConfig {
726    /// WASI target version: "preview1" or "preview2" (default: "preview2")
727    #[serde(default = "default_wasm_target")]
728    pub target: String,
729
730    /// Whether to run wasm-opt on the output
731    #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
732    pub optimize: bool,
733
734    /// Optimization level for wasm-opt: "O", "Os", "Oz", "O2", "O3" (default: "Oz")
735    #[serde(
736        default = "default_wasm_opt_level",
737        skip_serializing_if = "Option::is_none"
738    )]
739    pub opt_level: Option<String>,
740
741    /// Source language (auto-detected if omitted): rust, go, python, typescript, assemblyscript, c, zig
742    #[serde(default, skip_serializing_if = "Option::is_none")]
743    pub language: Option<String>,
744
745    /// Path to WIT definitions (default: "./wit")
746    #[serde(default, skip_serializing_if = "Option::is_none")]
747    pub wit: Option<String>,
748
749    /// Target WIT world name (e.g., "zlayer-http-handler", "zlayer-plugin", "zlayer-transformer",
750    /// "zlayer-authenticator", "zlayer-rate-limiter", "zlayer-middleware", "zlayer-router")
751    #[serde(default, skip_serializing_if = "Option::is_none")]
752    pub world: Option<String>,
753
754    /// Output path for the compiled WASM file
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    pub output: Option<String>,
757
758    /// Language-specific features to enable during build
759    #[serde(default, skip_serializing_if = "Vec::is_empty")]
760    pub features: Vec<String>,
761
762    /// Additional build arguments (language-specific, e.g. `CARGO_PROFILE_RELEASE_LTO`)
763    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
764    pub build_args: HashMap<String, String>,
765
766    /// Pre-build commands to run before compilation (e.g., WIT binding generation for Go)
767    #[serde(default, skip_serializing_if = "Vec::is_empty")]
768    pub pre_build: Vec<ZCommand>,
769
770    /// Post-build commands to run after compilation (before optimization)
771    #[serde(default, skip_serializing_if = "Vec::is_empty")]
772    pub post_build: Vec<ZCommand>,
773
774    /// Component adapter path for WASI preview1 -> preview2 lifting
775    #[serde(default, skip_serializing_if = "Option::is_none")]
776    pub adapter: Option<String>,
777
778    /// When false, skip OCI artifact packaging and push — only produce the raw .wasm.
779    /// Compilation and caching are unaffected. Default: true.
780    #[serde(
781        default = "default_wasm_oci",
782        skip_serializing_if = "crate::zimage::types::is_true"
783    )]
784    pub oci: bool,
785}
786
787// ---------------------------------------------------------------------------
788// Helpers
789// ---------------------------------------------------------------------------
790
791/// Default WASM target version.
792fn default_wasm_target() -> String {
793    "preview2".to_string()
794}
795
796/// Default WASM optimization level.
797#[allow(clippy::unnecessary_wraps)]
798fn default_wasm_opt_level() -> Option<String> {
799    Some("Oz".to_string())
800}
801
802/// Helper for `skip_serializing_if` on boolean fields.
803#[allow(clippy::trivially_copy_pass_by_ref)]
804fn is_false(v: &bool) -> bool {
805    !v
806}
807
808/// Helper for `skip_serializing_if` on boolean fields whose default is `true`.
809#[allow(clippy::trivially_copy_pass_by_ref)]
810pub(crate) fn is_true(v: &bool) -> bool {
811    *v
812}
813
814/// Default for `wasm.oci`: produce the OCI layout alongside the raw `.wasm`.
815fn default_wasm_oci() -> bool {
816    true
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822
823    #[test]
824    fn test_runtime_mode_deserialize() {
825        let yaml = r#"
826runtime: node22
827cmd: "node server.js"
828"#;
829        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
830        assert_eq!(img.runtime.as_deref(), Some("node22"));
831        assert!(matches!(img.cmd, Some(ZCommand::Shell(ref s)) if s == "node server.js"));
832    }
833
834    #[test]
835    fn test_single_stage_deserialize() {
836        let yaml = r#"
837base: "alpine:3.19"
838steps:
839  - run: "apk add --no-cache curl"
840  - copy: "app.sh"
841    to: "/usr/local/bin/app.sh"
842    chmod: "755"
843  - workdir: "/app"
844env:
845  NODE_ENV: production
846expose: 8080
847cmd: ["./app.sh"]
848"#;
849        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
850        assert_eq!(img.base.as_deref(), Some("alpine:3.19"));
851        assert_eq!(img.steps.len(), 3);
852        assert_eq!(img.env.get("NODE_ENV").unwrap(), "production");
853        assert!(matches!(img.expose, Some(ZExpose::Single(8080))));
854        assert!(matches!(img.cmd, Some(ZCommand::Exec(ref v)) if v.len() == 1));
855    }
856
857    #[test]
858    fn test_multi_stage_deserialize() {
859        let yaml = r#"
860stages:
861  builder:
862    base: "node:22-alpine"
863    workdir: "/src"
864    steps:
865      - copy: ["package.json", "package-lock.json"]
866        to: "./"
867      - run: "npm ci"
868      - copy: "."
869        to: "."
870      - run: "npm run build"
871  runtime:
872    base: "node:22-alpine"
873    workdir: "/app"
874    steps:
875      - copy: "dist"
876        from: builder
877        to: "/app"
878    cmd: ["node", "dist/index.js"]
879expose: 3000
880"#;
881        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
882        let stages = img.stages.as_ref().unwrap();
883        assert_eq!(stages.len(), 2);
884
885        // Verify insertion order is preserved
886        let keys: Vec<&String> = stages.keys().collect();
887        assert_eq!(keys, vec!["builder", "runtime"]);
888
889        let builder = &stages["builder"];
890        assert_eq!(builder.base.as_deref(), Some("node:22-alpine"));
891        assert_eq!(builder.steps.len(), 4);
892
893        let runtime = &stages["runtime"];
894        assert_eq!(runtime.steps.len(), 1);
895        assert_eq!(runtime.steps[0].from.as_deref(), Some("builder"));
896    }
897
898    #[test]
899    fn test_wasm_mode_deserialize() {
900        let yaml = r#"
901wasm:
902  target: preview2
903  optimize: true
904  language: rust
905  wit: "./wit"
906  output: "./output.wasm"
907"#;
908        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
909        let wasm = img.wasm.as_ref().unwrap();
910        assert_eq!(wasm.target, "preview2");
911        assert!(wasm.optimize);
912        assert_eq!(wasm.language.as_deref(), Some("rust"));
913        assert_eq!(wasm.wit.as_deref(), Some("./wit"));
914        assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
915    }
916
917    #[test]
918    fn test_wasm_defaults() {
919        let yaml = r"
920wasm: {}
921";
922        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
923        let wasm = img.wasm.as_ref().unwrap();
924        assert_eq!(wasm.target, "preview2");
925        assert!(!wasm.optimize);
926        assert!(wasm.language.is_none());
927        assert_eq!(wasm.opt_level.as_deref(), Some("Oz"));
928        assert!(wasm.world.is_none());
929        assert!(wasm.features.is_empty());
930        assert!(wasm.build_args.is_empty());
931        assert!(wasm.pre_build.is_empty());
932        assert!(wasm.post_build.is_empty());
933        assert!(wasm.adapter.is_none());
934        assert!(
935            wasm.oci,
936            "default ZWasmConfig.oci must be true so OCI packaging still happens unless explicitly opted out"
937        );
938    }
939
940    #[test]
941    fn test_wasm_oci_opt_out() {
942        let yaml = r"
943wasm:
944  oci: false
945";
946        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
947        let wasm = img.wasm.as_ref().unwrap();
948        assert!(
949            !wasm.oci,
950            "wasm.oci: false must deserialize to ZWasmConfig.oci == false"
951        );
952    }
953
954    #[test]
955    fn test_wasm_full_config() {
956        let yaml = r#"
957wasm:
958  target: "preview2"
959  optimize: true
960  opt_level: "O3"
961  language: "rust"
962  world: "zlayer-http-handler"
963  wit: "./wit"
964  output: "./output.wasm"
965  features:
966    - json
967    - metrics
968  build_args:
969    CARGO_PROFILE_RELEASE_LTO: "true"
970    RUSTFLAGS: "-C target-feature=+simd128"
971  pre_build:
972    - "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
973  post_build:
974    - "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
975  adapter: "./wasi_snapshot_preview1.reactor.wasm"
976"#;
977        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
978        let wasm = img.wasm.as_ref().unwrap();
979        assert_eq!(wasm.target, "preview2");
980        assert!(wasm.optimize);
981        assert_eq!(wasm.opt_level.as_deref(), Some("O3"));
982        assert_eq!(wasm.language.as_deref(), Some("rust"));
983        assert_eq!(wasm.world.as_deref(), Some("zlayer-http-handler"));
984        assert_eq!(wasm.wit.as_deref(), Some("./wit"));
985        assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
986        assert_eq!(wasm.features, vec!["json", "metrics"]);
987        assert_eq!(
988            wasm.build_args.get("CARGO_PROFILE_RELEASE_LTO").unwrap(),
989            "true"
990        );
991        assert_eq!(
992            wasm.build_args.get("RUSTFLAGS").unwrap(),
993            "-C target-feature=+simd128"
994        );
995        assert_eq!(wasm.pre_build.len(), 1);
996        assert_eq!(wasm.post_build.len(), 1);
997        assert_eq!(
998            wasm.adapter.as_deref(),
999            Some("./wasi_snapshot_preview1.reactor.wasm")
1000        );
1001    }
1002
1003    #[test]
1004    fn test_zcommand_shell() {
1005        let yaml = r#""echo hello""#;
1006        let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
1007        assert!(matches!(cmd, ZCommand::Shell(ref s) if s == "echo hello"));
1008    }
1009
1010    #[test]
1011    fn test_zcommand_exec() {
1012        let yaml = r#"["echo", "hello"]"#;
1013        let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
1014        assert!(matches!(cmd, ZCommand::Exec(ref v) if v == &["echo", "hello"]));
1015    }
1016
1017    #[test]
1018    fn test_zcopy_sources_single() {
1019        let yaml = r#""package.json""#;
1020        let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
1021        assert_eq!(src.to_vec(), vec!["package.json"]);
1022    }
1023
1024    #[test]
1025    fn test_zcopy_sources_multiple() {
1026        let yaml = r#"["package.json", "tsconfig.json"]"#;
1027        let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
1028        assert_eq!(src.to_vec(), vec!["package.json", "tsconfig.json"]);
1029    }
1030
1031    #[test]
1032    fn test_zexpose_single() {
1033        let yaml = "8080";
1034        let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
1035        assert!(matches!(exp, ZExpose::Single(8080)));
1036    }
1037
1038    #[test]
1039    fn test_zexpose_multiple() {
1040        let yaml = r#"
1041- 8080
1042- "9090/udp"
1043"#;
1044        let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
1045        if let ZExpose::Multiple(ports) = exp {
1046            assert_eq!(ports.len(), 2);
1047            assert!(matches!(ports[0], ZPortSpec::Number(8080)));
1048            assert!(matches!(ports[1], ZPortSpec::WithProtocol(ref s) if s == "9090/udp"));
1049        } else {
1050            panic!("Expected ZExpose::Multiple");
1051        }
1052    }
1053
1054    #[test]
1055    fn test_healthcheck_deserialize() {
1056        let yaml = r#"
1057cmd: "curl -f http://localhost/ || exit 1"
1058interval: "30s"
1059timeout: "10s"
1060start_period: "5s"
1061retries: 3
1062"#;
1063        let hc: ZHealthcheck = serde_yaml::from_str(yaml).unwrap();
1064        assert!(matches!(hc.cmd, ZCommand::Shell(_)));
1065        assert_eq!(hc.interval.as_deref(), Some("30s"));
1066        assert_eq!(hc.timeout.as_deref(), Some("10s"));
1067        assert_eq!(hc.start_period.as_deref(), Some("5s"));
1068        assert_eq!(hc.retries, Some(3));
1069    }
1070
1071    #[test]
1072    fn test_cache_mount_deserialize() {
1073        let yaml = r"
1074target: /var/cache/apt
1075id: apt-cache
1076sharing: shared
1077readonly: false
1078";
1079        let cm: ZCacheMount = serde_yaml::from_str(yaml).unwrap();
1080        assert_eq!(cm.target, "/var/cache/apt");
1081        assert_eq!(cm.id.as_deref(), Some("apt-cache"));
1082        assert_eq!(cm.sharing.as_deref(), Some("shared"));
1083        assert!(!cm.readonly);
1084    }
1085
1086    #[test]
1087    fn test_step_with_cache_mounts() {
1088        let yaml = r#"
1089run: "apt-get update && apt-get install -y curl"
1090cache:
1091  - target: /var/cache/apt
1092    id: apt-cache
1093    sharing: shared
1094  - target: /var/lib/apt
1095    readonly: true
1096"#;
1097        let step: ZStep = serde_yaml::from_str(yaml).unwrap();
1098        assert!(step.run.is_some());
1099        assert_eq!(step.cache.len(), 2);
1100        assert_eq!(step.cache[0].target, "/var/cache/apt");
1101        assert!(step.cache[1].readonly);
1102    }
1103
1104    #[test]
1105    fn test_deny_unknown_fields_zimage() {
1106        let yaml = r#"
1107base: "alpine:3.19"
1108bogus_field: "should fail"
1109"#;
1110        let result: Result<ZImage, _> = serde_yaml::from_str(yaml);
1111        assert!(result.is_err(), "Should reject unknown fields");
1112    }
1113
1114    #[test]
1115    fn test_deny_unknown_fields_zstep() {
1116        let yaml = r#"
1117run: "echo hello"
1118bogus: "nope"
1119"#;
1120        let result: Result<ZStep, _> = serde_yaml::from_str(yaml);
1121        assert!(result.is_err(), "Should reject unknown fields on ZStep");
1122    }
1123
1124    // -----------------------------------------------------------------------
1125    // L-2: os: field + resolve_target_os priority
1126    // -----------------------------------------------------------------------
1127
1128    #[test]
1129    fn test_zimage_os_field_linux() {
1130        let yaml = r#"
1131base: "alpine:3.19"
1132os: linux
1133"#;
1134        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1135        assert_eq!(img.os, Some(ImageOs::Linux));
1136        assert_eq!(img.resolve_target_os(), Some(ImageOs::Linux));
1137    }
1138
1139    #[test]
1140    fn test_zimage_os_field_windows() {
1141        let yaml = r#"
1142base: "mcr.microsoft.com/windows/nanoserver:ltsc2022"
1143os: windows
1144"#;
1145        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1146        assert_eq!(img.os, Some(ImageOs::Windows));
1147        assert_eq!(img.resolve_target_os(), Some(ImageOs::Windows));
1148    }
1149
1150    #[test]
1151    fn test_zimage_os_missing_is_none() {
1152        // With no `os:` and no `platform:`, callers should interpret None as
1153        // "default to Linux". The serde-default here must stay `None` so we
1154        // never silently override an explicit platform further down.
1155        let yaml = r#"
1156base: "alpine:3.19"
1157"#;
1158        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1159        assert_eq!(img.os, None);
1160        assert_eq!(img.resolve_target_os(), None);
1161    }
1162
1163    #[test]
1164    fn test_zimage_os_wins_over_platform() {
1165        // `os:` and `platform:` can both be set. `os:` determines the target
1166        // OS; `platform:` keeps its role as the full platform triple (arch
1167        // info is still carried by `platform:`).
1168        let yaml = r#"
1169base: "mcr.microsoft.com/windows/nanoserver:ltsc2022"
1170platform: "linux/amd64"
1171os: windows
1172"#;
1173        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1174        assert_eq!(img.platform.as_deref(), Some("linux/amd64"));
1175        assert_eq!(img.os, Some(ImageOs::Windows));
1176        assert_eq!(
1177            img.resolve_target_os(),
1178            Some(ImageOs::Windows),
1179            "explicit os: must win over OS inferred from platform:"
1180        );
1181    }
1182
1183    #[test]
1184    fn test_zimage_os_inferred_from_platform() {
1185        // No explicit `os:` — fall back to the OS portion of `platform:`.
1186        let yaml = r#"
1187base: "alpine:3.19"
1188platform: "linux/arm64"
1189"#;
1190        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1191        assert_eq!(img.os, None);
1192        assert_eq!(
1193            img.resolve_target_os(),
1194            Some(ImageOs::Linux),
1195            "resolve_target_os must fall back to parsing platform:"
1196        );
1197    }
1198
1199    #[test]
1200    fn test_zimage_os_unknown_platform_ignored() {
1201        // Arch-only or unknown `platform:` values stay free-form. The OS
1202        // resolver returns None and the caller defaults to Linux.
1203        let yaml = r#"
1204base: "alpine:3.19"
1205platform: "amd64"
1206"#;
1207        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1208        assert_eq!(img.resolve_target_os(), None);
1209    }
1210
1211    #[test]
1212    fn test_zstage_os_field() {
1213        let yaml = r#"
1214stages:
1215  builder:
1216    base: "alpine:3.19"
1217    os: linux
1218  runtime:
1219    base: "mcr.microsoft.com/windows/nanoserver:ltsc2022"
1220    os: windows
1221"#;
1222        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1223        let stages = img.stages.as_ref().unwrap();
1224        assert_eq!(stages["builder"].os, Some(ImageOs::Linux));
1225        assert_eq!(stages["runtime"].os, Some(ImageOs::Windows));
1226    }
1227
1228    #[test]
1229    fn test_zimage_os_field_darwin() {
1230        let yaml = r#"
1231base: "scratch"
1232os: darwin
1233"#;
1234        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1235        assert_eq!(img.os, Some(ImageOs::Darwin));
1236        assert_eq!(img.resolve_target_os(), Some(ImageOs::Darwin));
1237    }
1238
1239    #[test]
1240    fn test_zimage_os_inferred_from_platform_darwin() {
1241        // No explicit `os:` — fall back to the OS portion of `platform:`.
1242        let yaml = r#"
1243base: "scratch"
1244platform: "darwin/arm64"
1245"#;
1246        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1247        assert_eq!(img.os, None);
1248        assert_eq!(
1249            img.resolve_target_os(),
1250            Some(ImageOs::Darwin),
1251            "resolve_target_os must infer Darwin from a darwin/* platform:"
1252        );
1253    }
1254
1255    #[test]
1256    fn test_detect_image_os_from_binary() {
1257        use std::io::Write;
1258
1259        // Unique temp dir under the system temp root (tempfile is a dev-dep, but a
1260        // std temp dir keeps this test self-contained and dependency-light).
1261        let dir = std::env::temp_dir().join(format!(
1262            "zlayer-detect-os-{}-{}",
1263            std::process::id(),
1264            std::time::SystemTime::now()
1265                .duration_since(std::time::UNIX_EPOCH)
1266                .map_or(0, |d| d.as_nanos())
1267        ));
1268        std::fs::create_dir_all(&dir).unwrap();
1269
1270        // Helper: build a ZImage with a single COPY step landing the entrypoint.
1271        let make_image = |entrypoint: Option<&str>| {
1272            let mut steps = Vec::new();
1273            if entrypoint.is_some() {
1274                steps.push(ZStep {
1275                    run: None,
1276                    copy: Some(ZCopySources::Single("./task-executor".to_string())),
1277                    add: None,
1278                    env: None,
1279                    workdir: None,
1280                    user: None,
1281                    to: Some("/usr/local/bin/task-executor".to_string()),
1282                    from: None,
1283                    owner: None,
1284                    chmod: None,
1285                    cache: Vec::new(),
1286                });
1287            }
1288            ZImage {
1289                version: None,
1290                runtime: None,
1291                base: Some("scratch".to_string()),
1292                build: None,
1293                steps,
1294                platform: None,
1295                os: None,
1296                isolation: None,
1297                stages: None,
1298                wasm: None,
1299                env: HashMap::new(),
1300                workdir: None,
1301                expose: None,
1302                cmd: None,
1303                entrypoint: entrypoint.map(|e| ZCommand::Exec(vec![e.to_string()])),
1304                user: None,
1305                labels: HashMap::new(),
1306                volumes: Vec::new(),
1307                healthcheck: None,
1308                stopsignal: None,
1309                args: HashMap::new(),
1310            }
1311        };
1312
1313        // -- Mach-O magic -> Darwin --
1314        {
1315            let mut f = std::fs::File::create(dir.join("task-executor")).unwrap();
1316            f.write_all(&[0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00, 0x00, 0x00])
1317                .unwrap();
1318            f.flush().unwrap();
1319            let img = make_image(Some("/usr/local/bin/task-executor"));
1320            assert_eq!(
1321                detect_image_os_from_binary(&img, &dir),
1322                Some(ImageOs::Darwin),
1323                "Mach-O magic at the COPY source must classify as Darwin"
1324            );
1325        }
1326
1327        // -- ELF magic -> Linux (overwrite the same source file) --
1328        {
1329            let mut f = std::fs::File::create(dir.join("task-executor")).unwrap();
1330            f.write_all(&[0x7f, b'E', b'L', b'F', 0x02, 0x01, 0x01, 0x00])
1331                .unwrap();
1332            f.flush().unwrap();
1333            let img = make_image(Some("/usr/local/bin/task-executor"));
1334            assert_eq!(
1335                detect_image_os_from_binary(&img, &dir),
1336                Some(ImageOs::Linux),
1337                "ELF magic at the COPY source must classify as Linux"
1338            );
1339        }
1340
1341        // -- No entrypoint / no cmd -> None --
1342        {
1343            let img = make_image(None);
1344            assert_eq!(
1345                detect_image_os_from_binary(&img, &dir),
1346                None,
1347                "an image with no entrypoint or cmd must yield None"
1348            );
1349        }
1350
1351        let _ = std::fs::remove_dir_all(&dir);
1352    }
1353
1354    // -----------------------------------------------------------------------
1355    // isolation: field + resolve_isolation mapping
1356    // -----------------------------------------------------------------------
1357
1358    #[test]
1359    fn test_zimage_isolation_vz() {
1360        use zlayer_types::spec::RuntimeIsolation as RI;
1361        let yaml = r#"
1362base: "alpine:3.19"
1363isolation: vz
1364"#;
1365        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1366        assert_eq!(img.isolation, Some(ZIsolation::Vz));
1367        assert_eq!(img.resolve_isolation(), Some(RI::Vz));
1368    }
1369
1370    #[test]
1371    fn test_zimage_isolation_vz_linux() {
1372        use zlayer_types::spec::RuntimeIsolation as RI;
1373        let yaml = r#"
1374base: "alpine:3.19"
1375isolation: vz-linux
1376"#;
1377        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1378        assert_eq!(img.isolation, Some(ZIsolation::VzLinux));
1379        assert_eq!(img.resolve_isolation(), Some(RI::VzLinux));
1380    }
1381
1382    #[test]
1383    fn test_zimage_isolation_native_is_sandbox() {
1384        use zlayer_types::spec::RuntimeIsolation as RI;
1385        let yaml = r#"
1386base: "alpine:3.19"
1387isolation: native
1388"#;
1389        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1390        assert_eq!(img.isolation, Some(ZIsolation::Native));
1391        assert_eq!(
1392            img.resolve_isolation(),
1393            Some(RI::Sandbox),
1394            "`native` is a friendly synonym for `sandbox`"
1395        );
1396    }
1397
1398    #[test]
1399    fn test_zimage_isolation_container_darwin_is_vz() {
1400        use zlayer_types::spec::RuntimeIsolation as RI;
1401        let yaml = r#"
1402base: "scratch"
1403os: darwin
1404isolation: container
1405"#;
1406        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1407        assert_eq!(img.isolation, Some(ZIsolation::Container));
1408        assert_eq!(
1409            img.resolve_isolation(),
1410            Some(RI::Vz),
1411            "`container` on a darwin target resolves to vz"
1412        );
1413    }
1414
1415    #[test]
1416    fn test_zimage_isolation_container_default_is_vz_linux() {
1417        use zlayer_types::spec::RuntimeIsolation as RI;
1418        // No os: -> resolve_target_os() is None -> container maps to vz-linux.
1419        let yaml = r#"
1420base: "alpine:3.19"
1421isolation: container
1422"#;
1423        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1424        assert_eq!(img.isolation, Some(ZIsolation::Container));
1425        assert_eq!(img.resolve_isolation(), Some(RI::VzLinux));
1426    }
1427
1428    #[test]
1429    fn test_zimage_isolation_absent_is_none() {
1430        let yaml = r#"
1431base: "alpine:3.19"
1432"#;
1433        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1434        assert_eq!(img.isolation, None);
1435        assert_eq!(img.resolve_isolation(), None);
1436    }
1437
1438    #[test]
1439    fn test_zstage_isolation_field() {
1440        let yaml = r#"
1441stages:
1442  builder:
1443    base: "alpine:3.19"
1444    isolation: sandbox
1445  runtime:
1446    base: "alpine:3.19"
1447    isolation: vz-linux
1448"#;
1449        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1450        let stages = img.stages.as_ref().unwrap();
1451        assert_eq!(stages["builder"].isolation, Some(ZIsolation::Sandbox));
1452        assert_eq!(stages["runtime"].isolation, Some(ZIsolation::VzLinux));
1453    }
1454
1455    #[test]
1456    fn test_roundtrip_serialize() {
1457        let yaml = r#"
1458base: "alpine:3.19"
1459steps:
1460  - run: "echo hello"
1461  - copy: "."
1462    to: "/app"
1463cmd: "echo done"
1464"#;
1465        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1466        let serialized = serde_yaml::to_string(&img).unwrap();
1467        let img2: ZImage = serde_yaml::from_str(&serialized).unwrap();
1468        assert_eq!(img.base, img2.base);
1469        assert_eq!(img.steps.len(), img2.steps.len());
1470    }
1471}