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 Model •
Platform Status •
Architecture •
+ Conformance •
Development
@@ -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