Skip to main content

zlayer_builder/zimage/
parser.rs

1//! `ZImagefile` parser — YAML deserialization + semantic validation.
2//!
3//! The entry point is [`parse_zimagefile`], which takes raw YAML content,
4//! deserializes it into a [`ZImage`], and then runs validation rules that
5//! cannot be expressed through serde alone.
6
7use super::types::{ZImage, ZStep, ZWasmConfig};
8use crate::error::{BuildError, Result};
9
10/// Parse and validate a `ZImagefile` from its YAML content.
11///
12/// This performs two phases:
13/// 1. **Deserialization** — YAML string into [`ZImage`] via `serde_yaml`.
14/// 2. **Validation** — semantic rules that serde annotations cannot enforce.
15///
16/// # Errors
17///
18/// Returns [`BuildError::ZImagefileParse`] for malformed YAML and
19/// [`BuildError::ZImagefileValidation`] for semantic rule violations.
20pub fn parse_zimagefile(content: &str) -> Result<ZImage> {
21    // Phase 1: Deserialize YAML into the ZImage struct.
22    let mut image: ZImage =
23        serde_yaml::from_str(content).map_err(|e| BuildError::zimagefile_parse(e.to_string()))?;
24
25    // `runtime: wasm` is a shorthand that routes to WASM build mode. Rewrite
26    // it to an explicit `wasm:` section with defaults so the rest of the
27    // pipeline (validation + builder dispatch) treats it uniformly as WASM
28    // mode. Keeps `runtime: wasm` distinct from language runtime templates
29    // which expand into Dockerfiles.
30    if image
31        .runtime
32        .as_deref()
33        .is_some_and(|r| r.eq_ignore_ascii_case("wasm") || r.eq_ignore_ascii_case("webassembly"))
34        && image.wasm.is_none()
35    {
36        image.runtime = None;
37        image.wasm = Some(default_wasm_config());
38    }
39
40    // Stamp the resolved runtime isolation into image labels so it flows into
41    // the OCI config Labels via the existing label emitters. `Auto` (and an
42    // absent `isolation:`) carry no label. An explicit user-provided label is
43    // never overwritten.
44    if let Some(ri) = image.resolve_isolation() {
45        if let Some(v) = ri.label_value() {
46            image
47                .labels
48                .entry("com.zlayer.isolation".to_string())
49                .or_insert_with(|| v.to_string());
50        }
51    }
52
53    // Phase 2: Semantic validation.
54    validate_version(&image)?;
55    validate_mode_exclusivity(&image)?;
56    validate_steps(&image)?;
57    validate_wasm(&image)?;
58
59    Ok(image)
60}
61
62/// Construct a default [`ZWasmConfig`] by routing through serde so the
63/// `#[serde(default = ...)]` attributes on [`ZWasmConfig`] populate fields
64/// like `target` and `opt_level`. This avoids needing a `Default` impl on
65/// [`ZWasmConfig`] just for this path.
66fn default_wasm_config() -> ZWasmConfig {
67    serde_yaml::from_str::<ZWasmConfig>("{}")
68        .expect("empty ZWasmConfig must deserialize from '{}' via serde defaults")
69}
70
71// ---------------------------------------------------------------------------
72// Version validation
73// ---------------------------------------------------------------------------
74
75/// The version field, if present, must be `"1"`.
76fn validate_version(image: &ZImage) -> Result<()> {
77    if let Some(ref v) = image.version {
78        if v != "1" {
79            return Err(BuildError::zimagefile_validation(format!(
80                "unsupported version '{v}', only version \"1\" is supported"
81            )));
82        }
83    }
84    Ok(())
85}
86
87// ---------------------------------------------------------------------------
88// Mode exclusivity
89// ---------------------------------------------------------------------------
90
91/// Exactly one of `runtime`, `wasm`, `stages`, `base`, or `build` must be set.
92/// `base` and `build` are treated as the same mode (single-stage) and are
93/// mutually exclusive with each other.
94fn validate_mode_exclusivity(image: &ZImage) -> Result<()> {
95    let modes_present: Vec<&str> = [
96        image.runtime.as_ref().map(|_| "runtime"),
97        image.wasm.as_ref().map(|_| "wasm"),
98        image.stages.as_ref().map(|_| "stages"),
99        image.base.as_ref().map(|_| "base"),
100        image.build.as_ref().map(|_| "build"),
101    ]
102    .into_iter()
103    .flatten()
104    .collect();
105
106    match modes_present.len() {
107        0 => Err(BuildError::zimagefile_validation(
108            "exactly one of 'runtime', 'wasm', 'stages', 'base', or 'build' must be set, \
109             but none were found"
110                .to_string(),
111        )),
112        1 => Ok(()),
113        _ => Err(BuildError::zimagefile_validation(format!(
114            "exactly one of 'runtime', 'wasm', 'stages', 'base', or 'build' must be set, \
115             but multiple were found: {}",
116            modes_present.join(", ")
117        ))),
118    }
119}
120
121// ---------------------------------------------------------------------------
122// Step validation
123// ---------------------------------------------------------------------------
124
125/// Validate all steps reachable from the current image configuration.
126fn validate_steps(image: &ZImage) -> Result<()> {
127    // Single-stage mode: validate top-level steps.
128    if image.base.is_some() || image.build.is_some() {
129        for (i, step) in image.steps.iter().enumerate() {
130            validate_step(step, i, None)?;
131        }
132    }
133
134    // Multi-stage mode: validate steps in every stage + base/build exclusivity.
135    if let Some(ref stages) = image.stages {
136        for (stage_name, stage) in stages {
137            // Exactly one of base or build must be set per stage.
138            match (&stage.base, &stage.build) {
139                (None, None) => {
140                    return Err(BuildError::zimagefile_validation(format!(
141                        "stage '{stage_name}': exactly one of 'base' or 'build' must be set, \
142                         but neither was found"
143                    )));
144                }
145                (Some(_), Some(_)) => {
146                    return Err(BuildError::zimagefile_validation(format!(
147                        "stage '{stage_name}': 'base' and 'build' are mutually exclusive, \
148                         but both were set"
149                    )));
150                }
151                _ => {}
152            }
153
154            for (i, step) in stage.steps.iter().enumerate() {
155                validate_step(step, i, Some(stage_name))?;
156            }
157        }
158    }
159
160    Ok(())
161}
162
163/// Validate a single build step.
164///
165/// Rules enforced:
166/// - Exactly one instruction type must be set (`run`, `copy`, `add`, `env`, `workdir`, `user`),
167///   with one exception: `env` may appear alongside `run`, in which case `env` acts as a
168///   transient per-RUN modifier (env vars scoped to that single RUN command, not baked into
169///   the image) rather than a separate persistent ENV instruction.
170/// - `copy` and `add` steps must have a `to` field.
171/// - `cache` is only valid on `run` steps.
172/// - `from` is only valid on `copy`/`add` steps.
173/// - `owner`/`chmod` are only valid on `copy`/`add` steps.
174fn validate_step(step: &ZStep, index: usize, stage: Option<&str>) -> Result<()> {
175    let location = match stage {
176        Some(s) => format!("stage '{s}', step {}", index + 1),
177        None => format!("step {}", index + 1),
178    };
179
180    // Count how many instruction fields are set.
181    let mut instructions: Vec<&str> = [
182        step.run.as_ref().map(|_| "run"),
183        step.copy.as_ref().map(|_| "copy"),
184        step.add.as_ref().map(|_| "add"),
185        step.env.as_ref().map(|_| "env"),
186        step.workdir.as_ref().map(|_| "workdir"),
187        step.user.as_ref().map(|_| "user"),
188    ]
189    .into_iter()
190    .flatten()
191    .collect();
192
193    // Special case: `run` + `env` is allowed. The `env` map is treated as
194    // transient environment variables scoped to that single RUN command (not a
195    // separate persistent ENV instruction), so it must not be counted toward
196    // the mutual-exclusion check.
197    if step.run.is_some() && step.env.is_some() {
198        instructions.retain(|i| *i != "env");
199    }
200
201    match instructions.len() {
202        0 => {
203            return Err(BuildError::zimagefile_validation(format!(
204                "{location}: step must have exactly one instruction type \
205                 (run, copy, add, env, workdir, user), but none were found"
206            )));
207        }
208        1 => {} // good
209        _ => {
210            return Err(BuildError::zimagefile_validation(format!(
211                "{location}: step must have exactly one instruction type, \
212                 but multiple were found: {}",
213                instructions.join(", ")
214            )));
215        }
216    }
217
218    let instruction = instructions[0];
219    let is_copy_or_add = instruction == "copy" || instruction == "add";
220
221    // `to` is required on copy/add steps.
222    if is_copy_or_add && step.to.is_none() {
223        return Err(BuildError::zimagefile_validation(format!(
224            "{location}: '{instruction}' step must have a 'to' field"
225        )));
226    }
227
228    // `cache` is only valid on `run` steps.
229    if !step.cache.is_empty() && instruction != "run" {
230        return Err(BuildError::zimagefile_validation(format!(
231            "{location}: 'cache' is only valid on 'run' steps, not '{instruction}'"
232        )));
233    }
234
235    // `from` is only valid on `copy`/`add` steps.
236    if step.from.is_some() && !is_copy_or_add {
237        return Err(BuildError::zimagefile_validation(format!(
238            "{location}: 'from' is only valid on 'copy'/'add' steps, not '{instruction}'"
239        )));
240    }
241
242    // `owner` is only valid on `copy`/`add` steps.
243    if step.owner.is_some() && !is_copy_or_add {
244        return Err(BuildError::zimagefile_validation(format!(
245            "{location}: 'owner' is only valid on 'copy'/'add' steps, not '{instruction}'"
246        )));
247    }
248
249    // `chmod` is only valid on `copy`/`add` steps.
250    if step.chmod.is_some() && !is_copy_or_add {
251        return Err(BuildError::zimagefile_validation(format!(
252            "{location}: 'chmod' is only valid on 'copy'/'add' steps, not '{instruction}'"
253        )));
254    }
255
256    Ok(())
257}
258
259// ---------------------------------------------------------------------------
260// WASM validation
261// ---------------------------------------------------------------------------
262
263/// Validate WASM-specific configuration fields when the `wasm` mode is active.
264#[allow(clippy::items_after_statements)]
265fn validate_wasm(image: &ZImage) -> Result<()> {
266    let Some(ref wasm) = image.wasm else {
267        return Ok(());
268    };
269
270    // `target` must be "preview1" or "preview2".
271    const VALID_TARGETS: &[&str] = &["preview1", "preview2"];
272    if !VALID_TARGETS.contains(&wasm.target.as_str()) {
273        return Err(BuildError::zimagefile_validation(format!(
274            "wasm.target must be one of {VALID_TARGETS:?}, got '{}'",
275            wasm.target
276        )));
277    }
278
279    // `world` must be a known ZLayer world name.
280    if let Some(ref world) = wasm.world {
281        const VALID_WORLDS: &[&str] = &[
282            "zlayer-plugin",
283            "zlayer-http-handler",
284            "zlayer-transformer",
285            "zlayer-authenticator",
286            "zlayer-rate-limiter",
287            "zlayer-middleware",
288            "zlayer-router",
289        ];
290        if !VALID_WORLDS.contains(&world.as_str()) {
291            return Err(BuildError::zimagefile_validation(format!(
292                "wasm.world must be one of {VALID_WORLDS:?}, got '{world}'"
293            )));
294        }
295    }
296
297    // `opt_level` must be a valid wasm-opt optimization level.
298    if let Some(ref opt_level) = wasm.opt_level {
299        const VALID_OPT_LEVELS: &[&str] = &["O", "Os", "Oz", "O2", "O3"];
300        if !VALID_OPT_LEVELS.contains(&opt_level.as_str()) {
301            return Err(BuildError::zimagefile_validation(format!(
302                "wasm.opt_level must be one of {VALID_OPT_LEVELS:?}, got '{opt_level}'"
303            )));
304        }
305    }
306
307    // `language` must be a supported source language.
308    if let Some(ref language) = wasm.language {
309        const VALID_LANGUAGES: &[&str] = &[
310            "rust",
311            "go",
312            "python",
313            "typescript",
314            "assemblyscript",
315            "c",
316            "zig",
317        ];
318        if !VALID_LANGUAGES.contains(&language.as_str()) {
319            return Err(BuildError::zimagefile_validation(format!(
320                "wasm.language must be one of {VALID_LANGUAGES:?}, got '{language}'"
321            )));
322        }
323    }
324
325    Ok(())
326}
327
328// ---------------------------------------------------------------------------
329// Tests
330// ---------------------------------------------------------------------------
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    // -- Happy-path tests ------------------------------------------------
337
338    #[test]
339    fn test_parse_runtime_mode() {
340        let yaml = r#"
341version: "1"
342runtime: node22
343cmd: "node server.js"
344"#;
345        let img = parse_zimagefile(yaml).unwrap();
346        assert_eq!(img.runtime.as_deref(), Some("node22"));
347    }
348
349    #[test]
350    fn test_parse_single_stage() {
351        let yaml = r#"
352version: "1"
353base: "alpine:3.19"
354steps:
355  - run: "apk add --no-cache curl"
356  - copy: "app.sh"
357    to: "/usr/local/bin/app.sh"
358    chmod: "755"
359  - workdir: "/app"
360cmd: ["./app.sh"]
361"#;
362        let img = parse_zimagefile(yaml).unwrap();
363        assert_eq!(img.base.as_deref(), Some("alpine:3.19"));
364        assert_eq!(img.steps.len(), 3);
365    }
366
367    #[test]
368    fn test_parse_multi_stage() {
369        let yaml = r#"
370version: "1"
371stages:
372  builder:
373    base: "node:22-alpine"
374    steps:
375      - copy: "package.json"
376        to: "./"
377      - run: "npm ci"
378  runtime:
379    base: "node:22-alpine"
380    steps:
381      - copy: "dist"
382        from: builder
383        to: "/app"
384cmd: ["node", "dist/index.js"]
385"#;
386        let img = parse_zimagefile(yaml).unwrap();
387        let stages = img.stages.as_ref().unwrap();
388        assert_eq!(stages.len(), 2);
389    }
390
391    #[test]
392    fn test_parse_wasm_mode() {
393        let yaml = r#"
394version: "1"
395wasm:
396  target: preview2
397  optimize: true
398"#;
399        let img = parse_zimagefile(yaml).unwrap();
400        assert!(img.wasm.is_some());
401    }
402
403    #[test]
404    fn test_version_omitted_is_ok() {
405        let yaml = r"
406runtime: node22
407";
408        let img = parse_zimagefile(yaml).unwrap();
409        assert!(img.version.is_none());
410        assert_eq!(img.runtime.as_deref(), Some("node22"));
411    }
412
413    // -- Version validation -----------------------------------------------
414
415    #[test]
416    fn test_bad_version_rejected() {
417        let yaml = r#"
418version: "2"
419runtime: node22
420"#;
421        let err = parse_zimagefile(yaml).unwrap_err();
422        let msg = err.to_string();
423        assert!(msg.contains("unsupported version"), "got: {msg}");
424    }
425
426    // -- Mode exclusivity -------------------------------------------------
427
428    #[test]
429    fn test_no_mode_rejected() {
430        let yaml = r#"
431version: "1"
432cmd: "echo hi"
433"#;
434        let err = parse_zimagefile(yaml).unwrap_err();
435        let msg = err.to_string();
436        assert!(msg.contains("none were found"), "got: {msg}");
437    }
438
439    #[test]
440    fn test_multiple_modes_rejected() {
441        let yaml = r#"
442version: "1"
443runtime: node22
444base: "alpine:3.19"
445"#;
446        let err = parse_zimagefile(yaml).unwrap_err();
447        let msg = err.to_string();
448        assert!(msg.contains("multiple were found"), "got: {msg}");
449    }
450
451    // -- Step validation --------------------------------------------------
452
453    #[test]
454    fn test_step_no_instruction_rejected() {
455        let yaml = r#"
456version: "1"
457base: "alpine:3.19"
458steps:
459  - to: "/app"
460"#;
461        let err = parse_zimagefile(yaml).unwrap_err();
462        let msg = err.to_string();
463        assert!(msg.contains("none were found"), "got: {msg}");
464    }
465
466    #[test]
467    fn test_step_multiple_instructions_rejected() {
468        let yaml = r#"
469version: "1"
470base: "alpine:3.19"
471steps:
472  - run: "echo hi"
473    workdir: "/app"
474"#;
475        let err = parse_zimagefile(yaml).unwrap_err();
476        let msg = err.to_string();
477        assert!(msg.contains("multiple were found"), "got: {msg}");
478    }
479
480    #[test]
481    fn test_copy_missing_to_rejected() {
482        let yaml = r#"
483version: "1"
484base: "alpine:3.19"
485steps:
486  - copy: "file.txt"
487"#;
488        let err = parse_zimagefile(yaml).unwrap_err();
489        let msg = err.to_string();
490        assert!(msg.contains("must have a 'to' field"), "got: {msg}");
491    }
492
493    #[test]
494    fn test_add_missing_to_rejected() {
495        let yaml = r#"
496version: "1"
497base: "alpine:3.19"
498steps:
499  - add: "archive.tar.gz"
500"#;
501        let err = parse_zimagefile(yaml).unwrap_err();
502        let msg = err.to_string();
503        assert!(msg.contains("must have a 'to' field"), "got: {msg}");
504    }
505
506    #[test]
507    fn test_cache_on_non_run_rejected() {
508        let yaml = r#"
509version: "1"
510base: "alpine:3.19"
511steps:
512  - copy: "file.txt"
513    to: "/app/file.txt"
514    cache:
515      - target: /var/cache
516"#;
517        let err = parse_zimagefile(yaml).unwrap_err();
518        let msg = err.to_string();
519        assert!(msg.contains("'cache' is only valid on 'run'"), "got: {msg}");
520    }
521
522    #[test]
523    fn test_from_on_non_copy_add_rejected() {
524        let yaml = r#"
525version: "1"
526base: "alpine:3.19"
527steps:
528  - run: "echo hi"
529    from: builder
530"#;
531        let err = parse_zimagefile(yaml).unwrap_err();
532        let msg = err.to_string();
533        assert!(
534            msg.contains("'from' is only valid on 'copy'/'add'"),
535            "got: {msg}"
536        );
537    }
538
539    #[test]
540    fn test_owner_on_non_copy_add_rejected() {
541        let yaml = r#"
542version: "1"
543base: "alpine:3.19"
544steps:
545  - run: "echo hi"
546    owner: "root:root"
547"#;
548        let err = parse_zimagefile(yaml).unwrap_err();
549        let msg = err.to_string();
550        assert!(
551            msg.contains("'owner' is only valid on 'copy'/'add'"),
552            "got: {msg}"
553        );
554    }
555
556    #[test]
557    fn test_chmod_on_non_copy_add_rejected() {
558        let yaml = r#"
559version: "1"
560base: "alpine:3.19"
561steps:
562  - run: "echo hi"
563    chmod: "755"
564"#;
565        let err = parse_zimagefile(yaml).unwrap_err();
566        let msg = err.to_string();
567        assert!(
568            msg.contains("'chmod' is only valid on 'copy'/'add'"),
569            "got: {msg}"
570        );
571    }
572
573    #[test]
574    fn test_from_on_copy_allowed() {
575        let yaml = r#"
576version: "1"
577base: "alpine:3.19"
578steps:
579  - copy: "dist"
580    from: builder
581    to: "/app"
582"#;
583        parse_zimagefile(yaml).unwrap();
584    }
585
586    #[test]
587    fn test_from_on_add_allowed() {
588        let yaml = r#"
589version: "1"
590base: "alpine:3.19"
591steps:
592  - add: "https://example.com/file.tar.gz"
593    from: builder
594    to: "/app"
595"#;
596        parse_zimagefile(yaml).unwrap();
597    }
598
599    #[test]
600    fn test_cache_on_run_allowed() {
601        let yaml = r#"
602version: "1"
603base: "alpine:3.19"
604steps:
605  - run: "apt-get update"
606    cache:
607      - target: /var/cache/apt
608        id: apt-cache
609"#;
610        parse_zimagefile(yaml).unwrap();
611    }
612
613    #[test]
614    fn test_owner_chmod_on_copy_allowed() {
615        let yaml = r#"
616version: "1"
617base: "alpine:3.19"
618steps:
619  - copy: "app.sh"
620    to: "/usr/local/bin/app.sh"
621    owner: "1000:1000"
622    chmod: "755"
623"#;
624        parse_zimagefile(yaml).unwrap();
625    }
626
627    #[test]
628    fn test_multi_stage_step_validation() {
629        // Ensure validation runs on every stage, not just top-level.
630        let yaml = r#"
631version: "1"
632stages:
633  builder:
634    base: "node:22"
635    steps:
636      - copy: "package.json"
637"#;
638        let err = parse_zimagefile(yaml).unwrap_err();
639        let msg = err.to_string();
640        assert!(msg.contains("stage 'builder'"), "got: {msg}");
641        assert!(msg.contains("must have a 'to' field"), "got: {msg}");
642    }
643
644    #[test]
645    fn test_yaml_syntax_error() {
646        let yaml = ":::not valid yaml:::";
647        let err = parse_zimagefile(yaml).unwrap_err();
648        let msg = err.to_string();
649        assert!(msg.contains("parse error"), "got: {msg}");
650    }
651
652    #[test]
653    fn test_env_step_valid() {
654        let yaml = r#"
655version: "1"
656base: "alpine:3.19"
657steps:
658  - env:
659      NODE_ENV: production
660"#;
661        parse_zimagefile(yaml).unwrap();
662    }
663
664    #[test]
665    fn test_user_step_valid() {
666        let yaml = r#"
667version: "1"
668base: "alpine:3.19"
669steps:
670  - user: "nobody"
671"#;
672        parse_zimagefile(yaml).unwrap();
673    }
674
675    // -- run + env modifier tests --------------------------------------------
676
677    /// Helper: construct an otherwise-empty [`ZStep`] for direct `validate_step`
678    /// tests. Mirrors the pattern of building literals since `ZStep` has no
679    /// `Default` impl.
680    fn empty_step() -> ZStep {
681        ZStep {
682            run: None,
683            copy: None,
684            add: None,
685            env: None,
686            workdir: None,
687            user: None,
688            to: None,
689            from: None,
690            owner: None,
691            chmod: None,
692            cache: Vec::new(),
693        }
694    }
695
696    #[test]
697    fn test_step_run_plus_env_is_valid() {
698        let mut env = std::collections::HashMap::new();
699        env.insert("FOO".to_string(), "bar".to_string());
700        let step = ZStep {
701            run: Some(super::super::types::ZCommand::Shell("echo hi".to_string())),
702            env: Some(env),
703            ..empty_step()
704        };
705        validate_step(&step, 0, None).expect("run + env must be a valid combination");
706    }
707
708    #[test]
709    fn test_step_env_alone_is_valid() {
710        let mut env = std::collections::HashMap::new();
711        env.insert("FOO".to_string(), "bar".to_string());
712        let step = ZStep {
713            env: Some(env),
714            ..empty_step()
715        };
716        validate_step(&step, 0, None).expect("env alone must remain valid");
717    }
718
719    #[test]
720    fn test_step_env_plus_copy_is_rejected() {
721        let mut env = std::collections::HashMap::new();
722        env.insert("FOO".to_string(), "bar".to_string());
723        let step = ZStep {
724            copy: Some(super::super::types::ZCopySources::Single("src".to_string())),
725            to: Some("/dst".to_string()),
726            env: Some(env),
727            ..empty_step()
728        };
729        let err = validate_step(&step, 0, None).expect_err("env + copy must be rejected");
730        let msg = err.to_string();
731        assert!(
732            msg.contains("multiple") && msg.contains("instruction type"),
733            "expected multiple-instruction-type error, got: {msg}"
734        );
735    }
736
737    #[test]
738    fn test_step_env_plus_workdir_is_rejected() {
739        let mut env = std::collections::HashMap::new();
740        env.insert("FOO".to_string(), "bar".to_string());
741        let step = ZStep {
742            workdir: Some("/app".to_string()),
743            env: Some(env),
744            ..empty_step()
745        };
746        let err = validate_step(&step, 0, None).expect_err("env + workdir must be rejected");
747        let msg = err.to_string();
748        assert!(
749            msg.contains("multiple") && msg.contains("instruction type"),
750            "expected multiple-instruction-type error, got: {msg}"
751        );
752    }
753
754    // -- Build directive tests ------------------------------------------------
755
756    #[test]
757    fn test_build_short_form() {
758        let yaml = r#"
759version: "1"
760build: "."
761steps:
762  - run: "echo hello"
763"#;
764        let img = parse_zimagefile(yaml).unwrap();
765        assert!(img.build.is_some());
766        assert!(img.base.is_none());
767    }
768
769    #[test]
770    fn test_build_long_form() {
771        let yaml = r#"
772version: "1"
773build:
774  context: "./subdir"
775  file: "ZImagefile.prod"
776  args:
777    RUST_VERSION: "1.90"
778steps:
779  - run: "echo hello"
780"#;
781        let img = parse_zimagefile(yaml).unwrap();
782        assert!(img.build.is_some());
783        assert!(img.base.is_none());
784    }
785
786    #[test]
787    fn test_build_and_base_rejected() {
788        let yaml = r#"
789version: "1"
790base: "alpine:3.19"
791build: "."
792steps:
793  - run: "echo hi"
794"#;
795        let err = parse_zimagefile(yaml).unwrap_err();
796        let msg = err.to_string();
797        assert!(msg.contains("multiple were found"), "got: {msg}");
798    }
799
800    #[test]
801    fn test_stage_build_directive() {
802        let yaml = r#"
803version: "1"
804stages:
805  builder:
806    build: "."
807    steps:
808      - run: "make build"
809  runtime:
810    base: "debian:bookworm-slim"
811    steps:
812      - copy: "target/release/app"
813        from: builder
814        to: "/usr/local/bin/app"
815"#;
816        let img = parse_zimagefile(yaml).unwrap();
817        let stages = img.stages.as_ref().unwrap();
818        assert!(stages["builder"].build.is_some());
819        assert!(stages["builder"].base.is_none());
820        assert!(stages["runtime"].base.is_some());
821        assert!(stages["runtime"].build.is_none());
822    }
823
824    #[test]
825    fn test_stage_build_and_base_rejected() {
826        let yaml = r#"
827version: "1"
828stages:
829  builder:
830    base: "rust:1.90"
831    build: "."
832    steps:
833      - run: "cargo build"
834"#;
835        let err = parse_zimagefile(yaml).unwrap_err();
836        let msg = err.to_string();
837        assert!(msg.contains("mutually exclusive"), "got: {msg}");
838    }
839
840    #[test]
841    fn test_stage_neither_base_nor_build_rejected() {
842        let yaml = r#"
843version: "1"
844stages:
845  builder:
846    steps:
847      - run: "echo hi"
848"#;
849        let err = parse_zimagefile(yaml).unwrap_err();
850        let msg = err.to_string();
851        assert!(msg.contains("neither was found"), "got: {msg}");
852    }
853
854    // -- WASM validation tests ------------------------------------------------
855
856    #[test]
857    fn test_wasm_valid_full_config() {
858        let yaml = r#"
859version: "1"
860wasm:
861  target: "preview2"
862  optimize: true
863  opt_level: "Oz"
864  language: "rust"
865  world: "zlayer-http-handler"
866  wit: "./wit"
867  output: "./output.wasm"
868  features: [json, metrics]
869  build_args:
870    CARGO_PROFILE_RELEASE_LTO: "true"
871  pre_build:
872    - "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
873  post_build:
874    - "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
875  adapter: "./wasi_snapshot_preview1.reactor.wasm"
876"#;
877        parse_zimagefile(yaml).unwrap();
878    }
879
880    #[test]
881    fn test_wasm_preview1_target_valid() {
882        let yaml = r#"
883version: "1"
884wasm:
885  target: "preview1"
886"#;
887        parse_zimagefile(yaml).unwrap();
888    }
889
890    #[test]
891    fn test_wasm_invalid_target_rejected() {
892        let yaml = r#"
893version: "1"
894wasm:
895  target: "preview3"
896"#;
897        let err = parse_zimagefile(yaml).unwrap_err();
898        let msg = err.to_string();
899        assert!(msg.contains("wasm.target"), "got: {msg}");
900        assert!(msg.contains("preview3"), "got: {msg}");
901    }
902
903    #[test]
904    fn test_wasm_invalid_world_rejected() {
905        let yaml = r#"
906version: "1"
907wasm:
908  world: "unknown-world"
909"#;
910        let err = parse_zimagefile(yaml).unwrap_err();
911        let msg = err.to_string();
912        assert!(msg.contains("wasm.world"), "got: {msg}");
913        assert!(msg.contains("unknown-world"), "got: {msg}");
914    }
915
916    #[test]
917    fn test_wasm_all_valid_worlds() {
918        for world in &[
919            "zlayer-plugin",
920            "zlayer-http-handler",
921            "zlayer-transformer",
922            "zlayer-authenticator",
923            "zlayer-rate-limiter",
924            "zlayer-middleware",
925            "zlayer-router",
926        ] {
927            let yaml = format!(
928                r#"
929version: "1"
930wasm:
931  world: "{world}"
932"#
933            );
934            parse_zimagefile(&yaml).unwrap_or_else(|e| {
935                panic!("world '{world}' should be valid, got: {e}");
936            });
937        }
938    }
939
940    #[test]
941    fn test_wasm_invalid_opt_level_rejected() {
942        let yaml = r#"
943version: "1"
944wasm:
945  opt_level: "O4"
946"#;
947        let err = parse_zimagefile(yaml).unwrap_err();
948        let msg = err.to_string();
949        assert!(msg.contains("wasm.opt_level"), "got: {msg}");
950        assert!(msg.contains("O4"), "got: {msg}");
951    }
952
953    #[test]
954    fn test_wasm_all_valid_opt_levels() {
955        for level in &["O", "Os", "Oz", "O2", "O3"] {
956            let yaml = format!(
957                r#"
958version: "1"
959wasm:
960  opt_level: "{level}"
961"#
962            );
963            parse_zimagefile(&yaml).unwrap_or_else(|e| {
964                panic!("opt_level '{level}' should be valid, got: {e}");
965            });
966        }
967    }
968
969    #[test]
970    fn test_wasm_invalid_language_rejected() {
971        let yaml = r#"
972version: "1"
973wasm:
974  language: "java"
975"#;
976        let err = parse_zimagefile(yaml).unwrap_err();
977        let msg = err.to_string();
978        assert!(msg.contains("wasm.language"), "got: {msg}");
979        assert!(msg.contains("java"), "got: {msg}");
980    }
981
982    #[test]
983    fn test_runtime_wasm_rewrites_to_wasm_mode() {
984        // `runtime: wasm` is shorthand: it should be transparently rewritten
985        // to the `wasm:` mode with serde-default values so the rest of the
986        // pipeline dispatches it through the WASM build path.
987        let yaml = r#"
988version: "1"
989runtime: wasm
990"#;
991        let img = parse_zimagefile(yaml).unwrap();
992        assert!(img.runtime.is_none(), "runtime should be cleared");
993        let wasm = img.wasm.expect("wasm mode should be set");
994        assert_eq!(wasm.target, "preview2");
995        assert_eq!(wasm.opt_level.as_deref(), Some("Oz"));
996    }
997
998    #[test]
999    fn test_runtime_webassembly_alias_rewrites_to_wasm_mode() {
1000        let yaml = r#"
1001version: "1"
1002runtime: WebAssembly
1003"#;
1004        let img = parse_zimagefile(yaml).unwrap();
1005        assert!(img.runtime.is_none());
1006        assert!(img.wasm.is_some());
1007    }
1008
1009    #[test]
1010    fn test_runtime_wasm_does_not_override_explicit_wasm_mode() {
1011        // If the user sets both `runtime: wasm` and an explicit `wasm:`
1012        // block... actually, the mode exclusivity check should reject that.
1013        let yaml = r#"
1014version: "1"
1015runtime: wasm
1016wasm:
1017  target: preview1
1018"#;
1019        // Since we only rewrite when `image.wasm.is_none()`, both fields
1020        // remain set and mode exclusivity rejects the combination.
1021        let err = parse_zimagefile(yaml).unwrap_err();
1022        let msg = err.to_string();
1023        assert!(msg.contains("multiple were found"), "got: {msg}");
1024    }
1025
1026    // -- isolation label stamping ---------------------------------------------
1027
1028    #[test]
1029    fn test_isolation_native_stamps_sandbox_label() {
1030        let yaml = r#"
1031version: "1"
1032base: "alpine:3.19"
1033isolation: native
1034"#;
1035        let img = parse_zimagefile(yaml).unwrap();
1036        assert_eq!(
1037            img.labels.get("com.zlayer.isolation").map(String::as_str),
1038            Some("sandbox"),
1039            "`isolation: native` must stamp com.zlayer.isolation=sandbox"
1040        );
1041    }
1042
1043    #[test]
1044    fn test_isolation_absent_stamps_no_label() {
1045        let yaml = r#"
1046version: "1"
1047base: "alpine:3.19"
1048"#;
1049        let img = parse_zimagefile(yaml).unwrap();
1050        assert!(
1051            !img.labels.contains_key("com.zlayer.isolation"),
1052            "no isolation: must leave the label unset"
1053        );
1054    }
1055
1056    #[test]
1057    fn test_isolation_explicit_label_not_overwritten() {
1058        let yaml = r#"
1059version: "1"
1060base: "alpine:3.19"
1061isolation: native
1062labels:
1063  com.zlayer.isolation: vm
1064"#;
1065        let img = parse_zimagefile(yaml).unwrap();
1066        assert_eq!(
1067            img.labels.get("com.zlayer.isolation").map(String::as_str),
1068            Some("vm"),
1069            "an explicit user label must win over the resolved isolation"
1070        );
1071    }
1072
1073    #[test]
1074    fn test_wasm_all_valid_languages() {
1075        for lang in &[
1076            "rust",
1077            "go",
1078            "python",
1079            "typescript",
1080            "assemblyscript",
1081            "c",
1082            "zig",
1083        ] {
1084            let yaml = format!(
1085                r#"
1086version: "1"
1087wasm:
1088  language: "{lang}"
1089"#
1090            );
1091            parse_zimagefile(&yaml).unwrap_or_else(|e| {
1092                panic!("language '{lang}' should be valid, got: {e}");
1093            });
1094        }
1095    }
1096}