Optionally back /tmp and /var/tmp with stable host directories

This commit is contained in:
2026-05-15 01:36:58 +02:00
parent 28e68b0fff
commit 3fb0da0577
13 changed files with 839 additions and 52 deletions
+188 -5
View File
@@ -5,16 +5,29 @@ use tempfile::TempDir;
use crate::common::*;
struct CleanupFile(&'static str);
struct CleanupPath(String);
impl Drop for CleanupFile {
impl CleanupPath {
fn new(path: impl Into<String>) -> Self {
Self(path.into())
}
}
impl Drop for CleanupPath {
fn drop(&mut self) {
let _ = fs::remove_file(self.0);
match std::path::Path::new(&self.0) {
p if p.is_dir() => {
let _ = fs::remove_dir_all(p);
}
p => {
let _ = fs::remove_file(p);
}
}
}
}
#[test]
fn cwd_is_writable() {
let _cleanup = CleanupFile("./sandbox_canary");
let _cleanup = CleanupPath::new("./sandbox_canary");
let output = Sandbox::new(&[])
.args(["--", "bash", "-c", "touch ./sandbox_canary && echo ok"])
@@ -166,7 +179,7 @@ fn rw_refines_ro_parent() {
fn blacklist_overlays_survive_tmp_bind() {
let mut sandbox = Sandbox::new_for_host_mutation(&["--blacklist"]);
fs::write("/tmp/ssh-sandbox-test", "secret").expect("failed to write sentinel");
let _cleanup = CleanupFile("/tmp/ssh-sandbox-test");
let _cleanup = CleanupPath::new("/tmp/ssh-sandbox-test");
let output = sandbox
.args([
@@ -385,6 +398,176 @@ fn build_bwrap_command(sandbox_args: &[&str]) -> Vec<String> {
parsed
}
#[test]
fn persistent_tmp_dry_run_uses_session_path_and_creates_no_dirs() {
let label = format!("e2e-dry-{}-{}", std::process::id(), rand_suffix());
let output = Sandbox::new(&["--persistent-tmp", "--persistent-key", &label, "--dry-run"])
.args(["--", "true"])
.output()
.expect("agent-sandbox binary failed to execute");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(&format!("/tmp/agent-sandbox-{label}")),
"dry-run output should embed session /tmp path, got: {stdout}"
);
assert!(
stdout.contains(&format!("/var/tmp/agent-sandbox-{label}")),
"dry-run output should embed session /var/tmp path, got: {stdout}"
);
assert!(
!std::path::Path::new(&format!("/tmp/agent-sandbox-{label}")).exists(),
"--dry-run must not create the host directory"
);
}
#[test]
fn persistent_tmp_survives_across_invocations() {
let label = format!("e2e-persist-{}-{}", std::process::id(), rand_suffix());
let _cleanup_tmp = CleanupPath::new(format!("/tmp/agent-sandbox-{label}"));
let _cleanup_var = CleanupPath::new(format!("/var/tmp/agent-sandbox-{label}"));
let writer = Sandbox::new(&["--persistent-tmp", "--persistent-key", &label])
.args([
"--",
"bash",
"-c",
"echo persisted > /tmp/canary && echo also-persisted > /var/tmp/canary",
])
.output()
.expect("write run failed");
assert!(
writer.status.success(),
"stderr: {}",
String::from_utf8_lossy(&writer.stderr)
);
let reader = Sandbox::new(&["--persistent-tmp", "--persistent-key", &label])
.args(["--", "bash", "-c", "cat /tmp/canary /var/tmp/canary"])
.output()
.expect("read run failed");
assert!(
reader.status.success(),
"stderr: {}",
String::from_utf8_lossy(&reader.stderr)
);
let out = String::from_utf8_lossy(&reader.stdout);
assert!(
out.contains("persisted"),
"expected /tmp canary, got: {out}"
);
assert!(
out.contains("also-persisted"),
"expected /var/tmp canary, got: {out}"
);
}
#[test]
fn persistent_tmp_is_noop_in_blacklist_mode() {
let label = format!("e2e-bl-noop-{}-{}", std::process::id(), rand_suffix());
let canary_name = format!(
"agent-sandbox-bl-canary-{}-{}",
std::process::id(),
rand_suffix()
);
let canary_host = format!("/tmp/{canary_name}");
let _cleanup_canary = CleanupPath::new(&canary_host);
let output = Sandbox::new(&[
"--blacklist",
"--persistent-tmp",
"--persistent-key",
&label,
])
.args([
"--",
"bash",
"-c",
&format!("echo bl-shared > /tmp/{canary_name}"),
])
.output()
.expect("write run failed");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
std::path::Path::new(&canary_host).exists(),
"blacklist /tmp should be host /tmp; expected canary at {canary_host}"
);
assert!(
!std::path::Path::new(&format!("/tmp/agent-sandbox-{label}")).exists(),
"--persistent-tmp must be a no-op in blacklist mode, but session dir was created"
);
}
#[test]
fn persistent_tmp_resumes_with_explicit_key_matching_derived_hash() {
let chdir = TempDir::new().unwrap();
let chdir_str = chdir.path().to_str().unwrap();
let dry = Sandbox::new(&["--persistent-tmp", "--dry-run", "--chdir", chdir_str])
.args(["--", "true"])
.output()
.expect("dry-run failed");
assert!(dry.status.success());
let key = extract_persistent_key(&String::from_utf8_lossy(&dry.stdout));
let _cleanup_tmp = CleanupPath::new(format!("/tmp/agent-sandbox-{key}"));
let _cleanup_var = CleanupPath::new(format!("/var/tmp/agent-sandbox-{key}"));
let writer = Sandbox::new(&["--persistent-tmp", "--chdir", chdir_str])
.args(["--", "bash", "-c", "echo persisted > /tmp/canary"])
.output()
.expect("write run failed");
assert!(
writer.status.success(),
"stderr: {}",
String::from_utf8_lossy(&writer.stderr)
);
// Second run: different chdir (would have derived a different hash) plus an
// extra flag, but an explicit key pinning the same host directory.
let other_chdir = TempDir::new().unwrap();
let reader = Sandbox::new(&[
"--persistent-tmp",
"--persistent-key",
&key,
"--chdir",
other_chdir.path().to_str().unwrap(),
"--unshare-net",
])
.args(["--", "cat", "/tmp/canary"])
.output()
.expect("read run failed");
assert!(
reader.status.success(),
"stderr: {}",
String::from_utf8_lossy(&reader.stderr)
);
assert!(String::from_utf8_lossy(&reader.stdout).contains("persisted"));
}
fn extract_persistent_key(dryrun_stdout: &str) -> String {
let prefix = "/tmp/agent-sandbox-";
let idx = dryrun_stdout
.find(prefix)
.expect("dry-run output should mention /tmp/agent-sandbox-<key>");
dryrun_stdout[idx + prefix.len()..]
.chars()
.take_while(|c| c.is_ascii_hexdigit())
.collect()
}
fn rand_suffix() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos();
format!("{nanos:08x}")
}
fn inject_absolute_var_run_symlink(bwrap_args: &mut Vec<String>) {
assert_eq!(bwrap_args[1], "--ro-bind");
assert_eq!(bwrap_args[2], "/");
+163
View File
@@ -1066,6 +1066,169 @@ fn build_unsetenv_accumulates() {
assert_eq!(config.unsetenv, vec!["G", "P", "C"]);
}
#[test]
fn persistent_tmp_off_by_default() {
let config = build(args_with_command(), None).unwrap();
assert!(config.persistent_tmp_key.is_none());
}
#[test]
fn persistent_tmp_enabled_via_cli_derives_a_key() {
let args = Args {
persistent_tmp: true,
..args_with_command()
};
let config = build(args, None).unwrap();
let key = config.persistent_tmp_key.expect("expected derived key");
assert_eq!(key.len(), 12);
assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn persistent_tmp_enabled_via_config() {
let file_config = FileConfig {
options: Options {
persistent_tmp: Some(true),
..Options::default()
},
..FileConfig::default()
};
let config = build(args_with_command(), Some(file_config)).unwrap();
assert!(config.persistent_tmp_key.is_some());
}
#[test]
fn persistent_key_override_replaces_hash() {
let args = Args {
persistent_tmp: true,
persistent_key: Some("my-label".into()),
..args_with_command()
};
let config = build(args, None).unwrap();
assert_eq!(config.persistent_tmp_key.as_deref(), Some("my-label"));
}
#[test]
fn persistent_key_alone_is_ignored_without_persistent_tmp() {
let args = Args {
persistent_key: Some("my-label".into()),
..args_with_command()
};
let config = build(args, None).unwrap();
assert!(config.persistent_tmp_key.is_none());
}
#[test]
fn persistent_tmp_is_noop_in_blacklist_mode() {
let derived = build(
Args {
persistent_tmp: true,
blacklist: true,
..args_with_command()
},
None,
)
.unwrap();
assert!(derived.persistent_tmp_key.is_none());
let explicit = build(
Args {
persistent_tmp: true,
blacklist: true,
persistent_key: Some("explicit".into()),
..args_with_command()
},
None,
)
.unwrap();
assert!(explicit.persistent_tmp_key.is_none());
}
#[test]
fn persistent_tmp_cli_no_overrides_config() {
let file_config = FileConfig {
options: Options {
persistent_tmp: Some(true),
..Options::default()
},
..FileConfig::default()
};
let args = Args {
no_persistent_tmp: true,
..args_with_command()
};
let config = build(args, Some(file_config)).unwrap();
assert!(config.persistent_tmp_key.is_none());
}
#[test]
fn persistent_tmp_uses_leaf_profile_name() {
let file_config = FileConfig {
profiles: HashMap::from([
(
"parent".into(),
Options {
..Options::default()
},
),
(
"leaf".into(),
Options {
profile: Some("parent".into()),
persistent_tmp: Some(true),
..Options::default()
},
),
]),
..FileConfig::default()
};
let leaf_args = Args {
profile: Some("leaf".into()),
..args_with_command()
};
let parent_args = Args {
profile: Some("leaf".into()),
..args_with_command()
};
// Same profile selected: same key.
let key1 = build(leaf_args, Some(clone_file_config(&file_config)))
.unwrap()
.persistent_tmp_key
.unwrap();
let key2 = build(parent_args, Some(clone_file_config(&file_config)))
.unwrap()
.persistent_tmp_key
.unwrap();
assert_eq!(key1, key2);
// Selecting the parent directly (with persistent_tmp injected) should
// produce a different key because the leaf profile name differs.
let mut file_config2 = clone_file_config(&file_config);
file_config2
.profiles
.get_mut("parent")
.unwrap()
.persistent_tmp = Some(true);
let parent_direct = Args {
profile: Some("parent".into()),
..args_with_command()
};
let key3 = build(parent_direct, Some(file_config2))
.unwrap()
.persistent_tmp_key
.unwrap();
assert_ne!(key1, key3);
}
fn clone_file_config(src: &FileConfig) -> FileConfig {
FileConfig {
options: src.options.clone(),
profiles: src.profiles.clone(),
..FileConfig::default()
}
}
#[test]
fn build_mask_accumulates() {
let file_config = FileConfig {