Replace setenv with env list supporting host passthrough

This commit is contained in:
2026-04-22 20:47:01 +02:00
parent 76c5be0e72
commit 494da52fc6
7 changed files with 266 additions and 62 deletions
+135 -31
View File
@@ -1,11 +1,11 @@
use std::collections::{BTreeMap, HashMap};
use std::collections::{HashMap, HashSet};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::cli::Args;
use crate::{SandboxConfig, SandboxError, SandboxMode};
use crate::{EnvEntry, SandboxConfig, SandboxError, SandboxMode};
pub fn build(args: Args, file_config: Option<FileConfig>) -> Result<SandboxConfig, SandboxError> {
let (mut globals, mut profile) = match file_config {
@@ -26,6 +26,14 @@ pub fn build(args: Args, file_config: Option<FileConfig>) -> Result<SandboxConfi
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,
&globals.env,
))?);
let unsetenv = merge_vecs(args.unsetenv, &profile.unsetenv, &globals.unsetenv);
reject_env_key_conflicts(&env, &unsetenv)?;
Ok(SandboxConfig {
mode: merge_mode(args.blacklist, args.whitelist, &profile, &globals),
hardened: merge_flag(
@@ -59,8 +67,8 @@ pub fn build(args: Args, file_config: Option<FileConfig>) -> Result<SandboxConfi
extra_rw: merge_paths(args.extra_rw, &profile.rw, &globals.rw)?,
extra_ro: merge_paths(args.extra_ro, &profile.ro, &globals.ro)?,
mask: merge_vecs(args.mask, &profile.mask, &globals.mask),
setenv: merge_setenv(args.setenv, &profile.setenv, &globals.setenv),
unsetenv: merge_vecs(args.unsetenv, &profile.unsetenv, &globals.unsetenv),
env,
unsetenv,
bwrap_args: split_bwrap_args(merge_vecs(
args.bwrap_args,
&profile.bwrap_args,
@@ -166,19 +174,36 @@ fn merge_vecs<T: Clone>(cli: Vec<T>, profile: &[T], globals: &[T]) -> Vec<T> {
globals.iter().chain(profile).cloned().chain(cli).collect()
}
fn merge_setenv(
cli: Vec<(String, String)>,
profile: &BTreeMap<String, String>,
globals: &BTreeMap<String, String>,
) -> Vec<(String, String)> {
let mut merged: BTreeMap<String, String> = globals.clone();
for (k, v) in profile {
merged.insert(k.clone(), v.clone());
fn parse_env_entries(raw: &[String]) -> Result<Vec<EnvEntry>, SandboxError> {
raw.iter()
.map(|s| match s.split_once('=') {
None if s.is_empty() => Err(SandboxError::InvalidEnvEntry(s.clone())),
None => Ok(EnvEntry::Keep(s.clone())),
Some(("", _)) => Err(SandboxError::InvalidEnvEntry(s.clone())),
Some((key, value)) => Ok(EnvEntry::Set(key.to_string(), value.to_string())),
})
.collect()
}
fn dedupe_env_last_wins(entries: Vec<EnvEntry>) -> Vec<EnvEntry> {
let mut seen: HashSet<String> = HashSet::new();
let mut result: Vec<EnvEntry> = Vec::new();
for entry in entries.into_iter().rev() {
if seen.insert(entry.key().to_string()) {
result.push(entry);
}
}
for (k, v) in cli {
merged.insert(k, v);
result.reverse();
result
}
fn reject_env_key_conflicts(env: &[EnvEntry], unsetenv: &[String]) -> Result<(), SandboxError> {
let env_keys: HashSet<&str> = env.iter().map(EnvEntry::key).collect();
let unsetenv_keys: HashSet<&str> = unsetenv.iter().map(String::as_str).collect();
match env_keys.intersection(&unsetenv_keys).next() {
Some(&key) => Err(SandboxError::ConflictingEnvKey(key.to_string())),
None => Ok(()),
}
merged.into_iter().collect()
}
fn resolve_command(
@@ -295,7 +320,7 @@ pub struct Options {
#[serde(default)]
pub mask: Vec<PathBuf>,
#[serde(default)]
pub setenv: BTreeMap<String, String>,
pub env: Vec<String>,
#[serde(default)]
pub unsetenv: Vec<String>,
#[serde(default)]
@@ -937,22 +962,16 @@ mod tests {
}
#[test]
fn build_setenv_merges_globals_profile_cli() {
fn build_env_accumulates_disjoint_keys() {
let file_config = FileConfig {
options: Options {
setenv: BTreeMap::from([
("A".into(), "global".into()),
("B".into(), "global".into()),
]),
env: vec!["A=global".into(), "B".into()],
..Options::default()
},
profile: HashMap::from([(
"p".into(),
Options {
setenv: BTreeMap::from([
("B".into(), "profile".into()),
("C".into(), "profile".into()),
]),
env: vec!["C=profile".into()],
..Options::default()
},
)]),
@@ -960,21 +979,106 @@ mod tests {
};
let args = Args {
profile: Some("p".into()),
setenv: vec![("C".into(), "cli".into()), ("D".into(), "cli".into())],
env: vec!["D".into()],
..args_with_command()
};
let config = build(args, Some(file_config)).unwrap();
assert_eq!(
config.setenv,
config.env,
vec![
("A".into(), "global".into()),
("B".into(), "profile".into()),
("C".into(), "cli".into()),
("D".into(), "cli".into()),
EnvEntry::Set("A".into(), "global".into()),
EnvEntry::Keep("B".into()),
EnvEntry::Set("C".into(), "profile".into()),
EnvEntry::Keep("D".into()),
]
);
}
#[test]
fn build_env_later_tier_overrides_earlier() {
let file_config = FileConfig {
options: Options {
env: vec!["A=global".into()],
..Options::default()
},
profile: HashMap::from([(
"p".into(),
Options {
env: vec!["A=profile".into()],
..Options::default()
},
)]),
..FileConfig::default()
};
let args = Args {
profile: Some("p".into()),
env: vec!["A=cli".into()],
..args_with_command()
};
let config = build(args, Some(file_config)).unwrap();
assert_eq!(config.env, vec![EnvEntry::Set("A".into(), "cli".into())]);
}
#[test]
fn build_env_conflicts_with_unsetenv() {
let file_config = FileConfig {
options: Options {
env: vec!["X=1".into()],
unsetenv: vec!["X".into()],
..Options::default()
},
..FileConfig::default()
};
assert!(matches!(
build(args_with_command(), Some(file_config)),
Err(SandboxError::ConflictingEnvKey(k)) if k == "X"
));
}
#[test]
fn build_env_keep_conflicts_with_unsetenv() {
let file_config = FileConfig {
options: Options {
env: vec!["X".into()],
unsetenv: vec!["X".into()],
..Options::default()
},
..FileConfig::default()
};
assert!(matches!(
build(args_with_command(), Some(file_config)),
Err(SandboxError::ConflictingEnvKey(k)) if k == "X"
));
}
#[test]
fn build_env_rejects_empty_key() {
let file_config = FileConfig {
options: Options {
env: vec!["=foo".into()],
..Options::default()
},
..FileConfig::default()
};
assert!(matches!(
build(args_with_command(), Some(file_config)),
Err(SandboxError::InvalidEnvEntry(_))
));
}
#[test]
fn build_env_set_to_empty_string_is_allowed() {
let file_config = FileConfig {
options: Options {
env: vec!["DEBUG=".into()],
..Options::default()
},
..FileConfig::default()
};
let config = build(args_with_command(), Some(file_config)).unwrap();
assert_eq!(config.env, vec![EnvEntry::Set("DEBUG".into(), "".into())]);
}
#[test]
fn build_unsetenv_accumulates() {
let file_config = FileConfig {