test: add bwrap integration-test sandbox

This commit is contained in:
Adam Perry
2026-07-10 00:42:29 +00:00
parent 2b44896c5a
commit ec85a19f31
5 changed files with 993 additions and 0 deletions
+57
View File
@@ -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",
],
)
+6
View File
@@ -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",
}
@@ -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<BTreeMap<String, String>> {
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<String> {
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<u32> {
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(())
}
+335
View File
@@ -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<OsString>,
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<BwrapProcesses>,
}
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<PathBuf>) -> 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<OsString>) -> 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<OsString>, value: impl Into<OsString>) -> 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<BwrapTestProcess> {
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<T>(self, future: impl Future<Output = Result<T>>) -> Result<T> {
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<Self> {
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;
+390
View File
@@ -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<PathBuf> {
Ok(codex_utils_cargo_bin::cargo_bin("bwrap-smoke")?)
}
async fn waiting_smoke_process() -> Result<BwrapTestProcess> {
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<dyn Any + Send>, expected: &str) {
assert_eq!(panic.downcast_ref::<&str>(), Some(&expected));
}
async fn assert_future_panics<T>(future: impl Future<Output = T>, 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<BTreeMap<String, String>> {
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<BTreeMap<String, String>> {
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<BTreeMap<String, String>> {
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<String> {
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::<u32>().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::<u32>().is_ok(), "invalid {name}: {value:?}");
}
Ok(())
}