From 89ef029ff4609c4b1b672c651ccfa2a158762b08 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Wed, 15 Jul 2026 00:19:50 +0100 Subject: hule-image -> hule-hmi --- Cargo.lock | 8 +- Cargo.toml | 2 +- crates/hule-hmi/Cargo.toml | 17 + crates/hule-hmi/src/lib.rs | 843 ++++++++++++++++++++++++ crates/hule-image/Cargo.toml | 17 - crates/hule-image/src/lib.rs | 843 ------------------------ crates/hule-oci/Cargo.toml | 2 +- crates/hule-oci/src/lib.rs | 2 +- crates/hule-vmm/Cargo.toml | 2 +- crates/hule-vmm/src/backend/qemu/cli.rs | 2 +- crates/hule-vmm/src/backend/qemu/mod.rs | 2 +- crates/hule-vmm/src/hypervisor.rs | 2 +- crates/hule-vmm/src/lib.rs | 2 +- crates/hule-vmm/src/machine.rs | 2 +- crates/hule-vmm/src/monitor.rs | 2 +- crates/hule/Cargo.toml | 2 +- crates/hule/src/main.rs | 4 +- 17 files changed, 877 insertions(+), 877 deletions(-) create mode 100644 crates/hule-hmi/Cargo.toml create mode 100644 crates/hule-hmi/src/lib.rs delete mode 100644 crates/hule-image/Cargo.toml delete mode 100644 crates/hule-image/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index d33a8ad..38d3abe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -570,7 +570,7 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" name = "hule" version = "0.1.0" dependencies = [ - "hule-image", + "hule-hmi", "hule-oci", "hule-vmm", "tokio", @@ -578,7 +578,7 @@ dependencies = [ ] [[package]] -name = "hule-image" +name = "hule-hmi" version = "0.1.0" dependencies = [ "serde", @@ -589,7 +589,7 @@ dependencies = [ name = "hule-oci" version = "0.1.0" dependencies = [ - "hule-image", + "hule-hmi", "oci-client", "serde_json", "sha2 0.10.9", @@ -602,7 +602,7 @@ version = "0.1.0" dependencies = [ "async-trait", "base64", - "hule-image", + "hule-hmi", "serde", "serde_json", "tokio", diff --git a/Cargo.toml b/Cargo.toml index c21c088..7710d06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,9 @@ repository = "https://github.com/dimidiumlabs/hule" [workspace.dependencies] # local dependencies +hule-hmi = { path = "crates/hule-hmi" } hule-oci = { path = "crates/hule-oci" } hule-vmm = { path = "crates/hule-vmm" } -hule-image = { path = "crates/hule-image" } # external dependencies serde = { version = "1", features = ["derive"] } diff --git a/crates/hule-hmi/Cargo.toml b/crates/hule-hmi/Cargo.toml new file mode 100644 index 0000000..d2b03e7 --- /dev/null +++ b/crates/hule-hmi/Cargo.toml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2026 Nikolay Govorov +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "hule-hmi" +description = "Types and validation for Hule machine images" + +publish.workspace = true +edition.workspace = true +version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true diff --git a/crates/hule-hmi/src/lib.rs b/crates/hule-hmi/src/lib.rs new file mode 100644 index 0000000..6165294 --- /dev/null +++ b/crates/hule-hmi/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>(deserializer: D) -> Result { + 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, + #[serde(default)] + initrd: Option, + #[serde(default)] + cmdline: Option, + } + + 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 { + 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, + 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>(deserializer: D) -> Result { + 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, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub access: Vec, + 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, +} + +impl Validation { + fn error(&mut self, message: impl Into) { + 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, +} + +impl ValidationError { + fn single(message: impl Into) -> 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, +} + +impl MachineImage { + pub fn from_json(data: &[u8]) -> Result { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Header { + schema_version: u8, + #[serde(default)] + kind: Option, + } + + 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 { + 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 { + 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(); + } + } +} diff --git a/crates/hule-image/Cargo.toml b/crates/hule-image/Cargo.toml deleted file mode 100644 index 3c6c20c..0000000 --- a/crates/hule-image/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 - -[package] -name = "hule-image" -description = "Types and validation for Hule machine images" - -publish.workspace = true -edition.workspace = true -version.workspace = true -license.workspace = true -authors.workspace = true -repository.workspace = true - -[dependencies] -serde.workspace = true -serde_json.workspace = true diff --git a/crates/hule-image/src/lib.rs b/crates/hule-image/src/lib.rs deleted file mode 100644 index 6165294..0000000 --- a/crates/hule-image/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>(deserializer: D) -> Result { - 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, - #[serde(default)] - initrd: Option, - #[serde(default)] - cmdline: Option, - } - - 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 { - 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, - 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>(deserializer: D) -> Result { - 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, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub access: Vec, - 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, -} - -impl Validation { - fn error(&mut self, message: impl Into) { - 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, -} - -impl ValidationError { - fn single(message: impl Into) -> 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, -} - -impl MachineImage { - pub fn from_json(data: &[u8]) -> Result { - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct Header { - schema_version: u8, - #[serde(default)] - kind: Option, - } - - 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 { - 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 { - 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(); - } - } -} diff --git a/crates/hule-oci/Cargo.toml b/crates/hule-oci/Cargo.toml index 76ba6ed..9fa92a9 100644 --- a/crates/hule-oci/Cargo.toml +++ b/crates/hule-oci/Cargo.toml @@ -13,7 +13,7 @@ authors.workspace = true repository.workspace = true [dependencies] -hule-image.workspace = true +hule-hmi.workspace = true serde_json.workspace = true oci-client = "0.17" zstd = "0.13" diff --git a/crates/hule-oci/src/lib.rs b/crates/hule-oci/src/lib.rs index a63f448..e53615b 100644 --- a/crates/hule-oci/src/lib.rs +++ b/crates/hule-oci/src/lib.rs @@ -3,7 +3,7 @@ //! OCI image storage and registry operations for Hule machine images. -use hule_image::MachineImage; +use hule_hmi::MachineImage; use oci_client::annotations::{ORG_OPENCONTAINERS_IMAGE_REF_NAME, ORG_OPENCONTAINERS_IMAGE_TITLE}; use oci_client::client::{ClientConfig, ClientProtocol, Config, ImageLayer}; use oci_client::manifest::{ diff --git a/crates/hule-vmm/Cargo.toml b/crates/hule-vmm/Cargo.toml index 37d2085..fc56926 100644 --- a/crates/hule-vmm/Cargo.toml +++ b/crates/hule-vmm/Cargo.toml @@ -13,7 +13,7 @@ authors.workspace = true repository.workspace = true [dependencies] -hule-image.workspace = true +hule-hmi.workspace = true uuid = { version = "1", features = ["v7"] } tokio = { workspace = true, features = ["process", "fs", "io-util", "sync", "net", "time"] } async-trait = "0.1" diff --git a/crates/hule-vmm/src/backend/qemu/cli.rs b/crates/hule-vmm/src/backend/qemu/cli.rs index 1612d13..5c3e7b9 100644 --- a/crates/hule-vmm/src/backend/qemu/cli.rs +++ b/crates/hule-vmm/src/backend/qemu/cli.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; /// One of the architectures qemu ships a `qemu-system-*` binary for. -/// Distinct from `hule_image::Architecture`: this is qemu's own set of +/// Distinct from `hule_hmi::Architecture`: this is qemu's own set of /// per-arch conventions (binary name, machine type, TCG cpu model), not /// Hule's. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/hule-vmm/src/backend/qemu/mod.rs b/crates/hule-vmm/src/backend/qemu/mod.rs index 0cd47cb..73ad36c 100644 --- a/crates/hule-vmm/src/backend/qemu/mod.rs +++ b/crates/hule-vmm/src/backend/qemu/mod.rs @@ -22,7 +22,7 @@ use std::process::{ExitStatus, Output}; use std::time::Duration; use async_trait::async_trait; -use hule_image::{Access, Architecture, Boot, BootLinux, Disk, MachineImage}; +use hule_hmi::{Access, Architecture, Boot, BootLinux, Disk, MachineImage}; use tokio::process::Child; use tokio::sync::Mutex; diff --git a/crates/hule-vmm/src/hypervisor.rs b/crates/hule-vmm/src/hypervisor.rs index 149cd2a..31262b4 100644 --- a/crates/hule-vmm/src/hypervisor.rs +++ b/crates/hule-vmm/src/hypervisor.rs @@ -5,7 +5,7 @@ use std::fmt; use std::path::Path; use async_trait::async_trait; -use hule_image::{Boot, MachineImage}; +use hule_hmi::{Boot, MachineImage}; use crate::{Machine, MachineId, R, Settings}; diff --git a/crates/hule-vmm/src/lib.rs b/crates/hule-vmm/src/lib.rs index 924193f..6f1b67a 100644 --- a/crates/hule-vmm/src/lib.rs +++ b/crates/hule-vmm/src/lib.rs @@ -5,7 +5,7 @@ //! that tracks a fleet of them. //! //! No OCI, registry, or storage concerns -- callers hand in an -//! already-resolved [`hule_image::MachineImage`] plus a directory of its +//! already-resolved [`hule_hmi::MachineImage`] plus a directory of its //! files. //! //! `Machine`/`Monitor` mirror Docker's container API term-for-term. diff --git a/crates/hule-vmm/src/machine.rs b/crates/hule-vmm/src/machine.rs index 26af2f1..3f826e6 100644 --- a/crates/hule-vmm/src/machine.rs +++ b/crates/hule-vmm/src/machine.rs @@ -38,7 +38,7 @@ pub trait Console: AsyncRead + AsyncWrite + Send + Unpin {} impl Console for T {} /// Deployment-time config of one instance -- distinct from -/// [`hule_image::MachineImage`], which only declares a cpu/ram *range* and +/// [`hule_hmi::MachineImage`], which only declares a cpu/ram *range* and /// knows nothing about e.g. host ports. Always concrete: callers resolve /// image defaults before constructing this. #[derive(Debug, Clone, PartialEq)] diff --git a/crates/hule-vmm/src/monitor.rs b/crates/hule-vmm/src/monitor.rs index 8e9c829..038b219 100644 --- a/crates/hule-vmm/src/monitor.rs +++ b/crates/hule-vmm/src/monitor.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; -use hule_image::MachineImage; +use hule_hmi::MachineImage; use tokio::sync::mpsc::Receiver; use uuid::Uuid; diff --git a/crates/hule/Cargo.toml b/crates/hule/Cargo.toml index a783fda..12f361d 100644 --- a/crates/hule/Cargo.toml +++ b/crates/hule/Cargo.toml @@ -13,8 +13,8 @@ authors.workspace = true repository.workspace = true [dependencies] +hule-hmi.workspace = true hule-oci.workspace = true hule-vmm.workspace = true -hule-image.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros", "fs"] } uuid = { version = "1", features = ["v7"] } diff --git a/crates/hule/src/main.rs b/crates/hule/src/main.rs index 6af3d54..3f316f5 100644 --- a/crates/hule/src/main.rs +++ b/crates/hule/src/main.rs @@ -4,7 +4,7 @@ use std::path::Path; use std::process::exit; -use hule_image::{Access, MachineImage}; +use hule_hmi::{Access, MachineImage}; use hule_vmm::backend::qemu::QemuHypervisor; use hule_vmm::{Hypervisor, MachineId, Settings}; use uuid::Uuid; @@ -31,7 +31,7 @@ async fn cmd_run(reference: &str, port: u16) -> R<()> { .machine .boot .iter() - .find(|b| matches!(b, hule_image::Boot::Linux(_)) && hv.supports(b)) + .find(|b| matches!(b, hule_hmi::Boot::Linux(_)) && hv.supports(b)) .or_else(|| image.machine.boot.iter().find(|b| hv.supports(b))) .ok_or("no boot protocol in manifest is supported by the qemu backend")?; -- Gilti