diff --git a/.github/scripts/native-linux-smoke.sh b/.github/scripts/native-linux-smoke.sh index e4b106e..2af0873 100644 --- a/.github/scripts/native-linux-smoke.sh +++ b/.github/scripts/native-linux-smoke.sh @@ -189,7 +189,7 @@ run_multi_container_smoke() { fi jq --exit-status \ --argjson expected "$expected_kvm_present" \ - '.schema_version == "a3s.oci.native-linux-multi-container-smoke.v2" + '.schema_version == "a3s.oci.native-linux-multi-container-smoke.v3" and .platform == "linux" and .status == "available" and .kvm_device_present == $expected and .bundles_loaded @@ -235,6 +235,14 @@ run_multi_container_smoke() { and .lifecycle.wait_b_replayed and .lifecycle.delete_b_replayed and .lifecycle.b_missing_after_delete + and (.namespace_join.donor_pid > 0) + and .namespace_join.wrong_type_rejected_before_state + and .namespace_join.joined_non_mount_namespaces + and .namespace_join.joined_pid_time_workload_verified + and .namespace_join.joined_mount_namespace + and .namespace_join.retained_rootfs_verified + and .namespace_join.donor_unchanged_after_joins + and .namespace_join.all_state_removed and .markers_removed and .executor_runtime_clean and .session_root_clean diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e9de90..c25dafb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -496,7 +496,7 @@ jobs: if [[ "$support" == "1" ]]; then test "$status" -eq 0 jq --exit-status \ - '.schema_version == "a3s.oci.oci-vm-multi-container-smoke.v2" + '.schema_version == "a3s.oci.oci-vm-multi-container-smoke.v3" and .platform == "macos" and .status == "available" and .bundles_loaded and .lifecycle.distinct_bundle_directories @@ -541,6 +541,14 @@ jobs: and .lifecycle.wait_b_replayed and .lifecycle.delete_b_replayed and .lifecycle.b_missing_after_delete + and (.namespace_join.donor_pid > 0) + and .namespace_join.wrong_type_rejected_before_state + and .namespace_join.joined_non_mount_namespaces + and .namespace_join.joined_pid_time_workload_verified + and .namespace_join.joined_mount_namespace + and .namespace_join.retained_rootfs_verified + and .namespace_join.donor_unchanged_after_joins + and .namespace_join.all_state_removed and .markers_removed and .guest_runtime_clean and .bridge.platform == "macos" and .bridge.status == "available" @@ -563,7 +571,7 @@ jobs: else test "$status" -eq 2 jq --exit-status \ - '.schema_version == "a3s.oci.oci-vm-multi-container-smoke.v2" + '.schema_version == "a3s.oci.oci-vm-multi-container-smoke.v3" and .platform == "macos" and .status == "unavailable" and .bundles_loaded and .bridge.platform == "macos" diff --git a/README.md b/README.md index 6592d97..799dfa6 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Runtime ModelPlatform StatusArchitecture • + ConformanceDevelopment

@@ -350,12 +351,13 @@ target/debug/a3s-oci oci-vm-multi-container-smoke \ --console "$rootfs_dir/oci-multi-container.log" ``` -`a3s.oci.oci-vm-multi-container-smoke.v2` requires that starting, killing, +`a3s.oci.oci-vm-multi-container-smoke.v3` requires that starting, killing, waiting for, and deleting A never changes or blocks B; both waits return and replay the exact normal exit status; recreating A advances generation 1 to 2; stale and cross-container replay requests fail; B then completes -independently; and VM shutdown restores guest-runtime, endpoint, descriptor, -shim, and worker inventories. +independently; existing namespace descriptors are type-checked and joined +across the shared executor; and VM shutdown restores guest-runtime, endpoint, +descriptor, shim, and worker inventories. Fault cleanup reuses the same signed shim and bundle but stops after each successful lifecycle boundary: @@ -433,6 +435,8 @@ The current executor implements a reviewed bootstrap vertical slice: - parent-authenticated rootful UID/GID mappings plus read-back verification, with normalized monotonic and boottime offsets applied before the first time-namespace child; +- type-checked joins for existing UTS, mount, IPC, network, cgroup, PID, user, + and time namespaces, including retained rootfs access after a mount join; - hostname and domain name configuration; - recursively private mount propagation and `pivot_root`; - ordered existing-target OCI mounts with bind/rbind and common VFS options; @@ -443,8 +447,9 @@ The current executor implements a reviewed bootstrap vertical slice: The supported user-namespace slice is rootful: it requires both UID and GID mappings, coverage for every configured process ID, and an `allow` setgroups -policy. Unimplemented OCI fields are rejected instead of ignored. Rootless -mapping policy, namespace joins, complete mount semantics, cgroup resources, +policy. Mount entries remain unsupported when joining an existing mount +namespace. Other unimplemented OCI fields are rejected instead of ignored. +Rootless mapping policy, complete mount semantics, cgroup resources, capabilities, hooks, seccomp, full I/O, recovery, and the remaining SDK operations are still release gates. @@ -473,9 +478,9 @@ boundary. | Host | Execution path | Retained evidence | Current readiness | | --- | --- | --- | --- | -| Linux x86_64/aarch64 | Native Linux executor | Kernel pidfd signaling probe, real rootful lifecycle with exact SIGKILL status and repeated wait, two-container wait and mutation isolation, plus shutdown cleanup after create, start, and kill without delete; `/dev/kvm` absent and present-but-unusable | Default inventory `probe-only`; explicitly opened development instance `experimental` | +| Linux x86_64/aarch64 | Native Linux executor | Kernel pidfd signaling probe, real rootful lifecycle with exact SIGKILL status and repeated wait, two-container isolation, type-checked existing-namespace joins, plus shutdown cleanup after create, start, and kill without delete; `/dev/kvm` absent and present-but-unusable | Default inventory `probe-only`; explicitly opened development instance `experimental` | | Linux x86_64/aarch64 | libkrun + KVM utility VM | Device access, ioctl result, and KVM API version | `probe-only`; VM driver not implemented | -| macOS arm64 | libkrun + HVF utility VM | Direct HVF VM create/destroy, checksum-pinned context lifecycle, authenticated protocol-v2 arm64 guest agent, pidfd-backed fixed and two-container OCI lifecycles with exact repeated exit status and nonblocking wait evidence, and no-delete cleanup after create, start, and kill | `probe-only`; complete enforcement and recovery pending | +| macOS arm64 | libkrun + HVF utility VM | Direct HVF VM create/destroy, checksum-pinned context lifecycle, authenticated protocol-v2 arm64 guest agent, pidfd-backed fixed and two-container OCI lifecycles, type-checked existing-namespace joins, exact repeated exit status and nonblocking wait evidence, and no-delete cleanup after create, start, and kill | `probe-only`; complete enforcement and recovery pending | | Windows x86_64 | libkrun + WHPX utility VM | Partition, context, guest command, authenticated agent, and fixed OCI core lifecycle | `probe-only`; complete enforcement and recovery pending | Linux installation, feature inspection, and the native SDK path must work when @@ -484,47 +489,69 @@ runtime prerequisite. ## Architecture -The platform-neutral control plane is independent of the host isolation -mechanism. Native libraries and Linux-specific execution stay behind explicit -driver, shim, and guest-agent boundaries: - -```text -A3S Box / a3s-oci CLI / Rust SDK consumers - │ - RuntimeClient / OciRuntimeService - │ - in-process call or bounded local IPC - │ - OCI validation and lifecycle - │ - HostRuntimeService - exact bundle · generations · journal · reconciliation - │ - RuntimeDriver - ┌─────────┴──────────┐ - │ │ - Native Linux host Utility VM qualification - NativeLinuxDriver a3s-oci-krun-shim → libkrun - │ KVM · HVF · WHPX - │ │ - │ authenticated guest protocol - │ │ - │ a3s-oci-agent - │ │ - └─────────┬──────────┘ - │ - LinuxExecutor - namespaces · mounts · PID 1 · pidfds · wait · cleanup +The public contract is separated from replaceable infrastructure. The SDK and +lifecycle control plane remain platform-neutral, while native isolation and +hypervisor libraries stay behind explicit driver, shim, and guest boundaries: + +```mermaid +flowchart TB + subgraph consumers["Consumers and SDK"] + box["A3S Box"] + cli["a3s-oci CLI"] + containerd["containerd shim
(planned)"] + client["RuntimeClient
in-process service or bounded local IPC"] + box --> client + cli --> client + containerd -.-> client + end + + subgraph control["Platform-neutral host control plane"] + service["OciRuntimeService
HostRuntimeService"] + validation["OCI schema and semantic validation"] + lifecycle["Durable lifecycle
generations · replay · fencing · reconciliation"] + state[("Runtime-owned state
exact config · operation journal")] + selection{"RuntimeDriver
explicit isolation selection"} + + service --> validation --> lifecycle --> selection + lifecycle <--> state + end + + subgraph native["Native Linux — experimental opt-in"] + native_driver["NativeLinuxDriver
shared host kernel"] + end + + subgraph utility["Utility VM — qualification path"] + utility_driver["Utility VM RuntimeDriver
(integration pending)"] + shim["a3s-oci-krun-shim
checksum-pinned native loading"] + hypervisor["libkrun
HVF · WHPX
KVM probe only"] + bridge["Authenticated host/guest bridge
Unix socket or named pipe → AF_VSOCK"] + agent["a3s-oci-agent
static Linux guest service"] + + utility_driver -.-> shim + shim --> hypervisor --> bridge --> agent + end + + executor["Shared LinuxExecutor
new and joined namespaces · mounts · create/start barrier
PID 1 · pidfds · wait · scoped cleanup"] + + client --> service + selection -->|"shared-host-kernel"| native_driver + selection -.->|"dedicated-vm / shared-guest-kernel"| utility_driver + native_driver --> executor + agent --> executor ``` -The same `LinuxExecutor` is called directly on Linux and through the guest -agent in a utility VM. The utility-VM branch represents the qualification -architecture; readiness remains defined by the -[platform status](#platform-status), not by presence in the diagram. +The two paths compile and place the same `LinuxExecutor` differently: directly +behind `NativeLinuxDriver` on Linux, or inside `a3s-oci-agent` in a utility VM. +The utility-VM driver edge is dashed because it is a qualification path and is +not yet wired into `HostRuntimeService`. Solid edges show implemented or +directly exercised boundaries, not `supported` readiness; the +[platform status](#platform-status) remains authoritative. -A3S Box owns product-level images, builds, volumes, networks, and policy. A3S -OCI Runtime owns the validated OCI lifecycle, platform execution, durable -state, guest protocol, and runtime-scoped cleanup. +| Boundary | Owns | Deliberately leaves outside | +| --- | --- | --- | +| A3S Box product plane | Images, builds, volumes, networks, and product policy | OCI process and isolation enforcement | +| OCI Runtime control plane | Exact OCI validation, lifecycle state, replay, reconciliation, capability reporting, and driver selection | Silent isolation fallback and product policy | +| Platform execution plane | Hypervisor bridge, Linux namespaces and mounts, PID 1 lifecycle, signaling, and runtime-scoped cleanup | Image distribution and workload orchestration | The main runtime, CLI, and SDK do not link libkrun. Only `a3s-oci-krun-shim` loads the checksum-verified native runtime bundle, keeping @@ -597,6 +624,7 @@ Run checks from the OCI Runtime repository root: cargo fmt --all -- --check cargo test --workspace --all-targets cargo clippy --workspace --all-targets -- -D warnings +RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps ``` Cross-check Linux compilation without treating the monorepo root as a Rust @@ -615,13 +643,13 @@ Platform CI covers: - the 237-point durable commit matrix and all 14 `RuntimeDriver` call boundaries on Linux, macOS, and Windows; -- Ubuntu x86_64 native pidfd probe, lifecycle, multi-container isolation, and - three-phase no-delete cleanup without KVM; -- Ubuntu aarch64 native pidfd probe, lifecycle, multi-container isolation, and - three-phase no-delete cleanup without KVM; +- Ubuntu x86_64 native pidfd probe, lifecycle, multi-container and + existing-namespace isolation, and three-phase no-delete cleanup without KVM; +- Ubuntu aarch64 native pidfd probe, lifecycle, multi-container and + existing-namespace isolation, and three-phase no-delete cleanup without KVM; - macOS HVF, isolated libkrun context, guest-marker, authenticated-agent, - pidfd-backed fixed and multi-container OCI lifecycles, three-phase no-delete - cleanup, and missing-entitlement fail-closed gates; + pidfd-backed fixed, multi-container, and existing-namespace OCI lifecycles, + three-phase no-delete cleanup, and missing-entitlement fail-closed gates; - Windows WHPX and libkrun context gates; - static x86_64 and aarch64 musl guest-agent output. diff --git a/ROADMAP.md b/ROADMAP.md index 2f9b319..3c7357d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -70,6 +70,10 @@ Completed: - real native Linux create/state/start/kill/wait/delete SDK evidence on x86_64 and aarch64, including exact repeated SIGKILL status and bounded running wait, repeated with `/dev/kvm` absent and present but unusable; +- type-checked joins for existing UTS, mount, IPC, network, cgroup, PID, user, + and time namespaces, including retained rootfs execution after a mount join, + three-pass user-namespace permission recovery, and shared native Linux/macOS + utility-VM lifecycle evidence; - real native Linux no-delete cleanup after create, start, and kill on x86_64 and aarch64, including init-PID reap and executor, durable-state, marker, and session-root removal; @@ -305,7 +309,11 @@ then may HVF become `experimental`. UID/GID mappings through the authenticated parent, apply and verify monotonic/boottime offsets before the first child, and prove the path through native Linux and the macOS utility VM. -- [ ] Join existing namespaces. +- [x] Open and type-check all existing namespace descriptors before mutation, + join non-user namespaces around the user-namespace capability transition, + preserve PID/time next-child semantics, and prove UTS, mount, IPC, network, + cgroup, PID, user, and time joins through native Linux and the macOS + utility-VM path. - [ ] Mount-target creation, rootfs propagation overrides, idmapped and recursive-attribute mounts, masked paths, read-only paths, and read-only rootfs. diff --git a/crates/agent/src/executor/init.rs b/crates/agent/src/executor/init.rs index f4519f7..5407ea2 100644 --- a/crates/agent/src/executor/init.rs +++ b/crates/agent/src/executor/init.rs @@ -1,4 +1,5 @@ use std::ffi::{CString, OsStr}; +use std::fs::File; use std::io::{self, Read}; use std::mem::MaybeUninit; use std::os::linux::net::SocketAddrExt; @@ -57,7 +58,7 @@ fn run_container_init( format!("failed to connect abstract prepared init control socket: {error}"), ) })?; - let (plan, canonical_bundle, rootfs) = + let (plan, canonical_bundle, rootfs, rootfs_file) = match prepare_container_init(config_snapshot, bundle_directory) { Ok(prepared) => prepared, Err(error) => return reject_before_ready(&mut control, error), @@ -66,7 +67,7 @@ fn run_container_init( return reject_before_ready(&mut control, error); } if plan.namespaces.requires_child_process() { - return run_namespaced_init(&plan, &canonical_bundle, &rootfs, control); + return run_namespaced_init(&plan, &canonical_bundle, &rootfs, &rootfs_file, control); } if let Err(error) = prepare_create_environment(&plan, &canonical_bundle, &rootfs) { return reject_before_ready(&mut control, error); @@ -75,10 +76,10 @@ fn run_container_init( // PID namespace that changes the runtime-visible process. let pid = unsafe { libc::getpid() }; write_ready(&mut control, pid)?; - wait_for_start_and_exec(&plan, &rootfs, control) + wait_for_start_and_exec(&plan, &rootfs_file, control) } -fn wait_for_start_and_exec(plan: &InitPlan, rootfs: &Path, mut control: UnixStream) -> Result<()> { +fn wait_for_start_and_exec(plan: &InitPlan, rootfs: &File, mut control: UnixStream) -> Result<()> { let mut start = [0_u8; 1]; control.read_exact(&mut start).map_err(|error| { init_error( @@ -100,6 +101,7 @@ fn run_namespaced_init( plan: &InitPlan, bundle_directory: &Path, rootfs: &Path, + rootfs_file: &File, mut control: UnixStream, ) -> Result<()> { match pid::fork_namespaced_init() { @@ -113,7 +115,7 @@ fn run_namespaced_init( return reject_before_ready(&mut control, error); } write_ready(&mut control, runtime_pid)?; - wait_for_start_and_exec(plan, rootfs, control) + wait_for_start_and_exec(plan, rootfs_file, control) } Err(error) => reject_before_ready(&mut control, error), } @@ -133,7 +135,7 @@ fn reject_before_ready(control: &mut UnixStream, error: Error) -> Result<()> { fn prepare_container_init( config_snapshot: PathBuf, bundle_directory: PathBuf, -) -> Result<(InitPlan, PathBuf, PathBuf)> { +) -> Result<(InitPlan, PathBuf, PathBuf, File)> { let config_json = read_bounded_config(&config_snapshot)?; let bundle = OciBundle::from_json(bundle_directory, config_json)?; let plan = InitPlan::from_bundle(&bundle, &null_io())?; @@ -164,7 +166,34 @@ fn prepare_container_init( ), )); } - Ok((plan, canonical_bundle, rootfs)) + let rootfs_file = File::open(&rootfs).map_err(|error| { + init_error( + ErrorCode::InvalidArgument, + format!( + "failed to retain the container rootfs {} before namespace entry: {error}", + rootfs.display() + ), + ) + })?; + if !rootfs_file + .metadata() + .map_err(|error| { + init_error( + ErrorCode::InvalidArgument, + format!( + "failed to inspect retained container rootfs {}: {error}", + rootfs.display() + ), + ) + })? + .is_dir() + { + return Err(init_error( + ErrorCode::InvalidArgument, + format!("container rootfs is not a directory: {}", rootfs.display()), + )); + } + Ok((plan, canonical_bundle, rootfs, rootfs_file)) } fn prepare_create_environment( @@ -173,10 +202,10 @@ fn prepare_create_environment( rootfs: &Path, ) -> Result<()> { if let Some(hostname) = &plan.hostname { - if !plan.namespaces.new_uts() { + if !plan.namespaces.has_uts() { return Err(init_error( ErrorCode::FailedPrecondition, - "refusing to change hostname outside a new UTS namespace", + "refusing to change hostname outside a configured UTS namespace", )); } // SAFETY: the byte slice remains live for the call and its exact @@ -186,10 +215,10 @@ fn prepare_create_environment( } } if let Some(domainname) = &plan.domainname { - if !plan.namespaces.new_uts() { + if !plan.namespaces.has_uts() { return Err(init_error( ErrorCode::FailedPrecondition, - "refusing to change domainname outside a new UTS namespace", + "refusing to change domainname outside a configured UTS namespace", )); } // SAFETY: the byte slice remains live for the call and its exact @@ -299,7 +328,7 @@ fn read_bounded_config(path: &Path) -> Result { }) } -fn enter_rootfs_and_exec(plan: &InitPlan, rootfs: &Path) -> Result<()> { +fn enter_rootfs_and_exec(plan: &InitPlan, rootfs: &File) -> Result<()> { let cwd = CString::new(plan.cwd.as_bytes()).map_err(|error| { init_error( ErrorCode::InvalidArgument, diff --git a/crates/agent/src/executor/namespace.rs b/crates/agent/src/executor/namespace.rs index 73d088e..875728e 100644 --- a/crates/agent/src/executor/namespace.rs +++ b/crates/agent/src/executor/namespace.rs @@ -1,11 +1,14 @@ use std::io; +use std::os::unix::ffi::OsStrExt; use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; use a3s_oci_sdk::oci_spec::runtime::{Linux, LinuxIdMapping, LinuxNamespaceType}; use a3s_oci_sdk::{Error, ErrorCode, Result}; use super::control; +mod join; mod time; mod user; @@ -37,16 +40,41 @@ pub(super) struct TimeOffset { pub(super) nanosecs: u32, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +enum NamespaceAction { + #[default] + Inherit, + Create, + Join(PathBuf), +} + +impl NamespaceAction { + const fn is_new(&self) -> bool { + matches!(self, Self::Create) + } + + const fn is_configured(&self) -> bool { + !matches!(self, Self::Inherit) + } + + fn joined(&self) -> Option<&Path> { + match self { + Self::Join(path) => Some(path), + Self::Inherit | Self::Create => None, + } + } +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(super) struct NamespacePlan { - new_uts: bool, - new_mount: bool, - new_ipc: bool, - new_network: bool, - new_cgroup: bool, - new_pid: bool, - new_user: bool, - new_time: bool, + uts: NamespaceAction, + mount: NamespaceAction, + ipc: NamespaceAction, + network: NamespaceAction, + cgroup: NamespaceAction, + pid: NamespaceAction, + user: NamespaceAction, + time: NamespaceAction, uid_mappings: Vec, gid_mappings: Vec, monotonic_offset: Option, @@ -66,35 +94,32 @@ impl NamespacePlan { let mut plan = Self::default(); if let Some(namespaces) = linux.namespaces().as_deref() { for (index, namespace) in namespaces.iter().enumerate() { - if namespace.path().is_some() { - return Err(unsupported( - &format!("linux.namespaces[{index}].path"), - "joining an existing namespace is not implemented", - )); - } - let present = match namespace.typ() { - LinuxNamespaceType::Uts => &mut plan.new_uts, - LinuxNamespaceType::Mount => &mut plan.new_mount, - LinuxNamespaceType::Ipc => &mut plan.new_ipc, - LinuxNamespaceType::Network => &mut plan.new_network, - LinuxNamespaceType::Cgroup => &mut plan.new_cgroup, - LinuxNamespaceType::Pid => &mut plan.new_pid, - LinuxNamespaceType::User => &mut plan.new_user, - LinuxNamespaceType::Time => &mut plan.new_time, + let action = match namespace.typ() { + LinuxNamespaceType::Uts => &mut plan.uts, + LinuxNamespaceType::Mount => &mut plan.mount, + LinuxNamespaceType::Ipc => &mut plan.ipc, + LinuxNamespaceType::Network => &mut plan.network, + LinuxNamespaceType::Cgroup => &mut plan.cgroup, + LinuxNamespaceType::Pid => &mut plan.pid, + LinuxNamespaceType::User => &mut plan.user, + LinuxNamespaceType::Time => &mut plan.time, }; - if *present { + if action.is_configured() { return Err(invalid(format!( "linux.namespaces contains duplicate {:?} entries", namespace.typ() ))); } - *present = true; + *action = match namespace.path() { + Some(path) => NamespaceAction::Join(validate_join_path(index, path)?), + None => NamespaceAction::Create, + }; } } plan.uid_mappings = collect_mappings("linux.uidMappings", linux.uid_mappings().as_deref())?; plan.gid_mappings = collect_mappings("linux.gidMappings", linux.gid_mappings().as_deref())?; - if plan.new_user { + if plan.new_user() { if plan.uid_mappings.is_empty() || plan.gid_mappings.is_empty() { return Err(unsupported( "linux.uidMappings/linux.gidMappings", @@ -117,7 +142,7 @@ impl NamespacePlan { } if let Some(offsets) = linux.time_offsets() { - if !plan.new_time { + if !plan.new_time() { return Err(invalid( "linux.timeOffsets requires a newly created time namespace", )); @@ -142,39 +167,83 @@ impl NamespacePlan { } pub(super) const fn new_uts(&self) -> bool { - self.new_uts + self.uts.is_new() } pub(super) const fn new_mount(&self) -> bool { - self.new_mount + self.mount.is_new() } pub(super) const fn new_ipc(&self) -> bool { - self.new_ipc + self.ipc.is_new() } pub(super) const fn new_network(&self) -> bool { - self.new_network + self.network.is_new() } pub(super) const fn new_cgroup(&self) -> bool { - self.new_cgroup + self.cgroup.is_new() } pub(super) const fn new_pid(&self) -> bool { - self.new_pid + self.pid.is_new() } pub(super) const fn new_user(&self) -> bool { - self.new_user + self.user.is_new() } pub(super) const fn new_time(&self) -> bool { - self.new_time + self.time.is_new() + } + + pub(super) const fn has_uts(&self) -> bool { + self.uts.is_configured() + } + + pub(super) const fn has_pid(&self) -> bool { + self.pid.is_configured() + } + + pub(super) const fn has_time(&self) -> bool { + self.time.is_configured() + } + + pub(super) fn joined_uts(&self) -> Option<&Path> { + self.uts.joined() + } + + pub(super) fn joined_mount(&self) -> Option<&Path> { + self.mount.joined() + } + + pub(super) fn joined_ipc(&self) -> Option<&Path> { + self.ipc.joined() + } + + pub(super) fn joined_network(&self) -> Option<&Path> { + self.network.joined() + } + + pub(super) fn joined_cgroup(&self) -> Option<&Path> { + self.cgroup.joined() + } + + pub(super) fn joined_pid(&self) -> Option<&Path> { + self.pid.joined() + } + + pub(super) fn joined_user(&self) -> Option<&Path> { + self.user.joined() + } + + pub(super) fn joined_time(&self) -> Option<&Path> { + self.time.joined() } pub(super) const fn requires_child_process(&self) -> bool { - self.new_pid || self.new_time + self.has_pid() || self.has_time() } pub(super) fn uid_mappings(&self) -> &[IdMapping] { @@ -195,6 +264,8 @@ impl NamespacePlan { } pub(super) fn enter_new_namespaces(plan: &NamespacePlan, control: &mut UnixStream) -> Result<()> { + join::enter(plan)?; + if plan.new_user() { unshare(libc::CLONE_NEWUSER, "create Linux OCI user namespace")?; control::request_user_mapping(control)?; @@ -231,6 +302,17 @@ pub(super) fn enter_new_namespaces(plan: &NamespacePlan, control: &mut UnixStrea Ok(()) } +fn validate_join_path(index: usize, path: &Path) -> Result { + let field = format!("linux.namespaces[{index}].path"); + if !path.is_absolute() { + return Err(invalid(format!("{field} must be absolute"))); + } + if path.as_os_str().as_bytes().contains(&0) { + return Err(invalid(format!("{field} must not contain a NUL byte"))); + } + Ok(path.to_path_buf()) +} + fn unshare(flags: libc::c_int, operation: &str) -> Result<()> { // SAFETY: `unshare` has no pointer preconditions. The dedicated init // process is single-threaded before it reports the created barrier. diff --git a/crates/agent/src/executor/namespace/join.rs b/crates/agent/src/executor/namespace/join.rs new file mode 100644 index 0000000..f62815d --- /dev/null +++ b/crates/agent/src/executor/namespace/join.rs @@ -0,0 +1,317 @@ +use std::ffi::CString; +use std::fs::File; +use std::io; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::path::{Path, PathBuf}; + +use a3s_oci_sdk::{Error, ErrorCode, Result}; + +use super::NamespacePlan; + +struct OpenNamespace { + name: &'static str, + path: PathBuf, + file: File, + namespace_type: libc::c_int, + current_name: &'static str, + joined: bool, +} + +pub(super) fn enter(plan: &NamespacePlan) -> Result<()> { + let mut namespaces = open_all(plan)?; + if namespaces.is_empty() { + return Ok(()); + } + let current_namespaces = File::open("/proc/self/ns").map_err(|error| { + join_error( + ErrorCode::Internal, + format!("failed to retain /proc/self/ns before namespace entry: {error}"), + ) + })?; + + join_pass(&mut namespaces, ¤t_namespaces, false)?; + join_pass(&mut namespaces, ¤t_namespaces, true)?; + join_pass(&mut namespaces, ¤t_namespaces, false)?; + + let missing = namespaces + .iter() + .filter(|namespace| !namespace.joined) + .map(|namespace| namespace.name) + .collect::>(); + if missing.is_empty() { + Ok(()) + } else { + Err(join_error( + ErrorCode::PermissionDenied, + format!( + "failed to join existing Linux namespaces after the user namespace permission \ + transition: {}", + missing.join(", ") + ), + )) + } +} + +fn open_all(plan: &NamespacePlan) -> Result> { + let specifications = [ + ( + "cgroup", + plan.joined_cgroup(), + libc::CLONE_NEWCGROUP, + "cgroup", + ), + ("ipc", plan.joined_ipc(), libc::CLONE_NEWIPC, "ipc"), + ("mount", plan.joined_mount(), libc::CLONE_NEWNS, "mnt"), + ("network", plan.joined_network(), libc::CLONE_NEWNET, "net"), + ( + "pid", + plan.joined_pid(), + libc::CLONE_NEWPID, + "pid_for_children", + ), + ( + "time", + plan.joined_time(), + libc::CLONE_NEWTIME, + "time_for_children", + ), + ("user", plan.joined_user(), libc::CLONE_NEWUSER, "user"), + ("uts", plan.joined_uts(), libc::CLONE_NEWUTS, "uts"), + ]; + let mut opened = Vec::new(); + for (name, path, namespace_type, current_name) in specifications { + let Some(path) = path else { + continue; + }; + let file = File::open(path).map_err(|error| open_error(name, path, error))?; + validate_namespace_type(name, path, &file, namespace_type)?; + opened.push(OpenNamespace { + name, + path: path.to_path_buf(), + file, + namespace_type, + current_name, + joined: false, + }); + } + Ok(opened) +} + +fn join_pass( + namespaces: &mut [OpenNamespace], + current_namespaces: &File, + user_namespace: bool, +) -> Result<()> { + for namespace in namespaces { + if namespace.joined || (namespace.namespace_type == libc::CLONE_NEWUSER) != user_namespace { + continue; + } + if same_namespace(&namespace.file, current_namespaces, namespace.current_name)? { + namespace.joined = true; + continue; + } + + // SAFETY: the descriptor was opened and type-checked before any + // namespace transition. This dedicated init wrapper is single-threaded. + if unsafe { libc::setns(namespace.file.as_raw_fd(), namespace.namespace_type) } != 0 { + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::EPERM) { + continue; + } + return Err(join_error( + error_code(&error), + format!( + "setns into existing {} namespace {} failed: {error}", + namespace.name, + namespace.path.display() + ), + )); + } + if namespace.namespace_type == libc::CLONE_NEWUSER { + become_user_namespace_root()?; + } + if !same_namespace(&namespace.file, current_namespaces, namespace.current_name)? { + return Err(join_error( + ErrorCode::PermissionDenied, + format!( + "setns did not place the container init in the requested {} namespace {}", + namespace.name, + namespace.path.display() + ), + )); + } + namespace.joined = true; + } + Ok(()) +} + +fn validate_namespace_type( + name: &str, + path: &Path, + file: &File, + expected: libc::c_int, +) -> Result<()> { + // SAFETY: NS_GET_NSTYPE reads namespace metadata from a live descriptor + // and does not require a third ioctl argument. + let actual = unsafe { libc::ioctl(file.as_raw_fd(), libc::NS_GET_NSTYPE) }; + if actual < 0 { + let error = io::Error::last_os_error(); + return Err(join_error( + if matches!(error.raw_os_error(), Some(libc::ENOTTY | libc::EINVAL)) { + ErrorCode::InvalidArgument + } else { + error_code(&error) + }, + format!( + "failed to verify existing {name} namespace path {}: {error}", + path.display() + ), + )); + } + if actual != expected { + return Err(join_error( + ErrorCode::InvalidArgument, + format!( + "existing {name} namespace path {} has namespace type {actual:#x}, expected \ + {expected:#x}", + path.display() + ), + )); + } + Ok(()) +} + +fn same_namespace(target: &File, current_namespaces: &File, current_name: &str) -> Result { + let current_name = CString::new(current_name).map_err(|error| { + join_error( + ErrorCode::Internal, + format!("current namespace name contains a NUL byte: {error}"), + ) + })?; + // SAFETY: the retained descriptor references `/proc/self/ns`, the name is + // NUL-terminated, and ownership of a successful descriptor is transferred + // exactly once to `File`. + let current = unsafe { + libc::openat( + current_namespaces.as_raw_fd(), + current_name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC, + ) + }; + if current < 0 { + let error = io::Error::last_os_error(); + return Err(join_error( + ErrorCode::Internal, + format!("failed to inspect current namespace through retained /proc/self/ns: {error}"), + )); + } + // SAFETY: `current` is a new owned descriptor returned by successful + // `openat` and has not been wrapped or closed elsewhere. + let current = unsafe { File::from_raw_fd(current) }; + let target = target.metadata().map_err(|error| { + join_error( + ErrorCode::Internal, + format!("failed to inspect an opened namespace descriptor: {error}"), + ) + })?; + let current = current.metadata().map_err(|error| { + join_error( + ErrorCode::Internal, + format!("failed to inspect current namespace through retained /proc/self/ns: {error}"), + ) + })?; + + #[cfg(target_os = "linux")] + { + use std::os::linux::fs::MetadataExt; + + Ok(target.st_dev() == current.st_dev() && target.st_ino() == current.st_ino()) + } +} + +fn become_user_namespace_root() -> Result<()> { + // SAFETY: setresuid receives plain integer IDs. A successful user + // namespace transition grants capabilities in the target namespace, and + // switching all UID slots to namespace root matches the OCI setup model. + if unsafe { libc::setresuid(0, 0, 0) } == 0 { + Ok(()) + } else { + let error = io::Error::last_os_error(); + Err(join_error( + error_code(&error), + format!("failed to become root in the joined user namespace: {error}"), + )) + } +} + +fn open_error(name: &str, path: &Path, error: io::Error) -> Error { + join_error( + error_code(&error), + format!( + "failed to open existing {name} namespace {}: {error}", + path.display() + ), + ) +} + +fn error_code(error: &io::Error) -> ErrorCode { + match error.raw_os_error() { + Some(libc::EACCES | libc::EPERM) => ErrorCode::PermissionDenied, + Some(libc::ENOENT | libc::ENOTDIR | libc::ELOOP) => ErrorCode::InvalidArgument, + _ => ErrorCode::FailedPrecondition, + } +} + +fn join_error(code: ErrorCode, message: impl Into) -> Error { + Error::new(code, message).for_operation("run-container-init") +} + +#[cfg(test)] +mod tests { + use std::fs::File; + use std::path::Path; + + use a3s_oci_sdk::ErrorCode; + + use super::{same_namespace, validate_namespace_type}; + + #[test] + fn namespace_descriptors_are_type_checked_before_setns() { + let uts = File::open("/proc/self/ns/uts").expect("open current UTS namespace"); + validate_namespace_type( + "uts", + Path::new("/proc/self/ns/uts"), + &uts, + libc::CLONE_NEWUTS, + ) + .expect("correct namespace type"); + + let error = validate_namespace_type( + "network", + Path::new("/proc/self/ns/uts"), + &uts, + libc::CLONE_NEWNET, + ) + .expect_err("wrong namespace type"); + assert_eq!(error.code, ErrorCode::InvalidArgument); + assert!(error.message.contains("expected")); + } + + #[test] + fn regular_files_are_rejected_as_namespace_targets() { + let file = File::open("/etc/passwd").expect("open regular file"); + let error = + validate_namespace_type("uts", Path::new("/etc/passwd"), &file, libc::CLONE_NEWUTS) + .expect_err("regular file is not an nsfs descriptor"); + assert_eq!(error.code, ErrorCode::InvalidArgument); + assert!(error.message.contains("verify")); + } + + #[test] + fn namespace_identity_uses_the_open_descriptor_not_a_re_resolved_target() { + let current = File::open("/proc/self/ns").expect("retain current namespace directory"); + let uts = File::open("/proc/self/ns/uts").expect("open current UTS namespace"); + assert!(same_namespace(&uts, ¤t, "uts").expect("compare namespace identities")); + assert!(!same_namespace(&uts, ¤t, "net").expect("compare different namespaces")); + } +} diff --git a/crates/agent/src/executor/plan.rs b/crates/agent/src/executor/plan.rs index 0d91ace..c154d83 100644 --- a/crates/agent/src/executor/plan.rs +++ b/crates/agent/src/executor/plan.rs @@ -126,10 +126,10 @@ impl InitPlan { .as_deref() .map(|value| validate_uts_name("domainname", value)) .transpose()?; - if (hostname.is_some() || domainname.is_some()) && !namespaces.new_uts() { + if (hostname.is_some() || domainname.is_some()) && !namespaces.has_uts() { return Err(unsupported( "hostname/domainname", - "the bootstrap executor changes UTS names only in a newly created UTS namespace", + "the bootstrap executor changes UTS names only in a configured UTS namespace", )); } @@ -225,11 +225,8 @@ fn validate_profile(raw: &Value) -> Result<()> { "only Linux OCI namespace types are accepted", )); } - if namespace.contains_key("path") { - return Err(unsupported( - &format!("{field}.path"), - "joining an existing namespace is not implemented", - )); + if namespace.get("path").is_some_and(|path| !path.is_string()) { + return Err(invalid(format!("{field}.path must be a string"))); } } reject_unimplemented_keys( diff --git a/crates/agent/src/executor/plan_tests.rs b/crates/agent/src/executor/plan_tests.rs index 7b7351f..311dc4f 100644 --- a/crates/agent/src/executor/plan_tests.rs +++ b/crates/agent/src/executor/plan_tests.rs @@ -401,40 +401,84 @@ fn rejects_uts_names_outside_the_supported_profile() { } #[test] -fn rejects_joined_namespaces() { - let joined = UTS_CONFIG.replace( - r#""type": "uts""#, - r#""type": "uts", "path": "/proc/1/ns/uts""#, - ); - let error = InitPlan::from_bundle(&bundle(&joined), &null_io()) - .expect_err("joined UTS namespace unsupported"); - assert_eq!(error.code, ErrorCode::Unsupported); - assert!(error.message.contains("namespaces[0].path")); +fn accepts_all_joined_namespace_types_and_retains_their_absolute_paths() { + let mut config: serde_json::Value = + serde_json::from_str(FIXED_CONFIG).expect("decode namespace configuration"); + config["hostname"] = serde_json::json!("joined-host"); + config["domainname"] = serde_json::json!("joined.test"); + config["linux"] = serde_json::json!({ + "namespaces": [ + {"type": "time", "path": "/proc/42/ns/time"}, + {"type": "pid", "path": "/proc/42/ns/pid"}, + {"type": "user", "path": "/proc/42/ns/user"}, + {"type": "mount", "path": "/proc/42/ns/mnt"}, + {"type": "network", "path": "/proc/42/ns/net"}, + {"type": "ipc", "path": "/proc/42/ns/ipc"}, + {"type": "cgroup", "path": "/proc/42/ns/cgroup"}, + {"type": "uts", "path": "/proc/42/ns/uts"} + ] + }); + let config = serde_json::to_string(&config).expect("encode namespace configuration"); - let joined_mount = UTS_CONFIG.replace( - r#"{"type": "uts"}"#, - r#"{"type": "uts"}, {"type": "mount", "path": "/proc/1/ns/mnt"}"#, + let plan = + InitPlan::from_bundle(&bundle(&config), &null_io()).expect("all existing Linux namespaces"); + assert_eq!( + plan.namespaces.joined_uts(), + Some(std::path::Path::new("/proc/42/ns/uts")) ); - let error = InitPlan::from_bundle(&bundle(&joined_mount), &null_io()) - .expect_err("joined mount namespace unsupported"); - assert_eq!(error.code, ErrorCode::Unsupported); - assert!(error.message.contains("namespaces[1].path")); - - let joined_network = UTS_CONFIG.replace( - r#"{"type": "uts"}"#, - r#"{"type": "uts"}, {"type": "network", "path": "/proc/1/ns/net"}"#, + assert_eq!( + plan.namespaces.joined_mount(), + Some(std::path::Path::new("/proc/42/ns/mnt")) ); - let error = InitPlan::from_bundle(&bundle(&joined_network), &null_io()) - .expect_err("joined network namespace unsupported"); - assert_eq!(error.code, ErrorCode::Unsupported); - assert!(error.message.contains("namespaces[1].path")); - - let joined_pid = UTS_CONFIG.replace( - r#"{"type": "uts"}"#, - r#"{"type": "uts"}, {"type": "pid", "path": "/proc/1/ns/pid"}"#, + assert_eq!( + plan.namespaces.joined_ipc(), + Some(std::path::Path::new("/proc/42/ns/ipc")) + ); + assert_eq!( + plan.namespaces.joined_network(), + Some(std::path::Path::new("/proc/42/ns/net")) + ); + assert_eq!( + plan.namespaces.joined_cgroup(), + Some(std::path::Path::new("/proc/42/ns/cgroup")) ); - let error = InitPlan::from_bundle(&bundle(&joined_pid), &null_io()) - .expect_err("joined PID namespace unsupported"); + assert_eq!( + plan.namespaces.joined_pid(), + Some(std::path::Path::new("/proc/42/ns/pid")) + ); + assert_eq!( + plan.namespaces.joined_user(), + Some(std::path::Path::new("/proc/42/ns/user")) + ); + assert_eq!( + plan.namespaces.joined_time(), + Some(std::path::Path::new("/proc/42/ns/time")) + ); + assert!(plan.namespaces.requires_child_process()); + assert_eq!(plan.hostname.as_deref(), Some("joined-host")); + assert_eq!(plan.domainname.as_deref(), Some("joined.test")); +} + +#[test] +fn joined_mount_namespaces_do_not_enable_unimplemented_mount_mutation() { + let mut config: serde_json::Value = + serde_json::from_str(FIXED_CONFIG).expect("decode namespace configuration"); + config["mounts"] = serde_json::json!([{ + "destination": "/proc", + "type": "proc", + "source": "proc" + }]); + config["linux"] = serde_json::json!({ + "namespaces": [ + {"type": "mount", "path": "/proc/42/ns/mnt"} + ] + }); + let config = serde_json::to_string(&config).expect("encode namespace configuration"); + + let error = InitPlan::from_bundle(&bundle(&config), &null_io()) + .expect_err("mount mutation in a shared namespace must remain fail-closed"); assert_eq!(error.code, ErrorCode::Unsupported); - assert!(error.message.contains("namespaces[1].path")); + assert!(error + .message + .contains("only in a newly created mount namespace")); } diff --git a/crates/agent/src/executor/rootfs.rs b/crates/agent/src/executor/rootfs.rs index 266953e..5888247 100644 --- a/crates/agent/src/executor/rootfs.rs +++ b/crates/agent/src/executor/rootfs.rs @@ -1,5 +1,7 @@ use std::ffi::CString; +use std::fs::File; use std::io; +use std::os::fd::AsRawFd; use std::os::unix::ffi::OsStrExt; use std::path::Path; @@ -70,13 +72,15 @@ pub(super) fn pivot_root(rootfs: &Path) -> Result<()> { Ok(()) } -pub(super) fn chroot(rootfs: &Path) -> Result<()> { - let rootfs = path_cstring(rootfs)?; - - // SAFETY: both pathnames are NUL-terminated and remain live for each - // syscall. The caller is the dedicated single-threaded init process. +pub(super) fn chroot(rootfs: &File) -> Result<()> { + // SAFETY: the descriptor was opened on the validated rootfs directory + // before namespace entry. The caller is the dedicated single-threaded init + // process, and `.` resolves through the retained descriptor after fchdir. unsafe { - if libc::chroot(rootfs.as_ptr()) != 0 { + if libc::fchdir(rootfs.as_raw_fd()) != 0 { + return Err(last_os_error("change to the retained container rootfs")); + } + if libc::chroot(CURRENT_DIRECTORY.as_ptr().cast()) != 0 { return Err(last_os_error("chroot container rootfs")); } if libc::chdir(ROOT_DIRECTORY.as_ptr().cast()) != 0 { diff --git a/crates/cli/tests/cli.rs b/crates/cli/tests/cli.rs index 7db4dc6..be34f0b 100644 --- a/crates/cli/tests/cli.rs +++ b/crates/cli/tests/cli.rs @@ -136,7 +136,7 @@ fn native_linux_multi_container_smoke_fails_closed_with_versioned_output() { serde_json::from_slice(&output.stdout).expect("diagnostic output must be valid JSON"); assert_eq!( report["schema_version"], - "a3s.oci.native-linux-multi-container-smoke.v2" + "a3s.oci.native-linux-multi-container-smoke.v3" ); assert_ne!(report["status"], "available"); } @@ -217,7 +217,7 @@ fn oci_vm_multi_container_smoke_fails_closed_with_versioned_output() { serde_json::from_slice(&output.stdout).expect("diagnostic output must be valid JSON"); assert_eq!( report["schema_version"], - "a3s.oci.oci-vm-multi-container-smoke.v2" + "a3s.oci.oci-vm-multi-container-smoke.v3" ); assert_ne!(report["status"], "available"); } diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index e9a950d..6759718 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -21,6 +21,7 @@ mod fault; #[cfg(all(target_os = "macos", target_arch = "aarch64"))] mod host_cleanup; mod multi_container_report; +mod namespace_join; #[cfg(target_os = "linux")] mod native_linux_driver; mod native_smoke; @@ -46,7 +47,7 @@ pub use driver::{ DriverWaitRequest, RuntimeDriver, }; pub use multi_container_report::{ - MultiContainerLifecycleEvidence, NativeLinuxMultiContainerSmokeReport, + MultiContainerLifecycleEvidence, NamespaceJoinEvidence, NativeLinuxMultiContainerSmokeReport, OciVmMultiContainerSmokeReport, }; #[cfg(target_os = "linux")] diff --git a/crates/runtime/src/multi_container_report.rs b/crates/runtime/src/multi_container_report.rs index f77e6d8..0cb9efb 100644 --- a/crates/runtime/src/multi_container_report.rs +++ b/crates/runtime/src/multi_container_report.rs @@ -6,9 +6,51 @@ use crate::AgentVmSmokeReport; /// Schema emitted by the native Linux multi-container lifecycle diagnostic. pub const NATIVE_LINUX_MULTI_CONTAINER_SCHEMA_VERSION: &str = - "a3s.oci.native-linux-multi-container-smoke.v2"; + "a3s.oci.native-linux-multi-container-smoke.v3"; /// Schema emitted by the utility-VM multi-container lifecycle diagnostic. -pub const OCI_VM_MULTI_CONTAINER_SCHEMA_VERSION: &str = "a3s.oci.oci-vm-multi-container-smoke.v2"; +pub const OCI_VM_MULTI_CONTAINER_SCHEMA_VERSION: &str = "a3s.oci.oci-vm-multi-container-smoke.v3"; + +/// Existing-namespace join evidence shared by native and utility-VM paths. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct NamespaceJoinEvidence { + /// Positive runtime-visible PID of the prepared namespace donor. + #[serde(skip_serializing_if = "Option::is_none")] + pub donor_pid: Option, + /// Whether a path whose namespace type disagreed with `type` was rejected + /// before any container state became visible. + pub wrong_type_rejected_before_state: bool, + /// Whether one init joined the donor UTS, IPC, network, cgroup, PID, user, + /// and time namespaces while creating its own mount namespace. + pub joined_non_mount_namespaces: bool, + /// Whether the joined PID/time workload crossed start, reached exec, and + /// remained running for the bounded observation window. + pub joined_pid_time_workload_verified: bool, + /// Whether a second init joined the donor mount namespace. + pub joined_mount_namespace: bool, + /// Whether the rootfs retained before `setns` remained usable for exec + /// after the donor mount namespace made the original path unreachable. + pub retained_rootfs_verified: bool, + /// Whether both joiner lifecycles completed without changing the donor's + /// created state. + pub donor_unchanged_after_joins: bool, + /// Whether the donor, both joiners, and the negative case left no state. + pub all_state_removed: bool, +} + +impl NamespaceJoinEvidence { + /// Return whether every existing-namespace join invariant was proven. + #[must_use] + pub fn is_success(&self) -> bool { + self.donor_pid.is_some_and(|pid| pid > 0) + && self.wrong_type_rejected_before_state + && self.joined_non_mount_namespaces + && self.joined_pid_time_workload_verified + && self.joined_mount_namespace + && self.retained_rootfs_verified + && self.donor_unchanged_after_joins + && self.all_state_removed + } +} /// Exact multi-container lifecycle and isolation evidence shared by both paths. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -173,6 +215,8 @@ pub struct NativeLinuxMultiContainerSmokeReport { pub service_operations: Vec, /// Per-container generation, replay, isolation, and lifecycle evidence. pub lifecycle: MultiContainerLifecycleEvidence, + /// Existing-namespace type validation, joins, and lifecycle evidence. + pub namespace_join: NamespaceJoinEvidence, /// Whether both workload markers were removed. pub markers_removed: bool, /// Whether executor shutdown removed its private transient root. @@ -194,6 +238,7 @@ impl NativeLinuxMultiContainerSmokeReport { bundles_loaded: false, service_operations: Vec::new(), lifecycle: MultiContainerLifecycleEvidence::default(), + namespace_join: NamespaceJoinEvidence::default(), markers_removed: false, executor_runtime_clean: false, session_root_clean: false, @@ -237,6 +282,7 @@ impl NativeLinuxMultiContainerSmokeReport { oom_killed: false, }) && self.lifecycle.wait_status_b == self.lifecycle.wait_status_a + && self.namespace_join.is_success() && self.markers_removed && self.executor_runtime_clean && self.session_root_clean @@ -256,6 +302,8 @@ pub struct OciVmMultiContainerSmokeReport { pub bundles_loaded: bool, /// Per-container generation, replay, isolation, and lifecycle evidence. pub lifecycle: MultiContainerLifecycleEvidence, + /// Existing-namespace type validation, joins, and lifecycle evidence. + pub namespace_join: NamespaceJoinEvidence, /// Whether both workload markers were removed. pub markers_removed: bool, /// Whether VM shutdown left no new guest-agent runtime directory. @@ -275,6 +323,7 @@ impl OciVmMultiContainerSmokeReport { status: CapabilityStatus::Unavailable, bundles_loaded: false, lifecycle: MultiContainerLifecycleEvidence::default(), + namespace_join: NamespaceJoinEvidence::default(), markers_removed: false, guest_runtime_clean: false, bridge: AgentVmSmokeReport::initial(platform), @@ -317,6 +366,7 @@ impl OciVmMultiContainerSmokeReport { oom_killed: false, }) && self.lifecycle.wait_status_b == self.lifecycle.wait_status_a + && self.namespace_join.is_success() && self.markers_removed && self.guest_runtime_clean && self.bridge.is_success() @@ -327,7 +377,7 @@ impl OciVmMultiContainerSmokeReport { mod tests { use a3s_oci_sdk::ExitStatus; - use super::MultiContainerLifecycleEvidence; + use super::{MultiContainerLifecycleEvidence, NamespaceJoinEvidence}; #[test] fn multi_container_success_requires_every_isolation_invariant() { @@ -339,6 +389,25 @@ mod tests { assert!(!incomplete.is_success()); } + #[test] + fn namespace_join_success_requires_positive_negative_and_cleanup_evidence() { + let complete = NamespaceJoinEvidence { + donor_pid: Some(303), + wrong_type_rejected_before_state: true, + joined_non_mount_namespaces: true, + joined_pid_time_workload_verified: true, + joined_mount_namespace: true, + retained_rootfs_verified: true, + donor_unchanged_after_joins: true, + all_state_removed: true, + }; + assert!(complete.is_success()); + + let mut incomplete = complete; + incomplete.wrong_type_rejected_before_state = false; + assert!(!incomplete.is_success()); + } + fn complete_lifecycle() -> MultiContainerLifecycleEvidence { MultiContainerLifecycleEvidence { distinct_bundle_directories: true, diff --git a/crates/runtime/src/namespace_join.rs b/crates/runtime/src/namespace_join.rs new file mode 100644 index 0000000..5f3ee0e --- /dev/null +++ b/crates/runtime/src/namespace_join.rs @@ -0,0 +1,200 @@ +use std::path::Path; + +use a3s_oci_sdk::OciBundle; +use serde_json::{json, Map, Value}; + +pub(crate) struct NamespaceJoinBundles { + pub(crate) non_mount: OciBundle, + pub(crate) mount: OciBundle, + pub(crate) wrong_type: OciBundle, +} + +pub(crate) fn build_bundles( + base: &OciBundle, + donor_pid: i32, +) -> Result { + if donor_pid <= 0 { + return Err(format!( + "namespace donor PID must be positive, received {donor_pid}" + )); + } + let base_config: Value = serde_json::from_str(base.config_json()) + .map_err(|error| format!("failed to decode namespace join base config: {error}"))?; + + let mut non_mount = base_config.clone(); + replace_linux_profile(&mut non_mount, joined_non_mount_namespaces(donor_pid))?; + replace_process_command(&mut non_mount, join_workload_command())?; + + let mut mount = base_config.clone(); + remove_root_fields(&mut mount, &["hostname", "domainname", "mounts"])?; + replace_linux_profile( + &mut mount, + vec![json!({ + "type": "mount", + "path": namespace_path(donor_pid, "mnt") + })], + )?; + replace_process_command(&mut mount, join_workload_command())?; + + let mut wrong_type = base_config; + remove_root_fields(&mut wrong_type, &["hostname", "domainname", "mounts"])?; + replace_linux_profile( + &mut wrong_type, + vec![json!({ + "type": "uts", + "path": namespace_path(donor_pid, "net") + })], + )?; + + Ok(NamespaceJoinBundles { + non_mount: bundle_from_value(base.directory(), non_mount)?, + mount: bundle_from_value(base.directory(), mount)?, + wrong_type: bundle_from_value(base.directory(), wrong_type)?, + }) +} + +fn joined_non_mount_namespaces(donor_pid: i32) -> Vec { + [ + ("time", "time"), + ("pid", "pid"), + ("user", "user"), + ("mount", ""), + ("network", "net"), + ("ipc", "ipc"), + ("cgroup", "cgroup"), + ("uts", "uts"), + ] + .into_iter() + .map(|(namespace_type, proc_name)| { + if proc_name.is_empty() { + json!({"type": namespace_type}) + } else { + json!({ + "type": namespace_type, + "path": namespace_path(donor_pid, proc_name) + }) + } + }) + .collect() +} + +fn replace_linux_profile(config: &mut Value, namespaces: Vec) -> Result<(), String> { + let root = object_mut(config, "config")?; + let linux = root + .entry("linux") + .or_insert_with(|| Value::Object(Map::new())); + let linux = object_mut(linux, "linux")?; + linux.clear(); + linux.insert("namespaces".to_string(), Value::Array(namespaces)); + Ok(()) +} + +fn remove_root_fields(config: &mut Value, fields: &[&str]) -> Result<(), String> { + let root = object_mut(config, "config")?; + for field in fields { + root.remove(*field); + } + Ok(()) +} + +fn replace_process_command(config: &mut Value, replacement: String) -> Result<(), String> { + *process_command_mut(config)? = replacement; + Ok(()) +} + +fn process_command_mut(config: &mut Value) -> Result<&mut String, String> { + let command = config + .get_mut("process") + .and_then(Value::as_object_mut) + .and_then(|process| process.get_mut("args")) + .and_then(Value::as_array_mut) + .and_then(|args| args.get_mut(2)) + .ok_or_else(|| { + "namespace join fixture requires process.args[2] to be a shell command".to_string() + })?; + match command { + Value::String(command) => Ok(command), + _ => Err("namespace join fixture requires process.args[2] to be a shell command".into()), + } +} + +fn object_mut<'a>(value: &'a mut Value, field: &str) -> Result<&'a mut Map, String> { + value + .as_object_mut() + .ok_or_else(|| format!("namespace join {field} must be an object")) +} + +fn namespace_path(pid: i32, namespace: &str) -> String { + format!("/proc/{pid}/ns/{namespace}") +} + +fn join_workload_command() -> String { + "set -eu; trap 'exit 0' TERM; while :; do /bin/busybox sleep 1; done".into() +} + +fn bundle_from_value(directory: &Path, config: Value) -> Result { + let config = serde_json::to_string(&config) + .map_err(|error| format!("failed to encode namespace join config: {error}"))?; + OciBundle::from_json(directory.to_path_buf(), config) + .map_err(|error| format!("failed to validate namespace join bundle: {error}")) +} + +#[cfg(test)] +mod tests { + use a3s_oci_sdk::OciBundle; + use serde_json::Value; + + use super::build_bundles; + + const CONFIG: &str = include_str!("../../../fixtures/utility-vm/config.json"); + + #[test] + fn derives_positive_and_negative_join_profiles_from_the_qualified_bundle() { + let bundle_directory = std::env::current_dir() + .expect("current test directory") + .join("namespace-join-bundle"); + let base = OciBundle::from_json(bundle_directory, CONFIG).expect("qualified base bundle"); + let bundles = build_bundles(&base, 4242).expect("namespace join bundles"); + + let non_mount: Value = + serde_json::from_str(bundles.non_mount.config_json()).expect("non-mount JSON"); + let namespaces = non_mount["linux"]["namespaces"] + .as_array() + .expect("namespace list"); + assert_eq!(namespaces.len(), 8); + assert_eq!( + namespaces[0]["path"], + Value::String("/proc/4242/ns/time".into()) + ); + assert_eq!(namespaces[3], serde_json::json!({"type": "mount"})); + assert!(non_mount["linux"].get("uidMappings").is_none()); + assert!(non_mount["linux"].get("timeOffsets").is_none()); + assert!(non_mount["process"]["args"][2] + .as_str() + .expect("join command") + .contains("/bin/busybox sleep 1")); + + let mount: Value = serde_json::from_str(bundles.mount.config_json()).expect("mount JSON"); + assert_eq!( + mount["linux"]["namespaces"], + serde_json::json!([ + {"type": "mount", "path": "/proc/4242/ns/mnt"} + ]) + ); + assert!(mount.get("mounts").is_none()); + assert!(mount.get("hostname").is_none()); + assert!(mount["process"]["args"][2] + .as_str() + .expect("mount command") + .contains("/bin/busybox sleep 1")); + + let wrong_type: Value = + serde_json::from_str(bundles.wrong_type.config_json()).expect("wrong-type JSON"); + assert_eq!( + wrong_type["linux"]["namespaces"], + serde_json::json!([ + {"type": "uts", "path": "/proc/4242/ns/net"} + ]) + ); + } +} diff --git a/crates/runtime/src/native_smoke/linux/multi_container.rs b/crates/runtime/src/native_smoke/linux/multi_container.rs index bcbcf5e..fb7f241 100644 --- a/crates/runtime/src/native_smoke/linux/multi_container.rs +++ b/crates/runtime/src/native_smoke/linux/multi_container.rs @@ -13,6 +13,7 @@ use crate::{ }; mod lifecycle; +mod namespace_join; use lifecycle::{best_effort_delete, exercise}; @@ -136,13 +137,25 @@ pub(super) async fn run( }; let client = RuntimeClient::new(service.clone()); - let exercise = exercise( - &client, - [&bundle_a, &bundle_b], - &nonce, - [&marker_a, &marker_b], - &mut report, - ) + let exercise = async { + exercise( + &client, + [&bundle_a, &bundle_b], + &nonce, + [&marker_a, &marker_b], + &mut report, + ) + .await?; + namespace_join::exercise( + &client, + &bundle_a, + &bundle_b, + &nonce, + [&marker_a, &marker_b], + &mut report, + ) + .await + } .await; if exercise.is_err() { best_effort_delete(&client, &nonce).await; diff --git a/crates/runtime/src/native_smoke/linux/multi_container/lifecycle.rs b/crates/runtime/src/native_smoke/linux/multi_container/lifecycle.rs index 8db9440..ced0832 100644 --- a/crates/runtime/src/native_smoke/linux/multi_container/lifecycle.rs +++ b/crates/runtime/src/native_smoke/linux/multi_container/lifecycle.rs @@ -296,7 +296,7 @@ pub(super) async fn exercise( Ok(()) } -fn wait_request(target: ContainerTarget) -> WaitRequest { +pub(super) fn wait_request(target: ContainerTarget) -> WaitRequest { WaitRequest { target, timeout_ms: Some(15_000), @@ -340,7 +340,14 @@ async fn wait_does_not_block_state( } pub(super) async fn best_effort_delete(client: &RuntimeClient, nonce: &str) { - for label in ["a", "b"] { + for label in [ + "a", + "b", + "namespace-donor", + "namespace-wrong-type", + "namespace-non-mount", + "namespace-mount", + ] { let (Ok(id), Ok(context)) = ( container_id(nonce, label), operation(nonce, &format!("{label}-cleanup")), @@ -359,7 +366,7 @@ pub(super) async fn best_effort_delete(client: &RuntimeClient, nonce: &str) { } } -fn create_request( +pub(super) fn create_request( nonce: &str, operation_name: &str, id: ContainerId, @@ -374,7 +381,7 @@ fn create_request( }) } -fn kill_request( +pub(super) fn kill_request( nonce: &str, operation_name: &str, target: ContainerTarget, @@ -400,7 +407,7 @@ async fn assert_state( ) } -async fn state_equals( +pub(super) async fn state_equals( client: &RuntimeClient, target: &ContainerTarget, expected: &ContainerRecord, @@ -416,7 +423,7 @@ async fn state_equals( Ok(&observed == expected) } -async fn wait_for_marker( +pub(super) async fn wait_for_marker( client: &RuntimeClient, target: &ContainerTarget, marker: &Path, @@ -450,7 +457,7 @@ async fn wait_for_marker( } } -async fn wait_until_stopped( +pub(super) async fn wait_until_stopped( client: &RuntimeClient, target: &ContainerTarget, ) -> Result { @@ -478,7 +485,7 @@ async fn wait_until_stopped( } } -async fn state_is_missing( +pub(super) async fn state_is_missing( client: &RuntimeClient, target: &ContainerTarget, description: &str, @@ -532,7 +539,7 @@ async fn operation_conflicts( } } -async fn native_call( +pub(super) async fn native_call( operation_name: &str, future: impl Future>, ) -> Result { @@ -543,21 +550,24 @@ async fn native_call( } } -fn require_created(record: &ContainerRecord, description: &str) -> Result<(), String> { +pub(super) fn require_created(record: &ContainerRecord, description: &str) -> Result<(), String> { require( *record.state.status() == ContainerState::Created, format!("{description} did not preserve the created barrier"), ) } -fn require_running(record: &ContainerRecord, description: &str) -> Result<(), String> { +pub(super) fn require_running(record: &ContainerRecord, description: &str) -> Result<(), String> { require( *record.state.status() == ContainerState::Running, format!("{description} did not enter running"), ) } -fn require_kill_state(record: &ContainerRecord, description: &str) -> Result<(), String> { +pub(super) fn require_kill_state( + record: &ContainerRecord, + description: &str, +) -> Result<(), String> { require( matches!( *record.state.status(), @@ -567,7 +577,7 @@ fn require_kill_state(record: &ContainerRecord, description: &str) -> Result<(), ) } -fn require(condition: bool, message: impl Into) -> Result<(), String> { +pub(super) fn require(condition: bool, message: impl Into) -> Result<(), String> { if condition { Ok(()) } else { @@ -575,18 +585,18 @@ fn require(condition: bool, message: impl Into) -> Result<(), String> { } } -fn container_id(nonce: &str, label: &str) -> Result { +pub(super) fn container_id(nonce: &str, label: &str) -> Result { ContainerId::new(format!("native-multi-{label}-{nonce}")) .map_err(|error| format!("failed to construct container {label} ID: {error}")) } -fn operation(nonce: &str, name: &str) -> Result { +pub(super) fn operation(nonce: &str, name: &str) -> Result { let id = OperationId::new(format!("native-multi-{nonce}-{name}")) .map_err(|error| format!("failed to construct {name} operation ID: {error}"))?; Ok(OperationContext::new(id)) } -fn null_io() -> ProcessIo { +pub(super) fn null_io() -> ProcessIo { ProcessIo { stdin: a3s_oci_sdk::IoMode::Null, stdout: a3s_oci_sdk::IoMode::Null, diff --git a/crates/runtime/src/native_smoke/linux/multi_container/namespace_join.rs b/crates/runtime/src/native_smoke/linux/multi_container/namespace_join.rs new file mode 100644 index 0000000..264d2d7 --- /dev/null +++ b/crates/runtime/src/native_smoke/linux/multi_container/namespace_join.rs @@ -0,0 +1,234 @@ +use std::path::Path; +use std::time::Duration; + +use a3s_oci_sdk::{ + ContainerTarget, DeleteMode, DeleteRequest, ErrorCode, ExitStatus, OciBundle, RuntimeClient, + StartRequest, WaitRequest, +}; +use tokio::time::timeout; + +use super::super::filesystem::remove_marker; +use super::lifecycle::{ + container_id, create_request, kill_request, native_call, operation, require, require_created, + require_kill_state, require_running, state_equals, state_is_missing, wait_request, + wait_until_stopped, +}; +use crate::namespace_join::build_bundles; +use crate::NativeLinuxMultiContainerSmokeReport; + +const CALL_TIMEOUT: Duration = Duration::from_secs(15); + +pub(super) async fn exercise( + client: &RuntimeClient, + donor_bundle: &OciBundle, + joiner_bundle: &OciBundle, + nonce: &str, + markers: [&Path; 2], + report: &mut NativeLinuxMultiContainerSmokeReport, +) -> Result<(), String> { + remove_marker(markers[0]).await?; + remove_marker(markers[1]).await?; + + let donor_id = container_id(nonce, "namespace-donor")?; + let donor_create = create_request( + nonce, + "namespace-donor-create", + donor_id.clone(), + donor_bundle, + )?; + let donor = native_call("create namespace donor", client.create(donor_create)).await?; + require_created(&donor, "namespace donor")?; + let donor_target = ContainerTarget::exact(donor_id, donor.generation); + let donor_pid = donor + .state + .pid() + .filter(|pid| *pid > 0) + .ok_or_else(|| "namespace donor did not report a positive PID".to_string())?; + report.namespace_join.donor_pid = Some(donor_pid); + + let bundles = build_bundles(joiner_bundle, donor_pid)?; + verify_wrong_type_rejection(client, &bundles.wrong_type, nonce).await?; + report.namespace_join.wrong_type_rejected_before_state = true; + + let non_mount_id = container_id(nonce, "namespace-non-mount")?; + let non_mount_create = create_request( + nonce, + "namespace-non-mount-create", + non_mount_id.clone(), + &bundles.non_mount, + )?; + let non_mount = native_call( + "create non-mount namespace joiner", + client.create(non_mount_create), + ) + .await?; + require_created(&non_mount, "non-mount namespace joiner")?; + report.namespace_join.joined_non_mount_namespaces = true; + let non_mount_target = ContainerTarget::exact(non_mount_id, non_mount.generation); + run_joiner(client, nonce, "namespace-non-mount", &non_mount_target).await?; + report.namespace_join.joined_pid_time_workload_verified = true; + + let mount_id = container_id(nonce, "namespace-mount")?; + let mount_create = create_request( + nonce, + "namespace-mount-create", + mount_id.clone(), + &bundles.mount, + )?; + let mount = native_call("create mount namespace joiner", client.create(mount_create)).await?; + require_created(&mount, "mount namespace joiner")?; + report.namespace_join.joined_mount_namespace = true; + let mount_target = ContainerTarget::exact(mount_id, mount.generation); + run_joiner(client, nonce, "namespace-mount", &mount_target).await?; + report.namespace_join.retained_rootfs_verified = true; + + report.namespace_join.donor_unchanged_after_joins = state_equals( + client, + &donor_target, + &donor, + "namespace donor after joiners", + ) + .await?; + require( + report.namespace_join.donor_unchanged_after_joins, + "namespace joiners changed the prepared donor state", + )?; + + native_call( + "delete namespace donor", + client.delete(DeleteRequest { + context: operation(nonce, "namespace-donor-delete")?, + target: donor_target.clone(), + mode: DeleteMode::Force, + }), + ) + .await?; + report.namespace_join.all_state_removed = + state_is_missing(client, &donor_target, "namespace donor after delete").await? + && state_is_missing( + client, + &non_mount_target, + "non-mount namespace joiner after delete", + ) + .await? + && state_is_missing(client, &mount_target, "mount namespace joiner after delete") + .await?; + require( + report.namespace_join.all_state_removed, + "namespace join qualification left container state", + ) +} + +async fn verify_wrong_type_rejection( + client: &RuntimeClient, + bundle: &OciBundle, + nonce: &str, +) -> Result<(), String> { + let id = container_id(nonce, "namespace-wrong-type")?; + let create = create_request(nonce, "namespace-wrong-type-create", id.clone(), bundle)?; + match timeout(CALL_TIMEOUT, client.create(create)).await { + Ok(Err(error)) if error.code == ErrorCode::InvalidArgument => {} + Ok(Err(error)) => { + return Err(format!( + "wrong namespace type returned {:?}, expected InvalidArgument: {}", + error.code, error.message + )); + } + Ok(Ok(_)) => return Err("wrong namespace type unexpectedly created a container".into()), + Err(_) => return Err("wrong namespace type create timed out".into()), + } + let target = ContainerTarget::current(id); + require( + state_is_missing(client, &target, "wrong namespace type after rejection").await?, + "wrong namespace type rejection left container state", + ) +} + +async fn run_joiner( + client: &RuntimeClient, + nonce: &str, + label: &str, + target: &ContainerTarget, +) -> Result<(), String> { + let started = native_call( + &format!("start {label} joiner"), + client.start(StartRequest { + context: operation(nonce, &format!("{label}-start"))?, + target: target.clone(), + }), + ) + .await?; + require_running(&started, label)?; + match timeout( + CALL_TIMEOUT, + client.wait(WaitRequest { + target: target.clone(), + timeout_ms: Some(300), + }), + ) + .await + { + Ok(Err(error)) if error.code == ErrorCode::DeadlineExceeded => {} + Ok(Err(error)) => { + return Err(format!( + "{label} bounded running wait failed with {:?}: {}", + error.code, error.message + )); + } + Ok(Ok(status)) => { + return Err(format!( + "{label} exited during the running observation window: {status:?}" + )); + } + Err(_) => return Err(format!("{label} bounded running wait call timed out")), + } + require( + state_equals( + client, + target, + &started, + &format!("{label} after bounded running wait"), + ) + .await?, + format!("{label} did not remain running after exec"), + )?; + + let killed = native_call( + &format!("kill {label} joiner"), + client.kill(kill_request( + nonce, + &format!("{label}-kill"), + target.clone(), + )?), + ) + .await?; + require_kill_state(&killed, label)?; + let waited = native_call( + &format!("wait for {label} joiner"), + client.wait(wait_request(target.clone())), + ) + .await?; + require( + waited + == ExitStatus::signaled(libc::SIGKILL, false) + .map_err(|error| format!("failed to construct expected joiner exit: {error}"))?, + format!("{label} joiner returned unexpected exit status {waited:?}"), + )?; + require( + wait_until_stopped(client, target).await?, + format!("{label} joiner did not stop"), + )?; + native_call( + &format!("delete {label} joiner"), + client.delete(DeleteRequest { + context: operation(nonce, &format!("{label}-delete"))?, + target: target.clone(), + mode: DeleteMode::StoppedOnly, + }), + ) + .await?; + require( + state_is_missing(client, target, &format!("{label} after delete")).await?, + format!("{label} joiner remained visible after delete"), + ) +} diff --git a/crates/runtime/src/oci_smoke/utility_vm/multi_container.rs b/crates/runtime/src/oci_smoke/utility_vm/multi_container.rs index 29ec99e..f4b9645 100644 --- a/crates/runtime/src/oci_smoke/utility_vm/multi_container.rs +++ b/crates/runtime/src/oci_smoke/utility_vm/multi_container.rs @@ -11,6 +11,7 @@ use crate::agent_session::AgentVmSession; use crate::OciVmMultiContainerSmokeReport; mod lifecycle; +mod namespace_join; use lifecycle::{best_effort_delete, exercise}; @@ -136,14 +137,27 @@ pub(super) async fn run( } }; - let exercise = exercise( - session.client(), - [&bundle_a, &bundle_b], - guest_bundles, - &nonce, - [&markers[0], &markers[1]], - &mut report, - ) + let exercise = async { + exercise( + session.client(), + [&bundle_a, &bundle_b], + guest_bundles.clone(), + &nonce, + [&markers[0], &markers[1]], + &mut report, + ) + .await?; + namespace_join::exercise( + session.client(), + &bundle_a, + &bundle_b, + guest_bundles, + &nonce, + [&markers[0], &markers[1]], + &mut report, + ) + .await + } .await; if exercise.is_err() { best_effort_delete(session.client(), &nonce).await; diff --git a/crates/runtime/src/oci_smoke/utility_vm/multi_container/lifecycle.rs b/crates/runtime/src/oci_smoke/utility_vm/multi_container/lifecycle.rs index 7a9f7d4..77f4016 100644 --- a/crates/runtime/src/oci_smoke/utility_vm/multi_container/lifecycle.rs +++ b/crates/runtime/src/oci_smoke/utility_vm/multi_container/lifecycle.rs @@ -315,7 +315,7 @@ pub(super) async fn exercise( Ok(()) } -fn wait_request(target: ContainerTarget) -> AgentWaitRequest { +pub(super) fn wait_request(target: ContainerTarget) -> AgentWaitRequest { AgentWaitRequest { target, timeout_ms: Some(15_000), @@ -359,41 +359,35 @@ async fn wait_does_not_block_state( } pub(super) async fn best_effort_delete(client: &AgentClient, nonce: &str) { - for label in ["a", "b"] { - let (Ok(generation_two), Ok(context)) = ( - target(nonce, label, 2), - operation(nonce, &format!("{label}-cleanup")), - ) else { - continue; - }; - let _ = timeout( - CALL_TIMEOUT, - client.delete(AgentDeleteRequest { - context, - target: generation_two, - mode: DeleteMode::Force, - }), - ) - .await; - let Ok(generation_one) = target(nonce, label, 1) else { - continue; - }; - let Ok(context) = operation(nonce, &format!("{label}-cleanup-1")) else { - continue; - }; - let _ = timeout( - CALL_TIMEOUT, - client.delete(AgentDeleteRequest { - context, - target: generation_one, - mode: DeleteMode::Force, - }), - ) - .await; + for label in [ + "a", + "b", + "namespace-donor", + "namespace-wrong-type", + "namespace-non-mount", + "namespace-mount", + ] { + for generation in [2, 1] { + let (Ok(target), Ok(context)) = ( + target(nonce, label, generation), + operation(nonce, &format!("{label}-cleanup-{generation}")), + ) else { + continue; + }; + let _ = timeout( + CALL_TIMEOUT, + client.delete(AgentDeleteRequest { + context, + target, + mode: DeleteMode::Force, + }), + ) + .await; + } } } -fn create_request( +pub(super) fn create_request( nonce: &str, operation_name: &str, target: ContainerTarget, @@ -408,7 +402,7 @@ fn create_request( }) } -fn start_request( +pub(super) fn start_request( nonce: &str, operation_name: &str, target: ContainerTarget, @@ -421,7 +415,7 @@ fn start_request( }) } -fn kill_request( +pub(super) fn kill_request( nonce: &str, operation_name: &str, target: ContainerTarget, @@ -460,7 +454,7 @@ async fn assert_state( ) } -async fn state_equals( +pub(super) async fn state_equals( client: &AgentClient, target: &ContainerTarget, expected: &AgentState, @@ -476,7 +470,7 @@ async fn state_equals( Ok(&observed == expected) } -async fn wait_for_marker( +pub(super) async fn wait_for_marker( client: &AgentClient, target: &ContainerTarget, marker: &Path, @@ -510,7 +504,7 @@ async fn wait_for_marker( } } -async fn wait_until_stopped( +pub(super) async fn wait_until_stopped( client: &AgentClient, target: &ContainerTarget, ) -> Result { @@ -538,7 +532,7 @@ async fn wait_until_stopped( } } -async fn state_is_missing( +pub(super) async fn state_is_missing( client: &AgentClient, target: &ContainerTarget, description: &str, @@ -577,7 +571,7 @@ async fn operation_conflicts( } } -async fn guest_call( +pub(super) async fn guest_call( operation_name: &str, future: impl Future>, ) -> Result { @@ -588,21 +582,21 @@ async fn guest_call( } } -fn require_created(state: &AgentState, description: &str) -> Result<(), String> { +pub(super) fn require_created(state: &AgentState, description: &str) -> Result<(), String> { require( state.status() == ContainerState::Created, format!("{description} did not preserve the created barrier"), ) } -fn require_running(state: &AgentState, description: &str) -> Result<(), String> { +pub(super) fn require_running(state: &AgentState, description: &str) -> Result<(), String> { require( state.status() == ContainerState::Running, format!("{description} did not enter running"), ) } -fn require_kill_state(state: &AgentState, description: &str) -> Result<(), String> { +pub(super) fn require_kill_state(state: &AgentState, description: &str) -> Result<(), String> { require( matches!( state.status(), @@ -612,7 +606,7 @@ fn require_kill_state(state: &AgentState, description: &str) -> Result<(), Strin ) } -fn require(condition: bool, message: impl Into) -> Result<(), String> { +pub(super) fn require(condition: bool, message: impl Into) -> Result<(), String> { if condition { Ok(()) } else { @@ -620,13 +614,13 @@ fn require(condition: bool, message: impl Into) -> Result<(), String> { } } -fn target(nonce: &str, label: &str, generation: u64) -> Result { +pub(super) fn target(nonce: &str, label: &str, generation: u64) -> Result { let id = ContainerId::new(format!("smoke-multi-{label}-{nonce}")) .map_err(|error| format!("failed to construct container {label} ID: {error}"))?; Ok(ContainerTarget::exact(id, Generation(generation))) } -fn operation(nonce: &str, name: &str) -> Result { +pub(super) fn operation(nonce: &str, name: &str) -> Result { let id = OperationId::new(format!("smoke-multi-{nonce}-{name}")) .map_err(|error| format!("failed to construct {name} operation ID: {error}"))?; Ok(OperationContext::new(id)) diff --git a/crates/runtime/src/oci_smoke/utility_vm/multi_container/namespace_join.rs b/crates/runtime/src/oci_smoke/utility_vm/multi_container/namespace_join.rs new file mode 100644 index 0000000..8dfdef8 --- /dev/null +++ b/crates/runtime/src/oci_smoke/utility_vm/multi_container/namespace_join.rs @@ -0,0 +1,256 @@ +use std::path::Path; +use std::time::Duration; + +use a3s_oci_agent_protocol::{AgentClient, AgentDeleteRequest, AgentWaitRequest, GuestPath}; +use a3s_oci_sdk::{DeleteMode, ErrorCode, ExitStatus, OciBundle}; +use tokio::time::timeout; + +use super::super::remove_marker; +use super::lifecycle::{ + create_request, guest_call, kill_request, operation, require, require_created, + require_kill_state, require_running, start_request, state_equals, state_is_missing, target, + wait_request, wait_until_stopped, AgentStream, +}; +use crate::namespace_join::build_bundles; +use crate::OciVmMultiContainerSmokeReport; + +const CALL_TIMEOUT: Duration = Duration::from_secs(15); + +pub(super) async fn exercise( + client: &AgentClient, + donor_bundle: &OciBundle, + joiner_bundle: &OciBundle, + guest_bundles: [GuestPath; 2], + nonce: &str, + markers: [&Path; 2], + report: &mut OciVmMultiContainerSmokeReport, +) -> Result<(), String> { + remove_marker(markers[0]).await?; + remove_marker(markers[1]).await?; + + let donor_target = target(nonce, "namespace-donor", 1)?; + let donor_create = create_request( + nonce, + "namespace-donor-create", + donor_target.clone(), + donor_bundle, + guest_bundles[0].clone(), + )?; + let donor = guest_call("create namespace donor", client.create(donor_create)).await?; + require_created(&donor, "namespace donor")?; + let donor_pid = donor + .pid() + .filter(|pid| *pid > 0) + .ok_or_else(|| "namespace donor did not report a positive PID".to_string())?; + report.namespace_join.donor_pid = Some(donor_pid); + + let bundles = build_bundles(joiner_bundle, donor_pid)?; + verify_wrong_type_rejection(client, &bundles.wrong_type, guest_bundles[1].clone(), nonce) + .await?; + report.namespace_join.wrong_type_rejected_before_state = true; + + let non_mount_target = target(nonce, "namespace-non-mount", 1)?; + let non_mount_create = create_request( + nonce, + "namespace-non-mount-create", + non_mount_target.clone(), + &bundles.non_mount, + guest_bundles[1].clone(), + )?; + let non_mount = guest_call( + "create non-mount namespace joiner", + client.create(non_mount_create), + ) + .await?; + require_created(&non_mount, "non-mount namespace joiner")?; + report.namespace_join.joined_non_mount_namespaces = true; + run_joiner( + client, + nonce, + "namespace-non-mount", + &non_mount_target, + &bundles.non_mount, + ) + .await?; + report.namespace_join.joined_pid_time_workload_verified = true; + + let mount_target = target(nonce, "namespace-mount", 1)?; + let mount_create = create_request( + nonce, + "namespace-mount-create", + mount_target.clone(), + &bundles.mount, + guest_bundles[1].clone(), + )?; + let mount = guest_call("create mount namespace joiner", client.create(mount_create)).await?; + require_created(&mount, "mount namespace joiner")?; + report.namespace_join.joined_mount_namespace = true; + run_joiner( + client, + nonce, + "namespace-mount", + &mount_target, + &bundles.mount, + ) + .await?; + report.namespace_join.retained_rootfs_verified = true; + + report.namespace_join.donor_unchanged_after_joins = state_equals( + client, + &donor_target, + &donor, + "namespace donor after joiners", + ) + .await?; + require( + report.namespace_join.donor_unchanged_after_joins, + "namespace joiners changed the prepared donor state", + )?; + + guest_call( + "delete namespace donor", + client.delete(AgentDeleteRequest { + context: operation(nonce, "namespace-donor-delete")?, + target: donor_target.clone(), + mode: DeleteMode::Force, + }), + ) + .await?; + report.namespace_join.all_state_removed = + state_is_missing(client, &donor_target, "namespace donor after delete").await? + && state_is_missing( + client, + &non_mount_target, + "non-mount namespace joiner after delete", + ) + .await? + && state_is_missing(client, &mount_target, "mount namespace joiner after delete") + .await?; + require( + report.namespace_join.all_state_removed, + "namespace join qualification left container state", + ) +} + +async fn verify_wrong_type_rejection( + client: &AgentClient, + bundle: &OciBundle, + guest_bundle: GuestPath, + nonce: &str, +) -> Result<(), String> { + let target = target(nonce, "namespace-wrong-type", 1)?; + let create = create_request( + nonce, + "namespace-wrong-type-create", + target.clone(), + bundle, + guest_bundle, + )?; + match timeout(CALL_TIMEOUT, client.create(create)).await { + Ok(Err(error)) if error.code == ErrorCode::InvalidArgument => {} + Ok(Err(error)) => { + return Err(format!( + "wrong namespace type returned {:?}, expected InvalidArgument: {}", + error.code, error.message + )); + } + Ok(Ok(_)) => return Err("wrong namespace type unexpectedly created a container".into()), + Err(_) => return Err("wrong namespace type create timed out".into()), + } + require( + state_is_missing(client, &target, "wrong namespace type after rejection").await?, + "wrong namespace type rejection left container state", + ) +} + +async fn run_joiner( + client: &AgentClient, + nonce: &str, + label: &str, + target: &a3s_oci_sdk::ContainerTarget, + bundle: &OciBundle, +) -> Result<(), String> { + let started = guest_call( + &format!("start {label} joiner"), + client.start(start_request( + nonce, + &format!("{label}-start"), + target.clone(), + bundle, + )?), + ) + .await?; + require_running(&started, label)?; + match timeout( + CALL_TIMEOUT, + client.wait(AgentWaitRequest { + target: target.clone(), + timeout_ms: Some(300), + }), + ) + .await + { + Ok(Err(error)) if error.code == ErrorCode::DeadlineExceeded => {} + Ok(Err(error)) => { + return Err(format!( + "{label} bounded running wait failed with {:?}: {}", + error.code, error.message + )); + } + Ok(Ok(status)) => { + return Err(format!( + "{label} exited during the running observation window: {status:?}" + )); + } + Err(_) => return Err(format!("{label} bounded running wait call timed out")), + } + require( + state_equals( + client, + target, + &started, + &format!("{label} after bounded running wait"), + ) + .await?, + format!("{label} did not remain running after exec"), + )?; + + let killed = guest_call( + &format!("kill {label} joiner"), + client.kill(kill_request( + nonce, + &format!("{label}-kill"), + target.clone(), + )?), + ) + .await?; + require_kill_state(&killed, label)?; + let waited = guest_call( + &format!("wait for {label} joiner"), + client.wait(wait_request(target.clone())), + ) + .await?; + require( + waited + == ExitStatus::exited(0) + .map_err(|error| format!("failed to construct expected joiner exit: {error}"))?, + format!("{label} joiner returned unexpected exit status {waited:?}"), + )?; + require( + wait_until_stopped(client, target).await?, + format!("{label} joiner did not stop"), + )?; + guest_call( + &format!("delete {label} joiner"), + client.delete(AgentDeleteRequest { + context: operation(nonce, &format!("{label}-delete"))?, + target: target.clone(), + mode: DeleteMode::StoppedOnly, + }), + ) + .await?; + require( + state_is_missing(client, target, &format!("{label} after delete")).await?, + format!("{label} joiner remained visible after delete"), + ) +} diff --git a/docs/agent-protocol.md b/docs/agent-protocol.md index a4b5a3c..ef84e6e 100644 --- a/docs/agent-protocol.md +++ b/docs/agent-protocol.md @@ -147,9 +147,12 @@ the same connection. It proves distinct runtime slots and PIDs, simultaneous create barriers, A/B transition isolation, session-local generation fencing, exact operation replay, rejection of cross-container operation-ID reuse, a bounded wait on A that does not block B state, exact repeated terminal results -for both containers, and independent pidfd-backed cleanup. Native Linux runs -the equivalent sequence through the durable SDK service around the same -executor. +for both containers, and independent pidfd-backed cleanup. Its schema-v3 +namespace phase retains a prepared donor, rejects a wrong-type namespace +descriptor before state, joins all eight Linux namespace types across two +workloads, proves retained-rootfs execution after the mount join, and removes +all state without changing the donor's created record. Native Linux runs the +equivalent sequence through the durable SDK service around the same executor. The `oci-vm-fault-cleanup` companion stops after a successful create, start, or kill request and never sends delete. Session EOF must still make the agent call @@ -171,7 +174,7 @@ pathname socket. This is the first Linux executor vertical slice, not complete OCI enforcement. A pinned immutable system image, complete process I/O, rootless -ID mapping, namespace joins, advanced mount semantics, resources, hooks, -exhaustive recovery injection, negative isolation cases, and full -platform-specific lifecycle evidence remain required before a utility-VM -driver can advance beyond `probe-only`. +ID mapping, advanced mount semantics, resources, hooks, exhaustive recovery +injection, broader negative isolation cases, and full platform-specific +lifecycle evidence remain required before a utility-VM driver can advance +beyond `probe-only`. diff --git a/docs/guest-agent.md b/docs/guest-agent.md index bec0798..276b836 100644 --- a/docs/guest-agent.md +++ b/docs/guest-agent.md @@ -43,11 +43,12 @@ The accepted bootstrap profile requires: - numeric UID, GID, optional supplementary groups, and optional umask; - bounded arguments and environment with unique environment names. -When `linux.namespaces` is present, it accepts unique, newly created UTS, -mount, IPC, network, cgroup, PID, user, and time namespace entries in any -order, with no join paths. Omitting a namespace inherits the runtime namespace -of that type. Configured hostname and domainname values are bounded to the -Linux kernel limit and require the new UTS namespace. +When `linux.namespaces` is present, it accepts unique UTS, mount, IPC, network, +cgroup, PID, user, and time namespace entries in any order. Omitting `path` +creates a namespace; an absolute `path` joins an existing namespace; omitting +the entry inherits the runtime namespace of that type. Configured hostname and +domainname values are bounded to the Linux kernel limit and require a created +or joined UTS namespace. The user-namespace profile is deliberately rootful. A new user namespace requires both `uidMappings` and `gidMappings`, each list is bounded to the @@ -58,22 +59,32 @@ namespace, writes each `/proc//{uid,gid}_map` exactly once, reads both maps back, and requires `/proc//setgroups` to remain `allow`. Rootless `setgroups=deny` and subordinate-ID helper flows are not implemented. -The wrapper then requests the configured UTS, mount, IPC, network, cgroup, PID, +Before any namespace transition, the wrapper opens every join target and +verifies its type with `NS_GET_NSTYPE`. It joins non-user namespaces, then the +user namespace, switches all UID slots to root in that namespace, and retries +non-user joins that initially lacked permission. A retained `/proc/self/ns` +directory descriptor lets it verify each resulting namespace identity even +after a joined mount namespace hides the original proc path. + +The wrapper then requests newly created UTS, mount, IPC, network, cgroup, PID, and time namespaces in one `unshare` call. Time offsets accept only normalized `monotonic` and `boottime` values; the wrapper writes and reads them back through `/proc/self/timens_offsets` before forking. A new PID or time namespace -applies to the caller's next child, so the wrapper remains as a supervisor and -forks the container init. With PID isolation that child is namespace PID 1. -The child applies and reads back hostname and domainname with `uname`. When a -mount namespace is requested, it then makes `/` recursively private, +applies to the caller's next child; joined PID and time namespaces have the +same next-child execution requirement. The wrapper therefore remains as a +supervisor and forks the container init whenever either type is configured. +With a newly created PID namespace that child is namespace PID 1. The child +applies and reads back hostname and domainname with `uname`. When a new mount +namespace is requested, it then makes `/` recursively private, recursively bind-mounts the rootfs onto itself, applies every configured mount in listed order, and uses `pivot_root(".", ".")` followed by a detached unmount of the old root. All of this succeeds before readiness is reported, so namespace, mount, and rootfs -isolation are part of the create barrier. When a mount namespace is omitted, -the wrapper preserves the inherited namespace and uses the compatible -`chroot` path after start; mount entries are rejected on that path to prevent -changes from escaping into the agent's runtime mount namespace. +isolation are part of the create barrier. When the mount namespace is inherited +or joined, the wrapper uses `fchdir` plus `chroot` through a rootfs directory +descriptor retained before namespace entry. Mount entries are rejected on +that path to prevent changes from escaping into a shared or donor mount +namespace. The current mount slice: @@ -131,12 +142,12 @@ closed host connection force-stops remaining init processes and removes the agent-owned runtime root. Agent restart recovery is not implemented yet. The executor requires both `pidfd_open` and `pidfd_send_signal`. It currently -rejects mount-target creation, rootfs propagation overrides, idmapped and -recursive-attribute mounts, all namespace joins, rootless user-mapping policy, -cgroup resources, capabilities, seccomp, hooks, read-only rootfs, terminals, -non-null I/O, process-group signals, and every other unimplemented OCI -property. These are release blockers, not silently accepted compatibility -gaps. +rejects mount entries in inherited or joined mount namespaces, mount-target +creation, rootfs propagation overrides, idmapped and recursive-attribute +mounts, rootless user-mapping policy, cgroup resources, capabilities, seccomp, +hooks, read-only rootfs, terminals, non-null I/O, process-group signals, and +every other unimplemented OCI property. These are release blockers, not +silently accepted compatibility gaps. ## Build And Evidence @@ -161,7 +172,10 @@ wait, delete, recreation, stale generation, and replay conflicts do not alter or block B, then completes B independently. The macOS HVF gate sends both init signals through distinct retained pidfds and retains both exact repeated exit statuses and per-container markers together with guest-runtime and -host-process cleanup evidence. +host-process cleanup evidence. Schema v3 then retains a prepared donor and +qualifies wrong-type rejection plus UTS, mount, IPC, network, cgroup, PID, +user, and time joins. Both joiner workloads must cross `exec`, remain running +for a bounded observation window, stop cleanly, and leave the donor unchanged. `a3s-oci oci-vm-fault-cleanup` stops after create, start, or kill, explicitly records that delete was not attempted, and requires guest executor shutdown to diff --git a/docs/linux-native.md b/docs/linux-native.md index 36a8422..15af46d 100644 --- a/docs/linux-native.md +++ b/docs/linux-native.md @@ -170,12 +170,25 @@ sudo target/debug/a3s-oci native-linux-multi-container-smoke \ --work-parent "$work_parent" ``` -The `a3s.oci.native-linux-multi-container-smoke.v2` success additionally +The `a3s.oci.native-linux-multi-container-smoke.v3` success additionally requires exact create/start/kill/delete replay, stable repeated wait results, independent wait/state progress, both marker removals, executor shutdown, and -complete durable-session removal. GitHub Actions runs the gate on x86_64 and -aarch64 both without `/dev/kvm` and with a present but unusable placeholder at -that path. +complete durable-session removal. It then keeps a prepared donor behind its +create barrier and requires: + +1. a namespace descriptor whose type disagrees with its OCI entry to fail + before container state; +2. one workload to join the donor UTS, IPC, network, cgroup, PID, user, and + time namespaces while retaining a private mount namespace; +3. a second workload to join the donor mount namespace and execute through the + rootfs descriptor retained before `setns`; +4. PID/time joins to cross `exec` and remain running for a bounded observation + window; +5. both joiners to complete without changing the donor's created state; +6. all donor, joiner, and negative-case state to be removed. + +GitHub Actions runs the gate on x86_64 and aarch64 both without `/dev/kvm` and +with a present but unusable placeholder at that path. ## Fault-injected shutdown cleanup @@ -214,7 +227,8 @@ The default driver must remain `probe-only` until at least the following pass: - rootless lifecycle using subordinate UID/GID mappings and the `setgroups=deny` flow; -- namespace joins and lifecycle-level namespace security-negative cases; +- broader namespace-join security negatives, donor teardown races, and + restart recovery beyond the retained wrong-type pre-state rejection; - complete mount, credential, capability, seccomp, LSM, and cgroup v2 enforcement; - namespace-internal init supervision and orphan/zombie reaping, exec, diff --git a/docs/macos-hvf.md b/docs/macos-hvf.md index 2286fb8..11195e0 100644 --- a/docs/macos-hvf.md +++ b/docs/macos-hvf.md @@ -459,10 +459,18 @@ target/debug/a3s-oci oci-vm-multi-container-smoke \ --console "$asset_dir/oci-multi-container.log" ``` -The `a3s.oci.oci-vm-multi-container-smoke.v2` report also requires exact +The `a3s.oci.oci-vm-multi-container-smoke.v3` report also requires exact mutation replay, exact repeated normal-exit results for A and B, independent -wait/state progress, both marker removals, no new guest runtime root, exact -host endpoint removal, shim and direct VM-worker reap, and full +wait/state progress, and an existing-namespace phase. That phase rejects a +wrong-type descriptor before state, joins donor UTS, IPC, network, cgroup, PID, +user, and time namespaces while retaining a private mount namespace, then +joins the donor mount namespace in a second workload. Both workloads must +cross `exec`, remain running for a bounded observation window, stop with the +expected status, leave the donor created record unchanged, and remove all +state. + +The complete report also requires both marker removals, no new guest runtime +root, exact host endpoint removal, shim and direct VM-worker reap, and full descriptor-inventory restoration. The Apple Silicon HVF qualification and macOS CI both run this gate; an unavailable-hypervisor branch must fail before negotiation while retaining the same host cleanup evidence. @@ -482,6 +490,16 @@ The fixed lifecycle, two-container lifecycle, and all three no-delete cleanup phases passed on Apple Silicon HVF only after the workload verified its exact UID/GID maps and monotonic/boottime offsets. +The existing-namespace requalification used the 8,632,448-byte static arm64 +agent with SHA-256 +`4ffa237d74cd1578542f85f47b02f75c5a62c6386f75c573fe2acfe06425e39b`. +The schema-v3 report rejected the wrong-type descriptor before state, joined +all eight namespace types, kept both PID/time joiners running through the +bounded observation, executed through the retained rootfs after the mount +join, preserved the donor's created record, and removed every container state. +The host descriptor inventory returned from 10 to 10, both shim processes were +reaped, and the endpoint and guest runtime root were removed. + ## Fault-injected shutdown cleanup `oci-vm-fault-cleanup` deliberately skips OCI delete after a successful create, diff --git a/docs/oci-conformance.md b/docs/oci-conformance.md index 20e0c75..d1f0049 100644 --- a/docs/oci-conformance.md +++ b/docs/oci-conformance.md @@ -76,7 +76,7 @@ does not make it a native Windows container. | Complete `Spec` object | Yes | Official schema, version range, unknown fields, initial semantics | No | No | | Common root, mounts, process, hostname, annotations | Yes | Initial cross-field rules; normative manifest pending | Bootstrap slice applies writable rootfs, ordered existing-target mounts, hostname/domainname, credentials, and process launch; advanced mount semantics remain rejected | No | | POSIX hooks | Yes | Initial path and environment rules | No | No | -| Linux namespaces and ID mappings | Yes | Initial relationship and range rules | Bootstrap slice creates new UTS, mount, IPC, network, cgroup, PID, user, and time namespaces and installs verified rootful UID/GID maps; joins and rootless mapping policy remain rejected | No | +| Linux namespaces and ID mappings | Yes | Initial relationship and range rules | Bootstrap slice creates or type-checks and joins UTS, mount, IPC, network, cgroup, PID, user, and time namespaces, installs verified rootful UID/GID maps for new user namespaces, and preserves retained-rootfs execution after mount joins; rootless mapping policy and broader join hardening remain incomplete | No | | Linux devices, seccomp, capabilities, LSM, sysctl | Yes | Initial path, seccomp, and namespaced-sysctl rules; capability/LSM rules pending | No | No | | Linux cgroup resources | Yes | Initial CPU, block I/O, and RDMA relationships | No | No | | Linux Intel RDT, memory policy, time offsets, net devices | Yes | Initial cross-field and path rules | Bootstrap slice applies and reads back normalized monotonic/boottime offsets; other fields remain unenforced | No | diff --git a/docs/windows-whpx.md b/docs/windows-whpx.md index 40da741..650f2d1 100644 --- a/docs/windows-whpx.md +++ b/docs/windows-whpx.md @@ -157,15 +157,15 @@ onto `/tmp`. The workload verified both filesystem types through `/proc/self/mountinfo` and completed the full lifecycle after `pivot_root`. A companion bundle omitted the mount namespace; create retained the exact typed `Unsupported` rejection and left no guest runtime directory. A joined-mount -namespace negative remains rejected as well. +namespace negative was rejected by that historical agent. The namespace qualification combined that ordered mount sequence with new IPC, network, and cgroup namespaces. The workload compared `/proc/self/ns/{ipc,net,cgroup}` with guest PID 1 and produced its marker only after all three identities differed. The full lifecycle and cleanup report passed. A companion bundle supplied `/proc/1/ns/net` as a network namespace -join path; create retained the exact typed `Unsupported` rejection and left no -guest runtime directory. +join path; the historical agent retained the exact typed `Unsupported` +rejection and left no guest runtime directory. The PID qualification used the 6,371,704-byte static musl agent with SHA-256 `45d27bfdfec50ddedabd1f11a143dba4c11b4f472e7d2627a686594a0c514f6d`. @@ -201,9 +201,8 @@ The next vertical slice must: 1. boot a version-pinned A3S system image; 2. mount one protected runtime-owned root through virtio-fs; -3. qualify the shared rootful user/time namespace slice on WHPX and add - namespace joins, advanced mount, capability, resource, seccomp, and hook - enforcement; +3. qualify the shared rootful user/time and existing-namespace slices on WHPX + and add advanced mount, capability, resource, seccomp, and hook enforcement; 4. return stdout, stderr, and arbitrary natural exit codes; 5. reconcile stopped state after host runtime restart; 6. add concurrent-container and negative isolation evidence;