Skip to main content

a3s_box_runtime/vmm/
controller.rs

1//! VmController - Default VMM backend using shim subprocesses.
2
3use 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
14#[cfg(target_os = "windows")]
15struct StandardHandleInheritanceGuard {
16    _spawn_lock: std::sync::MutexGuard<'static, ()>,
17    changed_handles: Vec<windows_sys::Win32::Foundation::HANDLE>,
18}
19
20#[cfg(target_os = "windows")]
21impl StandardHandleInheritanceGuard {
22    fn acquire() -> std::io::Result<Self> {
23        use windows_sys::Win32::System::Console::{
24            GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
25        };
26
27        static SPAWN_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
28        let spawn_lock = SPAWN_LOCK
29            .lock()
30            .unwrap_or_else(|poisoned| poisoned.into_inner());
31        let mut guard = Self {
32            _spawn_lock: spawn_lock,
33            changed_handles: Vec::new(),
34        };
35        for standard_handle in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
36            let handle = unsafe { GetStdHandle(standard_handle) };
37            match clear_handle_inherit_flag(handle) {
38                Ok(true) => guard.changed_handles.push(handle),
39                Ok(false) => {}
40                Err(error) => {
41                    let error = std::io::Error::new(
42                        error.kind(),
43                        format!(
44                            "failed to clear inheritance for standard handle {standard_handle}: {error}"
45                        ),
46                    );
47                    if let Err(rollback_error) = guard.restore() {
48                        return Err(std::io::Error::new(
49                            error.kind(),
50                            format!("{error}; rollback also failed: {rollback_error}"),
51                        ));
52                    }
53                    return Err(error);
54                }
55            }
56        }
57
58        Ok(guard)
59    }
60
61    fn restore(&mut self) -> std::io::Result<()> {
62        use windows_sys::Win32::Foundation::{SetHandleInformation, HANDLE_FLAG_INHERIT};
63
64        let mut first_error = None;
65        let mut failed_handles = Vec::new();
66        for handle in std::mem::take(&mut self.changed_handles) {
67            if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) }
68                == 0
69            {
70                failed_handles.push(handle);
71                if first_error.is_none() {
72                    first_error = Some(std::io::Error::last_os_error());
73                }
74            }
75        }
76        self.changed_handles = failed_handles;
77        match first_error {
78            Some(error) => Err(error),
79            None => Ok(()),
80        }
81    }
82}
83
84#[cfg(target_os = "windows")]
85impl Drop for StandardHandleInheritanceGuard {
86    fn drop(&mut self) {
87        if let Err(error) = self.restore() {
88            tracing::warn!(
89                %error,
90                "Failed to restore standard-handle inheritance after shim spawn"
91            );
92        }
93    }
94}
95
96#[cfg(target_os = "windows")]
97fn clear_handle_inherit_flag(
98    handle: windows_sys::Win32::Foundation::HANDLE,
99) -> std::io::Result<bool> {
100    use windows_sys::Win32::Foundation::{
101        GetHandleInformation, SetHandleInformation, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE,
102    };
103
104    if handle == 0 || handle == INVALID_HANDLE_VALUE {
105        return Ok(false);
106    }
107
108    let mut flags = 0;
109    if unsafe { GetHandleInformation(handle, &mut flags) } == 0 {
110        return Err(std::io::Error::last_os_error());
111    }
112    if flags & HANDLE_FLAG_INHERIT == 0 {
113        return Ok(false);
114    }
115    if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) } == 0 {
116        return Err(std::io::Error::last_os_error());
117    }
118    Ok(true)
119}
120
121/// Controller for spawning VM subprocesses.
122///
123/// Spawns the `a3s-box-shim` binary in a subprocess and returns a ShimHandler
124/// for runtime operations. The subprocess isolation ensures that VM process
125/// takeover doesn't affect the host application.
126pub struct VmController {
127    /// Path to the a3s-box-shim binary
128    shim_path: PathBuf,
129}
130
131impl VmController {
132    fn configure_shim_stdio(&self, cmd: &mut Command, spec: &InstanceSpec) {
133        use std::fs::OpenOptions;
134
135        let Some(console_output) = spec.console_output.as_ref() else {
136            cmd.stdout(Stdio::null()).stderr(Stdio::null());
137            return;
138        };
139        let Some(log_dir) = console_output.parent() else {
140            cmd.stdout(Stdio::null()).stderr(Stdio::null());
141            return;
142        };
143        if let Err(error) = std::fs::create_dir_all(log_dir) {
144            tracing::warn!(
145                box_id = %spec.box_id,
146                path = %log_dir.display(),
147                error = %error,
148                "Failed to create shim log directory"
149            );
150            cmd.stdout(Stdio::null()).stderr(Stdio::null());
151            return;
152        }
153
154        let stdout_path = log_dir.join("shim.stdout.log");
155        let stderr_path = log_dir.join("shim.stderr.log");
156
157        let stdout_file = OpenOptions::new()
158            .create(true)
159            .truncate(true)
160            .write(true)
161            .open(&stdout_path);
162        let stderr_file = OpenOptions::new()
163            .create(true)
164            .truncate(true)
165            .write(true)
166            .open(&stderr_path);
167
168        match (stdout_file, stderr_file) {
169            (Ok(stdout_file), Ok(stderr_file)) => {
170                tracing::debug!(
171                    box_id = %spec.box_id,
172                    stdout = %stdout_path.display(),
173                    stderr = %stderr_path.display(),
174                    "Redirecting shim stdio to per-box files"
175                );
176                cmd.stdout(Stdio::from(stdout_file))
177                    .stderr(Stdio::from(stderr_file));
178            }
179            (stdout_result, stderr_result) => {
180                if let Err(error) = stdout_result {
181                    tracing::warn!(
182                        box_id = %spec.box_id,
183                        path = %stdout_path.display(),
184                        error = %error,
185                        "Failed to open shim stdout log file"
186                    );
187                }
188                if let Err(error) = stderr_result {
189                    tracing::warn!(
190                        box_id = %spec.box_id,
191                        path = %stderr_path.display(),
192                        error = %error,
193                        "Failed to open shim stderr log file"
194                    );
195                }
196                cmd.stdout(Stdio::null()).stderr(Stdio::null());
197            }
198        }
199    }
200
201    /// Create a new VmController.
202    ///
203    /// # Arguments
204    /// * `shim_path` - Path to the a3s-box-shim binary
205    ///
206    /// # Returns
207    /// * `Ok(VmController)` - Successfully created controller
208    /// * `Err(...)` - Failed to create controller (e.g., binary not found)
209    pub fn new(shim_path: PathBuf) -> Result<Self> {
210        // Verify that the shim binary exists
211        if !shim_path.exists() {
212            return Err(BoxError::BoxBootError {
213                message: format!("Shim binary not found: {}", shim_path.display()),
214                hint: Some("Build the shim with: cargo build -p a3s-box-shim".to_string()),
215            });
216        }
217
218        // On macOS, ensure the shim has the Hypervisor.framework entitlement
219        #[cfg(target_os = "macos")]
220        Self::ensure_entitlement(&shim_path)?;
221
222        Ok(Self { shim_path })
223    }
224
225    /// Ensure the shim binary has the com.apple.security.hypervisor entitlement.
226    ///
227    /// On macOS, Hypervisor.framework requires this entitlement. If the binary
228    /// was built with `cargo build` directly (without `just build`), it won't
229    /// have the entitlement. This method checks and signs it if needed.
230    ///
231    /// Uses a file lock to prevent race conditions when multiple processes
232    /// (e.g., concurrent tests) try to sign the same binary simultaneously.
233    #[cfg(target_os = "macos")]
234    fn ensure_entitlement(shim_path: &std::path::Path) -> Result<()> {
235        use std::fs::File;
236
237        // Fast path: check without lock first
238        if Self::has_hypervisor_entitlement(shim_path)? {
239            return Ok(());
240        }
241
242        // Acquire exclusive file lock to prevent concurrent codesign
243        let lock_path = std::env::temp_dir().join("a3s-box-shim-codesign.lock");
244        let lock_file = File::create(&lock_path).map_err(|e| BoxError::BoxBootError {
245            message: format!("Failed to create codesign lock file: {}", e),
246            hint: None,
247        })?;
248
249        // flock(LOCK_EX) — blocks until exclusive lock is acquired
250        let fd = std::os::unix::io::AsRawFd::as_raw_fd(&lock_file);
251        let ret = unsafe { libc::flock(fd, libc::LOCK_EX) };
252        if ret != 0 {
253            return Err(BoxError::BoxBootError {
254                message: format!(
255                    "Failed to acquire codesign lock: {}",
256                    std::io::Error::last_os_error()
257                ),
258                hint: None,
259            });
260        }
261
262        // Re-check after acquiring lock — another process may have signed it
263        if Self::has_hypervisor_entitlement(shim_path)? {
264            // Lock is released when lock_file is dropped
265            return Ok(());
266        }
267
268        tracing::info!("Signing shim with Hypervisor.framework entitlement");
269
270        let entitlements_path = Self::find_entitlements_plist(shim_path)?;
271
272        let status = Command::new("codesign")
273            .args(["--entitlements"])
274            .arg(&entitlements_path)
275            .args(["--force", "-s", "-"])
276            .arg(shim_path)
277            .status()
278            .map_err(|e| BoxError::BoxBootError {
279                message: format!("Failed to codesign shim: {}", e),
280                hint: None,
281            })?;
282
283        if !status.success() {
284            return Err(BoxError::BoxBootError {
285                message: "Failed to sign shim with Hypervisor entitlement".to_string(),
286                hint: Some(format!(
287                    "Try manually: codesign --entitlements {} --force -s - {}",
288                    entitlements_path.display(),
289                    shim_path.display()
290                )),
291            });
292        }
293
294        // Lock is released when lock_file is dropped
295        Ok(())
296    }
297
298    /// Check if the shim binary already has the Hypervisor entitlement.
299    #[cfg(target_os = "macos")]
300    fn has_hypervisor_entitlement(shim_path: &std::path::Path) -> Result<bool> {
301        let output = Command::new("codesign")
302            .args(["-d", "--entitlements", "-", "--xml"])
303            .arg(shim_path)
304            .output()
305            .map_err(|e| BoxError::BoxBootError {
306                message: format!("Failed to check entitlements: {}", e),
307                hint: None,
308            })?;
309
310        let stdout = String::from_utf8_lossy(&output.stdout);
311        Ok(stdout.contains("com.apple.security.hypervisor"))
312    }
313
314    /// Find the entitlements.plist file.
315    #[cfg(target_os = "macos")]
316    fn find_entitlements_plist(shim_path: &std::path::Path) -> Result<PathBuf> {
317        // Try next to the shim binary
318        if let Some(dir) = shim_path.parent() {
319            let plist = dir.join("entitlements.plist");
320            if plist.exists() {
321                return Ok(plist);
322            }
323        }
324
325        // Try the source tree relative to the shim binary
326        // target/debug/a3s-box-shim -> ../../shim/entitlements.plist
327        if let Some(dir) = shim_path.parent() {
328            for ancestor in dir.ancestors().take(5) {
329                let plist = ancestor.join("shim").join("entitlements.plist");
330                if plist.exists() {
331                    return Ok(plist);
332                }
333            }
334        }
335
336        // Generate a temporary entitlements plist as fallback
337        let tmp_plist = std::env::temp_dir().join("a3s-box-entitlements.plist");
338        std::fs::write(
339            &tmp_plist,
340            r#"<?xml version="1.0" encoding="UTF-8"?>
341<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
342<plist version="1.0">
343<dict>
344    <key>com.apple.security.hypervisor</key>
345    <true/>
346</dict>
347</plist>
348"#,
349        )
350        .map_err(|e| BoxError::BoxBootError {
351            message: format!("Failed to write temporary entitlements plist: {}", e),
352            hint: None,
353        })?;
354
355        Ok(tmp_plist)
356    }
357
358    /// Find the shim binary in common locations.
359    ///
360    /// Searches in order:
361    /// 1. Same directory as current executable
362    /// 2. `~/.a3s/bin/` (SDK-extracted shim)
363    /// 3. target/debug or target/release (for development)
364    /// 4. PATH
365    pub fn find_shim() -> Result<PathBuf> {
366        // On Windows the binary has a .exe suffix; on other platforms it's empty.
367        #[cfg(target_os = "windows")]
368        let shim_name = "a3s-box-shim.exe";
369        #[cfg(not(target_os = "windows"))]
370        let shim_name = "a3s-box-shim";
371
372        // Try same directory as current executable
373        if let Ok(exe_path) = std::env::current_exe() {
374            if let Some(exe_dir) = exe_path.parent() {
375                let shim_path = exe_dir.join(shim_name);
376                if shim_path.exists() {
377                    return Ok(shim_path);
378                }
379            }
380        }
381
382        // Try ~/.a3s/bin/ (SDK-extracted shim)
383        {
384            let shim_path = a3s_box_core::dirs_home().join("bin").join(shim_name);
385            if shim_path.exists() {
386                return Ok(shim_path);
387            }
388        }
389
390        // Try target directories (for development)
391        let target_dirs = ["target/debug", "target/release"];
392        for dir in target_dirs {
393            let shim_path = PathBuf::from(dir).join(shim_name);
394            if shim_path.exists() {
395                return Ok(shim_path);
396            }
397        }
398
399        // Try PATH — use `where` on Windows, `which` elsewhere
400        #[cfg(target_os = "windows")]
401        let which_cmd = "where";
402        #[cfg(not(target_os = "windows"))]
403        let which_cmd = "which";
404
405        if let Ok(output) = Command::new(which_cmd).arg(shim_name).output() {
406            if output.status.success() {
407                let path = String::from_utf8_lossy(&output.stdout)
408                    .lines()
409                    .next()
410                    .unwrap_or("")
411                    .trim()
412                    .to_string();
413                if !path.is_empty() {
414                    return Ok(PathBuf::from(path));
415                }
416            }
417        }
418
419        Err(BoxError::BoxBootError {
420            message: "Could not find a3s-box-shim binary".to_string(),
421            hint: Some("Build the shim with: cargo build -p a3s-box-shim".to_string()),
422        })
423    }
424
425    #[cfg(target_os = "windows")]
426    fn windows_shim_path_env(shim_path: &std::path::Path) -> Option<std::ffi::OsString> {
427        use std::collections::HashSet;
428
429        let mut dirs = Vec::<PathBuf>::new();
430        if let Ok(dir) = std::env::var("LIBKRUN_DIR") {
431            dirs.push(PathBuf::from(dir));
432        }
433        if let Some(dir) = option_env!("LIBKRUN_DIR") {
434            dirs.push(PathBuf::from(dir));
435        }
436        if let Some(dir) = shim_path.parent() {
437            dirs.push(dir.to_path_buf());
438            dirs.push(dir.join("lib"));
439        }
440
441        let mut seen = HashSet::new();
442        let mut path_entries = Vec::new();
443        for dir in dirs {
444            if !seen.insert(dir.clone()) {
445                continue;
446            }
447            if dir.join("krun.dll").exists() {
448                path_entries.push(dir);
449            }
450        }
451
452        if path_entries.is_empty() {
453            return None;
454        }
455
456        let mut merged = std::ffi::OsString::new();
457        for entry in path_entries {
458            if !merged.is_empty() {
459                merged.push(";");
460            }
461            merged.push(entry);
462        }
463        if let Some(existing) = std::env::var_os("PATH") {
464            if !merged.is_empty() {
465                merged.push(";");
466            }
467            merged.push(existing);
468        }
469        Some(merged)
470    }
471}
472
473#[async_trait]
474impl VmmProvider for VmController {
475    async fn start(&self, spec: &InstanceSpec) -> Result<Box<dyn VmHandler>> {
476        tracing::debug!(
477            box_id = %spec.box_id,
478            vcpus = spec.vcpus,
479            memory_mib = spec.memory_mib,
480            "Starting VM subprocess"
481        );
482
483        // Serialize the config for passing to subprocess
484        let config_json = serde_json::to_string(spec).map_err(|e| BoxError::BoxBootError {
485            message: format!("Failed to serialize config: {}", e),
486            hint: None,
487        })?;
488
489        tracing::trace!(config = %config_json, "VM configuration");
490
491        // Ensure socket directory exists
492        if let Some(socket_dir) = spec.exec_socket_path.parent() {
493            std::fs::create_dir_all(socket_dir).map_err(|e| BoxError::BoxBootError {
494                message: format!(
495                    "Failed to create socket directory {}: {}",
496                    socket_dir.display(),
497                    e
498                ),
499                hint: None,
500            })?;
501        }
502
503        // Spawn shim subprocess
504        #[cfg(target_os = "macos")]
505        tracing::info!(
506            shim = %self.shim_path.display(),
507            box_id = %spec.box_id,
508            net_socket_fd = spec.network.as_ref().and_then(|net| net.net_socket_fd),
509            net_proxy_fd = spec.network.as_ref().and_then(|net| net.net_proxy_fd),
510            "Spawning shim subprocess"
511        );
512        #[cfg(not(target_os = "macos"))]
513        tracing::info!(
514            shim = %self.shim_path.display(),
515            box_id = %spec.box_id,
516            "Spawning shim subprocess"
517        );
518
519        let mut cmd = Command::new(&self.shim_path);
520        cmd.arg("--config").arg(&config_json).stdin(Stdio::null());
521        self.configure_shim_stdio(&mut cmd, spec);
522
523        // KSM page-merging: the shim opts its (guest) memory in via prctl when this
524        // env is set; driven by InstanceSpec.ksm (BoxConfig.ksm or A3S_BOX_KSM).
525        if spec.ksm {
526            cmd.env("A3S_BOX_KSM", "1");
527        }
528
529        // Snapshot-fork: set the file-backed-RAM / snapshot-trigger / restore paths
530        // for the shim/libkrun. PER-VM values from the InstanceSpec take precedence —
531        // this is what lets ONE process (the pool / fork daemon) drive a different
532        // template/restore per VM, which a process-global env cannot. Fall back to the
533        // process env only when the spec doesn't set a given var (single-VM `run`).
534        let snap_env: [(&str, Option<&str>); 3] = [
535            ("KRUN_SNAPSHOT_MEM_FILE", spec.snapshot_mem_file.as_deref()),
536            ("KRUN_SNAPSHOT_SOCK", spec.snapshot_sock.as_deref()),
537            ("KRUN_RESTORE_FROM", spec.restore_from.as_deref()),
538        ];
539        for (var, spec_val) in snap_env {
540            match spec_val {
541                Some(val) if !val.is_empty() => {
542                    cmd.env(var, val);
543                }
544                _ => {
545                    if let Ok(val) = std::env::var(var) {
546                        if !val.is_empty() {
547                            cmd.env(var, val);
548                        }
549                    }
550                }
551            }
552        }
553
554        // On macOS, set DYLD_LIBRARY_PATH to help find libkrunfw
555        #[cfg(target_os = "macos")]
556        {
557            let mut dylib_paths = Vec::new();
558            let bundled_lib_dir = self
559                .shim_path
560                .parent()
561                .and_then(|dir| dir.parent())
562                .map(|dir| dir.join("lib"));
563            if let Some(path) = bundled_lib_dir.filter(|path| path.exists()) {
564                dylib_paths.push(path);
565            }
566            let home_lib_dir = a3s_box_core::dirs_home().join("lib");
567            if home_lib_dir.exists() {
568                dylib_paths.push(home_lib_dir);
569            }
570            if let Some(existing) = std::env::var_os("DYLD_LIBRARY_PATH") {
571                dylib_paths.extend(std::env::split_paths(&existing));
572            } else {
573                dylib_paths.push(std::path::PathBuf::from("/opt/homebrew/lib"));
574            }
575            if let Ok(joined) = std::env::join_paths(dylib_paths) {
576                cmd.env("DYLD_LIBRARY_PATH", joined);
577            }
578        }
579
580        #[cfg(target_os = "windows")]
581        if let Some(path) = Self::windows_shim_path_env(&self.shim_path) {
582            cmd.env("PATH", path);
583        }
584
585        // Put the VMM shim in its own session/process-group so it survives teardown
586        // of the launcher's session — e.g. a containerd-shim foreground `a3s-box run`
587        // whose process group is reaped on container kill. Without this the libkrun
588        // shim (which owns the box's exec.sock) dies with the launcher and `a3s-box
589        // exec` fails with "exec socket missing".
590        #[cfg(unix)]
591        {
592            use std::os::unix::process::CommandExt;
593            unsafe {
594                cmd.pre_exec(|| {
595                    // setsid() fails harmlessly if already a group leader; ignore.
596                    libc::setsid();
597                    Ok(())
598                });
599            }
600        }
601
602        #[cfg(target_os = "windows")]
603        let mut standard_handle_guard =
604            StandardHandleInheritanceGuard::acquire().map_err(|e| BoxError::BoxBootError {
605                message: format!("Failed to isolate shim standard handles: {e}"),
606                hint: Some("Retry the command from a fresh terminal".to_string()),
607            })?;
608
609        let child = cmd.spawn().map_err(|e| BoxError::BoxBootError {
610            message: format!("Failed to spawn shim: {}", e),
611            hint: Some(format!("Shim path: {}", self.shim_path.display())),
612        })?;
613
614        #[cfg(target_os = "windows")]
615        if let Err(error) = standard_handle_guard.restore() {
616            let mut failed_child = child;
617            let _ = failed_child.kill();
618            let _ = failed_child.wait();
619            return Err(BoxError::BoxBootError {
620                message: format!(
621                    "Failed to restore standard-handle inheritance after shim spawn: {error}"
622                ),
623                hint: Some("Retry the command from a fresh terminal".to_string()),
624            });
625        }
626
627        let pid = child.id();
628        tracing::info!(
629            box_id = %spec.box_id,
630            pid = pid,
631            "Shim subprocess spawned"
632        );
633
634        // Create handler for the running VM
635        let handler = ShimHandler::from_child(child, spec.box_id.clone());
636
637        Ok(Box::new(handler))
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    #[cfg(unix)]
646    fn make_fake_shim(dir: &std::path::Path) -> PathBuf {
647        use std::os::unix::fs::PermissionsExt;
648
649        let shim_path = dir.join("fake-a3s-box-shim");
650        std::fs::write(
651            &shim_path,
652            r#"#!/bin/sh
653printf '%s\n' "$@" > "$A3S_TEST_ARGS_FILE"
654printf '%s\n' "$A3S_BOX_KSM" > "$A3S_TEST_KSM_FILE"
655printf '%s\n' "$KRUN_SNAPSHOT_MEM_FILE" > "$A3S_TEST_SNAPSHOT_MEM_FILE"
656printf '%s\n' "$KRUN_SNAPSHOT_SOCK" > "$A3S_TEST_SNAPSHOT_SOCK_FILE"
657printf '%s\n' "$KRUN_RESTORE_FROM" > "$A3S_TEST_RESTORE_FILE"
658printf shim-stdout
659printf shim-stderr >&2
660exec /bin/sleep 30
661"#,
662        )
663        .unwrap();
664        std::fs::set_permissions(&shim_path, std::fs::Permissions::from_mode(0o755)).unwrap();
665        shim_path
666    }
667
668    #[cfg(unix)]
669    fn wait_for_file(path: &std::path::Path) {
670        for _ in 0..250 {
671            // Shell redirection creates the file before `printf` writes its
672            // contents. Waiting for existence alone makes the test race with
673            // the fake shim and intermittently observe an empty value in CI.
674            if path.metadata().is_ok_and(|metadata| metadata.len() > 0) {
675                return;
676            }
677            std::thread::sleep(std::time::Duration::from_millis(20));
678        }
679        panic!("expected file to appear: {}", path.display());
680    }
681
682    #[test]
683    fn new_reports_missing_shim_with_build_hint() {
684        let missing = tempfile::tempdir()
685            .unwrap()
686            .path()
687            .join("missing-a3s-box-shim");
688
689        let error = match VmController::new(missing.clone()) {
690            Ok(_) => panic!("missing shim should be rejected"),
691            Err(error) => error,
692        };
693        let message = error.to_string();
694
695        assert!(message.contains("Shim binary not found"));
696        assert!(message.contains(&missing.display().to_string()));
697        assert!(message.contains("cargo build -p a3s-box-shim"));
698    }
699
700    #[cfg(target_os = "windows")]
701    #[test]
702    fn clear_handle_inherit_flag_clears_an_inheritable_file_handle() {
703        use std::os::windows::io::AsRawHandle;
704        use windows_sys::Win32::Foundation::{
705            GetHandleInformation, SetHandleInformation, HANDLE, HANDLE_FLAG_INHERIT,
706        };
707
708        static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
709
710        let file = tempfile::tempfile().unwrap();
711        let handle = file.as_raw_handle() as HANDLE;
712        assert_ne!(
713            unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) },
714            0
715        );
716
717        assert!(clear_handle_inherit_flag(handle).unwrap());
718        let mut flags = 0;
719        assert_ne!(unsafe { GetHandleInformation(handle, &mut flags) }, 0);
720        assert_eq!(flags & HANDLE_FLAG_INHERIT, 0);
721        assert!(!clear_handle_inherit_flag(handle).unwrap());
722
723        let mut guard = StandardHandleInheritanceGuard {
724            _spawn_lock: TEST_LOCK.lock().unwrap(),
725            changed_handles: vec![handle],
726        };
727        guard.restore().unwrap();
728        assert_ne!(unsafe { GetHandleInformation(handle, &mut flags) }, 0);
729        assert_ne!(flags & HANDLE_FLAG_INHERIT, 0);
730    }
731
732    #[cfg(target_os = "windows")]
733    #[test]
734    fn configure_shim_stdio_keeps_explicit_windows_log_handles() {
735        let temp = tempfile::tempdir().unwrap();
736        let controller = VmController {
737            shim_path: PathBuf::from("unused"),
738        };
739        let spec = InstanceSpec {
740            box_id: "box-windows-stdio".to_string(),
741            console_output: Some(temp.path().join("logs").join("console.log")),
742            ..Default::default()
743        };
744        let mut cmd = Command::new("cmd.exe");
745        cmd.arg("/C")
746            .arg("echo fresh-stdout & echo fresh-stderr 1>&2");
747
748        controller.configure_shim_stdio(&mut cmd, &spec);
749        let status = cmd.status().unwrap();
750
751        assert!(status.success());
752        assert_eq!(
753            std::fs::read_to_string(temp.path().join("logs").join("shim.stdout.log"))
754                .unwrap()
755                .trim(),
756            "fresh-stdout"
757        );
758        assert_eq!(
759            std::fs::read_to_string(temp.path().join("logs").join("shim.stderr.log"))
760                .unwrap()
761                .trim(),
762            "fresh-stderr"
763        );
764    }
765
766    #[cfg(unix)]
767    #[tokio::test]
768    async fn start_spawns_shim_with_config_env_and_stdio() {
769        let temp = tempfile::tempdir().unwrap();
770        let fake_shim = make_fake_shim(temp.path());
771        let controller = VmController {
772            shim_path: fake_shim,
773        };
774
775        let args_file = temp.path().join("shim.args");
776        let ksm_file = temp.path().join("shim.ksm");
777        let snapshot_mem_file = temp.path().join("shim.snapshot_mem");
778        let snapshot_sock_file = temp.path().join("shim.snapshot_sock");
779        let restore_file = temp.path().join("shim.restore");
780
781        std::env::set_var("A3S_TEST_ARGS_FILE", &args_file);
782        std::env::set_var("A3S_TEST_KSM_FILE", &ksm_file);
783        std::env::set_var("A3S_TEST_SNAPSHOT_MEM_FILE", &snapshot_mem_file);
784        std::env::set_var("A3S_TEST_SNAPSHOT_SOCK_FILE", &snapshot_sock_file);
785        std::env::set_var("A3S_TEST_RESTORE_FILE", &restore_file);
786
787        let socket_dir = temp.path().join("runtime").join("sockets");
788        let spec = InstanceSpec {
789            box_id: "box-start".to_string(),
790            exec_socket_path: socket_dir.join("exec.sock"),
791            console_output: Some(temp.path().join("logs").join("console.log")),
792            ksm: true,
793            snapshot_mem_file: Some("/tmp/a3s-mem".to_string()),
794            snapshot_sock: Some("/tmp/a3s-snapshot.sock".to_string()),
795            restore_from: Some("/tmp/a3s-restore".to_string()),
796            ..Default::default()
797        };
798
799        let mut handler = controller.start(&spec).await.unwrap();
800        wait_for_file(&args_file);
801        wait_for_file(&restore_file);
802        wait_for_file(&temp.path().join("logs").join("shim.stderr.log"));
803
804        assert!(socket_dir.exists());
805        let args = std::fs::read_to_string(&args_file).unwrap();
806        assert!(args.contains("--config"));
807        assert!(args.contains("\"box_id\":\"box-start\""));
808        assert_eq!(std::fs::read_to_string(&ksm_file).unwrap().trim(), "1");
809        assert_eq!(
810            std::fs::read_to_string(&snapshot_mem_file).unwrap().trim(),
811            "/tmp/a3s-mem"
812        );
813        assert_eq!(
814            std::fs::read_to_string(&snapshot_sock_file).unwrap().trim(),
815            "/tmp/a3s-snapshot.sock"
816        );
817        assert_eq!(
818            std::fs::read_to_string(&restore_file).unwrap().trim(),
819            "/tmp/a3s-restore"
820        );
821        assert_eq!(
822            std::fs::read_to_string(temp.path().join("logs").join("shim.stdout.log")).unwrap(),
823            "shim-stdout"
824        );
825        assert_eq!(
826            std::fs::read_to_string(temp.path().join("logs").join("shim.stderr.log")).unwrap(),
827            "shim-stderr"
828        );
829
830        handler.stop(libc::SIGTERM, 1_000).unwrap();
831
832        std::env::remove_var("A3S_TEST_ARGS_FILE");
833        std::env::remove_var("A3S_TEST_KSM_FILE");
834        std::env::remove_var("A3S_TEST_SNAPSHOT_MEM_FILE");
835        std::env::remove_var("A3S_TEST_SNAPSHOT_SOCK_FILE");
836        std::env::remove_var("A3S_TEST_RESTORE_FILE");
837    }
838
839    #[cfg(unix)]
840    #[tokio::test]
841    async fn start_reports_socket_directory_creation_failure() {
842        let temp = tempfile::tempdir().unwrap();
843        let controller = VmController {
844            shim_path: PathBuf::from("/bin/sh"),
845        };
846        let socket_dir = temp.path().join("socket-dir-is-file");
847        std::fs::write(&socket_dir, "not a directory").unwrap();
848        let spec = InstanceSpec {
849            box_id: "box-start-error".to_string(),
850            exec_socket_path: socket_dir.join("exec.sock"),
851            ..Default::default()
852        };
853
854        let err = match controller.start(&spec).await {
855            Ok(_) => panic!("socket directory creation should fail before spawning the shim"),
856            Err(err) => err,
857        };
858
859        assert!(err
860            .to_string()
861            .contains("Failed to create socket directory"));
862    }
863
864    #[cfg(unix)]
865    #[test]
866    fn configure_shim_stdio_writes_per_box_stdout_and_stderr_logs() {
867        let temp = tempfile::tempdir().unwrap();
868        let controller = VmController {
869            shim_path: PathBuf::from("/bin/sh"),
870        };
871        let spec = InstanceSpec {
872            box_id: "box-stdio".to_string(),
873            console_output: Some(temp.path().join("logs").join("console.log")),
874            ..Default::default()
875        };
876
877        let mut cmd = Command::new("sh");
878        cmd.arg("-c")
879            .arg("printf fresh-stdout; printf fresh-stderr >&2");
880
881        std::fs::create_dir_all(temp.path().join("logs")).unwrap();
882        std::fs::write(temp.path().join("logs").join("shim.stdout.log"), "stale").unwrap();
883        std::fs::write(temp.path().join("logs").join("shim.stderr.log"), "stale").unwrap();
884
885        controller.configure_shim_stdio(&mut cmd, &spec);
886        let status = cmd.status().unwrap();
887
888        assert!(status.success());
889        assert_eq!(
890            std::fs::read_to_string(temp.path().join("logs").join("shim.stdout.log")).unwrap(),
891            "fresh-stdout"
892        );
893        assert_eq!(
894            std::fs::read_to_string(temp.path().join("logs").join("shim.stderr.log")).unwrap(),
895            "fresh-stderr"
896        );
897    }
898
899    #[cfg(unix)]
900    #[test]
901    fn configure_shim_stdio_creates_missing_log_directory() {
902        let temp = tempfile::tempdir().unwrap();
903        let controller = VmController {
904            shim_path: PathBuf::from("/bin/sh"),
905        };
906        let spec = InstanceSpec {
907            box_id: "box-stdio-dir".to_string(),
908            console_output: Some(temp.path().join("missing").join("console.log")),
909            ..Default::default()
910        };
911
912        let mut cmd = Command::new("sh");
913        cmd.arg("-c").arg("printf out; printf err >&2");
914
915        controller.configure_shim_stdio(&mut cmd, &spec);
916        let status = cmd.status().unwrap();
917
918        assert!(status.success());
919        assert_eq!(
920            std::fs::read_to_string(temp.path().join("missing").join("shim.stdout.log")).unwrap(),
921            "out"
922        );
923        assert_eq!(
924            std::fs::read_to_string(temp.path().join("missing").join("shim.stderr.log")).unwrap(),
925            "err"
926        );
927    }
928}