Skip to main content

mvm_core/
naming.rs

1use anyhow::{Result, bail};
2
3/// Validate a VM name: lowercase alphanumeric + hyphens, 1-63 chars (RFC 1123).
4///
5/// VM names flow into filesystem paths and shell commands, so only
6/// a safe subset of characters is accepted.
7pub fn validate_vm_name(name: &str) -> Result<()> {
8    validate_id(name, "VM name")
9}
10
11/// Validate a template name: lowercase alphanumeric + hyphens + underscores, 1-63 chars.
12pub 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
34/// Validate a Nix flake reference for safe shell interpolation.
35///
36/// Rejects empty strings and any character that would be interpreted by the
37/// shell as a metacharacter (`;`, `|`, `&`, `$`, `(`, `)`, `` ` ``, `!`,
38/// `<`, `>`, newline).
39pub 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
53/// Validate a tenant or pool ID: lowercase alphanumeric + hyphens, 1-63 chars.
54pub 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
74/// Generate a random instance ID: "i-" followed by 8 hex chars.
75pub 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
86/// Generate a TAP device name: tn<net_id>i<ip_offset>.
87/// Max 12 chars, fits within Linux 15-char IFNAMSIZ limit.
88pub fn tap_name(tenant_net_id: u16, ip_offset: u8) -> String {
89    format!("tn{}i{}", tenant_net_id, ip_offset)
90}
91
92/// Deterministic MAC address from tenant_net_id and ip_offset.
93/// Format: 02:xx:xx:xx:xx:xx (locally administered).
94pub 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
102/// Simple random bytes using uuid crate (already a dependency).
103fn 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
109/// Parse a "tenant/pool" or "tenant/pool/instance" path.
110pub 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
118/// Parse a "tenant/pool/instance" path.
119pub 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        // Worst case: tn4095i254 = 10 chars, under 15
157        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); // "i-" + 8 hex chars
173    }
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    // validate_vm_name
191    #[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    // validate_template_name
227    #[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    // validate_flake_ref
257    #[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}