1use 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#[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 pub const MAX_LEN: usize = 64;
42
43 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub struct MacAddr([u8; 6]);
93
94impl MacAddr {
95 pub const fn new(octets: [u8; 6]) -> Self {
97 Self(octets)
98 }
99
100 pub const fn octets(&self) -> [u8; 6] {
102 self.0
103 }
104
105 pub const fn is_unicast(&self) -> bool {
107 self.0[0] & 0x01 == 0
108 }
109
110 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct VmSpec {
176 pub id: VmId,
178 pub cpus: u32,
180 pub memory_mib: u32,
182 pub boot: BootSpec,
184 #[serde(default)]
186 pub disks: Vec<DiskSpec>,
187 #[serde(default)]
189 pub nics: Vec<NicSpec>,
190 #[serde(default)]
192 pub vsock: Option<VsockSpec>,
193 #[serde(default)]
195 pub shares: Vec<ShareSpec>,
196 #[serde(default)]
198 pub console: ConsoleSpec,
199 #[serde(default)]
201 pub balloon: bool,
202 #[serde(default)]
204 pub entropy: bool,
205 #[serde(default)]
207 pub dirty_tracking: bool,
208 #[serde(default)]
210 pub isolation: IsolationSpec,
211}
212
213impl VmSpec {
214 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
293fn 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
308fn 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332#[serde(rename_all = "snake_case")]
333#[non_exhaustive]
334pub enum BootSpec {
335 Kernel {
337 image: PathBuf,
339 cmdline: String,
341 initrd: Option<PathBuf>,
343 },
344 Firmware {
346 image: PathBuf,
348 },
349 MacOs {
351 aux_storage: PathBuf,
353 hardware_model: Vec<u8>,
355 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
378pub struct DiskSpec {
379 pub id: String,
383 pub path: PathBuf,
385 #[serde(default)]
387 pub read_only: bool,
388 #[serde(default)]
390 pub root: bool,
391 #[serde(default)]
393 pub cache: CacheMode,
394}
395
396#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(rename_all = "snake_case")]
399pub enum CacheMode {
400 #[default]
402 Unsafe,
403 Writeback,
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
409pub struct NicSpec {
410 pub id: String,
412 pub mac: MacAddr,
414 pub attachment: NicAttachment,
416}
417
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
420#[serde(rename_all = "snake_case")]
421#[non_exhaustive]
422pub enum NicAttachment {
423 Tap {
425 name: String,
427 },
428 HostNat,
430 FileHandle {
443 fd: RawFd,
445 },
446 Bridge {
448 interface: String,
450 },
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
455pub struct VsockSpec {
456 pub guest_cid: u32,
459}
460
461impl VsockSpec {
462 pub const FIRST_GUEST_CID: u32 = 3;
464}
465
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
468pub struct ShareSpec {
469 pub tag: String,
471 pub host_path: PathBuf,
473 #[serde(default)]
475 pub read_only: bool,
476}
477
478#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
480#[serde(rename_all = "snake_case")]
481#[non_exhaustive]
482pub enum ConsoleSpec {
483 #[default]
485 Off,
486 File(PathBuf),
488 Socket(PathBuf),
490}
491
492#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
494#[serde(rename_all = "snake_case")]
495#[non_exhaustive]
496pub enum IsolationSpec {
497 #[default]
499 None,
500 Jailer {
502 uid: u32,
504 gid: u32,
506 chroot_base: PathBuf,
508 netns: Option<PathBuf>,
510 new_pid_ns: bool,
512 cgroup: Option<CgroupSpec>,
514 },
515}
516
517#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
519pub struct CgroupSpec {
520 pub version: u8,
522 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 #[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 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 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}