1use anyhow::{Result, bail};
2
3pub fn validate_vm_name(name: &str) -> Result<()> {
8 validate_id(name, "VM name")
9}
10
11pub fn validate_template_name(name: &str) -> Result<()> {
13 if name.is_empty() || name.len() > 63 {
14 bail!("template name must be 1-63 characters, got {}", name.len());
15 }
16 if !name
17 .chars()
18 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
19 {
20 bail!(
21 "template name must be lowercase alphanumeric + hyphens/underscores: {:?}",
22 name
23 );
24 }
25 if name.starts_with('-') || name.starts_with('_') {
26 bail!(
27 "template name must not start with a hyphen or underscore: {:?}",
28 name
29 );
30 }
31 Ok(())
32}
33
34pub fn validate_flake_ref(s: &str) -> Result<()> {
40 if s.is_empty() {
41 bail!("flake reference must not be empty");
42 }
43 const SHELL_META: &[char] = &[';', '|', '&', '$', '(', ')', '`', '!', '<', '>', '\n', '\r'];
44 if let Some(bad) = s.chars().find(|c| SHELL_META.contains(c)) {
45 bail!(
46 "flake reference contains unsafe character {:?} — shell metacharacters not allowed",
47 bad
48 );
49 }
50 Ok(())
51}
52
53pub fn validate_id(id: &str, kind: &str) -> Result<()> {
55 if id.is_empty() || id.len() > 63 {
56 bail!("{} ID must be 1-63 characters, got {}", kind, id.len());
57 }
58 if !id
59 .chars()
60 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
61 {
62 bail!(
63 "{} ID must be lowercase alphanumeric + hyphens: {:?}",
64 kind,
65 id
66 );
67 }
68 if id.starts_with('-') || id.ends_with('-') {
69 bail!("{} ID must not start or end with a hyphen: {:?}", kind, id);
70 }
71 Ok(())
72}
73
74pub fn generate_instance_id() -> String {
76 let bytes: [u8; 4] = rand_bytes();
77 format!(
78 "i-{}",
79 bytes
80 .iter()
81 .map(|b| format!("{:02x}", b))
82 .collect::<String>()
83 )
84}
85
86pub fn tap_name(tenant_net_id: u16, ip_offset: u8) -> String {
89 format!("tn{}i{}", tenant_net_id, ip_offset)
90}
91
92pub fn mac_address(tenant_net_id: u16, ip_offset: u8) -> String {
95 let net_bytes = tenant_net_id.to_be_bytes();
96 format!(
97 "02:fc:{:02x}:{:02x}:00:{:02x}",
98 net_bytes[0], net_bytes[1], ip_offset
99 )
100}
101
102fn rand_bytes() -> [u8; 4] {
104 let id = uuid::Uuid::new_v4();
105 let bytes = id.as_bytes();
106 [bytes[0], bytes[1], bytes[2], bytes[3]]
107}
108
109pub fn parse_pool_path(path: &str) -> Result<(&str, &str)> {
111 let parts: Vec<&str> = path.splitn(3, '/').collect();
112 if parts.len() < 2 {
113 bail!("Expected <tenant>/<pool>, got {:?}", path);
114 }
115 Ok((parts[0], parts[1]))
116}
117
118pub fn parse_instance_path(path: &str) -> Result<(&str, &str, &str)> {
120 let parts: Vec<&str> = path.splitn(4, '/').collect();
121 if parts.len() < 3 {
122 bail!("Expected <tenant>/<pool>/<instance>, got {:?}", path);
123 }
124 Ok((parts[0], parts[1], parts[2]))
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[test]
132 fn test_validate_id_valid() {
133 assert!(validate_id("acme", "Tenant").is_ok());
134 assert!(validate_id("my-pool-1", "Pool").is_ok());
135 assert!(validate_id("a", "Tenant").is_ok());
136 }
137
138 #[test]
139 fn test_validate_id_invalid() {
140 assert!(validate_id("", "Tenant").is_err());
141 assert!(validate_id("UPPER", "Tenant").is_err());
142 assert!(validate_id("-leading", "Tenant").is_err());
143 assert!(validate_id("trailing-", "Tenant").is_err());
144 assert!(validate_id("has space", "Tenant").is_err());
145 assert!(validate_id(&"a".repeat(64), "Tenant").is_err());
146 }
147
148 #[test]
149 fn test_tap_name() {
150 assert_eq!(tap_name(3, 5), "tn3i5");
151 assert_eq!(tap_name(4095, 254), "tn4095i254");
152 }
153
154 #[test]
155 fn test_tap_name_fits_linux_limit() {
156 let name = tap_name(4095, 254);
158 assert!(name.len() <= 15, "TAP name too long: {}", name);
159 }
160
161 #[test]
162 fn test_mac_address_format() {
163 let mac = mac_address(3, 5);
164 assert!(mac.starts_with("02:fc:"));
165 assert_eq!(mac.len(), 17);
166 }
167
168 #[test]
169 fn test_generate_instance_id_format() {
170 let id = generate_instance_id();
171 assert!(id.starts_with("i-"));
172 assert_eq!(id.len(), 10); }
174
175 #[test]
176 fn test_parse_pool_path() {
177 let (t, p) = parse_pool_path("acme/workers").unwrap();
178 assert_eq!(t, "acme");
179 assert_eq!(p, "workers");
180 }
181
182 #[test]
183 fn test_parse_instance_path() {
184 let (t, p, i) = parse_instance_path("acme/workers/i-a3f7b2c1").unwrap();
185 assert_eq!(t, "acme");
186 assert_eq!(p, "workers");
187 assert_eq!(i, "i-a3f7b2c1");
188 }
189
190 #[test]
192 fn test_validate_vm_name_valid() {
193 assert!(validate_vm_name("myvm").is_ok());
194 assert!(validate_vm_name("my-vm-1").is_ok());
195 assert!(validate_vm_name("a").is_ok());
196 assert!(validate_vm_name(&"a".repeat(63)).is_ok());
197 }
198
199 #[test]
200 fn test_validate_vm_name_empty() {
201 assert!(validate_vm_name("").is_err());
202 }
203
204 #[test]
205 fn test_validate_vm_name_too_long() {
206 assert!(validate_vm_name(&"a".repeat(64)).is_err());
207 }
208
209 #[test]
210 fn test_validate_vm_name_uppercase() {
211 assert!(validate_vm_name("MyVM").is_err());
212 }
213
214 #[test]
215 fn test_validate_vm_name_leading_hyphen() {
216 assert!(validate_vm_name("-bad").is_err());
217 }
218
219 #[test]
220 fn test_validate_vm_name_special_chars() {
221 assert!(validate_vm_name("vm;evil").is_err());
222 assert!(validate_vm_name("vm name").is_err());
223 assert!(validate_vm_name("vm/path").is_err());
224 }
225
226 #[test]
228 fn test_validate_template_name_valid() {
229 assert!(validate_template_name("base").is_ok());
230 assert!(validate_template_name("my-template").is_ok());
231 assert!(validate_template_name("my_template").is_ok());
232 assert!(validate_template_name("worker1").is_ok());
233 }
234
235 #[test]
236 fn test_validate_template_name_empty() {
237 assert!(validate_template_name("").is_err());
238 }
239
240 #[test]
241 fn test_validate_template_name_leading_hyphen() {
242 assert!(validate_template_name("-bad").is_err());
243 }
244
245 #[test]
246 fn test_validate_template_name_special_chars() {
247 assert!(validate_template_name("bad;name").is_err());
248 assert!(validate_template_name("bad name").is_err());
249 }
250
251 #[test]
252 fn test_validate_template_name_too_long() {
253 assert!(validate_template_name(&"a".repeat(64)).is_err());
254 }
255
256 #[test]
258 fn test_validate_flake_ref_valid() {
259 assert!(validate_flake_ref(".").is_ok());
260 assert!(validate_flake_ref("./my-flake").is_ok());
261 assert!(validate_flake_ref("github:org/repo").is_ok());
262 assert!(validate_flake_ref("git+https://github.com/org/repo").is_ok());
263 assert!(validate_flake_ref("/absolute/path").is_ok());
264 }
265
266 #[test]
267 fn test_validate_flake_ref_empty() {
268 assert!(validate_flake_ref("").is_err());
269 }
270
271 #[test]
272 fn test_validate_flake_ref_semicolon() {
273 assert!(validate_flake_ref(". ; rm -rf /").is_err());
274 }
275
276 #[test]
277 fn test_validate_flake_ref_pipe() {
278 assert!(validate_flake_ref(".|evil").is_err());
279 }
280
281 #[test]
282 fn test_validate_flake_ref_dollar() {
283 assert!(validate_flake_ref("$(evil)").is_err());
284 }
285
286 #[test]
287 fn test_validate_flake_ref_newline() {
288 assert!(validate_flake_ref("flake\nmalicious").is_err());
289 }
290}