Skip to main content

microsandbox_types/
validation.rs

1//! Shared validation rules for sandbox task descriptors.
2
3use crate::{TypesError, TypesResult};
4
5//--------------------------------------------------------------------------------------------------
6// Constants
7//--------------------------------------------------------------------------------------------------
8
9/// Maximum UTF-8 byte length for a sandbox name.
10pub const MAX_SANDBOX_NAME_BYTES: usize = 128;
11
12/// Maximum UTF-8 byte length for a guest hostname (Linux `__NEW_UTS_LEN`).
13pub const MAX_HOSTNAME_BYTES: usize = 64;
14
15//--------------------------------------------------------------------------------------------------
16// Functions
17//--------------------------------------------------------------------------------------------------
18
19/// Validate that a sandbox name is safe: alphanumeric / dot / hyphen / underscore, 1..=128 bytes, and must start alphanumeric.
20pub fn validate_sandbox_name(name: &str) -> TypesResult<()> {
21    if name.is_empty() {
22        return Err(TypesError::invalid_config("sandbox name must not be empty"));
23    }
24
25    if name.len() > MAX_SANDBOX_NAME_BYTES {
26        return Err(TypesError::invalid_config(format!(
27            "sandbox name must be at most {MAX_SANDBOX_NAME_BYTES} characters: got {}",
28            name.len()
29        )));
30    }
31
32    let first_alphanumeric = name
33        .chars()
34        .next()
35        .is_some_and(|c| c.is_ascii_alphanumeric());
36    let charset_ok = name
37        .chars()
38        .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_');
39
40    if !first_alphanumeric || !charset_ok {
41        return Err(TypesError::invalid_config(format!(
42            "sandbox name must start with an alphanumeric and contain only \
43             alphanumeric, dots, hyphens, and underscores: {name}"
44        )));
45    }
46
47    Ok(())
48}
49
50/// Validate an optional explicit guest hostname before it is forwarded to the guest agent.
51pub fn validate_hostname(hostname: Option<&str>) -> TypesResult<()> {
52    let Some(hostname) = hostname else {
53        return Ok(());
54    };
55
56    if hostname.is_empty() {
57        return Err(TypesError::invalid_config("hostname must not be empty"));
58    }
59
60    let len = hostname.len();
61    if len > MAX_HOSTNAME_BYTES {
62        return Err(TypesError::invalid_config(format!(
63            "hostname is too long: {len} bytes (max {MAX_HOSTNAME_BYTES})"
64        )));
65    }
66
67    Ok(())
68}
69
70/// Derive a guest hostname from a sandbox name while fitting within [`MAX_HOSTNAME_BYTES`].
71pub fn hostname_from_sandbox_name(name: &str) -> String {
72    if name.len() <= MAX_HOSTNAME_BYTES {
73        return name.to_string();
74    }
75
76    // 55-byte prefix + '-' + 8 hex chars of sha256 = 64 bytes.
77    const HASH_HEX_LEN: usize = 8;
78    const PREFIX_MAX: usize = MAX_HOSTNAME_BYTES - 1 - HASH_HEX_LEN;
79
80    use sha2::Digest;
81    let mut hasher = sha2::Sha256::new();
82    hasher.update(name.as_bytes());
83    let digest = hasher.finalize();
84    let suffix = format!(
85        "{:02x}{:02x}{:02x}{:02x}",
86        digest[0], digest[1], digest[2], digest[3]
87    );
88
89    let mut end = PREFIX_MAX;
90    while end > 0 && !name.is_char_boundary(end) {
91        end -= 1;
92    }
93    format!("{}-{}", &name[..end], suffix)
94}
95
96//--------------------------------------------------------------------------------------------------
97// Tests
98//--------------------------------------------------------------------------------------------------
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn sandbox_name_accepts_typical() {
106        for name in [
107            "foo",
108            "foo-bar",
109            "foo.bar",
110            "foo_bar",
111            "FooBar",
112            "abc123",
113            "a",
114            "0",
115            "agent-1",
116            "my.app_2026",
117        ] {
118            assert!(
119                validate_sandbox_name(name).is_ok(),
120                "expected {name:?} to be accepted"
121            );
122        }
123    }
124
125    #[test]
126    fn sandbox_name_rejects_empty() {
127        assert_eq!(
128            validate_sandbox_name("").unwrap_err().to_string(),
129            "invalid config: sandbox name must not be empty"
130        );
131    }
132
133    #[test]
134    fn sandbox_name_rejects_too_long() {
135        let long = "a".repeat(MAX_SANDBOX_NAME_BYTES + 1);
136        assert_eq!(
137            validate_sandbox_name(&long).unwrap_err().to_string(),
138            "invalid config: sandbox name must be at most 128 characters: got 129"
139        );
140    }
141
142    #[test]
143    fn sandbox_name_accepts_at_max_length() {
144        let max = "a".repeat(MAX_SANDBOX_NAME_BYTES);
145        assert!(validate_sandbox_name(&max).is_ok());
146    }
147
148    #[test]
149    fn sandbox_name_rejects_disallowed_chars() {
150        for name in [
151            "foo bar", "foo/bar", "foo:bar", "foo!", "foo@bar", "foo#1", "✨",
152        ] {
153            assert!(
154                validate_sandbox_name(name).is_err(),
155                "expected {name:?} to be rejected"
156            );
157        }
158    }
159
160    #[test]
161    fn sandbox_name_rejects_non_alphanumeric_start() {
162        for name in [".foo", "-foo", "_foo"] {
163            assert!(
164                validate_sandbox_name(name).is_err(),
165                "expected {name:?} to be rejected (non-alphanumeric start)"
166            );
167        }
168    }
169
170    #[test]
171    fn hostname_from_sandbox_name_passes_short_names_through() {
172        let name = "short-name";
173        assert_eq!(hostname_from_sandbox_name(name), name);
174
175        let name = "a".repeat(MAX_HOSTNAME_BYTES);
176        assert_eq!(hostname_from_sandbox_name(&name), name);
177    }
178
179    #[test]
180    fn hostname_from_sandbox_name_collapses_long_names_to_64_bytes() {
181        let derived = hostname_from_sandbox_name(&"a".repeat(MAX_HOSTNAME_BYTES + 1));
182        assert_eq!(derived.len(), MAX_HOSTNAME_BYTES);
183
184        let derived = hostname_from_sandbox_name(&"a".repeat(MAX_SANDBOX_NAME_BYTES));
185        assert_eq!(derived.len(), MAX_HOSTNAME_BYTES);
186
187        let bytes = derived.as_bytes();
188        assert_eq!(bytes[MAX_HOSTNAME_BYTES - 9], b'-');
189        assert!(
190            bytes[MAX_HOSTNAME_BYTES - 8..]
191                .iter()
192                .all(u8::is_ascii_hexdigit)
193        );
194    }
195
196    #[test]
197    fn hostname_from_sandbox_name_is_deterministic_and_unique() {
198        let a = "a".repeat(MAX_SANDBOX_NAME_BYTES);
199        let mut b = a.clone();
200        b.pop();
201        b.push('b');
202
203        assert_eq!(
204            hostname_from_sandbox_name(&a),
205            hostname_from_sandbox_name(&a)
206        );
207        assert_ne!(
208            hostname_from_sandbox_name(&a),
209            hostname_from_sandbox_name(&b)
210        );
211    }
212
213    #[test]
214    fn hostname_from_sandbox_name_respects_utf8_boundaries() {
215        let name = "é".repeat(64);
216        assert_eq!(name.len(), 128);
217
218        let derived = hostname_from_sandbox_name(&name);
219        assert!(derived.len() <= MAX_HOSTNAME_BYTES);
220        assert!(derived.is_char_boundary(derived.len()));
221    }
222
223    #[test]
224    fn validate_hostname_accepts_absent_and_64_byte_hostname() {
225        validate_hostname(None).unwrap();
226        validate_hostname(Some(&"y".repeat(MAX_HOSTNAME_BYTES))).unwrap();
227    }
228
229    #[test]
230    fn validate_hostname_rejects_empty_hostname() {
231        assert_eq!(
232            validate_hostname(Some("")).unwrap_err().to_string(),
233            "invalid config: hostname must not be empty"
234        );
235    }
236
237    #[test]
238    fn validate_hostname_rejects_over_64_byte_hostname() {
239        assert_eq!(
240            validate_hostname(Some(&"y".repeat(MAX_HOSTNAME_BYTES + 1)))
241                .unwrap_err()
242                .to_string(),
243            "invalid config: hostname is too long: 65 bytes (max 64)"
244        );
245    }
246}