mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 17:10:44 -10:00
test(tui): unshadow sandbox fixtures and break the workshop/env lock cycle
Two independent test-infrastructure defects. #6305 — the Linux bwrap wrapper mounts a fresh `--tmpfs /tmp` before it binds the policy's writable roots, and an enforced read-only command has no writable roots at all, so nothing re-exposes the host `/tmp`. A fixture workspace rooted there is shadowed inside the sandbox and the trailing `--chdir <workspace>` lands on a path that no longer exists (`bwrap: Can't chdir to /tmp/.tmpXXXXXX`). The tmpfs is deliberate isolation and a security boundary, so the fixture moves instead of the mount: `test_support::sandbox_visible_tempdir()` roots it under this crate's `OUT_DIR` (the Cargo target directory, not `TMPDIR`) and fails loudly if that itself is under `/tmp`. Applied to the reported delivery probe and to the two `enforced_readonly` native probes, which have the same shape — one of them was passing only because the bwrap chdir failure happened to satisfy its negative assertion. #6306 — the two `declared_model_posts_*` tests were victims, not the cause. `active_workshop_test_guard()` takes a process-wide serial mutex and its one caller holds it across `reload_config`, which loads settings and reads the environment through `test_support::with_test_env_lock`; a test that seals the environment *first* and then reaches `WorkshopConfig::install_active` through that same `reload_config` takes the two locks in the opposite order. Confirmed by sampling the wedged binary: `http_and_web_server_thread_manager_installs_configured_workshop_byte_budgets` held the workshop gate and was parked in `lock_test_env`, while `scoped_runtime_selections_leave_device_memory_unchanged` held the env barrier and was parked at `large_output_router.rs:121` on the workshop gate — with every other `config` test queued behind the barrier. The guard now takes the env barrier before the gate, the same shape as `provider_lake::lock_live_snapshot`, which makes the inversion impossible for every caller at once. Validation (macOS, aarch64, lib test binary): - before the #6306 fix: `config` filter wedged; 17 tests reporting "has been running for over 60 seconds"; killed after ~10 minutes. - after: `test result: ok. 1054 passed; 0 failed; 1 ignored; 0 measured; 12015 filtered out; finished in 8.56s` — reproduced stable over three further runs (8.35s / 8.37s / 8.35s). - `enforced_readonly`: `test result: ok. 7 passed; 0 failed` with real NATIVE_READONLY_ENFORCED receipts (macOS seatbelt), not UNRUN. - `large_output_router` 5 passed, `workshop` 3 passed, `test_env_lock` 2 passed, `sandbox` 123 passed, `tools::subagent::delivery_tests` 19 passed; all 0 failed. - `cargo fmt --all -- --check` clean. Not verified: #6305 is Linux/bwrap-only and cannot be exercised on this macOS host. The relocation is argued against the argv built in `sandbox/bwrap.rs` (`--tmpfs /tmp` precedes the writable binds, and `safe_mounts` yields none for a read-only command) and is proven only to keep the macOS seatbelt probes passing from the new root. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
2f47a370f4
commit
4e27b4ee17
4 files changed
+90
-4
No files matched your search
@@ -81,6 +81,56 @@ thread_local! {
|
||||
static HOLDER_ROOT: OnceLock<PathBuf> = const { OnceLock::new() };
|
||||
}
|
||||
|
||||
/// A fixture workspace an OS sandbox can still see (#6305).
|
||||
///
|
||||
/// The Linux bwrap wrapper mounts a fresh `--tmpfs /tmp` before it binds the
|
||||
/// policy's writable roots (`sandbox/bwrap.rs`), and an enforced read-only
|
||||
/// command has no writable roots at all — nothing re-exposes the host `/tmp`.
|
||||
/// A fixture rooted there is shadowed inside the sandbox, so the trailing
|
||||
/// `--chdir <workspace>` lands on a path that no longer exists and bwrap exits
|
||||
/// with `Can't chdir to /tmp/.tmpXXXXXX`. The tmpfs is deliberate isolation and
|
||||
/// a security boundary, so the fixture moves instead of the mount.
|
||||
///
|
||||
/// Known limitations: this relocates only the directory the sandboxed command
|
||||
/// chdirs into. It does not make anything else under the host `/tmp` reachable
|
||||
/// from inside the sandbox, and it says nothing about `/dev` or `/proc`, which
|
||||
/// bwrap also replaces. Use it for the sandbox probes; plain `tempfile::tempdir`
|
||||
/// stays correct everywhere else.
|
||||
pub(crate) fn sandbox_visible_tempdir() -> tempfile::TempDir {
|
||||
let root = sandbox_visible_fixture_root();
|
||||
tempfile::tempdir_in(root).unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"failed to create a sandbox-visible fixture workspace in {}: {error}",
|
||||
root.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// `OUT_DIR` is this crate's own build directory, so it follows the Cargo
|
||||
/// target directory rather than `TMPDIR` — the one path every test binary
|
||||
/// already owns and that is outside `/tmp` in every normal layout. A target
|
||||
/// directory deliberately placed under `/tmp` would silently reintroduce
|
||||
/// #6305, so say so instead of handing back a shadowed path.
|
||||
fn sandbox_visible_fixture_root() -> &'static Path {
|
||||
static ROOT: OnceLock<PathBuf> = OnceLock::new();
|
||||
ROOT.get_or_init(|| {
|
||||
let root = Path::new(env!("OUT_DIR")).join("sandbox-fixtures");
|
||||
assert!(
|
||||
!root.starts_with("/tmp"),
|
||||
"sandbox fixtures need a root outside /tmp, which bwrap replaces with a fresh \
|
||||
tmpfs: {} is under it. Point CARGO_TARGET_DIR somewhere else.",
|
||||
root.display()
|
||||
);
|
||||
std::fs::create_dir_all(&root).unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"failed to create the sandbox fixture root {}: {error}",
|
||||
root.display()
|
||||
)
|
||||
});
|
||||
root
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a syntactically valid, non-secret JWT fixture without embedding a
|
||||
/// high-entropy token-shaped literal in Git history.
|
||||
pub(crate) fn future_test_jwt(label: &str) -> String {
|
||||
|
||||
@@ -48,9 +48,15 @@ std::thread_local! {
|
||||
/// Holds every test-side workshop activation behind one process-wide gate.
|
||||
/// The thread-local marker lets the owning current-thread test call
|
||||
/// `install_active` without trying to acquire its own non-reentrant lock.
|
||||
///
|
||||
/// The guard also owns the process-wide test env barrier, because holding this
|
||||
/// gate across a `reload_config` is holding it across `Settings::load` and
|
||||
/// `Config`'s env reads, which take that barrier (#6306). Field order is the
|
||||
/// release order: the serial gate first, then the barrier.
|
||||
#[cfg(test)]
|
||||
pub(crate) struct ActiveWorkshopTestGuard {
|
||||
_serial: std::sync::MutexGuard<'static, ()>,
|
||||
_env: Option<crate::test_support::TestEnvLock>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -60,18 +66,42 @@ impl Drop for ActiveWorkshopTestGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the workshop gate, and the env barrier under it, in that order (#6306).
|
||||
///
|
||||
/// A holder of this gate keeps it across whole product calls — `reload_config`
|
||||
/// installs the budgets and then loads settings — and those calls read the
|
||||
/// environment through `test_support::with_test_env_lock`. A test that sealed
|
||||
/// the environment first and then reaches `install_active` through the very
|
||||
/// same `reload_config` takes the two locks in the opposite order, and the
|
||||
/// pair wedges the whole binary: `cargo test -p codewhale-tui --lib config`
|
||||
/// never returned, with dozens of unrelated tests queued on the barrier.
|
||||
/// Acquiring the barrier here, before the gate, makes that inversion
|
||||
/// impossible for every caller at once — the same shape as
|
||||
/// `provider_lake::lock_live_snapshot`.
|
||||
///
|
||||
/// Known limitation: this orders these two locks and nothing else. A future
|
||||
/// process-wide test gate held across product code has to join the same order
|
||||
/// rather than invent a third one.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn active_workshop_test_guard() -> ActiveWorkshopTestGuard {
|
||||
assert!(
|
||||
!ACTIVE_WORKSHOP_TEST_SERIAL_HELD.with(std::cell::Cell::get),
|
||||
"active workshop test guard is not reentrant"
|
||||
);
|
||||
let env = if crate::test_support::current_thread_holds_test_env_lock() {
|
||||
None
|
||||
} else {
|
||||
Some(crate::test_support::lock_test_env())
|
||||
};
|
||||
let serial = ACTIVE_WORKSHOP_TEST_SERIAL
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
ACTIVE_WORKSHOP_TEST_SERIAL_HELD.with(|held| held.set(true));
|
||||
ActiveWorkshopTestGuard { _serial: serial }
|
||||
ActiveWorkshopTestGuard {
|
||||
_serial: serial,
|
||||
_env: env,
|
||||
}
|
||||
}
|
||||
|
||||
fn active_workshop_slot() -> &'static Mutex<WorkshopConfig> {
|
||||
|
||||
@@ -142,7 +142,9 @@ fn python(script: &str) -> String {
|
||||
#[tokio::test]
|
||||
#[allow(clippy::print_stderr)] // Native enforcement receipt, outside the TUI runtime.
|
||||
async fn enforced_readonly_native_python_reads_sqlite_and_cannot_write() {
|
||||
let tmp = tempdir().unwrap();
|
||||
// #6305: the sandbox replaces /tmp with a fresh tmpfs, so a fixture
|
||||
// rooted there vanishes before --chdir reaches it.
|
||||
let tmp = crate::test_support::sandbox_visible_tempdir();
|
||||
let Some(context) = native_context(tmp.path()) else {
|
||||
return;
|
||||
};
|
||||
@@ -192,7 +194,9 @@ async fn enforced_readonly_native_python_reads_sqlite_and_cannot_write() {
|
||||
#[tokio::test]
|
||||
#[allow(clippy::print_stderr)] // Native enforcement receipt, outside the TUI runtime.
|
||||
async fn enforced_readonly_native_python_cannot_reach_a_loopback_listener() {
|
||||
let tmp = tempdir().unwrap();
|
||||
// #6305: the sandbox replaces /tmp with a fresh tmpfs, so a fixture
|
||||
// rooted there vanishes before --chdir reaches it.
|
||||
let tmp = crate::test_support::sandbox_visible_tempdir();
|
||||
let Some(context) = native_context(tmp.path()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -435,7 +435,9 @@ fn disjoint_sibling_write_paths_admit_and_ancestor_overlap_names_actual_remedy()
|
||||
#[tokio::test]
|
||||
#[allow(clippy::print_stderr)] // Test receipt must distinguish refused probes from exercised isolation.
|
||||
async fn enforced_readonly_python_queries_sqlite_under_a_live_peer_write_claim() {
|
||||
let tmp = tempdir().unwrap();
|
||||
// #6305: the sandbox replaces /tmp with a fresh tmpfs, so a fixture
|
||||
// rooted there vanishes before --chdir reaches it.
|
||||
let tmp = crate::test_support::sandbox_visible_tempdir();
|
||||
let database = rusqlite::Connection::open(tmp.path().join("fixture.sqlite")).unwrap();
|
||||
database
|
||||
.execute_batch(
|
||||
|
||||
Reference in new issue
Block a user