Skip to main content

squib_api/schemas/
common.rs

1//! Boundary newtypes shared across endpoint schemas.
2//!
3//! Per [70-security.md § 4](../../../specs/70-security.md#4-input-validation), every
4//! domain primitive that crosses the HTTP boundary is wrapped in a newtype with private
5//! fields and a fallible constructor. Validation runs once in `try_from`; every
6//! downstream use is provably safe by construction.
7//!
8//! Per [10-data-model.md § 2.3](../../../specs/10-data-model.md#23-schema-layer):
9//!
10//! - identifier regex allowlist: `^[A-Za-z0-9_]{1,64}$`
11//! - default string cap 256 **bytes** (not chars; multi-byte exhaustion is an attack)
12//! - path cap 1024 bytes (Darwin `PATH_MAX`)
13//! - UDS path cap 103 bytes (Darwin `sun_path` minus the NUL terminator)
14//! - per-class collection caps calibrated against the 32-slot virtio-MMIO budget
15
16use std::path::PathBuf;
17
18use serde::{Deserialize, Serialize};
19
20/// Maximum number of `/drives` rows in a single configuration.
21pub const MAX_DRIVES: usize = 8;
22
23/// Maximum number of `/network-interfaces` rows in a single configuration.
24pub const MAX_NICS: usize = 8;
25
26/// Maximum number of `/pmem` rows in a single configuration.
27pub const MAX_PMEM: usize = 4;
28
29/// Maximum number of `virtio-mem` instances in a single configuration.
30pub const MAX_VIRTIO_MEM: usize = 1;
31
32/// Default per-string byte cap for fields without an explicit override.
33pub const DEFAULT_STRING_CAP: usize = 256;
34
35/// Path-shaped string cap (Darwin `PATH_MAX`).
36pub const PATH_MAX: usize = 1024;
37
38/// Unix-domain-socket path cap (Darwin `sun_path` size minus the NUL terminator).
39pub const UDS_PATH_MAX: usize = 103;
40
41/// Maximum vCPU count, matching upstream `MAX_SUPPORTED_VCPUS` and D19.
42pub const MAX_VCPU_COUNT: u32 = 32;
43
44/// Identifier regex per upstream Firecracker (`^[A-Za-z0-9_]{1,64}$`).
45fn is_valid_identifier(s: &str) -> bool {
46    !s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
47}
48
49fn ensure_no_nul(value: &str, field: &str) -> Result<(), String> {
50    if value.contains('\0') {
51        return Err(format!("{field} must not contain NUL bytes"));
52    }
53    Ok(())
54}
55
56/// Validated microVM instance ID (`--id` / `InstanceInfo.id`). Regex
57/// `^[A-Za-z0-9_]{1,64}$`; empty rejected.
58#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize)]
59#[serde(transparent)]
60pub struct InstanceId(String);
61
62impl InstanceId {
63    /// Wrap an instance ID after validating the upstream identifier rules.
64    pub fn new(id: impl Into<String>) -> Result<Self, String> {
65        let id = id.into();
66        if !is_valid_identifier(&id) {
67            return Err(format!(
68                "Invalid id: must match ^[A-Za-z0-9_]{{1,64}}$ (got {} bytes)",
69                id.len()
70            ));
71        }
72        Ok(Self(id))
73    }
74
75    /// Borrow the inner string.
76    #[must_use]
77    pub fn as_str(&self) -> &str {
78        &self.0
79    }
80}
81
82impl AsRef<str> for InstanceId {
83    fn as_ref(&self) -> &str {
84        &self.0
85    }
86}
87
88impl<'de> Deserialize<'de> for InstanceId {
89    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
90        let s = String::deserialize(de)?;
91        Self::new(s).map_err(serde::de::Error::custom)
92    }
93}
94
95/// Validated `drive_id`. Regex `^[A-Za-z0-9_]{1,64}$`.
96#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize)]
97#[serde(transparent)]
98pub struct DriveId(String);
99
100impl DriveId {
101    /// Wrap a drive ID after running the identifier allowlist.
102    pub fn new(id: impl Into<String>) -> Result<Self, String> {
103        let id = id.into();
104        if !is_valid_identifier(&id) {
105            return Err("Invalid drive_id: must match ^[A-Za-z0-9_]{1,64}$".to_string());
106        }
107        Ok(Self(id))
108    }
109
110    /// Borrow the inner string.
111    #[must_use]
112    pub fn as_str(&self) -> &str {
113        &self.0
114    }
115}
116
117impl<'de> Deserialize<'de> for DriveId {
118    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
119        let s = String::deserialize(de)?;
120        Self::new(s).map_err(serde::de::Error::custom)
121    }
122}
123
124/// Validated `iface_id`. Regex `^[A-Za-z0-9_]{1,64}$`.
125#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize)]
126#[serde(transparent)]
127pub struct IfaceId(String);
128
129impl IfaceId {
130    /// Wrap a network-interface ID after running the identifier allowlist.
131    pub fn new(id: impl Into<String>) -> Result<Self, String> {
132        let id = id.into();
133        if !is_valid_identifier(&id) {
134            return Err("Invalid iface_id: must match ^[A-Za-z0-9_]{1,64}$".to_string());
135        }
136        Ok(Self(id))
137    }
138
139    /// Borrow the inner string.
140    #[must_use]
141    pub fn as_str(&self) -> &str {
142        &self.0
143    }
144}
145
146impl<'de> Deserialize<'de> for IfaceId {
147    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
148        let s = String::deserialize(de)?;
149        Self::new(s).map_err(serde::de::Error::custom)
150    }
151}
152
153/// Validated vsock ID (the upstream `vsock_id` field). Regex `^[A-Za-z0-9_]{1,64}$`.
154#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize)]
155#[serde(transparent)]
156pub struct VsockId(String);
157
158impl VsockId {
159    /// Wrap a vsock ID after running the identifier allowlist.
160    pub fn new(id: impl Into<String>) -> Result<Self, String> {
161        let id = id.into();
162        if !is_valid_identifier(&id) {
163            return Err("Invalid vsock_id: must match ^[A-Za-z0-9_]{1,64}$".to_string());
164        }
165        Ok(Self(id))
166    }
167
168    /// Borrow the inner string.
169    #[must_use]
170    pub fn as_str(&self) -> &str {
171        &self.0
172    }
173}
174
175impl<'de> Deserialize<'de> for VsockId {
176    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
177        let s = String::deserialize(de)?;
178        Self::new(s).map_err(serde::de::Error::custom)
179    }
180}
181
182/// Validated host-filesystem path (`path_on_host`, `kernel_image_path`, etc.).
183///
184/// Performs three checks: byte length cap (`PATH_MAX = 1024`), no NUL bytes, and (for
185/// fields where it applies) parent-traversal rejection. Canonicalization happens at
186/// the consumer (the loader / drive opener), not here — Phase 2 cannot canonicalize
187/// because the file may not exist yet at config-load time.
188#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
189#[serde(transparent)]
190pub struct SafePath(PathBuf);
191
192impl SafePath {
193    /// Wrap a host filesystem path after running the boundary checks.
194    pub fn new(path: impl Into<PathBuf>) -> Result<Self, String> {
195        let path = path.into();
196        let s = path
197            .to_str()
198            .ok_or_else(|| "path is not valid UTF-8".to_string())?;
199        if s.is_empty() {
200            return Err("path must not be empty".into());
201        }
202        if s.len() > PATH_MAX {
203            return Err(format!(
204                "path exceeds {PATH_MAX} bytes (got {} bytes)",
205                s.len()
206            ));
207        }
208        ensure_no_nul(s, "path")?;
209        Ok(Self(path))
210    }
211
212    /// Borrow the inner [`std::path::Path`].
213    #[must_use]
214    pub fn as_path(&self) -> &std::path::Path {
215        &self.0
216    }
217}
218
219impl AsRef<std::path::Path> for SafePath {
220    fn as_ref(&self) -> &std::path::Path {
221        &self.0
222    }
223}
224
225impl From<SafePath> for PathBuf {
226    fn from(p: SafePath) -> Self {
227        p.0
228    }
229}
230
231impl<'de> Deserialize<'de> for SafePath {
232    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
233        let s = String::deserialize(de)?;
234        Self::new(s).map_err(serde::de::Error::custom)
235    }
236}
237
238/// Validated UDS path (`api_sock`, `vsock.uds_path`, `mem_backend.backend_path`).
239///
240/// Capped at 103 bytes to fit Darwin's `sockaddr_un.sun_path` minus the NUL terminator;
241/// the kernel silently truncates beyond this, so a hard cap surfaces the misconfiguration
242/// as a 4xx instead of a "connection refused" debugging session ([70 §
243/// 5](../../../specs/70-security.md#5-path-inputs)).
244#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
245#[serde(transparent)]
246pub struct UdsPath(PathBuf);
247
248impl UdsPath {
249    /// Wrap a UDS path, enforcing the Darwin `sun_path` cap.
250    pub fn new(path: impl Into<PathBuf>) -> Result<Self, String> {
251        let path = path.into();
252        let s = path
253            .to_str()
254            .ok_or_else(|| "uds path is not valid UTF-8".to_string())?;
255        if s.is_empty() {
256            return Err("uds path must not be empty".into());
257        }
258        if s.len() > UDS_PATH_MAX {
259            return Err(format!(
260                "uds path exceeds {UDS_PATH_MAX} bytes on Darwin (got {} bytes)",
261                s.len()
262            ));
263        }
264        ensure_no_nul(s, "uds path")?;
265        Ok(Self(path))
266    }
267
268    /// Borrow the inner [`std::path::Path`].
269    #[must_use]
270    pub fn as_path(&self) -> &std::path::Path {
271        &self.0
272    }
273}
274
275impl<'de> Deserialize<'de> for UdsPath {
276    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
277        let s = String::deserialize(de)?;
278        Self::new(s).map_err(serde::de::Error::custom)
279    }
280}
281
282/// Validated memory size in MiB. The upper bound is host-RAM-dependent and validated
283/// at the controller against `BackendCapabilities`; the type-level constraint is
284/// `mem_size_mib >= 1`.
285#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)]
286#[serde(transparent)]
287pub struct MemSizeMib(u64);
288
289impl MemSizeMib {
290    /// Wrap a memory-size value in MiB, enforcing the lower bound.
291    pub fn new(value: u64) -> Result<Self, String> {
292        if value == 0 {
293            return Err("mem_size_mib must be >= 1".into());
294        }
295        Ok(Self(value))
296    }
297
298    /// The raw MiB count.
299    #[must_use]
300    pub const fn get(self) -> u64 {
301        self.0
302    }
303}
304
305impl<'de> Deserialize<'de> for MemSizeMib {
306    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
307        let v = u64::deserialize(de)?;
308        Self::new(v).map_err(serde::de::Error::custom)
309    }
310}
311
312/// Validated 48-bit MAC address. Accepts the canonical `aa:bb:cc:dd:ee:ff` shape
313/// (lowercase or uppercase), rejects anything else.
314#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize)]
315#[serde(transparent)]
316pub struct MacAddr {
317    #[serde(with = "mac_str")]
318    bytes: [u8; 6],
319}
320
321impl MacAddr {
322    /// Wrap raw bytes.
323    #[must_use]
324    pub const fn from_bytes(bytes: [u8; 6]) -> Self {
325        Self { bytes }
326    }
327
328    /// Borrow the underlying 6-byte representation. The VMM consumes this
329    /// to seed the virtio-net `guest_mac`; upstream keeps the getter narrow
330    /// so the raw bytes don't leak into arbitrary code.
331    #[must_use]
332    pub const fn bytes(&self) -> [u8; 6] {
333        self.bytes
334    }
335
336    /// The wire-shape `aa:bb:cc:dd:ee:ff` representation.
337    #[must_use]
338    pub fn to_canonical_string(self) -> String {
339        format!(
340            "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
341            self.bytes[0],
342            self.bytes[1],
343            self.bytes[2],
344            self.bytes[3],
345            self.bytes[4],
346            self.bytes[5]
347        )
348    }
349
350    /// Parse from the canonical `aa:bb:cc:dd:ee:ff` form.
351    pub fn parse(s: &str) -> Result<Self, String> {
352        let mut bytes = [0u8; 6];
353        let mut count = 0usize;
354        for (i, part) in s.split(':').enumerate() {
355            if i >= 6 {
356                return Err(format!(
357                    "Invalid MAC: expected 6 octets (saw at least {})",
358                    i + 1
359                ));
360            }
361            if part.len() != 2 {
362                return Err(format!(
363                    "Invalid MAC: octet {i} must be exactly 2 hex digits"
364                ));
365            }
366            let octet = u8::from_str_radix(part, 16)
367                .map_err(|_| format!("Invalid MAC: octet {i} ({part}) is not valid hexadecimal"))?;
368            // SAFETY-EQUIVALENT: `i < 6` guarded above; `bytes.get_mut` would also work
369            // but `[i]` keeps the array-typed assignment explicit.
370            if let Some(slot) = bytes.get_mut(i) {
371                *slot = octet;
372            }
373            count = i + 1;
374        }
375        if count != 6 {
376            return Err(format!(
377                "Invalid MAC: expected 6 colon-separated octets (got {count})"
378            ));
379        }
380        Ok(Self { bytes })
381    }
382}
383
384impl<'de> Deserialize<'de> for MacAddr {
385    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
386        let s = String::deserialize(de)?;
387        Self::parse(&s).map_err(serde::de::Error::custom)
388    }
389}
390
391mod mac_str {
392    use serde::Serializer;
393
394    #[allow(clippy::trivially_copy_pass_by_ref)] // signature dictated by `#[serde(with)]`
395    pub(super) fn serialize<S: Serializer>(bytes: &[u8; 6], s: S) -> Result<S::Ok, S::Error> {
396        let formatted = format!(
397            "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
398            bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]
399        );
400        s.serialize_str(&formatted)
401    }
402}
403
404/// Length-cap any free-form string field to `DEFAULT_STRING_CAP` bytes (256), unless
405/// a per-field override is documented in the field's spec entry.
406pub fn check_string_cap(value: &str, field: &str, max: usize) -> Result<(), String> {
407    if value.len() > max {
408        return Err(format!(
409            "{field} exceeds {max} bytes (got {} bytes)",
410            value.len()
411        ));
412    }
413    ensure_no_nul(value, field)?;
414    Ok(())
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn test_should_accept_valid_drive_id() {
423        let id = DriveId::new("rootfs").unwrap();
424        assert_eq!(id.as_str(), "rootfs");
425    }
426
427    #[test]
428    fn test_should_reject_drive_id_with_dash() {
429        let err = DriveId::new("root-fs").unwrap_err();
430        assert!(err.contains("Invalid drive_id"));
431    }
432
433    #[test]
434    fn test_should_reject_empty_drive_id() {
435        assert!(DriveId::new("").is_err());
436    }
437
438    #[test]
439    fn test_should_reject_drive_id_over_64_bytes() {
440        let id = "a".repeat(65);
441        assert!(DriveId::new(id).is_err());
442    }
443
444    #[test]
445    fn test_should_reject_drive_id_with_nul_byte() {
446        assert!(DriveId::new("root\0fs").is_err());
447    }
448
449    #[test]
450    fn test_should_accept_path_under_path_max() {
451        let p = SafePath::new("/tmp/kernel.bin").unwrap();
452        assert_eq!(p.as_path().as_os_str(), "/tmp/kernel.bin");
453    }
454
455    #[test]
456    fn test_should_reject_path_over_path_max() {
457        let s = format!("/tmp/{}", "a".repeat(PATH_MAX));
458        assert!(SafePath::new(s).is_err());
459    }
460
461    #[test]
462    fn test_should_reject_path_with_nul_byte() {
463        assert!(SafePath::new("/tmp/k\0ernel").is_err());
464    }
465
466    #[test]
467    fn test_should_reject_uds_path_over_103_bytes() {
468        let s = format!("/tmp/{}", "a".repeat(UDS_PATH_MAX));
469        assert!(UdsPath::new(s).is_err());
470    }
471
472    #[test]
473    fn test_should_accept_uds_path_under_cap() {
474        let p = UdsPath::new("/tmp/squib.sock").unwrap();
475        assert_eq!(p.as_path().as_os_str(), "/tmp/squib.sock");
476    }
477
478    #[test]
479    fn test_should_round_trip_mac_through_canonical_string() {
480        let m = MacAddr::parse("AA:bb:CC:dd:EE:ff").unwrap();
481        assert_eq!(m.to_canonical_string(), "aa:bb:cc:dd:ee:ff");
482    }
483
484    #[test]
485    fn test_should_reject_mac_with_invalid_octet_count() {
486        assert!(MacAddr::parse("aa:bb:cc:dd:ee").is_err());
487    }
488
489    #[test]
490    fn test_should_reject_mac_with_non_hex_octet() {
491        assert!(MacAddr::parse("zz:bb:cc:dd:ee:ff").is_err());
492    }
493
494    #[test]
495    fn test_should_reject_zero_mem_size_mib() {
496        assert!(MemSizeMib::new(0).is_err());
497    }
498
499    #[test]
500    fn test_should_accept_one_mib_and_above() {
501        assert_eq!(MemSizeMib::new(256).unwrap().get(), 256);
502    }
503}