use super::types::{ZImage, ZStep};
use crate::error::{BuildError, Result};
pub fn parse_zimagefile(content: &str) -> Result<ZImage> {
let image: ZImage =
serde_yaml::from_str(content).map_err(|e| BuildError::zimagefile_parse(e.to_string()))?;
validate_version(&image)?;
validate_mode_exclusivity(&image)?;
validate_steps(&image)?;
validate_wasm(&image)?;
Ok(image)
}
fn validate_version(image: &ZImage) -> Result<()> {
if let Some(ref v) = image.version {
if v != "1" {
return Err(BuildError::zimagefile_validation(format!(
"unsupported version '{v}', only version \"1\" is supported"
)));
}
}
Ok(())
}
fn validate_mode_exclusivity(image: &ZImage) -> Result<()> {
let modes_present: Vec<&str> = [
image.runtime.as_ref().map(|_| "runtime"),
image.wasm.as_ref().map(|_| "wasm"),
image.stages.as_ref().map(|_| "stages"),
image.base.as_ref().map(|_| "base"),
image.build.as_ref().map(|_| "build"),
]
.into_iter()
.flatten()
.collect();
match modes_present.len() {
0 => Err(BuildError::zimagefile_validation(
"exactly one of 'runtime', 'wasm', 'stages', 'base', or 'build' must be set, \
but none were found"
.to_string(),
)),
1 => Ok(()),
_ => Err(BuildError::zimagefile_validation(format!(
"exactly one of 'runtime', 'wasm', 'stages', 'base', or 'build' must be set, \
but multiple were found: {}",
modes_present.join(", ")
))),
}
}
fn validate_steps(image: &ZImage) -> Result<()> {
if image.base.is_some() || image.build.is_some() {
for (i, step) in image.steps.iter().enumerate() {
validate_step(step, i, None)?;
}
}
if let Some(ref stages) = image.stages {
for (stage_name, stage) in stages {
match (&stage.base, &stage.build) {
(None, None) => {
return Err(BuildError::zimagefile_validation(format!(
"stage '{stage_name}': exactly one of 'base' or 'build' must be set, \
but neither was found"
)));
}
(Some(_), Some(_)) => {
return Err(BuildError::zimagefile_validation(format!(
"stage '{stage_name}': 'base' and 'build' are mutually exclusive, \
but both were set"
)));
}
_ => {}
}
for (i, step) in stage.steps.iter().enumerate() {
validate_step(step, i, Some(stage_name))?;
}
}
}
Ok(())
}
fn validate_step(step: &ZStep, index: usize, stage: Option<&str>) -> Result<()> {
let location = match stage {
Some(s) => format!("stage '{s}', step {}", index + 1),
None => format!("step {}", index + 1),
};
let instructions: Vec<&str> = [
step.run.as_ref().map(|_| "run"),
step.copy.as_ref().map(|_| "copy"),
step.add.as_ref().map(|_| "add"),
step.env.as_ref().map(|_| "env"),
step.workdir.as_ref().map(|_| "workdir"),
step.user.as_ref().map(|_| "user"),
]
.into_iter()
.flatten()
.collect();
match instructions.len() {
0 => {
return Err(BuildError::zimagefile_validation(format!(
"{location}: step must have exactly one instruction type \
(run, copy, add, env, workdir, user), but none were found"
)));
}
1 => {} _ => {
return Err(BuildError::zimagefile_validation(format!(
"{location}: step must have exactly one instruction type, \
but multiple were found: {}",
instructions.join(", ")
)));
}
}
let instruction = instructions[0];
let is_copy_or_add = instruction == "copy" || instruction == "add";
if is_copy_or_add && step.to.is_none() {
return Err(BuildError::zimagefile_validation(format!(
"{location}: '{instruction}' step must have a 'to' field"
)));
}
if !step.cache.is_empty() && instruction != "run" {
return Err(BuildError::zimagefile_validation(format!(
"{location}: 'cache' is only valid on 'run' steps, not '{instruction}'"
)));
}
if step.from.is_some() && !is_copy_or_add {
return Err(BuildError::zimagefile_validation(format!(
"{location}: 'from' is only valid on 'copy'/'add' steps, not '{instruction}'"
)));
}
if step.owner.is_some() && !is_copy_or_add {
return Err(BuildError::zimagefile_validation(format!(
"{location}: 'owner' is only valid on 'copy'/'add' steps, not '{instruction}'"
)));
}
if step.chmod.is_some() && !is_copy_or_add {
return Err(BuildError::zimagefile_validation(format!(
"{location}: 'chmod' is only valid on 'copy'/'add' steps, not '{instruction}'"
)));
}
Ok(())
}
#[allow(clippy::items_after_statements)]
fn validate_wasm(image: &ZImage) -> Result<()> {
let Some(ref wasm) = image.wasm else {
return Ok(());
};
const VALID_TARGETS: &[&str] = &["preview1", "preview2"];
if !VALID_TARGETS.contains(&wasm.target.as_str()) {
return Err(BuildError::zimagefile_validation(format!(
"wasm.target must be one of {VALID_TARGETS:?}, got '{}'",
wasm.target
)));
}
if let Some(ref world) = wasm.world {
const VALID_WORLDS: &[&str] = &[
"zlayer-plugin",
"zlayer-http-handler",
"zlayer-transformer",
"zlayer-authenticator",
"zlayer-rate-limiter",
"zlayer-middleware",
"zlayer-router",
];
if !VALID_WORLDS.contains(&world.as_str()) {
return Err(BuildError::zimagefile_validation(format!(
"wasm.world must be one of {VALID_WORLDS:?}, got '{world}'"
)));
}
}
if let Some(ref opt_level) = wasm.opt_level {
const VALID_OPT_LEVELS: &[&str] = &["O", "Os", "Oz", "O2", "O3"];
if !VALID_OPT_LEVELS.contains(&opt_level.as_str()) {
return Err(BuildError::zimagefile_validation(format!(
"wasm.opt_level must be one of {VALID_OPT_LEVELS:?}, got '{opt_level}'"
)));
}
}
if let Some(ref language) = wasm.language {
const VALID_LANGUAGES: &[&str] = &[
"rust",
"go",
"python",
"typescript",
"assemblyscript",
"c",
"zig",
];
if !VALID_LANGUAGES.contains(&language.as_str()) {
return Err(BuildError::zimagefile_validation(format!(
"wasm.language must be one of {VALID_LANGUAGES:?}, got '{language}'"
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_runtime_mode() {
let yaml = r#"
version: "1"
runtime: node22
cmd: "node server.js"
"#;
let img = parse_zimagefile(yaml).unwrap();
assert_eq!(img.runtime.as_deref(), Some("node22"));
}
#[test]
fn test_parse_single_stage() {
let yaml = r#"
version: "1"
base: "alpine:3.19"
steps:
- run: "apk add --no-cache curl"
- copy: "app.sh"
to: "/usr/local/bin/app.sh"
chmod: "755"
- workdir: "/app"
cmd: ["./app.sh"]
"#;
let img = parse_zimagefile(yaml).unwrap();
assert_eq!(img.base.as_deref(), Some("alpine:3.19"));
assert_eq!(img.steps.len(), 3);
}
#[test]
fn test_parse_multi_stage() {
let yaml = r#"
version: "1"
stages:
builder:
base: "node:22-alpine"
steps:
- copy: "package.json"
to: "./"
- run: "npm ci"
runtime:
base: "node:22-alpine"
steps:
- copy: "dist"
from: builder
to: "/app"
cmd: ["node", "dist/index.js"]
"#;
let img = parse_zimagefile(yaml).unwrap();
let stages = img.stages.as_ref().unwrap();
assert_eq!(stages.len(), 2);
}
#[test]
fn test_parse_wasm_mode() {
let yaml = r#"
version: "1"
wasm:
target: preview2
optimize: true
"#;
let img = parse_zimagefile(yaml).unwrap();
assert!(img.wasm.is_some());
}
#[test]
fn test_version_omitted_is_ok() {
let yaml = r"
runtime: node22
";
let img = parse_zimagefile(yaml).unwrap();
assert!(img.version.is_none());
assert_eq!(img.runtime.as_deref(), Some("node22"));
}
#[test]
fn test_bad_version_rejected() {
let yaml = r#"
version: "2"
runtime: node22
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("unsupported version"), "got: {msg}");
}
#[test]
fn test_no_mode_rejected() {
let yaml = r#"
version: "1"
cmd: "echo hi"
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("none were found"), "got: {msg}");
}
#[test]
fn test_multiple_modes_rejected() {
let yaml = r#"
version: "1"
runtime: node22
base: "alpine:3.19"
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("multiple were found"), "got: {msg}");
}
#[test]
fn test_step_no_instruction_rejected() {
let yaml = r#"
version: "1"
base: "alpine:3.19"
steps:
- to: "/app"
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("none were found"), "got: {msg}");
}
#[test]
fn test_step_multiple_instructions_rejected() {
let yaml = r#"
version: "1"
base: "alpine:3.19"
steps:
- run: "echo hi"
workdir: "/app"
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("multiple were found"), "got: {msg}");
}
#[test]
fn test_copy_missing_to_rejected() {
let yaml = r#"
version: "1"
base: "alpine:3.19"
steps:
- copy: "file.txt"
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("must have a 'to' field"), "got: {msg}");
}
#[test]
fn test_add_missing_to_rejected() {
let yaml = r#"
version: "1"
base: "alpine:3.19"
steps:
- add: "archive.tar.gz"
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("must have a 'to' field"), "got: {msg}");
}
#[test]
fn test_cache_on_non_run_rejected() {
let yaml = r#"
version: "1"
base: "alpine:3.19"
steps:
- copy: "file.txt"
to: "/app/file.txt"
cache:
- target: /var/cache
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("'cache' is only valid on 'run'"), "got: {msg}");
}
#[test]
fn test_from_on_non_copy_add_rejected() {
let yaml = r#"
version: "1"
base: "alpine:3.19"
steps:
- run: "echo hi"
from: builder
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("'from' is only valid on 'copy'/'add'"),
"got: {msg}"
);
}
#[test]
fn test_owner_on_non_copy_add_rejected() {
let yaml = r#"
version: "1"
base: "alpine:3.19"
steps:
- run: "echo hi"
owner: "root:root"
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("'owner' is only valid on 'copy'/'add'"),
"got: {msg}"
);
}
#[test]
fn test_chmod_on_non_copy_add_rejected() {
let yaml = r#"
version: "1"
base: "alpine:3.19"
steps:
- run: "echo hi"
chmod: "755"
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("'chmod' is only valid on 'copy'/'add'"),
"got: {msg}"
);
}
#[test]
fn test_from_on_copy_allowed() {
let yaml = r#"
version: "1"
base: "alpine:3.19"
steps:
- copy: "dist"
from: builder
to: "/app"
"#;
parse_zimagefile(yaml).unwrap();
}
#[test]
fn test_from_on_add_allowed() {
let yaml = r#"
version: "1"
base: "alpine:3.19"
steps:
- add: "https://example.com/file.tar.gz"
from: builder
to: "/app"
"#;
parse_zimagefile(yaml).unwrap();
}
#[test]
fn test_cache_on_run_allowed() {
let yaml = r#"
version: "1"
base: "alpine:3.19"
steps:
- run: "apt-get update"
cache:
- target: /var/cache/apt
id: apt-cache
"#;
parse_zimagefile(yaml).unwrap();
}
#[test]
fn test_owner_chmod_on_copy_allowed() {
let yaml = r#"
version: "1"
base: "alpine:3.19"
steps:
- copy: "app.sh"
to: "/usr/local/bin/app.sh"
owner: "1000:1000"
chmod: "755"
"#;
parse_zimagefile(yaml).unwrap();
}
#[test]
fn test_multi_stage_step_validation() {
let yaml = r#"
version: "1"
stages:
builder:
base: "node:22"
steps:
- copy: "package.json"
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("stage 'builder'"), "got: {msg}");
assert!(msg.contains("must have a 'to' field"), "got: {msg}");
}
#[test]
fn test_yaml_syntax_error() {
let yaml = ":::not valid yaml:::";
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("parse error"), "got: {msg}");
}
#[test]
fn test_env_step_valid() {
let yaml = r#"
version: "1"
base: "alpine:3.19"
steps:
- env:
NODE_ENV: production
"#;
parse_zimagefile(yaml).unwrap();
}
#[test]
fn test_user_step_valid() {
let yaml = r#"
version: "1"
base: "alpine:3.19"
steps:
- user: "nobody"
"#;
parse_zimagefile(yaml).unwrap();
}
#[test]
fn test_build_short_form() {
let yaml = r#"
version: "1"
build: "."
steps:
- run: "echo hello"
"#;
let img = parse_zimagefile(yaml).unwrap();
assert!(img.build.is_some());
assert!(img.base.is_none());
}
#[test]
fn test_build_long_form() {
let yaml = r#"
version: "1"
build:
context: "./subdir"
file: "ZImagefile.prod"
args:
RUST_VERSION: "1.90"
steps:
- run: "echo hello"
"#;
let img = parse_zimagefile(yaml).unwrap();
assert!(img.build.is_some());
assert!(img.base.is_none());
}
#[test]
fn test_build_and_base_rejected() {
let yaml = r#"
version: "1"
base: "alpine:3.19"
build: "."
steps:
- run: "echo hi"
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("multiple were found"), "got: {msg}");
}
#[test]
fn test_stage_build_directive() {
let yaml = r#"
version: "1"
stages:
builder:
build: "."
steps:
- run: "make build"
runtime:
base: "debian:bookworm-slim"
steps:
- copy: "target/release/app"
from: builder
to: "/usr/local/bin/app"
"#;
let img = parse_zimagefile(yaml).unwrap();
let stages = img.stages.as_ref().unwrap();
assert!(stages["builder"].build.is_some());
assert!(stages["builder"].base.is_none());
assert!(stages["runtime"].base.is_some());
assert!(stages["runtime"].build.is_none());
}
#[test]
fn test_stage_build_and_base_rejected() {
let yaml = r#"
version: "1"
stages:
builder:
base: "rust:1.90"
build: "."
steps:
- run: "cargo build"
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("mutually exclusive"), "got: {msg}");
}
#[test]
fn test_stage_neither_base_nor_build_rejected() {
let yaml = r#"
version: "1"
stages:
builder:
steps:
- run: "echo hi"
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("neither was found"), "got: {msg}");
}
#[test]
fn test_wasm_valid_full_config() {
let yaml = r#"
version: "1"
wasm:
target: "preview2"
optimize: true
opt_level: "Oz"
language: "rust"
world: "zlayer-http-handler"
wit: "./wit"
output: "./output.wasm"
features: [json, metrics]
build_args:
CARGO_PROFILE_RELEASE_LTO: "true"
pre_build:
- "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
post_build:
- "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
adapter: "./wasi_snapshot_preview1.reactor.wasm"
"#;
parse_zimagefile(yaml).unwrap();
}
#[test]
fn test_wasm_preview1_target_valid() {
let yaml = r#"
version: "1"
wasm:
target: "preview1"
"#;
parse_zimagefile(yaml).unwrap();
}
#[test]
fn test_wasm_invalid_target_rejected() {
let yaml = r#"
version: "1"
wasm:
target: "preview3"
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("wasm.target"), "got: {msg}");
assert!(msg.contains("preview3"), "got: {msg}");
}
#[test]
fn test_wasm_invalid_world_rejected() {
let yaml = r#"
version: "1"
wasm:
world: "unknown-world"
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("wasm.world"), "got: {msg}");
assert!(msg.contains("unknown-world"), "got: {msg}");
}
#[test]
fn test_wasm_all_valid_worlds() {
for world in &[
"zlayer-plugin",
"zlayer-http-handler",
"zlayer-transformer",
"zlayer-authenticator",
"zlayer-rate-limiter",
"zlayer-middleware",
"zlayer-router",
] {
let yaml = format!(
r#"
version: "1"
wasm:
world: "{world}"
"#
);
parse_zimagefile(&yaml).unwrap_or_else(|e| {
panic!("world '{world}' should be valid, got: {e}");
});
}
}
#[test]
fn test_wasm_invalid_opt_level_rejected() {
let yaml = r#"
version: "1"
wasm:
opt_level: "O4"
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("wasm.opt_level"), "got: {msg}");
assert!(msg.contains("O4"), "got: {msg}");
}
#[test]
fn test_wasm_all_valid_opt_levels() {
for level in &["O", "Os", "Oz", "O2", "O3"] {
let yaml = format!(
r#"
version: "1"
wasm:
opt_level: "{level}"
"#
);
parse_zimagefile(&yaml).unwrap_or_else(|e| {
panic!("opt_level '{level}' should be valid, got: {e}");
});
}
}
#[test]
fn test_wasm_invalid_language_rejected() {
let yaml = r#"
version: "1"
wasm:
language: "java"
"#;
let err = parse_zimagefile(yaml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("wasm.language"), "got: {msg}");
assert!(msg.contains("java"), "got: {msg}");
}
#[test]
fn test_wasm_all_valid_languages() {
for lang in &[
"rust",
"go",
"python",
"typescript",
"assemblyscript",
"c",
"zig",
] {
let yaml = format!(
r#"
version: "1"
wasm:
language: "{lang}"
"#
);
parse_zimagefile(&yaml).unwrap_or_else(|e| {
panic!("language '{lang}' should be valid, got: {e}");
});
}
}
}