microsandbox_types/
validation.rs1use crate::{TypesError, TypesResult};
4
5pub const MAX_SANDBOX_NAME_BYTES: usize = 128;
11
12pub const MAX_HOSTNAME_BYTES: usize = 64;
14
15pub 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
50pub 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
70pub fn hostname_from_sandbox_name(name: &str) -> String {
72 if name.len() <= MAX_HOSTNAME_BYTES {
73 return name.to_string();
74 }
75
76 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#[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}