mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
test: add bwrap integration-test sandbox
This commit is contained in:
@@ -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",
|
||||
],
|
||||
)
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user