From ec85a19f31b5537ad96c448ed29d74efa6ef24aa Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Wed, 24 Jun 2026 19:17:09 +0000 Subject: [PATCH] test: add bwrap integration-test sandbox --- bazel/rules/testing/bwrap/BUILD.bazel | 57 +++ bazel/rules/testing/bwrap/defs.bzl | 6 + .../testing/bwrap/fixtures/bwrap_smoke.rs | 205 +++++++++ bazel/rules/testing/bwrap/src/lib.rs | 335 +++++++++++++++ bazel/rules/testing/bwrap/src/lib_tests.rs | 390 ++++++++++++++++++ 5 files changed, 993 insertions(+) create mode 100644 bazel/rules/testing/bwrap/BUILD.bazel create mode 100644 bazel/rules/testing/bwrap/defs.bzl create mode 100644 bazel/rules/testing/bwrap/fixtures/bwrap_smoke.rs create mode 100644 bazel/rules/testing/bwrap/src/lib.rs create mode 100644 bazel/rules/testing/bwrap/src/lib_tests.rs diff --git a/bazel/rules/testing/bwrap/BUILD.bazel b/bazel/rules/testing/bwrap/BUILD.bazel new file mode 100644 index 0000000000..8efae5e7b6 --- /dev/null +++ b/bazel/rules/testing/bwrap/BUILD.bazel @@ -0,0 +1,57 @@ +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test") +load(":defs.bzl", "BWRAP_EXEC_PROPERTIES") + +package(default_visibility = ["//visibility:public"]) + +exports_files(["defs.bzl"]) + +rust_library( + name = "bwrap_test_support", + testonly = True, + srcs = [ + "src/lib.rs", + "src/lib_tests.rs", + ], + crate_name = "bwrap_test_support", + crate_root = "src/lib.rs", + edition = "2024", + target_compatible_with = ["@platforms//os:linux"], + deps = [ + "//codex-rs/utils/cargo-bin", + "@crates//:anyhow", + "@crates//:tempfile", + "@crates//:tokio", + ], +) + +rust_binary( + name = "bwrap-smoke", + testonly = True, + srcs = ["fixtures/bwrap_smoke.rs"], + crate_name = "bwrap_smoke", + crate_root = "fixtures/bwrap_smoke.rs", + edition = "2024", + target_compatible_with = ["@platforms//os:linux"], + visibility = ["//visibility:private"], + deps = ["@crates//:anyhow"], +) + +rust_test( + name = "bwrap-test-support-smoke-test", + crate = ":bwrap_test_support", + data = [ + ":bwrap-smoke", + "//codex-rs/bwrap", + ], + env = { + "CARGO_BIN_EXE_bwrap": "$(rlocationpath //codex-rs/bwrap:bwrap)", + "CARGO_BIN_EXE_bwrap-smoke": "$(rlocationpath :bwrap-smoke)", + }, + exec_properties = BWRAP_EXEC_PROPERTIES, + target_compatible_with = ["@platforms//os:linux"], + deps = [ + "@crates//:futures", + "@crates//:libc", + "@crates//:pretty_assertions", + ], +) diff --git a/bazel/rules/testing/bwrap/defs.bzl b/bazel/rules/testing/bwrap/defs.bzl new file mode 100644 index 0000000000..4e0e6f7aab --- /dev/null +++ b/bazel/rules/testing/bwrap/defs.bzl @@ -0,0 +1,6 @@ +BWRAP_EXEC_PROPERTIES = { + # BuildBuddy's isolated network mode disables loopback, which the runner uses to reach exec-server. + "test.network": "external", + # A fresh VM isolates the deliberately writable outer namespace and permits nested user namespaces. + "test.workload-isolation-type": "firecracker", +} diff --git a/bazel/rules/testing/bwrap/fixtures/bwrap_smoke.rs b/bazel/rules/testing/bwrap/fixtures/bwrap_smoke.rs new file mode 100644 index 0000000000..8229c6607c --- /dev/null +++ b/bazel/rules/testing/bwrap/fixtures/bwrap_smoke.rs @@ -0,0 +1,205 @@ +#[cfg(not(target_os = "linux"))] +compile_error!("the bwrap smoke fixture can only run on Linux"); + +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::io::Write; +use std::net::TcpStream; +use std::path::Path; +use std::process::Command; + +use anyhow::Context; +use anyhow::Result; + +const NAMESPACES: &[&str] = &["user", "mnt", "pid", "ipc", "uts", "net"]; + +fn main() -> Result<()> { + let mut args = env::args_os(); + let _executable = args.next(); + let mode = args.next().context("missing bwrap smoke fixture mode")?; + match mode.to_str() { + Some("wait") => wait(), + Some("inspect") => inspect(), + Some("filesystem") => { + let tmp_dir = args.next().context("missing tmp directory")?; + let workspace_dir = args.next().context("missing workspace directory")?; + let root_target = args.next().context("missing root write target")?; + filesystem( + Path::new(&tmp_dir), + Path::new(&workspace_dir), + Path::new(&root_target), + ) + } + Some("connect") => { + let address = args.next().context("missing listener address")?; + connect(address.to_string_lossy().as_ref()) + } + Some("nested") => { + let bwrap = args.next().context("missing nested bwrap path")?; + let fixture = args.next().context("missing nested fixture path")?; + nested(Path::new(&bwrap), Path::new(&fixture)) + } + _ => anyhow::bail!("unknown bwrap smoke fixture mode: {mode:?}"), + } +} + +fn wait() -> Result<()> { + println!("BWRAP_TEST_READY"); + std::io::stdout().flush()?; + loop { + std::thread::park(); + } +} + +fn inspect() -> Result<()> { + for (key, value) in inspection()? { + println!("{key}={value}"); + } + Ok(()) +} + +fn inspection() -> Result> { + let mut values = BTreeMap::new(); + let status = fs::read_to_string("/proc/self/status").context("read /proc/self/status")?; + values.insert( + "id.effective_uid".to_string(), + status_id(&status, "Uid")?.to_string(), + ); + values.insert( + "id.effective_gid".to_string(), + status_id(&status, "Gid")?.to_string(), + ); + for (name, field) in [ + ("effective", "CapEff"), + ("permitted", "CapPrm"), + ("inheritable", "CapInh"), + ("ambient", "CapAmb"), + ] { + values.insert( + format!("cap.{name}"), + status_field(&status, field)?.to_string(), + ); + } + for namespace in NAMESPACES { + values.insert( + format!("ns.{namespace}"), + namespace_identity("self", namespace)?, + ); + } + match namespace_identity("1", "pid") { + Ok(namespace) => { + values.insert("proc.1.pid_ns".to_string(), namespace); + } + Err(error) => { + values.insert("proc.1.pid_ns.error".to_string(), error.to_string()); + } + } + values.insert( + "proc.self.pid_ns".to_string(), + namespace_identity("self", "pid")?, + ); + for name in ["overflowuid", "overflowgid"] { + let value = fs::read_to_string(format!("/proc/sys/kernel/{name}")) + .with_context(|| format!("read /proc/sys/kernel/{name}"))?; + values.insert(format!("proc.{name}"), value.trim().to_string()); + } + for name in ["HOME", "CODEX_HOME", "XDG_RUNTIME_DIR", "TMPDIR"] { + let value = env::var(name).with_context(|| format!("read {name}"))?; + values.insert(format!("env.{name}"), value); + } + Ok(values) +} + +fn namespace_identity(pid: &str, namespace: &str) -> Result { + let path = format!("/proc/{pid}/ns/{namespace}"); + Ok(fs::read_link(&path) + .with_context(|| format!("read namespace link {path}"))? + .to_string_lossy() + .into_owned()) +} + +fn status_field<'a>(status: &'a str, name: &str) -> Result<&'a str> { + status + .lines() + .find_map(|line| line.strip_prefix(&format!("{name}:"))) + .map(str::trim) + .with_context(|| format!("/proc/self/status is missing {name}")) +} + +fn status_id(status: &str, name: &str) -> Result { + status_field(status, name)? + .split_whitespace() + .nth(1) + .with_context(|| format!("/proc/self/status field {name} has no effective ID"))? + .parse() + .with_context(|| format!("parse effective {name}")) +} + +fn filesystem(tmp_dir: &Path, workspace_dir: &Path, root_target: &Path) -> Result<()> { + fs::create_dir_all(tmp_dir) + .with_context(|| format!("create private tmp fixture {}", tmp_dir.display()))?; + println!( + "tmp.host_sentinel_visible={}", + tmp_dir.join("host-sentinel.txt").exists() + ); + for (name, directory) in [("tmp", tmp_dir), ("workspace", workspace_dir)] { + let writable_file = directory.join("round-trip.txt"); + fs::write(&writable_file, "round-trip") + .with_context(|| format!("write {}", writable_file.display()))?; + println!("{name}.value={}", fs::read_to_string(&writable_file)?); + } + + let error = fs::write(root_target, "must not be written") + .expect_err("writing through the read-only root unexpectedly succeeded"); + println!( + "root.write_errno={}", + error + .raw_os_error() + .context("root write error had no errno")? + ); + Ok(()) +} + +fn connect(address: &str) -> Result<()> { + let mut stream = TcpStream::connect(address) + .with_context(|| format!("connect to parent listener at {address}"))?; + stream.write_all(b"BWRAP_LOOPBACK")?; + Ok(()) +} + +fn nested(bwrap: &Path, fixture: &Path) -> Result<()> { + for (key, value) in inspection()? { + println!("outer.{key}={value}"); + } + let output = Command::new(bwrap) + .args([ + "--new-session", + "--die-with-parent", + "--ro-bind", + "/", + "/", + "--dev", + "/dev", + "--unshare-user", + "--unshare-pid", + "--unshare-net", + "--proc", + "/proc", + "--", + ]) + .arg(fixture) + .arg("inspect") + .output() + .context("run raw nested bwrap")?; + anyhow::ensure!( + output.status.success(), + "raw nested bwrap exited with {}; stderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + for line in String::from_utf8(output.stdout)?.lines() { + println!("child.{line}"); + } + Ok(()) +} diff --git a/bazel/rules/testing/bwrap/src/lib.rs b/bazel/rules/testing/bwrap/src/lib.rs new file mode 100644 index 0000000000..3cbbdd9d7a --- /dev/null +++ b/bazel/rules/testing/bwrap/src/lib.rs @@ -0,0 +1,335 @@ +#[cfg(not(target_os = "linux"))] +compile_error!("bwrap_test_support can only run on Linux"); + +use std::ffi::OsString; +use std::fs; +use std::future::Future; +use std::io::Write; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command as StdCommand; +use std::process::Stdio; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use tempfile::Builder; +use tempfile::TempDir; +use tokio::process::Child; +use tokio::process::ChildStdout; +use tokio::process::Command as TokioCommand; + +/// Builds a command that runs inside the Linux integration-test namespace. +pub struct BwrapTestCommand { + executable: PathBuf, + args: Vec, + env: Vec<(OsString, OsString)>, +} + +/// Owns a process running inside the Linux integration-test namespace. +/// +/// Call [`Self::scope`] or [`Self::shutdown`] on every successful path. A +/// normal unguarded drop panics, while a drop during unwinding performs +/// blocking cleanup without introducing a second panic. +pub struct BwrapTestProcess { + processes: Option, +} + +struct BwrapProcesses { + child: Child, + cleanup_complete: bool, + environment: TempDir, +} + +struct BwrapRuntimePaths { + bwrap: PathBuf, +} + +impl BwrapTestCommand { + /// Creates an isolated bwrap command for `executable`. + pub fn new(executable: impl Into) -> Self { + Self { + executable: executable.into(), + args: Vec::new(), + env: Vec::new(), + } + } + + /// Adds an argument passed to the isolated executable. + #[must_use] + pub fn arg(mut self, arg: impl Into) -> Self { + self.args.push(arg.into()); + self + } + + /// Adds or overrides an environment variable for the isolated process. + #[must_use] + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.env.push((key.into(), value.into())); + self + } + + /// Starts the executable with an isolated filesystem, IPC, and UTS view. + pub fn spawn(self) -> Result { + let runtime = BwrapRuntimePaths::from_runfiles()?; + let workspace = std::env::current_dir().context("resolve Bazel test workspace")?; + anyhow::ensure!( + workspace != Path::new("/"), + "refusing to make the filesystem root writable" + ); + let environment = Builder::new() + .prefix("codex-bwrap-test-") + .tempdir_in("/tmp") + .context("create isolated bwrap test environment")?; + let private_tmp = environment.path().join("tmp"); + fs::create_dir(&private_tmp) + .with_context(|| format!("create private tmp directory {}", private_tmp.display()))?; + fs::set_permissions(&private_tmp, fs::Permissions::from_mode(0o1777)).with_context( + || format!("set permissions on private tmp directory {}", private_tmp.display()), + )?; + let home = private_tmp.join("home"); + let codex_home = private_tmp.join("codex-home"); + let xdg_runtime_dir = private_tmp.join("xdg-runtime"); + for path in [&home, &codex_home, &xdg_runtime_dir] { + fs::create_dir(path) + .with_context(|| format!("create bwrap test directory {}", path.display()))?; + } + fs::set_permissions(&xdg_runtime_dir, fs::Permissions::from_mode(0o700)).with_context( + || { + format!( + "set permissions on bwrap runtime directory {}", + xdg_runtime_dir.display() + ) + }, + )?; + + // Keep the VM's PID namespace and expose its existing procfs + // read-write: a nested bwrap must write UID/GID maps before mounting + // the fresh procfs for its own PID namespace. Mounting a fresh procfs + // here instead leaves locked child mounts that make the nested mount + // fail Linux's mount_too_revealing check. A private directory backs + // /tmp so remote fixtures cannot alias files owned by the test runner. + // The Bazel workspace is the other deliberate writable carveout: + // production bwrap setup needs to materialize missing mount targets + // below a writable command cwd. + // + // Map the caller to a non-root ID and leave the final process with no + // capabilities. Bubblewrap rejects a non-root caller that already has + // capabilities, while an unprivileged nested bwrap acquires the setup + // capabilities it needs inside the user namespace it creates. + let mut command = StdCommand::new(&runtime.bwrap); + command + .args([ + "--new-session", + "--die-with-parent", + "--ro-bind", + "/", + "/", + "--bind", + "/proc", + "/proc", + "--bind", + ]) + .arg(&private_tmp) + .arg("/tmp") + .args([ + "--dev", + "/dev", + "--bind", + ]) + .arg(&workspace) + .arg(&workspace) + .args([ + "--unshare-user", + "--unshare-ipc", + "--unshare-uts", + "--uid", + "1000", + "--gid", + "1000", + "--cap-drop", + "ALL", + "--", + ]) + .arg(self.executable) + .args(self.args) + .env("HOME", "/tmp/home") + .env("CODEX_HOME", "/tmp/codex-home") + .env("XDG_RUNTIME_DIR", "/tmp/xdg-runtime") + .env("TMPDIR", "/tmp") + .envs(self.env) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut command = TokioCommand::from(command); + command.kill_on_drop(true); + let child = command + .spawn() + .context("start process inside bwrap test environment")?; + + Ok(BwrapTestProcess { + processes: Some(BwrapProcesses { + child, + cleanup_complete: false, + environment, + }), + }) + } +} + +impl BwrapTestProcess { + /// Returns the host path containing this process's isolated environment. + pub fn environment_path(&self) -> &Path { + let Some(processes) = self.processes.as_ref() else { + panic!("bwrap process guard is missing"); + }; + processes.environment.path() + } + + /// Takes the piped standard output of the isolated process. + /// + /// This may only be called once for a process created by + /// [`BwrapTestCommand::spawn`]. + pub fn take_stdout(&mut self) -> ChildStdout { + let Some(processes) = self.processes.as_mut() else { + panic!("bwrap process guard is missing"); + }; + let Some(stdout) = processes.child.stdout.take() else { + panic!("bwrap process stdout has already been taken"); + }; + stdout + } + + /// Runs `future`, then asynchronously tears down bwrap before returning. + /// + /// If both the scoped operation and teardown fail, the operation error is + /// returned with the teardown error attached as context. A panic in the + /// scoped operation triggers the blocking unwind-time fallback instead. + pub async fn scope(self, future: impl Future>) -> Result { + let scope_result = future.await; + let shutdown_result = self.shutdown().await; + match (scope_result, shutdown_result) { + (Ok(value), Ok(())) => Ok(value), + (Err(error), Ok(())) => Err(error), + (Ok(_), Err(error)) => Err(error), + (Err(error), Err(shutdown_error)) => { + Err(error.context(format!("bwrap teardown also failed: {shutdown_error:#}"))) + } + } + } + + /// Kills the isolated process and waits for bwrap to exit. + pub async fn shutdown(mut self) -> Result<()> { + let Some(processes) = self.processes.as_mut() else { + anyhow::bail!("bwrap process guard is missing"); + }; + let result = processes.shutdown().await; + self.processes.take(); + result + } +} + +impl Drop for BwrapTestProcess { + fn drop(&mut self) { + // Panicking here starts unwinding, after which BwrapProcesses performs + // the blocking fallback while its field is dropped. + if self.processes.is_some() && !std::thread::panicking() { + panic!("BwrapTestProcess dropped without async teardown"); + } + } +} + +impl BwrapRuntimePaths { + fn from_runfiles() -> Result { + Ok(Self { + bwrap: codex_utils_cargo_bin::cargo_bin("bwrap")?, + }) + } +} + +impl BwrapProcesses { + async fn shutdown(&mut self) -> Result<()> { + let (kill_result, check_exit_status) = match self.child.try_wait() { + Ok(Some(_)) => (Ok(()), true), + Ok(None) => ( + self.child + .start_kill() + .context("kill process running inside bwrap"), + false, + ), + Err(error) => (Err(error).context("check bwrap process status"), false), + }; + let wait_result = self + .child + .wait() + .await + .context("wait for process running inside bwrap") + .and_then(|status| { + anyhow::ensure!( + !check_exit_status || status.success(), + "bwrap process exited with {status}" + ); + Ok(()) + }); + + // Every cleanup action has been attempted, so an individual error + // should not cause the blocking fallback to repeat them. + self.cleanup_complete = true; + kill_result?; + wait_result + } + + fn shutdown_blocking(&mut self) { + log_panic_cleanup(format_args!( + "bwrap panic cleanup starting for environment {}", + self.environment.path().display() + )); + if let Err(error) = self.child.start_kill() { + log_panic_cleanup(format_args!( + "bwrap panic cleanup could not kill its child: {error}" + )); + } + + log_panic_cleanup(format_args!("bwrap panic cleanup waiting for its child")); + loop { + match self.child.try_wait() { + Ok(Some(status)) => { + log_panic_cleanup(format_args!( + "bwrap panic cleanup child exited with {status}" + )); + break; + } + Ok(None) => std::thread::sleep(Duration::from_millis(10)), + Err(error) => { + log_panic_cleanup(format_args!( + "bwrap panic cleanup could not wait for its child: {error}" + )); + break; + } + } + } + + self.cleanup_complete = true; + log_panic_cleanup(format_args!("bwrap panic cleanup complete")); + } +} + +impl Drop for BwrapProcesses { + fn drop(&mut self) { + // Never introduce a second panic while unwinding. Blocking here is + // intentional because test failures must not leak bwrap children. + if !self.cleanup_complete && std::thread::panicking() { + self.shutdown_blocking(); + } + } +} + +fn log_panic_cleanup(args: std::fmt::Arguments<'_>) { + let _ = writeln!(std::io::stderr().lock(), "{args}"); +} + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; diff --git a/bazel/rules/testing/bwrap/src/lib_tests.rs b/bazel/rules/testing/bwrap/src/lib_tests.rs new file mode 100644 index 0000000000..ae223f952e --- /dev/null +++ b/bazel/rules/testing/bwrap/src/lib_tests.rs @@ -0,0 +1,390 @@ +use std::any::Any; +use std::collections::BTreeMap; +use std::fs; +use std::future::Future; +use std::panic::AssertUnwindSafe; +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use futures::FutureExt; +use pretty_assertions::assert_eq; +use tempfile::Builder; +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncReadExt; +use tokio::io::BufReader; +use tokio::net::TcpListener; +use tokio::time::Duration; +use tokio::time::timeout; + +use super::BwrapTestCommand; +use super::BwrapTestProcess; + +fn smoke_fixture() -> Result { + Ok(codex_utils_cargo_bin::cargo_bin("bwrap-smoke")?) +} + +async fn waiting_smoke_process() -> Result { + let mut process = BwrapTestCommand::new(smoke_fixture()?) + .arg("wait") + .spawn()?; + let mut lines = BufReader::new(process.take_stdout()).lines(); + let ready_line = lines + .next_line() + .await? + .context("bwrap smoke process exited before becoming ready")?; + assert_eq!(ready_line, "BWRAP_TEST_READY"); + Ok(process) +} + +fn environment_path(process: &BwrapTestProcess) -> PathBuf { + process.environment_path().to_path_buf() +} + +fn assert_environment_removed(path: &Path) { + assert!( + !path.exists(), + "bwrap test environment remains: {}", + path.display() + ); +} + +fn assert_panic_message(panic: Box, expected: &str) { + assert_eq!(panic.downcast_ref::<&str>(), Some(&expected)); +} + +async fn assert_future_panics(future: impl Future, expected: &str) { + let panic = match AssertUnwindSafe(future).catch_unwind().await { + Ok(_) => panic!("future should panic"), + Err(panic) => panic, + }; + assert_panic_message(panic, expected); +} + +async fn run_smoke(args: &[&str]) -> Result> { + let mut command = BwrapTestCommand::new(smoke_fixture()?); + for arg in args { + command = command.arg(*arg); + } + run_smoke_command(command).await +} + +async fn run_smoke_command(command: BwrapTestCommand) -> Result> { + let mut process = command.spawn()?; + let mut stdout = process.take_stdout(); + let output = process + .scope(async move { + let mut output = String::new(); + stdout.read_to_string(&mut output).await?; + Ok(output) + }) + .await?; + parse_key_values(&output) +} + +fn parse_key_values(output: &str) -> Result> { + output + .lines() + .map(|line| { + line.split_once('=') + .map(|(key, value)| (key.to_string(), value.to_string())) + .with_context(|| format!("invalid smoke output line: {line:?}")) + }) + .collect() +} + +fn namespace_identity(namespace: &str) -> Result { + Ok(fs::read_link(format!("/proc/self/ns/{namespace}"))? + .to_string_lossy() + .into_owned()) +} + +#[tokio::test] +async fn dropping_without_teardown_panics_and_cleans_up() -> Result<()> { + let process = waiting_smoke_process().await?; + let environment = environment_path(&process); + assert_future_panics( + async move { drop(process) }, + "BwrapTestProcess dropped without async teardown", + ) + .await; + assert_environment_removed(&environment); + Ok(()) +} + +#[tokio::test] +async fn dropping_while_panicking_does_not_panic_again() -> Result<()> { + let process = waiting_smoke_process().await?; + let environment = environment_path(&process); + assert_future_panics( + async move { + let _process = process; + panic!("sentinel panic"); + }, + "sentinel panic", + ) + .await; + assert_environment_removed(&environment); + Ok(()) +} + +#[tokio::test] +async fn async_teardown_disarms_drop_bomb_and_cleans_up() -> Result<()> { + let process = waiting_smoke_process().await?; + let environment = environment_path(&process); + process.shutdown().await?; + assert_environment_removed(&environment); + Ok(()) +} + +#[tokio::test] +async fn scope_returns_value_and_cleans_up() -> Result<()> { + let process = waiting_smoke_process().await?; + let environment = environment_path(&process); + let value = process + .scope(async { Ok::<_, anyhow::Error>("scope value") }) + .await?; + assert_eq!(value, "scope value"); + assert_environment_removed(&environment); + Ok(()) +} + +#[tokio::test] +async fn outer_namespace_topology_matches_the_exec_server_contract() -> Result<()> { + let values = run_smoke(&["inspect"]).await?; + + assert_eq!( + values.get("id.effective_uid").map(String::as_str), + Some("1000") + ); + assert_eq!( + values.get("id.effective_gid").map(String::as_str), + Some("1000") + ); + for set in ["effective", "permitted", "inheritable", "ambient"] { + assert_eq!( + u64::from_str_radix( + values + .get(&format!("cap.{set}")) + .with_context(|| format!("missing {set} capabilities"))?, + 16, + )?, + 0, + "the non-root outer process must not carry {set} capabilities" + ); + } + assert_ne!( + values.get("ns.user"), + Some(&namespace_identity("user")?), + "outer user namespace should differ from its parent" + ); + for namespace in ["pid", "net"] { + assert_eq!( + values.get(&format!("ns.{namespace}")), + Some(&namespace_identity(namespace)?), + "outer {namespace} namespace should deliberately match its parent" + ); + } + for namespace in ["mnt", "ipc", "uts"] { + assert_ne!( + values.get(&format!("ns.{namespace}")), + Some(&namespace_identity(namespace)?), + "outer {namespace} namespace should differ from its parent" + ); + } + if let Some(pid_one_namespace) = values.get("proc.1.pid_ns") { + assert_eq!(Some(pid_one_namespace), values.get("proc.self.pid_ns")); + } else { + assert!( + values.contains_key("proc.1.pid_ns.error"), + "outer procfs should either expose PID 1 or report why it is unreadable" + ); + } + assert_eq!( + values.get("proc.self.pid_ns"), + Some(&namespace_identity("pid")?), + "outer /proc should deliberately expose the parent PID namespace" + ); + for name in ["overflowuid", "overflowgid"] { + let value = values + .get(&format!("proc.{name}")) + .with_context(|| format!("missing {name}"))?; + assert!(value.parse::().is_ok(), "invalid {name}: {value:?}"); + } + Ok(()) +} + +#[tokio::test] +async fn default_home_and_runtime_directories_use_private_tmp() -> Result<()> { + let values = run_smoke(&["inspect"]).await?; + + for (name, expected) in [ + ("HOME", "/tmp/home"), + ("CODEX_HOME", "/tmp/codex-home"), + ("XDG_RUNTIME_DIR", "/tmp/xdg-runtime"), + ("TMPDIR", "/tmp"), + ] { + assert_eq!( + values.get(&format!("env.{name}")).map(String::as_str), + Some(expected) + ); + } + Ok(()) +} + +#[tokio::test] +async fn command_environment_overrides_isolated_defaults() -> Result<()> { + let overrides = [ + ("HOME", "/tmp/bwrap-test-home-override"), + ("CODEX_HOME", "/tmp/bwrap-test-codex-home-override"), + ("XDG_RUNTIME_DIR", "/tmp/bwrap-test-xdg-runtime-override"), + ]; + let mut command = BwrapTestCommand::new(smoke_fixture()?).arg("inspect"); + for (name, value) in overrides { + command = command.env(name, value); + } + let values = run_smoke_command(command).await?; + + for (name, value) in overrides { + assert_eq!( + values.get(&format!("env.{name}")).map(String::as_str), + Some(value) + ); + } + Ok(()) +} + +#[tokio::test] +async fn root_is_read_only_while_tmp_and_workspace_round_trip() -> Result<()> { + let tmp_writable = Builder::new() + .prefix("bwrap-write-smoke-") + .tempdir_in("/tmp")?; + fs::write(tmp_writable.path().join("host-sentinel.txt"), "host")?; + let workspace = std::env::current_dir()?; + let workspace_writable = Builder::new() + .prefix("bwrap-workspace-write-smoke-") + .tempdir_in(workspace)?; + let root_target = format!("/bwrap-test-root-write-{}", std::process::id()); + let values = run_smoke(&[ + "filesystem", + tmp_writable + .path() + .to_str() + .context("non-UTF-8 temp path")?, + workspace_writable + .path() + .to_str() + .context("non-UTF-8 workspace path")?, + &root_target, + ]) + .await?; + + assert_eq!( + values.get("tmp.value").map(String::as_str), + Some("round-trip") + ); + assert_eq!( + values.get("tmp.host_sentinel_visible").map(String::as_str), + Some("false") + ); + assert_eq!( + fs::read_to_string(tmp_writable.path().join("host-sentinel.txt"))?, + "host" + ); + assert!(!tmp_writable.path().join("round-trip.txt").exists()); + assert_eq!( + values.get("workspace.value").map(String::as_str), + Some("round-trip") + ); + assert_eq!( + fs::read_to_string(workspace_writable.path().join("round-trip.txt"))?, + "round-trip" + ); + assert_eq!( + values.get("root.write_errno").map(String::as_str), + Some(libc::EROFS.to_string().as_str()) + ); + assert!(!Path::new(&root_target).exists()); + Ok(()) +} + +#[tokio::test] +async fn outer_loopback_reaches_a_parent_listener() -> Result<()> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let process = BwrapTestCommand::new(smoke_fixture()?) + .arg("connect") + .arg(address.to_string()) + .spawn()?; + + let received = process + .scope(async move { + timeout(Duration::from_secs(10), async move { + let (mut stream, _) = listener.accept().await?; + let mut received = Vec::new(); + stream.read_to_end(&mut received).await?; + Ok::<_, anyhow::Error>(received) + }) + .await + .context("parent listener timed out")? + }) + .await?; + assert_eq!(received, b"BWRAP_LOOPBACK"); + Ok(()) +} + +#[tokio::test] +async fn raw_nested_bwrap_creates_required_namespaces_and_fresh_proc() -> Result<()> { + let bwrap = codex_utils_cargo_bin::cargo_bin("bwrap")?; + let fixture = smoke_fixture()?; + let values = run_smoke(&[ + "nested", + bwrap.to_str().context("non-UTF-8 bwrap path")?, + fixture.to_str().context("non-UTF-8 fixture path")?, + ]) + .await?; + + for namespace in ["user", "mnt", "pid", "net"] { + assert_ne!( + values.get(&format!("outer.ns.{namespace}")), + values.get(&format!("child.ns.{namespace}")), + "nested {namespace} namespace should differ from the outer namespace" + ); + } + assert_eq!( + values.get("child.id.effective_uid").map(String::as_str), + Some("1000") + ); + assert_eq!( + values.get("child.id.effective_gid").map(String::as_str), + Some("1000") + ); + for set in ["effective", "permitted", "inheritable", "ambient"] { + assert_eq!( + u64::from_str_radix( + values + .get(&format!("child.cap.{set}")) + .with_context(|| format!("missing nested {set} capabilities"))?, + 16, + )?, + 0, + "the nested command should not retain {set} bwrap setup capabilities" + ); + } + assert_eq!( + values + .get("child.proc.1.pid_ns") + .context("nested procfs did not expose PID 1")?, + values + .get("child.proc.self.pid_ns") + .context("nested procfs did not expose self")? + ); + for name in ["overflowuid", "overflowgid"] { + let value = values + .get(&format!("child.proc.{name}")) + .with_context(|| format!("missing nested {name}"))?; + assert!(value.parse::().is_ok(), "invalid {name}: {value:?}"); + } + Ok(()) +}