use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]
pub enum PathPolicy {
DenyAll,
AllowList(HashSet<PathBuf>),
DenyList(HashSet<PathBuf>),
AllowAll,
}
impl Default for PathPolicy {
fn default() -> Self {
PathPolicy::DenyAll
}
}
impl PathPolicy {
pub fn is_allowed(&self, path: &Path) -> bool {
match self {
PathPolicy::DenyAll => false,
PathPolicy::AllowAll => true,
PathPolicy::AllowList(allowed) => {
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
allowed.iter().any(|allowed_path| {
canonical.starts_with(allowed_path)
})
}
PathPolicy::DenyList(denied) => {
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
!denied.iter().any(|denied_path| {
canonical.starts_with(denied_path)
})
}
}
}
pub fn allow<I, P>(paths: I) -> Self
where
I: IntoIterator<Item = P>,
P: Into<PathBuf>,
{
PathPolicy::AllowList(paths.into_iter().map(Into::into).collect())
}
pub fn deny<I, P>(paths: I) -> Self
where
I: IntoIterator<Item = P>,
P: Into<PathBuf>,
{
PathPolicy::DenyList(paths.into_iter().map(Into::into).collect())
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum NetPolicy {
DenyAll,
AllowList(HashSet<String>),
DenyList(HashSet<String>),
AllowAll,
}
impl Default for NetPolicy {
fn default() -> Self {
NetPolicy::DenyAll
}
}
impl NetPolicy {
pub fn is_allowed(&self, host: &str) -> bool {
let host_lower = host.to_lowercase();
match self {
NetPolicy::DenyAll => false,
NetPolicy::AllowAll => true,
NetPolicy::AllowList(allowed) => {
allowed.iter().any(|a| {
let a_lower = a.to_lowercase();
if a_lower.starts_with("*.") {
let suffix = &a_lower[1..];
host_lower.ends_with(suffix) || host_lower == &a_lower[2..]
} else {
host_lower == a_lower
}
})
}
NetPolicy::DenyList(denied) => {
!denied.iter().any(|d| {
let d_lower = d.to_lowercase();
if d_lower.starts_with("*.") {
let suffix = &d_lower[1..];
host_lower.ends_with(suffix) || host_lower == &d_lower[2..]
} else {
host_lower == d_lower
}
})
}
}
}
pub fn allow<I, S>(hosts: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
NetPolicy::AllowList(hosts.into_iter().map(Into::into).collect())
}
pub fn deny<I, S>(hosts: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
NetPolicy::DenyList(hosts.into_iter().map(Into::into).collect())
}
}
#[derive(Debug, Clone, Default)]
pub struct SandboxConfig {
pub fs_read: PathPolicy,
pub fs_write: PathPolicy,
pub net_outgoing: NetPolicy,
pub net_incoming: NetPolicy,
pub env_vars: Option<HashSet<String>>,
pub working_dir: Option<PathBuf>,
pub isolate_temp: bool,
}
impl SandboxConfig {
pub fn locked() -> Self {
Self {
fs_read: PathPolicy::DenyAll,
fs_write: PathPolicy::DenyAll,
net_outgoing: NetPolicy::DenyAll,
net_incoming: NetPolicy::DenyAll,
env_vars: Some(HashSet::new()),
working_dir: None,
isolate_temp: true,
}
}
pub fn permissive() -> Self {
Self {
fs_read: PathPolicy::AllowAll,
fs_write: PathPolicy::AllowAll,
net_outgoing: NetPolicy::AllowAll,
net_incoming: NetPolicy::AllowAll,
env_vars: None,
working_dir: None,
isolate_temp: false,
}
}
pub fn with_read_paths<I, P>(mut self, paths: I) -> Self
where
I: IntoIterator<Item = P>,
P: Into<PathBuf>,
{
self.fs_read = PathPolicy::allow(paths);
self
}
pub fn with_write_paths<I, P>(mut self, paths: I) -> Self
where
I: IntoIterator<Item = P>,
P: Into<PathBuf>,
{
self.fs_write = PathPolicy::allow(paths);
self
}
pub fn with_allowed_hosts<I, S>(mut self, hosts: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.net_outgoing = NetPolicy::allow(hosts);
self
}
pub fn with_env_vars<I, S>(mut self, vars: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.env_vars = Some(vars.into_iter().map(Into::into).collect());
self
}
pub fn with_working_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
self.working_dir = Some(path.into());
self
}
pub fn with_temp_isolation(mut self) -> Self {
self.isolate_temp = true;
self
}
pub fn can_read(&self, path: &Path) -> bool {
self.fs_read.is_allowed(path)
}
pub fn can_write(&self, path: &Path) -> bool {
self.fs_write.is_allowed(path)
}
pub fn can_connect(&self, host: &str) -> bool {
self.net_outgoing.is_allowed(host)
}
pub fn can_access_env(&self, name: &str) -> bool {
match &self.env_vars {
None => true,
Some(allowed) => allowed.contains(name),
}
}
}
#[derive(Debug)]
pub struct Sandbox {
config: SandboxConfig,
temp_dir: Option<PathBuf>,
}
impl Sandbox {
pub fn new(config: SandboxConfig) -> crate::Result<Self> {
let temp_dir = if config.isolate_temp {
let dir = std::env::temp_dir().join(format!(
"fusabi-sandbox-{}",
std::process::id()
));
std::fs::create_dir_all(&dir)?;
Some(dir)
} else {
None
};
Ok(Self { config, temp_dir })
}
pub fn config(&self) -> &SandboxConfig {
&self.config
}
pub fn temp_dir(&self) -> Option<&Path> {
self.temp_dir.as_deref()
}
pub fn check_read(&self, path: &Path) -> crate::Result<()> {
if self.config.can_read(path) {
Ok(())
} else {
Err(crate::Error::sandbox_violation(format!(
"read access denied: {}",
path.display()
)))
}
}
pub fn check_write(&self, path: &Path) -> crate::Result<()> {
if self.config.can_write(path) {
Ok(())
} else {
Err(crate::Error::sandbox_violation(format!(
"write access denied: {}",
path.display()
)))
}
}
pub fn check_connect(&self, host: &str) -> crate::Result<()> {
if self.config.can_connect(host) {
Ok(())
} else {
Err(crate::Error::sandbox_violation(format!(
"network access denied: {}",
host
)))
}
}
pub fn check_env(&self, name: &str) -> crate::Result<()> {
if self.config.can_access_env(name) {
Ok(())
} else {
Err(crate::Error::sandbox_violation(format!(
"environment variable access denied: {}",
name
)))
}
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
if let Some(ref dir) = self.temp_dir {
let _ = std::fs::remove_dir_all(dir);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_path_policy_deny_all() {
let policy = PathPolicy::DenyAll;
assert!(!policy.is_allowed(Path::new("/any/path")));
}
#[test]
fn test_path_policy_allow_all() {
let policy = PathPolicy::AllowAll;
assert!(policy.is_allowed(Path::new("/any/path")));
}
#[test]
fn test_path_policy_allowlist() {
let policy = PathPolicy::allow(["/tmp", "/home/user/data"]);
assert!(!policy.is_allowed(Path::new("/etc/passwd")));
}
#[test]
fn test_net_policy_deny_all() {
let policy = NetPolicy::DenyAll;
assert!(!policy.is_allowed("example.com"));
}
#[test]
fn test_net_policy_allow_all() {
let policy = NetPolicy::AllowAll;
assert!(policy.is_allowed("example.com"));
}
#[test]
fn test_net_policy_allowlist() {
let policy = NetPolicy::allow(["example.com", "*.trusted.org"]);
assert!(policy.is_allowed("example.com"));
assert!(policy.is_allowed("api.trusted.org"));
assert!(policy.is_allowed("trusted.org"));
assert!(!policy.is_allowed("malicious.com"));
}
#[test]
fn test_net_policy_denylist() {
let policy = NetPolicy::deny(["evil.com", "*.malware.net"]);
assert!(policy.is_allowed("example.com"));
assert!(!policy.is_allowed("evil.com"));
assert!(!policy.is_allowed("download.malware.net"));
}
#[test]
fn test_sandbox_config_locked() {
let config = SandboxConfig::locked();
assert!(!config.can_read(Path::new("/etc/passwd")));
assert!(!config.can_write(Path::new("/tmp/file")));
assert!(!config.can_connect("example.com"));
assert!(!config.can_access_env("PATH"));
}
#[test]
fn test_sandbox_config_permissive() {
let config = SandboxConfig::permissive();
assert!(config.can_read(Path::new("/etc/passwd")));
assert!(config.can_connect("example.com"));
assert!(config.can_access_env("PATH"));
}
#[test]
fn test_sandbox_config_builder() {
let config = SandboxConfig::locked()
.with_allowed_hosts(["api.example.com"])
.with_env_vars(["HOME", "USER"]);
assert!(config.can_connect("api.example.com"));
assert!(!config.can_connect("other.com"));
assert!(config.can_access_env("HOME"));
assert!(!config.can_access_env("SECRET"));
}
}