From 68b4d9438eafbd4bb5e754691bafd886bcda2b7f Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Tue, 28 Jul 2026 15:13:34 -0700 Subject: [PATCH 1/6] test(learn): collapse over a private tempdir, not /usr/bin The collapse test assumed the four /usr/bin files it cats share a parent directory after learn canonicalizes them. On hosts where coreutils are symlinks into a multicall link farm (Ubuntu rust-coreutils on riscv64, issue #170), cat/ls/env resolve into /usr/lib/cargo/bin/coreutils/ while sh resolves to dash, so no directory reaches the N=4 threshold, the profile keeps individual file grants, and run's cat /usr/bin/true is denied because its canonical target was never observed. Collapse over files the test creates itself so the observed paths canonicalize to the same parent on every filesystem layout; reading a fifth, unobserved file still proves the directory grant is what admits it. Signed-off-by: Cong Wang --- .../sandlock-cli/tests/learn_integration.rs | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/sandlock-cli/tests/learn_integration.rs b/crates/sandlock-cli/tests/learn_integration.rs index e87e3d82..2ef6aa90 100644 --- a/crates/sandlock-cli/tests/learn_integration.rs +++ b/crates/sandlock-cli/tests/learn_integration.rs @@ -171,26 +171,44 @@ fn test_learn_then_run_unix_bind() { } /// Collapsed profile: directory grant covers files not individually observed during learn. +/// +/// Uses a private tempdir rather than /usr/bin: learn canonicalizes observed +/// paths, and on hosts where coreutils are symlinks into a multicall link farm +/// (e.g. Ubuntu rust-coreutils on riscv64) the /usr/bin names resolve to +/// parents that never reach the collapse threshold (issue #170). #[test] fn test_learn_then_run_collapse() { let profile = tempfile::NamedTempFile::new().expect("tempfile"); let profile_path = profile.path().to_str().unwrap().to_owned(); + let dir = tempfile::TempDir::new_in("/var/tmp").expect("tempdir in /var/tmp"); - // Learn touches several /usr/bin files; --collapse folds them into /usr/bin. - let learn = sandlock_bin() - .args(["learn", "--collapse", "-o", &profile_path, "--", "sh", "-c", - "cat /usr/bin/cat /usr/bin/sh /usr/bin/ls /usr/bin/env"]) - .output() - .expect("failed to run sandlock learn"); + // Four observed files meet the pinned collapse threshold. + let observed: Vec = (1..=4) + .map(|i| { + let p = dir.path().join(format!("observed{i}.txt")); + std::fs::write(&p, "collapse me\n").expect("write observed file"); + p.to_str().unwrap().to_owned() + }) + .collect(); + let extra = dir.path().join("unobserved.txt"); + std::fs::write(&extra, "covered by directory grant\n").expect("write unobserved file"); + + let mut learn_cmd = sandlock_bin(); + learn_cmd.args(["learn", "--collapse=4", "-o", &profile_path, "--", "cat"]); + learn_cmd.args(&observed); + let learn = learn_cmd.output().expect("failed to run sandlock learn"); assert!(learn.status.success(), "learn failed: {}", String::from_utf8_lossy(&learn.stderr)); - // Run accesses /usr/bin/true which was not individually observed, the collapsed grant covers it. + // Run reads a file that was not individually observed; the collapsed + // directory grant covers it. let run = sandlock_bin() - .args(["run", "--profile-file", &profile_path, "--", "cat", "/usr/bin/true"]) + .args(["run", "--profile-file", &profile_path, "--", "cat", extra.to_str().unwrap()]) .output() .expect("failed to run sandlock run"); assert!(run.status.success(), "run failed with collapsed profile: {}", String::from_utf8_lossy(&run.stderr)); + assert_eq!(String::from_utf8_lossy(&run.stdout).trim(), "covered by directory grant", + "unexpected output from unobserved file"); } /// Learned memory limit reflects actual anonymous allocation, not just the floor. From 6fbe4012d4567c07cf6ce8b22e148c0d0c2ceb85 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Tue, 28 Jul 2026 15:23:57 -0700 Subject: [PATCH 2/6] test(cli): diagnose and widen the wait for a sandbox to appear in ps test_config_toml_flag_produces_toml timed out on riscv64 waiting for its sandbox to show up in ps, while identical spawns in sibling tests passed the same run. The harness gave no way to tell why: spawn_sandbox discarded the child's stderr and wait_for_sandbox polled ps blindly, so a sandbox that died at startup and one that was merely slow produced the same bare timeout message. Capture stderr, fail fast with the exit status and stderr when the child dies before appearing, and raise the poll ceiling from 5s to 15s so parallel sandbox startups on slow boards are not misreported as failures. If the riscv64 failure recurs, the panic now carries the evidence needed to diagnose it. Signed-off-by: Cong Wang --- crates/sandlock-cli/tests/cli_test.rs | 44 ++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/sandlock-cli/tests/cli_test.rs b/crates/sandlock-cli/tests/cli_test.rs index 16455627..14e0cd56 100644 --- a/crates/sandlock-cli/tests/cli_test.rs +++ b/crates/sandlock-cli/tests/cli_test.rs @@ -429,21 +429,40 @@ fn sandlock_sleep_args(name: &str) -> Vec { /// Start a background sandbox, wait for it to be listed by `ps`, then /// return its PID. The caller is responsible for killing it. +/// +/// stderr is captured so wait_for_sandbox can report why a sandbox never +/// appeared instead of a bare timeout. fn spawn_sandbox(name: &str) -> std::process::Child { let bin = env!("CARGO_BIN_EXE_sandlock"); let args = sandlock_sleep_args(name); std::process::Command::new(bin) .args(&args) .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) .spawn() .expect("spawn sandlock") } +/// Kill `child` if still running and return whatever it wrote to stderr. +fn drain_child_stderr(child: &mut std::process::Child) -> String { + let _ = child.kill(); + let _ = child.wait(); + let mut stderr = String::new(); + if let Some(mut pipe) = child.stderr.take() { + use std::io::Read; + let _ = pipe.read_to_string(&mut stderr); + } + stderr +} + /// Wait for a sandbox to appear in `sandlock ps` output. -fn wait_for_sandbox(name: &str) -> Result<(), String> { +/// +/// Fails fast with the child's exit status and stderr if the sandbox process +/// dies before appearing. The 15s ceiling leaves room for slow hosts running +/// several sandbox startups in parallel (riscv64 boards). +fn wait_for_sandbox(child: &mut std::process::Child, name: &str) -> Result<(), String> { let bin = env!("CARGO_BIN_EXE_sandlock"); - for _ in 0..10 { + for _ in 0..30 { let out = std::process::Command::new(bin) .args(["ps"]) .output() @@ -452,9 +471,18 @@ fn wait_for_sandbox(name: &str) -> Result<(), String> { if stdout.contains(name) { return Ok(()); } + if let Ok(Some(status)) = child.try_wait() { + return Err(format!( + "sandbox '{}' exited ({}) before appearing in ps: {}", + name, status, drain_child_stderr(child) + )); + } std::thread::sleep(std::time::Duration::from_millis(500)); } - Err(format!("sandbox '{}' did not appear in ps output", name)) + Err(format!( + "sandbox '{}' did not appear in ps output within 15s: {}", + name, drain_child_stderr(child) + )) } #[test] @@ -462,7 +490,7 @@ fn test_ps_lists_running_sandbox() { let name = format!("test-ps-cli-{}", std::process::id()); let mut child = spawn_sandbox(&name); - match wait_for_sandbox(&name) { + match wait_for_sandbox(&mut child, &name) { Ok(()) => { let out = sandlock_bin() .args(["ps"]) @@ -514,7 +542,7 @@ fn test_config_returns_json_policy() { let name = format!("test-config-cli-{}", std::process::id()); let mut child = spawn_sandbox(&name); - match wait_for_sandbox(&name) { + match wait_for_sandbox(&mut child, &name) { Ok(()) => { let out = sandlock_bin() .args(["config", &name]) @@ -553,7 +581,7 @@ fn test_config_toml_flag_produces_toml() { let name = format!("test-config-toml-cli-{}", std::process::id()); let mut child = spawn_sandbox(&name); - match wait_for_sandbox(&name) { + match wait_for_sandbox(&mut child, &name) { Ok(()) => { let out = sandlock_bin() .args(["config", "--toml", &name]) @@ -601,7 +629,7 @@ fn test_kill_stops_sandbox() { let name = format!("test-kill-cli-{}", std::process::id()); let mut child = spawn_sandbox(&name); - match wait_for_sandbox(&name) { + match wait_for_sandbox(&mut child, &name) { Ok(()) => { let out = sandlock_bin() .args(["kill", &name]) From 4d9c41eaa304978527b3dab0f86340078dd313b7 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Tue, 28 Jul 2026 15:29:11 -0700 Subject: [PATCH 3/6] test(learn): make the unit-level collapse tests hermetic too test_collapse_threshold_reads fails on the rust-coreutils riscv64 host for the same reason as the integration collapse test: learn canonicalizes observed paths, so the four /usr/bin names scatter across /usr/bin (raw exec paths), /usr/bin/dash, and the multicall link farm, and no directory reaches the N=4 threshold. test_collapse_prefix_forces_collapse passes there only because a raw exec path happens to remain under /usr/bin; if exec recording ever canonicalizes fully, it breaks the same way. Point both tests at a tempdir the test populates itself, so observed paths share a canonical parent on every filesystem layout. The prefix test keeps its file count below the threshold so only --collapse-prefix can produce the directory grant. Signed-off-by: Cong Wang --- crates/sandlock-cli/tests/learn_test.rs | 64 ++++++++++++++++++------- 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/crates/sandlock-cli/tests/learn_test.rs b/crates/sandlock-cli/tests/learn_test.rs index 0498befb..354b3796 100644 --- a/crates/sandlock-cli/tests/learn_test.rs +++ b/crates/sandlock-cli/tests/learn_test.rs @@ -539,13 +539,27 @@ fn test_read_dedup_removes_leaf_when_ancestor_present() { } /// N-threshold collapse replaces individual files with their parent directory. +/// +/// Uses a private tempdir rather than /usr/bin: learn canonicalizes observed +/// paths, and on hosts where coreutils are symlinks into a multicall link farm +/// (e.g. Ubuntu rust-coreutils on riscv64) the /usr/bin names scatter across +/// parents that never reach the collapse threshold (issue #170). #[test] fn test_collapse_threshold_reads() { - let output = sandlock_bin() - .args(["learn", "--collapse", "--", "sh", "-c", - "cat /usr/bin/cat /usr/bin/sh /usr/bin/ls /usr/bin/env"]) - .output() - .expect("failed to run sandlock learn"); + let dir = tempfile::TempDir::new_in("/var/tmp").expect("tempdir in /var/tmp"); + let dir_path = dir.path().canonicalize().expect("canonicalize tempdir"); + let observed: Vec = (1..=4) + .map(|i| { + let p = dir_path.join(format!("observed{i}.txt")); + std::fs::write(&p, "collapse me\n").expect("write observed file"); + p.to_str().unwrap().to_owned() + }) + .collect(); + + let mut cmd = sandlock_bin(); + cmd.args(["learn", "--collapse=4", "--", "cat"]); + cmd.args(&observed); + let output = cmd.output().expect("failed to run sandlock learn"); assert!( output.status.success(), "sandlock learn failed: stderr={}", @@ -554,20 +568,35 @@ fn test_collapse_threshold_reads() { let stdout = String::from_utf8_lossy(&output.stdout); let read_line = stdout.lines().find(|l| l.starts_with("read = [")).unwrap_or(""); assert!( - read_line.contains("/usr/bin\"") || read_line.contains("/usr/bin,"), - "expected /usr/bin collapsed in reads, got: {read_line}", + read_line.contains(&format!("\"{}\"", dir_path.display())), + "expected {} collapsed in reads, got: {read_line}", + dir_path.display(), ); - assert!(!read_line.contains("/usr/bin/cat"), - "expected individual /usr/bin files removed after collapse, got: {read_line}"); + assert!(!read_line.contains("observed1.txt"), + "expected individual files removed after collapse, got: {read_line}"); } /// --collapse-prefix forces collapse regardless of the file count threshold. +/// +/// Two observed files stay below the N=4 default threshold, so only the +/// prefix can produce the directory grant. Hermetic for the same reason as +/// test_collapse_threshold_reads (issue #170). #[test] fn test_collapse_prefix_forces_collapse() { - let output = sandlock_bin() - .args(["learn", "--collapse-prefix", "/usr/bin", "--", "cat", "/usr/bin/cat", "/usr/bin/sh"]) - .output() - .expect("failed to run sandlock learn"); + let dir = tempfile::TempDir::new_in("/var/tmp").expect("tempdir in /var/tmp"); + let dir_path = dir.path().canonicalize().expect("canonicalize tempdir"); + let observed: Vec = (1..=2) + .map(|i| { + let p = dir_path.join(format!("observed{i}.txt")); + std::fs::write(&p, "collapse me\n").expect("write observed file"); + p.to_str().unwrap().to_owned() + }) + .collect(); + + let mut cmd = sandlock_bin(); + cmd.args(["learn", "--collapse-prefix", dir_path.to_str().unwrap(), "--", "cat"]); + cmd.args(&observed); + let output = cmd.output().expect("failed to run sandlock learn"); assert!( output.status.success(), "sandlock learn failed: stderr={}", @@ -576,11 +605,12 @@ fn test_collapse_prefix_forces_collapse() { let stdout = String::from_utf8_lossy(&output.stdout); let read_line = stdout.lines().find(|l| l.starts_with("read = [")).unwrap_or(""); assert!( - read_line.contains("/usr/bin\"") || read_line.contains("/usr/bin,"), - "expected /usr/bin collapsed in reads, got: {read_line}", + read_line.contains(&format!("\"{}\"", dir_path.display())), + "expected {} collapsed in reads, got: {read_line}", + dir_path.display(), ); - assert!(!read_line.contains("/usr/bin/cat"), - "expected /usr/bin/cat removed after prefix collapse, got: {read_line}"); + assert!(!read_line.contains("observed1.txt"), + "expected individual files removed after prefix collapse, got: {read_line}"); } /// Guarded paths are not collapsed by the N-threshold; individual files are kept. From 2737061f017068147faea916cc9e1ddd8835eb57 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Tue, 28 Jul 2026 15:42:25 -0700 Subject: [PATCH 4/6] fix(core): give internally assigned sandbox names a unique run id Since names started claiming a per-UID runtime dir with a hard failure on live collision, every internal fixed name became a global mutex: two concurrent pipelines collide on 'pipeline-stage-0', transactions on 'txn-stage-0', gathers on 'gather-consumer', and single-stage runs on 'stage'. A parallel test run surfaces this immediately (36 core integration tests fail), but any library embedder running two pipelines at once hits the same wall. Factor the auto-name generator's - suffix into unique_instance_id() and stamp it into every internally assigned name; one run id is shared across a pipeline, gather, or transaction so its stages remain recognizable as one unit in ps. Signed-off-by: Cong Wang --- crates/sandlock-core/src/pipeline.rs | 20 ++++++++++++++------ crates/sandlock-core/src/sandbox.rs | 18 +++++++++++++----- crates/sandlock-core/src/transaction.rs | 5 ++++- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/crates/sandlock-core/src/pipeline.rs b/crates/sandlock-core/src/pipeline.rs index 9f10b413..2230d611 100644 --- a/crates/sandlock-core/src/pipeline.rs +++ b/crates/sandlock-core/src/pipeline.rs @@ -47,7 +47,10 @@ impl Stage { /// Run this single stage and return the result. pub async fn run(self, timeout: Option) -> Result { let cmd_refs: Vec<&str> = self.args.iter().map(|s| s.as_str()).collect(); - let mut sb = self.sandbox.with_name("stage"); + // Names claim a per-UID runtime dir and a live collision is a hard + // error, so every internally assigned name carries a unique id. + let mut sb = self.sandbox.with_name( + format!("stage-{}", crate::sandbox::unique_instance_id())); if let Some(dur) = timeout { match tokio::time::timeout(dur, sb.run_interactive(&cmd_refs)).await { Ok(result) => result, @@ -180,11 +183,13 @@ async fn run_pipeline(stages: Vec) -> Result { let (cap_stdout_r, cap_stdout_w) = make_pipe().map_err(SandboxRuntimeError::Io)?; let (cap_stderr_r, cap_stderr_w) = make_pipe().map_err(SandboxRuntimeError::Io)?; - // Spawn each stage + // Spawn each stage. Stage names share one unique run id so concurrent + // pipelines in the same UID never collide on their runtime dirs. + let run = crate::sandbox::unique_instance_id(); let mut sandboxes: Vec = Vec::with_capacity(n); for (i, stage) in stages.into_iter().enumerate() { - let name = format!("pipeline-stage-{}", i); + let name = format!("pipeline-{run}-stage-{i}"); let mut sb = stage.sandbox.clone().with_name(name); // Determine stdin for this stage @@ -363,10 +368,13 @@ async fn run_gather( let (cap_stdout_r, cap_stdout_w) = make_pipe().map_err(SandboxRuntimeError::Io)?; let (cap_stderr_r, cap_stderr_w) = make_pipe().map_err(SandboxRuntimeError::Io)?; - // Spawn producers: each writes stdout to its pipe + // Spawn producers: each writes stdout to its pipe. Source and consumer + // names share one unique run id so concurrent gathers in the same UID + // never collide on their runtime dirs. + let run = crate::sandbox::unique_instance_id(); let mut sandboxes: Vec = Vec::with_capacity(n + 1); for (i, ns) in sources.into_iter().enumerate() { - let name = format!("gather-source-{}", ns.name); + let name = format!("gather-{run}-source-{}", ns.name); let mut sb = ns.stage.sandbox.clone().with_name(name); let stdout_fd = source_pipes[i].1.as_raw_fd(); let cmd_refs: Vec<&str> = ns.stage.args.iter().map(|s| s.as_str()).collect(); @@ -380,7 +388,7 @@ async fn run_gather( // Inject _SANDLOCK_GATHER env var consumer_sandbox.env.insert("_SANDLOCK_GATHER".to_string(), gather_env); - let mut consumer_sb = consumer_sandbox.clone().with_name("gather-consumer"); + let mut consumer_sb = consumer_sandbox.clone().with_name(format!("gather-{run}-consumer")); let stdin_fd = source_pipes[n - 1].0.as_raw_fd(); // Build extra fd mappings for non-stdin sources diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index cc6c974b..e4e886d5 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -2487,14 +2487,22 @@ static NEXT_SANDBOX_NAME: std::sync::atomic::AtomicU64 = std::sync::atomic::Atom fn sandbox_resolve_name(name: Option<&str>) -> Result { match name { Some(n) => sandbox_validate_name(n.to_string()), - None => Ok(format!( - "sandbox-{}-{}", - std::process::id(), - NEXT_SANDBOX_NAME.fetch_add(1, std::sync::atomic::Ordering::Relaxed), - )), + None => Ok(format!("sandbox-{}", unique_instance_id())), } } +/// A `-` suffix that makes an internally generated sandbox name +/// unique across processes and within one. The runtime dir under +/// /dev/shm/sandlock-$UID/ is claimed per name and a live collision is a hard +/// error, so no internal caller may use a fixed name. +pub(crate) fn unique_instance_id() -> String { + format!( + "{}-{}", + std::process::id(), + NEXT_SANDBOX_NAME.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + ) +} + fn sandbox_validate_name(name: String) -> Result { use crate::error::SandboxRuntimeError; if name.is_empty() { diff --git a/crates/sandlock-core/src/transaction.rs b/crates/sandlock-core/src/transaction.rs index f8232d08..8fbaaceb 100644 --- a/crates/sandlock-core/src/transaction.rs +++ b/crates/sandlock-core/src/transaction.rs @@ -673,10 +673,13 @@ async fn drive_txn_stages( // (best-effort). See `open_stderr_tee` for why it must not be a dup of fd 2. let tee_fd: Option = open_stderr_tee(); + // Stage names share one unique run id so concurrent transactions in the + // same UID never collide on their runtime dirs. + let run = crate::sandbox::unique_instance_id(); for (i, stage) in stages.into_iter().enumerate() { let at = |source: SandlockError| TxnError::Stage { index: i, source }; let cmd_refs: Vec<&str> = stage.args.iter().map(|s| s.as_str()).collect(); - let mut sb = stage.sandbox.with_name(format!("txn-stage-{i}")); + let mut sb = stage.sandbox.with_name(format!("txn-{run}-stage-{i}")); sb.set_shared_cow(shared.clone()).map_err(at)?; // Redirect the stage's fd 2 onto a pipe the coordinator drains, keeping From 036d86a32ba9bc6143dc2f7f52beb669b51ccf90 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Tue, 28 Jul 2026 15:42:25 -0700 Subject: [PATCH 5/6] test(core): stop sharing fixed sandbox names across parallel tests The 'test', 't', and 'mybox' names are used by several integration tests each; with names now claiming a per-UID runtime dir, any two of those tests running in parallel collide. Drop the name where it carried no meaning (the auto-generated name is unique), and give the hostname-virtualization tests, which must know their name to assert the virtual hostname, names that are distinct per test and suffixed with the process id so concurrent test binaries cannot collide either. Signed-off-by: Cong Wang --- .../tests/integration/test_cow.rs | 8 ++++---- .../tests/integration/test_determinism.rs | 19 +++++++++++++------ .../tests/integration/test_landlock.rs | 4 ---- .../tests/integration/test_sandbox.rs | 6 +++--- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/crates/sandlock-core/tests/integration/test_cow.rs b/crates/sandlock-core/tests/integration/test_cow.rs index d333bbf2..9e3c8bd9 100644 --- a/crates/sandlock-core/tests/integration/test_cow.rs +++ b/crates/sandlock-core/tests/integration/test_cow.rs @@ -342,7 +342,7 @@ async fn test_seccomp_cow_legacy_stat_honors_whiteout() { .unwrap(); let script = "rm gone.txt ; legacy-stat gone.txt ; legacy-lstat gone.txt ; legacy-access gone.txt"; - let result = policy.clone().with_name("test") + let result = policy.clone() .run(&[helper.to_str().unwrap(), "sh", "-c", script]).await; match result { Ok(r) => { @@ -1204,7 +1204,7 @@ async fn test_cow_child_rm_r_directory_stays_deleted() { .unwrap(); let cmd = format!("rm -r {}/d", workdir.display()); - let result = policy.clone().with_name("test").run(&["sh", "-c", &cmd]).await; + let result = policy.clone().run(&["sh", "-c", &cmd]).await; match result { Ok(r) => { assert!(r.success(), "rm -r should succeed, stderr: {}", r.stderr_str().unwrap_or("")); @@ -1235,7 +1235,7 @@ async fn test_cow_child_mv_directory_preserves_contents() { .unwrap(); let cmd = format!("mv {}/d {}/d2", workdir.display(), workdir.display()); - let result = policy.clone().with_name("test").run(&["sh", "-c", &cmd]).await; + let result = policy.clone().run(&["sh", "-c", &cmd]).await; match result { Ok(r) => { assert!(r.success(), "mv should succeed, stderr: {}", r.stderr_str().unwrap_or("")); @@ -1270,7 +1270,7 @@ async fn test_cow_child_rmdir_nonempty_fails() { .unwrap(); let cmd = format!("rmdir {}/d", workdir.display()); - let result = policy.clone().with_name("test").run(&["sh", "-c", &cmd]).await; + let result = policy.clone().run(&["sh", "-c", &cmd]).await; match result { Ok(r) => { assert!(!r.success(), "rmdir of a non-empty directory must fail"); diff --git a/crates/sandlock-core/tests/integration/test_determinism.rs b/crates/sandlock-core/tests/integration/test_determinism.rs index bb64d4ee..2bac634e 100644 --- a/crates/sandlock-core/tests/integration/test_determinism.rs +++ b/crates/sandlock-core/tests/integration/test_determinism.rs @@ -210,17 +210,21 @@ async fn test_hostname_virtualization() { .build() .unwrap(); + // Unique per process so concurrent test binaries never collide on the + // per-name runtime dir. + let name = format!("mybox-{}", std::process::id()); + // Verify uname() returns the virtual hostname. - let result = policy.clone().with_name("mybox").run(&["hostname"]).await.unwrap(); + let result = policy.clone().with_name(&name).run(&["hostname"]).await.unwrap(); assert!(result.success(), "hostname command failed"); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); - assert_eq!(stdout.trim(), "mybox", "Expected hostname 'mybox', got: {:?}", stdout.trim()); + assert_eq!(stdout.trim(), name, "Expected hostname {name:?}, got: {:?}", stdout.trim()); // Verify /etc/hostname also returns the virtual hostname. - let result = policy.clone().with_name("mybox").run(&["cat", "/etc/hostname"]).await.unwrap(); + let result = policy.clone().with_name(&name).run(&["cat", "/etc/hostname"]).await.unwrap(); assert!(result.success(), "cat /etc/hostname failed"); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); - assert_eq!(stdout.trim(), "mybox", "Expected /etc/hostname 'mybox', got: {:?}", stdout.trim()); + assert_eq!(stdout.trim(), name, "Expected /etc/hostname {name:?}, got: {:?}", stdout.trim()); } /// The /etc/hostname shim used to do a literal `path == "/etc/hostname"` @@ -251,10 +255,13 @@ async fn test_hostname_virtualization_resists_path_bypasses() { "print(results)\n", ); - let result = policy.clone().with_name("mybox").run(&["python3", "-c", script]).await.unwrap(); + // Unique per process and distinct from test_hostname_virtualization's + // name; both tests run concurrently in this binary. + let name = format!("mybox-bypass-{}", std::process::id()); + let result = policy.clone().with_name(&name).run(&["python3", "-c", script]).await.unwrap(); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); for label in ["dirfd", "dotdot", "curdir", "slash2"] { - let needle = format!("'{label}': 'mybox'"); + let needle = format!("'{label}': '{name}'"); assert!( stdout.contains(&needle), "{label}: host /etc/hostname leaked. stdout: {stdout}" diff --git a/crates/sandlock-core/tests/integration/test_landlock.rs b/crates/sandlock-core/tests/integration/test_landlock.rs index 5a4c9d59..d44f5d71 100644 --- a/crates/sandlock-core/tests/integration/test_landlock.rs +++ b/crates/sandlock-core/tests/integration/test_landlock.rs @@ -1565,7 +1565,6 @@ async fn test_deny_carveout_on_behalf_open_preserves_io() { // Allowed read still works (on-behalf probe + reopen path). let r = policy .clone() - .with_name("t") .run(&["cat", ok.to_str().unwrap()]) .await .unwrap(); @@ -1575,7 +1574,6 @@ async fn test_deny_carveout_on_behalf_open_preserves_io() { let cmd = format!("echo hi > {}", created.display()); let w = policy .clone() - .with_name("t") .run_interactive(&["sh", "-c", &cmd]) .await .unwrap(); @@ -1585,7 +1583,6 @@ async fn test_deny_carveout_on_behalf_open_preserves_io() { // The denied carve-out stays blocked. let d = policy .clone() - .with_name("t") .run(&["cat", secret.to_str().unwrap()]) .await .unwrap(); @@ -1638,7 +1635,6 @@ async fn test_deny_openat2_does_not_bypass() { let r = policy .clone() - .with_name("t") .run(&["python3", "-c", &script]) .await .unwrap(); diff --git a/crates/sandlock-core/tests/integration/test_sandbox.rs b/crates/sandlock-core/tests/integration/test_sandbox.rs index 08302d98..58ede2c2 100644 --- a/crates/sandlock-core/tests/integration/test_sandbox.rs +++ b/crates/sandlock-core/tests/integration/test_sandbox.rs @@ -741,7 +741,7 @@ fn capture_policy() -> Sandbox { #[tokio::test] async fn test_run_captures_stdout_larger_than_the_pipe_buffer() { let cmd = format!("head -c {} /dev/zero | tr '\\0' 'X'", OVER_PIPE_BUFFER); - let mut sb = capture_policy().with_name("test"); + let mut sb = capture_policy(); let args = ["sh", "-c", cmd.as_str()]; let fut = sb.run(&args); let result = tokio::time::timeout(std::time::Duration::from_secs(60), fut) @@ -764,7 +764,7 @@ async fn test_run_captures_stdout_larger_than_the_pipe_buffer() { #[tokio::test] async fn test_run_captures_stderr_larger_than_the_pipe_buffer() { let cmd = format!("head -c {} /dev/zero | tr '\\0' 'E' 1>&2", OVER_PIPE_BUFFER); - let mut sb = capture_policy().with_name("test"); + let mut sb = capture_policy(); let args = ["sh", "-c", cmd.as_str()]; let fut = sb.run(&args); let result = tokio::time::timeout(std::time::Duration::from_secs(60), fut) @@ -793,7 +793,7 @@ async fn test_run_captures_large_stdout_and_stderr_together() { "head -c {n} /dev/zero | tr '\\0' 'O'; head -c {n} /dev/zero | tr '\\0' 'E' 1>&2", n = OVER_PIPE_BUFFER ); - let mut sb = capture_policy().with_name("test"); + let mut sb = capture_policy(); let args = ["sh", "-c", cmd.as_str()]; let fut = sb.run(&args); let result = tokio::time::timeout(std::time::Duration::from_secs(60), fut) From 1d6e2feace221f39c42cd9cd004e78914f7956c6 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Wed, 29 Jul 2026 14:11:03 -0700 Subject: [PATCH 6/6] test(learn): canonicalize the tempdir in the collapse run test too The other two collapse tests canonicalize their tempdir because learn records canonicalized paths; test_learn_then_run_collapse used the raw tempdir path, which happens to work only while /var/tmp resolves to itself. Canonicalize it the same way so the learned grant and the run command always agree on the path. Signed-off-by: Cong Wang --- crates/sandlock-cli/tests/learn_integration.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/sandlock-cli/tests/learn_integration.rs b/crates/sandlock-cli/tests/learn_integration.rs index 2ef6aa90..1918ac33 100644 --- a/crates/sandlock-cli/tests/learn_integration.rs +++ b/crates/sandlock-cli/tests/learn_integration.rs @@ -181,16 +181,17 @@ fn test_learn_then_run_collapse() { let profile = tempfile::NamedTempFile::new().expect("tempfile"); let profile_path = profile.path().to_str().unwrap().to_owned(); let dir = tempfile::TempDir::new_in("/var/tmp").expect("tempdir in /var/tmp"); + let dir_path = dir.path().canonicalize().expect("canonicalize tempdir"); // Four observed files meet the pinned collapse threshold. let observed: Vec = (1..=4) .map(|i| { - let p = dir.path().join(format!("observed{i}.txt")); + let p = dir_path.join(format!("observed{i}.txt")); std::fs::write(&p, "collapse me\n").expect("write observed file"); p.to_str().unwrap().to_owned() }) .collect(); - let extra = dir.path().join("unobserved.txt"); + let extra = dir_path.join("unobserved.txt"); std::fs::write(&extra, "covered by directory grant\n").expect("write unobserved file"); let mut learn_cmd = sandlock_bin();