1use super::types::{ZImage, ZStep};
8use crate::error::{BuildError, Result};
9
10pub fn parse_zimagefile(content: &str) -> Result<ZImage> {
21 let image: ZImage =
23 serde_yaml::from_str(content).map_err(|e| BuildError::zimagefile_parse(e.to_string()))?;
24
25 validate_version(&image)?;
27 validate_mode_exclusivity(&image)?;
28 validate_steps(&image)?;
29 validate_wasm(&image)?;
30
31 Ok(image)
32}
33
34fn validate_version(image: &ZImage) -> Result<()> {
40 if let Some(ref v) = image.version {
41 if v != "1" {
42 return Err(BuildError::zimagefile_validation(format!(
43 "unsupported version '{v}', only version \"1\" is supported"
44 )));
45 }
46 }
47 Ok(())
48}
49
50fn validate_mode_exclusivity(image: &ZImage) -> Result<()> {
58 let modes_present: Vec<&str> = [
59 image.runtime.as_ref().map(|_| "runtime"),
60 image.wasm.as_ref().map(|_| "wasm"),
61 image.stages.as_ref().map(|_| "stages"),
62 image.base.as_ref().map(|_| "base"),
63 image.build.as_ref().map(|_| "build"),
64 ]
65 .into_iter()
66 .flatten()
67 .collect();
68
69 match modes_present.len() {
70 0 => Err(BuildError::zimagefile_validation(
71 "exactly one of 'runtime', 'wasm', 'stages', 'base', or 'build' must be set, \
72 but none were found"
73 .to_string(),
74 )),
75 1 => Ok(()),
76 _ => Err(BuildError::zimagefile_validation(format!(
77 "exactly one of 'runtime', 'wasm', 'stages', 'base', or 'build' must be set, \
78 but multiple were found: {}",
79 modes_present.join(", ")
80 ))),
81 }
82}
83
84fn validate_steps(image: &ZImage) -> Result<()> {
90 if image.base.is_some() || image.build.is_some() {
92 for (i, step) in image.steps.iter().enumerate() {
93 validate_step(step, i, None)?;
94 }
95 }
96
97 if let Some(ref stages) = image.stages {
99 for (stage_name, stage) in stages {
100 match (&stage.base, &stage.build) {
102 (None, None) => {
103 return Err(BuildError::zimagefile_validation(format!(
104 "stage '{stage_name}': exactly one of 'base' or 'build' must be set, \
105 but neither was found"
106 )));
107 }
108 (Some(_), Some(_)) => {
109 return Err(BuildError::zimagefile_validation(format!(
110 "stage '{stage_name}': 'base' and 'build' are mutually exclusive, \
111 but both were set"
112 )));
113 }
114 _ => {}
115 }
116
117 for (i, step) in stage.steps.iter().enumerate() {
118 validate_step(step, i, Some(stage_name))?;
119 }
120 }
121 }
122
123 Ok(())
124}
125
126fn validate_step(step: &ZStep, index: usize, stage: Option<&str>) -> Result<()> {
135 let location = match stage {
136 Some(s) => format!("stage '{s}', step {}", index + 1),
137 None => format!("step {}", index + 1),
138 };
139
140 let instructions: Vec<&str> = [
142 step.run.as_ref().map(|_| "run"),
143 step.copy.as_ref().map(|_| "copy"),
144 step.add.as_ref().map(|_| "add"),
145 step.env.as_ref().map(|_| "env"),
146 step.workdir.as_ref().map(|_| "workdir"),
147 step.user.as_ref().map(|_| "user"),
148 ]
149 .into_iter()
150 .flatten()
151 .collect();
152
153 match instructions.len() {
154 0 => {
155 return Err(BuildError::zimagefile_validation(format!(
156 "{location}: step must have exactly one instruction type \
157 (run, copy, add, env, workdir, user), but none were found"
158 )));
159 }
160 1 => {} _ => {
162 return Err(BuildError::zimagefile_validation(format!(
163 "{location}: step must have exactly one instruction type, \
164 but multiple were found: {}",
165 instructions.join(", ")
166 )));
167 }
168 }
169
170 let instruction = instructions[0];
171 let is_copy_or_add = instruction == "copy" || instruction == "add";
172
173 if is_copy_or_add && step.to.is_none() {
175 return Err(BuildError::zimagefile_validation(format!(
176 "{location}: '{instruction}' step must have a 'to' field"
177 )));
178 }
179
180 if !step.cache.is_empty() && instruction != "run" {
182 return Err(BuildError::zimagefile_validation(format!(
183 "{location}: 'cache' is only valid on 'run' steps, not '{instruction}'"
184 )));
185 }
186
187 if step.from.is_some() && !is_copy_or_add {
189 return Err(BuildError::zimagefile_validation(format!(
190 "{location}: 'from' is only valid on 'copy'/'add' steps, not '{instruction}'"
191 )));
192 }
193
194 if step.owner.is_some() && !is_copy_or_add {
196 return Err(BuildError::zimagefile_validation(format!(
197 "{location}: 'owner' is only valid on 'copy'/'add' steps, not '{instruction}'"
198 )));
199 }
200
201 if step.chmod.is_some() && !is_copy_or_add {
203 return Err(BuildError::zimagefile_validation(format!(
204 "{location}: 'chmod' is only valid on 'copy'/'add' steps, not '{instruction}'"
205 )));
206 }
207
208 Ok(())
209}
210
211#[allow(clippy::items_after_statements)]
217fn validate_wasm(image: &ZImage) -> Result<()> {
218 let Some(ref wasm) = image.wasm else {
219 return Ok(());
220 };
221
222 const VALID_TARGETS: &[&str] = &["preview1", "preview2"];
224 if !VALID_TARGETS.contains(&wasm.target.as_str()) {
225 return Err(BuildError::zimagefile_validation(format!(
226 "wasm.target must be one of {VALID_TARGETS:?}, got '{}'",
227 wasm.target
228 )));
229 }
230
231 if let Some(ref world) = wasm.world {
233 const VALID_WORLDS: &[&str] = &[
234 "zlayer-plugin",
235 "zlayer-http-handler",
236 "zlayer-transformer",
237 "zlayer-authenticator",
238 "zlayer-rate-limiter",
239 "zlayer-middleware",
240 "zlayer-router",
241 ];
242 if !VALID_WORLDS.contains(&world.as_str()) {
243 return Err(BuildError::zimagefile_validation(format!(
244 "wasm.world must be one of {VALID_WORLDS:?}, got '{world}'"
245 )));
246 }
247 }
248
249 if let Some(ref opt_level) = wasm.opt_level {
251 const VALID_OPT_LEVELS: &[&str] = &["O", "Os", "Oz", "O2", "O3"];
252 if !VALID_OPT_LEVELS.contains(&opt_level.as_str()) {
253 return Err(BuildError::zimagefile_validation(format!(
254 "wasm.opt_level must be one of {VALID_OPT_LEVELS:?}, got '{opt_level}'"
255 )));
256 }
257 }
258
259 if let Some(ref language) = wasm.language {
261 const VALID_LANGUAGES: &[&str] = &[
262 "rust",
263 "go",
264 "python",
265 "typescript",
266 "assemblyscript",
267 "c",
268 "zig",
269 ];
270 if !VALID_LANGUAGES.contains(&language.as_str()) {
271 return Err(BuildError::zimagefile_validation(format!(
272 "wasm.language must be one of {VALID_LANGUAGES:?}, got '{language}'"
273 )));
274 }
275 }
276
277 Ok(())
278}
279
280#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
291 fn test_parse_runtime_mode() {
292 let yaml = r#"
293version: "1"
294runtime: node22
295cmd: "node server.js"
296"#;
297 let img = parse_zimagefile(yaml).unwrap();
298 assert_eq!(img.runtime.as_deref(), Some("node22"));
299 }
300
301 #[test]
302 fn test_parse_single_stage() {
303 let yaml = r#"
304version: "1"
305base: "alpine:3.19"
306steps:
307 - run: "apk add --no-cache curl"
308 - copy: "app.sh"
309 to: "/usr/local/bin/app.sh"
310 chmod: "755"
311 - workdir: "/app"
312cmd: ["./app.sh"]
313"#;
314 let img = parse_zimagefile(yaml).unwrap();
315 assert_eq!(img.base.as_deref(), Some("alpine:3.19"));
316 assert_eq!(img.steps.len(), 3);
317 }
318
319 #[test]
320 fn test_parse_multi_stage() {
321 let yaml = r#"
322version: "1"
323stages:
324 builder:
325 base: "node:22-alpine"
326 steps:
327 - copy: "package.json"
328 to: "./"
329 - run: "npm ci"
330 runtime:
331 base: "node:22-alpine"
332 steps:
333 - copy: "dist"
334 from: builder
335 to: "/app"
336cmd: ["node", "dist/index.js"]
337"#;
338 let img = parse_zimagefile(yaml).unwrap();
339 let stages = img.stages.as_ref().unwrap();
340 assert_eq!(stages.len(), 2);
341 }
342
343 #[test]
344 fn test_parse_wasm_mode() {
345 let yaml = r#"
346version: "1"
347wasm:
348 target: preview2
349 optimize: true
350"#;
351 let img = parse_zimagefile(yaml).unwrap();
352 assert!(img.wasm.is_some());
353 }
354
355 #[test]
356 fn test_version_omitted_is_ok() {
357 let yaml = r"
358runtime: node22
359";
360 let img = parse_zimagefile(yaml).unwrap();
361 assert!(img.version.is_none());
362 assert_eq!(img.runtime.as_deref(), Some("node22"));
363 }
364
365 #[test]
368 fn test_bad_version_rejected() {
369 let yaml = r#"
370version: "2"
371runtime: node22
372"#;
373 let err = parse_zimagefile(yaml).unwrap_err();
374 let msg = err.to_string();
375 assert!(msg.contains("unsupported version"), "got: {msg}");
376 }
377
378 #[test]
381 fn test_no_mode_rejected() {
382 let yaml = r#"
383version: "1"
384cmd: "echo hi"
385"#;
386 let err = parse_zimagefile(yaml).unwrap_err();
387 let msg = err.to_string();
388 assert!(msg.contains("none were found"), "got: {msg}");
389 }
390
391 #[test]
392 fn test_multiple_modes_rejected() {
393 let yaml = r#"
394version: "1"
395runtime: node22
396base: "alpine:3.19"
397"#;
398 let err = parse_zimagefile(yaml).unwrap_err();
399 let msg = err.to_string();
400 assert!(msg.contains("multiple were found"), "got: {msg}");
401 }
402
403 #[test]
406 fn test_step_no_instruction_rejected() {
407 let yaml = r#"
408version: "1"
409base: "alpine:3.19"
410steps:
411 - to: "/app"
412"#;
413 let err = parse_zimagefile(yaml).unwrap_err();
414 let msg = err.to_string();
415 assert!(msg.contains("none were found"), "got: {msg}");
416 }
417
418 #[test]
419 fn test_step_multiple_instructions_rejected() {
420 let yaml = r#"
421version: "1"
422base: "alpine:3.19"
423steps:
424 - run: "echo hi"
425 workdir: "/app"
426"#;
427 let err = parse_zimagefile(yaml).unwrap_err();
428 let msg = err.to_string();
429 assert!(msg.contains("multiple were found"), "got: {msg}");
430 }
431
432 #[test]
433 fn test_copy_missing_to_rejected() {
434 let yaml = r#"
435version: "1"
436base: "alpine:3.19"
437steps:
438 - copy: "file.txt"
439"#;
440 let err = parse_zimagefile(yaml).unwrap_err();
441 let msg = err.to_string();
442 assert!(msg.contains("must have a 'to' field"), "got: {msg}");
443 }
444
445 #[test]
446 fn test_add_missing_to_rejected() {
447 let yaml = r#"
448version: "1"
449base: "alpine:3.19"
450steps:
451 - add: "archive.tar.gz"
452"#;
453 let err = parse_zimagefile(yaml).unwrap_err();
454 let msg = err.to_string();
455 assert!(msg.contains("must have a 'to' field"), "got: {msg}");
456 }
457
458 #[test]
459 fn test_cache_on_non_run_rejected() {
460 let yaml = r#"
461version: "1"
462base: "alpine:3.19"
463steps:
464 - copy: "file.txt"
465 to: "/app/file.txt"
466 cache:
467 - target: /var/cache
468"#;
469 let err = parse_zimagefile(yaml).unwrap_err();
470 let msg = err.to_string();
471 assert!(msg.contains("'cache' is only valid on 'run'"), "got: {msg}");
472 }
473
474 #[test]
475 fn test_from_on_non_copy_add_rejected() {
476 let yaml = r#"
477version: "1"
478base: "alpine:3.19"
479steps:
480 - run: "echo hi"
481 from: builder
482"#;
483 let err = parse_zimagefile(yaml).unwrap_err();
484 let msg = err.to_string();
485 assert!(
486 msg.contains("'from' is only valid on 'copy'/'add'"),
487 "got: {msg}"
488 );
489 }
490
491 #[test]
492 fn test_owner_on_non_copy_add_rejected() {
493 let yaml = r#"
494version: "1"
495base: "alpine:3.19"
496steps:
497 - run: "echo hi"
498 owner: "root:root"
499"#;
500 let err = parse_zimagefile(yaml).unwrap_err();
501 let msg = err.to_string();
502 assert!(
503 msg.contains("'owner' is only valid on 'copy'/'add'"),
504 "got: {msg}"
505 );
506 }
507
508 #[test]
509 fn test_chmod_on_non_copy_add_rejected() {
510 let yaml = r#"
511version: "1"
512base: "alpine:3.19"
513steps:
514 - run: "echo hi"
515 chmod: "755"
516"#;
517 let err = parse_zimagefile(yaml).unwrap_err();
518 let msg = err.to_string();
519 assert!(
520 msg.contains("'chmod' is only valid on 'copy'/'add'"),
521 "got: {msg}"
522 );
523 }
524
525 #[test]
526 fn test_from_on_copy_allowed() {
527 let yaml = r#"
528version: "1"
529base: "alpine:3.19"
530steps:
531 - copy: "dist"
532 from: builder
533 to: "/app"
534"#;
535 parse_zimagefile(yaml).unwrap();
536 }
537
538 #[test]
539 fn test_from_on_add_allowed() {
540 let yaml = r#"
541version: "1"
542base: "alpine:3.19"
543steps:
544 - add: "https://example.com/file.tar.gz"
545 from: builder
546 to: "/app"
547"#;
548 parse_zimagefile(yaml).unwrap();
549 }
550
551 #[test]
552 fn test_cache_on_run_allowed() {
553 let yaml = r#"
554version: "1"
555base: "alpine:3.19"
556steps:
557 - run: "apt-get update"
558 cache:
559 - target: /var/cache/apt
560 id: apt-cache
561"#;
562 parse_zimagefile(yaml).unwrap();
563 }
564
565 #[test]
566 fn test_owner_chmod_on_copy_allowed() {
567 let yaml = r#"
568version: "1"
569base: "alpine:3.19"
570steps:
571 - copy: "app.sh"
572 to: "/usr/local/bin/app.sh"
573 owner: "1000:1000"
574 chmod: "755"
575"#;
576 parse_zimagefile(yaml).unwrap();
577 }
578
579 #[test]
580 fn test_multi_stage_step_validation() {
581 let yaml = r#"
583version: "1"
584stages:
585 builder:
586 base: "node:22"
587 steps:
588 - copy: "package.json"
589"#;
590 let err = parse_zimagefile(yaml).unwrap_err();
591 let msg = err.to_string();
592 assert!(msg.contains("stage 'builder'"), "got: {msg}");
593 assert!(msg.contains("must have a 'to' field"), "got: {msg}");
594 }
595
596 #[test]
597 fn test_yaml_syntax_error() {
598 let yaml = ":::not valid yaml:::";
599 let err = parse_zimagefile(yaml).unwrap_err();
600 let msg = err.to_string();
601 assert!(msg.contains("parse error"), "got: {msg}");
602 }
603
604 #[test]
605 fn test_env_step_valid() {
606 let yaml = r#"
607version: "1"
608base: "alpine:3.19"
609steps:
610 - env:
611 NODE_ENV: production
612"#;
613 parse_zimagefile(yaml).unwrap();
614 }
615
616 #[test]
617 fn test_user_step_valid() {
618 let yaml = r#"
619version: "1"
620base: "alpine:3.19"
621steps:
622 - user: "nobody"
623"#;
624 parse_zimagefile(yaml).unwrap();
625 }
626
627 #[test]
630 fn test_build_short_form() {
631 let yaml = r#"
632version: "1"
633build: "."
634steps:
635 - run: "echo hello"
636"#;
637 let img = parse_zimagefile(yaml).unwrap();
638 assert!(img.build.is_some());
639 assert!(img.base.is_none());
640 }
641
642 #[test]
643 fn test_build_long_form() {
644 let yaml = r#"
645version: "1"
646build:
647 context: "./subdir"
648 file: "ZImagefile.prod"
649 args:
650 RUST_VERSION: "1.90"
651steps:
652 - run: "echo hello"
653"#;
654 let img = parse_zimagefile(yaml).unwrap();
655 assert!(img.build.is_some());
656 assert!(img.base.is_none());
657 }
658
659 #[test]
660 fn test_build_and_base_rejected() {
661 let yaml = r#"
662version: "1"
663base: "alpine:3.19"
664build: "."
665steps:
666 - run: "echo hi"
667"#;
668 let err = parse_zimagefile(yaml).unwrap_err();
669 let msg = err.to_string();
670 assert!(msg.contains("multiple were found"), "got: {msg}");
671 }
672
673 #[test]
674 fn test_stage_build_directive() {
675 let yaml = r#"
676version: "1"
677stages:
678 builder:
679 build: "."
680 steps:
681 - run: "make build"
682 runtime:
683 base: "debian:bookworm-slim"
684 steps:
685 - copy: "target/release/app"
686 from: builder
687 to: "/usr/local/bin/app"
688"#;
689 let img = parse_zimagefile(yaml).unwrap();
690 let stages = img.stages.as_ref().unwrap();
691 assert!(stages["builder"].build.is_some());
692 assert!(stages["builder"].base.is_none());
693 assert!(stages["runtime"].base.is_some());
694 assert!(stages["runtime"].build.is_none());
695 }
696
697 #[test]
698 fn test_stage_build_and_base_rejected() {
699 let yaml = r#"
700version: "1"
701stages:
702 builder:
703 base: "rust:1.90"
704 build: "."
705 steps:
706 - run: "cargo build"
707"#;
708 let err = parse_zimagefile(yaml).unwrap_err();
709 let msg = err.to_string();
710 assert!(msg.contains("mutually exclusive"), "got: {msg}");
711 }
712
713 #[test]
714 fn test_stage_neither_base_nor_build_rejected() {
715 let yaml = r#"
716version: "1"
717stages:
718 builder:
719 steps:
720 - run: "echo hi"
721"#;
722 let err = parse_zimagefile(yaml).unwrap_err();
723 let msg = err.to_string();
724 assert!(msg.contains("neither was found"), "got: {msg}");
725 }
726
727 #[test]
730 fn test_wasm_valid_full_config() {
731 let yaml = r#"
732version: "1"
733wasm:
734 target: "preview2"
735 optimize: true
736 opt_level: "Oz"
737 language: "rust"
738 world: "zlayer-http-handler"
739 wit: "./wit"
740 output: "./output.wasm"
741 features: [json, metrics]
742 build_args:
743 CARGO_PROFILE_RELEASE_LTO: "true"
744 pre_build:
745 - "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
746 post_build:
747 - "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
748 adapter: "./wasi_snapshot_preview1.reactor.wasm"
749"#;
750 parse_zimagefile(yaml).unwrap();
751 }
752
753 #[test]
754 fn test_wasm_preview1_target_valid() {
755 let yaml = r#"
756version: "1"
757wasm:
758 target: "preview1"
759"#;
760 parse_zimagefile(yaml).unwrap();
761 }
762
763 #[test]
764 fn test_wasm_invalid_target_rejected() {
765 let yaml = r#"
766version: "1"
767wasm:
768 target: "preview3"
769"#;
770 let err = parse_zimagefile(yaml).unwrap_err();
771 let msg = err.to_string();
772 assert!(msg.contains("wasm.target"), "got: {msg}");
773 assert!(msg.contains("preview3"), "got: {msg}");
774 }
775
776 #[test]
777 fn test_wasm_invalid_world_rejected() {
778 let yaml = r#"
779version: "1"
780wasm:
781 world: "unknown-world"
782"#;
783 let err = parse_zimagefile(yaml).unwrap_err();
784 let msg = err.to_string();
785 assert!(msg.contains("wasm.world"), "got: {msg}");
786 assert!(msg.contains("unknown-world"), "got: {msg}");
787 }
788
789 #[test]
790 fn test_wasm_all_valid_worlds() {
791 for world in &[
792 "zlayer-plugin",
793 "zlayer-http-handler",
794 "zlayer-transformer",
795 "zlayer-authenticator",
796 "zlayer-rate-limiter",
797 "zlayer-middleware",
798 "zlayer-router",
799 ] {
800 let yaml = format!(
801 r#"
802version: "1"
803wasm:
804 world: "{world}"
805"#
806 );
807 parse_zimagefile(&yaml).unwrap_or_else(|e| {
808 panic!("world '{world}' should be valid, got: {e}");
809 });
810 }
811 }
812
813 #[test]
814 fn test_wasm_invalid_opt_level_rejected() {
815 let yaml = r#"
816version: "1"
817wasm:
818 opt_level: "O4"
819"#;
820 let err = parse_zimagefile(yaml).unwrap_err();
821 let msg = err.to_string();
822 assert!(msg.contains("wasm.opt_level"), "got: {msg}");
823 assert!(msg.contains("O4"), "got: {msg}");
824 }
825
826 #[test]
827 fn test_wasm_all_valid_opt_levels() {
828 for level in &["O", "Os", "Oz", "O2", "O3"] {
829 let yaml = format!(
830 r#"
831version: "1"
832wasm:
833 opt_level: "{level}"
834"#
835 );
836 parse_zimagefile(&yaml).unwrap_or_else(|e| {
837 panic!("opt_level '{level}' should be valid, got: {e}");
838 });
839 }
840 }
841
842 #[test]
843 fn test_wasm_invalid_language_rejected() {
844 let yaml = r#"
845version: "1"
846wasm:
847 language: "java"
848"#;
849 let err = parse_zimagefile(yaml).unwrap_err();
850 let msg = err.to_string();
851 assert!(msg.contains("wasm.language"), "got: {msg}");
852 assert!(msg.contains("java"), "got: {msg}");
853 }
854
855 #[test]
856 fn test_wasm_all_valid_languages() {
857 for lang in &[
858 "rust",
859 "go",
860 "python",
861 "typescript",
862 "assemblyscript",
863 "c",
864 "zig",
865 ] {
866 let yaml = format!(
867 r#"
868version: "1"
869wasm:
870 language: "{lang}"
871"#
872 );
873 parse_zimagefile(&yaml).unwrap_or_else(|e| {
874 panic!("language '{lang}' should be valid, got: {e}");
875 });
876 }
877 }
878}