diff --git a/src/Cargo.toml b/src/Cargo.toml index ff41a13..c94a92b 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -12,7 +12,7 @@ members = [ resolver = "2" [workspace.package] -version = "2.0.0" +version = "2.0.1" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" diff --git a/src/guest/init/src/main.rs b/src/guest/init/src/main.rs index b5ffe84..810cd95 100644 --- a/src/guest/init/src/main.rs +++ b/src/guest/init/src/main.rs @@ -780,8 +780,9 @@ fn remount_rootfs_readonly() -> Result<(), Box> { /// Wait for all child processes and reap zombies. /// -/// Only exits when `container_pid` exits. Other child processes (e.g., from -/// exec server) are reaped silently. +/// Only exits when `container_pid` exits AND `shutdown_requested` is set (SIGTERM). +/// Otherwise loops forever so background services (exec/PTY/attestation) stay alive +/// after the container exits — the VM stays alive until explicitly stopped by the host. fn wait_for_children(container_pid: nix::unistd::Pid) -> Result<(), Box> { use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus}; use nix::unistd::Pid; @@ -802,8 +803,22 @@ fn wait_for_children(container_pid: nix::unistd::Pid) -> Result<(), Box { if pid == container_pid { - info!("Container process {} exited with status {}", pid, status); - process::exit(status); + // Container exited — keep the VM alive so vsock services remain available. + // The host stops the VM via SIGTERM, at which point graceful_shutdown runs. + info!( + "Container process {} exited with status {}. \ + VM staying alive for vsock services (stop with Ctrl-C)", + pid, status + ); + // Loop forever — VM stays alive until SIGTERM from host + loop { + if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) { + info!("SIGTERM received during container-exited idle, shutting down"); + graceful_shutdown(CHILD_SHUTDOWN_TIMEOUT_MS); + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_secs(1)); + } } else { info!( "Child process {} exited with status {} (reaped)", @@ -814,7 +829,14 @@ fn wait_for_children(container_pid: nix::unistd::Pid) -> Result<(), Box { if pid == container_pid { error!("Container process {} killed by signal {:?}", pid, signal); - process::exit(128 + signal as i32); + loop { + if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) { + info!("SIGTERM received, shutting down"); + graceful_shutdown(CHILD_SHUTDOWN_TIMEOUT_MS); + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_secs(1)); + } } else if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) { info!( "Child process {} terminated by signal {:?} during shutdown", diff --git a/src/runtime/src/vm/layout.rs b/src/runtime/src/vm/layout.rs index 6a74174..efbd800 100644 --- a/src/runtime/src/vm/layout.rs +++ b/src/runtime/src/vm/layout.rs @@ -485,10 +485,12 @@ mod tests { event_emitter: emitter, provider: None, handler: Arc::new(RwLock::new(None)), + #[cfg(unix)] exec_client: None, net_manager: None, home_dir: home_dir.to_path_buf(), anonymous_volumes: Vec::new(), + #[cfg(unix)] tee: None, rootfs_provider: crate::rootfs::default_provider(), exec_socket_path: None, @@ -622,6 +624,7 @@ mod tests { assert!(cache.entry_count().unwrap() <= 2); } + #[cfg(unix)] #[tokio::test] async fn test_exec_command_rejects_created_state() { let tmp = TempDir::new().unwrap(); @@ -633,6 +636,7 @@ mod tests { assert!(err.to_string().contains("not yet booted")); } + #[cfg(unix)] #[tokio::test] async fn test_exec_command_rejects_stopped_state() { let tmp = TempDir::new().unwrap(); @@ -645,6 +649,7 @@ mod tests { assert!(err.to_string().contains("stopped")); } + #[cfg(unix)] #[tokio::test] async fn test_exec_command_no_client() { let tmp = TempDir::new().unwrap(); diff --git a/src/runtime/src/vm/spec.rs b/src/runtime/src/vm/spec.rs index 73755b7..b9f6c49 100644 --- a/src/runtime/src/vm/spec.rs +++ b/src/runtime/src/vm/spec.rs @@ -101,7 +101,7 @@ impl VmManager { ); (exec, args, oci_config.env.clone()) } - None => (SBIN_INIT.to_string(), vec![], vec![]), + None => ("/bin/sh".to_string(), vec!["-c".to_string(), "echo No command specified; exec /bin/sh".to_string()], vec![]), }; // Pass exec + args as individual env vars (avoids spaces being truncated @@ -215,8 +215,8 @@ impl VmManager { } } None => Entrypoint { - executable: SBIN_INIT.to_string(), - args: vec![], + executable: "/bin/sh".to_string(), + args: vec!["-c".to_string(), "echo No command specified; exec /bin/sh".to_string()], env: vec![], }, } @@ -342,8 +342,8 @@ impl VmManager { let args: Vec = oci_cmd.iter().skip(1).cloned().collect(); (exec, args) } else { - // Neither set: fall back to default init - (SBIN_INIT.to_string(), vec![]) + // Neither set: fall back to /bin/sh (universal across all Linux distros) + ("/bin/sh".to_string(), vec!["-c".to_string(), "echo No command specified; exec /bin/sh".to_string()]) } } @@ -375,40 +375,105 @@ impl VmManager { None } - /// Parse a volume mount string into an FsMount. + /// Parse a volume mount string into a FsMount. /// /// Supported formats: /// - `host_path:guest_path` (read-write) /// - `host_path:guest_path:ro` (read-only) /// - `host_path:guest_path:rw` (read-write, explicit) + /// + /// Handles Windows paths with drive letters (e.g. `C:\Users\Temp:/data:ro`) by + /// using the colon-split parts array to reliably determine the host/guest boundary. fn parse_volume_mount(volume: &str, index: usize) -> Result { let parts: Vec<&str> = volume.split(':').collect(); - let (host_path_str, _guest_path, read_only) = match parts.len() { - 2 => (parts[0], parts[1], false), - 3 => { - let ro = match parts[2] { - "ro" => true, - "rw" => false, - other => { - return Err(BoxError::ConfigError(format!( - "Invalid volume mode '{}' (expected 'ro' or 'rw'): {}", - other, volume - ))); - } - }; - (parts[0], parts[1], ro) - } - _ => { + // A valid volume must have at least 2 colon-separated parts (host:guest) + if parts.len() < 2 { + return Err(BoxError::ConfigError(format!( + "Invalid volume format (expected host:guest[:ro|rw]): {}", + volume + ))); + } + + // Detect whether a mode suffix is present by checking if the LAST + // colon-separated segment is "ro" or "rw". + let last = parts.last(); + let has_mode = last.map_or(false, |s| s == &"ro" || s == &"rw"); + + // If the last segment is NOT a valid mode (ro/rw), check if it looks like + // a path component. If it does NOT, it's an invalid mode suffix (e.g. :invalid). + // Path-like segments start with /, \, ./, ../, or (on Windows) a drive letter. + let looks_like_path = |s: &&str| -> bool { + s.starts_with('/') + || s.starts_with('\\') + || s.starts_with("./") + || s.starts_with("../") + || (s.len() == 2 && s.chars().next().map_or(false, |c| c.is_alphabetic()) && s.ends_with(':')) + }; + if parts.len() >= 2 && !has_mode && !last.map_or(false, looks_like_path) { + return Err(BoxError::ConfigError(format!( + "Invalid volume mode '{}' (expected 'ro' or 'rw'): {}", + last.unwrap(), + volume + ))); + } + + // Determine the guest path and mode based on whether a mode suffix exists. + // With mode: guest = parts[parts.len() - 2], mode = parts[parts.len() - 1] + // Without mode: guest = parts[parts.len() - 1] + let (guest_path_str, mode_str) = if has_mode { + let guest = parts[parts.len() - 2]; + let mode = parts[parts.len() - 1]; + (guest, mode) + } else { + (parts[parts.len() - 1], "") + }; + + // Validate guest path is not empty or a mode keyword + if guest_path_str.is_empty() + || guest_path_str == "ro" + || guest_path_str == "rw" + { + return Err(BoxError::ConfigError(format!( + "Invalid volume format (expected host:guest[:ro|rw]): {}", + volume + ))); + } + + // Determine read_only from mode string + let read_only = match mode_str { + "ro" => true, + "rw" => false, + other if !other.is_empty() => { return Err(BoxError::ConfigError(format!( - "Invalid volume format (expected host:guest[:ro|rw]): {}", - volume + "Invalid volume mode '{}' (expected 'ro' or 'rw'): {}", + other, volume ))); } + _ => false, + }; + + // Reconstruct the host path from parts: + // - If parts[0] is a single-letter (Windows drive letter), reconstruct the + // Windows path by joining parts[0..guest_idx] with colons. + // - Otherwise (Unix), join parts[0..guest_idx] with colons. + let guest_idx = if has_mode { parts.len() - 2 } else { parts.len() - 1 }; + let host_path_str = if parts[0].len() == 1 { + // Windows drive letter — parts[0] is "C", parts[1..] is the rest of the path + let host_parts = &parts[..guest_idx]; + let reconstructed = host_parts.join(":"); + // If the reconstructed path ends with a trailing colon (from a trailing + // backslash in the original Windows path like "C:\path\:"), strip it. + reconstructed + .strip_suffix(':') + .map(|s| s.to_string()) + .unwrap_or_else(|| reconstructed) + } else { + parts[..guest_idx].join(":") }; // Resolve and validate host path - let host_path = PathBuf::from(host_path_str); + let host_path = PathBuf::from(&host_path_str); if !host_path.exists() { std::fs::create_dir_all(&host_path).map_err(|e| BoxError::BoxBootError { message: format!( @@ -436,7 +501,7 @@ impl VmManager { tracing::info!( tag = %tag, host = %host_path.display(), - guest = _guest_path, + guest = guest_path_str, read_only, "Adding user volume mount" ); @@ -612,7 +677,7 @@ mod tests { }; let (exec, _args) = VmManager::resolve_oci_entrypoint(&config, &[], None); - assert_eq!(exec, "/sbin/init"); + assert_eq!(exec, "/bin/sh"); } #[test]