#[derive(Debug, Clone)]
pub struct SandboxConfig {
pub fs_whitelist: Vec<String>,
pub network_access: bool,
pub env_allowlist: Vec<String>,
pub restrict_umask: bool,
pub isolated_tempdir: bool,
}
impl SandboxConfig {
pub fn with_fs_whitelist(mut self, dirs: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.fs_whitelist = dirs.into_iter().map(|d| d.into()).collect();
self
}
pub fn with_network(mut self, enabled: bool) -> Self {
self.network_access = enabled;
self
}
pub fn with_env_allowlist(mut self, vars: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.env_allowlist = vars.into_iter().map(|v| v.into()).collect();
self
}
pub fn with_restrict_umask(mut self, yes: bool) -> Self {
self.restrict_umask = yes;
self
}
pub fn with_isolated_tempdir(mut self, yes: bool) -> Self {
self.isolated_tempdir = yes;
self
}
pub fn apply(&self) -> SandboxGuard {
let mut actions: Vec<String> = Vec::new();
if !self.env_allowlist.is_empty() {
let allowed: std::collections::HashSet<String> = self
.env_allowlist
.iter()
.map(|v| v.to_uppercase())
.collect();
let preserved: Vec<(String, String)> = allowed
.iter()
.filter_map(|k| {
std::env::var(k)
.ok()
.map(|v| (k.clone(), v))
})
.collect();
for (key, _) in &preserved {
unsafe { std::env::remove_var(key); }
}
for (key, val) in &preserved {
unsafe { std::env::set_var(key, val); }
}
actions.push(format!("env: allowlisted {} var(s)", allowed.len()));
} else {
actions.push("env: unrestricted".into());
}
#[cfg(unix)]
if self.restrict_umask {
unsafe {
libc::umask(0o077);
}
actions.push("umask: 0o077".into());
}
if self.isolated_tempdir {
let tmp = std::env::temp_dir().join(format!("rvtest-sandbox-{}", std::process::id()));
let _ = std::fs::create_dir_all(&tmp);
unsafe {
std::env::set_var("TMPDIR", &tmp);
std::env::set_var("TEMP", &tmp);
std::env::set_var("TMP", &tmp);
}
actions.push(format!("tmpdir: {}", tmp.display()));
}
if !self.fs_whitelist.is_empty() {
actions.push(format!("fs: whitelisted {} path(s)", self.fs_whitelist.len()));
}
if !self.network_access {
actions.push("net: disabled".into());
}
SandboxGuard { actions, isolated_tempdir: self.isolated_tempdir }
}
pub fn env_overrides(&self) -> Vec<(String, Option<String>)> {
let mut overrides = Vec::new();
if !self.env_allowlist.is_empty() {
let allowed: std::collections::HashSet<String> = self
.env_allowlist
.iter()
.map(|v| v.to_uppercase())
.collect();
for (key, val) in std::env::vars() {
let upper = key.to_uppercase();
if !allowed.contains(&upper) {
overrides.push((key, None));
} else {
overrides.push((key, Some(val)));
}
}
}
if self.isolated_tempdir {
let tmp = std::env::temp_dir().join(format!("rvtest-sandbox-{}", std::process::id()));
overrides.push(("TMPDIR".into(), Some(tmp.to_string_lossy().into_owned())));
overrides.push(("TEMP".into(), Some(tmp.to_string_lossy().into_owned())));
overrides.push(("TMP".into(), Some(tmp.to_string_lossy().into_owned())));
}
overrides
}
pub fn temp_dir(&self) -> Option<std::path::PathBuf> {
if self.isolated_tempdir {
Some(std::env::temp_dir().join(format!("rvtest-sandbox-{}", std::process::id())))
} else {
None
}
}
}
impl Default for SandboxConfig {
fn default() -> Self {
SandboxConfig {
fs_whitelist: Vec::new(),
network_access: true,
env_allowlist: Vec::new(),
restrict_umask: false,
isolated_tempdir: false,
}
}
}
#[must_use]
pub struct SandboxGuard {
actions: Vec<String>,
isolated_tempdir: bool,
}
impl SandboxGuard {
pub fn summary(&self) -> &[String] {
&self.actions
}
}
impl Drop for SandboxGuard {
fn drop(&mut self) {
if self.isolated_tempdir {
let tmp = std::env::temp_dir().join(format!("rvtest-sandbox-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
}
}
}
unsafe impl Send for SandboxGuard {}
unsafe impl Sync for SandboxGuard {}
pub fn parse_fs_whitelist(s: &str) -> Vec<String> {
s.split(',')
.map(|p| p.trim().to_owned())
.filter(|p| !p.is_empty())
.collect()
}
pub fn parse_env_allowlist(s: &str) -> Vec<String> {
s.split(',')
.map(|v| v.trim().to_uppercase())
.filter(|v| !v.is_empty())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sandbox_default_no_restrictions() {
let cfg = SandboxConfig::default();
assert!(cfg.fs_whitelist.is_empty());
assert!(cfg.network_access);
assert!(cfg.env_allowlist.is_empty());
assert!(!cfg.restrict_umask);
assert!(!cfg.isolated_tempdir);
}
#[test]
fn sandbox_with_fs_whitelist() {
let cfg = SandboxConfig::default()
.with_fs_whitelist(["src/", "tests/"]);
assert_eq!(cfg.fs_whitelist, vec!["src/", "tests/"]);
}
#[test]
fn sandbox_with_env_allowlist() {
let cfg = SandboxConfig::default()
.with_env_allowlist(["PATH", "HOME"]);
assert_eq!(cfg.env_allowlist, vec!["PATH", "HOME"]);
}
#[test]
fn sandbox_with_network_disabled() {
let cfg = SandboxConfig::default()
.with_network(false);
assert!(!cfg.network_access);
}
#[test]
fn sandbox_with_restrict_umask() {
let cfg = SandboxConfig::default()
.with_restrict_umask(true);
assert!(cfg.restrict_umask);
}
#[test]
fn sandbox_with_isolated_tempdir() {
let cfg = SandboxConfig::default()
.with_isolated_tempdir(true);
assert!(cfg.isolated_tempdir);
}
#[test]
fn sandbox_apply_returns_guard() {
let cfg = SandboxConfig::default()
.with_env_allowlist(["PATH"]);
let guard = cfg.apply();
assert!(!guard.actions.is_empty());
}
#[test]
fn sandbox_env_overrides_contains_allowlisted() {
unsafe { std::env::set_var("RVTEST_SANDBOX_TEST_VAR", "present"); }
let cfg = SandboxConfig::default()
.with_env_allowlist(["PATH", "RVTEST_SANDBOX_TEST_VAR"]);
let overrides = cfg.env_overrides();
assert!(overrides.iter().any(|(k, v)| k == "PATH" && v.is_some()));
unsafe { std::env::remove_var("RVTEST_SANDBOX_TEST_VAR"); }
}
#[test]
fn sandbox_env_overrides_clears_unlisted() {
unsafe { std::env::set_var("RVTEST_SANDBOX_CLEAR_ME", "secret"); }
let cfg = SandboxConfig::default()
.with_env_allowlist(["PATH"]);
let overrides = cfg.env_overrides();
assert!(overrides.iter().any(|(k, v)| k == "RVTEST_SANDBOX_CLEAR_ME" && v.is_none()));
unsafe { std::env::remove_var("RVTEST_SANDBOX_CLEAR_ME"); }
}
#[test]
fn sandbox_empty_env_allowlist_no_overrides() {
let cfg = SandboxConfig::default();
let overrides = cfg.env_overrides();
assert!(overrides.is_empty() || !overrides.iter().any(|(_, v)| v.is_none()));
}
#[test]
fn parse_fs_whitelist_empty() {
assert!(parse_fs_whitelist("").is_empty());
}
#[test]
fn parse_fs_whitelist_single() {
assert_eq!(parse_fs_whitelist("src/"), vec!["src/"]);
}
#[test]
fn parse_fs_whitelist_multiple() {
assert_eq!(parse_fs_whitelist("src/,tests/,data/"), vec!["src/", "tests/", "data/"]);
}
#[test]
fn parse_env_allowlist_empty() {
assert!(parse_env_allowlist("").is_empty());
}
#[test]
fn parse_env_allowlist_single() {
assert_eq!(parse_env_allowlist("PATH"), vec!["PATH"]);
}
#[test]
fn parse_env_allowlist_uppercased() {
assert_eq!(parse_env_allowlist("Path,Home"), vec!["PATH", "HOME"]);
}
}