Skip to main content

arcbox_vm_driver/
spec.rs

1//! What a VM looks like before it runs: the serializable per-VM shape.
2//!
3//! Everything here is data. Node-wide knobs — VMM binary paths, seccomp,
4//! jailer defaults, MTU — are the adapter's own `DriverConfig`; a
5//! [`VmSpec`] says only what *this* VM is made of, so the same spec renders
6//! into a Firecracker API payload, a Virtualization.framework configuration,
7//! or an in-process VMM's config without the orchestrator knowing which.
8
9use std::collections::HashSet;
10use std::fmt;
11use std::os::fd::RawFd;
12use std::path::{Path, PathBuf};
13use std::str::FromStr;
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::{Error, Result};
18
19/// The identity of a VM: a plain name — non-empty, at most 64 characters,
20/// and `[A-Za-z0-9-]`.
21///
22/// Drivers use it as a runtime-directory and socket-name component, and hand
23/// it to the VMM as the instance id it runs under. The alphabet is therefore
24/// the *intersection* of what those uses allow, not the union: Firecracker
25/// validates its `--id` as `[A-Za-z0-9-]`, 1..=64, and **panics** on anything
26/// else — `Invalid instance ID: InvalidChar('_', 4)`, raised from inside
27/// whatever task spawned the VMM, with nothing tying it back to the request
28/// that supplied the id. A separator this type accepted but a driver could
29/// not run would be a promise the port cannot keep, so it accepts only `-`.
30/// [`MAX_LEN`](Self::MAX_LEN) is that same VMM-imposed 64.
31///
32/// A driver-internal name that never becomes a VMM identity — [`DiskSpec::id`]
33/// is the one here — keeps the wider path-safe alphabet (`.` and `_` too).
34#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
35#[serde(try_from = "String", into = "String")]
36pub struct VmId(String);
37
38impl VmId {
39    /// The longest id a driver has to fit into a path or socket name, and
40    /// the longest a VMM will take as its instance id.
41    pub const MAX_LEN: usize = 64;
42
43    /// Validates `id` and wraps it.
44    pub fn new(id: impl Into<String>) -> Result<Self> {
45        let id = id.into();
46        require_name_length("vm id", &id)?;
47        if let Some(bad) = id
48            .chars()
49            .find(|c| !(c.is_ascii_alphanumeric() || *c == '-'))
50        {
51            return Err(Error::InvalidSpec(format!(
52                "vm id `{id}` contains `{bad}`; allowed: A-Z a-z 0-9 -"
53            )));
54        }
55        Ok(Self(id))
56    }
57
58    /// The id as text.
59    pub fn as_str(&self) -> &str {
60        &self.0
61    }
62}
63
64impl fmt::Display for VmId {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str(&self.0)
67    }
68}
69
70impl AsRef<str> for VmId {
71    fn as_ref(&self) -> &str {
72        &self.0
73    }
74}
75
76impl TryFrom<String> for VmId {
77    type Error = Error;
78
79    fn try_from(value: String) -> Result<Self> {
80        Self::new(value)
81    }
82}
83
84impl From<VmId> for String {
85    fn from(id: VmId) -> Self {
86        id.0
87    }
88}
89
90/// A 48-bit Ethernet address, written `aa:bb:cc:dd:ee:ff`.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub struct MacAddr([u8; 6]);
93
94impl MacAddr {
95    /// Wraps six octets, most significant first.
96    pub const fn new(octets: [u8; 6]) -> Self {
97        Self(octets)
98    }
99
100    /// The six octets, most significant first.
101    pub const fn octets(&self) -> [u8; 6] {
102        self.0
103    }
104
105    /// `true` unless the group (multicast) bit is set.
106    pub const fn is_unicast(&self) -> bool {
107        self.0[0] & 0x01 == 0
108    }
109
110    /// `true` when every octet is zero — no NIC may carry this address.
111    pub const fn is_nil(&self) -> bool {
112        matches!(self.0, [0, 0, 0, 0, 0, 0])
113    }
114}
115
116impl fmt::Display for MacAddr {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        for (i, octet) in self.0.iter().enumerate() {
119            if i > 0 {
120                f.write_str(":")?;
121            }
122            write!(f, "{octet:02x}")?;
123        }
124        Ok(())
125    }
126}
127
128impl FromStr for MacAddr {
129    type Err = Error;
130
131    fn from_str(s: &str) -> Result<Self> {
132        let invalid =
133            || Error::InvalidSpec(format!("`{s}` is not a MAC address (aa:bb:cc:dd:ee:ff)"));
134        let mut octets = [0u8; 6];
135        let mut parts = s.split(':');
136        for octet in &mut octets {
137            let part = parts.next().ok_or_else(invalid)?;
138            if part.len() != 2 {
139                return Err(invalid());
140            }
141            *octet = u8::from_str_radix(part, 16).map_err(|_| invalid())?;
142        }
143        if parts.next().is_some() {
144            return Err(invalid());
145        }
146        Ok(Self(octets))
147    }
148}
149
150impl Serialize for MacAddr {
151    fn serialize<S: serde::Serializer>(
152        &self,
153        serializer: S,
154    ) -> std::result::Result<S::Ok, S::Error> {
155        serializer.collect_str(self)
156    }
157}
158
159impl<'de> Deserialize<'de> for MacAddr {
160    fn deserialize<D: serde::Deserializer<'de>>(
161        deserializer: D,
162    ) -> std::result::Result<Self, D::Error> {
163        String::deserialize(deserializer)?
164            .parse()
165            .map_err(serde::de::Error::custom)
166    }
167}
168
169/// Everything a driver needs to boot one VM.
170///
171/// Validate with [`VmSpec::validate`] before handing it to a driver; drivers
172/// validate again at `VmDriver::boot`, so an orchestrator that
173/// forgets still gets [`Error::InvalidSpec`] rather than a half-built VM.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct VmSpec {
176    /// The VM's identity.
177    pub id: VmId,
178    /// Virtual CPU count, at least 1.
179    pub cpus: u32,
180    /// Guest memory in MiB, at least 1.
181    pub memory_mib: u32,
182    /// How the guest starts.
183    pub boot: BootSpec,
184    /// Block devices, in bus order.
185    #[serde(default)]
186    pub disks: Vec<DiskSpec>,
187    /// Network interfaces, in bus order.
188    #[serde(default)]
189    pub nics: Vec<NicSpec>,
190    /// The vsock device, when the guest should have one.
191    #[serde(default)]
192    pub vsock: Option<VsockSpec>,
193    /// Shared host directories (virtiofs).
194    #[serde(default)]
195    pub shares: Vec<ShareSpec>,
196    /// Where the guest console goes.
197    #[serde(default)]
198    pub console: ConsoleSpec,
199    /// Attach a memory balloon.
200    #[serde(default)]
201    pub balloon: bool,
202    /// Attach an entropy (virtio-rng) device.
203    #[serde(default)]
204    pub entropy: bool,
205    /// Track dirty pages so a checkpoint can be incremental.
206    #[serde(default)]
207    pub dirty_tracking: bool,
208    /// How the VMM process is confined.
209    #[serde(default)]
210    pub isolation: IsolationSpec,
211}
212
213impl VmSpec {
214    /// Checks the invariants no driver can repair.
215    ///
216    /// CPU and memory are at least 1; disk ids are plain names (the path-safe
217    /// alphabet, wider than [`VmId`]'s) and unique; NIC ids and share tags are
218    /// non-empty and unique; at most one disk is the root; every MAC is a
219    /// non-nil unicast address; every boot, disk and share path is non-empty;
220    /// the vsock guest CID is 3 or above.
221    pub fn validate(&self) -> Result<()> {
222        if self.cpus == 0 {
223            return Err(Error::InvalidSpec("cpus must be at least 1".into()));
224        }
225        if self.memory_mib == 0 {
226            return Err(Error::InvalidSpec("memory_mib must be at least 1".into()));
227        }
228        self.boot.validate()?;
229        let mut disk_ids = HashSet::new();
230        for disk in &self.disks {
231            require_plain_name("disk id", &disk.id)?;
232            if !disk_ids.insert(disk.id.as_str()) {
233                return Err(Error::InvalidSpec(format!(
234                    "duplicate disk id `{}`",
235                    disk.id
236                )));
237            }
238            require_path("disk path", &disk.path)?;
239        }
240        if self.disks.iter().filter(|d| d.root).count() > 1 {
241            return Err(Error::InvalidSpec(
242                "more than one disk is marked root".into(),
243            ));
244        }
245        let mut nic_ids = HashSet::new();
246        for nic in &self.nics {
247            if nic.id.is_empty() {
248                return Err(Error::InvalidSpec("nic id must not be empty".into()));
249            }
250            if !nic_ids.insert(nic.id.as_str()) {
251                return Err(Error::InvalidSpec(format!("duplicate nic id `{}`", nic.id)));
252            }
253            if nic.mac.is_nil() || !nic.mac.is_unicast() {
254                return Err(Error::InvalidSpec(format!(
255                    "nic `{}` mac {} is not a unicast address",
256                    nic.id, nic.mac
257                )));
258            }
259        }
260        if let Some(vsock) = &self.vsock
261            && vsock.guest_cid < VsockSpec::FIRST_GUEST_CID
262        {
263            return Err(Error::InvalidSpec(format!(
264                "vsock guest_cid {} is reserved; use {} or above",
265                vsock.guest_cid,
266                VsockSpec::FIRST_GUEST_CID
267            )));
268        }
269        let mut share_tags = HashSet::new();
270        for share in &self.shares {
271            if share.tag.is_empty() {
272                return Err(Error::InvalidSpec("share tag must not be empty".into()));
273            }
274            if !share_tags.insert(share.tag.as_str()) {
275                return Err(Error::InvalidSpec(format!(
276                    "duplicate share tag `{}`",
277                    share.tag
278                )));
279            }
280            require_path("share host path", &share.host_path)?;
281        }
282        Ok(())
283    }
284}
285
286fn require_path(what: &str, path: &Path) -> Result<()> {
287    if path.as_os_str().is_empty() {
288        return Err(Error::InvalidSpec(format!("{what} must not be empty")));
289    }
290    Ok(())
291}
292
293/// The half of the name rules that does not depend on the alphabet:
294/// non-empty and at most [`VmId::MAX_LEN`] bytes.
295fn require_name_length(what: &str, name: &str) -> Result<()> {
296    if name.is_empty() {
297        return Err(Error::InvalidSpec(format!("{what} must not be empty")));
298    }
299    if name.len() > VmId::MAX_LEN {
300        return Err(Error::InvalidSpec(format!(
301            "{what} `{name}` exceeds {} characters",
302            VmId::MAX_LEN
303        )));
304    }
305    Ok(())
306}
307
308/// The rule for a driver-internal name — one a driver may turn into a path
309/// component or a URL segment, but never hands to a VMM as its instance id:
310/// [`require_name_length`] plus `[A-Za-z0-9._-]`, and not the `.` / `..`
311/// traversal names. Wider than [`VmId`]'s alphabet, which a VMM constrains.
312fn require_plain_name(what: &str, name: &str) -> Result<()> {
313    require_name_length(what, name)?;
314    if let Some(bad) = name
315        .chars()
316        .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')))
317    {
318        return Err(Error::InvalidSpec(format!(
319            "{what} `{name}` contains `{bad}`; allowed: A-Z a-z 0-9 . _ -"
320        )));
321    }
322    if name == "." || name == ".." {
323        return Err(Error::InvalidSpec(format!(
324            "{what} `{name}` is a path traversal name"
325        )));
326    }
327    Ok(())
328}
329
330/// How the guest starts.
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332#[serde(rename_all = "snake_case")]
333#[non_exhaustive]
334pub enum BootSpec {
335    /// Direct kernel boot.
336    Kernel {
337        /// The kernel image (vmlinux / Image).
338        image: PathBuf,
339        /// The kernel command line, verbatim.
340        cmdline: String,
341        /// An optional initial ramdisk.
342        initrd: Option<PathBuf>,
343    },
344    /// Firmware (UEFI) boot; the firmware finds the OS on the disks.
345    Firmware {
346        /// The firmware image.
347        image: PathBuf,
348    },
349    /// A macOS guest under Virtualization.framework.
350    MacOs {
351        /// The auxiliary storage (NVRAM) file.
352        aux_storage: PathBuf,
353        /// The opaque hardware-model blob the guest was installed for.
354        hardware_model: Vec<u8>,
355        /// The opaque machine-identifier blob.
356        machine_id: Vec<u8>,
357    },
358}
359
360impl BootSpec {
361    fn validate(&self) -> Result<()> {
362        match self {
363            Self::Kernel { image, initrd, .. } => {
364                require_path("kernel image", image)?;
365                if let Some(initrd) = initrd {
366                    require_path("initrd", initrd)?;
367                }
368                Ok(())
369            }
370            Self::Firmware { image } => require_path("firmware image", image),
371            Self::MacOs { aux_storage, .. } => require_path("aux storage", aux_storage),
372        }
373    }
374}
375
376/// One block device.
377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
378pub struct DiskSpec {
379    /// Unique within the spec; drivers use it as the device's name, and as
380    /// a file name or URL segment, so it is held to the [`VmId`] rule
381    /// (`[A-Za-z0-9._-]`, at most 64 characters, not `.` or `..`).
382    pub id: String,
383    /// The backing file on the host.
384    pub path: PathBuf,
385    /// Expose the disk read-only.
386    #[serde(default)]
387    pub read_only: bool,
388    /// This disk holds the root filesystem (at most one per spec).
389    #[serde(default)]
390    pub root: bool,
391    /// Host-side write caching.
392    #[serde(default)]
393    pub cache: CacheMode,
394}
395
396/// Host-side write caching for a disk (Firecracker's `DriveCacheType`).
397#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(rename_all = "snake_case")]
399pub enum CacheMode {
400    /// Writes complete when the host has them in memory (today's value).
401    #[default]
402    Unsafe,
403    /// Writes complete when the host has flushed them.
404    Writeback,
405}
406
407/// One network interface.
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
409pub struct NicSpec {
410    /// Unique within the spec; drivers use it as the device's name.
411    pub id: String,
412    /// The guest-visible MAC address.
413    pub mac: MacAddr,
414    /// What the host side of the interface is plugged into.
415    pub attachment: NicAttachment,
416}
417
418/// The host side of a NIC.
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
420#[serde(rename_all = "snake_case")]
421#[non_exhaustive]
422pub enum NicAttachment {
423    /// A Linux TAP device the guest network created.
424    Tap {
425        /// The interface name (`tap12`).
426        name: String,
427    },
428    /// The VMM's own NAT (Virtualization.framework's `NAT` attachment).
429    HostNat,
430    /// A host socket the VMM reads and writes Ethernet frames on.
431    ///
432    /// The number is a *borrowed* descriptor: the caller keeps it open for the
433    /// duration of `VmDriver::boot`, and the driver duplicates it if
434    /// the VMM needs it beyond that call.
435    ///
436    /// It is meaningful only in the process that opened it, so a spec
437    /// carrying this variant is process-local: it serializes (the number is
438    /// just data), but it is not what goes into a durable record or a TOML
439    /// file — an orchestrator persists the VM's identity as
440    /// [`VmRecord`](crate::driver::VmRecord) and re-derives the attachment
441    /// when it builds the next spec.
442    FileHandle {
443        /// The raw descriptor number, valid in the calling process.
444        fd: RawFd,
445    },
446    /// A host bridge interface (vmnet bridged mode, a Linux bridge).
447    Bridge {
448        /// The host interface to bridge onto.
449        interface: String,
450    },
451}
452
453/// The vsock device.
454#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
455pub struct VsockSpec {
456    /// The guest's context id: [`Self::FIRST_GUEST_CID`] or above (0 and 1
457    /// are reserved, 2 is the host).
458    pub guest_cid: u32,
459}
460
461impl VsockSpec {
462    /// The lowest context id a guest may take.
463    pub const FIRST_GUEST_CID: u32 = 3;
464}
465
466/// One shared host directory (virtiofs).
467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
468pub struct ShareSpec {
469    /// The mount tag the guest sees.
470    pub tag: String,
471    /// The host directory.
472    pub host_path: PathBuf,
473    /// Export read-only.
474    #[serde(default)]
475    pub read_only: bool,
476}
477
478/// Where the guest console goes.
479#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
480#[serde(rename_all = "snake_case")]
481#[non_exhaustive]
482pub enum ConsoleSpec {
483    /// No console device.
484    #[default]
485    Off,
486    /// Append console output to a file.
487    File(PathBuf),
488    /// Serve the console on a Unix socket.
489    Socket(PathBuf),
490}
491
492/// How the VMM process is confined.
493#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
494#[serde(rename_all = "snake_case")]
495#[non_exhaustive]
496pub enum IsolationSpec {
497    /// Run the VMM as the caller, unconfined.
498    #[default]
499    None,
500    /// Run under a jailer (Firecracker's `jailer`, or an equivalent).
501    Jailer {
502        /// The uid the VMM drops to.
503        uid: u32,
504        /// The gid the VMM drops to.
505        gid: u32,
506        /// The directory the per-VM chroot is created under.
507        chroot_base: PathBuf,
508        /// A network namespace to enter (`/var/run/netns/<name>`).
509        netns: Option<PathBuf>,
510        /// Start the VMM in a fresh PID namespace.
511        new_pid_ns: bool,
512        /// Cgroup placement.
513        cgroup: Option<CgroupSpec>,
514    },
515}
516
517/// Cgroup placement for a jailed VMM.
518#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
519pub struct CgroupSpec {
520    /// The cgroup hierarchy version (1 or 2).
521    pub version: u8,
522    /// The parent cgroup, relative to the hierarchy root.
523    pub parent: Option<String>,
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    fn mac(n: u8) -> MacAddr {
531        MacAddr::new([0x02, 0, 0, 0, 0, n])
532    }
533
534    fn spec() -> VmSpec {
535        VmSpec {
536            id: VmId::new("vm-1").unwrap(),
537            cpus: 2,
538            memory_mib: 512,
539            boot: BootSpec::Kernel {
540                image: "/boot/vmlinux".into(),
541                cmdline: "console=ttyS0".into(),
542                initrd: None,
543            },
544            disks: vec![DiskSpec {
545                id: "rootfs".into(),
546                path: "/img/rootfs.ext4".into(),
547                read_only: false,
548                root: true,
549                cache: CacheMode::default(),
550            }],
551            nics: vec![NicSpec {
552                id: "eth0".into(),
553                mac: mac(1),
554                attachment: NicAttachment::Tap {
555                    name: "tap0".into(),
556                },
557            }],
558            vsock: Some(VsockSpec { guest_cid: 3 }),
559            shares: vec![],
560            console: ConsoleSpec::Off,
561            balloon: false,
562            entropy: true,
563            dirty_tracking: false,
564            isolation: IsolationSpec::None,
565        }
566    }
567
568    fn invalid(spec: &VmSpec) -> String {
569        match spec.validate() {
570            Err(Error::InvalidSpec(msg)) => msg,
571            other => panic!("expected InvalidSpec, got {other:?}"),
572        }
573    }
574
575    #[test]
576    fn vm_id_accepts_the_documented_alphabet_and_nothing_else() {
577        assert!(VmId::new("a-b-c-D9").is_ok());
578        assert!(VmId::new("x".repeat(VmId::MAX_LEN)).is_ok());
579        assert!(VmId::new("").is_err());
580        assert!(VmId::new("x".repeat(VmId::MAX_LEN + 1)).is_err());
581        assert!(VmId::new("has space").is_err());
582        assert!(VmId::new("slash/y").is_err());
583        assert!(VmId::new("ünïcode").is_err());
584    }
585
586    /// A VMM validates the id it is given and refuses it late and loudly —
587    /// Firecracker `panic!`s on `--id inst_abc` from inside whatever task
588    /// spawned it. The separators a path would tolerate are refused here so
589    /// no such id can be built in the first place.
590    #[test]
591    fn vm_id_refuses_the_separators_a_vmm_will_not_take() {
592        for bad in ["inst_abc", "a.b", ".hidden", "...", ".", ".."] {
593            match VmId::new(bad) {
594                Err(Error::InvalidSpec(msg)) => {
595                    assert!(msg.contains("allowed: A-Z a-z 0-9 -"), "{msg}");
596                }
597                other => panic!("`{bad}` gave {other:?}"),
598            }
599            assert!(serde_json::from_str::<VmId>(&format!("\"{bad}\"")).is_err());
600        }
601        // A disk id is a driver-internal name, never a VMM identity, so it
602        // keeps the wider alphabet.
603        let mut spec = spec();
604        spec.disks[0].id = "root_disk.img".to_owned();
605        assert!(spec.validate().is_ok());
606    }
607
608    #[test]
609    fn vm_id_deserialization_validates() {
610        let ok: VmId = serde_json::from_str("\"vm-1\"").unwrap();
611        assert_eq!(ok.as_str(), "vm-1");
612        assert!(serde_json::from_str::<VmId>("\"bad id\"").is_err());
613        assert_eq!(serde_json::to_string(&ok).unwrap(), "\"vm-1\"");
614    }
615
616    #[test]
617    fn mac_parses_and_prints_canonical_form() {
618        let m: MacAddr = "AA:bb:0C:dd:ee:0f".parse().unwrap();
619        assert_eq!(m.octets(), [0xaa, 0xbb, 0x0c, 0xdd, 0xee, 0x0f]);
620        assert_eq!(m.to_string(), "aa:bb:0c:dd:ee:0f");
621        for bad in [
622            "aa:bb:cc:dd:ee",
623            "aa:bb:cc:dd:ee:ff:00",
624            "aa:bb:cc:dd:ee:g0",
625            "aabbccddeeff",
626            "a:b:c:d:e:f",
627        ] {
628            assert!(bad.parse::<MacAddr>().is_err(), "{bad} parsed");
629        }
630        assert_eq!(serde_json::to_string(&m).unwrap(), "\"aa:bb:0c:dd:ee:0f\"");
631        assert_eq!(
632            serde_json::from_str::<MacAddr>("\"aa:bb:0c:dd:ee:0f\"").unwrap(),
633            m
634        );
635        assert!(serde_json::from_str::<MacAddr>("\"nope\"").is_err());
636    }
637
638    #[test]
639    fn mac_classifies_unicast_and_nil() {
640        assert!(MacAddr::new([0x02, 1, 2, 3, 4, 5]).is_unicast());
641        assert!(!MacAddr::new([0x01, 0, 0x5e, 0, 0, 1]).is_unicast());
642        assert!(MacAddr::new([0; 6]).is_nil());
643        assert!(!mac(1).is_nil());
644    }
645
646    #[test]
647    fn a_well_formed_spec_validates() {
648        spec().validate().unwrap();
649    }
650
651    #[test]
652    fn zero_cpus_or_memory_are_rejected() {
653        let mut s = spec();
654        s.cpus = 0;
655        assert!(invalid(&s).contains("cpus"));
656        let mut s = spec();
657        s.memory_mib = 0;
658        assert!(invalid(&s).contains("memory_mib"));
659    }
660
661    #[test]
662    fn disk_ids_must_be_unique_and_root_is_singular() {
663        let mut s = spec();
664        s.disks.push(s.disks[0].clone());
665        assert!(invalid(&s).contains("duplicate disk id"));
666        let mut s = spec();
667        let mut second = s.disks[0].clone();
668        second.id = "data".into();
669        s.disks.push(second);
670        assert!(invalid(&s).contains("root"));
671        let mut s = spec();
672        s.disks[0].id.clear();
673        assert!(invalid(&s).contains("disk id"));
674        let mut s = spec();
675        s.disks[0].path = PathBuf::new();
676        assert!(invalid(&s).contains("disk path"));
677    }
678
679    #[test]
680    fn disk_ids_are_plain_names() {
681        for bad in [
682            ".",
683            "..",
684            "a/b",
685            "has space",
686            "x".repeat(VmId::MAX_LEN + 1).as_str(),
687        ] {
688            let mut s = spec();
689            s.disks[0].id = bad.into();
690            assert!(invalid(&s).contains("disk id"), "`{bad}` accepted");
691        }
692        let mut s = spec();
693        s.disks[0].id = "root.fs_v2-a".into();
694        s.validate().unwrap();
695    }
696
697    #[test]
698    fn nic_ids_must_be_unique_and_macs_unicast() {
699        let mut s = spec();
700        let mut second = s.nics[0].clone();
701        second.mac = mac(2);
702        s.nics.push(second);
703        assert!(invalid(&s).contains("duplicate nic id"));
704        let mut s = spec();
705        s.nics[0].mac = MacAddr::new([0x01, 0, 0, 0, 0, 1]);
706        assert!(invalid(&s).contains("unicast"));
707        let mut s = spec();
708        s.nics[0].mac = MacAddr::new([0; 6]);
709        assert!(invalid(&s).contains("unicast"));
710        let mut s = spec();
711        s.nics[0].id.clear();
712        assert!(invalid(&s).contains("nic id"));
713    }
714
715    #[test]
716    fn vsock_guest_cid_must_not_be_reserved() {
717        for cid in 0..VsockSpec::FIRST_GUEST_CID {
718            let mut s = spec();
719            s.vsock = Some(VsockSpec { guest_cid: cid });
720            assert!(invalid(&s).contains("guest_cid"), "cid {cid} accepted");
721        }
722        let mut s = spec();
723        s.vsock = Some(VsockSpec {
724            guest_cid: VsockSpec::FIRST_GUEST_CID,
725        });
726        s.validate().unwrap();
727    }
728
729    #[test]
730    fn share_tags_must_be_unique_and_paths_non_empty() {
731        let share = ShareSpec {
732            tag: "data".into(),
733            host_path: "/srv/data".into(),
734            read_only: false,
735        };
736        let mut s = spec();
737        s.shares = vec![share.clone(), share.clone()];
738        assert!(invalid(&s).contains("duplicate share tag"));
739        let mut s = spec();
740        s.shares = vec![ShareSpec {
741            tag: String::new(),
742            ..share.clone()
743        }];
744        assert!(invalid(&s).contains("share tag"));
745        let mut s = spec();
746        s.shares = vec![ShareSpec {
747            host_path: PathBuf::new(),
748            ..share.clone()
749        }];
750        assert!(invalid(&s).contains("share host path"));
751        let mut s = spec();
752        s.shares = vec![share];
753        s.validate().unwrap();
754    }
755
756    #[test]
757    fn boot_paths_must_be_non_empty() {
758        let mut s = spec();
759        s.boot = BootSpec::Kernel {
760            image: PathBuf::new(),
761            cmdline: String::new(),
762            initrd: None,
763        };
764        assert!(invalid(&s).contains("kernel image"));
765        let mut s = spec();
766        s.boot = BootSpec::Kernel {
767            image: "/boot/vmlinux".into(),
768            cmdline: String::new(),
769            initrd: Some(PathBuf::new()),
770        };
771        assert!(invalid(&s).contains("initrd"));
772        let mut s = spec();
773        s.boot = BootSpec::Firmware {
774            image: PathBuf::new(),
775        };
776        assert!(invalid(&s).contains("firmware image"));
777        let mut s = spec();
778        s.boot = BootSpec::MacOs {
779            aux_storage: PathBuf::new(),
780            hardware_model: vec![],
781            machine_id: vec![],
782        };
783        assert!(invalid(&s).contains("aux storage"));
784    }
785
786    #[test]
787    fn spec_round_trips_through_json_with_defaults_filled() {
788        let mut s = spec();
789        s.isolation = IsolationSpec::Jailer {
790            uid: 1000,
791            gid: 1000,
792            chroot_base: "/srv/jailer".into(),
793            netns: None,
794            new_pid_ns: true,
795            cgroup: Some(CgroupSpec {
796                version: 2,
797                parent: Some("arcbox".into()),
798            }),
799        };
800        s.console = ConsoleSpec::File("/tmp/console.log".into());
801        s.nics.push(NicSpec {
802            id: "eth1".into(),
803            mac: mac(3),
804            attachment: NicAttachment::FileHandle { fd: 7 },
805        });
806        let json = serde_json::to_string(&s).unwrap();
807        let back: VmSpec = serde_json::from_str(&json).unwrap();
808        assert_eq!(back, s);
809
810        // A minimal document — only the required fields — decodes with the
811        // documented defaults, which is what a hand-written TOML relies on.
812        let minimal = serde_json::json!({
813            "id": "vm-2",
814            "cpus": 1,
815            "memory_mib": 128,
816            "boot": { "firmware": { "image": "/fw/OVMF.fd" } },
817        });
818        let s: VmSpec = serde_json::from_value(minimal).unwrap();
819        assert_eq!(s.console, ConsoleSpec::Off);
820        assert_eq!(s.isolation, IsolationSpec::None);
821        assert!(s.disks.is_empty() && s.nics.is_empty() && s.vsock.is_none());
822        assert!(!s.balloon && !s.entropy && !s.dirty_tracking);
823    }
824}