diff options
Diffstat (limited to 'crates/hule-image/src/lib.rs')
| -rw-r--r-- | crates/hule-image/src/lib.rs | 843 | +843 −0 |
1 files changed, 843 insertions, 0 deletions
diff --git a/crates/hule-image/src/lib.rs b/crates/hule-image/src/lib.rs new file mode 100644 --- /dev/null +++ b/crates/hule-image/src/lib.rs @@ -0,0 +1,843 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: Apache-2.0 + +//! Types and validation for a Hule machine image `config.json`. +//! +//! This crate deliberately has no filesystem, registry, or VMM concerns. It +//! validates the declared image contract; callers decide how referenced files +//! are stored and executed. +//! +//! # Schema evolution +//! +//! Bump [`SCHEMA_VERSION`] only for breaking changes. Within a version: +//! [`Boot`]/[`Access`] are menus, so they tolerate unrecognized entries via +//! `Unknown`; [`Architecture`]/[`DiskFormat`] are single mandatory values +//! with no fallback, so they stay closed enums; `system.os` and digest +//! algorithms stay plain strings. No `deny_unknown_fields`; `rename_all = +//! "camelCase"` on every struct. + +use std::collections::HashSet; +use std::fmt; +use std::path::{Component, Path}; + +use serde::{Deserialize, Serialize}; + +pub const SCHEMA_VERSION: u8 = 1; +pub const KIND: &str = "MachineImage"; + +pub const BOOT_LINUX_DIRECT: &str = "linux/direct"; +pub const BOOT_FIRMWARE_DISK_BIOS: &str = "firmware-disk/bios"; +pub const BOOT_FIRMWARE_DISK_UEFI: &str = "firmware-disk/uefi"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DiskFormat { + Qcow2, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Digest(pub String); + +impl fmt::Display for Digest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl Digest { + fn validate_into(&self, field: &str, validation: &mut Validation) { + let Some(hex) = self.0.strip_prefix("sha256:") else { + validation.error(format!("{field} must use sha256")); + return; + }; + if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + validation.error(format!("{field} must contain 64 hexadecimal digits")); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourceRange { + pub minimum: u64, + pub default: u64, +} + +impl ResourceRange { + fn validate_into(self, field: &str, validation: &mut Validation) { + if self.minimum == 0 { + validation.error(format!("{field}.minimum must be greater than zero")); + } + if self.default < self.minimum { + validation.error(format!("{field}.default must be at least minimum")); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileRef { + pub path: String, + pub digest: Digest, +} + +impl FileRef { + fn validate_into(&self, field: &str, validation: &mut Validation) { + validation.path(&self.path, &format!("{field}.path")); + self.digest + .validate_into(&format!("{field}.digest"), validation); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BootBios { + pub protocol: String, + pub disk: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BootUefi { + pub protocol: String, + pub disk: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BootLinux { + pub protocol: String, + pub disk: String, + pub kernel: FileRef, + pub initrd: FileRef, + pub cmdline: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(untagged)] +pub enum Boot { + Bios(BootBios), + Uefi(BootUefi), + Linux(BootLinux), + Unknown(serde_json::Value), +} + +impl<'de> Deserialize<'de> for Boot { + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { + use serde::de::Error; + + let value = serde_json::Value::deserialize(deserializer)?; + let protocol = value.get("protocol").and_then(|p| p.as_str()).unwrap_or(""); + + #[derive(Deserialize)] + struct Raw { + disk: String, + #[serde(default)] + kernel: Option<FileRef>, + #[serde(default)] + initrd: Option<FileRef>, + #[serde(default)] + cmdline: Option<String>, + } + + match protocol { + BOOT_FIRMWARE_DISK_BIOS => { + let raw: Raw = serde_json::from_value(value).map_err(Error::custom)?; + if raw.kernel.is_some() || raw.initrd.is_some() || raw.cmdline.is_some() { + return Err(Error::custom( + "firmware-disk boot must not contain kernel, initrd, or cmdline", + )); + } + Ok(Self::Bios(BootBios { + protocol: BOOT_FIRMWARE_DISK_BIOS.into(), + disk: raw.disk, + })) + } + BOOT_FIRMWARE_DISK_UEFI => { + let raw: Raw = serde_json::from_value(value).map_err(Error::custom)?; + if raw.kernel.is_some() || raw.initrd.is_some() || raw.cmdline.is_some() { + return Err(Error::custom( + "firmware-disk boot must not contain kernel, initrd, or cmdline", + )); + } + Ok(Self::Uefi(BootUefi { + protocol: BOOT_FIRMWARE_DISK_UEFI.into(), + disk: raw.disk, + })) + } + BOOT_LINUX_DIRECT => { + let raw: Raw = serde_json::from_value(value).map_err(Error::custom)?; + Ok(Self::Linux(BootLinux { + protocol: BOOT_LINUX_DIRECT.into(), + disk: raw.disk, + kernel: raw.kernel.ok_or_else(|| Error::missing_field("kernel"))?, + initrd: raw.initrd.ok_or_else(|| Error::missing_field("initrd"))?, + cmdline: raw.cmdline.ok_or_else(|| Error::missing_field("cmdline"))?, + })) + } + _ => Ok(Self::Unknown(value)), + } + } +} + +impl Boot { + pub fn protocol(&self) -> &str { + match self { + Self::Bios(v) => &v.protocol, + Self::Uefi(v) => &v.protocol, + Self::Linux(v) => &v.protocol, + Self::Unknown(v) => v.get("protocol").and_then(|p| p.as_str()).unwrap_or(""), + } + } + + pub fn disk(&self) -> &str { + match self { + Self::Bios(v) => &v.disk, + Self::Uefi(v) => &v.disk, + Self::Linux(v) => &v.disk, + Self::Unknown(_) => "", + } + } + + /// False only for `Unknown`. + pub fn is_recognized(&self) -> bool { + !matches!(self, Self::Unknown(_)) + } + + fn validate_into(&self, field: &str, validation: &mut Validation) { + match self { + Self::Bios(value) => { + if value.protocol != BOOT_FIRMWARE_DISK_BIOS { + validation.error(format!( + "{field} BIOS protocol must be '{BOOT_FIRMWARE_DISK_BIOS}'" + )); + } + validation.required(&value.disk, &format!("{field}.disk")); + } + Self::Uefi(value) => { + if value.protocol != BOOT_FIRMWARE_DISK_UEFI { + validation.error(format!( + "{field} UEFI protocol must be '{BOOT_FIRMWARE_DISK_UEFI}'" + )); + } + validation.required(&value.disk, &format!("{field}.disk")); + } + Self::Linux(value) => { + if value.protocol != BOOT_LINUX_DIRECT { + validation.error(format!( + "{field} direct Linux protocol must be '{BOOT_LINUX_DIRECT}'" + )); + } + validation.required(&value.disk, &format!("{field}.disk")); + value + .kernel + .validate_into(&format!("{field}.kernel"), validation); + value + .initrd + .validate_into(&format!("{field}.initrd"), validation); + validation.required(&value.cmdline, &format!("{field}.cmdline")); + } + // Nothing to check: unrecognized entries are tolerated, not inspected. + Self::Unknown(_) => {} + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Architecture { + Amd64, + Arm64, + Loong64, + Ppc64le, + Riscv64, + S390x, +} + +impl Architecture { + /// The architecture this process is running on, if it's one we know. + pub fn host() -> Option<Self> { + match std::env::consts::ARCH { + "x86_64" => Some(Self::Amd64), + "aarch64" => Some(Self::Arm64), + "loongarch64" => Some(Self::Loong64), + "riscv64" => Some(Self::Riscv64), + "s390x" => Some(Self::S390x), + "powerpc64" if cfg!(target_endian = "little") => Some(Self::Ppc64le), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct System { + pub os: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<String>, + pub version: String, + pub architecture: Architecture, +} + +impl System { + fn validate_into(&self, field: &str, validation: &mut Validation) { + validation.required(&self.os, &format!("{field}.os")); + if let Some(name) = &self.name { + validation.required(name, &format!("{field}.name")); + } + validation.required(&self.version, &format!("{field}.version")); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Disk { + pub id: String, + pub format: DiskFormat, + pub path: String, + pub digest: Digest, + pub virt_size: u64, + pub disk_size: u64, +} + +impl Disk { + fn validate_into(&self, field: &str, validation: &mut Validation) { + validation.required(&self.id, &format!("{field}.id")); + validation.path(&self.path, &format!("{field}.path")); + self.digest + .validate_into(&format!("{field}.digest"), validation); + if self.virt_size == 0 { + validation.error(format!("{field}.virtSize must be greater than zero")); + } + if self.disk_size == 0 { + validation.error(format!("{field}.diskSize must be greater than zero")); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(untagged, rename_all = "camelCase")] +pub enum Access { + Ssh { + #[serde(rename = "type")] + kind: String, + port: u16, + user: String, + auth: String, + }, + /// qemu-guest-agent. The hypervisor backend chooses and configures a + /// compatible transport; the image only promises that the agent runs. + Qga { + #[serde(rename = "type")] + kind: String, + }, + /// Unrecognized access surface, preserved verbatim. + Unknown(serde_json::Value), +} + +impl<'de> Deserialize<'de> for Access { + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { + use serde::de::Error; + + let value = serde_json::Value::deserialize(deserializer)?; + let kind = value.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + match kind { + "ssh" => { + #[derive(Deserialize)] + struct Raw { + port: u16, + user: String, + auth: String, + } + let raw: Raw = serde_json::from_value(value).map_err(Error::custom)?; + Ok(Self::Ssh { + kind: "ssh".into(), + port: raw.port, + user: raw.user, + auth: raw.auth, + }) + } + "qga" => Ok(Self::Qga { kind: "qga".into() }), + _ => Ok(Self::Unknown(value)), + } + } +} + +impl Access { + /// False only for `Unknown`. + pub fn is_recognized(&self) -> bool { + !matches!(self, Self::Unknown(_)) + } + + fn validate_into(&self, field: &str, validation: &mut Validation) { + match self { + Self::Ssh { + port, user, auth, .. + } => { + if *port == 0 { + validation.error(format!("{field}.port must be greater than zero")); + } + validation.required(user, &format!("{field}.user")); + validation.required(auth, &format!("{field}.auth")); + } + Self::Qga { .. } | Self::Unknown(_) => {} + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkDhcp {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkStatic { + pub address: String, + pub gateway: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "lowercase")] +pub enum Network { + Dhcp(NetworkDhcp), + Static(NetworkStatic), +} + +impl Network { + fn validate_into(&self, field: &str, validation: &mut Validation) { + if let Self::Static(network) = self { + validation.required(&network.address, &format!("{field}.address")); + validation.required(&network.gateway, &format!("{field}.gateway")); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Machine { + pub cpu: ResourceRange, + pub ram: ResourceRange, + pub boot: Vec<Boot>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub access: Vec<Access>, + pub network: Network, +} + +impl Machine { + fn validate_into(&self, field: &str, validation: &mut Validation) { + self.cpu.validate_into(&format!("{field}.cpu"), validation); + self.ram.validate_into(&format!("{field}.ram"), validation); + if !self.boot.iter().any(Boot::is_recognized) { + validation.error(format!( + "{field}.boot must contain at least one supported boot protocol" + )); + } + for (index, boot) in self.boot.iter().enumerate() { + boot.validate_into(&format!("{field}.boot[{index}]"), validation); + } + if !self.access.is_empty() && !self.access.iter().any(Access::is_recognized) { + validation.error(format!( + "{field}.access must contain at least one supported access method" + )); + } + for (index, access) in self.access.iter().enumerate() { + access.validate_into(&format!("{field}.access[{index}]"), validation); + } + self.network + .validate_into(&format!("{field}.network"), validation); + } +} + +#[derive(Default)] +struct Validation { + issues: Vec<String>, +} + +impl Validation { + fn error(&mut self, message: impl Into<String>) { + self.issues.push(message.into()); + } + + fn required(&mut self, value: &str, field: &str) { + if value.trim().is_empty() { + self.error(format!("{field} must not be empty")); + } + } + + fn path(&mut self, value: &str, field: &str) { + let path = Path::new(value); + if value.is_empty() + || path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + self.error(format!("{field} must be a relative normalized path")); + } + } + + fn finish(self) -> Result<(), ValidationError> { + if self.issues.is_empty() { + Ok(()) + } else { + Err(ValidationError { + issues: self.issues, + }) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidationError { + issues: Vec<String>, +} + +impl ValidationError { + fn single(message: impl Into<String>) -> Self { + Self { + issues: vec![message.into()], + } + } + + pub fn issues(&self) -> &[String] { + &self.issues + } +} + +impl fmt::Display for ValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "invalid Hule image configuration: {}", + self.issues.join("; ") + ) + } +} + +impl std::error::Error for ValidationError {} + +#[derive(Debug)] +pub enum ParseError { + Json(serde_json::Error), + Validation(ValidationError), +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Json(error) => write!(f, "invalid JSON: {error}"), + Self::Validation(error) => error.fmt(f), + } + } +} + +impl std::error::Error for ParseError {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MachineImage { + pub schema_version: u8, + pub kind: String, + pub system: System, + pub machine: Machine, + pub disks: Vec<Disk>, +} + +impl MachineImage { + pub fn from_json(data: &[u8]) -> Result<Self, ParseError> { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Header { + schema_version: u8, + #[serde(default)] + kind: Option<String>, + } + + let header: Header = serde_json::from_slice(data).map_err(ParseError::Json)?; + if header.schema_version != SCHEMA_VERSION { + return Err(ParseError::Validation(ValidationError::single(format!( + "unsupported schemaVersion {}", + header.schema_version + )))); + } + if header.kind.as_deref() != Some(KIND) { + return Err(ParseError::Validation(ValidationError::single(format!( + "kind must be '{KIND}'" + )))); + } + + let image: Self = serde_json::from_slice(data).map_err(ParseError::Json)?; + image.validate().map_err(ParseError::Validation)?; + Ok(image) + } + + pub fn validate(&self) -> Result<(), ValidationError> { + if self.schema_version != SCHEMA_VERSION { + return Err(ValidationError::single(format!( + "unsupported schemaVersion {}", + self.schema_version + ))); + } + if self.kind != KIND { + return Err(ValidationError::single(format!("kind must be '{KIND}'"))); + } + + let mut validation = Validation::default(); + self.system.validate_into("system", &mut validation); + self.machine.validate_into("machine", &mut validation); + + if self.disks.is_empty() { + validation.error("disks must not be empty"); + } + let mut disk_ids = HashSet::new(); + let mut paths = HashSet::new(); + for (index, disk) in self.disks.iter().enumerate() { + let at = format!("disks[{index}]"); + disk.validate_into(&at, &mut validation); + if !disk_ids.insert(disk.id.as_str()) { + validation.error(format!("duplicate disk id '{}'", disk.id)); + } + if !paths.insert(disk.path.as_str()) { + validation.error(format!("duplicate image path '{}'", disk.path)); + } + } + + let mut boot_protocols = HashSet::new(); + for (index, boot) in self.machine.boot.iter().enumerate() { + if !boot.is_recognized() { + // Tolerated, not cross-referenced: we don't know what its + // fields (e.g. "disk") even mean. + continue; + } + if !boot_protocols.insert(boot.protocol()) { + validation.error(format!("duplicate boot protocol '{}'", boot.protocol())); + } + if !disk_ids.contains(boot.disk()) { + validation.error(format!( + "machine.boot[{index}] references unknown disk '{}'", + boot.disk() + )); + } + if let Boot::Linux(linux) = boot { + if self.system.os != "linux" { + validation.error(format!( + "machine.boot[{index}] protocol '{BOOT_LINUX_DIRECT}' requires system.os 'linux'" + )); + } + for path in [&linux.kernel.path, &linux.initrd.path] { + if !paths.insert(path) { + validation.error(format!("duplicate image path '{path}'")); + } + } + } + } + + validation.finish() + } + + pub fn referenced_paths(&self) -> impl Iterator<Item = &str> { + self.disks.iter().map(|d| d.path.as_str()).chain( + self.machine + .boot + .iter() + .flat_map(|boot| match boot { + Boot::Bios(_) | Boot::Uefi(_) | Boot::Unknown(_) => [None, None], + Boot::Linux(v) => [Some(v.kernel.path.as_str()), Some(v.initrd.path.as_str())], + }) + .flatten(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_json() -> Vec<u8> { + br#"{"schemaVersion":1,"kind":"MachineImage","system":{"os":"linux","name":"alpine","version":"3.24","architecture":"amd64"},"machine":{"cpu":{"minimum":1,"default":2},"ram":{"minimum":268435456,"default":1073741824},"boot":[{"protocol":"firmware-disk/bios","disk":"root"}],"access":[{"type":"ssh","port":22,"user":"build","auth":"empty-password"}],"network":{"mode":"static","address":"10.0.2.15/24","gateway":"10.0.2.2"}},"disks":[{"id":"root","format":"qcow2","path":"root.hmi","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","virtSize":1024,"diskSize":512}]}"#.to_vec() + } + + #[test] + fn parses_and_round_trips_valid_config() { + let image = MachineImage::from_json(&valid_json()).unwrap(); + let encoded = serde_json::to_vec(&image).unwrap(); + assert_eq!(MachineImage::from_json(&encoded).unwrap(), image); + } + + #[test] + fn rejects_unknown_disk_and_unsafe_path() { + let mut image = MachineImage::from_json(&valid_json()).unwrap(); + image.disks[0].path = "../root.hmi".into(); + match &mut image.machine.boot[0] { + Boot::Bios(v) => v.disk = "missing".into(), + Boot::Uefi(_) | Boot::Linux(_) | Boot::Unknown(_) => unreachable!(), + } + let error = image.validate().unwrap_err(); + assert!( + error + .issues() + .iter() + .any(|v| v.contains("relative normalized path")) + ); + assert!(error.issues().iter().any(|v| v.contains("unknown disk"))); + } + + #[test] + fn rejects_invalid_digest_and_resource_range() { + let mut image = MachineImage::from_json(&valid_json()).unwrap(); + image.disks[0].digest = Digest("md5:no".into()); + image.machine.cpu.default = 0; + let error = image.validate().unwrap_err(); + assert_eq!(error.issues().len(), 2); + } + + #[test] + fn system_name_is_optional_but_not_empty() { + let mut image = MachineImage::from_json(&valid_json()).unwrap(); + image.system.name = None; + let encoded = serde_json::to_value(&image).unwrap(); + assert!(encoded["system"].get("name").is_none()); + image.validate().unwrap(); + + image.system.name = Some(String::new()); + assert!(image.validate().is_err()); + } + + #[test] + fn parses_uefi_firmware_disk_boot() { + let mut image = MachineImage::from_json(&valid_json()).unwrap(); + image.machine.boot[0] = Boot::Uefi(BootUefi { + protocol: BOOT_FIRMWARE_DISK_UEFI.into(), + disk: "root".into(), + }); + + let encoded = serde_json::to_vec(&image).unwrap(); + let decoded = MachineImage::from_json(&encoded).unwrap(); + assert_eq!(decoded.machine.boot[0].protocol(), BOOT_FIRMWARE_DISK_UEFI); + } + + #[test] + fn rejects_config_whose_only_boot_entry_is_unrecognized() { + // An unrecognized boot protocol must not fail *parsing* -- see + // `mixed_boot_list_tolerates_unrecognized_entries` below for why -- + // but a config with *no* recognized entry at all is unusable and + // must fail *validation*. + let json = String::from_utf8(valid_json()) + .unwrap() + .replace(BOOT_FIRMWARE_DISK_BIOS, "firmware-disk/unknown"); + let error = MachineImage::from_json(json.as_bytes()).unwrap_err(); + assert!( + error + .to_string() + .contains("must contain at least one supported boot protocol") + ); + } + + #[test] + fn mixed_boot_list_tolerates_unrecognized_entries() { + // A newer image may list a protocol this version doesn't know about + // alongside one it does; parsing and validation must both succeed, + // and the unrecognized entry must round-trip losslessly. + let json = String::from_utf8(valid_json()).unwrap().replace( + r#"[{"protocol":"firmware-disk/bios","disk":"root"}]"#, + r#"[{"protocol":"firmware-disk/bios","disk":"root"},{"protocol":"vsock/direct","disk":"root","futureField":42}]"#, + ); + let image = MachineImage::from_json(json.as_bytes()).unwrap(); + assert_eq!(image.machine.boot.len(), 2); + assert!(image.machine.boot[0].is_recognized()); + assert!(!image.machine.boot[1].is_recognized()); + + let encoded = serde_json::to_vec(&image).unwrap(); + assert_eq!(MachineImage::from_json(&encoded).unwrap(), image); + } + + #[test] + fn rejects_config_whose_only_access_entry_is_unrecognized() { + let json = String::from_utf8(valid_json()).unwrap().replace( + r#"{"type":"ssh","port":22,"user":"build","auth":"empty-password"}"#, + r#"{"type":"vsock-agent","channel":9000}"#, + ); + let error = MachineImage::from_json(json.as_bytes()).unwrap_err(); + assert!( + error + .to_string() + .contains("must contain at least one supported access method") + ); + } + + #[test] + fn parses_qga_access() { + let json = String::from_utf8(valid_json()).unwrap().replace( + r#"[{"type":"ssh","port":22,"user":"build","auth":"empty-password"}]"#, + r#"[{"type":"ssh","port":22,"user":"build","auth":"empty-password"},{"type":"qga"}]"#, + ); + let image = MachineImage::from_json(json.as_bytes()).unwrap(); + assert_eq!(image.machine.access.len(), 2); + assert!(matches!(image.machine.access[1], Access::Qga { .. })); + } + + #[test] + fn access_may_be_omitted_for_a_black_box_image() { + let json = String::from_utf8(valid_json()).unwrap().replace( + r#","access":[{"type":"ssh","port":22,"user":"build","auth":"empty-password"}]"#, + "", + ); + let image = MachineImage::from_json(json.as_bytes()).unwrap(); + assert!(image.machine.access.is_empty()); + assert!( + serde_json::to_value(image).unwrap()["machine"] + .get("access") + .is_none() + ); + } + + #[test] + fn parses_dhcp_network_without_static_parameters() { + let json = String::from_utf8(valid_json()).unwrap().replace( + r#"{"mode":"static","address":"10.0.2.15/24","gateway":"10.0.2.2"}"#, + r#"{"mode":"dhcp"}"#, + ); + let image = MachineImage::from_json(json.as_bytes()).unwrap(); + assert!(matches!(image.machine.network, Network::Dhcp(_))); + } + + #[test] + fn rejects_unknown_schema_before_parsing_schema_fields() { + let error = MachineImage::from_json(br#"{"schemaVersion":2}"#).unwrap_err(); + let ParseError::Validation(error) = error else { + panic!("expected validation error") + }; + assert_eq!(error.issues(), ["unsupported schemaVersion 2"]); + } + + #[test] + fn rejects_unknown_kind_before_parsing_kind_fields() { + let error = MachineImage::from_json(br#"{"schemaVersion":1,"kind":"Other"}"#).unwrap_err(); + let ParseError::Validation(error) = error else { + panic!("expected validation error") + }; + assert_eq!(error.issues(), ["kind must be 'MachineImage'"]); + } + + #[test] + fn rejects_unknown_architecture() { + let json = String::from_utf8(valid_json()) + .unwrap() + .replace("\"architecture\":\"amd64\"", "\"architecture\":\"mips\""); + let error = MachineImage::from_json(json.as_bytes()).unwrap_err(); + assert!(error.to_string().contains("unknown variant `mips`")); + } + + #[test] + fn parses_additional_oci_architectures() { + for architecture in ["loong64", "riscv64", "s390x"] { + let json = String::from_utf8(valid_json()).unwrap().replace( + "\"architecture\":\"amd64\"", + &format!("\"architecture\":\"{architecture}\""), + ); + MachineImage::from_json(json.as_bytes()).unwrap(); + } + } +} |
