diff options
Diffstat (limited to 'crates/hule-hmi/src/lib.rs')
| -rw-r--r-- | crates/hule-hmi/src/lib.rs | 843 | +0 −843 |
1 files changed, 0 insertions, 843 deletions
diff --git a/crates/hule-hmi/src/lib.rs b/crates/hule-hmi/src/lib.rs deleted file mode 100644 --- a/crates/hule-hmi/src/lib.rs +++ /dev/null @@ -1,843 +0,0 @@ -// 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(); - } - } -} |
