use std::collections::HashSet;
use std::fmt;
use std::os::fd::RawFd;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct VmId(String);
impl VmId {
pub const MAX_LEN: usize = 64;
pub fn new(id: impl Into<String>) -> Result<Self> {
let id = id.into();
require_name_length("vm id", &id)?;
if let Some(bad) = id
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || *c == '-'))
{
return Err(Error::InvalidSpec(format!(
"vm id `{id}` contains `{bad}`; allowed: A-Z a-z 0-9 -"
)));
}
Ok(Self(id))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for VmId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for VmId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for VmId {
type Error = Error;
fn try_from(value: String) -> Result<Self> {
Self::new(value)
}
}
impl From<VmId> for String {
fn from(id: VmId) -> Self {
id.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MacAddr([u8; 6]);
impl MacAddr {
pub const fn new(octets: [u8; 6]) -> Self {
Self(octets)
}
pub const fn octets(&self) -> [u8; 6] {
self.0
}
pub const fn is_unicast(&self) -> bool {
self.0[0] & 0x01 == 0
}
pub const fn is_nil(&self) -> bool {
matches!(self.0, [0, 0, 0, 0, 0, 0])
}
}
impl fmt::Display for MacAddr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, octet) in self.0.iter().enumerate() {
if i > 0 {
f.write_str(":")?;
}
write!(f, "{octet:02x}")?;
}
Ok(())
}
}
impl FromStr for MacAddr {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
let invalid =
|| Error::InvalidSpec(format!("`{s}` is not a MAC address (aa:bb:cc:dd:ee:ff)"));
let mut octets = [0u8; 6];
let mut parts = s.split(':');
for octet in &mut octets {
let part = parts.next().ok_or_else(invalid)?;
if part.len() != 2 {
return Err(invalid());
}
*octet = u8::from_str_radix(part, 16).map_err(|_| invalid())?;
}
if parts.next().is_some() {
return Err(invalid());
}
Ok(Self(octets))
}
}
impl Serialize for MacAddr {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for MacAddr {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
String::deserialize(deserializer)?
.parse()
.map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VmSpec {
pub id: VmId,
pub cpus: u32,
pub memory_mib: u32,
pub boot: BootSpec,
#[serde(default)]
pub disks: Vec<DiskSpec>,
#[serde(default)]
pub nics: Vec<NicSpec>,
#[serde(default)]
pub vsock: Option<VsockSpec>,
#[serde(default)]
pub shares: Vec<ShareSpec>,
#[serde(default)]
pub console: ConsoleSpec,
#[serde(default)]
pub balloon: bool,
#[serde(default)]
pub entropy: bool,
#[serde(default)]
pub dirty_tracking: bool,
#[serde(default)]
pub isolation: IsolationSpec,
}
impl VmSpec {
pub fn validate(&self) -> Result<()> {
if self.cpus == 0 {
return Err(Error::InvalidSpec("cpus must be at least 1".into()));
}
if self.memory_mib == 0 {
return Err(Error::InvalidSpec("memory_mib must be at least 1".into()));
}
self.boot.validate()?;
let mut disk_ids = HashSet::new();
for disk in &self.disks {
require_plain_name("disk id", &disk.id)?;
if !disk_ids.insert(disk.id.as_str()) {
return Err(Error::InvalidSpec(format!(
"duplicate disk id `{}`",
disk.id
)));
}
require_path("disk path", &disk.path)?;
}
if self.disks.iter().filter(|d| d.root).count() > 1 {
return Err(Error::InvalidSpec(
"more than one disk is marked root".into(),
));
}
let mut nic_ids = HashSet::new();
for nic in &self.nics {
if nic.id.is_empty() {
return Err(Error::InvalidSpec("nic id must not be empty".into()));
}
if !nic_ids.insert(nic.id.as_str()) {
return Err(Error::InvalidSpec(format!("duplicate nic id `{}`", nic.id)));
}
if nic.mac.is_nil() || !nic.mac.is_unicast() {
return Err(Error::InvalidSpec(format!(
"nic `{}` mac {} is not a unicast address",
nic.id, nic.mac
)));
}
}
if let Some(vsock) = &self.vsock
&& vsock.guest_cid < VsockSpec::FIRST_GUEST_CID
{
return Err(Error::InvalidSpec(format!(
"vsock guest_cid {} is reserved; use {} or above",
vsock.guest_cid,
VsockSpec::FIRST_GUEST_CID
)));
}
let mut share_tags = HashSet::new();
for share in &self.shares {
if share.tag.is_empty() {
return Err(Error::InvalidSpec("share tag must not be empty".into()));
}
if !share_tags.insert(share.tag.as_str()) {
return Err(Error::InvalidSpec(format!(
"duplicate share tag `{}`",
share.tag
)));
}
require_path("share host path", &share.host_path)?;
}
Ok(())
}
}
fn require_path(what: &str, path: &Path) -> Result<()> {
if path.as_os_str().is_empty() {
return Err(Error::InvalidSpec(format!("{what} must not be empty")));
}
Ok(())
}
fn require_name_length(what: &str, name: &str) -> Result<()> {
if name.is_empty() {
return Err(Error::InvalidSpec(format!("{what} must not be empty")));
}
if name.len() > VmId::MAX_LEN {
return Err(Error::InvalidSpec(format!(
"{what} `{name}` exceeds {} characters",
VmId::MAX_LEN
)));
}
Ok(())
}
fn require_plain_name(what: &str, name: &str) -> Result<()> {
require_name_length(what, name)?;
if let Some(bad) = name
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')))
{
return Err(Error::InvalidSpec(format!(
"{what} `{name}` contains `{bad}`; allowed: A-Z a-z 0-9 . _ -"
)));
}
if name == "." || name == ".." {
return Err(Error::InvalidSpec(format!(
"{what} `{name}` is a path traversal name"
)));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum BootSpec {
Kernel {
image: PathBuf,
cmdline: String,
initrd: Option<PathBuf>,
},
Firmware {
image: PathBuf,
},
MacOs {
aux_storage: PathBuf,
hardware_model: Vec<u8>,
machine_id: Vec<u8>,
},
}
impl BootSpec {
fn validate(&self) -> Result<()> {
match self {
Self::Kernel { image, initrd, .. } => {
require_path("kernel image", image)?;
if let Some(initrd) = initrd {
require_path("initrd", initrd)?;
}
Ok(())
}
Self::Firmware { image } => require_path("firmware image", image),
Self::MacOs { aux_storage, .. } => require_path("aux storage", aux_storage),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiskSpec {
pub id: String,
pub path: PathBuf,
#[serde(default)]
pub read_only: bool,
#[serde(default)]
pub root: bool,
#[serde(default)]
pub cache: CacheMode,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CacheMode {
#[default]
Unsafe,
Writeback,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NicSpec {
pub id: String,
pub mac: MacAddr,
pub attachment: NicAttachment,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum NicAttachment {
Tap {
name: String,
},
HostNat,
FileHandle {
fd: RawFd,
},
Bridge {
interface: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct VsockSpec {
pub guest_cid: u32,
}
impl VsockSpec {
pub const FIRST_GUEST_CID: u32 = 3;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShareSpec {
pub tag: String,
pub host_path: PathBuf,
#[serde(default)]
pub read_only: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ConsoleSpec {
#[default]
Off,
File(PathBuf),
Socket(PathBuf),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum IsolationSpec {
#[default]
None,
Jailer {
uid: u32,
gid: u32,
chroot_base: PathBuf,
netns: Option<PathBuf>,
new_pid_ns: bool,
cgroup: Option<CgroupSpec>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CgroupSpec {
pub version: u8,
pub parent: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn mac(n: u8) -> MacAddr {
MacAddr::new([0x02, 0, 0, 0, 0, n])
}
fn spec() -> VmSpec {
VmSpec {
id: VmId::new("vm-1").unwrap(),
cpus: 2,
memory_mib: 512,
boot: BootSpec::Kernel {
image: "/boot/vmlinux".into(),
cmdline: "console=ttyS0".into(),
initrd: None,
},
disks: vec![DiskSpec {
id: "rootfs".into(),
path: "/img/rootfs.ext4".into(),
read_only: false,
root: true,
cache: CacheMode::default(),
}],
nics: vec![NicSpec {
id: "eth0".into(),
mac: mac(1),
attachment: NicAttachment::Tap {
name: "tap0".into(),
},
}],
vsock: Some(VsockSpec { guest_cid: 3 }),
shares: vec![],
console: ConsoleSpec::Off,
balloon: false,
entropy: true,
dirty_tracking: false,
isolation: IsolationSpec::None,
}
}
fn invalid(spec: &VmSpec) -> String {
match spec.validate() {
Err(Error::InvalidSpec(msg)) => msg,
other => panic!("expected InvalidSpec, got {other:?}"),
}
}
#[test]
fn vm_id_accepts_the_documented_alphabet_and_nothing_else() {
assert!(VmId::new("a-b-c-D9").is_ok());
assert!(VmId::new("x".repeat(VmId::MAX_LEN)).is_ok());
assert!(VmId::new("").is_err());
assert!(VmId::new("x".repeat(VmId::MAX_LEN + 1)).is_err());
assert!(VmId::new("has space").is_err());
assert!(VmId::new("slash/y").is_err());
assert!(VmId::new("ünïcode").is_err());
}
#[test]
fn vm_id_refuses_the_separators_a_vmm_will_not_take() {
for bad in ["inst_abc", "a.b", ".hidden", "...", ".", ".."] {
match VmId::new(bad) {
Err(Error::InvalidSpec(msg)) => {
assert!(msg.contains("allowed: A-Z a-z 0-9 -"), "{msg}");
}
other => panic!("`{bad}` gave {other:?}"),
}
assert!(serde_json::from_str::<VmId>(&format!("\"{bad}\"")).is_err());
}
let mut spec = spec();
spec.disks[0].id = "root_disk.img".to_owned();
assert!(spec.validate().is_ok());
}
#[test]
fn vm_id_deserialization_validates() {
let ok: VmId = serde_json::from_str("\"vm-1\"").unwrap();
assert_eq!(ok.as_str(), "vm-1");
assert!(serde_json::from_str::<VmId>("\"bad id\"").is_err());
assert_eq!(serde_json::to_string(&ok).unwrap(), "\"vm-1\"");
}
#[test]
fn mac_parses_and_prints_canonical_form() {
let m: MacAddr = "AA:bb:0C:dd:ee:0f".parse().unwrap();
assert_eq!(m.octets(), [0xaa, 0xbb, 0x0c, 0xdd, 0xee, 0x0f]);
assert_eq!(m.to_string(), "aa:bb:0c:dd:ee:0f");
for bad in [
"aa:bb:cc:dd:ee",
"aa:bb:cc:dd:ee:ff:00",
"aa:bb:cc:dd:ee:g0",
"aabbccddeeff",
"a:b:c:d:e:f",
] {
assert!(bad.parse::<MacAddr>().is_err(), "{bad} parsed");
}
assert_eq!(serde_json::to_string(&m).unwrap(), "\"aa:bb:0c:dd:ee:0f\"");
assert_eq!(
serde_json::from_str::<MacAddr>("\"aa:bb:0c:dd:ee:0f\"").unwrap(),
m
);
assert!(serde_json::from_str::<MacAddr>("\"nope\"").is_err());
}
#[test]
fn mac_classifies_unicast_and_nil() {
assert!(MacAddr::new([0x02, 1, 2, 3, 4, 5]).is_unicast());
assert!(!MacAddr::new([0x01, 0, 0x5e, 0, 0, 1]).is_unicast());
assert!(MacAddr::new([0; 6]).is_nil());
assert!(!mac(1).is_nil());
}
#[test]
fn a_well_formed_spec_validates() {
spec().validate().unwrap();
}
#[test]
fn zero_cpus_or_memory_are_rejected() {
let mut s = spec();
s.cpus = 0;
assert!(invalid(&s).contains("cpus"));
let mut s = spec();
s.memory_mib = 0;
assert!(invalid(&s).contains("memory_mib"));
}
#[test]
fn disk_ids_must_be_unique_and_root_is_singular() {
let mut s = spec();
s.disks.push(s.disks[0].clone());
assert!(invalid(&s).contains("duplicate disk id"));
let mut s = spec();
let mut second = s.disks[0].clone();
second.id = "data".into();
s.disks.push(second);
assert!(invalid(&s).contains("root"));
let mut s = spec();
s.disks[0].id.clear();
assert!(invalid(&s).contains("disk id"));
let mut s = spec();
s.disks[0].path = PathBuf::new();
assert!(invalid(&s).contains("disk path"));
}
#[test]
fn disk_ids_are_plain_names() {
for bad in [
".",
"..",
"a/b",
"has space",
"x".repeat(VmId::MAX_LEN + 1).as_str(),
] {
let mut s = spec();
s.disks[0].id = bad.into();
assert!(invalid(&s).contains("disk id"), "`{bad}` accepted");
}
let mut s = spec();
s.disks[0].id = "root.fs_v2-a".into();
s.validate().unwrap();
}
#[test]
fn nic_ids_must_be_unique_and_macs_unicast() {
let mut s = spec();
let mut second = s.nics[0].clone();
second.mac = mac(2);
s.nics.push(second);
assert!(invalid(&s).contains("duplicate nic id"));
let mut s = spec();
s.nics[0].mac = MacAddr::new([0x01, 0, 0, 0, 0, 1]);
assert!(invalid(&s).contains("unicast"));
let mut s = spec();
s.nics[0].mac = MacAddr::new([0; 6]);
assert!(invalid(&s).contains("unicast"));
let mut s = spec();
s.nics[0].id.clear();
assert!(invalid(&s).contains("nic id"));
}
#[test]
fn vsock_guest_cid_must_not_be_reserved() {
for cid in 0..VsockSpec::FIRST_GUEST_CID {
let mut s = spec();
s.vsock = Some(VsockSpec { guest_cid: cid });
assert!(invalid(&s).contains("guest_cid"), "cid {cid} accepted");
}
let mut s = spec();
s.vsock = Some(VsockSpec {
guest_cid: VsockSpec::FIRST_GUEST_CID,
});
s.validate().unwrap();
}
#[test]
fn share_tags_must_be_unique_and_paths_non_empty() {
let share = ShareSpec {
tag: "data".into(),
host_path: "/srv/data".into(),
read_only: false,
};
let mut s = spec();
s.shares = vec![share.clone(), share.clone()];
assert!(invalid(&s).contains("duplicate share tag"));
let mut s = spec();
s.shares = vec![ShareSpec {
tag: String::new(),
..share.clone()
}];
assert!(invalid(&s).contains("share tag"));
let mut s = spec();
s.shares = vec![ShareSpec {
host_path: PathBuf::new(),
..share.clone()
}];
assert!(invalid(&s).contains("share host path"));
let mut s = spec();
s.shares = vec![share];
s.validate().unwrap();
}
#[test]
fn boot_paths_must_be_non_empty() {
let mut s = spec();
s.boot = BootSpec::Kernel {
image: PathBuf::new(),
cmdline: String::new(),
initrd: None,
};
assert!(invalid(&s).contains("kernel image"));
let mut s = spec();
s.boot = BootSpec::Kernel {
image: "/boot/vmlinux".into(),
cmdline: String::new(),
initrd: Some(PathBuf::new()),
};
assert!(invalid(&s).contains("initrd"));
let mut s = spec();
s.boot = BootSpec::Firmware {
image: PathBuf::new(),
};
assert!(invalid(&s).contains("firmware image"));
let mut s = spec();
s.boot = BootSpec::MacOs {
aux_storage: PathBuf::new(),
hardware_model: vec![],
machine_id: vec![],
};
assert!(invalid(&s).contains("aux storage"));
}
#[test]
fn spec_round_trips_through_json_with_defaults_filled() {
let mut s = spec();
s.isolation = IsolationSpec::Jailer {
uid: 1000,
gid: 1000,
chroot_base: "/srv/jailer".into(),
netns: None,
new_pid_ns: true,
cgroup: Some(CgroupSpec {
version: 2,
parent: Some("arcbox".into()),
}),
};
s.console = ConsoleSpec::File("/tmp/console.log".into());
s.nics.push(NicSpec {
id: "eth1".into(),
mac: mac(3),
attachment: NicAttachment::FileHandle { fd: 7 },
});
let json = serde_json::to_string(&s).unwrap();
let back: VmSpec = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
let minimal = serde_json::json!({
"id": "vm-2",
"cpus": 1,
"memory_mib": 128,
"boot": { "firmware": { "image": "/fw/OVMF.fd" } },
});
let s: VmSpec = serde_json::from_value(minimal).unwrap();
assert_eq!(s.console, ConsoleSpec::Off);
assert_eq!(s.isolation, IsolationSpec::None);
assert!(s.disks.is_empty() && s.nics.is_empty() && s.vsock.is_none());
assert!(!s.balloon && !s.entropy && !s.dirty_tracking);
}
}