use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct SandboxSection {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub network: SandboxNetwork,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fs_read_paths: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fs_write_paths: Vec<String>,
#[serde(default = "default_drop_user")]
pub drop_user: bool,
}
fn default_drop_user() -> bool {
true
}
impl Default for SandboxSection {
fn default() -> Self {
Self {
enabled: false,
network: SandboxNetwork::default(),
fs_read_paths: Vec::new(),
fs_write_paths: Vec::new(),
drop_user: default_drop_user(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SandboxNetwork {
#[default]
Deny,
Host,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SandboxPathKind {
Read,
Write,
}
pub const SANDBOX_STATE_DIR_TOKEN: &str = "${state_dir}";
pub const SANDBOX_DENYLIST_HOST_PATHS: &[&str] = &[
"/etc/shadow",
"/etc/sudoers",
"/etc/sudoers.d",
"/proc/sys",
"/proc/kcore",
"/proc/kallsyms",
"/sys/firmware",
"/sys/kernel",
"/dev/mem",
"/dev/kmem",
"/dev/port",
"/var/run/docker.sock",
"/run/docker.sock",
"/private/var/run/docker.sock",
"/root",
"/boot",
];
pub const SANDBOX_DENYLIST_HOME_SUBPATHS: &[&str] = &[
".aws",
".ssh",
".gnupg",
".netrc",
".docker",
".kube",
".cargo/credentials",
".cargo/credentials.toml",
".npmrc",
".config/gh",
".config/git",
];
pub fn path_under_or_equals_denylist<'a>(path: &str, denylist: &'a [&'a str]) -> Option<&'a str> {
if path == "/" {
return Some("/");
}
for blocked in denylist {
if path == *blocked {
return Some(*blocked);
}
let with_slash = format!("{}/", blocked);
if path.starts_with(&with_slash) {
return Some(*blocked);
}
if *path == *"/" || (*blocked).starts_with(path) && path != *blocked {
let parent_with_slash = format!("{}/", path);
if blocked.starts_with(&parent_with_slash) {
return Some(*blocked);
}
}
}
None
}
pub fn contains_state_dir_token(path: &str) -> bool {
path.contains(SANDBOX_STATE_DIR_TOKEN)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_serde_default_section() {
let section = SandboxSection::default();
let toml = toml::to_string(§ion).unwrap();
let parsed: SandboxSection = toml::from_str(&toml).unwrap();
assert_eq!(section, parsed);
assert!(!parsed.enabled);
assert_eq!(parsed.network, SandboxNetwork::Deny);
assert!(parsed.fs_read_paths.is_empty());
assert!(parsed.fs_write_paths.is_empty());
assert!(parsed.drop_user);
}
#[test]
fn round_trip_serde_full_section() {
let section = SandboxSection {
enabled: true,
network: SandboxNetwork::Host,
fs_read_paths: vec!["/etc/ssl/certs".into()],
fs_write_paths: vec!["${state_dir}".into(), "/tmp/x".into()],
drop_user: false,
};
let toml = toml::to_string(§ion).unwrap();
let parsed: SandboxSection = toml::from_str(&toml).unwrap();
assert_eq!(section, parsed);
}
#[test]
fn drop_user_default_is_true() {
let toml = "enabled = true\nnetwork = \"deny\"\n";
let parsed: SandboxSection = toml::from_str(toml).unwrap();
assert!(parsed.drop_user);
}
#[test]
fn denylist_path_exact_match() {
let hit = path_under_or_equals_denylist("/etc/shadow", SANDBOX_DENYLIST_HOST_PATHS);
assert_eq!(hit, Some("/etc/shadow"));
}
#[test]
fn denylist_path_under_match() {
let hit =
path_under_or_equals_denylist("/etc/sudoers.d/myrule", SANDBOX_DENYLIST_HOST_PATHS);
assert_eq!(hit, Some("/etc/sudoers.d"));
}
#[test]
fn denylist_root_covers_everything() {
let hit = path_under_or_equals_denylist("/", SANDBOX_DENYLIST_HOST_PATHS);
assert_eq!(hit, Some("/"));
}
#[test]
fn denylist_allowlist_parent_swallows_blocked() {
let hit = path_under_or_equals_denylist("/etc", SANDBOX_DENYLIST_HOST_PATHS);
assert!(hit.is_some());
let matched = hit.unwrap();
assert!(
matched == "/etc/shadow" || matched == "/etc/sudoers" || matched == "/etc/sudoers.d",
"expected an /etc/* denylist entry, got {}",
matched
);
}
#[test]
fn denylist_path_unrelated_no_match() {
let hit = path_under_or_equals_denylist(
"/usr/share/ca-certificates",
SANDBOX_DENYLIST_HOST_PATHS,
);
assert!(hit.is_none());
let hit = path_under_or_equals_denylist("/etc/shadow_backup", SANDBOX_DENYLIST_HOST_PATHS);
assert!(hit.is_none());
}
#[test]
fn state_dir_token_detection() {
assert!(contains_state_dir_token("${state_dir}"));
assert!(contains_state_dir_token("${state_dir}/cache"));
assert!(contains_state_dir_token("/prefix/${state_dir}"));
assert!(!contains_state_dir_token("/etc/state_dir"));
assert!(!contains_state_dir_token("$state_dir"));
}
}