use objc2::rc::Retained;
use objc2_foundation::{NSArray, NSData};
use objc2_virtualization::{
VZDirectorySharingDeviceConfiguration, VZEntropyDeviceConfiguration,
VZGenericMachineIdentifier, VZGenericPlatformConfiguration,
VZNetworkDeviceConfiguration, VZSerialPortConfiguration,
VZStorageDeviceConfiguration, VZVirtioEntropyDeviceConfiguration,
VZVirtualMachineConfiguration,
};
use crate::boot::{self, BootConfig};
use crate::disk::{self, DiskConfig};
use crate::network::{self, NetworkConfig};
use crate::serial::{self, SerialConfig};
use crate::shared_dir::{self, SharedDirConfig};
use crate::KasouError;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct VmConfig {
#[serde(default)]
pub id: crate::types::VmId,
pub cpus: u32,
pub memory_mib: u64,
pub boot: BootConfig,
pub disks: Vec<DiskConfig>,
pub network: NetworkConfig,
pub serial: Option<SerialConfig>,
pub shared_dirs: Vec<SharedDirConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub machine_identifier_path: Option<std::path::PathBuf>,
}
impl VmConfig {
pub fn validate(&self) -> Result<(), KasouError> {
if self.cpus == 0 {
return Err(KasouError::Validation("cpus must be >= 1".into()));
}
if self.memory_mib == 0 {
return Err(KasouError::Validation("memory_mib must be >= 1".into()));
}
if self.disks.is_empty() {
return Err(KasouError::Validation("at least one disk is required".into()));
}
match &self.boot {
BootConfig::Linux { kernel, initrd, .. } => {
if !kernel.exists() {
return Err(KasouError::BootFilesNotFound(format!(
"kernel: {}",
kernel.display()
)));
}
if !initrd.exists() {
return Err(KasouError::BootFilesNotFound(format!(
"initrd: {}",
initrd.display()
)));
}
}
BootConfig::Efi { variable_store } => {
if let Some(store) = variable_store {
if let Some(parent) = store.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
return Err(KasouError::Validation(format!(
"EFI variable store parent directory missing: {}",
parent.display()
)));
}
}
}
}
}
for disk in &self.disks {
if !disk.path.exists() {
return Err(KasouError::DiskNotFound(disk.path.clone()));
}
}
Ok(())
}
}
pub(crate) fn build_vz_config(
config: &VmConfig,
) -> Result<Retained<VZVirtualMachineConfiguration>, KasouError> {
let result = objc2::exception::catch(|| build_vz_config_inner(config));
match result {
Ok(inner) => inner,
Err(exception) => {
let desc = format!("{exception:?}");
Err(KasouError::Framework(format!(
"Objective-C exception during VZ configuration: {desc}"
)))
}
}
}
fn build_vz_config_inner(
config: &VmConfig,
) -> Result<Retained<VZVirtualMachineConfiguration>, KasouError> {
let vz_config = unsafe { VZVirtualMachineConfiguration::new() };
unsafe {
vz_config.setCPUCount(config.cpus as usize);
vz_config.setMemorySize(config.memory_mib * 1024 * 1024);
}
let platform = unsafe { VZGenericPlatformConfiguration::new() };
if let Some(id_path) = &config.machine_identifier_path {
let identifier = load_or_create_machine_identifier(id_path, &platform)?;
unsafe { platform.setMachineIdentifier(&identifier) };
}
unsafe { vz_config.setPlatform(&platform) };
boot::setup_boot_loader(&config.boot, &vz_config)?;
let mut storage_devices = Vec::new();
for disk in &config.disks {
let device = disk::create_storage_device(disk)?;
storage_devices.push(device);
}
let storage_refs: Vec<&VZStorageDeviceConfiguration> =
storage_devices.iter().map(|d| -> &VZStorageDeviceConfiguration { d }).collect();
let storage_array = NSArray::from_slice(&storage_refs);
unsafe { vz_config.setStorageDevices(&storage_array) };
let network_device = network::create_network_device(&config.network)?;
let net_ref: &VZNetworkDeviceConfiguration = &network_device;
let network_array = NSArray::from_slice(&[net_ref]);
unsafe { vz_config.setNetworkDevices(&network_array) };
if let Some(serial_config) = &config.serial {
let serial_port = serial::create_serial_port(serial_config)?;
let serial_ref: &VZSerialPortConfiguration = &serial_port;
let serial_array = NSArray::from_slice(&[serial_ref]);
unsafe { vz_config.setSerialPorts(&serial_array) };
}
if !config.shared_dirs.is_empty() {
let mut dir_devices = Vec::new();
for dir_config in &config.shared_dirs {
let device = shared_dir::create_shared_dir(dir_config)?;
dir_devices.push(device);
}
let dir_refs: Vec<&VZDirectorySharingDeviceConfiguration> =
dir_devices.iter().map(|d| -> &VZDirectorySharingDeviceConfiguration { d }).collect();
let dir_array = NSArray::from_slice(&dir_refs);
unsafe { vz_config.setDirectorySharingDevices(&dir_array) };
}
let entropy = unsafe { VZVirtioEntropyDeviceConfiguration::new() };
let entropy_ref: &VZEntropyDeviceConfiguration = &entropy;
let entropy_array = NSArray::from_slice(&[entropy_ref]);
unsafe { vz_config.setEntropyDevices(&entropy_array) };
tracing::debug!("validating VZ configuration...");
unsafe { vz_config.validateWithError() }.map_err(|e| {
let chain = crate::util::ns_error_chain(&e);
KasouError::Framework(format!("VM configuration validation failed: {chain}"))
})?;
tracing::debug!("VZ configuration validated successfully");
Ok(vz_config)
}
fn load_or_create_machine_identifier(
path: &std::path::Path,
platform: &VZGenericPlatformConfiguration,
) -> Result<Retained<VZGenericMachineIdentifier>, KasouError> {
use std::io::Write;
if path.exists() {
let bytes = std::fs::read(path).map_err(|e| {
KasouError::Validation(format!(
"read machine identifier from {}: {e}",
path.display()
))
})?;
let nsdata = NSData::with_bytes(&bytes);
use objc2::AllocAnyThread;
let alloc = VZGenericMachineIdentifier::alloc();
let maybe = unsafe {
VZGenericMachineIdentifier::initWithDataRepresentation(alloc, &nsdata)
};
return maybe.ok_or_else(|| {
KasouError::Validation(format!(
"machine identifier at {} is corrupt or wrong format",
path.display()
))
});
}
let identifier = unsafe { platform.machineIdentifier() };
let data = unsafe { identifier.dataRepresentation() };
let bytes_vec: Vec<u8> = data.to_vec();
let bytes_slice: &[u8] = &bytes_vec;
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|e| {
KasouError::Validation(format!(
"mkdir -p {}: {e}",
parent.display()
))
})?;
}
}
let tmp = path.with_extension("bin.tmp");
{
let mut f = std::fs::File::create(&tmp).map_err(|e| {
KasouError::Validation(format!("create {}: {e}", tmp.display()))
})?;
f.write_all(bytes_slice).map_err(|e| {
KasouError::Validation(format!("write {}: {e}", tmp.display()))
})?;
f.sync_all().map_err(|e| {
KasouError::Validation(format!("fsync {}: {e}", tmp.display()))
})?;
}
std::fs::rename(&tmp, path).map_err(|e| {
KasouError::Validation(format!(
"rename {} → {}: {e}",
tmp.display(),
path.display()
))
})?;
Ok(identifier)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn validate_rejects_zero_cpus() {
let config = VmConfig {
id: crate::types::VmId::default(),
cpus: 0,
memory_mib: 1024,
boot: BootConfig::Linux {
kernel: PathBuf::from("/nonexistent/kernel"),
initrd: PathBuf::from("/nonexistent/initrd"),
cmdline: String::new(),
},
disks: vec![DiskConfig {
path: PathBuf::from("/nonexistent/disk"),
read_only: false,
}],
network: NetworkConfig { mac_address: None },
serial: None,
shared_dirs: vec![],
machine_identifier_path: None,
};
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("cpus"));
}
#[test]
fn validate_rejects_zero_memory() {
let config = VmConfig {
id: crate::types::VmId::default(),
cpus: 1,
memory_mib: 0,
boot: BootConfig::Linux {
kernel: PathBuf::from("/nonexistent/kernel"),
initrd: PathBuf::from("/nonexistent/initrd"),
cmdline: String::new(),
},
disks: vec![DiskConfig {
path: PathBuf::from("/nonexistent/disk"),
read_only: false,
}],
network: NetworkConfig { mac_address: None },
serial: None,
shared_dirs: vec![],
machine_identifier_path: None,
};
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("memory"));
}
#[test]
fn machine_identifier_round_trip() {
let tmp = std::env::temp_dir().join(format!(
"kasou-machine-id-test-{}.bin",
std::process::id()
));
let _ = std::fs::remove_file(&tmp);
let platform = unsafe { VZGenericPlatformConfiguration::new() };
let first = load_or_create_machine_identifier(&tmp, &platform).unwrap();
assert!(tmp.exists(), "first call should persist the identifier");
let first_bytes = unsafe { first.dataRepresentation() }.to_vec();
let platform2 = unsafe { VZGenericPlatformConfiguration::new() };
let second = load_or_create_machine_identifier(&tmp, &platform2).unwrap();
let second_bytes = unsafe { second.dataRepresentation() }.to_vec();
assert_eq!(
first_bytes, second_bytes,
"restored identifier must match the persisted one byte-for-byte"
);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn machine_identifier_rejects_corrupt_file() {
let tmp = std::env::temp_dir().join(format!(
"kasou-corrupt-id-{}.bin",
std::process::id()
));
std::fs::write(&tmp, b"not a real machine identifier").unwrap();
let platform = unsafe { VZGenericPlatformConfiguration::new() };
let res = load_or_create_machine_identifier(&tmp, &platform);
assert!(
res.is_err(),
"corrupt identifier file should fail loudly, not silently regenerate"
);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn machine_identifier_creates_parent_dir() {
let tmp = std::env::temp_dir().join(format!(
"kasou-nested-{}-vmdir/machine-id.bin",
std::process::id()
));
let _ = std::fs::remove_dir_all(tmp.parent().unwrap());
let platform = unsafe { VZGenericPlatformConfiguration::new() };
let _id = load_or_create_machine_identifier(&tmp, &platform).unwrap();
assert!(tmp.exists());
let _ = std::fs::remove_dir_all(tmp.parent().unwrap());
}
#[test]
fn validate_rejects_no_disks() {
let config = VmConfig {
id: crate::types::VmId::default(),
cpus: 1,
memory_mib: 1024,
boot: BootConfig::Linux {
kernel: PathBuf::from("/nonexistent/kernel"),
initrd: PathBuf::from("/nonexistent/initrd"),
cmdline: String::new(),
},
disks: vec![],
network: NetworkConfig { mac_address: None },
serial: None,
shared_dirs: vec![],
machine_identifier_path: None,
};
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("disk"));
}
}