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