1#[cfg(unix)]
4use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
5use std::path::PathBuf;
6#[cfg(unix)]
7use std::sync::Arc;
8
9pub const READY_MARKER: &str = "VMRS_READY";
12
13#[cfg(unix)]
15#[derive(Debug, Clone)]
16pub struct VmSocketEndpoint(Arc<OwnedFd>);
17
18#[cfg(unix)]
19impl VmSocketEndpoint {
20 pub fn new(fd: OwnedFd) -> Self {
21 Self(Arc::new(fd))
22 }
23
24 pub fn try_clone_owned(&self) -> std::io::Result<OwnedFd> {
25 let duplicated = unsafe { libc::dup(self.as_raw_fd()) };
27 if duplicated < 0 {
28 return Err(std::io::Error::last_os_error());
29 }
30 Ok(unsafe { OwnedFd::from_raw_fd(duplicated) })
32 }
33}
34
35#[cfg(unix)]
36impl AsRawFd for VmSocketEndpoint {
37 fn as_raw_fd(&self) -> RawFd {
38 self.0.as_raw_fd()
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct VmmProcess {
45 pid: u32,
46 start_time_ticks: Option<u64>,
47}
48
49impl VmmProcess {
50 pub fn pid(&self) -> u32 {
51 self.pid
52 }
53
54 pub fn start_time_ticks(&self) -> Option<u64> {
55 self.start_time_ticks
56 }
57
58 pub fn new(pid: u32, start_time_ticks: Option<u64>) -> Self {
64 Self {
65 pid,
66 start_time_ticks,
67 }
68 }
69}
70
71#[derive(Debug, Clone)]
84pub struct VmConfig {
85 pub name: String,
87 pub namespace: String,
89 pub kernel: PathBuf,
91 pub initramfs: Option<PathBuf>,
93 pub root_disk: Option<PathBuf>,
95 pub data_disk: Option<PathBuf>,
97 pub seed_iso: Option<PathBuf>,
99 pub cpus: usize,
101 pub memory_mb: usize,
103 pub networks: Vec<NetworkAttachment>,
105 pub shared_dirs: Vec<SharedDir>,
107 pub serial_log: PathBuf,
109 pub cmdline: Option<String>,
111 pub netns: Option<String>,
114 pub vsock: bool,
116 pub machine_id: Option<Vec<u8>>,
118 pub efi_variable_store: Option<PathBuf>,
120 pub rosetta: bool,
122}
123
124impl VmConfig {
125 pub fn validate(&self) -> Result<(), crate::driver::VmError> {
127 use crate::driver::VmError;
128 if self.name.is_empty() {
129 return Err(VmError::InvalidConfig("VM name must not be empty".into()));
130 }
131 if self.name.len() > 128 {
132 return Err(VmError::InvalidConfig(
133 "VM name must be 128 characters or fewer".into(),
134 ));
135 }
136 if !self
137 .name
138 .chars()
139 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
140 {
141 return Err(VmError::InvalidConfig(
142 "VM name must contain only alphanumeric characters, hyphens, underscores, and dots"
143 .into(),
144 ));
145 }
146 if self.name.starts_with('.') || self.name.starts_with('-') {
147 return Err(VmError::InvalidConfig(
148 "VM name must not start with '.' or '-'".into(),
149 ));
150 }
151 if self.cpus == 0 {
152 return Err(VmError::InvalidConfig("cpus must be at least 1".into()));
153 }
154 if self.memory_mb == 0 {
155 return Err(VmError::InvalidConfig(
156 "memory_mb must be at least 1".into(),
157 ));
158 }
159 if self.kernel.as_os_str().is_empty() {
160 return Err(VmError::InvalidConfig(
161 "kernel path must not be empty".into(),
162 ));
163 }
164 Ok(())
165 }
166}
167
168#[derive(Debug, Clone)]
170pub enum NetworkAttachment {
171 #[cfg(unix)]
174 SocketPairFd(VmSocketEndpoint),
175 Tap { name: String, mac: Option<String> },
177}
178
179#[derive(Debug, Clone)]
181pub struct SharedDir {
182 pub host_path: PathBuf,
184 pub tag: String,
186 pub read_only: bool,
188}
189
190#[derive(Debug, Clone)]
192pub struct VmHandle {
193 pub name: String,
195 pub namespace: String,
197 pub state: VmState,
199 pub process: Option<VmmProcess>,
201 pub serial_log: PathBuf,
203 pub machine_id: Option<Vec<u8>>,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
209#[must_use]
210pub enum VmState {
211 Starting,
213 Running,
215 Ready {
217 ip: String,
219 },
220 Paused,
222 Stopped,
224 Failed {
226 reason: String,
228 },
229}
230
231impl VmState {
232 pub fn is_running(&self) -> bool {
234 matches!(self, Self::Running | Self::Ready { .. })
235 }
236
237 pub fn is_ready(&self) -> bool {
239 matches!(self, Self::Ready { .. })
240 }
241
242 pub fn ip(&self) -> Option<&str> {
244 match self {
245 Self::Ready { ip } => Some(ip),
246 _ => None,
247 }
248 }
249}
250
251impl std::fmt::Display for VmState {
252 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253 match self {
254 VmState::Starting => write!(f, "starting"),
255 VmState::Running => write!(f, "running"),
256 VmState::Ready { ip } => write!(f, "ready ({})", ip),
257 VmState::Paused => write!(f, "paused"),
258 VmState::Stopped => write!(f, "stopped"),
259 VmState::Failed { reason } => write!(f, "failed: {}", reason),
260 }
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn vm_state_display_starting() {
270 assert_eq!(VmState::Starting.to_string(), "starting");
271 }
272
273 #[test]
274 fn vm_state_display_running() {
275 let state = VmState::Ready {
276 ip: "10.0.1.2".into(),
277 };
278 assert_eq!(state.to_string(), "ready (10.0.1.2)");
279 }
280
281 #[test]
282 fn vm_state_display_running_without_ready_ip() {
283 assert_eq!(VmState::Running.to_string(), "running");
284 }
285
286 #[test]
287 fn vm_state_display_stopped() {
288 assert_eq!(VmState::Stopped.to_string(), "stopped");
289 }
290
291 #[test]
292 fn vm_state_display_failed() {
293 let state = VmState::Failed {
294 reason: "timeout".into(),
295 };
296 assert_eq!(state.to_string(), "failed: timeout");
297 }
298
299 #[test]
300 fn vm_state_equality() {
301 assert_eq!(VmState::Starting, VmState::Starting);
302 assert_eq!(VmState::Stopped, VmState::Stopped);
303 assert_ne!(VmState::Starting, VmState::Stopped);
304 }
305
306 #[test]
307 fn vm_state_helper_methods() {
308 let ready = VmState::Ready {
309 ip: "10.0.1.2".into(),
310 };
311 assert!(VmState::Running.is_running());
312 assert!(!VmState::Running.is_ready());
313 assert!(ready.is_running());
314 assert!(ready.is_ready());
315 assert_eq!(ready.ip(), Some("10.0.1.2"));
316 assert_eq!(VmState::Starting.ip(), None);
317 }
318
319 #[test]
320 fn ready_marker_value() {
321 assert_eq!(READY_MARKER, "VMRS_READY");
322 }
323
324 #[test]
325 fn vm_state_display_paused() {
326 assert_eq!(VmState::Paused.to_string(), "paused");
327 }
328
329 fn test_vm_config(name: &str) -> VmConfig {
330 VmConfig {
331 name: name.into(),
332 namespace: "test".into(),
333 kernel: std::path::PathBuf::from("/tmp/kernel"),
334 initramfs: None,
335 root_disk: None,
336 data_disk: None,
337 seed_iso: None,
338 cpus: 1,
339 memory_mb: 256,
340 networks: vec![],
341 shared_dirs: vec![],
342 serial_log: std::path::PathBuf::from("/tmp/serial.log"),
343 cmdline: None,
344 netns: None,
345 vsock: false,
346 machine_id: None,
347 efi_variable_store: None,
348 rosetta: false,
349 }
350 }
351
352 #[test]
353 fn validate_rejects_empty_name() {
354 let config = test_vm_config("");
355 let err = config
356 .validate()
357 .expect_err("empty VM name should fail validation")
358 .to_string();
359 assert!(err.contains("empty"), "expected 'empty' in error: {}", err);
360 }
361
362 #[test]
363 fn validate_rejects_path_traversal() {
364 let config = test_vm_config("../etc");
365 let err = config
366 .validate()
367 .expect_err("path traversal characters should fail validation")
368 .to_string();
369 assert!(
370 err.contains("alphanumeric") || err.contains("characters"),
371 "expected name validation error: {}",
372 err
373 );
374 }
375
376 #[test]
377 fn validate_rejects_zero_cpus() {
378 let mut config = test_vm_config("good-name");
379 config.cpus = 0;
380 let err = config
381 .validate()
382 .expect_err("zero CPUs should fail validation")
383 .to_string();
384 assert!(err.contains("cpus"), "expected 'cpus' in error: {}", err);
385 }
386
387 #[test]
388 fn validate_accepts_valid_config() {
389 let config = test_vm_config("my-vm.01");
390 config
391 .validate()
392 .expect("valid config should pass validation");
393 }
394}