Optionally back /tmp and /var/tmp with stable host directories
This commit is contained in:
Generated
+58
@@ -6,6 +6,7 @@ version = 4
|
||||
name = "agent-sandbox"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"clap",
|
||||
"glob",
|
||||
"libc",
|
||||
@@ -73,12 +74,48 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.62"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -131,6 +168,21 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -153,6 +205,12 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
|
||||
@@ -18,6 +18,7 @@ name = "e2e"
|
||||
path = "tests/e2e/main.rs"
|
||||
|
||||
[dependencies]
|
||||
blake3 = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
glob = "0.3"
|
||||
libc = "0.2"
|
||||
|
||||
@@ -60,6 +60,16 @@ rw = ["/var/run/docker.sock"]
|
||||
command = ["claude", "--dangerously-skip-permissions"]
|
||||
```
|
||||
|
||||
## Persistent state
|
||||
|
||||
In whitelist mode, the sandbox's `/tmp` and `/var/tmp` are a fresh tmpfs by default. Pass `--persistent-tmp` (or set `persistent-tmp = true`) to back both with stable host directories at `/tmp/agent-sandbox-<key>` and `/var/tmp/agent-sandbox-<key>`. The host dirs are created on demand (mode `0700`, owned by the invoking user) and as such inherit the host's cleanup policy (typical `systemd-tmpfiles` defaults: `/tmp` cleared on reboot + 10d age-out, `/var/tmp` survives reboot + 30d age-out).
|
||||
|
||||
`<key>` is derived from `profile + canonical cwd`, so different profiles and different working directories each get their own state. Override with `--persistent-key=LABEL` (or `persistent-key` in the config) to share state across profiles or to keep state after moving the project directory.
|
||||
|
||||
`persistent-tmp` is a no-op in blacklist mode — blacklist already binds the host's `/tmp` and `/var/tmp`, so they persist on the host filesystem with no extra opt-in.
|
||||
|
||||
Stale `/tmp/agent-sandbox-*` directories are not auto-cleaned — remove them by hand when you no longer need them. If `/tmp/agent-sandbox-<key>` already exists owned by a different user, the sandbox refuses to start rather than risk hijacked writes.
|
||||
|
||||
## Escape hatches
|
||||
|
||||
When the agent needs access to something the sandbox blocks, use `--rw` or `--ro` for paths and `--setenv`/`--unsetenv` for env vars. User overrides always win over the built-in policies.
|
||||
|
||||
@@ -34,6 +34,12 @@ rw = [
|
||||
]
|
||||
# mask = ["~/.ssh"] # hide path with tmpfs/over /dev/null
|
||||
|
||||
persistent-tmp = true # back /tmp and /var/tmp with host dirs that survive
|
||||
# across runs (key derived from profile+cwd).
|
||||
# No-op in blacklist mode (which already shares host /tmp).
|
||||
# persistent-key = "shared" # override the derived key — e.g. to share state
|
||||
# across profiles or after moving the project dir
|
||||
|
||||
env = [
|
||||
"XDG_RUNTIME_DIR", # KEY -> pass through from host if set
|
||||
# "DEBUG=", # KEY= -> set to empty string
|
||||
|
||||
+12
@@ -66,6 +66,18 @@ pub struct Args {
|
||||
#[arg(long, overrides_with = "dry_run")]
|
||||
pub no_dry_run: bool,
|
||||
|
||||
/// Back /tmp and /var/tmp with persistent host directories
|
||||
#[arg(long, overrides_with = "no_persistent_tmp")]
|
||||
pub persistent_tmp: bool,
|
||||
|
||||
/// Disable persistent /tmp (overrides config-file `persistent-tmp = true`)
|
||||
#[arg(long, overrides_with = "persistent_tmp")]
|
||||
pub no_persistent_tmp: bool,
|
||||
|
||||
/// Override the derived session key with an explicit label
|
||||
#[arg(long = "persistent-key", value_name = "LABEL")]
|
||||
pub persistent_key: Option<String>,
|
||||
|
||||
/// Working directory inside the sandbox (default: current directory)
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub chdir: Option<PathBuf>,
|
||||
|
||||
+93
-27
@@ -5,6 +5,7 @@ use std::path::{Path, PathBuf};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::cli::Args;
|
||||
use crate::session_key::SessionKey;
|
||||
use crate::{BindSpec, EnvEntry, SandboxConfig, SandboxError, SandboxMode};
|
||||
|
||||
pub fn build(args: Args, file_config: Option<FileConfig>) -> Result<SandboxConfig, SandboxError> {
|
||||
@@ -13,72 +14,120 @@ pub fn build(args: Args, file_config: Option<FileConfig>) -> Result<SandboxConfi
|
||||
let profile = c.resolve_profile(args.profile.as_deref())?;
|
||||
(c.options, profile)
|
||||
}
|
||||
None => (Options::default(), Options::default()),
|
||||
None => (
|
||||
Options::default(),
|
||||
ResolvedProfile {
|
||||
name: args.profile.clone(),
|
||||
options: Options::default(),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
validate_mode(&globals)?;
|
||||
validate_mode(&profile)?;
|
||||
validate_mode(&profile.options)?;
|
||||
globals.validate_paths()?;
|
||||
profile.validate_paths()?;
|
||||
profile.options.validate_paths()?;
|
||||
|
||||
let (command, command_args) =
|
||||
resolve_command(args.entrypoint, args.command_and_args, &profile, &globals)?;
|
||||
let mode = merge_mode(args.blacklist, args.whitelist, &profile.options, &globals);
|
||||
let chdir = resolve_chdir(
|
||||
args.chdir.clone(),
|
||||
profile.options.chdir.clone(),
|
||||
globals.chdir.clone(),
|
||||
)?;
|
||||
let persistent_tmp_key = resolve_persistent_tmp_key(&args, &profile, &globals, &mode, &chdir);
|
||||
|
||||
let (command, command_args) = resolve_command(
|
||||
args.entrypoint,
|
||||
args.command_and_args,
|
||||
&profile.options,
|
||||
&globals,
|
||||
)?;
|
||||
let command = resolve_binary(&command)
|
||||
.ok_or_else(|| SandboxError::CommandNotFound(PathBuf::from(&command)))?;
|
||||
|
||||
let env = dedupe_env_last_wins(parse_env_entries(&merge_vecs(
|
||||
args.env,
|
||||
&profile.env,
|
||||
&profile.options.env,
|
||||
&globals.env,
|
||||
))?);
|
||||
let unsetenv = merge_vecs(args.unsetenv, &profile.unsetenv, &globals.unsetenv);
|
||||
let unsetenv = merge_vecs(args.unsetenv, &profile.options.unsetenv, &globals.unsetenv);
|
||||
reject_env_key_conflicts(&env, &unsetenv)?;
|
||||
|
||||
Ok(SandboxConfig {
|
||||
mode: merge_mode(args.blacklist, args.whitelist, &profile, &globals),
|
||||
mode,
|
||||
hardened: merge_flag(
|
||||
merge_flag_pair(args.hardened, args.no_hardened),
|
||||
profile.hardened,
|
||||
profile.options.hardened,
|
||||
globals.hardened,
|
||||
),
|
||||
unshare_net: merge_flag(
|
||||
merge_flag_pair(args.unshare_net, args.share_net),
|
||||
profile.unshare_net,
|
||||
profile.options.unshare_net,
|
||||
globals.unshare_net,
|
||||
),
|
||||
seccomp: merge_flag_with_default(
|
||||
merge_flag_pair(args.seccomp, args.no_seccomp),
|
||||
profile.seccomp,
|
||||
profile.options.seccomp,
|
||||
globals.seccomp,
|
||||
true,
|
||||
),
|
||||
env_filter: merge_flag_with_default(
|
||||
merge_flag_pair(args.env_filter, args.no_env_filter),
|
||||
profile.env_filter,
|
||||
profile.options.env_filter,
|
||||
globals.env_filter,
|
||||
true,
|
||||
),
|
||||
dry_run: merge_flag(
|
||||
merge_flag_pair(args.dry_run, args.no_dry_run),
|
||||
profile.dry_run,
|
||||
profile.options.dry_run,
|
||||
globals.dry_run,
|
||||
),
|
||||
chdir: resolve_chdir(args.chdir, profile.chdir, globals.chdir)?,
|
||||
extra_rw: merge_bind_specs(args.extra_rw, &profile.rw, &globals.rw)?,
|
||||
extra_ro: merge_bind_specs(args.extra_ro, &profile.ro, &globals.ro)?,
|
||||
mask: merge_vecs(args.mask, &profile.mask, &globals.mask),
|
||||
chdir,
|
||||
extra_rw: merge_bind_specs(args.extra_rw, &profile.options.rw, &globals.rw)?,
|
||||
extra_ro: merge_bind_specs(args.extra_ro, &profile.options.ro, &globals.ro)?,
|
||||
mask: merge_vecs(args.mask, &profile.options.mask, &globals.mask),
|
||||
env,
|
||||
unsetenv,
|
||||
bwrap_args: split_bwrap_args(merge_vecs(
|
||||
args.bwrap_args,
|
||||
&profile.bwrap_args,
|
||||
&profile.options.bwrap_args,
|
||||
&globals.bwrap_args,
|
||||
))?,
|
||||
command,
|
||||
command_args,
|
||||
persistent_tmp_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_persistent_tmp_key(
|
||||
args: &Args,
|
||||
profile: &ResolvedProfile,
|
||||
globals: &Options,
|
||||
mode: &SandboxMode,
|
||||
chdir: &Path,
|
||||
) -> Option<String> {
|
||||
if matches!(mode, SandboxMode::Blacklist) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let enabled = merge_flag(
|
||||
merge_flag_pair(args.persistent_tmp, args.no_persistent_tmp),
|
||||
profile.options.persistent_tmp,
|
||||
globals.persistent_tmp,
|
||||
);
|
||||
if !enabled {
|
||||
return None;
|
||||
}
|
||||
let label = args
|
||||
.persistent_key
|
||||
.clone()
|
||||
.or_else(|| profile.options.persistent_key.clone())
|
||||
.or_else(|| globals.persistent_key.clone());
|
||||
Some(
|
||||
label.unwrap_or_else(|| SessionKey::default().derive(profile.name.as_deref(), mode, chdir)),
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_mode(
|
||||
cli_blacklist: bool,
|
||||
cli_whitelist: bool,
|
||||
@@ -297,6 +346,12 @@ pub struct FileConfig {
|
||||
_unknown: HashMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ResolvedProfile {
|
||||
pub name: Option<String>,
|
||||
pub options: Options,
|
||||
}
|
||||
|
||||
impl FileConfig {
|
||||
pub fn load(path: &Path) -> Result<Self, SandboxError> {
|
||||
Self::load_file(path)?.load_extra()
|
||||
@@ -364,15 +419,22 @@ impl FileConfig {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn resolve_profile(&self, selected: Option<&str>) -> Result<Options, SandboxError> {
|
||||
match selected.or(self.options.profile.as_deref()) {
|
||||
Some(leaf) => self.resolve_chain(leaf),
|
||||
None => Ok(Options::default()),
|
||||
}
|
||||
fn resolve_profile(&self, selected: Option<&str>) -> Result<ResolvedProfile, SandboxError> {
|
||||
let Some(name) = selected
|
||||
.map(String::from)
|
||||
.or_else(|| self.options.profile.clone())
|
||||
else {
|
||||
return Ok(ResolvedProfile::default());
|
||||
};
|
||||
let options = self.resolve_chain(&name)?;
|
||||
Ok(ResolvedProfile {
|
||||
name: Some(name),
|
||||
options,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_chain(&self, leaf: &str) -> Result<Options, SandboxError> {
|
||||
let chain = self.collect_chain(leaf)?;
|
||||
fn resolve_chain(&self, name: &str) -> Result<Options, SandboxError> {
|
||||
let chain = self.collect_chain(name)?;
|
||||
|
||||
let merged = chain
|
||||
.into_iter()
|
||||
@@ -382,10 +444,10 @@ impl FileConfig {
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
fn collect_chain(&self, leaf: &str) -> Result<Vec<Options>, SandboxError> {
|
||||
fn collect_chain(&self, name: &str) -> Result<Vec<Options>, SandboxError> {
|
||||
let mut visited: Vec<String> = Vec::new();
|
||||
let mut chain: Vec<Options> = Vec::new();
|
||||
let mut current = leaf.to_string();
|
||||
let mut current = name.to_string();
|
||||
|
||||
loop {
|
||||
if visited.iter().any(|seen| seen == ¤t) {
|
||||
@@ -424,6 +486,8 @@ pub struct Options {
|
||||
pub entrypoint: Option<CommandValue>,
|
||||
pub command: Option<CommandValue>,
|
||||
pub dry_run: Option<bool>,
|
||||
pub persistent_tmp: Option<bool>,
|
||||
pub persistent_key: Option<String>,
|
||||
pub chdir: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub rw: Vec<String>,
|
||||
@@ -453,6 +517,8 @@ impl Options {
|
||||
entrypoint: extra.entrypoint.or(self.entrypoint),
|
||||
command: extra.command.or(self.command),
|
||||
dry_run: extra.dry_run.or(self.dry_run),
|
||||
persistent_tmp: extra.persistent_tmp.or(self.persistent_tmp),
|
||||
persistent_key: extra.persistent_key.or(self.persistent_key),
|
||||
chdir: extra.chdir.or(self.chdir),
|
||||
rw: append(self.rw, extra.rw),
|
||||
ro: append(self.ro, extra.ro),
|
||||
|
||||
@@ -33,6 +33,7 @@ pub enum SandboxError {
|
||||
NoCommand,
|
||||
Seccomp(String),
|
||||
SeccompUnsupportedArch(String),
|
||||
PersistentTmpDir(PathBuf, String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SandboxError {
|
||||
@@ -107,6 +108,9 @@ impl std::fmt::Display for SandboxError {
|
||||
f,
|
||||
"seccomp filtering is not supported on this architecture: {arch} (use --no-seccomp to disable)"
|
||||
),
|
||||
Self::PersistentTmpDir(path, reason) => {
|
||||
write!(f, "persistent-tmp directory '{}': {reason}", path.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-11
@@ -4,17 +4,20 @@ pub mod cli;
|
||||
pub mod config;
|
||||
mod env;
|
||||
mod errors;
|
||||
mod persistent_tmp;
|
||||
mod preflight;
|
||||
mod sandbox;
|
||||
mod seccomp;
|
||||
mod session_key;
|
||||
|
||||
pub use errors::SandboxError;
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::sandbox::Bwrap;
|
||||
|
||||
pub enum SandboxMode {
|
||||
Blacklist,
|
||||
Whitelist,
|
||||
@@ -57,6 +60,7 @@ pub struct SandboxConfig {
|
||||
pub command_args: Vec<OsString>,
|
||||
pub chdir: PathBuf,
|
||||
pub dry_run: bool,
|
||||
pub persistent_tmp_key: Option<String>,
|
||||
}
|
||||
|
||||
pub fn require_home() -> Result<String, SandboxError> {
|
||||
@@ -87,19 +91,12 @@ fn resolve_run_user_from_proc() -> Option<String> {
|
||||
pub fn run(config: SandboxConfig) -> Result<(), SandboxError> {
|
||||
preflight::check(&config)?;
|
||||
|
||||
let mut cmd = sandbox::build_command(&config)?;
|
||||
let bwrap = Bwrap::build(&config)?;
|
||||
|
||||
if config.dry_run {
|
||||
println!("{}", shell_quote_command(&cmd));
|
||||
println!("{}", bwrap.shell_quoted());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(SandboxError::Io(cmd.exec()))
|
||||
}
|
||||
|
||||
fn shell_quote_command(cmd: &std::process::Command) -> String {
|
||||
let prog = cmd.get_program().to_string_lossy();
|
||||
let args = cmd.get_args().map(|a| a.to_string_lossy());
|
||||
let all: Vec<_> = std::iter::once(prog).chain(args).collect();
|
||||
shlex::try_join(all.iter().map(|s| s.as_ref())).unwrap()
|
||||
Err(SandboxError::Io(bwrap.exec()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
use std::fs::{DirBuilder, File, OpenOptions};
|
||||
use std::io;
|
||||
use std::os::fd::{AsRawFd, OwnedFd};
|
||||
use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::SandboxConfig;
|
||||
use crate::errors::SandboxError;
|
||||
|
||||
pub struct PersistentTmpDirs {
|
||||
pub tmp: PathBuf,
|
||||
pub var_tmp: PathBuf,
|
||||
// Held to keep `/proc/self/fd/<n>` bind sources valid through `exec()`.
|
||||
_keep_fds: Vec<OwnedFd>,
|
||||
}
|
||||
|
||||
impl PersistentTmpDirs {
|
||||
pub fn resolve(config: &SandboxConfig) -> Result<Option<Self>, SandboxError> {
|
||||
let Some(key) = &config.persistent_tmp_key else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let tmp = PersistentTmpDir::new(format!("/tmp/agent-sandbox-{key}"));
|
||||
let var_tmp = PersistentTmpDir::new(format!("/var/tmp/agent-sandbox-{key}"));
|
||||
|
||||
if config.dry_run {
|
||||
return Ok(Some(Self {
|
||||
tmp: tmp.host_path,
|
||||
var_tmp: var_tmp.host_path,
|
||||
_keep_fds: Vec::new(),
|
||||
}));
|
||||
}
|
||||
|
||||
let tmp_fd = tmp.open()?;
|
||||
let var_tmp_fd = var_tmp.open()?;
|
||||
Ok(Some(Self {
|
||||
tmp: format!("/proc/self/fd/{}", tmp_fd.as_raw_fd()).into(),
|
||||
var_tmp: format!("/proc/self/fd/{}", var_tmp_fd.as_raw_fd()).into(),
|
||||
_keep_fds: vec![tmp_fd, var_tmp_fd],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PersistentTmpDir {
|
||||
pub host_path: PathBuf,
|
||||
}
|
||||
|
||||
impl PersistentTmpDir {
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
host_path: path.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(&self) -> Result<OwnedFd, SandboxError> {
|
||||
self.create_if_missing()?;
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY)
|
||||
.open(&self.host_path)
|
||||
.map_err(|e| self.error(format!("open: {e}")))?;
|
||||
self.verify_owner(&file)?;
|
||||
let fd: OwnedFd = file.into();
|
||||
clear_cloexec(&fd).map_err(|e| self.error(format!("clear FD_CLOEXEC: {e}")))?;
|
||||
Ok(fd)
|
||||
}
|
||||
|
||||
fn create_if_missing(&self) -> Result<(), SandboxError> {
|
||||
match DirBuilder::new().mode(0o700).create(&self.host_path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()),
|
||||
Err(e) => Err(self.error(format!("create: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_owner(&self, file: &File) -> Result<(), SandboxError> {
|
||||
let meta = file
|
||||
.metadata()
|
||||
.map_err(|e| self.error(format!("stat: {e}")))?;
|
||||
let euid = unsafe { libc::geteuid() };
|
||||
if meta.uid() != euid {
|
||||
return Err(self.error(format!("owned by uid {} (expected {euid})", meta.uid())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn error(&self, reason: String) -> SandboxError {
|
||||
SandboxError::PersistentTmpDir(self.host_path.clone(), reason)
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_cloexec(fd: &OwnedFd) -> io::Result<()> {
|
||||
let raw = fd.as_raw_fd();
|
||||
let flags = unsafe { libc::fcntl(raw, libc::F_GETFD) };
|
||||
if flags < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if unsafe { libc::fcntl(raw, libc::F_SETFD, flags & !libc::FD_CLOEXEC) } < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn creates_dir_on_first_open_with_mode_0700() {
|
||||
let scratch = TempDir::new().unwrap();
|
||||
let dir = PersistentTmpDir::new(scratch.path().join("new"));
|
||||
let _fd = dir.open().unwrap();
|
||||
let mode = fs::metadata(&dir.host_path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_open_preserves_contents() {
|
||||
let scratch = TempDir::new().unwrap();
|
||||
let dir = PersistentTmpDir::new(scratch.path().join("reuse"));
|
||||
let _fd1 = dir.open().unwrap();
|
||||
let marker = dir.host_path.join("marker");
|
||||
fs::write(&marker, "x").unwrap();
|
||||
let _fd2 = dir.open().unwrap();
|
||||
assert!(marker.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_regular_file_at_path() {
|
||||
let scratch = TempDir::new().unwrap();
|
||||
let path = scratch.path().join("plain-file");
|
||||
fs::write(&path, "x").unwrap();
|
||||
assert!(matches!(
|
||||
PersistentTmpDir::new(path).open(),
|
||||
Err(SandboxError::PersistentTmpDir(_, _))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_symlink_at_path() {
|
||||
let scratch = TempDir::new().unwrap();
|
||||
let real = scratch.path().join("real");
|
||||
fs::create_dir(&real).unwrap();
|
||||
let link = scratch.path().join("link");
|
||||
symlink(&real, &link).unwrap();
|
||||
assert!(matches!(
|
||||
PersistentTmpDir::new(link).open(),
|
||||
Err(SandboxError::PersistentTmpDir(_, _))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fd_survives_fork_and_exec() {
|
||||
let scratch = TempDir::new().unwrap();
|
||||
let dir = PersistentTmpDir::new(scratch.path().join("fd"));
|
||||
let fd = dir.open().unwrap();
|
||||
let raw = fd.as_raw_fd();
|
||||
let output = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(format!("test -e /proc/self/fd/{raw}"))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"fd {raw} should be inherited across exec; stderr={}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
+55
-9
@@ -1,13 +1,38 @@
|
||||
use std::io;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::agents;
|
||||
use crate::blacklist;
|
||||
use crate::env;
|
||||
use crate::persistent_tmp::PersistentTmpDirs;
|
||||
use crate::seccomp;
|
||||
use crate::{BindSpec, EnvEntry, SandboxConfig, SandboxError, SandboxMode};
|
||||
|
||||
pub fn build_command(config: &SandboxConfig) -> Result<Command, SandboxError> {
|
||||
pub struct Bwrap {
|
||||
command: Command,
|
||||
_persistent: Option<PersistentTmpDirs>,
|
||||
}
|
||||
|
||||
impl Bwrap {
|
||||
pub fn build(config: &SandboxConfig) -> Result<Self, SandboxError> {
|
||||
build_command(config)
|
||||
}
|
||||
|
||||
pub fn exec(mut self) -> io::Error {
|
||||
self.command.exec()
|
||||
}
|
||||
|
||||
pub fn shell_quoted(&self) -> String {
|
||||
let prog = self.command.get_program().to_string_lossy();
|
||||
let args = self.command.get_args().map(|a| a.to_string_lossy());
|
||||
let all: Vec<_> = std::iter::once(prog).chain(args).collect();
|
||||
shlex::try_join(all.iter().map(|s| s.as_ref())).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_command(config: &SandboxConfig) -> Result<Bwrap, SandboxError> {
|
||||
let mut cmd = Command::new("bwrap");
|
||||
let hardened = config.hardened || matches!(config.mode, SandboxMode::Whitelist);
|
||||
|
||||
@@ -18,10 +43,17 @@ pub fn build_command(config: &SandboxConfig) -> Result<Command, SandboxError> {
|
||||
cmd.arg("--unshare-net");
|
||||
}
|
||||
|
||||
match config.mode {
|
||||
SandboxMode::Blacklist => add_blacklist_mode(&mut cmd)?,
|
||||
SandboxMode::Whitelist => add_whitelist_mode(&mut cmd)?,
|
||||
}
|
||||
let persistent = match config.mode {
|
||||
SandboxMode::Blacklist => {
|
||||
add_blacklist_mode(&mut cmd)?;
|
||||
None
|
||||
}
|
||||
SandboxMode::Whitelist => {
|
||||
let persistent = PersistentTmpDirs::resolve(config)?;
|
||||
add_whitelist_mode(&mut cmd, persistent.as_ref())?;
|
||||
persistent
|
||||
}
|
||||
};
|
||||
|
||||
for path in agents::agent_rw_paths() {
|
||||
cmd.arg("--bind-try").arg(&path).arg(&path);
|
||||
@@ -54,7 +86,10 @@ pub fn build_command(config: &SandboxConfig) -> Result<Command, SandboxError> {
|
||||
.arg(&config.command)
|
||||
.args(&config.command_args);
|
||||
|
||||
Ok(cmd)
|
||||
Ok(Bwrap {
|
||||
command: cmd,
|
||||
_persistent: persistent,
|
||||
})
|
||||
}
|
||||
|
||||
fn add_env_policy(cmd: &mut Command, config: &SandboxConfig) {
|
||||
@@ -140,7 +175,10 @@ fn add_blacklist_mode(cmd: &mut Command) -> Result<(), SandboxError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_whitelist_mode(cmd: &mut Command) -> Result<(), SandboxError> {
|
||||
fn add_whitelist_mode(
|
||||
cmd: &mut Command,
|
||||
persistent: Option<&PersistentTmpDirs>,
|
||||
) -> Result<(), SandboxError> {
|
||||
let home = crate::require_home()?;
|
||||
|
||||
cmd.args(["--ro-bind", "/usr", "/usr"]);
|
||||
@@ -178,8 +216,16 @@ fn add_whitelist_mode(cmd: &mut Command) -> Result<(), SandboxError> {
|
||||
let cache_dir = format!("{home}/.cache");
|
||||
cmd.arg("--tmpfs").arg(&cache_dir);
|
||||
|
||||
cmd.args(["--tmpfs", "/tmp"]);
|
||||
cmd.args(["--tmpfs", "/var/tmp"]);
|
||||
match persistent {
|
||||
Some(p) => {
|
||||
cmd.arg("--bind").arg(&p.tmp).arg("/tmp");
|
||||
cmd.arg("--bind").arg(&p.var_tmp).arg("/var/tmp");
|
||||
}
|
||||
None => {
|
||||
cmd.args(["--tmpfs", "/tmp"]);
|
||||
cmd.args(["--tmpfs", "/var/tmp"]);
|
||||
}
|
||||
}
|
||||
cmd.args(["--dev", "/dev"]);
|
||||
cmd.args(["--tmpfs", "/dev/shm"]);
|
||||
cmd.args(["--tmpfs", "/run"]);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::SandboxMode;
|
||||
|
||||
pub struct SessionKey {
|
||||
pub default_profile: &'static str,
|
||||
pub hex_len: usize,
|
||||
}
|
||||
|
||||
impl Default for SessionKey {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_profile: "<default>",
|
||||
hex_len: 12,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionKey {
|
||||
pub fn derive(
|
||||
&self,
|
||||
profile: Option<&str>,
|
||||
mode: &SandboxMode,
|
||||
canonical_cwd: &Path,
|
||||
) -> String {
|
||||
let profile = profile.unwrap_or(self.default_profile);
|
||||
let mode = match mode {
|
||||
SandboxMode::Blacklist => "blacklist",
|
||||
SandboxMode::Whitelist => "whitelist",
|
||||
};
|
||||
let cwd = canonical_cwd.to_string_lossy();
|
||||
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(profile.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update(mode.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update(cwd.as_bytes());
|
||||
|
||||
let hex = hasher.finalize().to_hex();
|
||||
hex[..self.hex_len].to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_profile_uses_default_label() {
|
||||
let sk = SessionKey::default();
|
||||
let cwd = Path::new("/x");
|
||||
assert_eq!(
|
||||
sk.derive(None, &SandboxMode::Whitelist, cwd),
|
||||
sk.derive(Some(sk.default_profile), &SandboxMode::Whitelist, cwd),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_respects_configured_hex_len() {
|
||||
let sk = SessionKey {
|
||||
hex_len: 8,
|
||||
..SessionKey::default()
|
||||
};
|
||||
let key = sk.derive(Some("foo"), &SandboxMode::Whitelist, Path::new("/x"));
|
||||
assert_eq!(key.len(), 8);
|
||||
}
|
||||
}
|
||||
+188
-5
@@ -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], "/");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user