1use std::path::PathBuf;
4use std::process::{Command, Stdio};
5
6use a3s_box_core::error::{BoxError, Result};
7use async_trait::async_trait;
8
9use super::handler::ShimHandler;
10use super::provider::VmmProvider;
11use super::spec::InstanceSpec;
12use super::VmHandler;
13
14pub struct VmController {
20 shim_path: PathBuf,
22}
23
24impl VmController {
25 fn configure_shim_stdio(&self, cmd: &mut Command, spec: &InstanceSpec) {
26 use std::fs::OpenOptions;
27
28 let Some(console_output) = spec.console_output.as_ref() else {
29 cmd.stdout(Stdio::null()).stderr(Stdio::null());
30 return;
31 };
32 let Some(log_dir) = console_output.parent() else {
33 cmd.stdout(Stdio::null()).stderr(Stdio::null());
34 return;
35 };
36 if let Err(error) = std::fs::create_dir_all(log_dir) {
37 tracing::warn!(
38 box_id = %spec.box_id,
39 path = %log_dir.display(),
40 error = %error,
41 "Failed to create shim log directory"
42 );
43 cmd.stdout(Stdio::null()).stderr(Stdio::null());
44 return;
45 }
46
47 let stdout_path = log_dir.join("shim.stdout.log");
48 let stderr_path = log_dir.join("shim.stderr.log");
49
50 let stdout_file = OpenOptions::new()
51 .create(true)
52 .truncate(true)
53 .write(true)
54 .open(&stdout_path);
55 let stderr_file = OpenOptions::new()
56 .create(true)
57 .truncate(true)
58 .write(true)
59 .open(&stderr_path);
60
61 match (stdout_file, stderr_file) {
62 (Ok(stdout_file), Ok(stderr_file)) => {
63 tracing::debug!(
64 box_id = %spec.box_id,
65 stdout = %stdout_path.display(),
66 stderr = %stderr_path.display(),
67 "Redirecting shim stdio to per-box files"
68 );
69 cmd.stdout(Stdio::from(stdout_file))
70 .stderr(Stdio::from(stderr_file));
71 }
72 (stdout_result, stderr_result) => {
73 if let Err(error) = stdout_result {
74 tracing::warn!(
75 box_id = %spec.box_id,
76 path = %stdout_path.display(),
77 error = %error,
78 "Failed to open shim stdout log file"
79 );
80 }
81 if let Err(error) = stderr_result {
82 tracing::warn!(
83 box_id = %spec.box_id,
84 path = %stderr_path.display(),
85 error = %error,
86 "Failed to open shim stderr log file"
87 );
88 }
89 cmd.stdout(Stdio::null()).stderr(Stdio::null());
90 }
91 }
92 }
93
94 pub fn new(shim_path: PathBuf) -> Result<Self> {
103 if !shim_path.exists() {
105 return Err(BoxError::BoxBootError {
106 message: format!("Shim binary not found: {}", shim_path.display()),
107 hint: Some("Build the shim with: cargo build -p a3s-box-shim".to_string()),
108 });
109 }
110
111 #[cfg(target_os = "macos")]
113 Self::ensure_entitlement(&shim_path)?;
114
115 Ok(Self { shim_path })
116 }
117
118 #[cfg(target_os = "macos")]
127 fn ensure_entitlement(shim_path: &std::path::Path) -> Result<()> {
128 use std::fs::File;
129
130 if Self::has_hypervisor_entitlement(shim_path)? {
132 return Ok(());
133 }
134
135 let lock_path = std::env::temp_dir().join("a3s-box-shim-codesign.lock");
137 let lock_file = File::create(&lock_path).map_err(|e| BoxError::BoxBootError {
138 message: format!("Failed to create codesign lock file: {}", e),
139 hint: None,
140 })?;
141
142 let fd = std::os::unix::io::AsRawFd::as_raw_fd(&lock_file);
144 let ret = unsafe { libc::flock(fd, libc::LOCK_EX) };
145 if ret != 0 {
146 return Err(BoxError::BoxBootError {
147 message: format!(
148 "Failed to acquire codesign lock: {}",
149 std::io::Error::last_os_error()
150 ),
151 hint: None,
152 });
153 }
154
155 if Self::has_hypervisor_entitlement(shim_path)? {
157 return Ok(());
159 }
160
161 tracing::info!("Signing shim with Hypervisor.framework entitlement");
162
163 let entitlements_path = Self::find_entitlements_plist(shim_path)?;
164
165 let status = Command::new("codesign")
166 .args(["--entitlements"])
167 .arg(&entitlements_path)
168 .args(["--force", "-s", "-"])
169 .arg(shim_path)
170 .status()
171 .map_err(|e| BoxError::BoxBootError {
172 message: format!("Failed to codesign shim: {}", e),
173 hint: None,
174 })?;
175
176 if !status.success() {
177 return Err(BoxError::BoxBootError {
178 message: "Failed to sign shim with Hypervisor entitlement".to_string(),
179 hint: Some(format!(
180 "Try manually: codesign --entitlements {} --force -s - {}",
181 entitlements_path.display(),
182 shim_path.display()
183 )),
184 });
185 }
186
187 Ok(())
189 }
190
191 #[cfg(target_os = "macos")]
193 fn has_hypervisor_entitlement(shim_path: &std::path::Path) -> Result<bool> {
194 let output = Command::new("codesign")
195 .args(["-d", "--entitlements", "-", "--xml"])
196 .arg(shim_path)
197 .output()
198 .map_err(|e| BoxError::BoxBootError {
199 message: format!("Failed to check entitlements: {}", e),
200 hint: None,
201 })?;
202
203 let stdout = String::from_utf8_lossy(&output.stdout);
204 Ok(stdout.contains("com.apple.security.hypervisor"))
205 }
206
207 #[cfg(target_os = "macos")]
209 fn find_entitlements_plist(shim_path: &std::path::Path) -> Result<PathBuf> {
210 if let Some(dir) = shim_path.parent() {
212 let plist = dir.join("entitlements.plist");
213 if plist.exists() {
214 return Ok(plist);
215 }
216 }
217
218 if let Some(dir) = shim_path.parent() {
221 for ancestor in dir.ancestors().take(5) {
222 let plist = ancestor.join("shim").join("entitlements.plist");
223 if plist.exists() {
224 return Ok(plist);
225 }
226 }
227 }
228
229 let tmp_plist = std::env::temp_dir().join("a3s-box-entitlements.plist");
231 std::fs::write(
232 &tmp_plist,
233 r#"<?xml version="1.0" encoding="UTF-8"?>
234<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
235<plist version="1.0">
236<dict>
237 <key>com.apple.security.hypervisor</key>
238 <true/>
239</dict>
240</plist>
241"#,
242 )
243 .map_err(|e| BoxError::BoxBootError {
244 message: format!("Failed to write temporary entitlements plist: {}", e),
245 hint: None,
246 })?;
247
248 Ok(tmp_plist)
249 }
250
251 pub fn find_shim() -> Result<PathBuf> {
259 #[cfg(target_os = "windows")]
261 let shim_name = "a3s-box-shim.exe";
262 #[cfg(not(target_os = "windows"))]
263 let shim_name = "a3s-box-shim";
264
265 if let Ok(exe_path) = std::env::current_exe() {
267 if let Some(exe_dir) = exe_path.parent() {
268 let shim_path = exe_dir.join(shim_name);
269 if shim_path.exists() {
270 return Ok(shim_path);
271 }
272 }
273 }
274
275 {
277 let shim_path = a3s_box_core::dirs_home().join("bin").join(shim_name);
278 if shim_path.exists() {
279 return Ok(shim_path);
280 }
281 }
282
283 let target_dirs = ["target/debug", "target/release"];
285 for dir in target_dirs {
286 let shim_path = PathBuf::from(dir).join(shim_name);
287 if shim_path.exists() {
288 return Ok(shim_path);
289 }
290 }
291
292 #[cfg(target_os = "windows")]
294 let which_cmd = "where";
295 #[cfg(not(target_os = "windows"))]
296 let which_cmd = "which";
297
298 if let Ok(output) = Command::new(which_cmd).arg(shim_name).output() {
299 if output.status.success() {
300 let path = String::from_utf8_lossy(&output.stdout)
301 .lines()
302 .next()
303 .unwrap_or("")
304 .trim()
305 .to_string();
306 if !path.is_empty() {
307 return Ok(PathBuf::from(path));
308 }
309 }
310 }
311
312 Err(BoxError::BoxBootError {
313 message: "Could not find a3s-box-shim binary".to_string(),
314 hint: Some("Build the shim with: cargo build -p a3s-box-shim".to_string()),
315 })
316 }
317
318 #[cfg(target_os = "windows")]
319 fn windows_shim_path_env(shim_path: &std::path::Path) -> Option<std::ffi::OsString> {
320 use std::collections::HashSet;
321
322 let mut dirs = Vec::<PathBuf>::new();
323 if let Ok(dir) = std::env::var("LIBKRUN_DIR") {
324 dirs.push(PathBuf::from(dir));
325 }
326 if let Some(dir) = option_env!("LIBKRUN_DIR") {
327 dirs.push(PathBuf::from(dir));
328 }
329 if let Some(dir) = shim_path.parent() {
330 dirs.push(dir.to_path_buf());
331 dirs.push(dir.join("lib"));
332 }
333
334 let mut seen = HashSet::new();
335 let mut path_entries = Vec::new();
336 for dir in dirs {
337 if !seen.insert(dir.clone()) {
338 continue;
339 }
340 if dir.join("krun.dll").exists() {
341 path_entries.push(dir);
342 }
343 }
344
345 if path_entries.is_empty() {
346 return None;
347 }
348
349 let mut merged = std::ffi::OsString::new();
350 for entry in path_entries {
351 if !merged.is_empty() {
352 merged.push(";");
353 }
354 merged.push(entry);
355 }
356 if let Some(existing) = std::env::var_os("PATH") {
357 if !merged.is_empty() {
358 merged.push(";");
359 }
360 merged.push(existing);
361 }
362 Some(merged)
363 }
364}
365
366#[async_trait]
367impl VmmProvider for VmController {
368 async fn start(&self, spec: &InstanceSpec) -> Result<Box<dyn VmHandler>> {
369 tracing::debug!(
370 box_id = %spec.box_id,
371 vcpus = spec.vcpus,
372 memory_mib = spec.memory_mib,
373 "Starting VM subprocess"
374 );
375
376 let config_json = serde_json::to_string(spec).map_err(|e| BoxError::BoxBootError {
378 message: format!("Failed to serialize config: {}", e),
379 hint: None,
380 })?;
381
382 tracing::trace!(config = %config_json, "VM configuration");
383
384 if let Some(socket_dir) = spec.exec_socket_path.parent() {
386 std::fs::create_dir_all(socket_dir).map_err(|e| BoxError::BoxBootError {
387 message: format!(
388 "Failed to create socket directory {}: {}",
389 socket_dir.display(),
390 e
391 ),
392 hint: None,
393 })?;
394 }
395
396 #[cfg(target_os = "macos")]
398 tracing::info!(
399 shim = %self.shim_path.display(),
400 box_id = %spec.box_id,
401 net_socket_fd = spec.network.as_ref().and_then(|net| net.net_socket_fd),
402 net_proxy_fd = spec.network.as_ref().and_then(|net| net.net_proxy_fd),
403 "Spawning shim subprocess"
404 );
405 #[cfg(not(target_os = "macos"))]
406 tracing::info!(
407 shim = %self.shim_path.display(),
408 box_id = %spec.box_id,
409 "Spawning shim subprocess"
410 );
411
412 let mut cmd = Command::new(&self.shim_path);
413 cmd.arg("--config").arg(&config_json).stdin(Stdio::null());
414 self.configure_shim_stdio(&mut cmd, spec);
415
416 if spec.ksm {
419 cmd.env("A3S_BOX_KSM", "1");
420 }
421
422 let snap_env: [(&str, Option<&str>); 3] = [
428 ("KRUN_SNAPSHOT_MEM_FILE", spec.snapshot_mem_file.as_deref()),
429 ("KRUN_SNAPSHOT_SOCK", spec.snapshot_sock.as_deref()),
430 ("KRUN_RESTORE_FROM", spec.restore_from.as_deref()),
431 ];
432 for (var, spec_val) in snap_env {
433 match spec_val {
434 Some(val) if !val.is_empty() => {
435 cmd.env(var, val);
436 }
437 _ => {
438 if let Ok(val) = std::env::var(var) {
439 if !val.is_empty() {
440 cmd.env(var, val);
441 }
442 }
443 }
444 }
445 }
446
447 #[cfg(target_os = "macos")]
449 {
450 let mut dylib_paths = Vec::new();
451 let bundled_lib_dir = self
452 .shim_path
453 .parent()
454 .and_then(|dir| dir.parent())
455 .map(|dir| dir.join("lib"));
456 if let Some(path) = bundled_lib_dir.filter(|path| path.exists()) {
457 dylib_paths.push(path);
458 }
459 let home_lib_dir = a3s_box_core::dirs_home().join("lib");
460 if home_lib_dir.exists() {
461 dylib_paths.push(home_lib_dir);
462 }
463 if let Some(existing) = std::env::var_os("DYLD_LIBRARY_PATH") {
464 dylib_paths.extend(std::env::split_paths(&existing));
465 } else {
466 dylib_paths.push(std::path::PathBuf::from("/opt/homebrew/lib"));
467 }
468 if let Ok(joined) = std::env::join_paths(dylib_paths) {
469 cmd.env("DYLD_LIBRARY_PATH", joined);
470 }
471 }
472
473 #[cfg(target_os = "windows")]
474 if let Some(path) = Self::windows_shim_path_env(&self.shim_path) {
475 cmd.env("PATH", path);
476 }
477
478 #[cfg(unix)]
484 {
485 use std::os::unix::process::CommandExt;
486 unsafe {
487 cmd.pre_exec(|| {
488 libc::setsid();
490 Ok(())
491 });
492 }
493 }
494
495 let child = cmd.spawn().map_err(|e| BoxError::BoxBootError {
496 message: format!("Failed to spawn shim: {}", e),
497 hint: Some(format!("Shim path: {}", self.shim_path.display())),
498 })?;
499
500 let pid = child.id();
501 tracing::info!(
502 box_id = %spec.box_id,
503 pid = pid,
504 "Shim subprocess spawned"
505 );
506
507 let handler = ShimHandler::from_child(child, spec.box_id.clone());
509
510 Ok(Box::new(handler))
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 #[cfg(unix)]
519 fn make_fake_shim(dir: &std::path::Path) -> PathBuf {
520 use std::os::unix::fs::PermissionsExt;
521
522 let shim_path = dir.join("fake-a3s-box-shim");
523 std::fs::write(
524 &shim_path,
525 r#"#!/bin/sh
526printf '%s\n' "$@" > "$A3S_TEST_ARGS_FILE"
527printf '%s\n' "$A3S_BOX_KSM" > "$A3S_TEST_KSM_FILE"
528printf '%s\n' "$KRUN_SNAPSHOT_MEM_FILE" > "$A3S_TEST_SNAPSHOT_MEM_FILE"
529printf '%s\n' "$KRUN_SNAPSHOT_SOCK" > "$A3S_TEST_SNAPSHOT_SOCK_FILE"
530printf '%s\n' "$KRUN_RESTORE_FROM" > "$A3S_TEST_RESTORE_FILE"
531printf shim-stdout
532printf shim-stderr >&2
533exec /bin/sleep 30
534"#,
535 )
536 .unwrap();
537 std::fs::set_permissions(&shim_path, std::fs::Permissions::from_mode(0o755)).unwrap();
538 shim_path
539 }
540
541 #[cfg(unix)]
542 fn wait_for_file(path: &std::path::Path) {
543 for _ in 0..250 {
544 if path.exists() {
545 return;
546 }
547 std::thread::sleep(std::time::Duration::from_millis(20));
548 }
549 panic!("expected file to appear: {}", path.display());
550 }
551
552 #[test]
553 fn new_reports_missing_shim_with_build_hint() {
554 let missing = tempfile::tempdir()
555 .unwrap()
556 .path()
557 .join("missing-a3s-box-shim");
558
559 let error = match VmController::new(missing.clone()) {
560 Ok(_) => panic!("missing shim should be rejected"),
561 Err(error) => error,
562 };
563 let message = error.to_string();
564
565 assert!(message.contains("Shim binary not found"));
566 assert!(message.contains(&missing.display().to_string()));
567 assert!(message.contains("cargo build -p a3s-box-shim"));
568 }
569
570 #[cfg(unix)]
571 #[tokio::test]
572 async fn start_spawns_shim_with_config_env_and_stdio() {
573 let temp = tempfile::tempdir().unwrap();
574 let fake_shim = make_fake_shim(temp.path());
575 let controller = VmController {
576 shim_path: fake_shim,
577 };
578
579 let args_file = temp.path().join("shim.args");
580 let ksm_file = temp.path().join("shim.ksm");
581 let snapshot_mem_file = temp.path().join("shim.snapshot_mem");
582 let snapshot_sock_file = temp.path().join("shim.snapshot_sock");
583 let restore_file = temp.path().join("shim.restore");
584
585 std::env::set_var("A3S_TEST_ARGS_FILE", &args_file);
586 std::env::set_var("A3S_TEST_KSM_FILE", &ksm_file);
587 std::env::set_var("A3S_TEST_SNAPSHOT_MEM_FILE", &snapshot_mem_file);
588 std::env::set_var("A3S_TEST_SNAPSHOT_SOCK_FILE", &snapshot_sock_file);
589 std::env::set_var("A3S_TEST_RESTORE_FILE", &restore_file);
590
591 let socket_dir = temp.path().join("runtime").join("sockets");
592 let spec = InstanceSpec {
593 box_id: "box-start".to_string(),
594 exec_socket_path: socket_dir.join("exec.sock"),
595 console_output: Some(temp.path().join("logs").join("console.log")),
596 ksm: true,
597 snapshot_mem_file: Some("/tmp/a3s-mem".to_string()),
598 snapshot_sock: Some("/tmp/a3s-snapshot.sock".to_string()),
599 restore_from: Some("/tmp/a3s-restore".to_string()),
600 ..Default::default()
601 };
602
603 let mut handler = controller.start(&spec).await.unwrap();
604 wait_for_file(&args_file);
605
606 assert!(socket_dir.exists());
607 let args = std::fs::read_to_string(&args_file).unwrap();
608 assert!(args.contains("--config"));
609 assert!(args.contains("\"box_id\":\"box-start\""));
610 assert_eq!(std::fs::read_to_string(&ksm_file).unwrap().trim(), "1");
611 assert_eq!(
612 std::fs::read_to_string(&snapshot_mem_file).unwrap().trim(),
613 "/tmp/a3s-mem"
614 );
615 assert_eq!(
616 std::fs::read_to_string(&snapshot_sock_file).unwrap().trim(),
617 "/tmp/a3s-snapshot.sock"
618 );
619 assert_eq!(
620 std::fs::read_to_string(&restore_file).unwrap().trim(),
621 "/tmp/a3s-restore"
622 );
623 assert_eq!(
624 std::fs::read_to_string(temp.path().join("logs").join("shim.stdout.log")).unwrap(),
625 "shim-stdout"
626 );
627 assert_eq!(
628 std::fs::read_to_string(temp.path().join("logs").join("shim.stderr.log")).unwrap(),
629 "shim-stderr"
630 );
631
632 handler.stop(libc::SIGTERM, 1_000).unwrap();
633
634 std::env::remove_var("A3S_TEST_ARGS_FILE");
635 std::env::remove_var("A3S_TEST_KSM_FILE");
636 std::env::remove_var("A3S_TEST_SNAPSHOT_MEM_FILE");
637 std::env::remove_var("A3S_TEST_SNAPSHOT_SOCK_FILE");
638 std::env::remove_var("A3S_TEST_RESTORE_FILE");
639 }
640
641 #[cfg(unix)]
642 #[tokio::test]
643 async fn start_reports_socket_directory_creation_failure() {
644 let temp = tempfile::tempdir().unwrap();
645 let controller = VmController {
646 shim_path: PathBuf::from("/bin/sh"),
647 };
648 let socket_dir = temp.path().join("socket-dir-is-file");
649 std::fs::write(&socket_dir, "not a directory").unwrap();
650 let spec = InstanceSpec {
651 box_id: "box-start-error".to_string(),
652 exec_socket_path: socket_dir.join("exec.sock"),
653 ..Default::default()
654 };
655
656 let err = match controller.start(&spec).await {
657 Ok(_) => panic!("socket directory creation should fail before spawning the shim"),
658 Err(err) => err,
659 };
660
661 assert!(err
662 .to_string()
663 .contains("Failed to create socket directory"));
664 }
665
666 #[cfg(unix)]
667 #[test]
668 fn configure_shim_stdio_writes_per_box_stdout_and_stderr_logs() {
669 let temp = tempfile::tempdir().unwrap();
670 let controller = VmController {
671 shim_path: PathBuf::from("/bin/sh"),
672 };
673 let spec = InstanceSpec {
674 box_id: "box-stdio".to_string(),
675 console_output: Some(temp.path().join("logs").join("console.log")),
676 ..Default::default()
677 };
678
679 let mut cmd = Command::new("sh");
680 cmd.arg("-c")
681 .arg("printf fresh-stdout; printf fresh-stderr >&2");
682
683 std::fs::create_dir_all(temp.path().join("logs")).unwrap();
684 std::fs::write(temp.path().join("logs").join("shim.stdout.log"), "stale").unwrap();
685 std::fs::write(temp.path().join("logs").join("shim.stderr.log"), "stale").unwrap();
686
687 controller.configure_shim_stdio(&mut cmd, &spec);
688 let status = cmd.status().unwrap();
689
690 assert!(status.success());
691 assert_eq!(
692 std::fs::read_to_string(temp.path().join("logs").join("shim.stdout.log")).unwrap(),
693 "fresh-stdout"
694 );
695 assert_eq!(
696 std::fs::read_to_string(temp.path().join("logs").join("shim.stderr.log")).unwrap(),
697 "fresh-stderr"
698 );
699 }
700
701 #[cfg(unix)]
702 #[test]
703 fn configure_shim_stdio_creates_missing_log_directory() {
704 let temp = tempfile::tempdir().unwrap();
705 let controller = VmController {
706 shim_path: PathBuf::from("/bin/sh"),
707 };
708 let spec = InstanceSpec {
709 box_id: "box-stdio-dir".to_string(),
710 console_output: Some(temp.path().join("missing").join("console.log")),
711 ..Default::default()
712 };
713
714 let mut cmd = Command::new("sh");
715 cmd.arg("-c").arg("printf out; printf err >&2");
716
717 controller.configure_shim_stdio(&mut cmd, &spec);
718 let status = cmd.status().unwrap();
719
720 assert!(status.success());
721 assert_eq!(
722 std::fs::read_to_string(temp.path().join("missing").join("shim.stdout.log")).unwrap(),
723 "out"
724 );
725 assert_eq!(
726 std::fs::read_to_string(temp.path().join("missing").join("shim.stderr.log")).unwrap(),
727 "err"
728 );
729 }
730}