Skip to main content

squib_core/
identifiers.rs

1//! Squib-wide validated identifier newtypes.
2//!
3//! Per [70-security.md § 4](../../../specs/70-security.md#4-input-validation):
4//! every external string crossing into squib goes through a fallible-constructor newtype
5//! before reaching downstream code. Validation runs once in `new`/`try_from`; every
6//! downstream use is provably safe by construction.
7//!
8//! Placing the canonical newtypes at `squib-core` (the bottom of the dependency DAG —
9//! see [I-CRATE-1](../../../specs/61-crates-and-features.md#7-invariants)) means any crate
10//! can witness the validation in the type system without cross-crate detours via
11//! `squib-api`. The API layer's `Raw* → Validated TryFrom` boundary still drives the
12//! validation; this module hosts the result types.
13
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17/// Errors that can surface while validating an identifier.
18#[derive(Debug, Error, Clone, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum IdentifierError {
21    /// Value is empty when a non-empty identifier was required.
22    #[error("{kind}: must not be empty")]
23    Empty {
24        /// Kind tag (`"host_dev_name"`, `"iface_id"`, …) for the operator-facing
25        /// error message.
26        kind: &'static str,
27    },
28    /// Value exceeds its byte cap.
29    #[error("{kind}: exceeds {max} bytes (got {len} bytes)")]
30    TooLong {
31        /// Kind tag.
32        kind: &'static str,
33        /// Actual byte length.
34        len: usize,
35        /// Maximum allowed.
36        max: usize,
37    },
38    /// Value contains an interior NUL byte.
39    #[error("{kind}: contains a NUL byte")]
40    ContainsNul {
41        /// Kind tag.
42        kind: &'static str,
43    },
44}
45
46/// Maximum byte length for a `host_dev_name`. Mirrors the API-layer cap that's been
47/// in force since Phase 2 (see `crates/api/src/schemas/network.rs`).
48pub const HOST_DEV_NAME_MAX_BYTES: usize = 64;
49
50/// Validated host-side network device name (the `host_dev_name` field on a
51/// `/network-interfaces/{id}` PUT body).
52///
53/// Validation: non-empty, ≤ 64 bytes, no NUL byte. Charset is left intentionally open
54/// because the value is opaque on macOS — squib derives a vmnet handle name from
55/// `iface_id`, not from `host_dev_name`, so we only need the length / NUL guard here.
56///
57/// The newtype is `Serialize` + `Deserialize`-transparent so it round-trips through
58/// snapshot save/restore (see I-NET-3) without an envelope-format change.
59#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize)]
60#[serde(transparent)]
61pub struct HostDevName(String);
62
63impl HostDevName {
64    /// Wrap a host device name after running the boundary checks.
65    ///
66    /// # Errors
67    /// Surfaces [`IdentifierError`] on empty / oversize / NUL-bearing input.
68    pub fn new(name: impl Into<String>) -> Result<Self, IdentifierError> {
69        let name = name.into();
70        if name.is_empty() {
71            return Err(IdentifierError::Empty {
72                kind: "host_dev_name",
73            });
74        }
75        if name.len() > HOST_DEV_NAME_MAX_BYTES {
76            return Err(IdentifierError::TooLong {
77                kind: "host_dev_name",
78                len: name.len(),
79                max: HOST_DEV_NAME_MAX_BYTES,
80            });
81        }
82        if name.as_bytes().contains(&0) {
83            return Err(IdentifierError::ContainsNul {
84                kind: "host_dev_name",
85            });
86        }
87        Ok(Self(name))
88    }
89
90    /// Borrow the inner string.
91    #[must_use]
92    pub fn as_str(&self) -> &str {
93        &self.0
94    }
95
96    /// Consume the newtype and yield the inner string.
97    #[must_use]
98    pub fn into_string(self) -> String {
99        self.0
100    }
101}
102
103impl AsRef<str> for HostDevName {
104    fn as_ref(&self) -> &str {
105        &self.0
106    }
107}
108
109impl<'de> Deserialize<'de> for HostDevName {
110    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
111        let s = String::deserialize(de)?;
112        Self::new(s).map_err(serde::de::Error::custom)
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_should_accept_short_ascii_name() {
122        let n = HostDevName::new("vmnet0").unwrap();
123        assert_eq!(n.as_str(), "vmnet0");
124    }
125
126    #[test]
127    fn test_should_reject_empty() {
128        let err = HostDevName::new("").unwrap_err();
129        assert!(matches!(err, IdentifierError::Empty { .. }));
130    }
131
132    #[test]
133    fn test_should_reject_overlong() {
134        let huge = "a".repeat(HOST_DEV_NAME_MAX_BYTES + 1);
135        let err = HostDevName::new(huge).unwrap_err();
136        assert!(matches!(err, IdentifierError::TooLong { .. }));
137    }
138
139    #[test]
140    fn test_should_reject_nul_byte() {
141        let err = HostDevName::new("vmnet\0bad").unwrap_err();
142        assert!(matches!(err, IdentifierError::ContainsNul { .. }));
143    }
144
145    #[test]
146    fn test_should_round_trip_through_serde_json() {
147        let n = HostDevName::new("eth0").unwrap();
148        let json = serde_json::to_string(&n).unwrap();
149        assert_eq!(json, "\"eth0\"");
150        let back: HostDevName = serde_json::from_str(&json).unwrap();
151        assert_eq!(back.as_str(), "eth0");
152    }
153
154    #[test]
155    fn test_should_reject_invalid_value_through_deserialize() {
156        let json = "\"\""; // empty string
157        let err = serde_json::from_str::<HostDevName>(json).unwrap_err();
158        assert!(err.to_string().contains("empty"));
159    }
160}