From d16b50941360f9b053daf458e2585daea4384a82 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Wed, 24 Jun 2026 19:17:40 +0000 Subject: [PATCH] test: add bwrap exec-server test support --- codex-rs/exec-server/testing/BUILD.bazel | 63 ++- codex-rs/exec-server/testing/README.md | 15 +- .../exec-server/testing/bwrap_exec_server.rs | 65 +++ .../testing/bwrap_exec_server_smoke.rs | 177 ++++++++ .../testing/bwrap_exec_server_tests.rs | 379 ++++++++++++++++++ 5 files changed, 697 insertions(+), 2 deletions(-) create mode 100644 codex-rs/exec-server/testing/bwrap_exec_server.rs create mode 100644 codex-rs/exec-server/testing/bwrap_exec_server_smoke.rs create mode 100644 codex-rs/exec-server/testing/bwrap_exec_server_tests.rs diff --git a/codex-rs/exec-server/testing/BUILD.bazel b/codex-rs/exec-server/testing/BUILD.bazel index 2e2cc220d4..66d98b2370 100644 --- a/codex-rs/exec-server/testing/BUILD.bazel +++ b/codex-rs/exec-server/testing/BUILD.bazel @@ -1,4 +1,65 @@ -load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library") +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test") +load("//bazel/rules/testing/bwrap:defs.bzl", "BWRAP_EXEC_PROPERTIES") + +rust_library( + name = "bwrap-exec-server-test-support", + testonly = True, + srcs = [ + "bwrap_exec_server.rs", + "bwrap_exec_server_tests.rs", + ], + crate_name = "bwrap_exec_server_test_support", + crate_root = "bwrap_exec_server.rs", + target_compatible_with = ["@platforms//os:linux"], + visibility = ["//visibility:private"], + deps = [ + "//bazel/rules/testing/bwrap:bwrap_test_support", + "//codex-rs/utils/cargo-bin", + "@crates//:anyhow", + "@crates//:tempfile", + "@crates//:tokio", + ], +) + +rust_binary( + name = "bwrap-exec-smoke", + testonly = True, + srcs = ["bwrap_exec_server_smoke.rs"], + crate_name = "bwrap_exec_server_smoke", + crate_root = "bwrap_exec_server_smoke.rs", + target_compatible_with = ["@platforms//os:linux"], + visibility = ["//visibility:private"], + deps = [ + "@crates//:serde", + "@crates//:serde_json", + ], +) + +rust_test( + name = "bwrap-exec-server-smoke-test", + crate = ":bwrap-exec-server-test-support", + data = [ + ":bwrap-exec-smoke", + "//codex-rs/bwrap", + "//codex-rs/cli:codex", + ], + env = { + "CARGO_BIN_EXE_bwrap": "$(rlocationpath //codex-rs/bwrap:bwrap)", + "CARGO_BIN_EXE_bwrap-exec-smoke": "$(rlocationpath :bwrap-exec-smoke)", + "CARGO_BIN_EXE_codex": "$(rlocationpath //codex-rs/cli:codex)", + }, + exec_properties = BWRAP_EXEC_PROPERTIES, + target_compatible_with = ["@platforms//os:linux"], + deps = [ + "//codex-rs/exec-server", + "//codex-rs/protocol", + "//codex-rs/utils/cargo-bin", + "//codex-rs/utils/path-uri", + "@crates//:pretty_assertions", + "@crates//:serde", + "@crates//:serde_json", + ], +) rust_library( name = "wine-exec-server-test-support", diff --git a/codex-rs/exec-server/testing/README.md b/codex-rs/exec-server/testing/README.md index 77f8e990d4..35ab98a2da 100644 --- a/codex-rs/exec-server/testing/README.md +++ b/codex-rs/exec-server/testing/README.md @@ -1,4 +1,17 @@ -# Windows exec-server fixture +# Exec-server test support + +## Linux bwrap exec-server + +The bwrap fixture runs Linux exec-server in an RBE-isolated outer namespace, +then checks that the production sandbox can create nested namespaces, a fresh +`/proc`, and filesystem and network restrictions. Run both contract layers with: + +``` +bazel test --config=buildbuddy-openai-rbe //bazel/rules/testing/bwrap:bwrap-test-support-smoke-test --test_output=errors +bazel test --config=buildbuddy-openai-rbe //codex-rs/exec-server/testing:bwrap-exec-server-smoke-test --test_output=errors +``` + +## Windows exec-server fixture This directory contains the small Windows exec-server binary used by foreign-OS tests. It links only `codex-exec-server` because the full Codex diff --git a/codex-rs/exec-server/testing/bwrap_exec_server.rs b/codex-rs/exec-server/testing/bwrap_exec_server.rs new file mode 100644 index 0000000000..5b9eaec029 --- /dev/null +++ b/codex-rs/exec-server/testing/bwrap_exec_server.rs @@ -0,0 +1,65 @@ +//! Test support for running the Linux exec-server inside bubblewrap. + +use std::future::Future; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use bwrap_test_support::BwrapTestCommand; +use tokio::io::AsyncBufReadExt; +use tokio::io::BufReader; +use tokio::time::timeout; + +const STARTUP_TIMEOUT: Duration = Duration::from_secs(15); + +/// Runs the Linux exec-server in a fresh bubblewrap environment for a scoped operation. +pub struct BwrapExecServer; + +impl BwrapExecServer { + /// Starts the server, passes its WebSocket URL to `operation`, and tears it down afterward. + pub async fn scope(self, operation: F) -> Result + where + F: FnOnce(String) -> Fut, + Fut: Future>, + { + let executable = codex_utils_cargo_bin::cargo_bin("codex")?; + let bwrap = codex_utils_cargo_bin::cargo_bin("bwrap")?; + let bwrap_dir = bwrap.parent().context("bwrap runfile has no parent")?; + let mut path_entries = vec![bwrap_dir.to_path_buf()]; + if let Some(path) = std::env::var_os("PATH") { + path_entries.extend(std::env::split_paths(&path)); + } + let path = std::env::join_paths(path_entries).context("build bwrap exec-server PATH")?; + let mut exec_server = BwrapTestCommand::new(executable) + .arg("exec-server") + .arg("--listen") + .arg("ws://127.0.0.1:0") + .env("PATH", path) + .spawn()?; + let stdout = exec_server.take_stdout(); + + exec_server + .scope(async move { + let mut lines = BufReader::new(stdout).lines(); + let exec_server_url = timeout(STARTUP_TIMEOUT, async { + loop { + let line = lines + .next_line() + .await? + .context("bwrap exec-server exited before reporting its URL")?; + if line.starts_with("ws://") { + return Ok::<_, anyhow::Error>(line); + } + } + }) + .await + .context("timed out waiting for bwrap exec-server URL")??; + operation(exec_server_url).await + }) + .await + } +} + +#[cfg(test)] +#[path = "bwrap_exec_server_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/testing/bwrap_exec_server_smoke.rs b/codex-rs/exec-server/testing/bwrap_exec_server_smoke.rs new file mode 100644 index 0000000000..ae90ffe361 --- /dev/null +++ b/codex-rs/exec-server/testing/bwrap_exec_server_smoke.rs @@ -0,0 +1,177 @@ +use std::fs::OpenOptions; +use std::io::Write; +use std::net::SocketAddr; +use std::net::TcpStream; +use std::path::PathBuf; +use std::process::ExitCode; +use std::time::Duration; + +use serde::Serialize; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct NamespaceReport { + user_namespace: String, + mount_namespace: String, + pid_namespace: String, + network_namespace: String, + ipc_namespace: String, + uts_namespace: String, + proc_pid_one_namespace: Option, + proc_file_system_type: String, + overflow_uid: String, + overflow_gid: String, + effective_uid: u32, + effective_gid: u32, + effective_capabilities: String, + permitted_capabilities: String, + inheritable_capabilities: String, + ambient_capabilities: String, + no_new_privileges: u32, + seccomp_mode: u32, + connect_succeeded: Option, + allowed_write_succeeded: Option, + denied_read_succeeded: Option, + denied_write_succeeded: Option, +} + +fn main() -> Result> { + let mut connect = None; + let mut allowed_write = None; + let mut denied_write = None; + let mut exit_code = 0; + let mut args = std::env::args_os().skip(1); + while let Some(arg) = args.next() { + match arg.to_str() { + Some("--connect") => { + connect = Some( + args.next() + .ok_or("--connect requires an address")? + .into_string() + .map_err(|_| "connect address must be valid UTF-8")?, + ); + } + Some("--allowed-write") => { + allowed_write = Some(PathBuf::from( + args.next().ok_or("--allowed-write requires a path")?, + )); + } + Some("--denied-write") => { + denied_write = Some(PathBuf::from( + args.next().ok_or("--denied-write requires a path")?, + )); + } + Some("--exit-code") => { + exit_code = args + .next() + .ok_or("--exit-code requires a value")? + .to_str() + .ok_or("--exit-code must be valid UTF-8")? + .parse::()?; + } + _ => return Err(format!("unexpected argument: {}", arg.to_string_lossy()).into()), + } + } + + let status = std::fs::read_to_string("/proc/self/status")?; + let denied_read_succeeded = denied_write + .as_deref() + .map(|path| std::fs::read(path).is_ok()); + let report = NamespaceReport { + user_namespace: namespace("user")?, + mount_namespace: namespace("mnt")?, + pid_namespace: namespace("pid")?, + network_namespace: namespace("net")?, + ipc_namespace: namespace("ipc")?, + uts_namespace: namespace("uts")?, + proc_pid_one_namespace: std::fs::read_link("/proc/1/ns/pid") + .ok() + .map(|path| path.to_string_lossy().into_owned()), + proc_file_system_type: proc_file_system_type()?, + overflow_uid: std::fs::read_to_string("/proc/sys/kernel/overflowuid")? + .trim() + .to_string(), + overflow_gid: std::fs::read_to_string("/proc/sys/kernel/overflowgid")? + .trim() + .to_string(), + effective_uid: status_id(&status, "Uid")?, + effective_gid: status_id(&status, "Gid")?, + effective_capabilities: status_field(&status, "CapEff")?.to_string(), + permitted_capabilities: status_field(&status, "CapPrm")?.to_string(), + inheritable_capabilities: status_field(&status, "CapInh")?.to_string(), + ambient_capabilities: status_field(&status, "CapAmb")?.to_string(), + no_new_privileges: status_number(&status, "NoNewPrivs")?, + seccomp_mode: status_number(&status, "Seccomp")?, + connect_succeeded: connect + .map(|address| connect_to(address.as_str())) + .transpose()?, + allowed_write_succeeded: allowed_write.map(|path| append_probe(path.as_path())), + denied_read_succeeded, + denied_write_succeeded: denied_write.map(|path| append_probe(path.as_path())), + }; + + println!("{}", serde_json::to_string(&report)?); + std::io::stdout().flush()?; + Ok(ExitCode::from(exit_code)) +} + +fn namespace(name: &str) -> std::io::Result { + std::fs::read_link(format!("/proc/self/ns/{name}")) + .map(|path| path.to_string_lossy().into_owned()) +} + +fn proc_file_system_type() -> Result> { + let mountinfo = std::fs::read_to_string("/proc/self/mountinfo")?; + for line in mountinfo.lines() { + let Some((mount, file_system)) = line.split_once(" - ") else { + continue; + }; + if mount.split_whitespace().nth(4) == Some("/proc") { + return file_system + .split_whitespace() + .next() + .map(str::to_string) + .ok_or_else(|| "proc mount is missing its filesystem type".into()); + } + } + Err("/proc is not present in mountinfo".into()) +} + +fn status_field<'a>(status: &'a str, name: &str) -> Result<&'a str, Box> { + status + .lines() + .find_map(|line| line.strip_prefix(&format!("{name}:"))) + .map(str::trim) + .ok_or_else(|| format!("/proc/self/status is missing {name}").into()) +} + +fn status_number(status: &str, name: &str) -> Result> { + status_field(status, name)? + .split_whitespace() + .next() + .ok_or_else(|| format!("/proc/self/status field {name} is empty"))? + .parse() + .map_err(Into::into) +} + +fn status_id(status: &str, name: &str) -> Result> { + status_field(status, name)? + .split_whitespace() + .nth(1) + .ok_or_else(|| format!("/proc/self/status field {name} has no effective ID"))? + .parse() + .map_err(Into::into) +} + +fn connect_to(address: &str) -> Result> { + let address: SocketAddr = address.parse()?; + Ok(TcpStream::connect_timeout(&address, Duration::from_millis(500)).is_ok()) +} + +fn append_probe(path: &std::path::Path) -> bool { + OpenOptions::new() + .append(true) + .open(path) + .and_then(|mut file| file.write_all(b"changed")) + .is_ok() +} diff --git a/codex-rs/exec-server/testing/bwrap_exec_server_tests.rs b/codex-rs/exec-server/testing/bwrap_exec_server_tests.rs new file mode 100644 index 0000000000..9bd37fe06a --- /dev/null +++ b/codex-rs/exec-server/testing/bwrap_exec_server_tests.rs @@ -0,0 +1,379 @@ +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::Environment; +use codex_exec_server::ExecOutputStream; +use codex_exec_server::ExecParams; +use codex_exec_server::ExecProcess; +use codex_exec_server::ExecProcessEvent; +use codex_exec_server::FileSystemSandboxContext; +use codex_exec_server::ProcessId; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_utils_path_uri::PathUri; +use pretty_assertions::assert_eq; +use serde::Deserialize; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::time::timeout; + +use super::BwrapExecServer; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NamespaceReport { + user_namespace: String, + mount_namespace: String, + pid_namespace: String, + network_namespace: String, + ipc_namespace: String, + uts_namespace: String, + proc_pid_one_namespace: Option, + proc_file_system_type: String, + overflow_uid: String, + overflow_gid: String, + effective_uid: u32, + effective_gid: u32, + effective_capabilities: String, + permitted_capabilities: String, + inheritable_capabilities: String, + ambient_capabilities: String, + no_new_privileges: u32, + seccomp_mode: u32, + connect_succeeded: Option, + allowed_write_succeeded: Option, + denied_read_succeeded: Option, + denied_write_succeeded: Option, +} + +#[derive(Debug)] +struct ReportOutcome { + report: NamespaceReport, + stderr: String, + exit_code: Option, +} + +#[derive(Clone, Copy)] +enum FixtureExit { + Success, + Code(u8), +} + +#[derive(Debug)] +struct NamespaceIdentity { + user: String, + mount: String, + pid: String, + network: String, + ipc: String, + uts: String, +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn production_sandbox_nests_with_fresh_namespaces_and_proc() -> Result<()> { + let parent_namespaces = current_namespace_identity()?; + let fixture = codex_utils_cargo_bin::cargo_bin("bwrap-exec-smoke")?; + let bwrap = codex_utils_cargo_bin::cargo_bin("bwrap")?; + let bazel_workspace = std::env::current_dir().context("resolve Bazel test workspace")?; + let workspace = TempDir::new_in(&bazel_workspace)?; + let outside_workspace = TempDir::new_in(&bazel_workspace)?; + let allowed_marker = workspace.path().join("allowed-write-probe"); + let denied_marker = outside_workspace.path().join("denied-write-probe"); + std::fs::write(&allowed_marker, "sentinel")?; + std::fs::write(&denied_marker, "sentinel")?; + for protected_name in [".git", ".agents", ".codex"] { + assert!(!workspace.path().join(protected_name).exists()); + } + let listener = TcpListener::bind("127.0.0.1:0").await?; + let listener_address = listener.local_addr()?.to_string(); + let host_tmp = TempDir::new_in("/tmp")?; + let isolated_tmp_dir = host_tmp.path().to_path_buf(); + let isolated_tmp_marker = isolated_tmp_dir.join("same-path-marker"); + std::fs::write(&isolated_tmp_marker, "host")?; + + BwrapExecServer + .scope(|exec_server_url| async move { + let environment = Environment::create_for_tests(Some(exec_server_url))?; + let file_system = environment.get_filesystem(); + let isolated_tmp_dir_uri = PathUri::from_host_native_path(&isolated_tmp_dir)?; + let isolated_tmp_marker_uri = PathUri::from_host_native_path(&isolated_tmp_marker)?; + file_system + .create_directory( + &isolated_tmp_dir_uri, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + file_system + .write_file( + &isolated_tmp_marker_uri, + b"remote".to_vec(), + /*sandbox*/ None, + ) + .await?; + assert_eq!( + file_system + .read_file_text(&isolated_tmp_marker_uri, /*sandbox*/ None) + .await?, + "remote" + ); + assert_eq!(std::fs::read_to_string(&isolated_tmp_marker)?, "host"); + + let outer = run_report( + &environment, + "bwrap-outer-report", + &fixture, + &bwrap, + workspace.path(), + &allowed_marker, + &denied_marker, + &listener_address, + /*sandbox*/ None, + FixtureExit::Success, + ) + .await?; + + assert_eq!(outer.exit_code, Some(0), "{}", outer.stderr); + let outer = outer.report; + + let _connection = timeout(Duration::from_secs(2), listener.accept()) + .await + .context("outer bwrap did not reach the test listener")??; + assert_eq!(outer.connect_succeeded, Some(true)); + assert_eq!(outer.allowed_write_succeeded, Some(true)); + assert_eq!(outer.denied_read_succeeded, Some(true)); + assert_eq!(outer.denied_write_succeeded, Some(true)); + assert_eq!(std::fs::read_to_string(&allowed_marker)?, "sentinelchanged"); + assert_eq!(std::fs::read_to_string(&denied_marker)?, "sentinelchanged"); + std::fs::write(&allowed_marker, "sentinel")?; + std::fs::write(&denied_marker, "sentinel")?; + + assert_ne!(outer.user_namespace, parent_namespaces.user); + assert_ne!(outer.mount_namespace, parent_namespaces.mount); + assert_ne!(outer.pid_namespace, parent_namespaces.pid); + assert_ne!(outer.ipc_namespace, parent_namespaces.ipc); + assert_ne!(outer.uts_namespace, parent_namespaces.uts); + assert_eq!(outer.network_namespace, parent_namespaces.network); + assert_proc_is_available(&outer); + assert_eq!((outer.effective_uid, outer.effective_gid), (1000, 1000)); + assert_no_capabilities(&outer)?; + + let cwd = PathUri::from_host_native_path(workspace.path())?; + let mut file_system_policy = FileSystemSandboxPolicy::workspace_write( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); + file_system_policy.entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: denied_marker.as_path().try_into()?, + }, + access: FileSystemAccessMode::Deny, + }); + let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ), + cwd, + ); + let inner = run_report( + &environment, + "bwrap-inner-report", + &fixture, + &bwrap, + workspace.path(), + &allowed_marker, + &denied_marker, + &listener_address, + Some(sandbox), + FixtureExit::Code(73), + ) + .await?; + + assert_eq!(inner.exit_code, Some(73), "{}", inner.stderr); + let inner = inner.report; + + assert_eq!(inner.connect_succeeded, Some(false)); + assert_eq!(inner.allowed_write_succeeded, Some(true)); + assert_eq!(inner.denied_read_succeeded, Some(false)); + assert_eq!(inner.denied_write_succeeded, Some(false)); + assert_eq!(std::fs::read_to_string(&allowed_marker)?, "sentinelchanged"); + assert_eq!(std::fs::read_to_string(&denied_marker)?, "sentinel"); + assert!( + timeout(Duration::from_millis(200), listener.accept()) + .await + .is_err(), + "sandboxed process unexpectedly reached the outer loopback listener" + ); + + assert_ne!(inner.user_namespace, outer.user_namespace); + assert_ne!(inner.mount_namespace, outer.mount_namespace); + assert_ne!(inner.pid_namespace, outer.pid_namespace); + assert_ne!(inner.network_namespace, outer.network_namespace); + assert_proc_matches_pid_namespace(&inner); + assert_eq!((inner.effective_uid, inner.effective_gid), (1000, 1000)); + assert_no_capabilities(&inner)?; + assert_eq!(inner.no_new_privileges, 1); + assert_eq!(inner.seccomp_mode, 2); + for protected_name in [".git", ".agents", ".codex"] { + assert!( + !workspace.path().join(protected_name).exists(), + "sandbox setup leaked synthetic {protected_name} mount target" + ); + } + Ok(()) + }) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn run_report( + environment: &Environment, + process_id: &str, + fixture: &Path, + bwrap: &Path, + cwd: &Path, + allowed_marker: &Path, + denied_marker: &Path, + listener_address: &str, + sandbox: Option, + fixture_exit: FixtureExit, +) -> Result { + let bwrap_dir = bwrap.parent().context("bwrap runfile has no parent")?; + let mut path_entries = vec![bwrap_dir.to_path_buf()]; + if let Some(path) = std::env::var_os("PATH") { + path_entries.extend(std::env::split_paths(&path)); + } + let path = std::env::join_paths(path_entries).context("build sandbox smoke PATH")?; + let mut argv = vec![ + fixture.to_string_lossy().into_owned(), + "--connect".to_string(), + listener_address.to_string(), + "--allowed-write".to_string(), + allowed_marker.to_string_lossy().into_owned(), + "--denied-write".to_string(), + denied_marker.to_string_lossy().into_owned(), + ]; + if let FixtureExit::Code(exit_code) = fixture_exit { + argv.extend(["--exit-code".to_string(), exit_code.to_string()]); + } + let started = environment + .get_exec_backend() + .start(ExecParams { + process_id: ProcessId::from(process_id), + argv, + cwd: PathUri::from_host_native_path(cwd)?, + env_policy: /*env_policy*/ None, + env: HashMap::from([ + ( + "CARGO_BIN_EXE_bwrap".to_string(), + bwrap.to_string_lossy().into_owned(), + ), + ("CODEX_HOME".to_string(), cwd.to_string_lossy().into_owned()), + ("HOME".to_string(), cwd.to_string_lossy().into_owned()), + ("PATH".to_string(), path.to_string_lossy().into_owned()), + ("TMPDIR".to_string(), "/tmp".to_string()), + ]), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox, + enforce_managed_network: false, + managed_network: None, + }) + .await?; + let (stdout, stderr, exit_code) = collect_process_output(started.process).await?; + let report = serde_json::from_str(stdout.trim()) + .with_context(|| format!("parse namespace report; stderr: {stderr}"))?; + Ok(ReportOutcome { + report, + stderr, + exit_code, + }) +} + +async fn collect_process_output( + process: Arc, +) -> Result<(String, String, Option)> { + let mut events = process.subscribe_events(); + let mut stdout = String::new(); + let mut stderr = String::new(); + let mut exit_code = None; + loop { + match timeout(Duration::from_secs(10), events.recv()) + .await + .context("timed out waiting for bwrap smoke process")?? + { + ExecProcessEvent::Output(chunk) => match chunk.stream { + ExecOutputStream::Stdout | ExecOutputStream::Pty => { + stdout.push_str(&String::from_utf8_lossy(&chunk.chunk.into_inner())); + } + ExecOutputStream::Stderr => { + stderr.push_str(&String::from_utf8_lossy(&chunk.chunk.into_inner())); + } + }, + ExecProcessEvent::Exited { + exit_code: code, .. + } => exit_code = Some(code), + ExecProcessEvent::Closed { .. } => break, + ExecProcessEvent::Failed(message) => anyhow::bail!(message), + } + } + Ok((stdout, stderr, exit_code)) +} + +fn current_namespace_identity() -> Result { + Ok(NamespaceIdentity { + user: namespace("user")?, + mount: namespace("mnt")?, + pid: namespace("pid")?, + network: namespace("net")?, + ipc: namespace("ipc")?, + uts: namespace("uts")?, + }) +} + +fn namespace(name: &str) -> std::io::Result { + std::fs::read_link(format!("/proc/self/ns/{name}")) + .map(|path| path.to_string_lossy().into_owned()) +} + +fn assert_proc_matches_pid_namespace(report: &NamespaceReport) { + assert_proc_is_available(report); + assert_eq!( + report.proc_pid_one_namespace.as_deref(), + Some(report.pid_namespace.as_str()) + ); +} + +fn assert_proc_is_available(report: &NamespaceReport) { + assert_eq!(report.proc_file_system_type, "proc"); + assert!(report.overflow_uid.parse::().is_ok()); + assert!(report.overflow_gid.parse::().is_ok()); +} + +fn assert_no_capabilities(report: &NamespaceReport) -> Result<()> { + for (name, capabilities) in [ + ("effective", report.effective_capabilities.as_str()), + ("permitted", report.permitted_capabilities.as_str()), + ("inheritable", report.inheritable_capabilities.as_str()), + ("ambient", report.ambient_capabilities.as_str()), + ] { + assert_eq!( + u64::from_str_radix(capabilities, 16)?, + 0, + "process retained {name} capabilities" + ); + } + Ok(()) +}