1use super::types::{ZImage, ZStep, ZWasmConfig};
8use crate::error::{BuildError, Result};
9
10pub fn parse_zimagefile(content: &str) -> Result<ZImage> {
21 let mut image: ZImage =
23 serde_yaml::from_str(content).map_err(|e| BuildError::zimagefile_parse(e.to_string()))?;
24
25 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 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 validate_version(&image)?;
55 validate_mode_exclusivity(&image)?;
56 validate_steps(&image)?;
57 validate_wasm(&image)?;
58
59 Ok(image)
60}
61
62fn default_wasm_config() -> ZWasmConfig {
67 serde_yaml::from_str::<ZWasmConfig>("{}")
68 .expect("empty ZWasmConfig must deserialize from '{}' via serde defaults")
69}
70
71fn 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
87fn 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
121fn validate_steps(image: &ZImage) -> Result<()> {
127 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 if let Some(ref stages) = image.stages {
136 for (stage_name, stage) in stages {
137 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
163fn 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 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 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 => {} _ => {
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 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 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 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 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 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#[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 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 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 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 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#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[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 #[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 #[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 #[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 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 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 #[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 #[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 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 let yaml = r#"
1014version: "1"
1015runtime: wasm
1016wasm:
1017 target: preview1
1018"#;
1019 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 #[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}