diff --git a/Cargo.lock b/Cargo.lock index fa0c5edaf..3ac30849c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4175,6 +4175,7 @@ dependencies = [ "microsandbox-vsock", "msb_krun", "nix 0.31.3", + "oci-spec 0.10.0", "rand 0.10.2", "rustls", "sea-orm", diff --git a/crates/agentd/lib/init.rs b/crates/agentd/lib/init.rs index dd7f3882a..ce06162fd 100644 --- a/crates/agentd/lib/init.rs +++ b/crates/agentd/lib/init.rs @@ -204,8 +204,9 @@ mod linux { "/dev/pts", Some("devpts"), noexec_nosuid, - None::<&str>, + Some("ptmxmode=0666,mode=0620"), )?; + ensure_dev_ptmx()?; // /dev/shm — tmpfs mkdir_ignore_exists("/dev/shm")?; @@ -244,6 +245,16 @@ mod linux { mount_ignore_busy(Some("sysfs"), "/sys", Some("sysfs"), flags, None::<&str>) } + fn ensure_dev_ptmx() -> AgentdResult<()> { + let ptmx = Path::new("/dev/ptmx"); + if fs::symlink_metadata(ptmx).is_ok() { + return Ok(()); + } + + unix_fs::symlink("pts/ptmx", ptmx) + .map_err(|e| AgentdError::Init(format!("failed to symlink /dev/ptmx: {e}"))) + } + /// Mounts the virtiofs runtime filesystem at the canonical mount point. pub fn mount_runtime() -> AgentdResult<()> { mkdir_ignore_exists(microsandbox_protocol::RUNTIME_MOUNT_POINT)?; diff --git a/crates/agentd/lib/process.rs b/crates/agentd/lib/process.rs index a9a99508c..07c98455c 100644 --- a/crates/agentd/lib/process.rs +++ b/crates/agentd/lib/process.rs @@ -531,6 +531,8 @@ fn signal_process_group_only(pid: i32, signum: i32) -> AgentdResult<()> { fn exit_code(status: i32) -> i32 { if libc::WIFEXITED(status) { libc::WEXITSTATUS(status) + } else if libc::WIFSIGNALED(status) { + 128 + libc::WTERMSIG(status) } else { -1 } @@ -554,6 +556,13 @@ mod tests { const HELPER_SENTINEL: &str = "process-manager-helper-passed"; const TEST_NAME: &str = "process::tests::reaping_is_batched_and_tracks_exit_codes"; + #[test] + fn maps_normal_and_signal_wait_statuses_to_shell_exit_codes() { + assert_eq!(exit_code(42 << 8), 42); + assert_eq!(exit_code(libc::SIGTERM), 128 + libc::SIGTERM); + assert_eq!(exit_code(libc::SIGKILL), 128 + libc::SIGKILL); + } + #[test] fn reaping_is_batched_and_tracks_exit_codes() { if std::env::var_os(HELPER_ENV).is_some() { diff --git a/crates/agentd/lib/session.rs b/crates/agentd/lib/session.rs index 869b70185..a97bbab0f 100644 --- a/crates/agentd/lib/session.rs +++ b/crates/agentd/lib/session.rs @@ -620,9 +620,9 @@ impl ExecSession { if libc::setsid() < 0 { return Err(std::io::Error::last_os_error()); } - apply_exec_security_profile(security_profile).map_err(agentd_to_io_error)?; + apply_exec_security_profile(security_profile).map_err(agentd_to_pre_exec_error)?; if let Some(ref user) = resolved_user { - apply_resolved_user(user).map_err(agentd_to_io_error)?; + apply_resolved_user(user).map_err(agentd_to_pre_exec_error)?; } for (resource, limit) in &parsed_rlimits { if libc::setrlimit(*resource as _, limit) != 0 { @@ -996,6 +996,9 @@ fn lookup_passwd_by_uid(uid: libc::uid_t) -> AgentdResult { ) }; if rc != 0 { + if passwd_lookup_errno_means_missing(rc) { + return Ok(ResolvedUserLookup::Numeric(uid)); + } return Err(AgentdError::ExecSession(format!( "failed to resolve guest uid {uid}: {}", std::io::Error::from_raw_os_error(rc) @@ -1054,6 +1057,10 @@ fn lookup_buffer_len() -> usize { if size > 0 { size as usize } else { 16 * 1024 } } +fn passwd_lookup_errno_means_missing(errno: libc::c_int) -> bool { + matches!(errno, libc::ENOENT | libc::ESRCH) +} + fn apply_resolved_user(user: &ResolvedUser) -> AgentdResult<()> { if let Some(ref name) = user.initgroups_user { if unsafe { libc::initgroups(name.as_ptr(), user.gid) } != 0 { @@ -1097,8 +1104,12 @@ fn env_contains_key(env: &[String], key: &str) -> bool { }) } -fn agentd_to_io_error(err: AgentdError) -> std::io::Error { - std::io::Error::other(err.to_string()) +fn agentd_to_pre_exec_error(err: AgentdError) -> std::io::Error { + match err { + AgentdError::Io(err) => err, + AgentdError::Nix(err) => std::io::Error::from_raw_os_error(err as i32), + _ => std::io::Error::from_raw_os_error(libc::EINVAL), + } } /// Writes data to a raw fd using a blocking task, handling short writes. diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index cb11bf477..50a16395b 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -33,6 +33,7 @@ microsandbox-utils.workspace = true microsandbox-vsock.workspace = true msb_krun = { workspace = true, features = ["blk"] } nix = { workspace = true, features = ["process", "signal"] } +oci-spec.workspace = true rand.workspace = true rustls = { workspace = true } sea-orm.workspace = true diff --git a/crates/runtime/lib/lib.rs b/crates/runtime/lib/lib.rs index 3d90b0f16..02a75824d 100644 --- a/crates/runtime/lib/lib.rs +++ b/crates/runtime/lib/lib.rs @@ -24,6 +24,7 @@ pub mod launch; pub mod logging; pub mod maintenance; pub mod metrics; +pub mod oci; pub mod policy; pub mod relay; mod startup; diff --git a/crates/runtime/lib/oci/bundle.rs b/crates/runtime/lib/oci/bundle.rs new file mode 100644 index 000000000..88f4c952d --- /dev/null +++ b/crates/runtime/lib/oci/bundle.rs @@ -0,0 +1,220 @@ +//! OCI bundle parsing used by Microsandbox runtime integration. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use oci_spec::runtime::{Mount, Process, Spec}; + +use super::{OciResult, OciRuntimeError, io_error}; + +//-------------------------------------------------------------------------------------------------- +// Constants +//-------------------------------------------------------------------------------------------------- + +const CONFIG_JSON: &str = "config.json"; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// OCI bundle loaded from a host directory. +#[derive(Debug, Clone)] +pub struct OciBundle { + /// Absolute path to the bundle directory. + pub path: PathBuf, + + /// Parsed OCI `config.json`. + pub spec: OciSpec, +} + +/// OCI runtime specification parsed from `config.json`. +pub type OciSpec = Spec; + +/// OCI process descriptor parsed from `config.json` or `process.json`. +pub type OciProcess = Process; + +/// OCI mount descriptor parsed from `config.json`. +pub type OciMount = Mount; + +//-------------------------------------------------------------------------------------------------- +// Methods +//-------------------------------------------------------------------------------------------------- + +impl OciBundle { + /// Load and validate an OCI bundle from a directory. + pub fn load(path: impl AsRef) -> OciResult { + let path = absolutize_existing(path.as_ref())?; + let config_path = path.join(CONFIG_JSON); + let spec = OciSpec::load(&config_path).map_err(|e| OciRuntimeError::InvalidBundle { + bundle: path.clone(), + reason: format!( + "failed to load `{}` with oci-spec: {e}", + config_path.display() + ), + })?; + + let bundle = Self { path, spec }; + bundle.validate()?; + Ok(bundle) + } + + /// Resolve the OCI rootfs path to an absolute host path. + pub fn rootfs_path(&self) -> PathBuf { + let root = self + .spec + .root() + .as_ref() + .expect("validated OCI bundle must have a root filesystem"); + if root.path().is_absolute() { + root.path().clone() + } else { + self.path.join(root.path()) + } + } + + /// Return the OCI process configured for `start`, if present. + pub fn process(&self) -> Option<&OciProcess> { + self.spec.process().as_ref() + } + + /// Return additional OCI mounts. + pub fn mounts(&self) -> &[OciMount] { + self.spec.mounts().as_deref().unwrap_or_default() + } + + /// Return annotations as the deterministic map used by persisted OCI state. + pub fn annotations(&self) -> BTreeMap { + self.spec + .annotations() + .as_ref() + .map(|annotations| { + annotations + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + }) + .unwrap_or_default() + } + + /// Validate the bundle shape needed by Microsandbox's OCI layer. + pub fn validate(&self) -> OciResult<()> { + if self.spec.version().trim().is_empty() { + return Err(OciRuntimeError::InvalidBundle { + bundle: self.path.clone(), + reason: "ociVersion must not be empty".to_string(), + }); + } + if self.spec.root().is_none() { + return Err(OciRuntimeError::InvalidBundle { + bundle: self.path.clone(), + reason: "root must be present".to_string(), + }); + } + if let Some(process) = self.process() { + validate_process(process, &self.path)?; + } + let rootfs = self.rootfs_path(); + if !rootfs.is_dir() { + return Err(OciRuntimeError::InvalidBundle { + bundle: self.path.clone(), + reason: format!("rootfs `{}` is not a directory", rootfs.display()), + }); + } + Ok(()) + } +} + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +/// Validate process fields needed by Microsandbox's OCI execution layer. +pub fn validate_process(process: &OciProcess, bundle: &Path) -> OciResult<()> { + if !process.cwd().is_absolute() { + return Err(OciRuntimeError::InvalidBundle { + bundle: bundle.to_path_buf(), + reason: format!("process.cwd must be absolute: {}", process.cwd().display()), + }); + } + if process.args().as_deref().unwrap_or_default().is_empty() { + return Err(OciRuntimeError::InvalidBundle { + bundle: bundle.to_path_buf(), + reason: "process.args must contain at least one entry".to_string(), + }); + } + Ok(()) +} + +fn absolutize_existing(path: &Path) -> OciResult { + std::fs::canonicalize(path).map_err(|e| io_error("canonicalize", path, e)) +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn load_resolves_relative_rootfs_against_bundle() { + let temp = TempDir::new().expect("tempdir"); + let rootfs = temp.path().join("rootfs"); + fs::create_dir(&rootfs).expect("rootfs"); + fs::write( + temp.path().join(CONFIG_JSON), + r#"{ + "ociVersion": "1.2.0", + "root": { "path": "rootfs" }, + "process": { + "user": { "uid": 0, "gid": 0 }, + "cwd": "/", + "args": ["/bin/sh"], + "env": ["PATH=/bin"] + }, + "annotations": { + "io.containerd.runtime.v2.task": "default/demo" + } + }"#, + ) + .expect("config"); + + let bundle = OciBundle::load(temp.path()).expect("load bundle"); + let rootfs = fs::canonicalize(rootfs).expect("canonical rootfs"); + + assert_eq!(bundle.rootfs_path(), rootfs); + assert_eq!(bundle.spec.version(), "1.2.0"); + assert_eq!( + bundle.annotations()["io.containerd.runtime.v2.task"], + "default/demo" + ); + } + + #[test] + fn load_rejects_relative_process_cwd() { + let temp = TempDir::new().expect("tempdir"); + fs::create_dir(temp.path().join("rootfs")).expect("rootfs"); + fs::write( + temp.path().join(CONFIG_JSON), + r#"{ + "ociVersion": "1.2.0", + "root": { "path": "rootfs" }, + "process": { + "user": { "uid": 0, "gid": 0 }, + "cwd": "app", + "args": ["/bin/sh"] + } + }"#, + ) + .expect("config"); + + let err = OciBundle::load(temp.path()).expect_err("invalid cwd"); + + assert!(err.to_string().contains("process.cwd must be absolute")); + } +} diff --git a/crates/runtime/lib/oci/error.rs b/crates/runtime/lib/oci/error.rs new file mode 100644 index 000000000..a92cf6f29 --- /dev/null +++ b/crates/runtime/lib/oci/error.rs @@ -0,0 +1,121 @@ +//! Error types for OCI runtime compatibility. + +use std::path::PathBuf; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Result type used by OCI compatibility components. +pub type OciResult = Result; + +/// Errors returned by OCI compatibility components. +#[derive(Debug, thiserror::Error)] +pub enum OciRuntimeError { + /// The supplied container ID is empty or contains path separators. + #[error("invalid container ID `{id}`")] + InvalidContainerId { + /// Invalid container ID. + id: String, + }, + + /// The OCI bundle does not contain a valid `config.json`. + #[error("invalid OCI bundle `{bundle}`: {reason}")] + InvalidBundle { + /// Bundle path. + bundle: PathBuf, + + /// Human-readable validation failure. + reason: String, + }, + + /// The requested container state was not found. + #[error("container `{id}` does not exist")] + NotFound { + /// Container ID. + id: String, + }, + + /// The requested container ID is already in use. + #[error("container `{id}` already exists")] + AlreadyExists { + /// Container ID. + id: String, + }, + + /// The requested operation is not valid for the current OCI status. + #[error("cannot {operation} container `{id}` while it is {status}")] + InvalidTransition { + /// Container ID. + id: String, + + /// Requested operation. + operation: &'static str, + + /// Current status. + status: String, + }, + + /// A required OCI process was not provided. + #[error("container `{id}` has no OCI process to start")] + MissingProcess { + /// Container ID. + id: String, + }, + + /// A filesystem operation failed. + #[error("{operation} `{path}`: {source}")] + Io { + /// Operation that failed. + operation: &'static str, + + /// Path involved in the failure. + path: PathBuf, + + /// Source I/O error. + #[source] + source: std::io::Error, + }, + + /// JSON serialization or parsing failed. + #[error("{operation} JSON `{path}`: {source}")] + Json { + /// Operation that failed. + operation: &'static str, + + /// Path involved in the failure. + path: PathBuf, + + /// Source JSON error. + #[source] + source: serde_json::Error, + }, +} + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +pub(crate) fn io_error( + operation: &'static str, + path: impl Into, + source: std::io::Error, +) -> OciRuntimeError { + OciRuntimeError::Io { + operation, + path: path.into(), + source, + } +} + +pub(crate) fn json_error( + operation: &'static str, + path: impl Into, + source: serde_json::Error, +) -> OciRuntimeError { + OciRuntimeError::Json { + operation, + path: path.into(), + source, + } +} diff --git a/crates/runtime/lib/oci/lifecycle.rs b/crates/runtime/lib/oci/lifecycle.rs new file mode 100644 index 000000000..e291dfdc8 --- /dev/null +++ b/crates/runtime/lib/oci/lifecycle.rs @@ -0,0 +1,194 @@ +//! OCI lifecycle command validation. + +use super::{OciResult, OciRuntimeError, OciState, OciStatus}; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// OCI lifecycle operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OciOperation { + /// Create the container environment. + Create, + + /// Start the configured init process. + Start, + + /// Execute an additional process in the running container. + Exec, + + /// Send a signal to the container init process. + Kill, + + /// Delete resources created by `create`. + Delete, + + /// Return current container state. + State, + + /// Suspend the container. + Pause, + + /// Resume the container. + Resume, +} + +/// State transition requested by an OCI operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OciTransition { + /// Operation being performed. + pub operation: OciOperation, + + /// Status before the operation. + pub from: OciStatus, + + /// Status after the operation. + pub to: OciStatus, +} + +//-------------------------------------------------------------------------------------------------- +// Methods +//-------------------------------------------------------------------------------------------------- + +impl OciOperation { + /// Operation name used in diagnostics. + pub fn as_str(self) -> &'static str { + match self { + Self::Create => "create", + Self::Start => "start", + Self::Exec => "exec", + Self::Kill => "kill", + Self::Delete => "delete", + Self::State => "state", + Self::Pause => "pause", + Self::Resume => "resume", + } + } + + /// Validate that this operation can run against the supplied state. + pub fn validate(self, state: &OciState) -> OciResult<()> { + let valid = match self { + Self::Create => false, + Self::Start => matches!(state.status, OciStatus::Created), + Self::Exec => matches!(state.status, OciStatus::Running), + Self::Kill => state.status.can_receive_signal(), + Self::Delete => matches!(state.status, OciStatus::Stopped), + Self::State => true, + Self::Pause => matches!(state.status, OciStatus::Running), + Self::Resume => matches!(state.status, OciStatus::Paused), + }; + + if valid { + Ok(()) + } else { + Err(OciRuntimeError::InvalidTransition { + id: state.id.clone(), + operation: self.as_str(), + status: state.status.as_str().to_string(), + }) + } + } +} + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +/// Validate and compute the next state for an OCI operation that has a direct status transition. +pub fn next_status(operation: OciOperation, state: &OciState) -> OciResult { + operation.validate(state)?; + let next = match operation { + OciOperation::Start => OciStatus::Running, + OciOperation::Kill => state.status, + OciOperation::Pause => OciStatus::Paused, + OciOperation::Resume => OciStatus::Running, + OciOperation::State | OciOperation::Exec => state.status, + OciOperation::Delete => state.status, + OciOperation::Create => OciStatus::Created, + }; + Ok(next) +} + +/// Validate and return a transition descriptor for a direct status-changing operation. +pub fn transition(operation: OciOperation, state: &OciState) -> OciResult { + Ok(OciTransition { + operation, + from: state.status, + to: next_status(operation, state)?, + }) +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use chrono::Utc; + + use super::super::MicrosandboxState; + use super::*; + + fn state(status: OciStatus) -> OciState { + let mut state = OciState::created( + "demo", + "1.2.0", + "/bundle", + BTreeMap::new(), + MicrosandboxState::new("oci-demo", "/state/demo", "/bundle/rootfs", Utc::now()), + ); + state.status = status; + state + } + + #[test] + fn start_only_accepts_created() { + assert_eq!( + next_status(OciOperation::Start, &state(OciStatus::Created)).expect("start"), + OciStatus::Running + ); + assert!(next_status(OciOperation::Start, &state(OciStatus::Running)).is_err()); + assert!(next_status(OciOperation::Start, &state(OciStatus::Stopped)).is_err()); + } + + #[test] + fn docker_exec_requires_running_container() { + assert_eq!( + next_status(OciOperation::Exec, &state(OciStatus::Running)).expect("exec"), + OciStatus::Running + ); + assert!(next_status(OciOperation::Exec, &state(OciStatus::Created)).is_err()); + assert!(next_status(OciOperation::Exec, &state(OciStatus::Paused)).is_err()); + } + + #[test] + fn pause_resume_cycle_matches_de_facto_runtime_behavior() { + assert_eq!( + next_status(OciOperation::Pause, &state(OciStatus::Running)).expect("pause"), + OciStatus::Paused + ); + assert_eq!( + next_status(OciOperation::Resume, &state(OciStatus::Paused)).expect("resume"), + OciStatus::Running + ); + assert!(next_status(OciOperation::Pause, &state(OciStatus::Created)).is_err()); + } + + #[test] + fn delete_only_accepts_stopped() { + assert!(next_status(OciOperation::Delete, &state(OciStatus::Stopped)).is_ok()); + assert!(next_status(OciOperation::Delete, &state(OciStatus::Running)).is_err()); + } + + #[test] + fn kill_validates_signalable_state_without_stopping_container() { + assert_eq!( + next_status(OciOperation::Kill, &state(OciStatus::Running)).expect("kill"), + OciStatus::Running + ); + assert!(next_status(OciOperation::Kill, &state(OciStatus::Stopped)).is_err()); + } +} diff --git a/crates/runtime/lib/oci/mod.rs b/crates/runtime/lib/oci/mod.rs new file mode 100644 index 000000000..63bb1129a --- /dev/null +++ b/crates/runtime/lib/oci/mod.rs @@ -0,0 +1,23 @@ +//! OCI Runtime Specification compatibility primitives. +//! +//! This module contains the host-side contracts that a future +//! `runmsb` OCI binary and a containerd shim can share. It does +//! not launch VMs directly; instead it defines the durable state model, +//! bundle parsing, and lifecycle validation used to map OCI commands onto +//! Microsandbox's existing microVM runtime. + +mod bundle; +mod error; +mod lifecycle; +mod state; +mod store; + +//-------------------------------------------------------------------------------------------------- +// Exports +//-------------------------------------------------------------------------------------------------- + +pub use bundle::*; +pub use error::*; +pub use lifecycle::*; +pub use state::*; +pub use store::*; diff --git a/crates/runtime/lib/oci/state.rs b/crates/runtime/lib/oci/state.rs new file mode 100644 index 000000000..5a572d3d9 --- /dev/null +++ b/crates/runtime/lib/oci/state.rs @@ -0,0 +1,275 @@ +//! OCI state model persisted by the runtime layer. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// OCI runtime lifecycle status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum OciStatus { + /// The runtime is creating the container environment. + Creating, + + /// The container environment exists, but the configured process has not run. + Created, + + /// The configured process has started and has not exited. + Running, + + /// The container process has exited. + Stopped, + + /// The VM or container process group is suspended. + /// + /// `paused` is a de-facto runtime status used by Docker/runc-style CLIs, + /// although the core OCI spec only standardizes creating/created/running/stopped. + Paused, +} + +/// OCI state returned by the `state` command. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OciState { + /// OCI Runtime Specification version represented by this state. + pub oci_version: String, + + /// Host-unique container ID supplied by Docker/containerd. + pub id: String, + + /// Current OCI lifecycle status. + pub status: OciStatus, + + /// Host PID of the Microsandbox VMM/sandbox process. + #[serde(skip_serializing_if = "Option::is_none")] + pub pid: Option, + + /// Absolute path to the OCI bundle. + pub bundle: PathBuf, + + /// OCI annotations copied from `config.json`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub annotations: BTreeMap, + + /// Microsandbox-specific state extensions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub microsandbox: Option, +} + +/// Microsandbox-specific extension fields persisted beside OCI state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MicrosandboxState { + /// Durable Microsandbox sandbox name derived from the OCI container ID. + pub sandbox_name: String, + + /// Path to this container's private OCI state directory. + pub state_dir: PathBuf, + + /// Rootfs path resolved from the OCI bundle. + pub rootfs: PathBuf, + + /// Guest PID reported for the OCI init process, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub guest_pid: Option, + + /// Agent protocol exec session ID for the OCI init process, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub init_exec_session_id: Option, + + /// Exit code reported for the OCI init process, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + + /// Time at which create started. + pub created_at: DateTime, + + /// Time at which the init process started. + #[serde(skip_serializing_if = "Option::is_none")] + pub started_at: Option>, + + /// Time at which the init process exited or the VM stopped. + #[serde(skip_serializing_if = "Option::is_none")] + pub stopped_at: Option>, +} + +//-------------------------------------------------------------------------------------------------- +// Methods +//-------------------------------------------------------------------------------------------------- + +impl OciStatus { + /// Return the status string used by OCI JSON and diagnostics. + pub fn as_str(self) -> &'static str { + match self { + Self::Creating => "creating", + Self::Created => "created", + Self::Running => "running", + Self::Stopped => "stopped", + Self::Paused => "paused", + } + } + + /// Whether OCI permits signals to be sent in this state. + pub fn can_receive_signal(self) -> bool { + matches!(self, Self::Created | Self::Running | Self::Paused) + } + + /// Whether the state is terminal from Docker/containerd's perspective. + pub fn is_terminal(self) -> bool { + matches!(self, Self::Stopped) + } +} + +impl OciState { + /// Construct new persisted state for a just-created OCI environment. + pub fn created( + id: impl Into, + oci_version: impl Into, + bundle: impl Into, + annotations: BTreeMap, + microsandbox: MicrosandboxState, + ) -> Self { + Self { + oci_version: oci_version.into(), + id: id.into(), + status: OciStatus::Created, + pid: None, + bundle: bundle.into(), + annotations, + microsandbox: Some(microsandbox), + } + } + + /// Mark the container init process as running. + pub fn mark_running( + &mut self, + host_pid: i32, + guest_pid: Option, + init_exec_session_id: Option, + now: DateTime, + ) { + self.status = OciStatus::Running; + self.pid = Some(host_pid); + if let Some(msb) = self.microsandbox.as_mut() { + msb.guest_pid = guest_pid; + msb.init_exec_session_id = init_exec_session_id; + msb.started_at = Some(now); + } + } + + /// Mark the container as stopped. + pub fn mark_stopped(&mut self, exit_code: Option, now: DateTime) { + self.status = OciStatus::Stopped; + if let Some(msb) = self.microsandbox.as_mut() { + msb.exit_code = exit_code; + msb.stopped_at = Some(now); + } + } +} + +impl MicrosandboxState { + /// Construct Microsandbox extension state for a newly created OCI container. + pub fn new( + sandbox_name: impl Into, + state_dir: impl Into, + rootfs: impl Into, + now: DateTime, + ) -> Self { + Self { + sandbox_name: sandbox_name.into(), + state_dir: state_dir.into(), + rootfs: rootfs.into(), + guest_pid: None, + init_exec_session_id: None, + exit_code: None, + created_at: now, + started_at: None, + stopped_at: None, + } + } +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::path::PathBuf; + + use chrono::TimeZone; + + use super::*; + + #[test] + fn serializes_oci_state_with_camel_case_fields() { + let now = Utc + .with_ymd_and_hms(2026, 6, 25, 12, 0, 0) + .single() + .expect("valid test time"); + let state = OciState::created( + "abc", + "1.2.0", + "/bundle", + BTreeMap::from([("com.example".to_string(), "yes".to_string())]), + MicrosandboxState::new("oci-abc", "/state/abc", "/bundle/rootfs", now), + ); + + let json = serde_json::to_value(&state).expect("serialize state"); + + assert_eq!(json["ociVersion"], "1.2.0"); + assert_eq!(json["id"], "abc"); + assert_eq!(json["status"], "created"); + assert_eq!(json["bundle"], "/bundle"); + assert_eq!(json["annotations"]["com.example"], "yes"); + assert_eq!(json["microsandbox"]["sandboxName"], "oci-abc"); + assert_eq!(json["microsandbox"]["stateDir"], "/state/abc"); + assert_eq!(json["microsandbox"]["rootfs"], "/bundle/rootfs"); + } + + #[test] + fn mark_running_and_stopped_updates_runtime_fields() { + let now = Utc + .with_ymd_and_hms(2026, 6, 25, 12, 0, 0) + .single() + .expect("valid test time"); + let mut state = OciState::created( + "abc", + "1.2.0", + PathBuf::from("/bundle"), + BTreeMap::new(), + MicrosandboxState::new("oci-abc", "/state/abc", "/bundle/rootfs", now), + ); + + state.mark_running(42, Some(7), Some(99), now); + assert_eq!(state.status, OciStatus::Running); + assert_eq!(state.pid, Some(42)); + assert_eq!( + state.microsandbox.as_ref().and_then(|msb| msb.guest_pid), + Some(7) + ); + assert_eq!( + state + .microsandbox + .as_ref() + .and_then(|msb| msb.init_exec_session_id), + Some(99) + ); + let json = serde_json::to_value(&state).expect("state JSON"); + assert_eq!(json["microsandbox"]["initExecSessionId"], 99); + + state.mark_stopped(Some(0), now); + assert_eq!(state.status, OciStatus::Stopped); + assert_eq!( + state.microsandbox.as_ref().and_then(|msb| msb.exit_code), + Some(0) + ); + } +} diff --git a/crates/runtime/lib/oci/store.rs b/crates/runtime/lib/oci/store.rs new file mode 100644 index 000000000..3775998e6 --- /dev/null +++ b/crates/runtime/lib/oci/store.rs @@ -0,0 +1,340 @@ +//! Durable OCI state storage. + +use std::fs::{self, File, OpenOptions}; +use std::io::{ErrorKind, Write}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use chrono::Utc; + +use super::{ + MicrosandboxState, OciBundle, OciResult, OciRuntimeError, OciState, io_error, json_error, +}; + +//-------------------------------------------------------------------------------------------------- +// Constants +//-------------------------------------------------------------------------------------------------- + +const STATE_JSON: &str = "state.json"; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Filesystem-backed OCI state store. +/// +/// The root directory is normally supplied by the OCI runtime CLI's `--root` +/// option. Each container ID owns one subdirectory containing `state.json` +/// and Microsandbox runtime metadata. +#[derive(Debug, Clone)] +pub struct OciStateStore { + root: PathBuf, +} + +//-------------------------------------------------------------------------------------------------- +// Methods +//-------------------------------------------------------------------------------------------------- + +impl OciStateStore { + /// Create a state store rooted at the supplied directory. + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + /// Return the state store root. + pub fn root(&self) -> &Path { + &self.root + } + + /// Return the private state directory for a container ID. + pub fn container_dir(&self, id: &str) -> OciResult { + validate_container_id(id)?; + Ok(self.root.join(id)) + } + + /// Return the `state.json` path for a container ID. + pub fn state_path(&self, id: &str) -> OciResult { + Ok(self.container_dir(id)?.join(STATE_JSON)) + } + + /// Create initial `created` state for a container. + pub fn create_created(&self, id: &str, bundle: &OciBundle) -> OciResult { + let state_dir = self.container_dir(id)?; + fs::create_dir_all(&self.root).map_err(|e| io_error("create directory", &self.root, e))?; + match fs::create_dir(&state_dir) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::AlreadyExists => { + return Err(OciRuntimeError::AlreadyExists { id: id.to_string() }); + } + Err(error) => return Err(io_error("create directory", &state_dir, error)), + } + + let microsandbox = MicrosandboxState::new( + sandbox_name_for_container(id), + &state_dir, + bundle.rootfs_path(), + Utc::now(), + ); + let state = OciState::created( + id, + bundle.spec.version().clone(), + bundle.path.clone(), + bundle.annotations(), + microsandbox, + ); + self.save(&state)?; + Ok(state) + } + + /// Load the current state for a container. + pub fn load(&self, id: &str) -> OciResult { + let path = self.state_path(id)?; + if !path.exists() { + return Err(OciRuntimeError::NotFound { id: id.to_string() }); + } + let data = fs::read_to_string(&path).map_err(|e| io_error("read", &path, e))?; + serde_json::from_str(&data).map_err(|e| json_error("parse", &path, e)) + } + + /// Atomically save state for a container. + pub fn save(&self, state: &OciState) -> OciResult<()> { + validate_container_id(&state.id)?; + let dir = self.container_dir(&state.id)?; + if !dir.is_dir() { + return Err(OciRuntimeError::NotFound { + id: state.id.clone(), + }); + } + + let path = dir.join(STATE_JSON); + let json = + serde_json::to_vec_pretty(state).map_err(|e| json_error("serialize", &path, e))?; + let (mut tmp_file, tmp_path) = create_state_temp_file(&dir)?; + tmp_file + .write_all(&json) + .map_err(|e| io_error("write", &tmp_path, e))?; + tmp_file + .sync_all() + .map_err(|e| io_error("sync", &tmp_path, e))?; + drop(tmp_file); + + if let Err(error) = fs::rename(&tmp_path, &path) { + let _ = fs::remove_file(&tmp_path); + return Err(io_error("rename", &path, error)); + } + sync_directory(&dir)?; + Ok(()) + } + + /// Delete all state for a stopped container. + pub fn delete(&self, id: &str) -> OciResult<()> { + let dir = self.container_dir(id)?; + if !dir.exists() { + return Err(OciRuntimeError::NotFound { id: id.to_string() }); + } + fs::remove_dir_all(&dir).map_err(|e| io_error("remove directory", &dir, e)) + } +} + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +/// Validate a container ID for safe use as a state-directory name. +pub fn validate_container_id(id: &str) -> OciResult<()> { + let valid = !id.is_empty() + && id != "." + && id != ".." + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'+' | b'-')); + + if valid { + Ok(()) + } else { + Err(OciRuntimeError::InvalidContainerId { id: id.to_string() }) + } +} + +/// Return the Microsandbox sandbox name derived from an OCI container ID. +pub fn sandbox_name_for_container(id: &str) -> String { + format!("oci-{id}") +} + +fn create_state_temp_file(dir: &Path) -> OciResult<(File, PathBuf)> { + for attempt in 0..128 { + let tmp_path = dir.join(format!( + ".{STATE_JSON}.{}.{}.tmp", + std::process::id(), + unique_temp_suffix(attempt) + )); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp_path) + { + Ok(file) => return Ok((file, tmp_path)), + Err(error) if error.kind() == ErrorKind::AlreadyExists => continue, + Err(error) => return Err(io_error("create", &tmp_path, error)), + } + } + + let path = dir.join(format!(".{STATE_JSON}.tmp")); + Err(io_error( + "create", + &path, + std::io::Error::new( + ErrorKind::AlreadyExists, + "could not allocate unique state temp file", + ), + )) +} + +fn unique_temp_suffix(attempt: u32) -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() + .saturating_add(attempt as u128) +} + +#[cfg(unix)] +fn sync_directory(dir: &Path) -> OciResult<()> { + let directory = File::open(dir).map_err(|e| io_error("open", dir, e))?; + directory.sync_all().map_err(|e| io_error("sync", dir, e)) +} + +#[cfg(not(unix))] +fn sync_directory(_dir: &Path) -> OciResult<()> { + Ok(()) +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::super::OciStatus; + use super::*; + + fn bundle() -> (TempDir, OciBundle) { + let temp = TempDir::new().expect("tempdir"); + fs::create_dir(temp.path().join("rootfs")).expect("rootfs"); + fs::write( + temp.path().join("config.json"), + r#"{ + "ociVersion": "1.2.0", + "root": { "path": "rootfs" }, + "process": { + "user": { "uid": 0, "gid": 0 }, + "cwd": "/", + "args": ["/bin/sh"] + } + }"#, + ) + .expect("config"); + + let bundle = OciBundle::load(temp.path()).expect("bundle"); + (temp, bundle) + } + + #[test] + fn create_created_persists_loadable_state() { + let (_bundle_dir, bundle) = bundle(); + let state_root = TempDir::new().expect("state root"); + let store = OciStateStore::new(state_root.path()); + + let state = store + .create_created("abc123", &bundle) + .expect("create state"); + let loaded = store.load("abc123").expect("load state"); + + assert_eq!(loaded, state); + assert_eq!(loaded.status, OciStatus::Created); + assert_eq!( + loaded + .microsandbox + .as_ref() + .map(|msb| msb.sandbox_name.as_str()), + Some("oci-abc123") + ); + } + + #[test] + fn create_created_rejects_duplicate_ids() { + let (_bundle_dir, bundle) = bundle(); + let state_root = TempDir::new().expect("state root"); + let store = OciStateStore::new(state_root.path()); + + store + .create_created("abc123", &bundle) + .expect("first create"); + let err = store + .create_created("abc123", &bundle) + .expect_err("duplicate should fail"); + + assert!(matches!(err, OciRuntimeError::AlreadyExists { .. })); + } + + #[test] + fn container_id_must_not_escape_state_root() { + assert!(validate_container_id("abc123").is_ok()); + assert!(validate_container_id("abc_123-DEF.456+ghi").is_ok()); + assert!(validate_container_id("../abc").is_err()); + assert!(validate_container_id("a/b").is_err()); + assert!(validate_container_id("a\nb").is_err()); + assert!(validate_container_id("a b").is_err()); + assert!(validate_container_id("").is_err()); + } + + #[test] + fn delete_removes_container_state_directory() { + let (_bundle_dir, bundle) = bundle(); + let state_root = TempDir::new().expect("state root"); + let store = OciStateStore::new(state_root.path()); + store.create_created("abc123", &bundle).expect("create"); + + store.delete("abc123").expect("delete"); + + assert!(!state_root.path().join("abc123").exists()); + } + + #[test] + fn save_does_not_recreate_deleted_container_directory() { + let (_bundle_dir, bundle) = bundle(); + let state_root = TempDir::new().expect("state root"); + let store = OciStateStore::new(state_root.path()); + let state = store.create_created("abc123", &bundle).expect("create"); + fs::remove_dir_all(state_root.path().join("abc123")).expect("remove container dir"); + + let err = store + .save(&state) + .expect_err("save should not recreate dir"); + + assert!(matches!(err, OciRuntimeError::NotFound { .. })); + assert!(!state_root.path().join("abc123").exists()); + } + + #[test] + fn save_uses_unique_temp_files_without_leaving_shared_tmp() { + let (_bundle_dir, bundle) = bundle(); + let state_root = TempDir::new().expect("state root"); + let store = OciStateStore::new(state_root.path()); + let state = store.create_created("abc123", &bundle).expect("create"); + + store.save(&state).expect("save"); + + assert!( + !state_root + .path() + .join("abc123") + .join("state.json.tmp") + .exists() + ); + } +} diff --git a/docs/changelog/2026-08-14.mdx b/docs/changelog/2026-08-14.mdx new file mode 100644 index 000000000..dd0049a81 --- /dev/null +++ b/docs/changelog/2026-08-14.mdx @@ -0,0 +1,13 @@ +--- +title: "Week of August 14, 2026" +description: "Reliable non-interactive stdin handling and conventional OCI signal exit codes." +icon: "bolt" +--- + +## Behavior changes + +- **Null stdin now closes immediately.** Rust SDK executions configured with `StdinMode::Null` + send EOF to the guest process instead of leaving its stdin pipe open. Programs such as `cat` and + interpreters reading from standard input now finish instead of waiting indefinitely. +- **OCI signal exits use conventional status codes.** `runmsb` propagates signal termination through + the VMM using `128 + signal`, so Docker reports `143` for `SIGTERM` and `137` for `SIGKILL`. diff --git a/docs/docs.json b/docs/docs.json index b5fb5a4e8..28ef53155 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -390,6 +390,7 @@ "group": "August 2026", "expanded": true, "pages": [ + "changelog/2026-08-14", "changelog/2026-08-07" ] }, diff --git a/sdk/rust/lib/runtime/spawn.rs b/sdk/rust/lib/runtime/spawn.rs index 51556cd85..c659e0e33 100644 --- a/sdk/rust/lib/runtime/spawn.rs +++ b/sdk/rust/lib/runtime/spawn.rs @@ -8,6 +8,8 @@ #[cfg(windows)] use std::fmt::Write as _; #[cfg(unix)] +use std::fs::OpenOptions; +#[cfg(unix)] use std::os::fd::AsRawFd; #[cfg(unix)] use std::os::fd::{FromRawFd, OwnedFd}; @@ -22,7 +24,7 @@ use std::os::windows::io::AsRawHandle; use std::{ collections::{BTreeMap, BTreeSet, HashMap}, ffi::{OsStr, OsString}, - fs::File, + fs::{self, File}, io::{Seek, SeekFrom, Write as IoWrite}, path::{Path, PathBuf}, process::Stdio, @@ -588,12 +590,24 @@ pub async fn spawn_sandbox( } } + let startup_stderr_path = startup_pipe + .is_some() + .then(|| log_dir.join("startup.stderr.log")); + // Capture stdout for attached startup JSON. Detached mode uses a - // dedicated startup fd so stdio can be severed from the launcher. + // dedicated startup fd so stdout can be severed from the launcher; stderr + // is written to a small startup log so callers like Docker can surface + // pre-handoff failures. #[cfg(unix)] if startup_pipe.is_some() { cmd.stdout(Stdio::null()); - cmd.stderr(Stdio::null()); + let stderr_path = startup_stderr_path.as_ref().expect("path set above"); + let stderr = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(stderr_path)?; + cmd.stderr(Stdio::from(stderr)); } else { cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::inherit()); @@ -670,9 +684,14 @@ pub async fn spawn_sandbox( Err(_) => { terminate_startup_process(&mut child).await; release_metrics_reservation(config, metrics_reservation.as_ref()); - return Err(crate::MicrosandboxError::Runtime( - "sandbox startup timeout: no JSON received within 30 seconds".into(), - )); + let stderr = startup_stderr_excerpt(startup_stderr_path.as_deref()); + let message = match stderr { + Some(stderr) => format!( + "sandbox startup timeout: no JSON received within 30 seconds; stderr: {stderr}" + ), + None => "sandbox startup timeout: no JSON received within 30 seconds".into(), + }; + return Err(crate::MicrosandboxError::Runtime(message)); } }; @@ -686,10 +705,18 @@ pub async fn spawn_sandbox( exit_status = ?status, "spawn_sandbox: failed to parse startup JSON" ); - return Err(crate::MicrosandboxError::Runtime(format!( - "sandbox process exited ({status:?}) before sending startup info \ - (line: {line:?}, check stderr above for details)" - ))); + let stderr = startup_stderr_excerpt(startup_stderr_path.as_deref()); + let message = match stderr { + Some(stderr) => format!( + "sandbox process exited ({status:?}) before sending startup info \ + (line: {line:?}); stderr: {stderr}" + ), + None => format!( + "sandbox process exited ({status:?}) before sending startup info \ + (line: {line:?}, check stderr above for details)" + ), + }; + return Err(crate::MicrosandboxError::Runtime(message)); } }; if startup.pid != _pid { @@ -2114,6 +2141,20 @@ async fn terminate_startup_process( child.wait().await.ok() } +fn startup_stderr_excerpt(path: Option<&Path>) -> Option { + const MAX_STARTUP_STDERR_BYTES: usize = 8 * 1024; + + let path = path?; + let bytes = fs::read(path).ok()?; + let bytes = if bytes.len() > MAX_STARTUP_STDERR_BYTES { + &bytes[bytes.len() - MAX_STARTUP_STDERR_BYTES..] + } else { + &bytes + }; + let stderr = String::from_utf8_lossy(bytes).trim().to_string(); + (!stderr.is_empty()).then_some(stderr) +} + /// Scan `config.spec.mounts` for file bind mounts and stage each file in its own /// isolated directory inside an ephemeral [`TempDir`]. /// @@ -2839,6 +2880,7 @@ fn sandbox_log_level_cli_flag(level: SandboxLogLevel) -> &'static str { mod tests { use std::collections::HashMap; use std::ffi::{OsStr, OsString}; + use std::fs; #[cfg(target_os = "linux")] use std::num::NonZero; use std::path::{Path, PathBuf}; @@ -2926,6 +2968,20 @@ mod tests { } } + #[test] + fn test_startup_stderr_excerpt_returns_trimmed_tail() { + let temp = tempdir().unwrap(); + let path = temp.path().join("startup.stderr.log"); + let prefix = "x".repeat(9 * 1024); + fs::write(&path, format!("{prefix}real startup error\n")).unwrap(); + + let excerpt = super::startup_stderr_excerpt(Some(&path)).unwrap(); + + assert!(excerpt.len() <= 8 * 1024 + "real startup error".len()); + assert!(excerpt.ends_with("real startup error")); + assert!(!excerpt.ends_with('\n')); + } + //---------------------------------------------------------------------------------------------- // Functions: Helpers //---------------------------------------------------------------------------------------------- diff --git a/sdk/rust/lib/sandbox/exec.rs b/sdk/rust/lib/sandbox/exec.rs index 46e65f262..5ce42e3b8 100644 --- a/sdk/rust/lib/sandbox/exec.rs +++ b/sdk/rust/lib/sandbox/exec.rs @@ -505,6 +505,20 @@ impl ExecSink { } } +// Functions +//-------------------------------------------------------------------------------------------------- + +pub(crate) fn initial_stdin_messages(stdin_mode: &StdinMode) -> Vec { + match stdin_mode { + StdinMode::Null => vec![ExecStdin { data: Vec::new() }], + StdinMode::Pipe => Vec::new(), + StdinMode::Bytes(data) => vec![ + ExecStdin { data: data.clone() }, + ExecStdin { data: Vec::new() }, + ], + } +} + //-------------------------------------------------------------------------------------------------- // Module: agent (backend-agnostic ops driven over an agent connection) //-------------------------------------------------------------------------------------------------- @@ -518,7 +532,7 @@ pub(crate) mod agent { use bytes::Bytes; use microsandbox_protocol::{ - exec::{ExecExited, ExecStarted, ExecStderr, ExecStdin, ExecStdout}, + exec::{ExecExited, ExecStarted, ExecStderr, ExecStdout}, message::{Message, MessageType}, }; use tokio::sync::mpsc; @@ -528,7 +542,10 @@ pub(crate) mod agent { sandbox::{SandboxConfig, build_exec_request}, }; - use super::{ExecEvent, ExecHandle, ExecOptions, ExecOutput, ExecSink, ExitStatus, StdinMode}; + use super::{ + ExecEvent, ExecHandle, ExecOptions, ExecOutput, ExecSink, ExitStatus, StdinMode, + initial_stdin_messages, + }; pub(crate) async fn exec_stream( backend: &dyn crate::backend::Backend, @@ -580,14 +597,13 @@ pub(crate) mod agent { _ => None, }; - if let StdinMode::Bytes(ref data) = stdin_mode { - let data = data.clone(); + let initial_stdin = initial_stdin_messages(&stdin_mode); + if !initial_stdin.is_empty() { let bridge = Arc::clone(&client); tokio::spawn(async move { - let payload = ExecStdin { data }; - let _ = bridge.send(id, MessageType::ExecStdin, &payload).await; - let close = ExecStdin { data: Vec::new() }; - let _ = bridge.send(id, MessageType::ExecStdin, &close).await; + for payload in initial_stdin { + let _ = bridge.send(id, MessageType::ExecStdin, &payload).await; + } }); } @@ -677,6 +693,37 @@ pub(crate) mod agent { } } +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn null_stdin_sends_eof() { + let messages = initial_stdin_messages(&StdinMode::Null); + + assert_eq!(messages.len(), 1); + assert!(messages[0].data.is_empty()); + } + + #[test] + fn pipe_stdin_leaves_stdin_open_for_caller() { + assert!(initial_stdin_messages(&StdinMode::Pipe).is_empty()); + } + + #[test] + fn byte_stdin_sends_data_then_eof() { + let messages = initial_stdin_messages(&StdinMode::Bytes(b"hello".to_vec())); + + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].data, b"hello"); + assert!(messages[1].data.is_empty()); + } +} + //-------------------------------------------------------------------------------------------------- // Re-Exports //-------------------------------------------------------------------------------------------------- diff --git a/sdk/rust/tests/stdin.rs b/sdk/rust/tests/stdin.rs index 88897fc44..38e46b0af 100644 --- a/sdk/rust/tests/stdin.rs +++ b/sdk/rust/tests/stdin.rs @@ -106,6 +106,38 @@ async fn stdin_bytes_waits_for_slow_reader() { assert_eq!(actual_sha, expected_sha); } +/// Regression test for null stdin: `cat` should receive EOF immediately and +/// exit instead of waiting forever for more input. +#[msb_test] +async fn stdin_null_lets_cat_finish() { + let name = "stdin-null-cat"; + + let sandbox = Sandbox::builder(name) + .image("mirror.gcr.io/library/alpine") + .cpus(1) + .memory(512) + .replace() + .create() + .await + .expect("create sandbox"); + + let output = sandbox + .exec_with("cat", |exec| exec.stdin_null()) + .await + .expect("run cat with null stdin"); + + stop_and_remove(name).await; + + assert!( + output.status().success, + "cat failed: stdout=`{}` stderr=`{}`", + output.stdout().unwrap_or_default(), + output.stderr().unwrap_or_default() + ); + assert_eq!(output.stdout().unwrap_or_default(), ""); + assert_eq!(output.stderr().unwrap_or_default(), ""); +} + /// Streaming test: multiple sequential `ExecSink::write` calls, each /// exceeding typical pipe capacity. Verifies that repeated invocations /// of `write_stdin` (rather than a single bytes payload) all reach the